From df59c5151629c842817f86bbfa4dadcafadb2e70 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 10 Sep 2026 15:38:40 -0700 Subject: [PATCH 01/76] chore: introduce non-blocking .NET quality analysis (#16) Enable the analyzers that ship with the pinned .NET SDK and add a root .editorconfig that defines formatting, naming, and IDE style explicitly. Every rule reports as a warning at this layer so the diagnostic backlog is visible without blocking the build. Add scripts/Test-DotNetQuality.ps1 as the single quality entry point shared by local development, CI, and the future pre-push hook, and invoke it from the Windows test job. Add CONTRIBUTING.md and a pull request template, which the repository previously lacked. GenerateDocumentationFile is required for build-time IDE0005, so CS1591 is suppressed rather than satisfied with low-value XML comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --- .editorconfig | 265 +++++++++++++++++++++++++++++ .github/copilot-instructions.md | 15 ++ .github/pull_request_template.md | 76 +++++++++ .github/workflows/gateway-msix.yml | 7 + CONTRIBUTING.md | 152 +++++++++++++++++ Directory.Build.props | 31 ++++ scripts/Test-DotNetQuality.ps1 | 80 +++++++++ 7 files changed, 626 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/pull_request_template.md create mode 100644 CONTRIBUTING.md create mode 100644 scripts/Test-DotNetQuality.ps1 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1d7a918c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,265 @@ +root = true + +# Repository-wide defaults. `.gitattributes` normalizes tracked text to LF, so +# `end_of_line` must stay `lf`; otherwise `dotnet format` rewrites files with +# CRLF and every formatted line shows up as a spurious diff. +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,jsonc,yml,yaml}] +indent_size = 2 + +[*.md] +# Markdown uses trailing double-space for hard line breaks. +trim_trailing_whitespace = false + +[*.{appxmanifest,csproj,props,targets,slnx,xml}] +indent_size = 2 + +[*.ps1] +indent_size = 4 + +[*.cs] +indent_size = 4 +max_line_length = 100 + +#### .NET code style #### + +# `this.` qualification is not used anywhere in this repository. +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +# IDE0045/IDE0046: collapsing guard clauses into conditional expressions hurts +# readability in this codebase, which uses early-return validation throughout. +dotnet_style_prefer_conditional_expression_over_assignment = false:silent +dotnet_style_prefer_conditional_expression_over_return = false:silent +dotnet_style_prefer_compound_assignment = true:warning +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_simplified_interpolation = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_coalesce_expression = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning +dotnet_style_readonly_field = true:warning +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_namespace_match_folder = true:suggestion + +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion + +# Accessibility modifiers are written explicitly, except where C# requires +# them to be omitted (for example interface members). +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +#### C# code style #### + +# `var` versus explicit types is left to the author. Both forms are idiomatic, +# this codebase deliberately mixes them, and enforcing either direction is +# churn that catches no defects. +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = false:silent + +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion + +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_prefer_not_pattern = true:warning +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion + +csharp_style_inlined_variable_declaration = true:warning +csharp_style_deconstructed_variable_declaration = true:suggestion +# Guard clauses that throw read better than ternary `throw` expressions, and +# this codebase already uses them consistently. +csharp_style_throw_expression = false:silent +csharp_style_conditional_delegate_call = true:warning +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:warning +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_prefer_primary_constructors = false:suggestion + +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_braces = true:warning +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_top_level_statements = false:suggestion +# IDE0058: requiring `_ =` on every ignored return value is noise. Ignoring the +# result of calls such as `Directory.CreateDirectory` is ordinary C#. +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +# IDE0059 catches dead stores, which are a real defect signal. +csharp_style_unused_value_assignment_preference = discard_variable:warning + +csharp_using_directive_placement = outside_namespace:warning + +#### C# formatting #### + +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_indent_block_contents = true +csharp_indent_braces = false + +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_after_comma = true +csharp_space_before_comma = false +csharp_space_after_dot = false +csharp_space_before_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false +csharp_space_around_declaration_statements = false +csharp_space_before_open_square_brackets = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_square_brackets = false +csharp_space_around_binary_operators = before_and_after + +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true + +#### Naming conventions #### + +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_style.interface_prefix.capitalization = pascal_case +dotnet_naming_style.interface_prefix.required_prefix = I + +dotnet_naming_style.type_parameter_prefix.capitalization = pascal_case +dotnet_naming_style.type_parameter_prefix.required_prefix = T + +dotnet_naming_style.underscore_camel_case.capitalization = camel_case +dotnet_naming_style.underscore_camel_case.required_prefix = _ + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = * + +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * + +dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, property, method, event, delegate, local_function +dotnet_naming_symbols.types_and_members.applicable_accessibilities = * + +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.applicable_accessibilities = * +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_symbols.static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.static_readonly_fields.applicable_accessibilities = * +dotnet_naming_symbols.static_readonly_fields.required_modifiers = static, readonly + +dotnet_naming_symbols.instance_fields.applicable_kinds = field +dotnet_naming_symbols.instance_fields.applicable_accessibilities = private, private_protected + +dotnet_naming_symbols.parameters_and_locals.applicable_kinds = parameter, local + +dotnet_naming_rule.interfaces_are_prefixed.symbols = interfaces +dotnet_naming_rule.interfaces_are_prefixed.style = interface_prefix +dotnet_naming_rule.interfaces_are_prefixed.severity = warning + +dotnet_naming_rule.type_parameters_are_prefixed.symbols = type_parameters +dotnet_naming_rule.type_parameters_are_prefixed.style = type_parameter_prefix +dotnet_naming_rule.type_parameters_are_prefixed.severity = warning + +dotnet_naming_rule.constants_are_pascal_case.symbols = constants +dotnet_naming_rule.constants_are_pascal_case.style = pascal_case +dotnet_naming_rule.constants_are_pascal_case.severity = warning + +dotnet_naming_rule.static_readonly_fields_are_pascal_case.symbols = static_readonly_fields +dotnet_naming_rule.static_readonly_fields_are_pascal_case.style = pascal_case +dotnet_naming_rule.static_readonly_fields_are_pascal_case.severity = warning + +dotnet_naming_rule.instance_fields_are_underscore_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_are_underscore_camel_case.style = underscore_camel_case +dotnet_naming_rule.instance_fields_are_underscore_camel_case.severity = warning + +dotnet_naming_rule.parameters_and_locals_are_camel_case.symbols = parameters_and_locals +dotnet_naming_rule.parameters_and_locals_are_camel_case.style = camel_case +dotnet_naming_rule.parameters_and_locals_are_camel_case.severity = warning + +dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members +dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case +dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning + +#### Analyzer severities #### +# +# This layer reports every rule below as a warning so the diagnostic backlog is +# visible without blocking the build. Later PRs in this stack promote one +# cohesive rule family at a time to `error` in the same change that clears it. + +# Unnecessary usings. Requires `GenerateDocumentationFile` to run at build time. +dotnet_diagnostic.IDE0005.severity = warning + +# Style rules with findings in the current tree. +dotnet_diagnostic.IDE0305.severity = warning +dotnet_diagnostic.IDE0330.severity = warning + +# Interop and security. +dotnet_diagnostic.CA5392.severity = warning +dotnet_diagnostic.CA1838.severity = warning + +# Public contracts and globalization. +dotnet_diagnostic.CA1062.severity = warning +dotnet_diagnostic.CA1307.severity = warning +dotnet_diagnostic.CA1308.severity = warning + +# Asynchronous code. +dotnet_diagnostic.CA2007.severity = warning +dotnet_diagnostic.CA1849.severity = warning + +# Lifetime and design. +dotnet_diagnostic.CA2000.severity = warning +dotnet_diagnostic.CA1031.severity = warning +dotnet_diagnostic.CA1515.severity = warning +dotnet_diagnostic.CA1859.severity = warning + +[tests/**/*.cs] +# xUnit theory and member data intentionally expose public members, and test +# names describe scenarios rather than following production naming. +dotnet_diagnostic.CA1515.severity = none diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e98d94f0..731c9cd9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,6 +10,10 @@ Run commands from the repository root in PowerShell 7 (`pwsh`). dotnet restore .\OpenClaw.Gateway.MSIX.slnx dotnet build .\OpenClaw.Gateway.MSIX.slnx --configuration Release --no-restore +# Canonical quality gate: restore plus a Release rebuild with static analysis. +# Used by local development, CI, and the optional pre-push hook. +.\scripts\Test-DotNetQuality.ps1 + # Publish the launcher through the NativeAOT toolchain without MSIX content. $vsInstaller = Join-Path ` ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86)) ` @@ -77,6 +81,17 @@ and ARM64 separately. ## Repository conventions +- Static analysis uses only the analyzers shipped by the pinned SDK. + `Directory.Build.props` sets `AnalysisMode=All`, `EnforceCodeStyleInBuild`, + and `GenerateDocumentationFile` (required for build-time `IDE0005`), and + suppresses `CS1591`. Rule severity belongs in the root `.editorconfig`, not + in the project files. Rules currently reported as warnings are a known + backlog being cleared and promoted to `error` one family at a time. Do not + commit a generated suppression baseline; fix the diagnostic or add a narrow + suppression with a written rationale. +- `.gitattributes` normalizes tracked text to LF and `.editorconfig` sets + `end_of_line = lf`. Avoid whole-file rewrites through `Set-Content` or + `Out-File`, which reintroduce CRLF. - Ordinary builds and tests must leave `IncludePackagingContent` unset. Packaging builds set it to `true`, supply a runtime identifier and platform, and use `obj\packaging` through `Directory.Build.props` to isolate MSIX diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..8e95c4dc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,76 @@ + + +
+Additional instructions + +**MUST:** Keep **Allow edits from maintainers** enabled for this PR so +maintainers can help update the branch when needed. + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for the validation commands this +repository expects and for the packaging, signing, and NativeAOT rules that +apply to launcher changes. + +
+ +## What Problem This Solves + + + +## Why This Change Was Made + + + +## User Impact + + + +## Evidence + + diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 15d1530d..58c2e23a 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -51,6 +51,13 @@ jobs: - name: Restore run: dotnet restore .\OpenClaw.Gateway.MSIX.slnx + - name: Static analysis + shell: pwsh + run: > + .\scripts\Test-DotNetQuality.ps1 + -Configuration Release + -SkipRestore + - name: Test run: > dotnet test .\OpenClaw.Gateway.MSIX.slnx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..68008f9c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ +# Contributing + +This repository builds the `OpenClaw.Gateway` MSIX package and the +`openclaw.exe` NativeAOT launcher it ships. It is Windows-focused and pins the +.NET SDK through `global.json`. Run every command below from the repository +root in PowerShell 7 (`pwsh`). + +## Prerequisites + +- PowerShell 7 +- The .NET SDK feature band pinned in `global.json` +- Visual Studio Build Tools with the **Desktop development with C++** workload + and the Windows SDK, for NativeAOT publish and MSIX composition + +## Build, analyze, and test + +```powershell +dotnet restore .\OpenClaw.Gateway.MSIX.slnx + +# Canonical quality gate: restore plus a Release rebuild with static analysis. +.\scripts\Test-DotNetQuality.ps1 + +dotnet test .\OpenClaw.Gateway.MSIX.slnx --configuration Release --no-restore +``` + +`Test-DotNetQuality.ps1` is the single entry point used by local development, +continuous integration, and the optional pre-push hook, so all three report the +same result. It always rebuilds: an up-to-date project is skipped and reports no +analyzer diagnostics at all. + +Run one test by fully qualified name: + +```powershell +dotnet test .\tests\OpenClaw.Launcher.Tests\OpenClaw.Launcher.Tests.csproj ` + --configuration Release ` + --filter "FullyQualifiedName=OpenClaw.Launcher.Tests.HostOptionsTests.ParseForwardsAllArgumentsUnchanged" +``` + +Run the PowerShell policy suites when you change the workflow, signing inputs, +or package version logic: + +```powershell +.\scripts\Test-SigningInputs.Tests.ps1 +.\scripts\Test-WorkflowPackageVersion.Tests.ps1 +``` + +Run the NativeAOT publish when you change host JSON, reflection, interop, or +anything else that is trimming-sensitive. A JIT `dotnet build` does not +exercise that path: + +```powershell +$vsInstaller = Join-Path ` + ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86)) ` + 'Microsoft Visual Studio\Installer' +$env:Path = "$vsInstaller;$env:Path" +dotnet publish .\src\OpenClaw.Launcher\OpenClaw.Launcher.csproj ` + --configuration Release --runtime win-x64 --self-contained +``` + +## Formatting and static analysis + +Formatting and analyzer severity are defined by the root `.editorconfig`. The +analyzer properties themselves live in `Directory.Build.props`, and the +repository uses only the analyzers that ship with the pinned SDK. + +Continuous integration never rewrites source. To apply formatting locally, +review the resulting diff before committing: + +```powershell +dotnet format whitespace .\OpenClaw.Gateway.MSIX.slnx +dotnet format style .\OpenClaw.Gateway.MSIX.slnx +``` + +To check without writing files: + +```powershell +dotnet format whitespace .\OpenClaw.Gateway.MSIX.slnx --verify-no-changes +dotnet format style .\OpenClaw.Gateway.MSIX.slnx --verify-no-changes +``` + +`--verify-no-changes` exits with code 2 when it finds violations. + +The analysis policy is being rolled out in stages. Rules currently reported as +warnings are a known backlog rather than an invitation to add new violations; +each is being cleared and promoted to `error` one family at a time. Do not +commit a generated suppression baseline. Fix the diagnostic, or add a narrow +suppression with a written rationale. + +`.gitattributes` normalizes tracked text to LF and `.editorconfig` sets +`end_of_line = lf`. Avoid whole-file rewrites through `Set-Content` or +`Out-File`, which can reintroduce CRLF. Verify with `git ls-files --eol`. + +## Repository conventions + +- Ordinary builds and tests must leave `IncludePackagingContent` unset. + Packaging builds set it to `true` and supply a runtime identifier. +- Treat launcher arguments as OpenClaw-owned. Do not add host-only switches, + consume `--`, rewrite arguments, or block upstream commands. +- Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, + project runtime identifiers, manifest content, and signing validation. +- Metadata files are part of the release trust chain. Coordinate changes across + payload creation, MSIX creation, signing validation, workflow artifacts, and + tests. +- Use source-generated `System.Text.Json` metadata through `OpenClawJsonContext`. + The launcher is NativeAOT and must not introduce reflection-based + serialization. +- PowerShell scripts set `$ErrorActionPreference = 'Stop'` and must also check + `$LASTEXITCODE` after invoking native tools. +- Package versions have four numeric components that each fit in `UInt16`. + Package dependency versions belong in `Directory.Packages.props`. + +## Tests + +- A test earns its place by the realistic defect it would catch. Prefer + functional and integration tests that drive the real path. +- Assert observable behavior: return values, emitted events, persisted state, + exit codes, rendered output. Do not read a source file and assert on string + markers of the implementation. +- Tests must never modify real user state. Use the isolated temporary + directory fixtures rather than touching a real OpenClaw profile, packaged + LocalState, or an installed MSIX. +- Tests must be deterministic: no sleep-based synchronization, hardcoded ports, + or inter-test ordering dependencies. + +## Pull requests + +Use the pull request template. Title the PR +`type: user-facing description`, where `type` is one of `feat`, `fix`, +`improve`, `refactor`, `docs`, or `chore`. Describe the outcome rather than the +mechanism. + +Every PR body should explain the problem it solves, why the change was made, +the user or contributor impact, and the evidence that it works. Name the exact +head SHA your evidence came from and state which validation lanes you did not +run. + +Keep the description current when review feedback changes the implementation; +the body is the durable explanation, not just the comment thread. Keep **Allow +edits from maintainers** enabled so a maintainer can update the branch. Do not +edit `CHANGELOG.md`. + +### Stacked pull requests + +Larger work may land as a linear stack of dependent PRs, each with a single +review boundary. When a change belongs to a stack: + +- State `Layer N of M`, the immediate parent, and the ordered stack in the PR + body. +- Validate each layer against its immediate parent, not only against the top of + the stack. +- Merge bottom-up, and retarget the child PR to the parent's base before the + parent merges. diff --git a/Directory.Build.props b/Directory.Build.props index ec3ccb08..b6b4a91c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,4 +4,35 @@ $(MSBuildProjectDirectory)\obj\packaging\ + + + true + latest + All + true + + + true + $(NoWarn);CS1591 + + diff --git a/scripts/Test-DotNetQuality.ps1 b/scripts/Test-DotNetQuality.ps1 new file mode 100644 index 00000000..91156703 --- /dev/null +++ b/scripts/Test-DotNetQuality.ps1 @@ -0,0 +1,80 @@ +<# +.SYNOPSIS + Runs the repository's canonical .NET quality checks. + +.DESCRIPTION + This is the single entry point used by local development, continuous + integration, and the optional pre-push hook, so every surface reports the + same result. + + The script performs a full restore and a Release rebuild with the analyzer + policy from Directory.Build.props and the root .editorconfig. A rebuild is + required because an up-to-date project is skipped and reports no analyzer + diagnostics at all. + + Analyzer severity is configured in .editorconfig rather than here. While + rules are still being cleared they report as warnings and this script + succeeds; once a rule family is promoted to error the same command fails. + +.PARAMETER Configuration + The MSBuild configuration to rebuild. Defaults to Release, which matches + continuous integration. + +.PARAMETER SkipRestore + Reuse the existing restore output instead of restoring first. Use this only + when a restore has already run against the current dependency manifests. + +.EXAMPLE + .\scripts\Test-DotNetQuality.ps1 + +.EXAMPLE + .\scripts\Test-DotNetQuality.ps1 -SkipRestore +#> +[CmdletBinding()] +param( + [string]$Configuration = 'Release', + + [switch]$SkipRestore +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$solution = Join-Path $repositoryRoot 'OpenClaw.Gateway.MSIX.slnx' + +function Invoke-CheckedCommand { + param( + [Parameter(Mandatory)] + [scriptblock]$Command, + + [Parameter(Mandatory)] + [string]$FailureMessage + ) + + & $Command + if ($LASTEXITCODE -ne 0) { + throw "$FailureMessage Exit code: $LASTEXITCODE." + } +} + +if (-not (Test-Path -LiteralPath $solution)) { + throw "Could not find the solution at '$solution'." +} + +if (-not $SkipRestore) { + Write-Host 'Restoring packages...' + Invoke-CheckedCommand ` + -Command { dotnet restore $solution } ` + -FailureMessage 'Package restore failed.' +} + +Write-Host "Rebuilding with static analysis ($Configuration)..." +Invoke-CheckedCommand ` + -Command { + dotnet build $solution ` + --configuration $Configuration ` + --no-restore ` + -t:Rebuild + } ` + -FailureMessage 'Static analysis build failed.' + +Write-Host 'Static analysis completed successfully.' From 87bb5c8a813ffb190b44f1942926e0613b74dc2a Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 10 Sep 2026 15:38:40 -0700 Subject: [PATCH 02/76] chore: enforce C# formatting and style (#17) Apply the outstanding whitespace and style fixes, then promote the IDE style and naming rules from warning to error so CI rejects future drift. The code changes are behavior preserving: indentation in ProgramTests, two collection expressions, and the diagnostic log's lock object moving from `object` to `System.Threading.Lock`. The lock field is only ever used in `lock` statements, so the new type introduces no Monitor-based behavior change. Also refine the style policy so it only enforces rules that add value. `var` versus explicit type, throw expressions, conditional expressions over guard clauses, and IDE0058 discards are left to the author, because forcing them produced worse code in this codebase rather than better. Test-DotNetQuality.ps1 now also verifies whitespace and style. It remains check-only and never rewrites source, including in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --- .editorconfig | 83 ++++++++++--------- CONTRIBUTING.md | 12 ++- Directory.Build.props | 8 +- scripts/Test-DotNetQuality.ps1 | 25 +++++- src/OpenClaw.Launcher/HostDiagnosticLog.cs | 2 +- src/OpenClaw.Launcher/HostOptions.cs | 2 +- .../PackageManifestTests.cs | 5 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 14 ++-- 8 files changed, 90 insertions(+), 61 deletions(-) diff --git a/.editorconfig b/.editorconfig index 1d7a918c..dd1af45b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -31,31 +31,31 @@ max_line_length = 100 #### .NET code style #### # `this.` qualification is not used anywhere in this repository. -dotnet_style_qualification_for_field = false:warning -dotnet_style_qualification_for_property = false:warning -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false:error +dotnet_style_qualification_for_property = false:error +dotnet_style_qualification_for_method = false:error +dotnet_style_qualification_for_event = false:error -dotnet_style_predefined_type_for_locals_parameters_members = true:warning -dotnet_style_predefined_type_for_member_access = true:warning +dotnet_style_predefined_type_for_locals_parameters_members = true:error +dotnet_style_predefined_type_for_member_access = true:error dotnet_style_object_initializer = true:suggestion dotnet_style_collection_initializer = true:suggestion dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_explicit_tuple_names = true:warning +dotnet_style_explicit_tuple_names = true:error dotnet_style_prefer_inferred_tuple_names = true:suggestion dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion # IDE0045/IDE0046: collapsing guard clauses into conditional expressions hurts # readability in this codebase, which uses early-return validation throughout. dotnet_style_prefer_conditional_expression_over_assignment = false:silent dotnet_style_prefer_conditional_expression_over_return = false:silent -dotnet_style_prefer_compound_assignment = true:warning -dotnet_style_prefer_simplified_boolean_expressions = true:warning -dotnet_style_prefer_simplified_interpolation = true:warning -dotnet_style_null_propagation = true:warning -dotnet_style_coalesce_expression = true:warning -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning -dotnet_style_readonly_field = true:warning +dotnet_style_prefer_compound_assignment = true:error +dotnet_style_prefer_simplified_boolean_expressions = true:error +dotnet_style_prefer_simplified_interpolation = true:error +dotnet_style_null_propagation = true:error +dotnet_style_coalesce_expression = true:error +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:error +dotnet_style_readonly_field = true:error dotnet_style_operator_placement_when_wrapping = beginning_of_line dotnet_style_namespace_match_folder = true:suggestion @@ -66,7 +66,7 @@ dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion # Accessibility modifiers are written explicitly, except where C# requires # them to be omitted (for example interface members). -dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error #### C# code style #### @@ -86,22 +86,22 @@ csharp_style_expression_bodied_accessors = true:suggestion csharp_style_expression_bodied_lambdas = true:suggestion csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion -csharp_style_pattern_matching_over_is_with_cast_check = true:warning -csharp_style_pattern_matching_over_as_with_null_check = true:warning -csharp_style_prefer_not_pattern = true:warning +csharp_style_pattern_matching_over_is_with_cast_check = true:error +csharp_style_pattern_matching_over_as_with_null_check = true:error +csharp_style_prefer_not_pattern = true:error csharp_style_prefer_pattern_matching = true:suggestion csharp_style_prefer_switch_expression = true:suggestion csharp_style_prefer_extended_property_pattern = true:suggestion -csharp_style_inlined_variable_declaration = true:warning +csharp_style_inlined_variable_declaration = true:error csharp_style_deconstructed_variable_declaration = true:suggestion # Guard clauses that throw read better than ternary `throw` expressions, and # this codebase already uses them consistently. csharp_style_throw_expression = false:silent -csharp_style_conditional_delegate_call = true:warning +csharp_style_conditional_delegate_call = true:error csharp_style_prefer_index_operator = true:suggestion csharp_style_prefer_range_operator = true:suggestion -csharp_style_prefer_null_check_over_type_check = true:warning +csharp_style_prefer_null_check_over_type_check = true:error csharp_style_prefer_tuple_swap = true:suggestion csharp_style_prefer_utf8_string_literals = true:suggestion csharp_style_prefer_readonly_struct = true:suggestion @@ -109,19 +109,19 @@ csharp_style_prefer_readonly_struct_member = true:suggestion csharp_style_prefer_primary_constructors = false:suggestion csharp_prefer_simple_using_statement = true:suggestion -csharp_prefer_static_local_function = true:warning +csharp_prefer_static_local_function = true:error csharp_prefer_static_anonymous_function = true:suggestion -csharp_prefer_braces = true:warning -csharp_style_namespace_declarations = file_scoped:warning +csharp_prefer_braces = true:error +csharp_style_namespace_declarations = file_scoped:error csharp_style_prefer_method_group_conversion = true:suggestion csharp_style_prefer_top_level_statements = false:suggestion # IDE0058: requiring `_ =` on every ignored return value is noise. Ignoring the # result of calls such as `Directory.CreateDirectory` is ordinary C#. csharp_style_unused_value_expression_statement_preference = discard_variable:silent # IDE0059 catches dead stores, which are a real defect signal. -csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_assignment_preference = discard_variable:error -csharp_using_directive_placement = outside_namespace:warning +csharp_using_directive_placement = outside_namespace:error #### C# formatting #### @@ -201,44 +201,47 @@ dotnet_naming_symbols.parameters_and_locals.applicable_kinds = parameter, local dotnet_naming_rule.interfaces_are_prefixed.symbols = interfaces dotnet_naming_rule.interfaces_are_prefixed.style = interface_prefix -dotnet_naming_rule.interfaces_are_prefixed.severity = warning +dotnet_naming_rule.interfaces_are_prefixed.severity = error dotnet_naming_rule.type_parameters_are_prefixed.symbols = type_parameters dotnet_naming_rule.type_parameters_are_prefixed.style = type_parameter_prefix -dotnet_naming_rule.type_parameters_are_prefixed.severity = warning +dotnet_naming_rule.type_parameters_are_prefixed.severity = error dotnet_naming_rule.constants_are_pascal_case.symbols = constants dotnet_naming_rule.constants_are_pascal_case.style = pascal_case -dotnet_naming_rule.constants_are_pascal_case.severity = warning +dotnet_naming_rule.constants_are_pascal_case.severity = error dotnet_naming_rule.static_readonly_fields_are_pascal_case.symbols = static_readonly_fields dotnet_naming_rule.static_readonly_fields_are_pascal_case.style = pascal_case -dotnet_naming_rule.static_readonly_fields_are_pascal_case.severity = warning +dotnet_naming_rule.static_readonly_fields_are_pascal_case.severity = error dotnet_naming_rule.instance_fields_are_underscore_camel_case.symbols = instance_fields dotnet_naming_rule.instance_fields_are_underscore_camel_case.style = underscore_camel_case -dotnet_naming_rule.instance_fields_are_underscore_camel_case.severity = warning +dotnet_naming_rule.instance_fields_are_underscore_camel_case.severity = error dotnet_naming_rule.parameters_and_locals_are_camel_case.symbols = parameters_and_locals dotnet_naming_rule.parameters_and_locals_are_camel_case.style = camel_case -dotnet_naming_rule.parameters_and_locals_are_camel_case.severity = warning +dotnet_naming_rule.parameters_and_locals_are_camel_case.severity = error dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case -dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning +dotnet_naming_rule.types_and_members_are_pascal_case.severity = error #### Analyzer severities #### # -# This layer reports every rule below as a warning so the diagnostic backlog is -# visible without blocking the build. Later PRs in this stack promote one -# cohesive rule family at a time to `error` in the same change that clears it. +# IDE style and naming rules above are errors: the tree is clean of them and +# CI must reject new drift. +# +# The CA rules below are still a known backlog and report as warnings. Later +# PRs in this stack promote one cohesive family at a time to `error` in the +# same change that clears it. # Unnecessary usings. Requires `GenerateDocumentationFile` to run at build time. -dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.IDE0005.severity = error -# Style rules with findings in the current tree. -dotnet_diagnostic.IDE0305.severity = warning -dotnet_diagnostic.IDE0330.severity = warning +# Style rules cleared by the formatting layer. +dotnet_diagnostic.IDE0305.severity = error +dotnet_diagnostic.IDE0330.severity = error # Interop and security. dotnet_diagnostic.CA5392.severity = warning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68008f9c..db0d054b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,9 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx --configuration Release --no-restore `Test-DotNetQuality.ps1` is the single entry point used by local development, continuous integration, and the optional pre-push hook, so all three report the -same result. It always rebuilds: an up-to-date project is skipped and reports no -analyzer diagnostics at all. +same result. It rebuilds with static analysis and then verifies whitespace and +code style. It always rebuilds: an up-to-date project is skipped and reports no +analyzer diagnostics at all. It never rewrites source. Run one test by fully qualified name: @@ -90,6 +91,13 @@ suppression with a written rationale. `end_of_line = lf`. Avoid whole-file rewrites through `Set-Content` or `Out-File`, which can reintroduce CRLF. Verify with `git ls-files --eol`. +A checkout always produces LF, so this only bites on files you have just +created. Many Windows editors write CRLF by default, so a brand-new `.cs` file +can fail the whitespace check before its first commit with +`error ENDOFLINE: Fix end of line marker`. Run +`dotnet format whitespace .\OpenClaw.Gateway.MSIX.slnx` to normalize it, or +configure your editor to write LF for this repository. + ## Repository conventions - Ordinary builds and tests must leave `IncludePackagingContent` unset. diff --git a/Directory.Build.props b/Directory.Build.props index b6b4a91c..6361cdcf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,10 +13,10 @@ a command-line build instead of only inside an editor. `EnforceCodeStyleInBuild` runs the IDE rules but does not set their - severity. Severity lives in the root `.editorconfig`, which currently - reports every rule as a warning. Later PRs in this stack promote one rule - family at a time to `error` in the same change that clears it, and the - final PR turns on `TreatWarningsAsErrors`. + severity. Severity lives in the root `.editorconfig`. IDE style and naming + rules are errors; the remaining CA rules are a known backlog reported as + warnings. Later PRs in this stack promote one rule family at a time to + `error`, and the final PR turns on `TreatWarningsAsErrors`. --> true diff --git a/scripts/Test-DotNetQuality.ps1 b/scripts/Test-DotNetQuality.ps1 index 91156703..e11b6982 100644 --- a/scripts/Test-DotNetQuality.ps1 +++ b/scripts/Test-DotNetQuality.ps1 @@ -7,11 +7,15 @@ integration, and the optional pre-push hook, so every surface reports the same result. - The script performs a full restore and a Release rebuild with the analyzer - policy from Directory.Build.props and the root .editorconfig. A rebuild is - required because an up-to-date project is skipped and reports no analyzer + The script performs a full restore, a Release rebuild with the analyzer + policy from Directory.Build.props and the root .editorconfig, and + check-only whitespace and code-style verification. A rebuild is required + because an up-to-date project is skipped and reports no analyzer diagnostics at all. + Nothing here rewrites source. Run `dotnet format whitespace` and + `dotnet format style` without `--verify-no-changes` to apply fixes locally. + Analyzer severity is configured in .editorconfig rather than here. While rules are still being cleared they report as warnings and this script succeeds; once a rule family is promoted to error the same command fails. @@ -77,4 +81,19 @@ Invoke-CheckedCommand ` } ` -FailureMessage 'Static analysis build failed.' +# Verification only. This never rewrites source, including in CI. Run +# `dotnet format whitespace` and `dotnet format style` without +# `--verify-no-changes` to apply fixes locally, then review the diff. +Write-Host 'Verifying whitespace formatting...' +Invoke-CheckedCommand ` + -Command { dotnet format whitespace $solution --verify-no-changes } ` + -FailureMessage ('Whitespace formatting check failed. Run ' + + '"dotnet format whitespace .\OpenClaw.Gateway.MSIX.slnx" to fix.') + +Write-Host 'Verifying code style...' +Invoke-CheckedCommand ` + -Command { dotnet format style $solution --verify-no-changes } ` + -FailureMessage ('Code style check failed. Run ' + + '"dotnet format style .\OpenClaw.Gateway.MSIX.slnx" to fix.') + Write-Host 'Static analysis completed successfully.' diff --git a/src/OpenClaw.Launcher/HostDiagnosticLog.cs b/src/OpenClaw.Launcher/HostDiagnosticLog.cs index ce9de5d2..54e1899b 100644 --- a/src/OpenClaw.Launcher/HostDiagnosticLog.cs +++ b/src/OpenClaw.Launcher/HostDiagnosticLog.cs @@ -9,7 +9,7 @@ public sealed class HostDiagnosticLog : IDisposable { private const int ErrorInsufficientBuffer = 122; private const int AppModelErrorNoPackage = 15700; - private readonly object _sync = new(); + private readonly Lock _sync = new(); private readonly Mutex _writeMutex; private bool _disposed; diff --git a/src/OpenClaw.Launcher/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 146e152e..3155f55c 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -26,6 +26,6 @@ internal static HostOptions Parse( return new HostOptions( directApplicationDirectory, - arguments.ToArray()); + [.. arguments]); } } diff --git a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs index bef5652c..f4619e24 100644 --- a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs +++ b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs @@ -19,12 +19,11 @@ public void ManifestRegistersBothAliasesToTheSingleExecutable() "openclaw.exe", (string?)extension.Attribute("Executable")); - string[] aliases = extension.Descendants() + string[] aliases = [.. extension.Descendants() .Where(element => element.Name.LocalName == "ExecutionAlias") .Select(element => (string?)element.Attribute("Alias")) .OfType() - .OrderBy(value => value, StringComparer.Ordinal) - .ToArray(); + .OrderBy(value => value, StringComparer.Ordinal)]; Assert.Equal(["clawctl.exe", "openclaw.exe"], aliases); } } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index a32bb2ad..df8e09a3 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -26,16 +26,16 @@ await File.WriteAllTextAsync( _ => { }, _ => { - nodeResolutionAttempted = true; - return Task.FromResult(nodeRuntime); + nodeResolutionAttempted = true; + return Task.FromResult(nodeRuntime); }, (nodePath, appDirectory, forwardedArguments, _, _) => { - launchAttempted = true; - Assert.Equal(nodeRuntime.ExecutablePath, nodePath); - Assert.Equal(applicationDirectory, appDirectory); - Assert.Equal(arguments, forwardedArguments); - return Task.FromResult(23); + launchAttempted = true; + Assert.Equal(nodeRuntime.ExecutablePath, nodePath); + Assert.Equal(applicationDirectory, appDirectory); + Assert.Equal(arguments, forwardedArguments); + return Task.FromResult(23); }); Assert.True(nodeResolutionAttempted); From b3d98ae592b490abb681408a6b0d68292dbf8ad8 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 10 Sep 2026 15:38:41 -0700 Subject: [PATCH 03/76] improve: harden native library loading (#18) * improve: harden native library loading All ten P/Invokes in the launcher target kernel32.dll. Without an explicit search path the loader also probes the application and current directories, so a planted kernel32.dll next to openclaw.exe could be loaded instead of the system one. Restrict DLL resolution to System32 for the whole assembly. Also stop marshalling the CreateProcessW command line through a StringBuilder. CreateProcessW may write to that buffer, so it now receives an explicitly null-terminated char array sized from the built command line. The buffer is never read back. Promote CA5392 and CA1838 to errors now that both are clear, and add a regression test that launches a real child process with an argument containing spaces and embedded quotes to prove the command line still arrives intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 * fix: keep child command text free of fixture paths The real-child argument test interpolated the fixture output path into a single-quoted PowerShell literal. A temporary root containing an apostrophe (for example C:\Users\O'Connor) terminated that literal early and turned the remaining path into PowerShell syntax, so the test failed for a reason unrelated to argument marshalling. Pass the output path to the child as environment data and keep the command text fixed. The spaced and embedded-quote payload still travels on the command line, so the marshalling assertion is unchanged, and the case is now covered for both a plain fixture subdirectory and one containing spaces and an apostrophe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --- .editorconfig | 4 +- src/OpenClaw.Launcher/AssemblyInfo.cs | 9 +++ .../WindowsKillOnCloseJob.cs | 12 +++- .../WindowsKillOnCloseJobTests.cs | 56 +++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/OpenClaw.Launcher/AssemblyInfo.cs diff --git a/.editorconfig b/.editorconfig index dd1af45b..6b5bab27 100644 --- a/.editorconfig +++ b/.editorconfig @@ -244,8 +244,8 @@ dotnet_diagnostic.IDE0305.severity = error dotnet_diagnostic.IDE0330.severity = error # Interop and security. -dotnet_diagnostic.CA5392.severity = warning -dotnet_diagnostic.CA1838.severity = warning +dotnet_diagnostic.CA5392.severity = error +dotnet_diagnostic.CA1838.severity = error # Public contracts and globalization. dotnet_diagnostic.CA1062.severity = warning diff --git a/src/OpenClaw.Launcher/AssemblyInfo.cs b/src/OpenClaw.Launcher/AssemblyInfo.cs new file mode 100644 index 00000000..ab1c9a4a --- /dev/null +++ b/src/OpenClaw.Launcher/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Runtime.InteropServices; + +// Every P/Invoke in this assembly targets kernel32.dll, which always lives in +// System32. Restricting the search path to System32 assembly-wide prevents DLL +// planting: without it the loader would also probe the application directory +// and the current directory, where an attacker-supplied kernel32.dll could be +// loaded instead. Individual P/Invokes can override this with their own +// DefaultDllImportSearchPaths attribute if they ever need a different policy. +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] diff --git a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs index 6d6d9a74..17cd7b0f 100644 --- a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs +++ b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs @@ -91,6 +91,14 @@ public Process StartProcess(ProcessStartInfo startInfo) } string commandLine = BuildCommandLine(startInfo); + + // CreateProcessW may write to lpCommandLine, so it needs a writable, + // null-terminated buffer rather than a marshalled immutable string. + // The array is one element longer than the command line, and the extra + // trailing element is already '\0'. The buffer is never read back. + char[] commandLineBuffer = new char[commandLine.Length + 1]; + commandLine.CopyTo(0, commandLineBuffer, 0, commandLine.Length); + IntPtr environment = BuildEnvironmentBlock(startInfo); var startupInfo = new StartupInfo { @@ -105,7 +113,7 @@ public Process StartProcess(ProcessStartInfo startInfo) { if (!CreateProcessW( startInfo.FileName, - new StringBuilder(commandLine), + commandLineBuffer, IntPtr.Zero, IntPtr.Zero, inheritHandles: true, @@ -256,7 +264,7 @@ private static extern bool AssignProcessToJobObject( [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CreateProcessW( string applicationName, - StringBuilder commandLine, + char[] commandLine, IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, diff --git a/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs b/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs index a9531fb6..a0fc8188 100644 --- a/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs +++ b/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs @@ -60,4 +60,60 @@ public async Task FastExitRetainsExitCodeAfterDelayedObservation() Assert.Equal(37, process.ExitCode); } + + // The child writes to a directory this test creates. "o'connor dir" + // stands in for a real profile or temporary path that contains spaces and + // an apostrophe, which PowerShell would otherwise read as syntax. + [Theory] + [InlineData("plain")] + [InlineData("o'connor dir")] + public async Task StartProcessDeliversArgumentsContainingSpacesAndQuotes( + string outputDirectoryName) + { + string directory = TestDirectory.Create(); + try + { + string outputDirectory = Path.Combine(directory, outputDirectoryName); + Directory.CreateDirectory(outputDirectory); + string outputPath = Path.Combine(outputDirectory, "argument.txt"); + const string expectedContent = "a b \"quoted\" c"; + + using WindowsKillOnCloseJob job = WindowsKillOnCloseJob.Create(); + using Process process = job.StartProcess(new ProcessStartInfo + { + FileName = Path.Combine( + Environment.SystemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"), + UseShellExecute = false, + + // The command text is fixed. The output path reaches the child + // as environment data instead, because interpolating it here + // would let an apostrophe in the path terminate the PowerShell + // literal and fail the run for a reason unrelated to argument + // marshalling. The command argument still carries the spaces + // and embedded quotes this test exists to prove survive. + ArgumentList = + { + "-NoLogo", + "-NoProfile", + "-Command", + "Set-Content -LiteralPath $env:OPENCLAW_TEST_OUTPUT_PATH " + + "-Value 'a b \"quoted\" c' -NoNewline" + }, + Environment = { ["OPENCLAW_TEST_OUTPUT_PATH"] = outputPath } + }); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(timeout.Token); + + Assert.Equal(0, process.ExitCode); + Assert.Equal(expectedContent, File.ReadAllText(outputPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } } From c3e18107d4efc634cf607903a0fa563fd0b0bba6 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 10 Sep 2026 15:38:41 -0700 Subject: [PATCH 04/76] improve: make validation and comparisons deterministic (#19) Narrow the launcher's implementation types to internal and make every string comparison in the test suite explicit and culture-independent, then promote the contract and globalization analyzer families to errors. Internalizing the seven implementation-only types removes the ambiguity about what this executable's supported surface is. Because these types are no longer externally visible, the six CA1062 public-argument-validation findings disappear without adding guard clauses that would have become dead weight. The test project keeps full access through the existing InternalsVisibleTo entry. The CA1307 findings were all Assert.Contains calls matching literal English fragments of diagnostic and exception messages. StringComparison.Ordinal is the semantically correct choice: these assertions want exact, invariant substring matching, not culture-sensitive collation. CA1062, CA1307, CA1308, and CA1515 are now errors. CA1308 and CA1062 have zero findings and act as forward guards. CA1515 is disabled for the test project because xUnit 2.x only discovers public test classes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --- .editorconfig | 8 ++--- src/OpenClaw.Launcher/ClawCtlCommand.cs | 6 ++-- src/OpenClaw.Launcher/ClawCtlConsole.cs | 2 +- src/OpenClaw.Launcher/GatewayLauncher.cs | 2 +- src/OpenClaw.Launcher/HostDiagnosticLog.cs | 2 +- src/OpenClaw.Launcher/HostOptions.cs | 2 +- .../ClawCtlCommandTests.cs | 32 +++++++++++++------ .../HostDiagnosticLogTests.cs | 2 +- .../NodeRuntimeResolverTests.cs | 18 +++++------ 9 files changed, 44 insertions(+), 30 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6b5bab27..6b87ff5b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -248,9 +248,9 @@ dotnet_diagnostic.CA5392.severity = error dotnet_diagnostic.CA1838.severity = error # Public contracts and globalization. -dotnet_diagnostic.CA1062.severity = warning -dotnet_diagnostic.CA1307.severity = warning -dotnet_diagnostic.CA1308.severity = warning +dotnet_diagnostic.CA1062.severity = error +dotnet_diagnostic.CA1307.severity = error +dotnet_diagnostic.CA1308.severity = error # Asynchronous code. dotnet_diagnostic.CA2007.severity = warning @@ -259,7 +259,7 @@ dotnet_diagnostic.CA1849.severity = warning # Lifetime and design. dotnet_diagnostic.CA2000.severity = warning dotnet_diagnostic.CA1031.severity = warning -dotnet_diagnostic.CA1515.severity = warning +dotnet_diagnostic.CA1515.severity = error dotnet_diagnostic.CA1859.severity = warning [tests/**/*.cs] diff --git a/src/OpenClaw.Launcher/ClawCtlCommand.cs b/src/OpenClaw.Launcher/ClawCtlCommand.cs index 2c1b141c..60f00c4e 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommand.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommand.cs @@ -1,17 +1,17 @@ namespace OpenClaw.Launcher; -public enum ClawCtlCommand +internal enum ClawCtlCommand { Help, Version, Setup } -public sealed record ClawCtlCommandParseResult( +internal sealed record ClawCtlCommandParseResult( ClawCtlCommand Command, string? Error = null); -public static class ClawCtlCommandParser +internal static class ClawCtlCommandParser { public static ClawCtlCommandParseResult Parse(IReadOnlyList args) { diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 49d44166..a741cf16 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -1,6 +1,6 @@ namespace OpenClaw.Launcher; -public static class ClawCtlConsole +internal static class ClawCtlConsole { public static void WriteHelp(TextWriter output) { diff --git a/src/OpenClaw.Launcher/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs index 9f4f0fd1..908212d7 100644 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ b/src/OpenClaw.Launcher/GatewayLauncher.cs @@ -2,7 +2,7 @@ namespace OpenClaw.Launcher; -public static class GatewayLauncher +internal static class GatewayLauncher { public static async Task RunAsync( string nodePath, diff --git a/src/OpenClaw.Launcher/HostDiagnosticLog.cs b/src/OpenClaw.Launcher/HostDiagnosticLog.cs index 54e1899b..f6b794b5 100644 --- a/src/OpenClaw.Launcher/HostDiagnosticLog.cs +++ b/src/OpenClaw.Launcher/HostDiagnosticLog.cs @@ -5,7 +5,7 @@ namespace OpenClaw.Launcher; -public sealed class HostDiagnosticLog : IDisposable +internal sealed class HostDiagnosticLog : IDisposable { private const int ErrorInsufficientBuffer = 122; private const int AppModelErrorNoPackage = 15700; diff --git a/src/OpenClaw.Launcher/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 3155f55c..0432db7a 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -1,6 +1,6 @@ namespace OpenClaw.Launcher; -public sealed record HostOptions( +internal sealed record HostOptions( string? PackagedApplicationDirectory, IReadOnlyList OpenClawArguments) { diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs index 667509cc..a2abc333 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs @@ -2,18 +2,32 @@ namespace OpenClaw.Launcher.Tests; public sealed class ClawCtlCommandTests { - [Theory] - [InlineData(ClawCtlCommand.Help)] - [InlineData(ClawCtlCommand.Setup, "setup")] - [InlineData(ClawCtlCommand.Version, "--version")] - public void ParseAcceptsOnlyThePublicManagementSurface( - ClawCtlCommand expected, - params string[] args) + [Fact] + public void ParseReturnsHelpWhenNoArgumentsAreProvided() { - ClawCtlCommandParseResult result = ClawCtlCommandParser.Parse(args); + ClawCtlCommandParseResult result = ClawCtlCommandParser.Parse([]); + + Assert.Null(result.Error); + Assert.Equal(ClawCtlCommand.Help, result.Command); + } + + [Fact] + public void ParseReturnsSetupForTheSetupCommand() + { + ClawCtlCommandParseResult result = ClawCtlCommandParser.Parse(["setup"]); + + Assert.Null(result.Error); + Assert.Equal(ClawCtlCommand.Setup, result.Command); + } + + [Fact] + public void ParseReturnsVersionForTheVersionOption() + { + ClawCtlCommandParseResult result = + ClawCtlCommandParser.Parse(["--version"]); Assert.Null(result.Error); - Assert.Equal(expected, result.Command); + Assert.Equal(ClawCtlCommand.Version, result.Command); } [Theory] diff --git a/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs index 982e5462..5f28c81c 100644 --- a/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs @@ -44,7 +44,7 @@ public void ConcurrentInvocationsSerializeCompleteLogRecords() string[] lines = File.ReadAllLines(path); Assert.Equal(200, lines.Length); - Assert.All(lines, line => Assert.Contains(" invocation ", line)); + Assert.All(lines, line => Assert.Contains(" invocation ", line, StringComparison.Ordinal)); } public void Dispose() diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs index e3d55556..3c77ea15 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs @@ -29,8 +29,8 @@ public async Task ResolveReportsInstallCommandWhenRuntimeIsMissing() Architecture.X64, Architecture.X64)); - Assert.Contains("not found on PATH", exception.Message); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message); + Assert.Contains("not found on PATH", exception.Message, StringComparison.Ordinal); + Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message, StringComparison.Ordinal); } [Fact] @@ -44,8 +44,8 @@ public async Task ResolveRejectsOutdatedRuntime() Architecture.X64, Architecture.X64)); - Assert.Contains("version 24.14.0 is unsupported", exception.Message); - Assert.Contains(NodeRuntimeResolver.SupportedVersions, exception.Message); + Assert.Contains("version 24.14.0 is unsupported", exception.Message, StringComparison.Ordinal); + Assert.Contains(NodeRuntimeResolver.SupportedVersions, exception.Message, StringComparison.Ordinal); } [Fact] @@ -59,7 +59,7 @@ public async Task ResolveRejectsMalformedVersion() Architecture.X64, Architecture.X64)); - Assert.Contains("invalid version", exception.Message); + Assert.Contains("invalid version", exception.Message, StringComparison.Ordinal); } [Fact] @@ -73,7 +73,7 @@ public async Task ResolveRejectsPrereleaseVersion() Architecture.X64, Architecture.X64)); - Assert.Contains("invalid version", exception.Message); + Assert.Contains("invalid version", exception.Message, StringComparison.Ordinal); } [Fact] @@ -87,7 +87,7 @@ public async Task ResolveRejectsIncompatibleArchitecture() Architecture.Arm64, Architecture.X64)); - Assert.Contains("architecture Arm64 does not match X64", exception.Message); + Assert.Contains("architecture Arm64 does not match X64", exception.Message, StringComparison.Ordinal); } [Fact] @@ -102,8 +102,8 @@ public async Task ResolveReportsVersionQueryFailure() Architecture.X64, Architecture.X64)); - Assert.Contains("query failed", exception.Message); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message); + Assert.Contains("query failed", exception.Message, StringComparison.Ordinal); + Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message, StringComparison.Ordinal); } [Theory] From 2904bd8dd641b44839ba0c2bd11e9b0744f353e2 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 10 Sep 2026 15:38:41 -0700 Subject: [PATCH 05/76] improve: remove blocking work from async paths (#20) Make every await in the launcher explicit about continuation scheduling and replace the remaining synchronous calls inside async methods, then promote the async analyzer family to errors. The launcher is a console executable with no SynchronizationContext, so ConfigureAwait(false) is a no-op at runtime today. It is applied anyway because the rule protects future code: if this assembly is ever consumed from a context-capturing host, or a UI-hosted setup surface is added, the absence of an explicit choice becomes a deadlock risk rather than a style question. Making the intent explicit at every await costs nothing now and removes an entire defect class later. CA1849 flagged two genuine synchronous calls on async paths: the clawctl --version write to the output TextWriter, and File.ReadAllText in the interop argument-delivery test. Both now use their asynchronous overloads, and the test threads its existing timeout token through the read so a hung file operation fails the test rather than hanging the run. CA2007 does not apply to the test project; the SDK excludes it automatically for projects that reference a test framework, so no ConfigureAwait noise is added to test code. Validated on this branch: static analysis gate clean, 53 tests pass, and win-x64 NativeAOT publish succeeds with no IL, trim, or AOT warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --- .editorconfig | 4 ++-- src/OpenClaw.Launcher/GatewayLauncher.cs | 6 ++++-- src/OpenClaw.Launcher/NodeRuntimeResolver.cs | 12 +++++++----- src/OpenClaw.Launcher/Program.cs | 18 ++++++++++-------- .../WindowsKillOnCloseJobTests.cs | 4 +++- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.editorconfig b/.editorconfig index 6b87ff5b..4fbf3f3d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -253,8 +253,8 @@ dotnet_diagnostic.CA1307.severity = error dotnet_diagnostic.CA1308.severity = error # Asynchronous code. -dotnet_diagnostic.CA2007.severity = warning -dotnet_diagnostic.CA1849.severity = warning +dotnet_diagnostic.CA2007.severity = error +dotnet_diagnostic.CA1849.severity = error # Lifetime and design. dotnet_diagnostic.CA2000.severity = warning diff --git a/src/OpenClaw.Launcher/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs index 908212d7..ec27931c 100644 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ b/src/OpenClaw.Launcher/GatewayLauncher.cs @@ -22,7 +22,8 @@ public static async Task RunAsync( try { - await process.WaitForExitAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken) + .ConfigureAwait(false); log?.Invoke($"OpenClaw child process exited with code {process.ExitCode}."); return process.ExitCode; } @@ -31,7 +32,8 @@ public static async Task RunAsync( if (!process.HasExited) { process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(CancellationToken.None); + await process.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); } throw; diff --git a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs index dd93b0cb..64bcd57f 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -57,7 +57,8 @@ internal static async Task ResolveAsync( { try { - string output = await queryVersion(candidate, cancellationToken); + string output = await queryVersion(candidate, cancellationToken) + .ConfigureAwait(false); Version version = ParseVersion(output); if (!IsSupported(version)) { @@ -187,9 +188,9 @@ private static async Task QueryVersionAsync( timeout.Token); Task standardError = process.StandardError.ReadToEndAsync( timeout.Token); - await process.WaitForExitAsync(timeout.Token); - string output = await standardOutput; - string error = await standardError; + await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); + string output = await standardOutput.ConfigureAwait(false); + string error = await standardError.ConfigureAwait(false); if (process.ExitCode != 0) { throw new InvalidOperationException( @@ -204,7 +205,8 @@ private static async Task QueryVersionAsync( if (!process.HasExited) { process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(CancellationToken.None); + await process.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); } throw new TimeoutException("Node.js version query timed out."); } diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index a3402c4d..4c2003b0 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -92,8 +92,9 @@ PlatformNotSupportedException or args, WriteDiagnostic, WriteConsoleError, - Console.Out) - : await RunAgentAsync(options, WriteDiagnostic); + Console.Out).ConfigureAwait(false) + : await RunAgentAsync(options, WriteDiagnostic) + .ConfigureAwait(false); } catch (Exception exception) { @@ -121,7 +122,7 @@ await RunAgentAsync( options, log, resolveNode ?? NodeRuntimeResolver.ResolveAsync, - GatewayLauncher.RunAsync); + GatewayLauncher.RunAsync).ConfigureAwait(false); // launchOpenClaw is a test seam: tests substitute a fake in place of // GatewayLauncher.RunAsync so they can assert launch behavior without @@ -132,7 +133,8 @@ internal static async Task RunAgentAsync( Func> resolveNode, LaunchOpenClawAsync launchOpenClaw) { - NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None); + NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) + .ConfigureAwait(false); log( $"Using Node.js {nodeRuntime.Version} from " + $"{nodeRuntime.ExecutablePath}."); @@ -143,7 +145,7 @@ internal static async Task RunAgentAsync( applicationDirectory, options.OpenClawArguments, CancellationToken.None, - log); + log).ConfigureAwait(false); } // output is a required parameter (not a Console.Out default) so tests @@ -171,15 +173,15 @@ internal static async Task RunControlAsync( ClawCtlConsole.WriteHelp(output); return 0; case ClawCtlCommand.Version: - output.WriteLine( + await output.WriteLineAsync( Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? - "unknown"); + "unknown").ConfigureAwait(false); return 0; case ClawCtlCommand.Setup: { NodeRuntime nodeRuntime = await ( resolveNode ?? NodeRuntimeResolver.ResolveAsync)( - CancellationToken.None); + CancellationToken.None).ConfigureAwait(false); ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); string applicationDirectory = GetPackagedApplicationDirectory(options); diff --git a/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs b/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs index a0fc8188..eff88d41 100644 --- a/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs +++ b/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs @@ -109,7 +109,9 @@ public async Task StartProcessDeliversArgumentsContainingSpacesAndQuotes( await process.WaitForExitAsync(timeout.Token); Assert.Equal(0, process.ExitCode); - Assert.Equal(expectedContent, File.ReadAllText(outputPath)); + string actualContent = + await File.ReadAllTextAsync(outputPath, timeout.Token); + Assert.Equal(expectedContent, actualContent); } finally { From 562416853d6835aad7e4cef7ba727210c924bcc8 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 10 Sep 2026 15:38:42 -0700 Subject: [PATCH 06/76] improve: tighten resource ownership and type boundaries (#21) Give every disposable in the test suite an explicit scope, document the one deliberate broad catch, sharpen a private return type, and promote the remaining lifetime and design analyzer families to errors. After this change the branch builds with zero warnings. The three CA2000 findings were StringWriter instances in tests that were never disposed. They are now scoped with using declarations. This is small but real: undisposed writers accumulate across a run and mask genuine ownership mistakes in the same files later. CA1031 flagged the last-chance catch in Main. That catch is correct and deliberate. Every other catch in this assembly already uses an exception filter to name the failures it expects; Main is the process boundary, and narrowing it would trade a logged diagnostic, a readable error message, and a deterministic exit code 1 for an unhandled-exception crash. The rule is suppressed at that single method with that rationale rather than repository wide, so any new broad catch elsewhere still fails the build. CA1859 flagged FindPathCandidates returning IReadOnlyList from a private helper that always produces a List. The interface added no contract value across a private call and cost an interface dispatch on the PATH scan; the concrete type is now returned directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --- .editorconfig | 6 +++--- src/OpenClaw.Launcher/NodeRuntimeResolver.cs | 2 +- src/OpenClaw.Launcher/Program.cs | 10 ++++++++++ tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs | 4 ++-- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.editorconfig b/.editorconfig index 4fbf3f3d..c1c76822 100644 --- a/.editorconfig +++ b/.editorconfig @@ -257,10 +257,10 @@ dotnet_diagnostic.CA2007.severity = error dotnet_diagnostic.CA1849.severity = error # Lifetime and design. -dotnet_diagnostic.CA2000.severity = warning -dotnet_diagnostic.CA1031.severity = warning +dotnet_diagnostic.CA2000.severity = error +dotnet_diagnostic.CA1031.severity = error dotnet_diagnostic.CA1515.severity = error -dotnet_diagnostic.CA1859.severity = warning +dotnet_diagnostic.CA1859.severity = error [tests/**/*.cs] # xUnit theory and member data intentionally expose public members, and test diff --git a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs index 64bcd57f..f9fbc589 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -122,7 +122,7 @@ internal static string CreateFailureMessage(string detail) => $" {InstallCommand}{Environment.NewLine}" + "Then open a new terminal and retry."; - private static IReadOnlyList FindPathCandidates() + private static List FindPathCandidates() { string? pathValue = Environment.GetEnvironmentVariable("PATH"); if (string.IsNullOrWhiteSpace(pathValue)) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 4c2003b0..365f984a 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -1,9 +1,19 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace OpenClaw.Launcher; internal static class Program { + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = + "Main is the process last-chance handler. Every narrower catch in " + + "this assembly uses an exception filter; this one deliberately does " + + "not, because narrowing it would replace the diagnostic log entry, " + + "the user-facing error message, and the deterministic exit code 1 " + + "with an unhandled-exception crash.")] public static async Task Main(string[] args) { HostEntrypoint entrypoint = HostEntrypointResolver.Resolve(); diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index d6851c03..f1f0db22 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -7,7 +7,7 @@ public sealed class ClawCtlConsoleTests : IDisposable [Fact] public void WriteHelpListsOnlyThePublicCommands() { - var output = new StringWriter(); + using var output = new StringWriter(); ClawCtlConsole.WriteHelp(output); @@ -31,7 +31,7 @@ public void WriteHelpListsOnlyThePublicCommands() [Fact] public void WriteReadinessSummaryDescribesPackagedApplication() { - var output = new StringWriter(); + using var output = new StringWriter(); string applicationDirectory = Path.Combine(_testDirectory, "app"); ClawCtlConsole.WriteReadinessSummary( diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index df8e09a3..1ef0b16d 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -56,7 +56,7 @@ public async Task SetupChecksNodeAndPackagedApplicationWithoutMutation() Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); - var output = new StringWriter(); + using var output = new StringWriter(); int exitCode = await Program.RunControlAsync( options, From 51969c7b2022e7a2cf61690f853c1b04f868f109 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 10 Sep 2026 15:38:42 -0700 Subject: [PATCH 07/76] chore: enforce the complete .NET quality gate (#22) * chore: enforce the complete .NET quality gate Turn on TreatWarningsAsErrors now that the branch builds warning-free, and add an opt-in pre-push hook so contributors can get the CI answer before pushing. The per-rule error entries the earlier layers added only cover diagnostics somebody has already looked at. The gap they leave is everything nobody has classified yet: a new compiler warning, a new analyzer from an SDK bump, a rule that changes default severity. Those arrive as warnings and get scrolled past. TreatWarningsAsErrors turns each one into a decision at the moment it appears. The explicit .editorconfig severities stay as readable policy history. NuGet audit advisories are excluded from the gate. NU1901 through NU1904 report published vulnerabilities, so one can appear against a dependency graph nobody touched and break main with no committed change and no fix available inside the failing build. They remain visible as warnings and are triaged as security work rather than treated as build breaks. The hook is deliberately small and deliberately opt in. hooks/pre-push is tracked, carries a marker identifying it as repository-owned, and runs only Test-DotNetQuality.ps1, so it cannot drift from what CI does. Install-GitHooks.ps1 copies it into the current clone and -Remove deletes it. Neither direction touches global Git configuration or core.hooksPath; installation stops if core.hooksPath is set rather than writing a hook Git would silently ignore, refuses to overwrite a pre-push hook it did not write, and removal only deletes a hook carrying the marker. Both are idempotent. The hook is a latency shortcut, not a policy boundary. It is local to one clone and `git push --no-verify` skips it; required CI checks remain authoritative. That is stated in the hook, the installer, and CONTRIBUTING so nobody mistakes it for enforcement. Test-GitHooks.Tests.ps1 covers install, repeated install, removal, repeated removal, unmanaged-hook conflict in both directions, and the core.hooksPath refusal. The pass and fail cases are end to end: they build a throwaway repository with a local bare remote and a stub quality script, then perform a real git push, proving Git invokes the hook and that its exit code decides whether the push lands. Nothing runs against this clone. CI runs the suite alongside the existing policy suites. Validated on this branch: quality gate clean, 53 tests pass, all three PowerShell suites pass, win-x64 NativeAOT publish succeeds with no IL, trim, or AOT warnings, and an injected #warning fails the build as CS1030. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 * fix: install the pre-push hook where Git looks for it The installer composed its destination from git rev-parse --absolute-git-dir plus 'hooks'. In a linked worktree that is the worktree's private directory, which Git never consults for hooks, so installation reported success and no push ever ran the quality gate. Ask Git for the hooks directory instead, with rev-parse --git-path hooks, and use that answer for both installation and removal. A clone has one hooks directory shared by all of its worktrees, so say so in the installer output and in CONTRIBUTING. The hook suite now runs every existing case against both an ordinary clone and a linked worktree. In the worktree layout the two work trees carry opposite stub exit codes, so the real pushes prove Git ran the pushing worktree's quality script. Two further cases cover the shared destination and clone-wide removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 331d9f1d-2f78-491c-8347-19969cb5ce11 --- .github/copilot-instructions.md | 14 +- .github/workflows/gateway-msix.yml | 5 + CONTRIBUTING.md | 48 +++- Directory.Build.props | 23 +- hooks/pre-push | 23 ++ scripts/Install-GitHooks.ps1 | 143 +++++++++++ scripts/Test-DotNetQuality.ps1 | 6 +- scripts/Test-GitHooks.Tests.ps1 | 384 +++++++++++++++++++++++++++++ 8 files changed, 630 insertions(+), 16 deletions(-) create mode 100644 hooks/pre-push create mode 100644 scripts/Install-GitHooks.ps1 create mode 100644 scripts/Test-GitHooks.Tests.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 731c9cd9..304db33e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -85,10 +85,16 @@ and ARM64 separately. `Directory.Build.props` sets `AnalysisMode=All`, `EnforceCodeStyleInBuild`, and `GenerateDocumentationFile` (required for build-time `IDE0005`), and suppresses `CS1591`. Rule severity belongs in the root `.editorconfig`, not - in the project files. Rules currently reported as warnings are a known - backlog being cleared and promoted to `error` one family at a time. Do not - commit a generated suppression baseline; fix the diagnostic or add a narrow - suppression with a written rationale. + in the project files. `TreatWarningsAsErrors` is on and the build is + warning-free, so any new warning fails the build; only NuGet audit + advisories (`NU1901`-`NU1904`) are excluded, because a new advisory can break + an unchanged dependency graph. Do not commit a generated suppression + baseline; fix the diagnostic or add a narrow suppression with a written + rationale. +- The pre-push hook is opt in. `scripts\Install-GitHooks.ps1` copies the + tracked `hooks\pre-push` into the current clone and `-Remove` deletes it. + Never change `core.hooksPath` or global Git configuration, and never + overwrite a hook the repository did not write. - `.gitattributes` normalizes tracked text to LF and `.editorconfig` sets `end_of_line = lf`. Avoid whole-file rewrites through `Set-Content` or `Out-File`, which reintroduce CRLF. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 58c2e23a..826434fd 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -74,6 +74,11 @@ jobs: run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test Git hooks + shell: pwsh + run: > + .\scripts\Test-GitHooks.Tests.ps1 + build-package: name: Build OpenClaw npm package runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db0d054b..58729737 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,7 @@ or package version logic: ```powershell .\scripts\Test-SigningInputs.Tests.ps1 .\scripts\Test-WorkflowPackageVersion.Tests.ps1 +.\scripts\Test-GitHooks.Tests.ps1 ``` Run the NativeAOT publish when you change host JSON, reflection, interop, or @@ -81,11 +82,18 @@ dotnet format style .\OpenClaw.Gateway.MSIX.slnx --verify-no-changes `--verify-no-changes` exits with code 2 when it finds violations. -The analysis policy is being rolled out in stages. Rules currently reported as -warnings are a known backlog rather than an invitation to add new violations; -each is being cleared and promoted to `error` one family at a time. Do not -commit a generated suppression baseline. Fix the diagnostic, or add a narrow -suppression with a written rationale. +The build is warning-free and `TreatWarningsAsErrors` is on, so any new warning +fails the build. That includes compiler diagnostics and analyzers this +repository has never seen, such as those introduced by an SDK upgrade. When one +appears, fix it or add a narrow suppression with a written rationale next to +the code it applies to. Do not commit a generated suppression baseline, and do +not relax a rule repository-wide to get past a single site. + +NuGet audit advisories (`NU1901`-`NU1904`) are deliberately excluded from the +error gate. A newly published advisory can appear against an unchanged +dependency graph, and breaking `main` with no committed change and no in-build +fix helps nobody. They stay visible as warnings and are triaged as security +work. `.gitattributes` normalizes tracked text to LF and `.editorconfig` sets `end_of_line = lf`. Avoid whole-file rewrites through `Set-Content` or @@ -98,6 +106,36 @@ can fail the whitespace check before its first commit with `dotnet format whitespace .\OpenClaw.Gateway.MSIX.slnx` to normalize it, or configure your editor to write LF for this repository. +## Optional pre-push hook + +You can have the quality gate run before every push instead of finding out from +CI. The hook is opt in and local to your clone: + +```powershell +.\scripts\Install-GitHooks.ps1 +``` + +That writes the tracked `hooks\pre-push` into the hooks directory Git consults +for your working tree. It runs `Test-DotNetQuality.ps1` and nothing else, so it +reports exactly what CI reports. To remove it: + +```powershell +.\scripts\Install-GitHooks.ps1 -Remove +``` + +A clone has one hooks directory, shared by every linked worktree +(`git worktree add`). Installing or removing from any worktree therefore +affects all of them, and each push runs the quality script from the worktree +you pushed. Other clones are unaffected. + +Both operations are idempotent. Installation refuses to overwrite a `pre-push` +hook it did not write, removal only deletes a hook carrying its own marker, and +neither touches your global Git configuration or `core.hooksPath`. + +Skip the hook for a single push with `git push --no-verify`. The hook is a +latency shortcut, not a policy boundary: it lives in one clone, it is +bypassable, and required CI checks remain authoritative. + ## Repository conventions - Ordinary builds and tests must leave `IncludePackagingContent` unset. diff --git a/Directory.Build.props b/Directory.Build.props index 6361cdcf..079e2915 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,16 +13,31 @@ a command-line build instead of only inside an editor. `EnforceCodeStyleInBuild` runs the IDE rules but does not set their - severity. Severity lives in the root `.editorconfig`. IDE style and naming - rules are errors; the remaining CA rules are a known backlog reported as - warnings. Later PRs in this stack promote one rule family at a time to - `error`, and the final PR turns on `TreatWarningsAsErrors`. + severity. Severity lives in the root `.editorconfig`, which records the + per-rule policy this repository arrived at one family at a time. + + `TreatWarningsAsErrors` closes the gate. The explicit per-rule `error` + entries in `.editorconfig` remain as readable policy history; this switch + is what catches warnings nobody has classified yet, including new compiler + diagnostics and new analyzers introduced by a future SDK bump. When an SDK + upgrade surfaces something new, that is a deliberate decision to make, not + a warning to scroll past. --> true latest All true + true + + + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58729737..ca675887 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,6 +142,9 @@ bypassable, and required CI checks remain authoritative. Packaging builds set it to `true` and supply a runtime identifier. - Treat launcher arguments as OpenClaw-owned. Do not add host-only switches, consume `--`, rewrite arguments, or block upstream commands. +- Preserve direct execution from the read-only MSIX package. `clawctl setup` + is a readiness check; do not add runtime extraction, copying, repair, or + launcher-managed package state under the user profile. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, and signing validation. - Metadata files are part of the release trust chain. Coordinate changes across diff --git a/README.md b/README.md index f5d4df6d..8be44a29 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,15 @@ the read-only application directory the workspace. ### `clawctl` -`clawctl` owns the package readiness check: +`clawctl` exposes package readiness and launcher version information: | Command | Behavior | |---|---| | `clawctl setup` | Verify compatible Node.js is on `PATH` and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl --version` | Print the packaged launcher version. | -Bare `clawctl` and `clawctl --help` print help without changing state. +Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing +state. Commands such as `doctor`, `gateway`, and `uninstall` belong to the OpenClaw CLI and must be invoked through `openclaw`. @@ -73,7 +75,7 @@ walk, or state mutation. The launcher places Node.js in a Windows job configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The launcher remains alive while Node.js runs; if the launcher exits or is terminated, Windows terminates Node.js and -its child processes before releasing the payload lease. +its child processes when the job handle closes. Install the current Node.js LTS release, open a new terminal, optionally check readiness, then use `openclaw`: @@ -155,21 +157,21 @@ key is stored in the repository. |---|---| | OpenClaw application files | Read-only MSIX package `app` directory | | OpenClaw configuration and user state | `%USERPROFILE%\.openclaw` | -| Launcher and package-management diagnostics | `%LOCALAPPDATA%\Packages\\LocalState\OpenClawGatewayMSIX\Logs\openclaw.log` | +| Launcher diagnostics | `%LOCALAPPDATA%\Packages\\LocalState\OpenClawGatewayMSIX\Logs\openclaw.log` | OpenClaw application files are owned and serviced by Windows as part of the immutable MSIX installation. OpenClaw user state remains outside the package. Updating or removing the MSIX does not automatically delete that state or stop a running Gateway. Use OpenClaw's documented [`openclaw uninstall`](https://docs.openclaw.ai/install/uninstall) flow before -removing the MSIX. A `%USERPROFILE%\.openclaw-msix` directory left by an older -staged-payload package may be removed manually after OpenClaw is stopped. +removing the MSIX. The current package does not create or use +`%USERPROFILE%\.openclaw-msix`. ## Integrity and isolation boundary The payload build emits an expanded npm-installed application tree. `Build-MSIX.ps1` rejects bundled Node.js, copies the tree into package content, -and records every application's file path, length, and SHA-256 in +and records every application file's path, length, and SHA-256 in `payload-files.json`. Package construction verifies that exact inventory against the generated MSIX. Official signing authorization repeats the inventory validation, including rejecting missing, changed, duplicate, unsafe, diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index a741cf16..b4bd2957 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -4,12 +4,17 @@ internal static class ClawCtlConsole { public static void WriteHelp(TextWriter output) { - output.WriteLine("clawctl - OpenClaw package preparation"); + output.WriteLine("clawctl - OpenClaw package readiness"); output.WriteLine(); WriteUsage(output); output.WriteLine(); output.WriteLine("Commands:"); - output.WriteLine(" setup Prepare the packaged OpenClaw environment."); + output.WriteLine( + " setup Verify Node.js and the packaged OpenClaw application."); + output.WriteLine(); + output.WriteLine("Options:"); + output.WriteLine(" -h, --help Show this help."); + output.WriteLine(" --version Print the packaged launcher version."); output.WriteLine(); WriteNodePrerequisite(output); output.WriteLine(); @@ -17,7 +22,7 @@ public static void WriteHelp(TextWriter output) } public static void WriteUsage(TextWriter output) => - output.WriteLine("Usage: clawctl setup"); + output.WriteLine("Usage: clawctl [setup | --help | --version]"); public static void WriteNodePrerequisite(TextWriter output) { diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index f1f0db22..2076d9cc 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -13,9 +13,20 @@ public void WriteHelpListsOnlyThePublicCommands() string help = output.ToString(); Assert.Contains("setup", help, StringComparison.Ordinal); - Assert.DoesNotContain("prepare", help, StringComparison.Ordinal); - Assert.DoesNotContain("verify", help, StringComparison.Ordinal); - Assert.DoesNotContain("repair", help, StringComparison.Ordinal); + Assert.Contains("readiness", help, StringComparison.OrdinalIgnoreCase); + Assert.Contains("--version", help, StringComparison.Ordinal); + Assert.DoesNotContain( + "prepare", + help, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + $"{Environment.NewLine} verify", + help, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + $"{Environment.NewLine} repair", + help, + StringComparison.OrdinalIgnoreCase); Assert.Contains( NodeRuntimeResolver.SupportedVersions, help, From bf2ce9f7c5184747701b1b7ea432731dc3e9396d Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Thu, 10 Sep 2026 16:36:14 -0700 Subject: [PATCH 09/76] docs: remove obsolete cleanup callout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7cb16ef4-e347-4342-a67e-385a8721db1c --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 8be44a29..cfbcdbc7 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,7 @@ immutable MSIX installation. OpenClaw user state remains outside the package. Updating or removing the MSIX does not automatically delete that state or stop a running Gateway. Use OpenClaw's documented [`openclaw uninstall`](https://docs.openclaw.ai/install/uninstall) flow before -removing the MSIX. The current package does not create or use -`%USERPROFILE%\.openclaw-msix`. +removing the MSIX. ## Integrity and isolation boundary From 27cc6b84ac367786e8df3caa5d263726725bd657 Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Fri, 11 Sep 2026 15:09:18 -0700 Subject: [PATCH 10/76] feat: bundle Node.js runtime in gateway MSIX Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a35bc4fd-e612-4be2-8166-7c100e8cf3ef --- .github/copilot-instructions.md | 24 +- .github/workflows/gateway-msix.yml | 12 + CONTRIBUTING.md | 7 +- README.md | 67 +++--- scripts/Build-LocalMSIX.ps1 | 24 ++ scripts/Build-MSIX.ps1 | 88 +++++++- scripts/Test-SigningInputs.Tests.ps1 | 41 +++- scripts/Test-SigningInputs.ps1 | 68 +++++- src/OpenClaw.Launcher/ClawCtlConsole.cs | 13 +- src/OpenClaw.Launcher/HostDataPaths.cs | 67 ++++++ src/OpenClaw.Launcher/HostDiagnosticLog.cs | 41 +--- src/OpenClaw.Launcher/HostOptions.cs | 10 + src/OpenClaw.Launcher/NodeRuntimeInstaller.cs | 208 ++++++++++++++++++ src/OpenClaw.Launcher/NodeRuntimeResolver.cs | 61 ++--- .../OpenClaw.Launcher.csproj | 5 + src/OpenClaw.Launcher/Program.cs | 35 ++- .../ClawCtlConsoleTests.cs | 9 +- .../HostOptionsTests.cs | 19 ++ .../NodeRuntimeInstallerTests.cs | 101 +++++++++ .../NodeRuntimeResolverTests.cs | 8 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 10 +- 21 files changed, 739 insertions(+), 179 deletions(-) create mode 100644 src/OpenClaw.Launcher/HostDataPaths.cs create mode 100644 src/OpenClaw.Launcher/NodeRuntimeInstaller.cs create mode 100644 tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 304db33e..7f3863d7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -57,10 +57,12 @@ and ARM64 separately. app execution alias and declares the `OpenClaw.Gateway` MSIX identity. - The package contains an expanded, read-only OpenClaw application tree. `HostOptions` resolves `app\openclaw.mjs` directly from the package. -- `openclaw` resolves device-installed Node.js, confirms the packaged entry - point exists, and forwards every argument unchanged to `openclaw.mjs`. -- `clawctl setup` is a read-only readiness check for compatible Node.js and the - packaged entry point. Runtime launches do not hash or walk package files. +- `openclaw` resolves the Node.js executable extracted into package LocalState, + confirms the packaged entry point exists, and forwards every argument + unchanged to `openclaw.mjs`. +- `clawctl setup` idempotently extracts the architecture-specific bundled + Node.js archive into versioned package LocalState and verifies the packaged + entry point. Runtime launches do not hash or walk application files. - `GatewayLauncher` starts Node without a shell, uses `ArgumentList`, inherits the console streams, and sets `OPENCLAW_SUPERVISOR_MODE=external` plus `OPENCLAW_NO_AUTO_UPDATE=1`. The child process exit code is the launcher exit @@ -71,9 +73,10 @@ and ARM64 separately. - The GitHub workflow first builds and packs a pinned `openclaw/openclaw` revision on Linux. Windows matrix jobs use `Build-Payload.ps1` to produce x64/ARM64 expanded trees and build metadata, - then `Build-MSIX.ps1` to reject bundled Node.js, build the application - inventory, publish the NativeAOT host, validate package contents, and emit - MSIX metadata. + then downloads the matching official Node.js archive and uses + `Build-MSIX.ps1` to reject Node.js from the application payload, build the + application inventory, publish the NativeAOT host, validate package + contents, and emit MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and the immutable upstream commit in `release-policy.json`; signing inputs are @@ -105,9 +108,10 @@ and ARM64 separately. - Treat launcher arguments as OpenClaw-owned. Do not add host-only switches, consume `--`, rewrite arguments, or block upstream commands; tests explicitly protect transparent forwarding. -- Preserve direct execution from the immutable package and the caller's - working directory. Do not add runtime extraction, copying, hashing, or - inventory walks. +- Preserve direct execution of `app\openclaw.mjs` from the immutable package + and the caller's working directory. Node.js extraction belongs only to + `clawctl setup` and targets versioned package LocalState; do not copy the + OpenClaw application payload. - The build-time inventory is a release trust boundary. Keep safe unique paths, lengths, and SHA-256 values synchronized across composition and signing validation. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 826434fd..97881282 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -227,6 +227,16 @@ jobs: name: openclaw-gateway-payload-${{ matrix.architecture }} path: ${{ runner.temp }}\openclaw-payload + - name: Download bundled Node.js runtime + shell: pwsh + run: | + $archiveName = 'node-v${{ env.NODE_VERSION }}-win-${{ matrix.architecture }}.zip' + $archivePath = Join-Path $env:RUNNER_TEMP $archiveName + $archiveUri = 'https://nodejs.org/dist/v${{ env.NODE_VERSION }}/' + $archiveName + Invoke-WebRequest -Uri $archiveUri -OutFile $archivePath -UseBasicParsing + "NODE_ARCHIVE_PATH=$archivePath" | + Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Restore NativeAOT and MSIX dependencies shell: pwsh run: | @@ -246,6 +256,8 @@ jobs: .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` + -NodeArchivePath $env:NODE_ARCHIVE_PATH ` + -NodeVersion '${{ env.NODE_VERSION }}' ` -Architecture '${{ matrix.architecture }}' ` -PackageVersion $packageVersion ` -SourceCommit '${{ github.sha }}' ` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca675887..f201aa41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,9 +142,10 @@ bypassable, and required CI checks remain authoritative. Packaging builds set it to `true` and supply a runtime identifier. - Treat launcher arguments as OpenClaw-owned. Do not add host-only switches, consume `--`, rewrite arguments, or block upstream commands. -- Preserve direct execution from the read-only MSIX package. `clawctl setup` - is a readiness check; do not add runtime extraction, copying, repair, or - launcher-managed package state under the user profile. +- Preserve direct execution of `app\openclaw.mjs` from the read-only MSIX + package. `clawctl setup` owns idempotent extraction of the bundled Node.js + archive into versioned package LocalState; do not copy the OpenClaw + application payload or use device-installed Node.js. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, and signing validation. - Metadata files are part of the release trust chain. Coordinate changes across diff --git a/README.md b/README.md index cfbcdbc7..3dacfe14 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,8 @@ This repository builds a Windows MSIX package containing: - one .NET 10 NativeAOT launcher exposed through the `openclaw` and `clawctl` app execution aliases; - a pinned, verified build of - [`openclaw/openclaw`](https://github.com/openclaw/openclaw). - -Node.js is a device prerequisite and is never downloaded or included in the -MSIX. + [`openclaw/openclaw`](https://github.com/openclaw/openclaw); +- the official Node.js 24.16.0 archive matching the package architecture. The package is independent from the [OpenClaw Windows Node and Companion](https://github.com/openclaw/openclaw-windows-node) @@ -29,9 +27,10 @@ own package-management commands. Every argument, including an empty argument list, is forwarded unchanged to `node openclaw.mjs`, and the launcher returns the exact child exit code. -Before launching, the host discovers `node.exe` on `PATH` and verifies its -version and executable architecture. It never downloads, installs, or services -Node.js. +Before launching, the host resolves the bundled Node.js executable previously +prepared by `clawctl setup` and verifies its version and executable +architecture. Device-installed Node.js and `PATH` do not affect command +passthrough. The expanded OpenClaw application is installed read-only inside the MSIX. After resolving Node.js, the launcher confirms that packaged @@ -57,7 +56,7 @@ the read-only application directory the workspace. | Command | Behavior | |---|---| -| `clawctl setup` | Verify compatible Node.js is on `PATH` and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl setup` | Extract the bundled Node.js runtime when needed and confirm packaged `app\openclaw.mjs` exists. | | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing @@ -65,23 +64,21 @@ state. Commands such as `doctor`, `gateway`, and `uninstall` belong to the OpenClaw CLI and must be invoked through `openclaw`. -`setup` requires a compatible device-installed Node.js runtime. Missing, -outdated, malformed, or architecture-incompatible runtimes produce an -actionable error rather than a later process-launch failure. - -`clawctl setup` is read-only. It performs no extraction, hashing, inventory -walk, or state mutation. +`setup` extracts the architecture-specific runtime archive from the immutable +MSIX into the package's writable LocalState: +`%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-`. +Extraction is idempotent, versioned, and serialized across concurrent setup +processes. The launcher validates the extracted `node.exe` before reporting +readiness. The launcher places Node.js in a Windows job configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The launcher remains alive while Node.js runs; if the launcher exits or is terminated, Windows terminates Node.js and its child processes when the job handle closes. -Install the current Node.js LTS release, open a new terminal, optionally check -readiness, then use `openclaw`: +Prepare the bundled runtime once, then use `openclaw`: ```powershell -winget install --id OpenJS.NodeJS.LTS --exact --source winget clawctl setup openclaw ``` @@ -127,12 +124,14 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. `scripts\Build-MSIX.ps1` copies that -tree into package content, rejects any Node.js executable or runtime archive, -creates a per-file inventory, and then creates an unsigned NativeAOT MSIX. +architecture-specific application tree. The workflow downloads the official +Node.js archive for the same architecture. `scripts\Build-MSIX.ps1` copies the +application tree and runtime archive into package content, rejects Node.js +inside the application payload, creates a per-file inventory, and then creates +an unsigned NativeAOT MSIX. `scripts\Build-LocalMSIX.ps1` can reuse a successful workflow payload or a -local payload directory. The Node.js used by the payload build jobs is build -infrastructure only and is not copied into the MSIX. +local payload directory; unless `-NodeArchivePath` is supplied, it downloads +the pinned runtime archive used by CI. Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: @@ -156,6 +155,8 @@ key is stored in the repository. | Data | Default path | |---|---| | OpenClaw application files | Read-only MSIX package `app` directory | +| Bundled Node.js archive | Read-only MSIX package `runtime` directory | +| Extracted Node.js runtime | `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-` | | OpenClaw configuration and user state | `%USERPROFILE%\.openclaw` | | Launcher diagnostics | `%LOCALAPPDATA%\Packages\\LocalState\OpenClawGatewayMSIX\Logs\openclaw.log` | @@ -169,18 +170,18 @@ removing the MSIX. ## Integrity and isolation boundary The payload build emits an expanded npm-installed application tree. -`Build-MSIX.ps1` rejects bundled Node.js, copies the tree into package content, +`Build-MSIX.ps1` rejects Node.js from that tree, copies it into package content, and records every application file's path, length, and SHA-256 in -`payload-files.json`. Package construction verifies that exact inventory -against the generated MSIX. Official signing authorization repeats the -inventory validation, including rejecting missing, changed, duplicate, unsafe, -or unlisted application entries, before requesting signing credentials. - -At runtime, Windows' MSIX package integrity and read-only enforcement is the -trust boundary. `openclaw` and `clawctl setup` only check that -`app\openclaw.mjs` exists; neither performs file hashing or an inventory walk. -This avoids redundant startup overhead while keeping package mutation under -Windows servicing control. +`payload-files.json`. It separately validates and hashes the pinned Node.js +archive. Package construction verifies both inputs against the generated MSIX. +Official signing authorization repeats the application inventory and Node.js +archive validation before requesting signing credentials. + +At runtime, Windows' MSIX package integrity and read-only enforcement remains +the trust boundary for the application and archive. `clawctl setup` extracts +the archive into versioned package LocalState; `openclaw` launches the packaged +`app\openclaw.mjs` directly with that extracted executable. Neither command +hashes or walks the expanded application inventory. The longer-term design is to run the Gateway payload in a dedicated isolated agent session rather than the interactive session where the human user is diff --git a/scripts/Build-LocalMSIX.ps1 b/scripts/Build-LocalMSIX.ps1 index 7dc0a76c..c0e77db8 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -7,6 +7,11 @@ param( [long]$PayloadRunId, + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$NodeVersion = '24.16.0', + + [string]$NodeArchivePath, + [string]$PackageVersion, [string]$OutputDirectory @@ -55,6 +60,23 @@ if (Test-Path -LiteralPath $OutputDirectory) { } New-Item -Path $workDirectory -ItemType Directory -Force | Out-Null +if ($NodeArchivePath) { + $resolvedNodeArchivePath = ( + Resolve-Path -LiteralPath $NodeArchivePath + ).Path +} +else { + $nodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" + $resolvedNodeArchivePath = Join-Path $workDirectory $nodeArchiveName + $nodeArchiveUri = + "https://nodejs.org/dist/v$NodeVersion/$nodeArchiveName" + Write-Host "Downloading bundled Node.js runtime from $nodeArchiveUri." + Invoke-WebRequest ` + -Uri $nodeArchiveUri ` + -OutFile $resolvedNodeArchivePath ` + -UseBasicParsing +} + if ($PayloadDirectory) { $resolvedPayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path } @@ -135,6 +157,8 @@ try { Write-Host "Building unsigned MSIX version $PackageVersion." & .\scripts\Build-MSIX.ps1 ` -PayloadDirectory $resolvedPayloadDirectory ` + -NodeArchivePath $resolvedNodeArchivePath ` + -NodeVersion $NodeVersion ` -Architecture $Architecture ` -PackageVersion $PackageVersion ` -SourceCommit $sourceCommit ` diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index d690f864..955d9ffa 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -3,6 +3,13 @@ param( [Parameter(Mandatory)] [string]$PayloadDirectory, + [Parameter(Mandatory)] + [string]$NodeArchivePath, + + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$NodeVersion, + [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string]$Architecture, @@ -129,6 +136,36 @@ function Assert-ApplicationHasNoReparsePoints { } } +function Assert-NodeArchive { + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [string]$ExpectedRoot + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $nodeEntries = @( + $archive.Entries | + Where-Object { + $_.FullName -ieq "$ExpectedRoot/node.exe" + } + ) + if ($nodeEntries.Count -ne 1) { + throw ( + "The Node.js archive must contain exactly one " + + "'$ExpectedRoot/node.exe' entry." + ) + } + } + finally { + $archive.Dispose() + } +} + function Add-VswhereToPath { if (Get-Command vswhere.exe -CommandType Application -ErrorAction SilentlyContinue) { return @@ -154,6 +191,20 @@ Add-VswhereToPath $PayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path $payloadApplication = Join-Path $PayloadDirectory 'app' $payloadMetadata = Join-Path $PayloadDirectory 'payload-metadata.json' +$NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path +$expectedNodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" +if ([IO.Path]::GetFileName($NodeArchivePath) -cne $expectedNodeArchiveName) { + throw ( + "NodeArchivePath must name the expected runtime archive: " + + $expectedNodeArchiveName + ) +} +$expectedNodeArchiveRoot = [IO.Path]::GetFileNameWithoutExtension( + $expectedNodeArchiveName +) +Assert-NodeArchive ` + -Path $NodeArchivePath ` + -ExpectedRoot $expectedNodeArchiveRoot if (-not (Test-Path -LiteralPath $payloadApplication -PathType Container)) { throw "Required MSIX input was not found: $payloadApplication" } @@ -184,6 +235,10 @@ Assert-ApplicationDoesNotBundleNode -Path $payloadApplication $contentRoot = Join-Path $repositoryRoot 'content' $openClawContent = Join-Path $contentRoot 'openclaw' $applicationTarget = Join-Path $openClawContent 'app' +$runtimeTargetDirectory = Join-Path $openClawContent 'runtime' +$nodeArchiveTarget = Join-Path ` + $runtimeTargetDirectory ` + $expectedNodeArchiveName New-Item -Path $openClawContent -ItemType Directory -Force | Out-Null if ( @@ -197,6 +252,15 @@ if ( -Recurse } +Remove-DirectoryIfPresent -Path $runtimeTargetDirectory +New-Item -Path $runtimeTargetDirectory -ItemType Directory -Force | Out-Null +Copy-Item ` + -LiteralPath $NodeArchivePath ` + -Destination $nodeArchiveTarget +$nodeArchiveHash = ( + Get-FileHash -LiteralPath $nodeArchiveTarget -Algorithm SHA256 +).Hash.ToLowerInvariant() + $payloadSymbols = @( # MSBuild's own AppxPackagePayload step strips .pdb files when it later # packages content\openclaw\app; remove them here too so the inventory @@ -328,6 +392,12 @@ try { ).Hash.ToLowerInvariant() } ) + $expectedPackageFiles.Add( + "runtime/$expectedNodeArchiveName", + [pscustomobject]@{ + Hash = $nodeArchiveHash + } + ) $packageEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -417,17 +487,20 @@ try { if (-not $packageEntries.Contains('openclaw.exe')) { throw 'The MSIX does not contain the NativeAOT host executable.' } - $bundledNodeEntries = @( + $unexpectedNodeEntries = @( $packageEntries | Where-Object { - [IO.Path]::GetFileName($_) -ieq 'node.exe' -or - [IO.Path]::GetFileName($_) -match '^node-v\d' + ( + [IO.Path]::GetFileName($_) -ieq 'node.exe' -or + [IO.Path]::GetFileName($_) -match '^node-v\d' + ) -and + $_ -ine "runtime/$expectedNodeArchiveName" } ) - if ($bundledNodeEntries.Count -ne 0) { + if ($unexpectedNodeEntries.Count -ne 0) { throw ( - 'The MSIX must not bundle Node.js: ' + - (($bundledNodeEntries | Sort-Object) -join ', ') + 'The MSIX contains unexpected Node.js content: ' + + (($unexpectedNodeEntries | Sort-Object) -join ', ') ) } foreach ($managedHostArtifact in @( @@ -463,6 +536,9 @@ try { payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $NodeVersion + nodeRuntimeArchive = $expectedNodeArchiveName + nodeRuntimeSha256 = $nodeArchiveHash architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index e31b3e01..59b61eeb 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -124,6 +124,38 @@ function New-TestArtifact { -LiteralPath (Join-Path $runtimeDirectory 'node.exe') ` -Value 'bundled-node' } + else { + $runtimeDirectory = Join-Path $staging 'runtime' + New-Item -Path $runtimeDirectory -ItemType Directory | Out-Null + } + $nodeRuntimeVersion = '24.16.0' + $nodeRuntimeArchive = + "node-v$nodeRuntimeVersion-win-$Architecture.zip" + $nodeRuntimePath = Join-Path $runtimeDirectory $nodeRuntimeArchive + $nodeRuntimeZip = [IO.Compression.ZipFile]::Open( + $nodeRuntimePath, + [IO.Compression.ZipArchiveMode]::Create) + try { + $nodeRuntimeRoot = [IO.Path]::GetFileNameWithoutExtension( + $nodeRuntimeArchive + ) + $nodeEntry = $nodeRuntimeZip.CreateEntry( + "$nodeRuntimeRoot/node.exe" + ) + $nodeWriter = [IO.StreamWriter]::new($nodeEntry.Open()) + try { + $nodeWriter.Write('bundled-node') + } + finally { + $nodeWriter.Dispose() + } + } + finally { + $nodeRuntimeZip.Dispose() + } + $nodeRuntimeHash = ( + Get-FileHash -LiteralPath $nodeRuntimePath -Algorithm SHA256 + ).Hash.ToLowerInvariant() $msixName = "OpenClawGateway-$Architecture.msix" $msixPath = Join-Path $directory $msixName @@ -143,6 +175,9 @@ function New-TestArtifact { payloadResolvedCommit = $PayloadCommit payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $nodeRuntimeVersion + nodeRuntimeArchive = $nodeRuntimeArchive + nodeRuntimeSha256 = $nodeRuntimeHash architecture = $Architecture archive = $msixName sha256 = $msixHash @@ -296,7 +331,7 @@ try { -IncludeBundledNode $true New-TestArtifact -Root $testRoot -Architecture arm64 Assert-Fails ` - -MessagePattern 'x64 MSIX bundles Node.js' ` + -MessagePattern 'x64 MSIX has unexpected Node.js content' ` -Action { Invoke-PolicyValidation -Root $testRoot } @@ -309,7 +344,7 @@ try { -IncludeApplicationBundledNode $true New-TestArtifact -Root $testRoot -Architecture arm64 Assert-Fails ` - -MessagePattern 'x64 MSIX bundles Node.js' ` + -MessagePattern 'x64 MSIX has unexpected Node.js content' ` -Action { Invoke-PolicyValidation -Root $testRoot } @@ -322,7 +357,7 @@ try { -IncludeApplicationNodeArchive $true New-TestArtifact -Root $testRoot -Architecture arm64 Assert-Fails ` - -MessagePattern 'x64 MSIX bundles Node.js' ` + -MessagePattern 'x64 MSIX has unexpected Node.js content' ` -Action { Invoke-PolicyValidation -Root $testRoot } diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 9b9cd52a..9a81655e 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -108,6 +108,39 @@ function Get-PackageEntrySha256 { } } +function Assert-NodeArchiveEntry { + param( + [Parameter(Mandatory)] + [IO.Compression.ZipArchiveEntry]$Entry, + + [Parameter(Mandatory)] + [string]$ExpectedRoot + ) + + $stream = $Entry.Open() + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Read) + try { + $nodeEntries = @( + $archive.Entries | + Where-Object { + $_.FullName -ieq "$ExpectedRoot/node.exe" + } + ) + if ($nodeEntries.Count -ne 1) { + throw ( + "The Node.js archive must contain exactly one " + + "'$ExpectedRoot/node.exe' entry." + ) + } + } + finally { + $archive.Dispose() + $stream.Dispose() + } +} + $resolvedArtifactsDirectory = ( Resolve-Path -LiteralPath $ArtifactsDirectory ).Path @@ -166,6 +199,10 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.payloadLayout -ne 'immutable-package' -or $metadata.payloadFileCount -isnot [int64] -or $metadata.payloadFileCount -le 0 -or + $metadata.nodeRuntimeVersion -notmatch '^\d+\.\d+\.\d+$' -or + $metadata.nodeRuntimeArchive -ne + "node-v$($metadata.nodeRuntimeVersion)-win-$architecture.zip" -or + $metadata.nodeRuntimeSha256 -notmatch '^[0-9a-fA-F]{64}$' -or $metadata.architecture -ne $architecture -or $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or @@ -193,16 +230,37 @@ foreach ($architecture in @('x64', 'arm64')) { try { # MSIX percent-encodes some names, so index decoded paths once. $entriesByPath = New-PackageEntryIndex -Archive $packageArchive - $bundledNodeEntries = @( + $expectedNodeArchivePath = + "runtime/$($metadata.nodeRuntimeArchive)" + $unexpectedNodeEntries = @( $entriesByPath.Keys | Where-Object { - [IO.Path]::GetFileName($_) -ieq 'node.exe' -or - [IO.Path]::GetFileName($_) -match '^node-v\d' + ( + [IO.Path]::GetFileName($_) -ieq 'node.exe' -or + [IO.Path]::GetFileName($_) -match '^node-v\d' + ) -and + $_ -ine $expectedNodeArchivePath } ) - if ($bundledNodeEntries.Count -ne 0) { - throw "The $architecture MSIX bundles Node.js." + if ($unexpectedNodeEntries.Count -ne 0) { + throw "The $architecture MSIX has unexpected Node.js content." } + $nodeArchiveEntry = Get-PackageEntry ` + -EntriesByPath $entriesByPath ` + -Path $expectedNodeArchivePath + if ( + (Get-PackageEntrySha256 -Entry $nodeArchiveEntry) -ine + $metadata.nodeRuntimeSha256 + ) { + throw "The embedded $architecture Node.js archive is invalid." + } + Assert-NodeArchiveEntry ` + -Entry $nodeArchiveEntry ` + -ExpectedRoot ( + [IO.Path]::GetFileNameWithoutExtension( + [string]$metadata.nodeRuntimeArchive + ) + ) [xml]$manifest = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index b4bd2957..866991f8 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -10,32 +10,23 @@ public static void WriteHelp(TextWriter output) output.WriteLine(); output.WriteLine("Commands:"); output.WriteLine( - " setup Verify Node.js and the packaged OpenClaw application."); + " setup Extract Node.js and verify the packaged OpenClaw application."); output.WriteLine(); output.WriteLine("Options:"); output.WriteLine(" -h, --help Show this help."); output.WriteLine(" --version Print the packaged launcher version."); output.WriteLine(); - WriteNodePrerequisite(output); - output.WriteLine(); output.WriteLine("Run `openclaw ` to invoke the OpenClaw CLI."); } public static void WriteUsage(TextWriter output) => output.WriteLine("Usage: clawctl [setup | --help | --version]"); - public static void WriteNodePrerequisite(TextWriter output) - { - output.WriteLine( - $"Prerequisite: install Node.js {NodeRuntimeResolver.SupportedVersions}."); - output.WriteLine($" {NodeRuntimeResolver.InstallCommand}"); - } - internal static void WriteNodeRuntimeSummary( TextWriter output, NodeRuntime runtime) => output.WriteLine( - $"Using Node.js {runtime.Version} from {runtime.ExecutablePath}"); + $"Bundled Node.js {runtime.Version} is ready at {runtime.ExecutablePath}"); public static void WriteReadinessSummary( TextWriter output, diff --git a/src/OpenClaw.Launcher/HostDataPaths.cs b/src/OpenClaw.Launcher/HostDataPaths.cs new file mode 100644 index 00000000..b4a085dd --- /dev/null +++ b/src/OpenClaw.Launcher/HostDataPaths.cs @@ -0,0 +1,67 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher; + +internal static class HostDataPaths +{ + private const int ErrorInsufficientBuffer = 122; + private const int AppModelErrorNoPackage = 15700; + + public static string GetProductLocalStateRoot() + { + string localAppData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + throw new InvalidOperationException( + "The local application data directory is unavailable."); + } + + string? packageFamilyName = TryGetPackageFamilyName(); + return packageFamilyName is null + ? Path.Combine(localAppData, "OpenClaw") + : Path.Combine( + localAppData, + "Packages", + packageFamilyName, + "LocalState", + "OpenClaw"); + } + + internal static string? TryGetPackageFamilyName() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + uint length = 0; + int result = GetCurrentPackageFamilyName(ref length, null); + if (result == AppModelErrorNoPackage) + { + return null; + } + + if (result != ErrorInsufficientBuffer || length == 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + var value = new char[length]; + result = GetCurrentPackageFamilyName(ref length, value); + if (result != 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + return new string(value, 0, checked((int)length - 1)); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFamilyName( + ref uint packageFamilyNameLength, + [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] + char[]? packageFamilyName); +} diff --git a/src/OpenClaw.Launcher/HostDiagnosticLog.cs b/src/OpenClaw.Launcher/HostDiagnosticLog.cs index f6b794b5..103866e2 100644 --- a/src/OpenClaw.Launcher/HostDiagnosticLog.cs +++ b/src/OpenClaw.Launcher/HostDiagnosticLog.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -7,8 +6,6 @@ namespace OpenClaw.Launcher; internal sealed class HostDiagnosticLog : IDisposable { - private const int ErrorInsufficientBuffer = 122; - private const int AppModelErrorNoPackage = 15700; private readonly Lock _sync = new(); private readonly Mutex _writeMutex; private bool _disposed; @@ -42,7 +39,7 @@ public static HostDiagnosticLog Create() "The local application data directory is unavailable."); } - string? packageFamilyName = GetPackageFamilyName(); + string? packageFamilyName = HostDataPaths.TryGetPackageFamilyName(); string logRoot = packageFamilyName is null ? System.IO.Path.Combine(localAppData, "OpenClawGatewayMSIX") : System.IO.Path.Combine( @@ -119,40 +116,4 @@ public void Dispose() GC.SuppressFinalize(this); } - private static string? GetPackageFamilyName() - { - if (!OperatingSystem.IsWindows()) - { - return null; - } - - uint length = 0; - int result = GetCurrentPackageFamilyName(ref length, null); - if (result == AppModelErrorNoPackage) - { - return null; - } - - if (result != ErrorInsufficientBuffer || length == 0) - { - throw new InvalidOperationException( - $"Unable to determine package identity (error {result})."); - } - - var value = new char[length]; - result = GetCurrentPackageFamilyName(ref length, value); - if (result != 0) - { - throw new InvalidOperationException( - $"Unable to determine package identity (error {result})."); - } - - return new string(value, 0, checked((int)length - 1)); - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern int GetCurrentPackageFamilyName( - ref uint packageFamilyNameLength, - [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] - char[]? packageFamilyName); } diff --git a/src/OpenClaw.Launcher/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 0432db7a..6dace6f0 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -2,6 +2,7 @@ namespace OpenClaw.Launcher; internal sealed record HostOptions( string? PackagedApplicationDirectory, + string? PackagedNodeArchivePath, IReadOnlyList OpenClawArguments) { public static HostOptions Parse(IReadOnlyList arguments) => @@ -23,9 +24,18 @@ internal static HostOptions Parse( "openclaw.mjs")) ? packagedApplicationDirectory : null; + string packagedNodeArchivePath = Path.Combine( + baseDirectory, + "runtime", + NodeRuntimeInstaller.GetArchiveFileName( + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); return new HostOptions( directApplicationDirectory, + File.Exists(packagedNodeArchivePath) + ? packagedNodeArchivePath + : null, [.. arguments]); } } diff --git a/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs new file mode 100644 index 00000000..06475f6a --- /dev/null +++ b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs @@ -0,0 +1,208 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher; + +internal static class NodeRuntimeInstaller +{ + public const string Version = "24.16.0"; + + public static string GetArchiveFileName(Architecture architecture) => + $"node-v{Version}-win-{GetArchitectureName(architecture)}.zip"; + + public static string GetInstallDirectory(Architecture architecture) => + Path.Combine( + HostDataPaths.GetProductLocalStateRoot(), + "NodeJS", + $"node-v{Version}-win-{GetArchitectureName(architecture)}"); + + public static string GetExecutablePath(Architecture architecture) => + Path.Combine(GetInstallDirectory(architecture), "node.exe"); + + public static async Task EnsureInstalledAsync( + string archivePath, + CancellationToken cancellationToken) + { + Architecture architecture = RuntimeInformation.ProcessArchitecture; + string executablePath = EnsureInstalled( + archivePath, + GetInstallDirectory(architecture), + architecture); + return await NodeRuntimeResolver.ResolvePathAsync( + executablePath, + cancellationToken).ConfigureAwait(false); + } + + internal static string EnsureInstalled( + string archivePath, + string installDirectory, + Architecture architecture) + { + string executablePath = Path.Combine(installDirectory, "node.exe"); + if (File.Exists(executablePath)) + { + return executablePath; + } + + if (!File.Exists(archivePath)) + { + throw new FileNotFoundException( + "The packaged Node.js runtime archive was not found.", + archivePath); + } + + string mutexName = + $"Local\\OpenClawGatewayMSIX.NodeRuntime.{GetArchitectureName(architecture)}"; + using var mutex = new Mutex(initiallyOwned: false, mutexName); + bool ownsMutex = false; + try + { + try + { + ownsMutex = mutex.WaitOne(); + } + catch (AbandonedMutexException) + { + ownsMutex = true; + } + + if (File.Exists(executablePath)) + { + return executablePath; + } + + string? parentDirectory = Path.GetDirectoryName(installDirectory); + if (string.IsNullOrWhiteSpace(parentDirectory)) + { + throw new InvalidOperationException( + "The Node.js installation path has no parent directory."); + } + + Directory.CreateDirectory(parentDirectory); + string stagingDirectory = + $"{installDirectory}.{Guid.NewGuid():N}.extract"; + try + { + ExtractRuntime( + archivePath, + stagingDirectory, + architecture); + if (!File.Exists(Path.Combine(stagingDirectory, "node.exe"))) + { + throw new InvalidDataException( + "The packaged Node.js archive does not contain node.exe."); + } + + if (Directory.Exists(installDirectory)) + { + Directory.Delete(installDirectory, recursive: true); + } + Directory.Move(stagingDirectory, installDirectory); + } + finally + { + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + } + + return executablePath; + } + finally + { + if (ownsMutex) + { + mutex.ReleaseMutex(); + } + } + } + + private static void ExtractRuntime( + string archivePath, + string stagingDirectory, + Architecture architecture) + { + string archiveRoot = + $"node-v{Version}-win-{GetArchitectureName(architecture)}"; + string archivePrefix = archiveRoot + "/"; + string fullStagingDirectory = + Path.GetFullPath(stagingDirectory) + Path.DirectorySeparatorChar; + Directory.CreateDirectory(stagingDirectory); + + using ZipArchive archive = ZipFile.OpenRead(archivePath); + foreach (ZipArchiveEntry entry in archive.Entries) + { + string archivePathName = entry.FullName.Replace('\\', '/'); + if (string.Equals( + archivePathName, + archiveRoot + "/", + StringComparison.Ordinal)) + { + continue; + } + + if (!archivePathName.StartsWith( + archivePrefix, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Unexpected Node.js archive entry: {entry.FullName}"); + } + + bool isDirectory = string.IsNullOrEmpty(entry.Name); + string relativePath = archivePathName[archivePrefix.Length..]; + if (isDirectory) + { + relativePath = relativePath.TrimEnd('/'); + } + string[] segments = relativePath.Split('/'); + if ( + string.IsNullOrWhiteSpace(relativePath) || + Path.IsPathRooted(relativePath) || + relativePath.Contains(':', StringComparison.Ordinal) || + segments.Contains(string.Empty, StringComparer.Ordinal) || + segments.Contains(".", StringComparer.Ordinal) || + segments.Contains("..", StringComparer.Ordinal)) + { + throw new InvalidDataException( + $"Unsafe Node.js archive entry: {entry.FullName}"); + } + + string destinationPath = Path.GetFullPath( + Path.Combine(stagingDirectory, relativePath)); + if (!destinationPath.StartsWith( + fullStagingDirectory, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Unsafe Node.js archive entry: {entry.FullName}"); + } + + if (isDirectory) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + string? destinationDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrWhiteSpace(destinationDirectory)) + { + throw new InvalidDataException( + $"Invalid Node.js archive entry: {entry.FullName}"); + } + + Directory.CreateDirectory(destinationDirectory); + entry.ExtractToFile(destinationPath); + } + } + + private static string GetArchitectureName(Architecture architecture) => + architecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException( + $"Node.js runtime packaging does not support {architecture}.") + }; +} diff --git a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs index f9fbc589..5f246861 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -13,9 +13,6 @@ internal sealed record NodeRuntime( internal static partial class NodeRuntimeResolver { - public const string InstallCommand = - "winget install --id OpenJS.NodeJS.LTS --exact --source winget"; - private static readonly TimeSpan VersionQueryTimeout = TimeSpan.FromSeconds(10); private static readonly NodeVersionRange[] SupportedVersionRanges = [ @@ -32,8 +29,16 @@ range.ExclusiveMajor is int exclusiveMajor : $">={range.Minimum}")); public static Task ResolveAsync(CancellationToken cancellationToken) => + ResolvePathAsync( + NodeRuntimeInstaller.GetExecutablePath( + RuntimeInformation.ProcessArchitecture), + cancellationToken); + + public static Task ResolvePathAsync( + string executablePath, + CancellationToken cancellationToken) => ResolveAsync( - FindPathCandidates(), + File.Exists(executablePath) ? [executablePath] : [], QueryVersionAsync, ReadArchitecture, RuntimeInformation.ProcessArchitecture, @@ -49,7 +54,8 @@ internal static async Task ResolveAsync( if (candidates.Count == 0) { throw new InvalidOperationException( - CreateFailureMessage("Node.js was not found on PATH.")); + CreateFailureMessage( + "The bundled Node.js runtime has not been extracted.")); } var failures = new List(); @@ -118,49 +124,8 @@ internal static Version ParseVersion(string output) internal static string CreateFailureMessage(string detail) => $"{detail}{Environment.NewLine}" + - $"Install a supported Node.js runtime ({SupportedVersions}):{Environment.NewLine}" + - $" {InstallCommand}{Environment.NewLine}" + - "Then open a new terminal and retry."; - - private static List FindPathCandidates() - { - string? pathValue = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrWhiteSpace(pathValue)) - { - return []; - } - - var candidates = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (string entry in pathValue.Split(Path.PathSeparator)) - { - string directory = entry.Trim().Trim('"'); - if (string.IsNullOrWhiteSpace(directory)) - { - continue; - } - - string candidate; - try - { - candidate = Path.GetFullPath(Path.Combine(directory, "node.exe")); - } - catch (Exception exception) when ( - exception is ArgumentException or - NotSupportedException or - PathTooLongException) - { - continue; - } - - if (File.Exists(candidate) && seen.Add(candidate)) - { - candidates.Add(candidate); - } - } - - return candidates; - } + $"Run `clawctl setup` to extract Node.js {NodeRuntimeInstaller.Version} " + + "from the installed OpenClaw package."; private static async Task QueryVersionAsync( string executablePath, diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index c5aa467e..47071d72 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -81,6 +81,9 @@ + + RunControlAsync( Action log, Action writeError, TextWriter output, - Func>? resolveNode = null) + Func>? setupNode = null) { ClawCtlCommandParseResult parsed = ClawCtlCommandParser.Parse(args); if (parsed.Error is not null) @@ -189,9 +189,19 @@ await output.WriteLineAsync( return 0; case ClawCtlCommand.Setup: { - NodeRuntime nodeRuntime = await ( - resolveNode ?? NodeRuntimeResolver.ResolveAsync)( + NodeRuntime nodeRuntime; + if (setupNode is not null) + { + nodeRuntime = await setupNode(CancellationToken.None) + .ConfigureAwait(false); + } + else + { + string archivePath = GetPackagedNodeArchivePath(options); + nodeRuntime = await NodeRuntimeInstaller.EnsureInstalledAsync( + archivePath, CancellationToken.None).ConfigureAwait(false); + } ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); string applicationDirectory = GetPackagedApplicationDirectory(options); @@ -231,4 +241,23 @@ private static string GetPackagedApplicationDirectory(HostOptions options) return applicationDirectory; } + + private static string GetPackagedNodeArchivePath(HostOptions options) + { + string? archivePath = options.PackagedNodeArchivePath; + string expectedPath = archivePath ?? Path.Combine( + AppContext.BaseDirectory, + "runtime", + NodeRuntimeInstaller.GetArchiveFileName( + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); + if (archivePath is null || !File.Exists(archivePath)) + { + throw new FileNotFoundException( + "The packaged Node.js runtime archive was not found.", + expectedPath); + } + + return archivePath; + } } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 2076d9cc..948999e6 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -27,14 +27,7 @@ public void WriteHelpListsOnlyThePublicCommands() $"{Environment.NewLine} repair", help, StringComparison.OrdinalIgnoreCase); - Assert.Contains( - NodeRuntimeResolver.SupportedVersions, - help, - StringComparison.Ordinal); - Assert.Contains( - NodeRuntimeResolver.InstallCommand, - help, - StringComparison.Ordinal); + Assert.Contains("Extract Node.js", help, StringComparison.Ordinal); Assert.DoesNotContain("update-package", help, StringComparison.Ordinal); Assert.DoesNotContain("gateway-service", help, StringComparison.Ordinal); } diff --git a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs index 41f8893d..48c9ccbe 100644 --- a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs @@ -31,6 +31,7 @@ public void ParseReportsMissingPackagedApplication() HostOptions options = HostOptions.Parse([], _testDirectory); Assert.Null(options.PackagedApplicationDirectory); + Assert.Null(options.PackagedNodeArchivePath); Assert.Empty(options.OpenClawArguments); } @@ -50,9 +51,27 @@ public void ParseResolvesPackagedApplicationWhenEntryPointExists() Assert.Equal( applicationDirectory, options.PackagedApplicationDirectory); + Assert.Null(options.PackagedNodeArchivePath); Assert.Equal(["gateway", "run"], options.OpenClawArguments); } + [Fact] + public void ParseResolvesArchitectureSpecificPackagedNodeArchive() + { + string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); + Directory.CreateDirectory(runtimeDirectory); + string archivePath = Path.Combine( + runtimeDirectory, + NodeRuntimeInstaller.GetArchiveFileName( + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); + File.WriteAllText(archivePath, "fixture"); + + HostOptions options = HostOptions.Parse([], _testDirectory); + + Assert.Equal(archivePath, options.PackagedNodeArchivePath); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs new file mode 100644 index 00000000..989d9442 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs @@ -0,0 +1,101 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher.Tests; + +public sealed class NodeRuntimeInstallerTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + [Fact] + public void EnsureInstalledExtractsArchitectureRuntimeOnce() + { + Architecture architecture = Architecture.X64; + string archivePath = CreateRuntimeArchive(architecture); + string installDirectory = Path.Combine(_testDirectory, "installed"); + + string firstPath = NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + architecture); + DateTime firstWriteTime = File.GetLastWriteTimeUtc(firstPath); + string secondPath = NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + architecture); + + Assert.Equal(Path.Combine(installDirectory, "node.exe"), firstPath); + Assert.Equal(firstPath, secondPath); + Assert.Equal("fixture-node", File.ReadAllText(firstPath)); + Assert.Equal(firstWriteTime, File.GetLastWriteTimeUtc(secondPath)); + } + + [Fact] + public void EnsureInstalledRejectsEntriesOutsideExpectedRuntimeRoot() + { + string archivePath = Path.Combine(_testDirectory, "unsafe.zip"); + using (ZipArchive archive = ZipFile.Open( + archivePath, + ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("unexpected/node.exe"); + using StreamWriter writer = new(entry.Open()); + writer.Write("fixture-node"); + } + + Assert.Throws(() => + NodeRuntimeInstaller.EnsureInstalled( + archivePath, + Path.Combine(_testDirectory, "unsafe-install"), + Architecture.X64)); + } + + [Fact] + public void EnsureInstalledReplacesIncompleteRuntimeDirectory() + { + Architecture architecture = Architecture.X64; + string archivePath = CreateRuntimeArchive(architecture); + string installDirectory = Path.Combine(_testDirectory, "incomplete"); + Directory.CreateDirectory(installDirectory); + File.WriteAllText( + Path.Combine(installDirectory, "partial.txt"), + "partial"); + + string executablePath = NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + architecture); + + Assert.True(File.Exists(executablePath)); + Assert.False(File.Exists( + Path.Combine(installDirectory, "partial.txt"))); + } + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } + + private string CreateRuntimeArchive(Architecture architecture) + { + string archivePath = Path.Combine( + _testDirectory, + NodeRuntimeInstaller.GetArchiveFileName(architecture)); + string rootName = Path.GetFileNameWithoutExtension(archivePath); + using ZipArchive archive = ZipFile.Open( + archivePath, + ZipArchiveMode.Create); + archive.CreateEntry($"{rootName}/node_modules/"); + ZipArchiveEntry nestedEntry = archive.CreateEntry( + $"{rootName}/node_modules/package.json"); + using (StreamWriter nestedWriter = new(nestedEntry.Open())) + { + nestedWriter.Write("{}"); + } + ZipArchiveEntry entry = archive.CreateEntry($"{rootName}/node.exe"); + using StreamWriter writer = new(entry.Open()); + writer.Write("fixture-node"); + return archivePath; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs index 3c77ea15..cf92dcda 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs @@ -19,7 +19,7 @@ public async Task ResolveAcceptsCompatibleRuntime() } [Fact] - public async Task ResolveReportsInstallCommandWhenRuntimeIsMissing() + public async Task ResolveReportsSetupCommandWhenRuntimeIsMissing() { InvalidOperationException exception = await Assert.ThrowsAsync< InvalidOperationException>( @@ -29,8 +29,8 @@ public async Task ResolveReportsInstallCommandWhenRuntimeIsMissing() Architecture.X64, Architecture.X64)); - Assert.Contains("not found on PATH", exception.Message, StringComparison.Ordinal); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message, StringComparison.Ordinal); + Assert.Contains("has not been extracted", exception.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } [Fact] @@ -103,7 +103,7 @@ public async Task ResolveReportsVersionQueryFailure() Architecture.X64)); Assert.Contains("query failed", exception.Message, StringComparison.Ordinal); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } [Theory] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 1ef0b16d..c18f3d7c 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -13,7 +13,7 @@ await File.WriteAllTextAsync( Path.Combine(applicationDirectory, "openclaw.mjs"), "console.log('fixture');"); string[] arguments = ["gateway", "run", "--port", "12345"]; - var options = new HostOptions(applicationDirectory, arguments); + var options = new HostOptions(applicationDirectory, null, arguments); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), @@ -44,14 +44,14 @@ await File.WriteAllTextAsync( } [Fact] - public async Task SetupChecksNodeAndPackagedApplicationWithoutMutation() + public async Task SetupPreparesNodeAndChecksPackagedApplication() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); await File.WriteAllTextAsync(entryPoint, "console.log('fixture');"); DateTime lastWriteTime = File.GetLastWriteTimeUtc(entryPoint); - var options = new HostOptions(applicationDirectory, []); + var options = new HostOptions(applicationDirectory, null, []); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), @@ -86,7 +86,7 @@ public async Task SetupResolvesNodeBeforeReportingMissingApplication() await Assert.ThrowsAsync( () => Program.RunControlAsync( - new HostOptions(null, []), + new HostOptions(null, null, []), ["setup"], _ => { }, _ => { }, @@ -112,7 +112,7 @@ public async Task AgentResolvesNodeBeforeReportingMissingApplication() await Assert.ThrowsAsync( () => Program.RunAgentAsync( - new HostOptions(null, []), + new HostOptions(null, null, []), _ => { }, _ => { From fa303bff28a8906a65566472e7f08259b540acfb Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Fri, 11 Sep 2026 15:48:52 -0700 Subject: [PATCH 11/76] fix: fail loudly when test MSIX signing has no inputs (#31) The signing script previously skipped both architecture directories when the artifact root was not CI-shaped, then reported success after creating and discarding a certificate. Validate the expected architecture directories before creating signing material so a no-op cannot look like a signed result. Add a regression test that runs the script against an empty artifact root and verifies the failure message, non-zero exit, absence of output, and absence of a newly created publisher certificate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- scripts/Sign-TestMSIX.ps1 | 20 ++++++++- scripts/Test-Sign-TestMSIX.Tests.ps1 | 63 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 scripts/Test-Sign-TestMSIX.Tests.ps1 diff --git a/scripts/Sign-TestMSIX.ps1 b/scripts/Sign-TestMSIX.ps1 index 6e0d1162..fa1d19f9 100644 --- a/scripts/Sign-TestMSIX.ps1 +++ b/scripts/Sign-TestMSIX.ps1 @@ -29,6 +29,24 @@ if ([string]::IsNullOrWhiteSpace($publisher)) { throw 'The Gateway MSIX release policy publisher is missing.' } +$architectures = @('x64', 'arm64') +$architectureDirectories = @( + $architectures | + ForEach-Object { + $sourceDirectory = Join-Path $resolvedArtifactsDirectory $_ + if (Test-Path -LiteralPath $sourceDirectory -PathType Container) { + $sourceDirectory + } + } +) +if ($architectureDirectories.Count -eq 0) { + throw ( + "No architecture directories were found under " + + "'$resolvedArtifactsDirectory'. Expected at least one of: " + + ($architectures -join ', ') + '.' + ) +} + $signtool = Get-ChildItem ` -LiteralPath "${env:ProgramFiles(x86)}\Windows Kits\10\bin" ` -Filter 'signtool.exe' ` @@ -75,7 +93,7 @@ try { -Password $password | Out-Null - foreach ($architecture in @('x64', 'arm64')) { + foreach ($architecture in $architectures) { $sourceDirectory = Join-Path $resolvedArtifactsDirectory $architecture if (-not (Test-Path -LiteralPath $sourceDirectory -PathType Container)) { continue diff --git a/scripts/Test-Sign-TestMSIX.Tests.ps1 b/scripts/Test-Sign-TestMSIX.Tests.ps1 new file mode 100644 index 00000000..88338e1c --- /dev/null +++ b/scripts/Test-Sign-TestMSIX.Tests.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $IsWindows) { + throw 'Test MSIX signing tests require Windows.' +} + +$scriptPath = Join-Path $PSScriptRoot 'Sign-TestMSIX.ps1' +$testRoot = Join-Path $env:TEMP ( + "openclaw-sign-testmsix-$([guid]::NewGuid().ToString('N'))" +) +$artifactsDirectory = Join-Path $testRoot 'artifacts' +$outputDirectory = Join-Path $testRoot 'signed' +$publisher = 'CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US' +$thumbprintsBefore = @( + Get-ChildItem -Path 'Cert:\CurrentUser\My' | + Where-Object Subject -eq $publisher | + Select-Object -ExpandProperty Thumbprint +) + +try { + New-Item -Path $artifactsDirectory -ItemType Directory -Force | Out-Null + + $output = @( + & pwsh -NoProfile -File $scriptPath ` + -ArtifactsDirectory $artifactsDirectory ` + -OutputDirectory $outputDirectory 2>&1 + ) + $exitCode = $LASTEXITCODE + $message = $output -join [Environment]::NewLine + + if ($exitCode -eq 0) { + throw 'Signing unexpectedly succeeded without an architecture directory.' + } + if ($message -notmatch 'No architecture directories were found') { + throw "Signing failure did not identify the missing architecture directories. Output: $message" + } + if (Test-Path -LiteralPath $outputDirectory) { + throw 'Signing created an output directory despite finding no architecture directory.' + } + + $thumbprintsAfter = @( + Get-ChildItem -Path 'Cert:\CurrentUser\My' | + Where-Object Subject -eq $publisher | + Select-Object -ExpandProperty Thumbprint + ) + $newThumbprints = Compare-Object ` + -ReferenceObject $thumbprintsBefore ` + -DifferenceObject $thumbprintsAfter ` + -PassThru | + Where-Object SideIndicator -eq '=>' + if ($null -ne $newThumbprints) { + throw 'Signing created a publisher certificate on the no-op failure path.' + } + + Write-Host 'Test MSIX no-op signing regression test passed.' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} From 6f89905a5349dcdd9416420fe1fa357afecf17ef Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Fri, 11 Sep 2026 15:49:12 -0700 Subject: [PATCH 12/76] refactor(clawctl): parse management arguments with System.CommandLine (#29) * Parse clawctl arguments with System.CommandLine Replace the hand-written clawctl parser and help renderer with a declarative System.CommandLine tree. The `openclaw` entrypoint is unchanged and still forwards its argument vector to the OpenClaw CLI without parsing it. - Add ClawCtlCommandLine: a root command plus a `setup` subcommand, with Node prerequisite and install guidance in the command descriptions so help is generated from one source instead of hand-formatted. - Delete ClawCtlCommand and its enum/parse-result/exact-token matcher rather than leaving a second dispatch model beside the library. - Rewrite Program.RunControlAsync around the tree. Disable the library's default exception handler so operational failures keep reaching the host's diagnostic boundary, and leave ProcessTerminationTimeout null so it does not compete with the Node job object for process lifetime. The setup action's cancellation token now flows into the Node resolver. - Replace the `Action writeError` seam with an injected TextWriter and drop the direct Console.Error write from the control dispatcher. - Report the launcher assembly version through a custom version action. The built-in one reports the entry assembly, which under a test or scenario host is not the launcher. - Disable response-file expansion. A leading `@` is an unrecognized argument for clawctl and stays uninterpreted through `openclaw`. Completion is kept. Invalid management input now exits 1, the library's parse-error code, instead of the previous 2. Add Test-NativeAotCli.Tests.ps1 and run it in CI. The xUnit suite runs under a JIT test host, so it cannot observe the root command name that System.CommandLine derives from native argv[0], and a successful AOT publish is not execution evidence. The script publishes win-x64 NativeAOT into a temporary directory it owns, runs the binary as clawctl.exe, and removes it afterwards. Node-dependent scenarios stay in xUnit with an injected runtime so the gate does not depend on the agent's installed Node version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79e90679-15f4-4fd5-a669-d70d363a9c34 * Cover the completion directive and version precedence The only completion test used the in-process GetCompletions API, which no shell can reach. Cover the `[suggest]` directive through the real control dispatcher instead, since that is the path a completion client actually calls. Also record that `--version` now wins over trailing arguments. The old parser rejected the combination; the library's version action clears parse errors, so `clawctl --version bogus` prints the version and exits 0. That is stock System.CommandLine behavior, not a local choice, but it is a visible change to this CLI's contract and was previously untested. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79e90679-15f4-4fd5-a669-d70d363a9c34 * fix: exit the NativeAOT clawctl gate with its own status The gate's final assertion runs clawctl with an argument that must be rejected, so $LASTEXITCODE is 1 when the script ends. GitHub Actions exits a pwsh step with that value, so the step failed even though every assertion passed. The job printed "NativeAOT clawctl checks completed successfully." immediately before reporting exit code 1. Exit 0 explicitly after cleanup. Assertion failures throw and never reach that line, so the gate still fails when it should: verified by injecting a pattern that cannot match, which exits 1 with the offending output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79e90679-15f4-4fd5-a669-d70d363a9c34 * Isolate the NativeAOT command-line gate from the user profile The native gate published the production launcher and ran its `Main`. `Main` creates the diagnostic log through `HostDiagnosticLog.Create()`, which resolves `%LOCALAPPDATA%\OpenClawGatewayMSIX` rather than anything relative to the publish directory, so every gate run appended startup and exit records to the developer's real log. Publishing into a temporary directory did not redirect that, and green CI never revealed it. Extract the startup path into `Program.RunAsync(args, HostStartup)` and leave `Main` as the production adapter that supplies the real collaborators. `HostStartup` carries the entrypoint, the diagnostic factory, the base directory, the writers, and the existing Node and launch seams, so startup can be driven with fixture-owned storage. Replace the gate's subject with a NativeAOT scenario driver in `tests/OpenClaw.Launcher.AotSmoke`. It runs the same `RunAsync` under the `clawctl.exe` name with an explicit temporary log path, in-memory writers, and delegates that cannot start a real process, so native coverage still includes startup logging and the error boundary. The script now also runs the driver under a wrong executable name and requires a nonzero exit, so the alias check cannot pass vacuously. Add JIT regressions for the same seam: diagnostics routed to a test path, a handled operational failure reporting that path and exiting 1, a failed diagnostic factory warning once and continuing, and `openclaw` forwarding arguments verbatim while returning the child's exit code. A read-only before/after observation of the normal unpackaged log across a full gate run showed an unchanged length, timestamp, and SHA-256. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79e90679-15f4-4fd5-a669-d70d363a9c34 * Keep the NativeAOT scenario fixtures out of %TEMP% CodeQL treats `Path.GetTempPath()` as a user-controlled source, because `TMP` is an environment variable a caller controls. The scenario driver is a plain console executable rather than a test-framework project, so it is not classified as test code, and its fixture roots flowed into `HostOptions.Parse` and the driver's own file writes as eight new high-severity `cs/path-injection` alerts on the pull request. Build the fixture roots under `AppContext.BaseDirectory` instead. The gate script already publishes into a directory it owns and deletes, so the scenarios stay isolated without reading an environment variable, and the alerts have no source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79e90679-15f4-4fd5-a669-d70d363a9c34 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79e90679-15f4-4fd5-a669-d70d363a9c34 --- .github/copilot-instructions.md | 6 + .github/workflows/gateway-msix.yml | 15 + CONTRIBUTING.md | 27 +- Directory.Packages.props | 1 + OpenClaw.Gateway.MSIX.slnx | 1 + README.md | 21 +- scripts/Test-NativeAotCli.Tests.ps1 | 159 +++++++ src/OpenClaw.Launcher/ClawCtlCommand.cs | 41 -- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 98 +++++ src/OpenClaw.Launcher/ClawCtlConsole.cs | 29 -- src/OpenClaw.Launcher/HostStartup.cs | 34 ++ .../OpenClaw.Launcher.csproj | 5 + src/OpenClaw.Launcher/Program.cs | 114 +++-- .../OpenClaw.Launcher.AotSmoke.csproj | 33 ++ .../SmokeProgram.cs | 408 ++++++++++++++++++ .../ClawCtlCommandLineTests.cs | 158 +++++++ .../ClawCtlCommandTests.cs | 48 --- .../ClawCtlConsoleTests.cs | 35 -- .../ClawCtlParserDefaultsTests.cs | 104 +++++ .../GatewayLauncherTests.cs | 14 + .../ProgramStartupTests.cs | 145 +++++++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 6 +- 22 files changed, 1297 insertions(+), 205 deletions(-) create mode 100644 scripts/Test-NativeAotCli.Tests.ps1 delete mode 100644 src/OpenClaw.Launcher/ClawCtlCommand.cs create mode 100644 src/OpenClaw.Launcher/ClawCtlCommandLine.cs create mode 100644 src/OpenClaw.Launcher/HostStartup.cs create mode 100644 tests/OpenClaw.Launcher.AotSmoke/OpenClaw.Launcher.AotSmoke.csproj create mode 100644 tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs create mode 100644 tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs delete mode 100644 tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 304db33e..3790db31 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -61,6 +61,12 @@ and ARM64 separately. point exists, and forwards every argument unchanged to `openclaw.mjs`. - `clawctl setup` is a read-only readiness check for compatible Node.js and the packaged entry point. Runtime launches do not hash or walk package files. +- `clawctl` parses its own arguments with System.CommandLine + (`ClawCtlCommandLine` builds the tree; `Program.RunControlAsync` invokes it). + Help, usage, version, and completion are library behavior; parse errors exit + `1`. Response-file expansion is disabled, so `@file` is an ordinary + unrecognized argument. The library is scoped to `clawctl` only and must never + see `openclaw` arguments. - `GatewayLauncher` starts Node without a shell, uses `ArgumentList`, inherits the console streams, and sets `OPENCLAW_SUPERVISOR_MODE=external` plus `OPENCLAW_NO_AUTO_UPDATE=1`. The child process exit code is the launcher exit diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 826434fd..81c0b757 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -64,6 +64,21 @@ jobs: --configuration Release --no-restore + # The xUnit suite runs under a JIT test host, so it cannot prove the + # native entrypoint alias and root command name, or that + # System.CommandLine survives trimming and ahead-of-time compilation. + # This publishes the scenario driver in tests\OpenClaw.Launcher.AotSmoke, + # which runs the shared startup path with fixture-owned diagnostics + # instead of the production Main, so nothing is written outside the + # temporary directory the script owns. x64 only: windows-latest cannot + # execute an ARM64 binary, and both architectures are still compiled by + # the packaging matrix. + - name: Test NativeAOT clawctl command line + shell: pwsh + run: > + .\scripts\Test-NativeAotCli.Tests.ps1 + -Configuration Release + - name: Test signing policy shell: pwsh run: > diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca675887..c6549aca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,29 @@ dotnet publish .\src\OpenClaw.Launcher\OpenClaw.Launcher.csproj ` --configuration Release --runtime win-x64 --self-contained ``` +Run the native `clawctl` gate when you change command-line parsing, help, +version output, or the host startup path. The xUnit suite runs under a JIT test +host, so it cannot see the entrypoint alias or root command name that the +launcher derives from native `argv[0]`, and a successful publish is not +execution evidence. The script publishes the scenario driver in +`tests\OpenClaw.Launcher.AotSmoke` for win-x64 with NativeAOT into a temporary +directory it owns, runs it as `clawctl.exe`, repeats the run under a wrong +executable name to prove the alias check is real, and removes the directory +afterwards: + +```powershell +.\scripts\Test-NativeAotCli.Tests.ps1 +``` + +The driver calls the same `Program.RunAsync` that the shipped `Main` calls, so +startup diagnostics, argument routing, the error boundary, and disposal are all +covered. It must never call `Main` itself: `Main` resolves the diagnostic log +under the user's profile, so a gate built on it would append to your real +`%LOCALAPPDATA%\OpenClawGatewayMSIX` log. Add scenarios by injecting +fixture-owned collaborators through `HostStartup` — an explicit temporary +diagnostic path, in-memory writers, and Node/launch delegates that cannot start +a real process. + ## Formatting and static analysis Formatting and analyzer severity are defined by the root `.editorconfig`. The @@ -141,7 +164,9 @@ bypassable, and required CI checks remain authoritative. - Ordinary builds and tests must leave `IncludePackagingContent` unset. Packaging builds set it to `true` and supply a runtime identifier. - Treat launcher arguments as OpenClaw-owned. Do not add host-only switches, - consume `--`, rewrite arguments, or block upstream commands. + consume `--`, rewrite arguments, or block upstream commands. The + System.CommandLine tree covers `clawctl` only; the `openclaw` entrypoint must + keep forwarding its argument vector without parsing it. - Preserve direct execution from the read-only MSIX package. `clawctl setup` is a readiness check; do not add runtime extraction, copying, repair, or launcher-managed package state under the user profile. diff --git a/Directory.Packages.props b/Directory.Packages.props index 05461f42..4ea58b06 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/OpenClaw.Gateway.MSIX.slnx b/OpenClaw.Gateway.MSIX.slnx index e22ddf63..d0f21dff 100644 --- a/OpenClaw.Gateway.MSIX.slnx +++ b/OpenClaw.Gateway.MSIX.slnx @@ -3,6 +3,7 @@ + diff --git a/README.md b/README.md index cfbcdbc7..064acd24 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,26 @@ the read-only application directory the workspace. | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing -state. +state. `clawctl setup --help` prints help for that command alone. Help, usage, +and completion come from +[System.CommandLine](https://learn.microsoft.com/en-us/dotnet/standard/commandline/). +Invalid management input is rejected with exit code `1` and a parse diagnostic +on standard error; no readiness check runs. + +Help and version requests take precedence over the rest of the command line. +`clawctl --version bogus` prints the launcher version and exits `0` rather than +reporting `bogus`, because the version request is satisfied before the +remaining arguments are validated. The version printed is always the packaged +launcher's assembly version, including when the launcher is hosted by another +process. + +Response-file expansion is disabled. A leading `@` has no meaning to `clawctl` +and is reported as an unrecognized argument rather than read from disk. + +These parser conveniences belong to `clawctl` only. `openclaw` forwards every +argument to the OpenClaw CLI verbatim, so a leading `@` or a directive-shaped +token reaches that CLI uninterpreted. + Commands such as `doctor`, `gateway`, and `uninstall` belong to the OpenClaw CLI and must be invoked through `openclaw`. diff --git a/scripts/Test-NativeAotCli.Tests.ps1 b/scripts/Test-NativeAotCli.Tests.ps1 new file mode 100644 index 00000000..e57bc7a4 --- /dev/null +++ b/scripts/Test-NativeAotCli.Tests.ps1 @@ -0,0 +1,159 @@ +<# +.SYNOPSIS + Verifies the launcher's startup path inside a published NativeAOT binary. + +.DESCRIPTION + clawctl parses its arguments with System.CommandLine. Two of its behaviors + cannot be proven by the xUnit suite, because that suite runs under a JIT + test host: + + - The entrypoint alias and the root command name in usage and error output + are derived from native argv[0]. Under `dotnet test` the entry process is + the test host, so the name is never `clawctl`. + - Command-line parsing, help rendering, and completion must survive + trimming and ahead-of-time compilation. A successful publish is not + evidence; the binary has to run. + + This script publishes the NativeAOT scenario driver in + `tests\OpenClaw.Launcher.AotSmoke` for win-x64, copies it to `clawctl.exe` + in an isolated directory so the native alias resolver selects the + management entrypoint, and runs it once. + + The driver calls the same `Program.RunAsync` that the shipped `Main` calls, + so startup diagnostics, argument routing, the operational error boundary, + and disposal are all covered. It never runs the production `Main` adapter, + because that adapter resolves the diagnostic log under the user's profile: + a gate that ran it would append to the developer's real + `%LOCALAPPDATA%\OpenClawGatewayMSIX` log. Every collaborator the driver + injects is fixture-owned, including an explicit temporary diagnostic path + and Node/launch delegates that cannot start a real process. + + Scenarios that require a compatible device-installed Node.js runtime are + deliberately excluded. Those stay in the xUnit suite, where the runtime is + injected, so this gate does not depend on the agent's installed Node + version. The publish output lives under a temporary directory that this + script owns and removes. + +.PARAMETER Configuration + The MSBuild configuration to publish. Defaults to Release, which matches + continuous integration. + +.EXAMPLE + .\scripts\Test-NativeAotCli.Tests.ps1 +#> +[CmdletBinding()] +param( + [string]$Configuration = 'Release' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$project = Join-Path $repositoryRoot ( + 'tests\OpenClaw.Launcher.AotSmoke\OpenClaw.Launcher.AotSmoke.csproj') + +if (-not (Test-Path -LiteralPath $project)) { + throw "Could not find the NativeAOT scenario driver at '$project'." +} + +$publishRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'clawctl-aot-' + [guid]::NewGuid().ToString('n')) + +function Assert-Invocation { + param( + [Parameter(Mandatory)] + [string]$Executable, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$Arguments, + + [Parameter(Mandatory)] + [int]$ExpectedExitCode, + + [string[]]$ExpectedPatterns = @(), + + [Parameter(Mandatory)] + [string]$Description + ) + + $output = & $Executable @Arguments 2>&1 | Out-String + $exitCode = $LASTEXITCODE + + if ($exitCode -ne $ExpectedExitCode) { + throw ( + "$Description expected exit code $ExpectedExitCode but received " + + "$exitCode. Output:`n$output" + ) + } + + foreach ($pattern in $ExpectedPatterns) { + if ($output -notmatch $pattern) { + throw ( + "$Description expected output matching '$pattern'. " + + "Output:`n$output" + ) + } + } + + return $output +} + +try { + Write-Host "Publishing the NativeAOT scenario driver ($Configuration)..." + dotnet publish $project ` + --configuration $Configuration ` + --runtime win-x64 ` + -p:Platform=x64 ` + --self-contained ` + --output $publishRoot ` + --nologo ` + --verbosity minimal + if ($LASTEXITCODE -ne 0) { + throw "NativeAOT publish failed. Exit code: $LASTEXITCODE." + } + + $driver = Join-Path $publishRoot 'OpenClaw.Launcher.AotSmoke.exe' + if (-not (Test-Path -LiteralPath $driver)) { + throw "The NativeAOT publish did not produce '$driver'." + } + + # The management entrypoint is selected by executable name, so the copy is + # how this gate reaches clawctl. It is test infrastructure and never a + # runtime extraction step. + $clawctl = Join-Path $publishRoot 'clawctl.exe' + Copy-Item -LiteralPath $driver -Destination $clawctl -Force + + $output = Assert-Invocation ` + -Executable $clawctl ` + -Arguments @() ` + -ExpectedExitCode 0 ` + -ExpectedPatterns @('NativeAOT scenarios passed\.') ` + -Description 'NativeAOT scenario driver running as clawctl.exe' + Write-Host $output + + # A gate that passes under any executable name would not be testing the + # alias at all. Under the wrong name the driver's first scenario must fail. + $wrongName = Join-Path $publishRoot 'notclawctl.exe' + Copy-Item -LiteralPath $driver -Destination $wrongName -Force + Assert-Invocation ` + -Executable $wrongName ` + -Arguments @() ` + -ExpectedExitCode 1 ` + -ExpectedPatterns @('native alias resolves to clawctl') ` + -Description 'NativeAOT scenario driver running under the wrong name' + + Write-Host 'NativeAOT clawctl checks completed successfully.' +} +finally { + if (Test-Path -LiteralPath $publishRoot) { + Remove-Item -LiteralPath $publishRoot -Recurse -Force + } +} + +# The negative check deliberately runs a native command that exits non-zero, and +# $LASTEXITCODE is still set when the script ends. GitHub Actions exits a pwsh +# step with that value, so a fully successful run would otherwise report +# failure. Any assertion failure throws instead and never reaches this line. +exit 0 diff --git a/src/OpenClaw.Launcher/ClawCtlCommand.cs b/src/OpenClaw.Launcher/ClawCtlCommand.cs deleted file mode 100644 index 60f00c4e..00000000 --- a/src/OpenClaw.Launcher/ClawCtlCommand.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace OpenClaw.Launcher; - -internal enum ClawCtlCommand -{ - Help, - Version, - Setup -} - -internal sealed record ClawCtlCommandParseResult( - ClawCtlCommand Command, - string? Error = null); - -internal static class ClawCtlCommandParser -{ - public static ClawCtlCommandParseResult Parse(IReadOnlyList args) - { - if (args.Count == 0 || IsSingle(args, "--help") || IsSingle(args, "-h")) - { - return new ClawCtlCommandParseResult(ClawCtlCommand.Help); - } - - if (IsSingle(args, "--version")) - { - return new ClawCtlCommandParseResult(ClawCtlCommand.Version); - } - - if (IsSingle(args, "setup")) - { - return new ClawCtlCommandParseResult(ClawCtlCommand.Setup); - } - - return new ClawCtlCommandParseResult( - ClawCtlCommand.Help, - $"Unknown command or option: {string.Join(' ', args)}"); - } - - private static bool IsSingle(IReadOnlyList args, string value) => - args.Count == 1 && - string.Equals(args[0], value, StringComparison.Ordinal); -} diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs new file mode 100644 index 00000000..de8b20aa --- /dev/null +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -0,0 +1,98 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; + +namespace OpenClaw.Launcher; + +// The clawctl command tree. Only the package-readiness surface belongs here: +// doctor, gateway, uninstall, and every other OpenClaw command is owned by the +// bundled CLI and reached through `openclaw`, which forwards its arguments +// without parsing them. +internal static class ClawCtlCommandLine +{ + public const string SetupCommandName = "setup"; + + // Response-file expansion is off. A leading `@` means nothing to clawctl, + // so it is reported as an unrecognized argument instead of silently reading + // a file from disk. `openclaw` already forwards such a token to the + // OpenClaw CLI untouched, and leaving the two entrypoints consistent + // matters more than the convenience. Completion stays enabled. + public static ParserConfiguration CreateParserConfiguration() => new() + { + ResponseFileTokenReplacer = null + }; + + // Node guidance is part of help rather than only a launch-time failure so + // that a user can discover the prerequisite before running anything. + public static string RootDescription => + "Verify that this device can run the packaged OpenClaw application." + + Environment.NewLine + + Environment.NewLine + + $"Prerequisite: install Node.js {NodeRuntimeResolver.SupportedVersions}." + + Environment.NewLine + + $" {NodeRuntimeResolver.InstallCommand}" + + Environment.NewLine + + Environment.NewLine + + "Run `openclaw ` to invoke the OpenClaw CLI."; + + public static string SetupDescription => + "Check for a compatible Node.js runtime and confirm the packaged " + + "OpenClaw application is present. Reads only; changes nothing." + + Environment.NewLine + + $"Requires Node.js {NodeRuntimeResolver.SupportedVersions}."; + + // runSetup stays a delegate so the command tree owns parsing and help while + // Program keeps the readiness operation and its test seams. + public static RootCommand Create(Func> runSetup) + { + Command setup = new(SetupCommandName, SetupDescription); + setup.SetAction((_, cancellationToken) => runSetup(cancellationToken)); + + RootCommand root = new(RootDescription) + { + setup + }; + + // Bare `clawctl` is a discovery request, not a usage error, so the root + // prints help and succeeds instead of reporting a missing command. + root.SetAction((parseResult, _) => Task.FromResult(WriteHelp(parseResult))); + UseLauncherVersion(root); + + return root; + } + + private static int WriteHelp(ParseResult parseResult) + { + HelpAction help = new(); + return help.Invoke(parseResult); + } + + // The built-in version action reports the entry assembly, which is the test + // or scenario host rather than the launcher. Report the launcher assembly so + // the value identifies the shipped package binary in every host. + private static void UseLauncherVersion(RootCommand root) + { + foreach (Option option in root.Options) + { + if (option is VersionOption versionOption) + { + versionOption.Action = new LauncherVersionAction(); + } + } + } + + private sealed class LauncherVersionAction : SynchronousCommandLineAction + { + public override bool ClearsParseErrors => true; + + public override int Invoke(ParseResult parseResult) + { + string version = typeof(LauncherVersionAction).Assembly + .GetName() + .Version? + .ToString() ?? "unknown"; + parseResult.InvocationConfiguration.Output.WriteLine(version); + return 0; + } + } +} diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index b4bd2957..648c6e44 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -2,35 +2,6 @@ namespace OpenClaw.Launcher; internal static class ClawCtlConsole { - public static void WriteHelp(TextWriter output) - { - output.WriteLine("clawctl - OpenClaw package readiness"); - output.WriteLine(); - WriteUsage(output); - output.WriteLine(); - output.WriteLine("Commands:"); - output.WriteLine( - " setup Verify Node.js and the packaged OpenClaw application."); - output.WriteLine(); - output.WriteLine("Options:"); - output.WriteLine(" -h, --help Show this help."); - output.WriteLine(" --version Print the packaged launcher version."); - output.WriteLine(); - WriteNodePrerequisite(output); - output.WriteLine(); - output.WriteLine("Run `openclaw ` to invoke the OpenClaw CLI."); - } - - public static void WriteUsage(TextWriter output) => - output.WriteLine("Usage: clawctl [setup | --help | --version]"); - - public static void WriteNodePrerequisite(TextWriter output) - { - output.WriteLine( - $"Prerequisite: install Node.js {NodeRuntimeResolver.SupportedVersions}."); - output.WriteLine($" {NodeRuntimeResolver.InstallCommand}"); - } - internal static void WriteNodeRuntimeSummary( TextWriter output, NodeRuntime runtime) => diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs new file mode 100644 index 00000000..050f1d70 --- /dev/null +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -0,0 +1,34 @@ +namespace OpenClaw.Launcher; + +// The collaborators the host startup path needs, so that startup itself can be +// exercised outside the production process. Main supplies the real ones; the +// NativeAOT scenario driver and the xUnit suite supply fixture-owned storage +// and dependencies that cannot reach the user's profile or start a real child +// process. +internal sealed class HostStartup +{ + public required HostEntrypoint Entrypoint { get; init; } + + // Returns a log the caller owns. Production resolves the packaged + // LocalState or %LOCALAPPDATA% location; tests pass an explicit path. + public required Func CreateDiagnostics { get; init; } + + public required string BaseDirectory { get; init; } + + public required TextWriter Output { get; init; } + + public required TextWriter Error { get; init; } + + public Func>? ResolveNode { get; init; } + + public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; } + + public static HostStartup CreateProduction() => new() + { + Entrypoint = HostEntrypointResolver.Resolve(), + CreateDiagnostics = HostDiagnosticLog.Create, + BaseDirectory = AppContext.BaseDirectory, + Output = Console.Out, + Error = Console.Error + }; +} diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index c5aa467e..b37cf8e6 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -32,6 +32,10 @@ true + + + + @@ -52,6 +56,7 @@ + Main(string[] args) => + await RunAsync(args, HostStartup.CreateProduction()).ConfigureAwait(false); + + // The whole startup path lives here rather than in Main so that tests can + // drive it with fixture-owned diagnostics and writers. Main is only the + // production adapter that supplies the real collaborators. [SuppressMessage( "Design", "CA1031:Do not catch general exception types", Justification = - "Main is the process last-chance handler. Every narrower catch in " + + "This is the process last-chance handler. Every narrower catch in " + "this assembly uses an exception filter; this one deliberately does " + "not, because narrowing it would replace the diagnostic log entry, " + "the user-facing error message, and the deterministic exit code 1 " + "with an unhandled-exception crash.")] - public static async Task Main(string[] args) + internal static async Task RunAsync(string[] args, HostStartup startup) { - HostEntrypoint entrypoint = HostEntrypointResolver.Resolve(); - string commandName = entrypoint == HostEntrypoint.Control + string commandName = startup.Entrypoint == HostEntrypoint.Control ? HostEntrypointResolver.ControlCommandName : HostEntrypointResolver.AgentCommandName; HostDiagnosticLog? diagnostics = null; @@ -28,7 +33,7 @@ void WriteConsoleError(string message) { try { - Console.Error.WriteLine(message); + startup.Error.WriteLine(message); } catch (Exception exception) when ( exception is IOException or ObjectDisposedException) @@ -44,7 +49,7 @@ void WriteConsoleError(string message) try { - diagnostics = HostDiagnosticLog.Create(); + diagnostics = startup.CreateDiagnostics(); } catch (Exception exception) when ( exception is IOException or @@ -95,15 +100,20 @@ PlatformNotSupportedException or try { WriteDiagnostic($"Host started through the {commandName} entrypoint."); - HostOptions options = HostOptions.Parse(args); - return entrypoint == HostEntrypoint.Control + HostOptions options = HostOptions.Parse(args, startup.BaseDirectory); + return startup.Entrypoint == HostEntrypoint.Control ? await RunControlAsync( options, args, WriteDiagnostic, - WriteConsoleError, - Console.Out).ConfigureAwait(false) - : await RunAgentAsync(options, WriteDiagnostic) + startup.Output, + startup.Error, + startup.ResolveNode).ConfigureAwait(false) + : await RunAgentAsync( + options, + WriteDiagnostic, + startup.ResolveNode ?? NodeRuntimeResolver.ResolveAsync, + startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync) .ConfigureAwait(false); } catch (Exception exception) @@ -158,52 +168,62 @@ internal static async Task RunAgentAsync( log).ConfigureAwait(false); } - // output is a required parameter (not a Console.Out default) so tests + // output and error are required parameters (not Console defaults) so tests // can capture clawctl output without mutating global console state, // which would be unsafe across parallel test runs. internal static async Task RunControlAsync( HostOptions options, IReadOnlyList args, Action log, - Action writeError, TextWriter output, + TextWriter error, Func>? resolveNode = null) { - ClawCtlCommandParseResult parsed = ClawCtlCommandParser.Parse(args); - if (parsed.Error is not null) - { - writeError($"clawctl: {parsed.Error}"); - ClawCtlConsole.WriteUsage(Console.Error); - return 2; - } + RootCommand command = ClawCtlCommandLine.Create( + cancellationToken => RunSetupAsync( + options, + log, + output, + resolveNode, + cancellationToken)); - switch (parsed.Command) + InvocationConfiguration configuration = new() { - case ClawCtlCommand.Help: - ClawCtlConsole.WriteHelp(output); - return 0; - case ClawCtlCommand.Version: - await output.WriteLineAsync( - Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? - "unknown").ConfigureAwait(false); - return 0; - case ClawCtlCommand.Setup: - { - NodeRuntime nodeRuntime = await ( - resolveNode ?? NodeRuntimeResolver.ResolveAsync)( - CancellationToken.None).ConfigureAwait(false); - ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); - string applicationDirectory = - GetPackagedApplicationDirectory(options); - log("Confirmed the packaged OpenClaw application is present."); - ClawCtlConsole.WriteReadinessSummary( - output, - applicationDirectory); - return 0; - } - default: - throw new InvalidOperationException("Unknown clawctl command."); - } + Output = output, + Error = error, + + // Operational failures stay the host's responsibility. The default + // handler would print its own message and return its own exit code, + // losing the diagnostic log entry and the log path Main reports. + EnableDefaultExceptionHandler = false, + + // Node lifetime is owned by the job object in GatewayLauncher. The + // library's termination timeout would add a second, conflicting + // forced-exit policy and process-wide signal handlers. + ProcessTerminationTimeout = null + }; + + return await command + .Parse(args, ClawCtlCommandLine.CreateParserConfiguration()) + .InvokeAsync(configuration) + .ConfigureAwait(false); + } + + private static async Task RunSetupAsync( + HostOptions options, + Action log, + TextWriter output, + Func>? resolveNode, + CancellationToken cancellationToken) + { + NodeRuntime nodeRuntime = await ( + resolveNode ?? NodeRuntimeResolver.ResolveAsync)( + cancellationToken).ConfigureAwait(false); + ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); + string applicationDirectory = GetPackagedApplicationDirectory(options); + log("Confirmed the packaged OpenClaw application is present."); + ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); + return 0; } internal delegate Task LaunchOpenClawAsync( diff --git a/tests/OpenClaw.Launcher.AotSmoke/OpenClaw.Launcher.AotSmoke.csproj b/tests/OpenClaw.Launcher.AotSmoke/OpenClaw.Launcher.AotSmoke.csproj new file mode 100644 index 00000000..aef69769 --- /dev/null +++ b/tests/OpenClaw.Launcher.AotSmoke/OpenClaw.Launcher.AotSmoke.csproj @@ -0,0 +1,33 @@ + + + + Exe + net10.0-windows10.0.19041.0 + 10.0.19041.0 + enable + enable + all + OpenClaw.Launcher.AotSmoke + OpenClaw.Launcher.AotSmoke + x64;arm64 + win-x64;win-arm64 + false + true + true + false + + + 9.9.9.9 + 9.9.9.9 + 9.9.9.9 + + + + + + + diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs new file mode 100644 index 00000000..898e0fb6 --- /dev/null +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -0,0 +1,408 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using LauncherProgram = OpenClaw.Launcher.Program; + +namespace OpenClaw.Launcher.AotSmoke; + +// A NativeAOT scenario driver for the launcher's startup path. +// +// Two things cannot be proven by the xUnit suite, because it runs under a JIT +// test host: the alias and root-command names that come from native argv[0], +// and that command-line parsing, help rendering, and completion survive +// trimming and ahead-of-time compilation. +// +// This driver runs the same Program.RunAsync that Main runs, so startup +// diagnostics, argument routing, the operational error boundary, and disposal +// are all exercised. Every collaborator it injects is fixture-owned: the +// diagnostic log is created at an explicit path the driver owns, the writers are +// in-memory, and the Node and launch delegates cannot start a real process. +// Nothing here reads or writes the user's profile. +internal static class SmokeProgram +{ + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = + "This is the scenario harness boundary. A scenario that fails with " + + "any exception type must be reported as a failed check with a " + + "nonzero exit code, not crash the driver and hide the remaining " + + "scenarios.")] + private static async Task Main() + { + (string Name, Func RunAsync)[] scenarios = + [ + ("native alias resolves to clawctl", NativeAliasResolvesToControlAsync), + ("bare clawctl prints help", BareInvocationPrintsHelpAsync), + ("--help prints help", HelpOptionPrintsHelpAsync), + ("setup --help prints command help", SetupHelpPrintsCommandHelpAsync), + ("--version reports the launcher", VersionReportsLauncherAssemblyAsync), + ("--version wins over trailing arguments", VersionWinsOverTrailingAsync), + ("unknown command fails", UnknownCommandFailsAsync), + ("response-file token is not expanded", ResponseFileTokenIsNotExpandedAsync), + ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), + ("setup reports readiness and logs", SetupReportsReadinessAsync), + ("missing application reports diagnostics", MissingApplicationReportsAsync), + ("openclaw forwards arguments verbatim", AgentForwardsArgumentsAsync) + ]; + + int failures = 0; + + // Console writes go through non-async helpers so this harness reports + // results the same way in the middle of an async scenario loop. + static void WriteLine(string message) => Console.Out.WriteLine(message); + static void WriteError(string message) => Console.Error.WriteLine(message); + + foreach ((string name, Func runAsync) in scenarios) + { + try + { + await runAsync().ConfigureAwait(false); + WriteLine($" ok {name}"); + } + catch (Exception exception) + { + failures++; + WriteError($" FAIL {name}"); + WriteError($" {exception.Message}"); + } + } + + if (failures != 0) + { + WriteError($"{failures} NativeAOT scenario(s) failed."); + return 1; + } + + WriteLine($"{scenarios.Length} NativeAOT scenarios passed."); + return 0; + } + + // The management entrypoint is selected from the native command line. This + // is the check the JIT suite cannot make, because there the first argument + // is the test host rather than clawctl. + private static Task NativeAliasResolvesToControlAsync() + { + HostEntrypoint resolved = HostEntrypointResolver.Resolve(); + Assert( + resolved == HostEntrypoint.Control, + $"Expected the native alias to resolve to Control but got {resolved}. " + + "This driver must run as clawctl.exe."); + return Task.CompletedTask; + } + + private static async Task BareInvocationPrintsHelpAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync([]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + + // The root command name comes from native argv[0]; a JIT host would + // render the test runner's name here instead. + AssertContains(fixture.Output.ToString(), "clawctl", fixture); + AssertContains(fixture.Output.ToString(), "setup", fixture); + AssertContains(fixture.Output.ToString(), "Node.js", fixture); + fixture.AssertNodeWasNotResolved(); + fixture.AssertLogRecordsStartupAndExit(); + } + + private static async Task HelpOptionPrintsHelpAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["--help"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + AssertContains(fixture.Output.ToString(), "setup", fixture); + AssertContains(fixture.Output.ToString(), "--version", fixture); + fixture.AssertNodeWasNotResolved(); + } + + private static async Task SetupHelpPrintsCommandHelpAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["setup", "--help"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + AssertContains(fixture.Output.ToString(), "clawctl setup", fixture); + fixture.AssertNodeWasNotResolved(); + } + + // This driver's assembly version is 9.9.9.9. The library's built-in action + // reports the entry assembly, so if the custom action were ever dropped + // this scenario would print 9.9.9.9 instead of the launcher's version. + private static async Task VersionReportsLauncherAssemblyAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + string launcherVersion = LauncherVersion(); + string driverVersion = DriverVersion(); + + int exitCode = await fixture.RunAsync(["--version"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + string reported = fixture.Output.ToString().Trim(); + Assert( + string.Equals(reported, launcherVersion, StringComparison.Ordinal), + $"Expected the launcher version '{launcherVersion}' but got '{reported}'."); + Assert( + !string.Equals(reported, driverVersion, StringComparison.Ordinal), + $"Reported this driver's version '{driverVersion}' instead of the launcher's."); + } + + private static async Task VersionWinsOverTrailingAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["--version", "bogus"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + Assert( + string.Equals( + fixture.Output.ToString().Trim(), + LauncherVersion(), + StringComparison.Ordinal), + "Expected the launcher version with a trailing argument present."); + } + + private static async Task UnknownCommandFailsAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["bogus"]).ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + AssertContains(fixture.Error.ToString(), "bogus", fixture); + fixture.AssertNodeWasNotResolved(); + } + + // Response-file expansion is disabled, so a readable file behind an `@` + // token must still be rejected as an unrecognized argument. + private static async Task ResponseFileTokenIsNotExpandedAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + string responseFile = Path.Combine(fixture.Root, "help.rsp"); + await File.WriteAllTextAsync(responseFile, "--help").ConfigureAwait(false); + + int exitCode = await fixture.RunAsync([$"@{responseFile}"]).ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + fixture.AssertNodeWasNotResolved(); + } + + private static async Task CompletionDirectiveSuggestsAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["[suggest:2]", "se"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + AssertContains(fixture.Output.ToString(), "setup", fixture); + fixture.AssertNodeWasNotResolved(); + } + + private static async Task SetupReportsReadinessAsync() + { + using Fixture fixture = await Fixture.CreateWithApplicationAsync().ConfigureAwait(false); + + int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); + + AssertExitCode(0, exitCode, fixture); + AssertContains(fixture.Output.ToString(), fixture.ApplicationDirectory, fixture); + AssertContains(fixture.Output.ToString(), Fixture.NodePath, fixture); + fixture.AssertLogRecordsStartupAndExit(); + Assert( + File.Exists(fixture.EntryPoint), + "The readiness check removed or replaced the fixture entry point."); + } + + // The operational error boundary is part of startup, not of the parser. + // It must still name the diagnostic log, which here is the fixture's. + private static async Task MissingApplicationReportsAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + AssertContains(fixture.Error.ToString(), fixture.LogPath, fixture); + Assert( + !fixture.Output.ToString().Contains("package is ready", StringComparison.Ordinal), + "A failed readiness check still reported success."); + fixture.AssertLogRecordsStartupAndExit(); + } + + // The agent entrypoint must never consult System.CommandLine. These tokens + // are all meaningful to the clawctl parser and must survive untouched. + private static async Task AgentForwardsArgumentsAsync() + { + string[] arguments = + ["--help", "--version", "--", "@response.rsp", "[suggest:1]", "", "a b"]; + using Fixture fixture = await Fixture + .CreateWithApplicationAsync(HostEntrypoint.Agent) + .ConfigureAwait(false); + + int exitCode = await fixture.RunAsync(arguments).ConfigureAwait(false); + + AssertExitCode(23, exitCode, fixture); + string[]? forwarded = fixture.ForwardedArguments; + Assert(forwarded is not null, "The agent path never reached the launch delegate."); + Assert( + forwarded!.Length == arguments.Length, + $"Expected {arguments.Length} forwarded arguments but got {forwarded.Length}."); + for (int index = 0; index < arguments.Length; index++) + { + Assert( + string.Equals(forwarded[index], arguments[index], StringComparison.Ordinal), + $"Argument {index} was rewritten from '{arguments[index]}' to " + + $"'{forwarded[index]}'."); + } + } + + private static string LauncherVersion() => + typeof(HostStartup).Assembly.GetName().Version?.ToString() ?? "unknown"; + + private static string DriverVersion() => + typeof(SmokeProgram).Assembly.GetName().Version?.ToString() ?? "unknown"; + + private static void AssertExitCode(int expected, int actual, Fixture fixture) + { + Assert( + expected == actual, + $"Expected exit code {expected} but got {actual}." + + $"{Environment.NewLine}stdout: {fixture.Output}" + + $"{Environment.NewLine}stderr: {fixture.Error}"); + } + + private static void AssertContains(string haystack, string needle, Fixture fixture) + { + Assert( + haystack.Contains(needle, StringComparison.Ordinal), + $"Expected to find '{needle}'." + + $"{Environment.NewLine}stdout: {fixture.Output}" + + $"{Environment.NewLine}stderr: {fixture.Error}"); + } + + private static void Assert(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + + private sealed class Fixture : IDisposable + { + public const string NodePath = @"C:\fixture\node.exe"; + + private readonly HostEntrypoint _entrypoint; + private bool _nodeResolved; + + private Fixture(string root, HostEntrypoint entrypoint) + { + Root = root; + _entrypoint = entrypoint; + LogPath = Path.Combine(root, "diagnostics", "openclaw.log"); + ApplicationDirectory = Path.Combine(root, "app"); + EntryPoint = Path.Combine(ApplicationDirectory, "openclaw.mjs"); + } + + public string Root { get; } + + public string LogPath { get; } + + public string ApplicationDirectory { get; } + + public string EntryPoint { get; } + + public StringWriter Output { get; } = new(); + + public StringWriter Error { get; } = new(); + + public string[]? ForwardedArguments { get; private set; } + + public static Fixture CreateWithoutApplication( + HostEntrypoint entrypoint = HostEntrypoint.Control) => + new(CreateRoot(), entrypoint); + + public static async Task CreateWithApplicationAsync( + HostEntrypoint entrypoint = HostEntrypoint.Control) + { + Fixture fixture = new(CreateRoot(), entrypoint); + Directory.CreateDirectory(fixture.ApplicationDirectory); + await File.WriteAllTextAsync(fixture.EntryPoint, "console.log('fixture');") + .ConfigureAwait(false); + return fixture; + } + + public async Task RunAsync(string[] args) + { + HostStartup startup = new() + { + Entrypoint = _entrypoint, + CreateDiagnostics = () => HostDiagnosticLog.Create(LogPath), + BaseDirectory = Root, + Output = Output, + Error = Error, + ResolveNode = _ => + { + _nodeResolved = true; + return Task.FromResult( + new NodeRuntime( + NodePath, + new Version(24, 15, 0), + RuntimeInformation.ProcessArchitecture)); + }, + LaunchOpenClaw = (_, _, forwarded, _, _) => + { + ForwardedArguments = [.. forwarded]; + return Task.FromResult(23); + } + }; + + return await LauncherProgram.RunAsync(args, startup).ConfigureAwait(false); + } + + public void AssertNodeWasNotResolved() => + Assert( + !_nodeResolved, + "Node resolution ran for an invocation that must not need a runtime."); + + public void AssertLogRecordsStartupAndExit() + { + Assert(File.Exists(LogPath), $"No diagnostic log was written at '{LogPath}'."); + string log = File.ReadAllText(LogPath); + Assert( + log.Contains("Host started", StringComparison.Ordinal), + "The diagnostic log has no startup record."); + Assert( + log.Contains("Host exiting", StringComparison.Ordinal), + "The diagnostic log has no exit record."); + } + + public void Dispose() + { + Output.Dispose(); + Error.Dispose(); + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } + + private static string CreateRoot() + { + // Scenario state lives under the driver's own output directory + // rather than %TEMP%. The gate script publishes into a directory it + // owns and deletes, so this stays isolated without reading an + // environment variable a caller could point somewhere else. + string path = Path.Combine( + AppContext.BaseDirectory, + "scenarios", + $"clawctl-aot-scenario-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs new file mode 100644 index 00000000..8592ad63 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -0,0 +1,158 @@ +using System.CommandLine; +using System.Reflection; + +namespace OpenClaw.Launcher.Tests; + +public sealed class ClawCtlCommandLineTests +{ + // Any invocation that reaches this resolver has started the readiness + // operation, which help, version, and rejected input must never do. + private static Task FailIfSetupRuns(CancellationToken _) => + throw new InvalidOperationException( + "Setup ran for an invocation that should not have started it."); + + private static async Task<(int ExitCode, string Output, string Error)> RunAsync( + params string[] args) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, []), + args, + _ => { }, + output, + error, + FailIfSetupRuns).ConfigureAwait(false); + + return (exitCode, output.ToString(), error.ToString()); + } + + // Generated help wraps to the terminal width, so assert on content rather + // than layout. Pinning a width would make the tests pass while real users + // saw different output. + private static string Normalize(string text) => + string.Join( + ' ', + text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + [Theory] + [InlineData()] + [InlineData("--help")] + [InlineData("-h")] + [InlineData("-?")] + public async Task DiscoveryInputPrintsHelpAndSucceeds(params string[] args) + { + (int exitCode, string output, string error) = await RunAsync(args).ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains("setup", Normalize(output), StringComparison.Ordinal); + } + + [Fact] + public async Task HelpDescribesReadinessAndTheNodePrerequisite() + { + (_, string output, _) = await RunAsync("--help").ConfigureAwait(true); + string help = Normalize(output); + + Assert.Contains("setup", help, StringComparison.Ordinal); + Assert.Contains("--version", help, StringComparison.Ordinal); + Assert.Contains( + Normalize(NodeRuntimeResolver.SupportedVersions), + help, + StringComparison.Ordinal); + Assert.Contains( + Normalize(NodeRuntimeResolver.InstallCommand), + help, + StringComparison.Ordinal); + Assert.Contains("openclaw ", help, StringComparison.Ordinal); + } + + // clawctl owns package readiness only. If an upstream command name ever + // becomes a clawctl subcommand, the host has started shadowing the bundled + // CLI instead of forwarding through `openclaw`. + [Fact] + public void OnlyTheReadinessCommandIsExposed() + { + RootCommand root = ClawCtlCommandLine.Create(_ => Task.FromResult(0)); + + Assert.Equal( + [ClawCtlCommandLine.SetupCommandName], + root.Subcommands.Select(command => command.Name)); + } + + [Fact] + public async Task SetupHelpDescribesTheReadOnlyCheckWithoutRunningIt() + { + (int exitCode, string output, string error) = + await RunAsync("setup", "--help").ConfigureAwait(true); + string help = Normalize(output); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains("setup", help, StringComparison.Ordinal); + Assert.Contains( + Normalize(ClawCtlCommandLine.SetupDescription), + help, + StringComparison.Ordinal); + } + + // The built-in version action reports the entry assembly, which under a test + // host or scenario runner is not the launcher. Assert the launcher's own + // version so that substitution is caught. + [Fact] + public async Task VersionReportsTheLauncherAssemblyVersion() + { + string expected = typeof(Program).Assembly.GetName().Version?.ToString() + ?? "unknown"; + + (int exitCode, string output, string error) = await RunAsync("--version").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Equal(expected, output.Trim()); + Assert.NotEqual( + Assembly.GetEntryAssembly()?.GetName().Version?.ToString(), + output.Trim()); + } + + // The old parser rejected `--version` combined with anything else. The + // library's version action clears parse errors instead, so trailing + // garbage is ignored. This is a deliberate consequence of adopting the + // standard UX, recorded here so the change is visible rather than + // discovered by a user. + [Fact] + public async Task VersionWinsOverTrailingArguments() + { + (int exitCode, string output, _) = + await RunAsync("--version", "bogus").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Equal( + typeof(Program).Assembly.GetName().Version?.ToString(), + output.Trim()); + } + + [Theory] + [InlineData("bogus")] + [InlineData("--bogus")] + [InlineData("prepare")] + [InlineData("verify")] + [InlineData("repair")] + [InlineData("update-package")] + [InlineData("gateway-service")] + [InlineData("setup", "extra")] + [InlineData("setup", "--bogus")] + public async Task RejectedInputFailsWithoutStartingSetup(params string[] args) + { + (int exitCode, string output, string error) = await RunAsync(args).ConfigureAwait(true); + + Assert.Equal(1, exitCode); + Assert.NotEmpty(error); + Assert.DoesNotContain( + "package is ready", + output, + StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs deleted file mode 100644 index a2abc333..00000000 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace OpenClaw.Launcher.Tests; - -public sealed class ClawCtlCommandTests -{ - [Fact] - public void ParseReturnsHelpWhenNoArgumentsAreProvided() - { - ClawCtlCommandParseResult result = ClawCtlCommandParser.Parse([]); - - Assert.Null(result.Error); - Assert.Equal(ClawCtlCommand.Help, result.Command); - } - - [Fact] - public void ParseReturnsSetupForTheSetupCommand() - { - ClawCtlCommandParseResult result = ClawCtlCommandParser.Parse(["setup"]); - - Assert.Null(result.Error); - Assert.Equal(ClawCtlCommand.Setup, result.Command); - } - - [Fact] - public void ParseReturnsVersionForTheVersionOption() - { - ClawCtlCommandParseResult result = - ClawCtlCommandParser.Parse(["--version"]); - - Assert.Null(result.Error); - Assert.Equal(ClawCtlCommand.Version, result.Command); - } - - [Theory] - [InlineData("prepare")] - [InlineData("doctor")] - [InlineData("gateway", "status")] - [InlineData("update-package")] - [InlineData("verify")] - [InlineData("repair")] - [InlineData("setup", "--force")] - public void ParseRejectsCommandsOutsideThePublicManagementSurface( - params string[] args) - { - ClawCtlCommandParseResult result = ClawCtlCommandParser.Parse(args); - - Assert.NotNull(result.Error); - } -} diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 2076d9cc..1e0745a1 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -4,41 +4,6 @@ public sealed class ClawCtlConsoleTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); - [Fact] - public void WriteHelpListsOnlyThePublicCommands() - { - using var output = new StringWriter(); - - ClawCtlConsole.WriteHelp(output); - - string help = output.ToString(); - Assert.Contains("setup", help, StringComparison.Ordinal); - Assert.Contains("readiness", help, StringComparison.OrdinalIgnoreCase); - Assert.Contains("--version", help, StringComparison.Ordinal); - Assert.DoesNotContain( - "prepare", - help, - StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain( - $"{Environment.NewLine} verify", - help, - StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain( - $"{Environment.NewLine} repair", - help, - StringComparison.OrdinalIgnoreCase); - Assert.Contains( - NodeRuntimeResolver.SupportedVersions, - help, - StringComparison.Ordinal); - Assert.Contains( - NodeRuntimeResolver.InstallCommand, - help, - StringComparison.Ordinal); - Assert.DoesNotContain("update-package", help, StringComparison.Ordinal); - Assert.DoesNotContain("gateway-service", help, StringComparison.Ordinal); - } - [Fact] public void WriteReadinessSummaryDescribesPackagedApplication() { diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs new file mode 100644 index 00000000..2b4cbc49 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -0,0 +1,104 @@ +using System.CommandLine; + +namespace OpenClaw.Launcher.Tests; + +// clawctl keeps System.CommandLine's completion support but deliberately +// disables response-file expansion. These cases exist so a later "restore the +// library defaults" change cannot silently reintroduce file reads driven by an +// argument, or drop completion. +public sealed class ClawCtlParserDefaultsTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + private static Task FailIfSetupRuns(CancellationToken _) => + throw new InvalidOperationException( + "Setup ran for an invocation that should not have started it."); + + private static async Task<(int ExitCode, string Output)> RunAsync( + params string[] args) + { + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, []), + args, + _ => { }, + output, + TextWriter.Null, + FailIfSetupRuns).ConfigureAwait(false); + + return (exitCode, output.ToString()); + } + + private string CreateResponseFile(string name, params string[] lines) + { + string path = Path.Combine(_testDirectory, name); + File.WriteAllLines(path, lines); + return path; + } + + // A readable file holding a valid command is the case that would succeed if + // expansion were ever re-enabled, so it is the one worth asserting on. + [Fact] + public async Task ResponseFileTokenIsRejectedRatherThanExpanded() + { + string responseFile = CreateResponseFile("commands.rsp", "setup"); + + (int exitCode, string output) = + await RunAsync($"@{responseFile}").ConfigureAwait(true); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain( + "package is ready", + output, + StringComparison.Ordinal); + } + + [Fact] + public async Task ResponseFileTokenForAMissingFileIsAlsoJustAnArgument() + { + string missing = Path.Combine(_testDirectory, "absent.rsp"); + + (int exitCode, _) = await RunAsync($"@{missing}").ConfigureAwait(true); + + Assert.Equal(1, exitCode); + } + + // Completion is a parse-time query. It must never start the readiness + // operation, because a shell issues it on every keystroke. + [Fact] + public void CompletionSuggestsCommandsWithoutResolvingNode() + { + RootCommand root = ClawCtlCommandLine.Create( + _ => throw new InvalidOperationException( + "Completion started the readiness operation.")); + + IEnumerable completions = root + .Parse("se", ClawCtlCommandLine.CreateParserConfiguration()) + .GetCompletions() + .Select(completion => completion.Label); + + Assert.Contains( + ClawCtlCommandLine.SetupCommandName, + completions, + StringComparer.Ordinal); + } + + // The directive is the only completion path a shell can actually reach, so + // it is covered through the real dispatcher rather than the in-process + // completion API alone. + [Fact] + public async Task CompletionDirectiveWritesSuggestionsWithoutRunningSetup() + { + (int exitCode, string output) = + await RunAsync("[suggest:2]", "se").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Contains( + ClawCtlCommandLine.SetupCommandName, + output, + StringComparison.Ordinal); + } + + public void Dispose() => Directory.Delete(_testDirectory, recursive: true); +} diff --git a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs index 34bd1dbf..cc7c894a 100644 --- a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs +++ b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs @@ -79,6 +79,20 @@ public void CreateStartInfoPreservesExplicitArguments() [InlineData("gateway", "install")] [InlineData("setup", "--install-daemon")] [InlineData("onboard", "--mode", "local")] + + // clawctl now parses its own arguments with System.CommandLine. These + // tokens are the ones that library would claim, so they prove the openclaw + // entrypoint still forwards instead of parsing. + [InlineData("--help")] + [InlineData("-h")] + [InlineData("-?")] + [InlineData("--version")] + [InlineData("--")] + [InlineData("--", "--version")] + [InlineData("@arguments.rsp")] + [InlineData("[suggest:3]", "se")] + [InlineData("")] + [InlineData("chat", "", "--prompt", "value with spaces")] public void CreateStartInfoForwardsCommandsWithoutInterpretation( params string[] arguments) { diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs new file mode 100644 index 00000000..f8741895 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -0,0 +1,145 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher.Tests; + +// Startup is the part of the host that owns diagnostics, argument routing, the +// operational error boundary, and disposal. These scenarios drive the real +// Program.RunAsync with fixture-owned storage, so a regression there is caught +// without a test ever writing to the developer's profile. +public sealed class ProgramStartupTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + [Fact] + public async Task StartupWritesRecordsToTheSuppliedDiagnosticLog() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + string logPath = Path.Combine(_testDirectory, "logs", "openclaw.log"); + + int exitCode = await Program.RunAsync( + ["--version"], + CreateStartup(logPath, output, error)); + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(logPath)); + string log = await File.ReadAllTextAsync(logPath); + Assert.Contains("Host started", log, StringComparison.Ordinal); + Assert.Contains("Host exiting", log, StringComparison.Ordinal); + } + + // The library's default exception handler is disabled so this boundary, + // not System.CommandLine, reports operational failures. + [Fact] + public async Task OperationalFailureReportsTheDiagnosticPathAndExitsNonZero() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + string logPath = Path.Combine(_testDirectory, "logs", "openclaw.log"); + + int exitCode = await Program.RunAsync( + ["setup"], + CreateStartup(logPath, output, error)); + + Assert.Equal(1, exitCode); + Assert.Contains(logPath, error.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("package is ready", output.ToString(), StringComparison.Ordinal); + Assert.Contains( + "Unhandled failure", + await File.ReadAllTextAsync(logPath), + StringComparison.Ordinal); + } + + // Losing the log must not take the command down with it: the host warns + // once and keeps running. + [Fact] + public async Task UnavailableDiagnosticsWarnsOnceAndStillRunsTheCommand() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + HostStartup startup = new() + { + Entrypoint = HostEntrypoint.Control, + CreateDiagnostics = () => throw new IOException("The log is unavailable."), + BaseDirectory = _testDirectory, + Output = output, + Error = error + }; + + int exitCode = await Program.RunAsync(["--version"], startup); + + Assert.Equal(0, exitCode); + Assert.Contains( + "Unable to create diagnostics", + error.ToString(), + StringComparison.Ordinal); + Assert.Equal( + typeof(Program).Assembly.GetName().Version?.ToString(), + output.ToString().Trim()); + } + + // Startup routes the agent alias without letting the clawctl parser see + // the arguments, and the child's exit code is the host's exit code. + [Fact] + public async Task AgentStartupForwardsArgumentsAndChildExitCode() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + string[] arguments = ["--help", "--", "@file.rsp", "[suggest:1]", "", "a b"]; + string[]? forwarded = null; + using var output = new StringWriter(); + using var error = new StringWriter(); + + HostStartup startup = new() + { + Entrypoint = HostEntrypoint.Agent, + CreateDiagnostics = () => HostDiagnosticLog.Create( + Path.Combine(_testDirectory, "logs", "openclaw.log")), + BaseDirectory = _testDirectory, + Output = output, + Error = error, + ResolveNode = _ => Task.FromResult( + new NodeRuntime( + Path.Combine(_testDirectory, "node.exe"), + new Version(24, 15, 0), + RuntimeInformation.ProcessArchitecture)), + LaunchOpenClaw = (_, _, launchArguments, _, _) => + { + forwarded = [.. launchArguments]; + return Task.FromResult(23); + } + }; + + int exitCode = await Program.RunAsync(arguments, startup); + + Assert.Equal(23, exitCode); + Assert.Equal(arguments, forwarded); + Assert.Empty(output.ToString()); + } + + private HostStartup CreateStartup( + string logPath, + TextWriter output, + TextWriter error) => new() + { + Entrypoint = HostEntrypoint.Control, + CreateDiagnostics = () => HostDiagnosticLog.Create(logPath), + BaseDirectory = _testDirectory, + Output = output, + Error = error, + ResolveNode = _ => Task.FromResult( + new NodeRuntime( + Path.Combine(_testDirectory, "node.exe"), + new Version(24, 15, 0), + RuntimeInformation.ProcessArchitecture)) + }; + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 1ef0b16d..59479b43 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -62,8 +62,8 @@ public async Task SetupChecksNodeAndPackagedApplicationWithoutMutation() options, ["setup"], _ => { }, - _ => { }, output, + TextWriter.Null, _ => Task.FromResult(nodeRuntime)); Assert.Equal(0, exitCode); @@ -89,8 +89,8 @@ await Assert.ThrowsAsync( new HostOptions(null, []), ["setup"], _ => { }, - _ => { }, - new StringWriter(), + TextWriter.Null, + TextWriter.Null, _ => { nodeResolutionAttempted = true; From 5208c1d4f3f308487a05990d159837c486c99dec Mon Sep 17 00:00:00 2001 From: Linus Huang Date: Fri, 11 Sep 2026 16:23:14 -0700 Subject: [PATCH 13/76] fix: follow upstream Node selection and repair bundled runtimes Carry the upstream toolchain version through payload metadata and MSIX composition. Validate bundled executables without launching staged images, repair invalid caches under a cross-session installation lock, reclaim interrupted staging, and expose bundled tools only to the child PATH. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 25 +- .github/workflows/gateway-msix.yml | 42 ++-- CONTRIBUTING.md | 4 + README.md | 44 ++-- scripts/Build-LocalMSIX.ps1 | 21 +- scripts/Build-MSIX.ps1 | 53 ++-- scripts/Build-Payload.ps1 | 23 +- scripts/Test-NodeRuntimeInputs.Tests.ps1 | 103 ++++++++ scripts/Test-SigningInputs.Tests.ps1 | 18 +- scripts/Test-SigningInputs.ps1 | 8 + src/OpenClaw.Launcher/GatewayLauncher.cs | 8 + src/OpenClaw.Launcher/HostOptions.cs | 13 +- src/OpenClaw.Launcher/NodeRuntimeInstaller.cs | 136 +++++++---- src/OpenClaw.Launcher/NodeRuntimeResolver.cs | 190 +++++---------- .../OpenClaw.Launcher.csproj | 5 +- src/OpenClaw.Launcher/Program.cs | 12 +- .../GatewayLauncherTests.cs | 18 ++ .../HostOptionsTests.cs | 26 +- .../NodeRuntimeInstallerTests.cs | 227 +++++++++++++++--- .../NodeRuntimeResolverTests.cs | 133 +++++----- 20 files changed, 691 insertions(+), 418 deletions(-) create mode 100644 scripts/Test-NodeRuntimeInputs.Tests.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7f3863d7..78291470 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -60,23 +60,23 @@ and ARM64 separately. - `openclaw` resolves the Node.js executable extracted into package LocalState, confirms the packaged entry point exists, and forwards every argument unchanged to `openclaw.mjs`. -- `clawctl setup` idempotently extracts the architecture-specific bundled - Node.js archive into versioned package LocalState and verifies the packaged - entry point. Runtime launches do not hash or walk application files. +- `clawctl setup` validates and reuses or repairs the architecture-specific + bundled Node.js runtime in versioned package LocalState and verifies the + packaged entry point. Runtime launches do not hash or walk application files. - `GatewayLauncher` starts Node without a shell, uses `ArgumentList`, inherits the console streams, and sets `OPENCLAW_SUPERVISOR_MODE=external` plus `OPENCLAW_NO_AUTO_UPDATE=1`. The child process exit code is the launcher exit - code. + code. Only the child environment prepends the bundled runtime to `PATH`. - Diagnostics are written to packaged LocalState (or `%LOCALAPPDATA%\OpenClawGatewayMSIX` outside an MSIX context) with a named mutex so concurrent processes append complete records. -- The GitHub workflow first builds and packs a pinned - `openclaw/openclaw` revision on Linux. Windows matrix jobs use - `Build-Payload.ps1` to produce x64/ARM64 expanded trees and build metadata, - then downloads the matching official Node.js archive and uses - `Build-MSIX.ps1` to reject Node.js from the application payload, build the - application inventory, publish the NativeAOT host, validate package - contents, and emit MSIX metadata including the runtime hash. +- The GitHub workflow first builds and packs a pinned `openclaw/openclaw` + revision on Linux using that revision's `setup-node-env` action. The resolved + Node.js version flows through `source.json` and `payload-metadata.json`; + Windows payload builds use the same version. `Build-MSIX.ps1` downloads its + matching official archive, rejects Node.js from the application payload, + builds the application inventory, publishes the NativeAOT host, validates + package contents, and emits MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and the immutable upstream commit in `release-policy.json`; signing inputs are @@ -118,6 +118,9 @@ and ARM64 separately. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, payload metadata, and signing validation. +- Do not add a packaging-side Node.js version pin or support-range policy. + The selected upstream toolchain owns version selection; package composition + supplies `NodeRuntimeArchiveFileName`, and the host reads the archive name. - Metadata files are part of the release trust chain, not incidental build output. Changes to their fields must be coordinated across payload creation, MSIX creation, signing validation, workflow artifacts, and tests. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 97881282..f089454a 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -26,8 +26,6 @@ permissions: contents: read env: - NODE_VERSION: 24.16.0 - PNPM_VERSION: 11.15.1 OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '0965053fe6b9341776df147a6934b7485c60b5ca' }} PACKAGING_ROOT: . @@ -69,6 +67,10 @@ jobs: run: > .\scripts\Test-SigningInputs.Tests.ps1 + - name: Test Node.js packaging inputs + shell: pwsh + run: .\scripts\Test-NodeRuntimeInputs.Tests.ps1 + - name: Test workflow package version shell: pwsh run: > @@ -85,32 +87,26 @@ jobs: outputs: source_sha: ${{ steps.source.outputs.sha }} package_version: ${{ steps.source.outputs.version }} + node_version: ${{ steps.source.outputs.node_version }} steps: - name: Check out OpenClaw source uses: actions/checkout@v7 with: repository: openclaw/openclaw ref: ${{ env.OPENCLAW_REF }} - path: openclaw-source persist-credentials: false fetch-depth: 1 - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Set up upstream Node.js and pnpm + uses: ./.github/actions/setup-node-env with: - node-version: ${{ env.NODE_VERSION }} - - - name: Enable pnpm - run: | - corepack enable - corepack prepare "pnpm@${PNPM_VERSION}" --activate + install-bun: "false" + install-deps: "false" - name: Install dependencies - working-directory: openclaw-source run: pnpm install --frozen-lockfile - name: Build OpenClaw - working-directory: openclaw-source env: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" run: | @@ -119,7 +115,6 @@ jobs: - name: Pack npm package id: source - working-directory: openclaw-source run: | set -euo pipefail artifact_dir="${RUNNER_TEMP}/openclaw-package" @@ -133,15 +128,18 @@ jobs: source_sha="$(git rev-parse HEAD)" package_version="$(node -p "require('./package.json').version")" + node_version="$(node -p 'process.versions.node')" echo "sha=${source_sha}" >> "${GITHUB_OUTPUT}" echo "version=${package_version}" >> "${GITHUB_OUTPUT}" + echo "node_version=${node_version}" >> "${GITHUB_OUTPUT}" cat > "${artifact_dir}/source.json" <\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-`. +`%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v-win-`. Extraction is idempotent, versioned, and serialized across concurrent setup -processes. The launcher validates the extracted `node.exe` before reporting -readiness. +processes, including different Windows sessions. Setup validates existing +runtimes before reuse, replaces invalid runtimes, and validates extraction +before publishing it. The launcher places Node.js in a Windows job configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The launcher remains alive while Node.js @@ -83,9 +87,9 @@ clawctl setup openclaw ``` -The packaged OpenClaw revision accepts Node.js -`>=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0`. The launcher keeps this -requirement in one shared validator used by `clawctl` and `openclaw`. +When an MSIX update changes the bundled Node.js version, run `clawctl setup` +again before launching OpenClaw. Previously extracted versions are left in +place so an update does not remove a running process's runtime. ## Selecting the OpenClaw revision @@ -101,6 +105,14 @@ For a one-time override, run **Build OpenClaw Gateway MSIX** manually and provide a tag, branch, or preferably a full 40-character commit SHA in `openclaw_ref`. +The source build uses that revision's `.github/actions/setup-node-env` action +to select Node.js and pnpm. Its resolved Node.js version is recorded in +`source.json`, reused for both Windows payload builds, and carried in +`payload-metadata.json`. Package composition downloads that exact version; +the launcher derives its runtime version and LocalState path from the bundled +archive name. There is no separate packaging-side Node.js version pin or +runtime-support policy. + The payload artifact records the requested ref and resolved upstream commit in `payload-metadata.json`. That build-only file is not embedded in the MSIX. `msix-metadata.json` records both the packaging repository commit and bundled @@ -124,14 +136,14 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. The workflow downloads the official -Node.js archive for the same architecture. `scripts\Build-MSIX.ps1` copies the -application tree and runtime archive into package content, rejects Node.js +architecture-specific application tree. `scripts\Build-MSIX.ps1` downloads +the official Node.js archive matching the payload's recorded build version +and architecture, copies both inputs into package content, rejects Node.js inside the application payload, creates a per-file inventory, and then creates an unsigned NativeAOT MSIX. `scripts\Build-LocalMSIX.ps1` can reuse a successful workflow payload or a -local payload directory; unless `-NodeArchivePath` is supplied, it downloads -the pinned runtime archive used by CI. +local payload directory. `-NodeArchivePath` can supply an already-downloaded +archive, but its version and architecture must match the payload metadata. Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: @@ -156,7 +168,7 @@ key is stored in the repository. |---|---| | OpenClaw application files | Read-only MSIX package `app` directory | | Bundled Node.js archive | Read-only MSIX package `runtime` directory | -| Extracted Node.js runtime | `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v24.16.0-win-` | +| Extracted Node.js runtime | `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v-win-` | | OpenClaw configuration and user state | `%USERPROFILE%\.openclaw` | | Launcher diagnostics | `%LOCALAPPDATA%\Packages\\LocalState\OpenClawGatewayMSIX\Logs\openclaw.log` | diff --git a/scripts/Build-LocalMSIX.ps1 b/scripts/Build-LocalMSIX.ps1 index c0e77db8..57d36643 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -7,9 +7,6 @@ param( [long]$PayloadRunId, - [ValidatePattern('^\d+\.\d+\.\d+$')] - [string]$NodeVersion = '24.16.0', - [string]$NodeArchivePath, [string]$PackageVersion, @@ -61,20 +58,7 @@ if (Test-Path -LiteralPath $OutputDirectory) { New-Item -Path $workDirectory -ItemType Directory -Force | Out-Null if ($NodeArchivePath) { - $resolvedNodeArchivePath = ( - Resolve-Path -LiteralPath $NodeArchivePath - ).Path -} -else { - $nodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" - $resolvedNodeArchivePath = Join-Path $workDirectory $nodeArchiveName - $nodeArchiveUri = - "https://nodejs.org/dist/v$NodeVersion/$nodeArchiveName" - Write-Host "Downloading bundled Node.js runtime from $nodeArchiveUri." - Invoke-WebRequest ` - -Uri $nodeArchiveUri ` - -OutFile $resolvedNodeArchivePath ` - -UseBasicParsing + $NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path } if ($PayloadDirectory) { @@ -157,8 +141,7 @@ try { Write-Host "Building unsigned MSIX version $PackageVersion." & .\scripts\Build-MSIX.ps1 ` -PayloadDirectory $resolvedPayloadDirectory ` - -NodeArchivePath $resolvedNodeArchivePath ` - -NodeVersion $NodeVersion ` + -NodeArchivePath $NodeArchivePath ` -Architecture $Architecture ` -PackageVersion $PackageVersion ` -SourceCommit $sourceCommit ` diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 955d9ffa..0159d7cd 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -3,13 +3,8 @@ param( [Parameter(Mandatory)] [string]$PayloadDirectory, - [Parameter(Mandatory)] [string]$NodeArchivePath, - [Parameter(Mandatory)] - [ValidatePattern('^\d+\.\d+\.\d+$')] - [string]$NodeVersion, - [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string]$Architecture, @@ -186,25 +181,10 @@ function Add-VswhereToPath { } Test-PackageVersion -Add-VswhereToPath $PayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path $payloadApplication = Join-Path $PayloadDirectory 'app' $payloadMetadata = Join-Path $PayloadDirectory 'payload-metadata.json' -$NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path -$expectedNodeArchiveName = "node-v$NodeVersion-win-$Architecture.zip" -if ([IO.Path]::GetFileName($NodeArchivePath) -cne $expectedNodeArchiveName) { - throw ( - "NodeArchivePath must name the expected runtime archive: " + - $expectedNodeArchiveName - ) -} -$expectedNodeArchiveRoot = [IO.Path]::GetFileNameWithoutExtension( - $expectedNodeArchiveName -) -Assert-NodeArchive ` - -Path $NodeArchivePath ` - -ExpectedRoot $expectedNodeArchiveRoot if (-not (Test-Path -LiteralPath $payloadApplication -PathType Container)) { throw "Required MSIX input was not found: $payloadApplication" } @@ -217,6 +197,8 @@ if ( $payloadInfo.repository -ne 'https://github.com/openclaw/openclaw' -or $payloadInfo.architecture -ne $Architecture -or $payloadInfo.layout -ne 'expanded-directory' -or + $payloadInfo.nodeVersion -isnot [string] -or + $payloadInfo.nodeVersion -notmatch '^v?\d+\.\d+\.\d+$' -or $payloadInfo.requestedRef -isnot [string] -or [string]::IsNullOrWhiteSpace($payloadInfo.requestedRef) -or $payloadInfo.resolvedCommit -notmatch '^[0-9a-fA-F]{40}$' @@ -224,6 +206,18 @@ if ( throw 'Payload metadata is not valid for this MSIX package.' } +$nodeVersion = $payloadInfo.nodeVersion.TrimStart('v') +$expectedNodeArchiveName = "node-v$nodeVersion-win-$Architecture.zip" +if ($NodeArchivePath) { + $NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path + if ([IO.Path]::GetFileName($NodeArchivePath) -cne $expectedNodeArchiveName) { + throw ( + 'NodeArchivePath must match the payload build runtime: ' + + $expectedNodeArchiveName + ) + } +} + if (-not (Test-Path ` -LiteralPath (Join-Path $payloadApplication 'openclaw.mjs') ` -PathType Leaf)) { @@ -252,11 +246,18 @@ if ( -Recurse } -Remove-DirectoryIfPresent -Path $runtimeTargetDirectory New-Item -Path $runtimeTargetDirectory -ItemType Directory -Force | Out-Null -Copy-Item ` - -LiteralPath $NodeArchivePath ` - -Destination $nodeArchiveTarget +if (-not $NodeArchivePath) { + $archiveUri = "https://nodejs.org/dist/v$nodeVersion/$expectedNodeArchiveName" + Write-Host "Downloading bundled Node.js runtime from $archiveUri." + Invoke-WebRequest -Uri $archiveUri -OutFile $nodeArchiveTarget +} +elseif ($NodeArchivePath -ne $nodeArchiveTarget) { + Copy-Item -LiteralPath $NodeArchivePath -Destination $nodeArchiveTarget -Force +} +Assert-NodeArchive ` + -Path $nodeArchiveTarget ` + -ExpectedRoot ([IO.Path]::GetFileNameWithoutExtension($expectedNodeArchiveName)) $nodeArchiveHash = ( Get-FileHash -LiteralPath $nodeArchiveTarget -Algorithm SHA256 ).Hash.ToLowerInvariant() @@ -327,6 +328,7 @@ New-Item ` Out-Null try { + Add-VswhereToPath $appxOutput = $msixBuildDirectory.TrimEnd('\') + '\' Write-Host "Building unsigned NativeAOT win-$Architecture MSIX with MSBuild." Invoke-CheckedCommand ` @@ -341,6 +343,7 @@ try { -p:PublishAot=true ` -p:SelfContained=true ` -p:IncludePackagingContent=true ` + "-p:NodeRuntimeArchiveFileName=$expectedNodeArchiveName" ` -p:GenerateAppxPackageOnBuild=true ` "-p:AssemblyVersion=$PackageVersion" ` "-p:FileVersion=$PackageVersion" ` @@ -536,7 +539,7 @@ try { payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count - nodeRuntimeVersion = $NodeVersion + nodeRuntimeVersion = $nodeVersion nodeRuntimeArchive = $expectedNodeArchiveName nodeRuntimeSha256 = $nodeArchiveHash architecture = $Architecture diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index a823b9da..4ce97e38 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -23,6 +23,22 @@ if (-not (Test-Path $sourceMetadataPath -PathType Leaf)) { throw "Missing source metadata: $sourceMetadataPath" } +$sourceMetadata = Get-Content $sourceMetadataPath -Raw | ConvertFrom-Json +$nodeVersion = & node -p 'process.versions.node' +if ($LASTEXITCODE -ne 0 -or $nodeVersion -notmatch '^\d+\.\d+\.\d+$') { + throw 'Unable to determine the payload build Node.js version.' +} +if ($sourceMetadata.nodeVersion -cne $nodeVersion) { + throw ( + "Payload Node.js $nodeVersion does not match the source build " + + "version '$($sourceMetadata.nodeVersion)'." + ) +} +$npmVersion = & npm --version +if ($LASTEXITCODE -ne 0) { + throw 'Unable to determine the payload build npm version.' +} + $stagingDirectory = Join-Path $env:RUNNER_TEMP "openclaw-stage-$Architecture" Remove-Item $stagingDirectory -Recurse -Force -ErrorAction SilentlyContinue New-Item $stagingDirectory -ItemType Directory | Out-Null @@ -105,16 +121,15 @@ Copy-Item ` -Destination $applicationDirectory ` -Recurse -$sourceMetadata = Get-Content $sourceMetadataPath -Raw | ConvertFrom-Json [ordered]@{ repository = $sourceMetadata.repository requestedRef = $sourceMetadata.requestedRef resolvedCommit = $sourceMetadata.resolvedCommit packageVersion = $sourceMetadata.packageVersion architecture = $Architecture - layout = 'expanded-directory' - nodeVersion = (& node --version) - npmVersion = (& npm --version) + layout = 'expanded-directory' + nodeVersion = $nodeVersion + npmVersion = $npmVersion } | ConvertTo-Json | Set-Content (Join-Path $OutputDirectory 'payload-metadata.json') -Encoding utf8 $files = @(Get-ChildItem -LiteralPath $applicationDirectory -File -Recurse) diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 new file mode 100644 index 00000000..751efe8d --- /dev/null +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ( + "openclaw-node-inputs-$([guid]::NewGuid().ToString('N'))" +) +$previousRunnerTemp = $env:RUNNER_TEMP +$previousNpmCache = $env:npm_config_cache + +function Assert-Fails { + param([scriptblock]$Action, [string]$MessagePattern) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw + } + return + } + throw "Expected failure matching '$MessagePattern'." +} + +try { + $env:RUNNER_TEMP = $testRoot + $env:npm_config_cache = Join-Path $testRoot 'npm-cache' + $source = Join-Path $testRoot 'source' + $package = Join-Path $testRoot 'package' + $payload = Join-Path $testRoot 'payload' + New-Item -ItemType Directory -Path "$source\dist", $package -Force | Out-Null + '{"name":"openclaw","version":"0.0.0","type":"module"}' | + Set-Content -LiteralPath "$source\package.json" + 'console.log("fixture");' | Set-Content -LiteralPath "$source\openclaw.mjs" + 'export {};' | Set-Content -LiteralPath "$source\dist\index.js" + & npm pack $source --ignore-scripts --offline --silent --pack-destination $package + if ($LASTEXITCODE -ne 0) { + throw 'Unable to pack the local Node.js input fixture.' + } + $nodeVersion = & node -p 'process.versions.node' + if ($LASTEXITCODE -ne 0) { + throw 'Unable to determine the fixture Node.js version.' + } + $sourceMetadata = @{ + repository = 'https://github.com/openclaw/openclaw' + requestedRef = '1' * 40 + resolvedCommit = '1' * 40 + packageVersion = '0.0.0' + nodeVersion = $nodeVersion + } + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + $metadataPath = Join-Path $payload 'payload-metadata.json' + $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + if ($metadata.nodeVersion -cne $nodeVersion) { + throw 'The payload did not preserve the exact source build Node.js version.' + } + + $sourceMetadata.nodeVersion = '0.0.0' + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + Assert-Fails -MessagePattern 'does not match the source build' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + } + + foreach ($architecture in @('x64', 'arm64')) { + $wrongArchive = Join-Path $testRoot "node-v0.0.0-win-$architecture.zip" + Set-Content -LiteralPath $wrongArchive -Value 'wrong runtime' + $metadata.architecture = $architecture + foreach ($version in @('v24.15.0', '26.1.0')) { + $metadata.nodeVersion = $version + $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath + $expectedName = "node-v$($version.TrimStart('v'))-win-$architecture.zip" + Assert-Fails -MessagePattern ([regex]::Escape($expectedName)) -Action { + & "$PSScriptRoot\Build-MSIX.ps1" ` + -PayloadDirectory $payload -NodeArchivePath $wrongArchive ` + -Architecture $architecture -PackageVersion '0.1.1.0' ` + -SourceCommit ('1' * 40) -OutputDirectory "$testRoot\msix" + } + } + } + $metadata.nodeVersion = '>=24' + $metadata | ConvertTo-Json | Set-Content -LiteralPath $metadataPath + Assert-Fails -MessagePattern 'Payload metadata is not valid' -Action { + & "$PSScriptRoot\Build-MSIX.ps1" ` + -PayloadDirectory $payload -Architecture arm64 ` + -PackageVersion '0.1.1.0' -SourceCommit ('1' * 40) ` + -OutputDirectory "$testRoot\msix" + } + + Write-Host 'Node.js source and packaging input tests passed.' +} +finally { + $env:RUNNER_TEMP = $previousRunnerTemp + $env:npm_config_cache = $previousNpmCache + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 59b61eeb..1a83dec0 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -26,6 +26,8 @@ function New-TestArtifact { [bool]$SourceTreeDirty = $false, + [string]$NodeRuntimeVersion = '24.16.0', + [bool]$IncludeBundledNode = $false, [bool]$IncludeApplicationBundledNode = $false, @@ -128,7 +130,6 @@ function New-TestArtifact { $runtimeDirectory = Join-Path $staging 'runtime' New-Item -Path $runtimeDirectory -ItemType Directory | Out-Null } - $nodeRuntimeVersion = '24.16.0' $nodeRuntimeArchive = "node-v$nodeRuntimeVersion-win-$Architecture.zip" $nodeRuntimePath = Join-Path $runtimeDirectory $nodeRuntimeArchive @@ -289,6 +290,21 @@ try { Reset-TestArtifacts Invoke-PolicyValidation -Root $testRoot + Remove-Item -LiteralPath $testRoot -Recurse -Force + New-Item -Path $testRoot -ItemType Directory | Out-Null + New-TestArtifact -Root $testRoot -Architecture x64 -NodeRuntimeVersion '26.1.0' + New-TestArtifact -Root $testRoot -Architecture arm64 -NodeRuntimeVersion '26.1.0' + Invoke-PolicyValidation -Root $testRoot + + Remove-Item -LiteralPath $testRoot -Recurse -Force + New-Item -Path $testRoot -ItemType Directory | Out-Null + New-TestArtifact -Root $testRoot -Architecture x64 + New-TestArtifact -Root $testRoot -Architecture arm64 -NodeRuntimeVersion '26.1.0' + Assert-Fails ` + -MessagePattern 'Node.js runtime versions do not match' ` + -Action { Invoke-PolicyValidation -Root $testRoot } + + Reset-TestArtifacts Assert-Fails ` -MessagePattern 'approved immutable OpenClaw commit' ` -Action { diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 9a81655e..bc31d642 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -170,6 +170,7 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null +$expectedNodeRuntimeVersion = $null foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture $metadataPath = Join-Path $directory 'msix-metadata.json' @@ -219,6 +220,13 @@ foreach ($architecture in @('x64', 'arm64')) { throw 'The x64 and ARM64 package versions do not match.' } + if ($null -eq $expectedNodeRuntimeVersion) { + $expectedNodeRuntimeVersion = $metadata.nodeRuntimeVersion + } + elseif ($metadata.nodeRuntimeVersion -ne $expectedNodeRuntimeVersion) { + throw 'The x64 and ARM64 Node.js runtime versions do not match.' + } + $actualMsixHash = ( Get-FileHash -LiteralPath $msix.FullName -Algorithm SHA256 ).Hash.ToLowerInvariant() diff --git a/src/OpenClaw.Launcher/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs index ec27931c..531a98a3 100644 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ b/src/OpenClaw.Launcher/GatewayLauncher.cs @@ -69,6 +69,14 @@ public static ProcessStartInfo CreateStartInfo( startInfo.Environment["OPENCLAW_SUPERVISOR_MODE"] = "external"; startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"] = "external"; startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"] = "1"; + string? nodeDirectory = Path.GetDirectoryName(nodePath); + if (!string.IsNullOrEmpty(nodeDirectory)) + { + startInfo.Environment.TryGetValue("PATH", out string? inheritedPath); + startInfo.Environment["PATH"] = string.IsNullOrEmpty(inheritedPath) + ? nodeDirectory + : $"{nodeDirectory}{Path.PathSeparator}{inheritedPath}"; + } startInfo.ArgumentList.Add(entryPoint); foreach (string argument in openClawArguments) diff --git a/src/OpenClaw.Launcher/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 6dace6f0..5c14526f 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -24,18 +24,13 @@ internal static HostOptions Parse( "openclaw.mjs")) ? packagedApplicationDirectory : null; - string packagedNodeArchivePath = Path.Combine( - baseDirectory, - "runtime", - NodeRuntimeInstaller.GetArchiveFileName( - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); + string? packagedNodeArchivePath = NodeRuntimeInstaller.FindArchivePath( + Path.Combine(baseDirectory, "runtime"), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); return new HostOptions( directApplicationDirectory, - File.Exists(packagedNodeArchivePath) - ? packagedNodeArchivePath - : null, + packagedNodeArchivePath, [.. arguments]); } } diff --git a/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs index 06475f6a..92451d99 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs @@ -1,49 +1,77 @@ using System.IO.Compression; using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; namespace OpenClaw.Launcher; -internal static class NodeRuntimeInstaller +internal static partial class NodeRuntimeInstaller { - public const string Version = "24.16.0"; + public static string? FindArchivePath( + string runtimeDirectory, + Architecture architecture) + { + if (!Directory.Exists(runtimeDirectory)) + { + return null; + } - public static string GetArchiveFileName(Architecture architecture) => - $"node-v{Version}-win-{GetArchitectureName(architecture)}.zip"; + string[] archives = Directory.GetFiles( + runtimeDirectory, + $"node-v*-win-{GetArchitectureName(architecture)}.zip"); + return archives.Length switch + { + 0 => null, + 1 => archives[0], + _ => throw new InvalidDataException( + "The package contains multiple Node.js runtime archives.") + }; + } - public static string GetInstallDirectory(Architecture architecture) => + public static Version GetArchiveVersion( + string archivePath, + Architecture architecture) + { + Match match = ArchiveNameRegex().Match(Path.GetFileName(archivePath)); + if (!match.Success || + match.Groups["architecture"].Value != GetArchitectureName(architecture)) + { + throw new InvalidDataException( + $"Unexpected Node.js runtime archive: {Path.GetFileName(archivePath)}"); + } + + return NodeRuntimeResolver.ParseVersion(match.Groups["version"].Value); + } + + public static string GetInstallDirectory(string archivePath) => Path.Combine( HostDataPaths.GetProductLocalStateRoot(), "NodeJS", - $"node-v{Version}-win-{GetArchitectureName(architecture)}"); + Path.GetFileNameWithoutExtension(archivePath)); - public static string GetExecutablePath(Architecture architecture) => - Path.Combine(GetInstallDirectory(architecture), "node.exe"); - - public static async Task EnsureInstalledAsync( + public static NodeRuntime EnsureInstalled( string archivePath, - CancellationToken cancellationToken) + Action log) { Architecture architecture = RuntimeInformation.ProcessArchitecture; - string executablePath = EnsureInstalled( + Version version = GetArchiveVersion(archivePath, architecture); + return EnsureInstalled( archivePath, - GetInstallDirectory(architecture), - architecture); - return await NodeRuntimeResolver.ResolvePathAsync( - executablePath, - cancellationToken).ConfigureAwait(false); + GetInstallDirectory(archivePath), + path => NodeRuntimeResolver.ResolvePath( + path, + version), + log); } - internal static string EnsureInstalled( + internal static NodeRuntime EnsureInstalled( string archivePath, string installDirectory, - Architecture architecture) + Func resolveNode, + Action log) { string executablePath = Path.Combine(installDirectory, "node.exe"); - if (File.Exists(executablePath)) - { - return executablePath; - } - if (!File.Exists(archivePath)) { throw new FileNotFoundException( @@ -51,9 +79,11 @@ internal static string EnsureInstalled( archivePath); } - string mutexName = - $"Local\\OpenClawGatewayMSIX.NodeRuntime.{GetArchitectureName(architecture)}"; - using var mutex = new Mutex(initiallyOwned: false, mutexName); + // LocalState is shared across Windows sessions. Keep validation and + // publication on this thread because mutex ownership is thread-affine. + using var mutex = new Mutex( + initiallyOwned: false, + GetInstallMutexName(installDirectory)); bool ownsMutex = false; try { @@ -66,38 +96,38 @@ internal static string EnsureInstalled( ownsMutex = true; } - if (File.Exists(executablePath)) + string stagingDirectory = $"{installDirectory}.extract"; + if (Directory.Exists(stagingDirectory)) { - return executablePath; + Directory.Delete(stagingDirectory, recursive: true); } - string? parentDirectory = Path.GetDirectoryName(installDirectory); - if (string.IsNullOrWhiteSpace(parentDirectory)) + if (File.Exists(executablePath)) { - throw new InvalidOperationException( - "The Node.js installation path has no parent directory."); + try + { + return resolveNode(executablePath); + } + catch (InvalidOperationException exception) + { + log($"Reinstalling invalid bundled Node.js: {exception.Message}"); + } } - Directory.CreateDirectory(parentDirectory); - string stagingDirectory = - $"{installDirectory}.{Guid.NewGuid():N}.extract"; try { ExtractRuntime( archivePath, - stagingDirectory, - architecture); - if (!File.Exists(Path.Combine(stagingDirectory, "node.exe"))) - { - throw new InvalidDataException( - "The packaged Node.js archive does not contain node.exe."); - } + stagingDirectory); + NodeRuntime runtime = resolveNode( + Path.Combine(stagingDirectory, "node.exe")); if (Directory.Exists(installDirectory)) { Directory.Delete(installDirectory, recursive: true); } Directory.Move(stagingDirectory, installDirectory); + return runtime with { ExecutablePath = executablePath }; } finally { @@ -106,8 +136,6 @@ internal static string EnsureInstalled( Directory.Delete(stagingDirectory, recursive: true); } } - - return executablePath; } finally { @@ -118,13 +146,18 @@ internal static string EnsureInstalled( } } + internal static string GetInstallMutexName(string installDirectory) + { + string identity = Path.GetFullPath(installDirectory).ToUpperInvariant(); + string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"Global\\OpenClawGatewayMSIX.NodeRuntime.{hash}"; + } + private static void ExtractRuntime( string archivePath, - string stagingDirectory, - Architecture architecture) + string stagingDirectory) { - string archiveRoot = - $"node-v{Version}-win-{GetArchitectureName(architecture)}"; + string archiveRoot = Path.GetFileNameWithoutExtension(archivePath); string archivePrefix = archiveRoot + "/"; string fullStagingDirectory = Path.GetFullPath(stagingDirectory) + Path.DirectorySeparatorChar; @@ -197,6 +230,11 @@ private static void ExtractRuntime( } } + [GeneratedRegex( + @"^node-v(?\d+\.\d+\.\d+)-win-(?x64|arm64)\.zip$", + RegexOptions.CultureInvariant)] + private static partial Regex ArchiveNameRegex(); + private static string GetArchitectureName(Architecture architecture) => architecture switch { diff --git a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs index 5f246861..6f1e9992 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -13,101 +13,71 @@ internal sealed record NodeRuntime( internal static partial class NodeRuntimeResolver { - private static readonly TimeSpan VersionQueryTimeout = TimeSpan.FromSeconds(10); - private static readonly NodeVersionRange[] SupportedVersionRanges = - [ - new(new Version(22, 22, 3), 23), - new(new Version(24, 15, 0), 25), - new(new Version(25, 9, 0), null) - ]; - - public static string SupportedVersions { get; } = string.Join( - " || ", - SupportedVersionRanges.Select(range => - range.ExclusiveMajor is int exclusiveMajor - ? $">={range.Minimum} <{exclusiveMajor}" - : $">={range.Minimum}")); - - public static Task ResolveAsync(CancellationToken cancellationToken) => - ResolvePathAsync( - NodeRuntimeInstaller.GetExecutablePath( - RuntimeInformation.ProcessArchitecture), - cancellationToken); - - public static Task ResolvePathAsync( + public static NodeRuntime Resolve(string archivePath) => + ResolvePath( + Path.Combine(NodeRuntimeInstaller.GetInstallDirectory(archivePath), "node.exe"), + NodeRuntimeInstaller.GetArchiveVersion( + archivePath, + RuntimeInformation.ProcessArchitecture)); + + public static NodeRuntime ResolvePath( string executablePath, - CancellationToken cancellationToken) => - ResolveAsync( - File.Exists(executablePath) ? [executablePath] : [], - QueryVersionAsync, - ReadArchitecture, - RuntimeInformation.ProcessArchitecture, - cancellationToken); - - internal static async Task ResolveAsync( - IReadOnlyList candidates, - Func> queryVersion, - Func readArchitecture, - Architecture requiredArchitecture, - CancellationToken cancellationToken) + Version expectedVersion) { - if (candidates.Count == 0) + if (!File.Exists(executablePath)) { throw new InvalidOperationException( CreateFailureMessage( "The bundled Node.js runtime has not been extracted.")); } - var failures = new List(); - foreach (string candidate in candidates) + return Resolve( + executablePath, + expectedVersion, + ReadVersion, + ReadArchitecture, + RuntimeInformation.ProcessArchitecture); + } + + internal static NodeRuntime Resolve( + string executablePath, + Version expectedVersion, + Func readVersion, + Func readArchitecture, + Architecture requiredArchitecture) + { + try { - try + Version version = ParseVersion(readVersion(executablePath)); + if (version != expectedVersion) { - string output = await queryVersion(candidate, cancellationToken) - .ConfigureAwait(false); - Version version = ParseVersion(output); - if (!IsSupported(version)) - { - throw new InvalidDataException( - $"version {version} is unsupported; required {SupportedVersions}"); - } - - Architecture architecture = readArchitecture(candidate); - if (architecture != requiredArchitecture) - { - throw new InvalidDataException( - $"architecture {architecture} does not match {requiredArchitecture}"); - } - - return new NodeRuntime(candidate, version, architecture); + throw new InvalidDataException( + $"version {version} does not match bundled version {expectedVersion}"); } - catch (Exception exception) when ( - exception is IOException or - UnauthorizedAccessException or - BadImageFormatException or - InvalidDataException or - InvalidOperationException or - TimeoutException or - Win32Exception) + + Architecture architecture = readArchitecture(executablePath); + if (architecture != requiredArchitecture) { - failures.Add($"{candidate}: {exception.Message}"); + throw new InvalidDataException( + $"architecture {architecture} does not match {requiredArchitecture}"); } - } - throw new InvalidOperationException( - CreateFailureMessage( - "No compatible Node.js runtime was found. " + - string.Join(" ", failures))); + return new NodeRuntime(executablePath, version, architecture); + } + catch (Exception exception) when ( + exception is IOException or + UnauthorizedAccessException or + BadImageFormatException or + InvalidDataException or + InvalidOperationException or + Win32Exception) + { + throw new InvalidOperationException( + CreateFailureMessage($"{executablePath}: {exception.Message}"), + exception); + } } - internal static bool IsSupported(Version version) => - SupportedVersionRanges.Any(range => - version >= range.Minimum && - ( - range.ExclusiveMajor is null || - version.Major < range.ExclusiveMajor - )); - internal static Version ParseVersion(string output) { Match match = NodeVersionRegex().Match(output.Trim()); @@ -116,7 +86,7 @@ internal static Version ParseVersion(string output) !Version.TryParse(match.Groups["version"].Value, out Version? version)) { throw new InvalidDataException( - $"Node.js returned an invalid version: {output.Trim()}"); + $"Node.js has an invalid product version: {output.Trim()}"); } return version; @@ -124,57 +94,19 @@ internal static Version ParseVersion(string output) internal static string CreateFailureMessage(string detail) => $"{detail}{Environment.NewLine}" + - $"Run `clawctl setup` to extract Node.js {NodeRuntimeInstaller.Version} " + - "from the installed OpenClaw package."; + "Run `clawctl setup` to prepare the Node.js runtime bundled " + + "with the installed OpenClaw package."; - private static async Task QueryVersionAsync( - string executablePath, - CancellationToken cancellationToken) + private static string ReadVersion(string executablePath) { - var startInfo = new ProcessStartInfo - { - FileName = executablePath, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - startInfo.ArgumentList.Add("--version"); - - using Process process = Process.Start(startInfo) ?? - throw new InvalidOperationException("Unable to start Node.js."); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken); - timeout.CancelAfter(VersionQueryTimeout); - - try + // Executing a staged image can keep it locked after process exit, + // preventing Windows from publishing the extracted directory. + string? version = FileVersionInfo.GetVersionInfo(executablePath).ProductVersion; + if (string.IsNullOrWhiteSpace(version)) { - Task standardOutput = process.StandardOutput.ReadToEndAsync( - timeout.Token); - Task standardError = process.StandardError.ReadToEndAsync( - timeout.Token); - await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); - string output = await standardOutput.ConfigureAwait(false); - string error = await standardError.ConfigureAwait(false); - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"Node.js version query exited with code {process.ExitCode}: " + - error.Trim()); - } - - return output; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(CancellationToken.None) - .ConfigureAwait(false); - } - throw new TimeoutException("Node.js version query timed out."); + throw new InvalidDataException("Node.js has no product version."); } + return version; } private static Architecture ReadArchitecture(string executablePath) @@ -196,8 +128,4 @@ private static Architecture ReadArchitecture(string executablePath) @"^v?(?\d+\.\d+\.\d+)(?:\+[0-9A-Za-z.-]+)?$", RegexOptions.CultureInvariant)] private static partial Regex NodeVersionRegex(); - - private sealed record NodeVersionRange( - Version Minimum, - int? ExclusiveMajor); } diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index 47071d72..ddbe9d9a 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -81,7 +81,8 @@ - @@ -93,7 +94,7 @@ Text="Missing expanded OpenClaw application content." /> - diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 36024480..05454cf2 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -131,7 +131,8 @@ internal static async Task RunAgentAsync( await RunAgentAsync( options, log, - resolveNode ?? NodeRuntimeResolver.ResolveAsync, + resolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( + GetPackagedNodeArchivePath(options)))), GatewayLauncher.RunAsync).ConfigureAwait(false); // launchOpenClaw is a test seam: tests substitute a fake in place of @@ -198,9 +199,9 @@ await output.WriteLineAsync( else { string archivePath = GetPackagedNodeArchivePath(options); - nodeRuntime = await NodeRuntimeInstaller.EnsureInstalledAsync( + nodeRuntime = NodeRuntimeInstaller.EnsureInstalled( archivePath, - CancellationToken.None).ConfigureAwait(false); + log); } ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); string applicationDirectory = @@ -247,10 +248,7 @@ private static string GetPackagedNodeArchivePath(HostOptions options) string? archivePath = options.PackagedNodeArchivePath; string expectedPath = archivePath ?? Path.Combine( AppContext.BaseDirectory, - "runtime", - NodeRuntimeInstaller.GetArchiveFileName( - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); + "runtime"); if (archivePath is null || !File.Exists(archivePath)) { throw new FileNotFoundException( diff --git a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs index 34bd1dbf..6c3b5741 100644 --- a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs +++ b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs @@ -48,6 +48,24 @@ public void CreateStartInfoDefaultsToCurrentWorkingDirectory() Assert.NotEqual(_payloadDirectory, startInfo.WorkingDirectory); } + [Fact] + public void CreateStartInfoPrependsBundledRuntimeOnlyToChildPath() + { + string? inheritedPath = Environment.GetEnvironmentVariable("PATH"); + string nodeDirectory = Path.Combine(_payloadDirectory, "runtime"); + + var startInfo = GatewayLauncher.CreateStartInfo( + Path.Combine(nodeDirectory, "node.exe"), + _payloadDirectory, + []); + + string expectedPath = string.IsNullOrEmpty(inheritedPath) + ? nodeDirectory + : $"{nodeDirectory}{Path.PathSeparator}{inheritedPath}"; + Assert.Equal(expectedPath, startInfo.Environment["PATH"]); + Assert.Equal(inheritedPath, Environment.GetEnvironmentVariable("PATH")); + } + [Fact] public void CreateStartInfoPreservesExplicitArguments() { diff --git a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs index 48c9ccbe..a165df85 100644 --- a/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs @@ -55,16 +55,20 @@ public void ParseResolvesPackagedApplicationWhenEntryPointExists() Assert.Equal(["gateway", "run"], options.OpenClawArguments); } - [Fact] - public void ParseResolvesArchitectureSpecificPackagedNodeArchive() + [Theory] + [InlineData("24.16.0")] + [InlineData("26.1.0")] + public void ParseResolvesArchitectureSpecificPackagedNodeArchive(string version) { string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); Directory.CreateDirectory(runtimeDirectory); + string architecture = System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture == System.Runtime.InteropServices.Architecture.X64 + ? "x64" + : "arm64"; string archivePath = Path.Combine( runtimeDirectory, - NodeRuntimeInstaller.GetArchiveFileName( - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); + $"node-v{version}-win-{architecture}.zip"); File.WriteAllText(archivePath, "fixture"); HostOptions options = HostOptions.Parse([], _testDirectory); @@ -72,6 +76,18 @@ public void ParseResolvesArchitectureSpecificPackagedNodeArchive() Assert.Equal(archivePath, options.PackagedNodeArchivePath); } + [Fact] + public void ArchiveDiscoveryRejectsAmbiguousVersions() + { + File.WriteAllText(Path.Combine(_testDirectory, "node-v24.16.0-win-x64.zip"), "fixture"); + File.WriteAllText(Path.Combine(_testDirectory, "node-v26.1.0-win-x64.zip"), "fixture"); + + Assert.Throws(() => + NodeRuntimeInstaller.FindArchivePath( + _testDirectory, + System.Runtime.InteropServices.Architecture.X64)); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs index 989d9442..a397a11b 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs @@ -7,81 +7,240 @@ public sealed class NodeRuntimeInstallerTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); - [Fact] - public void EnsureInstalledExtractsArchitectureRuntimeOnce() + [Theory] + [InlineData("24.16.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.Arm64)] + public void EnsureInstalledExtractsSelectedRuntimeOnce( + string version, + Architecture architecture) { - Architecture architecture = Architecture.X64; - string archivePath = CreateRuntimeArchive(architecture); + string archivePath = CreateRuntimeArchive(version, architecture); string installDirectory = Path.Combine(_testDirectory, "installed"); - string firstPath = NodeRuntimeInstaller.EnsureInstalled( + NodeRuntime first = EnsureInstalled( archivePath, - installDirectory, - architecture); - DateTime firstWriteTime = File.GetLastWriteTimeUtc(firstPath); - string secondPath = NodeRuntimeInstaller.EnsureInstalled( + installDirectory); + DateTime firstWriteTime = File.GetLastWriteTimeUtc(first.ExecutablePath); + NodeRuntime second = EnsureInstalled( archivePath, - installDirectory, - architecture); + installDirectory); - Assert.Equal(Path.Combine(installDirectory, "node.exe"), firstPath); - Assert.Equal(firstPath, secondPath); - Assert.Equal("fixture-node", File.ReadAllText(firstPath)); - Assert.Equal(firstWriteTime, File.GetLastWriteTimeUtc(secondPath)); + Assert.Equal(Path.Combine(installDirectory, "node.exe"), first.ExecutablePath); + Assert.Equal(Version.Parse(version), first.Version); + Assert.Equal(architecture, first.Architecture); + Assert.Equal(first, second); + Assert.Equal("fixture-node", File.ReadAllText(first.ExecutablePath)); + Assert.Equal(firstWriteTime, File.GetLastWriteTimeUtc(second.ExecutablePath)); + Assert.Equal( + Version.Parse(version), + NodeRuntimeInstaller.GetArchiveVersion(archivePath, architecture)); + Assert.EndsWith( + Path.GetFileNameWithoutExtension(archivePath), + NodeRuntimeInstaller.GetInstallDirectory(archivePath), + StringComparison.Ordinal); } - [Fact] - public void EnsureInstalledRejectsEntriesOutsideExpectedRuntimeRoot() + [Theory] + [InlineData("unexpected/node.exe")] + [InlineData("{root}/../outside.txt")] + [InlineData("{root}/nested/../../outside.txt")] + public void EnsureInstalledRejectsUnsafeArchiveEntries(string entryName) { - string archivePath = Path.Combine(_testDirectory, "unsafe.zip"); + ArgumentNullException.ThrowIfNull(entryName); + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); using (ZipArchive archive = ZipFile.Open( archivePath, - ZipArchiveMode.Create)) + ZipArchiveMode.Update)) { - ZipArchiveEntry entry = archive.CreateEntry("unexpected/node.exe"); + ZipArchiveEntry entry = archive.CreateEntry(entryName.Replace( + "{root}", + Path.GetFileNameWithoutExtension(archivePath), + StringComparison.Ordinal)); using StreamWriter writer = new(entry.Open()); writer.Write("fixture-node"); } + string installDirectory = Path.Combine(_testDirectory, "unsafe-install"); Assert.Throws(() => - NodeRuntimeInstaller.EnsureInstalled( - archivePath, - Path.Combine(_testDirectory, "unsafe-install"), - Architecture.X64)); + EnsureInstalled(archivePath, installDirectory)); + Assert.False(Directory.Exists(installDirectory)); + Assert.False(Directory.Exists($"{installDirectory}.extract")); } [Fact] public void EnsureInstalledReplacesIncompleteRuntimeDirectory() { - Architecture architecture = Architecture.X64; - string archivePath = CreateRuntimeArchive(architecture); + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); string installDirectory = Path.Combine(_testDirectory, "incomplete"); Directory.CreateDirectory(installDirectory); File.WriteAllText( Path.Combine(installDirectory, "partial.txt"), "partial"); - string executablePath = NodeRuntimeInstaller.EnsureInstalled( - archivePath, - installDirectory, - architecture); + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); - Assert.True(File.Exists(executablePath)); + Assert.True(File.Exists(runtime.ExecutablePath)); Assert.False(File.Exists( Path.Combine(installDirectory, "partial.txt"))); } + [Fact] + public void EnsureInstalledRepairsInvalidExecutableAndThenReusesIt() + { + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); + string installDirectory = Path.Combine(_testDirectory, "invalid"); + Directory.CreateDirectory(installDirectory); + File.WriteAllText(Path.Combine(installDirectory, "node.exe"), "invalid-node"); + var messages = new List(); + + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory, messages.Add); + DateTime writeTime = File.GetLastWriteTimeUtc(runtime.ExecutablePath); + NodeRuntime reused = EnsureInstalled(archivePath, installDirectory, messages.Add); + + Assert.Equal("fixture-node", File.ReadAllText(runtime.ExecutablePath)); + Assert.Equal(runtime, reused); + Assert.Equal(writeTime, File.GetLastWriteTimeUtc(reused.ExecutablePath)); + Assert.Contains("Reinstalling invalid bundled Node.js", Assert.Single(messages), + StringComparison.Ordinal); + } + + [Fact] + public void EnsureInstalledDoesNotPublishInvalidRuntimeAndCanRetry() + { + string archivePath = CreateRuntimeArchive( + "26.1.0", + Architecture.X64, + "invalid-node"); + string installDirectory = Path.Combine(_testDirectory, "retry"); + + Assert.Throws(() => + EnsureInstalled(archivePath, installDirectory)); + Assert.False(Directory.Exists(installDirectory)); + Assert.False(Directory.Exists($"{installDirectory}.extract")); + + File.Delete(archivePath); + CreateRuntimeArchive("26.1.0", Architecture.X64); + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); + + Assert.Equal("fixture-node", File.ReadAllText(runtime.ExecutablePath)); + } + + [Theory] + [InlineData(Architecture.X64)] + [InlineData(Architecture.Arm64)] + public void EnsureInstalledClearsInterruptedExtraction(Architecture architecture) + { + string archivePath = CreateRuntimeArchive("26.1.0", architecture); + string installDirectory = Path.Combine(_testDirectory, "interrupted"); + string stagingDirectory = $"{installDirectory}.extract"; + Directory.CreateDirectory(stagingDirectory); + File.WriteAllText(Path.Combine(stagingDirectory, "node.exe"), "partial-node"); + + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); + + Assert.Equal("fixture-node", File.ReadAllText(runtime.ExecutablePath)); + Assert.False(Directory.Exists(stagingDirectory)); + } + + [Fact] + public async Task ConcurrentSetupPublishesOnlyOnce() + { + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); + string installDirectory = Path.Combine(_testDirectory, "concurrent"); + int publications = 0; + using var start = new Barrier(3); + + NodeRuntime Install() + { + start.SignalAndWait(); + return NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + path => + { + if (path == Path.Combine($"{installDirectory}.extract", "node.exe")) + { + Interlocked.Increment(ref publications); + } + return ResolveFixture(path, archivePath); + }, + _ => { }); + } + + Task first = Task.Run(Install); + Task second = Task.Run(Install); + start.SignalAndWait(); + NodeRuntime[] runtimes = await Task.WhenAll(first, second); + + Assert.Equal(1, publications); + Assert.Equal(runtimes[0], runtimes[1]); + Assert.False(Directory.Exists($"{installDirectory}.extract")); + } + + [Fact] + public void InstallationLockIsGlobalAndScopedToItsDirectory() + { + string first = Path.Combine(_testDirectory, "first"); + string second = Path.Combine(_testDirectory, "second"); + string name = NodeRuntimeInstaller.GetInstallMutexName(first); + + Assert.StartsWith("Global\\", name, StringComparison.Ordinal); + Assert.Equal(name, NodeRuntimeInstaller.GetInstallMutexName(first.ToUpperInvariant())); + Assert.NotEqual(name, NodeRuntimeInstaller.GetInstallMutexName(second)); + } + + [Theory] + [InlineData("node-v26-win-x64.zip")] + [InlineData("node-v26.1.0-rc.1-win-x64.zip")] + [InlineData("node-v26.1.0-win-arm64.zip")] + public void ArchiveVersionRejectsMalformedOrWrongArchitectureNames(string name) + { + Assert.Throws(() => + NodeRuntimeInstaller.GetArchiveVersion(name, Architecture.X64)); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); GC.SuppressFinalize(this); } - private string CreateRuntimeArchive(Architecture architecture) + private static NodeRuntime EnsureInstalled( + string archivePath, + string installDirectory, + Action? log = null) => + NodeRuntimeInstaller.EnsureInstalled( + archivePath, + installDirectory, + path => ResolveFixture(path, archivePath), + log ?? (_ => { })); + + private static NodeRuntime ResolveFixture(string path, string archivePath) + { + if (!File.Exists(path) || File.ReadAllText(path) != "fixture-node") + { + throw new InvalidOperationException("Invalid fixture runtime."); + } + + Architecture architecture = archivePath.EndsWith("-win-x64.zip", StringComparison.Ordinal) + ? Architecture.X64 + : Architecture.Arm64; + return new NodeRuntime( + path, + NodeRuntimeInstaller.GetArchiveVersion(archivePath, architecture), + architecture); + } + + private string CreateRuntimeArchive( + string version, + Architecture architecture, + string nodeContent = "fixture-node") { + string architectureName = architecture == Architecture.X64 ? "x64" : "arm64"; string archivePath = Path.Combine( _testDirectory, - NodeRuntimeInstaller.GetArchiveFileName(architecture)); + $"node-v{version}-win-{architectureName}.zip"); string rootName = Path.GetFileNameWithoutExtension(archivePath); using ZipArchive archive = ZipFile.Open( archivePath, @@ -95,7 +254,7 @@ private string CreateRuntimeArchive(Architecture architecture) } ZipArchiveEntry entry = archive.CreateEntry($"{rootName}/node.exe"); using StreamWriter writer = new(entry.Open()); - writer.Write("fixture-node"); + writer.Write(nodeContent); return archivePath; } } diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs index cf92dcda..8ccbc2b0 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs @@ -4,86 +4,87 @@ namespace OpenClaw.Launcher.Tests; public sealed class NodeRuntimeResolverTests { - [Fact] - public async Task ResolveAcceptsCompatibleRuntime() + [Theory] + [InlineData("24.16.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.X64)] + [InlineData("26.1.0", Architecture.Arm64)] + public void ResolveAcceptsSelectedRuntime(string version, Architecture architecture) { - NodeRuntime runtime = await ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.16.0"), - Architecture.X64, - Architecture.X64); + NodeRuntime runtime = Resolve( + _ => $"v{version}", + architecture, + architecture, + version); Assert.Equal("C:\\Node\\node.exe", runtime.ExecutablePath); - Assert.Equal(new Version(24, 16, 0), runtime.Version); - Assert.Equal(Architecture.X64, runtime.Architecture); + Assert.Equal(Version.Parse(version), runtime.Version); + Assert.Equal(architecture, runtime.Architecture); } [Fact] - public async Task ResolveReportsSetupCommandWhenRuntimeIsMissing() + public void ResolveReportsSetupCommandWhenRuntimeIsMissing() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - [], - _ => Task.FromResult("v24.16.0"), - Architecture.X64, - Architecture.X64)); + () => NodeRuntimeResolver.ResolvePath( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "node.exe"), + new Version(26, 1, 0))); Assert.Contains("has not been extracted", exception.Message, StringComparison.Ordinal); Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } - [Fact] - public async Task ResolveRejectsOutdatedRuntime() + [Theory] + [InlineData("24.14.0")] + [InlineData("24.17.0")] + public void ResolveRejectsRuntimeDifferentFromArchive(string version) { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.14.0"), + () => Resolve( + _ => $"v{version}", Architecture.X64, Architecture.X64)); - Assert.Contains("version 24.14.0 is unsupported", exception.Message, StringComparison.Ordinal); - Assert.Contains(NodeRuntimeResolver.SupportedVersions, exception.Message, StringComparison.Ordinal); + Assert.Contains( + $"version {version} does not match bundled version 24.16.0", + exception.Message, + StringComparison.Ordinal); } [Fact] - public async Task ResolveRejectsMalformedVersion() + public void ResolveRejectsMalformedVersion() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("not-node"), + () => Resolve( + _ => "not-node", Architecture.X64, Architecture.X64)); - Assert.Contains("invalid version", exception.Message, StringComparison.Ordinal); + Assert.Contains("invalid product version", exception.Message, StringComparison.Ordinal); } [Fact] - public async Task ResolveRejectsPrereleaseVersion() + public void ResolveRejectsPrereleaseVersion() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.15.0-rc.1"), + () => Resolve( + _ => "v24.15.0-rc.1", Architecture.X64, Architecture.X64)); - Assert.Contains("invalid version", exception.Message, StringComparison.Ordinal); + Assert.Contains("invalid product version", exception.Message, StringComparison.Ordinal); } [Fact] - public async Task ResolveRejectsIncompatibleArchitecture() + public void ResolveRejectsIncompatibleArchitecture() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromResult("v24.16.0"), + () => Resolve( + _ => "v24.16.0", Architecture.Arm64, Architecture.X64)); @@ -91,50 +92,28 @@ public async Task ResolveRejectsIncompatibleArchitecture() } [Fact] - public async Task ResolveReportsVersionQueryFailure() + public void ResolveReportsVersionReadFailure() { - InvalidOperationException exception = await Assert.ThrowsAsync< + InvalidOperationException exception = Assert.Throws< InvalidOperationException>( - () => ResolveAsync( - ["C:\\Node\\node.exe"], - _ => Task.FromException( - new InvalidOperationException("query failed")), + () => Resolve( + _ => throw new IOException("version read failed"), Architecture.X64, Architecture.X64)); - Assert.Contains("query failed", exception.Message, StringComparison.Ordinal); + Assert.Contains("version read failed", exception.Message, StringComparison.Ordinal); Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } - [Theory] - [InlineData(22, 22, 2, false)] - [InlineData(22, 22, 3, true)] - [InlineData(23, 99, 0, false)] - [InlineData(24, 14, 99, false)] - [InlineData(24, 15, 0, true)] - [InlineData(25, 8, 99, false)] - [InlineData(25, 9, 0, true)] - [InlineData(26, 0, 0, true)] - public void SupportedVersionsMatchPackagedOpenClawRequirement( - int major, - int minor, - int build, - bool expected) - { - Assert.Equal( - expected, - NodeRuntimeResolver.IsSupported(new Version(major, minor, build))); - } - - private static Task ResolveAsync( - IReadOnlyList candidates, - Func> queryVersion, + private static NodeRuntime Resolve( + Func readVersion, Architecture candidateArchitecture, - Architecture requiredArchitecture) => - NodeRuntimeResolver.ResolveAsync( - candidates, - (path, _) => queryVersion(path), + Architecture requiredArchitecture, + string expectedVersion = "24.16.0") => + NodeRuntimeResolver.Resolve( + "C:\\Node\\node.exe", + Version.Parse(expectedVersion), + readVersion, _ => candidateArchitecture, - requiredArchitecture, - CancellationToken.None); + requiredArchitecture); } From cc1d833182813a3ee9aebd7fe9a4e7a16c7ec052 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 12 Sep 2026 00:30:21 -0700 Subject: [PATCH 14/76] chore(ci): finalize Artifact Signing setup (#24) * chore(ci): finalize Artifact Signing setup * docs: name the dedicated packaging signer * docs: use the MSIX signing identity name * fix(ci): recurse into signing artifact folders --- .github/workflows/gateway-msix.yml | 12 ++++-- README.md | 29 +++++++++++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 37 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 scripts/Test-WorkflowSigningConfiguration.ps1 diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 81c0b757..2aff1cb8 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -84,6 +84,11 @@ jobs: run: > .\scripts\Test-SigningInputs.Tests.ps1 + - name: Test signing workflow configuration + shell: pwsh + run: > + .\scripts\Test-WorkflowSigningConfiguration.ps1 + - name: Test workflow package version shell: pwsh run: > @@ -404,9 +409,9 @@ jobs: - name: Azure login uses: azure/login@v3 with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - name: Sign MSIX packages uses: azure/artifact-signing-action@v2 @@ -416,6 +421,7 @@ jobs: certificate-profile-name: openclaw files-folder: artifacts files-folder-filter: msix + files-folder-recurse: true files-folder-depth: 2 file-digest: SHA256 timestamp-rfc3161: http://timestamp.acs.microsoft.com diff --git a/README.md b/README.md index 064acd24..b874ae82 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,35 @@ Test-signing private keys are generated only on the temporary GitHub runner and are deleted before artifacts are uploaded. No signing secret or private key is stored in the repository. +### Official signing setup + +The `release-signing` GitHub environment must define these environment +variables (they are identifiers, not credentials): + +- `AZURE_CLIENT_ID`: application (client) ID of the dedicated + `openclaw-windows-msix-signing` Entra application; +- `AZURE_TENANT_ID`: Entra tenant ID; +- `AZURE_SUBSCRIPTION_ID`: Azure subscription containing the signing resource. + +Do not create an `AZURE_CLIENT_SECRET`. The `sign-msix` job requests a +short-lived Azure token with GitHub OIDC. The Entra application must have a +federated identity credential with: + +- issuer: `https://token.actions.githubusercontent.com`; +- subject: + `repo:openclaw@252820863/openclaw-windows-packaging@1347889239:environment:release-signing`; +- audience: `api://AzureADTokenExchange`. + +This repository was created after GitHub's immutable OIDC subject rollout, so +the subject includes the organization and repository IDs. The older mutable +`repo:openclaw/openclaw-windows-packaging:...` form will not match its tokens. + +The service principal must have `Artifact Signing Certificate Profile Signer` +on the `openclaw` certificate profile (or a containing scope). The workflow +uses account `openclaw`, certificate profile `openclaw`, and endpoint +`https://eus.codesigning.azure.net/`. The expected public certificate subject +is recorded in `release-policy.json`. + ## Installed data | Data | Default path | diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 new file mode 100644 index 00000000..b47146f9 --- /dev/null +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -0,0 +1,37 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$workflowPath = Join-Path ` + $repositoryRoot ` + '.github\workflows\gateway-msix.yml' +$workflow = Get-Content -LiteralPath $workflowPath -Raw + +$requiredFragments = @( + 'environment: release-signing' + 'id-token: write' + 'uses: azure/login@v3' + 'client-id: ${{ vars.AZURE_CLIENT_ID }}' + 'tenant-id: ${{ vars.AZURE_TENANT_ID }}' + 'subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}' + 'uses: azure/artifact-signing-action@v2' + 'files-folder-recurse: true' + 'endpoint: https://eus.codesigning.azure.net/' + 'signing-account-name: openclaw' + 'certificate-profile-name: openclaw' +) + +foreach ($fragment in $requiredFragments) { + if (-not $workflow.Contains($fragment, [StringComparison]::Ordinal)) { + throw "Signing workflow is missing required configuration: $fragment" + } +} + +if ($workflow.Contains('AZURE_CLIENT_SECRET', [StringComparison]::Ordinal)) { + throw 'Signing workflow must use OIDC, not an Azure client secret.' +} + +Write-Host 'Gateway MSIX signing workflow configuration passed.' From 54d4c43adfd8d2ef5f8c8d5a52482e988125780b Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 12 Sep 2026 00:54:57 -0700 Subject: [PATCH 15/76] feat(ci): publish signed Gateway MSIX releases (#33) * feat(ci): publish durable signed MSIX releases * feat(ci): publish multi-architecture MSIX bundle * fix(ci): authorize exact MSIX bundle contents * fix(ci): use explicit zero-version signing proof --- .github/copilot-instructions.md | 8 +- .github/workflows/gateway-msix.yml | 216 +++++++++++++++++- README.md | 34 ++- release-policy.json | 4 +- scripts/Build-MSIX.ps1 | 2 + scripts/Build-MSIXBundle.ps1 | 126 ++++++++++ scripts/Get-WorkflowPackageVersion.ps1 | 34 ++- scripts/Test-Build-MSIXBundle.Tests.ps1 | 110 +++++++++ scripts/Test-SigningInputs.Tests.ps1 | 114 ++++++++- scripts/Test-SigningInputs.ps1 | 99 ++++++++ scripts/Test-WorkflowPackageVersion.Tests.ps1 | 34 +++ scripts/Test-WorkflowSigningConfiguration.ps1 | 14 ++ 12 files changed, 779 insertions(+), 16 deletions(-) create mode 100644 scripts/Build-MSIXBundle.ps1 create mode 100644 scripts/Test-Build-MSIXBundle.Tests.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3790db31..851251ea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -120,12 +120,18 @@ and ARM64 separately. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, payload metadata, and signing validation. +- Official releases combine the x64 and ARM64 packages into one signed + `.msixbundle` while retaining signed standalone packages for explicit + architecture-specific deployment. Compose the bundle before signing; bundle + signing recursively covers its contained packages. - Metadata files are part of the release trust chain, not incidental build output. Changes to their fields must be coordinated across payload creation, MSIX creation, signing validation, workflow artifacts, and tests. - Keep the workflow's manual `openclaw_ref` default and automatic `env.OPENCLAW_REF` fallback identical. Official-release changes also update - the reviewed immutable commit in `release-policy.json`. + the reviewed immutable commit and stable or correction tag in + `release-policy.json`. The tag determines the four-part MSIX identity + version and the permanent GitHub Release tag. - The launcher is NativeAOT. `dotnet build` and the xUnit suite exercise a JIT build, so run the NativeAOT publish path when changing reflection, interop, or trimming-sensitive code. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 2aff1cb8..3b6e1760 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -94,6 +94,11 @@ jobs: run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test MSIX bundle build + shell: pwsh + run: > + .\scripts\Test-Build-MSIXBundle.Tests.ps1 + - name: Test Git hooks shell: pwsh run: > @@ -259,10 +264,20 @@ jobs: - name: Compose unsigned MSIX shell: pwsh + env: + SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} run: | + $versionParameters = @{ + RunNumber = '${{ github.run_number }}' + RunAttempt = '${{ github.run_attempt }}' + } + if ($env:SIGNING_MODE -eq 'official') { + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $versionParameters.ReleaseVersion = [string]$policy.packageVersion + } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` - -RunNumber '${{ github.run_number }}' ` - -RunAttempt '${{ github.run_attempt }}' + @versionParameters .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` @@ -330,6 +345,59 @@ jobs: if-no-files-found: error retention-days: 7 + build-msix-bundle: + name: Build unsigned multi-architecture Gateway MSIX bundle + needs: build-msix + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download unsigned x64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-x64 + path: artifacts\x64 + + - name: Download unsigned ARM64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-arm64 + path: artifacts\arm64 + + - name: Compose unsigned multi-architecture MSIX bundle + shell: pwsh + env: + SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} + run: | + $versionParameters = @{ + RunNumber = '${{ github.run_number }}' + RunAttempt = '${{ github.run_attempt }}' + } + if ($env:SIGNING_MODE -eq 'official') { + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $versionParameters.ReleaseVersion = [string]$policy.packageVersion + } + $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` + @versionParameters + + .\scripts\Build-MSIXBundle.ps1 ` + -X64Package artifacts\x64\OpenClawGateway-x64.msix ` + -Arm64Package artifacts\arm64\OpenClawGateway-arm64.msix ` + -PackageVersion $packageVersion ` + -OutputPath artifacts\bundle\OpenClawGateway.msixbundle + + - name: Upload unsigned multi-architecture MSIX bundle + uses: actions/upload-artifact@v7 + with: + name: openclaw-gateway-msix-unsigned-bundle + path: artifacts\bundle\OpenClawGateway.msixbundle + if-no-files-found: error + retention-days: 7 + reject-untrusted-official-signing: name: Reject official signing outside main if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref != 'refs/heads/main' }} @@ -346,8 +414,14 @@ jobs: authorize-signing: name: Authorize official Gateway MSIX signing if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref == 'refs/heads/main' }} - needs: build-msix + needs: + - build-msix + - build-msix-bundle runs-on: windows-latest + outputs: + package_version: ${{ steps.release.outputs.package_version }} + release_tag: ${{ steps.release.outputs.release_tag }} + release_version: ${{ steps.release.outputs.release_version }} permissions: actions: read contents: read @@ -369,6 +443,28 @@ jobs: name: openclaw-gateway-msix-unsigned-arm64 path: artifacts\arm64 + - name: Download unsigned multi-architecture bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-bundle + path: artifacts\bundle + + - name: Resolve official release metadata + id: release + shell: pwsh + run: | + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $releaseTag = ([string]$policy.releaseTag).Trim() + $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` + -RunNumber '${{ github.run_number }}' ` + -RunAttempt '${{ github.run_attempt }}' ` + -ReleaseVersion ([string]$policy.packageVersion) + + "package_version=$packageVersion" >> $env:GITHUB_OUTPUT + "release_tag=$releaseTag" >> $env:GITHUB_OUTPUT + "release_version=$($releaseTag.Substring(1))" >> $env:GITHUB_OUTPUT + - name: Enforce official signing policy shell: pwsh env: @@ -378,6 +474,7 @@ jobs: .\scripts\Test-SigningInputs.ps1 ` -ArtifactsDirectory artifacts ` -PolicyPath .\release-policy.json ` + -BundlePath artifacts\bundle\OpenClawGateway.msixbundle ` -RequestedRef $env:OPENCLAW_REF ` -PackagingCommit $env:PACKAGING_COMMIT @@ -386,6 +483,7 @@ jobs: if: ${{ needs.authorize-signing.result == 'success' }} needs: - build-msix + - build-msix-bundle - authorize-signing runs-on: windows-latest environment: release-signing @@ -406,6 +504,12 @@ jobs: name: openclaw-gateway-msix-unsigned-arm64 path: artifacts\arm64 + - name: Download unsigned multi-architecture bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-bundle + path: artifacts\bundle + - name: Azure login uses: azure/login@v3 with: @@ -413,7 +517,7 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - - name: Sign MSIX packages + - name: Sign standalone MSIX packages uses: azure/artifact-signing-action@v2 with: endpoint: https://eus.codesigning.azure.net/ @@ -427,6 +531,17 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Sign multi-architecture MSIX bundle + uses: azure/artifact-signing-action@v2 + with: + endpoint: https://eus.codesigning.azure.net/ + signing-account-name: openclaw + certificate-profile-name: openclaw + files: ${{ github.workspace }}\artifacts\bundle\OpenClawGateway.msixbundle + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + - name: Verify signatures and refresh metadata shell: pwsh run: | @@ -460,6 +575,24 @@ jobs: Set-Content -LiteralPath $metadataPath -Encoding utf8 } + $bundle = Get-Item ` + -LiteralPath artifacts\bundle\OpenClawGateway.msixbundle + $bundleSignature = Get-AuthenticodeSignature ` + -LiteralPath $bundle.FullName + if ($bundleSignature.Status -ne 'Valid') { + throw "$($bundle.Name) signature status was $($bundleSignature.Status)." + } + if (-not [string]::Equals( + $bundleSignature.SignerCertificate.Subject, + $expectedSubject, + [StringComparison]::OrdinalIgnoreCase + )) { + throw ( + "$($bundle.Name) signer was unexpected: " + + $bundleSignature.SignerCertificate.Subject + ) + } + - name: Upload signed x64 MSIX uses: actions/upload-artifact@v7 with: @@ -475,3 +608,78 @@ jobs: path: artifacts\arm64\ if-no-files-found: error retention-days: 7 + + - name: Upload signed multi-architecture MSIX bundle + uses: actions/upload-artifact@v7 + with: + name: openclaw-gateway-msix-bundle + path: artifacts\bundle\OpenClawGateway.msixbundle + if-no-files-found: error + retention-days: 7 + + publish-release: + name: Publish signed Gateway MSIX release + if: ${{ needs.sign-msix.result == 'success' }} + needs: + - authorize-signing + - sign-msix + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download signed x64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-x64 + path: signed/x64 + + - name: Download signed ARM64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-arm64 + path: signed/arm64 + + - name: Download signed multi-architecture bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-bundle + path: signed/bundle + + - name: Stage versioned release assets + shell: bash + env: + RELEASE_VERSION: ${{ needs.authorize-signing.outputs.release_version }} + run: | + set -euo pipefail + mkdir release-assets + cp signed/x64/OpenClawGateway-x64.msix \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}-x64.msix" + cp signed/arm64/OpenClawGateway-arm64.msix \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}-arm64.msix" + cp signed/bundle/OpenClawGateway.msixbundle \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}.msixbundle" + + - name: Create permanent GitHub release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.authorize-signing.outputs.release_tag }} + target_commitish: ${{ github.sha }} + name: OpenClaw Gateway MSIX ${{ needs.authorize-signing.outputs.release_tag }} + generate_release_notes: true + prerelease: false + make_latest: true + overwrite_files: false + fail_on_unmatched_files: true + files: | + release-assets/*.msix + release-assets/*.msixbundle + body: | + Packages OpenClaw Gateway `${{ needs.authorize-signing.outputs.release_tag }}` + from [`openclaw/openclaw@${{ inputs.openclaw_ref }}`](https://github.com/openclaw/openclaw/commit/${{ inputs.openclaw_ref }}). + + ### Downloads + - **Recommended:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}.msixbundle` + - **x64:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}-x64.msix` + - **ARM64:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}-arm64.msix` + + The packages are signed by OpenClaw Foundation through Azure Artifact Signing. diff --git a/README.md b/README.md index b874ae82..20205914 100644 --- a/README.md +++ b/README.md @@ -129,12 +129,15 @@ The payload artifact records the requested ref and resolved upstream commit in OpenClaw commit, while embedded `payload-files.json` records every packaged application file's path, length, and SHA-256. -`release-policy.json` records the immutable OpenClaw commit approved for -official signing. Updating that policy requires a reviewed repository change. -Official signing runs only from `main` and verifies the workflow input, both +`release-policy.json` records the immutable OpenClaw commit and payload version +approved for official signing, plus the independent MSIX package version and +release tag. Updating that +policy requires a reviewed repository change. Official signing runs only from +`main` and verifies the workflow input, policy-approved package version, both architecture metadata files, both MSIX hashes, the embedded manifests, and -every file against the embedded application inventory before requesting Azure -credentials. +every file against the embedded application inventory. It also byte-compares +the bundle's embedded packages with those authorized standalone packages before +requesting Azure credentials. ## Build and test @@ -162,7 +165,8 @@ validation. Manual runs support three signing modes: temporary self-signed certificate plus the public `.cer` needed for local installation; - `official` requires the approved immutable commit from - `release-policy.json` and may run only from `main`. + `release-policy.json`, may run only from `main`, and publishes the signed + packages as permanent assets on a GitHub Release named by the policy. Official signing uses the protected `release-signing` environment, Azure OIDC, and the existing OpenClaw Artifact Signing account and certificate profile. @@ -170,6 +174,24 @@ Test-signing private keys are generated only on the temporary GitHub runner and are deleted before artifacts are uploaded. No signing secret or private key is stored in the repository. +Official releases use the independent four-part numeric `packageVersion` and +`releaseTag` from `release-policy.json`. The initial signing proof uses package +version `0.0.0.0` and tag `v0.0.0.0`; a later policy change can establish the +long-term Gateway-to-MSIX version mapping. The workflow creates the tag in this +repository and a GitHub Release with generated release notes. Each release +contains a signed, multi-architecture +`OpenClawGateway-.msixbundle` as the recommended download, plus signed +`OpenClawGateway--x64.msix` and +`OpenClawGateway--arm64.msix` packages for architecture-specific +deployment. The duplicate GitHub Actions artifacts remain short-lived transport +and diagnostic copies. + +An `.msixbundle` is a single installable container for the x64 and ARM64 MSIX +packages; Windows selects the package appropriate for the device. An +`.appinstaller` file is separate update-channel metadata rather than an +alternative package format. This repository does not publish one yet, so GitHub +Release installs do not opt devices into automatic update checks. + ### Official signing setup The `release-signing` GitHub environment must define these environment diff --git a/release-policy.json b/release-policy.json index daef22a3..6bc2f234 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,6 +1,8 @@ { "repository": "https://github.com/openclaw/openclaw", - "releaseTag": "v2026.8.2", + "releaseTag": "v0.0.0.0", + "packageVersion": "0.0.0.0", + "payloadPackageVersion": "2026.8.2", "approvedCommit": "0965053fe6b9341776df147a6934b7485c60b5ca", "publisher": "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" } diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index d690f864..6bab046d 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -166,6 +166,7 @@ if ( $payloadInfo.repository -ne 'https://github.com/openclaw/openclaw' -or $payloadInfo.architecture -ne $Architecture -or $payloadInfo.layout -ne 'expanded-directory' -or + [string]::IsNullOrWhiteSpace([string]$payloadInfo.packageVersion) -or $payloadInfo.requestedRef -isnot [string] -or [string]::IsNullOrWhiteSpace($payloadInfo.requestedRef) -or $payloadInfo.resolvedCommit -notmatch '^[0-9a-fA-F]{40}$' @@ -461,6 +462,7 @@ try { payloadRepository = $payloadInfo.repository payloadRequestedRef = $payloadInfo.requestedRef payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() + payloadPackageVersion = [string]$payloadInfo.packageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count architecture = $Architecture diff --git a/scripts/Build-MSIXBundle.ps1 b/scripts/Build-MSIXBundle.ps1 new file mode 100644 index 00000000..a1a9727a --- /dev/null +++ b/scripts/Build-MSIXBundle.ps1 @@ -0,0 +1,126 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$X64Package, + + [Parameter(Mandatory)] + [string]$Arm64Package, + + [Parameter(Mandatory)] + [string]$PackageVersion, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [string]$MakeAppxPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-MakeAppx { + if (-not [string]::IsNullOrWhiteSpace($MakeAppxPath)) { + return (Resolve-Path -LiteralPath $MakeAppxPath).Path + } + + $command = Get-Command ` + MakeAppx.exe ` + -CommandType Application ` + -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $windowsKits = Join-Path ` + ${env:ProgramFiles(x86)} ` + 'Windows Kits\10\bin' + $candidate = Get-ChildItem ` + -LiteralPath $windowsKits ` + -Filter MakeAppx.exe ` + -File ` + -Recurse ` + -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -eq 'x64' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $candidate) { + throw 'MakeAppx.exe was not found in PATH or the Windows 10 SDK.' + } + + $candidate.FullName +} + +foreach ($package in @($X64Package, $Arm64Package)) { + if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + throw "Required MSIX package was not found: $package" + } + if ([IO.Path]::GetExtension($package) -ine '.msix') { + throw "Bundle input must be an MSIX package: $package" + } +} + +$version = $null +if (-not [version]::TryParse($PackageVersion, [ref]$version)) { + throw "PackageVersion is not a valid four-part version: $PackageVersion" +} +$versionSegments = @($PackageVersion.Split('.')) +if ($versionSegments.Count -ne 4) { + throw 'PackageVersion must contain four numeric components.' +} +foreach ($segment in $versionSegments) { + [uint16]$value = 0 + if (-not [uint16]::TryParse($segment, [ref]$value) -or $value -gt 65534) { + throw "Invalid MSIX bundle version component: $segment" + } +} + +$resolvedX64Package = (Resolve-Path -LiteralPath $X64Package).Path +$resolvedArm64Package = (Resolve-Path -LiteralPath $Arm64Package).Path +if ($resolvedX64Package -eq $resolvedArm64Package) { + throw 'The x64 and ARM64 bundle inputs must be different packages.' +} + +$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath) +if ([IO.Path]::GetExtension($resolvedOutputPath) -ine '.msixbundle') { + throw 'OutputPath must use the .msixbundle extension.' +} +if (Test-Path -LiteralPath $resolvedOutputPath) { + throw "MSIX bundle output already exists: $resolvedOutputPath" +} + +$outputDirectory = Split-Path $resolvedOutputPath -Parent +New-Item -Path $outputDirectory -ItemType Directory -Force | Out-Null +$workRoot = Join-Path ` + ([IO.Path]::GetTempPath()) ` + "openclaw-msixbundle-$([guid]::NewGuid().ToString('N'))" +$bundleInput = Join-Path $workRoot 'packages' +New-Item -Path $bundleInput -ItemType Directory -Force | Out-Null + +try { + Copy-Item ` + -LiteralPath $resolvedX64Package ` + -Destination (Join-Path $bundleInput 'OpenClawGateway-x64.msix') + Copy-Item ` + -LiteralPath $resolvedArm64Package ` + -Destination (Join-Path $bundleInput 'OpenClawGateway-arm64.msix') + + $resolvedMakeAppx = Resolve-MakeAppx + & $resolvedMakeAppx bundle ` + /v ` + /bv $PackageVersion ` + /d $bundleInput ` + /p $resolvedOutputPath + if ($LASTEXITCODE -ne 0) { + throw "MakeAppx.exe failed to build the MSIX bundle. Exit code: $LASTEXITCODE." + } + if (-not (Test-Path -LiteralPath $resolvedOutputPath -PathType Leaf)) { + throw 'MakeAppx.exe completed without producing the requested bundle.' + } + + Write-Host "Unsigned MSIX bundle is ready: $resolvedOutputPath" +} +finally { + if ([IO.Directory]::Exists($workRoot)) { + [IO.Directory]::Delete($workRoot, $true) + } +} diff --git a/scripts/Get-WorkflowPackageVersion.ps1 b/scripts/Get-WorkflowPackageVersion.ps1 index 11376077..9fb31fa4 100644 --- a/scripts/Get-WorkflowPackageVersion.ps1 +++ b/scripts/Get-WorkflowPackageVersion.ps1 @@ -4,7 +4,9 @@ param( [long]$RunNumber, [Parameter(Mandatory)] - [long]$RunAttempt + [long]$RunAttempt, + + [string]$ReleaseVersion ) Set-StrictMode -Version Latest @@ -13,6 +15,36 @@ $ErrorActionPreference = 'Stop' $maximumComponent = 65534L $componentBase = $maximumComponent + 1L +if (-not [string]::IsNullOrWhiteSpace($ReleaseVersion)) { + $releaseMatch = [regex]::Match( + $ReleaseVersion.Trim(), + '^(?\d+)\.(?\d+)\.(?\d+)\.(?\d+)$' + ) + if (-not $releaseMatch.Success) { + throw ( + "ReleaseVersion '$ReleaseVersion' must contain exactly four " + + 'numeric components, such as 0.0.0.0.' + ) + } + + $components = @( + [long]::Parse($releaseMatch.Groups['major'].Value), + [long]::Parse($releaseMatch.Groups['minor'].Value), + [long]::Parse($releaseMatch.Groups['build'].Value), + [long]::Parse($releaseMatch.Groups['revision'].Value) + ) + foreach ($component in $components) { + if ($component -gt $maximumComponent) { + throw ( + "ReleaseVersion '$ReleaseVersion' contains a component greater than " + + "$maximumComponent." + ) + } + } + + return ($components -join '.') +} + if ($RunNumber -lt 1) { throw 'RunNumber must be greater than zero.' } diff --git a/scripts/Test-Build-MSIXBundle.Tests.ps1 b/scripts/Test-Build-MSIXBundle.Tests.ps1 new file mode 100644 index 00000000..4cc51698 --- /dev/null +++ b/scripts/Test-Build-MSIXBundle.Tests.ps1 @@ -0,0 +1,110 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Build-MSIXBundle.ps1' +$testRoot = Join-Path $env:TEMP ` + "openclaw-bundle-tests-$([guid]::NewGuid().ToString('N'))" +New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + +function Assert-Fails { + param( + [Parameter(Mandatory)] + [scriptblock]$Action, + + [Parameter(Mandatory)] + [string]$MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "Expected failure matching '$MessagePattern'; received: " + + $_.Exception.Message + ) + } + return + } + + throw "Expected failure matching '$MessagePattern', but the action succeeded." +} + +try { + $x64Package = Join-Path $testRoot 'x64.msix' + $arm64Package = Join-Path $testRoot 'arm64.msix' + Set-Content -LiteralPath $x64Package -Value 'x64' -Encoding ascii + Set-Content -LiteralPath $arm64Package -Value 'arm64' -Encoding ascii + + $fakeMakeAppx = Join-Path $testRoot 'MakeAppx.cmd' + $makeAppxArguments = Join-Path $testRoot 'makeappx-arguments.txt' + $env:OPENCLAW_BUNDLE_TEST_ARGUMENTS = $makeAppxArguments + @' +@echo off +echo %* > "%OPENCLAW_BUNDLE_TEST_ARGUMENTS%" +set output= +:parse +if "%~1"=="" goto done +if /I "%~1"=="/p" ( + set output=%~2 + shift +) +shift +goto parse +:done +if "%output%"=="" exit /b 2 +type nul > "%output%" +'@ | Set-Content -LiteralPath $fakeMakeAppx -Encoding ascii + + $bundle = Join-Path $testRoot 'OpenClawGateway.msixbundle' + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $arm64Package ` + -PackageVersion '2026.9.4.0' ` + -OutputPath $bundle ` + -MakeAppxPath $fakeMakeAppx + if (-not (Test-Path -LiteralPath $bundle -PathType Leaf)) { + throw 'The bundle builder did not preserve the MakeAppx output.' + } + $arguments = Get-Content -LiteralPath $makeAppxArguments -Raw + if ($arguments -notmatch '/bv 2026\.9\.4\.0') { + throw "MakeAppx did not receive the expected bundle version: $arguments" + } + + Assert-Fails -MessagePattern 'must be different' -Action { + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $x64Package ` + -PackageVersion '2026.9.4.0' ` + -OutputPath (Join-Path $testRoot 'duplicate.msixbundle') ` + -MakeAppxPath $fakeMakeAppx + } + Assert-Fails -MessagePattern 'four numeric components' -Action { + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $arm64Package ` + -PackageVersion '2026.9.4' ` + -OutputPath (Join-Path $testRoot 'bad-version.msixbundle') ` + -MakeAppxPath $fakeMakeAppx + } + Assert-Fails -MessagePattern 'already exists' -Action { + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $arm64Package ` + -PackageVersion '2026.9.4.0' ` + -OutputPath $bundle ` + -MakeAppxPath $fakeMakeAppx + } + + Write-Host 'MSIX bundle build tests passed.' +} +finally { + Remove-Item Env:OPENCLAW_BUNDLE_TEST_ARGUMENTS -ErrorAction SilentlyContinue + if ([IO.Directory]::Exists($testRoot)) { + [IO.Directory]::Delete($testRoot, $true) + } +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index e31b3e01..4a8f09f2 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -8,6 +8,13 @@ $repositoryRoot = Split-Path $PSScriptRoot -Parent $policyPath = Join-Path $repositoryRoot 'release-policy.json' $policy = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json $approvedCommit = [string]$policy.approvedCommit +$approvedPackageVersion = & ( + Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1' +) ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion ([string]$policy.packageVersion) +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $packagingCommit = '1111111111111111111111111111111111111111' $testRoot = Join-Path $env:TEMP ( "openclaw-signing-policy-$([guid]::NewGuid().ToString('N'))" @@ -24,6 +31,8 @@ function New-TestArtifact { [string]$PayloadCommit = $approvedCommit, + [string]$PayloadPackageVersion = $approvedPayloadVersion, + [bool]$SourceTreeDirty = $false, [bool]$IncludeBundledNode = $false, @@ -110,7 +119,7 @@ function New-TestArtifact { "@ | Set-Content ` @@ -141,13 +150,14 @@ function New-TestArtifact { payloadRepository = $policy.repository payloadRequestedRef = $PayloadCommit payloadResolvedCommit = $PayloadCommit + payloadPackageVersion = $PayloadPackageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count architecture = $Architecture archive = $msixName sha256 = $msixHash signed = $false - packageVersion = '0.1.1.0' + packageVersion = $approvedPackageVersion publisher = $policy.publisher } | ConvertTo-Json | @@ -161,16 +171,86 @@ function Invoke-PolicyValidation { [Parameter(Mandatory)] [string]$Root, - [string]$RequestedRef = $approvedCommit + [string]$RequestedRef = $approvedCommit, + + [switch]$PreserveBundle ) + if (-not $PreserveBundle) { + New-TestBundle -Root $Root + } + & (Join-Path $PSScriptRoot 'Test-SigningInputs.ps1') ` -ArtifactsDirectory $Root ` -PolicyPath $policyPath ` + -BundlePath (Join-Path $Root 'bundle\OpenClawGateway.msixbundle') ` -RequestedRef $RequestedRef ` -PackagingCommit $packagingCommit } +function New-TestBundle { + param( + [Parameter(Mandatory)] + [string]$Root, + + [string]$X64Package = (Join-Path ` + $Root ` + 'x64\OpenClawGateway-x64.msix') + ) + + $bundleDirectory = Join-Path $Root 'bundle' + $bundleStaging = Join-Path $Root '.bundle-package' + Remove-Item ` + -LiteralPath $bundleDirectory, $bundleStaging ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + $bundleMetadata = Join-Path $bundleStaging 'AppxMetadata' + New-Item ` + -Path $bundleDirectory, $bundleMetadata ` + -ItemType Directory ` + -Force | + Out-Null + + Copy-Item ` + -LiteralPath $X64Package ` + -Destination (Join-Path $bundleStaging 'OpenClawGateway-x64.msix') + Copy-Item ` + -LiteralPath (Join-Path ` + $Root ` + 'arm64\OpenClawGateway-arm64.msix') ` + -Destination (Join-Path $bundleStaging 'OpenClawGateway-arm64.msix') + + @" + + + + + + + + +"@ | Set-Content ` + -LiteralPath (Join-Path ` + $bundleMetadata ` + 'AppxBundleManifest.xml') ` + -Encoding utf8 + + [IO.Compression.ZipFile]::CreateFromDirectory( + $bundleStaging, + (Join-Path $bundleDirectory 'OpenClawGateway.msixbundle') + ) + Remove-Item -LiteralPath $bundleStaging -Recurse -Force +} + function Assert-Fails { param( [Parameter(Mandatory)] @@ -275,6 +355,19 @@ try { Invoke-PolicyValidation -Root $testRoot } + Remove-Item -LiteralPath $testRoot -Recurse -Force + New-Item -Path $testRoot -ItemType Directory | Out-Null + New-TestArtifact ` + -Root $testRoot ` + -Architecture x64 ` + -PayloadPackageVersion '2026.9.4' + New-TestArtifact -Root $testRoot -Architecture arm64 + Assert-Fails ` + -MessagePattern 'metadata is not eligible' ` + -Action { + Invoke-PolicyValidation -Root $testRoot + } + Remove-Item -LiteralPath $testRoot -Recurse -Force New-Item -Path $testRoot -ItemType Directory | Out-Null New-TestArtifact -Root $testRoot -Architecture x64 @@ -452,6 +545,21 @@ try { Invoke-PolicyValidation -Root $testRoot } + Reset-TestArtifacts + $substitutedX64 = Join-Path $testRoot 'substituted-x64.msix' + Copy-Item ` + -LiteralPath (Join-Path ` + $testRoot ` + 'x64\OpenClawGateway-x64.msix') ` + -Destination $substitutedX64 + Add-Content -LiteralPath $substitutedX64 -Value 'substituted' + New-TestBundle -Root $testRoot -X64Package $substitutedX64 + Assert-Fails ` + -MessagePattern 'does not match the authorized standalone package' ` + -Action { + Invoke-PolicyValidation -Root $testRoot -PreserveBundle + } + Write-Host 'Gateway MSIX signing policy tests passed.' } finally { diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 9b9cd52a..3ca2f047 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -6,6 +6,9 @@ param( [Parameter(Mandatory)] [string]$PolicyPath, + [Parameter(Mandatory)] + [string]$BundlePath, + [Parameter(Mandatory)] [string]$RequestedRef, @@ -117,12 +120,23 @@ $policy = Get-Content -LiteralPath $resolvedPolicyPath -Raw | if ( $policy.repository -ne 'https://github.com/openclaw/openclaw' -or + [string]::IsNullOrWhiteSpace([string]$policy.releaseTag) -or + $policy.packageVersion -notmatch '^\d+\.\d+\.\d+\.\d+$' -or + $policy.releaseTag -ne "v$($policy.packageVersion)" -or + [string]::IsNullOrWhiteSpace([string]$policy.payloadPackageVersion) -or $policy.approvedCommit -notmatch '^[0-9a-fA-F]{40}$' -or [string]::IsNullOrWhiteSpace([string]$policy.publisher) ) { throw 'The Gateway MSIX release policy is invalid.' } +$approvedPackageVersion = & ( + Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1' +) ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion ([string]$policy.packageVersion) +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $approvedCommit = ([string]$policy.approvedCommit).ToLowerInvariant() $normalizedRequestedRef = $RequestedRef.Trim().ToLowerInvariant() if ( @@ -137,6 +151,7 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null +$expectedPackages = @{} foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture $metadataPath = Join-Path $directory 'msix-metadata.json' @@ -163,6 +178,7 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.payloadRepository -ne $policy.repository -or $metadata.payloadRequestedRef -ine $approvedCommit -or $metadata.payloadResolvedCommit -ine $approvedCommit -or + $metadata.payloadPackageVersion -ne $approvedPayloadVersion -or $metadata.payloadLayout -ne 'immutable-package' -or $metadata.payloadFileCount -isnot [int64] -or $metadata.payloadFileCount -le 0 -or @@ -170,6 +186,7 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or $metadata.signed -ne $false -or + $metadata.packageVersion -ne $approvedPackageVersion -or $metadata.publisher -ne $policy.publisher ) { throw "The $architecture MSIX metadata is not eligible for signing." @@ -188,6 +205,11 @@ foreach ($architecture in @('x64', 'arm64')) { if ($actualMsixHash -ne ([string]$metadata.sha256).ToLowerInvariant()) { throw "The $architecture MSIX hash does not match its metadata." } + $expectedPackages[$architecture] = @{ + Name = $msix.Name + Path = $msix.FullName + Sha256 = $actualMsixHash + } $packageArchive = [IO.Compression.ZipFile]::OpenRead($msix.FullName) try { @@ -321,6 +343,83 @@ foreach ($architecture in @('x64', 'arm64')) { } } +$resolvedBundlePath = (Resolve-Path -LiteralPath $BundlePath).Path +if ([IO.Path]::GetExtension($resolvedBundlePath) -ine '.msixbundle') { + throw 'The official signing bundle must use the .msixbundle extension.' +} + +$bundleArchive = [IO.Compression.ZipFile]::OpenRead($resolvedBundlePath) +try { + $bundleEntries = New-PackageEntryIndex -Archive $bundleArchive + [xml]$bundleManifest = Read-ZipEntryText ` + -EntriesByPath $bundleEntries ` + -Path 'AppxMetadata/AppxBundleManifest.xml' + $bundleIdentity = $bundleManifest.SelectSingleNode( + "/*[local-name()='Bundle']/*[local-name()='Identity']" + ) + if ( + $null -eq $bundleIdentity -or + $bundleIdentity.Name -ne 'OpenClaw.Gateway' -or + $bundleIdentity.Publisher -ne $policy.publisher -or + $bundleIdentity.Version -ne $expectedPackageVersion + ) { + throw 'The MSIX bundle manifest identity is unexpected.' + } + + $bundlePackages = @( + $bundleManifest.SelectNodes( + "/*[local-name()='Bundle']/*[local-name()='Packages']/*[local-name()='Package']" + ) + ) + if ($bundlePackages.Count -ne 2) { + throw 'The MSIX bundle must contain exactly two application packages.' + } + + $seenArchitectures = + [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + foreach ($bundlePackage in $bundlePackages) { + $architecture = [string]$bundlePackage.Architecture + if ( + -not $expectedPackages.ContainsKey($architecture) -or + -not $seenArchitectures.Add($architecture) -or + $bundlePackage.Type -ne 'application' -or + $bundlePackage.Version -ne $expectedPackageVersion + ) { + throw 'The MSIX bundle package manifest is unexpected.' + } + + $expectedPackage = $expectedPackages[$architecture] + $fileName = [string]$bundlePackage.FileName + if ($fileName -ne $expectedPackage.Name) { + throw "The bundled $architecture MSIX filename is unexpected." + } + + $bundleEntry = Get-PackageEntry ` + -EntriesByPath $bundleEntries ` + -Path $fileName + $bundledHash = Get-PackageEntrySha256 -Entry $bundleEntry + if ($bundledHash -ne $expectedPackage.Sha256) { + throw ( + "The bundled $architecture MSIX does not match the " + + 'authorized standalone package.' + ) + } + } + + $embeddedMsixEntries = @( + $bundleEntries.Keys | + Where-Object { [IO.Path]::GetExtension($_) -ieq '.msix' } + ) + if ($embeddedMsixEntries.Count -ne 2) { + throw 'The MSIX bundle contains an unexpected package file set.' + } +} +finally { + $bundleArchive.Dispose() +} + Write-Host ( "Authorized official signing for OpenClaw commit $approvedCommit " + "and Gateway MSIX version $expectedPackageVersion." diff --git a/scripts/Test-WorkflowPackageVersion.Tests.ps1 b/scripts/Test-WorkflowPackageVersion.Tests.ps1 index 36091276..d9393762 100644 --- a/scripts/Test-WorkflowPackageVersion.Tests.ps1 +++ b/scripts/Test-WorkflowPackageVersion.Tests.ps1 @@ -54,6 +54,24 @@ function Assert-Fails { throw "Expected failure matching '$MessagePattern', but the action succeeded." } +function Assert-ReleaseVersion { + param( + [Parameter(Mandatory)] + [string]$ReleaseVersion, + + [Parameter(Mandatory)] + [string]$Expected + ) + + $actual = & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion $ReleaseVersion + if ($actual -ne $Expected) { + throw "Expected $ReleaseVersion to produce $Expected; received $actual." + } +} + Assert-Version -RunNumber 1 -RunAttempt 1 -Expected '0.1.1.1' Assert-Version -RunNumber 65534 -RunAttempt 1 -Expected '0.1.65534.1' Assert-Version -RunNumber 65535 -RunAttempt 1 -Expected '0.2.0.1' @@ -63,6 +81,22 @@ Assert-Version -RunNumber 131069 -RunAttempt 1 -Expected '0.2.65534.1' Assert-Version -RunNumber 131070 -RunAttempt 1 -Expected '0.3.0.1' Assert-Version -RunNumber 1 -RunAttempt 65534 -Expected '0.1.1.65534' +Assert-ReleaseVersion -ReleaseVersion '0.0.0.0' -Expected '0.0.0.0' +Assert-ReleaseVersion -ReleaseVersion '2026.9.4.0' -Expected '2026.9.4.0' + +Assert-Fails -MessagePattern 'exactly four numeric components' -Action { + & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion '2026.9.1-beta.1' +} +Assert-Fails -MessagePattern 'greater than 65534' -Action { + & $scriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion '2026.9.4.65535' +} + $maximumRunNumber = (65534L * 65535L) - 1L Assert-Version ` -RunNumber $maximumRunNumber ` diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index b47146f9..d1f553d4 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -18,10 +18,24 @@ $requiredFragments = @( 'tenant-id: ${{ vars.AZURE_TENANT_ID }}' 'subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}' 'uses: azure/artifact-signing-action@v2' + 'name: Compose unsigned multi-architecture MSIX bundle' + 'name: Upload unsigned multi-architecture MSIX bundle' + '-BundlePath artifacts\bundle\OpenClawGateway.msixbundle' 'files-folder-recurse: true' + 'files: ${{ github.workspace }}\artifacts\bundle\OpenClawGateway.msixbundle' + 'name: Upload signed multi-architecture MSIX bundle' 'endpoint: https://eus.codesigning.azure.net/' 'signing-account-name: openclaw' 'certificate-profile-name: openclaw' + 'name: Publish signed Gateway MSIX release' + 'contents: write' + 'uses: softprops/action-gh-release@v3' + 'tag_name: ${{ needs.authorize-signing.outputs.release_tag }}' + 'target_commitish: ${{ github.sha }}' + 'generate_release_notes: true' + 'overwrite_files: false' + 'fail_on_unmatched_files: true' + 'release-assets/*.msixbundle' ) foreach ($fragment in $requiredFragments) { From 735bd4e27ff5efbbce88296ebc2e68587d266cc8 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sat, 12 Sep 2026 01:40:34 -0700 Subject: [PATCH 16/76] fix(ci): authorize MakeAppx zero-version bundles (#34) --- README.md | 6 ++++++ scripts/Build-MSIXBundle.ps1 | 9 ++++++++- scripts/Test-Build-MSIXBundle.Tests.ps1 | 15 +++++++++++++++ scripts/Test-SigningInputs.Tests.ps1 | 6 ++++-- scripts/Test-SigningInputs.ps1 | 24 ++++++++++++++++++++++-- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 20205914..d5645c23 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,12 @@ contains a signed, multi-architecture deployment. The duplicate GitHub Actions artifacts remain short-lived transport and diagnostic copies. +For the all-zero proof only, MakeAppx assigns the outer bundle identity its +date/time-based version because it does not preserve `0.0.0.0` as a bundle +version. The two embedded architecture packages retain identity version +`0.0.0.0`; signing authorization verifies those versions and byte-compares both +embedded packages with the approved standalone inputs. + An `.msixbundle` is a single installable container for the x64 and ARM64 MSIX packages; Windows selects the package appropriate for the device. An `.appinstaller` file is separate update-channel metadata rather than an diff --git a/scripts/Build-MSIXBundle.ps1 b/scripts/Build-MSIXBundle.ps1 index a1a9727a..96d06f76 100644 --- a/scripts/Build-MSIXBundle.ps1 +++ b/scripts/Build-MSIXBundle.ps1 @@ -105,9 +105,16 @@ try { -Destination (Join-Path $bundleInput 'OpenClawGateway-arm64.msix') $resolvedMakeAppx = Resolve-MakeAppx + $bundleVersionArguments = @('/bv', $PackageVersion) + if ($PackageVersion -eq '0.0.0.0') { + # MakeAppx does not preserve an all-zero bundle identity version. Let + # it assign its date/time-based version while the embedded + # architecture packages retain the requested 0.0.0.0 identity. + $bundleVersionArguments = @() + } & $resolvedMakeAppx bundle ` /v ` - /bv $PackageVersion ` + @bundleVersionArguments ` /d $bundleInput ` /p $resolvedOutputPath if ($LASTEXITCODE -ne 0) { diff --git a/scripts/Test-Build-MSIXBundle.Tests.ps1 b/scripts/Test-Build-MSIXBundle.Tests.ps1 index 4cc51698..70c390ee 100644 --- a/scripts/Test-Build-MSIXBundle.Tests.ps1 +++ b/scripts/Test-Build-MSIXBundle.Tests.ps1 @@ -75,6 +75,21 @@ type nul > "%output%" throw "MakeAppx did not receive the expected bundle version: $arguments" } + $zeroBundle = Join-Path $testRoot 'OpenClawGateway-zero.msixbundle' + & $scriptPath ` + -X64Package $x64Package ` + -Arm64Package $arm64Package ` + -PackageVersion '0.0.0.0' ` + -OutputPath $zeroBundle ` + -MakeAppxPath $fakeMakeAppx + $arguments = Get-Content -LiteralPath $makeAppxArguments -Raw + if ($arguments -match '/bv') { + throw "MakeAppx received an invalid all-zero bundle version: $arguments" + } + if (-not (Test-Path -LiteralPath $zeroBundle -PathType Leaf)) { + throw 'The zero-version bundle builder did not preserve MakeAppx output.' + } + Assert-Fails -MessagePattern 'must be different' -Action { & $scriptPath ` -X64Package $x64Package ` diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 4a8f09f2..15d58e50 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -195,7 +195,9 @@ function New-TestBundle { [string]$X64Package = (Join-Path ` $Root ` - 'x64\OpenClawGateway-x64.msix') + 'x64\OpenClawGateway-x64.msix'), + + [string]$BundleVersion = '2026.912.815.0' ) $bundleDirectory = Join-Path $Root 'bundle' @@ -226,7 +228,7 @@ function New-TestBundle { + Version="$BundleVersion" /> \d+)\.(?\d+)\.(?\d+)\.(?\d+)$' + ) + $bundleVersionIsValid = $bundleVersionMatch.Success + if ($bundleVersionIsValid) { + foreach ($groupName in @('major', 'minor', 'build', 'revision')) { + if ([long]::Parse($bundleVersionMatch.Groups[$groupName].Value) -gt 65534) { + $bundleVersionIsValid = $false + break + } + } + } if ( $null -eq $bundleIdentity -or $bundleIdentity.Name -ne 'OpenClaw.Gateway' -or $bundleIdentity.Publisher -ne $policy.publisher -or - $bundleIdentity.Version -ne $expectedPackageVersion + -not $bundleVersionIsValid ) { throw 'The MSIX bundle manifest identity is unexpected.' } @@ -422,5 +441,6 @@ finally { Write-Host ( "Authorized official signing for OpenClaw commit $approvedCommit " + - "and Gateway MSIX version $expectedPackageVersion." + "and Gateway MSIX version $expectedPackageVersion " + + "(bundle version $bundleVersion)." ) From df917ac47314ecd964f2cae48cf6c3572a1952d3 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Mon, 14 Sep 2026 11:33:34 -0700 Subject: [PATCH 17/76] docs: make PR descriptions plain-language first (#35) Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com> --- .github/pull_request_template.md | 36 ++++++++++++++++---------------- CONTRIBUTING.md | 5 +++-- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b79ac3e6..08e9180f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -13,10 +13,6 @@ For fixes, describe the user-visible symptom and trigger: fix: launcher exits without a diagnostic when Node is missing Avoid implementation details such as: fix: add null check to payload resolver ---> - -
-Additional instructions **MUST:** Keep **Allow edits from maintainers** enabled for this PR so maintainers can help update the branch when needed. @@ -24,37 +20,39 @@ maintainers can help update the branch when needed. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the validation commands this repository expects and for the packaging, signing, and NativeAOT rules that apply to launcher changes. - -
+--> ## What Problem This Solves -## Why This Change Was Made +## User Impact -## User Impact +## Why This Change Was Made ## Evidence @@ -73,4 +71,6 @@ Include CI links, artifact links, redacted logs, or terminal output. State which lanes you did not run and why. Reviewers will inspect the code, tests, and CI; use this section to make validation easy to understand rather than to restate the diff. +Summarize what was checked and the result; note meaningful gaps. Link long output +or put it in optional
, keeping the useful evidence summary visible. --> diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6549aca..52b218bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,8 +203,9 @@ Use the pull request template. Title the PR `improve`, `refactor`, `docs`, or `chore`. Describe the outcome rather than the mechanism. -Every PR body should explain the problem it solves, why the change was made, -the user or contributor impact, and the evidence that it works. Name the exact +Lead with the problem and user or contributor impact in short, plain-language +sentences, followed by a brief explanation and useful evidence. Keep technical +details optional, but important risks and required actions visible. Name the exact head SHA your evidence came from and state which validation lanes you did not run. From ba1a1e32bdf0b61f0332b774238c8b2274b97f40 Mon Sep 17 00:00:00 2001 From: "Anna Koenig (Dingler)" Date: Mon, 14 Sep 2026 11:40:38 -0700 Subject: [PATCH 18/76] fix: keep dashboard and gateway builds aligned Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 9 ++++-- scripts/Test-WorkflowBuildConfiguration.ps1 | 32 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 scripts/Test-WorkflowBuildConfiguration.ps1 diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 3b6e1760..a7941866 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -89,6 +89,11 @@ jobs: run: > .\scripts\Test-WorkflowSigningConfiguration.ps1 + - name: Test build workflow configuration + shell: pwsh + run: > + .\scripts\Test-WorkflowBuildConfiguration.ps1 + - name: Test workflow package version shell: pwsh run: > @@ -138,9 +143,7 @@ jobs: working-directory: openclaw-source env: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" - run: | - pnpm build - pnpm ui:build + run: pnpm build - name: Pack npm package id: source diff --git a/scripts/Test-WorkflowBuildConfiguration.ps1 b/scripts/Test-WorkflowBuildConfiguration.ps1 new file mode 100644 index 00000000..c0c0a03e --- /dev/null +++ b/scripts/Test-WorkflowBuildConfiguration.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$workflowPath = Join-Path ` + $repositoryRoot ` + '.github\workflows\gateway-msix.yml' +$workflow = Get-Content -LiteralPath $workflowPath -Raw + +$requiredFragments = @( + 'name: Build OpenClaw' + 'OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1"' + 'run: pnpm build' +) + +foreach ($fragment in $requiredFragments) { + if (-not $workflow.Contains($fragment, [StringComparison]::Ordinal)) { + throw "Build workflow is missing required configuration: $fragment" + } +} + +if ($workflow.Contains('pnpm ui:build', [StringComparison]::Ordinal)) { + throw ( + 'Build workflow must not rebuild the Control UI after pnpm build; ' + + 'a second build creates a different release build identity.' + ) +} + +Write-Host 'Gateway MSIX build workflow configuration passed.' From bf02d020add675eedc8cb77c6f0624f127c2e354 Mon Sep 17 00:00:00 2001 From: "Anna Koenig (Dingler)" Date: Mon, 14 Sep 2026 13:23:44 -0700 Subject: [PATCH 19/76] test: validate emitted dashboard build identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 16 ++- scripts/Test-OpenClawBuildIdentity.Tests.ps1 | 111 +++++++++++++++++++ scripts/Test-OpenClawBuildIdentity.ps1 | 73 ++++++++++++ scripts/Test-WorkflowBuildConfiguration.ps1 | 32 ------ 4 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 scripts/Test-OpenClawBuildIdentity.Tests.ps1 create mode 100644 scripts/Test-OpenClawBuildIdentity.ps1 delete mode 100644 scripts/Test-WorkflowBuildConfiguration.ps1 diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index a7941866..5b9f94f5 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -89,10 +89,10 @@ jobs: run: > .\scripts\Test-WorkflowSigningConfiguration.ps1 - - name: Test build workflow configuration + - name: Test OpenClaw build identity validation shell: pwsh run: > - .\scripts\Test-WorkflowBuildConfiguration.ps1 + .\scripts\Test-OpenClawBuildIdentity.Tests.ps1 - name: Test workflow package version shell: pwsh @@ -116,6 +116,12 @@ jobs: source_sha: ${{ steps.source.outputs.sha }} package_version: ${{ steps.source.outputs.version }} steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + path: packaging-source + persist-credentials: false + - name: Check out OpenClaw source uses: actions/checkout@v7 with: @@ -145,6 +151,12 @@ jobs: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" run: pnpm build + - name: Validate OpenClaw build identities + shell: pwsh + run: > + ./packaging-source/scripts/Test-OpenClawBuildIdentity.ps1 + -OpenClawDirectory ./openclaw-source + - name: Pack npm package id: source working-directory: openclaw-source diff --git a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 new file mode 100644 index 00000000..4b2a28cb --- /dev/null +++ b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 @@ -0,0 +1,111 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Test-OpenClawBuildIdentity.ps1' +$testRoot = Join-Path ` + ([IO.Path]::GetTempPath()) ` + "openclaw-build-identity-$([guid]::NewGuid().ToString('N'))" + +function New-BuildFixture { + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string]$GatewayBuildId, + + [string]$ControlUiBuildId, + + [switch]$OmitControlUiBuildId + ) + + $root = Join-Path $testRoot $Name + $distDirectory = Join-Path $root 'dist' + $controlUiDirectory = Join-Path $distDirectory 'control-ui' + $assetsDirectory = Join-Path $controlUiDirectory 'assets' + New-Item -Path $assetsDirectory -ItemType Directory -Force | Out-Null + + @{ buildId = $GatewayBuildId } | + ConvertTo-Json | + Set-Content (Join-Path $distDirectory 'build-info.json') -Encoding utf8 + + if ($OmitControlUiBuildId) { + 'const CACHE_NAME = "openclaw-control-ui";' | + Set-Content (Join-Path $controlUiDirectory 'sw.js') -Encoding utf8 + } + else { + $encodedBuildId = $ControlUiBuildId | ConvertTo-Json -Compress + "const EMBEDDED_CACHE_VERSION = $encodedBuildId;" | + Set-Content (Join-Path $controlUiDirectory 'sw.js') -Encoding utf8 + } + + $clientBuildId = if ($null -eq $ControlUiBuildId) { + '' + } + else { + $ControlUiBuildId + } + "const buildInfo = { buildId: ""$clientBuildId"" };" | + Set-Content (Join-Path $assetsDirectory 'app.js') -Encoding utf8 + + return $root +} + +function Assert-Fails { + param( + [Parameter(Mandatory)] + [scriptblock]$Action, + + [Parameter(Mandatory)] + [string]$MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "Expected failure matching '$MessagePattern'; received: " + + $_.Exception.Message + ) + } + return + } + + throw "Expected failure matching '$MessagePattern', but the action succeeded." +} + +try { + $matching = New-BuildFixture ` + -Name 'matching' ` + -GatewayBuildId 'release-build-a' ` + -ControlUiBuildId 'release-build-a' + & $scriptPath -OpenClawDirectory $matching + + $missing = New-BuildFixture ` + -Name 'missing' ` + -GatewayBuildId 'release-build-a' ` + -OmitControlUiBuildId + Assert-Fails -MessagePattern 'Control UI build identity is missing' -Action { + & $scriptPath -OpenClawDirectory $missing + } + + $mismatched = New-BuildFixture ` + -Name 'mismatched' ` + -GatewayBuildId 'release-build-a' ` + -ControlUiBuildId 'release-build-b' + Assert-Fails -MessagePattern 'OpenClaw build identity mismatch' -Action { + & $scriptPath -OpenClawDirectory $mismatched + } +} +finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} + +Write-Host 'OpenClaw build identity validation tests passed.' diff --git a/scripts/Test-OpenClawBuildIdentity.ps1 b/scripts/Test-OpenClawBuildIdentity.ps1 new file mode 100644 index 00000000..f67e1f1b --- /dev/null +++ b/scripts/Test-OpenClawBuildIdentity.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$OpenClawDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$distDirectory = Join-Path $OpenClawDirectory 'dist' +$buildInfoPath = Join-Path $distDirectory 'build-info.json' +$controlUiDirectory = Join-Path $distDirectory 'control-ui' +$serviceWorkerPath = Join-Path $controlUiDirectory 'sw.js' +$assetsDirectory = Join-Path $controlUiDirectory 'assets' + +foreach ($requiredPath in @( + $buildInfoPath + $serviceWorkerPath + $assetsDirectory +)) { + if (-not (Test-Path -LiteralPath $requiredPath)) { + throw "OpenClaw build identity validation is missing: $requiredPath" + } +} + +$buildInfo = Get-Content -LiteralPath $buildInfoPath -Raw | + ConvertFrom-Json +$gatewayBuildId = [string]$buildInfo.buildId +if ([string]::IsNullOrWhiteSpace($gatewayBuildId)) { + throw "Gateway build identity is missing from '$buildInfoPath'." +} + +$serviceWorker = Get-Content -LiteralPath $serviceWorkerPath -Raw +$serviceWorkerMatch = [regex]::Match( + $serviceWorker, + '(?m)^\s*const\s+EMBEDDED_CACHE_VERSION\s*=\s*(?"[^"\r\n]+")\s*;') +if (-not $serviceWorkerMatch.Success) { + throw "Control UI build identity is missing from '$serviceWorkerPath'." +} + +$controlUiBuildId = [string]( + $serviceWorkerMatch.Groups['value'].Value | + ConvertFrom-Json +) +if ([string]::IsNullOrWhiteSpace($controlUiBuildId)) { + throw "Control UI build identity is empty in '$serviceWorkerPath'." +} +if (-not [string]::Equals( + $gatewayBuildId, + $controlUiBuildId, + [StringComparison]::Ordinal)) { + throw ( + "OpenClaw build identity mismatch: Gateway '$gatewayBuildId'; " + + "Control UI '$controlUiBuildId'." + ) +} + +$clientBundleContainsBuildId = @( + Get-ChildItem -LiteralPath $assetsDirectory -Filter '*.js' -File -Recurse | + Where-Object { + (Get-Content -LiteralPath $_.FullName -Raw).Contains( + $gatewayBuildId, + [StringComparison]::Ordinal) + } +).Count -gt 0 +if (-not $clientBundleContainsBuildId) { + throw ( + "Control UI client bundle does not contain Gateway build identity " + + "'$gatewayBuildId'." + ) +} + +Write-Host "OpenClaw Gateway and Control UI build identity match: $gatewayBuildId" diff --git a/scripts/Test-WorkflowBuildConfiguration.ps1 b/scripts/Test-WorkflowBuildConfiguration.ps1 deleted file mode 100644 index c0c0a03e..00000000 --- a/scripts/Test-WorkflowBuildConfiguration.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -[CmdletBinding()] -param() - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path $PSScriptRoot -Parent -$workflowPath = Join-Path ` - $repositoryRoot ` - '.github\workflows\gateway-msix.yml' -$workflow = Get-Content -LiteralPath $workflowPath -Raw - -$requiredFragments = @( - 'name: Build OpenClaw' - 'OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1"' - 'run: pnpm build' -) - -foreach ($fragment in $requiredFragments) { - if (-not $workflow.Contains($fragment, [StringComparison]::Ordinal)) { - throw "Build workflow is missing required configuration: $fragment" - } -} - -if ($workflow.Contains('pnpm ui:build', [StringComparison]::Ordinal)) { - throw ( - 'Build workflow must not rebuild the Control UI after pnpm build; ' + - 'a second build creates a different release build identity.' - ) -} - -Write-Host 'Gateway MSIX build workflow configuration passed.' From e4fee8a3e95acf4c5442a60a8676e8d25bcb5da5 Mon Sep 17 00:00:00 2001 From: "Anna Koenig (Dingler)" Date: Mon, 14 Sep 2026 13:39:26 -0700 Subject: [PATCH 20/76] docs: clarify build identity validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/Test-OpenClawBuildIdentity.Tests.ps1 | 6 ++++++ scripts/Test-OpenClawBuildIdentity.ps1 | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 index 4b2a28cb..ede7de61 100644 --- a/scripts/Test-OpenClawBuildIdentity.Tests.ps1 +++ b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 @@ -80,12 +80,16 @@ function Assert-Fails { } try { + # A single OpenClaw build must emit one identity across all runtime and UI + # artifacts consumed by the packaged dashboard handshake. $matching = New-BuildFixture ` -Name 'matching' ` -GatewayBuildId 'release-build-a' ` -ControlUiBuildId 'release-build-a' & $scriptPath -OpenClawDirectory $matching + # Missing UI identity is unsafe because packaging could not prove which + # dashboard build will connect to the Gateway. $missing = New-BuildFixture ` -Name 'missing' ` -GatewayBuildId 'release-build-a' ` @@ -94,6 +98,8 @@ try { & $scriptPath -OpenClawDirectory $missing } + # A separately rebuilt dashboard must fail even when both artifacts are + # otherwise complete, reproducing the release-only regression. $mismatched = New-BuildFixture ` -Name 'mismatched' ` -GatewayBuildId 'release-build-a' ` diff --git a/scripts/Test-OpenClawBuildIdentity.ps1 b/scripts/Test-OpenClawBuildIdentity.ps1 index f67e1f1b..db56d5f5 100644 --- a/scripts/Test-OpenClawBuildIdentity.ps1 +++ b/scripts/Test-OpenClawBuildIdentity.ps1 @@ -7,6 +7,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# A release is safe to package only when the Gateway and its same-origin +# dashboard were emitted by the same OpenClaw build lifecycle. $distDirectory = Join-Path $OpenClawDirectory 'dist' $buildInfoPath = Join-Path $distDirectory 'build-info.json' $controlUiDirectory = Join-Path $distDirectory 'control-ui' @@ -30,6 +32,8 @@ if ([string]::IsNullOrWhiteSpace($gatewayBuildId)) { throw "Gateway build identity is missing from '$buildInfoPath'." } +# Vite writes the dashboard identity into the service worker so stale browser +# assets can retire themselves when a new Gateway build is installed. $serviceWorker = Get-Content -LiteralPath $serviceWorkerPath -Raw $serviceWorkerMatch = [regex]::Match( $serviceWorker, @@ -55,6 +59,8 @@ if (-not [string]::Equals( ) } +# The browser sends its embedded identity during the Gateway handshake. Check +# the JavaScript payload as well as the service worker before packing the app. $clientBundleContainsBuildId = @( Get-ChildItem -LiteralPath $assetsDirectory -Filter '*.js' -File -Recurse | Where-Object { From 9a8cd4af139513c21d290a01a8a1f2be19b602bc Mon Sep 17 00:00:00 2001 From: Anna Koenig <98650930+anna-dingler@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:01:41 -0700 Subject: [PATCH 21/76] fix: preserve build identity validator in pipeline (#39) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index f1b169cd..c0e563c3 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -122,9 +122,19 @@ jobs: - name: Check out repository uses: actions/checkout@v7 with: - path: packaging-source persist-credentials: false + # The OpenClaw checkout below must own GITHUB_WORKSPACE because its + # composite setup action resolves workspace files from that root. Keep + # the packaging-owned validator outside the workspace before replacing + # this checkout with the OpenClaw source tree. + - name: Stage build identity validator + shell: pwsh + run: > + Copy-Item + ./scripts/Test-OpenClawBuildIdentity.ps1 + $env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1 + - name: Check out OpenClaw source uses: actions/checkout@v7 with: @@ -150,8 +160,8 @@ jobs: - name: Validate OpenClaw build identities shell: pwsh run: > - ./packaging-source/scripts/Test-OpenClawBuildIdentity.ps1 - -OpenClawDirectory ./openclaw-source + & "$env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1" + -OpenClawDirectory $env:GITHUB_WORKSPACE - name: Pack npm package id: source From bfc266429dc60d49486bcaad1e05be0552593b3d Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 10 Sep 2026 18:04:34 -0700 Subject: [PATCH 22/76] Add Gateway isolation status plugin Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 10 + README.md | 39 ++- plugins/gateway-isolation/index.js | 265 ++++++++++++++++++ plugins/gateway-isolation/index.test.js | 142 ++++++++++ .../gateway-isolation/openclaw.plugin.json | 17 ++ plugins/gateway-isolation/package.json | 23 ++ scripts/Build-Payload.ps1 | 96 +++++++ scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 161 +++++++++++ src/OpenClaw.Launcher/GatewayIsolationMode.cs | 18 ++ src/OpenClaw.Launcher/GatewayLauncher.cs | 10 +- src/OpenClaw.Launcher/Program.cs | 2 + .../GatewayLauncherTests.cs | 24 ++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 11 +- 13 files changed, 802 insertions(+), 16 deletions(-) create mode 100644 plugins/gateway-isolation/index.js create mode 100644 plugins/gateway-isolation/index.test.js create mode 100644 plugins/gateway-isolation/openclaw.plugin.json create mode 100644 plugins/gateway-isolation/package.json create mode 100644 scripts/Test-GatewayIsolationPlugin.Tests.ps1 create mode 100644 src/OpenClaw.Launcher/GatewayIsolationMode.cs diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index c0e563c3..c9e71c94 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -46,6 +46,11 @@ jobs: cache: true cache-dependency-path: Directory.Packages.props + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Restore run: dotnet restore .\OpenClaw.Gateway.MSIX.slnx @@ -111,6 +116,11 @@ jobs: run: > .\scripts\Test-GitHooks.Tests.ps1 + - name: Test Gateway isolation plugin + shell: pwsh + run: > + .\scripts\Test-GatewayIsolationPlugin.Tests.ps1 + build-package: name: Build OpenClaw npm package runs-on: ubuntu-latest diff --git a/README.md b/README.md index 8299cfb2..fb6d8e60 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,18 @@ copy, repair, or otherwise change package files at runtime. Every OpenClaw child process runs with `OPENCLAW_SUPERVISOR_MODE=external`, `OPENCLAW_SERVICE_REPAIR_POLICY=external`, and -`OPENCLAW_NO_AUTO_UPDATE=1`. These declare external lifecycle ownership, -prevent doctor-owned service repair, and disable configured background -auto-updates. The pinned OpenClaw `v2026.8.2` release honors external supervisor -mode by refusing native service mutation and OpenClaw self-update with guidance -to use the external supervisor's workflow. This behavior belongs to upstream -OpenClaw; the launcher does not reserve, reject, or rewrite upstream command -arguments. +`OPENCLAW_NO_AUTO_UPDATE=1`. It also reports the selected Windows Gateway +session mode through the process-stable +`CLAWCTL_GATEWAY_ISOLATION=enabled|disabled` environment variable. The current +interactive-session launch path reports `disabled`; the future isolated-session +launch path will select `enabled` when that session switch is implemented. +These values declare external lifecycle ownership, prevent doctor-owned service +repair, disable configured background auto-updates, and expose diagnostic +isolation status without claiming independent attestation. The pinned OpenClaw +`v2026.8.2` release honors external supervisor mode by refusing native service +mutation and OpenClaw self-update with guidance to use the external supervisor's +workflow. This behavior belongs to upstream OpenClaw; the launcher does not +reserve, reject, or rewrite upstream command arguments. OpenClaw inherits the terminal's working directory; the launcher does not make the read-only application directory the workspace. @@ -122,7 +127,10 @@ both: Changing only the workflow-dispatch default does not change automatic builds. For a one-time override, run **Build OpenClaw Gateway MSIX** manually and provide a tag, branch, or preferably a full 40-character commit SHA in -`openclaw_ref`. +`openclaw_ref`. Payload composition validates that the selected OpenClaw +runtime can discover and load the packaging-owned Gateway Isolation plugin +with its required read-only route shape; incompatible older refs fail instead +of producing a package without status UI. The source build uses that revision's `.github/actions/setup-node-env` action to select Node.js and pnpm. Its resolved Node.js version is recorded in @@ -158,11 +166,16 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. `scripts\Build-MSIX.ps1` downloads -the official Node.js archive matching the payload's recorded build version -and architecture, copies both inputs into package content, rejects Node.js -inside the application payload, creates a per-file inventory, and then creates -an unsigned NativeAOT MSIX. +architecture-specific application tree and provisions the packaging-owned, +enabled-by-default Gateway Isolation plugin into OpenClaw's bundled plugin +directory. The plugin adds a read-only **Gateway Isolation** tab to the Control +group and serves it through an authenticated, sandboxed plugin route. It reads +only the launch-time `CLAWCTL_GATEWAY_ISOLATION` value and registers no mutation +RPC or process control. `scripts\Build-MSIX.ps1` downloads the official Node.js +archive matching the payload's recorded build version and architecture, copies +both inputs into package content, rejects Node.js inside the application +payload, creates a per-file inventory, and then creates an unsigned NativeAOT +MSIX. `scripts\Build-LocalMSIX.ps1` can reuse a successful workflow payload or a local payload directory. `-NodeArchivePath` can supply an already-downloaded archive, but its version and architecture must match the payload metadata. diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js new file mode 100644 index 00000000..7690e828 --- /dev/null +++ b/plugins/gateway-isolation/index.js @@ -0,0 +1,265 @@ +const ISOLATION_ENVIRONMENT_VARIABLE = "CLAWCTL_GATEWAY_ISOLATION"; +const STATUS_PATH = "/plugins/gateway-isolation/status"; + +export function readGatewayIsolationMode(env) { + const value = env[ISOLATION_ENVIRONMENT_VARIABLE]; + return value === "enabled" || value === "disabled" ? value : null; +} + +export function renderGatewayIsolationPage(mode) { + if (mode !== "enabled" && mode !== "disabled") { + throw new TypeError("Gateway isolation mode must be enabled or disabled."); + } + + const enabled = mode === "enabled"; + const status = enabled ? "Enabled" : "Disabled"; + const command = `clawctl gateway-isolation ${enabled ? "disable" : "enable"}`; + const tone = enabled ? "ok" : "warn"; + + return ` + + + + + Gateway Isolation + + + +
+

Gateway Isolation

+

Diagnostic launch mode reported by the Windows launcher.

+
+
+
Reported Gateway Isolation
+
+ ${status} +
+
+
+
+
Change with CLI
+
Run from the signed-in user session on the Gateway host.
+
+
+ ${command} + +
+
+
+
+
+ + +`; +} + +function writeHtmlResponse(response, statusCode, html) { + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'self'", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }); + response.end(html); +} + +export function createGatewayIsolationPlugin(env = process.env) { + const launchMode = readGatewayIsolationMode(env); + + return { + id: "gateway-isolation", + name: "Gateway Isolation", + description: "Reports the Windows launch mode selected for the running Gateway.", + register(api) { + api.session.controls.registerControlUiDescriptor({ + surface: "tab", + id: "gateway-isolation", + label: "Gateway Isolation", + description: "Read-only Windows Gateway isolation status.", + icon: "shield-check", + group: "control", + order: 20, + path: STATUS_PATH, + requiredScopes: ["operator.read"], + }); + api.registerHttpRoute({ + path: STATUS_PATH, + auth: "gateway", + match: "exact", + handler(_request, response) { + if (!launchMode) { + writeHtmlResponse( + response, + 503, + "Gateway Isolation unavailable

The Windows launcher did not provide a valid Gateway isolation mode.

", + ); + return true; + } + writeHtmlResponse(response, 200, renderGatewayIsolationPage(launchMode)); + return true; + }, + }); + }, + }; +} + +export default createGatewayIsolationPlugin(); diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js new file mode 100644 index 00000000..2e5c09dd --- /dev/null +++ b/plugins/gateway-isolation/index.test.js @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createGatewayIsolationPlugin, + readGatewayIsolationMode, + renderGatewayIsolationPage, +} from "./index.js"; + +function registerPlugin(mode) { + const descriptors = []; + const routes = []; + const plugin = createGatewayIsolationPlugin({ + CLAWCTL_GATEWAY_ISOLATION: mode, + }); + plugin.register({ + session: { + controls: { + registerControlUiDescriptor(descriptor) { + descriptors.push(descriptor); + }, + }, + }, + registerHttpRoute(route) { + routes.push(route); + }, + }); + assert.equal(descriptors.length, 1); + assert.equal(routes.length, 1); + return { descriptors, routes }; +} + +function invokeRoute(route) { + const result = { + body: "", + headers: {}, + statusCode: 0, + }; + const handled = route.handler( + {}, + { + writeHead(statusCode, headers) { + result.statusCode = statusCode; + result.headers = headers; + }, + end(body) { + result.body = body; + }, + }, + ); + assert.equal(handled, true); + return result; +} + +test("accepts only the exact launcher isolation values", () => { + assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: "enabled" }), "enabled"); + assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: "disabled" }), "disabled"); + for (const value of [undefined, "", "ENABLED", "unknown"]) { + assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: value }), null); + } +}); + +for (const expected of [ + { + mode: "enabled", + status: "Enabled", + command: "clawctl gateway-isolation disable", + tone: "status--ok", + }, + { + mode: "disabled", + status: "Disabled", + command: "clawctl gateway-isolation enable", + tone: "status--warn", + }, +]) { + test(`renders the exact ${expected.mode} read-only status`, () => { + const html = renderGatewayIsolationPage(expected.mode); + assert.match(html, /Gateway Isolation/); + assert.match(html, /Reported Gateway Isolation/); + assert.match(html, new RegExp(`>${expected.status}<`)); + assert.match(html, /Change with CLI/); + assert.match(html, /Run from the signed-in user session on the Gateway host\./); + assert.match(html, new RegExp(expected.command)); + assert.match(html, new RegExp(expected.tone)); + assert.match(html, /aria-label="Copy command"/); + assert.match(html, /Copy the selected command manually\./); + assert.match(html, /copied = document\.execCommand\("copy"\)/); + assert.doesNotMatch(html, /next manual Gateway restart/i); + }); +} + +test("registers one read-only Control tab and one authenticated sandbox route", () => { + const { descriptors, routes } = registerPlugin("enabled"); + assert.deepEqual(descriptors[0], { + surface: "tab", + id: "gateway-isolation", + label: "Gateway Isolation", + description: "Read-only Windows Gateway isolation status.", + icon: "shield-check", + group: "control", + order: 20, + path: "/plugins/gateway-isolation/status", + requiredScopes: ["operator.read"], + }); + assert.equal(routes[0].path, descriptors[0].path); + assert.equal(routes[0].auth, "gateway"); + assert.equal(routes[0].match, "exact"); + + const response = invokeRoute(routes[0]); + assert.equal(response.statusCode, 200); + assert.equal(response.headers["Cache-Control"], "no-store"); + assert.match(response.headers["Content-Security-Policy"], /frame-ancestors 'self'/); + assert.match(response.body, />Enabled { + const env = { CLAWCTL_GATEWAY_ISOLATION: "enabled" }; + const plugin = createGatewayIsolationPlugin(env); + env.CLAWCTL_GATEWAY_ISOLATION = "disabled"; + const routes = []; + plugin.register({ + session: { + controls: { + registerControlUiDescriptor() {}, + }, + }, + registerHttpRoute(route) { + routes.push(route); + }, + }); + + const response = invokeRoute(routes[0]); + assert.match(response.body, />EnabledDisabled { + const { routes } = registerPlugin("invalid"); + const response = invokeRoute(routes[0]); + assert.equal(response.statusCode, 503); + assert.match(response.body, /did not provide a valid Gateway isolation mode/); +}); diff --git a/plugins/gateway-isolation/openclaw.plugin.json b/plugins/gateway-isolation/openclaw.plugin.json new file mode 100644 index 00000000..b07da303 --- /dev/null +++ b/plugins/gateway-isolation/openclaw.plugin.json @@ -0,0 +1,17 @@ +{ + "id": "gateway-isolation", + "name": "Gateway Isolation", + "description": "Reports the Windows launch mode selected for the running Gateway.", + "enabledByDefault": true, + "enabledByDefaultOnPlatforms": [ + "win32" + ], + "activation": { + "onStartup": true + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/plugins/gateway-isolation/package.json b/plugins/gateway-isolation/package.json new file mode 100644 index 00000000..ced9c931 --- /dev/null +++ b/plugins/gateway-isolation/package.json @@ -0,0 +1,23 @@ +{ + "name": "@openclaw/gateway-isolation", + "version": "2026.8.2", + "private": true, + "description": "Read-only Windows Gateway isolation status", + "type": "module", + "scripts": { + "test": "node --test index.test.js" + }, + "peerDependencies": { + "openclaw": ">=2026.8.2" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.js" + ] + } +} diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 4ce97e38..fc093c65 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -12,6 +12,7 @@ param( ) $ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent $package = @(Get-ChildItem -Path $PackageDirectory -Filter '*.tgz' -File) if ($package.Count -ne 1) { @@ -77,6 +78,101 @@ foreach ($requiredPath in @('package.json', 'openclaw.mjs', 'dist')) { } } +$pluginSource = Join-Path $repositoryRoot 'plugins\gateway-isolation' +$pluginFiles = @('package.json', 'openclaw.plugin.json', 'index.js') +foreach ($pluginFile in $pluginFiles) { + $sourcePath = Join-Path $pluginSource $pluginFile + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "Gateway isolation plugin is missing required file: $sourcePath" + } +} + +$pluginManifest = Get-Content ` + -LiteralPath (Join-Path $pluginSource 'openclaw.plugin.json') ` + -Raw | + ConvertFrom-Json +if ( + $pluginManifest.id -ne 'gateway-isolation' -or + $pluginManifest.enabledByDefault -ne $true -or + $pluginManifest.activation.onStartup -ne $true +) { + throw 'Gateway isolation plugin manifest is not enabled for Gateway startup.' +} + +$bundledPluginsDirectory = Join-Path $installedPackage 'dist\extensions' +if (-not (Test-Path -LiteralPath $bundledPluginsDirectory -PathType Container)) { + throw ( + 'The pinned OpenClaw payload does not expose the supported bundled ' + + "plugin directory: $bundledPluginsDirectory" + ) +} +$pluginTarget = Join-Path $bundledPluginsDirectory 'gateway-isolation' +if (Test-Path -LiteralPath $pluginTarget) { + throw ( + 'The OpenClaw payload already contains a gateway-isolation plugin; ' + + 'refusing to replace upstream content.' + ) +} +New-Item -Path $pluginTarget -ItemType Directory | Out-Null +foreach ($pluginFile in $pluginFiles) { + Copy-Item ` + -LiteralPath (Join-Path $pluginSource $pluginFile) ` + -Destination (Join-Path $pluginTarget $pluginFile) +} + +$previousStateDirectory = $env:OPENCLAW_STATE_DIR +$previousIsolationMode = $env:CLAWCTL_GATEWAY_ISOLATION +try { + $env:OPENCLAW_STATE_DIR = Join-Path ` + $stagingDirectory ` + 'gateway-isolation-validation' + $env:CLAWCTL_GATEWAY_ISOLATION = 'disabled' + Push-Location $installedPackage + try { + $inspectionOutput = ( + & node ` + .\openclaw.mjs ` + plugins inspect gateway-isolation ` + --runtime ` + --json + ) | Out-String + if ($LASTEXITCODE -ne 0) { + throw ( + 'The selected OpenClaw payload cannot load the Gateway ' + + "isolation plugin. Exit code: $LASTEXITCODE." + ) + } + } + finally { + Pop-Location + } + + $inspection = $inspectionOutput | ConvertFrom-Json + if ( + $inspection.plugin.id -ne 'gateway-isolation' -or + $inspection.plugin.origin -ne 'bundled' -or + $inspection.plugin.enabled -ne $true -or + $inspection.plugin.activated -ne $true -or + $inspection.plugin.status -ne 'loaded' -or + $inspection.plugin.imported -ne $true -or + $inspection.plugin.httpRoutes -ne 1 -or + $inspection.httpRouteCount -ne 1 -or + @($inspection.gatewayMethods).Count -ne 0 -or + @($inspection.tools).Count -ne 0 -or + @($inspection.services).Count -ne 0 -or + @($inspection.diagnostics).Count -ne 0 + ) { + throw ( + 'The selected OpenClaw payload did not load the Gateway isolation ' + + 'plugin with the required read-only runtime shape.' + ) + } +} +finally { + $env:OPENCLAW_STATE_DIR = $previousStateDirectory + $env:CLAWCTL_GATEWAY_ISOLATION = $previousIsolationMode +} + $bundledNodeFiles = @( Get-ChildItem -LiteralPath $installedPackage -File -Recurse | Where-Object { diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 new file mode 100644 index 00000000..5b3b3ee6 --- /dev/null +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -0,0 +1,161 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$pluginDirectory = Join-Path $repositoryRoot 'plugins\gateway-isolation' +$testRoot = Join-Path $env:TEMP ( + "openclaw-gateway-isolation-$([guid]::NewGuid().ToString('N'))" +) +$packageSource = Join-Path $testRoot 'package-source' +$packageDirectory = Join-Path $testRoot 'package' +$payloadDirectory = Join-Path $testRoot 'payload' + +function Assert-Path { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Expected file was not found: $Path" + } +} + +try { + $manifest = Get-Content ` + -LiteralPath (Join-Path $pluginDirectory 'openclaw.plugin.json') ` + -Raw | + ConvertFrom-Json + if ( + $manifest.id -ne 'gateway-isolation' -or + $manifest.enabledByDefault -ne $true -or + $manifest.activation.onStartup -ne $true -or + $manifest.configSchema.additionalProperties -ne $false + ) { + throw 'Gateway isolation plugin manifest is not valid for automatic startup.' + } + + $package = Get-Content ` + -LiteralPath (Join-Path $pluginDirectory 'package.json') ` + -Raw | + ConvertFrom-Json + if ($package.openclaw.extensions.Count -ne 1 -or + $package.openclaw.extensions[0] -ne './index.js') { + throw 'Gateway isolation plugin package has an invalid runtime entry.' + } + + & node --test (Join-Path $pluginDirectory 'index.test.js') + if ($LASTEXITCODE -ne 0) { + throw "Gateway isolation plugin tests failed with exit code $LASTEXITCODE." + } + + New-Item ` + -Path ( + Join-Path $packageSource 'dist\extensions\fixture' + ), $packageDirectory ` + -ItemType Directory ` + -Force | + Out-Null + Set-Content ` + -LiteralPath (Join-Path $packageSource 'openclaw.mjs') ` + -Value @' +const inspection = { + plugin: { + id: "gateway-isolation", + origin: "bundled", + enabled: true, + activated: true, + status: "loaded", + imported: true, + httpRoutes: 1 + }, + httpRouteCount: 1, + gatewayMethods: [], + tools: [], + services: [], + diagnostics: [] +}; +console.log(JSON.stringify(inspection)); +'@ ` + -Encoding utf8 + Set-Content ` + -LiteralPath ( + Join-Path $packageSource 'dist\extensions\fixture\package.json' + ) ` + -Value '{"name":"@openclaw/fixture","version":"1.0.0"}' ` + -Encoding utf8 + [ordered]@{ + name = 'openclaw' + version = '2026.8.2' + type = 'module' + } | + ConvertTo-Json | + Set-Content ` + -LiteralPath (Join-Path $packageSource 'package.json') ` + -Encoding utf8 + + Push-Location $packageSource + try { + & npm pack --pack-destination $packageDirectory --silent + if ($LASTEXITCODE -ne 0) { + throw "npm pack failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + } + + [ordered]@{ + repository = 'https://github.com/openclaw/openclaw' + requestedRef = '0965053fe6b9341776df147a6934b7485c60b5ca' + resolvedCommit = '0965053fe6b9341776df147a6934b7485c60b5ca' + packageVersion = '2026.8.2' + } | + ConvertTo-Json | + Set-Content ` + -LiteralPath (Join-Path $packageDirectory 'source.json') ` + -Encoding utf8 + + $previousRunnerTemp = $env:RUNNER_TEMP + try { + $env:RUNNER_TEMP = $testRoot + & (Join-Path $PSScriptRoot 'Build-Payload.ps1') ` + -PackageDirectory $packageDirectory ` + -Architecture arm64 ` + -OutputDirectory $payloadDirectory + } + finally { + $env:RUNNER_TEMP = $previousRunnerTemp + } + + $packagedPlugin = Join-Path ` + $payloadDirectory ` + 'app\dist\extensions\gateway-isolation' + foreach ($pluginFile in @('package.json', 'openclaw.plugin.json', 'index.js')) { + Assert-Path -Path (Join-Path $packagedPlugin $pluginFile) + } + if (Test-Path -LiteralPath (Join-Path $packagedPlugin 'index.test.js')) { + throw 'Plugin test sources must not be shipped in the MSIX payload.' + } + + $packagedManifest = Get-Content ` + -LiteralPath (Join-Path $packagedPlugin 'openclaw.plugin.json') ` + -Raw | + ConvertFrom-Json + if ( + $packagedManifest.id -ne 'gateway-isolation' -or + $packagedManifest.enabledByDefault -ne $true + ) { + throw 'Packaged Gateway isolation plugin manifest changed during provisioning.' + } + + Write-Host 'Gateway isolation plugin tests passed.' +} +finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} diff --git a/src/OpenClaw.Launcher/GatewayIsolationMode.cs b/src/OpenClaw.Launcher/GatewayIsolationMode.cs new file mode 100644 index 00000000..ff8e2c36 --- /dev/null +++ b/src/OpenClaw.Launcher/GatewayIsolationMode.cs @@ -0,0 +1,18 @@ +namespace OpenClaw.Launcher; + +public enum GatewayIsolationMode +{ + Disabled, + Enabled +} + +internal static class GatewayIsolationModeExtensions +{ + public static string ToEnvironmentValue(this GatewayIsolationMode mode) => + mode switch + { + GatewayIsolationMode.Disabled => "disabled", + GatewayIsolationMode.Enabled => "enabled", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + }; +} diff --git a/src/OpenClaw.Launcher/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs index 531a98a3..e75d23ea 100644 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ b/src/OpenClaw.Launcher/GatewayLauncher.cs @@ -8,13 +8,15 @@ public static async Task RunAsync( string nodePath, string payloadDirectory, IReadOnlyList openClawArguments, + GatewayIsolationMode gatewayIsolationMode, CancellationToken cancellationToken, Action? log = null) { ProcessStartInfo startInfo = CreateStartInfo( nodePath, payloadDirectory, - openClawArguments); + openClawArguments, + gatewayIsolationMode: gatewayIsolationMode); log?.Invoke("Launching OpenClaw with forwarded command arguments."); using WindowsKillOnCloseJob job = WindowsKillOnCloseJob.Create(); using Process process = job.StartProcess(startInfo); @@ -44,7 +46,9 @@ public static ProcessStartInfo CreateStartInfo( string nodePath, string applicationDirectory, IReadOnlyList openClawArguments, - string? workingDirectory = null) + string? workingDirectory = null, + GatewayIsolationMode gatewayIsolationMode = + GatewayIsolationMode.Disabled) { string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); if (!File.Exists(entryPoint)) @@ -77,6 +81,8 @@ public static ProcessStartInfo CreateStartInfo( ? nodeDirectory : $"{nodeDirectory}{Path.PathSeparator}{inheritedPath}"; } + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"] = + gatewayIsolationMode.ToEnvironmentValue(); startInfo.ArgumentList.Add(entryPoint); foreach (string argument in openClawArguments) diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index b294df84..be74d3a5 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -166,6 +166,7 @@ internal static async Task RunAgentAsync( nodeRuntime.ExecutablePath, applicationDirectory, options.OpenClawArguments, + GatewayIsolationMode.Disabled, CancellationToken.None, log).ConfigureAwait(false); } @@ -240,6 +241,7 @@ internal delegate Task LaunchOpenClawAsync( string nodePath, string applicationDirectory, IReadOnlyList openClawArguments, + GatewayIsolationMode gatewayIsolationMode, CancellationToken cancellationToken, Action? log); diff --git a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs index 3be9a213..ccd66eb1 100644 --- a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs +++ b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs @@ -31,6 +31,9 @@ public void CreateStartInfoForwardsNoArgumentsUnchanged() "external", startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); Assert.Equal("1", startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"]); + Assert.Equal( + "disabled", + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); Assert.Equal( [Path.Combine(_payloadDirectory, "openclaw.mjs")], startInfo.ArgumentList); @@ -85,11 +88,32 @@ public void CreateStartInfoPreservesExplicitArguments() "external", startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); Assert.Equal("1", startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"]); + Assert.Equal( + "disabled", + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); Assert.Equal( [Path.Combine(_payloadDirectory, "openclaw.mjs"), .. arguments], startInfo.ArgumentList); } + [Theory] + [InlineData(GatewayIsolationMode.Disabled, "disabled")] + [InlineData(GatewayIsolationMode.Enabled, "enabled")] + public void CreateStartInfoReportsExactGatewayIsolationMode( + GatewayIsolationMode mode, + string expected) + { + var startInfo = GatewayLauncher.CreateStartInfo( + "node", + _payloadDirectory, + [], + gatewayIsolationMode: mode); + + Assert.Equal( + expected, + startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); + } + [Theory] [InlineData("update", "--yes")] [InlineData("--update")] diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 4d8adde9..fd2b614a 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -29,12 +29,21 @@ await File.WriteAllTextAsync( nodeResolutionAttempted = true; return Task.FromResult(nodeRuntime); }, - (nodePath, appDirectory, forwardedArguments, _, _) => + ( + nodePath, + appDirectory, + forwardedArguments, + gatewayIsolationMode, + _, + _) => { launchAttempted = true; Assert.Equal(nodeRuntime.ExecutablePath, nodePath); Assert.Equal(applicationDirectory, appDirectory); Assert.Equal(arguments, forwardedArguments); + Assert.Equal( + GatewayIsolationMode.Disabled, + gatewayIsolationMode); return Task.FromResult(23); }); From d6855d0cea76ecf28fa813c22ec3ce04bbdeacfb Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 10 Sep 2026 19:48:13 -0700 Subject: [PATCH 23/76] docs: attach Gateway isolation local E2E evidence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/validation/pr-28/README.md | 129 ++++++++++++++++++ docs/validation/pr-28/e2e-results.json | 33 +++++ .../pr-28/gateway-isolation-e2e.png | Bin 0 -> 41807 bytes docs/validation/pr-28/hashes.json | 16 +++ 4 files changed, 178 insertions(+) create mode 100644 docs/validation/pr-28/README.md create mode 100644 docs/validation/pr-28/e2e-results.json create mode 100644 docs/validation/pr-28/gateway-isolation-e2e.png create mode 100644 docs/validation/pr-28/hashes.json diff --git a/docs/validation/pr-28/README.md b/docs/validation/pr-28/README.md new file mode 100644 index 00000000..fafc9dbc --- /dev/null +++ b/docs/validation/pr-28/README.md @@ -0,0 +1,129 @@ +# Gateway Isolation local end-to-end evidence + +## Why is this change being made? + +PR #28 needs evidence of the real Windows launcher starting a Gateway whose +authenticated Control UI exposes the Gateway Isolation tab. This records the +local run against packaging commit +`9aa1df286c2b6fd59b4c101201ddae0ae6000324`. + +## What changed? + +No launcher, plugin, or upstream runtime source was changed for this validation. +These files add the full-shell screenshot and sanitized machine-readable results. +The GitHub user-attachments endpoint returned HTTP 404 for the target repository, +so the evidence is stored in the PR branch instead. + +![Authenticated Control UI showing the real launcher-reported Disabled state](gateway-isolation-e2e.png) + +## How was the change tested? + +The run completed at `2026-09-11T02:45:20.860Z` on Windows x64 build +`10.0.26687.0`, using .NET SDK `10.0.401`, Node.js `24.16.0`, and Microsoft Edge +`152.0.4191.66` through Playwright in headless Chromium mode. + +### Runtime composition + +The launcher was published as NativeAOT from the exact PR commit. The expanded +x64 application came from the successful +[packaging workflow run 34546297901](https://github.com/openclaw/openclaw-windows-packaging/actions/runs/34546297901), +artifact `openclaw-gateway-payload-x64` (artifact ID `10179486438`). +That workflow's packaging commit was +`52f2a53fb62b13499ba2692d81870413fcaf943a`, not this PR. +Its recorded upstream source is OpenClaw `2026.8.2`, +commit `0965053fe6b9341776df147a6934b7485c60b5ca`. + +The PR's three shipping plugin files were copied into +`app\dist\extensions\gateway-isolation`; the plugin test file was not included. +The source and runtime `index.js` SHA-256 values matched. + +To resolve the previously reported UI/Gateway build-identity mismatch, the +Control UI was rebuilt from unmodified source at the same upstream commit, +using its frozen dependency lockfile and declared pnpm `12.1.0`. Only the +generated `dist\control-ui` tree was replaced in this validation payload. +The supported build inputs were: + +```powershell +$env:GIT_COMMIT = '0965053fe6b9341776df147a6934b7485c60b5ca' +$env:OPENCLAW_BUILD_TIMESTAMP = '2026-09-11T00:24:39.157Z' +$env:OPENCLAW_CONTROL_UI_RELEASE_BUILD = '1' +pnpm install --frozen-lockfile --ignore-scripts +pnpm --dir ui build +``` + +The timestamp came from the Gateway's canonical `dist\build-info.json`. +The authenticated WebSocket response reported: + +```text +type: hello-ok +version: 2026.8.2 +buildId: 2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z +controlUiBuildSource: bundled +``` + +The JavaScript served by the Gateway matched the rebuilt JavaScript SHA-256. +There was no custom Control UI root, cross-origin UI, edited build metadata, +mock Gateway, injected status HTML, disabled authentication, or build-admission +bypass. + +### Launcher-to-browser flow + +```powershell +dotnet restore .\OpenClaw.Gateway.MSIX.slnx +dotnet test .\OpenClaw.Gateway.MSIX.slnx --configuration Release --no-restore +dotnet publish .\src\OpenClaw.Launcher\OpenClaw.Launcher.csproj ` + --configuration Release --runtime win-x64 --self-contained +.\scripts\Test-GatewayIsolationPlugin.Tests.ps1 +.\scripts\Test-SigningInputs.Tests.ps1 +.\scripts\Test-WorkflowPackageVersion.Tests.ps1 + +# Run the published executable beside the expanded app directory. +# OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH point to a new test-only profile. +# OPENCLAW_GATEWAY_TOKEN contains a newly generated random token, not a real credential. +# CLAWCTL_GATEWAY_ISOLATION is absent from the parent environment. +.\openclaw.exe gateway run --port 19428 --bind loopback --auth token --ws-log compact +``` + +The profile used local Gateway mode, disabled mDNS and browser automation, and +contained no model credentials. Process inspection confirmed that the listening +`node.exe` process was a child of the published `openclaw.exe`. + +| Check | Observed result | +|---|---| +| .NET launcher tests | 54 passed | +| Node plugin tests | 6 passed | +| Synthetic payload provisioning | Passed; this is a fixture test, not the real Gateway | +| Signing-input and workflow-version tests | Passed | +| NativeAOT publish | Passed | +| Actual launcher `--version` | OpenClaw 2026.8.2 (0965053) | +| Real runtime plugin inspection | Bundled, enabled, activated, loaded, imported; one HTTP route; zero Gateway methods, tools, services, or diagnostics | +| Anonymous plugin route request | HTTP 401 | +| Authenticated plugin route request | HTTP 200; `Cache-Control: no-store`; restrictive CSP | +| Full Control UI connection | Token-authenticated `hello-ok`; bundled matching build identity | +| Normal UI navigation | Opened the Control UI, clicked Back to app from the fresh-profile model setup screen, then clicked Gateway Isolation in the sidebar | +| Rendered plugin iframe | `sandbox="allow-scripts"` without `allow-same-origin` | +| Real launcher state | **Disabled**, with `clawctl gateway-isolation enable` guidance | +| Copy affordance | Clicked Copy; button reported **Copied**. Clipboard contents were not independently read back | +| RPC errors during browser run | None observed | + +The screenshot was captured from the real rendered page after the sidebar click. +The model setup screen was exited through its normal Back to app button; no model +was configured or queried. See [e2e-results.json](e2e-results.json) for the sanitized +browser assertions and [hashes.json](hashes.json) for exact file hashes. + +### Scope and limitations + +This validates the local runtime path in an **expanded, unpackaged application +layout**, not an installed MSIX. It does not validate Store installation, +package identity, execution aliases, MSIX read-only enforcement, signing, ARM64 +execution, or an unchanged workflow payload. The UI rebuild described above is +part of the tested composition, not a workflow/source fix. + +**Disabled is the real interactive-session launcher's report.** Enabled, +missing, and invalid values are covered by unit tests only. No isolated-session +launch or isolation attestation is claimed. The CLI guidance was displayed and +copied, not executed; changing isolation remains outside this PR's scope. + +Only the validation-owned Gateway process tree was stopped after capture. The +test port was checked to be free afterward. No existing Gateway service, user +profile, installed package, or system Node installation was changed. diff --git a/docs/validation/pr-28/e2e-results.json b/docs/validation/pr-28/e2e-results.json new file mode 100644 index 00000000..c52a82f7 --- /dev/null +++ b/docs/validation/pr-28/e2e-results.json @@ -0,0 +1,33 @@ +{ + "timestamp": "2026-09-11T02:45:20.860Z", + "browser": "152.0.4191.66", + "browserEngine": "Microsoft Edge (headless Chromium)", + "launcher": "NativeAOT win-x64, unpackaged app layout", + "admissionBypasses": false, + "events": [ + { + "event": "route-auth-check", + "anonymous": 401, + "authenticated": 200, + "cacheControl": "no-store", + "csp": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'self'" + }, + { + "event": "authenticated-hello-ok", + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + { + "event": "control-ui-plugin-tab", + "state": "Disabled", + "sandbox": "allow-scripts", + "command": "clawctl gateway-isolation enable", + "realLauncherMode": true + }, + { + "event": "copy-affordance", + "result": "Copied" + } + ] +} \ No newline at end of file diff --git a/docs/validation/pr-28/gateway-isolation-e2e.png b/docs/validation/pr-28/gateway-isolation-e2e.png new file mode 100644 index 0000000000000000000000000000000000000000..e6c27a9bb2dd3bed080d698ed67d065506dfc3e4 GIT binary patch literal 41807 zcmb@uWmME()IW-e1*j+;0s;a83IftdN_UrlbPPSfP)bR6$IzY9Gr)j!w{&+6JunQz z+`-@fdG5OJ?p^D$d~p_@*xz$@e)ir3D=A3fJt2F7gM))7{Z(8A2j@X14$l40kM3fB zWAD5F00-wejjK*z7$ERE__{2P8mTKP zt?~j%`1)mUqG-yKybZ&fw`-xVB}uB7xPKe%69AJIpo52LD067F4^VG^U=KN+=roK> z!~SzRY~ejbAw45x5;GWlrKW4+?#IjIXjtFJl7S;qbxQ~K$M??*`+ozRPP*m)1|d?d zcm9oDMf%?RH+V;N2lwAVN#HL2zd@$keTshrk>f|7{|$Usi|+dx_w|e{4fXZK+$1Ku zqYwwQ{c|%jyq`aR^_iZTD^jykrXV=cx}Bb>cb`J!<99Ptv+c2i{R2vhPZ4CbX9el{ zqYIgKO9^9-(+vuSNA@^4&zQq={F=pr`G{_py`#K?`;E0(Au+yaJdSBgE8nxqHSXu* z_~NiLI@W@MrI9@SVHL{>EBVK~|K`WtCHS!1zzY1$<6J!)V@Sd`hoy(~CJL6DaKpHs zC?cIZB^xXj7+YP^x02E2^_ee0mDj>)2S#Ezqb zqJCqWjz!Fasha}d3gj`7;~yQ{_)MR7s}CQa`!e1^si;4p#Ep(i`l@w{QuyS5Ht9lp zg@nAa-FC4gh3F#l$&qo1hx>NY(69VYGQUT?YvRemumPX?grLa4{Up|L;NsUY+E=cKn_>UQqCOuF+8NGxmmoiFV&(T@#sK5J-6p z{5ar8q)j{#*bRfZrEnR z<&~m4nhIU>`sZ~ecL+o@G!p!{V2&y^$^+VtloV>fb>A`BAk?@6(_z)^(=QuUJ24x! zRfSEWH=dfJbc;&mn3gKPK#by|vs6j5(~S>apP#ZH2L|*2yo2|Ir)6KIc8w3JmW0Pz zF@4**7mLO%A9d09-mGR`h0lZV+;GC)8bxpxjNV4(YF69U9AFOcGLNORZV4vNsCWiA*%%vS z`I|;6(Be8&=X~(G?2M}}NA@*w_fJia^9+n^kn@g9`N8Jhll~6zbcgnM7GCH^*?eW! z(QHFmA=&$MVV`on#*^~$cvejYM*77j_x2J=`5(A{K8VxHKAAaLNJ%M9=UwxvAd9r5 z=-+mMTwb2mHd!wzGob&L4W|B;N7>uWI0Exy#=7}fINKe6g`EA)G%-c(O-E0bh~`?A zXq1#s8puN3s}(4h?)U}91KHk_Hm>~>U?s=Gi3krQ@-68q)%1=HUtW+wBEtgS6W!Pr zVa=kl42t7c)SY`8(6H&2YUJIetW-fMn^~l*V0$1ZjTir5$fx+R%cdrqgY``|WO>+A zcgR912{;()O}_pcwNMaLD*NfRy)`Flu3~{mNa(xl&~aENVtD^ztdGP=Fnnq0X#5+s zQL*`QE`zSwjZJBL*~JvU@QcIEG8$MpEzAxr0dX&1%8WkVFzk-(>h2U=*dV<#*O(BK z`A<8v=yGy$jsq4-w{=*T%7XCWkpfp+BQJ9DHu@hY-awDWi>QZsb!~QH0tWB?%y?vx z0UV#q4K0UrQ9KhA&o!Yp6f&%GbWyG}thbx3<}Y>Hq8j{}lE|W~@Z^22nkgzl9&-ai z#7}i;TpYdsZrJ}%Rzw~z66a0#HOb>uk&A zPst9S>fJ(z_6&NJsL{Crz2=0x)$bne42c%sq+ivJ_Hf3|6#3|akce*`Ya+s zxf(XB3hMA7Ca`%AuobX4&c+N`s7g?zbGn&^FUIyJ835ntu~+frHypo z{dHIe&|h3q{M7-5&3ono7aL5CSfixb=!OZGhSKOcA^6Q@kXB*Q$IFLwZ938rTAs*8_rMS&nPUFP5& z{0D1Lp_Y$R{K?kT(!N48QC}^&!0Ka7YuDbsp2{h$)4@|Xm$mbs(`367ksn0*AnU)S zkM)7e?F?hUEw+du(fxG(%Y}(HeLn-V>(X~*ALSq*uP0e=Vsu90S0UG7)9$4shWkr( zJ)ARw{@AKoyEN=ohAqE~h4nigy4{Iq?QXFRv3ycY<-ex#&6Y;2478fQM_q5AOPakj zhC>=-QVcp_CI$S~?|kDdt|8}%KEvs?_4PgwS>O+;$ueEb5*Q|56THQuR+t2|p2OUi zq7tle+3PZgT>R6hnIg>bn+6%-->QAvKdAnQztf$JXIP2{wb2SY-MbL4m(kd#Q};&I z<@X?8jQeP_5}i%LNOpuqM;5n^#;at*Utgz#7(B01T)YKWs zR>~XxDe=N@3GF^S#so@u;_lx&X1G=3((AO$nN@+8^gQ9+ry)Caabvixflxp=`8ypH ze!7Zk?M6TJmsD>(jp5u3|FcWj7t!Q1545n4)MUxh*Xf?6QDst6Qu)*en^5(uNsDnd zxA80Re^&fm9J*Ohb<9jmE#%{8db10BriFLrNe;&o36zij!htB)=p3~%;PrLt^PzC) zIec>Na(q;gPG!oonod90dR!1XxOg?3Y~lkqz&P+IIynE;gr4drmq?ab2r!+E2s0Ag zRC(?E+8hcN_PKIe4C?L*Xky4Cw-vrR9XM{sb@d*TpN`Whb8vAPxA?TG{E|HwrTK1r zMGRNy%HC0Kz|x4{vVP3!?+IIOt4ZXfj4YJtmd!+xLr$HeJ*VF<_w_b@g1r^qr=+cD3dsFwf>7`1F{u3rtT4l5K1S2xAhcPEEmo6}hPq z8#gRF5km&*>$nQJq1YGrx`a{){j2Egd0OA#yt29x%Sq8w5FB}C-jP_%gQ*kT?TaVj zJ^K|ZP>E>;R0qk=yg$^QNPF67{rnx5u>z)f7y7zv?z+1!ok>rhr6AwxDU&_=>O3T# z`ROg)Zf|EQaf9L`!2)$A!+13Fe$YDWnJ)^T*$!tYO%^gFTwgZ+Hc|I~?v3Wx5sJ!1 za(d-X)9!B>3M?5ME8`~SI2k_i{w;Cj3o!QdU{9YI5{AVSf%si7*HJ%iH0zrF7M;hk zLYko6Rf8z%i^P(PT|tw~$gg3Sj`P+|v}39G;Kjf49i{FEc|R&BEjVvX6={rfLLS*U zPQ6jGx|}FNt@^gDfH3tgrw@(zGcvf?44R^pkK}l^hQVdW(ulnOoqu-Ne(D9j|qLuP8Evtt@BJ`HL1#TA4tM z!~{Wt7rj!ddjfD4v{w0WU*GlNA9pUQ678>$e@=B?>R@2uBO^`c7Yi+vr~sO!4?+dv z<8`*OI~C-*JcopTD_5T@m1i$5(t@*8chw8EEJv0#HGD9G2wT`&JxJly9#+21O%#1@ zW^8bAQPWA|1sJ66?OU|c^_mHO&P^Sr1!?8(Yx!th$?A)}y{_uMJbLBD%TBY}kx_

iQ00ki4J@308s4Q`F!fqnu3%i6K7jS|Y7zuot-%~z*)9wVcYrbV zo{c94T-L5Th|-#y_24x_cHK|krwjQar(WNld-SVHyOqWVZ6Mwf-DECRb6tNk3>LaR zjj#;@?vK~nXsZ38@x>i}zGyl#voXHVI}E!~6!yj}v2RNEd!(}+Y|5{lP_7PvK`Wg< z+^1wGXXmz3UWj>Uk5v6G;!2571nwo7do^F-VF1fV8-}bD=UsCK29dAb|MRxwZHVlr z=Xk$|h=LtoR(rQI?NrDt+JcXibxRe4@%bqj5fw`*WAGUn2?|jhg0xpW-_3T_i#;LV z;#f3q%76^i!meuI^8SH=C<7scRvCYv1qB!T8MRiO9msq+|j( zv(OIn_M>T+if7RsO8L*MTN*=Yn8y;^ldnXdbnGvBX^-u@NEc_(ZT?G=Mhu${8P5_0oJk;HL_`#{EDAfc0U}y}zddc7Kk1g-pdpLicMaI6do5yAmD|C2ioaUL{9872}mS7z$Z5xy5 zL+GG7t@j5q(@)pw<>;&5f7b|%0V&D_X`Rzm+>|l&7OD7 za*9yow}}73qhMA>(psQ>Pv5|R&uJAU$nL8T(#2Af>#!?$ z0;zw5d9uxAW=$dPm(^q{PS+bP3~?5*)y|!RxC>Kseyy+V)g49%FgD%ezpyqeC{>$P zGFy7B%=+f5$HvWznp)&Osh$_mtOZXq~hYS2RVP{Y_TNu)Lf3 zekd2gumJ)k@8CMQYHKK7x~r==moHS=Jh) zow6bC>g`+qD5tcNhy+icYUq{1k~{5$^wG?&z1kkZiV zSfzLuhoa?NA;_U~>(l0SCe=yxIfR9qH>iV4waQ-*mF9u}sQCTl_M+XuUOL{Iqn@s- zt-2WVzRGz2&Iz(V+h%z?`QEiZ_swBHsp)u)rKo;er!;GtUT)t5nP=@=I#>7k=H=vj z2S!3c4C=J%>iMOiIvZ}?xVj!i8vHV;c2=>67yrW9%uiLvNuIdzCK_fs(F9?v4A>yE zYxXyrtt7t7WBuP#MGEMY{5y0W=#6RB(Js?Ng@Yz>(dpFLSBmi!;4x zObu}^L@Gx#X+JZ{8!gS9FCal#%8IcZt6Fwe4#0$luflccRLUNxR7Dd|3Mij2dx@>W!7LS?L@bc7> z_tl@oLmy)L;Sak8-KYeOPmiof-=Q0p1Pa(w$*NSHlRh+n=2`3`A1Bpd)=+(4Z(Bl#pfMEe)&}z7KOm>chZINhBv5R#VHJ?T4M_wC; zp3&=;P~k?NV))Y>LmTRe-}xn-ZufOP0R(d~Yk2W}ZHr&}j+}RRR4eEgWT077OOW(< z(?NK0GAZP&Mwh3;y27A$WUmjRmhci6gd5}uRU?U{H>EITX^=+c8OX=EPVB+^|9iNJ zO0??hXF&6muxII&4~m#Us8s@xgC(zLNI$T8(Qw}VPo)Ms{^y&{eQgw#)96h}1^@D7 zA3Vdh_OyUa$Uq*vzrJ1!qwjov!;ZK#nH|tZSwVC28Z-yJorA1PxZ~^4Xf>r7y?9OA z84h>E06Tu|zCb>D1R*O7GS4VT{1vI{LW1@v+ZR1YJqytj@Y>DdW1Fqstph|T@0py| z{5I&pr`&XWD*wbHGE#mo4iW@g*f92{p?XEmus+8S~L%Jcu( zzMXH3UNbE4dpqUtPcu02u^iks+b9v5i?v}^E^QU3y75FG0gE#ku^FdKW($99s#@;D zE3BCwt3>2$=yE;VMcA6;N7J*KE+pKGa2zU^I5aaS@h~=Lf99wEtRe&+7k8nhAt;0> z|4&!Yx8d2wH0ahHwi&&BKsa`w9(a?`pnG!4Vasc}lg=b6RP-mkBnEMyV9i@o=uC%! zgv=XzJ&t?hYBu(}nZ`~@J35S{U-!lx*bT9jXmtQ~|8rsUpjZ?vEEiQZ@JwIhBek35 zVWy*!rA%vcvO&pfHnw$wJiRIp`*ro9yx+Ip4$icw^gfn=VcufbrZN?BG5y2k|#lBh5sv)o?%S~HGaM#%6_Z9xLEJp2RnH+DzoBP&oNK!dTIe}JsIuOwg2K` zRc`x8q$tBfzkphr7`ub-11hS=I1ff7(Vxtnr(C~u@jD?4EK^%sa)=KbX=NJs1+u56 z9J1dAFQMfdJkMRK41EY`Zcl!SMRB7N5j`O*KL$M_F&z8fJ*|dzCpuW9+P(Qq(43Xy zwdS-fi7GKf_hhwHGI4fhlh7qXloR#x6|avsT{6-Q?DQqBbk_veH{RU3w_0w`o$PUZ zvvaADM*#*)9b^g3DDRUU{^3hahqx*0HDd&(&N(*cTwOxW!=r=2y-hJQ384t*wy17W zg6a;*R0zouAYNcM5;3@?O!g|H$WLx3a4F7k3a===xpTC$A2z=1o_>pA`@RgKVaEBk z)29fom?BGPGDtR!wp*74ah#2R@$$QuN@iaA=*d1m97WgF+zIE&!?g1w%kFaa(vaWNWar~S zjrHCQbmf(nB2TTWr#TgLEcaVzz<&nt9g}$wDHX-nglU|1dD}U<`>&lCob@zKdOg1A zGakM>KP|$3p8y{=CT|tJYCDh$m!(>OB8inh+btvk6ApwtRzfUOA|_a*>J@gUY{#N$ z5rpQV9<|%TmQJ~rQu@2el>G)4GkbfSDy1(cddIc2*I$^taiBMb@=8zpv^KnQ>6reC zStdFOd-VeR40%~_uS!!TJl1c!<|8hQY6cOYgxqXK^tY#y0VAHwfxUQqzIEHRFGUQW z+0c|`F7W58Vx)zs1!Ao-eBN@oVt$B+j_(NGq|WQYNXH=yN8q7XY9>auY#FN*g7E$? zZHG33mhX#X1sgr&DtMJ|g)Z+dHlLu?;Mh4o*apieLP;v%J~>rHR7+4;14)QZ_I{`q z;cL)Ixb>3A47HHCoD z=b7;8t1EIwx?Ukl<-_>N?p#?1;|xta!06idk6-RaQ>pyb$vQoV`6R6hL+m!H^Bgm#=!Ea zpSUNQG~tvlUmN`r_>D@cNp7^XTJp$Xi%{V#*Lz`C8}22tN~hR&aiI=@QP`B)UjVwK zN~jjjLbLXMnNPKxiyw;z*z$SM>wnfLnzDwMd}&nZcAB2APwjaTX%bHah~%W_$JMaw z!yJDMsGqUw6!19E>^eW^&^s~rNKp_J%1~y$edBxBH(qC97;*;e^L~$=?{CfKm#kx% z6Q-Ef$&kvC&PlNKGt{vpON}X>B+>)SkA77Z4xt1fQy~5|lvpNZ))2Bd#XktE?MHKE zBl>fcud&p=BanQt0*(CDz&U!7?UbsJ4{|<;R_}7BUpK3Pe{@i7YNQOL<8`IDjHy!X zp%OEXiZz1q;y?~ zQwR6f_tDPUhj9x#YV5eporo4TgFW8hAQ4?AH2eUi0uBab>V6%!#f>{^80Qg$}{ru3C$94Pja|aGKW2p1i;S8jc^Go0aoeO3(DOL*aj?~<@wRJKF zmWSsl#%Y?OYpXlT6=L>+Tl`kfc5lLr)OQo)RfHVh%-b7>qg+dAOV_z9`dgx+DVqk5 zMRt1!$30v;06>R7k7PA!NU?Oiqr$RH<;3@%P^zvxI?&52>r9jKJ)}DZ(!Yd_5&Y z6k3gQAKERT5rHpgY0bv1#1?6Eh6O3yZGh7W^lOl<$80)hK8w#=a_yF?fEau+B>p{= zPCB5c_#mhYV6%w6 zW5L+;qGJ*IYNEw1Ft4M`j}H%X)%x0mC*X|bxN$Xtr6?btYL+GNj`s6apiKSBBsIPn zn2IcHb_CM^`d;)(UkxN<*DG0qvjir0DPn1MUL> z)?O_%wRDIApa(8l>a1gjVi%GlC5)U2sBOt{iUNjv@hJ=)uo{+J+2WS9yn(syk#ayw zWQ8WRCpYt(2T|~(>956z@MdJ~KHLBOdMEaeQ+^B5Uhe@>+!y6BUTb9z<*S6#=y+{i zMA=9Dw>wa`^EmvjA+ux^w|_*w4W|Xb(qJOne!?5-+Y;Ju}jjmCsG(T&M7vE)Fr()>Q~S9z8kpi z3s-_1PVl;Czp#4F4}dK6Jny0u488UXh~>JA1TFekCOJX4e_j73DHb?fNE-$m-W2NJ zx#Op9YsbfBb9{Q9#+7Z~Da>~(L+6tH#O~*%gF2JV|dvAi{ryOFGuf2?7$yv zp+-dubM4l*QBr0N4VEQTSlQlQ3R9a4i%#avX+8xTS>09&7>3&h{|^=rCo0GB1Sj&L z`JqCrMi9G&SQFN@@>L0zmOL&riBZPR#wHozrZLJM|1UE4i(c+>`&T2>dBPxsyTa{S z9GrO{Zg`u+&RAy!k)jDoz_jAqW>}lA2E`9C$oT;}L>Uv!~zVojl0f)$j(B2bvVh!eV z(Gi+qIApLiJ>ZL!eQ@6%P_8e}>$xqmW_AZ=H}m!%p_SkKq$M!|Zmk7~wK2sn4|3%n zKTgawR=m$md?D_`YkUy6z{qAX3C9D&$4l~{Iy$7)=16dP^K8MvqZxMr=>yDwuQzLd z+Rb^83qSj#3hrS%Tf^=TbrK-st=V(bkd?YRdHvxOdu6eXh;pv8$rANSI@A@jG_*7e zbkq4q!nZbVMdeWo$4&OApJDqO`{H7%X^-($axN$bdc5DWrsn33buC({%{KXhIbh95?#^1>!jGr!PNN zsX!c#MfF?=*e#K(0hiFdS_fXMt=2oq6gk!d^`G!o<}y@HJ8nk zFN(uADr7#TH>Vo=;#{IJtp|ULK zW==NPYsi5`eU|+FI1w2he3G&7BSEN@`xK)1IO*Rdz2PCprFhA-@F9>aL7z7y&$U9j z-6;d=XnA^ODU}DSmG1phD@}P?t(LYcw8so1`)6`eqCw4dP~Ygc+Ix4@ ze$||lMq`%&k^KAv8NPG{#yoPxSi65GJTa{p6`1%5&68akU>#9Hf2SRDCRps0$@uBICAQlQg}pEuSl@`9E-wh#1Y!7+0Kn|u5ClqM;#0B(2XwEk!EhSHtK^Z`s4yg@#Jxs-7g3O zY=lamb1%IUwuFv#Id0Wcl$VFa-}DE9o14AVm?x=0KyyrP-omR^FV*J$4)uN_5}z*h zr{rL-AdTkkf_|ZGWqpBHDkmoz7C#+ka)xnBgmzYFj*hfj*kx=*&NmO38NvKuG~bGn z`0uZLj_>%B8c2W|4Ink29@wo99#D;b9*@d*oErt?vuXdf=`%pa_my;R^UOhX>Ps+{ z4rn4ay&JfjJ6uIQFH^I-w)nl$YCO;tKCwZX_~bPO1x0RUj^tpkh>+`~$}NU)io zZ+lVcc=+DbQ})>ScUTP=M@ZoH(_)=^rch{dgj)Sv>M3LgV8uDXrm*V?bT|}p zvICxm7B@2t9-8Vo+j4FCX^2)dGw3nfo0qUMiIc_*1O$l}Cr6sudr1gi=u)`Or&19ak zz6jY|c|(cR1lGT@hW*3*(faPEVuu4dv2Qzqwb|`lyuMhqx;KiRej(&0tKYPfrXuR} zUurO=yBAi5u7@Hs9e+4^5rsW|8NhY9+EvdGlTsZNda#0TnFxPXYar( z)+zBoovHn)Ix)}~{=Zu_O&*EMIz;E2dIx$NtaUeYX92QC)-iHreFaS`a$AUCYJexP zqkgOBoJoVsmp1eIK>gDB?2>6%=ZRO59WqeIAmpwhO?{d%tJHea3tssc7Gk7c^8Ukl z2kjF~?#3zAgPIe2s-8aMq`ky?B#HuC<4LD%Nv=%3rW^Ssi6xHxilB0W?_a37*Mi!x-*z#Y zjYmXZT~WP)_Q=wiP9fan#u^sfo`-j&79ck;+ohAa3SE_$SinLIwY%fyneZ5RroX(r zoHUtq@UG3-lDms`A~gKn1l#NTIIlEaDs)zR0z&fv)$+9XUxCIY>ll+*K&vR7$5$TC z6G}Dl@y7mV^*Mo{(cKxJVQ$R-%^1erd#(d>{!VDrpwn;G+~`9%NYLq0hJ@(*g^w1tI1S$ zz2B`abVq!h1vw|jT5Mo}%&k0z5$M#~ ztLkhFdG#RU4<*8@HaEYj_C!6UuBr=H z@MlW>wbgi5s9@MkTh>xe9=S~@1tW|=@&~x439tH_!<(IVth(W6g)cM(|B$$MfMp%a zwCR=}Zh0*|^@7zwB6Sfzc>9xTD+6EM`2^5(PjD98@Szi@i1K@;T1-nhQDGXTCWe5% z*?6D*?Qzget(%h=58^Si=d(oQJxhxtf$^X$-=%y)8dR@Q|A;I2 zrJjGFuHSexPP}}F{k|qoy#UV?aTnPvgW|;-FT6$m<%oo@JoWKT3LQC$7YSXwZ`M~{ zdZViqj|RW6%D>)(%sL_oaq%|dEzwS8Ueh1i7OKF z$amBd5uCYt!)|>ic@|_!g4oA$UwY~Tll%^hiyG>8Svm{~2j4NFV^4$4|CY5smvwLy z9Q(4z!$ZXFqmdUAkOrEh0;DX>Go}(9(bRmu5h3$F^xw&RaLqkQ{r$=`kuBX}U7>tD z&iX@I=$DOUA?JB)c^yra_V)5_Unvoe3{(5FP}akywrYxFqx~Y~W#WYgUmNVDC#*~z zhBey9-Ap!Ld~%G}x?Zj1c|vj=Ee2tHHJPHzJ7+OeNi6N9?CDPC$+NygWi{DQ9V5WF zsy+Vc!1TJaF9VsL>0H~{Qn5IFp^hM%N&%kzuFAirTwmln4yY}tL~?EHIVn@I##9%Z z8Wkv!M0U&FJ9Cu$+Iw-m&^xdB}2Dy=VL_cbO`+030mptGV z-*sQBV@=`_VW(!;62s0Xi8BEt&qV3(0!v>4EYrGetKY$q_~5LYlmzm|9lw7kkcQdO zzH+u^@q|jNrQ}tB;DT1m&PQeQd&MvI}@a((RdA@sV zCoKE&#JVzk9Z1`SGIH8B(PIpf|MOuTi@)nxN7UW~Q$2_i2BY$_ZW6FJ@%-f6a)xnu z;3@G_zJNb^mpn6PD@Ar>9W(``;i*qRD2!HwVff+?tuS`OCTs>L- z;`2^(G$7V9C0=l8Raz1ozI^C&mwS&Ra9<_rxT{H)xB2hjdsu`})9?hVC{@XCcITWT zfM>_Bb%@6s7ZDa%sBU6X#upY!YwslIYV#!VZ&k>)8iU#=6`~McxhJTJ>$nlI8L43> zTF0%Ys<6lHVj_6!{CuZh!=Gl!hRiItliOapP0TaW@<@^dO7v*H@o+&rE`Q8<0VyPV z{`T*{lGemmf13x1icg+$$-}?7Jq;7%ePX4UmAhAcSkeiMh+{f>1^<4$qAhSJ4uA1H zYh1$+f~q1w^6U?}%1FzcrIhu~psm+?*2bb20vkV+s!yXftVRRdpTc5J$kuw^v~MSk z(A7R;&+`lUB@dO?I=MQm~mBk3K0N!|K8E-mLH^uB_vskr|K6g0yx{J(xrXtV1L8>*C z(9q5N@1>E^N$8bd*JPxD{$+HSn6ljYxcmdO_<+yr1>WO;@O4vSx~Lz|UEy0=+}o zk)Fn_g79Ig#&5GFt*Q-vade)1`*`hxje5EY1vHJ3Vm&i#gQ0f5??i6!pnmhCVp?6K zfpeE!ogfh#qr)k^=<$dBL1AB!{Y@3(K5Me>@W>CRy&C7wjW+8C0Q0}^g5-K)bDuqp zmiqnm^~6O<#zN8yTsdk~U)vg_VF*=E63%nO{W{7)?clgK)2ZDNP5oe4?hej!R`qyZ z!QP><6B3Ix^q|MnF6i1j5cUBz_O3RZu96n}sN zf7<>la`o&0_lf;>b(9xxq#rhf7P%qGzKg?6H|&?hIkbc&D!OxLj$qx$ljD~_c5X7( zCl<*f$^I!tDW-NCSY-Nt`}JQ??3VlgTaEnxZd7gB>VQJnrYo2?N_sGry}CUS2j?P| z^PRACmwRlW?t!* z-BaGxRvSFBf59w}QX%A72Az2)IxAARiBZQ|?=O{p{YemI)xY!{RvXo&`L$Qcky3;C z60AKvsesPTaYWc6K=0Co`r6mQ*0?~-+Gx4z!GsqmC*(Z^=~d^hSmz_f5W1B_fX0nS z*QZ|vw~@qF3)!20bc#GS@uOkx?d>II??~rGN7m>Kw&&PF5o4mo8svnh!}0$8ru~Vh zMfwFgT#>624H%^+&DgAFW_lY8&8q)V1$)H=)j}l#Jd3{Kvj5n`*TQ{vw&s<>5DJ6dGc0DSwwccTuDqW~Vbv!7WZC)Ao@~CyzB)^ciIx!CCEcpxPF98Ck4w3jS z0EyBoySd&^1s>^v_)nbU_vAvCs@)fc2p@ZF>0s#s&92SQjw~>$1RyGEW!d=E@%N{O zkv%c#+2hNuLZa)yS1z^W?t^ zosPrvuy1HkJ1RoEbr0$1fz1Zj@$sbysAn5i3Tjd<74JXf<>#lKzCisQB|;x-GdPETdF<_v5_X#a8Ye79DvPV152=&$SWHik zOLPEIctPs9#XFq((E9gIo*VnTd#my&xZY`Ju@l!E&`R^*54)i!x7Y4iI~yxMu%u`S z)1DT^sgmsP0v^`(X=}#T;anl}(GZ)VV%hRYo}-few|$T+>3CrSY1Wt=T1D|*FNqW3 zkgq~6*CU(sPl5Sdc1z98pg%)^mTPrpukXm3k^Xa3M8@SwEDz9|7v%Kw9|z#nnfg&t zFvt7JY^W8*lpLEDYoMkKyB|KIpk>{2hCIYe{bV!Ydy$nBNUWnP5I$H}(O?z#t7(R6 z&@UX>yete^xap^l3}n4m(kBJA5wMue2uD)o#e2ST8S$Uu@2QS@7y|3AxI(2_M5i!6 z_@EH4$I?(ocJZa%npENcJaq!z-3b1Uj%(Ed1^u-XEDXr+3THN@jznEiPG3OP??_Dy ziK)<5+RfI;pRhDPuXjC^$M=|3*ZPtg_g*h`;$s1gf(y0NYpK|9Cx1v1VbYVKp)qj$ zVWHB&dW@sz7bEixr5Yua`;@r>R+9o0{c)oP&|Q@*;}xisl(?B&oqJ2VyQIoN z7AuT`FXh@9HQyOC_~Q?ynrLdwdvW!(lH6BiwdREj=$U=_Vk&_5$XIph)zP10mZvOw zO*w84VK>T^2A~_cj!kL}d0hq}N>Vk)46lC>nTX{#ly8nr;%^2cjxRlM-=2EV;l^s4HpDKHD`tfSx&&+AI6-bzA z;rY}JR*m{6X4w%lW;J_ivlkaTg%CNvdXZ?@$=E^;dwbAG5QIBbX4cuo__=rzfpC4D*59aEUZ}$R=-=0ok|yu)VY_mGXi1%uNi{%4F#^HuruR^W z`WC5g;9*pxfO(@mlui?7tn2I+m0Frk*IK+Mi`_}U#2{|&%_0P4D7;PiEn2?p-Cp*P zxgWQD!{;KW5Ys{@10BO=ME-{XPOHf;p-!*7W^13R2dvgVx>)#sn#;^jGR@Jh+-z+* zRvW)nK4Wj)H)f@4P?wmE5jjz))ABwN@GjlXBDQ1U_m|e_tSL;LSEx*$f^-6P)@;e) zsjI6iR)=(_@g8X9%Q>oKM-H!@@A6=~mavfdeTp7g5PEYX*E(Zz{?@R|WL(My!|h({ z_eDFzi4yQGe~?j7h;hWT%rfqj>3R^%RULAdfcOqBJSsWa<>UcqaJ4#YU?Uq=mq z6WE@H+hmnLi)LMzWErHmnFfawn^Np?*h`3rh`{b1^35CoPRL-x;k=@^4&?odNm>ux;r}&WEoq}u-oB`3D1q$jr!f=wc+l4hZA>~G+7&~ zZn0}bf*H}(@>kudI0M^lw-qBeIR78J4FA`IqFesA94DmUsUKR z4*!v-5-LkX#qU>===C@x0_L*AaNy+sH7~yVvfsfP{+UsNJp0qg+~a0Hiobg5PiZ^Ol?p-|ouh%i|xwe&qGCkW92i-VH6)RQA>pbE~PXC~^bsTPq{~9FezXaxdwq;k5 zU;^s0vQ3Ak@~sTwG1=_3$Z>dJ>-@=i?7qX|5-JSY&tZADB-$tLi|dd&tiJflBS+CO z$+4Q7-oS)2SLM~}a4v9 zKxSVYBALcg=I3yvG~FOIl05DM79>XvR5#~sG(&+h)Eemq7@Ogiv3_iaSN-8gZ*NiQ zza5f_I_W_U`T`!g)f%Cp9_~W;tq*NT?XmJFb4MzNL6O9;&T>kKZ+Cso#Vefs+#@|* zKblf9W=Q-xJq_JA(_gQEM_k*KMC5Z;vEGMA_SgPpXSC~0#hQbHi_Txyf2H6`W7Ch* zW%Qm$GVjcDif_#QQO3C#NVtPVcEnK21@(Zr`QkwzX0_6HWyhx#jO)vEbT-`xOwA!y zIHDErg)B5$ZICh&Gnzm->~5m+5nc{sN11&^K~p#OZ!^st?%^L~;g!@u3EnQ>evs13 zqE1*O;zRhHm4gA}?xW?58HVKs4=(!ER_ZoNzLJUER-&>Sff|@s(9n!-qy&`%utKHE zn4ZPDWZ^J&Wl*mFc#pxW&T29`^JB(px!M7jr8oOOmZXnN{|bE0v^vV5e`kkpB>3LN z(8JiUuC$x2BQUO)rxgI>Qms)E&U*^%3~9WQ{bWnzc92qYL#^zYA-~5TDogy*@o(B( z^Y>18(!SrJcFXr+?X{EIZA#fqc^~u5v?#yFKB-gPTJKBIEWA4v@=t}6uZI{7OVP5{ z^Rn2~)#R%7peqfjo84s*2$CK8ma5YV`bVK+T=D{?19Y(nKhHiMUm zvo#+wL`9Uk6B8suW0<^6g)vuX(N&4#4xg!7@0@a$vPF6Fn?+8+E?a4Am@&_TznW4R z9y-@umvWMZ+{%N4hmk9n8_uVHo#qN$G1n;Lh3ErArb$j8pG7>)zZc*WJ@`^b?Q!J4G8bE-F$=h?nE!5Eqzx2PW*i2I&ukUJ&|H|=L_(a^#yoYRhbd6 z_#Q6iYbSXaA*b#>Ri0AK%*%Wtu7>zT*;^1HmBG;zf}dQspExa}h}fSfpEWdOo43#b z>x<}ou5s`ElWrM42Lv2uRGy}?`^0Nf8*reyWm|@uk((pj^?zEl8g^oEe4(48NWbAN z_Ur3dqi~gWA<_>XXs;F?g=gSdOcAjL(S@Y2@W-X*5xdr!%D0CZhwO0*MnlsE-jGYt zR3Caz$%;BLQ5%P&7`ojc8hzb?4-Z6&%yUeJyBPAZ?gE?~*P-_4E^b|b_wirMAj$J< zWGXxQxIg93)6gevluMU|Q{>bx`&VvVqr+1Xa47Siqj~Lx&W`HdsfxFG!7uv^j`TTg zmKTExr=`PW)#poG;bVfJ_G1$%LNx@Zfv_P*b!0VY~#UP_l^cGwKhlah>o- z8JVW$pA^P&n{co5Z@(3^K@vz04G#}W^Ek@RqmG}O)H?D{$#%yDL7XqwZhdRVnT^mWk0)ZqVO|XHn62H>B3x891iWRSAk~a3KCR9bnFFEegf*)HiG~D zUO*WS*b=RQ?Wrn^C#O4@w6gL?F2CR;(`^};yG){%5+3i<5<6-#3D9fR14#f#rj{Bs zbGn*^gGW+yxQt6`DsBC%iW&oJUMUsqcnLI^dYZS72%9U1 z!ml@hc&Qmzo}(F_3{M-6rx3|r`Tc9g87YEJ{SxD|6VvBi%{tO7!+W7NC1uiTHAGuQQ68GVEwra4qLEBV*)vupCTZ(HI zE}wKiBiH&i7vXib8f270MKhPT7I8LV84B@b2#$vF3&A|Q;pwU7LK|+os4wGrHJzsc z09Wr>gW1c80w@rk{4#0p!_%{H(VS=L_|PEKw3dcO6eXI z5$O&|mF~^~27~TKQt56Oa*%G2?v9~{8ipBSeg{9_?_KMzb=O^Y-TV9VbN-t(C-&KA z?{~lN`#kTn8B=B@kZ}hPIs^}RHIXEZomAMVW28B&nhm&bxn=2{6uz?%4Sh=+Vb4EF zojTsQ_>cMls1fkCADQu4&hCMtuMn|3OB_%@?8i?It@oHhY*AxAf5Fy8Iqli4aVu3`Yof_ty3a>zf;u$V9jy) zG+Q;<74C7FYEu6TgfN_wquaR;JBd+Cf}28yM;n68mUQV)MxPa5KAqEp^{sK=zKuD% zMVuu1tNQ(X{rb=HMstwIR_*Ck+Ja3d=hccZe2mDdOFEtW6C#q${!Ec%31H7w4WOF()In3POCc*YG`*D_{dtnp53L@3;lMLiDehw2rW2PspN!n3;An3at_y}oUKurbHcVOzU0nL!UD-x}9g!ho7@IR; zg>O(0agj!y>}8a`h^6gijfAY8*xBp@6%${Jt^|p_si}euz-OG_>Dt^Fpa!FMy=o?% zMjTB|JM-04klXyR@$6b9BR?Ht<^hsYuD3K8Ox?~pZRv{8!ge3g=Y(FoUSFBru~;1K zl+cNr4fo-7K_C4QM%>LUP%zltpuUXZuSScbEMtb^<>?y=LSt&P-3T6Isv3a7vo89n zYX~=`joI1V`?Q^2rBa$zxMpPgO+9##9Ye5*Zd^Q&7Vy zZO3~!I2}&9LQ_FE1MK-Ol_s&pmotuGkdL~bs@W;79;}pGrqyYRxFt>Oq&pUmzE-d+!^8NQ+;;WJIGti-$9gTTO9pc!b?d={>dsYo$}z>^QN- z(_Yu^^hRvD%HJX^v(ZS!mel??`99JpHZCq((<-#@lcPyIc&vSO`%Pz&T`x#Nt31Dv}n`{kBF+oJ>Jn%&PG;#`FvCnw1~ub_(s?tlh`7-`E0#FagHq=n zZ)lRxO_JOQ<4pf{{+{6V%CTWdwNP!Rl;taBcENH1>ul*nK%PacX%sgV?uVuO;oj2V z{ff+^o`{V=WX!Yoh`+^laK?t)Io)Pv-0t_mJ3~knzZk1SvN@<(>nYmbxtf~fYz2B_ z>m~Jqr^iv17CHVUx$+WTzXa+e)y{>g0eX3E1+zeGq|~pkn}Nus^ky@5Zs9DI7za5L zuV}v3)dh`$By-QthZbYF4f8fN_YRo{u%qC}vXOCd-AODvm6DYcyqV#-AT8O5v-=3B zu+tlmLa9c&x{942L8kZ zI0ZvpOFOVsm>_<#F-8x`^xjwSsM}jhSf7Ecj`Rw5Y1dMV$_7a#B`~{;kT>UmR_kU9O6SF zf~V>PB^Gf#@S&*txuy|U?q=%pC*ct+Lm>mD^&{OS1_V)Q1y2XRznP)KFK za63z{(G6ODs$b%#!x^F@VT8-e!w)_!AAU&%Ybs)vSJBHyk{Y9=Z3#n!f+^XC@HD!7 zkk@HyP4%OsK<1X>w`(g>0PE!28pM*RllmQ{h=}C~Db%ZGF2lmMWe9(>Mlr2^6phAO z8Jgz?9z=9vACsl$xttvEtv}J!`~7w`RsqN?Z3XGVxkXIMWfXUf+FCQ+zSCD~>oy7k zr4TzNcN5;!Sf=#4dRnY)dMqlSZQ(n|S}q=FB8BB4n_3433`$(uXR*?4EARv3f+#>K zv9rd{os%lDzB+&hI^_#Wc|`PfmNt|c);+t-f1dk5OQ}egI=;|(?W5OuqDXR|D z4L?Kt-?9Ra#a6a;>gLsG>z#L0$}{G?d#0zEYKogn)!^k38r`Bv{R5F@w7_IzvMNNG&=q4&*9*b2G12>`|)y|@z z-OFCL!`7FJxaiPx+hr%%%f;}$d9z*5h$sZv!I)0MM5UW`((ov+>wq&b5+d9T4y-}O z6IMqKv-gr8>^^94v`?rhwFZp4(_jl&@$qIVoy29`>qUdeK~!cs9Ob;gU#^r;vsd3( zwi}D)xcsUsN|?^4Rq;5TA=P@PH`m8~ zy$j?Bzx5#;>!1T&x`jTFDI?eRac7JxJ;niMVo(XG;Rbfs?NG0fq zy}@ng+#4RcdA>ITd9LXYr#b1qRcNrct#`TCL>ALYn}1~4#VjQ#7UGLr^D$QrUjEcQ zOJ7b#IiGKuu{gLeMb6}l>N6gZni6P&JI+e;&WuHkyV*|sm+$51VO!dTqtwJ-Apfvq zQXp4rN#!u}IxA3GJ0~xT)TLb?Eh*dLc3by7OJC5$B5*?gZG7lx{(Co9{yVMXRNh)V28Ni*1p?+#PdAm>cuXL@X z`tEDk60+Qwx+$rCkY^NlmqWZ3sFlOP5$5R)G#5SWo==)5H?bwag3$}9PoHO#`~%Ot zs#D*}*ta<}7oR$;v6rWSg+BKs`X}G|Thdk6+&1WT#9-Uoino5c84ETJ3G;bhy#D$7 zmCR3B3K(U?@6cq=UnDm@8Hf&Vh;h=8( zC(rt1iw0_& zvmo2>$<7Y`q*6=xh3#FY_Y->@Y;L2ui}l{3a%wq6YSYptH!ZI(@Gav3v}Af4OBBeV zcA}Ov*`>XB%12YP)3c&?mY1F|(W>@G{gUGbG|#jFiv`f}FN@seclTxmt9n!meyLc! z7gRoykqJ2Y5J$eIPJDgFfjO(l())(|I3D6^Sx%hwNKiS{E_a^QZ)oB#RJW$_7T^Q8 zYGR6$pN|l+M#eg1E!MZF*Z#ehm6c(s`%gA{8=3dtT)^zC=nEb^*=OFidwYAxdfmS_ zQJx`tV84mnA|}@CAH5O#UR5js(2Kjih3`m7-~j_ri;p4Kd!**+n8C%wbe_F$8O+6| z0;pmDjAKB4?fOKVSXU`AW&w6~wY`tynZ}j#_0BI(3-Mf6YEa!!nnIv(8*;i^Aw_IL zV|B53P+`E2cYrfZ&`>1b^xvz$Q$Rz|du}82;F7DlMM!Vg$kB8*Vdd`Ii7;wp&Onqw zT9R-NxPO)^A^4`d@@`gEn9RHDtn1Q)k9}zV3Lc*5tEs&!XSdZpowhEa$x|dHGXBtH zqd3{sl~_|VJ46&_hV|Kk4<$ZIWtDMufb4_lv>FAnQ)Zg6>cMd)MyHP&d>*y#pW65X zodp06TV{Q}Z3fu|$C&SD)r-LUwAAMGs-^B~Y;!bSrF-x;NyD;TU28IuYKztIPigsG z4tv8y1^xO{U*sXIu;M;Vkw^8hiDj2BS8m7%H|}PdoEOWp%YPQ|;j=Qb{*t^-Dr7D3 zW$I5|M+rdM0D4+F)#he+6`=#x3$wh}FAokg3QO>BFTI>_1mt7h^i7oPtSy^JDb#}~ z-JIF z0oz1$rBoCGkoAqcsXiGQf|rmzB?r4hcP9PTE5ruZ*~$*`d`Q?GJ>lRKJj)A_GOja) zY0ze2uzHyBr(qt#YlR>l>apWmhl|`&at=Kh2HLEebvntBUTZd8vDb`TW&uN{x~_{~0+tG(RV2KV(&+?O8= zh`i?4>OZ$`*fnnJiXTGpoZuYid+s^9m7e4LH}U$q`&rd$%1R`+pte)9qVL=FhlS$F zSfw65EY&PoV*ozw&s#ED)#G!|31S&ACwvaj{c3!Gc=th;&+A=S>D)Fb%^UyXeBg5c zkBJx<)ts4#Vz<+qMiySpR5KKaA}1F!K7{z!X<=?fcxQ%`I#Z;VIgI2*s-=l9JQgnM z2pf-CxEGYaHeXcdCiV2WC)9kR(NBr#0rYThImD<5+<17}Er1ELYZibH?d-2?4~~P0 z7~ig(mdsT-eb~I=5llF5IQs@$uOf2{PbrV72@+^~7rwjf_TbuGW)9AxL#g`PUTtJqB+HPWng7zQtC5qid2@gkE?*Pq8FRD`oQ6KVN4qaB&j zYdBSj0~J)CkJS8#eX{$bUaV#SpYXO!U&g1l!vuU-og@6Wuuaks(KP*Wl3jKim|M_X z2X(N}RxiLAPA{Qc?gF7-26)^Bvi>4|;9aHUQv<~ZHsF)|^W)yIF8nYX)>FG#*GzQ# zpa*YDp@WuRp89Z;sz&K@r51ABf{%gO_;A6o2|h%%#%NMCh`4e{f+SIt}f{*|vjy$7N-B8P179x3!86KZU0UADeTm%@iiG4+;EgCOkr zK$9dzwUb|~)$vhuMr0owTaM_dDWl{Ye;IZG#T0vO2~D&k+qtA^iP(Jzx}s_cM7>O^_vX>7JDSi5-G#wOiPF0k(;Pjor1|liH?*gVH|wdY%$ZGX z<|rq#BfO4F*$ic1UnYOp!qr+$i405Vguy;#S{?(-!s0pWYG2;fx?!!S?bGM1)V}Wc zz#!NK>cir5!J;*?xsNShY+x@1XWpazgsM-f2_V&I`@stTI6eYu>F_(enxi#fcn1Po zWt}awyWru|<~6GWTQ2MPgsiJ5ba`)?L6F(9>JN zq@OGOK(QH?q`VL0y|eKDFLUY*{8W#>#{^T?)2Jq7u;fNBU*tGKpck&xt&b&56-_v2 zH~u1}zn->#^!<8xr8UHL*|-p@l)cW;B;kM}MiJ|0tyl0j%{L%}^)G%5 zmJ<9 zC71t7W08(~%-aZkK-VXgOdbVP@ z=+VI#^t^aN9Z(J^usy(V)YVUt%ReFoB8rCJe+~pzDY{eNR=BY(X%m4@P;?FQ5~UOL z)->i^d*?G3sP?hCwsRlbyh7v|I^N~4fy5znt$#z`;>d}Pez#|CW~QZlLtpjtuGX>l z;<>WUmDxf76>9s#q*{|9EqjxgI5xAl_)3kfEW1{;euqu)yMyfTTw!U*(K^3}oNh_{ z>A1!7IH;?C@WrNT}peL_p4U$emtbQ87S=QWDy1})V(Pfbd)pXI4j zA?F9pQ~|}4S8NX{Y5VS9dmY(l|5K=<86_^Xdv~!YfA@3>8z#iRa~VoK>HV&0Z*BQU zn!B*&%q@$Up-qB`aQU8Q%hh`{9$E<$QYBm;7r&CkQDaMHjWUMxcXd8e6V7YS zAAGxbYWcK(UH+d{>y&N+izEGL{{od(d_ zyr~=>+J8?J49>0K`rEvNJ3u%PITGf`RRZm>Nj2!wCX4lg+L=*G6lSFGD*a`~_)7a} zkR+Bt-Rw)WnfCkzE+-VgU{QR0eDD^xRqKPL*FDiJbCFjGYTD9==^+Lc^}HG#pUI z^p|HNrUQ^ihYagi4C=l*O;;#}Q^obZ22RdeY^ieAuUCW!NVF6dP&a=oBm3w4H(8>E z-`itFA~YuR&9G2DF3{TVZ-IFCZ%j!?#->vYUcZ|~{i^4w35|xHzG~GZp8e3Zo{HqZcw22yxy5d6pfHyqEp^L2IOU=~b#EE@I6^AjM;k+@&J7R@X_CT1~ z-Nrk*v*FU0#8bdgPOQeH@I9Gqq^E$$Y0-Q6gap#I-0&YX^Xm=#xZ=eNh7E2jm5c(< z2oSmc3LZ7(x}Vo3Y(z{_gngw4i=CTdGT#ATs5gE%D|hEV@55ZB%Ph0ykEm5xBtkjx z6Z?8Uw(t< zmMqm4G&+~Rjrp1$Uvt0tFeJ6rX!D*^eN@1HYy4sA`ZBeGXfs6#e~~PJX-6)RhlR3B zmqzfD^M&92Kp~99^H6)0{F3^!yvDQr#EH^!vD;Z1r4f*eZpWX}D}yKuwSNF#oB-|E zhKM~T{$<#O=wJj-V{L^`pt`kQZTY|=l84KTB!eJ*Y0;u)EPP6-{%jIK@tN*jw7I2x zW+yj>xC-C%xZiNw z2NxMvx-j4z`g@|!itiGNGV2J+Mu#e2L_@dbhSu>NwhqqO4>;?3;|M+ede z{+;p{XW0Nn;yy?=?1#9Ew^>N0sU=%)J#qo_*G4r#@8L7Ed1%N}(X8+lA$ zJ6#M;du!gaF}}p3A(9>dN@_apAi{T#_Wn{qa?h5#o(=n4jW`t3`W^h>7u{HL>bobk zI>|kiGKk&4;*_%brR^AF&})S6Snx=I ztF~4+dD0@{s?2>Dqd$C@A0*?}-?O!4QP3$yYH%@ao*(_n(1~U_MPH%6Tx}$MbcR1E zz6pw;wXqrpu&&1Ap9gbf8?z>ILnmeHqKpr5KNAQ4Rw*tBWbinD-1bn~4DMA^@8Nmp z0c1HiHVdGl9vlEOtc-NuHm+Tg36H&?f_S|*zAoB}b6=H#5}yv|+9v59?97cyzV#6R z2TaU}f8G$EgkYWysf0kTJa&eML@88eM%!ljh&wmm?Bb0^D>1SS-8uVsJ_sl#gBDNv zE^j>=`Z+q&_cD=!1Jc6G}8mf_n|-!k^xU zE%1OZ%SCg~PY^THoGxu$i#fOP&k2TEYXJ~xbLSgf@yr^ERoeo7_N`f3YIlyByJ(r$ z^7IvAthf|vdwg{MRM&O?&iUc!%gY@9WWijzS$2J*RVC*PU^1iBj;eRo`$U2s=6Fq4 zsJ7j*PX}^}zBWPvK4yKVXk6UXOo4u%9b~zYgaI{TMfW{E5ZapF0=JjY%w_MfQ-4e2 zF2E14-xWD-aGMqN_vWdZ4XMlo57H%w6L&bh>MkI7O)(L^xd5|i@UgQYOIz?bJ+-3W zEg}t1Z4ihz#H)rp2M{+XSL*Q(%CX8WKu$ppdfc{9>Sd-6d&*xt4F07{ab@Vs?77-y z7eRZB*`@!SdRZ}a(OP3MGXCOY&HDEfW=k>#$&bw5iv$?$g zl8ZkBvLDL+XY|ZoPd)Eh@4+88J8hkiwE1 zd^fFu$7?LWzc`Ptdz43=;L2X^AmDm=f8`^q`8$fH+;}S@N{c5;N+a&)FGfS_6$VZ^ z10kPvnZD03TnZ99=C?D8$qC$xU>$ZcEn(1t#b)AZ^}WiiwdSjljjd7FI+E0LOJBWr zK&eSD)2}YldjXF5^R}CP^E*^LUB_hu2AR8%XdD3fA2-Dj@FW0(uQqt(_hNk`LnO9e~ z31kTW?p4FS9F(xIqov!vr&DL#m++L1p6Hm}y~*!58Qwn=w>|%Psa?^f{fAAVWSc~? z2RcqhGT9iU#K!13KlmL-c&zNB)Ih{{5Q&stAjt6j4W-hqB=HBV!E7%ND^6IhJqT8Z zl{a6p$A^>_E!g=3g>Ulo?`-6RF2=iiacQi zI+^Z)!M9dLl4!dK_?_8c-QhyyutVMc)a;-r*fZ@@Q(1nK(RRO;C&(@lQTkb*9Gz}pllRmgB{#>tyt>_7qj8sCG4V}U(<;j@S+Sf}iodkF%v~WWS8%Vm<79zb z7j{!UTl3YdS$%bpMoXi@`Q!{!f`Urx>R!O@$w%WeZY5B=^#wmW1pBH^vFm7VgA1{J zLRZM6Dv7QitJpg#r7*}~Qd05F=a=Qj{U1IN3#HslvvBgb!u-jr!F7}+)8_?V#rJ~p z&E3QM;wQIb?*M+7VTg~J)!3_{T@9O!LGoAmAGu{Cspgq|t~P!v-l*179oi>K#qP5t zbCIdeboS(Qe~DG*V%p)DowQ+1My1`IZ@;uty9+z z9lojGUhl{!voukWLdUR9jDqNfdUArgQpvj{ZOvqFy~0w+>*8qq%D3KgB3jjWe6MU> zORP-tKFv+CdwxLYtm)aP;jZx;X(m#e8d!MbJL$7xJ6mUr);%is6)#HY67)h_(Q{L}7&}gN@yrVl+&wOF~eflFfxBB^l}Y z>12!qu4ujbyo6@aZMteW`*pJxJ_-NTt;twT^9WmQm~ncIZ6CZdG`ZZqGX$w_5rwZr z(YWwt&p(3j5EaB4B8l~3m2w|1G5JtnJqk}d3^b&t`XyLgmSH*v(=S?Csj1cP7P#)z zffw`dno0rfzj#RNZ7{iN&HPQZ@=7ciwMMh0IZ;`obQ1MglF46mwwAZNI{EBQn*!oz zqFY;-UzihDcbh>*D+{_OMpoOQ$V;a_BcX1PR_#IxRjSaYbOiMHM&bTZ3Ng^(2sE6Z zzj*p^WGHi86q4RqZS;91)NSjC2+|+LikLCTnV>2pIz*q5dZ;D4F{5qn@^=aT(zR0Z zEY$xJ78juZcqX!MUsyO{hEGu%cC};3Q@vg2$t${M%<8c|S>QL)SK82BnGJk3JKQxk z;kWNktEr+>y{%KPoQCY%WM3YS&ildeMw5462s~*-VP0>{eHxWtW!p_;2rcFj?ALZw z-96d;&al1uTDmKh6Qhl8`ryWLs$FEqn0^86kqz!K%xXN#m3jF+q*A^9Cz3qnD@MH9Xrd&p zlAVZVs#8rbNV{Q$#O8#Hsy^JM*hj?P2Y_nNjoE^W2x{o+dEO>3_AuSnU|2ucbOtjPqT}|iJ>6nN z>ix2xQojtgvwA%Y+|}*5zL_w@tt;KkTwl>ar3C>EA1ifZAwibn_cYi+MU?Fv~M?}bp@Zu z`f@^)P{fN|;(gjrKCgMrMff6z!F&8EJ%YPJDPIj}f0V5?xK!qp#u*mO=>qxskraz( z2<*!IH29!)ZD;9-9G-qN4Q9iJMBAAVMYKguc#NNto3-!%>4w<} zUTr~}7&=SyBWjfzfoN9zcy+=>BHr*l<(G2Y_ddnXl_g004lTC17ZvFr*dfW%_ro8U zd3k#D(2Be>?z-Qw*bN(qzZv*oRI8Cp6Fi?|+eDtENd@n&+JAJrR8(|INYS`mU+h35m^TwjG;WEAt1VM+6J&_bO^Uzecz*BGkGnj>+T>UzWG#26T8LI$^mCM#pU1 zqurCWOOzKyC>Cnepw0ZdyVCWIFC)BgNpxkBjMZa{=e_6f=Z}kQ1x#%#FM=CtXvE61 z$9D={k}GO;PuX+p=ifm_6gEdkzQ?GQ{CG3pFqD!!e>=B^5f0_V1V)sRb}<@#Ui%_a zy(4B&>ca@cGe3>}$38Ah+CkYcCH%}sp)c+&zPMK{#8}@gClMyKnF|UsAZ%(^0+os~ zG@ke3tv&hpC#6a9NNiD^_^nTP5UI`nO6Rx`Y;|yu8C}!+ry}QSSem2x6m^tO!TZBAr-j(+ik6iT(wzq{O`V z)SoWnq1@5T`tOerhvi3sS+5x-MLtA{rU+AUdU@UJIQ8M-)p71qdt>RlNok-gg>L@f z)^VH<_ckjfOrK{SGWsO$-PnPtJj|%>cf0a%7g$xJ)OM&FZ1jg1cWkiTd1 zTP}=o{*}YH-Bj&)rFT|3SV?fMpthl|4#;5n26S5~Pfadt^aye0EL5e`j|e##&q8y~ z>}ZWy1)6#qgbhb*7Ccpn@n%kt)AcfsyW9d@30>ubD5HjGVpN%0`Dk+vwH^G~Se%&Y zy)%EyP-ojOuiX!kc^Ae+Xc>1vbL5(!Ajv`f$MlyNLNY+CIC!Dz!}|w8vB;FU#%co_ zrB#?&C=-?xJ@O0`(l@@$^fc9UNKtBTg_@4 zzU1A;oS1rT>z7ccz&kJTn9v4=Ul)oRmW0jq;KikU+x0wVp2}@TpTnFi`{`^A=$jpP zyH=)&H>!+w#E!jamPy}P52WRcDbF@41XluLCI8e2_|SLAyBAjD(20oZV;C=<+1hO{^t65Y?1k`c}9(`6pILQR9shkj*CYNhmgpnpm% z_^u(QYCC-vGSnmSP57oCn9}|DoR~aXu>4SfNs05&8$m0u`2mNze4n28$SXs4fV*)K z5J`QctzCplEn9`(@UI3ux`wx>DfcgXcD395-Jr?v)fb3|scMp6?qc^Eb|By4attjW z&;MZ#YS734P=4PEjUAy+rb=~;(KMv}+B!DF2c|0vG&=RH%lRY}E`?nC>2IRHf=8@R z=GIq!nPavyR9&HZ4+pzV(@7;w0qIqh(}Ph)IcCFJ)+F+ir_fKEw_~}EaKchvQ<5{P z(Xp;}6ORzBTZkjt%RM@@xxWwja{>fK(NEh z{faywA{ZrFYUyREw8CuPYb9Lbawq<9v5&&`_BHe)}AA6J9ZutJ6p~8J}r7x zU95lAcap*S^g6+F6%9yCA$gFyWfxR)XH6%phfNM2g^WVUCAd?HQ zJ=j!p(K3&{!xyRGI{|-7A0PLS5_WUW|hc&|pDUIlq4HL*=n)tI8BwL+L4${ByWo-|FJycY7ahqfa ztqB=J;Y)q7$(uOLr^C_BzhjvK?)ew0re1l@>2>C{%XBV% z@p1}IL@)CuY&IB3`a~~6&`?>J)#YUnLEk#~y@yB7ubeu#VQr(ya0%5n_#odLgia#% zoKL|m!v86OBd_Xr$Z1XNAEK2uDW^i}0{xSY2Tli1m4wUZoCTAiM4bc@&&8RGlsBcg z{$vjr8XGQ1N)uBLmH!O6@1icB*3hUE#sA3Oz)i2-%e8U!v%x;EN69kBnyI`F!a~vJ zs3~z`&+4lG@xo68!V7}9NYRsE@S|lXmO*S4cA`EHZ{#>?E3d(yT`jK3MvzVUT^&W$ zGItKr9!a~kZJ6QK&!crC5&YK64d!hvA>!+|Y0$h<&*;S+^L3Gyj`(?&N@DEeuI|m~ zRTjf9L;+x8JM#PYYd?pSCq@#cM1Lg#q)%CW;Xl(O)+_6gyLqHy!gx%5j8~7yaLTgf zng{4aEE`Wqhd-BSsNH`5H1P10dudvtgbmwCrm=v`H&cLy9EIsEE$worBumT!#S53x z6(L~)bJd!5!&ylud!B}pas_xb6&v@_HQOUCi!t8kiTfGz4H7%aP8@{rRW54kQ4Z8x zjDJIynSw&`gpG5TEIBnhlG5aYT)#x~nO-Dmykr3JMoPZ3yu z;j!5}&u^GBGidIMY5HbU@AQIrYoU0T^cWh|xsPYwlp>Z}pH{gt74)C4Mc5vt?g{Qj zFY?EZ+*MmXgLJ5QUxXnlPDB}Wsw1gKt*g+2QqL{wYmQ*QK$OXD&ln1#%bx*N)5OH5 zKw2;3drx<_y0ASW=sf&7?aeFPw5Y{Fbhoy2ejahb+*qH`aK<|%A2u##8Av$3?|u5G zmN20`Un;W`UgUu6_U1s9@G=MXR0dqdyM1h3mm|n{=de$={zE!isR?AMHTWFsUPn|y zhbFcubN2}wpfg}ZeMsbB9(YV|Nyo18N2(0we=#J-*K|CxF76yIMlssg_V||eOO?K+ zU8~d#5KCGFB`@W#@D=$t-|uy+5h=g=g`BnT#%29!Z{8`aNi(Lvp(AIJR`9Gh(3p4~`wUP3 z_`wtYpTy?GP5+1)kVN{8n>DfL?}qaVx-OH#)$2)Jk&neQ4+84mQ+Caj_}6~gq*w`e zN&w%wm1tYc-I*Qursq=_BrVreuRe0e_F}~E_|0R4I%0BFXU&0f{@&`7PVNUzhMY?b zdK~YF(5VR=L)i^RT`8e$O*$Hf4ILe^bvkI+VjpaR4db-3a1tc@OBHiJa~15ruV#5weN4T0OGD7xlRI4A`RqA@hYjb{PwDdxm=5yM@@C; zwL0PN3f+Ri_*xwNN`LH)d+u&A@4?pDMv8w+{2JD#>99KDzZC&PPWVv@1cc*d>F()059N^)j}_15h$&KAUR;9>8{3 zyx-)%^mSZHA44F=&Hn!Tj|p_&kS6iPF*fJCzDkf3OoT9UoL|w17#|XvKYm;J$<+MK zX(9y6|}2Hm$7Rs26aLemc( zmw%s@l=kN4?nUx7W#(tDbSA&!s+Hg`Fv^yalK(z@GTuW0jC7E*-(j}HlFXZjtQsYm_MY_1bd_i3cjNAc=V*G_D&7=u$v?YXO zOA_H~zr7mrW1DK)P2O=*{d2)Gp~q0yAY0AdsY}co72Gq=?&(TIgKRofRX;`z?5C*D zH!dja!Y2R37GKy7S`~nUx*;j~Kj1SFpMyE+u}uw_}mH? zlR0{=uy)G$0>g+eJv-ws=o~$<}+IDHV^3-7v=cl}C(MN>}oLFXH_4PgH zn9O`>jrvKtR6+@g@$gpzq9mzn&4xL%&fG6Inefwlq!W8vo-#5Xe^QXad182EmyO#T zS9T)}Kp_ES+AlLxgj1gd08|0Z6B>R?08sr0CVt)<@6Z@mLV+V2tt+DcA(!W7tr_q@ zfGVY~Ua*vsn4vHk<$%NF7c0!1o26Os+bgf^#(x0j#)7)k=EV1%H)3z8)<1A#8?LNR z#J3|H7#d-Jzob|xh#MpSDF?)0i8Puc0HviFIW$Nxf zC^M&OhDu+hW$-Cx+)8l71p}a|H39J256;gs!1=pAd{?ez%&a%Nqg58^V=12h ziS z&G!r6&|LfaQpw4Ko0{{9w(8y%=+GR;AW6A^!GHYa5sXxW<-IgfHDaD{8i{8~VQJ^f zm>-BPfXG*$f88Ku`FkoiaFPjVIG89Z{w{Z3%NbS9TT6%@SY`!ph_BgsZCbF9v8Ri~ ze#J5(XJE7R`i17MH8*P0SGe>Xk$GvyGk`%gDp_7|>HMw<83bGcG18E0JOULCu)FtJ zxJlHshza={=ZGM3fF)lK*$98Q7~^pLd{p4|8M&5UiuIH@dk49RIrSf{QyV9`83lQm z02UIx>u$dFM@5e0FAGBR69AahmahF`{d~0hD-$NzctTs^iN4paf=w?HFb9cE2= zV=d<)j;~l&XlBc5L`NsnTy5OOM}+dbOoy>Njpz8+EyZVNWaQP)ZO9%qGch$aHs(Jm zmRclQ1Ikixj?C=r4A0BA?XKz^|CMCb})pPM2WGxmoKnW6@Rb{Ghs{icF;Dvo5LLZ@iQrjC5bj@wOj6yIJw<&a=oD zf)!&r1n&*L)B8xUu)twM-uvE{Z5M|=b%HOxRt`F=|2UoDpZ*MU{ljX?rHC=L$iL~DfBO?=>Yr$3O1 ze-%=Fu&Pm5waS@akcvE`KE5jT?8wllmYXzY^aDc?bH#11)AwAvf9{{3vdZ7rlx&;1 z_!_Z-07|<)qT;<9WjQ0Y451=>FG36o(QSJ}SdY$D(+wK2pR+O)s2Jg?51Jg{LXAR! zVqK`rtW`v-_qE_W=dt3zY(Ac=`jWPWn&5ichaqu3X!)ld!(Tnz-PHCouO$aXt0X_o z-l8v##}EIiSv>2aya`-avzVm8PqgFO#3B^mT)jPMl4ZaCuIi>anMidTNor z#5i{JJIZ1bruMpTAGqeAe7vm2XJC6~jHk}f*x5#3avG@wnW~RM_w{#>=%JilJ-(V( zfI3{PcCMR7uSzbcV<0#Zeusu6Jo&0~ckg0GK3a+B?*T7|w*ll_dBWMnyx&7gPNT7*}2ov*-kT6 zJ%?YD*yFjA1(1iZ%kYa{VZ-y^1gORH(R?*}&_?akR8horE0*lmj~-xzN6D>iWU;&u z`?-WJVqs(d(MtMH>M9!MJ;8qaWY=$uTTY$+S8 z(G_+EKI8uNQ9Z;J3DX~4pC?B;ZXdF(1D}9VQQTXORFUq=uhZY3B`5Z}=Iczf?j8*x zoYTsu)xfCURS8*WrH1nHMp?RUP&?I_kj8tMtip3Y!F{TQ6g-xeJ1pQyS}*G=T%p>5 zm5zf&h68v_V@Y9cA@oiAV+#R%VJL~i41#09 z&cF^btxzGy(Y2l4LV8La`dYKsR9C5hjtq)U#S5ix5>q%R0kuHH_3BEec%Z&sN{2UjYcKa7PF}28 zecOTX9%DuVr0HF@Vij?((C6^eV$SBh8M}06pV?d=j|)NyqQ+VVlNh_17ak`Lyrn)z zzntW@lVIoy_fcHR=|Uz#@-Z7BoigKfWk1b&f{X#``l$uGljOwW>i4y{1=yj`sLVI*IiLJ z7#5ap`*>^oyzHr@(v;XI*$#R`AB)vd2C(z)*&1mC5skBaf;T7%=$Vf(dbh)?kwgP2 zoZ?M+TRZrd;dQ?ZEmLV}CUW^ShLmP)Fm^2CxyzYJH77z#a`4y%m(??ZIC%J0YFgv@ zKHkS-o;?Aqb$L@V0ZD|B5Dss7HyU zBKtt$w24W~Zu#OTeI{=F^SRSdTkz2UDQsL&bhGl~#Tqpn1Zv)F{M5yrmeSd*%;P?F z*p)rs9cq^Daf#FdBe!E{wKN9}nmmyfjLnIT+r``4MRB{spCX93wI;^Ij?Ncd5e6fL zs0KR`vk7`SAM-`;23L;V*rc1%xk+T~1%(uMuQN4`T3oo&x!yCJr3Tf0G5Qh0MMees zBL812=l_(19ma8Q+;TTVZ`ZOy%X6+w4P0gDWWl-Sni1ke)RkbKsY}!}oM;MIuDUXF zE5j6$ji#k!Xa@KVsafYpkSS_>k#HCqDrn$05M*%M?JwA`pC6ukKhJ%h=kxkJ_k2DC z>$M*gJu7?R^OGzo5NQtK<+BB<#%a{uxI|{W<&J+vJ$@_*V(b@q%!EhZx~XMV znXL@6xfLY~9lVT0R)*j32JRwaXkRzsP4UMBo_iIAGWZp*Z!%;pJRaj{nh2V+_*f-B zD{Kg4gna@_=4N9bH7JWYMF!YuA^NcWI>0M30+!s7xeRt}s5Lce#Hq<)#h_lsG)|OA zksGT`*0&SAGL+DG6Zt$3foGebmBP zM*T>iB7e+=QuU6COCPh!&SPVQ_#p6|B(9!k4i3R&W0Kc4NUFo$URC}cJF*y-+gn3u z2kacn3Hl2y0+ydAF(zOpiB2tm;Cu88Qg*RcHN<9cY@N6za*ycn&*so<1Uu&5aLsh;TCG zm9g76SkWed?4g&+IC@I3+uRs5|%OVBm7j z^t?7^^>|F~3*#BcoI4)OC^*-B*gsDFQ`zE<808gDIimJA;d zh(I88JQ!}r$h=$W)gB+3R_EHOQBhG!@V^F9AP2giUA^Z;B^pPPQsqiK+wZrTzoHeo zt9M&}!Lsd0qNT=ll+4F4b!F25a8ncV`aI);FTgCUuh5ht!e2#i#l30dG=}zUI`- zN3QvgB7k4?WI3+Rbr)*$X=Ayd;{R+!JDhJ;9FG19n#yT-lWSiB+~!uBL6<&*%AG52 OG10MKJ^J$0#lHbY-#;_} literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/hashes.json b/docs/validation/pr-28/hashes.json new file mode 100644 index 00000000..e96d7406 --- /dev/null +++ b/docs/validation/pr-28/hashes.json @@ -0,0 +1,16 @@ +{ + "algorithm": "SHA-256", + "packagingSource": "9aa1df286c2b6fd59b4c101201ddae0ae6000324", + "upstreamSource": "0965053fe6b9341776df147a6934b7485c60b5ca", + "files": { + "launcher": "a85063b9caedf346444db4da72fa3ab62a0bf913b6a4b6056b8d0b6d4a476def", + "node": "b3094d0b49f9ad602262a9921551737bb97637c05dd357a06ae98188d7290aa3", + "upstreamNpmArtifact": "d0d5b0a0a2ace6c0a957b785929af6c0c0d69fe5dc5fb47e995db25ec700548f", + "runtimeEntry": "4f4d29770da4f86dbd0e07cbd4d46deab785905dd89ac719033fcfd866fb5d17", + "gatewayBuildInfo": "b5bbc98a27a81d41261ca7984a36126ccad982e3fb1ea46e67234cc007038648", + "plugin": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "controlUiIndex": "73ac0e6f38896c9d491b45a6c3d23b4990dae139d8ff3cb93da7a33e640bb1e7", + "controlUiEntry": "1430574e4d89d8c43b303057d918fdba41a4a940d436d7feaf8e0122a533e4f6", + "screenshot": "1dd7153742614febf8d00aa08c50f3c3465cdd8a7a3b4cf87e8fa585177a0142" + } +} From db2b16c579410d1e8895e1f491d920d2a024b89e Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 10 Sep 2026 21:15:15 -0700 Subject: [PATCH 24/76] test: expand Gateway isolation contract proof Attach runtime and payload matrices, clipboard checks, regression results, and authenticated Control UI screenshots. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/validation/pr-28/README.md | 183 +- docs/validation/pr-28/copy-checks.txt | 6 + docs/validation/pr-28/copy-fixtures.json | 104 + docs/validation/pr-28/fixture-disabled.png | Bin 0 -> 41835 bytes docs/validation/pr-28/fixture-empty.png | Bin 0 -> 28633 bytes docs/validation/pr-28/fixture-enabled.png | Bin 0 -> 41850 bytes docs/validation/pr-28/fixture-invalid.png | Bin 0 -> 28633 bytes docs/validation/pr-28/fixture-missing.png | Bin 0 -> 28633 bytes docs/validation/pr-28/fixture-uppercase.png | Bin 0 -> 28633 bytes docs/validation/pr-28/fixture-whitespace.png | Bin 0 -> 28633 bytes docs/validation/pr-28/hashes.json | 11 + docs/validation/pr-28/launcher-disabled.png | Bin 0 -> 41835 bytes docs/validation/pr-28/launcher-tests.json | 234 ++ docs/validation/pr-28/payload-matrix.json | 3656 +++++++++++++++++ docs/validation/pr-28/payload-matrix.txt | 625 +++ docs/validation/pr-28/plugin-tests.tap | 136 + docs/validation/pr-28/policy-checks.txt | 4 + docs/validation/pr-28/runtime-checks.txt | 8 + docs/validation/pr-28/runtime-matrix.json | 686 ++++ .../pr-28/runtime-registration.json | 28 + plugins/gateway-isolation/index.test.js | 68 +- .../Test-GatewayIsolationPayloadMatrix.ps1 | 377 ++ scripts/validation/gateway-isolation-copy.mjs | 72 + .../validation/gateway-isolation-runtime.mjs | 231 ++ .../GatewayLauncherTests.cs | 14 + 25 files changed, 6321 insertions(+), 122 deletions(-) create mode 100644 docs/validation/pr-28/copy-checks.txt create mode 100644 docs/validation/pr-28/copy-fixtures.json create mode 100644 docs/validation/pr-28/fixture-disabled.png create mode 100644 docs/validation/pr-28/fixture-empty.png create mode 100644 docs/validation/pr-28/fixture-enabled.png create mode 100644 docs/validation/pr-28/fixture-invalid.png create mode 100644 docs/validation/pr-28/fixture-missing.png create mode 100644 docs/validation/pr-28/fixture-uppercase.png create mode 100644 docs/validation/pr-28/fixture-whitespace.png create mode 100644 docs/validation/pr-28/launcher-disabled.png create mode 100644 docs/validation/pr-28/launcher-tests.json create mode 100644 docs/validation/pr-28/payload-matrix.json create mode 100644 docs/validation/pr-28/payload-matrix.txt create mode 100644 docs/validation/pr-28/plugin-tests.tap create mode 100644 docs/validation/pr-28/policy-checks.txt create mode 100644 docs/validation/pr-28/runtime-checks.txt create mode 100644 docs/validation/pr-28/runtime-matrix.json create mode 100644 docs/validation/pr-28/runtime-registration.json create mode 100644 scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1 create mode 100644 scripts/validation/gateway-isolation-copy.mjs create mode 100644 scripts/validation/gateway-isolation-runtime.mjs diff --git a/docs/validation/pr-28/README.md b/docs/validation/pr-28/README.md index fafc9dbc..f9815d0a 100644 --- a/docs/validation/pr-28/README.md +++ b/docs/validation/pr-28/README.md @@ -1,47 +1,79 @@ -# Gateway Isolation local end-to-end evidence +# Gateway Isolation validation ## Why is this change being made? -PR #28 needs evidence of the real Windows launcher starting a Gateway whose -authenticated Control UI exposes the Gateway Isolation tab. This records the -local run against packaging commit -`9aa1df286c2b6fd59b4c101201ddae0ae6000324`. +PR #28 adds launcher-reported Gateway isolation status, a read-only authenticated +Control UI tab, and payload provisioning. This evidence covers those implemented +contracts against production source `9aa1df286c2b6fd59b4c101201ddae0ae6000324`. ## What changed? -No launcher, plugin, or upstream runtime source was changed for this validation. -These files add the full-shell screenshot and sanitized machine-readable results. -The GitHub user-attachments endpoint returned HTTP 404 for the target repository, -so the evidence is stored in the PR branch instead. - -![Authenticated Control UI showing the real launcher-reported Disabled state](gateway-isolation-e2e.png) +Added reusable validation harnesses, expanded regression tests, named test results, +HTTP/browser assertions, runtime registration output, and authentic screenshots. +Launcher and plugin production code are unchanged. ## How was the change tested? -The run completed at `2026-09-11T02:45:20.860Z` on Windows x64 build -`10.0.26687.0`, using .NET SDK `10.0.401`, Node.js `24.16.0`, and Microsoft Edge -`152.0.4191.66` through Playwright in headless Chromium mode. +### Evidence matrix + +| Implemented contract | Executed check and result | Proof | +|---|---|---| +| Interactive launcher selects `disabled` | Real NativeAOT `openclaw.exe` started the Gateway with inherited `CLAWCTL_GATEWAY_ISOLATION=enabled`. The child Gateway and authenticated Control UI reported **Disabled**, proving the launcher overrides inherited input | [Runtime matrix: launcher-disabled](runtime-matrix.json), [screenshot](launcher-disabled.png) | +| Both exact values are supported | Real Gateway processes with explicit `enabled` and `disabled` launcher-input fixtures rendered the correct state, green/warning tone, and inverse CLI command | [Runtime matrix](runtime-matrix.json), [Enabled](fixture-enabled.png), [Disabled](fixture-disabled.png) | +| Invalid or absent input fails closed | Missing, `invalid`, `ENABLED`, empty, and whitespace-padded input each returned **HTTP 503** and rendered the invalid-launcher-mode diagnostic, with no status badge, CLI command, or Copy control | [Runtime matrix](runtime-matrix.json), [missing](fixture-missing.png), [invalid](fixture-invalid.png), [uppercase](fixture-uppercase.png), [empty](fixture-empty.png), [whitespace](fixture-whitespace.png) | +| Route authentication | Each of eight Gateway runs checked GET and HEAD with no token, a wrong token, and the correct token. Unauthorized requests returned **401**; authorized requests returned **200** or **503** according to input. HEAD had an empty body | [80 HTTP case results](runtime-matrix.json) | +| Read-only route | Authenticated POST, PUT, PATCH, and DELETE returned the same read-only page bytes as GET. A subsequent GET remained identical. This handler serves status for these methods; it does not implement a mutation action | [Runtime matrix](runtime-matrix.json), [command output](runtime-checks.txt) | +| Response hardening | Successful and fail-closed responses had `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and CSP restricting frame ancestors to self | [Runtime assertions](../../../scripts/validation/gateway-isolation-runtime.mjs) | +| Full authenticated Control UI integration | All eight runs completed normal `hello-ok` handshakes with the matching bundled build, exposed the sidebar tab, and rendered the plugin in `sandbox="allow-scripts"` without `allow-same-origin` | [Runtime matrix](runtime-matrix.json), screenshots below | +| Copy behavior | Clicked Copy for the real launcher and both valid input fixtures. The button reported **Copied**, and browser clipboard readback equaled the exact inverse CLI command | [Runtime matrix](runtime-matrix.json) | +| Clipboard capability branches | Six browser capability fixtures exercised Clipboard API success, API denial with legacy-copy success, and both methods unavailable, for both valid states. Manual fallback kept the exact command selected and showed the manual-copy instruction | [Fixture results](copy-fixtures.json), [output](copy-checks.txt), [harness](../../../scripts/validation/gateway-isolation-copy.mjs) | +| Process-stable reporting | Seven initial inputs were each read exactly once. After plugin construction, repeated changes to the input source did not change any response, including fail-closed responses | [21 passing Node tests](plugin-tests.tap), [test source](../../../plugins/gateway-isolation/index.test.js) | +| Typed launcher selection | Enabled/Disabled mappings and invalid enum rejection passed, alongside argument forwarding, working-directory, process, and entrypoint regressions | [57 named passing .NET results](launcher-tests.json), [test source](../../../tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs) | +| Minimal runtime surface | Real launcher-driven runtime inspection reported bundled, enabled, activated, loaded, imported; **one HTTP route**, **zero Gateway methods, tools, services, diagnostics, hooks, commands, discovery services, MCP servers, and LSP servers** | [Sanitized runtime inspection](runtime-registration.json) | +| Control descriptor | Node tests verify the exact label, Control group, order, icon, route, `operator.read` scope, gateway authentication, and exact path matching | [Node results](plugin-tests.tap) | +| Payload acceptance and rejection | **66 passed:** 33 per architecture. Six accepted cases verified exact three-file shipping content, matching hashes, metadata, excluded tests, and environment restoration. Sixty cases rejected missing/conflicting plugin directories, failed runtime inspection, and invalid/missing runtime shapes | [Payload matrix](payload-matrix.json), [transcript](payload-matrix.txt), [reusable harness](../../../scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1) | +| Signing and workflow regressions | Existing signing-input and workflow package-version checks passed | [Command output](policy-checks.txt) | + +### Screenshots + +**Real NativeAOT launcher, inherited input `enabled`, actual reported state Disabled:** + +![Real launcher reports Disabled and copies the enable command](launcher-disabled.png) + +**Real Gateway with explicit `enabled` launcher-input fixture:** + +![Enabled input fixture displays Enabled and copies the disable command](fixture-enabled.png) + +**Real Gateway with missing launcher-input fixture:** -### Runtime composition +![Missing input fails closed in the authenticated Control UI](fixture-missing.png) -The launcher was published as NativeAOT from the exact PR commit. The expanded -x64 application came from the successful -[packaging workflow run 34546297901](https://github.com/openclaw/openclaw-windows-packaging/actions/runs/34546297901), -artifact `openclaw-gateway-payload-x64` (artifact ID `10179486438`). -That workflow's packaging commit was -`52f2a53fb62b13499ba2692d81870413fcaf943a`, not this PR. -Its recorded upstream source is OpenClaw `2026.8.2`, -commit `0965053fe6b9341776df147a6934b7485c60b5ca`. +**Real Gateway with invalid launcher-input fixture:** -The PR's three shipping plugin files were copied into -`app\dist\extensions\gateway-isolation`; the plugin test file was not included. -The source and runtime `index.js` SHA-256 values matched. +![Invalid input fails closed in the authenticated Control UI](fixture-invalid.png) -To resolve the previously reported UI/Gateway build-identity mismatch, the -Control UI was rebuilt from unmodified source at the same upstream commit, -using its frozen dependency lockfile and declared pnpm `12.1.0`. Only the -generated `dist\control-ui` tree was replaced in this validation payload. -The supported build inputs were: +The valid-state fixtures set the process input directly while running the real +Gateway, plugin, authentication, and Control UI. The launcher case runs the actual +published executable. All screenshots are captured after clicking Gateway +Isolation through the normal Control UI. The same browser context is reused +across the matrix, including valid-to-invalid transitions. + +### Test environment and provenance + +Windows x64 build `10.0.26687.0`, .NET SDK `10.0.401`, Node.js `24.16.0`, and +Microsoft Edge `152.0.4191.66` through Playwright. The application uses an expanded +package layout: the NativeAOT launcher beside `app\openclaw.mjs`. + +The pinned OpenClaw `2026.8.2` runtime, commit +`0965053fe6b9341776df147a6934b7485c60b5ca`, came from +[workflow run 34546297901](https://github.com/openclaw/openclaw-windows-packaging/actions/runs/34546297901), +artifact `openclaw-gateway-payload-x64` (`10179486438`). That artifact's packaging +commit was `52f2a53fb62b13499ba2692d81870413fcaf943a`. The three PR-owned plugin +files were provisioned into `app\dist\extensions\gateway-isolation`. + +The bundled Control UI was built from unmodified source at the same pinned +OpenClaw commit, with its frozen lockfile and declared pnpm `12.1.0`. +The supported build inputs used the Gateway's canonical build timestamp: ```powershell $env:GIT_COMMIT = '0965053fe6b9341776df147a6934b7485c60b5ca' @@ -51,79 +83,34 @@ pnpm install --frozen-lockfile --ignore-scripts pnpm --dir ui build ``` -The timestamp came from the Gateway's canonical `dist\build-info.json`. -The authenticated WebSocket response reported: +Gateway and UI reported +`2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z`. +Served JavaScript matched the rebuilt output hash. Authentication and bundled +build-admission checks ran normally. [SHA-256 fingerprints](hashes.json) identify +the exact launcher, Node runtime, plugin, runtime entrypoint, build metadata, +and UI assets. The runtime matrix also records the tested executable hashes. -```text -type: hello-ok -version: 2026.8.2 -buildId: 2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z -controlUiBuildSource: bundled -``` - -The JavaScript served by the Gateway matched the rebuilt JavaScript SHA-256. -There was no custom Control UI root, cross-origin UI, edited build metadata, -mock Gateway, injected status HTML, disabled authentication, or build-admission -bypass. +### Reproduction -### Launcher-to-browser flow +From the repository root, with a published launcher/application layout and the +compatible Node executable available: ```powershell -dotnet restore .\OpenClaw.Gateway.MSIX.slnx dotnet test .\OpenClaw.Gateway.MSIX.slnx --configuration Release --no-restore -dotnet publish .\src\OpenClaw.Launcher\OpenClaw.Launcher.csproj ` - --configuration Release --runtime win-x64 --self-contained -.\scripts\Test-GatewayIsolationPlugin.Tests.ps1 +node --test --test-reporter=tap .\plugins\gateway-isolation\index.test.js +node .\scripts\validation\gateway-isolation-runtime.mjs ` + +node .\scripts\validation\gateway-isolation-copy.mjs ` + +pwsh -NoProfile -File .\scripts\validation\Test-GatewayIsolationPayloadMatrix.ps1 ` + -TestRoot .\.validation-payload-matrix ` + -EvidenceDirectory .\docs\validation\pr-28 ` + -ProductionSourceCommit 9aa1df286c2b6fd59b4c101201ddae0ae6000324 .\scripts\Test-SigningInputs.Tests.ps1 .\scripts\Test-WorkflowPackageVersion.Tests.ps1 - -# Run the published executable beside the expanded app directory. -# OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH point to a new test-only profile. -# OPENCLAW_GATEWAY_TOKEN contains a newly generated random token, not a real credential. -# CLAWCTL_GATEWAY_ISOLATION is absent from the parent environment. -.\openclaw.exe gateway run --port 19428 --bind loopback --auth token --ws-log compact ``` -The profile used local Gateway mode, disabled mDNS and browser automation, and -contained no model credentials. Process inspection confirmed that the listening -`node.exe` process was a child of the published `openclaw.exe`. - -| Check | Observed result | -|---|---| -| .NET launcher tests | 54 passed | -| Node plugin tests | 6 passed | -| Synthetic payload provisioning | Passed; this is a fixture test, not the real Gateway | -| Signing-input and workflow-version tests | Passed | -| NativeAOT publish | Passed | -| Actual launcher `--version` | OpenClaw 2026.8.2 (0965053) | -| Real runtime plugin inspection | Bundled, enabled, activated, loaded, imported; one HTTP route; zero Gateway methods, tools, services, or diagnostics | -| Anonymous plugin route request | HTTP 401 | -| Authenticated plugin route request | HTTP 200; `Cache-Control: no-store`; restrictive CSP | -| Full Control UI connection | Token-authenticated `hello-ok`; bundled matching build identity | -| Normal UI navigation | Opened the Control UI, clicked Back to app from the fresh-profile model setup screen, then clicked Gateway Isolation in the sidebar | -| Rendered plugin iframe | `sandbox="allow-scripts"` without `allow-same-origin` | -| Real launcher state | **Disabled**, with `clawctl gateway-isolation enable` guidance | -| Copy affordance | Clicked Copy; button reported **Copied**. Clipboard contents were not independently read back | -| RPC errors during browser run | None observed | - -The screenshot was captured from the real rendered page after the sidebar click. -The model setup screen was exited through its normal Back to app button; no model -was configured or queried. See [e2e-results.json](e2e-results.json) for the sanitized -browser assertions and [hashes.json](hashes.json) for exact file hashes. - -### Scope and limitations - -This validates the local runtime path in an **expanded, unpackaged application -layout**, not an installed MSIX. It does not validate Store installation, -package identity, execution aliases, MSIX read-only enforcement, signing, ARM64 -execution, or an unchanged workflow payload. The UI rebuild described above is -part of the tested composition, not a workflow/source fix. - -**Disabled is the real interactive-session launcher's report.** Enabled, -missing, and invalid values are covered by unit tests only. No isolated-session -launch or isolation attestation is claimed. The CLI guidance was displayed and -copied, not executed; changing isolation remains outside this PR's scope. - -Only the validation-owned Gateway process tree was stopped after capture. The -test port was checked to be free afterward. No existing Gateway service, user -profile, installed package, or system Node installation was changed. +The runtime harness creates isolated profiles and random test credentials, checks +that its loopback port is free before starting, and stops only the process trees +it started. After each case it verifies the port is free. Public results contain +assertions, versions, and hashes rather than tokens or machine-local paths. diff --git a/docs/validation/pr-28/copy-checks.txt b/docs/validation/pr-28/copy-checks.txt new file mode 100644 index 00000000..7f2fa31f --- /dev/null +++ b/docs/validation/pr-28/copy-checks.txt @@ -0,0 +1,6 @@ +PASS enabled/clipboard-api: copy success +PASS enabled/legacy-fallback: copy success +PASS enabled/manual-selection: manual text remains selected +PASS disabled/clipboard-api: copy success +PASS disabled/legacy-fallback: copy success +PASS disabled/manual-selection: manual text remains selected diff --git a/docs/validation/pr-28/copy-fixtures.json b/docs/validation/pr-28/copy-fixtures.json new file mode 100644 index 00000000..c9f65a56 --- /dev/null +++ b/docs/validation/pr-28/copy-fixtures.json @@ -0,0 +1,104 @@ +{ + "kind": "Browser capability fixtures against the actual rendered plugin page", + "completedAt": "2026-09-11T03:55:15.070Z", + "cases": [ + { + "mode": "enabled", + "capabilityFixture": "clipboard-api", + "command": "clawctl gateway-isolation disable", + "label": "Copied", + "calls": [ + { + "method": "clipboard.writeText", + "value": "clawctl gateway-isolation disable" + } + ], + "passed": true + }, + { + "mode": "enabled", + "capabilityFixture": "legacy-fallback", + "command": "clawctl gateway-isolation disable", + "label": "Copied", + "calls": [ + { + "method": "clipboard.writeText", + "value": "clawctl gateway-isolation disable" + }, + { + "method": "execCommand", + "command": "copy", + "selection": "clawctl gateway-isolation disable" + } + ], + "passed": true + }, + { + "mode": "enabled", + "capabilityFixture": "manual-selection", + "command": "clawctl gateway-isolation disable", + "label": "Selected", + "calls": [ + { + "method": "clipboard.writeText", + "value": "clawctl gateway-isolation disable" + }, + { + "method": "execCommand", + "command": "copy", + "selection": "clawctl gateway-isolation disable" + } + ], + "passed": true + }, + { + "mode": "disabled", + "capabilityFixture": "clipboard-api", + "command": "clawctl gateway-isolation enable", + "label": "Copied", + "calls": [ + { + "method": "clipboard.writeText", + "value": "clawctl gateway-isolation enable" + } + ], + "passed": true + }, + { + "mode": "disabled", + "capabilityFixture": "legacy-fallback", + "command": "clawctl gateway-isolation enable", + "label": "Copied", + "calls": [ + { + "method": "clipboard.writeText", + "value": "clawctl gateway-isolation enable" + }, + { + "method": "execCommand", + "command": "copy", + "selection": "clawctl gateway-isolation enable" + } + ], + "passed": true + }, + { + "mode": "disabled", + "capabilityFixture": "manual-selection", + "command": "clawctl gateway-isolation enable", + "label": "Selected", + "calls": [ + { + "method": "clipboard.writeText", + "value": "clawctl gateway-isolation enable" + }, + { + "method": "execCommand", + "command": "copy", + "selection": "clawctl gateway-isolation enable" + } + ], + "passed": true + } + ] +} diff --git a/docs/validation/pr-28/fixture-disabled.png b/docs/validation/pr-28/fixture-disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..e3be96a07d9bab4293ef4d378cc98e101de84d44 GIT binary patch literal 41835 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≶6q`BwXQ%68lG*A*A?_B<5^2z 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 literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/fixture-empty.png b/docs/validation/pr-28/fixture-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4559fd397ae5bef26581b2deec5c38437bb009 GIT binary patch literal 28633 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 literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/fixture-enabled.png b/docs/validation/pr-28/fixture-enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..d0518d6f37f93c39f98f645fc2e5803719e70553 GIT binary patch literal 41850 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 literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/fixture-missing.png b/docs/validation/pr-28/fixture-missing.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4559fd397ae5bef26581b2deec5c38437bb009 GIT binary patch literal 28633 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 literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/fixture-uppercase.png b/docs/validation/pr-28/fixture-uppercase.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4559fd397ae5bef26581b2deec5c38437bb009 GIT binary patch literal 28633 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 literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/fixture-whitespace.png b/docs/validation/pr-28/fixture-whitespace.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4559fd397ae5bef26581b2deec5c38437bb009 GIT binary patch literal 28633 zcmd42XH=6}^f!v*C@KOvh>X(4K|rZ0y+&mO5do1721IGn6-W$_#D5j=7J z1Ro!tpy};@?(^{-`NYTfyY{i)fKP&6(2wx(o#He7=Z4MmEc)c}2+_fBT2md*4!a#c zb;c&h?ev3B0sr0;xTMlxKT$Fyyc}KUQyb_%SyE=}TssIn-$K_lkAE)v`~8g2S??pF zr{9Y!O#L?W>Ea(L*Z)XlToikB^5XCRcJ@(stt?31T?^4mWU$mW{-U5rz0 zQ;oahEr^9ea&=@4pw~6oHJzgp$+lXf_7qQ7D-COSHQdL}VN9Ty!+)r-jS2Q(6)6CriTB(&V>?B|$Jd)qc(!akE* zu(Y!GI#u`6SBty^tdqNwQ|tUD4HePH3D+D)i_a>LtGHsdAFM4Kf;M1v2ci_Lj}mUl z{v+;DAUj{6IGh|4bD zh>#xf78+y&s7uv3*{=!E@Tp+;!VC}3bAP{4B`*!4)vi@#X!wqf2wodvW85yZUQ%>U z9%!<)s9w+zFtd+*feCrsHaSvL2W+e%a=REWGgv%{Zn4 zHXyb1!$E`5D38iv`#f#72M>B*6-5VUP&Fa1m)tzgJ8Bj>1~l~g29y?I;du&gy%Qpi zF8!zL$oBU3?oNw>jCsw#gw*jW^T#e{M{lD{zgkJQe3Z*nwz*&VLoc)Qj7RzN#(+6s=J9)0>nRBFTbeEaETxQ|B!?7jPYMq); z)Y37KUg5QCYS+Y()g5LmGX4r}3t!D_;>4~a8=j?mVyoeZFr+F}F5LJ?pv|je?L7QCXfnU3bjHR3ZWkRVJ4{I@Ug$--&8- z+3up$csoMR#05D5w-v+r_fFd6l%SI;k6I9J9CvaMGT}KsGb1ajXcxjDZ`KTu{06bP z?)etZz>=L~J$szXRa6DDG45*aAIlX?Gb|jRba<}*42z_Ys;y(BB-R^IOr4dSCWfKT zQ(|VgkTivc6Si#fu3r)jHpvNEtgRmU*h$;V)?@6CB4EqlhKFwc!$#FCvvag) z{TFy@%sh=Fb?1XPJPr))4B?_oKu=V`eq$Q)i11%cwcGUdfv8nbo^REc9Jf@)?xKcp z{_@CD1IUsc`izFvC}r3s$V-J^49S5qwrjWOUDaMH4=NtEPn*c9XUpC?GCHbLY||Sy zT<(Go8|)PI->i3JjL}r@W@>D7OvTl+nVqiWihxSJ0GtQI5YD1iQ?O`5 z7dC}0H11_fx$wuB!hLC9q@#9*wU&n@Qho^VvRqbXB33UsbU;|?I?})V-vf9%MObcaVms3va zZ_j19PfzA~eX+kwQ2zbgVZ?TSmKK$;Hq4+qvD?dpyTQC>r?dJ&EhBMt=OGkyFgYT$ zc`!;nd}R-0zRH{g#r|1mGB{>nA-x_2;y(I1)X^>8`=^E~^^>c?F2+lK^69g}en%pE zH!Y)2VCmGVuf5!8~ zVbrCKjo$&-znShhnz^em_$A_>g7s|lXE9y`EC2JAzGpwrQ6}YyS2Jxsrg|-?FQigU zS!Cyo5Z$Hr68;#slh_9s+XCyCiq4d3jG)`;LA1!g4e&x}!?*0z_eWn}jQ zO7mROw_BODy=IBStN1y(Cq>8OI%QcpGXs&5zxiUlqsY;;@MV;geg$?y)g^N?dWmX& zw#jKs>AR>vbm~xucd&)j({Y+Dh>U_i7!EJbW=;3oIy1uBb3=WLOm3^^Jf|l>DKI@X zwP4^_$w%0hJ#c(RqO3}VvFpcbp5Ru;Uh+$*`z~y?Nr^*$^|i?Ycjt6xXJ@$so<%aQ}u3>+z~lJz2}mYJ2V#*kXd(4v86&vvF}uul}}v+TP%qV1+^Ba+#a zc9565XEu{(VL4-CXUAL+$E?)t4=2iP{8+{$r=~J$S%hVWMVqxOK8_5jjZO-KLBSYN_-4|++88zfcR7s`w=0{Cx2AfX2m$C*9%8F?~o*m)22%0t6C-s@k?GU;(fC{-uAg8 zX+a~POOSkVV|pMDWtj-J(8*^Cm@@U2c1}6peLj_7A;y`0R6K?9lD6Wr;9W&i;xU)lh4ynGtd)aTnwNSHo_I;BnXUU>r+(yGD(xF^`IiL0)>I zo2~25qwol>F1*`X3_Od;)T74(_a;JCW7PLimt9#==|s>EE8Xp-S%i)TEog?p+W>*h zcLi>19KpI=^eV5cgl*2nVVx>ctV|h8h5!}4;RYl zL-Mvy%Hyms?<99q$Mql+%nF%UN2u)+T8VEtmi?i+ck)SL`L_S6MkOEL*Ruhz^%-|- zjrqUwVlLV0uF#67Q9_6LZ9|)%?FrLUyVL3-#^!hzUB`o(`x@jliyX;A&RPM}Zv`U4 zo}`U0W<7c>hzw=##*VwfSLQQD0j$yb$4QL4$j*?(G06KLrYYusC)_FMrx}arLKaPl zgw@VfJ=RI2k1D3ffz#%UsOJdjX~qW}=&w;@R7Sxxv2yzF0&7%GiVanq#`-vSQep(e zu4Y%RTPOYS{zv4yuaPw4C?$8ZVEBtaZh*8yHbCO`JKCf#?@SRrJPH7SAR6$>D3s@L zJG%uoED*oFAbuQJsr~(=$#181QQ}Q4pBHKTUT={1$K{WZEG&rn1*IO;i;nSk5q?W? z`z+GvLy_aa<_0gYd`O6ZxFWyUtSrw0j{d*r+WpVxhQFm<^u} zXVm$OmTboANg?L6S~l!m(>YW<-WtdYLEP^(_yYhQOq!g4b_oJOxlXXs-lYwrxys?| zTvvjF*W`!7!W5=-P3{|+mn7HF=k}H4=?Lg(C~Kd$UJoMg;jVyz*H%5c6^4yH!oUY_ z6t@h4rAm6b*}wk%)2)tB=ac{Jnb5gsa~GDCDE=%mFL1;W%Aut&c#K;Bd8znzKmiGN zGnA&c3$4tF& zyc*rrRb`#|9M@KkW9^};36!c^T|fRK>K^?eaYb*WqwG^P?mZ;un?^T-z!ptAiMiv$ zE>?SfeLr2hZ@ZxMYu7SO<^n&x6HG(237ZfGu(v|BRDh#xP11S7nsAp9{eWL!&Xp>@ zg%lA1+UuxLn*eV$w!zhHTr7~rwzeNzH}=FIGN}N_vXDfCca^Ea&D!Fu3@=RcKWSw? zinTOSHGSf7>i@_jJK+BM!&{e!ua{lJ>6IlBd7Ix{DTLmPER3`ZhuN(YXAu@yol%qm z+TkbeY?IL32Zo>4%5f;y1og&OmBSPb#oZ6x4fA_#pxd_-SL((C_9Y>A>-a95aj?Cg z=r?B;amUOIRAS;_Yh`8S(9q!CT2)$V8Tr8tV%zb4Z%jc|6;td^>o2Iybf|JS4Lg-p zp^wkc&%gLlRY~b2Qr>jFBw2B(rna2NWfApO8)jRCL0J(ghA6ufWoO9OL;P8YJ(_7! z*m|oNbi^%c4QehzTb?`SL#`%XtjiQ#j%t+_~AN4-3I zp5SOEX2I?P*PA2sx-n_{TkWTZU34C8iD1N#SWcC&%TAjz5y#9R>T^2BH0Gl{<2YS{ z_T1HS`GqfN6i^kYMpv$C@4{KwzUyVaRzYSO}*H&O>M{IqD|NNegS*~K$KX4s(sIIA8YPHyrSy4p~tK3eYS^D<(GrGI=~blJ^Ur54n`tx z@3n2Q(b||yTuLqDV>!W^H!Hl{hkZ<(wbb{ZSadAQAj5e5hrC=M^XdHahQR~=s;R%| zKV~|#z@XqlYZ8$@BV%p(VaFu&nYDJP&r{TZ3U;HL9LV;V+d3876g2D~43%W&b*UJt z!{$~^%{Cx9TRQ|vXDDcCM?`Bqfj{Fx=rghAm)-rzT>zp5vftAFd^9d4p?5&`0hM&sTcp$KG zbJ1sSX1Q{f>^sDA6U6QwmNwUpl1D{68{uVKQpmo`Vb&A<8+w)J<^n$&BkqItM@e-f zIQxvXgL`>ItLyqI7Bc;njy^u5tvf82amaxF@5Tnm((U&jE^+gC~yd??6! ztG^1Vwymc`78r)!ERF3#r@7i*^D5`L`PvTio~%7PR8o|kV0y-q_F5X5VX(_i)Mz~+ zXW3)GtRbuz#Yi5_=DltyL3MR7kl(rEQOz(tNH2Jc{5ijsKm`YnshF-eYn@H5zjRr} z^uGVbGi%cTmm+<)Xe&54O7V(sYyW2;OcXPr@XyI*ytmOsAdvS_X@4gLG{Jpp^)&mM zn`W}lP}42)`vX&SJs z!HWD?+Y8RTJXh-t<^%>%F6i~w`Xe6%NG) zW_0$LX<1WMK8j-E2phxoaVSDXL+Emhlt41c+&Ju0Uw7##@lpr7hG+Ahf<_g#6|mF59#%dGUx+6Brs3-wTeHwv+*q{q;B%-Z2JeHHHeU&rD&JKBV`~B?b&FlI zql57B#NL3wU(nnIn_t{d1NIi!uULNPaPJRWcDcZb2ztX4 zNX)LLslIT)+;jW!;{zdKDdeZH%J~iM3W&4pYdh;3vPkyuc-oH+fGTp`LDig@*?w*+Bm=N@sc91*<9j*c_mi7e8Ui92I4A#@0JN9a?Uhke#fj_p7mz zX}(9P4(z>U)B1aEPHj?pDp}l{*dNWuWsG|tPy*L67O=9m4r;b;Lm*Fb)7K#G=VJKp zI}J>-rWnLq5zk(D|=8`x-%=>)pf^tt`<|& zU353GO9iJ?EOj=70%r*l)#L|xf!uKlmsxFHNZzI?*1MzSgzGWMh}l2&o0X1@)@T_^0XnhB4r|FX6$NR4VKtxn2yxFiv)CE*n2mR)(_ zW^G~CvpZoQqttq}W#9q6zH{6ty9E+Tnpx;Y%9(b5yLl?sdv{~LmZ5P>`X6lEd8Q`) z{!U1<+n3;^doTxbV=(oet#;@({9m^Ysc`!G*Tceo&MUM+6k;|;OGtn1tM1{{Hv%$S z$}M%dxk%2ktA>da_4gqfYG|O{?$fm&M9o9QRX#| zJK7^b?bELsXWlC|zpYj68(aEtwUQj?`PzpyZ3GRQj1HPiLd8Dmr!)jvG^anAgXZ=Y zA2TDvsM&6V{i%*=J2Xq=dB|T`@5kCi4QDb&fyp+GnZJRXxjtLQdoH<4&@%5WWs!nx z-xwT^I$L*QAhg#j4Nk9YYi>;^Pge-LS^AWtbB&_jGCUjNo1rBZGj##!LFRAcFVTa- zSpCu-a2%L2EdNos%P5j&bgU3AVhldQN4TFs*QnGe34f(CKVM*KeazOwGl2bA6~-Z9 z|7Ku@xgp=(#88dA;=&%n_MUIyYP>x=z%g#1H-M5m@O37$T;ghYW>xdV1iLEv!rPJU zUypO}OBud}kS@i`OFt_Y1|@g*g}qrj;`~hi_2N!FCd2hPN2A_?fgIU=9VJYd{b7a} zE)K2=y21QL#Xh5M@nw{@Y$RTcs+YH{u6(5XM7`uj^>UOAuiG-7Ak#O!_Q z>Uu5Ssm3QWIs3#7(=hE18BY)0OuY=gwsqpBuv*Byq^+`h=Q@;$#9h6Xw*cNNI9Aqt z9{MVAq1WNQLuk(%aM2w;MTPg#B>^+X1pOy-A4BXcx`tK~OBZr!KZmr4yFEYWwRh)| zy@}#COg=2>TQ6-;_jQoT*h#% z@9^FR_2%mYOftN?ztV;dMeDs4Krl$ z#BDPKx}NAmGrC<89!I;N1|9dW|5BAr zKebRM_o(6bii=y~zJ}i;1;pOMErcKZ(QAZF{+X1e;cIsZWfK>4npsB>`P1U|%I!-A zx}H^#n)YoW)alMQFJwX-2gKh>j)XJtE&nW1<(ESk!v@SPmqAva-@T%Xz==oQY7ukW zA-t(}60tZk{i(TG5Fz$zJLi4;yEhCc?H0F|pg;DQE6#z+BKpoU7>Bd#f@^loo^Ps=p&Uh z-q;&k3}MelyJ!!hXE3&mbwzC3x9zSmD^ zTfJYO?0?jiiTmdJg+FII`elc%xSd8n=a)3tKD_5=7sdfsKAS-kYC%q-d+Qp*bJn8` zMCxu|m@Vr3~z`9Hrm8yl;F8*`3wZ*c8N5kxIB-XS?IGj zM?*yq_!lSyqm4zu`+Flly^c**FyM@E9Q}SM>}~I~eD%3=CZRNd06I?E*?E+$YY!z8 zUV=in1J$`Q2||X=mJQQwgJZHr>G!TF2~Q>hUb6mVf&hH-C#Z21yWITGh$dbwcmFgN zkkz)QQvI(j&P{oRi>kTHiL2iJr9Yu#MH|x(B6A{Eq`SqR7kVzj2N6s+JRX;iO>$L} z^eI2CdOok0TZjwJ?Xq|MT3Md4^QG?KKt=ION35+)w+vX__C_;DZwpA9SXuRjWje5a zA4=dFfI|9OlJDVCU7Q_~S8Jlw9&EC2?|EEuvmGhOSSvD&+4XLoKmRCtjrNPi9+t2P zd-Ph&4F_3~dOr0s)Yp3lcaKthZ2Qe&;n5js?phQzCp_Ra!{GSK+uF+0O10j7e`i|_ zL3@6Uw#4Y7a_{yCKIjroLLto^1DQ~q&^{eQ&*RKK0Qm~Czo^D;s5jj*ab z?lf|!=`UG5J-<{T?epHBznJu0cOGX{%znMWufINLEh1+)03{-7y;$QI*Gm)abpSZA zKVbu|$Gb>{1=Q|qY8*cIhOpiQ+q@ZfmA!@l?B-y|UoFC8w8UJaL_(_po6ni@;>?rW zeX-bjfW!tPlFeCoaf+b8BzH{aj=GZMp5GOtgF~a5A-`IVmpcBTyA)%omAHg> zyeKkcDsi5BswHX=aKhIPMGC?8#I{3O3L((p{l5p_#?>rTCg@u3{W^^(vII4riZ!?^ zKlpSU-~&(~imO>=G~OO37s9YFby%O3<~0x$^RJ4zwTBIbE1c)5s0S(ZvohJ+805-C zWAI@sE2ojl`L5Kcu;z6jh4%eyOrxjfO2km3fam10Pw=n$alr_9hNjygKEjq^OTEII z9LEyJ=$~ja&HE$yVJ)P&NF{L@42h*8m4&)4Nh@Rl5rpC2Pl^9=g2>%lrj?y9*I_?6 z$Bjem*{$U7@sF4$$(7ZYC0XcRW~YdAG04Tg26I+^6$cHmDW%W0XJLz%uUUd zGQM!PX@3>hCJ}sh5%N~Z&0CkB|_;StE9A#)Q`CzB1IgTBA;!WHafYReNCG30n?PcKqTnH%|MQ_#RmgL3i zmIP7=;`%Q&?7@dgAKn;~04BFp;DQs#4ls~#>e-R2O)V_IqzfX{pImrwY}jpN7vSi4 z0yJ+3dRlH!9wcElGMHp(*!X2(JOHN*-w{NLo!1tfmLUT%7z}O|8;IDVEBfs4gW#Sc zVh`eTgoukdP%=!TEb7m{4X$5L6FgwP_SU8=*}`4Eyc3d$t#TNdU@?#@lDKL=Y&fTj z0}q5Pj^qn#(nl^tNo@Qy5-!j>&Z!I;m_AwKKYkFX$_<*e96Rub1L4cgH25)i{?9x! zR(5}K)Vqw5=EQd*@rWTna$h8HZ$iQA_|Zb)_GywA`&vLwvoEHF(dh$el?a zrJ*aCmFn!%z-8hN>pNIczrWoxQjeD^&SkEDu{2v8eS4?*IFHENCuVG>*u*tB4g~yr zVTj{B6fR?&eZ$Syu-1JgSYp}bO>XX(JkgV~l+|&RARvf(zt;EbLy(opifZ5`Bx@2H zg+H-wzA{;E4a4T>4qx9p<%1$+sDd5R5El!3HLR_Tddi2&)eL-$_oD5n7fb8A|2-{p z^Iz+I-zxHCIlMiTV5otz*r2JJwb$umf8%qt>xveW&s^F(rae@2-uz+czdFrC+Vqjc zCD-QRiFdA{dT_g{tAS=O<<`@WN~}2+O%_}HYrSXu?ydCPA-|3Ic=GCtQyGI@O|}C$_ueI?f-|X=KBN4KT%R-cs(l*5emV zt=y*7EJ0RIP8one*Okphg4q7QP&f#K%v(M@1QtCDStbNuivPOcxbd{_1C6v&2}Q|E z!L@QBS7~d!6`n=q?KD!0AcjArhkt5P?9_`a{u2qhmXAO`Pqa+s?QF}0#6+aur zWW=8kpqdkwTiLm<&2pr4o-${Cm78Qk9`P0e4c1weA{0wqVeKb7Q>%ldx--OU>jeHn z0yt~(Y1}vYoQA+Qo0~Q_u~)Z8k&=+_eY~ulUsJE64od6pnGH1El9i_>tqa_Yw^9Qq zdC&QE^YNRxqV{gWdLFFrd-#4iYZ%cJBXS1A7tE{?xZ9<+Q!_A_{T)b^s)f%P)SmV?Ew!H zH8{<)c+IDtru((wb)T;oobc(Gdn$4Qo4qrx#>8M{9#hDTGKgoS2Bff#7w?LF)>8Po zuu%E=<;rbmYev@Od<`XwV3szz{yKfw+|?$i2{7e}Cd?#MWZMq!XDaLu;J*yTc zLGZeH@+NluK`3?|(?O#DDsTo0lx_oJO$}sdPaV67O%T%Xp>$SSVFmH*QcE&!f!Pz_ur^lnZl^&?=crUXx0;}3dB(se%DiYh=e6Mt^V;Q+KCm+2* zRlUQc$5#|6Dk}@pew?Bm1zsZPv-e`QIw|{%n5dj-+t$R)=b0a?K+56bq~&fa>~OHi z-6Xav67wD&mIk}t5J*W;MbLa_$ykOK<~;qgr!ELYj0oMmU=-Wl`Tj|} zFyZgtC4l8Q|EsD!bz4#CK~_QnkcCUIu>->8k+K>C$)d@-E1|!Q-uw?M0rV6JQXf?o z-QC5)4zwaBlT%*=Jn;Q8`U(iZ_mAcOR|Dqq;aH#yr@y~{%&ghZA?-kjh?IFV)++D}89|Xm_*9N7Hmi29-Hdj}uY`MDugZm^z*Oxw*MFyPnkL zTfL$=zB0~OUb&f5$s{2-L_$y9_*+{bPkrjW2B9}`lr?D(_G_}W4E9_ ziDW$5d@jjV)xuKj0Gb(=BuilC7s_|7W54y4!ZypnxM7$6+U4GZWSt05Fyj%Gja9Gv z?A{@eM4kxvnLC+ck-af?RDg6oc-de;>u>$hKaa#s7%aVO^p%HYWrGc>CN}je+=cU+PB#a4%mUje1wm+6olq?7DsXe74L9vN8nE^sgIFJ6~w- zIszk28;y(V@3N?_rjSXzkK1~8)M|M3_&a%KCpsq+PD!hW7$PvOkL8+n5NW88aaWf+ zSjiCP*X;i{J8&lu_nc&1kJ8&C6bGh;{uwe;FSJ^@vD=4)vj-C=CWRysdxTxv&=jRC zeFXix{fSN|hWgsSB2j*%6YJNy_puuZV|GJ_V*kZ%tfXVQbBNah_g(OMbry+3lB~tS@Hh)tM{^XW z5wTTD$H(f)JBjbs|81NWXPI&`*Jee_}l6;1sxtu&)vMTC` zR5YE=8Xah(K)Svs8XT22XfXQr9T$lF`y+x@j{c!E#(A@;vfn#wf47OVw|?Z{c>Ju% zm=cPm(@W{}3CPOEK($$Pv@2p4R4BNQx&n4`8bh;k@Wi^|kESB7;h~T;?| z`rUH%9E)**YGa^fJwYpSwiwsAOSjwTFMZnawX0X^4`u_BS(={rJ~}C1DR4^1Ubv&j zH{njAi>!6dIs9{2p#iHWhc_o9?Kh0$5hT%0&L0D6+&3Dw>#C5pip2ym-qciy)oqqJ zxglsvoNq~cRQQcvH%2o#Y2Pmh2cu>R`R(EqoAFQLn8(0yIeFV8mLd&Utq*X;$Oqr1=+W^v{|vQM&! zOi4vw3;)cQg*jI?hr-M8i#ha(Sc9k>xRYz#0X$z9@db=&%dI2(TcfH5*>?)6u2naG z*Btj72EbuPR+?J-WPEJ_0|KF&23`aTI_drWWPQs-I^D23sC~*(O!0yErC$mE4@)fN zww^DJL2B=(2_VQs>*!w}oK++S?@h<}77W|~HLQNBnqe&%g1C%43|4J-MF(D=1*HI$ zZC_JwSOcZ@Ww*NQ1kN^P<`}ZuA4~li9C)8Iac6oaPBSgwqO3Ivc6oHO$eOh>j_g6b zj+?-5zq)MsA8(M4RFm&f6ma|Qncuw zKmRltxsT6(oC*f`iV_UQd{rAtotJBkKAQ|LS^QDE(dnwH&x@s|=5N$d=BJFHB+K^# z??wLSYI7tdz_?Hh?0f+%V3nr_e|{k5aJgP!(0JmgHPw^xbfFvtc;95$fTv}8ct5R8 zLVZ2uxE6bE^db(>B(q0)(+>M}m0xJ-dDk1XN>==qgyu}?riw$@xIfh8YJrbAgo>bo zuUr9{p1)d7RRJhtOBu_R*CKCJQn`O4yvA&S=o(KStmM7KxA9iWr2%3ky_4iSL^%G1 z^A$L!2%I#iiNs)Fx0;c2nwEDuH}~MwDdQt52fIG|KSmGW7u_y(evTB*-mntPk8M=u zR7-B8s*L+pgZJQ-fg}`kYRX76g1_x3Kr}3z<_d-P*yWNqf}B{8Z4#)MSJ0 zxxCTQ;?K<`_C-Pl)6VD4ox|XD!^so%85)5u4Lf<|U?|z2gv3sc1?5VpYEAP64A;* zZ5SI&S{wVhixJd*UkFm)>kw-Bk65mG45=S#Ym*i;;7wHXOGY?A6l#IYqidF-|Jt{x z!BP&XG*vfb1nWNzi771`e@8U*WoQftw7DQ=7yDGV4T#oNrn*9S_LtlP>#|lb^!uT< zrh0P3e)cZF*1|X5t?wgeU%-MX-s4PbmKhx=X-?wmxREEcaFB7G(c9U~G5mJEJz4d( z&u_6%xk=5dI$IxZfCpkM3CkUGpQ`y+&wNR3;%*tl8R`))LhFs9wpC&7kG3~5esh~S z@HCTx9a;SRn=9~ zs~7!RShr@s7qNAE zt98UQ*{R(%0Ss>Sf(;38xKW}folaR@Zj;jW&P)v?dQ4=@YaD6*zO`OId9Bht?wg75 z>b)!LtK+C5C;i#FW#0M?Qj$u;!<`q&A_4gMY2gEU@|R{NlHbHQK4%PT)Yc*d!g{vS zdEH7tA!zu@Kp}j;3@sp+elDiu=_)++frcjA*{-T4Utf<%TC;|lCf*VZw7#~Ro_YtO zy`Jhtcim1&f#c+fxV>+oCqxVPT-dK)qg`xqfg3*}^C-{d&n4n(v`sgjZH`mth4raR z2JgG@bAWZ^o2hO*WCBz-htsRZ^SyG;hfH0lO^pi4Yk1z_621*XELG!KBf-7CnAIv@ z%tB1K2|d;X%v>S+ehJQ-2at$;Gh3{7jU(J=A9Eye+)B6aLi^rleXDh<7JO=$xcoVZI736s`bfMPhhi~ z=E{my&&ZpO@>`q4AVGNbK$2m0qR!($nA>#X%na*GY(!ANtWP+6Za0;zS*tzxWuV;X zD67}odP)RsgkX@;Rp~uGeKp}(6cQGztL$6+6+=|`ybJKmMGi&TI$&{oI|2k!K zO)c{;m0 z0h(5uF!(<%t)@?dFv0zQ?QZqpU+3APhd2bosXh~_6OBJ!@=NyD`Qr-AqNNdw+*8>S z&wu8c@3`NQEN;@BO-|{-GfHrj)u-S4!fPymKn*(qs&~?mkU%rG4OQNFBMvO)R2eX- zj6YC902IddWNKNG${i~IHdS!0@lKB@Px(K=0Qtl*Ac|}hD?LVh-Tlfq=)htBdiGK9 z>XdiJi^DUrQ@~6kH~lQW_I+$Y{)eL%{sX-0TmRcbRZ@(N9k!$?VsNRdY7pI}pr|M^ z@|!#G<5Ad*vMF(dlsE!!_-fy6{JR8DCJA<|(pOWHLErI&*lqq^_oryG=mn^vb1e{I z_`47mJ#iLxfR(Q2Voqo)DA=hgs@zpj%$!#j042*;%y)FRcX!`OEG>->d6Smc6k^-e z#9q1u{C~u*#-o#fOFiEjd10{KbpE#1$VOzo%mcLBXN6=Sx-x}!YdK`n@qQ-$X?~UU z@$Y#{@5@S+99^=2@2-hfynK;YRrLsH!W!)t{QJzcSX;$Z@e^VPUwe4?kO_=d(%r3O z`Q=nz(4?oQXG(JUUEpgwRpZycTG`bpD56i#hI~DH`FrrZ2l@cF2z1R;E~PGDDZ~*= z&Hj?+xI5ABeLT%TDhgn>)o&G+7pgeHCb_!Lk3tpDR+z-H%8l2&Ufw{@ zIa80+V8n#qGEgrRzQ3Z!iyv$nW%3xMXS1Pqr-DBwxC0HF2e5r0!*i>~1R_Ee0oiC% zwHV)i?;1&~XEYT2EmQ13h^R*g9IFm4Z*1z?6`P-{HJ{o{Je{cd`U9{d5l7VLcg~B> z&qt%Of4Q~y(MGd-b%~+%Ibb4hc8k80ta$BO)p3~7b9hW{ST2{;B9aGf`L8@)In*t^nHf_qeW3KE47sUGFyyyPBFAHX zX)s-S>}@#*G1^lMAkO-*nMm{&;8sQ zEwF&sFDo)4W{)SIy}Q307(m!230s8AqXDwyJg<@#G2rT1JAw0RE4{~yhO(c8^^VaJ zkYSKe%w(Oj;XW5M8PNr1>2E)?8sSMg!_{|t^-y=iR;&F3wx!Oz85O&ahb*UAYt%>d z?nF_c277ChPO`UxrEr`d6wW#s*{jew9$Md9R)QiP7vOCajt3Txwv@zh_v!eg#KRV& z$ngrSg)>7Rve{nP&wmBUteKbGtIf2pwKsr{tEdzFdK;|jc~8F=Jtg&^7bnnEJKNBn z%%FjWxbT4eK5H%B!dpF520jdphmLE?=OX6OT|n>a;!rMdAH6(1k95d&JQ?~MV9TE_ ze=Jil)f`+Pf3tG3`SXvMatg76!{rarovwj0q!I?3V1=_5#nz==EH10XE9a7PmHXkr z9@uV-I|G3S;VYJJfY_9l@V71z-x|7zpl23^kGZTYwS8qGbwj|o*LT>1!NJy;C)R=Y zR`9OG@|DCRvnsm2-`Of{Y{o6>$xGtUzDBL4hEk)A_3zYU=om*dfmDO9e zwCV$hqJq%Z;cjFiVPS=-<(%mr)wg$2dm5%Eq8W}qX=!RvsD9jHP8Si`QZptz{IPu8 z4p$B|<4Ip1;o)&LW_QFaGPOMrwqRBA8Yg*FI)TAO?{vPzWVVD5CZt0SOA|ZOq_LMWP5i`R8$$aGBG=&=<6vU z74A*Tvac5pdloH;D|M{N)%N)I`H1O8N0QP&fcC-|vOpepa(|$wtwthfe)6?*ov&zB zEc{M*95$fuGX%`fR18yrY3Kiam?d<)c}|g$0h*#}p`^Yfk@2bz)akFRM4~3IJS0*+ zWN9)ca#lR=I{xy-NU444z9jiGkokc+hM4_WZW`sL6%$|&_Ud=za)d1%Z9bmVU)Yfn z{%~Gve@mKa@x_3DWJm*^;)Q@GRZM1w~=ZSRSH-{%*MU_^3JVfLIYaHMOSNp9~|=hlSF$W`2}Y zAKVMy<&QI{*?7&W7vC#m8S@%v6O2H6RBK5gL;XRlc}s2Rs>J8`TeoichS9@OYR~4< z-42dJj@6(YI_1`Yv5VSLVTgiT)7cldxB@VO1rq=k_5580d4`i!m z1g$ti3wj&5Uta6hccwpWxoTfl_$n*ErB90tvm3TlRn{$PV-%WaRz*x}SJ_c5hvy#s;e*U|&@O8>*qWtXEH|5zw31jF%?TnlptgijG2h-*iQ07zXO< zyloi#J#1Gx(V1I=A5fl$ z2AV9P*eY3RBD{?O-ER$S3AyDsYLb+*8nia;hHZ@n>q_W^aQ_v@A$ZLnx?}#e(v=SW zKia#_uqLx@&Bz&1Q8*r~6f=r~ij)D74k{`Fq9O*Qgouw>;5?Rxqs#!etplEC;QoZuf4y$*1Psv?_x0_D|I6ZTM0>jx zvH8i))tIf#&PScK*qJF23xz@k)D-FpeIbCDuF9XxJJj&P(QsTyqtFR(D6Jhm58-ri z^eDY7S^U8s#Li-phE!GQqpAwRklw*^1jQS&%@uXVA$Sfw?-b;$GIAr1a91ZB4U5e+ z7>Ve5bvNsj9yHkA;KL(m`b|JZh1g(H7q#mdw-8pNx>r8I?UQ;=BqSu{ITjC3A$qPR z|H(UGB1Ostf>ro7X#U4K|7Y03EB*k~ZZ7s3MpQQQwinY}kyZ`CmIF-ayIM#A7$hIK zP!!c%gr-4824&6qp+13si}5**GrIEWQ-(VO;gYUNi3H4ox+M2n-pwc#uM|g|P0vw9$OFKj%^j{V$p$Me!~`8X zq|JVx0Z)Hrb#s~?*`5myQ?vXO2H5bAh08SjJluX6dsH?**&ex4(jntNbRc-g+~OUO zDUNgQyWcM}1_=2Fuqpd(T+gh~>FCj;`@cM0qxTi2&HTU>z7qS?v0f>^D?D*@blg+6 zf580}4QodHPqWdana0AwBaj9CG=113xx zYP6+QRB$(Kf1sPjaKV^2@QMxs=FUZ9f{{*fQz8b7&EB#yv8sY60i#;#`~G1d$HB>| z5?<@mwlV>hvjAQkfKh}a#l>P8X?fW$jnM$R&1pb&P?d2v8T`e;ypHzgxxxu`O9drv z;DDi`K1au53yinEko7AdQlw=5q4~u=?Y{O8*+3W2YZ8Y&a`PS#A87IumKzhl*#GeA z|LdUcUq*rdaszCtYv=Fnj5Hb~EwkkHOlinLb$=ou5cWdjgcCIQG3^5Ls2iKoT~ z)DN`8oY0YDe;}Y|Q*oetG8f$*@7ml7Du)p|k%Gr6|v z#wNpF_SkCS0acf$H;B;vig%Ga+~1k1_cP6CQtMRMsDpIKYFG9Wj!%~K+~niApCzLX zM8?gF1YAq4YiK5Qq_2f8%F20J)Rg?1xcH zKo#~_;#jk9s-{c3{BaZa47>@hK3p>35lRG1it5F2#JphmL44GdXh-x2)1T9|BS zh?b><$JDxpOJjEGDM{9FV~_5I%JCGy%73o-<(TT;)^!8h^=%!Pw(|6lr6OL(vBShn z2DHjDj-lo^bXNzVc&v2l`<+!WGZ=ok{bj9ffbW(cTD{f+UQbXnTEE+Rs+`b^*HIpS z6?SE0!lI`;?<-T$=041>pp%Sq>C=2#@f8&EE>tL~ zi2nHV&yvhLHSSOB@RJcDIswcq!xgH&1ysv%z4xYpXz8f{Y0SVMFr>?k*AvO26tz;y zHJGS&p#d*GE{=w%c3CYsPBjW#ctl#EqTGf{*`0>B&)XWWTLp==YVk5`-+3?M%@!z6 z%LWV$biCHW>@%3y-;M>z_5wTuA0`YP9}G2W0>g05l|Mf=`=)jx!7#_%*O|A_K%P>N zSJ)r5QdL-eGSgu$ZlaQHl&L+T)H5fXC2YKCXYFXWBwte z8?vC1gGQ*i4L-ly7C{0MXJJvPY~4KMPJ!QfW-C5HBAnpajlU zRGuq_ijmh|3Wv+ryRW7Xm=r3c54g_;$j%?hr&|N0H3qgG*E|OxW^g<`nXXI@;ReAn zDrhyNlzGqYGKy^tY%!&}rS~8(|KOjLjVuz*LLrXl&KbhdZ?CIS%KOb{go`{WW&*hq<*p1o$eJx|%YyEJ;h}C%n=<1546My0V zuJIG#HWT=}&hBGZ1pijM!0h=4>VNqH{;g)d`?c>Lc)Mr*uG-sG`MWBASMlv?!(DB- zs||Ox;jT8^)rNlwZOB2cFKX8mZCxGPP~2a8N#V;L!=J*wWy{}}k2;XC$I9}{9 zv#!pEUt=_TA`L1YNqCQFE>zfqYhDdsA3h*uomFah6)Dv5|u!1w?p_GIth(c#R-7Aq3BlJ?RqrGZ&MejReF{0l~fkzXb02=fUwC z;J*bp@Bd6K8UtPgvSmH^kH18-wB&Mc0Rhol{5k@Ei2?5p64+Wd?v3$x*k5U*{eM|E z+bDSif?tzE;8)i!bVz{!vh|AlAW)hDsDFX_k#6|EpJ}G0xVSjqH%;vCAj|zGm~sw` z*8yI?yAkW^|Dm2~$v=9YS2)isfTKK?N)<7dWwVu7Uhzl;+?Z%hj(ZzpfLrLTgfL0I z3o0e#G9Asmugou=5ouMGk1`k@=u-s}zAaNc8x)r!|p2Jh5%A&03;A~&=^qW_YTjOs`F>F7oQGSmJpsc)1x|0SZTs{+6D%j8 zp1DNW5AWUBr>f_1)~d>-r-*o%e<{SZz`3)ue8|^XxyZL&el84Qc%Ubeace@6P&`B3 zaPSOxK6sXQ#1-c*@;MRUKo;1}%kpKAi5Q9T$A627a8d|ypH3Zre%5j-%Q|tdt`ptG zHQplAkj$uZFC2OV<=q9EJO-UryWi&CyYAQRg-@P=JT#ZT05x5)at=Qli%$%`%^R=& zEjFUPeSpaust2y|q4iVU083GLm`s8Z_vtv(Heh=Cp*}sN04Tl?v^u-FSth2eqSr4W zp>{d9${pPaM6tHJB===6C%m3qG=R%eNixnKx{f|(h?2d)_>-K{S&Ak{hK!bo)} z-fBjlV!i)Pl7dTLk)^R`==c0+iNtr8#MNitVQf|_MbnqYxO(yzEWNi*N^ALGq5PY8 zW>P1OW+7iVNPhR3#v6OBNQ+Hd>RUG_G1nKNa|Mw<6bAwBx5%c=C!+kF^am zG(=KFn9*OqTu)}Hxs5ClUGteAB>tHCB_Q>nOhMfK)1}9b9?ySJxGW~K__S&88T5AF zfS#?*3823*!S0jl{fX|*fla18HQvHL)~?ra*92o-JZD^s*B!+Yu`h;Pn`SRSiTl5L zfeH^qm|J$e=uT;#Fxa10Y#z*9PY&72*ocNh`z9uW`p zJ*o0R1GI()vyPd+G@Z;h5l<4!NH$v?Xfmx2UU!rZV$*wS2MTmmc4p$}>CNC+s(Ka_ z)v_E4gX+SFohMAe-U}OMz9tQ2AA?($u*v$jLql!COLWSb9vj?l%nlMa=Z}! zQH^qLTXt)tva&L1i}NYJAX?Ye?P7w}Lyj{rrRVsBPF3|Q=5RQDH8Y+S7@m@c49Heu zcf$+33m98Yq5QqkK{R(f!czcP;f6M$8k)os!5&ndwp0 zn#q+RAn(jonjTut$*(iW&j^QovT8{`I?uW1ClyyT8r$X&G zwPyCJR^pARU*=m-po5y+nQK{&o*oZ)b@!`fBtPci)1k*(OyDEkIRi-w?X~R0AupqX zJB;JUNGagMd#CjL$w}CF9!pO}5ny2Hf0&S@$RkaBr@yGB1-st%{A?-ciN)hGtDX?+ zKu3QUInG&$-PG5goazKhlT?pUTS!k=imhYo-8*r6WrVnLPKuu#iRG?X#Zw_peJ_-@ zt49v!khG;GZzbw*djY<~5g@*3>QjaHbbqNeDSh?p8*{`%bD7eqSH1@G{@cDjYfYoP zDr?+G1DDF}2FvX%eQRz#Ma~Op70LS5!}|lZq7ds@$~SNugYr~7yw&rGE$#9_>O<43 z@xu`*IP*)C3Z2dVQyQ}AZ@AT6b|zh?bm_BKvYwvvIzYdEKJALwX`A4dy z&{oyxuCr>gLA1J^<5XXR$<^Zc@w&RS^qe8-d`e30+c-JszAOZ01OggHHom$4F*g`N z2=MNpL>#gek)YJFwOuj-bskkWRdlmW9BzwcO^|F+@OVo_QzS6B|alI!?uUZCXp9yM3to`hTI#eAH z9TfV`Vm|T+m>>+2w*;rFDVy|^O*wd z4}o<{P1M-N>W6#bmp41!XPBgwrXZouoK@kR@9fu(u^HM*y+rx*`vqCT%#?TaZvE%% z@9HojH!E}C>3)n?{ilOgAoyxK{{;@CQ^&s7!byRYzS5|u(0OPmelgT}YqsCmDNTj$ zMYB=E`);g>>TE0;fE{8B$*nR>XBeTif@a@q^;9A0 zqhbA}@rg(1^mV(@G)RxH4d2kJb?ohfADUBPNZiC|<8?3JW5>r-ucB4-z52{8`l>n? z2LnL{#rmzHC-2@G)$ye{VXJeOG2(p z)=dV*iih|*S3SJw(}Tm9sOHxA5{4K6{VEU?=0JUqw03eD9v;a*gs5=tDsnXL0#wK^ zPo9cMx@JJw<;5UjB5RE&PMI`A-Xr*jc04pkbGt<07)t*}$qoG#3x|P?pjdL1J6n;b zxiEOy-SI)yRvs^4W)4Rk4iYCU2G6&lq*4YL8*g;^Ahpr%7vgWyWn&Q}T~+2+kIcL2 zBe0Q#ussnEx$6r65us;DNKt;i%pc+lT9@HnHQVZPa^*p>lR(B!lijs3KuGEbbG_bn zNp)suj;-|}>Nz9Sbt|rtj*x?Fl8{Jc$uapDf-%jEA7kfnKac-Utd)bUf6r3`$v!8f zqV|f|LY#0sfsd0Lcht|TW&vLu`oalh(;M`5s3G$+1ReU=ZJSHtpREFO&@g*kc=-LT z8=G-2x-Bf~hQ3rR%1U`{ZH8=%@*Hm6dLEmh%7^1XnTuaN=BpMH=wRb^!VV4_Na?5J z#PR6KE_W>-cR$Vr$hIhHE{%M;F-w|n0ZZ=Hjcoi01pB8*8Y44+-~La*Je3Dbk;glh i_#@hEFCS403aA`>BSHW@c?5JOV0iQH4fOTLFa86{C;Nv0 literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/hashes.json b/docs/validation/pr-28/hashes.json index e96d7406..886d2af6 100644 --- a/docs/validation/pr-28/hashes.json +++ b/docs/validation/pr-28/hashes.json @@ -12,5 +12,16 @@ "controlUiIndex": "73ac0e6f38896c9d491b45a6c3d23b4990dae139d8ff3cb93da7a33e640bb1e7", "controlUiEntry": "1430574e4d89d8c43b303057d918fdba41a4a940d436d7feaf8e0122a533e4f6", "screenshot": "1dd7153742614febf8d00aa08c50f3c3465cdd8a7a3b4cf87e8fa585177a0142" + }, + "evidenceFiles": { + "fixture-disabled.png": "9445d0a982b921b77113fa3889278c129b31ba2de50ea591caf2e5af40cd13b4", + "fixture-empty.png": "5b64cdc293b6fa1e6be4d04d5c37c7d0403293e9e8aaaf1576c3b55dcfc2a940", + "fixture-enabled.png": "6912ea4e768ae4a2f8fceff4a64c4f371eb44184b99b061ec6a9fe5599aa4fea", + "fixture-invalid.png": "5b64cdc293b6fa1e6be4d04d5c37c7d0403293e9e8aaaf1576c3b55dcfc2a940", + "fixture-missing.png": "5b64cdc293b6fa1e6be4d04d5c37c7d0403293e9e8aaaf1576c3b55dcfc2a940", + "fixture-uppercase.png": "5b64cdc293b6fa1e6be4d04d5c37c7d0403293e9e8aaaf1576c3b55dcfc2a940", + "fixture-whitespace.png": "5b64cdc293b6fa1e6be4d04d5c37c7d0403293e9e8aaaf1576c3b55dcfc2a940", + "gateway-isolation-e2e.png": "1dd7153742614febf8d00aa08c50f3c3465cdd8a7a3b4cf87e8fa585177a0142", + "launcher-disabled.png": "9445d0a982b921b77113fa3889278c129b31ba2de50ea591caf2e5af40cd13b4" } } diff --git a/docs/validation/pr-28/launcher-disabled.png b/docs/validation/pr-28/launcher-disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..e3be96a07d9bab4293ef4d378cc98e101de84d44 GIT binary patch literal 41835 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≶6q`BwXQ%68lG*A*A?_B<5^2z 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 literal 0 HcmV?d00001 diff --git a/docs/validation/pr-28/launcher-tests.json b/docs/validation/pr-28/launcher-tests.json new file mode 100644 index 00000000..de169c25 --- /dev/null +++ b/docs/validation/pr-28/launcher-tests.json @@ -0,0 +1,234 @@ +{ + "command": "dotnet test .\\OpenClaw.Gateway.MSIX.slnx --configuration Release --no-restore", + "total": 57, + "results": [ + { + "name": "OpenClaw.Launcher.Tests.HostEntrypointResolverTests.OnlyArgvZeroSelectsTheEntrypoint", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ProgramTests.SetupChecksNodeAndPackagedApplicationWithoutMutation", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostDiagnosticLogTests.WritePersistsTimestampedProcessDiagnostics", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoRejectsInvalidGatewayIsolationMode(mode: -1)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ProgramTests.AgentResolvesNodeBeforeReportingMissingApplication", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 25, minor: 8, build: 99, expected: False)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseRejectsCommandsOutsideThePublicManagementSurface(args: [\"doctor\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.WindowsKillOnCloseJobTests.FastExitRetainsExitCodeAfterDelayedObservation", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseAcceptsOnlyThePublicManagementSurface(expected: Help, args: [])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostOptionsTests.ParseForwardsAllArgumentsUnchanged", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoPreservesExplicitArguments", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ProgramTests.AgentLaunchResolvesNodeAndRunsPackagedApplication", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 23, minor: 99, build: 0, expected: False)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostOptionsTests.ParseReportsMissingPackagedApplication", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoForwardsCommandsWithoutInterpretation(arguments: [\"--update\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseAcceptsOnlyThePublicManagementSurface(expected: Setup, args: [\"setup\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlConsoleTests.WriteReadinessSummaryDescribesPackagedApplication", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoForwardsCommandsWithoutInterpretation(arguments: [\"update\", \"--yes\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 26, minor: 0, build: 0, expected: True)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoForwardsNoArgumentsUnchanged", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseRejectsCommandsOutsideThePublicManagementSurface(args: [\"verify\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 24, minor: 14, build: 99, expected: False)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseAcceptsOnlyThePublicManagementSurface(expected: Version, args: [\"--version\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoForwardsCommandsWithoutInterpretation(arguments: [\"onboard\", \"--mode\", \"local\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoRejectsInvalidGatewayIsolationMode(mode: 2147483647)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostEntrypointResolverTests.UnknownOrMalformedInvocationDefaultsToAgent", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoForwardsCommandsWithoutInterpretation(arguments: [\"setup\", \"--install-daemon\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseRejectsCommandsOutsideThePublicManagementSurface(args: [\"gateway\", \"status\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.ResolveAcceptsCompatibleRuntime", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.ResolveRejectsPrereleaseVersion", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseRejectsCommandsOutsideThePublicManagementSurface(args: [\"update-package\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoDefaultsToCurrentWorkingDirectory", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ProgramTests.SetupResolvesNodeBeforeReportingMissingApplication", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoRejectsInvalidGatewayIsolationMode(mode: 2)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseRejectsCommandsOutsideThePublicManagementSurface(args: [\"setup\", \"--force\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.ResolveRejectsIncompatibleArchitecture", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.WindowsKillOnCloseJobTests.DisposingJobTerminatesAssignedProcess", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.ResolveRejectsMalformedVersion", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlConsoleTests.WriteHelpListsOnlyThePublicCommands", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseRejectsCommandsOutsideThePublicManagementSurface(args: [\"prepare\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.ResolveReportsVersionQueryFailure", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostOptionsTests.ParseResolvesPackagedApplicationWhenEntryPointExists", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.PackageManifestTests.ManifestRegistersBothAliasesToTheSingleExecutable", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoReportsExactGatewayIsolationMode(mode: Disabled, expected: \"disabled\")", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostEntrypointResolverTests.AgentAliasSelectsThePassthroughEntrypoint", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 22, minor: 22, build: 2, expected: False)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.ClawCtlCommandTests.ParseRejectsCommandsOutsideThePublicManagementSurface(args: [\"repair\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 25, minor: 9, build: 0, expected: True)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.ResolveRejectsOutdatedRuntime", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 24, minor: 15, build: 0, expected: True)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoForwardsCommandsWithoutInterpretation(arguments: [\"gateway\", \"call\", \"update.run\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.ResolveReportsInstallCommandWhenRuntimeIsMissing", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoForwardsCommandsWithoutInterpretation(arguments: [\"gateway\", \"install\"])", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.NodeRuntimeResolverTests.SupportedVersionsMatchPackagedOpenClawRequirement(major: 22, minor: 22, build: 3, expected: True)", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostDiagnosticLogTests.ConcurrentInvocationsSerializeCompleteLogRecords", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.HostEntrypointResolverTests.ControlAliasSelectsTheManagementEntrypoint", + "outcome": "Passed" + }, + { + "name": "OpenClaw.Launcher.Tests.GatewayLauncherTests.CreateStartInfoReportsExactGatewayIsolationMode(mode: Enabled, expected: \"enabled\")", + "outcome": "Passed" + } + ] +} diff --git a/docs/validation/pr-28/payload-matrix.json b/docs/validation/pr-28/payload-matrix.json new file mode 100644 index 00000000..7f14d09c --- /dev/null +++ b/docs/validation/pr-28/payload-matrix.json @@ -0,0 +1,3656 @@ +{ + "schemaVersion": 1, + "fixtureType": "synthetic-local-npm-packages-and-synthetic-runtime-inspection-responses", + "scope": "Actual Build-Payload.ps1 x64 and ARM64 composition contract with controlled runtime-inspection fixtures.", + "productionSourceCommit": "9aa1df286c2b6fd59b4c101201ddae0ae6000324", + "evidenceCheckoutHead": "2ce86d5bf4b54809ab1b4dcf5930db74d36b6d84", + "verifiedSourceFiles": [ + { + "path": "scripts/Build-Payload.ps1", + "sha256": "7dbdc5b23b0b325a8338de26c8e00cb9b7c4475404c78090884c93ba7ae60691" + }, + { + "path": "plugins/gateway-isolation/package.json", + "sha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142" + }, + { + "path": "plugins/gateway-isolation/openclaw.plugin.json", + "sha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651" + }, + { + "path": "plugins/gateway-isolation/index.js", + "sha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd" + } + ], + "harness": "scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1", + "harnessSha256": "a538e627a1eda0737557e052a68b44d5e591c1923fb4223b7eb52084481f86d9", + "environment": { + "powershell": "7.6.6", + "node": "v24.16.0", + "npm": "11.13.0", + "host": "Windows x64", + "npmOffline": true + }, + "command": "pwsh -NoProfile -File .\\scripts\\validation\\Test-GatewayIsolationPayloadMatrix.ps1", + "summary": { + "total": 66, + "passed": 66, + "failed": 0, + "accepted": 6, + "rejected": 60, + "architectures": [ + "x64", + "arm64" + ] + }, + "harnessEnvironmentRestored": true, + "harnessWorkingDirectoryRestored": true, + "generatedFixtureDirectoryRemoved": true, + "cases": [ + { + "id": "x64-accepted-existing-environment", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "accepted", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "accepted", + "actual": "accepted", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-accepted-existing-environment\\package -Architecture x64 -OutputDirectory \\x64-accepted-existing-environment\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + }, + { + "command": "version-smoke", + "args": [ + "--version" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "matrix-original-CLAWCTL_GATEWAY_ISOLATION", + "stateDirectoryMatchesExpected": false + } + ], + "content": { + "shippingFileCount": 3, + "files": [ + { + "file": "index.js", + "sourceSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "payloadSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "identical": true + }, + { + "file": "openclaw.plugin.json", + "sourceSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "payloadSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "identical": true + }, + { + "file": "package.json", + "sourceSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "payloadSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "identical": true + } + ], + "noTestFile": true, + "metadataArchitecture": "x64", + "metadataLayout": "expanded-directory", + "metadataNodeVersion": "v24.16.0", + "metadataNpmVersion": "11.13.0" + }, + "rejectedOutputAbsent": false + }, + { + "id": "x64-accepted-absent-environment", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "accepted", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "accepted", + "actual": "accepted", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-accepted-absent-environment\\package -Architecture x64 -OutputDirectory \\x64-accepted-absent-environment\\payload", + "environmentBefore": "absent", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + }, + { + "command": "version-smoke", + "args": [ + "--version" + ], + "nodeVersion": "v24.16.0", + "isolationMode": null, + "stateDirectoryMatchesExpected": false + } + ], + "content": { + "shippingFileCount": 3, + "files": [ + { + "file": "index.js", + "sourceSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "payloadSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "identical": true + }, + { + "file": "openclaw.plugin.json", + "sourceSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "payloadSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "identical": true + }, + { + "file": "package.json", + "sourceSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "payloadSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "identical": true + } + ], + "noTestFile": true, + "metadataArchitecture": "x64", + "metadataLayout": "expanded-directory", + "metadataNodeVersion": "v24.16.0", + "metadataNpmVersion": "11.13.0" + }, + "rejectedOutputAbsent": false + }, + { + "id": "x64-accepted-empty-environment", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "accepted", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "accepted", + "actual": "accepted", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-accepted-empty-environment\\package -Architecture x64 -OutputDirectory \\x64-accepted-empty-environment\\payload", + "environmentBefore": "empty", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + }, + { + "command": "version-smoke", + "args": [ + "--version" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "", + "stateDirectoryMatchesExpected": false + } + ], + "content": { + "shippingFileCount": 3, + "files": [ + { + "file": "index.js", + "sourceSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "payloadSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "identical": true + }, + { + "file": "openclaw.plugin.json", + "sourceSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "payloadSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "identical": true + }, + { + "file": "package.json", + "sourceSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "payloadSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "identical": true + } + ], + "noTestFile": true, + "metadataArchitecture": "x64", + "metadataLayout": "expanded-directory", + "metadataNodeVersion": "v24.16.0", + "metadataNpmVersion": "11.13.0" + }, + "rejectedOutputAbsent": false + }, + { + "id": "x64-missing-extensions", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing-extensions", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The pinned OpenClaw payload does not expose the supported bundled plugin directory: \\x64-missing-extensions\\runner\\openclaw-stage-x64\\node_modules\\openclaw\\dist\\extensions", + "actual": "The pinned OpenClaw payload does not expose the supported bundled plugin directory: \\x64-missing-extensions\\runner\\openclaw-stage-x64\\node_modules\\openclaw\\dist\\extensions", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-extensions\\package -Architecture x64 -OutputDirectory \\x64-missing-extensions\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-preexisting-plugin", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "preexisting-plugin", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content.", + "actual": "The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-preexisting-plugin\\package -Architecture x64 -OutputDirectory \\x64-preexisting-plugin\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-inspection-exit-23", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "exit", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23.", + "actual": "The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-inspection-exit-23\\package -Architecture x64 -OutputDirectory \\x64-inspection-exit-23\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-plugin-id", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.id", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "unexpected-plugin", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-plugin-id\\package -Architecture x64 -OutputDirectory \\x64-wrong-plugin-id\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-id", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.id", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-id\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-id\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-plugin-origin", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.origin", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "global" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-plugin-origin\\package -Architecture x64 -OutputDirectory \\x64-wrong-plugin-origin\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-origin", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.origin", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-origin\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-origin\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-plugin-enabled", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.enabled", + "fixtureInspection": { + "plugin": { + "enabled": false, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-plugin-enabled\\package -Architecture x64 -OutputDirectory \\x64-wrong-plugin-enabled\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-enabled", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.enabled", + "fixtureInspection": { + "plugin": { + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-enabled\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-enabled\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-plugin-activated", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.activated", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": false, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-plugin-activated\\package -Architecture x64 -OutputDirectory \\x64-wrong-plugin-activated\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-activated", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.activated", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1 + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-activated\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-activated\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-plugin-status", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.status", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "error", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-plugin-status\\package -Architecture x64 -OutputDirectory \\x64-wrong-plugin-status\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-status", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.status", + "fixtureInspection": { + "plugin": { + "enabled": true, + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-status\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-status\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-plugin-imported", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.imported", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": false, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-plugin-imported\\package -Architecture x64 -OutputDirectory \\x64-wrong-plugin-imported\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-imported", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.imported", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-imported\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-imported\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-plugin-httpRoutes", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.httpRoutes", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 0, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-plugin-httpRoutes\\package -Architecture x64 -OutputDirectory \\x64-wrong-plugin-httpRoutes\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-httpRoutes", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.httpRoutes", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-httpRoutes\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-httpRoutes\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-httpRouteCount", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "httpRouteCount", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 0, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-httpRouteCount\\package -Architecture x64 -OutputDirectory \\x64-wrong-httpRouteCount\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-httpRouteCount", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "httpRouteCount", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-httpRouteCount\\package -Architecture x64 -OutputDirectory \\x64-missing-httpRouteCount\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-gatewayMethods", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "gatewayMethods", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [ + "fixture.method" + ], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-gatewayMethods\\package -Architecture x64 -OutputDirectory \\x64-wrong-gatewayMethods\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-gatewayMethods", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "gatewayMethods", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-gatewayMethods\\package -Architecture x64 -OutputDirectory \\x64-missing-gatewayMethods\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-tools", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "tools", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [ + "fixture.tool" + ], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-tools\\package -Architecture x64 -OutputDirectory \\x64-wrong-tools\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-tools", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "tools", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-tools\\package -Architecture x64 -OutputDirectory \\x64-missing-tools\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-services", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "services", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [ + "fixture.service" + ], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-services\\package -Architecture x64 -OutputDirectory \\x64-wrong-services\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-services", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "services", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-services\\package -Architecture x64 -OutputDirectory \\x64-missing-services\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-wrong-diagnostics", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "diagnostics", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [ + "fixture diagnostic" + ] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-wrong-diagnostics\\package -Architecture x64 -OutputDirectory \\x64-wrong-diagnostics\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-diagnostics", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "diagnostics", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-diagnostics\\package -Architecture x64 -OutputDirectory \\x64-missing-diagnostics\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-excess-plugin-httpRoutes", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.httpRoutes", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 2, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-excess-plugin-httpRoutes\\package -Architecture x64 -OutputDirectory \\x64-excess-plugin-httpRoutes\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-excess-httpRouteCount", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "httpRouteCount", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 2, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-excess-httpRouteCount\\package -Architecture x64 -OutputDirectory \\x64-excess-httpRouteCount\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "x64-missing-plugin-object", + "architecture": "x64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin", + "fixtureInspection": { + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\x64-missing-plugin-object\\package -Architecture x64 -OutputDirectory \\x64-missing-plugin-object\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-accepted-existing-environment", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "accepted", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "accepted", + "actual": "accepted", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-accepted-existing-environment\\package -Architecture arm64 -OutputDirectory \\arm64-accepted-existing-environment\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": { + "shippingFileCount": 3, + "files": [ + { + "file": "index.js", + "sourceSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "payloadSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "identical": true + }, + { + "file": "openclaw.plugin.json", + "sourceSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "payloadSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "identical": true + }, + { + "file": "package.json", + "sourceSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "payloadSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "identical": true + } + ], + "noTestFile": true, + "metadataArchitecture": "arm64", + "metadataLayout": "expanded-directory", + "metadataNodeVersion": "v24.16.0", + "metadataNpmVersion": "11.13.0" + }, + "rejectedOutputAbsent": false + }, + { + "id": "arm64-accepted-absent-environment", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "accepted", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "accepted", + "actual": "accepted", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-accepted-absent-environment\\package -Architecture arm64 -OutputDirectory \\arm64-accepted-absent-environment\\payload", + "environmentBefore": "absent", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": { + "shippingFileCount": 3, + "files": [ + { + "file": "index.js", + "sourceSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "payloadSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "identical": true + }, + { + "file": "openclaw.plugin.json", + "sourceSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "payloadSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "identical": true + }, + { + "file": "package.json", + "sourceSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "payloadSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "identical": true + } + ], + "noTestFile": true, + "metadataArchitecture": "arm64", + "metadataLayout": "expanded-directory", + "metadataNodeVersion": "v24.16.0", + "metadataNpmVersion": "11.13.0" + }, + "rejectedOutputAbsent": false + }, + { + "id": "arm64-accepted-empty-environment", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "accepted", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "accepted", + "actual": "accepted", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-accepted-empty-environment\\package -Architecture arm64 -OutputDirectory \\arm64-accepted-empty-environment\\payload", + "environmentBefore": "empty", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": { + "shippingFileCount": 3, + "files": [ + { + "file": "index.js", + "sourceSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "payloadSha256": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd", + "identical": true + }, + { + "file": "openclaw.plugin.json", + "sourceSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "payloadSha256": "a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651", + "identical": true + }, + { + "file": "package.json", + "sourceSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "payloadSha256": "5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142", + "identical": true + } + ], + "noTestFile": true, + "metadataArchitecture": "arm64", + "metadataLayout": "expanded-directory", + "metadataNodeVersion": "v24.16.0", + "metadataNpmVersion": "11.13.0" + }, + "rejectedOutputAbsent": false + }, + { + "id": "arm64-missing-extensions", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing-extensions", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The pinned OpenClaw payload does not expose the supported bundled plugin directory: \\arm64-missing-extensions\\runner\\openclaw-stage-arm64\\node_modules\\openclaw\\dist\\extensions", + "actual": "The pinned OpenClaw payload does not expose the supported bundled plugin directory: \\arm64-missing-extensions\\runner\\openclaw-stage-arm64\\node_modules\\openclaw\\dist\\extensions", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-extensions\\package -Architecture arm64 -OutputDirectory \\arm64-missing-extensions\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-preexisting-plugin", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "preexisting-plugin", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content.", + "actual": "The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-preexisting-plugin\\package -Architecture arm64 -OutputDirectory \\arm64-preexisting-plugin\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-inspection-exit-23", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "exit", + "field": null, + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23.", + "actual": "The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-inspection-exit-23\\package -Architecture arm64 -OutputDirectory \\arm64-inspection-exit-23\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-plugin-id", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.id", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "unexpected-plugin", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-plugin-id\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-plugin-id\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-id", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.id", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-id\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-id\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-plugin-origin", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.origin", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "global" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-plugin-origin\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-plugin-origin\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-origin", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.origin", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-origin\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-origin\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-plugin-enabled", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.enabled", + "fixtureInspection": { + "plugin": { + "enabled": false, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-plugin-enabled\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-plugin-enabled\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-enabled", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.enabled", + "fixtureInspection": { + "plugin": { + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-enabled\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-enabled\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-plugin-activated", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.activated", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": false, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-plugin-activated\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-plugin-activated\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-activated", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.activated", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1 + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-activated\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-activated\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-plugin-status", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.status", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "error", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-plugin-status\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-plugin-status\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-status", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.status", + "fixtureInspection": { + "plugin": { + "enabled": true, + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-status\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-status\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-plugin-imported", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.imported", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": false, + "status": "loaded", + "httpRoutes": 1, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-plugin-imported\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-plugin-imported\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-imported", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.imported", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-imported\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-imported\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-plugin-httpRoutes", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.httpRoutes", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 0, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-plugin-httpRoutes\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-plugin-httpRoutes\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-httpRoutes", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin.httpRoutes", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-httpRoutes\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-httpRoutes\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-httpRouteCount", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "httpRouteCount", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 0, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-httpRouteCount\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-httpRouteCount\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-httpRouteCount", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "httpRouteCount", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-httpRouteCount\\package -Architecture arm64 -OutputDirectory \\arm64-missing-httpRouteCount\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-gatewayMethods", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "gatewayMethods", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [ + "fixture.method" + ], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-gatewayMethods\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-gatewayMethods\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-gatewayMethods", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "gatewayMethods", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-gatewayMethods\\package -Architecture arm64 -OutputDirectory \\arm64-missing-gatewayMethods\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-tools", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "tools", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [ + "fixture.tool" + ], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-tools\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-tools\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-tools", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "tools", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-tools\\package -Architecture arm64 -OutputDirectory \\arm64-missing-tools\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-services", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "services", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [ + "fixture.service" + ], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-services\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-services\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-services", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "services", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-services\\package -Architecture arm64 -OutputDirectory \\arm64-missing-services\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-wrong-diagnostics", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "diagnostics", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [ + "fixture diagnostic" + ] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-wrong-diagnostics\\package -Architecture arm64 -OutputDirectory \\arm64-wrong-diagnostics\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-diagnostics", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "diagnostics", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-diagnostics\\package -Architecture arm64 -OutputDirectory \\arm64-missing-diagnostics\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-excess-plugin-httpRoutes", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "plugin.httpRoutes", + "fixtureInspection": { + "plugin": { + "enabled": true, + "activated": true, + "id": "gateway-isolation", + "imported": true, + "status": "loaded", + "httpRoutes": 2, + "origin": "bundled" + }, + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-excess-plugin-httpRoutes\\package -Architecture arm64 -OutputDirectory \\arm64-excess-plugin-httpRoutes\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-excess-httpRouteCount", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "wrong", + "field": "httpRouteCount", + "fixtureInspection": { + "plugin": { + "enabled": true, + "status": "loaded", + "imported": true, + "origin": "bundled", + "id": "gateway-isolation", + "httpRoutes": 1, + "activated": true + }, + "httpRouteCount": 2, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-excess-httpRouteCount\\package -Architecture arm64 -OutputDirectory \\arm64-excess-httpRouteCount\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + }, + { + "id": "arm64-missing-plugin-object", + "architecture": "arm64", + "outcome": "PASS", + "fixtureType": "synthetic-local-npm-package-and-runtime-inspection", + "mutation": "missing", + "field": "plugin", + "fixtureInspection": { + "httpRouteCount": 1, + "gatewayMethods": [], + "tools": [], + "services": [], + "diagnostics": [] + }, + "expected": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "actual": "The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.", + "command": "& .\\scripts\\Build-Payload.ps1 -PackageDirectory \\arm64-missing-plugin-object\\package -Architecture arm64 -OutputDirectory \\arm64-missing-plugin-object\\payload", + "environmentBefore": "existing sentinels", + "environmentRestored": { + "npm_config_arch": true, + "npm_config_target_arch": true, + "OPENCLAW_STATE_DIR": true, + "CLAWCTL_GATEWAY_ISOLATION": true, + "RUNNER_TEMP": true + }, + "workingDirectoryRestored": true, + "runtimeObservations": [ + { + "command": "runtime-inspection", + "args": [ + "plugins", + "inspect", + "gateway-isolation", + "--runtime", + "--json" + ], + "nodeVersion": "v24.16.0", + "isolationMode": "disabled", + "stateDirectoryMatchesExpected": true + } + ], + "content": null, + "rejectedOutputAbsent": true + } + ] +} diff --git a/docs/validation/pr-28/payload-matrix.txt b/docs/validation/pr-28/payload-matrix.txt new file mode 100644 index 00000000..12a960ca --- /dev/null +++ b/docs/validation/pr-28/payload-matrix.txt @@ -0,0 +1,625 @@ +Gateway isolation payload composition matrix +Fixture type: synthetic local npm packages and synthetic runtime-inspection responses. +Real operations: npm pack, npm install, scripts\Build-Payload.ps1, file copy, runtime command invocation, payload metadata. +This matrix tests the x64 and ARM64 composition contract with controlled runtime-inspection fixtures. +Production source: 9aa1df286c2b6fd59b4c101201ddae0ae6000324 +Evidence checkout HEAD: 2ce86d5bf4b54809ab1b4dcf5930db74d36b6d84 +Runtime: pwsh 7.6.6; Node.js v24.16.0; npm 11.13.0; Windows x64 host +Command: $env:PATH = ";" + $env:PATH +Command: pwsh -NoProfile -File .\scripts\validation\Test-GatewayIsolationPayloadMatrix.ps1 +All npm operations use an isolated generated cache and npm_config_offline=true; packages have no dependencies or lifecycle scripts. + +CASE x64-accepted-existing-environment PASS +Command (from \x64-accepted-existing-environment\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-accepted-existing-environment\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-accepted-existing-environment\package -Architecture x64 -OutputDirectory \x64-accepted-existing-environment\payload +Expected: accepted +Actual: accepted +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection, version-smoke +Shipping plugin files: 3; all source/payload SHA-256 hashes identical; index.test.js absent; metadata architecture: x64 +SHA256 index.js 7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd +SHA256 openclaw.plugin.json a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651 +SHA256 package.json 5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142 + +CASE x64-accepted-absent-environment PASS +Command (from \x64-accepted-absent-environment\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-accepted-absent-environment\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-accepted-absent-environment\package -Architecture x64 -OutputDirectory \x64-accepted-absent-environment\payload +Expected: accepted +Actual: accepted +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection, version-smoke +Shipping plugin files: 3; all source/payload SHA-256 hashes identical; index.test.js absent; metadata architecture: x64 +SHA256 index.js 7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd +SHA256 openclaw.plugin.json a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651 +SHA256 package.json 5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142 + +CASE x64-accepted-empty-environment PASS +Command (from \x64-accepted-empty-environment\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-accepted-empty-environment\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-accepted-empty-environment\package -Architecture x64 -OutputDirectory \x64-accepted-empty-environment\payload +Expected: accepted +Actual: accepted +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection, version-smoke +Shipping plugin files: 3; all source/payload SHA-256 hashes identical; index.test.js absent; metadata architecture: x64 +SHA256 index.js 7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd +SHA256 openclaw.plugin.json a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651 +SHA256 package.json 5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142 + +CASE x64-missing-extensions PASS +Command (from \x64-missing-extensions\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-extensions\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-extensions\package -Architecture x64 -OutputDirectory \x64-missing-extensions\payload +Expected: The pinned OpenClaw payload does not expose the supported bundled plugin directory: \x64-missing-extensions\runner\openclaw-stage-x64\node_modules\openclaw\dist\extensions +Actual: The pinned OpenClaw payload does not expose the supported bundled plugin directory: \x64-missing-extensions\runner\openclaw-stage-x64\node_modules\openclaw\dist\extensions +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: +Rejected application and payload metadata absent. + +CASE x64-preexisting-plugin PASS +Command (from \x64-preexisting-plugin\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-preexisting-plugin\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-preexisting-plugin\package -Architecture x64 -OutputDirectory \x64-preexisting-plugin\payload +Expected: The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content. +Actual: The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: +Rejected application and payload metadata absent. + +CASE x64-inspection-exit-23 PASS +Command (from \x64-inspection-exit-23\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-inspection-exit-23\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-inspection-exit-23\package -Architecture x64 -OutputDirectory \x64-inspection-exit-23\payload +Expected: The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23. +Actual: The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-plugin-id PASS +Command (from \x64-wrong-plugin-id\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-plugin-id\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-plugin-id\package -Architecture x64 -OutputDirectory \x64-wrong-plugin-id\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-id PASS +Command (from \x64-missing-plugin-id\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-id\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-id\package -Architecture x64 -OutputDirectory \x64-missing-plugin-id\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-plugin-origin PASS +Command (from \x64-wrong-plugin-origin\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-plugin-origin\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-plugin-origin\package -Architecture x64 -OutputDirectory \x64-wrong-plugin-origin\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-origin PASS +Command (from \x64-missing-plugin-origin\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-origin\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-origin\package -Architecture x64 -OutputDirectory \x64-missing-plugin-origin\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-plugin-enabled PASS +Command (from \x64-wrong-plugin-enabled\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-plugin-enabled\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-plugin-enabled\package -Architecture x64 -OutputDirectory \x64-wrong-plugin-enabled\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-enabled PASS +Command (from \x64-missing-plugin-enabled\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-enabled\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-enabled\package -Architecture x64 -OutputDirectory \x64-missing-plugin-enabled\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-plugin-activated PASS +Command (from \x64-wrong-plugin-activated\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-plugin-activated\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-plugin-activated\package -Architecture x64 -OutputDirectory \x64-wrong-plugin-activated\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-activated PASS +Command (from \x64-missing-plugin-activated\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-activated\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-activated\package -Architecture x64 -OutputDirectory \x64-missing-plugin-activated\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-plugin-status PASS +Command (from \x64-wrong-plugin-status\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-plugin-status\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-plugin-status\package -Architecture x64 -OutputDirectory \x64-wrong-plugin-status\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-status PASS +Command (from \x64-missing-plugin-status\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-status\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-status\package -Architecture x64 -OutputDirectory \x64-missing-plugin-status\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-plugin-imported PASS +Command (from \x64-wrong-plugin-imported\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-plugin-imported\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-plugin-imported\package -Architecture x64 -OutputDirectory \x64-wrong-plugin-imported\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-imported PASS +Command (from \x64-missing-plugin-imported\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-imported\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-imported\package -Architecture x64 -OutputDirectory \x64-missing-plugin-imported\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-plugin-httpRoutes PASS +Command (from \x64-wrong-plugin-httpRoutes\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-plugin-httpRoutes\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-plugin-httpRoutes\package -Architecture x64 -OutputDirectory \x64-wrong-plugin-httpRoutes\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-httpRoutes PASS +Command (from \x64-missing-plugin-httpRoutes\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-httpRoutes\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-httpRoutes\package -Architecture x64 -OutputDirectory \x64-missing-plugin-httpRoutes\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-httpRouteCount PASS +Command (from \x64-wrong-httpRouteCount\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-httpRouteCount\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-httpRouteCount\package -Architecture x64 -OutputDirectory \x64-wrong-httpRouteCount\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-httpRouteCount PASS +Command (from \x64-missing-httpRouteCount\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-httpRouteCount\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-httpRouteCount\package -Architecture x64 -OutputDirectory \x64-missing-httpRouteCount\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-gatewayMethods PASS +Command (from \x64-wrong-gatewayMethods\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-gatewayMethods\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-gatewayMethods\package -Architecture x64 -OutputDirectory \x64-wrong-gatewayMethods\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-gatewayMethods PASS +Command (from \x64-missing-gatewayMethods\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-gatewayMethods\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-gatewayMethods\package -Architecture x64 -OutputDirectory \x64-missing-gatewayMethods\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-tools PASS +Command (from \x64-wrong-tools\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-tools\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-tools\package -Architecture x64 -OutputDirectory \x64-wrong-tools\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-tools PASS +Command (from \x64-missing-tools\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-tools\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-tools\package -Architecture x64 -OutputDirectory \x64-missing-tools\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-services PASS +Command (from \x64-wrong-services\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-services\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-services\package -Architecture x64 -OutputDirectory \x64-wrong-services\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-services PASS +Command (from \x64-missing-services\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-services\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-services\package -Architecture x64 -OutputDirectory \x64-missing-services\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-wrong-diagnostics PASS +Command (from \x64-wrong-diagnostics\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-wrong-diagnostics\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-wrong-diagnostics\package -Architecture x64 -OutputDirectory \x64-wrong-diagnostics\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-diagnostics PASS +Command (from \x64-missing-diagnostics\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-diagnostics\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-diagnostics\package -Architecture x64 -OutputDirectory \x64-missing-diagnostics\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-excess-plugin-httpRoutes PASS +Command (from \x64-excess-plugin-httpRoutes\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-excess-plugin-httpRoutes\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-excess-plugin-httpRoutes\package -Architecture x64 -OutputDirectory \x64-excess-plugin-httpRoutes\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-excess-httpRouteCount PASS +Command (from \x64-excess-httpRouteCount\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-excess-httpRouteCount\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-excess-httpRouteCount\package -Architecture x64 -OutputDirectory \x64-excess-httpRouteCount\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE x64-missing-plugin-object PASS +Command (from \x64-missing-plugin-object\package-source): npm pack --offline --ignore-scripts --pack-destination \x64-missing-plugin-object\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \x64-missing-plugin-object\package -Architecture x64 -OutputDirectory \x64-missing-plugin-object\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-accepted-existing-environment PASS +Command (from \arm64-accepted-existing-environment\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-accepted-existing-environment\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-accepted-existing-environment\package -Architecture arm64 -OutputDirectory \arm64-accepted-existing-environment\payload +Expected: accepted +Actual: accepted +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Shipping plugin files: 3; all source/payload SHA-256 hashes identical; index.test.js absent; metadata architecture: arm64 +SHA256 index.js 7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd +SHA256 openclaw.plugin.json a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651 +SHA256 package.json 5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142 + +CASE arm64-accepted-absent-environment PASS +Command (from \arm64-accepted-absent-environment\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-accepted-absent-environment\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-accepted-absent-environment\package -Architecture arm64 -OutputDirectory \arm64-accepted-absent-environment\payload +Expected: accepted +Actual: accepted +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Shipping plugin files: 3; all source/payload SHA-256 hashes identical; index.test.js absent; metadata architecture: arm64 +SHA256 index.js 7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd +SHA256 openclaw.plugin.json a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651 +SHA256 package.json 5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142 + +CASE arm64-accepted-empty-environment PASS +Command (from \arm64-accepted-empty-environment\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-accepted-empty-environment\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-accepted-empty-environment\package -Architecture arm64 -OutputDirectory \arm64-accepted-empty-environment\payload +Expected: accepted +Actual: accepted +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Shipping plugin files: 3; all source/payload SHA-256 hashes identical; index.test.js absent; metadata architecture: arm64 +SHA256 index.js 7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd +SHA256 openclaw.plugin.json a1359e966e39d799f400a7250a4366cd1ef0b0bb8be954100285a2c1da298651 +SHA256 package.json 5a4c1c3966c782455e6505dedc9427c8096917f520ca6486f169fc4db5e50142 + +CASE arm64-missing-extensions PASS +Command (from \arm64-missing-extensions\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-extensions\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-extensions\package -Architecture arm64 -OutputDirectory \arm64-missing-extensions\payload +Expected: The pinned OpenClaw payload does not expose the supported bundled plugin directory: \arm64-missing-extensions\runner\openclaw-stage-arm64\node_modules\openclaw\dist\extensions +Actual: The pinned OpenClaw payload does not expose the supported bundled plugin directory: \arm64-missing-extensions\runner\openclaw-stage-arm64\node_modules\openclaw\dist\extensions +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: +Rejected application and payload metadata absent. + +CASE arm64-preexisting-plugin PASS +Command (from \arm64-preexisting-plugin\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-preexisting-plugin\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-preexisting-plugin\package -Architecture arm64 -OutputDirectory \arm64-preexisting-plugin\payload +Expected: The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content. +Actual: The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: +Rejected application and payload metadata absent. + +CASE arm64-inspection-exit-23 PASS +Command (from \arm64-inspection-exit-23\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-inspection-exit-23\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-inspection-exit-23\package -Architecture arm64 -OutputDirectory \arm64-inspection-exit-23\payload +Expected: The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23. +Actual: The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-plugin-id PASS +Command (from \arm64-wrong-plugin-id\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-plugin-id\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-plugin-id\package -Architecture arm64 -OutputDirectory \arm64-wrong-plugin-id\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-id PASS +Command (from \arm64-missing-plugin-id\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-id\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-id\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-id\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-plugin-origin PASS +Command (from \arm64-wrong-plugin-origin\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-plugin-origin\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-plugin-origin\package -Architecture arm64 -OutputDirectory \arm64-wrong-plugin-origin\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-origin PASS +Command (from \arm64-missing-plugin-origin\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-origin\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-origin\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-origin\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-plugin-enabled PASS +Command (from \arm64-wrong-plugin-enabled\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-plugin-enabled\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-plugin-enabled\package -Architecture arm64 -OutputDirectory \arm64-wrong-plugin-enabled\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-enabled PASS +Command (from \arm64-missing-plugin-enabled\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-enabled\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-enabled\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-enabled\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-plugin-activated PASS +Command (from \arm64-wrong-plugin-activated\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-plugin-activated\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-plugin-activated\package -Architecture arm64 -OutputDirectory \arm64-wrong-plugin-activated\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-activated PASS +Command (from \arm64-missing-plugin-activated\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-activated\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-activated\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-activated\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-plugin-status PASS +Command (from \arm64-wrong-plugin-status\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-plugin-status\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-plugin-status\package -Architecture arm64 -OutputDirectory \arm64-wrong-plugin-status\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-status PASS +Command (from \arm64-missing-plugin-status\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-status\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-status\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-status\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-plugin-imported PASS +Command (from \arm64-wrong-plugin-imported\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-plugin-imported\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-plugin-imported\package -Architecture arm64 -OutputDirectory \arm64-wrong-plugin-imported\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-imported PASS +Command (from \arm64-missing-plugin-imported\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-imported\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-imported\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-imported\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-plugin-httpRoutes PASS +Command (from \arm64-wrong-plugin-httpRoutes\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-plugin-httpRoutes\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-plugin-httpRoutes\package -Architecture arm64 -OutputDirectory \arm64-wrong-plugin-httpRoutes\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-httpRoutes PASS +Command (from \arm64-missing-plugin-httpRoutes\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-httpRoutes\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-httpRoutes\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-httpRoutes\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-httpRouteCount PASS +Command (from \arm64-wrong-httpRouteCount\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-httpRouteCount\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-httpRouteCount\package -Architecture arm64 -OutputDirectory \arm64-wrong-httpRouteCount\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-httpRouteCount PASS +Command (from \arm64-missing-httpRouteCount\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-httpRouteCount\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-httpRouteCount\package -Architecture arm64 -OutputDirectory \arm64-missing-httpRouteCount\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-gatewayMethods PASS +Command (from \arm64-wrong-gatewayMethods\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-gatewayMethods\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-gatewayMethods\package -Architecture arm64 -OutputDirectory \arm64-wrong-gatewayMethods\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-gatewayMethods PASS +Command (from \arm64-missing-gatewayMethods\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-gatewayMethods\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-gatewayMethods\package -Architecture arm64 -OutputDirectory \arm64-missing-gatewayMethods\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-tools PASS +Command (from \arm64-wrong-tools\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-tools\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-tools\package -Architecture arm64 -OutputDirectory \arm64-wrong-tools\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-tools PASS +Command (from \arm64-missing-tools\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-tools\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-tools\package -Architecture arm64 -OutputDirectory \arm64-missing-tools\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-services PASS +Command (from \arm64-wrong-services\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-services\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-services\package -Architecture arm64 -OutputDirectory \arm64-wrong-services\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-services PASS +Command (from \arm64-missing-services\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-services\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-services\package -Architecture arm64 -OutputDirectory \arm64-missing-services\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-wrong-diagnostics PASS +Command (from \arm64-wrong-diagnostics\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-wrong-diagnostics\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-wrong-diagnostics\package -Architecture arm64 -OutputDirectory \arm64-wrong-diagnostics\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-diagnostics PASS +Command (from \arm64-missing-diagnostics\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-diagnostics\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-diagnostics\package -Architecture arm64 -OutputDirectory \arm64-missing-diagnostics\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-excess-plugin-httpRoutes PASS +Command (from \arm64-excess-plugin-httpRoutes\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-excess-plugin-httpRoutes\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-excess-plugin-httpRoutes\package -Architecture arm64 -OutputDirectory \arm64-excess-plugin-httpRoutes\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-excess-httpRouteCount PASS +Command (from \arm64-excess-httpRouteCount\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-excess-httpRouteCount\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-excess-httpRouteCount\package -Architecture arm64 -OutputDirectory \arm64-excess-httpRouteCount\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +CASE arm64-missing-plugin-object PASS +Command (from \arm64-missing-plugin-object\package-source): npm pack --offline --ignore-scripts --pack-destination \arm64-missing-plugin-object\package --silent +Command: & .\scripts\Build-Payload.ps1 -PackageDirectory \arm64-missing-plugin-object\package -Architecture arm64 -OutputDirectory \arm64-missing-plugin-object\payload +Expected: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Actual: The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape. +Environment restored: npm_config_arch, npm_config_target_arch, OPENCLAW_STATE_DIR, CLAWCTL_GATEWAY_ISOLATION, RUNNER_TEMP; working directory restored: true +Runtime calls: runtime-inspection +Rejected application and payload metadata absent. + +RESULT: 66 passed; 6 accepted; 60 rejected as expected; 0 failed. +Harness environment and working directory restored. Generated TestRoot removed. No preexisting directories were removed. diff --git a/docs/validation/pr-28/plugin-tests.tap b/docs/validation/pr-28/plugin-tests.tap new file mode 100644 index 00000000..1d0ce224 --- /dev/null +++ b/docs/validation/pr-28/plugin-tests.tap @@ -0,0 +1,136 @@ +TAP version 13 +# Subtest: accepts only the exact launcher isolation values +ok 1 - accepts only the exact launcher isolation values + --- + duration_ms: 1.4354 + type: 'test' + ... +# Subtest: renders the exact enabled read-only status +ok 2 - renders the exact enabled read-only status + --- + duration_ms: 0.4472 + type: 'test' + ... +# Subtest: renders the exact disabled read-only status +ok 3 - renders the exact disabled read-only status + --- + duration_ms: 0.1477 + type: 'test' + ... +# Subtest: registers one read-only Control tab and one authenticated sandbox route +ok 4 - registers one read-only Control tab and one authenticated sandbox route + --- + duration_ms: 1.2608 + type: 'test' + ... +# Subtest: reads "enabled" exactly once and keeps every response stable +ok 5 - reads "enabled" exactly once and keeps every response stable + --- + duration_ms: 0.4925 + type: 'test' + ... +# Subtest: reads "disabled" exactly once and keeps every response stable +ok 6 - reads "disabled" exactly once and keeps every response stable + --- + duration_ms: 0.6573 + type: 'test' + ... +# Subtest: reads missing exactly once and keeps every response stable +ok 7 - reads missing exactly once and keeps every response stable + --- + duration_ms: 0.4355 + type: 'test' + ... +# Subtest: reads "" exactly once and keeps every response stable +ok 8 - reads "" exactly once and keeps every response stable + --- + duration_ms: 0.2525 + type: 'test' + ... +# Subtest: reads "invalid" exactly once and keeps every response stable +ok 9 - reads "invalid" exactly once and keeps every response stable + --- + duration_ms: 0.3347 + type: 'test' + ... +# Subtest: reads "ENABLED" exactly once and keeps every response stable +ok 10 - reads "ENABLED" exactly once and keeps every response stable + --- + duration_ms: 0.3578 + type: 'test' + ... +# Subtest: reads " enabled " exactly once and keeps every response stable +ok 11 - reads " enabled " exactly once and keeps every response stable + --- + duration_ms: 0.2516 + type: 'test' + ... +# Subtest: fails closed for missing with no status, command, or copy control +ok 12 - fails closed for missing with no status, command, or copy control + --- + duration_ms: 1.206 + type: 'test' + ... +# Subtest: renderer rejects missing +ok 13 - renderer rejects missing + --- + duration_ms: 0.7585 + type: 'test' + ... +# Subtest: fails closed for "" with no status, command, or copy control +ok 14 - fails closed for "" with no status, command, or copy control + --- + duration_ms: 0.2382 + type: 'test' + ... +# Subtest: renderer rejects "" +ok 15 - renderer rejects "" + --- + duration_ms: 0.08 + type: 'test' + ... +# Subtest: fails closed for "invalid" with no status, command, or copy control +ok 16 - fails closed for "invalid" with no status, command, or copy control + --- + duration_ms: 0.1374 + type: 'test' + ... +# Subtest: renderer rejects "invalid" +ok 17 - renderer rejects "invalid" + --- + duration_ms: 0.0748 + type: 'test' + ... +# Subtest: fails closed for "ENABLED" with no status, command, or copy control +ok 18 - fails closed for "ENABLED" with no status, command, or copy control + --- + duration_ms: 0.1684 + type: 'test' + ... +# Subtest: renderer rejects "ENABLED" +ok 19 - renderer rejects "ENABLED" + --- + duration_ms: 0.1728 + type: 'test' + ... +# Subtest: fails closed for " enabled " with no status, command, or copy control +ok 20 - fails closed for " enabled " with no status, command, or copy control + --- + duration_ms: 0.0809 + type: 'test' + ... +# Subtest: renderer rejects " enabled " +ok 21 - renderer rejects " enabled " + --- + duration_ms: 0.0741 + type: 'test' + ... +1..21 +# tests 21 +# suites 0 +# pass 21 +# fail 0 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 172.9231 diff --git a/docs/validation/pr-28/policy-checks.txt b/docs/validation/pr-28/policy-checks.txt new file mode 100644 index 00000000..7a9a3383 --- /dev/null +++ b/docs/validation/pr-28/policy-checks.txt @@ -0,0 +1,4 @@ +Authorized official signing for OpenClaw commit 0965053fe6b9341776df147a6934b7485c60b5ca and Gateway MSIX version 0.1.1.0. +Authorized official signing for OpenClaw commit 0965053fe6b9341776df147a6934b7485c60b5ca and Gateway MSIX version 0.1.1.0. +Gateway MSIX signing policy tests passed. +Workflow package-version tests passed. diff --git a/docs/validation/pr-28/runtime-checks.txt b/docs/validation/pr-28/runtime-checks.txt new file mode 100644 index 00000000..313b6390 --- /dev/null +++ b/docs/validation/pr-28/runtime-checks.txt @@ -0,0 +1,8 @@ +PASS launcher-disabled: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; visible sidebar tab; sandboxed iframe; exact status/tone/inverse CLI; Copy writes exact command to clipboard +PASS fixture-enabled: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; visible sidebar tab; sandboxed iframe; exact status/tone/inverse CLI; Copy writes exact command to clipboard +PASS fixture-disabled: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; visible sidebar tab; sandboxed iframe; exact status/tone/inverse CLI; Copy writes exact command to clipboard +PASS fixture-missing: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; HTTP 503 fails closed; Control UI error without stale status or command +PASS fixture-invalid: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; HTTP 503 fails closed; Control UI error without stale status or command +PASS fixture-uppercase: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; HTTP 503 fails closed; Control UI error without stale status or command +PASS fixture-empty: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; HTTP 503 fails closed; Control UI error without stale status or command +PASS fixture-whitespace: 10 HTTP assertions; GET/HEAD auth denial and valid auth; response hardening headers; write-method probes leave status unchanged; HTTP 503 fails closed; Control UI error without stale status or command diff --git a/docs/validation/pr-28/runtime-matrix.json b/docs/validation/pr-28/runtime-matrix.json new file mode 100644 index 00000000..e97d3f08 --- /dev/null +++ b/docs/validation/pr-28/runtime-matrix.json @@ -0,0 +1,686 @@ +{ + "source": "9aa1df286c2b6fd59b4c101201ddae0ae6000324", + "build": { + "version": "2026.8.2", + "commit": "0965053fe6b9341776df147a6934b7485c60b5ca", + "builtAt": "2026-09-11T00:24:39.157Z", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z" + }, + "environment": { + "os": "Windows x64", + "node": "v24.16.0", + "layout": "expanded application", + "port": 19428, + "browser": "152.0.4191.66" + }, + "cases": [ + { + "id": "launcher-disabled", + "launch": "NativeAOT launcher", + "input": "enabled", + "expectedMode": "disabled", + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 200 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 200 + }, + { + "method": "POST", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "visible sidebar tab", + "sandboxed iframe", + "exact status/tone/inverse CLI", + "Copy writes exact command to clipboard" + ], + "ui": { + "state": "Disabled", + "tone": "warn", + "command": "clawctl gateway-isolation enable", + "sandbox": "allow-scripts", + "copy": "exact clipboard text verified" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "launcher-disabled.png", + "passed": true + }, + { + "id": "fixture-enabled", + "launch": "Node with launcher-input fixture", + "input": "enabled", + "expectedMode": "enabled", + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 200 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 200 + }, + { + "method": "POST", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "visible sidebar tab", + "sandboxed iframe", + "exact status/tone/inverse CLI", + "Copy writes exact command to clipboard" + ], + "ui": { + "state": "Enabled", + "tone": "ok", + "command": "clawctl gateway-isolation disable", + "sandbox": "allow-scripts", + "copy": "exact clipboard text verified" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "fixture-enabled.png", + "passed": true + }, + { + "id": "fixture-disabled", + "launch": "Node with launcher-input fixture", + "input": "disabled", + "expectedMode": "disabled", + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 200 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 200 + }, + { + "method": "POST", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "visible sidebar tab", + "sandboxed iframe", + "exact status/tone/inverse CLI", + "Copy writes exact command to clipboard" + ], + "ui": { + "state": "Disabled", + "tone": "warn", + "command": "clawctl gateway-isolation enable", + "sandbox": "allow-scripts", + "copy": "exact clipboard text verified" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "fixture-disabled.png", + "passed": true + }, + { + "id": "fixture-missing", + "launch": "Node with launcher-input fixture", + "input": "(missing)", + "expectedMode": null, + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 503 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 503 + }, + { + "method": "POST", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "HTTP 503 fails closed", + "Control UI error without stale status or command" + ], + "ui": { + "status": "invalid launcher mode message", + "staleStatus": false, + "sandbox": "allow-scripts" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "fixture-missing.png", + "passed": true + }, + { + "id": "fixture-invalid", + "launch": "Node with launcher-input fixture", + "input": "invalid", + "expectedMode": null, + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 503 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 503 + }, + { + "method": "POST", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "HTTP 503 fails closed", + "Control UI error without stale status or command" + ], + "ui": { + "status": "invalid launcher mode message", + "staleStatus": false, + "sandbox": "allow-scripts" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "fixture-invalid.png", + "passed": true + }, + { + "id": "fixture-uppercase", + "launch": "Node with launcher-input fixture", + "input": "ENABLED", + "expectedMode": null, + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 503 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 503 + }, + { + "method": "POST", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "HTTP 503 fails closed", + "Control UI error without stale status or command" + ], + "ui": { + "status": "invalid launcher mode message", + "staleStatus": false, + "sandbox": "allow-scripts" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "fixture-uppercase.png", + "passed": true + }, + { + "id": "fixture-empty", + "launch": "Node with launcher-input fixture", + "input": "", + "expectedMode": null, + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 503 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 503 + }, + { + "method": "POST", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "HTTP 503 fails closed", + "Control UI error without stale status or command" + ], + "ui": { + "status": "invalid launcher mode message", + "staleStatus": false, + "sandbox": "allow-scripts" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "fixture-empty.png", + "passed": true + }, + { + "id": "fixture-whitespace", + "launch": "Node with launcher-input fixture", + "input": " enabled ", + "expectedMode": null, + "http": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 503 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 503 + }, + { + "method": "POST", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 503, + "effect": "same read-only response" + } + ], + "assertions": [ + "GET/HEAD auth denial and valid auth", + "response hardening headers", + "write-method probes leave status unchanged", + "HTTP 503 fails closed", + "Control UI error without stale status or command" + ], + "ui": { + "status": "invalid launcher mode message", + "staleStatus": false, + "sandbox": "allow-scripts" + }, + "hello": { + "version": "2026.8.2", + "buildId": "2026.8.2-release-0965053fe6b9-2026-09-11T00-24-39.157Z", + "controlUiBuildSource": "bundled" + }, + "screenshot": "fixture-whitespace.png", + "passed": true + } + ], + "sha256": { + "launcher": "a85063b9caedf346444db4da72fa3ab62a0bf913b6a4b6056b8d0b6d4a476def", + "node": "b3094d0b49f9ad602262a9921551737bb97637c05dd357a06ae98188d7290aa3", + "runtimeEntry": "4f4d29770da4f86dbd0e07cbd4d46deab785905dd89ac719033fcfd866fb5d17", + "plugin": "7f98aed21bf7773f78a25c94c47d4f0f4efd6998c314201869307b798f25d1cd" + }, + "completedAt": "2026-09-11T03:52:53.074Z", + "cleanup": "All validation-owned process trees stopped; port verified free" +} diff --git a/docs/validation/pr-28/runtime-registration.json b/docs/validation/pr-28/runtime-registration.json new file mode 100644 index 00000000..dd92ab42 --- /dev/null +++ b/docs/validation/pr-28/runtime-registration.json @@ -0,0 +1,28 @@ +{ + "command": "openclaw.exe plugins inspect gateway-isolation --runtime --json", + "plugin": { + "id": "gateway-isolation", + "origin": "bundled", + "enabled": true, + "activated": true, + "status": "loaded", + "imported": true, + "httpRoutes": 1 + }, + "capabilities": { + "httpRouteCount": 1, + "gatewayMethods": 0, + "tools": 0, + "services": 0, + "diagnostics": 0, + "typedHooks": 0, + "customHooks": 0, + "commands": 0, + "cliCommands": 0, + "gatewayDiscoveryServices": 0, + "mcpServers": 0, + "lspServers": 0 + }, + "shape": "non-capability", + "capabilityMode": "none" +} diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 2e5c09dd..c8f35830 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -113,30 +113,50 @@ test("registers one read-only Control tab and one authenticated sandbox route", assert.match(response.body, />Enabled { - const env = { CLAWCTL_GATEWAY_ISOLATION: "enabled" }; - const plugin = createGatewayIsolationPlugin(env); - env.CLAWCTL_GATEWAY_ISOLATION = "disabled"; - const routes = []; - plugin.register({ - session: { - controls: { - registerControlUiDescriptor() {}, +for (const initial of ["enabled", "disabled", undefined, "", "invalid", "ENABLED", " enabled "]) { + test(`reads ${JSON.stringify(initial) ?? "missing"} exactly once and keeps every response stable`, () => { + let reads = 0; + let value = initial; + const plugin = createGatewayIsolationPlugin({ + get CLAWCTL_GATEWAY_ISOLATION() { + reads++; + return value; }, - }, - registerHttpRoute(route) { - routes.push(route); - }, - }); + }); + assert.equal(reads, 1); + value = initial === "enabled" ? "disabled" : "enabled"; + const routes = []; + plugin.register({ + session: { controls: { registerControlUiDescriptor() {} } }, + registerHttpRoute(route) { + routes.push(route); + }, + }); - const response = invokeRoute(routes[0]); - assert.match(response.body, />EnabledDisabledEnabledDisabled { - const { routes } = registerPlugin("invalid"); - const response = invokeRoute(routes[0]); - assert.equal(response.statusCode, 503); - assert.match(response.body, /did not provide a valid Gateway isolation mode/); -}); +for (const mode of [undefined, "", "invalid", "ENABLED", " enabled "]) { + const label = JSON.stringify(mode) ?? "missing"; + test(`fails closed for ${label} with no status, command, or copy control`, () => { + const { routes } = registerPlugin(mode); + const response = invokeRoute(routes[0]); + assert.equal(response.statusCode, 503); + assert.match(response.body, /did not provide a valid Gateway isolation mode/); + assert.doesNotMatch(response.body, /status--(?:ok|warn)|isolation-command| + +

+ ${THEME_BRIDGE_SCRIPT} + `; } diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 2907b4de..0475aac0 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -101,11 +101,13 @@ for (const expected of [ mode: "enabled", status: "Enabled", tone: "status--ok", + command: "clawctl gateway-isolation disable", }, { mode: "disabled", status: "Disabled", tone: "status--warn", + command: "clawctl gateway-isolation enable", }, ]) { test(`renders the exact ${expected.mode} read-only status`, () => { @@ -114,8 +116,13 @@ for (const expected of [ assert.match(html, /Gateway Isolation/); assert.match(html, new RegExp(`>${expected.status}<`)); assert.match(html, new RegExp(expected.tone)); + assert.match(html, /Change with CLI/); + assert.match(html, /Run from the signed-in user session on the Gateway host\./); + assert.match(html, new RegExp(expected.command)); + assert.match(html, /aria-label="Copy command"/); + assert.match(html, /Copy the selected command manually\./); + assert.match(html, /copied = document\.execCommand\("copy"\)/); assert.match(html, /openclaw:widget-theme/); - assert.doesNotMatch(html, /Change with CLI|clipboard|copy-command|clawctl gateway-isolation/); assert.doesNotMatch(html, /next manual Gateway restart/i); }); } diff --git a/scripts/validation/gateway-isolation-runtime.mjs b/scripts/validation/gateway-isolation-runtime.mjs index 27d24448..65185dbe 100644 --- a/scripts/validation/gateway-isolation-runtime.mjs +++ b/scripts/validation/gateway-isolation-runtime.mjs @@ -286,7 +286,7 @@ async function themeSnapshot(page, frame, expectedTheme, expectedMode) { assert.equal(plugin.h1, "Windows Launcher"); assert.equal(plugin.status, "Disabled"); assert.equal(plugin.row, "Gateway Isolation"); - assert.equal(plugin.mutationGuidance, false); + assert.equal(plugin.mutationGuidance, true); return { hostTheme: expectedTheme, mode: expectedMode, tokens: plugin.values }; } @@ -351,6 +351,15 @@ const context = await browser.newContext({ const page = await context.newPage(); let hello; const rpcErrors = []; +const browserErrors = []; +page.on("console", (message) => { + if (message.type() === "error") { + browserErrors.push(`console: ${message.text()}`); + } +}); +page.on("pageerror", (error) => { + browserErrors.push(`page: ${error.message}`); +}); page.on("websocket", (socket) => socket.on("framereceived", ({ payload }) => { const frame = JSON.parse(payload.toString()); @@ -418,10 +427,9 @@ try { if (method === "GET") { canonicalBody = body; assert.match(body, />Disabled Boolean(hello), "authenticated hello-ok"); + try { + await until(() => Boolean(hello), "authenticated hello-ok"); + } catch (error) { + throw new Error( + `${error.message}\nGateway logs:\n${logs}\nBrowser errors:\n${browserErrors.join("\n")}`, + ); + } assert.equal(hello.buildId, build.buildId); assert.equal(hello.controlUiBuildSource, "bundled"); await pause(2_000); @@ -581,7 +595,7 @@ try { sameFrameAcrossLiveThemeSwitches: true, }, themes: themeResults, - unsupportedCliGuidanceAbsent: true, + cliControlPresent: true, passed: true, }; const json = `${JSON.stringify(result, null, 2)}\n`; From dbb141fd6c65c1915d3327f95d75659f4ea91edf Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 16:23:57 -0700 Subject: [PATCH 33/76] Add final themed launcher proof Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../themed-windows-launcher/README.md | 23 ++ .../themed-windows-launcher/claw-dark.png | Bin 0 -> 95738 bytes .../themed-windows-launcher/claw-light.png | Bin 0 -> 98912 bytes .../themed-windows-launcher/custom-dark.png | Bin 0 -> 88416 bytes .../themed-windows-launcher/custom-light.png | Bin 0 -> 91593 bytes .../runtime-results.json | 201 ++++++++++++++++++ .../validation/gateway-isolation-runtime.mjs | 28 +-- 7 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 docs/validation/themed-windows-launcher/README.md create mode 100644 docs/validation/themed-windows-launcher/claw-dark.png create mode 100644 docs/validation/themed-windows-launcher/claw-light.png create mode 100644 docs/validation/themed-windows-launcher/custom-dark.png create mode 100644 docs/validation/themed-windows-launcher/custom-light.png create mode 100644 docs/validation/themed-windows-launcher/runtime-results.json diff --git a/docs/validation/themed-windows-launcher/README.md b/docs/validation/themed-windows-launcher/README.md new file mode 100644 index 00000000..91db6ba2 --- /dev/null +++ b/docs/validation/themed-windows-launcher/README.md @@ -0,0 +1,23 @@ +# Themed Windows Launcher validation + +This proof was generated from packaging implementation commit +`5ef558a28f182fd002478db89b8844c8ac4747fc` against the merged generic theme +forwarding in `openclaw/openclaw` commit +`f65ecca89667b8a55d9f88d76c487f0a0ab11da8`. + +The four screenshots cover the default Claw dark and light themes plus +distinctive imported custom dark and light themes. Each run uses the same +authenticated Gateway process and iframe. The result metadata verifies: + +- `Windows Launcher` in the Control UI sidebar, tab, and page heading. +- `Gateway Isolation` as the launcher status row. +- The CLI command and Copy control remain present. +- Exact semantic color, font, and radius forwarding. +- One authenticated iframe request with no navigation or reload during live + theme changes. +- `sandbox="allow-scripts"` without `allow-same-origin`. +- Read-only HTTP behavior and fail-closed launcher mode handling. + +`runtime-results.json` contains sanitized environment details, source commits, +artifact hashes, route checks, frame checks, and semantic token results. This +is expanded-layout browser proof, not installed MSIX proof. diff --git a/docs/validation/themed-windows-launcher/claw-dark.png b/docs/validation/themed-windows-launcher/claw-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..b62b10a1f135772d4104dc7640a06845ccda039b GIT binary patch literal 95738 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 literal 0 HcmV?d00001 diff --git a/docs/validation/themed-windows-launcher/custom-light.png b/docs/validation/themed-windows-launcher/custom-light.png new file mode 100644 index 0000000000000000000000000000000000000000..00d0f38c7f06b36f33915aafb411a87e5fc6a146 GIT binary patch literal 91593 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%0Dtw6_GM!O^5UUYEFv?T1)y{}wE2YfVp);H6|p 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 { }); try { - await until(async () => { - if (child.exitCode !== null) { - throw new Error(`Gateway exited with ${child.exitCode}: ${logs}`); - } - try { - return (await fetch(`${base}/healthz`)).ok; - } catch (error) { - if (error.cause?.code !== "ECONNREFUSED") { - throw error; + try { + await until(async () => { + if (child.exitCode !== null) { + throw new Error(`Gateway exited with ${child.exitCode}: ${logs}`); } - return false; - } - }, "Gateway readiness"); + try { + return (await fetch(`${base}/healthz`)).ok; + } catch (error) { + if (error.cause?.code !== "ECONNREFUSED") { + throw error; + } + return false; + } + }, "Gateway readiness"); + } catch (error) { + throw new Error(`${error.message}\nGateway logs:\n${logs}`); + } let canonicalBody; const httpResults = []; From 13cded52d4ba7685894fa68d401956481b7e5bc9 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 16:27:21 -0700 Subject: [PATCH 34/76] Revert "Restore launcher CLI control" This reverts commit 5ef558a28f182fd002478db89b8844c8ac4747fc. --- plugins/gateway-isolation/index.js | 81 ------------------- plugins/gateway-isolation/index.test.js | 9 +-- .../validation/gateway-isolation-runtime.mjs | 28 ++----- 3 files changed, 8 insertions(+), 110 deletions(-) diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index 6f457d06..e1d95866 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -64,7 +64,6 @@ export function renderGatewayIsolationPage(mode) { const enabled = mode === "enabled"; const status = enabled ? "Enabled" : "Disabled"; - const command = `clawctl gateway-isolation ${enabled ? "disable" : "enable"}`; const tone = enabled ? "ok" : "warn"; return ` @@ -148,14 +147,7 @@ export function renderGatewayIsolationPage(mode) { align-items: center; padding: 18px; } - .settings-row + .settings-row { border-top: 1px solid var(--border); } - .settings-row--stacked { align-items: start; } .settings-row__title { color: var(--text-strong); font-weight: 600; } - .settings-row__description { - margin-top: 5px; - color: var(--muted); - line-height: 1.45; - } .settings-row__control { justify-self: end; min-width: 0; } .status { display: inline-flex; @@ -174,38 +166,6 @@ export function renderGatewayIsolationPage(mode) { } .status--ok { color: var(--ok-text); background: var(--ok-bg); } .status--warn { color: var(--warn-text); background: var(--warn-bg); } - .command { - display: flex; - min-width: 0; - align-items: center; - gap: 8px; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 8px 9px; - background: var(--bg); - } - code { - min-width: 0; - overflow-wrap: anywhere; - font-family: var(--font-mono); - font-size: 13px; - } - button { - flex: none; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 5px 9px; - background: var(--button-bg); - color: var(--text); - cursor: pointer; - font: inherit; - } - .copy-status { - margin-top: 6px; - color: var(--muted); - font-size: 12px; - } - button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } @media (max-width: 620px) { main { padding: 16px; } .settings-row { grid-template-columns: 1fr; gap: 12px; } @@ -225,50 +185,9 @@ export function renderGatewayIsolationPage(mode) { ${status} -

-
-
Change with CLI
-
Run from the signed-in user session on the Gateway host.
-
-
- ${command} - -
-
-
${THEME_BRIDGE_SCRIPT} - `; } diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 0475aac0..2907b4de 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -101,13 +101,11 @@ for (const expected of [ mode: "enabled", status: "Enabled", tone: "status--ok", - command: "clawctl gateway-isolation disable", }, { mode: "disabled", status: "Disabled", tone: "status--warn", - command: "clawctl gateway-isolation enable", }, ]) { test(`renders the exact ${expected.mode} read-only status`, () => { @@ -116,13 +114,8 @@ for (const expected of [ assert.match(html, /Gateway Isolation/); assert.match(html, new RegExp(`>${expected.status}<`)); assert.match(html, new RegExp(expected.tone)); - assert.match(html, /Change with CLI/); - assert.match(html, /Run from the signed-in user session on the Gateway host\./); - assert.match(html, new RegExp(expected.command)); - assert.match(html, /aria-label="Copy command"/); - assert.match(html, /Copy the selected command manually\./); - assert.match(html, /copied = document\.execCommand\("copy"\)/); assert.match(html, /openclaw:widget-theme/); + assert.doesNotMatch(html, /Change with CLI|clipboard|copy-command|clawctl gateway-isolation/); assert.doesNotMatch(html, /next manual Gateway restart/i); }); } diff --git a/scripts/validation/gateway-isolation-runtime.mjs b/scripts/validation/gateway-isolation-runtime.mjs index 218cd1d4..47e3ead2 100644 --- a/scripts/validation/gateway-isolation-runtime.mjs +++ b/scripts/validation/gateway-isolation-runtime.mjs @@ -286,7 +286,7 @@ async function themeSnapshot(page, frame, expectedTheme, expectedMode) { assert.equal(plugin.h1, "Windows Launcher"); assert.equal(plugin.status, "Disabled"); assert.equal(plugin.row, "Gateway Isolation"); - assert.equal(plugin.mutationGuidance, true); + assert.equal(plugin.mutationGuidance, false); return { hostTheme: expectedTheme, mode: expectedMode, tokens: plugin.values }; } @@ -351,15 +351,6 @@ const context = await browser.newContext({ const page = await context.newPage(); let hello; const rpcErrors = []; -const browserErrors = []; -page.on("console", (message) => { - if (message.type() === "error") { - browserErrors.push(`console: ${message.text()}`); - } -}); -page.on("pageerror", (error) => { - browserErrors.push(`page: ${error.message}`); -}); page.on("websocket", (socket) => socket.on("framereceived", ({ payload }) => { const frame = JSON.parse(payload.toString()); @@ -431,9 +422,10 @@ try { if (method === "GET") { canonicalBody = body; assert.match(body, />Disabled Boolean(hello), "authenticated hello-ok"); - } catch (error) { - throw new Error( - `${error.message}\nGateway logs:\n${logs}\nBrowser errors:\n${browserErrors.join("\n")}`, - ); - } + await until(() => Boolean(hello), "authenticated hello-ok"); assert.equal(hello.buildId, build.buildId); assert.equal(hello.controlUiBuildSource, "bundled"); await pause(2_000); @@ -599,7 +585,7 @@ try { sameFrameAcrossLiveThemeSwitches: true, }, themes: themeResults, - cliControlPresent: true, + unsupportedCliGuidanceAbsent: true, passed: true, }; const json = `${JSON.stringify(result, null, 2)}\n`; From 1d4489591af611cb31a782600610e22173028842 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 16:27:54 -0700 Subject: [PATCH 35/76] Reapply "Restore launcher CLI control" This reverts commit e2789b1fae4b3c14e8860e5d470a93569cd82240. --- plugins/gateway-isolation/index.js | 81 +++++++++++++++++++ plugins/gateway-isolation/index.test.js | 9 ++- .../validation/gateway-isolation-runtime.mjs | 28 +++++-- 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index e1d95866..6f457d06 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -64,6 +64,7 @@ export function renderGatewayIsolationPage(mode) { const enabled = mode === "enabled"; const status = enabled ? "Enabled" : "Disabled"; + const command = `clawctl gateway-isolation ${enabled ? "disable" : "enable"}`; const tone = enabled ? "ok" : "warn"; return ` @@ -147,7 +148,14 @@ export function renderGatewayIsolationPage(mode) { align-items: center; padding: 18px; } + .settings-row + .settings-row { border-top: 1px solid var(--border); } + .settings-row--stacked { align-items: start; } .settings-row__title { color: var(--text-strong); font-weight: 600; } + .settings-row__description { + margin-top: 5px; + color: var(--muted); + line-height: 1.45; + } .settings-row__control { justify-self: end; min-width: 0; } .status { display: inline-flex; @@ -166,6 +174,38 @@ export function renderGatewayIsolationPage(mode) { } .status--ok { color: var(--ok-text); background: var(--ok-bg); } .status--warn { color: var(--warn-text); background: var(--warn-bg); } + .command { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 9px; + background: var(--bg); + } + code { + min-width: 0; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 13px; + } + button { + flex: none; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 5px 9px; + background: var(--button-bg); + color: var(--text); + cursor: pointer; + font: inherit; + } + .copy-status { + margin-top: 6px; + color: var(--muted); + font-size: 12px; + } + button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } @media (max-width: 620px) { main { padding: 16px; } .settings-row { grid-template-columns: 1fr; gap: 12px; } @@ -185,9 +225,50 @@ export function renderGatewayIsolationPage(mode) { ${status} +
+
+
Change with CLI
+
Run from the signed-in user session on the Gateway host.
+
+
+ ${command} + +
+
+
${THEME_BRIDGE_SCRIPT} + `; } diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 2907b4de..0475aac0 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -101,11 +101,13 @@ for (const expected of [ mode: "enabled", status: "Enabled", tone: "status--ok", + command: "clawctl gateway-isolation disable", }, { mode: "disabled", status: "Disabled", tone: "status--warn", + command: "clawctl gateway-isolation enable", }, ]) { test(`renders the exact ${expected.mode} read-only status`, () => { @@ -114,8 +116,13 @@ for (const expected of [ assert.match(html, /Gateway Isolation/); assert.match(html, new RegExp(`>${expected.status}<`)); assert.match(html, new RegExp(expected.tone)); + assert.match(html, /Change with CLI/); + assert.match(html, /Run from the signed-in user session on the Gateway host\./); + assert.match(html, new RegExp(expected.command)); + assert.match(html, /aria-label="Copy command"/); + assert.match(html, /Copy the selected command manually\./); + assert.match(html, /copied = document\.execCommand\("copy"\)/); assert.match(html, /openclaw:widget-theme/); - assert.doesNotMatch(html, /Change with CLI|clipboard|copy-command|clawctl gateway-isolation/); assert.doesNotMatch(html, /next manual Gateway restart/i); }); } diff --git a/scripts/validation/gateway-isolation-runtime.mjs b/scripts/validation/gateway-isolation-runtime.mjs index 47e3ead2..218cd1d4 100644 --- a/scripts/validation/gateway-isolation-runtime.mjs +++ b/scripts/validation/gateway-isolation-runtime.mjs @@ -286,7 +286,7 @@ async function themeSnapshot(page, frame, expectedTheme, expectedMode) { assert.equal(plugin.h1, "Windows Launcher"); assert.equal(plugin.status, "Disabled"); assert.equal(plugin.row, "Gateway Isolation"); - assert.equal(plugin.mutationGuidance, false); + assert.equal(plugin.mutationGuidance, true); return { hostTheme: expectedTheme, mode: expectedMode, tokens: plugin.values }; } @@ -351,6 +351,15 @@ const context = await browser.newContext({ const page = await context.newPage(); let hello; const rpcErrors = []; +const browserErrors = []; +page.on("console", (message) => { + if (message.type() === "error") { + browserErrors.push(`console: ${message.text()}`); + } +}); +page.on("pageerror", (error) => { + browserErrors.push(`page: ${error.message}`); +}); page.on("websocket", (socket) => socket.on("framereceived", ({ payload }) => { const frame = JSON.parse(payload.toString()); @@ -422,10 +431,9 @@ try { if (method === "GET") { canonicalBody = body; assert.match(body, />Disabled Boolean(hello), "authenticated hello-ok"); + try { + await until(() => Boolean(hello), "authenticated hello-ok"); + } catch (error) { + throw new Error( + `${error.message}\nGateway logs:\n${logs}\nBrowser errors:\n${browserErrors.join("\n")}`, + ); + } assert.equal(hello.buildId, build.buildId); assert.equal(hello.controlUiBuildSource, "bundled"); await pause(2_000); @@ -585,7 +599,7 @@ try { sameFrameAcrossLiveThemeSwitches: true, }, themes: themeResults, - unsupportedCliGuidanceAbsent: true, + cliControlPresent: true, passed: true, }; const json = `${JSON.stringify(result, null, 2)}\n`; From 81f7ddd4e35e852be38af5d3561d2c98afbb1c02 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 16:39:45 -0700 Subject: [PATCH 36/76] Refresh proof after upstream rebase Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../themed-windows-launcher/README.md | 2 +- .../themed-windows-launcher/claw-dark.png | Bin 95738 -> 95771 bytes .../themed-windows-launcher/claw-light.png | Bin 98912 -> 98804 bytes .../themed-windows-launcher/custom-dark.png | Bin 88416 -> 88503 bytes .../themed-windows-launcher/custom-light.png | Bin 91593 -> 91774 bytes .../runtime-results.json | 2 +- 6 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/validation/themed-windows-launcher/README.md b/docs/validation/themed-windows-launcher/README.md index 91db6ba2..8d95cbf9 100644 --- a/docs/validation/themed-windows-launcher/README.md +++ b/docs/validation/themed-windows-launcher/README.md @@ -1,7 +1,7 @@ # Themed Windows Launcher validation This proof was generated from packaging implementation commit -`5ef558a28f182fd002478db89b8844c8ac4747fc` against the merged generic theme +`1d4489591af611cb31a782600610e22173028842` against the merged generic theme forwarding in `openclaw/openclaw` commit `f65ecca89667b8a55d9f88d76c487f0a0ab11da8`. diff --git a/docs/validation/themed-windows-launcher/claw-dark.png b/docs/validation/themed-windows-launcher/claw-dark.png index b62b10a1f135772d4104dc7640a06845ccda039b..560e14b04b4ff54b88166e1abac895a067721905 100644 GIT binary patch literal 95771 zcmb@ubyQSe_&188pi(LV(jo#PCEcJRAl)4!-5moAFeu%ibccj=Hw+;)0}S2WJ@in+ z+|lpveeYWLkGs~p?md6)IkC^vdq2nWDZ=a1?hY5NP0KWLbBqK|`7Q1_3@>U0yyC*49-KPdSxjK zQcg-se&=tdZ`u|0r&#MUG2K|`u$$>vAL`{a_$4ieRi(UrM0QVy{Rli{Fs;?fC=JO> zB!i1?3Bo-O8DcAq)^8(>Y)FaI|vl5W5EZ=@dWhy8C*NOKSO-#}UPKH?Yc@6{*viyiWBWRbN%6C>?98OzO8g7JDA$ zI&s~-yu1iO)cLl?g#{e0j6b=IM#@%I%u61={dWzEb$u3_ zkX*F{Wf@T#8E;9~7(O2x@gJ+&P+lA=yV>bdUvNzWp)*h?^uI?M?5AEjvU*h@dCwLK z6%W~2p8a=z>Ohx~oM3m~5Ya-}*wlFvb`7kodv34WtIJ}>P&}2n(e{pCE|J|}eqnz7 zdwTi_-&?kcsdq6yQ$dIGf=&uTuG`ByD@GO;S{~=Sfp}!DLg^JT7YB3wOJp2|&;Pw5 z$w03Cc)BG<*T#1d?0&PTCfKM&IRjfLRalR)m%zmtFR~s%-?7>!`T4P*`il2XPHv_; zv#H!tO?^1EFajgZ)+SXnW$n-wrOH&2L*^Qd)L zDgatKLmRPiiL}(?!uLp@m$abR4dG#(*7w7y2~1owQg#G7@$__)kjnSZo>o^gInxO)B~&00`Si1J!6T{EQq@ z+#IgY%uk+ZX4EK0Pck{m@p^u8vaY2?&R@X+_!DBN1nXeyZR=21C zPN1g-*n|DR*)!Q{zQdnLl;Y_yukg)i)IsVyyofL=FgoqZ0-oV(J#zm_nKYq!{@Hk& zSDW<53072$($v%}V^>r|WUz*(3;E>R zi}vvLc+J*2OjSzYt}$w9O?lZojr@@11+W?kL9jY!CKpbW0yHQT+=c?SMgC-^Z4CM zy{*ny#Uz=&$b%{2I+S{=pZ8lWsKWW7YJI2dNtL)riIR@aOtyceIJ$+OgQMI=IR^jP zdcJUP^y$T6jnP_u+cSTNe&uM#qVLJC=3N7w*3QQ&Yw79fVmIJS*nO_q?colVw{I;E zW@#gS+R#Kr-nbtCA-5;6mL+&B*{)ulJ$QG=BANyz$;jxkIqy)?vA9@fGu0l?GUeTT zakKj4M|;+X##(!V9AyUPCFbsN|%cIpf|dytHJx|!x{Q!Kex$j zUAD2mffWyC@R@s_m$S&l0zI$4(1zmVivA)e2Xu2Iw@wO@0BGu}sBjuz#(*KGlX-H9 z`SYTvwwtZV<|?m1$?opDx;mgz^2uy58i+Bo75ZM3@1g|!_Ufvog~fTV|7Ss__inPN zxHKGeWJC*T-+)2RkVuXRma#wq!IalYCnqPhOQm|Z`z`>VRxT!{-kw=TCTXqWpiDly zXGI&+Ql9yNT6o>O%q?CMGc%3ZrCfKz0+wZ@1|)1M%F4~04+9@6Mk)U1v(~yIpBd<@ zhnJdrHa5~@MQsmk>kh;;)YWJEq9`I4wpSK<@b@(9JH24hFFECXRX8}3Alhvu&SoCk zw2dd#@C4w_yxDNpH-B6faqwl=ZM3+dE&|#*G7^7txAilRx@8u*PtN5o|LfNCELObj zdX^FfxvaYWi1*~l-OsqaF>!mr#at_!~8*5o=eSN)1OeJw}YHF&tROv5b z=}*S1c_nfv`fEUo#}0*%lYWC^wh}^_%wmh>>dLL&eP>>f3F$0X9KQkFpV#pjER3b! zD|_%)i}RbhRE|_KE&43WF{ZY3VBglH#2;Qdd$Ta)+lgXu*j-nOk zpJg&VTMJIu?07kc7TwIjjuGJ#N zFE0PbxGB&9(Se7^b+Lw(|O#l$pcc2-tfWQJ%hpC|Bykl=ut z*hTp-PgsuFH3G}iWQl@iB@V5rRQq;XzO+navrWwO)U!>(8G?ys^JlASYm~n+3>6CZ zx@}LdtmesK%*%5vT`eswZPK@z3lsL+{d}o~7fD9S0rCUic$Z2B`}=*WwAwAJlVkIbD~fq@=vMG2ZO9H}DT4puSy%EDa3~ z+T~iAn3zyb7Qm%xZ$aaQ&#L@NqIe?JVUk961fz9YF8d!7=P)X!DXXZ&rlnQg!phh4YnObMNitmy?=c)jM^@N*8M^z*J88K!pKk$)KZ!c!~snN?v^nRL9V1JotH8w%GmH6Px z8pm?au5mNmC0d&_7@q@zrPadQQWt?7%0P|bCnGktw?K%OqU~B&(SfBlkUr+sru$Kv z7Ar-$9OPsEzv9!SL|8Dg^3%-kz9+3{KXON}^LN+T%=>!4VJwQ6UMh-O$TrUV@-&0G z+1>tkT3`*kPJBtl;dLVfTzCi-7e|s^ndlp~c}w<2QQt_P!JtD$_#f*K(>g%2C13w{ zHkZ458VgN0fdLOPuei-!f9Wqa*)=N%MLuxbTSOhLOas8ETIZ$R^FFAJ zhF)>dW<%qMl%l=8CBm?0qwbLA1gM=^p=)-F@>z@K^HED`ieh-vM_a0CV_^fV-L^jW zaAixFyKnXj(}Q?a;~O-&d@g+#hz50Z7JiRetSc>?PxyxhL3zrqMx<;UF}vHV zxrIT6ySB8Sb~ZK^W*W^A^869qWs!;n#V*6^HqH0!+#ZKc-DTWMso}dmD>j*9rY5oV z0ijeLnJ7gLsWoX*CQpfdt;oDw9M-XsRRZ9yLAiqy`<7_gUS7ohrPYyGU#{JWQE=;q z&FXyU&&cGU({2C_t}%aW%@qtL82HS{muT>CPFb|-xlVk$g7cx*Xt<5r@hXFpPZ($cr?C8B0;%hw*SrlJRF_nY+ z)!SxAmUyH_+YkuOGQkG@Mu|AYID~!9?Pz<()Fm(!H<9D{RJ#LoOm6B>&}nqOZv7{d zI~&N#cH?3Sm^|z4dVYVbD6n_{4xHL!6Vl2#jk6Hf0l%rd_S8Q9Ja|!)d^)u0&%^>B zy>TgGItj8c@TB+|bqKgQX&Ks#j4$By+51rmFAwL=5XA`0ZxTU)9fe89EJT_V^l|@_ z56kIHD9@N`Z^mgHnDh*f>lZ=dkhfdU^tZl=dc@Z&c#JZjt0eU`$HCEJe$jdBUgqiR z*+z=y!I`FO0^iGcBrYZY70Ri*2}na@TqOSpcdBem>T06w>-Z15rMtDe4ud&ON!paU zkGW(m)t+N8=sB9HvLJ%K9b;CaU$v9?8j2fbm&&T~(L*P^L*zezUI>2UvsBok{`(>u zX|2dc?#!OrbO5Tp-?}s4W=k>T$jyzbesHpb*KxcvdJ;0Oqwg;@6u;f_^?VkX5%Rrl*Pq+xuLKgUHw zJ_aasosL~_im!Luq!>V1CIE0X6r^Ld*l*`c|m+o43TGR-vx|y=9KYWa@MWFI0yn?*^HqQF(1_jsN4CGpK z545^FCv7zyQ~B|LCHjz(OXH54?GL=)G3)0DZD=E2FAllGQ#U7A^_us(Q>Kr_T5@>z zs_u(DRiZL)pOyV@L0*%Lo_b6UX8E);PkC&(!caCUbxp;rHalFno__J~HUsRx zTXXfl>AO*%K+m0h7gBFD1rpB63Zz@W8us!bwhQC@3|iWW=ln+3eR-r^v{=zyuvcwt zUmg`?hDw%@ODW@ZgLNiCl!%X6Xe0UHMkQ_<_GUZ>Lx%bG_pRmt+Lt;lPmeRu+!OO{FjliyQbcBn;feUIin9+R zcUV7zO>m=DO+ZUOK^jAy>$5J<{Ttky?s9LRjwEt;xXsZ>nh|z^*v(M%i1AUnn*Z#M zYd*KO;6Q;Sg@{(y79nrG$m(bvB+~P&%hEGJHCNoz^-NT0s_>QFTrOHWl{E_EvgLxW zk55|F?tGcw>6-}sk&7;qf7(A%UM^yb|MAufUNm9T6UR@Gj=J6YyVosUa(hVC`CfBl z0!f6nA1OmgS5a^<XU9 z@tLEjPrE81k0;$5)HzBH=e?#(`h;ti11u88X}&WY;HH7b^?6qvg#4yUiSplSZ8HX+j2OUd zhTKPNeW7V&&@_r0EuXVHVI?>oKheRkr6i2kql^(JnJc7a45Y=U*)d_d_GxzFWOr{c zInMUTfJNTLv4tDzdM7%oP^c0r=pY-lNXdcWd-&P_W}v<+^fRq`8w_>_js@U=2%ntp z63>m&GLb)q>g zv>54bu$6-2r!P`1s-ceyyS149itHInJXFt#ac`3#6>xQVp0hlV9au0|5Dbh@Jfp*V zEkuDc_lxRgK>0RzhB*B0c&M|v3l*W}Hr?b-eSX9=SK}fjKDQqCnin7JbA?vivEJ9s z=3sv}Ajjf(Fgx;5d;UCwB{*SRIjva_!_NoFvU*%;H?GcU^7Omin_vskCbR7`#6~nG z-P@2ICBU+-S{x_⊥RIeREa#q~CIg;0RfFq+`j6z8f`{ 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

&<$OCY7)8}= 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 literal 95738 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

!f#{3ww=}IAnEaw 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�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 Nha2e>U%mJA-vCH?pmG2J delta 91038 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 literal 88416 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 diff --git a/docs/validation/themed-windows-launcher/custom-light.png b/docs/validation/themed-windows-launcher/custom-light.png index 00d0f38c7f06b36f33915aafb411a87e5fc6a146..b99851961748192a90248860ac3286a771e690d7 100644 GIT binary patch delta 81357 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 RDbQQV?`4#w%OpR3{y&?M)BOMd delta 81055 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 diff --git a/docs/validation/themed-windows-launcher/runtime-results.json b/docs/validation/themed-windows-launcher/runtime-results.json index 05e07d95..63cfada5 100644 --- a/docs/validation/themed-windows-launcher/runtime-results.json +++ b/docs/validation/themed-windows-launcher/runtime-results.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "packagingCommit": "5ef558a28f182fd002478db89b8844c8ac4747fc", + "packagingCommit": "1d4489591af611cb31a782600610e22173028842", "corePullRequest": "openclaw/openclaw#145409", "coreCommit": "f65ecca89667b8a55d9f88d76c487f0a0ab11da8", "build": { From d51e34bbeb1ea1f22d55d57d46426e6105075736 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 16:44:16 -0700 Subject: [PATCH 37/76] Fix plugin packaging fixture metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 index 5b3b3ee6..23796af4 100644 --- a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -113,6 +113,7 @@ console.log(JSON.stringify(inspection)); requestedRef = '0965053fe6b9341776df147a6934b7485c60b5ca' resolvedCommit = '0965053fe6b9341776df147a6934b7485c60b5ca' packageVersion = '2026.8.2' + nodeVersion = (& node -p 'process.versions.node' | Out-String).Trim() } | ConvertTo-Json | Set-Content ` From 3b45f60dad504d60f987294fca55ef5f93d53707 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 16:46:43 -0700 Subject: [PATCH 38/76] Host launcher proof images on GitHub Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../themed-windows-launcher/README.md | 7 ++++--- .../themed-windows-launcher/claw-dark.png | Bin 95771 -> 0 bytes .../themed-windows-launcher/claw-light.png | Bin 98804 -> 0 bytes .../themed-windows-launcher/custom-dark.png | Bin 88503 -> 0 bytes .../themed-windows-launcher/custom-light.png | Bin 91774 -> 0 bytes .../runtime-results.json | 8 ++++---- 6 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 docs/validation/themed-windows-launcher/claw-dark.png delete mode 100644 docs/validation/themed-windows-launcher/claw-light.png delete mode 100644 docs/validation/themed-windows-launcher/custom-dark.png delete mode 100644 docs/validation/themed-windows-launcher/custom-light.png diff --git a/docs/validation/themed-windows-launcher/README.md b/docs/validation/themed-windows-launcher/README.md index 8d95cbf9..63b78785 100644 --- a/docs/validation/themed-windows-launcher/README.md +++ b/docs/validation/themed-windows-launcher/README.md @@ -5,9 +5,10 @@ This proof was generated from packaging implementation commit forwarding in `openclaw/openclaw` commit `f65ecca89667b8a55d9f88d76c487f0a0ab11da8`. -The four screenshots cover the default Claw dark and light themes plus -distinctive imported custom dark and light themes. Each run uses the same -authenticated Gateway process and iframe. The result metadata verifies: +The four screenshots hosted in the pull request cover the default Claw dark +and light themes plus distinctive imported custom dark and light themes. Each +run uses the same authenticated Gateway process and iframe. The result +metadata links each hosted image and verifies: - `Windows Launcher` in the Control UI sidebar, tab, and page heading. - `Gateway Isolation` as the launcher status row. diff --git a/docs/validation/themed-windows-launcher/claw-dark.png b/docs/validation/themed-windows-launcher/claw-dark.png deleted file mode 100644 index 560e14b04b4ff54b88166e1abac895a067721905..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 95771 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⊥RIeREa#q~CIg;0RfFq+`j6z8f`{ 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

&<$OCY7)8}= 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 diff --git a/docs/validation/themed-windows-launcher/claw-light.png b/docs/validation/themed-windows-launcher/claw-light.png deleted file mode 100644 index 626679a56f3ac03355f40573f6c89e9efd7d8050..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98804 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

A^V3~0zJQR(grzc%H$0JwoW_29cS;axgWKLkp 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 z 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³(PYyoFml%WORy( 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 diff --git a/docs/validation/themed-windows-launcher/custom-light.png b/docs/validation/themed-windows-launcher/custom-light.png deleted file mode 100644 index b99851961748192a90248860ac3286a771e690d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 91774 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�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 HQ$iB}?SH2M diff --git a/docs/validation/themed-windows-launcher/runtime-results.json b/docs/validation/themed-windows-launcher/runtime-results.json index 63cfada5..7151f7aa 100644 --- a/docs/validation/themed-windows-launcher/runtime-results.json +++ b/docs/validation/themed-windows-launcher/runtime-results.json @@ -99,7 +99,7 @@ "id": "claw-dark", "family": "Claw", "importedCustomTheme": false, - "screenshot": "claw-dark.png", + "screenshot": "https://github.com/user-attachments/assets/a9335cd9-9d19-49ff-8333-860af1bddd9e", "hostTheme": "dark", "mode": "dark", "tokens": { @@ -124,7 +124,7 @@ "id": "claw-light", "family": "Claw", "importedCustomTheme": false, - "screenshot": "claw-light.png", + "screenshot": "https://github.com/user-attachments/assets/6ee317d9-827c-4156-a16c-4514843cb1ff", "hostTheme": "light", "mode": "light", "tokens": { @@ -149,7 +149,7 @@ "id": "custom-dark", "family": "Windows Launcher Proof", "importedCustomTheme": true, - "screenshot": "custom-dark.png", + "screenshot": "https://github.com/user-attachments/assets/64f9cd0e-db79-4748-b789-d78cbfe55711", "hostTheme": "custom", "mode": "dark", "tokens": { @@ -174,7 +174,7 @@ "id": "custom-light", "family": "Windows Launcher Proof", "importedCustomTheme": true, - "screenshot": "custom-light.png", + "screenshot": "https://github.com/user-attachments/assets/ad6f41c0-d045-4f80-bc14-544953d27825", "hostTheme": "custom-light", "mode": "light", "tokens": { From c32127c0e56a16684deac20717ccca1e8fad22c9 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 17:10:17 -0700 Subject: [PATCH 39/76] Disable Windows Launcher plugin by default Keep the bundled plugin available for explicit validation and future launcher activation while removing proof-only repository artifacts and qualifying planned CLI guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 26 +- .../themed-windows-launcher/README.md | 24 - .../runtime-results.json | 201 ------ plugins/gateway-isolation/index.js | 2 +- plugins/gateway-isolation/index.test.js | 19 +- .../gateway-isolation/openclaw.plugin.json | 5 +- scripts/Build-Payload.ps1 | 43 +- scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 35 +- scripts/Test-NodeRuntimeInputs.Tests.ps1 | 29 +- .../Test-GatewayIsolationPayloadMatrix.ps1 | 384 ----------- .../validation/gateway-isolation-runtime.mjs | 634 ------------------ 11 files changed, 127 insertions(+), 1275 deletions(-) delete mode 100644 docs/validation/themed-windows-launcher/README.md delete mode 100644 docs/validation/themed-windows-launcher/runtime-results.json delete mode 100644 scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1 delete mode 100644 scripts/validation/gateway-isolation-runtime.mjs diff --git a/README.md b/README.md index 17e6eb39..c5d911ff 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,10 @@ Changing only the workflow-dispatch default does not change automatic builds. For a one-time override, run **Build OpenClaw Gateway MSIX** manually and provide a tag, branch, or preferably a full 40-character commit SHA in `openclaw_ref`. Payload composition validates that the selected OpenClaw -runtime can discover and load the packaging-owned Windows Launcher plugin -with its required read-only route shape; incompatible older refs fail instead -of producing a package without status UI. +runtime discovers the packaging-owned Windows Launcher plugin in its +default-disabled state, then uses OpenClaw's non-activating runtime inspection +pass to validate its required read-only route shape. Incompatible older refs +fail instead of producing a package with an unvalidated plugin. The source build uses that revision's `.github/actions/setup-node-env` action to select Node.js and pnpm. Its resolved Node.js version is recorded in @@ -165,14 +166,25 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree and provisions the packaging-owned, -enabled-by-default Windows Launcher plugin into OpenClaw's bundled plugin -directory. Its internal package, path, and plugin ID remain -`gateway-isolation`. The plugin adds a read-only **Windows Launcher** tab to the +architecture-specific application tree and provisions the packaging-owned +Windows Launcher plugin into OpenClaw's bundled plugin directory. Its internal +package, path, and plugin ID remain `gateway-isolation`. The plugin is disabled +by default, so normal installs do not activate it, register its route, or show +the **Windows Launcher** tab. When explicitly enabled for validation or by the +future launcher command implementation, it adds the read-only tab to the Control group and serves it through an authenticated, sandboxed plugin route. It reads only the launch-time `CLAWCTL_GATEWAY_ISOLATION` value and registers no mutation RPC or process control. +The page preserves the planned `clawctl gateway-isolation enable|disable` +command and Copy control for the paired launcher command update. This package +does not register those `clawctl` commands yet, so the page explicitly tells +users to run the command only after that support is installed. + +The screenshots attached to the pull request are design and behavior proof +captured with the plugin explicitly enabled in an isolated validation profile; +they do not represent the default-disabled state of a normal install. + Full selected-theme cohesion requires the generic plugin-frame theme forwarding merged by [`openclaw/openclaw#145409`](https://github.com/openclaw/openclaw/pull/145409). diff --git a/docs/validation/themed-windows-launcher/README.md b/docs/validation/themed-windows-launcher/README.md deleted file mode 100644 index 63b78785..00000000 --- a/docs/validation/themed-windows-launcher/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Themed Windows Launcher validation - -This proof was generated from packaging implementation commit -`1d4489591af611cb31a782600610e22173028842` against the merged generic theme -forwarding in `openclaw/openclaw` commit -`f65ecca89667b8a55d9f88d76c487f0a0ab11da8`. - -The four screenshots hosted in the pull request cover the default Claw dark -and light themes plus distinctive imported custom dark and light themes. Each -run uses the same authenticated Gateway process and iframe. The result -metadata links each hosted image and verifies: - -- `Windows Launcher` in the Control UI sidebar, tab, and page heading. -- `Gateway Isolation` as the launcher status row. -- The CLI command and Copy control remain present. -- Exact semantic color, font, and radius forwarding. -- One authenticated iframe request with no navigation or reload during live - theme changes. -- `sandbox="allow-scripts"` without `allow-same-origin`. -- Read-only HTTP behavior and fail-closed launcher mode handling. - -`runtime-results.json` contains sanitized environment details, source commits, -artifact hashes, route checks, frame checks, and semantic token results. This -is expanded-layout browser proof, not installed MSIX proof. diff --git a/docs/validation/themed-windows-launcher/runtime-results.json b/docs/validation/themed-windows-launcher/runtime-results.json deleted file mode 100644 index 7151f7aa..00000000 --- a/docs/validation/themed-windows-launcher/runtime-results.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "schemaVersion": 1, - "packagingCommit": "1d4489591af611cb31a782600610e22173028842", - "corePullRequest": "openclaw/openclaw#145409", - "coreCommit": "f65ecca89667b8a55d9f88d76c487f0a0ab11da8", - "build": { - "version": "2026.9.4", - "buildId": "2026.9.4-release-f65ecca89667-2026-09-14T22-27-01.258Z", - "controlUiBuildSource": "bundled" - }, - "environment": { - "os": "Windows x64", - "node": "v24.18.0", - "browser": "153.0.4234.32", - "layout": "expanded application" - }, - "sha256": { - "launcher": "2d59e8937074d252af3b0c3aa89814e59b132ef6a0e77ad4a38fb90c14b958be", - "node": "9a4eb5f1c29c6a2e93852ead46b999e284a6a5ca8bab4d4e241d587d025a52de", - "runtimeEntry": "538e8ee2b65a0b24bb8a5ed3421bfe66621b1e0b5f726a167758c004f566fb36", - "plugin": "1f3a0125bfa25216b203cdf760347286091da896246e74645606bab7092a8760" - }, - "launcher": { - "inheritedIsolationInput": "enabled", - "reportedIsolationMode": "disabled", - "provesLauncherOverridesInheritedInput": true - }, - "route": { - "path": "/plugins/gateway-isolation/status", - "httpResults": [ - { - "method": "GET", - "access": "anonymous", - "status": 401 - }, - { - "method": "GET", - "access": "wrong-token", - "status": 401 - }, - { - "method": "GET", - "access": "authenticated", - "status": 200 - }, - { - "method": "HEAD", - "access": "anonymous", - "status": 401 - }, - { - "method": "HEAD", - "access": "wrong-token", - "status": 401 - }, - { - "method": "HEAD", - "access": "authenticated", - "status": 200 - }, - { - "method": "POST", - "access": "authenticated", - "status": 200, - "effect": "same read-only response" - }, - { - "method": "PUT", - "access": "authenticated", - "status": 200, - "effect": "same read-only response" - }, - { - "method": "PATCH", - "access": "authenticated", - "status": 200, - "effect": "same read-only response" - }, - { - "method": "DELETE", - "access": "authenticated", - "status": 200, - "effect": "same read-only response" - } - ], - "authenticated": true, - "readOnly": true, - "responseHardening": true - }, - "frame": { - "sandbox": "allow-scripts", - "allowSameOrigin": false, - "visibleRequests": 1, - "postMountNavigations": 0, - "sameFrameAcrossLiveThemeSwitches": true - }, - "themes": [ - { - "id": "claw-dark", - "family": "Claw", - "importedCustomTheme": false, - "screenshot": "https://github.com/user-attachments/assets/a9335cd9-9d19-49ff-8333-860af1bddd9e", - "hostTheme": "dark", - "mode": "dark", - "tokens": { - "--bg": "#0e1015", - "--card": "#161920", - "--button-bg": "#191c24", - "--text": "#bcbcc0", - "--text-strong": "#f4f4f5", - "--muted": "#8b8b94", - "--border": "#1e2028", - "--border-strong": "#2e3040", - "--focus": "#ff5c5c", - "--ok-text": "#22c55e", - "--warn-text": "#f59e0b", - "--radius": "10px", - "--radius-full": "9999px", - "--font-body": "\"Instrument Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", - "--font-mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace" - } - }, - { - "id": "claw-light", - "family": "Claw", - "importedCustomTheme": false, - "screenshot": "https://github.com/user-attachments/assets/6ee317d9-827c-4156-a16c-4514843cb1ff", - "hostTheme": "light", - "mode": "light", - "tokens": { - "--bg": "#faf9f7", - "--card": "#fff", - "--button-bg": "#fff", - "--text": "#403c35", - "--text-strong": "#211e1a", - "--muted": "#6e6960", - "--border": "#e8e4dc", - "--border-strong": "#d6d0c5", - "--focus": "#bd4531", - "--ok-text": "#166534", - "--warn-text": "#92400e", - "--radius": "10px", - "--radius-full": "9999px", - "--font-body": "\"Instrument Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", - "--font-mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace" - } - }, - { - "id": "custom-dark", - "family": "Windows Launcher Proof", - "importedCustomTheme": true, - "screenshot": "https://github.com/user-attachments/assets/64f9cd0e-db79-4748-b789-d78cbfe55711", - "hostTheme": "custom", - "mode": "dark", - "tokens": { - "--bg": "#151026", - "--card": "#281d45", - "--button-bg": "#3f2a63", - "--text": "#f8efff", - "--text-strong": "#ffffff", - "--muted": "#c5aad8", - "--border": "#7b5aa6", - "--border-strong": "#ad86d8", - "--focus": "#ff4fd8", - "--ok-text": "#22c55e", - "--warn-text": "#f59e0b", - "--radius": "10px", - "--radius-full": "9999px", - "--font-body": "Georgia, serif", - "--font-mono": "Courier New, monospace" - } - }, - { - "id": "custom-light", - "family": "Windows Launcher Proof", - "importedCustomTheme": true, - "screenshot": "https://github.com/user-attachments/assets/ad6f41c0-d045-4f80-bc14-544953d27825", - "hostTheme": "custom-light", - "mode": "light", - "tokens": { - "--bg": "#fff4d6", - "--card": "#ffe3a3", - "--button-bg": "#ffd166", - "--text": "#342400", - "--text-strong": "#1f1300", - "--muted": "#765b23", - "--border": "#b7791f", - "--border-strong": "#7c4f00", - "--focus": "#7b2cbf", - "--ok-text": "#166534", - "--warn-text": "#92400e", - "--radius": "10px", - "--radius-full": "9999px", - "--font-body": "Georgia, serif", - "--font-mono": "Courier New, monospace" - } - } - ], - "cliControlPresent": true, - "passed": true -} diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index 6f457d06..dfe6423c 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -228,7 +228,7 @@ export function renderGatewayIsolationPage(mode) {

Change with CLI
-
Run from the signed-in user session on the Gateway host.
+
Command support is expected in a paired launcher update. Run from the signed-in user session on the Gateway host after that support is installed.
${command} diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 0475aac0..bc4644b0 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; import test from "node:test"; import vm from "node:vm"; import { @@ -7,6 +8,15 @@ import { renderGatewayIsolationPage, } from "./index.js"; +test("ships disabled by default while retaining explicit startup activation", () => { + const manifest = JSON.parse( + fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), + ); + assert.equal(manifest.enabledByDefault, false); + assert.equal(manifest.enabledByDefaultOnPlatforms, undefined); + assert.equal(manifest.activation.onStartup, true); +}); + function registerPlugin(mode) { const descriptors = []; const routes = []; @@ -117,7 +127,14 @@ for (const expected of [ assert.match(html, new RegExp(`>${expected.status}<`)); assert.match(html, new RegExp(expected.tone)); assert.match(html, /Change with CLI/); - assert.match(html, /Run from the signed-in user session on the Gateway host\./); + assert.match( + html, + /Command support is expected in a paired launcher update\./, + ); + assert.match( + html, + /Run from the signed-in user session on the Gateway host after that support is installed\./, + ); assert.match(html, new RegExp(expected.command)); assert.match(html, /aria-label="Copy command"/); assert.match(html, /Copy the selected command manually\./); diff --git a/plugins/gateway-isolation/openclaw.plugin.json b/plugins/gateway-isolation/openclaw.plugin.json index 631d7ddd..f9bdf15d 100644 --- a/plugins/gateway-isolation/openclaw.plugin.json +++ b/plugins/gateway-isolation/openclaw.plugin.json @@ -2,10 +2,7 @@ "id": "gateway-isolation", "name": "Windows Launcher", "description": "Reports the Windows launch mode selected for the running Gateway.", - "enabledByDefault": true, - "enabledByDefaultOnPlatforms": [ - "win32" - ], + "enabledByDefault": false, "activation": { "onStartup": true }, diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index fc093c65..9c305057 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -93,10 +93,15 @@ $pluginManifest = Get-Content ` ConvertFrom-Json if ( $pluginManifest.id -ne 'gateway-isolation' -or - $pluginManifest.enabledByDefault -ne $true -or + $pluginManifest.enabledByDefault -ne $false -or + ( + $pluginManifest.PSObject.Properties.Name -contains + 'enabledByDefaultOnPlatforms' -and + @($pluginManifest.enabledByDefaultOnPlatforms).Count -ne 0 + ) -or $pluginManifest.activation.onStartup -ne $true ) { - throw 'Gateway isolation plugin manifest is not enabled for Gateway startup.' + throw 'Gateway isolation plugin manifest must be packaged but disabled by default.' } $bundledPluginsDirectory = Join-Path $installedPackage 'dist\extensions' @@ -129,6 +134,34 @@ try { $env:CLAWCTL_GATEWAY_ISOLATION = 'disabled' Push-Location $installedPackage try { + $defaultInspectionOutput = ( + & node ` + .\openclaw.mjs ` + plugins inspect gateway-isolation ` + --json + ) | Out-String + if ($LASTEXITCODE -ne 0) { + throw ( + 'The selected OpenClaw payload cannot inspect the packaged ' + + "Gateway isolation plugin. Exit code: $LASTEXITCODE." + ) + } + $defaultInspection = $defaultInspectionOutput | ConvertFrom-Json + if ( + $defaultInspection.plugin.id -ne 'gateway-isolation' -or + $defaultInspection.plugin.origin -ne 'bundled' -or + $defaultInspection.plugin.enabled -ne $false -or + $defaultInspection.plugin.activated -ne $false -or + $defaultInspection.plugin.imported -ne $false -or + $defaultInspection.plugin.httpRoutes -ne 0 -or + $defaultInspection.httpRouteCount -ne 0 + ) { + throw ( + 'The packaged Gateway isolation plugin must remain disabled ' + + 'and unregistered until it is explicitly enabled.' + ) + } + $inspectionOutput = ( & node ` .\openclaw.mjs ` @@ -151,7 +184,7 @@ try { if ( $inspection.plugin.id -ne 'gateway-isolation' -or $inspection.plugin.origin -ne 'bundled' -or - $inspection.plugin.enabled -ne $true -or + $inspection.plugin.enabled -ne $false -or $inspection.plugin.activated -ne $true -or $inspection.plugin.status -ne 'loaded' -or $inspection.plugin.imported -ne $true -or @@ -163,8 +196,8 @@ try { @($inspection.diagnostics).Count -ne 0 ) { throw ( - 'The selected OpenClaw payload did not load the Gateway isolation ' + - 'plugin with the required read-only runtime shape.' + 'The selected OpenClaw payload did not inspect the disabled Gateway ' + + 'isolation plugin with the required read-only runtime shape.' ) } } diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 index 23796af4..7e717e4e 100644 --- a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -31,11 +31,16 @@ try { ConvertFrom-Json if ( $manifest.id -ne 'gateway-isolation' -or - $manifest.enabledByDefault -ne $true -or + $manifest.enabledByDefault -ne $false -or + ( + $manifest.PSObject.Properties.Name -contains + 'enabledByDefaultOnPlatforms' -and + @($manifest.enabledByDefaultOnPlatforms).Count -ne 0 + ) -or $manifest.activation.onStartup -ne $true -or $manifest.configSchema.additionalProperties -ne $false ) { - throw 'Gateway isolation plugin manifest is not valid for automatic startup.' + throw 'Gateway isolation plugin manifest is not valid for explicit enablement.' } $package = Get-Content ` @@ -62,23 +67,24 @@ try { Set-Content ` -LiteralPath (Join-Path $packageSource 'openclaw.mjs') ` -Value @' -const inspection = { +const args = process.argv.slice(2); +const runtime = args.includes("--runtime"); +console.log(JSON.stringify({ plugin: { id: "gateway-isolation", origin: "bundled", - enabled: true, - activated: true, - status: "loaded", - imported: true, - httpRoutes: 1 + enabled: false, + activated: runtime, + status: runtime ? "loaded" : "disabled", + imported: runtime, + httpRoutes: runtime ? 1 : 0 }, - httpRouteCount: 1, + httpRouteCount: runtime ? 1 : 0, gatewayMethods: [], tools: [], services: [], diagnostics: [] -}; -console.log(JSON.stringify(inspection)); +})); '@ ` -Encoding utf8 Set-Content ` @@ -148,7 +154,12 @@ console.log(JSON.stringify(inspection)); ConvertFrom-Json if ( $packagedManifest.id -ne 'gateway-isolation' -or - $packagedManifest.enabledByDefault -ne $true + $packagedManifest.enabledByDefault -ne $false -or + ( + $packagedManifest.PSObject.Properties.Name -contains + 'enabledByDefaultOnPlatforms' -and + @($packagedManifest.enabledByDefaultOnPlatforms).Count -ne 0 + ) ) { throw 'Packaged Gateway isolation plugin manifest changed during provisioning.' } diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index 751efe8d..48f338ac 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -30,11 +30,36 @@ try { $source = Join-Path $testRoot 'source' $package = Join-Path $testRoot 'package' $payload = Join-Path $testRoot 'payload' - New-Item -ItemType Directory -Path "$source\dist", $package -Force | Out-Null + New-Item ` + -ItemType Directory ` + -Path "$source\dist\extensions\fixture", $package ` + -Force | + Out-Null '{"name":"openclaw","version":"0.0.0","type":"module"}' | Set-Content -LiteralPath "$source\package.json" - 'console.log("fixture");' | Set-Content -LiteralPath "$source\openclaw.mjs" + @' +const runtime = process.argv.includes("--runtime"); +console.log(JSON.stringify({ + plugin: { + id: "gateway-isolation", + origin: "bundled", + enabled: false, + activated: runtime, + status: runtime ? "loaded" : "disabled", + imported: runtime, + httpRoutes: runtime ? 1 : 0 + }, + httpRouteCount: runtime ? 1 : 0, + gatewayMethods: [], + tools: [], + services: [], + diagnostics: [] +})); +'@ | Set-Content -LiteralPath "$source\openclaw.mjs" 'export {};' | Set-Content -LiteralPath "$source\dist\index.js" + '{"name":"@openclaw/fixture","version":"1.0.0"}' | + Set-Content ` + -LiteralPath "$source\dist\extensions\fixture\package.json" & npm pack $source --ignore-scripts --offline --silent --pack-destination $package if ($LASTEXITCODE -ne 0) { throw 'Unable to pack the local Node.js input fixture.' diff --git a/scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1 b/scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1 deleted file mode 100644 index 3ecdbf90..00000000 --- a/scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1 +++ /dev/null @@ -1,384 +0,0 @@ -[CmdletBinding()] -param( - [string]$TestRoot, - [string]$EvidenceDirectory, - [Parameter(Mandatory)] - [string]$ProductionSourceCommit -) - -# Do not enable strict mode: Build-Payload.ps1 must exercise its own missing-field semantics. -$ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -if (-not $TestRoot) { - $TestRoot = Join-Path $repositoryRoot '.validation-payload-matrix' -} -if (-not $EvidenceDirectory) { - $EvidenceDirectory = Join-Path ` - $repositoryRoot ` - 'docs\validation\themed-windows-launcher' -} -$TestRoot = [IO.Path]::GetFullPath($TestRoot) -$EvidenceDirectory = [IO.Path]::GetFullPath($EvidenceDirectory) -if (-not $TestRoot.StartsWith("$repositoryRoot\", [StringComparison]::OrdinalIgnoreCase)) { - throw 'TestRoot must be a new directory beneath the repository.' -} -if (Test-Path -LiteralPath $TestRoot) { - throw 'TestRoot already exists. Refusing to overwrite or remove existing data.' -} -if ($EvidenceDirectory.StartsWith("$TestRoot\", [StringComparison]::OrdinalIgnoreCase) -or - $EvidenceDirectory -eq $TestRoot) { - throw 'EvidenceDirectory must not be inside the generated fixture directory.' -} -if ($PSVersionTable.PSEdition -ne 'Core' -or -not $IsWindows) { - throw 'Run this harness with pwsh on Windows.' -} -$nodeVersion = (& node --version | Out-String).Trim() -if ($LASTEXITCODE -ne 0 -or $nodeVersion -ne 'v24.16.0') { - throw 'Node.js v24.16.0 must be selected on PATH.' -} -$npmVersion = (& npm --version | Out-String).Trim() -if ($LASTEXITCODE -ne 0) { throw 'npm --version failed.' } -$head = (& git -C $repositoryRoot rev-parse HEAD | Out-String).Trim() -if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve evidence checkout HEAD.' } -$sourceFiles = @( - 'scripts/Build-Payload.ps1', - 'plugins/gateway-isolation/package.json', - 'plugins/gateway-isolation/openclaw.plugin.json', - 'plugins/gateway-isolation/index.js' -) -$sourceHashes = foreach ($relative in $sourceFiles) { - $path = Join-Path $repositoryRoot $relative.Replace('/', '\') - $baselineBlob = (& git -C $repositoryRoot rev-parse "${ProductionSourceCommit}:$relative" | Out-String).Trim() - if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve production source blob.' } - $currentBlob = (& git -C $repositoryRoot hash-object $path | Out-String).Trim() - if ($LASTEXITCODE -ne 0 -or $currentBlob -ne $baselineBlob) { - throw "Production source differs from the selected baseline: $relative" - } - [ordered]@{ path = $relative; sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() } -} - -function Assert-Matrix { - param([bool]$Condition, [string]$Message) - if (-not $Condition) { throw $Message } -} - -function Get-EnvironmentSnapshot { - param([string[]]$Names) - $snapshot = @{} - foreach ($name in $Names) { - $snapshot[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') - } - return $snapshot -} - -function Restore-EnvironmentSnapshot { - param([hashtable]$Snapshot) - foreach ($name in $Snapshot.Keys) { - if ($null -eq $Snapshot[$name]) { - Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue - } else { - [Environment]::SetEnvironmentVariable($name, $Snapshot[$name], 'Process') - } - } -} - -function ConvertTo-PublicText { - param([string]$Text) - return $Text.Replace($TestRoot, '').Replace($repositoryRoot, '') -} - -$shapeReason = 'The selected OpenClaw payload did not load the Gateway isolation plugin with the required read-only runtime shape.' -$templates = [Collections.Generic.List[object]]::new() -$templates.Add(@{ id = 'accepted-existing-environment'; kind = 'accepted'; environment = 'existing'; reason = 'accepted' }) -$templates.Add(@{ id = 'accepted-absent-environment'; kind = 'accepted'; environment = 'absent'; reason = 'accepted' }) -$templates.Add(@{ id = 'accepted-empty-environment'; kind = 'accepted'; environment = 'empty'; reason = 'accepted' }) -$templates.Add(@{ id = 'missing-extensions'; kind = 'missing-extensions'; reason = 'The pinned OpenClaw payload does not expose the supported bundled plugin directory: \\runner\openclaw-stage-\node_modules\openclaw\dist\extensions' }) -$templates.Add(@{ id = 'preexisting-plugin'; kind = 'preexisting-plugin'; reason = 'The OpenClaw payload already contains a gateway-isolation plugin; refusing to replace upstream content.' }) -$templates.Add(@{ id = 'inspection-exit-23'; kind = 'exit'; reason = 'The selected OpenClaw payload cannot load the Gateway isolation plugin. Exit code: 23.' }) -$fields = [ordered]@{ - 'plugin.id' = 'unexpected-plugin' - 'plugin.origin' = 'global' - 'plugin.enabled' = $false - 'plugin.activated' = $false - 'plugin.status' = 'error' - 'plugin.imported' = $false - 'plugin.httpRoutes' = 0 - 'httpRouteCount' = 0 - 'gatewayMethods' = @('fixture.method') - 'tools' = @('fixture.tool') - 'services' = @('fixture.service') - 'diagnostics' = @('fixture diagnostic') -} -foreach ($field in $fields.Keys) { - $templates.Add(@{ id = "wrong-$($field.Replace('.', '-'))"; kind = 'wrong'; field = $field; value = $fields[$field]; reason = $shapeReason }) - $templates.Add(@{ id = "missing-$($field.Replace('.', '-'))"; kind = 'missing'; field = $field; reason = $shapeReason }) -} -foreach ($field in @('plugin.httpRoutes', 'httpRouteCount')) { - $templates.Add(@{ id = "excess-$($field.Replace('.', '-'))"; kind = 'wrong'; field = $field; value = 2; reason = $shapeReason }) -} -$templates.Add(@{ id = 'missing-plugin-object'; kind = 'missing'; field = 'plugin'; reason = $shapeReason }) - -$runtimeFixture = @' -import fs from "node:fs"; -const args = process.argv.slice(2); -const inspect = JSON.stringify(args) === JSON.stringify(["plugins", "inspect", "gateway-isolation", "--runtime", "--json"]); -const version = JSON.stringify(args) === JSON.stringify(["--version"]); -const record = { - command: inspect ? "runtime-inspection" : version ? "version-smoke" : "unexpected", - args, - nodeVersion: process.version, - isolationMode: process.env.CLAWCTL_GATEWAY_ISOLATION ?? null, - stateDirectoryMatchesExpected: process.env.OPENCLAW_STATE_DIR === process.env.PAYLOAD_MATRIX_EXPECTED_STATE_DIR -}; -fs.appendFileSync(process.env.PAYLOAD_MATRIX_OBSERVATIONS, JSON.stringify(record) + "\n"); -if (version) { - console.log("2026.8.2-synthetic-fixture"); -} else if (inspect) { - const fixture = JSON.parse(fs.readFileSync(new URL("./inspection-fixture.json", import.meta.url), "utf8")); - console.log(JSON.stringify(fixture.inspection)); - process.exitCode = fixture.exitCode; -} else { - process.exitCode = 91; -} -'@ - -$environmentNames = @( - 'RUNNER_TEMP', 'npm_config_arch', 'npm_config_target_arch', - 'OPENCLAW_STATE_DIR', 'CLAWCTL_GATEWAY_ISOLATION', - 'npm_config_cache', 'npm_config_offline', 'npm_config_update_notifier', - 'PAYLOAD_MATRIX_OBSERVATIONS', 'PAYLOAD_MATRIX_EXPECTED_STATE_DIR' -) -$buildEnvironmentNames = @('npm_config_arch', 'npm_config_target_arch', 'OPENCLAW_STATE_DIR', 'CLAWCTL_GATEWAY_ISOLATION', 'RUNNER_TEMP') -$originalEnvironment = Get-EnvironmentSnapshot $environmentNames -$originalLocation = (Get-Location).Path -$results = [Collections.Generic.List[object]]::new() -$transcript = [Collections.Generic.List[string]]::new() -$transcript.Add('Gateway isolation payload composition matrix') -$transcript.Add('Fixture type: synthetic local npm packages and synthetic runtime-inspection responses.') -$transcript.Add('Real operations: npm pack, npm install, scripts\Build-Payload.ps1, file copy, runtime command invocation, payload metadata.') -$transcript.Add('This matrix tests the x64 and ARM64 composition contract with controlled runtime-inspection fixtures.') -$transcript.Add("Production source: $ProductionSourceCommit") -$transcript.Add("Evidence checkout HEAD: $head") -$transcript.Add("Runtime: pwsh $($PSVersionTable.PSVersion); Node.js $nodeVersion; npm $npmVersion; Windows x64 host") -$transcript.Add('Command: $env:PATH = ";" + $env:PATH') -$transcript.Add('Command: pwsh -NoProfile -File .\scripts\validation\Test-GatewayIsolationPayloadMatrix.ps1') -$transcript.Add('All npm operations use an isolated generated cache and npm_config_offline=true; packages have no dependencies or lifecycle scripts.') -$createdRoot = $false -try { - New-Item -Path $TestRoot -ItemType Directory | Out-Null - $createdRoot = $true - $env:npm_config_cache = Join-Path $TestRoot 'npm-cache' - $env:npm_config_offline = 'true' - $env:npm_config_update_notifier = 'false' - foreach ($architecture in @('x64', 'arm64')) { - foreach ($template in $templates) { - $id = "$architecture-$($template.id)" - $caseRoot = Join-Path $TestRoot $id - $packageSource = Join-Path $caseRoot 'package-source' - $packageDirectory = Join-Path $caseRoot 'package' - $payloadDirectory = Join-Path $caseRoot 'payload' - $runner = Join-Path $caseRoot 'runner' - New-Item -Path $packageSource, $packageDirectory, $runner -ItemType Directory | Out-Null - $extension = if ($template.kind -eq 'missing-extensions') { - 'dist\fixture' - } elseif ($template.kind -eq 'preexisting-plugin') { - 'dist\extensions\gateway-isolation' - } else { - 'dist\extensions\fixture' - } - $extensionPath = Join-Path $packageSource $extension - New-Item -Path $extensionPath -ItemType Directory -Force | Out-Null - '{"name":"synthetic-extension-fixture","version":"1.0.0"}' | - Set-Content -LiteralPath (Join-Path $extensionPath 'package.json') -Encoding utf8 - '{"name":"openclaw","version":"2026.8.2","type":"module"}' | - Set-Content -LiteralPath (Join-Path $packageSource 'package.json') -Encoding utf8 - $runtimeFixture | Set-Content -LiteralPath (Join-Path $packageSource 'openclaw.mjs') -Encoding utf8 - $inspection = @{ - plugin = @{ - id = 'gateway-isolation'; origin = 'bundled'; enabled = $true - activated = $true; status = 'loaded'; imported = $true; httpRoutes = 1 - } - httpRouteCount = 1; gatewayMethods = @(); tools = @(); services = @(); diagnostics = @() - } - if ($template.field) { - $parts = $template.field.Split('.') - $container = if ($parts.Count -eq 2) { $inspection.plugin } else { $inspection } - $key = $parts[-1] - if ($template.kind -eq 'missing') { $container.Remove($key) } - else { $container[$key] = $template.value } - } - @{ - inspection = $inspection - exitCode = $(if ($template.kind -eq 'exit') { 23 } else { 0 }) - } | ConvertTo-Json -Depth 10 | - Set-Content -LiteralPath (Join-Path $packageSource 'inspection-fixture.json') -Encoding utf8 - @{ - repository = 'synthetic-local-fixture' - requestedRef = 'synthetic-local-fixture' - resolvedCommit = '0000000000000000000000000000000000000000' - packageVersion = '2026.8.2' - } | ConvertTo-Json | - Set-Content -LiteralPath (Join-Path $packageDirectory 'source.json') -Encoding utf8 - Push-Location $packageSource - try { - $packOutput = & npm pack --offline --ignore-scripts --pack-destination $packageDirectory --silent 2>&1 - Assert-Matrix ($LASTEXITCODE -eq 0) "npm pack failed for $id." - } - finally { Pop-Location } - $env:RUNNER_TEMP = $runner - $env:PAYLOAD_MATRIX_OBSERVATIONS = Join-Path $caseRoot 'runtime-observations.jsonl' - $env:PAYLOAD_MATRIX_EXPECTED_STATE_DIR = Join-Path $runner "openclaw-stage-$architecture\gateway-isolation-validation" - foreach ($name in $buildEnvironmentNames | Where-Object { $_ -ne 'RUNNER_TEMP' }) { - if ($template.environment -eq 'absent') { - Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue - } else { - $value = if ($template.environment -eq 'empty') { '' } else { "matrix-original-$name" } - [Environment]::SetEnvironmentVariable($name, $value, 'Process') - } - } - $before = Get-EnvironmentSnapshot $buildEnvironmentNames - $beforeLocation = (Get-Location).Path - $actualReason = 'accepted' - try { - & (Join-Path $repositoryRoot 'scripts\Build-Payload.ps1') ` - -PackageDirectory $packageDirectory ` - -Architecture $architecture ` - -OutputDirectory $payloadDirectory *> (Join-Path $caseRoot 'build-private.txt') - } - catch { - $actualReason = ConvertTo-PublicText $_.Exception.Message - } - $expectedReason = $template.reason.Replace('', $id).Replace('', $architecture) - Assert-Matrix ($actualReason -eq $expectedReason) "Unexpected result for ${id}: $actualReason" - $restored = [ordered]@{} - foreach ($name in $buildEnvironmentNames) { - $restored[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') -ceq $before[$name] - Assert-Matrix $restored[$name] "$id did not restore $name." - } - Assert-Matrix ((Get-Location).Path -eq $beforeLocation) "$id did not restore the working directory." - $observations = @() - if (Test-Path -LiteralPath $env:PAYLOAD_MATRIX_OBSERVATIONS) { - $observations = @(Get-Content -LiteralPath $env:PAYLOAD_MATRIX_OBSERVATIONS | ForEach-Object { $_ | ConvertFrom-Json }) - } - $expectedCalls = if ($template.kind -in @('missing-extensions', 'preexisting-plugin')) { - @() - } elseif ($template.kind -eq 'accepted' -and $architecture -eq 'x64') { - @('runtime-inspection', 'version-smoke') - } else { - @('runtime-inspection') - } - Assert-Matrix (($observations.command -join ',') -eq ($expectedCalls -join ',')) "$id invoked unexpected runtime commands." - foreach ($observation in $observations) { - Assert-Matrix ($observation.nodeVersion -eq 'v24.16.0') "$id used an unexpected Node runtime." - if ($observation.command -eq 'runtime-inspection') { - Assert-Matrix ($observation.isolationMode -eq 'disabled') "$id did not disable isolation for inspection." - Assert-Matrix $observation.stateDirectoryMatchesExpected "$id did not isolate the inspection state directory." - } - } - $content = $null - if ($template.kind -eq 'accepted') { - $pluginSource = Join-Path $repositoryRoot 'plugins\gateway-isolation' - $pluginTarget = Join-Path $payloadDirectory 'app\dist\extensions\gateway-isolation' - $files = @(Get-ChildItem -LiteralPath $pluginTarget -File -Recurse) - $names = @($files | ForEach-Object { [IO.Path]::GetRelativePath($pluginTarget, $_.FullName) } | Sort-Object) - Assert-Matrix (($names -join ',') -eq 'index.js,openclaw.plugin.json,package.json') "$id did not ship exactly the three required files." - $hashes = foreach ($name in $names) { - $sourceHash = (Get-FileHash -LiteralPath (Join-Path $pluginSource $name)).Hash.ToLowerInvariant() - $targetHash = (Get-FileHash -LiteralPath (Join-Path $pluginTarget $name)).Hash.ToLowerInvariant() - Assert-Matrix ($sourceHash -eq $targetHash) "$id altered plugin file $name." - [ordered]@{ file = $name; sourceSha256 = $sourceHash; payloadSha256 = $targetHash; identical = $true } - } - $noTestFile = -not (Test-Path -LiteralPath (Join-Path $pluginTarget 'index.test.js')) - Assert-Matrix $noTestFile "$id shipped plugin test code." - $metadata = Get-Content -LiteralPath (Join-Path $payloadDirectory 'payload-metadata.json') -Raw | ConvertFrom-Json - Assert-Matrix ($metadata.architecture -eq $architecture) "$id wrote incorrect architecture metadata." - Assert-Matrix ($metadata.layout -eq 'expanded-directory') "$id wrote incorrect layout metadata." - Assert-Matrix ($metadata.nodeVersion -eq $nodeVersion -and $metadata.npmVersion -eq $npmVersion) "$id wrote incorrect runtime metadata." - $content = [ordered]@{ - shippingFileCount = $files.Count; files = @($hashes); noTestFile = $noTestFile - metadataArchitecture = $metadata.architecture; metadataLayout = $metadata.layout - metadataNodeVersion = $metadata.nodeVersion; metadataNpmVersion = $metadata.npmVersion - } - } else { - Assert-Matrix (-not (Test-Path -LiteralPath (Join-Path $payloadDirectory 'app'))) "$id published a rejected application." - Assert-Matrix (-not (Test-Path -LiteralPath (Join-Path $payloadDirectory 'payload-metadata.json'))) "$id published rejected metadata." - } - $command = "& .\scripts\Build-Payload.ps1 -PackageDirectory \$id\package -Architecture $architecture -OutputDirectory \$id\payload" - $result = [ordered]@{ - id = $id; architecture = $architecture; outcome = 'PASS' - fixtureType = 'synthetic-local-npm-package-and-runtime-inspection' - mutation = $template.kind; field = $template.field - fixtureInspection = $inspection - expected = $expectedReason; actual = $actualReason - command = $command - environmentBefore = $(if ($template.environment -in @('absent', 'empty')) { $template.environment } else { 'existing sentinels' }) - environmentRestored = $restored; workingDirectoryRestored = $true - runtimeObservations = $observations - content = $content - rejectedOutputAbsent = $template.kind -ne 'accepted' - } - $results.Add($result) - $transcript.Add('') - $transcript.Add("CASE $id PASS") - $transcript.Add("Command (from \$id\package-source): npm pack --offline --ignore-scripts --pack-destination \$id\package --silent") - $transcript.Add("Command: $command") - $transcript.Add("Expected: $expectedReason") - $transcript.Add("Actual: $actualReason") - $transcript.Add("Environment restored: $($buildEnvironmentNames -join ', '); working directory restored: true") - $transcript.Add("Runtime calls: $($expectedCalls -join ', ')") - if ($content) { - $transcript.Add("Shipping plugin files: 3; all source/payload SHA-256 hashes identical; index.test.js absent; metadata architecture: $architecture") - foreach ($hash in $content.files) { $transcript.Add("SHA256 $($hash.file) $($hash.payloadSha256)") } - } else { - $transcript.Add('Rejected application and payload metadata absent.') - } - Write-Host "PASS $id" - } - } -} -finally { - Restore-EnvironmentSnapshot $originalEnvironment - if ($createdRoot) { - Remove-Item -LiteralPath $TestRoot -Recurse -Force - } -} -foreach ($name in $environmentNames) { - Assert-Matrix ([Environment]::GetEnvironmentVariable($name, 'Process') -ceq $originalEnvironment[$name]) "Harness did not restore $name." -} -Assert-Matrix ((Get-Location).Path -eq $originalLocation) 'Harness did not restore the working directory.' -Assert-Matrix (-not (Test-Path -LiteralPath $TestRoot)) 'Generated fixture directory was not cleaned up.' -$acceptedCount = @($results | Where-Object { $_.actual -eq 'accepted' }).Count -$transcript.Add('') -$transcript.Add("RESULT: $($results.Count) passed; $acceptedCount accepted; $($results.Count - $acceptedCount) rejected as expected; 0 failed.") -$transcript.Add('Harness environment and working directory restored. Generated TestRoot removed. No preexisting directories were removed.') -$evidence = [ordered]@{ - schemaVersion = 1 - fixtureType = 'synthetic-local-npm-packages-and-synthetic-runtime-inspection-responses' - scope = 'Actual Build-Payload.ps1 x64 and ARM64 composition contract with controlled runtime-inspection fixtures.' - productionSourceCommit = $ProductionSourceCommit - evidenceCheckoutHead = $head - verifiedSourceFiles = @($sourceHashes) - harness = 'scripts/validation/Test-GatewayIsolationPayloadMatrix.ps1' - harnessSha256 = (Get-FileHash -LiteralPath $PSCommandPath).Hash.ToLowerInvariant() - environment = [ordered]@{ powershell = $PSVersionTable.PSVersion.ToString(); node = $nodeVersion; npm = $npmVersion; host = 'Windows x64'; npmOffline = $true } - command = 'pwsh -NoProfile -File .\scripts\validation\Test-GatewayIsolationPayloadMatrix.ps1 -ProductionSourceCommit ' - summary = [ordered]@{ total = $results.Count; passed = $results.Count; failed = 0; accepted = $acceptedCount; rejected = $results.Count - $acceptedCount; architectures = @('x64', 'arm64') } - verifiedContracts = @( - 'Exact three-file plugin content and hashes for accepted x64 and ARM64 payloads.' - 'Runtime inspection requires one read-only route and no methods, tools, services, or diagnostics.' - 'Missing, conflicting, failed, incomplete, and capability-expanded plugin shapes are rejected.' - 'Build environment and working directory are restored for accepted and rejected cases.' - 'Rejected cases do not publish application content or payload metadata.' - ) - harnessEnvironmentRestored = $true - harnessWorkingDirectoryRestored = $true - generatedFixtureDirectoryRemoved = $true -} -$json = $evidence | ConvertTo-Json -Depth 20 -foreach ($publicOutput in @($json)) { - Assert-Matrix ($publicOutput -notmatch '(?i)[a-z]:[\\/]') 'Evidence contains an absolute local path.' - Assert-Matrix (-not $publicOutput.Contains($env:USERNAME)) 'Evidence contains the local username.' -} -New-Item -Path $EvidenceDirectory -ItemType Directory -Force | Out-Null -$json | Set-Content -LiteralPath (Join-Path $EvidenceDirectory 'payload-matrix.json') -Encoding utf8 -Write-Host "Payload matrix passed: $($results.Count) cases. Sanitized summary written." diff --git a/scripts/validation/gateway-isolation-runtime.mjs b/scripts/validation/gateway-isolation-runtime.mjs deleted file mode 100644 index 218cd1d4..00000000 --- a/scripts/validation/gateway-isolation-runtime.mjs +++ /dev/null @@ -1,634 +0,0 @@ -import assert from "node:assert/strict"; -import crypto from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { execFileSync, spawn } from "node:child_process"; -import { pathToFileURL } from "node:url"; - -const [ - layoutArgument, - nodeArgument, - outputArgument, - coreCommit, - packagingCommit, -] = process.argv.slice(2); -assert.ok( - layoutArgument && - nodeArgument && - outputArgument && - /^[0-9a-f]{40}$/.test(coreCommit ?? "") && - /^[0-9a-f]{40}$/.test(packagingCommit ?? ""), - "Usage: node gateway-isolation-runtime.mjs ", -); - -const layout = path.resolve(layoutArgument); -const node = path.resolve(nodeArgument); -const output = path.resolve(outputArgument); -const pluginPath = "/plugins/gateway-isolation/status"; -const temp = fs.mkdtempSync(path.join(os.tmpdir(), "windows-launcher-proof-")); -const token = crypto.randomBytes(32).toString("hex"); -const auth = { Authorization: `Bearer ${token}` }; -const build = JSON.parse( - fs.readFileSync(path.join(layout, "app", "dist", "build-info.json"), "utf8"), -); -const { chromium } = await import( - pathToFileURL( - path.resolve(layout, "app", "node_modules", "playwright-core", "index.mjs"), - ).href -); - -const customThemeTokenNames = [ - "bg", - "bg-accent", - "bg-elevated", - "bg-hover", - "bg-muted", - "bg-content", - "card", - "card-foreground", - "card-highlight", - "popover", - "popover-foreground", - "panel", - "panel-strong", - "panel-hover", - "chrome", - "chrome-strong", - "text", - "text-strong", - "chat-text", - "muted", - "muted-strong", - "muted-foreground", - "border", - "border-strong", - "border-hover", - "input", - "ring", - "accent", - "accent-hover", - "accent-muted", - "accent-subtle", - "accent-foreground", - "accent-glow", - "primary", - "primary-foreground", - "secondary", - "secondary-foreground", - "accent-2", - "accent-2-muted", - "accent-2-subtle", - "destructive", - "destructive-foreground", - "danger", - "danger-muted", - "danger-subtle", - "focus", - "focus-ring", - "focus-glow", - "font-body", - "font-display", - "mono", - "grid-line", -]; - -function customMode(defaultColor, overrides) { - const tokens = Object.fromEntries( - customThemeTokenNames.map((name) => [name, defaultColor]), - ); - Object.assign(tokens, { - "font-body": "Georgia, serif", - "font-display": "Georgia, serif", - mono: "Courier New, monospace", - ...overrides, - }); - return tokens; -} - -const customTheme = { - sourceUrl: "https://tweakcn.com/themes/windows-launcher-proof", - themeId: "windows-launcher-proof", - label: "Windows Launcher Proof", - importedAt: "2026-09-14T00:00:00.000Z", - light: customMode("#fff4d6", { - bg: "#fff4d6", - "bg-elevated": "#ffd166", - card: "#ffe3a3", - text: "#342400", - "text-strong": "#1f1300", - muted: "#765b23", - border: "#b7791f", - "border-strong": "#7c4f00", - accent: "#7b2cbf", - primary: "#7b2cbf", - "primary-foreground": "#ffffff", - ok: "#006d3c", - warn: "#9c4a00", - }), - dark: customMode("#151026", { - bg: "#151026", - "bg-elevated": "#3f2a63", - card: "#281d45", - text: "#f8efff", - "text-strong": "#ffffff", - muted: "#c5aad8", - border: "#7b5aa6", - "border-strong": "#ad86d8", - accent: "#ff4fd8", - primary: "#ff4fd8", - "primary-foreground": "#201020", - ok: "#70e1a1", - warn: "#ffb347", - }), -}; - -const tokenMapping = { - "--bg": "--bg", - "--card": "--card", - "--button-bg": "--bg-elevated", - "--text": "--text", - "--text-strong": "--text-strong", - "--muted": "--muted", - "--border": "--border", - "--border-strong": "--border-strong", - "--focus": "--accent", - "--ok-text": "--ok", - "--warn-text": "--warn", - "--radius": "--radius", - "--radius-full": "--radius-full", - "--font-body": "--font-body", - "--font-mono": "--mono", -}; - -const pause = (milliseconds) => - new Promise((resolve) => setTimeout(resolve, milliseconds)); - -async function until(check, label, timeout = 45_000) { - const end = Date.now() + timeout; - while (Date.now() < end) { - if (await check()) { - return; - } - await pause(200); - } - throw new Error(`Timed out: ${label}`); -} - -async function reservePort() { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - assert.equal(typeof address, "object"); - const port = address.port; - server.close(() => resolve(port)); - }); - }); -} - -async function assertPortFree(port) { - await new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", reject); - server.listen(port, "127.0.0.1", () => server.close(resolve)); - }); -} - -function sha256(file) { - return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); -} - -function publicEnvironment(browserVersion) { - return { - os: "Windows x64", - node: process.version, - browser: browserVersion, - layout: "expanded application", - }; -} - -async function setTheme(page, theme, mode) { - await page.evaluate( - ({ customTheme, mode, theme }) => { - const key = Object.keys(localStorage).find((candidate) => - candidate.startsWith("openclaw.control.settings.v1:"), - ); - if (!key) { - throw new Error("Control UI settings key was not created."); - } - const settings = JSON.parse(localStorage.getItem(key)); - settings.theme = theme; - settings.themeMode = mode; - if (theme === "custom") { - settings.customTheme = customTheme; - } - const value = JSON.stringify(settings); - localStorage.setItem(key, value); - window.dispatchEvent(new StorageEvent("storage", { key, newValue: value })); - }, - { customTheme, mode, theme }, - ); -} - -async function themeSnapshot(page, frame, expectedTheme, expectedMode) { - await until( - async () => - (await page.locator("html").getAttribute("data-theme")) === expectedTheme && - (await page.locator("html").getAttribute("data-theme-mode")) === expectedMode, - `${expectedTheme} ${expectedMode} host theme`, - ); - await until( - async () => - (await frame.locator("html").getAttribute("data-theme-mode")) === expectedMode, - `${expectedTheme} ${expectedMode} plugin theme`, - ); - - const host = await page.evaluate((mapping) => { - const styles = getComputedStyle(document.documentElement); - return Object.fromEntries( - Object.values(mapping).map((property) => [ - property, - styles.getPropertyValue(property).trim(), - ]), - ); - }, tokenMapping); - const plugin = await frame.evaluate((mapping) => { - const styles = getComputedStyle(document.documentElement); - return { - mode: document.documentElement.dataset.themeMode, - values: Object.fromEntries( - Object.keys(mapping).map((property) => [ - property, - styles.getPropertyValue(property).trim(), - ]), - ), - h1: document.querySelector("h1")?.textContent, - status: document.querySelector(".status")?.textContent, - row: document.querySelector(".settings-row__title")?.textContent, - mutationGuidance: - document.body.textContent.includes("Change with CLI") || - document.body.textContent.includes("clawctl gateway-isolation"), - }; - }, tokenMapping); - - for (const [pluginProperty, hostProperty] of Object.entries(tokenMapping)) { - assert.ok(host[hostProperty], `Host did not publish ${hostProperty}.`); - assert.equal( - plugin.values[pluginProperty], - host[hostProperty], - `${pluginProperty} did not match ${hostProperty}.`, - ); - } - assert.equal(plugin.mode, expectedMode); - assert.equal(plugin.h1, "Windows Launcher"); - assert.equal(plugin.status, "Disabled"); - assert.equal(plugin.row, "Gateway Isolation"); - assert.equal(plugin.mutationGuidance, true); - return { hostTheme: expectedTheme, mode: expectedMode, tokens: plugin.values }; -} - -const port = await reservePort(); -const base = `http://127.0.0.1:${port}`; -const route = `${base}${pluginPath}`; -const profile = path.join(temp, "profile"); -fs.mkdirSync(profile); -fs.mkdirSync(output, { recursive: true }); -fs.writeFileSync( - path.join(profile, "openclaw.json"), - JSON.stringify({ - gateway: { - mode: "local", - port, - bind: "loopback", - auth: { mode: "token" }, - controlUi: { allowedOrigins: [base] }, - }, - agents: { defaults: { workspace: path.join(profile, "workspace") } }, - browser: { enabled: false }, - discovery: { mdns: { mode: "off" } }, - logging: { file: path.join(profile, "gateway.log") }, - }), -); - -const env = Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => !/^(OPENCLAW_|CLAWCTL_GATEWAY_ISOLATION$)/i.test(key), - ), -); -const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; -env[pathKey] = `${path.dirname(node)};${env[pathKey] ?? ""}`; -Object.assign(env, { - OPENCLAW_STATE_DIR: profile, - OPENCLAW_CONFIG_PATH: path.join(profile, "openclaw.json"), - OPENCLAW_GATEWAY_TOKEN: token, - OPENCLAW_SUPERVISOR_MODE: "external", - OPENCLAW_SERVICE_REPAIR_POLICY: "external", - OPENCLAW_NO_AUTO_UPDATE: "1", - CLAWCTL_GATEWAY_ISOLATION: "enabled", -}); - -const child = spawn( - path.join(layout, "openclaw.exe"), - ["gateway", "run", "--port", String(port), "--bind", "loopback", "--auth", "token"], - { env, cwd: profile, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, -); -let logs = ""; -child.stdout.on("data", (chunk) => { - logs += chunk; -}); -child.stderr.on("data", (chunk) => { - logs += chunk; -}); - -const browser = await chromium.launch({ channel: "msedge", headless: true }); -const context = await browser.newContext({ - viewport: { width: 1440, height: 1080 }, - deviceScaleFactor: 1, -}); -const page = await context.newPage(); -let hello; -const rpcErrors = []; -const browserErrors = []; -page.on("console", (message) => { - if (message.type() === "error") { - browserErrors.push(`console: ${message.text()}`); - } -}); -page.on("pageerror", (error) => { - browserErrors.push(`page: ${error.message}`); -}); -page.on("websocket", (socket) => - socket.on("framereceived", ({ payload }) => { - const frame = JSON.parse(payload.toString()); - if (frame.payload?.type === "hello-ok") { - hello = frame.payload.server; - } - if (frame.error) { - rpcErrors.push(frame.error.code); - } - }), -); -const visibleFrameRequests = []; -page.on("request", (request) => { - const url = new URL(request.url()); - if ( - request.isNavigationRequest() && - url.pathname === pluginPath && - url.search === "" - ) { - visibleFrameRequests.push(request.url()); - } -}); - -try { - try { - await until(async () => { - if (child.exitCode !== null) { - throw new Error(`Gateway exited with ${child.exitCode}: ${logs}`); - } - try { - return (await fetch(`${base}/healthz`)).ok; - } catch (error) { - if (error.cause?.code !== "ECONNREFUSED") { - throw error; - } - return false; - } - }, "Gateway readiness"); - } catch (error) { - throw new Error(`${error.message}\nGateway logs:\n${logs}`); - } - - let canonicalBody; - const httpResults = []; - for (const method of ["GET", "HEAD"]) { - for (const access of ["anonymous", "wrong-token", "authenticated"]) { - const response = await fetch(route, { - method, - headers: - access === "authenticated" - ? auth - : access === "wrong-token" - ? { Authorization: "Bearer wrong-token" } - : {}, - }); - assert.equal(response.status, access === "authenticated" ? 200 : 401); - const body = await response.text(); - if (method === "HEAD") { - assert.equal(body, ""); - } - if (access === "authenticated") { - assert.equal(response.headers.get("cache-control"), "no-store"); - assert.equal(response.headers.get("x-content-type-options"), "nosniff"); - assert.equal(response.headers.get("referrer-policy"), "no-referrer"); - assert.match( - response.headers.get("content-security-policy"), - /frame-ancestors 'self'/, - ); - if (method === "GET") { - canonicalBody = body; - assert.match(body, />Disabled Boolean(hello), "authenticated hello-ok"); - } catch (error) { - throw new Error( - `${error.message}\nGateway logs:\n${logs}\nBrowser errors:\n${browserErrors.join("\n")}`, - ); - } - assert.equal(hello.buildId, build.buildId); - assert.equal(hello.controlUiBuildSource, "bundled"); - await pause(2_000); - const back = page.getByRole("button", { name: /Back to app/ }); - if (await back.count()) { - await back.click(); - } - const tab = page.getByText("Windows Launcher", { exact: true }); - await tab.waitFor({ state: "visible" }); - await tab.click(); - - const iframe = page.locator("iframe.plugin-tab-embed__frame"); - await iframe.waitFor({ state: "visible" }); - assert.equal(await iframe.getAttribute("sandbox"), "allow-scripts"); - assert.equal((await iframe.getAttribute("sandbox")).includes("allow-same-origin"), false); - const pluginFrame = page.frames().find((candidate) => { - try { - return new URL(candidate.url()).pathname === pluginPath; - } catch { - return false; - } - }); - assert.ok(pluginFrame, "Plugin frame did not mount."); - await pluginFrame - .getByText("Gateway Isolation", { exact: true }) - .waitFor({ state: "visible" }); - assert.equal(visibleFrameRequests.length, 1); - - let postMountNavigations = 0; - page.on("framenavigated", (frame) => { - if (frame === pluginFrame) { - postMountNavigations++; - } - }); - - const themes = [ - { - id: "claw-dark", - theme: "claw", - mode: "dark", - resolved: "dark", - screenshot: "claw-dark.png", - }, - { - id: "claw-light", - theme: "claw", - mode: "light", - resolved: "light", - screenshot: "claw-light.png", - }, - { - id: "custom-dark", - theme: "custom", - mode: "dark", - resolved: "custom", - screenshot: "custom-dark.png", - }, - { - id: "custom-light", - theme: "custom", - mode: "light", - resolved: "custom-light", - screenshot: "custom-light.png", - }, - ]; - const themeResults = []; - for (const theme of themes) { - await setTheme(page, theme.theme, theme.mode); - const snapshot = await themeSnapshot( - page, - pluginFrame, - theme.resolved, - theme.mode, - ); - assert.equal( - page.frames().find((candidate) => candidate === pluginFrame), - pluginFrame, - ); - assert.equal(visibleFrameRequests.length, 1); - assert.equal(postMountNavigations, 0); - await page.screenshot({ - path: path.join(output, theme.screenshot), - fullPage: true, - }); - themeResults.push({ - id: theme.id, - family: theme.theme === "custom" ? customTheme.label : "Claw", - importedCustomTheme: theme.theme === "custom", - screenshot: theme.screenshot, - ...snapshot, - }); - } - - assert.deepEqual(rpcErrors, []); - const result = { - schemaVersion: 1, - packagingCommit, - corePullRequest: "openclaw/openclaw#145409", - coreCommit, - build: { - version: hello.version, - buildId: hello.buildId, - controlUiBuildSource: hello.controlUiBuildSource, - }, - environment: publicEnvironment(await browser.version()), - sha256: { - launcher: sha256(path.join(layout, "openclaw.exe")), - node: sha256(node), - runtimeEntry: sha256(path.join(layout, "app", "openclaw.mjs")), - plugin: sha256( - path.join( - layout, - "app", - "dist", - "extensions", - "gateway-isolation", - "index.js", - ), - ), - }, - launcher: { - inheritedIsolationInput: "enabled", - reportedIsolationMode: "disabled", - provesLauncherOverridesInheritedInput: true, - }, - route: { - path: pluginPath, - httpResults, - authenticated: true, - readOnly: true, - responseHardening: true, - }, - frame: { - sandbox: "allow-scripts", - allowSameOrigin: false, - visibleRequests: visibleFrameRequests.length, - postMountNavigations, - sameFrameAcrossLiveThemeSwitches: true, - }, - themes: themeResults, - cliControlPresent: true, - passed: true, - }; - const json = `${JSON.stringify(result, null, 2)}\n`; - assert.doesNotMatch(json, /[A-Za-z]:[\\/]/); - assert.equal(json.includes(os.userInfo().username), false); - fs.writeFileSync(path.join(output, "runtime-results.json"), json); - fs.rmSync(temp, { recursive: true, maxRetries: 10, retryDelay: 200 }); - console.log( - `Windows Launcher runtime proof passed: ${themes.length} themes, one frame request, no reloads.`, - ); -} catch (error) { - fs.writeFileSync(path.join(temp, "failure.log"), logs); - await page.screenshot({ - path: path.join(temp, "failure.png"), - fullPage: true, - }); - throw error; -} finally { - await page.close(); - await context.close(); - await browser.close(); - if (child.exitCode === null) { - execFileSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { - stdio: "ignore", - }); - } - await until( - () => child.exitCode !== null || child.signalCode !== null, - "owned process exit", - ); - await assertPortFree(port); -} From 543dc17a54c60c5cbfcc18f04c500a90288d43f4 Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 19:10:44 -0700 Subject: [PATCH 40/76] Validate plugin through isolated enablement Keep the shipping profile disabled, then enable only gateway-isolation in a temporary validation config before inspecting its real registration shape. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 8 ++- scripts/Build-Payload.ps1 | 62 +++++++++++++++++-- scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 42 +++++++++++-- scripts/Test-NodeRuntimeInputs.Tests.ps1 | 45 +++++++++++--- 4 files changed, 135 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c5d911ff..003919ce 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,11 @@ For a one-time override, run **Build OpenClaw Gateway MSIX** manually and provide a tag, branch, or preferably a full 40-character commit SHA in `openclaw_ref`. Payload composition validates that the selected OpenClaw runtime discovers the packaging-owned Windows Launcher plugin in its -default-disabled state, then uses OpenClaw's non-activating runtime inspection -pass to validate its required read-only route shape. Incompatible older refs -fail instead of producing a package with an unvalidated plugin. +default-disabled state, then explicitly enables only that plugin in an isolated +temporary validation profile before using OpenClaw's runtime inspection pass to +validate its required read-only route shape. The temporary profile is removed +after inspection and does not modify user configuration. Incompatible older +refs fail instead of producing a package with an unvalidated plugin. The source build uses that revision's `.github/actions/setup-node-env` action to select Node.js and pnpm. Its resolved Node.js version is recorded in diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 9c305057..ddf0025a 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -126,11 +126,17 @@ foreach ($pluginFile in $pluginFiles) { } $previousStateDirectory = $env:OPENCLAW_STATE_DIR +$previousConfigPath = $env:OPENCLAW_CONFIG_PATH $previousIsolationMode = $env:CLAWCTL_GATEWAY_ISOLATION +$validationStateDirectory = Join-Path ` + $stagingDirectory ` + 'gateway-isolation-validation' +$validationConfigPath = Join-Path ` + $validationStateDirectory ` + 'openclaw.json' try { - $env:OPENCLAW_STATE_DIR = Join-Path ` - $stagingDirectory ` - 'gateway-isolation-validation' + $env:OPENCLAW_STATE_DIR = $validationStateDirectory + $env:OPENCLAW_CONFIG_PATH = $validationConfigPath $env:CLAWCTL_GATEWAY_ISOLATION = 'disabled' Push-Location $installedPackage try { @@ -162,6 +168,42 @@ try { ) } + & node .\openclaw.mjs plugins enable gateway-isolation + if ($LASTEXITCODE -ne 0) { + throw ( + 'The selected OpenClaw payload cannot explicitly enable the ' + + "Gateway isolation plugin in its isolated validation profile. " + + "Exit code: $LASTEXITCODE." + ) + } + if (-not ( + Test-Path ` + -LiteralPath $validationConfigPath ` + -PathType Leaf + )) { + throw ( + 'The selected OpenClaw payload did not create the isolated ' + + 'Gateway isolation validation configuration.' + ) + } + $validationConfig = Get-Content ` + -LiteralPath $validationConfigPath ` + -Raw | + ConvertFrom-Json + $validationEntryNames = @( + $validationConfig.plugins.entries.PSObject.Properties.Name + ) + if ( + $validationEntryNames.Count -ne 1 -or + $validationEntryNames[0] -ne 'gateway-isolation' -or + $validationConfig.plugins.entries.'gateway-isolation'.enabled -ne $true + ) { + throw ( + 'The isolated validation configuration must explicitly enable ' + + 'only the Gateway isolation plugin.' + ) + } + $inspectionOutput = ( & node ` .\openclaw.mjs ` @@ -184,7 +226,7 @@ try { if ( $inspection.plugin.id -ne 'gateway-isolation' -or $inspection.plugin.origin -ne 'bundled' -or - $inspection.plugin.enabled -ne $false -or + $inspection.plugin.enabled -ne $true -or $inspection.plugin.activated -ne $true -or $inspection.plugin.status -ne 'loaded' -or $inspection.plugin.imported -ne $true -or @@ -196,14 +238,22 @@ try { @($inspection.diagnostics).Count -ne 0 ) { throw ( - 'The selected OpenClaw payload did not inspect the disabled Gateway ' + - 'isolation plugin with the required read-only runtime shape.' + 'The selected OpenClaw payload did not inspect the explicitly ' + + 'enabled Gateway isolation plugin with the required read-only ' + + 'runtime shape.' ) } } finally { $env:OPENCLAW_STATE_DIR = $previousStateDirectory + $env:OPENCLAW_CONFIG_PATH = $previousConfigPath $env:CLAWCTL_GATEWAY_ISOLATION = $previousIsolationMode + if (Test-Path -LiteralPath $validationStateDirectory) { + Remove-Item ` + -LiteralPath $validationStateDirectory ` + -Recurse ` + -Force + } } $bundledNodeFiles = @( diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 index 7e717e4e..b5bebfcc 100644 --- a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -68,18 +68,48 @@ try { -LiteralPath (Join-Path $packageSource 'openclaw.mjs') ` -Value @' const args = process.argv.slice(2); +const fs = await import("node:fs"); +const path = await import("node:path"); +const configPath = + process.env.OPENCLAW_CONFIG_PATH ?? + (process.env.OPENCLAW_STATE_DIR + ? path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json") + : undefined); +if (args[0] === "plugins" && args[1] === "enable") { + if (!configPath) { + throw new Error("Missing isolated validation configuration path."); + } + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + JSON.stringify({ + plugins: { + entries: { + "gateway-isolation": { + enabled: true + } + } + } + }), + ); + process.exit(0); +} + +const enabled = Boolean(configPath) && fs.existsSync(configPath) && + JSON.parse(fs.readFileSync(configPath, "utf8")) + .plugins?.entries?.["gateway-isolation"]?.enabled === true; const runtime = args.includes("--runtime"); console.log(JSON.stringify({ plugin: { id: "gateway-isolation", origin: "bundled", - enabled: false, - activated: runtime, - status: runtime ? "loaded" : "disabled", - imported: runtime, - httpRoutes: runtime ? 1 : 0 + enabled, + activated: enabled && runtime, + status: enabled && runtime ? "loaded" : "disabled", + imported: enabled && runtime, + httpRoutes: enabled && runtime ? 1 : 0 }, - httpRouteCount: runtime ? 1 : 0, + httpRouteCount: enabled && runtime ? 1 : 0, gatewayMethods: [], tools: [], services: [], diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index 48f338ac..146a4fb9 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -38,18 +38,49 @@ try { '{"name":"openclaw","version":"0.0.0","type":"module"}' | Set-Content -LiteralPath "$source\package.json" @' -const runtime = process.argv.includes("--runtime"); +const fs = await import("node:fs"); +const path = await import("node:path"); +const args = process.argv.slice(2); +const configPath = + process.env.OPENCLAW_CONFIG_PATH ?? + (process.env.OPENCLAW_STATE_DIR + ? path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json") + : undefined); +if (args[0] === "plugins" && args[1] === "enable") { + if (!configPath) { + throw new Error("Missing isolated validation configuration path."); + } + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + JSON.stringify({ + plugins: { + entries: { + "gateway-isolation": { + enabled: true + } + } + } + }), + ); + process.exit(0); +} + +const enabled = Boolean(configPath) && fs.existsSync(configPath) && + JSON.parse(fs.readFileSync(configPath, "utf8")) + .plugins?.entries?.["gateway-isolation"]?.enabled === true; +const runtime = args.includes("--runtime"); console.log(JSON.stringify({ plugin: { id: "gateway-isolation", origin: "bundled", - enabled: false, - activated: runtime, - status: runtime ? "loaded" : "disabled", - imported: runtime, - httpRoutes: runtime ? 1 : 0 + enabled, + activated: enabled && runtime, + status: enabled && runtime ? "loaded" : "disabled", + imported: enabled && runtime, + httpRoutes: enabled && runtime ? 1 : 0 }, - httpRouteCount: runtime ? 1 : 0, + httpRouteCount: enabled && runtime ? 1 : 0, gatewayMethods: [], tools: [], services: [], From 605640ea85e993105cde324280db1175372f1d3d Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Mon, 14 Sep 2026 19:57:18 -0700 Subject: [PATCH 41/76] Retain the approved OpenClaw baseline Keep the disabled plugin on the release-authorized runtime and defer selected-theme activation until the future enablement change qualifies the newer core revision. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/gateway-msix.yml | 4 ++-- README.md | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index ff4e36e8..c9e71c94 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -10,7 +10,7 @@ on: openclaw_ref: description: openclaw/openclaw tag, branch, or commit to package required: true - default: f65ecca89667b8a55d9f88d76c487f0a0ab11da8 + default: 0965053fe6b9341776df147a6934b7485c60b5ca type: string signing_mode: description: Package signing mode @@ -26,7 +26,7 @@ permissions: contents: read env: - OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || 'f65ecca89667b8a55d9f88d76c487f0a0ab11da8' }} + OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '0965053fe6b9341776df147a6934b7485c60b5ca' }} PACKAGING_ROOT: . jobs: diff --git a/README.md b/README.md index 003919ce..5f483ac1 100644 --- a/README.md +++ b/README.md @@ -190,12 +190,14 @@ they do not represent the default-disabled state of a normal install. Full selected-theme cohesion requires the generic plugin-frame theme forwarding merged by [`openclaw/openclaw#145409`](https://github.com/openclaw/openclaw/pull/145409). -The minimum selected OpenClaw revision is its merged commit -`f65ecca89667b8a55d9f88d76c487f0a0ab11da8`. The page consumes validated -`openclaw:widget-theme` messages from its parent frame and follows built-in and -custom light/dark themes without reloading. Direct opens and older compatible -hosts still use the browser or operating system light/dark preference with a -safe built-in palette. +The current workflow remains on the release-approved OpenClaw baseline +`0965053fe6b9341776df147a6934b7485c60b5ca` while this plugin is disabled by +default. That baseline packages and inspects the plugin safely but does not +forward selected Control UI themes into plugin frames. The future launcher +enablement change must also advance and qualify the runtime to the merged theme +forwarding commit `f65ecca89667b8a55d9f88d76c487f0a0ab11da8` or newer. Until +then, the page uses the browser or operating system light/dark preference with +a safe built-in palette. `scripts\Build-MSIX.ps1` downloads the official Node.js archive matching the payload's recorded build version and architecture, copies both inputs into From 814b02ac78761b7ad7d615e52be20d5a8f2f2c2b Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 15 Sep 2026 11:02:44 -0700 Subject: [PATCH 42/76] feat: register a local development build without building an MSIX (#41) Registration ownership is checked by install location, so the no-change short circuit cannot report "already up to date" for a registration belonging to another checkout. Without that check the identity takeover requested by -ReplaceExistingInstall silently did not happen and the aliases kept running the other checkout's layout. Returning to a released package requires -Unregister first. Windows will not replace a loose registration with a packaged install: Add-AppxPackage fails with 0x80073CFB, reporting that an unpackaged version is already installed and a packaged version cannot replace it. That is the same mutual exclusion as the packaged-to-local direction and is unrelated to version ordering. --- .github/copilot-instructions.md | 24 + .github/workflows/gateway-msix.yml | 5 + README.md | 57 ++ scripts/Deploy-LocalPackage.ps1 | 107 +++ scripts/LocalPackage.psm1 | 773 +++++++++++++++++++++ scripts/Test-Deploy-LocalPackage.Tests.ps1 | 360 ++++++++++ 6 files changed, 1326 insertions(+) create mode 100644 scripts/Deploy-LocalPackage.ps1 create mode 100644 scripts/LocalPackage.psm1 create mode 100644 scripts/Test-Deploy-LocalPackage.Tests.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f8904f44..2c660ed5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,6 +50,30 @@ downloading one with `gh`. MSIX composition requires Visual Studio Build Tools with the Desktop development with C++ workload and the Windows SDK. Build x64 and ARM64 separately. +For the development inner loop, register a Developer Mode layout instead of +building an MSIX: + +```powershell +.\scripts\Deploy-LocalPackage.ps1 +``` + +`Deploy-LocalPackage.ps1` publishes the NativeAOT launcher, assembles a layout +under `artifacts\local-package`, registers it with `Add-AppxPackage -Register`, +and runs `clawctl setup`. It never builds, signs, or installs an MSIX, and it +requires no changes to the packaging scripts or project files. It is idempotent +and skips work when nothing changed, so keep its up-to-date check honest: the +fingerprint hashes the launcher rather than trusting timestamps, because +publish can refresh timestamps with no source change. + +Loose registration and MSIX installation are mutually exclusive for one package +identity, and Windows cannot preserve packaged app data across that switch, so +the script refuses by default and requires `-ReplaceExistingInstall`. The +registered package reads its files from the repository, so treat +`artifacts\local-package` and the checkout as live inputs, not scratch output. +Its scenario tests inject every GitHub, publish, certificate-free registration, +and deployment operation; no test may register, remove, or modify a real +package. + ## Architecture - `OpenClaw.Gateway.Launcher` is a .NET 10 NativeAOT executable packaged as diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index c0e563c3..812a1075 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -101,6 +101,11 @@ jobs: run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test local package deployment + shell: pwsh + run: > + .\scripts\Test-Deploy-LocalPackage.Tests.ps1 + - name: Test MSIX bundle build shell: pwsh run: > diff --git a/README.md b/README.md index 8299cfb2..a306918f 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,63 @@ an unsigned NativeAOT MSIX. local payload directory. `-NodeArchivePath` can supply an already-downloaded archive, but its version and architecture must match the payload metadata. +### Running a local development build + +To go from a clean checkout to a registered, runnable package: + +```powershell +.\scripts\Deploy-LocalPackage.ps1 +``` + +This is the development inner loop. It does not build, sign, or install an +MSIX. It acquires the payload and the bundled Node.js runtime, publishes the +NativeAOT launcher, assembles a Developer Mode layout under +`artifacts\local-package`, registers it with `Add-AppxPackage -Register`, and +runs `clawctl setup` so `openclaw` is immediately usable. + +The command is idempotent: re-running with nothing changed reports that the +package is already up to date and does nothing, and re-running after a source +or payload change rebuilds only what changed. The expanded application is +linked into the layout rather than copied, so repeat runs neither re-download +nor duplicate hundreds of megabytes. + +| Option | Behavior | +| --- | --- | +| `-RefreshPayload` | Download the payload again; the previous one is kept until the new one registers successfully | +| `-PayloadRunId ` | Use a specific successful workflow run, reusing a matching cached payload | +| `-PayloadDirectory ` | Read a prepared payload directly, with no GitHub access and no modification; pass it on every run | +| `-Architecture x64` / `arm64` | Select the architecture; it must be runnable on this device | +| `-ReplaceExistingInstall` | Remove a conflicting MSIX-installed package first (see below) | +| `-SkipSetup` | Register without extracting the Node.js runtime | +| `-Force` | Re-register even when nothing changed | +| `-Unregister` | Remove the local registration, preserving app data and caches | + +**Requires Developer Mode**, which the script checks before doing any work. + +**It cannot coexist with an MSIX-installed `OpenClaw.Gateway`.** Windows +refuses to replace a packaged install with a local layout, and it cannot +preserve that package's app data across the switch, so the script stops and +explains rather than removing anything implicitly. Pass +`-ReplaceExistingInstall` to accept that trade. + +**Run `-Unregister` before installing a released package.** Windows will not +replace a loose registration with a packaged install: `Add-AppxPackage` fails +with `0x80073CFB`, reporting that an unpackaged version is already installed +and a packaged version cannot replace it. This is the same mutual exclusion as +above, in the other direction, and it applies regardless of version. Unregister +first, then install the release: + +```powershell +.\scripts\Deploy-LocalPackage.ps1 -Unregister +Add-AppxPackage -Path .\OpenClawGateway-0.0.0.0-x64.msix +``` + +**The registered package reads its files from the repository.** Deleting +`artifacts\local-package`, moving the checkout, or deleting the worktree breaks +the registration until the command runs again; `-Unregister` first if you plan +to remove the checkout. Local builds are unsigned development artifacts and are +never official-signing inputs. + Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: diff --git a/scripts/Deploy-LocalPackage.ps1 b/scripts/Deploy-LocalPackage.ps1 new file mode 100644 index 00000000..921a4459 --- /dev/null +++ b/scripts/Deploy-LocalPackage.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS +Builds and registers a local development OpenClaw Gateway package, ready to run. +.DESCRIPTION +Takes a clean checkout to a registered, runnable OpenClaw.Gateway package without +producing, signing, or installing an MSIX. It acquires the OpenClaw payload and +the bundled Node.js runtime, publishes the NativeAOT launcher, assembles a +Developer Mode layout, and registers it with Add-AppxPackage -Register. + +The command is idempotent. Re-running with nothing changed reports that the +package is already up to date and does nothing; re-running after a source or +payload change rebuilds only what changed and re-registers. Downloads are cached +under artifacts\local-package, and the expanded application is linked rather +than copied, so repeat runs do not re-fetch or duplicate hundreds of megabytes. + +Requires Developer Mode. The registered package reads its files from the +repository, so deleting artifacts\local-package or the checkout breaks it until +the command runs again. This is a development build and is not an +official-signing input; use Build-LocalMSIX.ps1 for a verified unsigned MSIX. +.PARAMETER Architecture +Build and register x64 (default) or arm64. The selected architecture must be +runnable on this device. +.PARAMETER PayloadDirectory +Use an existing payload root containing app and payload-metadata.json. The +directory is read directly and never modified, and no GitHub access is needed. +Pass it on every run; it is not remembered. +.PARAMETER PayloadRunId +Use a specific successful workflow run instead of the latest one on main. A +matching cached payload is reused rather than downloaded again. +.PARAMETER RefreshPayload +Download the payload again instead of reusing the cache. The previous payload +is kept until the new one is registered successfully. +.PARAMETER ReplaceExistingInstall +Take over an existing install this checkout does not own: an MSIX-installed +OpenClaw.Gateway, or a local registration from another checkout. Windows cannot +replace a packaged install with a local layout and cannot preserve its app data +across that switch, so this is never done implicitly. +.PARAMETER Force +Re-register even when nothing changed. +.PARAMETER SkipSetup +Skip the final `clawctl setup` that extracts the bundled Node.js runtime. The +package is registered but not runnable until setup is run once. +.PARAMETER Unregister +Remove the local development registration and exit. Cached payloads and +runtimes are kept, and the package's app data is preserved. Run this before +installing a released package: Windows will not replace a loose registration +with a packaged install, regardless of version. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 +Clean checkout to a registered, runnable package; later runs reuse the cache. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 -RefreshPayload +Pick up a newer OpenClaw payload from the latest successful main workflow run. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 -PayloadDirectory E:\payloads\x64 +Register from a prepared payload without contacting GitHub. +.EXAMPLE +.\scripts\Deploy-LocalPackage.ps1 -Unregister +Remove the local registration. +#> +[CmdletBinding(DefaultParameterSetName = 'Deploy')] +param( + [ValidateSet('x64', 'arm64')] + [string]$Architecture = 'x64', + + [Parameter(ParameterSetName = 'Deploy')] + [string]$PayloadDirectory, + + [Parameter(ParameterSetName = 'Deploy')] + [long]$PayloadRunId, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$RefreshPayload, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$ReplaceExistingInstall, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$Force, + + [Parameter(ParameterSetName = 'Deploy')] + [switch]$SkipSetup, + + [Parameter(Mandatory, ParameterSetName = 'Unregister')] + [switch]$Unregister +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $PSScriptRoot 'LocalPackage.psm1') -Force + +if ($Unregister) { + Remove-LocalPackageRegistration -RepositoryRoot $repositoryRoot -Architecture $Architecture + return +} + +Invoke-LocalPackageDeployment ` + -RepositoryRoot $repositoryRoot ` + -Architecture $Architecture ` + -PayloadDirectory $PayloadDirectory ` + -PayloadRunId $PayloadRunId ` + -RefreshPayload:$RefreshPayload ` + -ReplaceExistingInstall:$ReplaceExistingInstall ` + -Force:$Force ` + -SkipSetup:$SkipSetup | + Out-Null diff --git a/scripts/LocalPackage.psm1 b/scripts/LocalPackage.psm1 new file mode 100644 index 00000000..2b3dd5a7 --- /dev/null +++ b/scripts/LocalPackage.psm1 @@ -0,0 +1,773 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:PackageName = 'OpenClaw.Gateway' +$script:StateSchema = 1 + +function Read-LocalPackageRecord { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + try { + $record = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable + } + catch { + throw "Cannot read local state '$Path': $($_.Exception.Message)" + } + if ($record -isnot [System.Collections.IDictionary]) { + throw "Local state must be a JSON object: $Path" + } + return $record +} + +function Write-LocalPackageRecord { + param([string]$Path, [System.Collections.IDictionary]$Record) + + $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + try { + [IO.File]::WriteAllText($temporary, ($Record | ConvertTo-Json -Depth 8) + "`n") + [IO.File]::Move($temporary, $Path, $true) + } + finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } + } +} + +function Get-LocalPackageFingerprint { + param([string[]]$Parts) + + $sha = [Security.Cryptography.SHA256]::Create() + try { + return [Convert]::ToHexString( + $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($Parts -join "`n"))) + ).ToLowerInvariant() + } + finally { $sha.Dispose() } +} + +function Assert-LocalPackagePayload { + param([string]$Directory, [string]$Architecture) + + $application = Join-Path $Directory 'app' + if (-not (Test-Path -LiteralPath (Join-Path $application 'openclaw.mjs') -PathType Leaf)) { + throw "Payload is missing app\openclaw.mjs: $Directory. Supply a complete -PayloadDirectory or use -RefreshPayload." + } + $metadata = Read-LocalPackageRecord (Join-Path $Directory 'payload-metadata.json') + if ($null -eq $metadata -or $metadata['architecture'] -ne $Architecture -or + $metadata['layout'] -ne 'expanded-directory' -or + [string]$metadata['nodeVersion'] -notmatch '^v?\d+\.\d+\.\d+$') { + throw "Payload metadata must describe an expanded $Architecture directory with a Node.js version: $Directory. Supply a matching payload or use -RefreshPayload." + } + return ([string]$metadata['nodeVersion']).TrimStart('v') +} + +function Assert-LocalPackagePayloadIsSafe { + param([string]$Directory) + + # The packaged layout links to this tree, so a link inside it would resolve + # outside the package at run time. + $application = Join-Path $Directory 'app' + foreach ($entry in Get-ChildItem -LiteralPath $application -Force -Recurse) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw ( + 'The downloaded payload contains a link or reparse point: ' + + [IO.Path]::GetRelativePath($application, $entry.FullName) + ) + } + if (-not $entry.PSIsContainer -and + ($entry.Name -ieq 'node.exe' -or $entry.Name -match '^node-v\d')) { + throw ( + 'The downloaded payload must not bundle Node.js: ' + + [IO.Path]::GetRelativePath($application, $entry.FullName) + ) + } + } +} + +function Get-LocalPackageGitHubCommand { + # More than one gh.exe can be on PATH; take the first match rather than + # letting .Source concatenate every candidate path. + $gh = @(Get-Command gh -CommandType Application -ErrorAction SilentlyContinue) | + Select-Object -First 1 + if ($null -eq $gh) { + throw 'GitHub CLI (gh) is required to acquire a payload. Install and authenticate gh, or pass -PayloadDirectory; a completed cache needs no gh.' + } + return $gh +} + +function Resolve-LocalPackagePayload { + param( + [string]$CacheDirectory, + [string]$Architecture, + [string]$PayloadDirectory, + [long]$PayloadRunId, + [switch]$RefreshPayload, + [hashtable]$Operations + ) + + if ($PayloadDirectory) { + $directory = (Resolve-Path -LiteralPath $PayloadDirectory -ErrorAction Stop).Path + $nodeVersion = Assert-LocalPackagePayload $directory $Architecture + Write-Host "Payload: supplied directly ($directory)" + return [pscustomobject]@{ + Directory = $directory; NodeVersion = $nodeVersion + CacheHit = $true; Superseded = $null + PendingSelection = $null; SelectionPath = $null + } + } + + New-Item -Path $CacheDirectory -ItemType Directory -Force | Out-Null + $selectionPath = Join-Path $CacheDirectory 'current.json' + $current = $null + try { $current = Read-LocalPackageRecord $selectionPath } + catch { + if (-not $RefreshPayload) { + throw "$($_.Exception.Message) Run with -RefreshPayload to replace the invalid selection." + } + Write-Warning "Replacing an unreadable cache selection: $selectionPath" + } + if ($null -ne $current) { + if ($current['schemaVersion'] -ne $script:StateSchema -or + $current['architecture'] -ne $Architecture -or + $current['generation'] -isnot [string] -or + $current['generation'] -notmatch '^[1-9]\d*-[0-9a-f]{32}$' -or + $current['runId'] -isnot [long] -or $current['runId'] -le 0) { + if (-not $RefreshPayload) { + throw "Invalid payload cache selection: $selectionPath. Run with -RefreshPayload to replace it." + } + Write-Warning "Replacing an invalid cache selection: $selectionPath" + $current = $null + } + if ($null -ne $current -and -not $RefreshPayload -and + ($PayloadRunId -eq 0 -or $PayloadRunId -eq $current['runId'])) { + $directory = Join-Path $CacheDirectory $current['generation'] + $nodeVersion = Assert-LocalPackagePayload $directory $Architecture + Write-Host "Payload: cache hit, run $($current['runId'])" + return [pscustomobject]@{ + Directory = $directory; NodeVersion = $nodeVersion + CacheHit = $true; Superseded = $null + PendingSelection = $null; SelectionPath = $null + } + } + } + + $runId = if ($PayloadRunId -gt 0) { $PayloadRunId } else { & $Operations.LatestRun } + if ($runId -isnot [long] -or $runId -le 0) { + throw 'Payload selection did not return a valid workflow run ID.' + } + $generation = "$runId-$([guid]::NewGuid().ToString('N'))" + $directory = Join-Path $CacheDirectory $generation + New-Item -Path $directory -ItemType Directory | Out-Null + $complete = $false + try { + Write-Host "Payload: downloading run $runId" + & $Operations.Download $runId $Architecture $directory | Out-Null + $nodeVersion = Assert-LocalPackagePayload $directory $Architecture + Assert-LocalPackagePayloadIsSafe $directory + $complete = $true + } + finally { + if (-not $complete) { Remove-Item -LiteralPath $directory -Recurse -Force } + } + + # The selection is committed and the replaced generation retired by the + # caller, once the whole deployment succeeds. Committing here would leave a + # failed run selecting a payload that was never successfully deployed. + $superseded = $null + if ($null -ne $current) { + $old = Join-Path $CacheDirectory $current['generation'] + if ((Test-Path -LiteralPath $old -PathType Container) -and + (([IO.File]::GetAttributes($old) -band [IO.FileAttributes]::ReparsePoint) -eq 0)) { + $superseded = $old + } + } + return [pscustomobject]@{ + Directory = $directory; NodeVersion = $nodeVersion + CacheHit = $false; Superseded = $superseded + PendingSelection = [ordered]@{ + schemaVersion = $script:StateSchema + architecture = $Architecture + runId = $runId + generation = $generation + } + SelectionPath = $selectionPath + } +} + +function Resolve-LocalPackageRuntime { + param( + [string]$RuntimeDirectory, + [string]$Architecture, + [string]$NodeVersion, + [hashtable]$Operations + ) + + New-Item -Path $RuntimeDirectory -ItemType Directory -Force | Out-Null + $name = "node-v$NodeVersion-win-$Architecture.zip" + $path = Join-Path $RuntimeDirectory $name + if (Test-Path -LiteralPath $path -PathType Leaf) { + try { + & $Operations.TestArchive $path ([IO.Path]::GetFileNameWithoutExtension($name)) | Out-Null + Write-Host "Node.js runtime: cached ($name)" + return $path + } + catch { + Write-Host "Node.js runtime: replacing unusable cache ($($_.Exception.Message))" + Remove-Item -LiteralPath $path -Force + } + } + Write-Host "Node.js runtime: downloading $name" + & $Operations.DownloadRuntime $NodeVersion $name $path | Out-Null + & $Operations.TestArchive $path ([IO.Path]::GetFileNameWithoutExtension($name)) | Out-Null + return $path +} + +function New-LocalPackageLayout { + param( + [string]$LayoutDirectory, + [string]$RepositoryRoot, + [string]$HostExecutable, + [string]$PayloadDirectory, + [string]$RuntimeArchive, + [string]$Architecture, + [string]$Version + ) + + New-Item -Path $LayoutDirectory -ItemType Directory -Force | Out-Null + + $manifestPath = Join-Path $LayoutDirectory 'AppxManifest.xml' + [xml]$manifest = Get-Content -LiteralPath ( + Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\Package.appxmanifest' + ) -Raw + $identity = $manifest.SelectSingleNode("/*[local-name()='Package']/*[local-name()='Identity']") + $identity.SetAttribute('Version', $Version) + $identity.SetAttribute('ProcessorArchitecture', $Architecture) + $manifest.Save($manifestPath) + + Copy-Item -LiteralPath $HostExecutable -Destination (Join-Path $LayoutDirectory 'openclaw.exe') -Force + $images = Join-Path $LayoutDirectory 'Images' + if (Test-Path -LiteralPath $images) { Remove-Item -LiteralPath $images -Recurse -Force } + Copy-Item -LiteralPath (Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\Images') ` + -Destination $images -Recurse + + # A junction keeps the expanded application out of a second copy; the + # payload is hundreds of megabytes and never modified here. + $applicationLink = Join-Path $LayoutDirectory 'app' + $applicationTarget = Join-Path $PayloadDirectory 'app' + $existing = if (Test-Path -LiteralPath $applicationLink) { + Get-Item -LiteralPath $applicationLink -Force + } + else { $null } + if ($null -ne $existing -and + (($existing.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or + $existing.Target -ne $applicationTarget)) { + Remove-Item -LiteralPath $applicationLink -Recurse -Force + $existing = $null + } + if ($null -eq $existing) { + New-Item -ItemType Junction -Path $applicationLink -Target $applicationTarget | Out-Null + } + + $runtime = Join-Path $LayoutDirectory 'runtime' + New-Item -Path $runtime -ItemType Directory -Force | Out-Null + foreach ($stale in Get-ChildItem -LiteralPath $runtime -File) { + if ($stale.Name -ne [IO.Path]::GetFileName($RuntimeArchive)) { + Remove-Item -LiteralPath $stale.FullName -Force + } + } + # Replace the layout copy every time. Testing only for a file of the right + # name would keep a truncated archive from an interrupted copy forever, + # including under -Force, and clawctl setup would then read the bad copy. + $runtimeTarget = Join-Path $runtime ([IO.Path]::GetFileName($RuntimeArchive)) + $runtimeStaging = "$runtimeTarget.$([guid]::NewGuid().ToString('N')).tmp" + try { + Copy-Item -LiteralPath $RuntimeArchive -Destination $runtimeStaging -Force + [IO.File]::Move($runtimeStaging, $runtimeTarget, $true) + } + finally { + if (Test-Path -LiteralPath $runtimeStaging) { + Remove-Item -LiteralPath $runtimeStaging -Force + } + } + return $manifestPath +} + +function Test-LocalPackageOwnership { + param($Installed, [string]$LayoutDirectory) + + # An identity can only have one registration, so "ours" means the installed + # location is this checkout's layout. Anything else belongs to another + # checkout or tool even though the name and publisher match. + if ($null -eq $Installed -or [string]::IsNullOrWhiteSpace($Installed.InstallLocation)) { + return $false + } + return [IO.Path]::GetFullPath($Installed.InstallLocation).TrimEnd('\') -ieq + [IO.Path]::GetFullPath($LayoutDirectory).TrimEnd('\') +} + +function Test-LocalPackageLayout { + param( + [string]$LayoutDirectory, + [string]$PayloadDirectory, + [string]$RuntimeArchiveName + ) + + # The registered package serves these files directly, so a short circuit + # must confirm the live layout, not just that a previous run wrote one. + foreach ($relative in @('AppxManifest.xml', 'openclaw.exe', "runtime\$RuntimeArchiveName")) { + if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory $relative) -PathType Leaf)) { + return $false + } + } + if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory 'Images') -PathType Container)) { + return $false + } + $link = Join-Path $LayoutDirectory 'app' + if (-not (Test-Path -LiteralPath $link -PathType Container)) { return $false } + $item = Get-Item -LiteralPath $link -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or + $item.Target -ne (Join-Path $PayloadDirectory 'app')) { + return $false + } + return (Test-Path -LiteralPath (Join-Path $link 'openclaw.mjs') -PathType Leaf) +} + +function Get-LocalPackageOperations { + return @{ + Now = { Get-Date } + Preflight = { + param($architecture) + if (-not $IsWindows) { throw 'Registering a local package requires Windows.' } + $key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' + $unlock = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue + if ($null -eq $unlock -or + $null -eq $unlock.PSObject.Properties['AllowDevelopmentWithoutDevLicense'] -or + [int]$unlock.AllowDevelopmentWithoutDevLicense -ne 1) { + throw 'Developer Mode is required to register an unpackaged layout. Enable it in Settings > System > For developers.' + } + $osArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + if ($architecture -eq 'arm64' -and $osArchitecture -ne 'arm64') { + throw "An arm64 package cannot be registered on this $osArchitecture device." + } + if (-not (Get-Command dotnet -CommandType Application -ErrorAction SilentlyContinue)) { + throw 'The pinned .NET SDK is required. Install the SDK selected by global.json.' + } + $installer = Join-Path ( + [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + ) 'Microsoft Visual Studio\Installer' + if (-not (Get-Command vswhere.exe -CommandType Application -ErrorAction SilentlyContinue)) { + if (-not (Test-Path -LiteralPath (Join-Path $installer 'vswhere.exe') -PathType Leaf)) { + throw 'vswhere.exe was not found. Install Visual Studio Build Tools with the Desktop development with C++ workload.' + } + $env:Path = "$installer;$env:Path" + } + } + LatestRun = { + $gh = Get-LocalPackageGitHubCommand + $lines = @(& $gh.Source run list --repo openclaw/openclaw-windows-packaging ` + --workflow gateway-msix.yml --branch main --status success --limit 1 --json databaseId) + if ($LASTEXITCODE -ne 0) { + throw "Unable to query the payload workflow (exit $LASTEXITCODE). Check gh authentication, or pass -PayloadDirectory." + } + $runs = @([string]::Join("`n", [string[]]$lines) | ConvertFrom-Json) + if ($runs.Count -ne 1) { + throw 'No successful payload workflow run was found. Pass -PayloadRunId or -PayloadDirectory.' + } + return [long]$runs[0].databaseId + } + Download = { + param($runId, $architecture, $directory) + $gh = Get-LocalPackageGitHubCommand + & $gh.Source run download $runId --repo openclaw/openclaw-windows-packaging ` + --name "openclaw-gateway-payload-$architecture" --dir $directory | + ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + throw "Payload download failed (exit $LASTEXITCODE). Check gh access and artifact retention, or pass -PayloadDirectory." + } + return $null + } + DownloadRuntime = { + param($nodeVersion, $name, $path) + Invoke-WebRequest -Uri "https://nodejs.org/dist/v$nodeVersion/$name" -OutFile $path + return $null + } + TestArchive = { + param($path, $expectedRoot) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($path) + try { + $entries = @($archive.Entries | Where-Object { $_.FullName -ieq "$expectedRoot/node.exe" }) + if ($entries.Count -ne 1) { + throw "The Node.js archive must contain exactly one '$expectedRoot/node.exe' entry." + } + } + finally { $archive.Dispose() } + return $null + } + Publish = { + param($project, $architecture, $output) + & dotnet publish $project --configuration Release --runtime "win-$architecture" ` + --self-contained "-p:Platform=$architecture" -p:PublishAot=true ` + --output $output --nologo | + ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + throw "The NativeAOT publish failed (exit $LASTEXITCODE)." + } + return $null + } + GetPackage = { + param($name) + $package = Get-AppxPackage -Name $name -ErrorAction Stop | + Where-Object Name -eq $name | + Select-Object -First 1 + if ($null -eq $package) { return $null } + return [pscustomobject]@{ + Version = $package.Version.ToString() + PackageFullName = $package.PackageFullName + InstallLocation = $package.InstallLocation + IsDevelopmentMode = [bool]$package.IsDevelopmentMode + Status = $package.Status.ToString() + } + } + RegisterPackage = { + param($manifestPath) + Add-AppxPackage -Register $manifestPath -ErrorAction Stop + return $null + } + RemovePackage = { + param($packageFullName, $preserveData) + if ($preserveData) { + Remove-AppxPackage -Package $packageFullName -PreserveApplicationData -ErrorAction Stop + } + else { + Remove-AppxPackage -Package $packageFullName -ErrorAction Stop + } + return $null + } + TestPath = { param($path) Test-Path -LiteralPath $path } + RunSetup = { + # Control mode is selected by the alias name, so setup must go + # through clawctl.exe rather than the layout executable. + $alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\clawctl.exe' + if (-not (Test-Path -LiteralPath $alias -PathType Leaf)) { + throw "The clawctl alias was not created at $alias." + } + & $alias setup | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { + throw "clawctl setup failed (exit $LASTEXITCODE)." + } + return $null + } + } +} + +function Invoke-LocalPackagePhase { + param([hashtable]$ProgressState, [string]$Label, [scriptblock]$Action) + + $ProgressState.Stage = $Label + Write-Host "`n[$Label]" + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $result = & $Action + $stopwatch.Stop() + Write-Host (' done in {0:0.00}s' -f $stopwatch.Elapsed.TotalSeconds) + return $result +} + +function Get-LocalPackageServices { + param([hashtable]$Overrides) + + $services = Get-LocalPackageOperations + foreach ($name in $Overrides.Keys) { + if (-not $services.ContainsKey($name) -or $Overrides[$name] -isnot [scriptblock]) { + throw "Unknown or invalid local package operation adapter: $name" + } + $services[$name] = $Overrides[$name] + } + return $services +} + +function Remove-LocalPackageRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [ValidateSet('x64', 'arm64')][string]$Architecture = 'x64', + [hashtable]$Operations = @{} + ) + + $services = Get-LocalPackageServices $Operations + $state = Join-Path ([IO.Path]::GetFullPath($RepositoryRoot)) "artifacts\local-package\$Architecture" + $layoutDirectory = Join-Path $state 'layout' + $installed = & $services.GetPackage $script:PackageName + if ($null -eq $installed) { + Write-Host "$script:PackageName is not registered for the current user." + } + elseif (-not $installed.IsDevelopmentMode) { + throw "$script:PackageName is installed from a package, not a local layout. Remove it deliberately with Remove-AppxPackage if that is what you want." + } + elseif (-not (Test-LocalPackageOwnership -Installed $installed -LayoutDirectory $layoutDirectory)) { + throw ( + "$script:PackageName is registered from another location: " + + "$($installed.InstallLocation). Run -Unregister from that checkout instead; " + + 'this one does not own that registration.' + ) + } + else { + # Development-mode packages allow preserving app data, so unregistering + # does not throw away the extracted Node.js runtime. + & $services.RemovePackage $installed.PackageFullName $true | Out-Null + Write-Host "Unregistered $($installed.PackageFullName); its app data was preserved." + } + $statePath = Join-Path $state 'state.json' + if (Test-Path -LiteralPath $statePath) { Remove-Item -LiteralPath $statePath -Force } + Write-Host "Cached payload and runtime are kept under $state." +} + +function Invoke-LocalPackageDeployment { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [ValidateSet('x64', 'arm64')][string]$Architecture = 'x64', + [string]$PayloadDirectory, + [long]$PayloadRunId, + [switch]$RefreshPayload, + [switch]$ReplaceExistingInstall, + [switch]$Force, + [switch]$SkipSetup, + [hashtable]$Operations = @{} + ) + + if ($PayloadRunId -lt 0) { throw '-PayloadRunId must be a positive workflow run ID.' } + if ($PayloadDirectory -and ($PayloadRunId -ne 0 -or $RefreshPayload)) { + throw '-PayloadDirectory cannot be combined with -PayloadRunId or -RefreshPayload.' + } + + $services = Get-LocalPackageServices $Operations + $root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).Path + $stateRoot = Join-Path $root "artifacts\local-package\$Architecture" + $layoutDirectory = Join-Path $stateRoot 'layout' + $statePath = Join-Path $stateRoot 'state.json' + $progress = @{ Stage = 'preparing' } + $total = [Diagnostics.Stopwatch]::StartNew() + + try { + & $services.Preflight $Architecture | Out-Null + New-Item -Path $stateRoot -ItemType Directory -Force | Out-Null + Write-Host "Registering a local development build of $script:PackageName ($Architecture)." + Write-Host "Layout: $layoutDirectory" + + $installed = Invoke-LocalPackagePhase $progress 'Check current registration' { + & $services.GetPackage $script:PackageName + } + if ($null -ne $installed -and -not $installed.IsDevelopmentMode) { + if (-not $ReplaceExistingInstall) { + throw ( + "$script:PackageName is already installed from a package (version $($installed.Version)). " + + 'Windows cannot replace a packaged install with a local layout. ' + + 'Re-run with -ReplaceExistingInstall to remove it first; its packaged app data ' + + 'cannot be preserved across that switch.' + ) + } + Write-Warning "Removing the packaged $script:PackageName $($installed.Version); its app data cannot be preserved." + & $services.RemovePackage $installed.PackageFullName $false | Out-Null + $installed = $null + } + elseif ($null -ne $installed -and + -not (Test-LocalPackageOwnership -Installed $installed -LayoutDirectory $layoutDirectory)) { + # A development registration from another checkout or tool. Only one + # registration of this identity can exist, but it is not ours to take. + if (-not $ReplaceExistingInstall) { + throw ( + "$script:PackageName is already registered from another location: " + + "$($installed.InstallLocation). Run -Unregister from that checkout, or " + + 're-run with -ReplaceExistingInstall to take over the identity here.' + ) + } + Write-Warning "Taking over the registration at $($installed.InstallLocation)." + } + + $payload = Invoke-LocalPackagePhase $progress 'Resolve payload' { + Resolve-LocalPackagePayload -CacheDirectory (Join-Path $stateRoot 'payloads') ` + -Architecture $Architecture -PayloadDirectory $PayloadDirectory ` + -PayloadRunId $PayloadRunId -RefreshPayload:$RefreshPayload -Operations $services + } + $runtimeArchive = Invoke-LocalPackagePhase $progress 'Resolve Node.js runtime' { + Resolve-LocalPackageRuntime -RuntimeDirectory (Join-Path $stateRoot 'runtime') ` + -Architecture $Architecture -NodeVersion $payload.NodeVersion -Operations $services + } + $hostDirectory = Join-Path $stateRoot 'host' + Invoke-LocalPackagePhase $progress 'Build launcher (NativeAOT)' { + & $services.Publish (Join-Path $root 'src\OpenClaw.Launcher\OpenClaw.Launcher.csproj') ` + $Architecture $hostDirectory + } | Out-Null + $hostExecutable = Join-Path $hostDirectory 'openclaw.exe' + if (-not (& $services.TestPath $hostExecutable)) { + throw "The publish did not produce $hostExecutable." + } + + $hostInfo = Get-Item -LiteralPath $hostExecutable + $manifestSource = Join-Path $root 'src\OpenClaw.Launcher\Package.appxmanifest' + # Hash the launcher rather than trusting its timestamp: publish copies + # into the output directory and can refresh timestamps with no source + # change, which would defeat the up-to-date check on every run. + $hostHash = (Get-FileHash -LiteralPath $hostExecutable -Algorithm SHA256).Hash + $imageHashes = @( + Get-ChildItem -LiteralPath (Join-Path $root 'src\OpenClaw.Launcher\Images') -File -Recurse | + Sort-Object FullName | + ForEach-Object { "$($_.Name):$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" } + ) + $fingerprint = Get-LocalPackageFingerprint (@( + $Architecture + $payload.Directory + [IO.Path]::GetFileName($runtimeArchive) + $hostInfo.Length.ToString() + $hostHash + (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash + ) + $imageHashes) + $previous = Read-LocalPackageRecord $statePath + $setupSatisfied = $SkipSetup -or ($null -ne $previous -and $previous['setupComplete'] -eq $true) + if (-not $Force -and $null -ne $previous -and $null -ne $installed -and + $installed.IsDevelopmentMode -and + (Test-LocalPackageOwnership -Installed $installed -LayoutDirectory $layoutDirectory) -and + $previous['fingerprint'] -eq $fingerprint -and + $previous['version'] -eq $installed.Version -and + $installed.Status -ieq 'Ok' -and + $setupSatisfied -and + (Test-LocalPackageLayout -LayoutDirectory $layoutDirectory ` + -PayloadDirectory $payload.Directory ` + -RuntimeArchiveName ([IO.Path]::GetFileName($runtimeArchive)))) { + $total.Stop() + Write-Host "`nAlready up to date: $($installed.PackageFullName)" + Write-Host ('Total {0:0.00}s. Use -Force to re-register anyway.' -f $total.Elapsed.TotalSeconds) + return [pscustomobject]@{ + Version = $installed.Version + PackageFullName = $installed.PackageFullName + LayoutDirectory = $layoutDirectory + Changed = $false + } + } + + $version = Get-LocalPackageNextVersion ` + -InstalledVersion $(if ($null -ne $installed) { $installed.Version } else { '' }) ` + -PreviousVersion $(if ($null -ne $previous) { [string]$previous['version'] } else { '' }) ` + -Now (& $services.Now) + + $manifestPath = Invoke-LocalPackagePhase $progress 'Assemble layout' { + New-LocalPackageLayout -LayoutDirectory $layoutDirectory -RepositoryRoot $root ` + -HostExecutable $hostExecutable -PayloadDirectory $payload.Directory ` + -RuntimeArchive $runtimeArchive -Architecture $Architecture -Version $version + } + Invoke-LocalPackagePhase $progress 'Register package' { + # Re-registering over an existing development registration does not + # reliably repair one whose layout was deleted or damaged: the + # package still reports Status Ok while its aliases fail with "The + # process has no package identity". Removing first, preserving app + # data, makes registration deterministic and self-healing. + if ($null -ne $installed -and $installed.IsDevelopmentMode) { + & $services.RemovePackage $installed.PackageFullName $true | Out-Null + } + & $services.RegisterPackage $manifestPath + } | Out-Null + + $registered = & $services.GetPackage $script:PackageName + if ($null -eq $registered -or $registered.Version -ne $version -or + -not $registered.IsDevelopmentMode -or $registered.Status -inotin @('Ok', 'Ready')) { + throw 'The package did not register as a healthy local development build.' + } + Write-LocalPackageRecord $statePath ([ordered]@{ + schemaVersion = $script:StateSchema + version = $version + fingerprint = $fingerprint + setupComplete = $false + packageFullName = $registered.PackageFullName + layoutDirectory = $layoutDirectory + payloadDirectory = $payload.Directory + }) + + if (-not $SkipSetup) { + Invoke-LocalPackagePhase $progress 'Prepare bundled Node.js runtime' { + & $services.RunSetup + } | Out-Null + } + + # Record completion only once the package is actually runnable, so a + # failed or skipped setup cannot be short-circuited as up to date. + Write-LocalPackageRecord $statePath ([ordered]@{ + schemaVersion = $script:StateSchema + version = $version + fingerprint = $fingerprint + setupComplete = (-not $SkipSetup) + packageFullName = $registered.PackageFullName + layoutDirectory = $layoutDirectory + payloadDirectory = $payload.Directory + }) + if ($null -ne $payload.PendingSelection) { + Write-LocalPackageRecord $payload.SelectionPath $payload.PendingSelection + } + if ($payload.Superseded) { + Remove-Item -LiteralPath $payload.Superseded -Recurse -Force -ErrorAction SilentlyContinue + } + + $total.Stop() + Write-Host "`nRegistered: $($registered.PackageFullName)" + Write-Host $(if ($SkipSetup) { + 'Run `clawctl setup` once, then `openclaw`.' + } + else { 'Ready to run: `openclaw`' }) + Write-Host ('Total {0:0.00}s.' -f $total.Elapsed.TotalSeconds) + return [pscustomobject]@{ + Version = $version + PackageFullName = $registered.PackageFullName + LayoutDirectory = $layoutDirectory + Changed = $true + } + } + catch { + $total.Stop() + Write-Host "`nFAILED during $($progress.Stage): $($_.Exception.Message)" + throw + } +} + +function Get-LocalPackageNextVersion { + param( + [string]$InstalledVersion, + [string]$PreviousVersion, + [datetime]$Now = (Get-Date) + ) + + $parse = { + param($value) + if (-not $value) { return [version]'0.0.0.0' } + $parsed = $null + if ($value -notmatch '^\d{1,5}\.\d{1,5}\.\d{1,5}\.\d{1,5}$' -or + -not [version]::TryParse($value, [ref]$parsed) -or + @($parsed.Major, $parsed.Minor, $parsed.Build, $parsed.Revision). + Where({ $_ -gt [uint16]::MaxValue }).Count -ne 0) { + throw "Invalid MSIX version '$value'. Use four numeric components from 0 through 65535." + } + return $parsed + } + $highest = & $parse $InstalledVersion + $previous = & $parse $PreviousVersion + if ($previous -gt $highest) { $highest = $previous } + + $days = [int]($Now.Date - [datetime]'2020-01-01').TotalDays + if ($days -lt 0 -or $days -gt [uint16]::MaxValue) { + throw 'The current date cannot be encoded as a local package version.' + } + $candidate = [version]"0.1.$days.$([int]$Now.TimeOfDay.TotalSeconds % 65536)" + if ($candidate -gt $highest) { return $candidate.ToString(4) } + + $parts = @($highest.Major, $highest.Minor, $highest.Build, $highest.Revision) + for ($index = 3; $index -ge 0; $index--) { + if ($parts[$index] -lt [uint16]::MaxValue) { + $parts[$index]++ + return $parts -join '.' + } + $parts[$index] = 0 + } + throw 'The local package version space is exhausted.' +} + +Export-ModuleMember -Function Invoke-LocalPackageDeployment, Remove-LocalPackageRegistration, + Get-LocalPackageNextVersion diff --git a/scripts/Test-Deploy-LocalPackage.Tests.ps1 b/scripts/Test-Deploy-LocalPackage.Tests.ps1 new file mode 100644 index 00000000..59fab1cb --- /dev/null +++ b/scripts/Test-Deploy-LocalPackage.Tests.ps1 @@ -0,0 +1,360 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'LocalPackage.psm1') -Force + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-local-package-$([guid]::NewGuid().ToString('N'))" +New-Item -Path $testRoot -ItemType Directory | Out-Null + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw $Message } +} + +function Assert-Fails { + param([scriptblock]$Action, [string]$Pattern) + try { & $Action | Out-Null } + catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "Expected '$Pattern'; received '$($_.Exception.Message)'." + } + return + } + throw "Expected failure matching '$Pattern'." +} + +function New-Fixture { + $root = Join-Path $testRoot "repo with spaces $([guid]::NewGuid().ToString('N'))" + $project = Join-Path $root 'src\OpenClaw.Launcher' + New-Item -Path (Join-Path $project 'Images') -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $project 'Images\StoreLogo.png'), 'fixture image') + [IO.File]::WriteAllText((Join-Path $project 'Package.appxmanifest'), @' + + + + +'@) + + $state = @{ + Root = $root + Installed = $null + Now = [datetime]'2026-09-14T12:00:00' + RunId = [long]500 + Offline = $false + PayloadText = 'first payload' + NodeVersion = '24.20.0' + DownloadFailure = $false + PublishFailure = $false + SkipHost = $false + HostVersion = 1 + RegisterFailure = $false + BadRegistration = $false + Queries = 0 + Downloads = 0 + RuntimeDownloads = 0 + Publishes = 0 + Registrations = 0 + Setups = 0 + SetupFailure = $false + Removals = @() + PreserveFlags = @() + } + $state.WritePayload = { + param($directory, $architecture, $text, $nodeVersion) + New-Item -Path (Join-Path $directory 'app') -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $directory 'app\openclaw.mjs'), $text) + [IO.File]::WriteAllText((Join-Path $directory 'payload-metadata.json'), (@{ + architecture = $architecture; layout = 'expanded-directory'; nodeVersion = $nodeVersion + } | ConvertTo-Json)) + } + $state.Operations = @{ + Now = { $state.Now }.GetNewClosure() + Preflight = { param($architecture) return $null } + LatestRun = { + if ($state.Offline) { throw 'GitHub is unavailable.' } + $state.Queries++ + return $state.RunId + }.GetNewClosure() + Download = { + param($runId, $architecture, $directory) + if ($state.Offline) { throw 'Downloads are unavailable.' } + $state.Downloads++ + & $state.WritePayload $directory $architecture $state.PayloadText $state.NodeVersion + if ($state.DownloadFailure) { throw 'Interrupted payload download.' } + return $null + }.GetNewClosure() + DownloadRuntime = { + param($nodeVersion, $name, $path) + if ($state.Offline) { throw 'nodejs.org is unavailable.' } + $state.RuntimeDownloads++ + [IO.File]::WriteAllText($path, "fixture archive $name") + return $null + }.GetNewClosure() + TestArchive = { param($path, $expectedRoot) return $null } + Publish = { + param($project, $architecture, $output) + $state.Publishes++ + if ($state.PublishFailure) { throw 'NativeAOT publish failed.' } + New-Item -Path $output -ItemType Directory -Force | Out-Null + if (-not $state.SkipHost) { + # Unchanged source must produce identical bytes, as a real + # incremental publish does; HostVersion models a source edit. + [IO.File]::WriteAllText((Join-Path $output 'openclaw.exe'), "host $($state.HostVersion)") + } + return $null + }.GetNewClosure() + GetPackage = { param($name) $state.Installed }.GetNewClosure() + RegisterPackage = { + param($manifestPath) + $state.Registrations++ + if ($state.RegisterFailure) { throw '0x80073CFB: registration blocked.' } + [xml]$m = Get-Content -LiteralPath $manifestPath -Raw + $state.Installed = [pscustomobject]@{ + Version = if ($state.BadRegistration) { '9.9.9.9' } else { $m.Package.Identity.Version } + PackageFullName = "OpenClaw.Gateway_$($m.Package.Identity.Version)_fixture" + InstallLocation = Split-Path $manifestPath -Parent + IsDevelopmentMode = $true + Status = 'Ok' + } + return $null + }.GetNewClosure() + RemovePackage = { + param($packageFullName, $preserveData) + $state.Removals += $packageFullName + $state.PreserveFlags += [bool]$preserveData + $state.Installed = $null + return $null + }.GetNewClosure() + TestPath = { param($path) Test-Path -LiteralPath $path } + RunSetup = { + $state.Setups++ + if ($state.SetupFailure) { throw 'clawctl setup failed (exit 1).' } + return $null + }.GetNewClosure() + } + return $state +} + +function Invoke-Fixture { + param([hashtable]$State, [hashtable]$Arguments = @{}) + Invoke-LocalPackageDeployment -RepositoryRoot $State.Root -Operations $State.Operations @Arguments +} + +try { + # Clean checkout to a registered package. + $f = New-Fixture + $first = Invoke-Fixture $f + Assert-True ($first.Changed -and $f.Downloads -eq 1 -and $f.RuntimeDownloads -eq 1 -and + $f.Publishes -eq 1 -and $f.Registrations -eq 1) 'First deployment did not acquire and register exactly once.' + Assert-True ($f.Setups -eq 1) 'Deployment did not leave the package runnable by preparing the runtime.' + Assert-True (@($first).Count -eq 1 -and $first.PackageFullName) 'Deployment did not return a single registration record.' + $layout = $first.LayoutDirectory + Assert-True ((Get-Content (Join-Path $layout 'app\openclaw.mjs') -Raw) -eq 'first payload') 'Layout does not expose the payload application.' + Assert-True ((Get-Item (Join-Path $layout 'app')).Attributes -band [IO.FileAttributes]::ReparsePoint) 'The layout copied the application instead of linking it.' + Assert-True (Test-Path (Join-Path $layout 'openclaw.exe')) 'Layout is missing the launcher.' + Assert-True (Test-Path (Join-Path $layout 'Images\StoreLogo.png')) 'Layout is missing package images.' + Assert-True (@(Get-ChildItem (Join-Path $layout 'runtime') -File).Name -eq 'node-v24.20.0-win-x64.zip') 'Layout is missing the bundled Node.js runtime.' + [xml]$m = Get-Content (Join-Path $layout 'AppxManifest.xml') -Raw + Assert-True ($m.Package.Identity.Version -eq $first.Version -and + $m.Package.Identity.ProcessorArchitecture -eq 'x64') 'Manifest identity was not stamped.' + + # Idempotent: nothing changed, nothing done, and no network. + $f.Offline = $true + $second = Invoke-Fixture $f + Assert-True (-not $second.Changed) 'A no-change re-run reported work.' + Assert-True ($f.Registrations -eq 1 -and $f.Downloads -eq 1 -and $f.RuntimeDownloads -eq 1) 'A no-change re-run re-registered or re-downloaded.' + Assert-True ($second.Version -eq $first.Version) 'A no-change re-run altered the version.' + $forced = Invoke-Fixture $f @{ Force = $true } + Assert-True ($forced.Changed -and $f.Registrations -eq 2) '-Force did not re-register.' + Assert-True ([version]$forced.Version -gt [version]$first.Version) 'Re-registration did not advance the version.' + # Registering over a damaged development registration does not repair it, so + # the previous one must be removed first, preserving app data. + Assert-True (@($f.Removals).Count -eq 1 -and @($f.PreserveFlags)[0] -eq $true) 'Re-registration did not first remove the existing development registration.' + + # A source change must reach the registered package. + $f.Now = $f.Now.AddMinutes(5) + $f.HostVersion = 2 + $changed = Invoke-Fixture $f + Assert-True ($changed.Changed) 'A launcher change was not detected.' + Assert-True ((Get-Content (Join-Path $layout 'openclaw.exe') -Raw) -eq 'host 2') 'The layout kept a stale launcher.' + + # Payload refresh replaces content and retires the old generation only on success. + $f.Offline = $false + $f.PayloadText = 'refreshed payload' + $refreshed = Invoke-Fixture $f @{ RefreshPayload = $true } + Assert-True ($refreshed.Changed) 'Refresh reported no change.' + Assert-True ((Get-Content (Join-Path $layout 'app\openclaw.mjs') -Raw) -eq 'refreshed payload') 'Refresh did not repoint the layout at new content.' + Assert-True ($f.Downloads -eq 2) 'Refresh did not download.' + $keptPayload = @(Get-ChildItem (Join-Path $f.Root 'artifacts\local-package\x64\payloads') -Directory).Count + Assert-True ($keptPayload -eq 1) 'A successful refresh did not retire the superseded payload.' + + # A refresh that fails to register must keep the previous payload selected. + $f.PayloadText = 'never registered' + $f.RegisterFailure = $true + $selectionPath = Join-Path $f.Root 'artifacts\local-package\x64\payloads\current.json' + $selectedBefore = (Get-Content $selectionPath -Raw | ConvertFrom-Json).generation + Assert-Fails { Invoke-Fixture $f @{ RefreshPayload = $true } } '0x80073CFB' + Assert-True (@(Get-ChildItem (Join-Path $f.Root 'artifacts\local-package\x64\payloads') -Directory).Count -eq 2) 'A failed registration discarded the previous payload.' + Assert-True ((Get-Content $selectionPath -Raw | ConvertFrom-Json).generation -eq $selectedBefore) 'A failed deployment left the unusable payload selected.' + $f.RegisterFailure = $false + + # Conflicting packaged install. + $g = New-Fixture + $g.Installed = [pscustomobject]@{ + Version = '1.2.3.4'; PackageFullName = 'OpenClaw.Gateway_1.2.3.4_x64__pkg' + InstallLocation = 'C:\Program Files\WindowsApps\fake'; IsDevelopmentMode = $false; Status = 'Ok' + } + Assert-Fails { Invoke-Fixture $g } 'already installed from a package' + Assert-True ($g.Registrations -eq 0 -and @($g.Removals).Count -eq 0) 'A conflicting packaged install was touched without consent.' + $replaced = Invoke-Fixture $g @{ ReplaceExistingInstall = $true } + Assert-True ($g.Removals -contains 'OpenClaw.Gateway_1.2.3.4_x64__pkg' -and $replaced.Changed) 'Explicit replacement did not remove the packaged install.' + # Removal happens first, so the dev build need not out-version the package it + # replaced; staying on 0.1.x keeps a later real release installable. + Assert-True ([version]$replaced.Version -lt [version]'1.0.0.0') 'A replacement build should not claim a release-range version.' + + # Failure paths stop before registering. + $h = New-Fixture + $h.PublishFailure = $true + Assert-Fails { Invoke-Fixture $h } 'publish failed' + Assert-True ($h.Registrations -eq 0) 'A failed publish still registered.' + $h.PublishFailure = $false + $h.SkipHost = $true + Assert-Fails { Invoke-Fixture $h } 'did not produce' + Assert-True ($h.Registrations -eq 0) 'A missing launcher still registered.' + $h.SkipHost = $false + $h.BadRegistration = $true + Assert-Fails { Invoke-Fixture $h } 'healthy local development build' + $h.BadRegistration = $false + $h.SetupFailure = $true + Assert-Fails { Invoke-Fixture $h } 'clawctl setup failed' + $h.SetupFailure = $false + $h.HostVersion = 99 + $setupsBefore = $h.Setups + $skipped = Invoke-Fixture $h @{ SkipSetup = $true } + Assert-True ($skipped.Changed) '-SkipSetup did not deploy.' + Assert-True ($h.Setups -eq $setupsBefore) '-SkipSetup still prepared the runtime.' + + $i = New-Fixture + $i.DownloadFailure = $true + Assert-Fails { Invoke-Fixture $i } 'Interrupted payload download' + Assert-True ($i.Registrations -eq 0) 'A failed download still registered.' + $i.Offline = $true + Assert-Fails { Invoke-Fixture $i } 'unavailable|cache' + + # Argument guards. + $j = New-Fixture + $external = Join-Path $testRoot 'supplied payload with spaces' + & $j.WritePayload $external 'x64' 'supplied' '24.20.0' + $supplied = Invoke-Fixture $j @{ PayloadDirectory = $external } + Assert-True ($j.Downloads -eq 0 -and $j.Queries -eq 0) 'A supplied payload still contacted GitHub.' + Assert-True ((Get-Content (Join-Path $supplied.LayoutDirectory 'app\openclaw.mjs') -Raw) -eq 'supplied') 'A supplied payload was not used.' + Assert-Fails { Invoke-Fixture $j @{ PayloadDirectory = $external; RefreshPayload = $true } } 'cannot be combined' + Assert-Fails { Invoke-Fixture $j @{ PayloadDirectory = $external; PayloadRunId = [long]7 } } 'cannot be combined' + Assert-Fails { Invoke-Fixture $j @{ PayloadRunId = [long]-1 } } 'positive workflow run' + Assert-Fails { + Invoke-LocalPackageDeployment -RepositoryRoot $j.Root -Operations @{ NotAnOperation = { } } + } 'Unknown or invalid local package operation adapter' + Assert-Fails { + Invoke-LocalPackageDeployment -RepositoryRoot $j.Root -Operations @{ Preflight = 'not a scriptblock' } + } 'Unknown or invalid local package operation adapter' + + # Unregister. + $k = New-Fixture + $deployed = Invoke-Fixture $k + Remove-LocalPackageRegistration -RepositoryRoot $k.Root -Operations $k.Operations + Assert-True ($k.Removals -contains $deployed.PackageFullName) 'Unregister did not remove the local registration.' + Assert-True ($k.PreserveFlags -contains $true) 'Unregister discarded the package app data it could have preserved.' + Assert-True (-not (Test-Path (Join-Path $k.Root 'artifacts\local-package\x64\state.json'))) 'Unregister left deployment state behind.' + Assert-True (Test-Path (Join-Path $k.Root 'artifacts\local-package\x64\payloads')) 'Unregister discarded the payload cache.' + $k.Installed = [pscustomobject]@{ + Version = '1.0.0.0'; PackageFullName = 'pkg'; InstallLocation = 'x' + IsDevelopmentMode = $false; Status = 'Ok' + } + Assert-Fails { Remove-LocalPackageRegistration -RepositoryRoot $k.Root -Operations $k.Operations } 'not a local layout' + + # Version selection. + Assert-True ((Get-LocalPackageNextVersion -InstalledVersion '2026.1.0.65535' -Now ([datetime]'2026-09-14')) -eq '2026.1.1.0') 'Revision rollover failed.' + Assert-Fails { Get-LocalPackageNextVersion -InstalledVersion '65535.65535.65535.65535' } 'version space is exhausted' + Assert-Fails { Get-LocalPackageNextVersion -InstalledVersion '1.2.3' } 'Invalid MSIX version' + # A failed or skipped setup must not be recorded as a complete deployment. + $s = New-Fixture + $s.SetupFailure = $true + Assert-Fails { Invoke-Fixture $s } 'clawctl setup failed' + $s.SetupFailure = $false + $recovered = Invoke-Fixture $s + Assert-True ($recovered.Changed -and $s.Setups -ge 2) 'A run after a failed setup was short-circuited as up to date.' + $afterRecovery = Invoke-Fixture $s + Assert-True (-not $afterRecovery.Changed) 'A completed deployment did not settle.' + + $sk = New-Fixture + $skipDeploy = Invoke-Fixture $sk @{ SkipSetup = $true } + Assert-True ($sk.Setups -eq 0 -and $skipDeploy.Changed) '-SkipSetup prepared the runtime.' + $afterSkip = Invoke-Fixture $sk + Assert-True ($afterSkip.Changed -and $sk.Setups -eq 1) 'A run after -SkipSetup did not complete the setup it skipped.' + + # A damaged live layout must not be reported as up to date. + foreach ($break in @('openclaw.exe', 'Images', 'app', 'runtime')) { + $d = New-Fixture + $deployed = Invoke-Fixture $d + $target = Join-Path $deployed.LayoutDirectory $break + Remove-Item -LiteralPath $target -Recurse -Force + $repaired = Invoke-Fixture $d + Assert-True ($repaired.Changed) "A layout missing '$break' was reported as up to date." + Assert-True (Test-Path -LiteralPath $target) "A layout missing '$break' was not repaired." + } + + # The layout runtime copy is replaced, not trusted by name. + $c = New-Fixture + $cDeployed = Invoke-Fixture $c + $archive = Get-ChildItem (Join-Path $cDeployed.LayoutDirectory 'runtime') -File | Select-Object -First 1 + [IO.File]::WriteAllText($archive.FullName, 'truncated') + $c.HostVersion = 2 + Invoke-Fixture $c | Out-Null + Assert-True ((Get-Content $archive.FullName -Raw) -ne 'truncated') 'A corrupt layout runtime archive survived redeployment.' + + # A development registration owned by another location is not taken over. + $o = New-Fixture + $o.Installed = [pscustomobject]@{ + Version = '0.1.0.0'; PackageFullName = 'OpenClaw.Gateway_0.1.0.0_x64__other' + InstallLocation = (Join-Path $testRoot 'someone elses layout') + IsDevelopmentMode = $true; Status = 'Ok' + } + Assert-Fails { Invoke-Fixture $o } 'registered from another location' + Assert-True ($o.Registrations -eq 0 -and @($o.Removals).Count -eq 0) 'A foreign registration was touched without consent.' + Assert-Fails { + Remove-LocalPackageRegistration -RepositoryRoot $o.Root -Operations $o.Operations + } 'does not own that registration' + $takenOver = Invoke-Fixture $o @{ ReplaceExistingInstall = $true } + Assert-True ($takenOver.Changed) 'Explicit take-over did not register.' + + # A foreign registration whose version matches this checkout's retained + # state satisfies every other up-to-date condition, so only an ownership + # check stops the short circuit from silently skipping the takeover. + $fo = New-Fixture + $mine = Invoke-Fixture $fo + $foreignLayout = Join-Path $testRoot 'other checkout layout' + New-Item -Path $foreignLayout -ItemType Directory -Force | Out-Null + $fo.Installed = [pscustomobject]@{ + Version = $mine.Version + PackageFullName = "OpenClaw.Gateway_$($mine.Version)_x64__other" + InstallLocation = $foreignLayout + IsDevelopmentMode = $true + Status = 'Ok' + } + Assert-Fails { Invoke-Fixture $fo } 'registered from another location' + $registrationsBefore = $fo.Registrations + $reclaimed = Invoke-Fixture $fo @{ ReplaceExistingInstall = $true } + Assert-True ($reclaimed.Changed) 'A same-version foreign registration was reported as up to date.' + Assert-True ($fo.Registrations -eq $registrationsBefore + 1) 'Take-over did not actually register.' + Assert-True ( + [IO.Path]::GetFullPath($fo.Installed.InstallLocation).TrimEnd('\') -ieq + [IO.Path]::GetFullPath($mine.LayoutDirectory).TrimEnd('\') + ) 'The aliases still resolve to the other checkout after take-over.' + + Write-Host 'Local package deployment scenarios passed.' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} From 3e5b1fff078e3ed46e9d2598835870a4e456717a Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 15 Sep 2026 14:00:40 -0700 Subject: [PATCH 43/76] chore(release): prepare Gateway MSIX v0.0.0.1 (#56) --- release-policy.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release-policy.json b/release-policy.json index 6bc2f234..33c1adb7 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,7 +1,7 @@ { "repository": "https://github.com/openclaw/openclaw", - "releaseTag": "v0.0.0.0", - "packageVersion": "0.0.0.0", + "releaseTag": "v0.0.0.1", + "packageVersion": "0.0.0.1", "payloadPackageVersion": "2026.8.2", "approvedCommit": "0965053fe6b9341776df147a6934b7485c60b5ca", "publisher": "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" From 5134565e06fc9ce72ecefa0605b5eefb8b9dc82d Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Tue, 15 Sep 2026 17:14:08 -0700 Subject: [PATCH 44/76] Use Cascadia Code for plugin commands Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- plugins/gateway-isolation/index.js | 4 ++-- plugins/gateway-isolation/index.test.js | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index dfe6423c..54522241 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -77,7 +77,7 @@ export function renderGatewayIsolationPage(mode) { :root { color-scheme: light dark; --font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + --font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Liberation Mono", monospace; --radius: 10px; --radius-full: 9999px; --bg: #ffffff; @@ -284,7 +284,7 @@ function renderGatewayIsolationUnavailablePage() { :root { color-scheme: light dark; --font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + --font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Liberation Mono", monospace; --radius: 10px; --radius-full: 9999px; --bg: #ffffff; diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index bc4644b0..10c3db1b 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -140,6 +140,10 @@ for (const expected of [ assert.match(html, /Copy the selected command manually\./); assert.match(html, /copied = document\.execCommand\("copy"\)/); assert.match(html, /openclaw:widget-theme/); + assert.match( + html, + /--font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Liberation Mono", monospace;/, + ); assert.doesNotMatch(html, /next manual Gateway restart/i); }); } @@ -265,6 +269,10 @@ for (const initial of ["enabled", "disabled", undefined, "", "invalid", "ENABLED assert.equal(first.statusCode, initial === "enabled" || initial === "disabled" ? 200 : 503); if (initial === "enabled") assert.match(first.body, />EnabledDisabled Date: Tue, 15 Sep 2026 17:28:11 -0700 Subject: [PATCH 45/76] perf: cancel superseded pull request runs (#57) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3d515fe-cd93-465c-8bae-6686c73c1320 --- .github/workflows/gateway-msix.yml | 4 ++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 812a1075..73a8c005 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -22,6 +22,10 @@ on: - test - official +concurrency: + group: gateway-msix-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index d1f553d4..fba64fcf 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -11,6 +11,8 @@ $workflowPath = Join-Path ` $workflow = Get-Content -LiteralPath $workflowPath -Raw $requiredFragments = @( + 'group: gateway-msix-${{ github.event.pull_request.number || github.run_id }}' + "cancel-in-progress: `${{ github.event_name == 'pull_request' }}" 'environment: release-signing' 'id-token: write' 'uses: azure/login@v3' From 306df11af13ae841c13e8e44d917c72310136bce Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 15 Sep 2026 17:28:12 -0700 Subject: [PATCH 46/76] ci: skip packaging for documentation-only changes (#58) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3d515fe-cd93-465c-8bae-6686c73c1320 --- .github/workflows/gateway-msix.yml | 69 ++++++++++ CONTRIBUTING.md | 1 + scripts/Get-PackagingRelevance.ps1 | 71 ++++++++++ scripts/Test-PackagingRelevance.Tests.ps1 | 127 ++++++++++++++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 8 ++ 5 files changed, 276 insertions(+) create mode 100644 scripts/Get-PackagingRelevance.ps1 create mode 100644 scripts/Test-PackagingRelevance.Tests.ps1 diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 73a8c005..cc18d2e4 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -28,12 +28,46 @@ concurrency: permissions: contents: read + pull-requests: read env: OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '0965053fe6b9341776df147a6934b7485c60b5ca' }} PACKAGING_ROOT: . jobs: + changes: + name: Classify pull request changes + runs-on: ubuntu-latest + outputs: + packaging: ${{ steps.filter.outputs.packaging }} + steps: + - uses: actions/checkout@v6 + + - name: Detect packaging changes + id: filter + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + if ($env:GITHUB_EVENT_NAME -ne 'pull_request') { + 'packaging=true' >> $env:GITHUB_OUTPUT + return + } + + $fileListPath = Join-Path $env:RUNNER_TEMP 'pull-request-files.json' + & gh api ` + --paginate ` + --slurp ` + "repos/$env:GITHUB_REPOSITORY/pulls/${{ github.event.pull_request.number }}/files" ` + > $fileListPath + if ($LASTEXITCODE -ne 0) { + throw 'Unable to enumerate pull request files.' + } + + $packaging = .\scripts\Get-PackagingRelevance.ps1 ` + -FileListPath $fileListPath + "packaging=$packaging" >> $env:GITHUB_OUTPUT + test-host: name: Test Gateway MSIX host runs-on: windows-latest @@ -105,6 +139,11 @@ jobs: run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test packaging relevance + shell: pwsh + run: > + .\scripts\Test-PackagingRelevance.Tests.ps1 + - name: Test local package deployment shell: pwsh run: > @@ -122,6 +161,8 @@ jobs: build-package: name: Build OpenClaw npm package + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.packaging == 'true' }} + needs: changes runs-on: ubuntu-latest outputs: source_sha: ${{ steps.source.outputs.sha }} @@ -715,3 +756,31 @@ jobs: - **ARM64:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}-arm64.msix` The packages are signed by OpenClaw Foundation through Azure Artifact Signing. + + ci-gate: + name: Gateway MSIX CI + if: ${{ always() }} + needs: + - changes + - test-host + - build-package + - build-payload + - build-msix + - test-sign-msix + - build-msix-bundle + - reject-untrusted-official-signing + - authorize-signing + - sign-msix + - publish-release + runs-on: ubuntu-latest + steps: + - name: Check workflow results + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + shell: bash + run: | + echo "One or more required workflow jobs failed or were cancelled." >&2 + exit 1 + + - name: Confirm workflow success + shell: bash + run: echo "All required workflow jobs succeeded or were intentionally skipped." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26f26f68..b5550336 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,7 @@ or package version logic: ```powershell .\scripts\Test-SigningInputs.Tests.ps1 .\scripts\Test-NodeRuntimeInputs.Tests.ps1 +.\scripts\Test-PackagingRelevance.Tests.ps1 .\scripts\Test-WorkflowPackageVersion.Tests.ps1 .\scripts\Test-GitHooks.Tests.ps1 ``` diff --git a/scripts/Get-PackagingRelevance.ps1 b/scripts/Get-PackagingRelevance.ps1 new file mode 100644 index 00000000..caba0066 --- /dev/null +++ b/scripts/Get-PackagingRelevance.ps1 @@ -0,0 +1,71 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$FileListPath, + + [ValidateRange(1, 3000)] + [int]$MaximumFiles = 3000 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $FileListPath -PathType Leaf)) { + throw "Pull request file list does not exist: $FileListPath" +} + +try { + $pages = @( + Get-Content -LiteralPath $FileListPath -Raw | + ConvertFrom-Json + ) +} +catch { + throw "Unable to parse the pull request file list: $($_.Exception.Message)" +} + +$files = @( + foreach ($page in $pages) { + foreach ($file in @($page)) { + if ($null -eq $file -or + $file.PSObject.Properties.Name -notcontains 'filename' -or + [string]::IsNullOrWhiteSpace([string]$file.filename)) { + throw 'The pull request file list contains an invalid entry.' + } + $file + } + } +) +if ($files.Count -eq 0) { + throw 'The pull request file list was empty.' +} + +# GitHub returns at most 3,000 files. At the cap there is no proof that the +# response is complete, so conservatively run packaging. +if ($files.Count -ge $MaximumFiles) { + return 'true' +} + +foreach ($file in $files) { + $previousFilename = if ( + $file.PSObject.Properties.Name -contains 'previous_filename' + ) { + [string]$file.previous_filename + } + else { + '' + } + foreach ($path in @( + [string]$file.filename + $previousFilename + )) { + if ([string]::IsNullOrWhiteSpace($path)) { + continue + } + if ($path -notmatch '^(?:.*\.md|docs/.*|LICENSE)$') { + return 'true' + } + } +} + +'false' diff --git a/scripts/Test-PackagingRelevance.Tests.ps1 b/scripts/Test-PackagingRelevance.Tests.ps1 new file mode 100644 index 00000000..9f91d998 --- /dev/null +++ b/scripts/Test-PackagingRelevance.Tests.ps1 @@ -0,0 +1,127 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Get-PackagingRelevance.ps1' +$testRoot = Join-Path $env:TEMP ( + "openclaw-packaging-relevance-$([guid]::NewGuid().ToString('N'))" +) + +function Write-FileList { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Files, + + [string]$Name = 'files.json' + ) + + $path = Join-Path $testRoot $Name + ,$Files | + ConvertTo-Json -Depth 3 | + Set-Content -LiteralPath $path -Encoding utf8 + $path +} + +function Assert-Relevance { + param( + [Parameter(Mandatory)] + [object[]]$Files, + + [Parameter(Mandatory)] + [ValidateSet('true', 'false')] + [string]$Expected, + + [int]$MaximumFiles = 3000 + ) + + $path = Write-FileList -Files $Files + $actual = & $scriptPath ` + -FileListPath $path ` + -MaximumFiles $MaximumFiles + if ($actual -cne $Expected) { + throw "Expected packaging relevance $Expected; received $actual." + } +} + +function Assert-Fails { + param( + [Parameter(Mandatory)] + [scriptblock]$Action, + + [Parameter(Mandatory)] + [string]$MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "Expected failure matching '$MessagePattern'; received: " + + $_.Exception.Message + ) + } + return + } + throw "Expected failure matching '$MessagePattern', but the action succeeded." +} + +New-Item -Path $testRoot -ItemType Directory | Out-Null +try { + Assert-Relevance -Expected false -Files @( + @{ filename = 'README.md' } + @{ filename = 'docs/setup.md' } + @{ filename = 'LICENSE' } + ) + Assert-Relevance -Expected true -Files @( + @{ filename = 'README.md' } + @{ filename = 'scripts/Build-MSIX.ps1' } + ) + Assert-Relevance -Expected true -Files @( + @{ + filename = 'docs/removed-script.md' + previous_filename = 'scripts/Build-MSIX.ps1' + } + ) + + $cappedFiles = @( + for ($index = 0; $index -lt 3; $index++) { + @{ filename = "docs/$index.md" } + } + ) + Assert-Relevance ` + -Expected true ` + -Files $cappedFiles ` + -MaximumFiles 3 + + $emptyPath = Write-FileList -Files @() -Name 'empty.json' + Assert-Fails -MessagePattern 'empty' -Action { + & $scriptPath -FileListPath $emptyPath + } + + $malformedPath = Join-Path $testRoot 'malformed.json' + Set-Content -LiteralPath $malformedPath -Value '{' + Assert-Fails -MessagePattern 'Unable to parse' -Action { + & $scriptPath -FileListPath $malformedPath + } + + Assert-Fails -MessagePattern 'does not exist' -Action { + & $scriptPath -FileListPath (Join-Path $testRoot 'missing.json') + } + + $invalidPath = Write-FileList ` + -Files @(@{ status = 'modified' }) ` + -Name 'invalid.json' + Assert-Fails -MessagePattern 'invalid entry' -Action { + & $scriptPath -FileListPath $invalidPath + } +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host 'Packaging relevance tests passed.' diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index fba64fcf..1e556586 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -13,6 +13,14 @@ $workflow = Get-Content -LiteralPath $workflowPath -Raw $requiredFragments = @( 'group: gateway-msix-${{ github.event.pull_request.number || github.run_id }}' "cancel-in-progress: `${{ github.event_name == 'pull_request' }}" + 'name: Classify pull request changes' + '.\scripts\Get-PackagingRelevance.ps1' + 'name: Test packaging relevance' + "if: `${{ github.event_name != 'pull_request' || needs.changes.outputs.packaging == 'true' }}" + 'name: Gateway MSIX CI' + "if: `${{ always() }}" + "contains(needs.*.result, 'failure')" + "contains(needs.*.result, 'cancelled')" 'environment: release-signing' 'id-token: write' 'uses: azure/login@v3' From fec25e0f95fb6938854c9dfdcab8a7107edfd266 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 15 Sep 2026 17:28:12 -0700 Subject: [PATCH 47/76] refactor: compose payload and MSIX in one job (#59) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3d515fe-cd93-465c-8bae-6686c73c1320 --- .github/copilot-instructions.md | 7 ++-- .github/workflows/gateway-msix.yml | 35 ++++--------------- scripts/Test-WorkflowSigningConfiguration.ps1 | 16 +++++++++ 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c660ed5..27befc6f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,9 +103,10 @@ package. - The GitHub workflow first builds and packs a pinned `openclaw/openclaw` revision on Linux using that revision's `setup-node-env` action. The resolved Node.js version flows through `source.json` and `payload-metadata.json`; - Windows payload builds use the same version. `Build-MSIX.ps1` downloads its - matching official archive, rejects Node.js from the application payload, - builds the application inventory, publishes the NativeAOT host, validates + each architecture-specific Windows job uses that same version to build the + expanded payload and immediately compose its MSIX. `Build-MSIX.ps1` downloads + the matching official archive, rejects Node.js from the application payload, + builds the application inventory, and publishes the NativeAOT host, validates package contents, and emits MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index cc18d2e4..0610c4bc 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -251,9 +251,11 @@ jobs: if-no-files-found: error retention-days: 7 - build-payload: - name: Build ${{ matrix.architecture }} gateway payload - needs: build-package + build-msix: + name: Build unsigned ${{ matrix.architecture }} Gateway MSIX + needs: + - test-host + - build-package runs-on: windows-latest strategy: fail-fast: false @@ -292,25 +294,7 @@ jobs: name: openclaw-gateway-payload-${{ matrix.architecture }} path: ${{ runner.temp }}\openclaw-payload\ if-no-files-found: error - retention-days: 7 - - build-msix: - name: Build unsigned ${{ matrix.architecture }} Gateway MSIX - needs: - - test-host - - build-payload - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - architecture: - - x64 - - arm64 - steps: - - name: Check out repository - uses: actions/checkout@v7 - with: - persist-credentials: false + retention-days: ${{ github.event_name == 'pull_request' && 1 || 7 }} - name: Set up .NET uses: actions/setup-dotnet@v6 @@ -319,12 +303,6 @@ jobs: cache: true cache-dependency-path: Directory.Packages.props - - name: Download payload - uses: actions/download-artifact@v8 - with: - name: openclaw-gateway-payload-${{ matrix.architecture }} - path: ${{ runner.temp }}\openclaw-payload - - name: Restore NativeAOT and MSIX dependencies shell: pwsh run: | @@ -764,7 +742,6 @@ jobs: - changes - test-host - build-package - - build-payload - build-msix - test-sign-msix - build-msix-bundle diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 1e556586..60b6ec18 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -21,6 +21,8 @@ $requiredFragments = @( "if: `${{ always() }}" "contains(needs.*.result, 'failure')" "contains(needs.*.result, 'cancelled')" + 'name: Upload payload' + "retention-days: `${{ github.event_name == 'pull_request' && 1 || 7 }}" 'environment: release-signing' 'id-token: write' 'uses: azure/login@v3' @@ -54,6 +56,20 @@ foreach ($fragment in $requiredFragments) { } } +$buildMsixJobMatch = [regex]::Match( + $workflow, + '(?ms)^ build-msix:\s*(?.*?)(?=^ [a-z][a-z0-9-]+:)' +) +if (-not $buildMsixJobMatch.Success) { + throw 'Unable to locate the build-msix workflow job.' +} +$buildMsixJob = $buildMsixJobMatch.Groups['job'].Value +if ($buildMsixJob.Contains( + 'name: Download payload', + [StringComparison]::Ordinal)) { + throw 'The build-msix job must compose the locally built payload directly.' +} + if ($workflow.Contains('AZURE_CLIENT_SECRET', [StringComparison]::Ordinal)) { throw 'Signing workflow must use OIDC, not an Azure client secret.' } From c4e0aa331cc24732b17a3ecfccc12fbbe5912560 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 15 Sep 2026 17:28:12 -0700 Subject: [PATCH 48/76] feat: resolve immutable upstream source for caching (#60) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3d515fe-cd93-465c-8bae-6686c73c1320 --- .github/workflows/gateway-msix.yml | 34 +++++++- scripts/Get-OpenClawCacheKey.ps1 | 55 ++++++++++++ scripts/Test-OpenClawCacheKey.Tests.ps1 | 87 +++++++++++++++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 30 +++++++ 4 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 scripts/Get-OpenClawCacheKey.ps1 create mode 100644 scripts/Test-OpenClawCacheKey.Tests.ps1 diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 0610c4bc..1c9ec626 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -144,6 +144,11 @@ jobs: run: > .\scripts\Test-PackagingRelevance.Tests.ps1 + - name: Test OpenClaw cache keys + shell: pwsh + run: > + .\scripts\Test-OpenClawCacheKey.Tests.ps1 + - name: Test local package deployment shell: pwsh run: > @@ -165,7 +170,8 @@ jobs: needs: changes runs-on: ubuntu-latest outputs: - source_sha: ${{ steps.source.outputs.sha }} + source_sha: ${{ steps.resolve.outputs.sha }} + package_cache_key: ${{ steps.resolve.outputs.package_cache_key }} package_version: ${{ steps.source.outputs.version }} node_version: ${{ steps.source.outputs.node_version }} steps: @@ -174,6 +180,30 @@ jobs: with: persist-credentials: false + - name: Resolve immutable OpenClaw source + id: resolve + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $shaLines = @( + gh api ` + "repos/openclaw/openclaw/commits/$env:OPENCLAW_REF" ` + --jq .sha + ) + if ($LASTEXITCODE -ne 0) { + throw "Unable to resolve OpenClaw ref '$env:OPENCLAW_REF'." + } + $sha = [string]::Join('', [string[]]$shaLines).Trim().ToLowerInvariant() + if ($sha -notmatch '^[0-9a-f]{40}$') { + throw "OpenClaw ref resolved to an invalid commit SHA: '$sha'." + } + $cacheKey = .\scripts\Get-OpenClawCacheKey.ps1 ` + -Layer package ` + -Commit $sha + "sha=$sha" >> $env:GITHUB_OUTPUT + "package_cache_key=$cacheKey" >> $env:GITHUB_OUTPUT + # The OpenClaw checkout below must own GITHUB_WORKSPACE because its # composite setup action resolves workspace files from that root. Keep # the packaging-owned validator outside the workspace before replacing @@ -189,7 +219,7 @@ jobs: uses: actions/checkout@v7 with: repository: openclaw/openclaw - ref: ${{ env.OPENCLAW_REF }} + ref: ${{ steps.resolve.outputs.sha }} persist-credentials: false fetch-depth: 1 diff --git a/scripts/Get-OpenClawCacheKey.ps1 b/scripts/Get-OpenClawCacheKey.ps1 new file mode 100644 index 00000000..13e656bb --- /dev/null +++ b/scripts/Get-OpenClawCacheKey.ps1 @@ -0,0 +1,55 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('package', 'payload')] + [string]$Layer, + + [Parameter(Mandatory)] + [string]$Commit, + + [ValidateSet('x64', 'arm64')] + [string]$Architecture, + + [string]$NodeVersion, + + [string]$PayloadScriptPath = ( + Join-Path $PSScriptRoot 'Build-Payload.ps1' + ), + + [ValidateRange(1, 999)] + [int]$FormatVersion = 1 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$normalizedCommit = $Commit.Trim().ToLowerInvariant() +if ($normalizedCommit -notmatch '^[0-9a-f]{40}$') { + throw "Commit '$Commit' must be a full 40-character hexadecimal SHA." +} + +if ($Layer -eq 'package') { + return "openclaw-package-v$FormatVersion-$normalizedCommit" +} + +if ([string]::IsNullOrWhiteSpace($Architecture)) { + throw 'Architecture is required for a payload cache key.' +} +if ($NodeVersion -notmatch '^\d+\.\d+\.\d+$') { + throw "NodeVersion '$NodeVersion' must contain three numeric components." +} +if (-not (Test-Path -LiteralPath $PayloadScriptPath -PathType Leaf)) { + throw "Payload script does not exist: $PayloadScriptPath" +} + +$scriptHash = ( + Get-FileHash -LiteralPath $PayloadScriptPath -Algorithm SHA256 +).Hash.ToLowerInvariant() + +@( + "openclaw-payload-v$FormatVersion" + $Architecture + $normalizedCommit + "node-$NodeVersion" + $scriptHash +) -join '-' diff --git a/scripts/Test-OpenClawCacheKey.Tests.ps1 b/scripts/Test-OpenClawCacheKey.Tests.ps1 new file mode 100644 index 00000000..8559d2c9 --- /dev/null +++ b/scripts/Test-OpenClawCacheKey.Tests.ps1 @@ -0,0 +1,87 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Get-OpenClawCacheKey.ps1' +$commitA = '1111111111111111111111111111111111111111' +$commitB = '2222222222222222222222222222222222222222' +$testRoot = Join-Path $env:TEMP ( + "openclaw-cache-key-$([guid]::NewGuid().ToString('N'))" +) + +function Assert-NotEqual { + param( + [Parameter(Mandatory)] + [string]$Left, + + [Parameter(Mandatory)] + [string]$Right, + + [Parameter(Mandatory)] + [string]$Reason + ) + + if ($Left -ceq $Right) { + throw "Expected different cache keys: $Reason" + } +} + +New-Item -Path $testRoot -ItemType Directory | Out-Null +try { + $payloadScript = Join-Path $testRoot 'Build-Payload.ps1' + Set-Content -LiteralPath $payloadScript -Value 'Write-Output first' + + $packageA = & $scriptPath -Layer package -Commit $commitA + $packageARepeat = & $scriptPath -Layer package -Commit $commitA + if ($packageA -cne $packageARepeat) { + throw 'The same package inputs did not produce a stable cache key.' + } + + $packageB = & $scriptPath -Layer package -Commit $commitB + Assert-NotEqual $packageA $packageB 'distinct commits' + + $packageV2 = & $scriptPath ` + -Layer package ` + -Commit $commitA ` + -FormatVersion 2 + Assert-NotEqual $packageA $packageV2 'package cache format version' + + $payloadX64 = & $scriptPath ` + -Layer payload ` + -Commit $commitA ` + -Architecture x64 ` + -NodeVersion 24.20.0 ` + -PayloadScriptPath $payloadScript + $payloadArm64 = & $scriptPath ` + -Layer payload ` + -Commit $commitA ` + -Architecture arm64 ` + -NodeVersion 24.20.0 ` + -PayloadScriptPath $payloadScript + Assert-NotEqual $payloadX64 $payloadArm64 'distinct architectures' + + Set-Content -LiteralPath $payloadScript -Value 'Write-Output second' + $payloadAfterEdit = & $scriptPath ` + -Layer payload ` + -Commit $commitA ` + -Architecture x64 ` + -NodeVersion 24.20.0 ` + -PayloadScriptPath $payloadScript + Assert-NotEqual $payloadX64 $payloadAfterEdit 'payload script edit' + + $payloadV2 = & $scriptPath ` + -Layer payload ` + -Commit $commitA ` + -Architecture x64 ` + -NodeVersion 24.20.0 ` + -PayloadScriptPath $payloadScript ` + -FormatVersion 2 + Assert-NotEqual $payloadAfterEdit $payloadV2 'payload cache format version' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host 'OpenClaw cache key tests passed.' diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 60b6ec18..b9c7e4b4 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -70,6 +70,36 @@ if ($buildMsixJob.Contains( throw 'The build-msix job must compose the locally built payload directly.' } +$dispatchDefaultMatch = [regex]::Match( + $workflow, + '(?ms)openclaw_ref:\s+description:.*?default:\s*(?[0-9a-f]{40})' +) +$automaticFallbackMatch = [regex]::Match( + $workflow, + "OPENCLAW_REF:.*?\|\|\s*'(?[0-9a-f]{40})'" +) +if (-not $dispatchDefaultMatch.Success -or -not $automaticFallbackMatch.Success) { + throw 'Unable to locate both pinned OpenClaw workflow revisions.' +} + +$releasePolicy = Get-Content ` + -LiteralPath (Join-Path $repositoryRoot 'release-policy.json') ` + -Raw | + ConvertFrom-Json +$pinnedRevisions = @( + @( + $dispatchDefaultMatch.Groups['sha'].Value + $automaticFallbackMatch.Groups['sha'].Value + [string]$releasePolicy.approvedCommit + ) | Select-Object -Unique +) +if ($pinnedRevisions.Count -ne 1) { + throw ( + 'The workflow defaults and official release policy must pin the same ' + + "OpenClaw commit; found: $($pinnedRevisions -join ', ')." + ) +} + if ($workflow.Contains('AZURE_CLIENT_SECRET', [StringComparison]::Ordinal)) { throw 'Signing workflow must use OIDC, not an Azure client secret.' } From 8d5e0b5605373b87019357e09785eebda544ae96 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 15 Sep 2026 17:28:13 -0700 Subject: [PATCH 49/76] perf: cache verified upstream package builds (#61) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3d515fe-cd93-465c-8bae-6686c73c1320 --- .github/copilot-instructions.md | 8 +- .github/workflows/gateway-msix.yml | 66 ++++++++++--- CONTRIBUTING.md | 2 + README.md | 5 + scripts/Test-OpenClawPackage.Tests.ps1 | 92 +++++++++++++++++++ scripts/Test-OpenClawPackage.ps1 | 46 ++++++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 6 ++ 7 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 scripts/Test-OpenClawPackage.Tests.ps1 create mode 100644 scripts/Test-OpenClawPackage.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 27befc6f..6d178283 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -101,12 +101,14 @@ package. `%LOCALAPPDATA%\OpenClawGatewayMSIX` outside an MSIX context) with a named mutex so concurrent processes append complete records. - The GitHub workflow first builds and packs a pinned `openclaw/openclaw` - revision on Linux using that revision's `setup-node-env` action. The resolved - Node.js version flows through `source.json` and `payload-metadata.json`; + revision on Linux using that revision's `setup-node-env` action. Non-official + runs cache that tarball by resolved upstream commit and verify its recorded + commit and SHA-256 on every use; official signing always rebuilds it. The + resolved Node.js version flows through `source.json` and `payload-metadata.json`; each architecture-specific Windows job uses that same version to build the expanded payload and immediately compose its MSIX. `Build-MSIX.ps1` downloads the matching official archive, rejects Node.js from the application payload, - builds the application inventory, and publishes the NativeAOT host, validates + builds the application inventory, publishes the NativeAOT host, validates package contents, and emits MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 1c9ec626..69ad4547 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -149,6 +149,11 @@ jobs: run: > .\scripts\Test-OpenClawCacheKey.Tests.ps1 + - name: Test OpenClaw package verification + shell: pwsh + run: > + .\scripts\Test-OpenClawPackage.Tests.ps1 + - name: Test local package deployment shell: pwsh run: > @@ -172,8 +177,8 @@ jobs: outputs: source_sha: ${{ steps.resolve.outputs.sha }} package_cache_key: ${{ steps.resolve.outputs.package_cache_key }} - package_version: ${{ steps.source.outputs.version }} - node_version: ${{ steps.source.outputs.node_version }} + package_version: ${{ steps.package.outputs.version }} + node_version: ${{ steps.package.outputs.node_version }} steps: - name: Check out repository uses: actions/checkout@v7 @@ -204,18 +209,30 @@ jobs: "sha=$sha" >> $env:GITHUB_OUTPUT "package_cache_key=$cacheKey" >> $env:GITHUB_OUTPUT + - name: Restore cached OpenClaw package + id: package-cache + if: ${{ github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official' }} + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/openclaw-package + key: ${{ steps.resolve.outputs.package_cache_key }} + # The OpenClaw checkout below must own GITHUB_WORKSPACE because its # composite setup action resolves workspace files from that root. Keep # the packaging-owned validator outside the workspace before replacing # this checkout with the OpenClaw source tree. - - name: Stage build identity validator + - name: Stage packaging validators shell: pwsh - run: > - Copy-Item - ./scripts/Test-OpenClawBuildIdentity.ps1 - $env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1 + run: | + Copy-Item ` + ./scripts/Test-OpenClawBuildIdentity.ps1 ` + $env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1 + Copy-Item ` + ./scripts/Test-OpenClawPackage.ps1 ` + $env:RUNNER_TEMP/Test-OpenClawPackage.ps1 - name: Check out OpenClaw source + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} uses: actions/checkout@v7 with: repository: openclaw/openclaw @@ -224,27 +241,31 @@ jobs: fetch-depth: 1 - name: Set up upstream Node.js and pnpm + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} uses: ./.github/actions/setup-node-env with: install-bun: "false" install-deps: "false" - name: Install dependencies + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} run: pnpm install --frozen-lockfile - name: Build OpenClaw + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} env: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" run: pnpm build - name: Validate OpenClaw build identities + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} shell: pwsh run: > & "$env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1" -OpenClawDirectory $env:GITHUB_WORKSPACE - name: Pack npm package - id: source + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} run: | set -euo pipefail artifact_dir="${RUNNER_TEMP}/openclaw-package" @@ -259,9 +280,7 @@ jobs: source_sha="$(git rev-parse HEAD)" package_version="$(node -p "require('./package.json').version")" node_version="$(node -p 'process.versions.node')" - echo "sha=${source_sha}" >> "${GITHUB_OUTPUT}" - echo "version=${package_version}" >> "${GITHUB_OUTPUT}" - echo "node_version=${node_version}" >> "${GITHUB_OUTPUT}" + package_sha256="$(sha256sum "${artifact_dir}/openclaw.tgz" | cut -d' ' -f1)" cat > "${artifact_dir}/source.json" <> $env:GITHUB_OUTPUT + "node_version=$([string]$metadata.nodeVersion)" >> $env:GITHUB_OUTPUT + + - name: Save OpenClaw package cache + if: ${{ steps.package-cache.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official') }} + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/openclaw-package + key: ${{ steps.resolve.outputs.package_cache_key }} + - name: Upload intermediate package uses: actions/upload-artifact@v7 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5550336..87afd115 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,8 @@ or package version logic: .\scripts\Test-SigningInputs.Tests.ps1 .\scripts\Test-NodeRuntimeInputs.Tests.ps1 .\scripts\Test-PackagingRelevance.Tests.ps1 +.\scripts\Test-OpenClawCacheKey.Tests.ps1 +.\scripts\Test-OpenClawPackage.Tests.ps1 .\scripts\Test-WorkflowPackageVersion.Tests.ps1 .\scripts\Test-GitHooks.Tests.ps1 ``` diff --git a/README.md b/README.md index a306918f..0e33427c 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,11 @@ the launcher derives its runtime version and LocalState path from the bundled archive name. There is no separate packaging-side Node.js version pin or runtime-support policy. +Non-official workflows cache the packed OpenClaw tarball by its resolved +upstream commit. A cache hit still verifies the tarball's recorded commit and +SHA-256 before it can feed a payload build. Official-signing workflows bypass +the cache and always rebuild upstream source. + The payload artifact records the requested ref and resolved upstream commit in `payload-metadata.json`. That build-only file is not embedded in the MSIX. `msix-metadata.json` records both the packaging repository commit and bundled diff --git a/scripts/Test-OpenClawPackage.Tests.ps1 b/scripts/Test-OpenClawPackage.Tests.ps1 new file mode 100644 index 00000000..9e74ea4c --- /dev/null +++ b/scripts/Test-OpenClawPackage.Tests.ps1 @@ -0,0 +1,92 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Test-OpenClawPackage.ps1' +$commit = '1111111111111111111111111111111111111111' +$testRoot = Join-Path $env:TEMP ( + "openclaw-package-verification-$([guid]::NewGuid().ToString('N'))" +) + +function Assert-Fails { + param( + [Parameter(Mandatory)] + [scriptblock]$Action, + + [Parameter(Mandatory)] + [string]$MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "Expected failure matching '$MessagePattern'; received: " + + $_.Exception.Message + ) + } + return + } + + throw "Expected failure matching '$MessagePattern', but the action succeeded." +} + +New-Item -Path $testRoot -ItemType Directory | Out-Null +try { + $packagePath = Join-Path $testRoot 'openclaw.tgz' + $metadataPath = Join-Path $testRoot 'source.json' + Set-Content -LiteralPath $packagePath -Value 'package contents' + $hash = ( + Get-FileHash -LiteralPath $packagePath -Algorithm SHA256 + ).Hash.ToLowerInvariant() + [ordered]@{ + repository = 'https://github.com/openclaw/openclaw' + requestedRef = 'old-ref' + resolvedCommit = $commit + packageVersion = '1.2.3' + nodeVersion = '24.20.0' + packageSha256 = $hash + } | + ConvertTo-Json | + Set-Content -LiteralPath $metadataPath -Encoding utf8 + + & $scriptPath ` + -PackageDirectory $testRoot ` + -ExpectedCommit $commit ` + -RequestedRef 'current-ref' + $verified = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json + if ($verified.requestedRef -cne 'current-ref') { + throw 'Package verification did not refresh the requested ref.' + } + + Add-Content -LiteralPath $packagePath -Value 'corruption' + Assert-Fails -MessagePattern 'SHA-256 verification' -Action { + & $scriptPath ` + -PackageDirectory $testRoot ` + -ExpectedCommit $commit ` + -RequestedRef 'current-ref' + } + + Set-Content -LiteralPath $packagePath -Value 'package contents' + $verified.packageSha256 = ( + Get-FileHash -LiteralPath $packagePath -Algorithm SHA256 + ).Hash.ToLowerInvariant() + $verified | + ConvertTo-Json | + Set-Content -LiteralPath $metadataPath -Encoding utf8 + Assert-Fails -MessagePattern 'does not match' -Action { + & $scriptPath ` + -PackageDirectory $testRoot ` + -ExpectedCommit '2222222222222222222222222222222222222222' ` + -RequestedRef 'current-ref' + } +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host 'OpenClaw package verification tests passed.' diff --git a/scripts/Test-OpenClawPackage.ps1 b/scripts/Test-OpenClawPackage.ps1 new file mode 100644 index 00000000..26e1dcf3 --- /dev/null +++ b/scripts/Test-OpenClawPackage.ps1 @@ -0,0 +1,46 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackageDirectory, + + [Parameter(Mandatory)] + [string]$ExpectedCommit, + + [Parameter(Mandatory)] + [string]$RequestedRef +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$packagePath = Join-Path $PackageDirectory 'openclaw.tgz' +$metadataPath = Join-Path $PackageDirectory 'source.json' +if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf) -or + -not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { + throw 'The OpenClaw package cache is incomplete.' +} + +$metadata = Get-Content -LiteralPath $metadataPath -Raw | + ConvertFrom-Json +$normalizedCommit = $ExpectedCommit.Trim().ToLowerInvariant() +if ($normalizedCommit -notmatch '^[0-9a-f]{40}$') { + throw "ExpectedCommit '$ExpectedCommit' is not a full commit SHA." +} +if ([string]$metadata.resolvedCommit -cne $normalizedCommit) { + throw ( + "Cached package commit '$($metadata.resolvedCommit)' does not " + + "match '$normalizedCommit'." + ) +} + +$actualHash = ( + Get-FileHash -LiteralPath $packagePath -Algorithm SHA256 +).Hash.ToLowerInvariant() +if ([string]$metadata.packageSha256 -cne $actualHash) { + throw 'The cached OpenClaw package failed SHA-256 verification.' +} + +$metadata.requestedRef = $RequestedRef +$metadata | + ConvertTo-Json | + Set-Content -LiteralPath $metadataPath -Encoding utf8 diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index b9c7e4b4..3fd2e122 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -23,6 +23,12 @@ $requiredFragments = @( "contains(needs.*.result, 'cancelled')" 'name: Upload payload' "retention-days: `${{ github.event_name == 'pull_request' && 1 || 7 }}" + 'name: Restore cached OpenClaw package' + "if: `${{ github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official' }}" + 'uses: actions/cache/restore@v4' + 'name: Save OpenClaw package cache' + "steps.package-cache.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official')" + 'uses: actions/cache/save@v4' 'environment: release-signing' 'id-token: write' 'uses: azure/login@v3' From 390e5bc2abd5e091d697e9a289af66e6cedd0266 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 15 Sep 2026 17:28:13 -0700 Subject: [PATCH 50/76] refactor: validate reusable payload installs (#62) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3d515fe-cd93-465c-8bae-6686c73c1320 --- .github/copilot-instructions.md | 9 +- .github/workflows/gateway-msix.yml | 16 +--- README.md | 4 +- scripts/Build-Payload.ps1 | 99 +++++++++++++++------ scripts/Test-NodeRuntimeInputs.Tests.ps1 | 105 ++++++++++++++++++++++- 5 files changed, 188 insertions(+), 45 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6d178283..7d953629 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -106,10 +106,11 @@ package. commit and SHA-256 on every use; official signing always rebuilds it. The resolved Node.js version flows through `source.json` and `payload-metadata.json`; each architecture-specific Windows job uses that same version to build the - expanded payload and immediately compose its MSIX. `Build-MSIX.ps1` downloads - the matching official archive, rejects Node.js from the application payload, - builds the application inventory, publishes the NativeAOT host, validates - package contents, and emits MSIX metadata including the runtime hash. + expanded payload, validates the installed Gateway and Control UI build identities, + and immediately composes its MSIX. `Build-MSIX.ps1` downloads the matching + official archive, rejects Node.js from the application payload, builds the + application inventory, publishes the NativeAOT host, validates package contents, + and emits MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and the immutable upstream commit in `release-policy.json`; signing inputs are diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 69ad4547..6e82b5f5 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -219,14 +219,11 @@ jobs: # The OpenClaw checkout below must own GITHUB_WORKSPACE because its # composite setup action resolves workspace files from that root. Keep - # the packaging-owned validator outside the workspace before replacing - # this checkout with the OpenClaw source tree. - - name: Stage packaging validators + # the package verifier outside the workspace before replacing this + # checkout with the OpenClaw source tree. + - name: Stage package verifier shell: pwsh run: | - Copy-Item ` - ./scripts/Test-OpenClawBuildIdentity.ps1 ` - $env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1 Copy-Item ` ./scripts/Test-OpenClawPackage.ps1 ` $env:RUNNER_TEMP/Test-OpenClawPackage.ps1 @@ -257,13 +254,6 @@ jobs: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" run: pnpm build - - name: Validate OpenClaw build identities - if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} - shell: pwsh - run: > - & "$env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1" - -OpenClawDirectory $env:GITHUB_WORKSPACE - - name: Pack npm package if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} run: | diff --git a/README.md b/README.md index 0e33427c..57da3eb1 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,9 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. `scripts\Build-MSIX.ps1` downloads +architecture-specific application tree. It validates the Gateway and Control UI +build identities on the installed tree, including reused staged installs. +`scripts\Build-MSIX.ps1` downloads the official Node.js archive matching the payload's recorded build version and architecture, copies both inputs into package content, rejects Node.js inside the application payload, creates a per-file inventory, and then creates diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 4ce97e38..95870f55 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -8,7 +8,9 @@ param( [string]$Architecture, [Parameter(Mandatory)] - [string]$OutputDirectory + [string]$OutputDirectory, + + [switch]$ReuseStagedInstall ) $ErrorActionPreference = 'Stop' @@ -39,35 +41,77 @@ if ($LASTEXITCODE -ne 0) { throw 'Unable to determine the payload build npm version.' } +$packageHash = ( + Get-FileHash -LiteralPath $package[0].FullName -Algorithm SHA256 +).Hash.ToLowerInvariant() $stagingDirectory = Join-Path $env:RUNNER_TEMP "openclaw-stage-$Architecture" -Remove-Item $stagingDirectory -Recurse -Force -ErrorAction SilentlyContinue -New-Item $stagingDirectory -ItemType Directory | Out-Null -New-Item $OutputDirectory -ItemType Directory -Force | Out-Null - -$previousArch = $env:npm_config_arch -$previousTargetArch = $env:npm_config_target_arch -try { - $env:npm_config_arch = $Architecture - $env:npm_config_target_arch = $Architecture - - & npm install ` - --install-strategy=nested ` - --omit=dev ` - --no-audit ` - --no-fund ` - --os=win32 ` - --cpu=$Architecture ` - --prefix $stagingDirectory ` - $package[0].FullName - - if ($LASTEXITCODE -ne 0) { - throw "npm install failed with exit code $LASTEXITCODE." +$stagingMetadataPath = Join-Path $stagingDirectory '.openclaw-install.json' +$expectedStagingMetadata = [ordered]@{ + architecture = $Architecture + resolvedCommit = [string]$sourceMetadata.resolvedCommit + packageVersion = [string]$sourceMetadata.packageVersion + nodeVersion = $nodeVersion + npmVersion = $npmVersion + packageSha256 = $packageHash +} +if ($ReuseStagedInstall) { + if (-not (Test-Path -LiteralPath $stagingDirectory -PathType Container)) { + throw "The staged OpenClaw install does not exist: $stagingDirectory" + } + if (-not (Test-Path -LiteralPath $stagingMetadataPath -PathType Leaf)) { + throw "The staged OpenClaw install is missing provenance: $stagingMetadataPath" + } + try { + $stagingMetadata = Get-Content -LiteralPath $stagingMetadataPath -Raw | + ConvertFrom-Json + } + catch { + throw "The staged OpenClaw install provenance is invalid: $($_.Exception.Message)" + } + foreach ($property in $expectedStagingMetadata.Keys) { + if ($stagingMetadata.PSObject.Properties.Name -notcontains $property -or + [string]$stagingMetadata.$property -cne + [string]$expectedStagingMetadata[$property]) { + throw ( + "The staged OpenClaw install provenance does not match " + + "the requested $property." + ) + } } } -finally { - $env:npm_config_arch = $previousArch - $env:npm_config_target_arch = $previousTargetArch +else { + Remove-Item $stagingDirectory -Recurse -Force -ErrorAction SilentlyContinue + New-Item $stagingDirectory -ItemType Directory | Out-Null + + $previousArch = $env:npm_config_arch + $previousTargetArch = $env:npm_config_target_arch + try { + $env:npm_config_arch = $Architecture + $env:npm_config_target_arch = $Architecture + + & npm install ` + --install-strategy=nested ` + --omit=dev ` + --no-audit ` + --no-fund ` + --os=win32 ` + --cpu=$Architecture ` + --prefix $stagingDirectory ` + $package[0].FullName + + if ($LASTEXITCODE -ne 0) { + throw "npm install failed with exit code $LASTEXITCODE." + } + } + finally { + $env:npm_config_arch = $previousArch + $env:npm_config_target_arch = $previousTargetArch + } + $expectedStagingMetadata | + ConvertTo-Json | + Set-Content -LiteralPath $stagingMetadataPath -Encoding utf8 } +New-Item $OutputDirectory -ItemType Directory -Force | Out-Null $installedPackage = Join-Path $stagingDirectory 'node_modules\openclaw' foreach ($requiredPath in @('package.json', 'openclaw.mjs', 'dist')) { @@ -77,6 +121,9 @@ foreach ($requiredPath in @('package.json', 'openclaw.mjs', 'dist')) { } } +& (Join-Path $PSScriptRoot 'Test-OpenClawBuildIdentity.ps1') ` + -OpenClawDirectory $installedPackage + $bundledNodeFiles = @( Get-ChildItem -LiteralPath $installedPackage -File -Recurse | Where-Object { diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index 751efe8d..79a8e212 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -30,11 +30,21 @@ try { $source = Join-Path $testRoot 'source' $package = Join-Path $testRoot 'package' $payload = Join-Path $testRoot 'payload' - New-Item -ItemType Directory -Path "$source\dist", $package -Force | Out-Null + New-Item ` + -ItemType Directory ` + -Path "$source\dist\control-ui\assets", $package ` + -Force | + Out-Null '{"name":"openclaw","version":"0.0.0","type":"module"}' | Set-Content -LiteralPath "$source\package.json" 'console.log("fixture");' | Set-Content -LiteralPath "$source\openclaw.mjs" 'export {};' | Set-Content -LiteralPath "$source\dist\index.js" + '{"buildId":"fixture-build"}' | + Set-Content -LiteralPath "$source\dist\build-info.json" + 'const EMBEDDED_CACHE_VERSION = "fixture-build";' | + Set-Content -LiteralPath "$source\dist\control-ui\sw.js" + 'const buildId = "fixture-build";' | + Set-Content -LiteralPath "$source\dist\control-ui\assets\app.js" & npm pack $source --ignore-scripts --offline --silent --pack-destination $package if ($LASTEXITCODE -ne 0) { throw 'Unable to pack the local Node.js input fixture.' @@ -60,6 +70,99 @@ try { throw 'The payload did not preserve the exact source build Node.js version.' } + $reusedPayload = Join-Path $testRoot 'payload-reused' + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture x64 ` + -OutputDirectory $reusedPayload ` + -ReuseStagedInstall + if (-not (Test-Path -LiteralPath "$reusedPayload\app\openclaw.mjs")) { + throw 'The reused staged install did not produce an application payload.' + } + + $packagePath = @( + Get-ChildItem -LiteralPath $package -Filter '*.tgz' -File + )[0].FullName + $packageBytes = [IO.File]::ReadAllBytes($packagePath) + Add-Content -LiteralPath $packagePath -Value 'changed package' + Assert-Fails -MessagePattern 'does not match the requested packageSha256' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture x64 ` + -OutputDirectory (Join-Path $testRoot 'wrong-package-reuse') ` + -ReuseStagedInstall + } + [IO.File]::WriteAllBytes($packagePath, $packageBytes) + + $sourceMetadata.resolvedCommit = '2' * 40 + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + Assert-Fails -MessagePattern 'does not match the requested resolvedCommit' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture x64 ` + -OutputDirectory (Join-Path $testRoot 'wrong-source-reuse') ` + -ReuseStagedInstall + } + $sourceMetadata.resolvedCommit = '1' * 40 + $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + + $stagingMetadataPath = Join-Path ( + Join-Path $testRoot 'openclaw-stage-x64' + ) '.openclaw-install.json' + $stagingMetadata = Get-Content -LiteralPath $stagingMetadataPath -Raw + Remove-Item -LiteralPath $stagingMetadataPath -Force + Assert-Fails -MessagePattern 'missing provenance' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture x64 ` + -OutputDirectory (Join-Path $testRoot 'missing-provenance-reuse') ` + -ReuseStagedInstall + } + Set-Content -LiteralPath $stagingMetadataPath -Value '{' + Assert-Fails -MessagePattern 'provenance is invalid' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture x64 ` + -OutputDirectory (Join-Path $testRoot 'invalid-provenance-reuse') ` + -ReuseStagedInstall + } + [IO.File]::WriteAllText( + $stagingMetadataPath, + $stagingMetadata, + [Text.UTF8Encoding]::new($false) + ) + + Assert-Fails -MessagePattern 'staged OpenClaw install does not exist' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture arm64 ` + -OutputDirectory (Join-Path $testRoot 'missing-reuse') ` + -ReuseStagedInstall + } + + $stagedPackage = Join-Path $testRoot 'openclaw-stage-x64\node_modules\openclaw' + Set-Content -LiteralPath (Join-Path $stagedPackage 'node.exe') -Value 'unsafe' + Assert-Fails -MessagePattern 'must not bundle Node.js' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture x64 ` + -OutputDirectory (Join-Path $testRoot 'unsafe-reuse') ` + -ReuseStagedInstall + } + Remove-Item -LiteralPath (Join-Path $stagedPackage 'node.exe') -Force + + 'const EMBEDDED_CACHE_VERSION = "wrong-build";' | + Set-Content -LiteralPath ( + Join-Path $stagedPackage 'dist\control-ui\sw.js' + ) + Assert-Fails -MessagePattern 'build identity mismatch' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture x64 ` + -OutputDirectory (Join-Path $testRoot 'identity-reuse') ` + -ReuseStagedInstall + } + $sourceMetadata.nodeVersion = '0.0.0' $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" Assert-Fails -MessagePattern 'does not match the source build' -Action { From 3a0491cf8790a3336d118486b66aeaa04dba7ba7 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 15 Sep 2026 17:28:13 -0700 Subject: [PATCH 51/76] perf: cache validated Windows dependency trees (#63) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a3d515fe-cd93-465c-8bae-6686c73c1320 --- .github/copilot-instructions.md | 4 +- .github/workflows/gateway-msix.yml | 42 +++++++++++++++++-- README.md | 9 ++-- scripts/Get-OpenClawCacheKey.ps1 | 7 ++++ scripts/Test-OpenClawCacheKey.Tests.ps1 | 18 ++++++++ scripts/Test-WorkflowSigningConfiguration.ps1 | 5 +++ 6 files changed, 77 insertions(+), 8 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7d953629..59d3642d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,9 @@ package. - The GitHub workflow first builds and packs a pinned `openclaw/openclaw` revision on Linux using that revision's `setup-node-env` action. Non-official runs cache that tarball by resolved upstream commit and verify its recorded - commit and SHA-256 on every use; official signing always rebuilds it. The + commit and SHA-256 on every use. They also cache each architecture's Windows + dependency tree by commit, Node.js version, and payload script hash while + rerunning all payload validation; official signing bypasses both caches. The resolved Node.js version flows through `source.json` and `payload-metadata.json`; each architecture-specific Windows job uses that same version to build the expanded payload, validates the installed Gateway and Control UI build identities, diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 6e82b5f5..fafaa704 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -179,6 +179,7 @@ jobs: package_cache_key: ${{ steps.resolve.outputs.package_cache_key }} package_version: ${{ steps.package.outputs.version }} node_version: ${{ steps.package.outputs.node_version }} + package_sha256: ${{ steps.package.outputs.package_sha256 }} steps: - name: Check out repository uses: actions/checkout@v7 @@ -297,6 +298,7 @@ jobs: ConvertFrom-Json "version=$([string]$metadata.packageVersion)" >> $env:GITHUB_OUTPUT "node_version=$([string]$metadata.nodeVersion)" >> $env:GITHUB_OUTPUT + "package_sha256=$([string]$metadata.packageSha256)" >> $env:GITHUB_OUTPUT - name: Save OpenClaw package cache if: ${{ steps.package-cache.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official') }} @@ -342,13 +344,45 @@ jobs: name: openclaw-gateway-npm-package path: ${{ runner.temp }}\openclaw-package - - name: Build expanded payload + - name: Resolve payload cache key + id: payload-key shell: pwsh run: | - .\scripts\Build-Payload.ps1 ` - -PackageDirectory '${{ runner.temp }}\openclaw-package' ` + $cacheKey = .\scripts\Get-OpenClawCacheKey.ps1 ` + -Layer payload ` + -Commit '${{ needs.build-package.outputs.source_sha }}' ` -Architecture '${{ matrix.architecture }}' ` - -OutputDirectory '${{ runner.temp }}\openclaw-payload' + -NodeVersion '${{ needs.build-package.outputs.node_version }}' ` + -PackageSha256 '${{ needs.build-package.outputs.package_sha256 }}' + "key=$cacheKey" >> $env:GITHUB_OUTPUT + + - name: Restore cached Windows dependency tree + id: payload-cache + if: ${{ github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official' }} + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}\openclaw-stage-${{ matrix.architecture }} + key: ${{ steps.payload-key.outputs.key }} + + - name: Build expanded payload + shell: pwsh + run: | + $parameters = @{ + PackageDirectory = '${{ runner.temp }}\openclaw-package' + Architecture = '${{ matrix.architecture }}' + OutputDirectory = '${{ runner.temp }}\openclaw-payload' + } + if ('${{ steps.payload-cache.outputs.cache-hit }}' -eq 'true') { + $parameters.ReuseStagedInstall = $true + } + .\scripts\Build-Payload.ps1 @parameters + + - name: Save Windows dependency tree cache + if: ${{ steps.payload-cache.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official') }} + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}\openclaw-stage-${{ matrix.architecture }} + key: ${{ steps.payload-key.outputs.key }} - name: Upload payload uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 57da3eb1..ba59f9c4 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,12 @@ archive name. There is no separate packaging-side Node.js version pin or runtime-support policy. Non-official workflows cache the packed OpenClaw tarball by its resolved -upstream commit. A cache hit still verifies the tarball's recorded commit and -SHA-256 before it can feed a payload build. Official-signing workflows bypass -the cache and always rebuild upstream source. +upstream commit. They also cache each architecture's Windows dependency tree by +the resolved commit, tarball SHA-256, Node.js version, and payload-build script. +A tarball cache hit still verifies the recorded commit and SHA-256; a +dependency-tree hit still runs every payload validation and smoke test. +Official-signing workflows bypass +both caches and always rebuild upstream source and Windows dependencies. The payload artifact records the requested ref and resolved upstream commit in `payload-metadata.json`. That build-only file is not embedded in the MSIX. diff --git a/scripts/Get-OpenClawCacheKey.ps1 b/scripts/Get-OpenClawCacheKey.ps1 index 13e656bb..52df1053 100644 --- a/scripts/Get-OpenClawCacheKey.ps1 +++ b/scripts/Get-OpenClawCacheKey.ps1 @@ -12,6 +12,8 @@ param( [string]$NodeVersion, + [string]$PackageSha256, + [string]$PayloadScriptPath = ( Join-Path $PSScriptRoot 'Build-Payload.ps1' ), @@ -38,6 +40,10 @@ if ([string]::IsNullOrWhiteSpace($Architecture)) { if ($NodeVersion -notmatch '^\d+\.\d+\.\d+$') { throw "NodeVersion '$NodeVersion' must contain three numeric components." } +$normalizedPackageSha256 = $PackageSha256.Trim().ToLowerInvariant() +if ($normalizedPackageSha256 -notmatch '^[0-9a-f]{64}$') { + throw 'PackageSha256 must be a 64-character hexadecimal SHA-256.' +} if (-not (Test-Path -LiteralPath $PayloadScriptPath -PathType Leaf)) { throw "Payload script does not exist: $PayloadScriptPath" } @@ -51,5 +57,6 @@ $scriptHash = ( $Architecture $normalizedCommit "node-$NodeVersion" + $normalizedPackageSha256 $scriptHash ) -join '-' diff --git a/scripts/Test-OpenClawCacheKey.Tests.ps1 b/scripts/Test-OpenClawCacheKey.Tests.ps1 index 8559d2c9..a4da52e5 100644 --- a/scripts/Test-OpenClawCacheKey.Tests.ps1 +++ b/scripts/Test-OpenClawCacheKey.Tests.ps1 @@ -7,6 +7,8 @@ $ErrorActionPreference = 'Stop' $scriptPath = Join-Path $PSScriptRoot 'Get-OpenClawCacheKey.ps1' $commitA = '1111111111111111111111111111111111111111' $commitB = '2222222222222222222222222222222222222222' +$packageHashA = 'a' * 64 +$packageHashB = 'b' * 64 $testRoot = Join-Path $env:TEMP ( "openclaw-cache-key-$([guid]::NewGuid().ToString('N'))" ) @@ -53,21 +55,36 @@ try { -Commit $commitA ` -Architecture x64 ` -NodeVersion 24.20.0 ` + -PackageSha256 $packageHashA ` -PayloadScriptPath $payloadScript $payloadArm64 = & $scriptPath ` -Layer payload ` -Commit $commitA ` -Architecture arm64 ` -NodeVersion 24.20.0 ` + -PackageSha256 $packageHashA ` -PayloadScriptPath $payloadScript Assert-NotEqual $payloadX64 $payloadArm64 'distinct architectures' + $payloadAfterPackageRebuild = & $scriptPath ` + -Layer payload ` + -Commit $commitA ` + -Architecture x64 ` + -NodeVersion 24.20.0 ` + -PackageSha256 $packageHashB ` + -PayloadScriptPath $payloadScript + Assert-NotEqual ` + $payloadX64 ` + $payloadAfterPackageRebuild ` + 'distinct package hashes' + Set-Content -LiteralPath $payloadScript -Value 'Write-Output second' $payloadAfterEdit = & $scriptPath ` -Layer payload ` -Commit $commitA ` -Architecture x64 ` -NodeVersion 24.20.0 ` + -PackageSha256 $packageHashA ` -PayloadScriptPath $payloadScript Assert-NotEqual $payloadX64 $payloadAfterEdit 'payload script edit' @@ -76,6 +93,7 @@ try { -Commit $commitA ` -Architecture x64 ` -NodeVersion 24.20.0 ` + -PackageSha256 $packageHashA ` -PayloadScriptPath $payloadScript ` -FormatVersion 2 Assert-NotEqual $payloadAfterEdit $payloadV2 'payload cache format version' diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 3fd2e122..31c73bdf 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -29,6 +29,11 @@ $requiredFragments = @( 'name: Save OpenClaw package cache' "steps.package-cache.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official')" 'uses: actions/cache/save@v4' + 'name: Restore cached Windows dependency tree' + 'path: ${{ runner.temp }}\openclaw-stage-${{ matrix.architecture }}' + 'key: ${{ steps.payload-key.outputs.key }}' + 'name: Save Windows dependency tree cache' + "steps.payload-cache.outputs.cache-hit != 'true' && (github.event_name != 'workflow_dispatch' || inputs.signing_mode != 'official')" 'environment: release-signing' 'id-token: write' 'uses: azure/login@v3' From 685ee93b7ebbec1e784205a3544c460bea740e11 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 16 Sep 2026 23:43:50 -0700 Subject: [PATCH 52/76] feat(ci): derive deterministic MSIX release versions (#36) * feat(ci): derive Store-safe MSIX release versions * fix(ci): use readable MSIX release versions * fix(ci): accept full MSIX patch range * fix(ci): reserve MSIX rebuild slots per gateway correction * fix(ci): reserve thousand-wide MSIX release slots * docs: explain Gateway MSIX versioning policy * test: prove proof-release upgrade compatibility * test(ci): prove proof-release bundle upgrades * fix(ci): normalize empty package queries * fix(ci): trust test signing cert machine-wide * chore(release): advance Gateway baseline to 2026.9.4 * test(signing): keep payload mismatch fixture invalid * fix(payload): allow disabled plugin migration entries --- .github/workflows/gateway-msix.yml | 148 +++++++- CONTRIBUTING.md | 14 + README.md | 75 +++- release-policy.json | 8 +- scripts/Build-Payload.ps1 | 19 +- scripts/Get-MSIXReleaseIdentity.ps1 | 68 ++++ scripts/Sign-TestMSIX.ps1 | 54 ++- scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 14 + scripts/Test-MSIXReleaseIdentity.Tests.ps1 | 159 ++++++++ scripts/Test-MSIXUpgrade.ps1 | 352 ++++++++++++++++++ scripts/Test-Sign-TestMSIX.Tests.ps1 | 4 +- scripts/Test-SigningInputs.Tests.ps1 | 22 +- scripts/Test-SigningInputs.ps1 | 19 +- scripts/Test-WorkflowSigningConfiguration.ps1 | 10 + scripts/msix-upgrade-baselines.json | 32 ++ 15 files changed, 941 insertions(+), 57 deletions(-) create mode 100644 scripts/Get-MSIXReleaseIdentity.ps1 create mode 100644 scripts/Test-MSIXReleaseIdentity.Tests.ps1 create mode 100644 scripts/Test-MSIXUpgrade.ps1 create mode 100644 scripts/msix-upgrade-baselines.json diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 624919d0..63f9cb20 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -10,7 +10,7 @@ on: openclaw_ref: description: openclaw/openclaw tag, branch, or commit to package required: true - default: 0965053fe6b9341776df147a6934b7485c60b5ca + default: 3a9d69db306cd7f081e06254cb89c4bcc14a7107 type: string signing_mode: description: Package signing mode @@ -31,7 +31,7 @@ permissions: pull-requests: read env: - OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '0965053fe6b9341776df147a6934b7485c60b5ca' }} + OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '3a9d69db306cd7f081e06254cb89c4bcc14a7107' }} PACKAGING_ROOT: . jobs: @@ -40,6 +40,7 @@ jobs: runs-on: ubuntu-latest outputs: packaging: ${{ steps.filter.outputs.packaging }} + versioning: ${{ steps.filter.outputs.versioning }} steps: - uses: actions/checkout@v6 @@ -51,6 +52,7 @@ jobs: run: | if ($env:GITHUB_EVENT_NAME -ne 'pull_request') { 'packaging=true' >> $env:GITHUB_OUTPUT + 'versioning=false' >> $env:GITHUB_OUTPUT return } @@ -68,6 +70,25 @@ jobs: -FileListPath $fileListPath "packaging=$packaging" >> $env:GITHUB_OUTPUT + $pages = Get-Content -LiteralPath $fileListPath -Raw | + ConvertFrom-Json + $versioningPaths = @( + 'release-policy.json' + 'scripts/Get-MSIXReleaseIdentity.ps1' + 'scripts/Test-MSIXReleaseIdentity.Tests.ps1' + 'scripts/Test-MSIXUpgrade.ps1' + 'scripts/msix-upgrade-baselines.json' + ) + $versioning = 'false' + foreach ($page in @($pages)) { + foreach ($file in @($page)) { + if ([string]$file.filename -in $versioningPaths) { + $versioning = 'true' + } + } + } + "versioning=$versioning" >> $env:GITHUB_OUTPUT + test-host: name: Test Gateway MSIX host runs-on: windows-latest @@ -164,6 +185,11 @@ jobs: run: > .\scripts\Test-Deploy-LocalPackage.Tests.ps1 + - name: Test MSIX release identity + shell: pwsh + run: > + .\scripts\Test-MSIXReleaseIdentity.Tests.ps1 + - name: Test MSIX bundle build shell: pwsh run: > @@ -328,6 +354,7 @@ jobs: build-msix: name: Build unsigned ${{ matrix.architecture }} Gateway MSIX needs: + - changes - test-host - build-package runs-on: windows-latest @@ -423,15 +450,22 @@ jobs: shell: pwsh env: SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} + VERSIONING_CHANGE: ${{ needs.changes.outputs.versioning }} run: | $versionParameters = @{ RunNumber = '${{ github.run_number }}' RunAttempt = '${{ github.run_attempt }}' } - if ($env:SIGNING_MODE -eq 'official') { + if ( + $env:SIGNING_MODE -eq 'official' -or + $env:VERSIONING_CHANGE -eq 'true' + ) { $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json - $versionParameters.ReleaseVersion = [string]$policy.packageVersion + $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) + $versionParameters.ReleaseVersion = $identity.PackageVersion } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` @versionParameters @@ -504,7 +538,9 @@ jobs: build-msix-bundle: name: Build unsigned multi-architecture Gateway MSIX bundle - needs: build-msix + needs: + - changes + - build-msix runs-on: windows-latest steps: - name: Check out repository @@ -528,15 +564,22 @@ jobs: shell: pwsh env: SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} + VERSIONING_CHANGE: ${{ needs.changes.outputs.versioning }} run: | $versionParameters = @{ RunNumber = '${{ github.run_number }}' RunAttempt = '${{ github.run_attempt }}' } - if ($env:SIGNING_MODE -eq 'official') { + if ( + $env:SIGNING_MODE -eq 'official' -or + $env:VERSIONING_CHANGE -eq 'true' + ) { $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json - $versionParameters.ReleaseVersion = [string]$policy.packageVersion + $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) + $versionParameters.ReleaseVersion = $identity.PackageVersion } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` @versionParameters @@ -555,6 +598,86 @@ jobs: if-no-files-found: error retention-days: 7 + test-msix-upgrades: + name: Test proof-release MSIX upgrades + if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.versioning == 'true' }} + needs: + - changes + - build-msix + - build-msix-bundle + runs-on: windows-latest + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download unsigned x64 candidate + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-x64 + path: artifacts\x64 + + - name: Download unsigned bundle candidate + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-unsigned-bundle + path: artifacts\bundle + + - name: Apply temporary test signature + shell: pwsh + run: | + .\scripts\Sign-TestMSIX.ps1 ` + -ArtifactsDirectory artifacts ` + -OutputDirectory test-signed + + - name: Download hash-pinned proof releases + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $baselines = Get-Content ` + -LiteralPath .\scripts\msix-upgrade-baselines.json ` + -Raw | + ConvertFrom-Json + New-Item -Path baselines -ItemType Directory -Force | Out-Null + foreach ($baseline in $baselines.baselines) { + & gh release download ([string]$baseline.releaseTag) ` + --repo $env:GITHUB_REPOSITORY ` + --pattern ([string]$baseline.assetName) ` + --dir baselines + if ($LASTEXITCODE -ne 0) { + throw "Unable to download $($baseline.releaseTag) upgrade baseline." + } + } + + - name: Test installed-package upgrades and retained LocalState + shell: pwsh + run: | + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) + .\scripts\Test-MSIXUpgrade.ps1 ` + -BaselinesPath .\scripts\msix-upgrade-baselines.json ` + -BaselinesDirectory baselines ` + -CandidatePackagePath test-signed\x64\OpenClawGateway-x64.msix ` + -CandidateBundlePath test-signed\bundle\OpenClawGateway.msixbundle ` + -CandidateCertificatePath test-signed\x64\OpenClawGateway-test-signing.cer ` + -ExpectedCandidateVersion $identity.PackageVersion ` + -EvidencePath evidence\msix-upgrade-evidence.json + + - name: Upload upgrade evidence + uses: actions/upload-artifact@v7 + with: + name: openclaw-gateway-msix-upgrade-evidence + path: evidence\msix-upgrade-evidence.json + if-no-files-found: error + retention-days: 90 + reject-untrusted-official-signing: name: Reject official signing outside main if: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode == 'official' && github.ref != 'refs/heads/main' }} @@ -612,15 +735,17 @@ jobs: run: | $policy = Get-Content -LiteralPath .\release-policy.json -Raw | ConvertFrom-Json - $releaseTag = ([string]$policy.releaseTag).Trim() + $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` -RunNumber '${{ github.run_number }}' ` -RunAttempt '${{ github.run_attempt }}' ` - -ReleaseVersion ([string]$policy.packageVersion) + -ReleaseVersion $identity.PackageVersion "package_version=$packageVersion" >> $env:GITHUB_OUTPUT - "release_tag=$releaseTag" >> $env:GITHUB_OUTPUT - "release_version=$($releaseTag.Substring(1))" >> $env:GITHUB_OUTPUT + "release_tag=$($identity.ReleaseTag)" >> $env:GITHUB_OUTPUT + "release_version=$($identity.ReleaseVersion)" >> $env:GITHUB_OUTPUT - name: Enforce official signing policy shell: pwsh @@ -851,6 +976,7 @@ jobs: - build-msix - test-sign-msix - build-msix-bundle + - test-msix-upgrades - reject-untrusted-official-signing - authorize-signing - sign-msix diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87afd115..e28271a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,7 @@ or package version logic: .\scripts\Test-PackagingRelevance.Tests.ps1 .\scripts\Test-OpenClawCacheKey.Tests.ps1 .\scripts\Test-OpenClawPackage.Tests.ps1 +.\scripts\Test-MSIXReleaseIdentity.Tests.ps1 .\scripts\Test-WorkflowPackageVersion.Tests.ps1 .\scripts\Test-GitHooks.Tests.ps1 ``` @@ -183,6 +184,19 @@ bypassable, and required CI checks remain authoritative. - Metadata files are part of the release trust chain. Coordinate changes across payload creation, MSIX creation, signing validation, workflow artifacts, and tests. +- Keep official release identity derived from `gatewayTag` and `msixRevision`. + The unsuffixed Gateway tag owns revision block `1000-1999`; correction tags + `-2` through `-64` own their corresponding 1,000-number blocks. Use revision + `0` for the first MSIX of a Gateway tag and increment only for packaging-only + rebuilds of that exact tag. Do not assign package versions or release tags by + hand. +- Treat published proof releases in `scripts/msix-upgrade-baselines.json` as + immutable transition fixtures. Keep their release asset names and SHA-256 + digests pinned. Version-policy changes must pass the installed-package + upgrade job from every standalone and bundle baseline, retain package + LocalState, and prove both candidate delivery formats install fresh. Run the + harness only on an isolated clean Windows account; it refuses pre-existing + OpenClaw Gateway registrations and cleans up only its own installation. - Use source-generated `System.Text.Json` metadata through `OpenClawJsonContext`. The launcher is NativeAOT and must not introduce reflection-based serialization. diff --git a/README.md b/README.md index 1a519484..d73701b2 100644 --- a/README.md +++ b/README.md @@ -156,9 +156,9 @@ The payload artifact records the requested ref and resolved upstream commit in OpenClaw commit, while embedded `payload-files.json` records every packaged application file's path, length, and SHA-256. -`release-policy.json` records the immutable OpenClaw commit and payload version -approved for official signing, plus the independent MSIX package version and -release tag. Updating that +`release-policy.json` records the immutable OpenClaw commit and Gateway tag +approved for official signing, plus an independent MSIX packaging revision. +Updating that policy requires a reviewed repository change. Official signing runs only from `main` and verifies the workflow input, policy-approved package version, both architecture metadata files, both MSIX hashes, the embedded manifests, and @@ -200,8 +200,8 @@ they do not represent the default-disabled state of a normal install. Full selected-theme cohesion requires the generic plugin-frame theme forwarding merged by [`openclaw/openclaw#145409`](https://github.com/openclaw/openclaw/pull/145409). -The current workflow remains on the release-approved OpenClaw baseline -`0965053fe6b9341776df147a6934b7485c60b5ca` while this plugin is disabled by +The current workflow remains on the release-approved OpenClaw `v2026.9.4` +baseline (`3a9d69db306cd7f081e06254cb89c4bcc14a7107`) while this plugin is disabled by default. That baseline packages and inspects the plugin safely but does not forward selected Control UI themes into plugin frames. The future launcher enablement change must also advance and qualify the runtime to the merged theme @@ -292,23 +292,66 @@ Test-signing private keys are generated only on the temporary GitHub runner and are deleted before artifacts are uploaded. No signing secret or private key is stored in the repository. -Official releases use the independent four-part numeric `packageVersion` and -`releaseTag` from `release-policy.json`. The initial signing proof uses package -version `0.0.0.0` and tag `v0.0.0.0`; a later policy change can establish the -long-term Gateway-to-MSIX version mapping. The workflow creates the tag in this -repository and a GitHub Release with generated release notes. Each release -contains a signed, multi-architecture +Official releases derive their GitHub tag and four-part numeric MSIX identity +from `gatewayTag` and `msixRevision` in `release-policy.json`. The GitHub tag is +`-msix.`. The MSIX identity is +`year.month.patch.(gateway-release-sequence * 1000 + msix-revision)`. + +| Gateway tag | MSIX revision | GitHub release tag | MSIX version | +|---|---:|---|---| +| `v2026.7.1` | `0` | `v2026.7.1-msix.0` | `2026.7.1.1000` | +| `v2026.7.1-2` | `0` | `v2026.7.1-2-msix.0` | `2026.7.1.2000` | +| `v2026.7.1-2` | `1` | `v2026.7.1-2-msix.1` | `2026.7.1.2001` | +| `v2026.7.2` | `0` | `v2026.7.2-msix.0` | `2026.7.2.1000` | + +The unsuffixed Gateway tag is release sequence `1`; correction suffixes `-2` +through `-64` use their numeric suffix as the sequence. A `-1` suffix is +rejected because it would collide with the unsuffixed tag. Set `msixRevision` +from `0` through `999`, starting at `0` for each Gateway tag and incrementing it +only when that exact Gateway tag is repackaged. Each Gateway release therefore +owns a deterministic 1,000-number block, and an MSIX-only rebuild cannot shift +the version assigned to a later Gateway correction or patch. + +To prepare an official release, update these policy inputs together in a +reviewed pull request: + +1. `gatewayTag` to the stable upstream Gateway tag; +2. `approvedCommit` to the immutable commit resolved from that tag; +3. `payloadPackageVersion` to the version reported by the pinned payload; +4. `msixRevision` to `0`, or increment it for a packaging-only rebuild of the + same Gateway tag; +5. the workflow's `openclaw_ref` default and non-manual fallback to the same + `approvedCommit`. + +After that pull request merges, manually run **Build OpenClaw Gateway MSIX** on +`main` with `openclaw_ref` set to the approved commit and `signing_mode` set to +`official`. The workflow derives the package version and release tag, creates +the tag in this repository, and publishes a GitHub Release with generated +release notes. Each release contains a signed, multi-architecture `OpenClawGateway-.msixbundle` as the recommended download, plus signed `OpenClawGateway--x64.msix` and `OpenClawGateway--arm64.msix` packages for architecture-specific deployment. The duplicate GitHub Actions artifacts remain short-lived transport and diagnostic copies. -For the all-zero proof only, MakeAppx assigns the outer bundle identity its -date/time-based version because it does not preserve `0.0.0.0` as a bundle -version. The two embedded architecture packages retain identity version -`0.0.0.0`; signing authorization verifies those versions and byte-compares both -embedded packages with the approved standalone inputs. +Microsoft Store submissions reserve the fourth version component as zero, so +Store publication will need its own version policy when it is introduced. + +The signed `v0.0.0.0` and `v0.0.0.1` proof releases are not production version +identities, but they are retained as transition baselines. Pull requests that +change release versioning download the hash-pinned standalone x64 and +recommended `.msixbundle` assets, install each one on a clean GitHub-hosted +Windows runner, upgrade it in place through the same delivery format, and +verify that the package family remains stable and a LocalState marker is +retained. The gate also proves fresh installation of both the standalone and +bundle candidates. It refuses to run when an OpenClaw Gateway package is +already registered and removes only packages installed by that test +invocation. It temporarily trusts the ephemeral test-signing certificate in +the local-machine Trusted People store, as required by Windows deployment, and +removes that certificate in `finally`. The resulting JSON evidence is retained +as a workflow artifact for 90 days. Future versioning schemes must keep this +transition gate green or explicitly document and obtain approval for a +breaking reset. An `.msixbundle` is a single installable container for the x64 and ARM64 MSIX packages; Windows selects the package appropriate for the device. An diff --git a/release-policy.json b/release-policy.json index 33c1adb7..12ad1d78 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,8 +1,8 @@ { "repository": "https://github.com/openclaw/openclaw", - "releaseTag": "v0.0.0.1", - "packageVersion": "0.0.0.1", - "payloadPackageVersion": "2026.8.2", - "approvedCommit": "0965053fe6b9341776df147a6934b7485c60b5ca", + "gatewayTag": "v2026.9.4", + "msixRevision": 0, + "payloadPackageVersion": "2026.9.4", + "approvedCommit": "3a9d69db306cd7f081e06254cb89c4bcc14a7107", "publisher": "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" } diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 7d8166d4..1e768ebb 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -247,13 +247,22 @@ try { -LiteralPath $validationConfigPath ` -Raw | ConvertFrom-Json - $validationEntryNames = @( - $validationConfig.plugins.entries.PSObject.Properties.Name + $explicitlyEnabledEntryNames = @( + foreach ( + $entry in + $validationConfig.plugins.entries.PSObject.Properties + ) { + if ( + $entry.Value.PSObject.Properties.Name -contains 'enabled' -and + $entry.Value.enabled -eq $true + ) { + $entry.Name + } + } ) if ( - $validationEntryNames.Count -ne 1 -or - $validationEntryNames[0] -ne 'gateway-isolation' -or - $validationConfig.plugins.entries.'gateway-isolation'.enabled -ne $true + $explicitlyEnabledEntryNames.Count -ne 1 -or + $explicitlyEnabledEntryNames[0] -ne 'gateway-isolation' ) { throw ( 'The isolated validation configuration must explicitly enable ' + diff --git a/scripts/Get-MSIXReleaseIdentity.ps1 b/scripts/Get-MSIXReleaseIdentity.ps1 new file mode 100644 index 00000000..3730313a --- /dev/null +++ b/scripts/Get-MSIXReleaseIdentity.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$GatewayTag, + + [Parameter(Mandatory)] + [int]$MSIXRevision +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$match = [regex]::Match( + $GatewayTag.Trim(), + '^v(?\d{4})\.(?\d{1,2})\.(?\d{1,5})(?:-(?\d+))?$' +) +if (-not $match.Success) { + throw ( + "GatewayTag '$GatewayTag' must be a stable OpenClaw release tag " + + 'such as v2026.9.4 or v2026.7.1-2.' + ) +} + +[int]$year = $match.Groups['year'].Value +[int]$month = $match.Groups['month'].Value +[int]$patch = $match.Groups['patch'].Value +[int]$releaseSequence = if ($match.Groups['correction'].Success) { + [int]$correction = $match.Groups['correction'].Value + if ($correction -lt 2 -or $correction -gt 64) { + throw 'The Gateway correction suffix must be between 2 and 64.' + } + $correction +} +else { + 1 +} + +if ($year -lt 1 -or $year -gt 9999) { + throw 'The Gateway release year must be between 1 and 9999.' +} +if ($month -lt 1 -or $month -gt 12) { + throw 'The Gateway release month must be between 1 and 12.' +} +if ($patch -lt 0 -or $patch -gt 65534) { + throw 'The Gateway patch must be between 0 and 65534.' +} +if ($MSIXRevision -lt 0 -or $MSIXRevision -gt 999) { + throw 'MSIXRevision must be between 0 and 999.' +} + +# Give every Gateway release sequence 1,000 deterministic MSIX revision slots. The +# unsuffixed tag is sequence 1 and correction tags use their numeric suffix. This +# prevents an MSIX-only rebuild from consuming the number assigned to a later +# Gateway correction. +$packageRevision = ($releaseSequence * 1000) + $MSIXRevision +if ($packageRevision -gt 65534) { + throw 'The combined Gateway release sequence and MSIXRevision must not exceed 65534.' +} +$packageVersion = "$year.$month.$patch.$packageRevision" +$releaseTag = "$($GatewayTag.Trim())-msix.$MSIXRevision" + +[pscustomobject]@{ + GatewayTag = $GatewayTag.Trim() + MSIXRevision = $MSIXRevision + PackageVersion = $packageVersion + ReleaseTag = $releaseTag + ReleaseVersion = $releaseTag.Substring(1) +} diff --git a/scripts/Sign-TestMSIX.ps1 b/scripts/Sign-TestMSIX.ps1 index fa1d19f9..5f3ac672 100644 --- a/scripts/Sign-TestMSIX.ps1 +++ b/scripts/Sign-TestMSIX.ps1 @@ -39,11 +39,13 @@ $architectureDirectories = @( } } ) -if ($architectureDirectories.Count -eq 0) { +$bundleDirectory = Join-Path $resolvedArtifactsDirectory 'bundle' +$hasBundleDirectory = Test-Path -LiteralPath $bundleDirectory -PathType Container +if ($architectureDirectories.Count -eq 0 -and -not $hasBundleDirectory) { throw ( - "No architecture directories were found under " + + "No signable package directories were found under " + "'$resolvedArtifactsDirectory'. Expected at least one of: " + - ($architectures -join ', ') + '.' + ($architectures -join ', ') + ', bundle.' ) } @@ -176,6 +178,52 @@ try { ) ` -Encoding utf8 } + + if ($hasBundleDirectory) { + $sourceBundles = @( + Get-ChildItem ` + -LiteralPath $bundleDirectory ` + -Filter '*.msixbundle' ` + -File + ) + if ($sourceBundles.Count -ne 1) { + throw ( + "Expected one unsigned MSIX bundle in '$bundleDirectory'; " + + "found $($sourceBundles.Count)." + ) + } + + $destinationDirectory = Join-Path $OutputDirectory 'bundle' + New-Item ` + -Path $destinationDirectory ` + -ItemType Directory ` + -Force | + Out-Null + $signedBundlePath = Join-Path ` + $destinationDirectory ` + $sourceBundles[0].Name + Copy-Item ` + -LiteralPath $sourceBundles[0].FullName ` + -Destination $signedBundlePath ` + -Force + + & $signtool.FullName sign ` + /fd SHA256 ` + /f $temporaryPfx ` + /p $passwordText ` + $signedBundlePath + if ($LASTEXITCODE -ne 0) { + throw "Test signing failed for the bundle with exit code $LASTEXITCODE." + } + + $signature = Get-AuthenticodeSignature -LiteralPath $signedBundlePath + if ( + $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $certificate.Thumbprint + ) { + throw 'The bundle test signature was not applied.' + } + } } finally { Remove-Item -LiteralPath $temporaryPfx -Force -ErrorAction SilentlyContinue diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 index fe985925..31690c6b 100644 --- a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -89,6 +89,20 @@ if (args[0] === "plugins" && args[1] === "enable") { entries: { "gateway-isolation": { enabled: true + }, + anthropic: { + config: { + sessionCatalog: { + enabled: false + } + } + }, + codex: { + config: { + sessionCatalog: { + enabled: false + } + } } } } diff --git a/scripts/Test-MSIXReleaseIdentity.Tests.ps1 b/scripts/Test-MSIXReleaseIdentity.Tests.ps1 new file mode 100644 index 00000000..99c2be10 --- /dev/null +++ b/scripts/Test-MSIXReleaseIdentity.Tests.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1' +$workflowVersionScriptPath = Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1' + +function Assert-Identity { + param( + [Parameter(Mandatory)] + [string]$GatewayTag, + + [Parameter(Mandatory)] + [int]$MSIXRevision, + + [Parameter(Mandatory)] + [string]$PackageVersion, + + [Parameter(Mandatory)] + [string]$ReleaseTag + ) + + $identity = & $scriptPath ` + -GatewayTag $GatewayTag ` + -MSIXRevision $MSIXRevision + if ($identity.PackageVersion -ne $PackageVersion) { + throw ( + "Expected $GatewayTag revision $MSIXRevision to produce " + + "$PackageVersion; received $($identity.PackageVersion)." + ) + } + if ($identity.ReleaseTag -ne $ReleaseTag) { + throw ( + "Expected $GatewayTag revision $MSIXRevision to produce " + + "$ReleaseTag; received $($identity.ReleaseTag)." + ) + } + if ($identity.ReleaseVersion -ne $ReleaseTag.Substring(1)) { + throw 'ReleaseVersion did not match the release tag without its v prefix.' + } +} + +function Assert-Fails { + param( + [Parameter(Mandatory)] + [scriptblock]$Action, + + [Parameter(Mandatory)] + [string]$MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "Expected failure matching '$MessagePattern'; received: " + + $_.Exception.Message + ) + } + return + } + + throw "Expected failure matching '$MessagePattern', but the action succeeded." +} + +Assert-Identity ` + -GatewayTag 'v2026.9.4' ` + -MSIXRevision 0 ` + -PackageVersion '2026.9.4.1000' ` + -ReleaseTag 'v2026.9.4-msix.0' +Assert-Identity ` + -GatewayTag 'v2026.7.12' ` + -MSIXRevision 0 ` + -PackageVersion '2026.7.12.1000' ` + -ReleaseTag 'v2026.7.12-msix.0' +Assert-Identity ` + -GatewayTag 'v2026.7.2' ` + -MSIXRevision 0 ` + -PackageVersion '2026.7.2.1000' ` + -ReleaseTag 'v2026.7.2-msix.0' +Assert-Identity ` + -GatewayTag 'v2026.7.1' ` + -MSIXRevision 1 ` + -PackageVersion '2026.7.1.1001' ` + -ReleaseTag 'v2026.7.1-msix.1' +Assert-Identity ` + -GatewayTag 'v2026.7.1-2' ` + -MSIXRevision 0 ` + -PackageVersion '2026.7.1.2000' ` + -ReleaseTag 'v2026.7.1-2-msix.0' +Assert-Identity ` + -GatewayTag 'v2026.7.1-2' ` + -MSIXRevision 1 ` + -PackageVersion '2026.7.1.2001' ` + -ReleaseTag 'v2026.7.1-2-msix.1' + +$gatewayCorrection = & $scriptPath ` + -GatewayTag 'v2026.7.1-2' ` + -MSIXRevision 0 +$packagingCorrection = & $scriptPath ` + -GatewayTag 'v2026.7.1-2' ` + -MSIXRevision 1 +$nextGatewayCorrection = & $scriptPath ` + -GatewayTag 'v2026.7.1-3' ` + -MSIXRevision 0 +$nextGatewayPatch = & $scriptPath ` + -GatewayTag 'v2026.7.2' ` + -MSIXRevision 0 +if ( + [version]$packagingCorrection.PackageVersion -le + [version]$gatewayCorrection.PackageVersion -or + [version]$nextGatewayCorrection.PackageVersion -le + [version]$packagingCorrection.PackageVersion -or + [version]$nextGatewayPatch.PackageVersion -le + [version]$nextGatewayCorrection.PackageVersion +) { + throw 'Derived MSIX versions do not preserve release ordering.' +} + +Assert-Fails -MessagePattern 'stable OpenClaw release tag' -Action { + & $scriptPath -GatewayTag 'v2026.9.4-beta.1' -MSIXRevision 0 +} +Assert-Fails -MessagePattern 'month must be between 1 and 12' -Action { + & $scriptPath -GatewayTag 'v2026.13.1' -MSIXRevision 0 +} +Assert-Fails -MessagePattern 'patch must be between 0 and 65534' -Action { + & $scriptPath -GatewayTag 'v2026.9.65535' -MSIXRevision 0 +} +Assert-Fails -MessagePattern 'correction suffix must be between 2 and 64' -Action { + & $scriptPath -GatewayTag 'v2026.9.4-1' -MSIXRevision 0 +} +Assert-Fails -MessagePattern 'correction suffix must be between 2 and 64' -Action { + & $scriptPath -GatewayTag 'v2026.9.4-65' -MSIXRevision 0 +} +Assert-Fails -MessagePattern 'MSIXRevision must be between 0 and 999' -Action { + & $scriptPath -GatewayTag 'v2026.9.4' -MSIXRevision 1000 +} +Assert-Identity ` + -GatewayTag 'v2026.9.4-64' ` + -MSIXRevision 999 ` + -PackageVersion '2026.9.4.64999' ` + -ReleaseTag 'v2026.9.4-64-msix.999' + +$maximumIdentity = & $scriptPath ` + -GatewayTag 'v2026.9.4-64' ` + -MSIXRevision 999 +$validatedMaximumVersion = & $workflowVersionScriptPath ` + -RunNumber 1 ` + -RunAttempt 1 ` + -ReleaseVersion $maximumIdentity.PackageVersion +if ($validatedMaximumVersion -ne '2026.9.4.64999') { + throw 'The maximum derived identity did not pass workflow validation.' +} + +Write-Host 'MSIX release-identity tests passed.' diff --git a/scripts/Test-MSIXUpgrade.ps1 b/scripts/Test-MSIXUpgrade.ps1 new file mode 100644 index 00000000..077f595c --- /dev/null +++ b/scripts/Test-MSIXUpgrade.ps1 @@ -0,0 +1,352 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$BaselinesPath, + + [Parameter(Mandatory)] + [string]$BaselinesDirectory, + + [Parameter(Mandatory)] + [string]$CandidatePackagePath, + + [Parameter(Mandatory)] + [string]$CandidateBundlePath, + + [Parameter(Mandatory)] + [string]$CandidateCertificatePath, + + [Parameter(Mandatory)] + [string]$ExpectedCandidateVersion, + + [Parameter(Mandatory)] + [string]$EvidencePath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $IsWindows) { + throw 'MSIX upgrade validation requires Windows.' +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Read-MSIXIdentity { + param([Parameter(Mandatory)][string]$Path) + + $archive = [IO.Compression.ZipFile]::OpenRead( + (Resolve-Path -LiteralPath $Path).Path + ) + try { + $isBundle = [IO.Path]::GetExtension($Path) -ieq '.msixbundle' + $manifestPath = if ($isBundle) { + 'AppxMetadata/AppxBundleManifest.xml' + } + else { + 'AppxManifest.xml' + } + $entry = $archive.GetEntry($manifestPath) + if ($null -eq $entry) { + throw "Package '$Path' does not contain $manifestPath." + } + $reader = [IO.StreamReader]::new($entry.Open()) + try { + [xml]$manifest = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + if ($isBundle) { + $identity = $manifest.Bundle.Identity + $x64Package = @($manifest.Bundle.Packages.Package) | + Where-Object { [string]$_.Architecture -ceq 'x64' } | + Select-Object -First 1 + if ($null -eq $x64Package) { + throw "MSIX bundle '$Path' does not contain an x64 package." + } + return [pscustomobject]@{ + Name = [string]$identity.Name + Publisher = [string]$identity.Publisher + Architecture = 'x64' + Version = [string]$x64Package.Version + BundleVersion = [string]$identity.Version + DeliveryType = 'bundle' + } + } + + $identity = $manifest.Package.Identity + [pscustomobject]@{ + Name = [string]$identity.Name + Publisher = [string]$identity.Publisher + Architecture = [string]$identity.ProcessorArchitecture + Version = [string]$identity.Version + BundleVersion = $null + DeliveryType = 'standalone' + } + } + finally { + $archive.Dispose() + } +} + +function Get-GatewayPackages { + @(Get-AppxPackage -Name 'OpenClaw.Gateway' -ErrorAction SilentlyContinue) +} + +$testOwnsPackage = $false +$testPackageFamilyName = $null + +function Remove-TestPackage { + if (-not $script:testOwnsPackage) { + return + } + + $packages = @(Get-GatewayPackages) + if ($packages.Count -gt 1) { + throw 'More than one OpenClaw.Gateway registration exists during cleanup.' + } + if ($packages.Count -eq 1) { + if ( + $null -ne $script:testPackageFamilyName -and + [string]$packages[0].PackageFamilyName -cne + $script:testPackageFamilyName + ) { + throw 'Refusing to remove an OpenClaw package not owned by this test.' + } + Remove-AppxPackage ` + -Package $packages[0].PackageFullName ` + -ErrorAction Stop + } + + $script:testOwnsPackage = $false + $script:testPackageFamilyName = $null +} + +function Install-TestPackage { + param([Parameter(Mandatory)][string]$Path) + + if (@(Get-GatewayPackages).Count -ne 0) { + throw 'Refusing to install over an OpenClaw package not owned by this test.' + } + # The clean-machine guard above establishes ownership before installation, + # allowing finally cleanup even if installation only partially succeeds. + $script:testOwnsPackage = $true + Add-AppxPackage -Path $Path -ErrorAction Stop + $packages = @(Get-GatewayPackages) + if ($packages.Count -ne 1) { + throw 'Windows did not create exactly one OpenClaw.Gateway registration.' + } + $script:testPackageFamilyName = [string]$packages[0].PackageFamilyName + $packages[0] +} + +$resolvedBaselinesPath = (Resolve-Path -LiteralPath $BaselinesPath).Path +$resolvedBaselinesDirectory = ( + Resolve-Path -LiteralPath $BaselinesDirectory +).Path +$resolvedCandidatePath = ( + Resolve-Path -LiteralPath $CandidatePackagePath +).Path +$resolvedCandidateBundlePath = ( + Resolve-Path -LiteralPath $CandidateBundlePath +).Path +$resolvedCertificatePath = ( + Resolve-Path -LiteralPath $CandidateCertificatePath +).Path +$candidateIdentity = Read-MSIXIdentity -Path $resolvedCandidatePath +$candidateBundleIdentity = Read-MSIXIdentity -Path $resolvedCandidateBundlePath +if ( + $candidateIdentity.Name -cne 'OpenClaw.Gateway' -or + $candidateIdentity.Architecture -cne 'x64' -or + $candidateIdentity.Version -cne $ExpectedCandidateVersion +) { + throw 'The candidate MSIX identity is unexpected.' +} +if ( + $candidateBundleIdentity.Name -cne $candidateIdentity.Name -or + $candidateBundleIdentity.Publisher -cne $candidateIdentity.Publisher -or + $candidateBundleIdentity.Architecture -cne 'x64' -or + $candidateBundleIdentity.Version -cne $ExpectedCandidateVersion -or + $candidateBundleIdentity.DeliveryType -cne 'bundle' +) { + throw 'The candidate MSIX bundle identity is unexpected.' +} + +$policy = Get-Content ` + -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json') ` + -Raw | + ConvertFrom-Json +if ($candidateIdentity.Publisher -cne [string]$policy.publisher) { + throw 'The candidate MSIX publisher does not match release policy.' +} + +$baselineManifest = Get-Content -LiteralPath $resolvedBaselinesPath -Raw | + ConvertFrom-Json +if ($baselineManifest.baselines.Count -ne 4) { + throw 'Upgrade validation requires standalone and bundle proof-release baselines.' +} +if (@(Get-GatewayPackages).Count -ne 0) { + throw ( + 'Refusing to run MSIX upgrade validation while OpenClaw.Gateway is ' + + 'already installed. Use an isolated clean test account.' + ) +} + +$certificate = Import-Certificate ` + -FilePath $resolvedCertificatePath ` + -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' +$results = [Collections.Generic.List[object]]::new() +$freshInstall = $null + +try { + foreach ($baseline in $baselineManifest.baselines) { + Remove-TestPackage + $baselinePath = Join-Path ` + $resolvedBaselinesDirectory ` + ([string]$baseline.assetName) + if (-not (Test-Path -LiteralPath $baselinePath -PathType Leaf)) { + throw "Missing upgrade baseline '$($baseline.assetName)'." + } + $actualHash = ( + Get-FileHash -LiteralPath $baselinePath -Algorithm SHA256 + ).Hash.ToLowerInvariant() + if ($actualHash -cne [string]$baseline.sha256) { + throw "Upgrade baseline '$($baseline.assetName)' failed hash validation." + } + + $deliveryType = [string]$baseline.deliveryType + if ($deliveryType -notin @('standalone', 'bundle')) { + throw "Unknown delivery type '$deliveryType'." + } + $baselineIdentity = Read-MSIXIdentity -Path $baselinePath + if ( + $baselineIdentity.Name -cne $candidateIdentity.Name -or + $baselineIdentity.Publisher -cne $candidateIdentity.Publisher -or + $baselineIdentity.Architecture -cne 'x64' -or + $baselineIdentity.Version -cne [string]$baseline.packageVersion -or + $baselineIdentity.DeliveryType -cne $deliveryType + ) { + throw "Upgrade baseline '$($baseline.assetName)' has an unexpected identity." + } + if ( + [version]$candidateIdentity.Version -le + [version]$baselineIdentity.Version + ) { + throw 'The candidate MSIX must be newer than every upgrade baseline.' + } + + $installedBaseline = Install-TestPackage -Path $baselinePath + if ( + $null -eq $installedBaseline -or + [string]$installedBaseline.Version -cne $baselineIdentity.Version -or + [string]$installedBaseline.Status -cne 'Ok' + ) { + throw "Windows did not install '$($baseline.assetName)' successfully." + } + + $localState = Join-Path ` + $env:LOCALAPPDATA ` + "Packages\$($installedBaseline.PackageFamilyName)\LocalState" + New-Item -Path $localState -ItemType Directory -Force | Out-Null + $markerPath = Join-Path $localState 'msix-upgrade-proof.txt' + $marker = "upgrade-from-$($baseline.packageVersion)" + Set-Content -LiteralPath $markerPath -Value $marker -Encoding utf8 + + $candidatePath = if ($deliveryType -ceq 'bundle') { + $resolvedCandidateBundlePath + } + else { + $resolvedCandidatePath + } + Add-AppxPackage ` + -Path $candidatePath ` + -ForceApplicationShutdown ` + -ErrorAction Stop + $installedCandidate = Get-AppxPackage -Name $candidateIdentity.Name + if ( + $null -eq $installedCandidate -or + [string]$installedCandidate.Version -cne $candidateIdentity.Version -or + [string]$installedCandidate.Status -cne 'Ok' + ) { + throw "Windows did not upgrade from '$($baseline.assetName)'." + } + $candidateLocalState = Join-Path ` + $env:LOCALAPPDATA ` + "Packages\$($installedCandidate.PackageFamilyName)\LocalState" + $retainedMarkerPath = Join-Path ` + $candidateLocalState ` + 'msix-upgrade-proof.txt' + if ( + $installedCandidate.PackageFamilyName -cne + $installedBaseline.PackageFamilyName -or + -not (Test-Path -LiteralPath $retainedMarkerPath -PathType Leaf) -or + (Get-Content -LiteralPath $retainedMarkerPath -Raw).Trim() -cne $marker + ) { + throw "LocalState was not retained across the $($baseline.packageVersion) upgrade." + } + + $results.Add([pscustomobject]@{ + baselineRelease = [string]$baseline.releaseTag + deliveryType = $deliveryType + baselineVersion = $baselineIdentity.Version + candidateVersion = $candidateIdentity.Version + packageFamilyName = [string]$installedCandidate.PackageFamilyName + status = [string]$installedCandidate.Status + localStateRetained = $true + }) + } + + $freshInstalls = [Collections.Generic.List[object]]::new() + foreach ($candidate in @( + [pscustomobject]@{ + deliveryType = 'standalone' + path = $resolvedCandidatePath + }, + [pscustomobject]@{ + deliveryType = 'bundle' + path = $resolvedCandidateBundlePath + } + )) { + Remove-TestPackage + $installedFresh = Install-TestPackage -Path $candidate.path + if ( + $null -eq $installedFresh -or + [string]$installedFresh.Version -cne $candidateIdentity.Version -or + [string]$installedFresh.Status -cne 'Ok' + ) { + throw "Windows did not accept a fresh $($candidate.deliveryType) installation." + } + $freshInstalls.Add([pscustomobject]@{ + deliveryType = $candidate.deliveryType + candidateVersion = $candidateIdentity.Version + packageFamilyName = [string]$installedFresh.PackageFamilyName + status = [string]$installedFresh.Status + }) + } + $freshInstall = $freshInstalls +} +finally { + Remove-TestPackage + if ($null -ne $certificate) { + Remove-Item ` + -LiteralPath "Cert:\LocalMachine\TrustedPeople\$($certificate.Thumbprint)" ` + -Force ` + -ErrorAction SilentlyContinue + } +} + +$evidenceDirectory = Split-Path -Parent $EvidencePath +if (-not [string]::IsNullOrWhiteSpace($evidenceDirectory)) { + New-Item -Path $evidenceDirectory -ItemType Directory -Force | Out-Null +} +[pscustomobject]@{ + testedAt = (Get-Date).ToUniversalTime().ToString('o') + runner = [Environment]::OSVersion.VersionString + candidateVersion = $candidateIdentity.Version + freshInstall = $freshInstall + transitions = $results +} | + ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath $EvidencePath -Encoding utf8 + +Write-Host "MSIX upgrade compatibility passed for $($results.Count) proof-release paths." diff --git a/scripts/Test-Sign-TestMSIX.Tests.ps1 b/scripts/Test-Sign-TestMSIX.Tests.ps1 index 88338e1c..e305019c 100644 --- a/scripts/Test-Sign-TestMSIX.Tests.ps1 +++ b/scripts/Test-Sign-TestMSIX.Tests.ps1 @@ -35,8 +35,8 @@ try { if ($exitCode -eq 0) { throw 'Signing unexpectedly succeeded without an architecture directory.' } - if ($message -notmatch 'No architecture directories were found') { - throw "Signing failure did not identify the missing architecture directories. Output: $message" + if ($message -notmatch 'No signable package directories were found') { + throw "Signing failure did not identify the missing package directories. Output: $message" } if (Test-Path -LiteralPath $outputDirectory) { throw 'Signing created an output directory despite finding no architecture directory.' diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 003c6bb4..b6bd73b2 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -8,12 +8,12 @@ $repositoryRoot = Split-Path $PSScriptRoot -Parent $policyPath = Join-Path $repositoryRoot 'release-policy.json' $policy = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json $approvedCommit = [string]$policy.approvedCommit -$approvedPackageVersion = & ( - Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1' +$releaseIdentity = & ( + Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1' ) ` - -RunNumber 1 ` - -RunAttempt 1 ` - -ReleaseVersion ([string]$policy.packageVersion) + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) +$approvedPackageVersion = $releaseIdentity.PackageVersion $approvedPayloadVersion = [string]$policy.payloadPackageVersion $packagingCommit = '1111111111111111111111111111111111111111' $testRoot = Join-Path $env:TEMP ( @@ -233,7 +233,7 @@ function New-TestBundle { $Root ` 'x64\OpenClawGateway-x64.msix'), - [string]$BundleVersion = '2026.912.815.0' + [string]$BundleVersion = $approvedPackageVersion ) $bundleDirectory = Join-Path $Root 'bundle' @@ -386,6 +386,14 @@ try { -MessagePattern 'Node.js runtime versions do not match' ` -Action { Invoke-PolicyValidation -Root $testRoot } + Reset-TestArtifacts + New-TestBundle -Root $testRoot -BundleVersion '2026.8.2001.0' + Assert-Fails ` + -MessagePattern 'bundle manifest identity is unexpected' ` + -Action { + Invoke-PolicyValidation -Root $testRoot -PreserveBundle + } + Reset-TestArtifacts Assert-Fails ` -MessagePattern 'approved immutable OpenClaw commit' ` @@ -413,7 +421,7 @@ try { New-TestArtifact ` -Root $testRoot ` -Architecture x64 ` - -PayloadPackageVersion '2026.9.4' + -PayloadPackageVersion '2026.9.3' New-TestArtifact -Root $testRoot -Architecture arm64 Assert-Fails ` -MessagePattern 'metadata is not eligible' ` diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 14c6171b..8811f68f 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -153,22 +153,22 @@ $policy = Get-Content -LiteralPath $resolvedPolicyPath -Raw | if ( $policy.repository -ne 'https://github.com/openclaw/openclaw' -or - [string]::IsNullOrWhiteSpace([string]$policy.releaseTag) -or - $policy.packageVersion -notmatch '^\d+\.\d+\.\d+\.\d+$' -or - $policy.releaseTag -ne "v$($policy.packageVersion)" -or + [string]::IsNullOrWhiteSpace([string]$policy.gatewayTag) -or + $policy.msixRevision -isnot [int64] -or [string]::IsNullOrWhiteSpace([string]$policy.payloadPackageVersion) -or + $policy.gatewayTag -ne "v$($policy.payloadPackageVersion)" -or $policy.approvedCommit -notmatch '^[0-9a-fA-F]{40}$' -or [string]::IsNullOrWhiteSpace([string]$policy.publisher) ) { throw 'The Gateway MSIX release policy is invalid.' } -$approvedPackageVersion = & ( - Join-Path $PSScriptRoot 'Get-WorkflowPackageVersion.ps1' +$releaseIdentity = & ( + Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1' ) ` - -RunNumber 1 ` - -RunAttempt 1 ` - -ReleaseVersion ([string]$policy.packageVersion) + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) +$approvedPackageVersion = $releaseIdentity.PackageVersion $approvedPayloadVersion = [string]$policy.payloadPackageVersion $approvedCommit = ([string]$policy.approvedCommit).ToLowerInvariant() $normalizedRequestedRef = $RequestedRef.Trim().ToLowerInvariant() @@ -446,7 +446,8 @@ try { $null -eq $bundleIdentity -or $bundleIdentity.Name -ne 'OpenClaw.Gateway' -or $bundleIdentity.Publisher -ne $policy.publisher -or - -not $bundleVersionIsValid + -not $bundleVersionIsValid -or + $bundleVersion -ne $approvedPackageVersion ) { throw 'The MSIX bundle manifest identity is unexpected.' } diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index 31c73bdf..aae8fced 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -43,6 +43,15 @@ $requiredFragments = @( 'uses: azure/artifact-signing-action@v2' 'name: Compose unsigned multi-architecture MSIX bundle' 'name: Upload unsigned multi-architecture MSIX bundle' + 'name: Test proof-release MSIX upgrades' + "needs.changes.outputs.versioning == 'true'" + 'scripts/Test-MSIXReleaseIdentity.Tests.ps1' + '.\scripts\msix-upgrade-baselines.json' + '.\scripts\Test-MSIXUpgrade.ps1' + 'name: Download unsigned bundle candidate' + '-CandidateBundlePath test-signed\bundle\OpenClawGateway.msixbundle' + 'openclaw-gateway-msix-upgrade-evidence' + 'retention-days: 90' '-BundlePath artifacts\bundle\OpenClawGateway.msixbundle' 'files-folder-recurse: true' 'files: ${{ github.workspace }}\artifacts\bundle\OpenClawGateway.msixbundle' @@ -51,6 +60,7 @@ $requiredFragments = @( 'signing-account-name: openclaw' 'certificate-profile-name: openclaw' 'name: Publish signed Gateway MSIX release' + '.\scripts\Get-MSIXReleaseIdentity.ps1' 'contents: write' 'uses: softprops/action-gh-release@v3' 'tag_name: ${{ needs.authorize-signing.outputs.release_tag }}' diff --git a/scripts/msix-upgrade-baselines.json b/scripts/msix-upgrade-baselines.json new file mode 100644 index 00000000..2b50caa9 --- /dev/null +++ b/scripts/msix-upgrade-baselines.json @@ -0,0 +1,32 @@ +{ + "baselines": [ + { + "releaseTag": "v0.0.0.0", + "deliveryType": "standalone", + "assetName": "OpenClawGateway-0.0.0.0-x64.msix", + "packageVersion": "0.0.0.0", + "sha256": "3f288e267de01f8f4e25897a6c5bb3db9faaa09253360fb83e2247dcfffaf76b" + }, + { + "releaseTag": "v0.0.0.1", + "deliveryType": "standalone", + "assetName": "OpenClawGateway-0.0.0.1-x64.msix", + "packageVersion": "0.0.0.1", + "sha256": "098eae798413f3b831126d9806c0cb47695280cc9b2b4d52696bfde075aa1747" + }, + { + "releaseTag": "v0.0.0.0", + "deliveryType": "bundle", + "assetName": "OpenClawGateway-0.0.0.0.msixbundle", + "packageVersion": "0.0.0.0", + "sha256": "6dba82cc6cc0bf475463368be9d3899dff31addb840663d66587ad1267f3bb6e" + }, + { + "releaseTag": "v0.0.0.1", + "deliveryType": "bundle", + "assetName": "OpenClawGateway-0.0.0.1.msixbundle", + "packageVersion": "0.0.0.1", + "sha256": "48a65dd7bdc515acac96f73129aed9a343604bbfb3ceed8be4e5bd5367fd85c4" + } + ] +} From 8d98d48a5a2f8072b7039365fd1c6f73bc3067b7 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:48 -0700 Subject: [PATCH 53/76] Add console restoration and runtime environment policy (#43) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .editorconfig | 17 + .../OpenClawRuntimeEnvironment.cs | 142 +++++++++ src/OpenClaw.Launcher/WindowsHostConsole.cs | 294 ++++++++++++++++++ .../OpenClawRuntimeEnvironmentTests.cs | 132 ++++++++ .../WindowsHostConsoleTests.cs | 206 ++++++++++++ 5 files changed, 791 insertions(+) create mode 100644 src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs create mode 100644 src/OpenClaw.Launcher/WindowsHostConsole.cs create mode 100644 tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs diff --git a/.editorconfig b/.editorconfig index c1c76822..1f3addc8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -266,3 +266,20 @@ dotnet_diagnostic.CA1859.severity = error # xUnit theory and member data intentionally expose public members, and test # names describe scenarios rather than following production naming. dotnet_diagnostic.CA1515.severity = none + +# A test writes and reads its own fixture files. Synchronous file access keeps +# arrange and assert readable, and the thread-pool starvation this rule guards +# against does not apply to a test that owns its whole process. +dotnet_diagnostic.CA1849.severity = none + +# xUnit owns the lifetime of the fixture objects a test constructs, and test +# doubles that implement IDisposable are disposed by the class fixture. +dotnet_diagnostic.CA2000.severity = none + +# Arguments reaching a test helper come from the test itself, so a null guard +# would assert against a value the test just wrote. +dotnet_diagnostic.CA1062.severity = none + +# A helper that does not yet touch instance state is still part of the +# fixture's shape; forcing it static churns tests for no behavioral gain. +dotnet_diagnostic.CA1822.severity = none diff --git a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs new file mode 100644 index 00000000..55affd7f --- /dev/null +++ b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs @@ -0,0 +1,142 @@ +namespace OpenClaw.Launcher; + +/// +/// The environment every OpenClaw process this package starts must receive. +/// +/// +/// These three variables are what tell OpenClaw that this package owns +/// supervision, repair, and updates. They are built in one place so a +/// foreground launch, a session launch, a supervisor, and a logon launch cannot +/// drift apart and leave one path where OpenClaw supervises or updates itself. +/// +internal static class OpenClawRuntimeEnvironment +{ + public const string SupervisorModeVariable = "OPENCLAW_SUPERVISOR_MODE"; + public const string ServiceRepairPolicyVariable = "OPENCLAW_SERVICE_REPAIR_POLICY"; + public const string NoAutoUpdateVariable = "OPENCLAW_NO_AUTO_UPDATE"; + + public const string ExternalValue = "external"; + public const string NoAutoUpdateValue = "1"; + private const string ForceColorVariable = "FORCE_COLOR"; + private const string WindowsTerminalSessionVariable = "WT_SESSION"; + private const string NoColorVariable = "NO_COLOR"; + private const string PerfVariable = "OPENCLAW_PERF"; + private const string LogLevelVariable = "OPENCLAW_LOG_LEVEL"; + private const string GatewayStartupTraceVariable = "OPENCLAW_GATEWAY_STARTUP_TRACE"; + private const string DiagnosticsVariable = "OPENCLAW_DIAGNOSTICS"; + private const string DiagnosticsEventLoopVariable = "OPENCLAW_DIAGNOSTICS_EVENT_LOOP"; + private const string HandshakeTimeoutVariable = "OPENCLAW_HANDSHAKE_TIMEOUT_MS"; + + /// + /// The variables to apply, as an ordinary dictionary. + /// + public static IReadOnlyDictionary Build() => + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [SupervisorModeVariable] = ExternalValue, + [ServiceRepairPolicyVariable] = ExternalValue, + [NoAutoUpdateVariable] = NoAutoUpdateValue, + }; + + public static IReadOnlyDictionary Build( + bool isInteractive, + Func readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + + Dictionary result = new(Build(), StringComparer.OrdinalIgnoreCase); + string? forceColor = readEnvironmentVariable(ForceColorVariable); + string? wtSession = readEnvironmentVariable(WindowsTerminalSessionVariable); + string? noColor = readEnvironmentVariable(NoColorVariable); + + AddIfPresent(result, ForceColorVariable, forceColor); + AddIfPresent(result, WindowsTerminalSessionVariable, wtSession); + AddIfPresent(result, NoColorVariable, noColor); + + if (isInteractive) + { + if (forceColor is null && noColor is null) + { + result[ForceColorVariable] = "3"; + } + + if (wtSession is null) + { + result[WindowsTerminalSessionVariable] = "1"; + } + } + + string? perf = readEnvironmentVariable(PerfVariable); + string? logLevel = readEnvironmentVariable(LogLevelVariable); + string? gatewayStartupTrace = readEnvironmentVariable(GatewayStartupTraceVariable); + string? diagnostics = readEnvironmentVariable(DiagnosticsVariable); + string? diagnosticsEventLoop = readEnvironmentVariable(DiagnosticsEventLoopVariable); + string? handshakeTimeout = readEnvironmentVariable(HandshakeTimeoutVariable); + AddIfPresent(result, PerfVariable, perf); + AddIfPresent(result, LogLevelVariable, logLevel); + AddIfPresent(result, GatewayStartupTraceVariable, gatewayStartupTrace); + AddIfPresent(result, DiagnosticsVariable, diagnostics); + AddIfPresent(result, DiagnosticsEventLoopVariable, diagnosticsEventLoop); + AddIfPresent(result, HandshakeTimeoutVariable, handshakeTimeout); + + if (perf == "1") + { + AddDefault(result, LogLevelVariable, logLevel, "debug"); + AddDefault(result, GatewayStartupTraceVariable, gatewayStartupTrace, "1"); + AddDefault(result, DiagnosticsVariable, diagnostics, "1"); + AddDefault(result, DiagnosticsEventLoopVariable, diagnosticsEventLoop, "1"); + AddDefault(result, HandshakeTimeoutVariable, handshakeTimeout, "60000"); + } + + return result; + } + + /// + /// Applies the variables to a process environment. + /// + public static void ApplyTo(IDictionary environment) + { + ArgumentNullException.ThrowIfNull(environment); + + foreach ((string name, string value) in Build()) + { + environment[name] = value; + } + } + + public static void ApplyTo( + IDictionary environment, + bool isInteractive, + Func readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(environment); + + foreach ((string name, string value) in Build(isInteractive, readEnvironmentVariable)) + { + environment[name] = value; + } + } + + private static void AddIfPresent( + Dictionary environment, + string name, + string? value) + { + if (value is not null) + { + environment[name] = value; + } + } + + private static void AddDefault( + Dictionary environment, + string name, + string? currentValue, + string value) + { + if (currentValue is null) + { + environment[name] = value; + } + } +} diff --git a/src/OpenClaw.Launcher/WindowsHostConsole.cs b/src/OpenClaw.Launcher/WindowsHostConsole.cs new file mode 100644 index 00000000..838f5680 --- /dev/null +++ b/src/OpenClaw.Launcher/WindowsHostConsole.cs @@ -0,0 +1,294 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace OpenClaw.Launcher; + +internal interface IHostConsole +{ + IDisposable Capture(Action log); + + void InitializeUtf8(); + + bool IsInteractive { get; } +} + +internal interface IConsoleNativeApi +{ + nint GetStdHandle(uint standardHandle); + bool GetConsoleMode(nint handle, out uint mode); + bool SetConsoleMode(nint handle, uint mode); + uint GetConsoleCP(); + uint GetConsoleOutputCP(); + bool SetConsoleCP(uint codePage); + bool SetConsoleOutputCP(uint codePage); + bool WriteConsole(nint handle, string text); +} + +internal interface IConsoleEncoding +{ + Encoding InputEncoding { get; set; } + + Encoding OutputEncoding { get; set; } +} + +internal sealed class WindowsHostConsole : IHostConsole +{ + private const uint StdInput = unchecked(0xFFFFFFF6); + private const uint StdOutput = unchecked(0xFFFFFFF5); + private const uint StdError = unchecked(0xFFFFFFF4); + private const uint EnableProcessedOutput = 0x0001; + private const uint EnableVirtualTerminalProcessing = 0x0004; + private const uint InvalidHandle = unchecked(0xFFFFFFFF); + private const string ResetSequence = + "\u001b[?9001l\u001b[?2004l\u001b[?1000l\u001b[?1002l" + + "\u001b[?1003l\u001b[?1006l\u001b[?1015l\u001b[?1004l" + + "\u001b[?7h\u001b[?25h\u001b[0m"; + + internal static WindowsHostConsole Instance { get; } = new(); + + private readonly IConsoleNativeApi _native; + private readonly IConsoleEncoding _encoding; + + internal WindowsHostConsole( + IConsoleNativeApi? native = null, + IConsoleEncoding? encoding = null) + { + _native = native ?? new ConsoleNativeApi(); + _encoding = encoding ?? new ConsoleEncoding(); + } + + public bool IsInteractive + { + get + { + nint output = _native.GetStdHandle(StdOutput); + return output != 0 && output != InvalidHandle && + _native.GetConsoleMode(output, out _); + } + } + + public void InitializeUtf8() + { + TrySetCodePage(_native.SetConsoleCP, 65001); + TrySetCodePage(_native.SetConsoleOutputCP, 65001); + TrySetEncoding( + encoding => _encoding.InputEncoding = encoding, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + TrySetEncoding( + encoding => _encoding.OutputEncoding = encoding, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + public IDisposable Capture(Action log) + { + ArgumentNullException.ThrowIfNull(log); + return new ConsoleCapture(_native, _encoding, log); + } + + private static void TrySetCodePage(Func setter, uint codePage) + { + try + { + setter(codePage); + } + catch (Exception exception) when (exception is IOException or + InvalidOperationException or System.ComponentModel.Win32Exception) + { + } + } + + private static void TrySetEncoding(Action setter, Encoding encoding) + { + try + { + setter(encoding); + } + catch (Exception exception) when ( + exception is IOException or InvalidOperationException) + { + } + } + + private sealed class ConsoleCapture : IDisposable + { + private readonly IConsoleNativeApi _native; + private readonly IConsoleEncoding _encoding; + private readonly Action _log; + private readonly uint _inputMode; + private readonly List<(nint Handle, uint Mode)> _outputModes = []; + private readonly uint _inputCodePage; + private readonly uint _outputCodePage; + private readonly Encoding _inputEncoding; + private readonly Encoding _outputEncoding; + private readonly nint _cleanupOutput; + private int _disposed; + + public ConsoleCapture( + IConsoleNativeApi native, + IConsoleEncoding encoding, + Action log) + { + _native = native; + _encoding = encoding; + _log = log; + _inputCodePage = native.GetConsoleCP(); + _outputCodePage = native.GetConsoleOutputCP(); + _inputEncoding = encoding.InputEncoding; + _outputEncoding = encoding.OutputEncoding; + nint input = native.GetStdHandle(StdInput); + if (native.GetConsoleMode(input, out uint inputMode)) + { + _inputMode = inputMode; + HasInput = true; + } + + foreach (nint output in new[] { + native.GetStdHandle(StdOutput), native.GetStdHandle(StdError) }) + { + if (output == 0 || output == InvalidHandle || + !native.GetConsoleMode(output, out uint mode)) + { + continue; + } + + _outputModes.Add((output, mode)); + if (_cleanupOutput == 0) + { + _cleanupOutput = output; + } + } + } + + private bool HasInput { get; } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_cleanupOutput != 0) + { + try + { + _native.GetConsoleMode(_cleanupOutput, out uint current); + _native.SetConsoleMode( + _cleanupOutput, + current | EnableProcessedOutput | EnableVirtualTerminalProcessing); + _native.WriteConsole(_cleanupOutput, ResetSequence); + } + catch (Exception exception) when (exception is InvalidOperationException or + System.ComponentModel.Win32Exception) + { + _log($"Console cleanup failed: {exception.Message}"); + } + } + + if (HasInput) + { + TryRestore( + "input mode", + () => _native.SetConsoleMode( + _native.GetStdHandle(StdInput), _inputMode)); + } + + foreach ((nint handle, uint mode) in _outputModes) + { + TryRestore( + "output mode", + () => _native.SetConsoleMode(handle, mode)); + } + + // Console's managed encoding setters update the native code page. + // Restore them first so the captured code pages remain authoritative. + TryRestore("input encoding", () => _encoding.InputEncoding = _inputEncoding); + TryRestore("output encoding", () => _encoding.OutputEncoding = _outputEncoding); + TryRestore("input code page", () => + { + if (_inputCodePage != 0) + { + _native.SetConsoleCP(_inputCodePage); + } + }); + TryRestore("output code page", () => + { + if (_outputCodePage != 0) + { + _native.SetConsoleOutputCP(_outputCodePage); + } + }); + } + + private void TryRestore(string name, Action restore) + { + try + { + restore(); + } + catch (Exception exception) when (exception is IOException or + InvalidOperationException or System.ComponentModel.Win32Exception) + { + _log($"Console {name} restoration failed: {exception.Message}"); + } + } + } + + private sealed class ConsoleNativeApi : IConsoleNativeApi + { + public nint GetStdHandle(uint standardHandle) => Native.GetStdHandle(standardHandle); + public bool GetConsoleMode(nint handle, out uint mode) => + Native.GetConsoleMode(handle, out mode); + public bool SetConsoleMode(nint handle, uint mode) => + Native.SetConsoleMode(handle, mode); + public uint GetConsoleCP() => Native.GetConsoleCP(); + public uint GetConsoleOutputCP() => Native.GetConsoleOutputCP(); + public bool SetConsoleCP(uint codePage) => Native.SetConsoleCP(codePage); + public bool SetConsoleOutputCP(uint codePage) => + Native.SetConsoleOutputCP(codePage); + public bool WriteConsole(nint handle, string text) => + Native.WriteConsole(handle, text, text.Length, out _, 0); + } + + private sealed class ConsoleEncoding : IConsoleEncoding + { + public Encoding InputEncoding + { + get => Console.InputEncoding; + set => Console.InputEncoding = value; + } + + public Encoding OutputEncoding + { + get => Console.OutputEncoding; + set => Console.OutputEncoding = value; + } + } + + private static class Native + { + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern nint GetStdHandle(uint standardHandle); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GetConsoleMode(nint handle, out uint mode); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetConsoleMode(nint handle, uint mode); + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern uint GetConsoleCP(); + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern uint GetConsoleOutputCP(); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetConsoleCP(uint codePage); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetConsoleOutputCP(uint codePage); + [DllImport("kernel32.dll", EntryPoint = "WriteConsoleW", SetLastError = true, + CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool WriteConsole( + nint handle, string text, int chars, out int written, nint reserved); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs b/tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs new file mode 100644 index 00000000..45ade44a --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/OpenClawRuntimeEnvironmentTests.cs @@ -0,0 +1,132 @@ +namespace OpenClaw.Launcher.Tests; + +public sealed class OpenClawRuntimeEnvironmentTests +{ + [Fact] + public void BuildInteractiveAddsOnlyMissingTerminalHints() + { + IReadOnlyDictionary result = Build(true, new Dictionary()); + + Assert.Equal("3", result["FORCE_COLOR"]); + Assert.Equal("1", result["WT_SESSION"]); + Assert.False(result.ContainsKey("NO_COLOR")); + } + + [Fact] + public void BuildPreservesExplicitTerminalValuesAndNoColor() + { + IReadOnlyDictionary result = Build( + true, + new Dictionary + { + ["FORCE_COLOR"] = "0", + ["WT_SESSION"] = "real-session", + ["NO_COLOR"] = string.Empty, + }); + + Assert.Equal("0", result["FORCE_COLOR"]); + Assert.Equal("real-session", result["WT_SESSION"]); + Assert.Equal(string.Empty, result["NO_COLOR"]); + } + + [Fact] + public void BuildDetachedDoesNotSynthesizeTerminalHints() + { + IReadOnlyDictionary result = Build(false, new Dictionary()); + + Assert.DoesNotContain("FORCE_COLOR", result.Keys); + Assert.DoesNotContain("WT_SESSION", result.Keys); + } + + [Fact] + public void BuildPerfAddsDefaultsButExplicitValuesWin() + { + IReadOnlyDictionary result = Build( + false, + new Dictionary + { + ["OPENCLAW_PERF"] = "1", + ["OPENCLAW_LOG_LEVEL"] = "trace", + ["OPENCLAW_GATEWAY_STARTUP_TRACE"] = "0", + ["OPENCLAW_HANDSHAKE_TIMEOUT_MS"] = "1200", + }); + + Assert.Equal("1", result["OPENCLAW_PERF"]); + Assert.Equal("trace", result["OPENCLAW_LOG_LEVEL"]); + Assert.Equal("0", result["OPENCLAW_GATEWAY_STARTUP_TRACE"]); + Assert.Equal("1", result["OPENCLAW_DIAGNOSTICS"]); + Assert.Equal("1", result["OPENCLAW_DIAGNOSTICS_EVENT_LOOP"]); + Assert.Equal("1200", result["OPENCLAW_HANDSHAKE_TIMEOUT_MS"]); + } + + [Fact] + public void BuildLoggingAloneDoesNotEnablePerfBundle() + { + IReadOnlyDictionary result = Build( + false, + new Dictionary { ["OPENCLAW_LOG_LEVEL"] = "debug" }); + + Assert.Equal("debug", result["OPENCLAW_LOG_LEVEL"]); + Assert.DoesNotContain("OPENCLAW_GATEWAY_STARTUP_TRACE", result.Keys); + Assert.DoesNotContain("OPENCLAW_DIAGNOSTICS", result.Keys); + } + + [Fact] + public void BuildReadsEachSelectedEnvironmentVariableOnce() + { + Dictionary reads = new(StringComparer.OrdinalIgnoreCase); + + IReadOnlyDictionary result = OpenClawRuntimeEnvironment.Build( + false, + name => + { + reads[name] = reads.GetValueOrDefault(name) + 1; + return name == "OPENCLAW_PERF" ? "1" : null; + }); + + Assert.All(reads.Values, count => Assert.Equal(1, count)); + Assert.Equal("debug", result["OPENCLAW_LOG_LEVEL"]); + Assert.Equal("60000", result["OPENCLAW_HANDSHAKE_TIMEOUT_MS"]); + } + + [Fact] + public void BuildCarriesSelectedValuesButExcludesUnrelatedSensitiveEnvironment() + { + IReadOnlyDictionary result = Build( + false, + new Dictionary + { + ["OPENCLAW_DIAGNOSTICS"] = "0", + ["OPENAI_API_KEY"] = "secret", + ["PATH"] = "sensitive", + }); + + Assert.Equal("0", result["OPENCLAW_DIAGNOSTICS"]); + Assert.DoesNotContain("OPENAI_API_KEY", result.Keys); + Assert.DoesNotContain("PATH", result.Keys); + Assert.Equal("external", result["OPENCLAW_SUPERVISOR_MODE"]); + } + + [Fact] + public void ApplyToLeavesUnrelatedInputAndAddsPolicy() + { + Dictionary environment = new() + { + ["PATH"] = "unchanged", + ["CUSTOM"] = "value", + }; + + OpenClawRuntimeEnvironment.ApplyTo(environment, false, _ => null); + + Assert.Equal("unchanged", environment["PATH"]); + Assert.Equal("value", environment["CUSTOM"]); + Assert.Equal("external", environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); + } + + private static IReadOnlyDictionary Build( + bool isInteractive, + Dictionary values) => + OpenClawRuntimeEnvironment.Build( + isInteractive, + name => values.TryGetValue(name, out string? value) ? value : null); +} diff --git a/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs new file mode 100644 index 00000000..6c312dab --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs @@ -0,0 +1,206 @@ +using System.Text; + +namespace OpenClaw.Launcher.Tests; + +public sealed class WindowsHostConsoleTests +{ + [Fact] + public void CaptureRestoresModesCodePagesAndOnlyWritesVerifiedCleanup() + { + var native = new FakeConsoleNativeApi + { + InputMode = 0x11, + OutputMode = 0x04 | 0x20, + InputCodePage = 437, + OutputCodePage = 1252 + }; + var encoding = new FakeConsoleEncoding + { + InputEncoding = Encoding.ASCII, + OutputEncoding = Encoding.ASCII + }; + var console = new WindowsHostConsole(native, encoding); + + IDisposable capture = console.Capture(_ => { }); + console.InitializeUtf8(); + native.InputMode = 0; + native.OutputMode = 0x04; + native.InputCodePage = 65001; + native.OutputCodePage = 65001; + capture.Dispose(); + capture.Dispose(); + + Assert.Equal(0x11u, native.InputMode); + Assert.Equal(0x24u, native.OutputMode); + Assert.Equal(437u, native.InputCodePage); + Assert.Equal(1252u, native.OutputCodePage); + Assert.Equal(Encoding.ASCII.CodePage, encoding.InputEncoding.CodePage); + Assert.Equal(Encoding.ASCII.CodePage, encoding.OutputEncoding.CodePage); + Assert.Single(native.Writes); + Assert.DoesNotContain( + "?1049l", + native.Writes[0], + StringComparison.Ordinal); + Assert.Equal( + "\u001b[?9001l\u001b[?2004l\u001b[?1000l\u001b[?1002l" + + "\u001b[?1003l\u001b[?1006l\u001b[?1015l\u001b[?1004l" + + "\u001b[?7h\u001b[?25h\u001b[0m", + native.Writes[0]); + } + + [Fact] + public void CaptureDoesNotWriteCleanupForRedirectedOutputOrZeroCodePages() + { + var native = new FakeConsoleNativeApi + { + InputCodePage = 0, + OutputCodePage = 0, + OutputIsConsole = false + }; + var console = new WindowsHostConsole(native); + + using (console.Capture(_ => { })) + { + } + + Assert.Empty(native.Writes); + Assert.DoesNotContain(native.SetModes, entry => entry.Handle != 1); + Assert.Empty(native.SetCodePages); + } + + [Fact] + public void InitializeUtf8ToleratesEncodingSettersWithoutConsoleHandles() + { + var encoding = new FakeConsoleEncoding + { + InputEncoding = Encoding.ASCII, + OutputEncoding = Encoding.ASCII, + ThrowWhenSet = true + }; + var console = new WindowsHostConsole(new FakeConsoleNativeApi(), encoding); + + console.InitializeUtf8(); + + Assert.Equal(Encoding.ASCII.CodePage, encoding.InputEncoding.CodePage); + Assert.Equal(Encoding.ASCII.CodePage, encoding.OutputEncoding.CodePage); + } + + [Fact] + public void CaptureContinuesRestoringCodePagesWhenEncodingRestorationFails() + { + var native = new FakeConsoleNativeApi + { + InputCodePage = 437, + OutputCodePage = 1252 + }; + var encoding = new FakeConsoleEncoding + { + InputEncoding = Encoding.ASCII, + OutputEncoding = Encoding.ASCII + }; + var console = new WindowsHostConsole(native, encoding); + + using (console.Capture(_ => { })) + { + console.InitializeUtf8(); + encoding.ThrowWhenSet = true; + } + + Assert.Equal(437u, native.InputCodePage); + Assert.Equal(1252u, native.OutputCodePage); + } + + private sealed class FakeConsoleNativeApi : IConsoleNativeApi + { + public uint InputMode { get; set; } + public uint OutputMode { get; set; } + public uint InputCodePage { get; set; } + public uint OutputCodePage { get; set; } + public bool OutputIsConsole { get; set; } = true; + public List Writes { get; } = []; + public List<(nint Handle, uint Mode)> SetModes { get; } = []; + public List SetCodePages { get; } = []; + + public nint GetStdHandle(uint standardHandle) => standardHandle switch + { + 0xFFFFFFF6 => 1, + 0xFFFFFFF5 => 2, + 0xFFFFFFF4 => 3, + _ => 0 + }; + + public bool GetConsoleMode(nint handle, out uint mode) + { + if (handle == 1) + { + mode = InputMode; + return true; + } + + mode = OutputMode; + return OutputIsConsole; + } + + public bool SetConsoleMode(nint handle, uint mode) + { + SetModes.Add((handle, mode)); + if (handle == 1) + { + InputMode = mode; + } + else + { + OutputMode = mode; + } + + return true; + } + + public uint GetConsoleCP() => InputCodePage; + public uint GetConsoleOutputCP() => OutputCodePage; + public bool SetConsoleCP(uint codePage) + { + SetCodePages.Add(codePage); + InputCodePage = codePage; + return true; + } + + public bool SetConsoleOutputCP(uint codePage) + { + SetCodePages.Add(codePage); + OutputCodePage = codePage; + return true; + } + + public bool WriteConsole(nint handle, string text) + { + Writes.Add(text); + return true; + } + + } + + private sealed class FakeConsoleEncoding : IConsoleEncoding + { + private Encoding _inputEncoding = Encoding.ASCII; + private Encoding _outputEncoding = Encoding.ASCII; + + public bool ThrowWhenSet { get; set; } + + public required Encoding InputEncoding + { + get => _inputEncoding; + set => _inputEncoding = ThrowWhenSet + ? throw new IOException("No console input handle.") + : value; + } + + public required Encoding OutputEncoding + { + get => _outputEncoding; + set => _outputEncoding = ThrowWhenSet + ? throw new IOException("No console output handle.") + : value; + } + } +} From 539976ec2c36b7ce3654afcdda1e57af6ac2aedd Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:48 -0700 Subject: [PATCH 54/76] feat: add the pinned MXC isolation backend (#44) * Define MXC lifecycle contracts and wire serialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Consume MXC contracts through the CLI transport and readiness probe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Add the pinned MXC runtime acquisition and trust inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Fix deterministic MXC executor cancellation test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .github/workflows/gateway-msix.yml | 4 + .gitignore | 2 + mxc-runtime.lock.json | 65 +++ scripts/Build-MSIX.ps1 | 44 ++ scripts/Get-MxcRuntime.ps1 | 365 +++++++++++++++++ scripts/Test-Get-MxcRuntime.Tests.ps1 | 94 +++++ scripts/Test-SigningInputs.Tests.ps1 | 61 +++ scripts/Test-SigningInputs.ps1 | 86 ++++ .../Mxc/MxcCliSessionClient.cs | 383 ++++++++++++++++++ src/OpenClaw.Launcher/Mxc/MxcException.cs | 90 ++++ src/OpenClaw.Launcher/Mxc/MxcReadiness.cs | 240 +++++++++++ .../Mxc/MxcRuntimeLocator.cs | 152 +++++++ .../Mxc/MxcSessionContracts.cs | 157 +++++++ src/OpenClaw.Launcher/Mxc/MxcWireModels.cs | 165 ++++++++ src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs | 266 ++++++++++++ .../OpenClaw.Launcher.csproj | 15 + .../Mxc/MxcCliSessionClientTests.cs | 268 ++++++++++++ .../Mxc/MxcReadinessTests.cs | 197 +++++++++ .../Mxc/MxcRuntimeLocatorTests.cs | 133 ++++++ .../Mxc/MxcSandboxIdTests.cs | 38 ++ .../Mxc/MxcWireProtocolTests.cs | 321 +++++++++++++++ .../Mxc/ProcessMxcExecutorInvokerTests.cs | 156 +++++++ 22 files changed, 3302 insertions(+) create mode 100644 mxc-runtime.lock.json create mode 100644 scripts/Get-MxcRuntime.ps1 create mode 100644 scripts/Test-Get-MxcRuntime.Tests.ps1 create mode 100644 src/OpenClaw.Launcher/Mxc/MxcCliSessionClient.cs create mode 100644 src/OpenClaw.Launcher/Mxc/MxcException.cs create mode 100644 src/OpenClaw.Launcher/Mxc/MxcReadiness.cs create mode 100644 src/OpenClaw.Launcher/Mxc/MxcRuntimeLocator.cs create mode 100644 src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs create mode 100644 src/OpenClaw.Launcher/Mxc/MxcWireModels.cs create mode 100644 src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Mxc/MxcCliSessionClientTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Mxc/MxcReadinessTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Mxc/MxcRuntimeLocatorTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Mxc/MxcSandboxIdTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Mxc/MxcWireProtocolTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Mxc/ProcessMxcExecutorInvokerTests.cs diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 63f9cb20..2e2fb7a4 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -150,6 +150,10 @@ jobs: shell: pwsh run: .\scripts\Test-NodeRuntimeInputs.Tests.ps1 + - name: Test MXC packaging inputs + shell: pwsh + run: .\scripts\Test-Get-MxcRuntime.Tests.ps1 + - name: Test signing workflow configuration shell: pwsh run: > diff --git a/.gitignore b/.gitignore index 74472608..75876857 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ uninstall-validation-output/ # Generated payloads and build output content/openclaw/* !content/openclaw/.gitkeep +content/mxc/ +content/session-host/ artifacts/ test-signed/ bin/ diff --git a/mxc-runtime.lock.json b/mxc-runtime.lock.json new file mode 100644 index 00000000..ced5ef3b --- /dev/null +++ b/mxc-runtime.lock.json @@ -0,0 +1,65 @@ +{ + "$comment": [ + "Pinned MXC native runtime. This is a release trust-chain input: the", + "archive integrity, the per-file SHA-256 values, and the PE machine type", + "are all verified before any file is staged into the package.", + "", + "This pin is temporary. It is replaced by the official Microsoft.Mxc.Sdk", + ".NET package when that package is published; see docs/mxc-runtime.md.", + "", + "'wireSchemaVersion' is the state-aware envelope schema, which is", + "versioned independently of the npm package version. Both are pinned." + ], + "package": "@microsoft/mxc-sdk", + "version": "0.8.0", + "wireSchemaVersion": "0.6.0-alpha", + "minimumWindowsBuild": "26340.9212", + "tarballUrl": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.8.0.tgz", + "tarballIntegrity": "sha512-pnf5QsASwp+qtRi5uth2GDjwuyG0rHWRpxCf3RbAjQ4wDTNfBX/9l0A+RVZspU2agpF3/11uWB1JisIS7WrNYg==", + "architectures": { + "x64": { + "files": [ + { + "archivePath": "package/bin/x64/wxc-exec.exe", + "stagedPath": "wxc-exec.exe", + "length": 9478968, + "sha256": "6049c64723af1173c3739dc6cd6b2f33f6c021bb2832c4216233cba7f71aee9a", + "peMachine": "0x8664" + }, + { + "archivePath": "package/bin/x64/plm.exe", + "stagedPath": "plm.exe", + "length": 2106688, + "sha256": "f0ddecb7a1097e5108a14fcfc609dea692733d77f9f8e2435b05085b0b7b25f0", + "peMachine": "0x8664" + } + ] + }, + "arm64": { + "files": [ + { + "archivePath": "package/bin/arm64/wxc-exec.exe", + "stagedPath": "wxc-exec.exe", + "length": 4836152, + "sha256": "dde1c592270e9a659b01dccad70362da7b99fec114885fa4d625507aa775a503", + "peMachine": "0xaa64" + }, + { + "archivePath": "package/bin/arm64/plm.exe", + "stagedPath": "plm.exe", + "length": 1857848, + "sha256": "f8060c7d463479f6e64da7f1e082553113ca0a9c1c39347ae513eca4a0439dfd", + "peMachine": "0xaa64" + } + ] + } + }, + "licenseFiles": [ + { + "archivePath": "package/LICENSE.md", + "stagedPath": "LICENSE.md", + "length": 1140, + "sha256": "d9a1b1e30d633d5732ea18e3cba9538d293ebc53e1a9e4e96ab739e0c5c4f1cb" + } + ] +} diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 62da6fa1..e00225b7 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -312,6 +312,40 @@ $payloadInventoryPath = Join-Path $openClawContent 'payload-files.json' ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $payloadInventoryPath -Encoding utf8 +& (Join-Path $PSScriptRoot 'Get-MxcRuntime.ps1') ` + -Architecture $Architecture + +$mxcRuntimeDirectory = Join-Path $repositoryRoot "content\mxc\$Architecture" +$mxcRuntimeFiles = @( + Get-ChildItem -LiteralPath $mxcRuntimeDirectory -File -Force -Recurse | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $mxcRuntimeDirectory, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } | + Sort-Object path +) +if ( + $mxcRuntimeFiles.Count -eq 0 -or + -not ($mxcRuntimeFiles.path -contains 'wxc-exec.exe') -or + -not ($mxcRuntimeFiles.path -contains 'mxc-runtime.json') +) { + throw "The staged $Architecture MXC runtime is incomplete." +} +$mxcProvenance = Get-Content ` + -LiteralPath (Join-Path $mxcRuntimeDirectory 'mxc-runtime.json') ` + -Raw | + ConvertFrom-Json + $temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } @@ -402,6 +436,14 @@ try { Hash = $nodeArchiveHash } ) + foreach ($mxcFile in $mxcRuntimeFiles) { + $expectedPackageFiles.Add( + "mxc/$Architecture/$($mxcFile.path)", + [pscustomobject]@{ + Hash = $mxcFile.sha256 + } + ) + } $packageEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -544,6 +586,8 @@ try { nodeRuntimeVersion = $nodeVersion nodeRuntimeArchive = $expectedNodeArchiveName nodeRuntimeSha256 = $nodeArchiveHash + mxcRuntimeVersion = [string]$mxcProvenance.version + mxcRuntimeFiles = $mxcRuntimeFiles architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Get-MxcRuntime.ps1 b/scripts/Get-MxcRuntime.ps1 new file mode 100644 index 00000000..61895eb3 --- /dev/null +++ b/scripts/Get-MxcRuntime.ps1 @@ -0,0 +1,365 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Stages the pinned MXC native runtime for packaging. + +.DESCRIPTION + Acquires the exact npm archive named in mxc-runtime.lock.json, verifies its + registry integrity hash, extracts only the allowlisted runtime and licence + entries, and verifies each staged file's length, SHA-256, and PE machine + type before it is written to the output directory. + + The archive is treated as untrusted input until it is verified. This script + never runs 'npm install', never runs package lifecycle scripts, and never + executes any downloaded binary. + +.PARAMETER Architecture + Runtime architecture to stage. x64 and arm64 are staged separately. + +.PARAMETER ArchivePath + Optional path to an already-downloaded archive, for offline iteration. It + is subject to the same integrity, length, hash, and architecture checks as + a freshly downloaded archive. + +.PARAMETER OutputDirectory + Directory to stage into. Defaults to content\mxc\. + +.PARAMETER CacheDirectory + Directory holding verified archives. Defaults to artifacts\mxc-cache. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('x64', 'arm64')] + [string]$Architecture, + + [string]$ArchivePath, + + [string]$OutputDirectory, + + [string]$CacheDirectory, + + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$lockPath = Join-Path $repositoryRoot 'mxc-runtime.lock.json' + +function Get-Sha256Hex { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-NpmIntegrity { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $stream = [IO.File]::OpenRead($Path) + try { + $sha512 = [Security.Cryptography.SHA512]::Create() + try { + 'sha512-' + [Convert]::ToBase64String($sha512.ComputeHash($stream)) + } + finally { + $sha512.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Get-PeMachine { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $stream = [IO.File]::OpenRead($Path) + try { + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadUInt16() -ne 0x5A4D) { + throw "'$Path' is not a PE image." + } + + $stream.Position = 0x3C + $stream.Position = $reader.ReadUInt32() + if ($reader.ReadUInt32() -ne 0x00004550) { + throw "'$Path' has no PE signature." + } + + '0x{0:x4}' -f $reader.ReadUInt16() + } + finally { + $reader.Dispose() + } + } + finally { + $stream.Dispose() + } +} + +function Resolve-VerifiedArchive { + param( + [Parameter(Mandatory)] + [psobject]$Lock, + + [string]$SuppliedPath, + + [Parameter(Mandatory)] + [string]$CachePath + ) + + if ($SuppliedPath) { + if (-not (Test-Path -LiteralPath $SuppliedPath -PathType Leaf)) { + throw "The supplied MXC archive '$SuppliedPath' does not exist." + } + + $suppliedIntegrity = Get-NpmIntegrity -Path $SuppliedPath + if ($suppliedIntegrity -ne $Lock.tarballIntegrity) { + throw ( + "The supplied MXC archive does not match the pinned " + + "integrity value. Expected '$($Lock.tarballIntegrity)'; " + + "computed '$suppliedIntegrity'." + ) + } + + Write-Host "Using the verified local MXC archive '$SuppliedPath'." + return $SuppliedPath + } + + if (Test-Path -LiteralPath $CachePath -PathType Leaf) { + $cachedIntegrity = Get-NpmIntegrity -Path $CachePath + if ($cachedIntegrity -eq $Lock.tarballIntegrity) { + Write-Host "Using the verified cached MXC archive '$CachePath'." + return $CachePath + } + + Write-Warning ( + "Discarding the cached MXC archive '$CachePath': it no longer " + + 'matches the pinned integrity value.' + ) + Remove-Item -LiteralPath $CachePath -Force + } + + New-Item -Path (Split-Path $CachePath -Parent) -ItemType Directory -Force | + Out-Null + Write-Host "Downloading $($Lock.tarballUrl)." + $downloadPath = "$CachePath.$([guid]::NewGuid().ToString('N')).partial" + try { + Invoke-WebRequest ` + -Uri $Lock.tarballUrl ` + -OutFile $downloadPath ` + -MaximumRedirection 5 ` + -UseBasicParsing + + $downloadedIntegrity = Get-NpmIntegrity -Path $downloadPath + if ($downloadedIntegrity -ne $Lock.tarballIntegrity) { + throw ( + "The downloaded MXC archive does not match the pinned " + + "integrity value. Expected '$($Lock.tarballIntegrity)'; " + + "computed '$downloadedIntegrity'." + ) + } + + Move-Item -LiteralPath $downloadPath -Destination $CachePath -Force + return $CachePath + } + finally { + if (Test-Path -LiteralPath $downloadPath -PathType Leaf) { + Remove-Item -LiteralPath $downloadPath -Force + } + } +} + +if (-not (Test-Path -LiteralPath $lockPath -PathType Leaf)) { + throw "Missing the pinned runtime lock file '$lockPath'." +} + +$lock = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json +$architectureLock = $lock.architectures.$Architecture +if (-not $architectureLock) { + throw "mxc-runtime.lock.json does not pin a runtime for '$Architecture'." +} + +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $repositoryRoot "content\mxc\$Architecture" +} +if (-not $CacheDirectory) { + $CacheDirectory = Join-Path $repositoryRoot 'artifacts\mxc-cache' +} + +$provenancePath = Join-Path $OutputDirectory 'mxc-runtime.json' +$stagedEntries = @($architectureLock.files) + @($lock.licenseFiles) +$expectedStagedPaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($entry in $stagedEntries) { + [void]$expectedStagedPaths.Add( + $entry.stagedPath.Replace('/', [IO.Path]::DirectorySeparatorChar) + ) +} +[void]$expectedStagedPaths.Add('mxc-runtime.json') + +# Re-verify rather than trusting the directory's existence: a stale or +# tampered staging directory must not silently become package content. +if (-not $Force -and (Test-Path -LiteralPath $provenancePath -PathType Leaf)) { + $upToDate = $true + foreach ($entry in $stagedEntries) { + $candidate = Join-Path $OutputDirectory $entry.stagedPath + if ( + -not (Test-Path -LiteralPath $candidate -PathType Leaf) -or + (Get-Item -LiteralPath $candidate).Length -ne $entry.length -or + (Get-Sha256Hex -Path $candidate) -ne $entry.sha256 + ) { + $upToDate = $false + break + } + } + if ($upToDate) { + foreach ($candidate in @( + Get-ChildItem -LiteralPath $OutputDirectory -File -Force -Recurse + )) { + $relativePath = [IO.Path]::GetRelativePath( + $OutputDirectory, + $candidate.FullName + ) + if (-not $expectedStagedPaths.Contains($relativePath)) { + $upToDate = $false + break + } + } + } + + if ($upToDate) { + Write-Host ( + "MXC runtime $($lock.version) ($Architecture) is already staged " + + "in '$OutputDirectory'." + ) + return + } + + Write-Host 'Restaging the MXC runtime: the staged files no longer verify.' +} + +$cachePath = Join-Path ` + $CacheDirectory ` + "mxc-sdk-$($lock.version).tgz" +$verifiedArchive = Resolve-VerifiedArchive ` + -Lock $lock ` + -SuppliedPath $ArchivePath ` + -CachePath $cachePath + +$extractRoot = Join-Path ` + ([IO.Path]::GetTempPath()) ` + "openclaw-mxc-extract-$([guid]::NewGuid().ToString('N'))" +New-Item -Path $extractRoot -ItemType Directory -Force | Out-Null +try { + # Name every member explicitly so nothing outside the allowlist is written, + # and so a large archive is not fully expanded. + $members = @($stagedEntries | ForEach-Object { $_.archivePath }) + & tar -xzf $verifiedArchive -C $extractRoot @members + if ($LASTEXITCODE -ne 0) { + throw "Extracting the MXC archive failed with exit code $LASTEXITCODE." + } + + $stagingDirectory = Join-Path ` + $extractRoot ` + "staged-$([guid]::NewGuid().ToString('N'))" + New-Item -Path $stagingDirectory -ItemType Directory -Force | Out-Null + + foreach ($entry in $stagedEntries) { + $extracted = Join-Path $extractRoot ($entry.archivePath -replace '/', '\') + if (-not (Test-Path -LiteralPath $extracted -PathType Leaf)) { + throw ( + "The MXC archive does not contain '$($entry.archivePath)'. " + + 'Update mxc-runtime.lock.json before changing the pin.' + ) + } + + $length = (Get-Item -LiteralPath $extracted).Length + if ($length -ne $entry.length) { + throw ( + "'$($entry.archivePath)' is $length bytes; the pin expects " + + "$($entry.length)." + ) + } + + $sha256 = Get-Sha256Hex -Path $extracted + if ($sha256 -ne $entry.sha256) { + throw ( + "'$($entry.archivePath)' hashes to $sha256; the pin expects " + + "$($entry.sha256)." + ) + } + + if ($entry.PSObject.Properties.Name -contains 'peMachine') { + $machine = Get-PeMachine -Path $extracted + if ($machine -ne $entry.peMachine) { + throw ( + "'$($entry.archivePath)' targets machine $machine; the " + + "pin expects $($entry.peMachine) for $Architecture." + ) + } + } + + Copy-Item ` + -LiteralPath $extracted ` + -Destination (Join-Path $stagingDirectory $entry.stagedPath) ` + -Force + } + + [ordered]@{ + package = $lock.package + version = $lock.version + architecture = $Architecture + wireSchemaVersion = $lock.wireSchemaVersion + minimumWindowsBuild = $lock.minimumWindowsBuild + tarballIntegrity = $lock.tarballIntegrity + files = @( + $stagedEntries | + ForEach-Object { + [ordered]@{ + path = $_.stagedPath + length = $_.length + sha256 = $_.sha256 + } + } | + Sort-Object { $_.path } + ) + } | + # Keep the nested file records intact. + ConvertTo-Json -Depth 4 | + Set-Content ` + -LiteralPath (Join-Path $stagingDirectory 'mxc-runtime.json') ` + -Encoding utf8 + + # Publish only after every file verified, so a failed run cannot leave a + # partially staged runtime behind for the packaging build to pick up. + if (Test-Path -LiteralPath $OutputDirectory -PathType Container) { + Remove-Item -LiteralPath $OutputDirectory -Recurse -Force + } + New-Item -Path (Split-Path $OutputDirectory -Parent) ` + -ItemType Directory ` + -Force | + Out-Null + Move-Item -LiteralPath $stagingDirectory -Destination $OutputDirectory +} +finally { + if (Test-Path -LiteralPath $extractRoot -PathType Container) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } +} + +Write-Host ( + "Staged MXC runtime $($lock.package) $($lock.version) ($Architecture) " + + "in '$OutputDirectory'." +) diff --git a/scripts/Test-Get-MxcRuntime.Tests.ps1 b/scripts/Test-Get-MxcRuntime.Tests.ps1 new file mode 100644 index 00000000..223cb607 --- /dev/null +++ b/scripts/Test-Get-MxcRuntime.Tests.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$scriptPath = Join-Path $PSScriptRoot 'Get-MxcRuntime.ps1' +$testRoot = Join-Path $env:TEMP "openclaw-mxc-runtime-$([guid]::NewGuid().ToString('N'))" + +function Assert-True { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { + throw $Message + } +} + +try { + $source = Join-Path $testRoot 'source' + $package = Join-Path $source 'package' + $output = Join-Path $testRoot 'staged' + $scripts = Join-Path $testRoot 'scripts' + $archive = Join-Path $testRoot 'mxc-fixture.tgz' + New-Item -Path $package, $output, $scripts -ItemType Directory -Force | Out-Null + $runtimeFile = Join-Path $package 'fixture.bin' + [IO.File]::WriteAllText($runtimeFile, 'verified runtime') + & tar -czf $archive -C $source 'package/fixture.bin' + if ($LASTEXITCODE -ne 0) { + throw "Creating the fixture archive failed (exit $LASTEXITCODE)." + } + + $stream = [IO.File]::OpenRead($archive) + try { + $sha512 = [Security.Cryptography.SHA512]::Create() + try { + $integrity = 'sha512-' + [Convert]::ToBase64String( + $sha512.ComputeHash($stream) + ) + } + finally { + $sha512.Dispose() + } + } + finally { + $stream.Dispose() + } + + $hash = (Get-FileHash -LiteralPath $runtimeFile -Algorithm SHA256).Hash.ToLowerInvariant() + $lock = [ordered]@{ + package = '@microsoft/mxc-sdk' + version = 'fixture' + wireSchemaVersion = 'fixture' + minimumWindowsBuild = '0' + tarballUrl = 'https://example.invalid/mxc-fixture.tgz' + tarballIntegrity = $integrity + architectures = @{ + x64 = @{ + files = @(@{ + archivePath = 'package/fixture.bin' + stagedPath = 'fixture.bin' + length = (Get-Item -LiteralPath $runtimeFile).Length + sha256 = $hash + }) + } + } + licenseFiles = @() + } | ConvertTo-Json -Depth 8 + [IO.File]::WriteAllText((Join-Path $testRoot 'mxc-runtime.lock.json'), $lock) + + $fixtureScript = Join-Path $scripts 'Get-MxcRuntime.ps1' + Copy-Item -LiteralPath $scriptPath -Destination $fixtureScript + & $fixtureScript -Architecture x64 -ArchivePath $archive -OutputDirectory $output + if ($LASTEXITCODE -ne 0) { + throw "Initial MXC staging failed (exit $LASTEXITCODE)." + } + + $unexpected = Join-Path $output 'unverified.bin' + [IO.File]::WriteAllText($unexpected, 'must not be packaged') + & $fixtureScript -Architecture x64 -ArchivePath $archive -OutputDirectory $output + if ($LASTEXITCODE -ne 0) { + throw "MXC restaging failed (exit $LASTEXITCODE)." + } + + Assert-True (-not (Test-Path -LiteralPath $unexpected)) ( + 'MXC runtime reuse retained an unpinned file.' + ) + Assert-True (Test-Path -LiteralPath (Join-Path $output 'fixture.bin')) ( + 'MXC restaging did not preserve the pinned runtime file.' + ) + Write-Host 'MXC runtime staging tests passed.' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index b6bd73b2..530e19b0 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -167,6 +167,42 @@ function New-TestArtifact { Get-FileHash -LiteralPath $nodeRuntimePath -Algorithm SHA256 ).Hash.ToLowerInvariant() + $mxcRuntimeVersion = '0.8.0' + $mxcRuntimeDirectory = Join-Path $staging "mxc\$Architecture" + New-Item -Path $mxcRuntimeDirectory -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'wxc-exec.exe'), + "executor-$Architecture") + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'plm.exe'), + "plm-$Architecture") + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'LICENSE.md'), + 'fixture license') + [IO.File]::WriteAllText( + (Join-Path $mxcRuntimeDirectory 'mxc-runtime.json'), + "{`"version`":`"$mxcRuntimeVersion`",`"architecture`":`"$Architecture`"}") + $mxcRuntimeFiles = @( + Get-ChildItem -LiteralPath $mxcRuntimeDirectory -File -Recurse | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $mxcRuntimeDirectory, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash ` + -LiteralPath $_.FullName ` + -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } | + Sort-Object path + ) + $msixName = "OpenClawGateway-$Architecture.msix" $msixPath = Join-Path $directory $msixName [IO.Compression.ZipFile]::CreateFromDirectory($staging, $msixPath) @@ -189,6 +225,8 @@ function New-TestArtifact { nodeRuntimeVersion = $nodeRuntimeVersion nodeRuntimeArchive = $nodeRuntimeArchive nodeRuntimeSha256 = $nodeRuntimeHash + mxcRuntimeVersion = $mxcRuntimeVersion + mxcRuntimeFiles = $mxcRuntimeFiles architecture = $Architecture archive = $msixName sha256 = $msixHash @@ -394,6 +432,29 @@ try { Invoke-PolicyValidation -Root $testRoot -PreserveBundle } + Reset-TestArtifacts + Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { + param($Expanded) + Set-Content ` + -LiteralPath (Join-Path $Expanded 'mxc\x64\wxc-exec.exe') ` + -Value 'tampered' ` + -Encoding utf8 + } + Assert-Fails ` + -MessagePattern 'MXC runtime file is invalid' ` + -Action { Invoke-PolicyValidation -Root $testRoot } + + Reset-TestArtifacts + Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { + param($Expanded) + Remove-Item -LiteralPath ( + Join-Path $Expanded 'mxc\x64\mxc-runtime.json' + ) + } + Assert-Fails ` + -MessagePattern "Expected one 'mxc/x64/mxc-runtime.json' entry; found 0" ` + -Action { Invoke-PolicyValidation -Root $testRoot } + Reset-TestArtifacts Assert-Fails ` -MessagePattern 'approved immutable OpenClaw commit' ` diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 8811f68f..5ff7d201 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -185,6 +185,7 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null $expectedNodeRuntimeVersion = $null +$expectedMxcRuntimeVersion = $null $expectedPackages = @{} foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture @@ -220,6 +221,8 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.nodeRuntimeArchive -ne "node-v$($metadata.nodeRuntimeVersion)-win-$architecture.zip" -or $metadata.nodeRuntimeSha256 -notmatch '^[0-9a-fA-F]{64}$' -or + [string]::IsNullOrWhiteSpace([string]$metadata.mxcRuntimeVersion) -or + @($metadata.mxcRuntimeFiles).Count -eq 0 -or $metadata.architecture -ne $architecture -or $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or @@ -244,6 +247,13 @@ foreach ($architecture in @('x64', 'arm64')) { throw 'The x64 and ARM64 Node.js runtime versions do not match.' } + if ($null -eq $expectedMxcRuntimeVersion) { + $expectedMxcRuntimeVersion = [string]$metadata.mxcRuntimeVersion + } + elseif ($metadata.mxcRuntimeVersion -ne $expectedMxcRuntimeVersion) { + throw 'The x64 and ARM64 MXC runtime versions do not match.' + } + $actualMsixHash = ( Get-FileHash -LiteralPath $msix.FullName -Algorithm SHA256 ).Hash.ToLowerInvariant() @@ -292,6 +302,82 @@ foreach ($architecture in @('x64', 'arm64')) { ) ) + $expectedMxcPaths = + [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $hasMxcExecutor = $false + $hasMxcProvenance = $false + foreach ($file in @($metadata.mxcRuntimeFiles)) { + $relativePath = [string]$file.path + $segments = @($relativePath.Split('/')) + if ( + [string]::IsNullOrWhiteSpace($relativePath) -or + $relativePath.StartsWith('/') -or + [IO.Path]::IsPathRooted($relativePath) -or + $relativePath.Contains('\') -or + $relativePath.Contains(':') -or + $segments -contains '' -or + $segments -contains '.' -or + $segments -contains '..' -or + $file.length -isnot [int64] -or + $file.length -lt 0 -or + $file.sha256 -notmatch '^[0-9a-fA-F]{64}$' + ) { + throw "The embedded $architecture MXC runtime inventory is invalid." + } + + $packagePath = "mxc/$architecture/$relativePath" + if (-not $expectedMxcPaths.Add($packagePath)) { + throw ( + "The embedded $architecture MXC runtime inventory has " + + 'duplicate paths.' + ) + } + + $entry = Get-PackageEntry ` + -EntriesByPath $entriesByPath ` + -Path $packagePath + if ( + $entry.Length -ne $file.length -or + (Get-PackageEntrySha256 -Entry $entry) -ine $file.sha256 + ) { + throw ( + "The embedded $architecture MXC runtime file is invalid: " + + $relativePath + ) + } + + if ($relativePath -ieq 'wxc-exec.exe') { + $hasMxcExecutor = $true + } + if ($relativePath -ieq 'mxc-runtime.json') { + $hasMxcProvenance = $true + } + } + + if (-not $hasMxcExecutor -or -not $hasMxcProvenance) { + throw "The embedded $architecture MXC runtime is incomplete." + } + + $actualMxcPaths = @( + $entriesByPath.Keys | + Where-Object { + $_.StartsWith( + "mxc/$architecture/", + [StringComparison]::OrdinalIgnoreCase + ) + } + ) + if ( + $actualMxcPaths.Count -ne $expectedMxcPaths.Count -or + @($actualMxcPaths | Where-Object { + -not $expectedMxcPaths.Contains($_) + }).Count -ne 0 + ) { + throw "The embedded $architecture MXC runtime file set is invalid." + } + [xml]$manifest = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` -Path 'AppxManifest.xml' diff --git a/src/OpenClaw.Launcher/Mxc/MxcCliSessionClient.cs b/src/OpenClaw.Launcher/Mxc/MxcCliSessionClient.cs new file mode 100644 index 00000000..09273a6e --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcCliSessionClient.cs @@ -0,0 +1,383 @@ +using System.Diagnostics; +using System.Text; + +namespace OpenClaw.Launcher.Mxc; + +internal sealed record MxcExecutorInvocation( + string ExecutorPath, + IReadOnlyList Arguments); + +internal sealed record MxcExecutorOutcome( + int ExitCode, + string StandardOutput, + string StandardError); + +/// +/// Runs the MXC executor. Exists so lifecycle behavior can be tested without a +/// real sandbox, a capable host, or executing a downloaded binary. +/// +internal interface IMxcExecutorInvoker +{ + Task InvokeAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken); +} + +/// +/// Runs the MXC executor with the host's console streams attached. +/// +/// +/// Interactive OpenClaw cannot be served by the buffered invoker: reading both +/// streams to completion only returns after the child exits, so a prompt would +/// never reach the terminal and typed input would never reach the child. +/// Nothing is captured here, so the caller cannot read a dispatch error +/// envelope off standard output and must establish the outcome another way. +/// +internal interface IMxcAttachedExecutorInvoker +{ + Task InvokeAttachedAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken); +} + +/// +/// Temporary transport over the executor +/// published in @microsoft/mxc-sdk. +/// +/// +/// This exists only until the official Microsoft.Mxc.Sdk .NET package ships. +/// Every preview wire detail is confined to this adapter and +/// ; the published SDK adapter must satisfy the +/// same behavior. +/// +internal sealed class MxcCliSessionClient : IMxcSessionClient +{ + private readonly MxcRuntimeLocation _runtime; + private readonly IMxcExecutorInvoker _invoker; + private readonly IMxcAttachedExecutorInvoker _attachedInvoker; + + public MxcCliSessionClient( + MxcRuntimeLocation runtime, + IMxcExecutorInvoker? invoker = null, + IMxcAttachedExecutorInvoker? attachedInvoker = null) + { + _runtime = runtime; + _invoker = invoker ?? new ProcessMxcExecutorInvoker(); + _attachedInvoker = attachedInvoker ?? + invoker as IMxcAttachedExecutorInvoker ?? + new ProcessMxcExecutorInvoker(); + } + + public async Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.AppId)) + { + throw new MxcException( + MxcErrorCode.PolicyValidation, + "A packaged caller must supply PFN:."); + } + + MxcExecutorOutcome outcome = await InvokeAsync( + MxcWireProtocol.BuildProvisionEnvelope(request.AppId), + cancellationToken).ConfigureAwait(false); + return MxcWireProtocol.ReadProvisionResult(ReadResult(outcome)); + } + + public async Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + ReadResult(await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StartPhase, + sandboxId, + correlationVector), + cancellationToken).ConfigureAwait(false)); + + public async Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.CommandLine)) + { + throw new MxcException( + MxcErrorCode.PolicyValidation, + "An execution request requires a command line."); + } + + MxcExecutorOutcome outcome = await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.ExecPhase, + sandboxId, + correlationVector, + request.CommandLine), + cancellationToken).ConfigureAwait(false); + + // Execution forwards the guest command's own output and exit code. A + // dispatch failure is only claimed when the executor also failed, so a + // command that legitimately prints an error-shaped JSON document is + // reported as command output rather than an MXC fault. + if (outcome.ExitCode != 0) + { + MxcException? dispatchFailure = + MxcWireProtocol.TryParseExecutionError(outcome.StandardOutput); + if (dispatchFailure is not null) + { + throw dispatchFailure; + } + } + + return new MxcExecutionResult( + outcome.ExitCode, + outcome.StandardOutput, + outcome.StandardError); + } + + /// + /// Runs a command with the host's console streams attached. + /// + /// + /// Nothing is captured, so a dispatch failure cannot be read back as a + /// structured envelope and would instead print to the user's terminal. The + /// caller establishes the real outcome from the guest helper's control + /// result, which distinguishes "the application exited with this code" from + /// "the command never started". + /// + public Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + _attachedInvoker.InvokeAttachedAsync( + BuildInvocation(MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.ExecPhase, + sandboxId, + correlationVector, + request.CommandLine)), + cancellationToken); + + public async Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + ReadResult(await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StopPhase, + sandboxId, + correlationVector), + cancellationToken).ConfigureAwait(false)); + + public async Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + ReadResult(await InvokeAsync( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.DeprovisionPhase, + sandboxId, + correlationVector), + cancellationToken).ConfigureAwait(false)); + + /// + /// Runs the executor's host capability detector. This is the authoritative + /// answer to whether the IsolationSession backend is usable here; the + /// documented minimum Windows build only predicts it. The detector does not + /// spawn a sandbox, so this is safe on the read-only setup path. + /// + public async Task ProbeBackendAsync( + CancellationToken cancellationToken) + { + MxcExecutorOutcome outcome = await _invoker.InvokeAsync( + new MxcExecutorInvocation(_runtime.ExecutorPath, ["--probe"]), + cancellationToken).ConfigureAwait(false); + + if (outcome.ExitCode != 0 || string.IsNullOrWhiteSpace(outcome.StandardOutput)) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + "The MXC host capability probe failed " + + $"(exit code {outcome.ExitCode}). " + + Describe(outcome.StandardError)); + } + + return MxcWireProtocol.ReadProbeResponse(outcome.StandardOutput); + } + + private Task InvokeAsync( + MxcRequestEnvelope envelope, + CancellationToken cancellationToken) => + _invoker.InvokeAsync(BuildInvocation(envelope), cancellationToken); + + private MxcExecutorInvocation BuildInvocation(MxcRequestEnvelope envelope) => + new( + _runtime.ExecutorPath, + [ + "--config-base64", + MxcWireProtocol.EncodeConfig(envelope), + + // The state-aware lifecycle surface is gated behind this + // flag in the pinned runtime; without it the executor + // rejects the request before reading the envelope. + "--experimental" + ]); + + private static System.Text.Json.JsonElement ReadResult( + MxcExecutorOutcome outcome) + { + if (string.IsNullOrWhiteSpace(outcome.StandardOutput)) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "The MXC executor produced no response envelope " + + $"(exit code {outcome.ExitCode}). " + + Describe(outcome.StandardError)); + } + + // Parse before inspecting the exit code: a structured {error} envelope + // names the real failure, and reporting the exit code instead would + // discard it. + return MxcWireProtocol.ParseNonExecutionResponse(outcome.StandardOutput); + } + + private static string Describe(string standardError) => + string.IsNullOrWhiteSpace(standardError) + ? "The executor reported no diagnostics." + : $"Executor diagnostics: {standardError.Trim()}"; +} + +internal sealed class ProcessMxcExecutorInvoker + : IMxcExecutorInvoker, IMxcAttachedExecutorInvoker +{ + private readonly IHostConsole _console; + + internal ProcessMxcExecutorInvoker(IHostConsole? console = null) => + _console = console ?? WindowsHostConsole.Instance; + + public async Task InvokeAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ProcessStartInfo startInfo = new() + { + FileName = invocation.ExecutorPath, + + // No shell: the executor path and the base64 envelope must reach + // the process exactly as written. + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (string argument in invocation.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + process.StandardInput.Close(); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"The MXC executor could not be started: {exception.Message}", + innerException: exception); + } + + // Both streams are read concurrently so a large payload on one cannot + // fill its pipe buffer and deadlock the child before exit. + Task standardOutput = process.StandardOutput.ReadToEndAsync( + cancellationToken); + Task standardError = process.StandardError.ReadToEndAsync( + cancellationToken); + using CancellationTokenRegistration registration = + cancellationToken.Register(() => TryKill(process)); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return new MxcExecutorOutcome( + process.ExitCode, + await standardOutput.ConfigureAwait(false), + await standardError.ConfigureAwait(false)); + } + + public async Task InvokeAttachedAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable capture = _console.Capture(_ => { }); + _console.InitializeUtf8(); + ProcessStartInfo startInfo = new() + { + FileName = invocation.ExecutorPath, + + // Nothing is redirected, so the child inherits this process's + // console handles. That is the point: OpenClaw draws its own + // prompts and reads typed input, and any interposed pipe would + // both buffer that output and hide the terminal from the child. + UseShellExecute = false + }; + + foreach (string argument in invocation.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"The MXC executor could not be started: {exception.Message}", + innerException: exception); + } + + // Only this invocation's process tree is killed. The session itself + // outlives the command, and other OpenClaw invocations own their own + // executor processes. Awaiting without the cancelled token guarantees + // the process is gone before cancellation is returned to the caller. + using CancellationTokenRegistration registration = + cancellationToken.Register(() => TryKill(process)); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + return process.ExitCode; + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception exception) when ( + exception is InvalidOperationException or + System.ComponentModel.Win32Exception or + NotSupportedException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcException.cs b/src/OpenClaw.Launcher/Mxc/MxcException.cs new file mode 100644 index 00000000..e63cdacf --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcException.cs @@ -0,0 +1,90 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Error classifications this package acts on. Unrecognized backend codes map +/// to and keep their original text in +/// rather than being reshaped into a +/// code that implies a recovery path the backend did not report. +/// +internal enum MxcErrorCode +{ + Unknown, + + /// The persisted identity is not a well-formed sandbox id. + MalformedId, + + /// The sandbox named by a well-formed id no longer exists. + StaleId, + + /// The backend rejected the requested policy. + PolicyValidation, + + /// + /// The backend could not parse or accept the request this package built. + /// This indicates a defect here, not a recoverable runtime condition. + /// + MalformedRequest, + + /// No state-aware backend is registered for the id's prefix. + UnsupportedContainment, + + /// + /// The backend attempted the operation and failed, e.g. exec against a + /// session that is provisioned but not started. + /// + BackendError, + + /// The MXC runtime is missing, unusable, or unsupported here. + RuntimeUnavailable, + + /// The runtime produced output this package cannot interpret. + ProtocolViolation +} + +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = + "Every MXC failure carries the classified error code and the verbatim " + + "backend code that the error-handling paths switch on. A parameterless " + + "or message-only constructor would let a caller create an exception " + + "with no classification, which is precisely the state this type exists " + + "to prevent.")] +internal sealed class MxcException : Exception +{ + public MxcException( + MxcErrorCode code, + string message, + string? backendCode = null, + Exception? innerException = null) + : base(message, innerException) + { + Code = code; + BackendCode = backendCode; + } + + public MxcErrorCode Code { get; } + + /// + /// The verbatim backend error code, preserved even when + /// is , so diagnostics + /// report what the runtime actually said. + /// + public string? BackendCode { get; } + + // Codes observed from @microsoft/mxc-sdk 0.8.0 wxc-exec.exe against the + // Windows IsolationSession backend. Anything unlisted stays Unknown and + // keeps its verbatim text rather than being guessed into a recovery path. + internal static MxcErrorCode Classify(string? backendCode) => backendCode switch + { + "malformed_id" => MxcErrorCode.MalformedId, + "stale_id" => MxcErrorCode.StaleId, + "policy_validation" => MxcErrorCode.PolicyValidation, + "malformed_request" => MxcErrorCode.MalformedRequest, + "unsupported_containment" => MxcErrorCode.UnsupportedContainment, + "backend_error" => MxcErrorCode.BackendError, + _ => MxcErrorCode.Unknown + }; +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcReadiness.cs b/src/OpenClaw.Launcher/Mxc/MxcReadiness.cs new file mode 100644 index 00000000..0e66d0b6 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcReadiness.cs @@ -0,0 +1,240 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Windows build identity, including the update build revision that the +/// documented IsolationSession minimum specifies. +/// +internal sealed record MxcHostBuild(int Build, int UpdateBuildRevision) +{ + public override string ToString() => $"{Build}.{UpdateBuildRevision}"; +} + +internal enum MxcHostSupport +{ + /// The host build could not be determined. + Unknown, + + Supported, + + Unsupported +} + +/// +/// How was established. The +/// distinction matters: the documented minimum build predicts support, while +/// the backend probe measures it, and only the probe notices a host where the +/// feature is present but unusable. +/// +internal enum MxcSupportEvidence +{ + /// Nothing could be established. + None, + + /// Inferred from the Windows build against the documented minimum. + HostBuild, + + /// Measured by the runtime's own host capability detector. + BackendProbe +} + +internal sealed record MxcReadinessReport( + string? RuntimeDirectory, + MxcRuntimeProvenance? Provenance, + string? RuntimeUnavailableReason, + MxcHostSupport HostSupport, + MxcHostBuild? HostBuild, + MxcSupportEvidence SupportEvidence, + MxcBackendProbe? BackendProbe = null, + string? BackendProbeFailureReason = null) +{ + public bool RuntimeAvailable => RuntimeUnavailableReason is null; +} + +/// +/// Read-only MXC prerequisite probe. It inspects the staged runtime, asks the +/// runtime's own capability detector about the host, and falls back to the +/// documented minimum build when that detector cannot run. It never provisions, +/// starts, or otherwise mutates state. +/// +internal static class MxcReadiness +{ + /// + /// Minimum Windows build documented by the pinned runtime for the + /// IsolationSession backend. Used only when the backend probe is + /// unavailable, because a build number predicts support rather than + /// measuring it. + /// + public static readonly MxcHostBuild MinimumHostBuild = new(26340, 9212); + + /// + /// A report for a caller that has already ruled the runtime out and must + /// not pay for, or fail on, a probe it will ignore. + /// + public static MxcReadinessReport Unavailable(string reason) => + new( + null, + null, + reason, + MxcHostSupport.Unknown, + null, + MxcSupportEvidence.None); + + public static Task ProbeAsync( + CancellationToken cancellationToken) => + ProbeAsync( + AppContext.BaseDirectory, + Environment.GetEnvironmentVariable, + WindowsHostBuild.TryRead, + static (location, token) => + new MxcCliSessionClient(location).ProbeBackendAsync(token), + cancellationToken); + + [SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = + "Readiness detection reports what it could establish; it never " + + "decides an operation. A host detector that fails for an " + + "unanticipated reason is a gap in the evidence, so the reason is " + + "recorded and the report degrades to the build check. Narrowing " + + "this would instead make `clawctl setup` fail on a machine it was " + + "only being asked to describe.")] + internal static async Task ProbeAsync( + string baseDirectory, + Func readEnvironmentVariable, + Func readHostBuild, + Func> probeBackend, + CancellationToken cancellationToken) + { + string? runtimeDirectory = null; + MxcRuntimeProvenance? provenance = null; + string? unavailableReason = null; + MxcRuntimeLocation? location = null; + + try + { + location = MxcRuntimeLocator.Locate( + baseDirectory, + readEnvironmentVariable); + runtimeDirectory = location.Directory; + provenance = location.Provenance; + } + catch (MxcException exception) + { + unavailableReason = exception.Message; + } + + MxcHostBuild? hostBuild = readHostBuild(); + MxcBackendProbe? backendProbe = null; + string? probeFailure = null; + + if (location is not null) + { + try + { + backendProbe = await probeBackend(location, cancellationToken) + .ConfigureAwait(false); + } + catch (MxcException exception) + { + probeFailure = exception.Message; + } + catch (OperationCanceledException) + { + throw; + } + + // A host detector that cannot run is a readiness gap, not a reason + // to fail setup, so any other launch failure degrades to the build + // check rather than propagating. + catch (Exception exception) + { + probeFailure = exception.Message; + } + } + + (MxcHostSupport support, MxcSupportEvidence evidence) = + backendProbe is not null + ? (backendProbe.IsolationSessionAvailable + ? MxcHostSupport.Supported + : MxcHostSupport.Unsupported, + MxcSupportEvidence.BackendProbe) + : (Classify(hostBuild), + hostBuild is null + ? MxcSupportEvidence.None + : MxcSupportEvidence.HostBuild); + + return new MxcReadinessReport( + runtimeDirectory, + provenance, + unavailableReason, + support, + hostBuild, + evidence, + backendProbe, + probeFailure); + } + + private static MxcHostSupport Classify(MxcHostBuild? hostBuild) + { + if (hostBuild is null) + { + return MxcHostSupport.Unknown; + } + + if (hostBuild.Build != MinimumHostBuild.Build) + { + return hostBuild.Build > MinimumHostBuild.Build + ? MxcHostSupport.Supported + : MxcHostSupport.Unsupported; + } + + return hostBuild.UpdateBuildRevision >= MinimumHostBuild.UpdateBuildRevision + ? MxcHostSupport.Supported + : MxcHostSupport.Unsupported; + } +} + +internal static class WindowsHostBuild +{ + private const string CurrentVersionKey = + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + + /// + /// Reads the running Windows build and update build revision. The revision + /// is only available from the registry, so an unreadable value yields an + /// unknown build rather than a fabricated revision of zero, which would + /// wrongly report a serviced host as unsupported. + /// + public static MxcHostBuild? TryRead() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + int build = Environment.OSVersion.Version.Build; + if (build <= 0) + { + return null; + } + + try + { + using Microsoft.Win32.RegistryKey? key = + Microsoft.Win32.Registry.LocalMachine.OpenSubKey(CurrentVersionKey); + return key?.GetValue("UBR") is int revision + ? new MxcHostBuild(build, revision) + : null; + } + catch (Exception exception) when ( + exception is System.Security.SecurityException or + UnauthorizedAccessException or + IOException) + { + return null; + } + } +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcRuntimeLocator.cs b/src/OpenClaw.Launcher/Mxc/MxcRuntimeLocator.cs new file mode 100644 index 00000000..4874a125 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcRuntimeLocator.cs @@ -0,0 +1,152 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Provenance recorded when the pinned MXC runtime was staged into the package. +/// It is written by scripts\Get-MxcRuntime.ps1 from the verified npm archive +/// and read back so diagnostics can name the exact runtime in use. +/// +internal sealed record MxcRuntimeProvenance( + string Package, + string Version, + string Architecture); + +internal sealed record MxcRuntimeLocation( + string Directory, + string ExecutorPath, + string PackageLifecyclePath, + MxcRuntimeProvenance? Provenance); + +/// +/// Finds the MXC runtime staged beside the launcher. +/// +/// +/// Resolution never searches PATH or a user-writable location: the runtime is a +/// release trust-chain input, so an arbitrary wxc-exec.exe found on the machine +/// must not be able to service a managed OpenClaw session. +/// +internal static class MxcRuntimeLocator +{ + /// + /// Directory name under the application base that holds the staged runtime. + /// + public const string RuntimeDirectoryName = "mxc"; + + public const string ExecutorFileName = "wxc-exec.exe"; + public const string PackageLifecycleFileName = "plm.exe"; + public const string ProvenanceFileName = "mxc-runtime.json"; + + /// + /// Development and compatibility-experiment override naming a directory + /// that already contains a verified runtime layout. + /// + public const string RuntimeDirectoryVariable = "OPENCLAW_MXC_RUNTIME_DIR"; + + public static MxcRuntimeLocation Locate() => + Locate(AppContext.BaseDirectory, Environment.GetEnvironmentVariable); + + internal static MxcRuntimeLocation Locate( + string baseDirectory, + Func readEnvironmentVariable) + { + string? overrideDirectory = + readEnvironmentVariable(RuntimeDirectoryVariable); + string directory = string.IsNullOrWhiteSpace(overrideDirectory) + ? Path.Combine( + baseDirectory, + RuntimeDirectoryName, + CurrentArchitectureName()) + : overrideDirectory; + + string executorPath = Path.Combine(directory, ExecutorFileName); + if (!File.Exists(executorPath)) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"The MXC runtime is not available: {executorPath} is missing."); + } + + string packageLifecyclePath = + Path.Combine(directory, PackageLifecycleFileName); + if (!File.Exists(packageLifecyclePath)) + { + throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + "The MXC runtime is incomplete: " + + $"{packageLifecyclePath} is missing."); + } + + return new MxcRuntimeLocation( + directory, + executorPath, + packageLifecyclePath, + ReadProvenance(directory)); + } + + /// + /// Runtime identifier fragment naming the architecture-specific staging + /// directory. The process architecture is used rather than the OS + /// architecture so an x64 launcher emulated on ARM64 loads the matching + /// runtime instead of one it cannot execute. + /// + internal static string CurrentArchitectureName() => + RuntimeInformation.ProcessArchitecture switch + { + System.Runtime.InteropServices.Architecture.X64 => "x64", + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + var other => throw new MxcException( + MxcErrorCode.RuntimeUnavailable, + $"MXC does not ship a runtime for {other}.") + }; + + private static MxcRuntimeProvenance? ReadProvenance(string directory) + { + string path = Path.Combine(directory, ProvenanceFileName); + if (!File.Exists(path)) + { + return null; + } + + // Provenance is descriptive metadata for diagnostics. A damaged file + // must not block a runtime whose binaries were already verified at + // staging time, so report unknown provenance instead of failing. + try + { + MxcRuntimeProvenancePayload? payload = JsonSerializer.Deserialize( + File.ReadAllText(path), + MxcRuntimeJsonContext.Default.MxcRuntimeProvenancePayload); + return payload is null || + string.IsNullOrWhiteSpace(payload.Package) || + string.IsNullOrWhiteSpace(payload.Version) || + string.IsNullOrWhiteSpace(payload.Architecture) + ? null + : new MxcRuntimeProvenance( + payload.Package, + payload.Version, + payload.Architecture); + } + catch (Exception exception) when ( + exception is JsonException or IOException or UnauthorizedAccessException) + { + return null; + } + } +} + +internal sealed class MxcRuntimeProvenancePayload +{ + [JsonPropertyName("package")] + public string? Package { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + [JsonPropertyName("architecture")] + public string? Architecture { get; set; } +} + +[JsonSerializable(typeof(MxcRuntimeProvenancePayload))] +internal sealed partial class MxcRuntimeJsonContext : JsonSerializerContext; diff --git a/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs new file mode 100644 index 00000000..84f265e1 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs @@ -0,0 +1,157 @@ +namespace OpenClaw.Launcher.Mxc; + +internal sealed record MxcBackendProbe( + bool IsolationSessionAvailable, + string? Tier, + IReadOnlyList Warnings); + +/// +/// The MXC containment backend this package uses. Only IsolationSession is +/// supported; backend selection is explicit so a host that silently resolves a +/// different backend cannot masquerade as a managed OpenClaw session. +/// +internal enum MxcContainment +{ + IsolationSession +} + +/// +/// An opaque, backend-issued sandbox identity. The full value must be persisted +/// and replayed verbatim: it carries the provisioning application identity and +/// backend routing prefix, so a bare instance GUID is not a substitute. +/// +internal readonly record struct MxcSandboxId +{ + private MxcSandboxId(string value, string backendPrefix) + { + Value = value; + BackendPrefix = backendPrefix; + } + + public string Value { get; } + + /// + /// Leading segment of that selects the backend, such as + /// iso for IsolationSession. + /// + public string BackendPrefix { get; } + + public const string IsolationSessionPrefix = "iso"; + + public bool IsIsolationSession => + string.Equals(BackendPrefix, IsolationSessionPrefix, StringComparison.Ordinal); + + /// + /// Parses a persisted or backend-returned identity. A missing or unknown + /// prefix is a malformed identity, not an unknown backend to guess at. + /// + public static MxcSandboxId Parse(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new MxcException( + MxcErrorCode.MalformedId, + "Sandbox id is empty."); + } + + int separator = value.IndexOf(':', StringComparison.Ordinal); + if (separator <= 0) + { + throw new MxcException( + MxcErrorCode.MalformedId, + "Sandbox id must carry a backend prefix."); + } + + return new MxcSandboxId(value, value[..separator]); + } + + public override string ToString() => Value; +} + +/// +/// Provision-time metadata reported by IsolationSession. The workspace path is +/// an ephemeral directory shared between the caller and the isolated agent user +/// and is removed when the sandbox is deprovisioned. +/// +internal sealed record MxcProvisionMetadata( + string AgentUserName, + string AgentUserSid, + string EphemeralWorkspacePath); + +/// +/// Request to create a sandbox. must be +/// PFN:<packageFamilyName> for a packaged caller; it is fixed for +/// the sandbox lifetime and is rejected on every later phase. +/// +internal sealed record MxcProvisionRequest(string AppId); + +internal sealed record MxcProvisionResult( + MxcSandboxId SandboxId, + MxcProvisionMetadata? Metadata, + string? CorrelationVector); + +/// +/// A command to run inside a started sandbox. +/// +/// +/// The backend accepts a single command-line string, not an argument vector, so +/// arbitrary OpenClaw arguments must never be interpolated here. Callers launch +/// a controlled guest helper and pass real arguments as request data. +/// +internal sealed record MxcExecutionRequest(string CommandLine); + +internal sealed record MxcExecutionResult( + int ExitCode, + string StandardOutput, + string StandardError); + +/// +/// The MXC lifecycle operations this package requires, shaped after the +/// upcoming Microsoft.Mxc.Sdk state-aware API so the published SDK can +/// replace the transport without changing session or gateway behavior. +/// +internal interface IMxcSessionClient +{ + Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken); + + Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken); + + Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken); + + /// + /// Runs a command with the caller's console attached, returning its exit + /// code. Nothing is captured. + /// + /// + /// Interactive OpenClaw requires this: a buffered execution only returns + /// after the child exits, so prompts would never reach the terminal and + /// typed input would never reach the child. Because nothing is captured, a + /// dispatch failure cannot be read back as a structured envelope, and the + /// caller must establish the outcome from the guest helper's control + /// result instead. + /// + Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken); + + Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken); + + Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken); +} diff --git a/src/OpenClaw.Launcher/Mxc/MxcWireModels.cs b/src/OpenClaw.Launcher/Mxc/MxcWireModels.cs new file mode 100644 index 00000000..202fcf6a --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcWireModels.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Mxc; + +// Wire shapes for the pinned @microsoft/mxc-sdk state-aware protocol. They are +// deliberately internal: the preview envelope format must not leak into this +// package's public surface, because the published .NET SDK will replace this +// transport without changing IMxcSessionClient. + +internal sealed class MxcRequestEnvelope +{ + [JsonPropertyName("version")] + public string Version { get; set; } = MxcWireProtocol.IsolationSessionSchemaVersion; + + [JsonPropertyName("phase")] + public string Phase { get; set; } = string.Empty; + + [JsonPropertyName("containment")] + public string? Containment { get; set; } + + [JsonPropertyName("sandboxId")] + public string? SandboxId { get; set; } + + [JsonPropertyName("correlationVector")] + public string? CorrelationVector { get; set; } + + [JsonPropertyName("network")] + public MxcNetworkAcknowledgement? Network { get; set; } + + [JsonPropertyName("process")] + public MxcProcessConfig? Process { get; set; } + + [JsonPropertyName("experimental")] + public MxcExperimentalSection? Experimental { get; set; } +} + +/// +/// IsolationSession's required unrestricted-network acknowledgement. The only +/// accepted value is allow + local network; the constants are fixed rather than +/// configurable so this package cannot advertise a filter the backend refuses. +/// +internal sealed class MxcNetworkAcknowledgement +{ + [JsonPropertyName("defaultPolicy")] + public string DefaultPolicy { get; set; } = "allow"; + + [JsonPropertyName("allowLocalNetwork")] + public bool AllowLocalNetwork { get; set; } = true; +} + +internal sealed class MxcProcessConfig +{ + [JsonPropertyName("commandLine")] + public string CommandLine { get; set; } = string.Empty; +} + +internal sealed class MxcExperimentalSection +{ + [JsonPropertyName("isolation_session")] + public MxcIsolationSessionPhases? IsolationSession { get; set; } +} + +internal sealed class MxcIsolationSessionPhases +{ + [JsonPropertyName("provision")] + public MxcIsolationSessionProvisionFields? Provision { get; set; } +} + +internal sealed class MxcIsolationSessionProvisionFields +{ + [JsonPropertyName("appId")] + public string? AppId { get; set; } +} + +internal sealed class MxcResponseEnvelope +{ + [JsonPropertyName("result")] + public JsonElement? Result { get; set; } + + [JsonPropertyName("error")] + public MxcErrorEnvelope? Error { get; set; } +} + +internal sealed class MxcErrorEnvelope +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Backend operation that failed. Observed on IsolationSession exec + /// failures, e.g. "IsoSessionOps.RunProcessWithOptionsAsync". + /// + [JsonPropertyName("operation")] + public string? Operation { get; set; } + + /// Underlying Windows status, e.g. "0x80070520". + [JsonPropertyName("nativeCode")] + public string? NativeCode { get; set; } + + /// + /// Backend-authored guidance. Surfaced verbatim because it names the + /// actual recovery step more precisely than this package can infer. + /// + [JsonPropertyName("remediation")] + public string? Remediation { get; set; } +} + +internal sealed class MxcProvisionResultPayload +{ + [JsonPropertyName("sandboxId")] + public string? SandboxId { get; set; } + + [JsonPropertyName("correlationVector")] + public string? CorrelationVector { get; set; } + + [JsonPropertyName("metadata")] + public MxcProvisionMetadataPayload? Metadata { get; set; } +} + +internal sealed class MxcProvisionMetadataPayload +{ + [JsonPropertyName("agentUserName")] + public string? AgentUserName { get; set; } + + [JsonPropertyName("agentUserSid")] + public string? AgentUserSid { get; set; } + + [JsonPropertyName("ephemeralWorkspacePath")] + public string? EphemeralWorkspacePath { get; set; } +} + +internal sealed class MxcProbeResponse +{ + [JsonPropertyName("tier")] + public string? Tier { get; set; } + + [JsonPropertyName("warnings")] + public string[]? Warnings { get; set; } + + [JsonPropertyName("probes")] + public MxcProbeDetails? Probes { get; set; } +} + +internal sealed class MxcProbeDetails +{ + [JsonPropertyName("isolationSessionAvailable")] + public bool? IsolationSessionAvailable { get; set; } + + [JsonPropertyName("baseContainerApiPresent")] + public bool? BaseContainerApiPresent { get; set; } +} + +// Source generation keeps serialization reflection-free so the NativeAOT +// executable behaves the same as the JIT test build. +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(MxcRequestEnvelope))] +[JsonSerializable(typeof(MxcResponseEnvelope))] +[JsonSerializable(typeof(MxcProvisionResultPayload))] +[JsonSerializable(typeof(MxcProbeResponse))] +internal sealed partial class MxcJsonContext : JsonSerializerContext; diff --git a/src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs b/src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs new file mode 100644 index 00000000..e8d1fdc4 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcWireProtocol.cs @@ -0,0 +1,266 @@ +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Launcher.Mxc; + +/// +/// Builds and interprets the pinned MXC state-aware wire protocol: a base64 +/// UTF-8 JSON request envelope on the command line, and a single JSON response +/// envelope on standard output for every phase except execution. +/// +internal static class MxcWireProtocol +{ + /// + /// Schema version stamped on IsolationSession envelopes. This is the wire + /// schema, which is versioned separately from the npm package version + /// recorded in mxc-runtime.lock.json; both are pinned independently. + /// + public const string IsolationSessionSchemaVersion = "0.6.0-alpha"; + + public const string IsolationSessionContainment = "isolation_session"; + + public const string ProvisionPhase = "provision"; + public const string StartPhase = "start"; + public const string ExecPhase = "exec"; + public const string StopPhase = "stop"; + public const string DeprovisionPhase = "deprovision"; + + public static MxcRequestEnvelope BuildProvisionEnvelope(string appId) => + new() + { + Phase = ProvisionPhase, + Containment = IsolationSessionContainment, + Network = new MxcNetworkAcknowledgement(), + Experimental = new MxcExperimentalSection + { + IsolationSession = new MxcIsolationSessionPhases + { + Provision = new MxcIsolationSessionProvisionFields + { + AppId = appId + } + } + } + }; + + /// + /// Builds a post-provision envelope. Network policy and application + /// identity are fixed at provision and are rejected on later phases, so + /// they are deliberately absent here. + /// + public static MxcRequestEnvelope BuildPhaseEnvelope( + string phase, + MxcSandboxId sandboxId, + string? correlationVector, + string? commandLine = null) + { + if (!sandboxId.IsIsolationSession) + { + throw new MxcException( + MxcErrorCode.MalformedId, + $"Sandbox id prefix '{sandboxId.BackendPrefix}' is not an " + + "IsolationSession identity."); + } + + return new MxcRequestEnvelope + { + Phase = phase, + SandboxId = sandboxId.Value, + CorrelationVector = correlationVector, + Process = commandLine is null + ? null + : new MxcProcessConfig { CommandLine = commandLine } + }; + } + + public static string EncodeConfig(MxcRequestEnvelope envelope) + { + string json = JsonSerializer.Serialize( + envelope, + MxcJsonContext.Default.MxcRequestEnvelope); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + } + + internal static MxcRequestEnvelope DecodeConfig(string configBase64) + { + string json = Encoding.UTF8.GetString(Convert.FromBase64String(configBase64)); + return JsonSerializer.Deserialize( + json, + MxcJsonContext.Default.MxcRequestEnvelope) + ?? throw new MxcException( + MxcErrorCode.ProtocolViolation, + "Request envelope decoded to null."); + } + + /// + /// Parses the single response envelope produced by a non-execution phase. + /// An {error} envelope is raised as an + /// carrying the backend's own code. + /// + public static JsonElement ParseNonExecutionResponse(string standardOutput) + { + MxcResponseEnvelope envelope = DeserializeResponse(standardOutput); + if (envelope.Error is not null) + { + throw ToException(envelope.Error); + } + + if (envelope.Result is null) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC response contained neither a result nor an error."); + } + + return envelope.Result.Value; + } + + /// + /// Discriminates an MXC dispatch failure from ordinary command output. + /// + /// + /// Execution forwards the guest command's raw output, so output that merely + /// happens to be JSON must not be reported as a dispatch error. Only a + /// complete {error:{code}} envelope qualifies. + /// + public static MxcException? TryParseExecutionError(string standardOutput) + { + MxcResponseEnvelope envelope; + try + { + envelope = DeserializeResponse(standardOutput); + } + catch (MxcException) + { + return null; + } + + return string.IsNullOrEmpty(envelope.Error?.Code) + ? null + : ToException(envelope.Error!); + } + + public static MxcProvisionResult ReadProvisionResult(JsonElement result) + { + MxcProvisionResultPayload? payload; + try + { + payload = result.Deserialize( + MxcJsonContext.Default.MxcProvisionResultPayload); + } + catch (JsonException exception) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC provision result could not be interpreted.", + innerException: exception); + } + + if (string.IsNullOrWhiteSpace(payload?.SandboxId)) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC provision result did not include a sandbox id."); + } + + // Metadata is reported only when the backend supplies every field. A + // partially populated workspace/agent identity is worse than none: it + // would let later phases act on an unusable path or account. + MxcProvisionMetadataPayload? metadata = payload.Metadata; + MxcProvisionMetadata? provisionMetadata = + metadata is null || + string.IsNullOrWhiteSpace(metadata.AgentUserName) || + string.IsNullOrWhiteSpace(metadata.AgentUserSid) || + string.IsNullOrWhiteSpace(metadata.EphemeralWorkspacePath) + ? null + : new MxcProvisionMetadata( + metadata.AgentUserName, + metadata.AgentUserSid, + metadata.EphemeralWorkspacePath); + + return new MxcProvisionResult( + MxcSandboxId.Parse(payload.SandboxId), + provisionMetadata, + payload.CorrelationVector); + } + + private static MxcResponseEnvelope DeserializeResponse(string standardOutput) + { + try + { + return JsonSerializer.Deserialize( + standardOutput.Trim(), + MxcJsonContext.Default.MxcResponseEnvelope) + ?? throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC response envelope was empty."); + } + catch (JsonException exception) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "MXC response was not a JSON envelope.", + innerException: exception); + } + } + + /// + /// Reads the executor's --probe output. This is plain JSON, not a + /// lifecycle result/error envelope, so it is parsed separately. + /// + public static MxcBackendProbe ReadProbeResponse(string standardOutput) + { + MxcProbeResponse? payload; + try + { + payload = JsonSerializer.Deserialize( + standardOutput, + MxcJsonContext.Default.MxcProbeResponse); + } + catch (JsonException exception) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "The MXC host capability probe returned malformed JSON.", + innerException: exception); + } + + if (payload?.Probes?.IsolationSessionAvailable is not bool available) + { + throw new MxcException( + MxcErrorCode.ProtocolViolation, + "The MXC host capability probe did not report " + + "isolationSessionAvailable."); + } + + return new MxcBackendProbe( + available, + payload.Tier, + payload.Warnings ?? []); + } + + private static MxcException ToException(MxcErrorEnvelope error) + { + string message = error.Message is { Length: > 0 } + ? error.Message + : $"MXC reported error '{error.Code ?? "unspecified"}'."; + + // The backend's own remediation text names the recovery step more + // precisely than this package can infer from the code alone, so it is + // appended verbatim rather than replaced with a generic hint. + if (error.Remediation is { Length: > 0 }) + { + message = $"{message} {error.Remediation}"; + } + + if (error.NativeCode is { Length: > 0 }) + { + message = $"{message} (native code {error.NativeCode})"; + } + + return new MxcException( + MxcException.Classify(error.Code), + message, + error.Code); + } +} diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index e5c36c2d..adbf872a 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -79,6 +79,12 @@ + + x64 + arm64 + $(MSBuildThisFileDirectory)..\..\content\mxc\$(MxcRuntimeArchitecture)\ + + + + + + ( + () => client.ProvisionAsync( + new MxcProvisionRequest(string.Empty), + CancellationToken.None)); + + Assert.Equal(MxcErrorCode.PolicyValidation, exception.Code); + Assert.Null(invoker.Invocation); + } + + [Theory] + [InlineData(MxcWireProtocol.StartPhase)] + [InlineData(MxcWireProtocol.StopPhase)] + [InlineData(MxcWireProtocol.DeprovisionPhase)] + public async Task LifecyclePhasesReplayTheSandboxIdentityAndCorrelation( + string phase) + { + var invoker = new RecordingInvoker("""{"result":{}}"""); + var client = new MxcCliSessionClient(Runtime, invoker); + + await (phase switch + { + MxcWireProtocol.StartPhase => + client.StartAsync(SandboxId, "cv-9", CancellationToken.None), + MxcWireProtocol.StopPhase => + client.StopAsync(SandboxId, "cv-9", CancellationToken.None), + _ => client.DeprovisionAsync( + SandboxId, + "cv-9", + CancellationToken.None) + }).ConfigureAwait(true); + + MxcRequestEnvelope sent = + MxcWireProtocol.DecodeConfig(invoker.Invocation!.Arguments[1]); + Assert.Equal(phase, sent.Phase); + Assert.Equal(SandboxId.Value, sent.SandboxId); + Assert.Equal("cv-9", sent.CorrelationVector); + } + + [Fact] + public async Task LifecycleFailureReportsTheBackendErrorNotTheExitCode() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + """{"error":{"code":"stale_id","message":"sandbox is gone"}}""", + exitCode: 1)); + + MxcException exception = await Assert.ThrowsAsync( + () => client.StartAsync(SandboxId, null, CancellationToken.None)); + + Assert.Equal(MxcErrorCode.StaleId, exception.Code); + Assert.Equal("sandbox is gone", exception.Message); + } + + [Fact] + public async Task SilentExecutorFailureSurfacesItsDiagnostics() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: string.Empty, + exitCode: 9, + standardError: "backend unavailable")); + + MxcException exception = await Assert.ThrowsAsync( + () => client.StopAsync(SandboxId, null, CancellationToken.None)); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + Assert.Contains("backend unavailable", exception.Message, StringComparison.Ordinal); + Assert.Contains("9", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecutionForwardsTheCommandsOwnOutputAndExitCode() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: "openclaw output", + exitCode: 3, + standardError: "openclaw warning")); + + MxcExecutionResult result = await client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("\"C:\\helper.exe\" --request r.json"), + null, + CancellationToken.None); + + Assert.Equal(3, result.ExitCode); + Assert.Equal("openclaw output", result.StandardOutput); + Assert.Equal("openclaw warning", result.StandardError); + } + + [Fact] + public async Task FailingCommandThatPrintsErrorShapedJsonIsNotADispatchFailure() + { + // OpenClaw legitimately emits JSON. Treating it as an MXC fault would + // replace a real command failure with a misleading session error. + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: """{"error":{"reason":"login required"}}""", + exitCode: 1)); + + MxcExecutionResult result = await client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("helper.exe"), + null, + CancellationToken.None); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("login required", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public async Task DispatchFailureDuringExecutionIsReportedAsASessionError() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: + """{"error":{"code":"stale_id","message":"sandbox is gone"}}""", + exitCode: 1)); + + MxcException exception = await Assert.ThrowsAsync( + () => client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("helper.exe"), + null, + CancellationToken.None)); + + Assert.Equal(MxcErrorCode.StaleId, exception.Code); + } + + [Fact] + public async Task SuccessfulCommandOutputIsNeverInspectedForDispatchErrors() + { + var client = new MxcCliSessionClient( + Runtime, + new RecordingInvoker( + standardOutput: + """{"error":{"code":"stale_id","message":"printed by the app"}}""", + exitCode: 0)); + + MxcExecutionResult result = await client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest("helper.exe"), + null, + CancellationToken.None); + + Assert.Equal(0, result.ExitCode); + } + + [Fact] + public async Task ExecutionWithoutACommandLineIsRejectedLocally() + { + var invoker = new RecordingInvoker("{}"); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcException exception = await Assert.ThrowsAsync( + () => client.ExecuteAsync( + SandboxId, + new MxcExecutionRequest(" "), + null, + CancellationToken.None)); + + Assert.Equal(MxcErrorCode.PolicyValidation, exception.Code); + Assert.Null(invoker.Invocation); + } + + [Fact] + public async Task ProbeAsksTheExecutorWithoutAnEnvelopeOrSandbox() + { + // The probe must stay non-mutating: no config envelope, no + // experimental lifecycle flag, and therefore no sandbox is created on + // the read-only setup path. + var invoker = new RecordingInvoker( + """{"tier":"base-container","warnings":[],"probes":{"isolationSessionAvailable":true}}"""); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcBackendProbe probe = await client.ProbeBackendAsync( + CancellationToken.None); + + Assert.True(probe.IsolationSessionAvailable); + Assert.Equal(Runtime.ExecutorPath, invoker.Invocation!.ExecutorPath); + Assert.Equal(["--probe"], invoker.Invocation.Arguments); + } + + [Fact] + public async Task AFailedProbeInvocationSurfacesAsRuntimeUnavailable() + { + var invoker = new RecordingInvoker( + standardOutput: string.Empty, + exitCode: 1, + standardError: "probe unsupported"); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcException exception = await Assert.ThrowsAsync( + () => client.ProbeBackendAsync(CancellationToken.None)); + + Assert.Equal(MxcErrorCode.RuntimeUnavailable, exception.Code); + Assert.Contains("probe unsupported", exception.Message, StringComparison.Ordinal); + } + + private sealed class RecordingInvoker( + string standardOutput, + int exitCode = 0, + string standardError = "") : IMxcExecutorInvoker + { + public MxcExecutorInvocation? Invocation { get; private set; } + + public Task InvokeAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken) + { + Invocation = invocation; + return Task.FromResult( + new MxcExecutorOutcome(exitCode, standardOutput, standardError)); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcReadinessTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcReadinessTests.cs new file mode 100644 index 00000000..4bb98655 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcReadinessTests.cs @@ -0,0 +1,197 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcReadinessTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + private static string? NoEnvironment(string name) => null; + + /// + /// Stands in for a host whose backend detector cannot run, which is the + /// case whenever the runtime itself is missing. + /// + private static Task BackendUnreachable( + MxcRuntimeLocation location, + CancellationToken cancellationToken) => + Task.FromException( + new MxcException( + MxcErrorCode.RuntimeUnavailable, + "probe unavailable")); + + private static Func> + BackendReports(bool available, string? tier = "base-container") => + (_, _) => Task.FromResult(new MxcBackendProbe(available, tier, [])); + + private string StageRuntime() + { + string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); + Directory.CreateDirectory(runtimeDirectory); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ExecutorFileName), + string.Empty); + File.WriteAllText( + Path.Combine( + runtimeDirectory, + MxcRuntimeLocator.PackageLifecycleFileName), + string.Empty); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ProvenanceFileName), + """ + {"package":"@microsoft/mxc-sdk","version":"0.8.0","architecture":"x64"} + """); + return runtimeDirectory; + } + + private static Func RuntimeAt(string runtimeDirectory) => + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null; + + [Fact] + public async Task ProbeReportsAMissingRuntimeWithoutFailing() + { + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => new MxcHostBuild(27000, 1), + BackendUnreachable, + CancellationToken.None); + + Assert.False(report.RuntimeAvailable); + Assert.NotNull(report.RuntimeUnavailableReason); + Assert.Null(report.RuntimeDirectory); + + // Without the runtime the backend cannot be asked, so support falls + // back to the documented build minimum and says so. + Assert.Equal(MxcHostSupport.Supported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.HostBuild, report.SupportEvidence); + } + + [Fact] + public async Task ProbeReportsAStagedRuntimeAndItsProvenance() + { + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => MxcReadiness.MinimumHostBuild, + BackendReports(available: true), + CancellationToken.None); + + Assert.True(report.RuntimeAvailable); + Assert.Equal(runtimeDirectory, report.RuntimeDirectory); + Assert.Equal("0.8.0", report.Provenance?.Version); + } + + [Fact] + public async Task BackendProbeOverridesAnOtherwiseSupportedBuild() + { + // The decisive case: a new enough build whose backend is nonetheless + // unusable. Trusting the build number alone would promise a capability + // this host does not have. + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => new MxcHostBuild(27000, 1), + BackendReports(available: false), + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Unsupported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.BackendProbe, report.SupportEvidence); + } + + [Fact] + public async Task BackendProbeOverridesAnOtherwiseUnsupportedBuild() + { + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => new MxcHostBuild(19045, 1), + BackendReports(available: true), + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Supported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.BackendProbe, report.SupportEvidence); + } + + [Fact] + public async Task AFailedBackendProbeDegradesToTheBuildCheckAndIsReported() + { + string runtimeDirectory = StageRuntime(); + + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + RuntimeAt(runtimeDirectory), + () => new MxcHostBuild(27000, 1), + (_, _) => throw new InvalidOperationException("executor crashed"), + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Supported, report.HostSupport); + Assert.Equal(MxcSupportEvidence.HostBuild, report.SupportEvidence); + Assert.Contains("executor crashed", report.BackendProbeFailureReason, StringComparison.Ordinal); + } + + [Theory] + [InlineData(26339, 99999, nameof(MxcHostSupport.Unsupported))] + [InlineData(26340, 9211, nameof(MxcHostSupport.Unsupported))] + [InlineData(26340, 9212, nameof(MxcHostSupport.Supported))] + [InlineData(26340, 9300, nameof(MxcHostSupport.Supported))] + [InlineData(26341, 0, nameof(MxcHostSupport.Supported))] + public async Task HostSupportComparesTheUpdateBuildRevisionNotJustTheBuild( + int build, + int updateBuildRevision, + string expectedName) + { + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => new MxcHostBuild(build, updateBuildRevision), + BackendUnreachable, + CancellationToken.None); + + Assert.Equal(Enum.Parse(expectedName), report.HostSupport); + } + + [Fact] + public async Task AnUndeterminableHostBuildIsReportedAsUnknownNotUnsupported() + { + // Reporting unsupported would tell a capable machine's user that + // isolated sessions can never work there. + MxcReadinessReport report = await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => null, + BackendUnreachable, + CancellationToken.None); + + Assert.Equal(MxcHostSupport.Unknown, report.HostSupport); + Assert.Null(report.HostBuild); + Assert.Equal(MxcSupportEvidence.None, report.SupportEvidence); + } + + [Fact] + public async Task ProbeDoesNotCreateOrModifyAnything() + { + await MxcReadiness.ProbeAsync( + _testDirectory, + NoEnvironment, + () => null, + BackendUnreachable, + CancellationToken.None); + + Assert.Empty(Directory.GetFileSystemEntries(_testDirectory)); + } + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcRuntimeLocatorTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcRuntimeLocatorTests.cs new file mode 100644 index 00000000..e2d27f58 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcRuntimeLocatorTests.cs @@ -0,0 +1,133 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcRuntimeLocatorTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + private static string? NoEnvironment(string name) => null; + + [Fact] + public void RuntimeIsResolvedFromTheArchitectureSpecificPackageDirectory() + { + string runtimeDirectory = StageRuntime( + Path.Combine( + _testDirectory, + MxcRuntimeLocator.RuntimeDirectoryName, + MxcRuntimeLocator.CurrentArchitectureName())); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ProvenanceFileName), + """ + {"package":"@microsoft/mxc-sdk","version":"0.8.0","architecture":"x64"} + """); + + MxcRuntimeLocation location = MxcRuntimeLocator.Locate( + _testDirectory, + NoEnvironment); + + Assert.Equal(runtimeDirectory, location.Directory); + Assert.Equal( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ExecutorFileName), + location.ExecutorPath); + Assert.Equal("@microsoft/mxc-sdk", location.Provenance?.Package); + Assert.Equal("0.8.0", location.Provenance?.Version); + } + + [Fact] + public void AnExplicitRuntimeDirectoryOverridesThePackagedLayout() + { + string runtimeDirectory = StageRuntime( + Path.Combine(_testDirectory, "experiment")); + + MxcRuntimeLocation location = MxcRuntimeLocator.Locate( + _testDirectory, + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null); + + Assert.Equal(runtimeDirectory, location.Directory); + } + + [Fact] + public void AMissingRuntimeNamesTheExpectedExecutorPath() + { + MxcException exception = Assert.Throws( + () => MxcRuntimeLocator.Locate(_testDirectory, NoEnvironment)); + + Assert.Equal(MxcErrorCode.RuntimeUnavailable, exception.Code); + Assert.Contains( + MxcRuntimeLocator.ExecutorFileName, + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void AnIncompleteRuntimeIsRejectedRatherThanPartiallyUsed() + { + string runtimeDirectory = Path.Combine(_testDirectory, "experiment"); + Directory.CreateDirectory(runtimeDirectory); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ExecutorFileName), + string.Empty); + + MxcException exception = Assert.Throws( + () => MxcRuntimeLocator.Locate( + _testDirectory, + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null)); + + Assert.Equal(MxcErrorCode.RuntimeUnavailable, exception.Code); + Assert.Contains( + MxcRuntimeLocator.PackageLifecycleFileName, + exception.Message, + StringComparison.Ordinal); + } + + [Theory] + [InlineData("not json")] + [InlineData("{}")] + [InlineData("""{"package":"@microsoft/mxc-sdk"}""")] + public void DamagedProvenanceLeavesTheVerifiedRuntimeUsable(string provenance) + { + // Provenance is descriptive. The binaries were verified at staging + // time, so an unreadable record must not disable isolated sessions. + string runtimeDirectory = StageRuntime( + Path.Combine(_testDirectory, "experiment")); + File.WriteAllText( + Path.Combine(runtimeDirectory, MxcRuntimeLocator.ProvenanceFileName), + provenance); + + MxcRuntimeLocation location = MxcRuntimeLocator.Locate( + _testDirectory, + name => name == MxcRuntimeLocator.RuntimeDirectoryVariable + ? runtimeDirectory + : null); + + Assert.Null(location.Provenance); + } + + private static string StageRuntime(string runtimeDirectory) + { + Directory.CreateDirectory(runtimeDirectory); + foreach (string fileName in new[] + { + MxcRuntimeLocator.ExecutorFileName, + MxcRuntimeLocator.PackageLifecycleFileName + }) + { + File.WriteAllText( + Path.Combine(runtimeDirectory, fileName), + string.Empty); + } + + return runtimeDirectory; + } + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcSandboxIdTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcSandboxIdTests.cs new file mode 100644 index 00000000..96a09bde --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcSandboxIdTests.cs @@ -0,0 +1,38 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcSandboxIdTests +{ + [Fact] + public void IdentityPreservesTheCompleteOpaqueValue() + { + // The backend carries the provisioning app identity inside the id, so + // persisting only the trailing instance segment loses it. + const string value = "iso:PFN:Contoso.App_8wekyb3d8bbwe:5f2c"; + + MxcSandboxId sandboxId = MxcSandboxId.Parse(value); + + Assert.Equal(value, sandboxId.Value); + Assert.Equal(value, sandboxId.ToString()); + Assert.Equal("iso", sandboxId.BackendPrefix); + Assert.True(sandboxId.IsIsolationSession); + } + + [Fact] + public void AnotherBackendsIdentityIsNotTreatedAsIsolationSession() => + Assert.False(MxcSandboxId.Parse("wslc:abc").IsIsolationSession); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("abc123")] + [InlineData(":abc123")] + public void IdentitiesWithoutABackendPrefixAreMalformed(string value) + { + MxcException exception = + Assert.Throws(() => MxcSandboxId.Parse(value)); + + Assert.Equal(MxcErrorCode.MalformedId, exception.Code); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcWireProtocolTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcWireProtocolTests.cs new file mode 100644 index 00000000..8067f880 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcWireProtocolTests.cs @@ -0,0 +1,321 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcWireProtocolTests +{ + private const string SandboxIdValue = "iso:abc123"; + + [Fact] + public void ProvisionEnvelopeCarriesApplicationIdentityAndNetworkAcknowledgement() + { + MxcRequestEnvelope envelope = + MxcWireProtocol.BuildProvisionEnvelope("PFN:Contoso.App_8wekyb3d8bbwe"); + + JsonElement encoded = Decode(MxcWireProtocol.EncodeConfig(envelope)); + + Assert.Equal( + MxcWireProtocol.IsolationSessionSchemaVersion, + encoded.GetProperty("version").GetString()); + Assert.Equal("provision", encoded.GetProperty("phase").GetString()); + Assert.Equal( + "isolation_session", + encoded.GetProperty("containment").GetString()); + Assert.Equal( + "allow", + encoded.GetProperty("network").GetProperty("defaultPolicy").GetString()); + Assert.True( + encoded.GetProperty("network") + .GetProperty("allowLocalNetwork") + .GetBoolean()); + + // The backend only accepts appId nested per backend and phase; a + // top-level appId is silently ignored and the session would then be + // provisioned without this package's identity. + Assert.Equal( + "PFN:Contoso.App_8wekyb3d8bbwe", + encoded.GetProperty("experimental") + .GetProperty("isolation_session") + .GetProperty("provision") + .GetProperty("appId") + .GetString()); + Assert.False(encoded.TryGetProperty("appId", out _)); + Assert.False(encoded.TryGetProperty("sandboxId", out _)); + } + + [Fact] + public void PostProvisionEnvelopeOmitsPolicyFixedAtProvision() + { + MxcRequestEnvelope envelope = MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.ExecPhase, + MxcSandboxId.Parse(SandboxIdValue), + "cv-1", + "\"C:\\Program Files\\helper.exe\" --request r.json"); + + JsonElement encoded = Decode(MxcWireProtocol.EncodeConfig(envelope)); + + Assert.Equal("exec", encoded.GetProperty("phase").GetString()); + Assert.Equal(SandboxIdValue, encoded.GetProperty("sandboxId").GetString()); + Assert.Equal("cv-1", encoded.GetProperty("correlationVector").GetString()); + Assert.Equal( + "\"C:\\Program Files\\helper.exe\" --request r.json", + encoded.GetProperty("process").GetProperty("commandLine").GetString()); + + // network and appId are fixed at provision and are rejected on later + // phases, so resending them would fail the whole request. Confirmed + // against wxc-exec.exe 0.8.0, which rejects a post-provision + // containment with malformed_request: "State-aware 'stop' requests + // must not carry 'containment'". + Assert.False(encoded.TryGetProperty("network", out _)); + Assert.False(encoded.TryGetProperty("containment", out _)); + Assert.False(encoded.TryGetProperty("experimental", out _)); + } + + [Fact] + public void PhaseEnvelopeOmitsProcessWhenNoCommandIsSupplied() + { + JsonElement encoded = Decode(MxcWireProtocol.EncodeConfig( + MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StopPhase, + MxcSandboxId.Parse(SandboxIdValue), + correlationVector: null))); + + Assert.False(encoded.TryGetProperty("process", out _)); + Assert.False(encoded.TryGetProperty("correlationVector", out _)); + } + + [Fact] + public void PhaseEnvelopeRejectsAnotherBackendsSandboxId() + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.BuildPhaseEnvelope( + MxcWireProtocol.StartPhase, + MxcSandboxId.Parse("wsb:abc123"), + correlationVector: null)); + + Assert.Equal(MxcErrorCode.MalformedId, exception.Code); + } + + [Theory] + [InlineData("policy_validation", nameof(MxcErrorCode.PolicyValidation))] + [InlineData("stale_id", nameof(MxcErrorCode.StaleId))] + [InlineData("malformed_id", nameof(MxcErrorCode.MalformedId))] + [InlineData("some_future_code", nameof(MxcErrorCode.Unknown))] + public void ErrorEnvelopeIsRaisedWithTheBackendsOwnCode( + string backendCode, + string expectedName) + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse( + $"{{\"error\":{{\"code\":\"{backendCode}\",\"message\":\"denied\"}}}}")); + + Assert.Equal(Enum.Parse(expectedName), exception.Code); + Assert.Equal(backendCode, exception.BackendCode); + Assert.Equal("denied", exception.Message); + } + + [Theory] + [InlineData("not json at all")] + [InlineData("{}")] + [InlineData("{\"unexpected\":1}")] + public void UninterpretableResponsesAreProtocolViolations(string output) + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse(output)); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + [Theory] + [InlineData("hello from the sandbox")] + [InlineData("{\"error\":\"something the command printed\"}")] + [InlineData("{\"error\":{\"message\":\"no code\"}}")] + [InlineData("{\"result\":{\"sandboxId\":\"iso:x\"}}")] + public void CommandOutputIsNotMistakenForADispatchFailure(string output) => + Assert.Null(MxcWireProtocol.TryParseExecutionError(output)); + + [Fact] + public void DispatchFailureDuringExecutionIsRecognized() + { + MxcException? exception = MxcWireProtocol.TryParseExecutionError( + """{"error":{"code":"stale_id","message":"gone"}}"""); + + Assert.NotNull(exception); + Assert.Equal(MxcErrorCode.StaleId, exception.Code); + } + + [Fact] + public void ProvisionResultReportsIdentityAndWorkspaceMetadata() + { + MxcProvisionResult result = MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse( + """ + {"result":{"sandboxId":"iso:abc123","correlationVector":"cv-1", + "metadata":{"agentUserName":"MxcAgent_1","agentUserSid":"S-1-5-21-1", + "ephemeralWorkspacePath":"C:\\ws\\1"}}} + """)); + + Assert.Equal("iso:abc123", result.SandboxId.Value); + Assert.True(result.SandboxId.IsIsolationSession); + Assert.Equal("cv-1", result.CorrelationVector); + Assert.Equal("MxcAgent_1", result.Metadata?.AgentUserName); + Assert.Equal("S-1-5-21-1", result.Metadata?.AgentUserSid); + Assert.Equal("C:\\ws\\1", result.Metadata?.EphemeralWorkspacePath); + } + + [Fact] + public void ProvisionResultWithoutASandboxIdIsAProtocolViolation() + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse( + """{"result":{"correlationVector":"cv-1"}}"""))); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + [Fact] + public void PartialProvisionMetadataIsReportedAsAbsent() + { + // Half a workspace identity is worse than none: later phases would act + // on an unusable path or account. + MxcProvisionResult result = MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse( + """ + {"result":{"sandboxId":"iso:abc123", + "metadata":{"agentUserName":"MxcAgent_1"}}} + """)); + + Assert.Null(result.Metadata); + } + + // The envelopes below are verbatim captures from @microsoft/mxc-sdk 0.8.0 + // wxc-exec.exe running against the Windows IsolationSession backend, so + // they pin the real contract rather than an assumed one. + + [Theory] + [InlineData("malformed_id", nameof(MxcErrorCode.MalformedId))] + [InlineData("stale_id", nameof(MxcErrorCode.StaleId))] + [InlineData("policy_validation", nameof(MxcErrorCode.PolicyValidation))] + [InlineData("malformed_request", nameof(MxcErrorCode.MalformedRequest))] + [InlineData("unsupported_containment", nameof(MxcErrorCode.UnsupportedContainment))] + [InlineData("backend_error", nameof(MxcErrorCode.BackendError))] + [InlineData("some_future_code", nameof(MxcErrorCode.Unknown))] + public void ObservedBackendCodesAreClassified( + string backendCode, + string expectedName) + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse( + $"{{\"error\":{{\"code\":\"{backendCode}\",\"message\":\"m\"}}}}")); + + Assert.Equal(Enum.Parse(expectedName), exception.Code); + + // The verbatim code survives classification, including when this + // package has no specific handling for it. + Assert.Equal(backendCode, exception.BackendCode); + } + + [Fact] + public void ExecutionAgainstStoppedSessionSurfacesBackendRemediation() + { + // captured from an exec issued after a successful stop. + const string envelope = """ + {"error":{"code":"backend_error", + "message":"No active session exists.", + "operation":"IsoSessionOps.RunProcessWithOptionsAsync", + "nativeCode":"0x80070520", + "remediation":"No active session found for this agent user. Start a session first, then retry."}} + """; + + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ParseNonExecutionResponse(envelope)); + + Assert.Equal(MxcErrorCode.BackendError, exception.Code); + + // Operators need the backend's own recovery step and native status, + // not just the one-line summary. + Assert.Contains("No active session exists.", exception.Message, StringComparison.Ordinal); + Assert.Contains("Start a session first", exception.Message, StringComparison.Ordinal); + Assert.Contains("0x80070520", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ProvisionResultReadsRealIsolationSessionPayload() + { + // captured from a real provision on Windows build 26686.1000. + const string envelope = """ + {"result":{"metadata":{"agentUserName":"C8-H2", + "agentUserSid":"S-1-5-21-3955704215-4282272831-1814204317-1321", + "ephemeralWorkspacePath":"C:\\Users\\C8-H2\\Shared"}, + "sandboxId":"iso:eyJ2ZXJzaW9uIjoxfQ"}} + """; + + MxcProvisionResult result = MxcWireProtocol.ReadProvisionResult( + MxcWireProtocol.ParseNonExecutionResponse(envelope)); + + Assert.Equal("iso:eyJ2ZXJzaW9uIjoxfQ", result.SandboxId.Value); + Assert.NotNull(result.Metadata); + Assert.Equal("C8-H2", result.Metadata!.AgentUserName); + Assert.Equal( + @"C:\Users\C8-H2\Shared", + result.Metadata.EphemeralWorkspacePath); + } + + [Fact] + public void ProbeResponseIsReadFromTheCapturedRuntimeOutput() + { + // Verbatim `wxc-exec --probe` output captured from the pinned runtime + // on a capable host; see docs\mxc-compatibility-evidence.md (G5). + const string captured = """ + {"tier":"base-container","needsDaclAugmentation":false,"warnings":[],"probes":{"baseContainerApiPresent":true,"isolationSessionAvailable":true}} + """; + + MxcBackendProbe probe = MxcWireProtocol.ReadProbeResponse(captured); + + Assert.True(probe.IsolationSessionAvailable); + Assert.Equal("base-container", probe.Tier); + Assert.Empty(probe.Warnings); + } + + [Fact] + public void ProbeResponseCarriesWarningsAndAnUnavailableBackend() + { + const string payload = """ + {"tier":"none","warnings":["host preparation required"],"probes":{"isolationSessionAvailable":false}} + """; + + MxcBackendProbe probe = MxcWireProtocol.ReadProbeResponse(payload); + + Assert.False(probe.IsolationSessionAvailable); + Assert.Equal("none", probe.Tier); + Assert.Equal("host preparation required", Assert.Single(probe.Warnings)); + } + + [Fact] + public void AProbeResponseMissingTheAvailabilityFlagIsRejected() + { + // Defaulting a missing flag either way would silently invent a support + // verdict, so the absent field must surface as a protocol violation. + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ReadProbeResponse("""{"tier":"base-container"}""")); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + [Fact] + public void AMalformedProbeResponseIsRejected() + { + MxcException exception = Assert.Throws( + () => MxcWireProtocol.ReadProbeResponse("not json")); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + private static JsonElement Decode(string configBase64) => + JsonDocument + .Parse(Encoding.UTF8.GetString(Convert.FromBase64String(configBase64))) + .RootElement; +} diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/ProcessMxcExecutorInvokerTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/ProcessMxcExecutorInvokerTests.cs new file mode 100644 index 00000000..672a11c8 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/ProcessMxcExecutorInvokerTests.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Text; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class ProcessMxcExecutorInvokerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + [Fact] + public async Task AttachedInvocationInitializesUtf8InsideConsoleCapture() + { + var console = new RecordingHostConsole(); + var invoker = new ProcessMxcExecutorInvoker(console); + + int exitCode = await invoker.InvokeAttachedAsync( + Cmd("exit /b 0"), + CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.Equal(["capture", "utf8", "restore"], console.Events); + } + + [Fact] + public async Task BufferedInvocationClosesStandardInput() + { + var invoker = new ProcessMxcExecutorInvoker(); + + MxcExecutorOutcome outcome = await invoker.InvokeAsync( + Cmd("set /p value= & echo eof"), + CancellationToken.None); + + Assert.Equal(0, outcome.ExitCode); + Assert.Contains("eof", outcome.StandardOutput, StringComparison.Ordinal); + } + + // The deadline is a hang detector, not a synchronization budget. The wait + // below completes as soon as the executor connects or exits, so a slow + // cold start cannot reach it; only a genuine hang can. + [Fact(Timeout = 300_000)] + public async Task CancellationTerminatesBufferedExecutorBeforeReturning() + { + string scriptPath = Path.Combine(_root, "wait.ps1"); + string pipeName = $"OpenClaw-{Guid.NewGuid():N}"; + await File.WriteAllTextAsync( + scriptPath, + """ + $pipe = [IO.Pipes.NamedPipeClientStream]::new( + '.', $args[0], [IO.Pipes.PipeDirection]::InOut) + $pipe.Connect() + $writer = [IO.StreamWriter]::new($pipe) + $writer.AutoFlush = $true + $writer.WriteLine($PID) + [void]$pipe.ReadByte() + """); + + using var pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + using var cancellation = new CancellationTokenSource(); + var invoker = new ProcessMxcExecutorInvoker(); + string powershell = Path.Combine( + Environment.SystemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + Task invocation = invoker.InvokeAsync( + new MxcExecutorInvocation( + powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", scriptPath, + pipeName]), + cancellation.Token); + + Task connection = pipe.WaitForConnectionAsync(CancellationToken.None); + Task completed = await Task.WhenAny(connection, invocation) + .ConfigureAwait(true); + if (completed == invocation) + { + MxcExecutorOutcome outcome = await invocation.ConfigureAwait(true); + throw new Xunit.Sdk.XunitException( + "Executor exited before connecting to the named pipe " + + $"(exit code {outcome.ExitCode}). Standard output: " + + $"{outcome.StandardOutput} Standard error: {outcome.StandardError}"); + } + + await connection.ConfigureAwait(true); + using var reader = new StreamReader( + pipe, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + bufferSize: 1024, + leaveOpen: true); + string processIdText = await reader.ReadLineAsync(CancellationToken.None) + ?? throw new InvalidOperationException("Executor did not report its process ID."); + int processId = int.Parse( + processIdText, + System.Globalization.CultureInfo.InvariantCulture); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => invocation); + Assert.Throws(() => Process.GetProcessById(processId)); + } + + [Fact] + public async Task PreCancelledInvocationDoesNotSpawnExecutor() + { + string markerPath = Path.Combine(_root, "spawned.txt"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var invoker = new ProcessMxcExecutorInvoker(); + + await Assert.ThrowsAnyAsync( + () => invoker.InvokeAsync( + Cmd($"echo spawned>\"{markerPath}\""), + cancellation.Token)); + + Assert.False(File.Exists(markerPath)); + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + private static MxcExecutorInvocation Cmd(string command) => + new( + Environment.GetEnvironmentVariable("ComSpec") + ?? Path.Combine(Environment.SystemDirectory, "cmd.exe"), + ["/d", "/c", command]); + + private sealed class RecordingHostConsole : IHostConsole + { + public List Events { get; } = []; + + public bool IsInteractive => true; + + public IDisposable Capture(Action log) + { + Events.Add("capture"); + return new Restore(Events); + } + + public void InitializeUtf8() => Events.Add("utf8"); + + private sealed class Restore(List events) : IDisposable + { + public void Dispose() => events.Add("restore"); + } + } +} From d69eee62e7e9744a95a31b47e57ea617db47d51a Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:49 -0700 Subject: [PATCH 55/76] feat: run commands through an attached session guest helper (#45) * Define attached session launch protocol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Build attached session guest helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .github/workflows/gateway-msix.yml | 5 + OpenClaw.Gateway.MSIX.slnx | 2 + scripts/Build-LocalMSIX.ps1 | 9 + scripts/Build-MSIX.ps1 | 54 +++ scripts/Test-SigningInputs.Tests.ps1 | 40 +++ scripts/Test-SigningInputs.ps1 | 73 +++++ .../OpenClaw.Launcher.csproj | 7 + src/OpenClaw.SessionHost/AssemblyInfo.cs | 9 + .../OpenClaw.SessionHost.csproj | 32 ++ src/OpenClaw.SessionHost/Program.cs | 101 ++++++ .../SessionProcessLauncher.cs | 81 +++++ .../OpenClaw.SessionProtocol.csproj | 24 ++ .../SessionLaunchProtocol.cs | 307 ++++++++++++++++++ .../OpenClaw.Launcher.Tests.csproj | 2 + .../Session/SessionHostModeTests.cs | 51 +++ .../Session/SessionLaunchProtocolTests.cs | 188 +++++++++++ 16 files changed, 985 insertions(+) create mode 100644 src/OpenClaw.SessionHost/AssemblyInfo.cs create mode 100644 src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj create mode 100644 src/OpenClaw.SessionHost/Program.cs create mode 100644 src/OpenClaw.SessionHost/SessionProcessLauncher.cs create mode 100644 src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj create mode 100644 src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 2e2fb7a4..ebed3387 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -449,6 +449,11 @@ jobs: -p:PublishAot=true ` -p:IncludePackagingContent=true ` -p:Platform='${{ matrix.architecture }}' + dotnet restore ` + .\src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj ` + --runtime 'win-${{ matrix.architecture }}' ` + -p:PublishAot=true ` + -p:Platform='${{ matrix.architecture }}' - name: Compose unsigned MSIX shell: pwsh diff --git a/OpenClaw.Gateway.MSIX.slnx b/OpenClaw.Gateway.MSIX.slnx index d0f21dff..bdeeeca4 100644 --- a/OpenClaw.Gateway.MSIX.slnx +++ b/OpenClaw.Gateway.MSIX.slnx @@ -1,6 +1,8 @@ + + diff --git a/scripts/Build-LocalMSIX.ps1 b/scripts/Build-LocalMSIX.ps1 index 57d36643..2e8c89e8 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -127,6 +127,15 @@ try { -p:IncludePackagingContent=true ` "-p:Platform=$Architecture" } + Invoke-CheckedCommand ` + -FailureMessage 'Session host dependency restore failed.' ` + -Command { + & dotnet restore ` + .\src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj ` + --runtime "win-$Architecture" ` + -p:PublishAot=true ` + "-p:Platform=$Architecture" + } $sourceCommit = (& git rev-parse HEAD) -join '' if ($LASTEXITCODE -ne 0 -or diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index e00225b7..849869aa 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -364,6 +364,51 @@ New-Item ` try { Add-VswhereToPath + $sessionHostProject = Join-Path ` + $repositoryRoot ` + 'src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj' + $sessionHostOutput = Join-Path ` + $repositoryRoot ` + "content\session-host\$Architecture" + Remove-DirectoryIfPresent -Path $sessionHostOutput + New-Item -Path $sessionHostOutput -ItemType Directory -Force | Out-Null + Write-Host "Publishing the NativeAOT win-$Architecture session host." + Invoke-CheckedCommand ` + -FailureMessage 'NativeAOT session host publish failed.' ` + -Command { + & dotnet publish $sessionHostProject ` + --configuration Release ` + --runtime "win-$Architecture" ` + --self-contained ` + --no-restore ` + "-p:Platform=$Architecture" ` + -p:PublishAot=true ` + --output $sessionHostOutput ` + --nologo + } + $sessionHostFiles = @( + Get-ChildItem -LiteralPath $sessionHostOutput -File -Force -Recurse | + Where-Object Extension -notin '.pdb', '.xml' | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $sessionHostOutput, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } | + Sort-Object path + ) + if (-not ($sessionHostFiles.path -contains 'openclaw-session-host.exe')) { + throw "The published $Architecture session host is incomplete." + } + $appxOutput = $msixBuildDirectory.TrimEnd('\') + '\' Write-Host "Building unsigned NativeAOT win-$Architecture MSIX with MSBuild." Invoke-CheckedCommand ` @@ -444,6 +489,14 @@ try { } ) } + foreach ($sessionHostFile in $sessionHostFiles) { + $expectedPackageFiles.Add( + "session-host/$Architecture/$($sessionHostFile.path)", + [pscustomobject]@{ + Hash = $sessionHostFile.sha256 + } + ) + } $packageEntries = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -588,6 +641,7 @@ try { nodeRuntimeSha256 = $nodeArchiveHash mxcRuntimeVersion = [string]$mxcProvenance.version mxcRuntimeFiles = $mxcRuntimeFiles + sessionHostFiles = $sessionHostFiles architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index 530e19b0..c52c6ba3 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -203,6 +203,31 @@ function New-TestArtifact { Sort-Object path ) + $sessionHostDirectory = Join-Path $staging "session-host\$Architecture" + New-Item -Path $sessionHostDirectory -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $sessionHostDirectory 'openclaw-session-host.exe'), + "session-host-$Architecture") + $sessionHostFiles = @( + Get-ChildItem -LiteralPath $sessionHostDirectory -File -Recurse | + ForEach-Object { + [ordered]@{ + path = ( + [IO.Path]::GetRelativePath( + $sessionHostDirectory, + $_.FullName + ) + ).Replace('\', '/') + length = $_.Length + sha256 = ( + Get-FileHash ` + -LiteralPath $_.FullName ` + -Algorithm SHA256 + ).Hash.ToLowerInvariant() + } + } + ) + $msixName = "OpenClawGateway-$Architecture.msix" $msixPath = Join-Path $directory $msixName [IO.Compression.ZipFile]::CreateFromDirectory($staging, $msixPath) @@ -227,6 +252,7 @@ function New-TestArtifact { nodeRuntimeSha256 = $nodeRuntimeHash mxcRuntimeVersion = $mxcRuntimeVersion mxcRuntimeFiles = $mxcRuntimeFiles + sessionHostFiles = $sessionHostFiles architecture = $Architecture archive = $msixName sha256 = $msixHash @@ -464,6 +490,20 @@ try { -RequestedRef 'v2026.8.2' } + Reset-TestArtifacts + Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { + param($Expanded) + Set-Content ` + -LiteralPath ( + Join-Path $Expanded 'session-host\x64\openclaw-session-host.exe' + ) ` + -Value 'tampered' ` + -Encoding utf8 + } + Assert-Fails ` + -MessagePattern 'session host file is invalid' ` + -Action { Invoke-PolicyValidation -Root $testRoot } + $x64MetadataPath = Join-Path $testRoot 'x64\msix-metadata.json' $x64Metadata = Get-Content -LiteralPath $x64MetadataPath -Raw | ConvertFrom-Json diff --git a/scripts/Test-SigningInputs.ps1 b/scripts/Test-SigningInputs.ps1 index 5ff7d201..62c9b4b1 100644 --- a/scripts/Test-SigningInputs.ps1 +++ b/scripts/Test-SigningInputs.ps1 @@ -223,6 +223,7 @@ foreach ($architecture in @('x64', 'arm64')) { $metadata.nodeRuntimeSha256 -notmatch '^[0-9a-fA-F]{64}$' -or [string]::IsNullOrWhiteSpace([string]$metadata.mxcRuntimeVersion) -or @($metadata.mxcRuntimeFiles).Count -eq 0 -or + @($metadata.sessionHostFiles).Count -eq 0 -or $metadata.architecture -ne $architecture -or $metadata.archive -ne $msix.Name -or $metadata.sha256 -notmatch '^[0-9a-fA-F]{64}$' -or @@ -378,6 +379,78 @@ foreach ($architecture in @('x64', 'arm64')) { throw "The embedded $architecture MXC runtime file set is invalid." } + $expectedSessionHostPaths = + [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $hasSessionHost = $false + foreach ($file in @($metadata.sessionHostFiles)) { + $relativePath = [string]$file.path + $segments = @($relativePath.Split('/')) + if ( + [string]::IsNullOrWhiteSpace($relativePath) -or + $relativePath.StartsWith('/') -or + [IO.Path]::IsPathRooted($relativePath) -or + $relativePath.Contains('\') -or + $relativePath.Contains(':') -or + $segments -contains '' -or + $segments -contains '.' -or + $segments -contains '..' -or + $file.length -isnot [int64] -or + $file.length -lt 0 -or + $file.sha256 -notmatch '^[0-9a-fA-F]{64}$' + ) { + throw "The embedded $architecture session host inventory is invalid." + } + + $packagePath = "session-host/$architecture/$relativePath" + if (-not $expectedSessionHostPaths.Add($packagePath)) { + throw ( + "The embedded $architecture session host inventory has " + + 'duplicate paths.' + ) + } + + $entry = Get-PackageEntry ` + -EntriesByPath $entriesByPath ` + -Path $packagePath + if ( + $entry.Length -ne $file.length -or + (Get-PackageEntrySha256 -Entry $entry) -ine $file.sha256 + ) { + throw ( + "The embedded $architecture session host file is invalid: " + + $relativePath + ) + } + + if ($relativePath -ieq 'openclaw-session-host.exe') { + $hasSessionHost = $true + } + } + + if (-not $hasSessionHost) { + throw "The embedded $architecture session host is incomplete." + } + + $actualSessionHostPaths = @( + $entriesByPath.Keys | + Where-Object { + $_.StartsWith( + "session-host/$architecture/", + [StringComparison]::OrdinalIgnoreCase + ) + } + ) + if ( + $actualSessionHostPaths.Count -ne $expectedSessionHostPaths.Count -or + @($actualSessionHostPaths | Where-Object { + -not $expectedSessionHostPaths.Contains($_) + }).Count -ne 0 + ) { + throw "The embedded $architecture session host file set is invalid." + } + [xml]$manifest = Read-ZipEntryText ` -EntriesByPath $entriesByPath ` -Path 'AppxManifest.xml' diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index adbf872a..e2d557c4 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -83,6 +83,7 @@ x64 arm64 $(MSBuildThisFileDirectory)..\..\content\mxc\$(MxcRuntimeArchitecture)\ + $(MSBuildThisFileDirectory)..\..\content\session-host\$(MxcRuntimeArchitecture)\ @@ -99,6 +100,10 @@ + + + + + Exe + net10.0-windows10.0.19041.0 + 10.0.19041.0 + enable + enable + all + openclaw-session-host + OpenClaw.SessionHost + x64;arm64 + win-x64;win-arm64 + false + true + true + + + None + false + $(DefineConstants);OPENCLAW_SESSION_HOST + + + + + + + + diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs new file mode 100644 index 00000000..6d3e8b9c --- /dev/null +++ b/src/OpenClaw.SessionHost/Program.cs @@ -0,0 +1,101 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The guest-side helper that runs inside an isolated session. +/// +/// +/// It exists because the backend's execution API takes a command-line string +/// that is flattened through cmd.exe. The MXC command therefore launches +/// only this controlled helper, which reads the real argument vector as data +/// and replays it byte for byte. +/// +internal static class Program +{ + public static int Main(string[] args) => + Run(args, new SessionProcessLauncher(), Console.Error, File.ReadAllText, WriteResult); + + internal static int Run( + IReadOnlyList args, + ISessionProcessLauncher launcher, + TextWriter errorOutput, + Func readFile, + Action writeResult) + { + if (args.Count != 2 || args[0] != "--request") + { + // No request path means no control file to report through, so this + // is the one failure that can only surface on stderr. + errorOutput.WriteLine( + "openclaw-session-host: usage: openclaw-session-host " + + "--request "); + return SessionLaunchProtocol.HelperFailureExitCode; + } + + string requestPath = args[1]; + + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + + try + { + SessionLaunchRequest request = + SessionLaunchProtocol.ReadRequest(readFile(requestPath)); + requestId = request.RequestId; + if (request.Mode != SessionLaunchMode.Attached) + { + throw new SessionLaunchException( + $"Launch mode '{request.Mode}' is not supported by this helper."); + } + + int exitCode = launcher.Run(request); + writeResult( + resultPath, + new SessionLaunchResult + { + RequestId = requestId, + Launched = true, + ExitCode = exitCode + }); + return exitCode; + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + errorOutput.WriteLine($"openclaw-session-host: {exception.Message}"); + TryWriteFailure(writeResult, resultPath, requestId, exception.Message, errorOutput); + return SessionLaunchProtocol.HelperFailureExitCode; + } + } + + private static void TryWriteFailure( + Action writeResult, + string resultPath, + string? requestId, + string error, + TextWriter errorOutput) + { + try + { + writeResult( + resultPath, + new SessionLaunchResult + { + RequestId = requestId, + Launched = false, + Error = error + }); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + errorOutput.WriteLine( + $"openclaw-session-host: unable to record the failure: {exception.Message}"); + } + } + + private static void WriteResult(string path, SessionLaunchResult result) => + File.WriteAllText(path, SessionLaunchProtocol.SerializeResult(result)); +} diff --git a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs new file mode 100644 index 00000000..89095dc4 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// Starts a process from a launch request, preserving the argument vector +/// exactly. +/// +internal interface ISessionProcessLauncher +{ + /// Runs the request to completion and returns its exit code. + int Run(SessionLaunchRequest request); + +} + +/// +/// The supervising process left behind by a detached launch. +/// +/// +/// The creation time is carried with the identifier because Windows reuses +/// process identifiers. An identifier alone would eventually name an unrelated +/// process, which the gateway would then claim and could be asked to stop. +/// +/// +/// Launches the requested executable shell-free, so no quoting, metacharacter, +/// or %VAR% interpretation can alter the arguments. +/// +internal sealed class SessionProcessLauncher : ISessionProcessLauncher +{ + public int Run(SessionLaunchRequest request) + { + string workingDirectory = request.WorkingDirectory!; + if (!Directory.Exists(workingDirectory)) + { + // Falling back to another directory would run the caller's command + // somewhere they never asked for, so this is fatal. + throw new SessionLaunchException( + $"The requested working directory does not exist: {workingDirectory}"); + } + + ProcessStartInfo startInfo = new() + { + FileName = request.Executable!, + + // No shell, and no stream redirection: the isolated session's + // console handles are inherited so interactive and piped OpenClaw + // behave as they do on the host. + UseShellExecute = false, + WorkingDirectory = workingDirectory + }; + + foreach (string argument in request.Arguments!) + { + startInfo.ArgumentList.Add(argument); + } + + foreach ((string name, string value) in request.Environment ?? new Dictionary()) + { + startInfo.Environment[name] = value; + } + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException or + PlatformNotSupportedException) + { + throw new SessionLaunchException( + $"Unable to start '{request.Executable}': {exception.Message}"); + } + + process.WaitForExit(); + return process.ExitCode; + } + +} diff --git a/src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj b/src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj new file mode 100644 index 00000000..020f1893 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/OpenClaw.SessionProtocol.csproj @@ -0,0 +1,24 @@ + + + + net10.0-windows10.0.19041.0 + 10.0.19041.0 + enable + enable + all + OpenClaw.SessionProtocol + x64;arm64 + win-x64;win-arm64 + + + true + + + + + + + diff --git a/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs b/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs new file mode 100644 index 00000000..a8546895 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs @@ -0,0 +1,307 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// +/// A single OpenClaw invocation, expressed as data for the guest helper. +/// +/// +/// The MXC execution API accepts one command-line string, which the pinned +/// backend flattens through cmd.exe. That silently expands +/// %VAR% and lets a quote in one argument truncate a later one, so the +/// real argument vector must never appear in that command line. It travels +/// here instead and is replayed verbatim by +/// OpenClaw.SessionHost. +/// +public sealed record SessionLaunchRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + /// + /// Identifies this invocation so the helper's control result cannot be + /// confused with a concurrent invocation's. + /// + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// + /// Whether the helper waits for the application or leaves it running. + /// + [JsonPropertyName("mode")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public SessionLaunchMode Mode { get; init; } = SessionLaunchMode.Attached; + + [JsonPropertyName("executable")] + public string? Executable { get; init; } + + [JsonPropertyName("arguments")] + public IReadOnlyList? Arguments { get; init; } + + /// + /// Required. Execution does not inherit the caller's working directory; + /// without this the guest command would silently run in the system + /// directory. + /// + [JsonPropertyName("workingDirectory")] + public string? WorkingDirectory { get; init; } + + [JsonPropertyName("environment")] + public IReadOnlyDictionary? Environment { get; init; } + + /// + /// Detached only. Where the application's output is written, because a + /// detached process has no console to inherit and the pipe it was started + /// through closes as soon as the launching execution returns. + /// + [JsonPropertyName("logPath")] + public string? LogPath { get; init; } + + /// + /// Detached only. Where the supervising helper records that this launch + /// generation is running or has exited. + /// + /// + /// The path is unguessable and unique per launch, so a process left behind + /// by an earlier launch cannot write to it and cannot make itself look like + /// the current generation. + /// + [JsonPropertyName("statusPath")] + public string? StatusPath { get; init; } +} + +/// +/// Whether a launch is awaited or left running. +/// +public enum SessionLaunchMode +{ + /// + /// The helper waits for the application and returns its exit code. The + /// console is inherited, so interactive and piped OpenClaw behave as they + /// do on the host. + /// + Attached, + + /// + /// The helper leaves the application running and reports the identity of + /// the process that supervises it. Used for the gateway, which must outlive + /// the execution that started it. + /// + Detached, +} + +/// +/// The guest helper's own outcome, kept separate from the launched +/// application's stdout, stderr, and exit code. +/// +/// +/// Without this separation a helper that failed to start Node at all would be +/// indistinguishable from OpenClaw itself exiting with the same code. +/// +public sealed record SessionLaunchResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// Whether the helper actually started the application. + [JsonPropertyName("launched")] + public bool Launched { get; init; } + + /// The application's exit code; null when it never started. + [JsonPropertyName("exitCode")] + public int? ExitCode { get; init; } + + /// + /// Detached only. The supervising process's identifier. + /// + [JsonPropertyName("processId")] + public int? ProcessId { get; init; } + + /// + /// Detached only. The supervising process's creation time. + /// + /// + /// Recorded with the identifier because Windows reuses process + /// identifiers. The identifier alone would eventually name an unrelated + /// process, which the gateway would then claim as its own and, worse, + /// could be asked to stop. + /// + [JsonPropertyName("processStartTimeUtc")] + public DateTimeOffset? ProcessStartTimeUtc { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +/// +/// Raised when a launch request or result cannot be honored. The message is +/// written to the control result rather than to application output. +/// +public sealed class SessionLaunchException : Exception +{ + public SessionLaunchException() + { + } + + public SessionLaunchException(string message) + : base(message) + { + } + + public SessionLaunchException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(SessionLaunchRequest))] +[JsonSerializable(typeof(SessionLaunchResult))] +internal sealed partial class SessionJsonContext : JsonSerializerContext; + +public static class SessionLaunchProtocol +{ + /// + /// Incremented whenever the request or result shape changes. The helper and + /// the launcher ship in the same package, so a mismatch means a stale file + /// or a mixed installation and is always an error rather than something to + /// interpret leniently. + /// + public const int CurrentSchemaVersion = 1; + + /// + /// Exit code used when the helper itself fails. It is deliberately + /// ambiguous with an application exit code, which is exactly why the + /// control result file is authoritative. + /// + public const int HelperFailureExitCode = 64; + + public static string ResultPathFor(string requestPath) => + requestPath + ".result.json"; + + public static string SerializeRequest(SessionLaunchRequest request) => + JsonSerializer.Serialize(request, SessionJsonContext.Default.SessionLaunchRequest); + + public static string SerializeResult(SessionLaunchResult result) => + JsonSerializer.Serialize(result, SessionJsonContext.Default.SessionLaunchResult); + + /// + /// Parses and fully validates a request. Validation happens here so the + /// helper and its tests agree on what a usable request is. + /// + public static SessionLaunchRequest ReadRequest(string json) + { + SessionLaunchRequest? request; + try + { + request = JsonSerializer.Deserialize( + json, + SessionJsonContext.Default.SessionLaunchRequest); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The launch request is not valid JSON: {exception.Message}"); + } + + if (request is null) + { + throw new SessionLaunchException("The launch request is empty."); + } + + if (request.SchemaVersion != CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Launch request schema version {request.SchemaVersion} is not " + + $"supported; this helper implements version {CurrentSchemaVersion}."); + } + + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new SessionLaunchException("The launch request has no request id."); + } + + if (string.IsNullOrWhiteSpace(request.Executable)) + { + throw new SessionLaunchException("The launch request has no executable."); + } + + if (request.Arguments is null) + { + throw new SessionLaunchException("The launch request has no argument vector."); + } + + // An absent working directory would silently become the system + // directory, which is never what the caller asked for. + if (string.IsNullOrWhiteSpace(request.WorkingDirectory)) + { + throw new SessionLaunchException( + "The launch request has no working directory."); + } + + if (request.Mode == SessionLaunchMode.Detached) + { + // A detached process has no console to inherit and the pipe it was + // started through closes as soon as the launching execution + // returns. Without a log it would write into a dead handle and + // leave nothing behind to explain a failed start. + if (string.IsNullOrWhiteSpace(request.LogPath)) + { + throw new SessionLaunchException( + "A detached launch request has no log path."); + } + + if (string.IsNullOrWhiteSpace(request.StatusPath)) + { + throw new SessionLaunchException( + "A detached launch request has no status path."); + } + } + + return request; + } + + public static SessionLaunchResult ReadResult(string json) + { + SessionLaunchResult? result; + try + { + result = JsonSerializer.Deserialize( + json, + SessionJsonContext.Default.SessionLaunchResult); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The launch result is not valid JSON: {exception.Message}"); + } + + if (result is null) + { + throw new SessionLaunchException("The launch result is empty."); + } + + if (result.SchemaVersion != CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Launch result schema version {result.SchemaVersion} is not " + + $"supported; this launcher implements version {CurrentSchemaVersion}."); + } + + if (result.Launched && string.IsNullOrWhiteSpace(result.RequestId)) + { + // A result may legitimately carry no request id when the helper + // failed before it could parse the request, but a launched + // application must always be attributable to its invocation. + throw new SessionLaunchException( + "The launch result reports a launch but has no request id."); + } + + return result; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/OpenClaw.Launcher.Tests.csproj b/tests/OpenClaw.Launcher.Tests/OpenClaw.Launcher.Tests.csproj index bf8f46fb..2b124fb9 100644 --- a/tests/OpenClaw.Launcher.Tests/OpenClaw.Launcher.Tests.csproj +++ b/tests/OpenClaw.Launcher.Tests/OpenClaw.Launcher.Tests.csproj @@ -22,6 +22,8 @@ + + diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs new file mode 100644 index 00000000..1af19cb0 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs @@ -0,0 +1,51 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; +using SessionHostProgram = OpenClaw.SessionHost.Program; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionHostModeTests +{ + [Theory] + [InlineData(SessionLaunchMode.Detached)] + [InlineData((SessionLaunchMode)42)] + public void UnsupportedModeIsRejectedBeforeLaunching(SessionLaunchMode mode) + { + var launcher = new RecordingLauncher(); + SessionLaunchResult? result = null; + var request = new SessionLaunchRequest + { + RequestId = "request-1", + Mode = mode, + Executable = @"C:\fixture\openclaw.exe", + Arguments = [], + WorkingDirectory = @"C:\fixture", + LogPath = @"C:\fixture\gateway.log", + StatusPath = @"C:\fixture\gateway.json" + }; + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\shared\request.json"], + launcher, + TextWriter.Null, + _ => SessionLaunchProtocol.SerializeRequest(request), + (_, value) => result = value); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.Equal(0, launcher.RunCount); + Assert.NotNull(result); + Assert.False(result.Launched); + Assert.Contains("mode", result.Error, StringComparison.OrdinalIgnoreCase); + } + + private sealed class RecordingLauncher : ISessionProcessLauncher + { + public int RunCount { get; private set; } + + public int Run(SessionLaunchRequest request) + { + RunCount++; + return 0; + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs new file mode 100644 index 00000000..512b8bd2 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionLaunchProtocolTests.cs @@ -0,0 +1,188 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionLaunchProtocolTests +{ + private static SessionLaunchRequest Valid() => new() + { + RequestId = "r-1", + Executable = @"C:\Program Files\nodejs\node.exe", + Arguments = [@"C:\app\openclaw.mjs", "--help"], + WorkingDirectory = @"C:\work" + }; + + [Fact] + public void AHostileArgumentVectorSurvivesTheRoundTripByteForByte() + { + // These are the exact cases that cmd.exe corrupted when the vector was + // passed as a command line: a quote in one argument truncated a later + // one, and %USERPROFILE% was expanded before the process saw it. + string[] hostile = + [ + "%USERPROFILE%", + "q\"x", + "a&b", + "has spaces", + "trailing\\", + "pipe|caret^semi;", + "(parens)", + "bang!", + string.Empty, + "unicode-\u00e9\u4e2d\u6587" + ]; + + SessionLaunchRequest request = Valid() with { Arguments = hostile }; + + SessionLaunchRequest restored = SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request)); + + Assert.Equal(hostile, restored.Arguments); + } + + [Fact] + public void EnvironmentAndWorkingDirectorySurviveTheRoundTrip() + { + SessionLaunchRequest request = Valid() with + { + WorkingDirectory = @"E:\repo\some project", + Environment = new Dictionary + { + ["OPENCLAW_SUPERVISOR_MODE"] = "external", + ["LITERAL"] = "%NOT_EXPANDED%" + } + }; + + SessionLaunchRequest restored = SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request)); + + Assert.Equal(@"E:\repo\some project", restored.WorkingDirectory); + Assert.Equal("%NOT_EXPANDED%", restored.Environment!["LITERAL"]); + } + + [Fact] + public void AnUnsupportedSchemaVersionIsRejectedRatherThanInterpreted() + { + // The helper and the launcher ship together, so a version mismatch + // means a stale file or a mixed installation, never something to + // guess at. + string json = SessionLaunchProtocol.SerializeRequest( + Valid() with { SchemaVersion = SessionLaunchProtocol.CurrentSchemaVersion + 1 }); + + SessionLaunchException exception = Assert.Throws( + () => SessionLaunchProtocol.ReadRequest(json)); + + Assert.Contains("schema version", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void AMissingWorkingDirectoryIsRejected() + { + // Execution does not inherit the caller's directory, so an absent + // value would silently become the system directory. + string json = SessionLaunchProtocol.SerializeRequest( + Valid() with { WorkingDirectory = null }); + + SessionLaunchException exception = Assert.Throws( + () => SessionLaunchProtocol.ReadRequest(json)); + + Assert.Contains("working directory", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("requestId")] + [InlineData("executable")] + [InlineData("arguments")] + public void AnIncompleteRequestIsRejected(string omitted) + { + SessionLaunchRequest request = omitted switch + { + "requestId" => Valid() with { RequestId = null }, + "executable" => Valid() with { Executable = null }, + _ => Valid() with { Arguments = null } + }; + + Assert.Throws( + () => SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request))); + } + + [Fact] + public void MalformedJsonIsRejected() + { + Assert.Throws( + () => SessionLaunchProtocol.ReadRequest("{ not json")); + } + + [Fact] + public void AnEmptyArgumentVectorIsAllowed() + { + // `openclaw` with no arguments is upstream-owned behavior, so it must + // reach the application rather than being rejected here. + SessionLaunchRequest restored = SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(Valid() with { Arguments = [] })); + + Assert.Empty(restored.Arguments!); + } + + [Fact] + public void ResultRoundTripsAndDistinguishesAFailedLaunch() + { + SessionLaunchResult failed = SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = "r-1", + Launched = false, + Error = "node.exe is missing" + })); + + Assert.False(failed.Launched); + Assert.Null(failed.ExitCode); + Assert.Equal("node.exe is missing", failed.Error); + + SessionLaunchResult succeeded = SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = "r-1", + Launched = true, + ExitCode = 64 + })); + + // 64 is also the helper's own failure code; only `launched` can tell + // these apart, which is why the control result exists. + Assert.True(succeeded.Launched); + Assert.Equal(64, succeeded.ExitCode); + } + + [Fact] + public void AFailedResultMayCarryNoRequestIdButALaunchedOneMayNot() + { + // The helper cannot know the request id when the request itself was + // unreadable, which is precisely when the control result matters most. + SessionLaunchResult anonymousFailure = SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + Launched = false, + Error = "the request is not valid JSON" + })); + + Assert.False(anonymousFailure.Launched); + Assert.Null(anonymousFailure.RequestId); + + Assert.Throws( + () => SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + Launched = true, + ExitCode = 0 + }))); + } + + [Fact] + public void TheResultPathIsDerivedFromTheRequestPath() + { + Assert.Equal( + @"C:\ws\req-1.json.result.json", + SessionLaunchProtocol.ResultPathFor(@"C:\ws\req-1.json")); + } +} From 19d1f853259a078d6a986e601be95ca2047649e9 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:49 -0700 Subject: [PATCH 56/76] feat: run OpenClaw in an owned isolated agent session (#46) * Add durable session ownership and setup state Add synchronization and package-derived writable paths for owned sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Add session coordination and routing policy Provision or reuse owned sessions through the MXC abstraction and route required executions safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Add staged session helper execution Dispatch correlated argument vectors through the packaged guest helper with safe cleanup and runtime composition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Install the agent runtime through the guest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Activate public session setup and transparent routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Fix isolated session lifecycle boundaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Persist the agent Node runtime directory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Reject a sandbox issued to another application Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Refuse invalid isolated session state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Update lifecycle launch test seam Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 28 +- src/OpenClaw.Launcher/HostPaths.cs | 165 ++++ src/OpenClaw.Launcher/HostStartup.cs | 2 + .../Mxc/MxcSessionContracts.cs | 81 ++ .../OpenClaw.Launcher.csproj | 4 + .../OpenClawRuntimeEnvironment.cs | 12 +- src/OpenClaw.Launcher/Program.cs | 208 ++++- .../Session/LazyMxcSessionClient.cs | 67 ++ .../Session/SessionCoordinator.cs | 390 ++++++++ .../Session/SessionExecutor.cs | 394 ++++++++ .../Session/SessionHelperStager.cs | 133 +++ src/OpenClaw.Launcher/Session/SessionLock.cs | 141 +++ .../Session/SessionRoutingPolicy.cs | 152 ++++ .../Session/SessionRuntime.cs | 267 ++++++ .../Session/SessionStateStore.cs | 304 +++++++ .../Session/SessionWorkspaceOperation.cs | 148 +++ .../Session/SetupStateStore.cs | 211 +++++ src/OpenClaw.Launcher/Session/TrustedPath.cs | 840 ++++++++++++++++++ src/OpenClaw.SessionHost/Program.cs | 8 + .../SessionProcessLauncher.cs | 14 + .../SessionRuntimeInstaller.cs | 340 +++++++ .../SessionRuntimeProtocol.cs | 167 ++++ .../SmokeProgram.cs | 9 +- .../ClawCtlCommandLineTests.cs | 9 +- .../ClawCtlParserDefaultsTests.cs | 10 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 326 ++++++- .../Session/FakeMxcSessionClient.cs | 124 +++ .../Session/HostPathsTests.cs | 72 ++ .../Session/SessionCoordinatorTests.cs | 399 +++++++++ .../Session/SessionExecutorTests.cs | 491 ++++++++++ .../Session/SessionGuestCommandLineTests.cs | 183 ++++ .../Session/SessionHelperStagerTests.cs | 114 +++ .../Session/SessionLockTests.cs | 159 ++++ .../Session/SessionRoutingPolicyTests.cs | 241 +++++ .../Session/SessionRuntimeInstallerTests.cs | 242 +++++ .../Session/SessionRuntimePathTests.cs | 97 ++ .../Session/SessionRuntimeProtocolTests.cs | 78 ++ .../Session/SessionRuntimeTests.cs | 89 ++ .../Session/SessionStateStoreTests.cs | 341 +++++++ .../Session/SessionTestDoubles.cs | 29 + .../Session/SetupStateStoreTests.cs | 89 ++ 41 files changed, 7139 insertions(+), 39 deletions(-) create mode 100644 src/OpenClaw.Launcher/HostPaths.cs create mode 100644 src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionCoordinator.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionExecutor.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionHelperStager.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionLock.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionRuntime.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionStateStore.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs create mode 100644 src/OpenClaw.Launcher/Session/SetupStateStore.cs create mode 100644 src/OpenClaw.Launcher/Session/TrustedPath.cs create mode 100644 src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs create mode 100644 src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 10911998..86f098a1 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -4,6 +4,13 @@ namespace OpenClaw.Launcher; +internal sealed record ClawCtlHandlers +{ + public required Func> Setup { get; init; } + public required Func> Status { get; init; } + public required Func> Teardown { get; init; } +} + // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the // bundled CLI and reached through `openclaw`, which forwards its arguments @@ -11,6 +18,7 @@ namespace OpenClaw.Launcher; internal static class ClawCtlCommandLine { public const string SetupCommandName = "setup"; + public const string StatusCommandName = "status"; // Response-file expansion is off. A leading `@` means nothing to clawctl, // so it is reported as an unrecognized argument instead of silently reading @@ -24,10 +32,10 @@ internal static class ClawCtlCommandLine // Setup guidance is available without preparing the runtime. public static string RootDescription => - "Prepare the bundled Node.js runtime and verify the packaged OpenClaw application." + + "Set up and manage the packaged OpenClaw application." + Environment.NewLine + Environment.NewLine + - "Run `clawctl setup` to extract or repair the bundled runtime." + + "Run `clawctl setup` to prepare the bundled runtime and isolated session." + Environment.NewLine + Environment.NewLine + "Run `openclaw ` to invoke the OpenClaw CLI."; @@ -38,14 +46,24 @@ internal static class ClawCtlCommandLine // runSetup stays a delegate so the command tree owns parsing and help while // Program keeps the readiness operation and its test seams. - public static RootCommand Create(Func> runSetup) + public static RootCommand Create(ClawCtlHandlers handlers) { + ArgumentNullException.ThrowIfNull(handlers); Command setup = new(SetupCommandName, SetupDescription); - setup.SetAction((_, cancellationToken) => runSetup(cancellationToken)); + setup.SetAction((_, cancellationToken) => handlers.Setup(cancellationToken)); + Command status = new(StatusCommandName, "Show the recorded isolated session without changing it."); + status.SetAction((_, cancellationToken) => handlers.Status(cancellationToken)); + Option force = new("--force") { Description = "Skip confirmation and remove the owned session." }; + Command teardown = new("teardown", "Stop and remove the owned isolated session."); + teardown.Options.Add(force); + teardown.SetAction((parsed, cancellationToken) => + handlers.Teardown(parsed.GetValue(force), cancellationToken)); RootCommand root = new(RootDescription) { - setup + setup, + status, + teardown }; // Bare `clawctl` is a discovery request, not a usage error, so the root diff --git a/src/OpenClaw.Launcher/HostPaths.cs b/src/OpenClaw.Launcher/HostPaths.cs new file mode 100644 index 00000000..886c9ebc --- /dev/null +++ b/src/OpenClaw.Launcher/HostPaths.cs @@ -0,0 +1,165 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher; + +/// +/// The running process's MSIX package identity. +/// +internal static class PackageIdentity +{ + private const int ErrorInsufficientBuffer = 122; + private const int AppModelErrorNoPackage = 15700; + + /// + /// Prefix required by MXC when a packaged caller provisions a sandbox. + /// + public const string ApplicationIdPrefix = "PFN:"; + + /// + /// The package family name, or null when running unpackaged. + /// + /// + /// Unpackaged is a normal development configuration, not an error, so it + /// is reported as null rather than thrown. + /// + public static string? TryGetPackageFamilyName() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + uint length = 0; + int result = GetCurrentPackageFamilyName(ref length, null); + if (result == AppModelErrorNoPackage) + { + return null; + } + + if (result != ErrorInsufficientBuffer || length == 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + var value = new char[length]; + result = GetCurrentPackageFamilyName(ref length, value); + if (result != 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + return new string(value, 0, checked((int)length - 1)); + } + + /// + /// Builds the MXC application id for a package family name. + /// + /// + /// The backend fixes this value for the sandbox lifetime, so it must be the + /// real family name of the calling package. This package and the internal + /// MSIX therefore never share a session. + /// + public static string ToApplicationId(string packageFamilyName) => + ApplicationIdPrefix + packageFamilyName; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFamilyName( + ref uint packageFamilyNameLength, + [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] + char[]? packageFamilyName); +} + +/// +/// Where this installation keeps its own writable state. +/// +/// +/// Every writable path is derived here so nothing duplicates the packaged +/// versus unpackaged decision, and so tests can redirect the root instead of +/// touching real profile state. +/// +internal sealed class HostPaths +{ + public const string UnpackagedDirectoryName = "OpenClawGatewayMSIX"; + + private HostPaths(string stateRoot, string? packageFamilyName) + { + StateRoot = stateRoot; + PackageFamilyName = packageFamilyName; + } + + public string StateRoot { get; } + + public string? PackageFamilyName { get; } + + public string LogPath => Path.Combine(StateRoot, "Logs", "openclaw.log"); + + public string SessionStatePath => Path.Combine(StateRoot, "session.json"); + + /// + /// The marker written only after explicit setup completes all of its + /// session, launch-configuration, and sign-in-recovery steps. + /// + public string SetupStatePath => Path.Combine(StateRoot, "setup.json"); + + /// + /// The script the logon task runs. + /// + /// + /// The task deliberately does not invoke the app alias directly. This file + /// is rewritten without elevation on every install, whereas changing the + /// task's own arguments requires re-registering it. Routing through it + /// keeps the launch command free to change, and keeps the Startup-folder + /// lane calling the same single definition instead of a second one that + /// can drift. + /// + public string GatewayLauncherPath => + Path.Combine(StateRoot, "gateway-launcher.cmd"); + + /// + /// Where the recorded gateway process and its persistence choices live. + /// + public string GatewayStatePath => Path.Combine(StateRoot, "gateway.json"); + + /// + /// The gateway's launch configuration, kept separate from its recorded + /// process so that stopping the gateway never discards the user's port. + /// + public string GatewayConfigurationPath => + Path.Combine(StateRoot, "gateway-config.json"); + + public static HostPaths Create() => + Create( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + PackageIdentity.TryGetPackageFamilyName()); + + internal static HostPaths Create(string localAppData, string? packageFamilyName) + { + if (string.IsNullOrWhiteSpace(localAppData)) + { + throw new InvalidOperationException( + "The local application data directory is unavailable."); + } + + // A packaged process writes inside its own LocalState so the state is + // removed with the package and cannot collide with another + // installation's. + string stateRoot = packageFamilyName is null + ? Path.Combine(localAppData, UnpackagedDirectoryName) + : Path.Combine( + localAppData, + "Packages", + packageFamilyName, + "LocalState", + UnpackagedDirectoryName); + + return new HostPaths(stateRoot, packageFamilyName); + } + + /// + /// Builds paths rooted at an arbitrary directory, for tests and diagnosis. + /// + internal static HostPaths ForRoot(string stateRoot, string? packageFamilyName = null) => + new(Path.GetFullPath(stateRoot), packageFamilyName); +} diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index 050f1d70..1a815fa0 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -23,6 +23,8 @@ internal sealed class HostStartup public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; } + public Func, Session.SessionRuntime>? CreateSessionRuntime { get; init; } + public static HostStartup CreateProduction() => new() { Entrypoint = HostEntrypointResolver.Resolve(), diff --git a/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs index 84f265e1..50a1fd33 100644 --- a/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs +++ b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs @@ -1,3 +1,6 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + namespace OpenClaw.Launcher.Mxc; internal sealed record MxcBackendProbe( @@ -65,6 +68,84 @@ public static MxcSandboxId Parse(string value) return new MxcSandboxId(value, value[..separator]); } + /// + /// Reports the application this identity was issued to, when the backend + /// encodes one. + /// + /// + /// + /// The value stays opaque to this package: it is replayed verbatim and is + /// never rebuilt from parts. This reads the owning application only so a + /// caller can refuse an identity that provably belongs to someone else + /// before asking the backend to act on it. A record naming this + /// installation does not establish that the identity inside it does. + /// + /// + /// False is returned whenever the payload cannot be read, including a + /// backend or format this package does not recognize. Absence of evidence + /// is not evidence of a foreign owner, and inventing a failure here would + /// strand every existing session the moment the backend changed its + /// encoding. + /// + /// + public bool TryGetOwningApplicationId([NotNullWhen(true)] out string? applicationId) + { + applicationId = null; + if (!IsIsolationSession) + { + return false; + } + + int separator = Value.IndexOf(':', StringComparison.Ordinal); + string payload = Value[(separator + 1)..]; + if (payload.Length == 0) + { + return false; + } + + // The payload is base64url without padding. + string normalized = payload.Replace('-', '+').Replace('_', '/'); + normalized = (normalized.Length % 4) switch + { + 2 => normalized + "==", + 3 => normalized + "=", + 0 => normalized, + _ => string.Empty + }; + if (normalized.Length == 0) + { + return false; + } + + byte[] decoded; + try + { + decoded = Convert.FromBase64String(normalized); + } + catch (FormatException) + { + return false; + } + + try + { + using JsonDocument document = JsonDocument.Parse(decoded); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("appId", out JsonElement appId) || + appId.ValueKind != JsonValueKind.String) + { + return false; + } + + applicationId = appId.GetString(); + return !string.IsNullOrWhiteSpace(applicationId); + } + catch (JsonException) + { + return false; + } + } + public override string ToString() => Value; } diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index e2d557c4..b5ef09c8 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -50,6 +50,10 @@ PrivateAssets="all" /> + + + + Designer diff --git a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs index 55affd7f..79f5fb2f 100644 --- a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs +++ b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs @@ -14,6 +14,7 @@ internal static class OpenClawRuntimeEnvironment public const string SupervisorModeVariable = "OPENCLAW_SUPERVISOR_MODE"; public const string ServiceRepairPolicyVariable = "OPENCLAW_SERVICE_REPAIR_POLICY"; public const string NoAutoUpdateVariable = "OPENCLAW_NO_AUTO_UPDATE"; + public const string GatewayIsolationVariable = "CLAWCTL_GATEWAY_ISOLATION"; public const string ExternalValue = "external"; public const string NoAutoUpdateValue = "1"; @@ -30,21 +31,26 @@ internal static class OpenClawRuntimeEnvironment /// /// The variables to apply, as an ordinary dictionary. /// - public static IReadOnlyDictionary Build() => + public static IReadOnlyDictionary Build( + GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled) => new Dictionary(StringComparer.OrdinalIgnoreCase) { [SupervisorModeVariable] = ExternalValue, [ServiceRepairPolicyVariable] = ExternalValue, [NoAutoUpdateVariable] = NoAutoUpdateValue, + [GatewayIsolationVariable] = gatewayIsolationMode.ToEnvironmentValue(), }; public static IReadOnlyDictionary Build( bool isInteractive, - Func readEnvironmentVariable) + Func readEnvironmentVariable, + GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled) { ArgumentNullException.ThrowIfNull(readEnvironmentVariable); - Dictionary result = new(Build(), StringComparer.OrdinalIgnoreCase); + Dictionary result = new( + Build(gatewayIsolationMode), + StringComparer.OrdinalIgnoreCase); string? forceColor = readEnvironmentVariable(ForceColorVariable); string? wtSession = readEnvironmentVariable(WindowsTerminalSessionVariable); string? noColor = readEnvironmentVariable(NoColorVariable); diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index be74d3a5..556c1833 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.Diagnostics.CodeAnalysis; +using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher; @@ -114,7 +115,8 @@ PlatformNotSupportedException or WriteDiagnostic, startup.ResolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( GetPackagedNodeArchivePath(options)))), - startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync) + startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, + startup.CreateSessionRuntime) .ConfigureAwait(false); } catch (Exception exception) @@ -153,22 +155,94 @@ internal static async Task RunAgentAsync( HostOptions options, Action log, Func> resolveNode, - LaunchOpenClawAsync launchOpenClaw) + LaunchOpenClawAsync launchOpenClaw, + Func, Session.SessionRuntime>? createSessionRuntime = null, + Func? isInteractive = null, + Func>? probeReadiness = null, + Func? getPackageFamilyName = null) { - NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) - .ConfigureAwait(false); - log( - $"Using Node.js {nodeRuntime.Version} from " + - $"{nodeRuntime.ExecutablePath}."); + Session.SessionMode mode = Session.SessionRoutingPolicy.ReadMode( + Environment.GetEnvironmentVariable); + if (mode == Session.SessionMode.Disabled) + { + return await RunDirectAsync().ConfigureAwait(false); + } + + Session.SessionRuntime? runtime = null; + if (createSessionRuntime is null || probeReadiness is not null) + { + Mxc.MxcReadinessReport readiness = await (probeReadiness ?? + Mxc.MxcReadiness.ProbeAsync)(CancellationToken.None).ConfigureAwait(false); + string? packageFamilyName = + (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(); + Session.SessionRoutingDecision routing = Session.SessionRoutingPolicy.Decide( + mode, + packageFamilyName, + readiness); + log(routing.Reason); + if (routing.Routing == Session.SessionRouting.Direct) + { + if (packageFamilyName is not null) + { + runtime = (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + runtime.ValidateSavedOwnershipForHostFallback(); + } + + return await RunDirectAsync().ConfigureAwait(false); + } + } + + Session.SessionRecord record; + try + { + runtime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + record = await runtime.StartForExecutionAsync(CancellationToken.None) + .ConfigureAwait(false); + } + catch (Session.SessionCapabilityUnavailableException) + when (mode == Session.SessionMode.Automatic) + { + log("Isolated session unavailable; running OpenClaw directly."); + return await RunDirectAsync().ConfigureAwait(false); + } + + string agentNodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); string applicationDirectory = GetPackagedApplicationDirectory(options); log("Using the OpenClaw application directly from the package."); - return await launchOpenClaw( - nodeRuntime.ExecutablePath, - applicationDirectory, - options.OpenClawArguments, - GatewayIsolationMode.Disabled, - CancellationToken.None, - log).ConfigureAwait(false); + return await runtime.Executor.ExecuteAsync( + record, + new Session.SessionExecutionRequest( + runtime.RequireStagedHelper(record), + agentNodePath, + applicationDirectory, + options.OpenClawArguments, + record.WorkspacePath!) + { + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), + Environment.GetEnvironmentVariable, + GatewayIsolationMode.Enabled) + }, + CancellationToken.None).ConfigureAwait(false); + + async Task RunDirectAsync() + { + NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) + .ConfigureAwait(false); + log( + $"Using Node.js {nodeRuntime.Version} from " + + $"{nodeRuntime.ExecutablePath}."); + string directApplicationDirectory = GetPackagedApplicationDirectory(options); + log("Using the OpenClaw application directly from the package."); + return await launchOpenClaw( + nodeRuntime.ExecutablePath, + directApplicationDirectory, + options.OpenClawArguments, + GatewayIsolationMode.Disabled, + CancellationToken.None, + log).ConfigureAwait(false); + } } // output and error are required parameters (not Console defaults) so tests @@ -180,15 +254,107 @@ internal static async Task RunControlAsync( Action log, TextWriter output, TextWriter error, - Func>? resolveNode = null) + Func>? resolveNode = null, + Func? createSessionRuntime = null) { + Session.SessionRuntime? sessionRuntime = null; + Session.SessionRuntime GetSessionRuntime() => + sessionRuntime ??= createSessionRuntime?.Invoke() ?? + Session.SessionRuntime.Create(log); + RootCommand command = ClawCtlCommandLine.Create( - cancellationToken => RunSetupAsync( - options, - log, - output, - resolveNode, - cancellationToken)); + new ClawCtlHandlers + { + Setup = async cancellationToken => + { + int result = await RunSetupAsync( + options, log, output, resolveNode, cancellationToken) + .ConfigureAwait(false); + if (result != 0) + { + return result; + } + + if (Session.SessionRoutingPolicy.ReadMode( + Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) + { + await output.WriteLineAsync( + "Isolated session setup was skipped because OPENCLAW_SESSION is disabled.") + .ConfigureAwait(false); + return 0; + } + + try + { + Session.SessionRuntime runtime = GetSessionRuntime(); + using Session.ISessionLockHandle handle = + runtime.AcquireLifecycleLock(); + runtime.SetupState.Write(new Session.SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = Session.SetupPhase.Preparing + }); + Session.SessionRecord record = + await runtime.Coordinator.EnsureStartedAsync(cancellationToken) + .ConfigureAwait(false); + string helperPath = runtime.StageHelper(record); + SessionRuntimeInstallResult agentRuntime = + await runtime.Executor.InstallRuntimeAsync( + record, + helperPath, + GetPackagedNodeArchivePath(options), + cancellationToken) + .ConfigureAwait(false); + runtime.CompleteSetup( + record, + agentRuntime, + startupEnabled: false); + await output.WriteLineAsync("OpenClaw isolated session is ready.") + .ConfigureAwait(false); + return 0; + } + catch (Session.SessionException exception) + { + log($"Isolated session setup is unavailable: {exception.Message}"); + await output.WriteLineAsync( + $"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + }, + Status = async cancellationToken => + { + Session.SessionStatus status = GetSessionRuntime() + .Coordinator.GetRecordedStatus(); + await output.WriteLineAsync(status.Availability.ToString()) + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(status.Detail)) + { + await output.WriteLineAsync(status.Detail) + .ConfigureAwait(false); + } + return 0; + }, + Teardown = async (force, cancellationToken) => + { + if (!force) + { + await error.WriteLineAsync( + "Teardown removes the isolated session and its data. Re-run with --force to continue.") + .ConfigureAwait(false); + return 1; + } + + Session.SessionRuntime runtime = GetSessionRuntime(); + using Session.ISessionLockHandle handle = + runtime.AcquireLifecycleLock(); + await runtime.Coordinator.RemoveAsync(cancellationToken) + .ConfigureAwait(false); + await output.WriteLineAsync("OpenClaw isolated session was removed.") + .ConfigureAwait(false); + return 0; + } + }); InvocationConfiguration configuration = new() { diff --git a/src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs b/src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs new file mode 100644 index 00000000..60cfcf62 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/LazyMxcSessionClient.cs @@ -0,0 +1,67 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Defers backend construction until a lifecycle call actually needs it. +/// +/// +/// clawctl status can report local ownership without constructing +/// the backend, so it must work on a machine where the MXC runtime is missing. +/// Constructing the real client eagerly would turn a read-only status query +/// into a runtime-availability failure and hide the recorded state the user +/// asked about. +/// +internal sealed class LazyMxcSessionClient(Func create) + : IMxcSessionClient +{ + private readonly Lazy _inner = new(create); + + public Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) => + _inner.Value.ProvisionAsync(request, cancellationToken); + + public Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.StartAsync(sandboxId, correlationVector, cancellationToken); + + public Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.ExecuteAsync( + sandboxId, + request, + correlationVector, + cancellationToken); + + public Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.ExecuteAttachedAsync( + sandboxId, + request, + correlationVector, + cancellationToken); + + public Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.StopAsync(sandboxId, correlationVector, cancellationToken); + + public Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.DeprovisionAsync( + sandboxId, + correlationVector, + cancellationToken); +} diff --git a/src/OpenClaw.Launcher/Session/SessionCoordinator.cs b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs new file mode 100644 index 00000000..3a395e7d --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs @@ -0,0 +1,390 @@ +using System.Diagnostics.CodeAnalysis; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// A session lifecycle operation could not proceed. +/// +internal class SessionException : Exception +{ + public SessionException() + { + } + + public SessionException(string message) + : base(message) + { + } + + public SessionException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// The current installation or machine cannot host an isolated session. +/// +internal sealed class SessionCapabilityUnavailableException : SessionException +{ + public SessionCapabilityUnavailableException() + { + } + + public SessionCapabilityUnavailableException(string message) + : base(message) + { + } + + public SessionCapabilityUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// The recorded session exists but cannot be used, and replacing it silently +/// would risk abandoning a live backend session. +/// +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = + "The fault is what callers act on: it distinguishes a missing record " + + "from one that is merely unreadable, which decides whether replacing " + + "the session is safe. A message-only constructor would allow that " + + "distinction to be lost.")] +internal sealed class SessionStateException : SessionException +{ + public SessionStateException(SessionStateFault fault, string detail) + : base(detail) => Fault = fault; + + public SessionStateFault Fault { get; } +} + +/// +/// Another process held the lifecycle lock for longer than the caller allowed. +/// +[SuppressMessage( + "Design", + "CA1032:Implement standard exception constructors", + Justification = + "The timeout is the message: this exception exists to tell the user " + + "how long another process was given before the wait was abandoned. A " + + "message-only form would let that number be omitted or contradicted.")] +internal sealed class SessionBusyException : SessionException +{ + public SessionBusyException(TimeSpan timeout) + : base( + "Another OpenClaw process is changing the session and did not " + + $"finish within {timeout.TotalSeconds:0.#} seconds.") + { + } +} + +/// +/// Whether a usable session is recorded, without consulting the backend. +/// +internal enum SessionAvailability +{ + /// No session has been recorded; a first one may be created. + None, + + /// A usable record exists. It says nothing about liveness. + Recorded, + + /// A record exists but cannot be used. Recovery is required. + Unusable, +} + +/// +/// What the local record says. Liveness is deliberately not claimed here: the +/// backend offers no authoritative session enumeration, so recorded identity +/// and live evidence must stay separate. +/// +internal sealed record SessionStatus( + SessionAvailability Availability, + SessionRecord? Record, + SessionStateFault? Fault, + string? Detail); + +/// +/// The outcome of removing the owned session. +/// +/// False when there was nothing to remove. +/// +/// Set when stop failed but deprovision was still attempted, so the caller can +/// report the degraded path instead of claiming a clean teardown. +/// +internal sealed record SessionRemovalResult(bool Removed, string? StopFailure); + +/// +/// Owns this installation's isolated session across processes. +/// +internal sealed class SessionCoordinator +{ + public static readonly TimeSpan DefaultLockTimeout = TimeSpan.FromSeconds(30); + + private readonly IMxcSessionClient _backend; + private readonly SessionStateStore _store; + private readonly ISessionLock _lifecycleLock; + private readonly string _applicationId; + private readonly Action _log; + private readonly TimeProvider _clock; + private readonly TimeSpan _lockTimeout; + + public SessionCoordinator( + IMxcSessionClient backend, + SessionStateStore store, + ISessionLock lifecycleLock, + string applicationId, + Action log, + TimeProvider? clock = null, + TimeSpan? lockTimeout = null) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(lifecycleLock); + ArgumentException.ThrowIfNullOrWhiteSpace(applicationId); + ArgumentNullException.ThrowIfNull(log); + + _backend = backend; + _store = store; + _lifecycleLock = lifecycleLock; + _applicationId = applicationId; + _log = log; + _clock = clock ?? TimeProvider.System; + _lockTimeout = lockTimeout ?? DefaultLockTimeout; + } + + /// + /// Reports the recorded session without provisioning, starting, or writing. + /// + public SessionStatus GetRecordedStatus() + { + SessionStateResult result = _store.Read(_applicationId); + if (result.Record is not null) + { + return new SessionStatus( + SessionAvailability.Recorded, + result.Record, + null, + null); + } + + SessionAvailability availability = result.Fault == SessionStateFault.Missing + ? SessionAvailability.None + : SessionAvailability.Unusable; + + return new SessionStatus(availability, null, result.Fault, result.Detail); + } + + /// + /// Returns the owned, started session, creating it only on first use. + /// + public async Task EnsureStartedAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionStateResult state = _store.Read(_applicationId); + if (state.Record is not null) + { + _log("Reusing the recorded OpenClaw session."); + await StartAsync(state.Record, cancellationToken) + .ConfigureAwait(false); + return state.Record; + } + + if (state.Fault != SessionStateFault.Missing) + { + // Provisioning over a record we merely failed to read would abandon + // a live backend session and the user's guest profile with it. + throw new SessionStateException(state.Fault!.Value, state.Detail!); + } + + _log("Creating the first OpenClaw session for this installation."); + MxcProvisionResult provisioned = await _backend + .ProvisionAsync(new MxcProvisionRequest(_applicationId), cancellationToken) + .ConfigureAwait(false); + + // Ownership is recorded before the session is started. A crash between + // provision and start would otherwise leave a sandbox nothing claims, + // and the backend cannot be asked which sandboxes are ours. + var record = new SessionRecord + { + SandboxId = provisioned.SandboxId.Value, + ApplicationId = _applicationId, + AgentUserName = provisioned.Metadata?.AgentUserName, + AgentUserSid = provisioned.Metadata?.AgentUserSid, + WorkspacePath = provisioned.Metadata?.EphemeralWorkspacePath, + Generation = Guid.NewGuid().ToString("N"), + CreatedUtc = _clock.GetUtcNow(), + }; + try + { + _store.Write(record); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _log($"The new session could not be recorded: {exception.Message}. Deprovisioning this attempt."); + try + { + await _backend.DeprovisionAsync(provisioned.SandboxId, null, CancellationToken.None) + .ConfigureAwait(false); + } + catch (MxcException cleanup) + { + throw new SessionException( + $"The session record could not be saved, and deprovision failed: {cleanup.Message}. " + + $"Recover the owned sandbox ID from diagnostics before retrying. ID: {provisioned.SandboxId.Value}", + exception); + } + throw; + } + + await StartAsync(record, cancellationToken).ConfigureAwait(false); + return record; + } + + /// + /// Starts the recorded session without provisioning a replacement. + /// + /// + /// OpenClaw execution and gateway recovery use this path after explicit + /// setup. A missing record is a setup error, never permission to create a + /// new session implicitly. + /// + public async Task StartRecordedAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionRecord record = RequireUsableRecordOrNull() + ?? throw new SessionException( + "No isolated session is recorded. Run `clawctl setup` first."); + + await StartAsync(record, cancellationToken).ConfigureAwait(false); + return record; + } + + /// + /// Stops the session, keeping the provision and the guest profile. + /// + /// False when no session is recorded. + public async Task StopAsync(CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionRecord? record = RequireUsableRecordOrNull(); + if (record is null) + { + return false; + } + + await _backend + .StopAsync(record.ToSandboxIdOrThrow(), null, cancellationToken) + .ConfigureAwait(false); + _log("Stopped the OpenClaw session; its profile and data are retained."); + return true; + } + + /// + /// Stops and deprovisions the session, then forgets it. + /// + /// + /// This destroys the guest profile and workspace. The local record is + /// cleared only after deprovision succeeds, so a failed teardown leaves the + /// session owned rather than orphaned beyond recovery. + /// + public async Task RemoveAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionRecord? record = RequireUsableRecordOrNull(); + if (record is null) + { + return new SessionRemovalResult(false, null); + } + + MxcSandboxId sandboxId = record.ToSandboxIdOrThrow(); + + string? stopFailure = null; + try + { + await _backend.StopAsync(sandboxId, null, cancellationToken) + .ConfigureAwait(false); + } + catch (MxcException exception) + { + // Deprovision is what actually releases the account, profile, and + // workspace. Abandoning removal because stop failed would leave + // more behind than continuing does, so the failure is reported + // rather than used to stop the teardown. + stopFailure = exception.Message; + _log($"Stop failed before removal; continuing to deprovision: {exception.Message}"); + } + + await _backend.DeprovisionAsync(sandboxId, null, cancellationToken) + .ConfigureAwait(false); + _store.Clear(); + _log("Removed the OpenClaw session and its guest profile."); + return new SessionRemovalResult(true, stopFailure); + } + + /// + /// Discards the local record without contacting the backend. + /// + /// + /// This is the deliberate escape from a record that cannot be read well + /// enough to deprovision. It abandons whatever the backend still holds, so + /// it is never reached implicitly by another operation. + /// + public void ForgetRecordedState() + { + using ISessionLockHandle handle = AcquireLock(); + _store.Clear(); + _log("Discarded the local session record without contacting the backend."); + } + + private async Task StartAsync( + SessionRecord record, + CancellationToken cancellationToken) + { + // Start is issued on every path, including reuse. A stopped session + // fails execution with backend_error rather than restarting itself, and + // the backend offers no way to ask whether a session is running, so the + // only way to guarantee a usable session is to start it. + await _backend + .StartAsync(record.ToSandboxIdOrThrow(), null, cancellationToken) + .ConfigureAwait(false); + } + + private SessionRecord? RequireUsableRecordOrNull() + { + SessionStateResult state = _store.Read(_applicationId); + if (state.Record is not null) + { + return state.Record; + } + + if (state.Fault == SessionStateFault.Missing) + { + return null; + } + + throw new SessionStateException(state.Fault!.Value, state.Detail!); + } + + private ISessionLockHandle AcquireLock() => + _lifecycleLock.TryAcquire(_lockTimeout) + ?? throw new SessionBusyException(_lockTimeout); +} + +internal static class SessionRecordExtensions +{ + public static MxcSandboxId ToSandboxIdOrThrow(this SessionRecord record) => + MxcSandboxId.Parse(record.SandboxId); +} diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs new file mode 100644 index 00000000..469671a7 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -0,0 +1,394 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Session; + +/// +/// What to run inside the session. +/// +internal sealed record SessionExecutionRequest( + string HelperPath, + string NodePath, + string ApplicationDirectory, + IReadOnlyList Arguments, + string WorkingDirectory) +{ + /// + /// Environment determined by the host entrypoint for this invocation. + /// + public IReadOnlyDictionary? AdditionalEnvironment { get; init; } +} + +/// +/// An arbitrary foreground command to run inside the session. +/// +/// +/// This is the general form of : the +/// diagnostic commands need to run a shell or the guest helper's own +/// collection mode, neither of which goes through Node. The argument vector +/// still travels as JSON, so nothing here reaches the backend command line. +/// +internal sealed record SessionCommandRequest( + string HelperPath, + string Executable, + IReadOnlyList Arguments, + string WorkingDirectory) +{ + /// + /// Values merged over the shared runtime environment for this command only. + /// + /// + /// Kept per request rather than added to the shared environment because + /// these describe how one command was invoked, and OpenClaw itself must not + /// see a variable that only the shell's shim needs. + /// + public IReadOnlyDictionary? AdditionalEnvironment { get; init; } +} + +/// +/// Runs OpenClaw inside the owned session. +/// +/// +/// The backend's execution API takes a command line, not an argument vector, +/// and the pinned runtime flattens it through cmd.exe. Only the guest +/// helper's own path and its request file appear there; the user's arguments +/// travel as JSON in the shared workspace and are never exposed to that +/// flattening. +/// +internal sealed class SessionExecutor +{ + private readonly IMxcSessionClient _backend; + private readonly Action _log; + private readonly Func> _buildEnvironment; + private readonly Func _createRequestId; + private readonly Func _isCurrentRecord; + + public SessionExecutor(IMxcSessionClient backend, Action log, + Func>? buildEnvironment = null, + Func? createRequestId = null, + Func? isCurrentRecord = null) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(log); + _backend = backend; + _log = log; + _buildEnvironment = buildEnvironment ?? (() => OpenClawRuntimeEnvironment.Build()); + _createRequestId = createRequestId ?? (() => Guid.NewGuid().ToString("N")); + _isCurrentRecord = isCurrentRecord ?? (_ => true); + } + + public async Task ExecuteAsync( + SessionRecord record, + SessionExecutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(request); + + return await ExecuteCommandAsync( + record, + new SessionCommandRequest( + request.HelperPath, + request.NodePath, + BuildNodeArguments(request), + request.WorkingDirectory) + { + AdditionalEnvironment = request.AdditionalEnvironment + }, + "Running OpenClaw in the isolated session.", + "OpenClaw", + cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs one arbitrary foreground command inside the session and returns its + /// exit code. + /// + /// + /// The caller supplies the message reported before dispatch and the subject + /// name used in failures, so a shell that never started does not report + /// itself as OpenClaw. + /// + public async Task ExecuteCommandAsync( + SessionRecord record, + SessionCommandRequest request, + string startingMessage, + string subject, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(request); + + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("launch", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + + var launchRequest = new SessionLaunchRequest + { + RequestId = requestId, + Executable = request.Executable, + Arguments = request.Arguments, + WorkingDirectory = request.WorkingDirectory, + Environment = MergeEnvironment( + _buildEnvironment(), request.AdditionalEnvironment), + }; + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionLaunchProtocol.SerializeRequest(launchRequest), + cancellationToken).ConfigureAwait(false); + + string commandLine = BuildGuestCommandLine(request.HelperPath, requestPath); + _log(startingMessage); + + int executorExitCode = await _backend.ExecuteAttachedAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest(commandLine), + null, + cancellationToken).ConfigureAwait(false); + + operation.EnsureCurrent(); + return ReadOutcome(operation, resultPath, executorExitCode, requestId, subject); + } + finally + { + // Only this invocation's files are removed. Concurrent invocations + // own differently named requests in the same shared workspace. + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + + /// Installs the package's Node.js runtime in the agent profile. + public async Task InstallRuntimeAsync( + SessionRecord record, + string helperPath, + string archivePath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(helperPath); + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("runtime", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = requestId, + ArchivePath = archivePath + }), + cancellationToken).ConfigureAwait(false); + + _log("Installing the packaged Node.js runtime in the isolated session."); + + MxcExecutionResult execution = await _backend.ExecuteAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + BuildGuestCommandLine(helperPath, requestPath, "--install-runtime")), + null, + cancellationToken).ConfigureAwait(false); + + string resultText; + try + { + resultText = await operation.ReadTextAsync(resultPath, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + throw new SessionException( + "The isolated session did not report a runtime install " + + DescribeMissingResult(execution)); + } + + SessionRuntimeInstallResult result = + SessionRuntimeProtocol.ReadResult(resultText); + if (result.Error is { Length: > 0 } error) + { + throw new SessionException( + $"The packaged Node.js runtime could not be installed in the session: {error}"); + } + + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a runtime install result " + + "for a different request."); + } + + if (string.IsNullOrWhiteSpace(result.ExecutablePath) || + string.IsNullOrWhiteSpace(result.Version)) + { + throw new SessionException( + "The isolated session reported a runtime install without a " + + "Node.js executable path and version."); + } + + return result; + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + + internal static IReadOnlyDictionary MergeEnvironment( + IReadOnlyDictionary baseEnvironment, + IReadOnlyDictionary? additional) + { + ArgumentNullException.ThrowIfNull(baseEnvironment); + + if (additional is null || additional.Count == 0) + { + return baseEnvironment; + } + + var merged = new Dictionary( + baseEnvironment, StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair entry in additional) + { + merged[entry.Key] = entry.Value; + } + + return merged; + } + + private static List BuildNodeArguments( + SessionExecutionRequest request) + { + string entryPoint = Path.Combine(request.ApplicationDirectory, "openclaw.mjs"); + var arguments = new List(request.Arguments.Count + 1) { entryPoint }; + arguments.AddRange(request.Arguments); + return arguments; + } + + /// + /// Builds the only command line the backend ever sees. + /// + /// + /// + /// The pinned runtime dispatches this string through cmd.exe /c. + /// When that string contains more than two quote characters, the command + /// processor strips the first and the last one and keeps the rest, so a + /// naturally quoted "exe" --request "path" arrives with its + /// executable path unquoted and fails at the first space. An outer pair is + /// therefore added deliberately: it is the pair that gets sacrificed, and + /// the inner quoting survives intact. + /// + /// + /// Both values are paths this package controls, but they are still verified + /// rather than trusted, because cmd.exe also expands %VAR% + /// and lets an embedded quote truncate the rest of the line. + /// + /// + internal static string BuildGuestCommandLine( + string helperPath, + string requestPath, + string option = "--request") + { + Verify(helperPath, nameof(helperPath)); + Verify(requestPath, nameof(requestPath)); + return $"\"\"{helperPath}\" {option} \"{requestPath}\"\""; + + static void Verify(string value, string name) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new SessionException($"The {name} is empty."); + } + + if (value.Contains('"', StringComparison.Ordinal) || + value.Contains('%', StringComparison.Ordinal)) + { + throw new SessionException( + $"The {name} contains a quote or percent sign, which the " + + "isolated-session command line cannot carry safely: " + + value); + } + } + } + + /// + /// Establishes what actually happened from the helper's control result. + /// + /// + /// The executor's exit code alone is ambiguous. The helper's failure code + /// is a value OpenClaw itself can return, and an attached execution + /// captures nothing, so a dispatch failure is indistinguishable from an + /// application exit without this file. + /// + private static int ReadOutcome( + SessionWorkspaceOperation operation, + string resultPath, + int executorExitCode, + string requestId, + string subject) + { + string resultText; + try + { + resultText = operation.ReadTextAsync(resultPath, CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or IOException) + { + throw new SessionException( + "The isolated session did not report a launch result " + + $"(executor exit code {executorExitCode}). {subject} may not " + + "have started."); + } + + SessionLaunchResult result; + try + { + result = SessionLaunchProtocol.ReadResult(resultText); + } + catch (SessionLaunchException exception) + { + throw new SessionException( + $"The isolated session reported an unreadable launch result: " + + $"{exception.Message}", + exception); + } + + if (!result.Launched) + { + throw new SessionException( + $"{subject} could not be started inside the isolated session: " + + (result.Error ?? "no reason was reported.")); + } + + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a launch result for a " + + "different request."); + } + + return result.ExitCode ?? executorExitCode; + } + + private static string DescribeMissingResult(MxcExecutionResult execution) + { + string detail = string.Join( + " ", + new[] { execution.StandardOutput, execution.StandardError } + .Where(static value => !string.IsNullOrWhiteSpace(value))); + return string.IsNullOrWhiteSpace(detail) + ? $"result (executor exit code {execution.ExitCode})." + : $"result (executor exit code {execution.ExitCode}): {detail}"; + } +} diff --git a/src/OpenClaw.Launcher/Session/SessionHelperStager.cs b/src/OpenClaw.Launcher/Session/SessionHelperStager.cs new file mode 100644 index 00000000..062405fa --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionHelperStager.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; + +namespace OpenClaw.Launcher.Session; + +/// +/// Stages the packaged guest helper into the session's shared workspace. +/// +/// +/// IsolationSession agent identities cannot execute binaries directly from +/// another package's WindowsApps directory. The shared workspace is visible +/// to both identities, so setup copies the immutable packaged helper there +/// before any guest command is dispatched. +/// +internal static class SessionHelperStager +{ + private const string StagingDirectoryName = ".openclaw-session-host"; + + public static string Stage(string packagedHelperPath, string workspacePath) + { + string source = RequirePackagedHelper(packagedHelperPath); + using SessionWorkspaceOperation operation = CreateOperation(workspacePath); + string destination = ResolveStagedPath(operation.WorkspacePath); + + if (File.Exists(destination)) + { + using FileStream existing = operation.OpenRead(destination); + if (FilesMatch(source, existing)) + { + return destination; + } + } + + string? destinationDirectory = Path.GetDirectoryName(destination); + if (string.IsNullOrWhiteSpace(destinationDirectory)) + { + throw new SessionException( + $"The staged helper path has no parent directory: {destination}"); + } + + operation.EnsureDirectory(destinationDirectory); + try + { + operation.Delete(destination); + using FileStream input = File.OpenRead(source); + using Stream output = operation.CreateNew(destination); + input.CopyTo(output); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + throw new SessionException( + "The packaged session helper could not be staged into the " + + $"shared workspace: {exception.Message}", + exception); + } + + return destination; + } + + public static string RequireStaged( + string packagedHelperPath, + string workspacePath) + { + string source = RequirePackagedHelper(packagedHelperPath); + try + { + using SessionWorkspaceOperation operation = CreateOperation(workspacePath); + string path = ResolveStagedPath(operation.WorkspacePath); + using FileStream staged = operation.OpenRead(path); + return FilesMatch(source, staged) + ? path + : throw new SessionException( + "The isolated-session helper does not match this package version. " + + "Run `clawctl setup` again."); + } + catch (Exception exception) when ( + exception is FileNotFoundException or SessionException) + { + throw new SessionException( + "The isolated-session helper has not been staged for this " + + "package version. Run `clawctl setup` again.", + exception); + } + } + + internal static string ResolveStagedPath(string workspacePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + + string version = typeof(SessionHelperStager).Assembly + .GetName() + .Version? + .ToString() ?? "unknown"; + return Path.GetFullPath( + Path.Combine( + workspacePath, + StagingDirectoryName, + version, + SessionRuntime.HelperFileName)); + } + + internal static string RequirePackagedHelper(string packagedHelperPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagedHelperPath); + + string path = Path.GetFullPath(packagedHelperPath); + return File.Exists(path) + ? path + : throw new SessionException( + $"The packaged session helper is missing: {path}"); + } + + private static SessionWorkspaceOperation CreateOperation(string workspacePath) + { + var stagingRecord = new SessionRecord + { + SandboxId = "iso:helper-staging", + ApplicationId = "helper-staging", + WorkspacePath = workspacePath, + Generation = typeof(SessionHelperStager).Assembly + .GetName() + .Version? + .ToString() ?? "unknown" + }; + return new SessionWorkspaceOperation(stagingRecord, _ => true); + } + + private static bool FilesMatch(string source, Stream destination) => + new FileInfo(source).Length == destination.Length && + CryptographicOperations.FixedTimeEquals( + SHA256.HashData(File.ReadAllBytes(source)), + SHA256.HashData(destination)); +} diff --git a/src/OpenClaw.Launcher/Session/SessionLock.cs b/src/OpenClaw.Launcher/Session/SessionLock.cs new file mode 100644 index 00000000..78e7bc34 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionLock.cs @@ -0,0 +1,141 @@ +namespace OpenClaw.Launcher.Session; + +/// +/// A held lifecycle lock. Disposing releases it. +/// +internal interface ISessionLockHandle : IDisposable +{ +} + +/// +/// Serializes session lifecycle transitions across processes. +/// +/// +/// This guards provision, start, stop, and deprovision only. It is deliberately +/// not held for the lifetime of a foreground OpenClaw invocation: a long +/// interactive run would otherwise block every other command indefinitely, and +/// a crash would leave the next caller waiting on a lock whose owner is gone. +/// +internal interface ISessionLock +{ + /// + /// Acquires the lock, or returns null if elapses. + /// + ISessionLockHandle? TryAcquire(TimeSpan timeout); +} + +/// +/// Named-mutex lifecycle lock, scoped to one installation's LocalState path. +/// +internal sealed class NamedSessionLock : ISessionLock +{ + private readonly string _name; + + public NamedSessionLock(string scope) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + + // The LocalState path makes this installation-specific. Global\ then + // carries that same lock across console and remote desktop sessions. + _name = "Global\\OpenClawSessionLifecycle_" + Sanitize(scope); + } + + public string Name => _name; + + public ISessionLockHandle? TryAcquire(TimeSpan timeout) + { + ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero); + var handle = new Handle(_name, timeout); + try + { + if (handle.Acquired) + { + return handle; + } + } + catch + { + handle.Dispose(); + throw; + } + + handle.Dispose(); + return null; + } + + private static string Sanitize(string scope) + { + Span buffer = scope.Length <= 128 + ? stackalloc char[scope.Length] + : new char[scope.Length]; + + for (int index = 0; index < scope.Length; index++) + { + char value = scope[index]; + buffer[index] = char.IsAsciiLetterOrDigit(value) ? value : '_'; + } + + return new string(buffer); + } + + private sealed class Handle : ISessionLockHandle + { + private readonly ManualResetEventSlim _release = new(); + private readonly TaskCompletionSource _acquired = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Thread _owner; + private int _disposed; + + public Handle(string name, TimeSpan timeout) + { + // Mutex ownership is thread-affine; lifecycle methods await backend + // I/O and can dispose on a different thread. Keep ownership on one + // dedicated thread while the caller holds this transferable lease. + _owner = new Thread(() => Hold(name, timeout)) { IsBackground = true }; + _owner.Start(); + } + + public bool Acquired => _acquired.Task.GetAwaiter().GetResult(); + + private void Hold(string name, TimeSpan timeout) + { + try + { + using var mutex = new Mutex(false, name); + bool acquired; + try + { + acquired = mutex.WaitOne(timeout); + } + catch (AbandonedMutexException) + { + acquired = true; + } + + _acquired.SetResult(acquired); + if (acquired) + { + _release.Wait(); + mutex.ReleaseMutex(); + } + } + catch (Exception exception) when ( + exception is UnauthorizedAccessException or IOException or + WaitHandleCannotBeOpenedException) + { + _acquired.TrySetException(exception); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + _release.Set(); + _owner.Join(); + _release.Dispose(); + } + } +} diff --git a/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs b/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs new file mode 100644 index 00000000..a4c917a2 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs @@ -0,0 +1,152 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Whether the user has asked for, or ruled out, isolated-session execution. +/// +internal enum SessionMode +{ + /// Use a session wherever the backend reports support. + Automatic, + + /// Never use a session. + Disabled, + + /// Use a session, and fail rather than run outside one. + Required, +} + +/// +/// Where an openclaw invocation will run. +/// +internal enum SessionRouting +{ + /// Inside the owned isolated session. + Session, + + /// Directly on the host, as before this feature existed. + Direct, +} + +/// +/// The routing choice and the reason for it, so diagnostics can explain it. +/// +internal sealed record SessionRoutingDecision(SessionRouting Routing, string Reason); + +/// +/// Chooses between isolated-session and direct execution. +/// +internal static class SessionRoutingPolicy +{ + /// + /// Overrides the automatic choice. Unset means automatic. + /// + public const string ModeVariable = "OPENCLAW_SESSION"; + + public static SessionMode ReadMode(Func readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + + string? value = readEnvironmentVariable(ModeVariable)?.Trim(); + if (string.IsNullOrEmpty(value)) + { + return SessionMode.Automatic; + } + + // Upper-case normalization, because CA1308 warns that lower-casing can + // lose information for some cultures. The comparison set is ASCII, so + // either direction matches; upper-case is the safe convention. + return value.ToUpperInvariant() switch + { + "0" or "FALSE" or "OFF" or "NO" => SessionMode.Disabled, + "1" or "TRUE" or "ON" or "YES" => SessionMode.Required, + + // An unrecognized value is not treated as "off". Silently ignoring + // it would run outside the session the user was trying to request. + _ => throw new SessionException( + $"{ModeVariable} is set to '{value}', which is not one of " + + "1, 0, true, false, on, off, yes, or no."), + }; + } + + /// + /// Decides where to run. + /// + /// + /// + /// A machine that cannot host sessions runs directly, exactly as it did + /// before this feature existed. That is a capability of the machine, not a + /// failure to hide. + /// + /// + /// There is deliberately no fallback once a session is chosen. A backend + /// that breaks on a supported machine must surface, not quietly relocate + /// the user's work onto the host with a different profile and different + /// isolation. + /// + /// + public static SessionRoutingDecision Decide( + SessionMode mode, + string? packageFamilyName, + MxcReadinessReport readiness) + { + ArgumentNullException.ThrowIfNull(readiness); + + if (mode == SessionMode.Disabled) + { + return new SessionRoutingDecision( + SessionRouting.Direct, + $"{ModeVariable} is set to 0."); + } + + if (packageFamilyName is null) + { + return Unavailable( + mode, + "OpenClaw is not running from its installed package, so it has " + + "no identity to provision an isolated session with."); + } + + if (!readiness.RuntimeAvailable) + { + return Unavailable( + mode, + "The isolated-session runtime is unavailable: " + + (readiness.RuntimeUnavailableReason ?? "no reason was reported.")); + } + + if (readiness.BackendProbe is { IsolationSessionAvailable: false }) + { + return Unavailable( + mode, + "This machine's isolated-session backend reported that it is " + + "not available."); + } + + if (readiness.BackendProbe is null && + readiness.HostSupport != MxcHostSupport.Supported) + { + string detail = readiness.HostSupport == MxcHostSupport.Unsupported + ? "This Windows build does not support isolated agent sessions." + : "Isolated-session support could not be determined on this machine."; + return Unavailable( + mode, + readiness.BackendProbeFailureReason is null + ? detail + : $"{detail} The backend probe failed: " + + readiness.BackendProbeFailureReason); + } + + return new SessionRoutingDecision( + SessionRouting.Session, + "The isolated-session backend is available."); + } + + private static SessionRoutingDecision Unavailable(SessionMode mode, string reason) => + mode == SessionMode.Required + ? throw new SessionException( + $"{ModeVariable} requires an isolated session, but one cannot " + + $"be used. {reason}") + : new SessionRoutingDecision(SessionRouting.Direct, reason); +} diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs new file mode 100644 index 00000000..b091f923 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -0,0 +1,267 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Session; + +/// +/// Assembles the session stack from the running installation. +/// +/// +/// Composition lives here rather than in Program so both the agent and +/// control entry points build the same coordinator, over the same state root +/// and the same lifecycle lock, for the same package identity. +/// +internal sealed class SessionRuntime +{ + /// + /// Directory under the application base holding the guest helper, staged + /// per architecture exactly like the MXC runtime. + /// + public const string HelperDirectoryName = "session-host"; + + public const string HelperFileName = "openclaw-session-host.exe"; + + private SessionRuntime( + SessionCoordinator coordinator, + SessionExecutor executor, + IMxcSessionClient backend, + string helperPath, + string applicationId, + SetupStateStore setupState, + string lifecycleLockScope) + { + Coordinator = coordinator; + Executor = executor; + Backend = backend; + HelperPath = helperPath; + ApplicationId = applicationId; + SetupState = setupState; + LifecycleLock = new NamedSessionLock(lifecycleLockScope); + } + + public SessionCoordinator Coordinator { get; } + + public SessionExecutor Executor { get; } + + /// + /// The one backend instance this process uses, so the gateway and ordinary + /// invocations cannot end up talking to differently configured clients. + /// + public IMxcSessionClient Backend { get; } + + public string HelperPath { get; } + + public string ApplicationId { get; } + + public SetupStateStore SetupState { get; } + + public ISessionLock LifecycleLock { get; } + + public ISessionLockHandle AcquireLifecycleLock() => + LifecycleLock.TryAcquire(SessionCoordinator.DefaultLockTimeout) + ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); + + public async Task StartForExecutionAsync(CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLifecycleLock(); + RequireSetup(); + try + { + return await Coordinator.StartRecordedAsync(cancellationToken).ConfigureAwait(false); + } + catch (MxcException exception) when (exception.Code == MxcErrorCode.RuntimeUnavailable) + { + throw new SessionCapabilityUnavailableException(exception.Message, exception); + } + } + + public string StageHelper(SessionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return SessionHelperStager.Stage( + HelperPath, + RequireWorkspace(record)); + } + + public string RequireStagedHelper(SessionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return SessionHelperStager.RequireStaged( + HelperPath, + RequireWorkspace(record)); + } + + public static SessionRuntime Create(Action log) => + Create( + HostPaths.Create(), + MxcRuntimeLocator.Locate, + AppContext.BaseDirectory, + log); + + internal static SessionRuntime Create( + HostPaths paths, + Func locateRuntime, + string baseDirectory, + Action log, + IMxcSessionClient? backend = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(log); + + if (paths.PackageFamilyName is null) + { + throw new SessionCapabilityUnavailableException( + "OpenClaw is not running from its installed package, so it " + + "has no identity to provision an isolated session with."); + } + + string applicationId = + PackageIdentity.ToApplicationId(paths.PackageFamilyName); + IMxcSessionClient client = backend ?? new LazyMxcSessionClient( + () => new MxcCliSessionClient(locateRuntime())); + + var coordinator = new SessionCoordinator( + client, + new SessionStateStore(paths.SessionStatePath), + new NamedSessionLock(paths.SessionStatePath), + applicationId, + log); + + return new SessionRuntime( + coordinator, + new SessionExecutor(client, log, isCurrentRecord: IsCurrentSessionRecord), + client, + ResolveHelperPath(baseDirectory), + applicationId, + new SetupStateStore(paths.SetupStatePath), + paths.SessionStatePath + "_Installation"); + + bool IsCurrentSessionRecord(SessionRecord record) + { + SessionStatus status = coordinator.GetRecordedStatus(); + return status.Record is not null && + string.Equals(status.Record.SandboxId, record.SandboxId, StringComparison.Ordinal) && + string.Equals(status.Record.Generation, record.Generation, StringComparison.Ordinal); + } + } + + /// + /// Requires both the explicit setup marker and the owned session record. + /// + /// + /// This is read-only. Starting the session remains a separate operation so + /// a race with teardown cannot turn a stale marker into a new provision. + /// + public SessionRecord RequireSetup() + { + SetupStateResult setup = SetupState.Read(ApplicationId); + if (setup.Record is null) + { + throw new SessionException( + setup.Fault == SetupStateFault.Missing + ? "OpenClaw has not been set up. Run `clawctl setup` first." + : setup.Detail!); + } + + if (setup.Record.Phase != SetupPhase.Ready) + { + throw new SessionException( + setup.Record.Phase == SetupPhase.TearingDown + ? "OpenClaw teardown is incomplete. Run `clawctl teardown` again." + : "OpenClaw setup is incomplete. Run `clawctl setup` again."); + } + + SessionStatus session = Coordinator.GetRecordedStatus(); + if (session.Record is null) + { + throw new SessionException( + session.Detail is null + ? "OpenClaw setup is incomplete because its isolated session is " + + "not recorded. Run `clawctl setup` again." + : $"{session.Detail} Run `clawctl setup` again."); + } + + if (setup.Record.SandboxId is { } sandboxId && + !string.Equals(sandboxId, session.Record.SandboxId, StringComparison.Ordinal)) + { + throw new SessionException( + "The setup marker names a different session. Run `clawctl setup` again."); + } + + return session.Record; + } + + public void ValidateSavedOwnershipForHostFallback() + { + SetupStateResult setup = SetupState.Read(ApplicationId); + SessionStatus session = Coordinator.GetRecordedStatus(); + if (setup.Fault == SetupStateFault.Missing && + session.Availability == SessionAvailability.None) + { + return; + } + + RequireSetup(); + } + + /// + /// Returns the Node.js executable extracted by the agent for the currently + /// packaged runtime. + /// + public string RequireAgentNodePath(string packagedArchivePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagedArchivePath); + + SetupStateResult setup = SetupState.Read(ApplicationId); + SetupRecord? record = setup.Record; + if (record?.AgentNodePath is not { Length: > 0 } executablePath || + !string.Equals( + record.AgentNodeArchive, + Path.GetFileName(packagedArchivePath), + StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session runtime does not match this package. " + + "Run `clawctl setup` again."); + } + + return executablePath; + } + + /// Records a completed guest runtime installation. + public void CompleteSetup( + SessionRecord session, + SessionRuntimeInstallResult runtime, + bool startupEnabled) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(runtime); + + SetupState.Write(new SetupRecord + { + ApplicationId = ApplicationId, + SandboxId = session.SandboxId, + Phase = SetupPhase.Ready, + StartupEnabled = startupEnabled, + CompletedUtc = DateTimeOffset.UtcNow, + AgentNodePath = runtime.ExecutablePath, + AgentNodeVersion = runtime.Version, + AgentNodeArchive = runtime.ArchiveName + }); + } + + internal static string ResolveHelperPath(string baseDirectory) => + Path.GetFullPath( + Path.Combine( + baseDirectory, + HelperDirectoryName, + MxcRuntimeLocator.CurrentArchitectureName(), + HelperFileName)); + + private static string RequireWorkspace(SessionRecord record) => + string.IsNullOrWhiteSpace(record.WorkspacePath) + ? throw new SessionException( + "The recorded session has no shared workspace, so the session " + + "helper cannot be staged.") + : record.WorkspacePath; +} diff --git a/src/OpenClaw.Launcher/Session/SessionStateStore.cs b/src/OpenClaw.Launcher/Session/SessionStateStore.cs new file mode 100644 index 00000000..b7b0f5a3 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionStateStore.cs @@ -0,0 +1,304 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// The durable record of the session this installation owns. +/// +/// +/// +/// The opaque backend id is stored verbatim. It carries the provisioning +/// application identity and backend routing prefix, so it cannot be rebuilt +/// from parts and must never be normalized on the way in or out. +/// +/// +/// Ownership lives here and nowhere else. The backend's deprovision is +/// idempotent and unrelated agent accounts can already exist on a machine, so +/// neither the presence of an account nor the absence of an error proves this +/// installation created anything. +/// +/// +internal sealed record SessionRecord +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("sandboxId")] + public string SandboxId { get; init; } = string.Empty; + + [JsonPropertyName("applicationId")] + public string ApplicationId { get; init; } = string.Empty; + + [JsonPropertyName("agentUserName")] + public string? AgentUserName { get; init; } + + [JsonPropertyName("agentUserSid")] + public string? AgentUserSid { get; init; } + + [JsonPropertyName("workspacePath")] + public string? WorkspacePath { get; init; } + + [JsonPropertyName("generation")] + public string Generation { get; init; } = string.Empty; + + [JsonPropertyName("wireVersion")] + public string? WireVersion { get; init; } + + [JsonPropertyName("createdUtc")] + public DateTimeOffset CreatedUtc { get; init; } +} + +/// +/// Why a stored session record could not be used. +/// +internal enum SessionStateFault +{ + /// No record has been written yet. + Missing, + + /// The file exists but is not readable as a record. + Unreadable, + + /// The record was written by an incompatible newer version. + UnsupportedSchema, + + /// The record is missing values it cannot be used without. + Incomplete, + + /// The record belongs to a different package identity. + ForeignIdentity, +} + +/// +/// The outcome of reading the stored session record. +/// +internal sealed record SessionStateResult( + SessionRecord? Record, + SessionStateFault? Fault, + string? Detail) +{ + public bool HasRecord => Record is not null; + + public static SessionStateResult Found(SessionRecord record) => + new(record, null, null); + + public static SessionStateResult Failed(SessionStateFault fault, string detail) => + new(null, fault, detail); +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(SessionRecord))] +internal sealed partial class SessionStateJsonContext : JsonSerializerContext +{ +} + +/// +/// Reads and atomically writes the owned-session record. +/// +internal sealed class SessionStateStore +{ + public const int CurrentSchemaVersion = 2; + + private readonly string _filePath; + + public SessionStateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = Path.GetFullPath(filePath); + } + + public string FilePath => _filePath; + + /// + /// Reads the record, distinguishing "no session" from "unusable session". + /// + /// + /// A damaged record never degrades to . + /// Provisioning a replacement over a record we cannot read would abandon a + /// live backend session and the user's guest profile with it. + /// + public SessionStateResult Read(string expectedApplicationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedApplicationId); + + string text; + try + { + text = File.ReadAllText(_filePath); + } + catch (FileNotFoundException) + { + return SessionStateResult.Failed( + SessionStateFault.Missing, + "No session has been recorded."); + } + catch (DirectoryNotFoundException) + { + return SessionStateResult.Failed( + SessionStateFault.Missing, + "No session has been recorded."); + } + catch (IOException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + $"The session record could not be read: {exception.Message}"); + } + catch (UnauthorizedAccessException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + $"The session record could not be read: {exception.Message}"); + } + + SessionRecord? record; + try + { + record = JsonSerializer.Deserialize( + text, + SessionStateJsonContext.Default.SessionRecord); + } + catch (JsonException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + $"The session record is not valid JSON: {exception.Message}"); + } + + if (record is null) + { + return SessionStateResult.Failed( + SessionStateFault.Unreadable, + "The session record is empty."); + } + + if (record.SchemaVersion > CurrentSchemaVersion) + { + return SessionStateResult.Failed( + SessionStateFault.UnsupportedSchema, + $"The session record uses schema version {record.SchemaVersion}, but " + + $"this build understands version {CurrentSchemaVersion}. A newer " + + "OpenClaw installation may have written it."); + } + + if (record.SchemaVersion < 1) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + "The session record does not declare a schema version."); + } + + if (record.SchemaVersion < CurrentSchemaVersion) + { + return SessionStateResult.Failed( + SessionStateFault.UnsupportedSchema, + $"The session record uses schema version {record.SchemaVersion}, but " + + $"this pre-release build requires version {CurrentSchemaVersion}. Run " + + "`clawctl setup --fresh --force` to recreate it."); + } + + if (string.IsNullOrWhiteSpace(record.ApplicationId)) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + "The session record does not contain an application identity."); + } + + if (string.IsNullOrWhiteSpace(record.Generation)) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + "The session record does not contain an operation generation."); + } + + // Identity is checked before the sandbox id so a record from another + // installation is never reported as this installation's corruption. + if (!string.Equals( + record.ApplicationId, + expectedApplicationId, + StringComparison.OrdinalIgnoreCase)) + { + return SessionStateResult.Failed( + SessionStateFault.ForeignIdentity, + $"The session record belongs to '{record.ApplicationId}', but this " + + $"installation is '{expectedApplicationId}'."); + } + + MxcSandboxId sandboxId; + try + { + sandboxId = MxcSandboxId.Parse(record.SandboxId); + } + catch (MxcException exception) + { + return SessionStateResult.Failed( + SessionStateFault.Incomplete, + $"The session record does not contain a usable sandbox identifier: " + + $"{exception.Message}"); + } + + // The record naming this installation does not establish that the + // identity inside it does. This file is writable by the signed-in user, + // so a substituted sandbox id would otherwise be replayed to the + // backend on the caller's authority. + if (sandboxId.TryGetOwningApplicationId(out string? owningApplicationId) && + !string.Equals( + owningApplicationId, + expectedApplicationId, + StringComparison.OrdinalIgnoreCase)) + { + return SessionStateResult.Failed( + SessionStateFault.ForeignIdentity, + $"The recorded sandbox was issued to '{owningApplicationId}', but " + + $"this installation is '{expectedApplicationId}'."); + } + + return SessionStateResult.Found(record); + } + + /// + /// Replaces the record atomically. + /// + /// + /// The write lands on a temporary file in the same directory and is then + /// moved over the target, so a crash mid-write cannot leave a truncated + /// record that would later read as a damaged session. + /// + public void Write(SessionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(record.ApplicationId); + ArgumentException.ThrowIfNullOrWhiteSpace(record.Generation); + _ = MxcSandboxId.Parse(record.SandboxId); + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string text = JsonSerializer.Serialize( + record with { SchemaVersion = CurrentSchemaVersion }, + SessionStateJsonContext.Default.SessionRecord); + + string temporaryPath = _filePath + ".tmp"; + File.WriteAllText(temporaryPath, text); + File.Move(temporaryPath, _filePath, overwrite: true); + } + + /// + /// Removes the record. Deleting nothing is success. + /// + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs b/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs new file mode 100644 index 00000000..40e29bef --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionWorkspaceOperation.cs @@ -0,0 +1,148 @@ +using System.Text; + +namespace OpenClaw.Launcher.Session; + +/// +/// Owns one host/guest exchange in the shared session workspace. +/// +/// +/// The workspace is guest-writable, so callers must not authorize an operation +/// from paths alone. This contract pins the recorded sandbox generation, +/// validates the opened workspace object, rechecks that the same generation is +/// still current before each effect, and opens request/result files through +/// handle-bound TrustedPath helpers. +/// +internal sealed class SessionWorkspaceOperation : IDisposable +{ + private readonly SessionRecord _record; + private readonly Func _isCurrent; + private readonly TrustedPath.ValidatedDirectory _workspace; + + public SessionWorkspaceOperation( + SessionRecord record, + Func isCurrent) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(isCurrent); + + if (string.IsNullOrWhiteSpace(record.WorkspacePath)) + { + throw new SessionException( + "The recorded session has no shared workspace, so an operation " + + "cannot be delivered to it."); + } + + if (string.IsNullOrWhiteSpace(record.Generation)) + { + throw new SessionException( + "The recorded session has no operation generation. Run `clawctl setup` again."); + } + + _record = record; + _isCurrent = isCurrent; + _workspace = TrustedPath.TryOpenValidatedDirectory(record.WorkspacePath, expectedIdentity: null) + ?? throw new SessionException( + $"The recorded shared workspace could not be opened safely: {record.WorkspacePath}"); + try + { + EnsureCurrent(); + } + catch + { + _workspace.Dispose(); + throw; + } + } + + public string WorkspacePath => _workspace.FinalPath; + + public string FilePath(string prefix, string requestId, string suffix = ".json") + { + VerifyName(prefix, nameof(prefix)); + VerifyName(requestId, nameof(requestId)); + if (suffix.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + suffix.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new SessionException($"The operation suffix is not a file suffix: {suffix}"); + } + + return Path.Combine( + WorkspacePath, + $"{prefix}-{_record.Generation}-{requestId}{suffix}"); + } + + public async Task WriteTextNewAsync( + string path, + string text, + CancellationToken cancellationToken) + { + EnsureCurrent(); + using Stream stream = TrustedPath.CreateNew(_workspace, path); + byte[] bytes = Encoding.UTF8.GetBytes(text); + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + public async Task ReadTextAsync( + string path, + CancellationToken cancellationToken) + { + using FileStream stream = OpenRead(path); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + } + + public FileStream OpenRead(string path) + { + EnsureCurrent(); + return TrustedPath.OpenRead(WorkspacePath, path); + } + + public Stream CreateNew(string path) + { + EnsureCurrent(); + return TrustedPath.CreateNew(_workspace, path); + } + + public void EnsureDirectory(string path) + { + EnsureCurrent(); + TrustedPath.EnsureDirectory(_workspace, path); + } + + public void Delete(string path) + { + EnsureCurrent(); + try + { + _ = TrustedPath.TryDeleteOwnedEntry( + _workspace, + path, + deleteReparsePointLeaf: true); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + } + } + + public void EnsureCurrent() + { + if (!_isCurrent(_record)) + { + throw new SessionException( + "The recorded isolated-session generation changed before the operation completed. Retry the command."); + } + } + + public void Dispose() => _workspace.Dispose(); + + private static void VerifyName(string value, string name) + { + if (string.IsNullOrWhiteSpace(value) || + value.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + value.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new SessionException($"The operation {name} is not a single safe path segment."); + } + } +} diff --git a/src/OpenClaw.Launcher/Session/SetupStateStore.cs b/src/OpenClaw.Launcher/Session/SetupStateStore.cs new file mode 100644 index 00000000..66ad87af --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SetupStateStore.cs @@ -0,0 +1,211 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Session; + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum SetupPhase +{ + Ready, + Preparing, + TearingDown +} + +/// +/// The durable marker that this installation completed explicit setup. +/// +/// +/// The marker is separate from the session record because older management +/// commands can create a session without completing the new setup workflow. +/// A valid session therefore does not, by itself, authorize openclaw. +/// +internal sealed record SetupRecord +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("applicationId")] + public string ApplicationId { get; init; } = string.Empty; + + [JsonPropertyName("completedUtc")] + public DateTimeOffset CompletedUtc { get; init; } + + [JsonPropertyName("phase")] + public SetupPhase Phase { get; init; } = SetupPhase.Ready; + + [JsonPropertyName("sandboxId")] + public string? SandboxId { get; init; } + + [JsonPropertyName("startupEnabled")] + public bool StartupEnabled { get; init; } = true; + + [JsonPropertyName("agentNodePath")] + public string? AgentNodePath { get; init; } + + [JsonPropertyName("agentNodeVersion")] + public string? AgentNodeVersion { get; init; } + + [JsonPropertyName("agentNodeArchive")] + public string? AgentNodeArchive { get; init; } +} + +/// Why the explicit setup marker could not be used. +internal enum SetupStateFault +{ + /// Setup has not completed for this installation. + Missing, + + /// The marker exists but cannot be read as a record. + Unreadable, + + /// The marker was written by an incompatible newer version. + UnsupportedSchema, + + /// The marker is missing values it cannot be used without. + Incomplete, + + /// The marker belongs to a different package identity. + ForeignIdentity, +} + +internal sealed record SetupStateResult( + SetupRecord? Record, + SetupStateFault? Fault, + string? Detail) +{ + public static SetupStateResult Found(SetupRecord record) => + new(record, null, null); + + public static SetupStateResult Failed(SetupStateFault fault, string detail) => + new(null, fault, detail); +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(SetupRecord))] +internal sealed partial class SetupStateJsonContext : JsonSerializerContext; + +/// +/// Reads and atomically writes the explicit setup marker. +/// +internal sealed class SetupStateStore +{ + public const int CurrentSchemaVersion = 2; + + private readonly string _filePath; + + public SetupStateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = Path.GetFullPath(filePath); + } + + public string FilePath => _filePath; + + public SetupStateResult Read(string expectedApplicationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedApplicationId); + + string text; + try + { + text = File.ReadAllText(_filePath); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return SetupStateResult.Failed( + SetupStateFault.Missing, + "Explicit setup has not completed."); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return SetupStateResult.Failed( + SetupStateFault.Unreadable, + $"The setup record could not be read: {exception.Message}"); + } + + SetupRecord? record; + try + { + record = JsonSerializer.Deserialize( + text, + SetupStateJsonContext.Default.SetupRecord); + } + catch (JsonException exception) + { + return SetupStateResult.Failed( + SetupStateFault.Unreadable, + $"The setup record is not valid JSON: {exception.Message}"); + } + + if (record is null) + { + return SetupStateResult.Failed( + SetupStateFault.Unreadable, + "The setup record is empty."); + } + + if (record.SchemaVersion > CurrentSchemaVersion) + { + return SetupStateResult.Failed( + SetupStateFault.UnsupportedSchema, + $"The setup record uses schema version {record.SchemaVersion}, " + + $"but this build understands version {CurrentSchemaVersion}. A " + + "newer OpenClaw installation may have written it."); + } + + if (record.SchemaVersion < 1 || + string.IsNullOrWhiteSpace(record.ApplicationId) || + !Enum.IsDefined(record.Phase)) + { + return SetupStateResult.Failed( + SetupStateFault.Incomplete, + "The setup record is missing its schema version or application identity."); + } + + if (!string.Equals( + record.ApplicationId, + expectedApplicationId, + StringComparison.OrdinalIgnoreCase)) + { + return SetupStateResult.Failed( + SetupStateFault.ForeignIdentity, + $"The setup record belongs to '{record.ApplicationId}', but this " + + $"installation is '{expectedApplicationId}'."); + } + + return SetupStateResult.Found(record); + } + + public void Write(SetupRecord record) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(record.ApplicationId); + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string text = JsonSerializer.Serialize( + record with { SchemaVersion = CurrentSchemaVersion }, + SetupStateJsonContext.Default.SetupRecord); + + string temporaryPath = _filePath + ".tmp"; + File.WriteAllText(temporaryPath, text); + File.Move(temporaryPath, _filePath, overwrite: true); + } + + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/Session/TrustedPath.cs b/src/OpenClaw.Launcher/Session/TrustedPath.cs new file mode 100644 index 00000000..9ce1b112 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/TrustedPath.cs @@ -0,0 +1,840 @@ +using Microsoft.Win32.SafeHandles; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher.Session; + +/// Rejects path redirection below a caller-established root. +internal static partial class TrustedPath +{ + internal readonly record struct FileIdentity( + uint VolumeSerialNumber, + uint FileIndexHigh, + uint FileIndexLow); + + internal sealed class ValidatedDirectory : IDisposable + { + internal ValidatedDirectory( + SafeFileHandle handle, + FileIdentity identity, + string finalPath) + { + Handle = handle; + Identity = identity; + FinalPath = finalPath; + } + + internal SafeFileHandle Handle { get; } + + internal FileIdentity Identity { get; } + + internal string FinalPath { get; } + + public void Dispose() => Handle.Dispose(); + } + + private sealed class ProtectedDirectoryChain : IDisposable + { + private readonly List _directories = []; + + public ValidatedDirectory Leaf => _directories[^1]; + + public void Add(ValidatedDirectory directory) => _directories.Add(directory); + + public void Dispose() + { + for (int index = _directories.Count - 1; index >= 0; index--) + { + _directories[index].Dispose(); + } + } + } + + private sealed class ProtectedStream( + FileStream stream, + ProtectedDirectoryChain directories) : Stream + { + public override bool CanRead => stream.CanRead; + + public override bool CanSeek => stream.CanSeek; + + public override bool CanWrite => stream.CanWrite; + + public override long Length => stream.Length; + + public override long Position + { + get => stream.Position; + set => stream.Position = value; + } + + public override void Flush() => stream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + stream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => + stream.Seek(offset, origin); + + public override void SetLength(long value) => stream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => + stream.Write(buffer, offset, count); + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) => + stream.WriteAsync(buffer, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + stream.Dispose(); + directories.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await stream.DisposeAsync().ConfigureAwait(false); + directories.Dispose(); + await base.DisposeAsync().ConfigureAwait(false); + } + } + public static void EnsureNoReparsePoints(string trustedRoot, string candidatePath) => + EnsureNoReparsePoints(trustedRoot, candidatePath, File.GetAttributes); + + internal static void EnsureNoReparsePoints( + string trustedRoot, + string candidatePath, + Func getAttributes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(trustedRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(candidatePath); + ArgumentNullException.ThrowIfNull(getAttributes); + + string root = Path.GetFullPath(trustedRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string candidate = Path.GetFullPath(candidatePath); + string prefix = root + Path.DirectorySeparatorChar; + if (!candidate.Equals(root, StringComparison.OrdinalIgnoreCase) && + !candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException( + $"The path resolves outside its trusted root: {candidate}"); + } + + Check(root); + string relative = Path.GetRelativePath(root, candidate); + if (relative == ".") + { + return; + } + + string current = root; + foreach (string segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + Check(current); + } + + void Check(string path) + { + FileAttributes attributes; + try + { + attributes = getAttributes(path); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + $"The path contains a reparse point and is unsafe for host access: {path}"); + } + } + } + /// + /// Opens a file only when the object opened by the host is still below the + /// trusted root and is not a reparse point. + /// + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The FileStream constructor takes ownership of the SafeFileHandle.")] + public static FileStream OpenRead(string trustedRoot, string candidatePath) + { + EnsureNoReparsePoints(trustedRoot, candidatePath); + + SafeFileHandle handle = CreateFile( + candidatePath, + GenericRead, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + if (error is ErrorFileNotFound or ErrorPathNotFound) + { + throw new FileNotFoundException( + $"The trusted file does not exist: {candidatePath}", + candidatePath); + } + + throw new IOException( + $"The trusted file could not be opened: {candidatePath}", + new System.ComponentModel.Win32Exception(error)); + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + $"The opened path is a reparse point: {candidatePath}"); + } + + string root = Path.GetFullPath(trustedRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string opened = GetFinalPath(handle) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string prefix = root + Path.DirectorySeparatorChar; + if (!opened.Equals(root, StringComparison.OrdinalIgnoreCase) && + !opened.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException( + $"The opened file resolves outside its trusted root: {opened}"); + } + + return new FileStream(handle, FileAccess.Read, bufferSize: 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The FileStream constructor takes ownership of the SafeFileHandle.")] + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The returned protected stream owns both the file and directory-chain handles.")] + internal static Stream CreateNew(ValidatedDirectory root, string candidatePath) + { + ArgumentNullException.ThrowIfNull(root); + string? parent = Path.GetDirectoryName(Path.GetFullPath(candidatePath)); + if (string.IsNullOrWhiteSpace(parent)) + { + throw new SessionException( + $"The trusted file path has no parent directory: {candidatePath}"); + } + + ProtectedDirectoryChain protectedParent = ProtectDirectoryChain( + root, + parent, + createMissing: false); + + try + { + SafeFileHandle handle = CreateRelative( + protectedParent.Leaf.Handle, + Path.GetFileName(candidatePath), + GenericWrite | FileReadAttributes, + shareAccess: 0, + FileCreate, + FileNonDirectoryFile | FileOpenReparsePoint); + + FileStream? stream = null; + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0 || + !IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) + { + throw new SessionException( + $"The created file resolves outside its trusted root: {candidatePath}"); + } + + stream = new FileStream( + handle, + FileAccess.Write, + bufferSize: 4096, + isAsync: true); + return new ProtectedStream(stream, protectedParent); + } + catch + { + stream?.Dispose(); + if (stream is null) + { + handle.Dispose(); + } + + throw; + } + } + catch + { + protectedParent.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Each directory handle is owned by a using declaration before the next statement can observe it.")] + internal static void EnsureDirectory(ValidatedDirectory root, string directoryPath) + { + ArgumentNullException.ThrowIfNull(root); + + string target = Path.GetFullPath(directoryPath); + string relative = Path.GetRelativePath(root.FinalPath, target); + if (relative == "." || relative.StartsWith("..", StringComparison.Ordinal) || + Path.IsPathRooted(relative)) + { + throw new SessionException( + $"The directory resolves outside its trusted root: {target}"); + } + + using ProtectedDirectoryChain protectedDirectories = ProtectDirectoryChain( + root, + target, + createMissing: true); + } + + internal static FileIdentity? TryGetDirectoryIdentity(string path) + { + using ValidatedDirectory? directory = TryOpenValidatedDirectory(path, expectedIdentity: null); + return directory?.Identity; + } + + internal static ValidatedDirectory? TryOpenValidatedDirectory( + string path, + FileIdentity? expectedIdentity) + { + SafeFileHandle handle = CreateFile( + path, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + return null; + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + handle.Dispose(); + return null; + } + + FileIdentity identity = GetIdentity(handle); + if (expectedIdentity is not null && identity != expectedIdentity) + { + handle.Dispose(); + return null; + } + + return new ValidatedDirectory(handle, identity, NormalizePath(GetFinalPath(handle))); + } + catch + { + handle.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The relative handle is immediately owned by the using statement below.")] + internal static bool TryDeleteOwnedEntry( + ValidatedDirectory root, + string candidatePath, + bool deleteReparsePointLeaf = false) + { + string? parent = Path.GetDirectoryName(Path.GetFullPath(candidatePath)); + if (string.IsNullOrWhiteSpace(parent)) + { + return false; + } + + using ProtectedDirectoryChain protectedParent = ProtectDirectoryChain( + root, + parent, + createMissing: false); + + string name = Path.GetFileName(candidatePath); + SafeFileHandle handle; + try + { + handle = CreateRelative( + protectedParent.Leaf.Handle, + name, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + FileOpen, + FileOpenReparsePoint); + } + catch (IOException) + { + return false; + } + + FileAttributes attributes; + using (handle) + { + attributes = File.GetAttributes(handle); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + if (!deleteReparsePointLeaf) + { + return false; + } + } + else if (!IsBelowRoot(root.FinalPath, NormalizePath(GetFinalPath(handle)))) + { + return false; + } + + if ((attributes & FileAttributes.Directory) != 0 && + (attributes & FileAttributes.ReparsePoint) == 0) + { + foreach (string child in Directory.EnumerateFileSystemEntries(candidatePath)) + { + TryDeleteOwnedEntry(root, child, deleteReparsePointLeaf); + } + } + } + + try + { + using SafeFileHandle deleteHandle = CreateRelative( + protectedParent.Leaf.Handle, + name, + Delete | FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + FileOpen, + FileOpenReparsePoint); + return MarkForDeletion(deleteHandle); + } + catch (IOException) + { + return false; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Each validated directory is transferred to the returned chain.")] + private static ProtectedDirectoryChain ProtectDirectoryChain( + ValidatedDirectory root, + string directoryPath, + bool createMissing) + { + string target = Path.GetFullPath(directoryPath); + string relative = Path.GetRelativePath(root.FinalPath, target); + if (relative.StartsWith("..", StringComparison.Ordinal) || + Path.IsPathRooted(relative)) + { + throw new SessionException( + $"The directory resolves outside its trusted root: {target}"); + } + + ProtectedDirectoryChain chain = ProtectRoot(root); + try + { + if (relative == ".") + { + return chain; + } + + string current = root.FinalPath; + foreach (string segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + ValidatedDirectory directory = OpenRelativeDirectory( + chain.Leaf.Handle, + segment, + createMissing); + if (!IsBelowOrEqualRoot(root.FinalPath, directory.FinalPath)) + { + directory.Dispose(); + throw new SessionException( + $"The directory resolves outside its trusted root: {current}"); + } + + chain.Add(directory); + } + + return chain; + } + catch + { + chain.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The returned chain owns the validated root directory.")] + private static ProtectedDirectoryChain ProtectRoot(ValidatedDirectory root) + { + var chain = new ProtectedDirectoryChain(); + ValidatedDirectory protectedRoot = TryOpenProtectedDirectory( + root.FinalPath, + root.Identity) ?? throw new SessionException( + $"The trusted root changed before the host operation: {root.FinalPath}"); + chain.Add(protectedRoot); + return chain; + } + + private static ValidatedDirectory? TryOpenProtectedDirectory( + string path, + FileIdentity? expectedIdentity) + { + SafeFileHandle handle = CreateFile( + path, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + return null; + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + handle.Dispose(); + return null; + } + + FileIdentity identity = GetIdentity(handle); + if (expectedIdentity is not null && identity != expectedIdentity) + { + handle.Dispose(); + return null; + } + + return new ValidatedDirectory(handle, identity, NormalizePath(GetFinalPath(handle))); + } + catch + { + handle.Dispose(); + throw; + } + } + + private static ValidatedDirectory OpenRelativeDirectory( + SafeFileHandle parent, + string name, + bool createMissing) + { + SafeFileHandle handle = CreateRelative( + parent, + name, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + createMissing ? FileOpenIf : FileOpen, + FileDirectoryFile | FileOpenReparsePoint); + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + $"The directory contains a reparse point: {name}"); + } + + return new ValidatedDirectory( + handle, + GetIdentity(handle), + NormalizePath(GetFinalPath(handle))); + } + catch + { + handle.Dispose(); + throw; + } + } + + private static SafeFileHandle CreateRelative( + SafeFileHandle parent, + string name, + uint desiredAccess, + uint shareAccess, + uint createDisposition, + uint createOptions) + { + IntPtr nameBuffer = Marshal.StringToHGlobalUni(name); + try + { + var unicodeName = new UnicodeString + { + Length = checked((ushort)(name.Length * sizeof(char))), + MaximumLength = checked((ushort)((name.Length + 1) * sizeof(char))), + Buffer = nameBuffer + }; + IntPtr unicodeNamePointer = Marshal.AllocHGlobal( + Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(unicodeName, unicodeNamePointer, fDeleteOld: false); + var attributes = new ObjectAttributes + { + Length = Marshal.SizeOf(), + RootDirectory = parent.DangerousGetHandle(), + ObjectName = unicodeNamePointer, + Attributes = ObjectCaseInsensitive + }; + uint status = NtCreateFile( + out SafeFileHandle handle, + desiredAccess, + ref attributes, + out _, + IntPtr.Zero, + FileAttributeNormal, + shareAccess, + createDisposition, + createOptions, + IntPtr.Zero, + 0); + GC.KeepAlive(parent); + if (unchecked((int)status) < 0) + { + handle.Dispose(); + int error = checked((int)RtlNtStatusToDosError(status)); + throw new IOException( + $"The trusted relative path could not be opened: {name}", + new System.ComponentModel.Win32Exception(error)); + } + + return handle; + } + finally + { + Marshal.FreeHGlobal(unicodeNamePointer); + } + } + finally + { + Marshal.FreeHGlobal(nameBuffer); + } + } + + private static bool MarkForDeletion(SafeFileHandle handle) + { + FileDispositionInformation disposition = new() { DeleteFile = true }; + return SetFileInformationByHandle( + handle, + FileDispositionInfo, + ref disposition, + (uint)Marshal.SizeOf()); + } + + private static string GetFinalPath(SafeFileHandle handle) + { + char[] path = new char[260]; + uint length = GetFinalPathNameByHandle(handle, path, (uint)path.Length, 0); + if (length == 0) + { + throw new IOException("The opened file's final path could not be determined."); + } + + if (length >= path.Length) + { + path = new char[checked((int)length + 1)]; + length = GetFinalPathNameByHandle(handle, path, (uint)path.Length, 0); + if (length == 0 || length >= path.Length) + { + throw new IOException("The opened file's final path is too long."); + } + } + + const string extendedPathPrefix = @"\\?\"; + string value = new(path, 0, checked((int)length)); + return value.StartsWith(extendedPathPrefix, StringComparison.Ordinal) + ? value[extendedPathPrefix.Length..] + : value; + } + + private static FileIdentity GetIdentity(SafeFileHandle handle) + { + if (!GetFileInformationByHandle(handle, out ByHandleFileInformation information)) + { + throw new IOException("The opened path's identity could not be determined."); + } + + return new FileIdentity( + information.VolumeSerialNumber, + information.FileIndexHigh, + information.FileIndexLow); + } + + private static string NormalizePath(string path) => + path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + private static bool IsBelowRoot(string root, string candidate) + { + string prefix = root + Path.DirectorySeparatorChar; + return candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsBelowOrEqualRoot(string root, string candidate) => + candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || + IsBelowRoot(root, candidate); + + private const uint Delete = 0x00010000; + private const uint GenericRead = 0x80000000; + private const uint GenericWrite = 0x40000000; + private const uint FileReadAttributes = 0x00000080; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const int ErrorFileNotFound = 2; + private const int ErrorPathNotFound = 3; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x00000080; + private const uint FileOpen = 1; + private const uint FileCreate = 2; + private const uint FileOpenIf = 3; + private const uint FileDirectoryFile = 0x00000001; + private const uint FileNonDirectoryFile = 0x00000040; + private const uint FileOpenReparsePoint = 0x00200000; + private const uint ObjectCaseInsensitive = 0x00000040; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagOpenReparsePoint = 0x00200000; + private const int FileDispositionInfo = 4; + + [StructLayout(LayoutKind.Sequential)] + private struct FileDispositionInformation + { + [MarshalAs(UnmanagedType.Bool)] + public bool DeleteFile; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public uint CreationTimeLow; + public uint CreationTimeHigh; + public uint LastAccessTimeLow; + public uint LastAccessTimeHigh; + public uint LastWriteTimeLow; + public uint LastWriteTimeHigh; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [StructLayout(LayoutKind.Sequential)] + private struct UnicodeString + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ObjectAttributes + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoStatusBlock + { + public IntPtr Status; + public nuint Information; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("ntdll.dll")] + private static extern uint NtCreateFile( + out SafeFileHandle fileHandle, + uint desiredAccess, + ref ObjectAttributes objectAttributes, + out IoStatusBlock ioStatusBlock, + IntPtr allocationSize, + uint fileAttributes, + uint shareAccess, + uint createDisposition, + uint createOptions, + IntPtr eaBuffer, + uint eaLength); + + [DllImport("ntdll.dll")] + private static extern uint RtlNtStatusToDosError(uint status); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + [Out] char[] path, + uint length, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation information); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle( + SafeFileHandle file, + int fileInformationClass, + ref FileDispositionInformation fileInformation, + uint bufferSize); +} diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs index 6d3e8b9c..a7d74d26 100644 --- a/src/OpenClaw.SessionHost/Program.cs +++ b/src/OpenClaw.SessionHost/Program.cs @@ -23,6 +23,14 @@ internal static int Run( Func readFile, Action writeResult) { + if (args.Count == 2 && args[0] == "--install-runtime") + { + return SessionRuntimeInstaller.Run( + args[1], + readFile, + File.WriteAllText); + } + if (args.Count != 2 || args[0] != "--request") { // No request path means no control file to report through, so this diff --git a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs index 89095dc4..e67f5471 100644 --- a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs +++ b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs @@ -28,6 +28,19 @@ internal interface ISessionProcessLauncher /// internal sealed class SessionProcessLauncher : ISessionProcessLauncher { + internal static void PrependPath(ProcessStartInfo startInfo, string? runtimeDirectory) + { + if (string.IsNullOrEmpty(runtimeDirectory)) + { + return; + } + + string inheritedPath = startInfo.Environment["PATH"] ?? string.Empty; + startInfo.Environment["PATH"] = string.IsNullOrEmpty(inheritedPath) + ? runtimeDirectory + : $"{runtimeDirectory};{inheritedPath}"; + } + public int Run(SessionLaunchRequest request) { string workingDirectory = request.WorkingDirectory!; @@ -59,6 +72,7 @@ public int Run(SessionLaunchRequest request) { startInfo.Environment[name] = value; } + PrependPath(startInfo, Path.GetDirectoryName(request.Executable)); using Process process = new() { StartInfo = startInfo }; try diff --git a/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs new file mode 100644 index 00000000..1a54a9dd --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionRuntimeInstaller.cs @@ -0,0 +1,340 @@ +using System.IO.Compression; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Win32; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The --install-runtime mode: installs the packaged Node.js runtime +/// into this account's own profile. +/// +/// +/// +/// The host extracts its own copy into the invoking user's package LocalState, +/// which this account cannot read. The archive is package content and is +/// readable by both, so the agent extracts its own rather than being handed +/// files another identity owns. +/// +/// +/// Running extraction here means every file is created by the account that +/// will run it. +/// +/// +internal static class SessionRuntimeInstaller +{ + private static readonly Guid LocalApplicationDataFolderId = + new("F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); + + public static int Run( + string requestPath, + Func readFile, + Action writeFile, + Func? getLocalApplicationData = null, + Func? tryPrependUserPath = null, + Func? getRuntimeVersion = null) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + + try + { + SessionRuntimeInstallRequest request = + SessionRuntimeProtocol.ReadRequest(readFile(requestPath)); + requestId = request.RequestId; + + string directory = Path.Combine( + (getLocalApplicationData ?? + GetLocalApplicationData)(), + "OpenClawGatewayMSIX", + "agent-node"); + string archiveRoot = Path.GetFileNameWithoutExtension(request.ArchivePath!); + string executablePath = Path.Combine( + directory, + archiveRoot, + "node.exe"); + string expectedVersion = GetArchiveVersion(request.ArchivePath!); + if (!File.Exists(executablePath) || + !string.Equals( + (getRuntimeVersion ?? ReadRuntimeVersion)(executablePath), + expectedVersion, + StringComparison.Ordinal)) + { + string stagingDirectory = Path.Combine( + directory, + $"{archiveRoot}.extract-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(directory); + ZipFile.ExtractToDirectory(request.ArchivePath!, stagingDirectory, false); + string stagedRoot = Path.Combine(stagingDirectory, archiveRoot); + string stagedExecutable = Path.Combine(stagedRoot, "node.exe"); + if (!File.Exists(stagedExecutable)) + { + throw new SessionLaunchException( + "The installed Node.js runtime did not contain node.exe."); + } + + string targetRoot = Path.Combine(directory, archiveRoot); + if (Directory.Exists(targetRoot)) + { + Directory.Delete(targetRoot, recursive: true); + } + Directory.Move(stagedRoot, targetRoot); + } + finally + { + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + } + } + + if (!File.Exists(executablePath)) + { + throw new SessionLaunchException( + "The installed Node.js runtime did not contain node.exe."); + } + + string runtimeDirectory = Path.GetDirectoryName(executablePath) + ?? throw new SessionLaunchException( + "The installed Node.js runtime has no executable directory."); + bool pathUpdated = request.UpdateUserPath && + (tryPrependUserPath ?? TryPrependUserPath)(runtimeDirectory); + + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = requestId, + ExecutablePath = executablePath, + Version = expectedVersion, + ArchiveName = Path.GetFileName(request.ArchivePath!), + UserPathUpdated = pathUpdated + })); + return 0; + } + + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException or InvalidOperationException or + InvalidDataException or BadImageFormatException or + System.ComponentModel.Win32Exception) + { + TryWriteFailure(writeFile, resultPath, requestId, exception.Message); + return SessionLaunchProtocol.HelperFailureExitCode; + } + } + + private static string GetArchiveVersion(string archivePath) + { + string archiveName = Path.GetFileNameWithoutExtension(archivePath); + const string prefix = "node-v"; + const string platformMarker = "-win-"; + int platformIndex = archiveName.LastIndexOf( + platformMarker, + StringComparison.OrdinalIgnoreCase); + if (!archiveName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || + platformIndex <= prefix.Length) + { + throw new SessionLaunchException( + $"The packaged Node.js runtime archive has an unexpected name: {archiveName}"); + } + + string version = archiveName[prefix.Length..platformIndex]; + return Version.TryParse(version, out Version? parsed) + ? parsed.ToString() + : throw new SessionLaunchException( + $"The packaged Node.js runtime archive has an invalid version: {archiveName}"); + } + + private static string GetLocalApplicationData() + { + int result = SHGetKnownFolderPath( + LocalApplicationDataFolderId, + flags: 0, + token: IntPtr.Zero, + out IntPtr path); + if (result < 0) + { + throw new SessionLaunchException( + $"Windows could not resolve the local application data directory " + + $"(HRESULT 0x{result:X8})."); + } + + try + { + return Marshal.PtrToStringUni(path) + ?? throw new SessionLaunchException( + "Windows returned an empty local application data directory."); + } + finally + { + Marshal.FreeCoTaskMem(path); + } + } + + private static string? ReadRuntimeVersion(string executablePath) + { + try + { + using Process? process = Process.Start(new ProcessStartInfo + { + FileName = executablePath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "--version" } + }); + if (process is null || !process.WaitForExit(TimeSpan.FromSeconds(10))) + { + process?.Kill(entireProcessTree: true); + return null; + } + + string output = process.StandardOutput.ReadToEnd().Trim(); + return process.ExitCode == 0 + ? output.TrimStart('v') + : null; + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Puts the runtime directory at the front of this account's persistent + /// user PATH. + /// + /// + /// + /// Prepended, not appended: a machine-wide Node.js installation is common, + /// and the packaged runtime is the one this installation supports. + /// + /// + /// This covers processes that build their environment from the registry. + /// It is not what governs the processes this package starts - those inherit + /// an environment - so it is deliberately paired with the launch-time + /// prepend rather than relied on alone. + /// + /// + /// Only this account's own hive is touched, never the machine's. + /// + /// + internal static bool TryPrependUserPath(string directory) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + using RegistryKey? environment = Registry.CurrentUser.OpenSubKey( + "Environment", writable: true); + if (environment is null) + { + return false; + } + + // GetValue expands REG_EXPAND_SZ by default, which would bake the + // current expansion of every %VAR% in the value back into the registry. + object? raw = environment.GetValue( + "Path", null, RegistryValueOptions.DoNotExpandEnvironmentNames); + string current = raw as string ?? string.Empty; + RegistryValueKind kind = raw is null + ? RegistryValueKind.ExpandString + : environment.GetValueKind("Path"); + + string updated = BuildPath(current, directory); + if (string.Equals(updated, current, StringComparison.Ordinal)) + { + return false; + } + + environment.SetValue("Path", updated, kind); + return true; + } + + /// + /// Builds the new value: the directory first, every unrelated entry after, + /// and any previous runtime directory dropped. + /// + /// + /// A previous version's directory is removed rather than left in place, so + /// repeated setups across upgrades cannot accumulate stale runtimes ahead + /// of the current one. + /// + internal static string BuildPath(string current, string directory) + { + string? previousRoot = Path.GetDirectoryName(directory); + List entries = [directory]; + + foreach (string entry in current.Split( + Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string trimmed = entry.Trim(); + if (trimmed.Length == 0 || + string.Equals( + trimmed.TrimEnd(Path.DirectorySeparatorChar), + directory.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase) || + IsPreviousRuntime(trimmed, previousRoot)) + { + continue; + } + + entries.Add(trimmed); + } + + return string.Join(Path.PathSeparator, entries); + } + + private static bool IsPreviousRuntime(string entry, string? runtimeRoot) + { + if (string.IsNullOrEmpty(runtimeRoot)) + { + return false; + } + + string parent = Path.GetDirectoryName( + entry.TrimEnd(Path.DirectorySeparatorChar)) ?? string.Empty; + return string.Equals( + parent.TrimEnd(Path.DirectorySeparatorChar), + runtimeRoot.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + + private static void TryWriteFailure( + Action writeFile, + string resultPath, + string? requestId, + string message) + { + try + { + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = requestId, + Error = message + })); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + } + } + + [DllImport("shell32.dll")] + private static extern int SHGetKnownFolderPath( + in Guid folderId, + uint flags, + IntPtr token, + out IntPtr path); +} diff --git a/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs new file mode 100644 index 00000000..359450f3 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// +/// Asks the guest to install the packaged Node.js runtime into its own profile. +/// +/// +/// +/// The package ships Node.js as an archive and the host extracts it into the +/// invoking user's package LocalState, which the agent identity cannot read. +/// The archive itself is package content, readable by both identities, so the +/// guest extracts its own copy rather than being handed files it does not own. +/// +/// +/// The host names the archive; the guest decides where its own profile is, +/// because only it can resolve that. This is the same split the collect mode +/// uses, for the same reason. +/// +/// +public sealed record SessionRuntimeInstallRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// + /// The packaged Node.js archive to install, as a fully qualified path. + /// + [JsonPropertyName("archivePath")] + public string? ArchivePath { get; init; } + + /// + /// Whether to prepend the installed directory to the agent's persistent + /// user PATH. + /// + /// + /// Separate from the install itself so a caller that only wants the files + /// does not silently change the account's environment. + /// + [JsonPropertyName("updateUserPath")] + public bool UpdateUserPath { get; init; } = true; +} + +/// Where the guest put the runtime, and what it is. +public sealed record SessionRuntimeInstallResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// The agent-side node.exe the launcher must use. + [JsonPropertyName("executablePath")] + public string? ExecutablePath { get; init; } + + /// The version the installed runtime reported about itself. + [JsonPropertyName("version")] + public string? Version { get; init; } + + /// + /// The archive the install came from, so a later launch can tell that the + /// package has since shipped a different one. + /// + [JsonPropertyName("archiveName")] + public string? ArchiveName { get; init; } + + /// Whether the agent's persistent user path was changed. + [JsonPropertyName("userPathUpdated")] + public bool UserPathUpdated { get; init; } + + /// Set when the install could not be completed. + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(SessionRuntimeInstallRequest))] +[JsonSerializable(typeof(SessionRuntimeInstallResult))] +internal sealed partial class SessionRuntimeJsonContext : JsonSerializerContext; + +public static class SessionRuntimeProtocol +{ + public static string SerializeRequest(SessionRuntimeInstallRequest request) => + JsonSerializer.Serialize( + request, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallRequest); + + public static string SerializeResult(SessionRuntimeInstallResult result) => + JsonSerializer.Serialize( + result, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallResult); + + public static SessionRuntimeInstallRequest ReadRequest(string json) + { + SessionRuntimeInstallRequest request = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallRequest, + "runtime install request"); + + RequireCurrentSchema(request.SchemaVersion, "install request", "helper"); + + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new SessionLaunchException( + "The runtime install request has no request id."); + } + + // A relative or traversing archive path would let a malformed request + // name something outside the package this helper was staged from. + if (string.IsNullOrWhiteSpace(request.ArchivePath) || + !Path.IsPathFullyQualified(request.ArchivePath) || + request.ArchivePath.Contains("..", StringComparison.Ordinal)) + { + throw new SessionLaunchException( + "The runtime install request has no fully qualified archive path."); + } + + return request; + } + + public static SessionRuntimeInstallResult ReadResult(string json) + { + SessionRuntimeInstallResult result = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionRuntimeInstallResult, + "runtime install result"); + + RequireCurrentSchema(result.SchemaVersion, "install result", "launcher"); + return result; + } + + private static void RequireCurrentSchema(int version, string subject, string reader) + { + if (version != SessionLaunchProtocol.CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Runtime {subject} schema version {version} is not supported; " + + $"this {reader} implements version " + + $"{SessionLaunchProtocol.CurrentSchemaVersion}."); + } + } + + private static T Deserialize( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + string subject) + where T : class + { + try + { + return JsonSerializer.Deserialize(json, typeInfo) + ?? throw new SessionLaunchException($"The {subject} was empty."); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The {subject} could not be read: {exception.Message}", + exception); + } + } +} diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 964a3378..d4d51611 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -40,7 +40,7 @@ private static async Task Main() ("unknown command fails", UnknownCommandFailsAsync), ("response-file token is not expanded", ResponseFileTokenIsNotExpandedAsync), ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), - ("setup reports readiness and logs", SetupReportsReadinessAsync), + ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), ("missing application reports diagnostics", MissingApplicationReportsAsync), ("openclaw forwards arguments verbatim", AgentForwardsArgumentsAsync) ]; @@ -208,9 +208,12 @@ private static async Task SetupReportsReadinessAsync() int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); - AssertExitCode(0, exitCode, fixture); + AssertExitCode(1, exitCode, fixture); AssertContains(fixture.Output.ToString(), fixture.ApplicationDirectory, fixture); - AssertContains(fixture.Output.ToString(), Fixture.NodePath, fixture); + AssertContains( + fixture.Output.ToString(), + "not running from its installed package", + fixture); fixture.AssertLogRecordsStartupAndExit(); Assert( File.Exists(fixture.EntryPoint), diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index c184a734..89d27c83 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -69,10 +69,15 @@ public async Task HelpDescribesBundledRuntimePreparation() [Fact] public void OnlyTheReadinessCommandIsExposed() { - RootCommand root = ClawCtlCommandLine.Create(_ => Task.FromResult(0)); + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = _ => Task.FromResult(0), + Status = _ => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0) + }); Assert.Equal( - [ClawCtlCommandLine.SetupCommandName], + [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown"], root.Subcommands.Select(command => command.Name)); } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 8d4b7d83..688f8ced 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -69,9 +69,13 @@ public async Task ResponseFileTokenForAMissingFileIsAlsoJustAnArgument() [Fact] public void CompletionSuggestsCommandsWithoutResolvingNode() { - RootCommand root = ClawCtlCommandLine.Create( - _ => throw new InvalidOperationException( - "Completion started the readiness operation.")); + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = _ => throw new InvalidOperationException( + "Completion started the readiness operation."), + Status = _ => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0) + }); IEnumerable completions = root .Parse("se", ClawCtlCommandLine.CreateParserConfiguration()) diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index fd2b614a..2936d7a1 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1,8 +1,15 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.Launcher.Tests.Session; +using OpenClaw.SessionProtocol; + namespace OpenClaw.Launcher.Tests; public sealed class ProgramTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); + private FakeMxcSessionClient? _lastSessionBackend; + private string? _sessionStatePath; [Fact] public async Task AgentLaunchResolvesNodeAndRunsPackagedApplication() @@ -59,13 +66,16 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() Directory.CreateDirectory(applicationDirectory); string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); await File.WriteAllTextAsync(entryPoint, "console.log('fixture');"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + await File.WriteAllTextAsync(archivePath, "fixture"); DateTime lastWriteTime = File.GetLastWriteTimeUtc(entryPoint); - var options = new HostOptions(applicationDirectory, null, []); + var options = new HostOptions(applicationDirectory, archivePath, []); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); using var output = new StringWriter(); + SessionRuntime runtime = CreateSessionRuntime(); int exitCode = await Program.RunControlAsync( options, @@ -73,7 +83,8 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() _ => { }, output, TextWriter.Null, - _ => Task.FromResult(nodeRuntime)); + _ => Task.FromResult(nodeRuntime), + () => runtime); Assert.Equal(0, exitCode); Assert.True(File.Exists(entryPoint)); @@ -86,6 +97,206 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() applicationDirectory, output.ToString(), StringComparison.Ordinal); + SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; + Assert.Equal(SetupPhase.Ready, setup.Phase); + Assert.False(setup.StartupEnabled); + Assert.Equal("24.15.0", setup.AgentNodeVersion); + Assert.Contains( + _lastSessionBackend!.Calls, + call => call.StartsWith("execute:", StringComparison.Ordinal)); + Assert.DoesNotContain( + _lastSessionBackend.Calls, + call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AgentUsesTheRuntimeInstalledForTheSessionWithoutHostFallback() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + await File.WriteAllTextAsync(archivePath, "fixture"); + var options = new HostOptions(applicationDirectory, archivePath, ["gateway"]); + var hostNode = new NodeRuntime( + Path.Combine(_testDirectory, "host-node.exe"), + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + SessionRuntime runtime = CreateSessionRuntime(); + + int setupExitCode = await Program.RunControlAsync( + options, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + _ => Task.FromResult(hostNode), + () => runtime); + Assert.Equal(0, setupExitCode); + + string expectedAgentNode = runtime.SetupState + .Read(runtime.ApplicationId).Record!.AgentNodePath!; + bool directLaunchAttempted = false; + _lastSessionBackend!.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "launch-*.json").Single(); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + Assert.Equal(expectedAgentNode, request.Executable); + Assert.Equal( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + request.WorkingDirectory); + Assert.Equal( + "enabled", + request.Environment![OpenClawRuntimeEnvironment.GatewayIsolationVariable]); + return Task.FromResult(0); + }; + + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + options, + _ => { }, + _ => throw new InvalidOperationException("Host Node must not be resolved."), + (_, _, _, _, _, _) => + { + directLaunchAttempted = true; + return Task.FromResult(0); + }, + _ => runtime)); + + Assert.False(directLaunchAttempted); + } + + [Fact] + public async Task AgentRefusesForeignSessionRecordWithoutHostFallback() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + SessionRecord record = new SessionStateStore(_sessionStatePath!) + .Read(runtime.ApplicationId).Record!; + new SessionStateStore(_sessionStatePath!).Write(record with + { + SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") + }); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + + Assert.Contains("Some.Other.App", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + + [Fact] + public async Task AutomaticDirectRoutingValidatesExistingOwnershipFirst() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + SessionRecord record = new SessionStateStore(_sessionStatePath!) + .Read(runtime.ApplicationId).Record!; + new SessionStateStore(_sessionStatePath!).Write(record with + { + SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") + }); + + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(null, null, ["--version"]), + _ => { }, + _ => throw new InvalidOperationException("Host Node must not be resolved."), + (_, _, _, _, _, _) => + { + directLaunches.Add("direct"); + return Task.FromResult(0); + }, + _ => runtime, + probeReadiness: _ => Task.FromResult(UnavailableReadiness()), + getPackageFamilyName: () => "OpenClaw.Gateway_test")); + + Assert.Contains("Some.Other.App", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + + [Fact] + public async Task AgentRefusesSetupSessionMismatchWithoutHostFallback() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; + runtime.SetupState.Write(setup with { SandboxId = "iso:different" }); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + + Assert.Contains("different session", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + + [Fact] + public async Task AgentRefusesUnreadableSessionRecordWithoutHostFallback() + { + SessionRuntime runtime = await SetUpSessionAsync(); + var directLaunches = new List(); + await File.WriteAllTextAsync(_sessionStatePath!, "{ not json"); + + SessionException failure = await Assert.ThrowsAsync( + () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + + Assert.Contains("not valid JSON", failure.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + Assert.Empty(directLaunches); + } + + [Fact] + public async Task AgentFallsBackToHostWhenSessionRuntimeIsUnavailable() + { + SessionRuntime runtime = await SetUpSessionAsync(); + _lastSessionBackend!.StartFailure = new MxcException( + MxcErrorCode.RuntimeUnavailable, + "The MXC runtime is not available."); + bool directLaunchAttempted = false; + string applicationDirectory = Path.Combine(_testDirectory, "app"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + + int exitCode = await Program.RunAgentAsync( + new HostOptions(applicationDirectory, archivePath, ["--version"]), + _ => { }, + _ => Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), + (_, _, _, _, _, _) => + { + directLaunchAttempted = true; + return Task.FromResult(17); + }, + _ => runtime); + + Assert.True(directLaunchAttempted); + Assert.Equal(17, exitCode); + } + + [Fact] + public async Task TeardownRequiresForceBeforeRemovingTheSession() + { + SessionRuntime runtime = CreateSessionRuntime(); + using var error = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, null, []), + ["teardown"], + _ => { }, + TextWriter.Null, + error, + createSessionRuntime: () => runtime); + + Assert.Equal(1, exitCode); + Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain( + _lastSessionBackend!.Calls, + call => call.StartsWith("deprovision:", StringComparison.Ordinal)); } [Fact] @@ -142,4 +353,115 @@ public void Dispose() Directory.Delete(_testDirectory, recursive: true); GC.SuppressFinalize(this); } + + private SessionRuntime CreateSessionRuntime() + { + string stateRoot = Path.Combine(_testDirectory, "state"); + string baseDirectory = Path.Combine(_testDirectory, "base"); + string workspace = Path.Combine(_testDirectory, "workspace"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(workspace); + string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + File.WriteAllText(helperPath, "fixture"); + var backend = new FakeMxcSessionClient + { + Metadata = new MxcProvisionMetadata( + "agent_1", + "S-1-5-21-0-0-0-1001", + workspace) + }; + backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles(workspace, "runtime-*.json").Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + ExecutablePath = Path.Combine( + workspace, + "AppData", + "Local", + "OpenClaw", + "NodeJS", + "node-v24.15.0-win-x64", + "node.exe"), + Version = "24.15.0", + ArchiveName = "node-v24.15.0-win-x64.zip" + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + _lastSessionBackend = backend; + var paths = HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"); + _sessionStatePath = paths.SessionStatePath; + return SessionRuntime.Create( + paths, + () => throw new InvalidOperationException("The test supplies its backend."), + baseDirectory, + _ => { }, + backend); + } + + private async Task SetUpSessionAsync() + { + SessionRuntime runtime = CreateSessionRuntime(); + string applicationDirectory = Path.Combine(_testDirectory, "app"); + string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');").ConfigureAwait(false); + await File.WriteAllTextAsync(archivePath, "fixture").ConfigureAwait(false); + int exitCode = await Program.RunControlAsync( + new HostOptions(applicationDirectory, archivePath, []), + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + _ => Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), + () => runtime).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + return runtime; + } + + private static string SandboxIdFor(string applicationId) => + $"iso:{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( + $"{{\"appId\":\"{applicationId}\"}}")) + .TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + + private static MxcReadinessReport UnavailableReadiness() => + new( + RuntimeDirectory: null, + Provenance: null, + RuntimeUnavailableReason: "The runtime is unavailable.", + HostSupport: MxcHostSupport.Supported, + HostBuild: null, + SupportEvidence: MxcSupportEvidence.BackendProbe, + BackendProbe: new MxcBackendProbe(false, "base-container", []), + BackendProbeFailureReason: null); + + private static Task RunAgentWithDirectLaunchProbeAsync( + SessionRuntime runtime, + List directLaunches) + { + ArgumentNullException.ThrowIfNull(directLaunches); + + return Program.RunAgentAsync( + new HostOptions(null, null, ["--version"]), + _ => { }, + _ => throw new InvalidOperationException("Host Node must not be resolved."), + (_, _, _, _, _, _) => + { + directLaunches.Add("direct"); + return Task.FromResult(0); + }, + _ => runtime); + } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs new file mode 100644 index 00000000..390394d0 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs @@ -0,0 +1,124 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Records every lifecycle call so tests can assert ordering and ownership. +/// +internal sealed class FakeMxcSessionClient : IMxcSessionClient +{ + private int _provisionCount; + + public List Calls { get; } = []; + + public List ProvisionedAppIds { get; } = []; + + public string SandboxIdPrefix { get; set; } = "iso:sandbox"; + + public MxcProvisionMetadata? Metadata { get; set; } = new( + "agent_1", + "S-1-5-21-0-0-0-1001", + @"C:\Users\agent_1\Shared"); + + public Exception? ProvisionFailure { get; set; } + + public Exception? StartFailure { get; set; } + + public Exception? StopFailure { get; set; } + + public Exception? DeprovisionFailure { get; set; } + + public Exception? ExecuteFailure { get; set; } + + public MxcExecutionResult ExecutionResult { get; set; } = + new(0, string.Empty, string.Empty); + + public List ExecutedCommandLines { get; } = []; + + public Func>? ExecuteBehavior { get; set; } + + public Task ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) + { + Calls.Add("provision"); + ProvisionedAppIds.Add(request.AppId); + if (ProvisionFailure is not null) + { + return Task.FromException(ProvisionFailure); + } + + _provisionCount++; + return Task.FromResult(new MxcProvisionResult( + MxcSandboxId.Parse($"{SandboxIdPrefix}{_provisionCount}"), + Metadata, + null)); + } + + public Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"start:{sandboxId.Value}"); + return StartFailure is not null + ? Task.FromException(StartFailure) + : Task.CompletedTask; + } + + public Task ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"execute:{sandboxId.Value}"); + ExecutedCommandLines.Add(request.CommandLine); + return ExecuteFailure is not null + ? Task.FromException(ExecuteFailure) + : ExecuteBehavior is not null ? ExecuteBehavior(request) : Task.FromResult(ExecutionResult); + } + + public int AttachedExitCode { get; set; } + + public List AttachedCommandLines { get; } = []; + + public Func>? AttachedBehavior { get; set; } + + public Task ExecuteAttachedAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"execute-attached:{sandboxId.Value}"); + AttachedCommandLines.Add(request.CommandLine); + return AttachedBehavior is not null + ? AttachedBehavior(cancellationToken) + : ExecuteFailure is not null + ? Task.FromException(ExecuteFailure) + : Task.FromResult(AttachedExitCode); + } + + public Task StopAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"stop:{sandboxId.Value}"); + return StopFailure is not null + ? Task.FromException(StopFailure) + : Task.CompletedTask; + } + + public Task DeprovisionAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken) + { + Calls.Add($"deprovision:{sandboxId.Value}"); + return DeprovisionFailure is not null + ? Task.FromException(DeprovisionFailure) + : Task.CompletedTask; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs new file mode 100644 index 00000000..b1946fd9 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs @@ -0,0 +1,72 @@ + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class HostPathsTests +{ + [Fact] + public void UnpackagedStateRootLivesUnderLocalApplicationData() + { + HostPaths paths = HostPaths.Create(@"C:\Users\test\AppData\Local", null); + + Assert.Equal( + @"C:\Users\test\AppData\Local\OpenClawGatewayMSIX", + paths.StateRoot); + Assert.Null(paths.PackageFamilyName); + } + + [Fact] + public void PackagedStateRootLivesInsidePackageLocalState() + { + HostPaths paths = HostPaths.Create( + @"C:\Users\test\AppData\Local", + "OpenClaw.Gateway_abc123"); + + Assert.Equal( + @"C:\Users\test\AppData\Local\Packages\OpenClaw.Gateway_abc123\LocalState\OpenClawGatewayMSIX", + paths.StateRoot); + Assert.Equal("OpenClaw.Gateway_abc123", paths.PackageFamilyName); + } + + [Fact] + public void DifferentPackageIdentitiesDoNotShareState() + { + HostPaths first = HostPaths.Create(@"C:\local", "Public_abc"); + HostPaths second = HostPaths.Create(@"C:\local", "Internal_xyz"); + + Assert.NotEqual(first.StateRoot, second.StateRoot); + Assert.NotEqual(first.SessionStatePath, second.SessionStatePath); + } + + [Fact] + public void DerivedPathsSitUnderTheStateRoot() + { + HostPaths paths = HostPaths.Create(@"C:\local", null); + + Assert.StartsWith(paths.StateRoot, paths.LogPath, StringComparison.Ordinal); + Assert.StartsWith(paths.StateRoot, paths.SessionStatePath, StringComparison.Ordinal); + Assert.NotEqual(paths.LogPath, paths.SessionStatePath); + } + + [Fact] + public void MissingLocalApplicationDataIsReported() + { + Assert.Throws( + () => HostPaths.Create(string.Empty, null)); + } + + [Fact] + public void ApplicationIdCarriesThePackageFamilyNamePrefix() + { + Assert.Equal( + "PFN:OpenClaw.Gateway_abc123", + PackageIdentity.ToApplicationId("OpenClaw.Gateway_abc123")); + } + + [Fact] + public void PackageFamilyNameIsAbsentWhenRunningUnpackaged() + { + // The test host is never packaged, so this also proves that unpackaged + // is reported rather than thrown. + Assert.Null(PackageIdentity.TryGetPackageFamilyName()); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs new file mode 100644 index 00000000..788bb95a --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs @@ -0,0 +1,399 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionCoordinatorTests : IDisposable +{ + private const string ApplicationId = "PFN:OpenClaw.Gateway_abc123"; + + private readonly string _root = TestDirectory.Create(); + private readonly FakeMxcSessionClient _backend = new(); + private readonly List _log = []; + + private string StatePath => Path.Combine(_root, "session.json"); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private SessionStateStore Store() => new(StatePath); + + private SessionCoordinator Create( + ISessionLock? sessionLock = null, + SessionStateStore? store = null, + TimeSpan? lockTimeout = null) => + new( + _backend, + store ?? Store(), + sessionLock ?? new AlwaysFreeLock(), + ApplicationId, + _log.Add, + new FixedTimeProvider(new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero)), + lockTimeout); + + [Fact] + public async Task FirstUseProvisionsAndStartsExactlyOnce() + { + SessionCoordinator coordinator = Create(); + + SessionRecord record = await coordinator.EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(["provision", "start:iso:sandbox1"], _backend.Calls); + Assert.Equal("iso:sandbox1", record.SandboxId); + } + + [Fact] + public async Task FirstUseProvisionsWithThisInstallationsIdentity() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal([ApplicationId], _backend.ProvisionedAppIds); + } + + [Fact] + public async Task ProvisionMetadataIsPersisted() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + SessionRecord record = Store().Read(ApplicationId).Record!; + Assert.Equal("agent_1", record.AgentUserName); + Assert.Equal("S-1-5-21-0-0-0-1001", record.AgentUserSid); + Assert.Equal(@"C:\Users\agent_1\Shared", record.WorkspacePath); + Assert.Equal( + new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero), + record.CreatedUtc); + } + + [Fact] + public async Task OwnershipIsRecordedBeforeTheSessionIsStarted() + { + // A crash between provision and start must not leave a sandbox that + // nothing claims; the backend cannot be asked which sandboxes are ours. + _backend.StartFailure = new MxcException(MxcErrorCode.BackendError, "start failed"); + + await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.True(Store().Read(ApplicationId).HasRecord); + } + + [Fact] + public async Task FailedProvisionRecordsNothing() + { + _backend.ProvisionFailure = new MxcException( + MxcErrorCode.BackendError, + "provision failed"); + + await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + } + + [Fact] + public async Task SecondUseReusesTheRecordedSessionWithoutProvisioning() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + SessionRecord record = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + Assert.Equal("iso:sandbox1", record.SandboxId); + } + + [Fact] + public async Task SeparateCoordinatorsConvergeOnOneSession() + { + // Distinct instances stand in for distinct processes sharing the store. + SessionRecord first = await Create().EnsureStartedAsync(CancellationToken.None); + SessionRecord second = await Create().EnsureStartedAsync(CancellationToken.None); + SessionRecord third = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(first.SandboxId, second.SandboxId); + Assert.Equal(first.SandboxId, third.SandboxId); + Assert.Single(_backend.Calls, call => call == "provision"); + } + + [Fact] + public async Task ReuseStartsTheSessionBecauseItMayHaveBeenStopped() + { + // A stopped session fails execution with backend_error rather than + // restarting itself, and liveness cannot be queried. + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Contains("start:iso:sandbox1", _backend.Calls); + } + + [Fact] + public async Task UnreadableRecordIsNotReplacedBySilentProvisioning() + { + File.WriteAllText(StatePath, "{ not json"); + + SessionStateException exception = await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(SessionStateFault.Unreadable, exception.Fault); + Assert.DoesNotContain("provision", _backend.Calls); + } + + [Fact] + public async Task ForeignRecordIsNotAdopted() + { + Store().Write(new SessionRecord + { + SandboxId = "iso:other", + ApplicationId = "PFN:Internal.Gateway_xyz", + Generation = "other-generation", + }); + + SessionStateException exception = await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(SessionStateFault.ForeignIdentity, exception.Fault); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task BusyLockIsReportedRatherThanWaitingForever() + { + SessionCoordinator coordinator = Create( + new NeverFreeLock(), + lockTimeout: TimeSpan.FromMilliseconds(1)); + + await Assert.ThrowsAsync( + () => coordinator.EnsureStartedAsync(CancellationToken.None)); + + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task LockIsReleasedAfterAFailedOperation() + { + var sessionLock = new AlwaysFreeLock(); + _backend.ProvisionFailure = new MxcException(MxcErrorCode.BackendError, "no"); + + await Assert.ThrowsAsync( + () => Create(sessionLock).EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(0, sessionLock.HeldCount); + } + + [Fact] + public async Task LockIsNotHeldAfterASuccessfulOperation() + { + var sessionLock = new AlwaysFreeLock(); + + await Create(sessionLock).EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(0, sessionLock.HeldCount); + } + + [Fact] + public void StatusWithoutASessionReportsNone() + { + SessionStatus status = Create().GetRecordedStatus(); + + Assert.Equal(SessionAvailability.None, status.Availability); + Assert.Null(status.Record); + } + + [Fact] + public async Task StatusDoesNotProvisionOrContactTheBackend() + { + Create().GetRecordedStatus(); + + Assert.Empty(_backend.Calls); + Assert.False(File.Exists(StatePath)); + await Task.CompletedTask.ConfigureAwait(true); + } + + [Fact] + public async Task StatusReportsTheRecordedSession() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + SessionStatus status = Create().GetRecordedStatus(); + + Assert.Equal(SessionAvailability.Recorded, status.Availability); + Assert.Equal("iso:sandbox1", status.Record!.SandboxId); + Assert.Empty(_backend.Calls); + } + + [Fact] + public void StatusDistinguishesUnusableFromAbsent() + { + File.WriteAllText(StatePath, "{ not json"); + + SessionStatus status = Create().GetRecordedStatus(); + + Assert.Equal(SessionAvailability.Unusable, status.Availability); + Assert.Equal(SessionStateFault.Unreadable, status.Fault); + Assert.NotNull(status.Detail); + } + + [Fact] + public async Task StopRetainsTheRecordAndTheProfile() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + bool stopped = await Create().StopAsync(CancellationToken.None); + + Assert.True(stopped); + Assert.Equal(["stop:iso:sandbox1"], _backend.Calls); + Assert.True(Store().Read(ApplicationId).HasRecord); + } + + [Fact] + public async Task StopWithoutASessionDoesNothing() + { + bool stopped = await Create().StopAsync(CancellationToken.None); + + Assert.False(stopped); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task StopDoesNotDeprovision() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + await Create().StopAsync(CancellationToken.None); + + Assert.DoesNotContain("deprovision:iso:sandbox1", _backend.Calls); + } + + [Fact] + public async Task StoppedSessionIsStartedAgainOnNextUse() + { + await Create().EnsureStartedAsync(CancellationToken.None); + await Create().StopAsync(CancellationToken.None); + _backend.Calls.Clear(); + + await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task RemoveStopsBeforeDeprovisioning() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + SessionRemovalResult result = await Create().RemoveAsync(CancellationToken.None); + + Assert.True(result.Removed); + Assert.Null(result.StopFailure); + Assert.Equal(["stop:iso:sandbox1", "deprovision:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task RemoveForgetsTheSessionOnlyAfterDeprovisionSucceeds() + { + await Create().EnsureStartedAsync(CancellationToken.None); + + await Create().RemoveAsync(CancellationToken.None); + + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + } + + [Fact] + public async Task FailedDeprovisionRetainsOwnership() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.DeprovisionFailure = new MxcException( + MxcErrorCode.BackendError, + "deprovision failed"); + + await Assert.ThrowsAsync( + () => Create().RemoveAsync(CancellationToken.None)); + + Assert.True(Store().Read(ApplicationId).HasRecord); + } + + [Fact] + public async Task FailedStopStillDeprovisionsAndIsReported() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.StopFailure = new MxcException(MxcErrorCode.BackendError, "stop failed"); + _backend.Calls.Clear(); + + SessionRemovalResult result = await Create().RemoveAsync(CancellationToken.None); + + Assert.True(result.Removed); + Assert.Equal("stop failed", result.StopFailure); + Assert.Contains("deprovision:iso:sandbox1", _backend.Calls); + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + } + + [Fact] + public async Task RemoveWithoutASessionDoesNothing() + { + SessionRemovalResult result = await Create().RemoveAsync(CancellationToken.None); + + Assert.False(result.Removed); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task RemoveDoesNotTouchAnotherInstallationsSession() + { + Store().Write(new SessionRecord + { + SandboxId = "iso:other", + ApplicationId = "PFN:Internal.Gateway_xyz", + Generation = "other-generation", + }); + + await Assert.ThrowsAsync( + () => Create().RemoveAsync(CancellationToken.None)); + + Assert.Empty(_backend.Calls); + Assert.True(File.Exists(StatePath)); + } + + [Fact] + public async Task RemoveRefusesARecordItCannotRead() + { + File.WriteAllText(StatePath, "{ not json"); + + await Assert.ThrowsAsync( + () => Create().RemoveAsync(CancellationToken.None)); + + Assert.Empty(_backend.Calls); + await Task.CompletedTask.ConfigureAwait(true); + } + + [Fact] + public void ForgetDiscardsAnUnreadableRecordWithoutContactingTheBackend() + { + File.WriteAllText(StatePath, "{ not json"); + + Create().ForgetRecordedState(); + + Assert.Equal(SessionStateFault.Missing, Store().Read(ApplicationId).Fault); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task NewSessionCanBeCreatedAfterRemoval() + { + await Create().EnsureStartedAsync(CancellationToken.None); + await Create().RemoveAsync(CancellationToken.None); + + SessionRecord record = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal("iso:sandbox2", record.SandboxId); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs new file mode 100644 index 00000000..bded9d3d --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -0,0 +1,491 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionExecutorTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + private readonly FakeMxcSessionClient _backend = new(); + private readonly List _log = []; + + private string Workspace => _root; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private SessionRecord Record(string? workspace = null) => new() + { + SchemaVersion = 1, + SandboxId = "iso:sandbox1", + ApplicationId = "PFN:OpenClaw.Gateway_abc123", + WorkspacePath = workspace ?? Workspace, + Generation = "test-generation", + }; + + private SessionExecutionRequest Request(params string[] arguments) => + new( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Program Files\nodejs\node.exe", + @"C:\Package\app", + arguments, + @"C:\work"); + + private SessionExecutor Create() => new(_backend, _log.Add); + + [Fact] + public async Task IsolatedLaunchRetainsTheHostInteractiveEnvironment() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync( + Record(), + new SessionExecutionRequest( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Program Files\nodejs\node.exe", + @"C:\Package\app", + [], + Workspace) + { + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( + isInteractive: true, + _ => null) + }, + CancellationToken.None); + + Assert.Equal("3", delivered!.Environment!["FORCE_COLOR"]); + Assert.Equal("1", delivered.Environment["WT_SESSION"]); + } + + /// + /// Stands in for the guest helper: reads the delivered request and writes + /// the control result the real helper would. + /// + private void RespondAsHelper( + Func respond) + { + _backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + SessionLaunchResult result = respond(request); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(result)); + return Task.FromResult(result.ExitCode ?? 0); + }; + } + + private void RespondLaunched(int exitCode) => + RespondAsHelper(request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = exitCode, + }); + + [Fact] + public async Task ApplicationExitCodeIsReturned() + { + RespondLaunched(42); + + int exitCode = await Create().ExecuteAsync( + Record(), + Request("--version"), + CancellationToken.None); + + Assert.Equal(42, exitCode); + } + + [Fact] + public async Task ArgumentsTravelAsDataNotOnTheCommandLine() + { + // The pinned runtime flattens the command line through cmd.exe, where + // %USERPROFILE% expands and a quote truncates a later argument. + string[] hostile = ["%USERPROFILE%", "q\"x", "a&b", "trailing\\", "", "ünïcode"]; + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request(hostile), CancellationToken.None); + + Assert.Equal(hostile, delivered!.Arguments!.Skip(1)); + string commandLine = Assert.Single(_backend.AttachedCommandLines); + foreach (string argument in hostile.Where(value => value.Length > 0)) + { + Assert.DoesNotContain(argument, commandLine, StringComparison.Ordinal); + } + } + + [Fact] + public async Task EntryPointIsTheFirstNodeArgument() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request("doctor"), CancellationToken.None); + + Assert.Equal( + [@"C:\Package\app\openclaw.mjs", "doctor"], + delivered!.Arguments); + Assert.Equal(@"C:\Program Files\nodejs\node.exe", delivered.Executable); + } + + [Fact] + public async Task WorkingDirectoryIsCarriedExplicitly() + { + // Execution does not inherit the caller's directory; it defaults to + // C:\Windows\System32 unless the request sets it. + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal(@"C:\work", delivered!.WorkingDirectory); + } + + [Fact] + public async Task ExternalSupervisionVariablesAreDelivered() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal("external", delivered!.Environment!["OPENCLAW_SUPERVISOR_MODE"]); + Assert.Equal("external", delivered.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); + Assert.Equal("1", delivered.Environment["OPENCLAW_NO_AUTO_UPDATE"]); + } + + [Fact] + public async Task CommandLineCarriesOnlyTheHelperAndItsRequest() + { + RespondLaunched(0); + + await Create().ExecuteAsync(Record(), Request("chat"), CancellationToken.None); + + string commandLine = Assert.Single(_backend.AttachedCommandLines); + + // The properties that matter are what the command line carries, not how + // it is shaped: the helper is named, and neither the user's arguments + // nor the Node path ever reach a string the command processor expands. + Assert.Contains( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + commandLine, + StringComparison.Ordinal); + Assert.Contains("--request", commandLine, StringComparison.Ordinal); + Assert.DoesNotContain("chat", commandLine, StringComparison.Ordinal); + Assert.DoesNotContain("node.exe", commandLine, StringComparison.Ordinal); + } + + [Fact] + public async Task ExecutionIsAttachedRatherThanBuffered() + { + RespondLaunched(0); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal(["execute-attached:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task RequestAndResultFilesAreRemovedAfterwards() + { + RespondLaunched(0); + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task FilesAreRemovedEvenWhenExecutionFails() + { + _backend.AttachedBehavior = _ => + Task.FromException(new MxcException(MxcErrorCode.BackendError, "no")); + + await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task ConcurrentInvocationsUseDistinctRequestFiles() + { + var seen = new List(); + _backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + seen.Add(requestPath); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + })); + return Task.FromResult(0); + }; + + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + await Create().ExecuteAsync(Record(), Request(), CancellationToken.None); + + Assert.Equal(2, seen.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public async Task HelperFailureIsDistinguishedFromAnApplicationExit() + { + // Exit code 64 is both the helper's failure code and a value OpenClaw + // could return, so only the control result can tell them apart. + RespondAsHelper(request => new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = false, + Error = "The executable was not found.", + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("The executable was not found.", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ApplicationExitOf64IsNotMistakenForHelperFailure() + { + RespondLaunched(SessionLaunchProtocol.HelperFailureExitCode); + + int exitCode = await Create().ExecuteAsync( + Record(), + Request(), + CancellationToken.None); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + } + + [Fact] + public async Task MissingControlResultIsReportedRatherThanTrusted() + { + _backend.AttachedBehavior = _ => Task.FromResult(1); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("did not report a launch result", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task UnreadableControlResultIsReported() + { + _backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "launch-*.json").Single(); + File.WriteAllText(SessionLaunchProtocol.ResultPathFor(requestPath), "{ not json"); + return Task.FromResult(0); + }; + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("unreadable launch result", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ResultForAnotherRequestIsRejected() + { + RespondAsHelper(_ => new SessionLaunchResult + { + RequestId = "0123456789abcdef0123456789abcdef", + Launched = true, + ExitCode = 0, + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("different request", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SessionWithoutAWorkspaceIsReported() + { + SessionException exception = await Assert.ThrowsAsync( + () => Create().ExecuteAsync( + Record(workspace: string.Empty) with { WorkspacePath = null }, + Request(), + CancellationToken.None)); + + Assert.Contains("shared workspace", exception.Message, StringComparison.Ordinal); + Assert.Empty(_backend.Calls); + } + + [Fact] + public async Task StaleSessionGenerationIsRejectedBeforeWritingARequest() + { + SessionExecutor executor = new( + _backend, + _log.Add, + isCurrentRecord: _ => false); + + SessionException exception = await Assert.ThrowsAsync( + () => executor.ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("generation changed", exception.Message, StringComparison.Ordinal); + Assert.Empty(_backend.Calls); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Theory] + [InlineData("C:\\has%percent\\helper.exe")] + [InlineData("C:\\has\"quote\\helper.exe")] + public void UnsafeHelperPathIsRefusedRatherThanCorrupted(string helperPath) + { + SessionException exception = Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine(helperPath, @"C:\ws\r.json")); + + Assert.Contains("quote or percent sign", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnsafeRequestPathIsRefused() + { + Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine( + @"C:\p\helper.exe", + @"C:\ws\%TEMP%.json")); + } + + // Quoting is proven by dispatching the command line through the command + // processor the backend actually uses, in SessionGuestCommandLineTests. + // A string-equality assertion here previously passed against a command line + // that the command processor then broke apart at the first space. + + [Fact] + public async Task CancellationPropagates() + { + using var cancellation = new CancellationTokenSource(); + _backend.AttachedBehavior = token => + { + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + return Task.FromResult(0); + }; + + await Assert.ThrowsAnyAsync( + () => Create().ExecuteAsync(Record(), Request(), cancellation.Token)); + + Assert.Empty(Directory.GetFiles(Workspace)); + } +} + +public sealed class OpenClawRuntimeEnvironmentTests +{ + [Fact] + public void ExternalSupervisionIsDeclared() + { + IReadOnlyDictionary environment = + OpenClawRuntimeEnvironment.Build(); + + Assert.Equal("external", environment["OPENCLAW_SUPERVISOR_MODE"]); + Assert.Equal("external", environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); + Assert.Equal("1", environment["OPENCLAW_NO_AUTO_UPDATE"]); + } + + [Fact] + public void ForegroundLaunchUsesTheSameVariables() + { + // Both launch paths must agree; a path that supervises or updates + // itself would be a silent behavior difference. + string application = TestDirectory.Create(); + try + { + File.WriteAllText(Path.Combine(application, "openclaw.mjs"), "// test"); + System.Diagnostics.ProcessStartInfo startInfo = + GatewayLauncher.CreateStartInfo( + @"C:\node.exe", + application, + []); + + foreach ((string name, string value) in OpenClawRuntimeEnvironment.Build()) + { + Assert.Equal(value, startInfo.Environment[name]); + } + } + finally + { + Directory.Delete(application, recursive: true); + } + } + + [Fact] + public void ApplyToOverwritesAnInheritedValue() + { + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["OPENCLAW_SUPERVISOR_MODE"] = "self", + }; + + OpenClawRuntimeEnvironment.ApplyTo(environment); + + Assert.Equal("external", environment["OPENCLAW_SUPERVISOR_MODE"]); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs new file mode 100644 index 00000000..61cff383 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionGuestCommandLineTests.cs @@ -0,0 +1,183 @@ +using System.Diagnostics; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Runs the command line this package hands the backend through the command +/// processor the backend actually dispatches with. +/// +/// +/// +/// The defect this covers reached a real installation: every path was quoted, +/// the string looked correct, and the launch still failed with +/// 'C:\Program' is not recognized. Asserting on the composed string +/// would have passed, because the string was never the problem; the command +/// processor's treatment of it was. These tests therefore execute it. +/// +/// +/// The packaged helper lives under C:\Program Files\WindowsApps\..., so +/// a space in the executable path is the normal case rather than an edge one. +/// +/// +public sealed class SessionGuestCommandLineTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + /// + /// Writes a script that reports the argument vector it actually received. + /// + private string CreateProbe(string directoryName) + { + string directory = Path.Combine(_root, directoryName); + Directory.CreateDirectory(directory); + string probe = Path.Combine(directory, "probe.cmd"); + File.WriteAllText( + probe, + "@echo off\r\n" + + "echo STARTED\r\n" + + "echo OPTION=%~1\r\n" + + "echo REQUEST=%~2\r\n", + System.Text.Encoding.ASCII); + return probe; + } + + /// + /// Dispatches exactly as the pinned runtime does: the command line is + /// appended to cmd.exe /c verbatim, with no further quoting. + /// + private static (int ExitCode, string Output, string Error) Dispatch(string commandLine) + { + ProcessStartInfo startInfo = new() + { + FileName = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe"), + + // Arguments, not ArgumentList: the point is to reproduce a raw + // command line rather than let .NET re-quote it. + Arguments = "/c " + commandLine, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using Process process = Process.Start(startInfo)!; + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(TimeSpan.FromSeconds(30)); + return (process.ExitCode, output, error); + } + + [Fact] + public void TheHelperRunsWhenItsDirectoryContainsASpace() + { + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, string error) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + // The measured failure was exit 1 with 'C:\Program' is not recognized. + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains("STARTED", output, StringComparison.Ordinal); + } + + [Fact] + public void TheRequestPathArrivesWholeWhenItContainsASpace() + { + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch request.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + Assert.Equal(0, exitCode); + + // A truncated path here would send the helper looking for a request + // file that does not exist, which surfaces much later as "the session + // did not report a launch result". + Assert.Contains($"REQUEST={request}", output, StringComparison.Ordinal); + } + + [Fact] + public void TheOptionIsNotAbsorbedIntoTheExecutablePath() + { + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch.json"); + File.WriteAllText(request, "{}"); + + (_, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + Assert.Contains("OPTION=--request", output, StringComparison.Ordinal); + } + + [Theory] + [InlineData("--request")] + [InlineData("--inspect")] + [InlineData("--stop")] + public void EveryHelperModeSurvivesDispatch(string option) + { + // The gateway drives the same helper through inspect and stop, so a + // fix applied only to the launch path would leave those broken. + string probe = CreateProbe("Program Files"); + string request = Path.Combine(_root, "Program Files", "launch.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request, option)); + + Assert.Equal(0, exitCode); + Assert.Contains($"OPTION={option}", output, StringComparison.Ordinal); + } + + [Fact] + public void APathWithoutSpacesStillRunsAndKeepsItsArguments() + { + // The outer pair must not break the ordinary case it was added for the + // sake of the harder one. + string probe = CreateProbe("plain"); + string request = Path.Combine(_root, "plain", "launch.json"); + File.WriteAllText(request, "{}"); + + (int exitCode, string output, _) = Dispatch( + SessionExecutor.BuildGuestCommandLine(probe, request)); + + Assert.Equal(0, exitCode); + Assert.Contains($"REQUEST={request}", output, StringComparison.Ordinal); + } + + [Theory] + [InlineData("has\"quote")] + [InlineData("has%percent%")] + public void APathTheCommandProcessorCannotCarryIsRefused(string fragment) + { + // Refusing is the point: cmd.exe expands %VAR% and lets a quote + // truncate the rest of the line, and neither can be quoted around. + Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine( + Path.Combine(_root, fragment, "probe.exe"), + Path.Combine(_root, "launch.json"))); + + Assert.Throws( + () => SessionExecutor.BuildGuestCommandLine( + Path.Combine(_root, "probe.exe"), + Path.Combine(_root, fragment, "launch.json"))); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs new file mode 100644 index 00000000..4757727b --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHelperStagerTests.cs @@ -0,0 +1,114 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionHelperStagerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void StageCopiesThePackagedHelperToAFullVersionedWorkspacePath() + { + string source = Path.Combine(_root, "package", "openclaw-session-host.exe"); + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(Path.GetDirectoryName(source)!); + Directory.CreateDirectory(workspace); + File.WriteAllText(source, "helper bytes"); + + string staged = SessionHelperStager.Stage(source, workspace); + + Assert.True(Path.IsPathFullyQualified(staged)); + Assert.StartsWith( + Path.GetFullPath(workspace), + staged, + StringComparison.Ordinal); + Assert.Equal("helper bytes", File.ReadAllText(staged)); + Assert.Equal( + staged, + SessionHelperStager.RequireStaged(source, workspace)); + } + + [Fact] + public void StageReplacesSameLengthDifferentHelper() + { + string source = Path.Combine(_root, "package", "openclaw-session-host.exe"); + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(Path.GetDirectoryName(source)!); + Directory.CreateDirectory(workspace); + File.WriteAllText(source, "helper-v2"); + string stagedPath = SessionHelperStager.ResolveStagedPath(workspace); + Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!); + File.WriteAllText(stagedPath, "helper-v1"); + + string staged = SessionHelperStager.Stage(source, workspace); + + Assert.Equal(stagedPath, staged); + Assert.Equal("helper-v2", File.ReadAllText(staged)); + } + + [Fact] + public void RequireStagedDirectsTheUserBackToSetup() + { + string source = Path.Combine(_root, "openclaw-session-host.exe"); + File.WriteAllText(source, "helper bytes"); + + SessionException failure = Assert.Throws( + () => SessionHelperStager.RequireStaged( + source, + Path.Combine(_root, "workspace"))); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void MissingPackagedHelperIsReportedBeforeStaging() + { + SessionException failure = Assert.Throws( + () => SessionHelperStager.Stage( + Path.Combine(_root, "missing.exe"), + Path.Combine(_root, "workspace"))); + + Assert.Contains( + "packaged session helper is missing", + failure.Message, + StringComparison.Ordinal); + } + + [Fact] + public void CreatedFileRetainsDirectoryAuthorityUntilTheWriteCompletes() + { + string workspace = Path.Combine(_root, "workspace"); + string stagingDirectory = Path.Combine(workspace, "staging"); + string relocatedDirectory = Path.Combine(_root, "outside", "staging"); + string destination = Path.Combine(stagingDirectory, "helper.exe"); + Directory.CreateDirectory(workspace); + Directory.CreateDirectory(Path.GetDirectoryName(relocatedDirectory)!); + var record = new SessionRecord + { + SandboxId = "iso:test", + ApplicationId = "test", + WorkspacePath = workspace, + Generation = "generation" + }; + using var operation = new SessionWorkspaceOperation(record, _ => true); + operation.EnsureDirectory(stagingDirectory); + + using (Stream output = operation.CreateNew(destination)) + { + _ = Assert.ThrowsAny( + () => Directory.Move(stagingDirectory, relocatedDirectory)); + output.Write("helper bytes"u8); + } + + Assert.False(File.Exists(Path.Combine(relocatedDirectory, "helper.exe"))); + Assert.Equal("helper bytes", File.ReadAllText(destination)); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs new file mode 100644 index 00000000..1dbe5789 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionLockTests.cs @@ -0,0 +1,159 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionLockTests +{ + private static NamedSessionLock CreateLock(string scope) => + new($"{scope}-{Guid.NewGuid():N}"); + + [Fact] + public void LockIsAcquiredWhenFree() + { + NamedSessionLock sessionLock = CreateLock("free"); + + using ISessionLockHandle? handle = sessionLock.TryAcquire(TimeSpan.Zero); + + Assert.NotNull(handle); + } + + [Fact] + public void SecondAcquisitionFailsWhileTheFirstIsHeld() + { + NamedSessionLock sessionLock = CreateLock("contended"); + using ISessionLockHandle? first = sessionLock.TryAcquire(TimeSpan.Zero); + Assert.NotNull(first); + + // A separate thread stands in for a separate process: a named mutex is + // reentrant for its owning thread, so the same thread would succeed. + ISessionLockHandle? second = null; + var thread = new Thread(() => second = sessionLock.TryAcquire(TimeSpan.Zero)); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(10))); + + Assert.Null(second); + } + + [Fact] + public void LockIsAvailableAgainAfterRelease() + { + NamedSessionLock sessionLock = CreateLock("release"); + sessionLock.TryAcquire(TimeSpan.Zero)!.Dispose(); + + ISessionLockHandle? second = null; + var thread = new Thread(() => second = sessionLock.TryAcquire(TimeSpan.FromSeconds(5))); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(10))); + + Assert.NotNull(second); + second!.Dispose(); + } + + [Fact] + public void DifferentScopesDoNotContend() + { + NamedSessionLock first = CreateLock("scope-a"); + NamedSessionLock second = CreateLock("scope-b"); + + using ISessionLockHandle? firstHandle = first.TryAcquire(TimeSpan.Zero); + ISessionLockHandle? secondHandle = null; + var thread = new Thread(() => secondHandle = second.TryAcquire(TimeSpan.Zero)); + thread.Start(); + thread.Join(TimeSpan.FromSeconds(10)); + + Assert.NotNull(firstHandle); + Assert.NotNull(secondHandle); + secondHandle!.Dispose(); + } + + [Fact] + public void WaitingCallerAcquiresOnceTheHolderReleases() + { + NamedSessionLock sessionLock = CreateLock("handoff"); + ISessionLockHandle? holder = sessionLock.TryAcquire(TimeSpan.Zero); + Assert.NotNull(holder); + + using var waiterStarted = new ManualResetEventSlim(); + ISessionLockHandle? waiterHandle = null; + var waiter = new Thread(() => + { + waiterStarted.Set(); + waiterHandle = sessionLock.TryAcquire(TimeSpan.FromSeconds(30)); + }); + waiter.Start(); + + Assert.True(waiterStarted.Wait(TimeSpan.FromSeconds(10))); + holder!.Dispose(); + + Assert.True(waiter.Join(TimeSpan.FromSeconds(30))); + Assert.NotNull(waiterHandle); + waiterHandle!.Dispose(); + } + + [Fact] + public void AbandonedLockIsRecoveredRatherThanDeadlocking() + { + NamedSessionLock sessionLock = CreateLock("abandoned"); + + // Abandon the actual kernel mutex, not a transferable lease (which + // deliberately keeps ownership on a surviving dedicated thread). + using var kernelMutex = new Mutex(false, sessionLock.Name); + var abandoner = new Thread(() => kernelMutex.WaitOne()); + abandoner.Start(); + Assert.True(abandoner.Join(TimeSpan.FromSeconds(10))); + + using ISessionLockHandle? handle = sessionLock.TryAcquire(TimeSpan.FromSeconds(10)); + + Assert.NotNull(handle); + } + + [Fact] + public void AsyncContinuationCanReleaseLeaseFromAnotherThread() + { + NamedSessionLock sessionLock = CreateLock("cross-thread"); + ISessionLockHandle handle = sessionLock.TryAcquire(TimeSpan.Zero)!; + var continuation = new Thread(handle.Dispose); + continuation.Start(); + Assert.True(continuation.Join(TimeSpan.FromSeconds(10))); + using ISessionLockHandle? next = sessionLock.TryAcquire(TimeSpan.Zero); + Assert.NotNull(next); + } + + [Fact] + public void DisposingTwiceIsSafe() + { + NamedSessionLock sessionLock = CreateLock("double-dispose"); + ISessionLockHandle handle = sessionLock.TryAcquire(TimeSpan.Zero)!; + + handle.Dispose(); + handle.Dispose(); + + using ISessionLockHandle? reacquired = sessionLock.TryAcquire(TimeSpan.FromSeconds(5)); + Assert.NotNull(reacquired); + } + + [Fact] + public void NameIsScopedAcrossWindowsSessions() + { + var sessionLock = new NamedSessionLock("PFN:OpenClaw.Gateway_abc123"); + + Assert.StartsWith("Global\\", sessionLock.Name, StringComparison.Ordinal); + Assert.DoesNotContain(':', sessionLock.Name[7..]); + Assert.DoesNotContain('\\', sessionLock.Name[7..]); + } + + [Fact] + public void EmptyScopeIsRejected() + { + Assert.Throws(() => new NamedSessionLock(" ")); + } + + [Fact] + public void NegativeTimeoutIsRejected() + { + NamedSessionLock sessionLock = CreateLock("negative"); + + Assert.Throws( + () => sessionLock.TryAcquire(TimeSpan.FromSeconds(-1))); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs new file mode 100644 index 00000000..bd30c6f5 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs @@ -0,0 +1,241 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionRoutingPolicyTests +{ + private const string PackageFamilyName = "OpenClaw.Gateway_abc123"; + + private static MxcReadinessReport Ready( + bool? backendAvailable = true, + string? runtimeUnavailableReason = null, + MxcHostSupport hostSupport = MxcHostSupport.Supported, + string? probeFailureReason = null) => + new( + runtimeUnavailableReason is null ? @"C:\Package\mxc\x64" : null, + null, + runtimeUnavailableReason, + hostSupport, + null, + backendAvailable is null + ? MxcSupportEvidence.HostBuild + : MxcSupportEvidence.BackendProbe, + backendAvailable is null + ? null + : new MxcBackendProbe(backendAvailable.Value, "base-container", []), + probeFailureReason); + + private static Func Environment(string? value) => + name => name == SessionRoutingPolicy.ModeVariable ? value : null; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UnsetModeIsAutomatic(string? value) + { + Assert.Equal( + SessionMode.Automatic, + SessionRoutingPolicy.ReadMode(Environment(value))); + } + + [Theory] + [InlineData("0")] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("off")] + [InlineData("no")] + public void FalsyValuesDisableSessions(string value) + { + Assert.Equal( + SessionMode.Disabled, + SessionRoutingPolicy.ReadMode(Environment(value))); + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("On")] + [InlineData("yes")] + public void TruthyValuesRequireSessions(string value) + { + Assert.Equal( + SessionMode.Required, + SessionRoutingPolicy.ReadMode(Environment(value))); + } + + [Fact] + public void UnrecognizedValueIsRejectedRatherThanTreatedAsOff() + { + // Ignoring it would run outside the session the user asked for. + SessionException exception = Assert.Throws( + () => SessionRoutingPolicy.ReadMode(Environment("maybe"))); + + Assert.Contains("maybe", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SupportedMachineUsesTheSessionByDefault() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready()); + + Assert.Equal(SessionRouting.Session, decision.Routing); + } + + [Fact] + public void ProbeOverridesAnUnsupportedBuildVerdict() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: true, hostSupport: MxcHostSupport.Unsupported)); + + Assert.Equal(SessionRouting.Session, decision.Routing); + } + + [Fact] + public void ProbeSayingUnavailableRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: false)); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + Assert.Contains("not available", decision.Reason, StringComparison.Ordinal); + } + + [Fact] + public void UnsupportedBuildWithoutAProbeRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unsupported)); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + } + + [Fact] + public void UndeterminableSupportRunsDirectlyRatherThanGuessing() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unknown)); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + } + + [Fact] + public void ProbeFailureReasonIsCarriedIntoTheExplanation() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready( + backendAvailable: null, + hostSupport: MxcHostSupport.Unknown, + probeFailureReason: "the executor crashed")); + + Assert.Contains("the executor crashed", decision.Reason, StringComparison.Ordinal); + } + + [Fact] + public void MissingRuntimeRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready(runtimeUnavailableReason: "the runtime directory is absent")); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + Assert.Contains( + "the runtime directory is absent", + decision.Reason, + StringComparison.Ordinal); + } + + [Fact] + public void UnpackagedBuildRunsDirectly() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + null, + Ready()); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + Assert.Contains("installed package", decision.Reason, StringComparison.Ordinal); + } + + [Fact] + public void DisabledModeRunsDirectlyEvenWhenSupported() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Disabled, + PackageFamilyName, + Ready()); + + Assert.Equal(SessionRouting.Direct, decision.Routing); + } + + [Fact] + public void RequiredModeSucceedsWhenSupported() + { + SessionRoutingDecision decision = SessionRoutingPolicy.Decide( + SessionMode.Required, + PackageFamilyName, + Ready()); + + Assert.Equal(SessionRouting.Session, decision.Routing); + } + + [Theory] + [InlineData("unpackaged")] + [InlineData("no-runtime")] + [InlineData("backend-unavailable")] + [InlineData("unsupported-build")] + public void RequiredModeFailsLoudlyRatherThanFallingBack(string scenario) + { + (string? packageFamilyName, MxcReadinessReport readiness) = scenario switch + { + "unpackaged" => (null, Ready()), + "no-runtime" => (PackageFamilyName, Ready(runtimeUnavailableReason: "absent")), + "backend-unavailable" => (PackageFamilyName, Ready(backendAvailable: false)), + _ => ( + PackageFamilyName, + Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unsupported)), + }; + + SessionException exception = Assert.Throws( + () => SessionRoutingPolicy.Decide( + SessionMode.Required, + packageFamilyName, + readiness)); + + Assert.Contains( + SessionRoutingPolicy.ModeVariable, + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void EveryDecisionCarriesAReason() + { + SessionRoutingDecision session = SessionRoutingPolicy.Decide( + SessionMode.Automatic, + PackageFamilyName, + Ready()); + SessionRoutingDecision direct = SessionRoutingPolicy.Decide( + SessionMode.Disabled, + PackageFamilyName, + Ready()); + + Assert.False(string.IsNullOrWhiteSpace(session.Reason)); + Assert.False(string.IsNullOrWhiteSpace(direct.Reason)); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs new file mode 100644 index 00000000..7f6333c5 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeInstallerTests.cs @@ -0,0 +1,242 @@ +using System.IO.Compression; +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Drives the real guest installer against real files. +/// +/// +/// The whole contract is that a failure arrives as a readable result file: the +/// host has no other way to say why an install failed, and an unhandled +/// exception in the guest reports itself as a lost request instead. +/// +public sealed class SessionRuntimeInstallerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private string RequestPath => Path.Combine(_root, "runtime.json"); + + private SessionRuntimeInstallResult Run(string archivePath) + { + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath, + + // The account running tests is the developer's own, and its PATH + // is not this test's to change. + UpdateUserPath = false + })); + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root, + getRuntimeVersion: ReadFixtureVersion); + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + + return SessionRuntimeProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + } + + private SessionRuntimeInstallResult Install(string archivePath) + { + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath, + UpdateUserPath = false + })); + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root, + getRuntimeVersion: ReadFixtureVersion); + Assert.Equal(0, exitCode); + + return SessionRuntimeProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + } + + private string CreateArchive(string version) + { + string archivePath = Path.Combine(_root, $"node-v{version}-win-x64.zip"); + using ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + ZipArchiveEntry entry = archive.CreateEntry( + $"node-v{version}-win-x64/node.exe"); + using StreamWriter writer = new(entry.Open()); + writer.Write(version); + return archivePath; + } + + private static string? ReadFixtureVersion(string executablePath) => + File.Exists(executablePath) ? File.ReadAllText(executablePath) : null; + + [Fact] + public void ReinstallingIntoAnExistingAgentProfileReportsTheCurrentArchiveVersion() + { + Install(CreateArchive("24.15.0")); + + SessionRuntimeInstallResult result = Install(CreateArchive("24.20.0")); + + Assert.Equal("24.20.0", result.Version); + Assert.Equal("node-v24.20.0-win-x64.zip", result.ArchiveName); + Assert.Equal( + Path.Combine( + _root, + "OpenClawGatewayMSIX", + "agent-node", + "node-v24.20.0-win-x64", + "node.exe"), + result.ExecutablePath); + Assert.Equal("24.20.0", File.ReadAllText(result.ExecutablePath!)); + } + + [Fact] + public void InstallPersistsTheDirectoryContainingNode() + { + string archivePath = CreateArchive("24.20.0"); + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath + })); + string? persistedDirectory = null; + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root, + directory => + { + persistedDirectory = directory; + return true; + }, + ReadFixtureVersion); + + Assert.Equal(0, exitCode); + Assert.Equal( + Path.Combine( + _root, + "OpenClawGatewayMSIX", + "agent-node", + "node-v24.20.0-win-x64"), + persistedDirectory); + } + + [Fact] + public void SameVersionInstallReusesExistingNodeEvenWhenItIsOpen() + { + string archivePath = CreateArchive("24.20.0"); + SessionRuntimeInstallResult first = Install(archivePath); + using FileStream heldOpen = File.Open( + first.ExecutablePath!, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + SessionRuntimeInstallResult second = Install(archivePath); + + Assert.Equal(first.ExecutablePath, second.ExecutablePath); + Assert.Equal("24.20.0", second.Version); + } + + [Fact] + public void SameVersionInstallRepairsAnInvalidExistingNode() + { + string archivePath = CreateArchive("24.20.0"); + SessionRuntimeInstallResult first = Install(archivePath); + File.WriteAllText(first.ExecutablePath!, "broken"); + + SessionRuntimeInstallResult second = Install(archivePath); + + Assert.Equal(first.ExecutablePath, second.ExecutablePath); + Assert.Equal("24.20.0", File.ReadAllText(second.ExecutablePath!)); + } + + [Fact] + public void ProductionProbeRepairsAnUnlaunchableExistingNode() + { + string archivePath = CreateArchive("24.20.0"); + SessionRuntimeInstallResult first = Install(archivePath); + File.WriteAllText(first.ExecutablePath!, "not an executable"); + File.WriteAllText( + RequestPath, + SessionRuntimeProtocol.SerializeRequest(new SessionRuntimeInstallRequest + { + RequestId = "r1", + ArchivePath = archivePath, + UpdateUserPath = false + })); + + int exitCode = SessionRuntimeInstaller.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + () => _root); + + Assert.Equal(0, exitCode); + Assert.Equal("24.20.0", File.ReadAllText(first.ExecutablePath!)); + } + + // A truncated or corrupt archive is a real packaging failure. Left + // unhandled it crashes the guest, and the host then reports a lost request + // rather than the reason. + [Fact] + public void ACorruptArchiveIsReportedThroughTheResultFile() + { + string archive = Path.Combine(_root, "node-v24.15.0-win-x64.zip"); + File.WriteAllText(archive, "this is not a zip archive"); + + SessionRuntimeInstallResult result = Run(archive); + + Assert.NotNull(result.Error); + Assert.Null(result.ExecutablePath); + } + + [Fact] + public void AMissingArchiveIsReportedThroughTheResultFile() + { + SessionRuntimeInstallResult result = Run( + Path.Combine(_root, "node-v24.15.0-win-x64.zip")); + + Assert.NotNull(result.Error); + Assert.Null(result.ExecutablePath); + } + + // An archive whose name does not carry a version cannot be installed, and + // saying so beats extracting something unidentifiable. + [Fact] + public void AnUnrecognizedArchiveNameIsReportedThroughTheResultFile() + { + string archive = Path.Combine(_root, "node.zip"); + File.WriteAllText(archive, "irrelevant"); + + SessionRuntimeInstallResult result = Run(archive); + + Assert.NotNull(result.Error); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs new file mode 100644 index 00000000..6ecc0e55 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimePathTests.cs @@ -0,0 +1,97 @@ +using OpenClaw.SessionHost; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Covers the agent's own PATH, which is what makes the packaged Node.js +/// runtime win over a machine-wide installation. +/// +public sealed class SessionRuntimePathTests +{ + private const string Runtime = + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node-v24.15.0-win-x64"; + + // The whole point is precedence. Appended, a machine-wide Node.js keeps + // winning for anything that resolves `node` by name. + [Fact] + public void TheRuntimeComesFirst() + { + string result = SessionRuntimeInstaller.BuildPath( + @"C:\Windows\system32;C:\Program Files\nodejs", Runtime); + + Assert.StartsWith(Runtime + ";", result, StringComparison.Ordinal); + Assert.Contains(@"C:\Program Files\nodejs", result, StringComparison.Ordinal); + } + + // Setup is idempotent, so re-running it must not grow the value every time. + [Fact] + public void RepeatedInstallsDoNotAccumulateEntries() + { + string once = SessionRuntimeInstaller.BuildPath(@"C:\Windows\system32", Runtime); + string twice = SessionRuntimeInstaller.BuildPath(once, Runtime); + + Assert.Equal(once, twice); + } + + // A package upgrade installs a new version beside the old one. Leaving the + // old directory on PATH would keep resolving the runtime the installation + // no longer supports. + [Fact] + public void APreviousRuntimeVersionIsRemoved() + { + const string previous = + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node-v22.1.0-win-x64"; + + string result = SessionRuntimeInstaller.BuildPath( + $@"{previous};C:\Windows\system32", Runtime); + + Assert.DoesNotContain(previous, result, StringComparison.Ordinal); + Assert.StartsWith(Runtime + ";", result, StringComparison.Ordinal); + Assert.Contains(@"C:\Windows\system32", result, StringComparison.Ordinal); + } + + // An unrelated directory that merely looks similar must survive: this runs + // against a real account's PATH, not a fixture. + [Fact] + public void UnrelatedEntriesAreLeftAlone() + { + string result = SessionRuntimeInstaller.BuildPath( + @"C:\tools\node-v24.15.0-win-x64;C:\Windows", Runtime); + + Assert.Contains(@"C:\tools\node-v24.15.0-win-x64", result, StringComparison.Ordinal); + Assert.Contains(@"C:\Windows", result, StringComparison.Ordinal); + } + + // An empty value is the normal first-run case for a fresh profile. + [Fact] + public void AnEmptyPathBecomesJustTheRuntime() + { + Assert.Equal(Runtime, SessionRuntimeInstaller.BuildPath(string.Empty, Runtime)); + } + + // Only the guest can resolve the child's PATH, because the host never sees + // the agent account's environment. + [Fact] + public void ALaunchPutsTheRuntimeAheadOfTheInheritedPath() + { + System.Diagnostics.ProcessStartInfo startInfo = new(); + startInfo.Environment["PATH"] = @"C:\Program Files\nodejs"; + + SessionProcessLauncher.PrependPath(startInfo, Runtime); + + Assert.Equal( + $@"{Runtime};C:\Program Files\nodejs", + startInfo.Environment["PATH"]); + } + + [Fact] + public void ALaunchWithoutARuntimeLeavesThePathAlone() + { + System.Diagnostics.ProcessStartInfo startInfo = new(); + startInfo.Environment["PATH"] = @"C:\Program Files\nodejs"; + + SessionProcessLauncher.PrependPath(startInfo, null); + + Assert.Equal(@"C:\Program Files\nodejs", startInfo.Environment["PATH"]); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs new file mode 100644 index 00000000..e4d01832 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeProtocolTests.cs @@ -0,0 +1,78 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// The contract for installing the agent's own Node.js runtime. +/// +public sealed class SessionRuntimeProtocolTests +{ + private static SessionRuntimeInstallRequest Valid() => new() + { + RequestId = "r1", + ArchivePath = @"C:\Package\runtime\node-v24.15.0-win-x64.zip" + }; + + [Fact] + public void AValidRequestRoundTrips() + { + SessionRuntimeInstallRequest parsed = SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest(Valid())); + + Assert.Equal("r1", parsed.RequestId); + Assert.Equal(Valid().ArchivePath, parsed.ArchivePath); + Assert.True(parsed.UpdateUserPath); + } + + // The host names the archive; a relative or traversing value would let a + // malformed request point the guest outside the package. + [Theory] + [InlineData("")] + [InlineData(@"runtime\node.zip")] + [InlineData(@"C:\Package\..\elsewhere\node.zip")] + public void AnArchivePathThatIsNotFullyQualifiedIsRejected(string archivePath) + { + Assert.Throws( + () => SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest( + Valid() with { ArchivePath = archivePath }))); + } + + [Fact] + public void ARequestWithoutAnIdIsRejected() + { + Assert.Throws( + () => SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest(Valid() with { RequestId = null }))); + } + + // The helper and the launcher ship together, so a mismatch means a stale + // file or a mixed installation. + [Fact] + public void AMismatchedSchemaVersionIsRejected() + { + Assert.Throws( + () => SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest(Valid() with + { + SchemaVersion = SessionLaunchProtocol.CurrentSchemaVersion + 1 + }))); + } + + // A failed install has to arrive as a readable result, because the host + // reports it rather than guessing from an exit code. + [Fact] + public void AFailureResultRoundTrips() + { + SessionRuntimeInstallResult parsed = SessionRuntimeProtocol.ReadResult( + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = "r1", + Error = "the archive is corrupt" + })); + + Assert.Equal("the archive is corrupt", parsed.Error); + Assert.Null(parsed.ExecutablePath); + } +} + diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs new file mode 100644 index 00000000..8a0f8279 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs @@ -0,0 +1,89 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionRuntimeTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void StatusDoesNotRequireTheMxcRuntimeToBeInstalled() + { + bool runtimeLocated = false; + + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => + { + runtimeLocated = true; + throw new InvalidOperationException("no runtime here"); + }, + _root, + _ => { }); + + SessionStatus status = host.Coordinator.GetRecordedStatus(); + + Assert.Equal(SessionAvailability.None, status.Availability); + + // A read-only status query must never be turned into a runtime + // availability failure; the recorded state is what the user asked for. + Assert.False(runtimeLocated); + } + + [Fact] + public void UnpackagedExecutionIsRefusedWithAnExplicitReason() + { + SessionCapabilityUnavailableException failure = + Assert.Throws( + () => SessionRuntime.Create( + HostPaths.ForRoot(_root, packageFamilyName: null), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { })); + + Assert.Contains("installed package", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void RequireSetupRefusesAnUnmarkedInstallationWithoutContactingMxc() + { + var backend = new FakeMxcSessionClient(); + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { }, + backend); + + SessionException failure = Assert.Throws( + host.RequireSetup); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + Assert.Empty(backend.Calls); + } + + [Fact] + public void TheGuestHelperIsResolvedPerArchitectureBesideTheMxcRuntime() + { + string helperPath = SessionRuntime.ResolveHelperPath(@"C:\package"); + + Assert.StartsWith( + Path.Combine(@"C:\package", SessionRuntime.HelperDirectoryName), + helperPath, + StringComparison.Ordinal); + Assert.EndsWith(SessionRuntime.HelperFileName, helperPath, StringComparison.Ordinal); + Assert.Contains( + MxcRuntimeLocator.CurrentArchitectureName(), + helperPath, + StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs new file mode 100644 index 00000000..a36ce974 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionStateStoreTests.cs @@ -0,0 +1,341 @@ +using System.Text.Json; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionStateStoreTests : IDisposable +{ + private const string ApplicationId = "PFN:OpenClaw.Gateway_abc123"; + private const string SandboxId = "iso:AAAAbbbbCCCC"; + + private readonly string _root = TestDirectory.Create(); + + private string StatePath => Path.Combine(_root, "session.json"); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private static SessionRecord Record() => new() + { + SandboxId = SandboxId, + ApplicationId = ApplicationId, + AgentUserName = "agent_1", + AgentUserSid = "S-1-5-21-0-0-0-1001", + WorkspacePath = @"C:\Users\agent_1\Shared", + Generation = "test-generation", + WireVersion = "0.6.0-alpha", + CreatedUtc = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero), + }; + + [Fact] + public void MissingRecordIsReportedAsMissing() + { + var store = new SessionStateStore(StatePath); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.False(result.HasRecord); + Assert.Equal(SessionStateFault.Missing, result.Fault); + } + + [Fact] + public void MissingDirectoryIsMissingRatherThanUnreadable() + { + var store = new SessionStateStore( + Path.Combine(_root, "no-such-dir", "session.json")); + + Assert.Equal(SessionStateFault.Missing, store.Read(ApplicationId).Fault); + } + + [Fact] + public void WrittenRecordRoundTripsEveryPersistedField() + { + var store = new SessionStateStore(StatePath); + SessionRecord written = Record(); + + store.Write(written); + SessionStateResult result = store.Read(ApplicationId); + + SessionRecord read = Assert.IsType(result.Record); + Assert.Equal(SessionStateStore.CurrentSchemaVersion, read.SchemaVersion); + Assert.Equal(written.SandboxId, read.SandboxId); + Assert.Equal(written.ApplicationId, read.ApplicationId); + Assert.Equal(written.AgentUserName, read.AgentUserName); + Assert.Equal(written.AgentUserSid, read.AgentUserSid); + Assert.Equal(written.WorkspacePath, read.WorkspacePath); + Assert.Equal(written.Generation, read.Generation); + Assert.Equal(written.WireVersion, read.WireVersion); + Assert.Equal(written.CreatedUtc, read.CreatedUtc); + } + + [Fact] + public void OpaqueSandboxIdIsPreservedVerbatim() + { + const string opaque = "iso:eyJhIjoiYiJ9-_=="; + var store = new SessionStateStore(StatePath); + + store.Write(Record() with { SandboxId = opaque }); + + Assert.Equal(opaque, store.Read(ApplicationId).Record!.SandboxId); + } + + [Fact] + public void WriteStampsTheCurrentSchemaVersion() + { + var store = new SessionStateStore(StatePath); + + store.Write(Record() with { SchemaVersion = 0 }); + + Assert.Equal( + SessionStateStore.CurrentSchemaVersion, + store.Read(ApplicationId).Record!.SchemaVersion); + } + + [Fact] + public void WriteCreatesMissingDirectories() + { + var store = new SessionStateStore( + Path.Combine(_root, "deep", "deeper", "session.json")); + + store.Write(Record()); + + Assert.True(store.Read(ApplicationId).HasRecord); + } + + [Fact] + public void WriteLeavesNoTemporaryFileBehind() + { + var store = new SessionStateStore(StatePath); + + store.Write(Record()); + + Assert.Empty(Directory.GetFiles(_root, "*.tmp")); + } + + [Fact] + public void WriteReplacesAnExistingRecord() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + store.Write(Record() with { SandboxId = "iso:second", AgentUserName = "agent_2" }); + + SessionRecord read = store.Read(ApplicationId).Record!; + Assert.Equal("iso:second", read.SandboxId); + Assert.Equal("agent_2", read.AgentUserName); + } + + [Fact] + public void CorruptJsonIsUnreadableRatherThanMissing() + { + File.WriteAllText(StatePath, "{ not json"); + var store = new SessionStateStore(StatePath); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.Equal(SessionStateFault.Unreadable, result.Fault); + Assert.False(result.HasRecord); + } + + [Fact] + public void EmptyJsonDocumentIsUnreadable() + { + File.WriteAllText(StatePath, "null"); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Unreadable, store.Read(ApplicationId).Fault); + } + + [Fact] + public void NewerSchemaIsRejectedInsteadOfPartiallyRead() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + string text = File.ReadAllText(StatePath) + .Replace("\"schemaVersion\": 2", "\"schemaVersion\": 99", StringComparison.Ordinal); + File.WriteAllText(StatePath, text); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.Equal(SessionStateFault.UnsupportedSchema, result.Fault); + Assert.Contains("99", result.Detail!, StringComparison.Ordinal); + } + + [Fact] + public void RecordWithoutSchemaVersionIsIncomplete() + { + File.WriteAllText( + StatePath, + $$"""{"sandboxId":"{{SandboxId}}","applicationId":"{{ApplicationId}}"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Fact] + public void RecordWithoutApplicationIdIsIncomplete() + { + File.WriteAllText( + StatePath, + $$"""{"schemaVersion":2,"sandboxId":"{{SandboxId}}","generation":"test-generation"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Fact] + public void RecordWithoutGenerationIsIncomplete() + { + File.WriteAllText( + StatePath, + $$"""{"schemaVersion":2,"sandboxId":"{{SandboxId}}","applicationId":"{{ApplicationId}}"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Theory] + [InlineData("")] + [InlineData("no-prefix")] + public void RecordWithoutAUsableSandboxIdIsIncomplete(string sandboxId) + { + File.WriteAllText( + StatePath, + $$"""{"schemaVersion":2,"sandboxId":"{{sandboxId}}","applicationId":"{{ApplicationId}}","generation":"test-generation"}"""); + var store = new SessionStateStore(StatePath); + + Assert.Equal(SessionStateFault.Incomplete, store.Read(ApplicationId).Fault); + } + + [Fact] + public void RecordFromAnotherInstallationIsForeignRatherThanMissing() + { + var store = new SessionStateStore(StatePath); + store.Write(Record() with { ApplicationId = "PFN:Internal.Gateway_xyz" }); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.Equal(SessionStateFault.ForeignIdentity, result.Fault); + Assert.Contains("Internal.Gateway_xyz", result.Detail!, StringComparison.Ordinal); + } + + [Fact] + public void ApplicationIdComparisonIgnoresCase() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + Assert.True(store.Read(ApplicationId.ToUpperInvariant()).HasRecord); + } + + [Fact] + public void WriteRejectsAnUnusableSandboxIdBeforeTouchingDisk() + { + var store = new SessionStateStore(StatePath); + + Assert.ThrowsAny( + () => store.Write(Record() with { SandboxId = "no-prefix" })); + Assert.False(File.Exists(StatePath)); + } + + [Fact] + public void ClearRemovesTheRecord() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + store.Clear(); + + Assert.Equal(SessionStateFault.Missing, store.Read(ApplicationId).Fault); + } + + [Fact] + public void ClearWithoutARecordSucceeds() + { + var store = new SessionStateStore( + Path.Combine(_root, "absent", "session.json")); + + store.Clear(); + } + + [Fact] + public void PersistedFileIsReadableJson() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(StatePath)); + + Assert.Equal( + SandboxId, + document.RootElement.GetProperty("sandboxId").GetString()); + } + + // The record file is writable by the signed-in user. Substituting only the + // sandbox id leaves every other identity field intact, so the record's own + // application check still passes and the forged id would otherwise be + // replayed to the backend on this installation's authority. + [Fact] + public void ASandboxIssuedToAnotherApplicationIsForeign() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + Rewrite(store, SandboxIdFor("PFN:Attacker.App_zzzzzzzzzzzzz")); + + SessionStateResult result = store.Read(ApplicationId); + + Assert.False(result.HasRecord); + Assert.Equal(SessionStateFault.ForeignIdentity, result.Fault); + } + + [Fact] + public void ASandboxIssuedToThisApplicationIsUsable() + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + Rewrite(store, SandboxIdFor(ApplicationId)); + + Assert.True(store.Read(ApplicationId).HasRecord); + } + + // The identity stays opaque. A payload this package cannot read is not + // evidence of a foreign owner, and rejecting it would strand every existing + // session the moment the backend changed its encoding. + [Theory] + [InlineData("iso:AAAAbbbbCCCC")] + [InlineData("iso:!!!not-base64!!!")] + [InlineData("iso:eyJ2ZXJzaW9uIjoxfQ")] + public void AnUnreadableSandboxPayloadIsNotTreatedAsForeign(string sandboxId) + { + var store = new SessionStateStore(StatePath); + store.Write(Record()); + Rewrite(store, sandboxId); + + Assert.True(store.Read(ApplicationId).HasRecord); + } + + private void Rewrite(SessionStateStore store, string sandboxId) + { + _ = store; + string text = File.ReadAllText(StatePath); + using JsonDocument document = JsonDocument.Parse(text); + Dictionary fields = document.RootElement + .EnumerateObject() + .ToDictionary(property => property.Name, property => property.Value.Clone()); + fields["sandboxId"] = JsonSerializer.SerializeToElement(sandboxId); + File.WriteAllText(StatePath, JsonSerializer.Serialize(fields)); + } + + private static string SandboxIdFor(string applicationId) => + "iso:" + Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes( + $"{{\"version\":1,\"agentUserName\":\"F4-F8\",\"appId\":\"{applicationId}\"}}")) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs new file mode 100644 index 00000000..31a4dbf4 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionTestDoubles.cs @@ -0,0 +1,29 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +internal sealed class AlwaysFreeLock : ISessionLock +{ + public int HeldCount { get; private set; } + + public ISessionLockHandle? TryAcquire(TimeSpan timeout) + { + HeldCount++; + return new Handle(this); + } + + private sealed class Handle(AlwaysFreeLock owner) : ISessionLockHandle + { + public void Dispose() => owner.HeldCount--; + } +} + +internal sealed class NeverFreeLock : ISessionLock +{ + public ISessionLockHandle? TryAcquire(TimeSpan timeout) => null; +} + +internal sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => now; +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs new file mode 100644 index 00000000..a727235e --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SetupStateStoreTests.cs @@ -0,0 +1,89 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SetupStateStoreTests : IDisposable +{ + private const string ApplicationId = "PFN:OpenClaw.Gateway_test"; + private readonly string _root = TestDirectory.Create(); + + private string StatePath => Path.Combine(_root, "setup.json"); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void MissingMarkerIsDistinctFromAnUnreadableOne() + { + SetupStateStore store = new(StatePath); + + Assert.Equal(SetupStateFault.Missing, store.Read(ApplicationId).Fault); + + File.WriteAllText(StatePath, "{ not json"); + + Assert.Equal(SetupStateFault.Unreadable, store.Read(ApplicationId).Fault); + } + + [Fact] + public void MarkerRoundTripsWithItsApplicationIdentity() + { + SetupStateStore store = new(StatePath); + DateTimeOffset completed = new(2026, 9, 11, 18, 0, 0, TimeSpan.Zero); + + store.Write(new SetupRecord + { + ApplicationId = ApplicationId, + CompletedUtc = completed + }); + + SetupRecord? record = store.Read(ApplicationId).Record; + + Assert.NotNull(record); + Assert.Equal(ApplicationId, record.ApplicationId); + Assert.Equal(completed, record.CompletedUtc); + Assert.Equal(SetupStateStore.CurrentSchemaVersion, record.SchemaVersion); + } + + [Fact] + public void AMarkerForAnotherPackageCannotBeAdopted() + { + SetupStateStore store = new(StatePath); + store.Write(new SetupRecord { ApplicationId = "PFN:Other.Package_test" }); + + SetupStateResult result = store.Read(ApplicationId); + + Assert.Equal(SetupStateFault.ForeignIdentity, result.Fault); + Assert.Null(result.Record); + } + + [Fact] + public void ANewerMarkerIsRefusedRatherThanMisread() + { + File.WriteAllText( + StatePath, + """{"schemaVersion":99,"applicationId":"PFN:OpenClaw.Gateway_test"}"""); + + Assert.Equal( + SetupStateFault.UnsupportedSchema, + new SetupStateStore(StatePath).Read(ApplicationId).Fault); + } + + [Fact] + public void ClearingTheMarkerIsIdempotent() + { + SetupStateStore store = new(StatePath); + + store.Clear(); + Assert.Equal(SetupStateFault.Missing, store.Read(ApplicationId).Fault); + + store.Write(new SetupRecord { ApplicationId = ApplicationId }); + store.Clear(); + + Assert.Equal(SetupStateFault.Missing, store.Read(ApplicationId).Fault); + } +} From 4abfee5a9df833c7a09846b8dbf1f70d30fc117d Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:50 -0700 Subject: [PATCH 57/76] feat: open an interactive shell inside the isolated agent session (#47) * Add the isolated agent PowerShell shell Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Install agent command tools inside the session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Include session host job support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Avoid duplicating launcher job support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Scope shared job support to the session host Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 8 +- src/OpenClaw.Launcher/Program.cs | 66 +++++++++++++++- .../Session/AgentShellResolver.cs | 64 +++++++++++++++ .../Session/AgentToolShim.cs | 25 ++++++ .../Session/SessionExecutor.cs | 71 +++++++++++++++++ .../WindowsKillOnCloseJob.cs | 4 + .../OpenClaw.SessionHost.csproj | 1 + src/OpenClaw.SessionHost/Program.cs | 10 ++- .../SessionToolInstaller.cs | 68 ++++++++++++++++ .../SessionRuntimeProtocol.cs | 68 ++++++++++++++++ .../ClawCtlCommandLineTests.cs | 16 +++- .../ClawCtlParserDefaultsTests.cs | 3 +- .../Session/AgentShellTests.cs | 79 +++++++++++++++++++ .../Session/SessionExecutorTests.cs | 53 +++++++++++++ .../Session/SessionRuntimeTests.cs | 59 ++++++++++++++ .../Session/SessionToolInstallerTests.cs | 44 +++++++++++ 16 files changed, 633 insertions(+), 6 deletions(-) create mode 100644 src/OpenClaw.Launcher/Session/AgentShellResolver.cs create mode 100644 src/OpenClaw.Launcher/Session/AgentToolShim.cs create mode 100644 src/OpenClaw.SessionHost/SessionToolInstaller.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 86f098a1..9ac3ec96 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -9,6 +9,7 @@ internal sealed record ClawCtlHandlers public required Func> Setup { get; init; } public required Func> Status { get; init; } public required Func> Teardown { get; init; } + public required Func> PowerShell { get; init; } } // The clawctl command tree. Only the package-readiness surface belongs here: @@ -58,12 +59,17 @@ public static RootCommand Create(ClawCtlHandlers handlers) teardown.Options.Add(force); teardown.SetAction((parsed, cancellationToken) => handlers.Teardown(parsed.GetValue(force), cancellationToken)); + Command powerShell = new( + "pwsh", + "Open an interactive PowerShell session inside the isolated agent."); + powerShell.SetAction((_, cancellationToken) => handlers.PowerShell(cancellationToken)); RootCommand root = new(RootDescription) { setup, status, - teardown + teardown, + powerShell }; // Bare `clawctl` is a discovery request, not a usage error, so the root diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 556c1833..3ec1658e 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -353,7 +353,12 @@ await runtime.Coordinator.RemoveAsync(cancellationToken) await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); return 0; - } + }, + PowerShell = cancellationToken => RunPowerShellAsync( + options, + GetSessionRuntime(), + output, + cancellationToken) }); InvocationConfiguration configuration = new() @@ -378,6 +383,65 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); } + private static async Task RunPowerShellAsync( + HostOptions options, + Session.SessionRuntime runtime, + TextWriter output, + CancellationToken cancellationToken) + { + Session.SessionRecord record = runtime.RequireSetup(); + record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) + .ConfigureAwait(false); + string helperPath = runtime.RequireStagedHelper(record); + string applicationDirectory = GetPackagedApplicationDirectory(options); + string agentNodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); + string nodeDirectory = Path.GetDirectoryName(agentNodePath) + ?? throw new Session.SessionException( + "The agent's Node.js runtime has no parent directory."); + SessionToolInstallResult installedTools = await runtime.Executor.InstallToolsAsync( + record, + helperPath, + cancellationToken).ConfigureAwait(false); + Session.AgentTools tools = new( + Path.GetDirectoryName(installedTools.ShimPath) + ?? throw new Session.SessionException( + "The installed agent command shim has no parent directory."), + installedTools.ShimPath!); + Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); + + await output.WriteLineAsync( + $"Opening {shell.DisplayName} as {record.AgentUserName ?? "the agent account"} " + + "in the isolated session.").ConfigureAwait(false); + await output.WriteLineAsync( + "`openclaw` and `node` are on PATH; `clawctl` is not, because it " + + "manages this session from outside it.").ConfigureAwait(false); + await output.WriteLineAsync("Exit the shell to return.").ConfigureAwait(false); + + return await runtime.Executor.ExecuteCommandAsync( + record, + new Session.SessionCommandRequest( + helperPath, + shell.ExecutablePath, + Session.AgentShellResolver.BuildArguments( + record.WorkspacePath!, + record.AgentUserName ?? "agent", + tools.DirectoryPath, + nodeDirectory), + record.WorkspacePath!) + { + AdditionalEnvironment = Session.SessionExecutor.MergeEnvironment( + OpenClawRuntimeEnvironment.Build( + WindowsHostConsole.Instance.IsInteractive, + Environment.GetEnvironmentVariable, + GatewayIsolationMode.Enabled), + Session.AgentToolShim.BuildEnvironment(agentNodePath, applicationDirectory)) + }, + $"Opening {shell.DisplayName} in the isolated session.", + shell.DisplayName, + cancellationToken).ConfigureAwait(false); + } + private static async Task RunSetupAsync( HostOptions options, Action log, diff --git a/src/OpenClaw.Launcher/Session/AgentShellResolver.cs b/src/OpenClaw.Launcher/Session/AgentShellResolver.cs new file mode 100644 index 00000000..a1413600 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/AgentShellResolver.cs @@ -0,0 +1,64 @@ +namespace OpenClaw.Launcher.Session; + +/// The shell opened inside the agent session. +internal sealed record AgentShell(string ExecutablePath, string DisplayName); + +/// Chooses the machine-wide PowerShell available to the agent. +internal static class AgentShellResolver +{ + internal static readonly string[] PowerShell7Paths = + [ + @"C:\Program Files\PowerShell\7\pwsh.exe", + @"C:\Program Files\PowerShell\7-preview\pwsh.exe", + ]; + + internal static string WindowsPowerShellPath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + + public static AgentShell Resolve(Func fileExists) + { + ArgumentNullException.ThrowIfNull(fileExists); + + foreach (string candidate in PowerShell7Paths) + { + if (fileExists(candidate)) + { + return new AgentShell(candidate, "PowerShell 7"); + } + } + + return new AgentShell(WindowsPowerShellPath, "Windows PowerShell"); + } + + public static IReadOnlyList BuildArguments( + string workspacePath, + string agentName, + string toolsDirectory, + string nodeDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath); + ArgumentException.ThrowIfNullOrWhiteSpace(toolsDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(nodeDirectory); + + string prompt = + "function prompt { " + + $"Write-Host '[openclaw {Escape(agentName)}]' -NoNewline -ForegroundColor Cyan; " + + "return ' ' + $ExecutionContext.SessionState.Path.CurrentLocation + '> ' }"; + string path = + "$env:PATH = " + + $"'{Escape(toolsDirectory)}' + [IO.Path]::PathSeparator + " + + $"'{Escape(nodeDirectory)}' + [IO.Path]::PathSeparator + $env:PATH; "; + string preparation = + path + + $"Set-Location -LiteralPath '{Escape(workspacePath)}' -ErrorAction SilentlyContinue; " + + prompt; + + return ["-NoLogo", "-NoProfile", "-NoExit", "-Command", preparation]; + } + + private static string Escape(string value) => + value.Replace("'", "''", StringComparison.Ordinal); +} diff --git a/src/OpenClaw.Launcher/Session/AgentToolShim.cs b/src/OpenClaw.Launcher/Session/AgentToolShim.cs new file mode 100644 index 00000000..d0318a15 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/AgentToolShim.cs @@ -0,0 +1,25 @@ +namespace OpenClaw.Launcher.Session; + +/// Where the agent's openclaw command lives. +internal sealed record AgentTools(string DirectoryPath, string ShimPath); + +/// Builds environment values consumed by the guest-side command shim. +internal static class AgentToolShim +{ + internal const string NodeVariable = "OPENCLAW_SHIM_NODE"; + internal const string EntryPointVariable = "OPENCLAW_SHIM_ENTRY"; + + public static IReadOnlyDictionary BuildEnvironment( + string nodePath, + string applicationDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(nodePath); + ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [NodeVariable] = nodePath, + [EntryPointVariable] = Path.Combine(applicationDirectory, "openclaw.mjs"), + }; + } +} diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index 469671a7..c1fc7109 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -265,6 +265,77 @@ internal static IReadOnlyDictionary MergeEnvironment( return merged; } + /// Installs the agent command shim under the guest identity. + public async Task InstallToolsAsync( + SessionRecord record, + string helperPath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(helperPath); + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("tools", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionRuntimeProtocol.SerializeToolInstallRequest(new SessionToolInstallRequest + { + RequestId = requestId, + WorkspacePath = record.WorkspacePath + }), + cancellationToken).ConfigureAwait(false); + + _log("Installing OpenClaw agent tools in the isolated session."); + MxcExecutionResult execution = await _backend.ExecuteAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + BuildGuestCommandLine(helperPath, requestPath, "--install-tools")), + null, + cancellationToken).ConfigureAwait(false); + string resultText; + try + { + resultText = await operation.ReadTextAsync(resultPath, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + throw new SessionException( + "The isolated session did not report a tool install " + + DescribeMissingResult(execution)); + } + + SessionToolInstallResult result = + SessionRuntimeProtocol.ReadToolInstallResult(resultText); + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a tool install result for a different request."); + } + if (!string.IsNullOrWhiteSpace(result.Error)) + { + throw new SessionException( + $"The OpenClaw agent tools could not be installed: {result.Error}"); + } + if (string.IsNullOrWhiteSpace(result.ShimPath)) + { + throw new SessionException( + "The isolated session did not report the installed command shim."); + } + + return result; + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + private static List BuildNodeArguments( SessionExecutionRequest request) { diff --git a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs index 17cd7b0f..5eaff088 100644 --- a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs +++ b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs @@ -4,7 +4,11 @@ using System.Text; using Microsoft.Win32.SafeHandles; +#if OPENCLAW_SESSION_HOST +namespace OpenClaw.SessionHost; +#else namespace OpenClaw.Launcher; +#endif internal sealed class WindowsKillOnCloseJob : IDisposable { diff --git a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj index bc3a8b60..50c0a6d6 100644 --- a/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj +++ b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj @@ -27,6 +27,7 @@ + diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs index a7d74d26..fa540af0 100644 --- a/src/OpenClaw.SessionHost/Program.cs +++ b/src/OpenClaw.SessionHost/Program.cs @@ -31,13 +31,21 @@ internal static int Run( File.WriteAllText); } + if (args.Count == 2 && args[0] == "--install-tools") + { + return SessionToolInstaller.Run( + args[1], + readFile, + File.WriteAllText); + } + if (args.Count != 2 || args[0] != "--request") { // No request path means no control file to report through, so this // is the one failure that can only surface on stderr. errorOutput.WriteLine( "openclaw-session-host: usage: openclaw-session-host " + - "--request "); + "--request|--install-runtime|--install-tools "); return SessionLaunchProtocol.HelperFailureExitCode; } diff --git a/src/OpenClaw.SessionHost/SessionToolInstaller.cs b/src/OpenClaw.SessionHost/SessionToolInstaller.cs new file mode 100644 index 00000000..3c6d12d8 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionToolInstaller.cs @@ -0,0 +1,68 @@ +using System.Text; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// Installs the agent command shim from inside the isolated account. +internal static class SessionToolInstaller +{ + private const string DirectoryName = ".openclaw-tools"; + private const string ShimFileName = "openclaw.cmd"; + private const string ShimContent = + "@echo off\r\n" + + "setlocal\r\n" + + "if not defined OPENCLAW_SHIM_NODE goto :missing\r\n" + + "if not defined OPENCLAW_SHIM_ENTRY goto :missing\r\n" + + "\"%OPENCLAW_SHIM_NODE%\" \"%OPENCLAW_SHIM_ENTRY%\" %*\r\n" + + "exit /b %ERRORLEVEL%\r\n" + + ":missing\r\n" + + "echo openclaw: this shim only runs inside an OpenClaw agent session.>&2\r\n" + + "exit /b 9009\r\n"; + + public static int Run( + string requestPath, + Func readFile, + Action writeFile) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + try + { + SessionToolInstallRequest request = + SessionRuntimeProtocol.ReadToolInstallRequest(readFile(requestPath)); + requestId = request.RequestId; + string directory = Path.Combine(request.WorkspacePath!, DirectoryName); + string shimPath = Path.Combine(directory, ShimFileName); + Directory.CreateDirectory(directory); + File.WriteAllText(shimPath, ShimContent, Encoding.ASCII); + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeToolInstallResult(new SessionToolInstallResult + { + RequestId = requestId, + ShimPath = shimPath + })); + return 0; + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or UnauthorizedAccessException) + { + try + { + writeFile( + resultPath, + SessionRuntimeProtocol.SerializeToolInstallResult(new SessionToolInstallResult + { + RequestId = requestId, + Error = exception.Message + })); + } + catch (Exception writeException) when ( + writeException is IOException or UnauthorizedAccessException) + { + } + + return SessionLaunchProtocol.HelperFailureExitCode; + } + } +} diff --git a/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs index 359450f3..4c84fa83 100644 --- a/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs +++ b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs @@ -78,10 +78,41 @@ public sealed record SessionRuntimeInstallResult public string? Error { get; init; } } +/// Asks the guest to install its OpenClaw command shim. +public sealed record SessionToolInstallRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("workspacePath")] + public string? WorkspacePath { get; init; } +} + +/// Reports guest-side command shim installation. +public sealed record SessionToolInstallResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("shimPath")] + public string? ShimPath { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(SessionRuntimeInstallRequest))] [JsonSerializable(typeof(SessionRuntimeInstallResult))] +[JsonSerializable(typeof(SessionToolInstallRequest))] +[JsonSerializable(typeof(SessionToolInstallResult))] internal sealed partial class SessionRuntimeJsonContext : JsonSerializerContext; public static class SessionRuntimeProtocol @@ -96,6 +127,16 @@ public static string SerializeResult(SessionRuntimeInstallResult result) => result, SessionRuntimeJsonContext.Default.SessionRuntimeInstallResult); + public static string SerializeToolInstallRequest(SessionToolInstallRequest request) => + JsonSerializer.Serialize( + request, + SessionRuntimeJsonContext.Default.SessionToolInstallRequest); + + public static string SerializeToolInstallResult(SessionToolInstallResult result) => + JsonSerializer.Serialize( + result, + SessionRuntimeJsonContext.Default.SessionToolInstallResult); + public static SessionRuntimeInstallRequest ReadRequest(string json) { SessionRuntimeInstallRequest request = Deserialize( @@ -124,6 +165,33 @@ public static SessionRuntimeInstallRequest ReadRequest(string json) return request; } + public static SessionToolInstallRequest ReadToolInstallRequest(string json) + { + SessionToolInstallRequest request = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionToolInstallRequest, + "tool install request"); + RequireCurrentSchema(request.SchemaVersion, "tool install request", "helper"); + if (string.IsNullOrWhiteSpace(request.RequestId) || + string.IsNullOrWhiteSpace(request.WorkspacePath)) + { + throw new SessionLaunchException( + "The tool install request is missing its request ID or workspace path."); + } + + return request; + } + + public static SessionToolInstallResult ReadToolInstallResult(string json) + { + SessionToolInstallResult result = Deserialize( + json, + SessionRuntimeJsonContext.Default.SessionToolInstallResult, + "tool install result"); + RequireCurrentSchema(result.SchemaVersion, "tool install result", "launcher"); + return result; + } + public static SessionRuntimeInstallResult ReadResult(string json) { SessionRuntimeInstallResult result = Deserialize( diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index 89d27c83..b9f0627e 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -73,11 +73,12 @@ public void OnlyTheReadinessCommandIsExposed() { Setup = _ => Task.FromResult(0), Status = _ => Task.FromResult(0), - Teardown = (_, _) => Task.FromResult(0) + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0) }); Assert.Equal( - [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown"], + [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown", "pwsh"], root.Subcommands.Select(command => command.Name)); } @@ -97,6 +98,17 @@ public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() StringComparison.Ordinal); } + [Fact] + public async Task PowerShellHelpDescribesTheIsolatedAgentShell() + { + (int exitCode, string output, string error) = + await RunAsync("pwsh", "--help").ConfigureAwait(true); + + Assert.Equal(0, exitCode); + Assert.Empty(error); + Assert.Contains("isolated agent", Normalize(output), StringComparison.Ordinal); + } + // The built-in version action reports the entry assembly, which under a test // host or scenario runner is not the launcher. Assert the launcher's own // version so that substitution is caught. diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 688f8ced..75edd7ea 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -74,7 +74,8 @@ public void CompletionSuggestsCommandsWithoutResolvingNode() Setup = _ => throw new InvalidOperationException( "Completion started the readiness operation."), Status = _ => Task.FromResult(0), - Teardown = (_, _) => Task.FromResult(0) + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0) }); IEnumerable completions = root diff --git a/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs b/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs new file mode 100644 index 00000000..25ff8ed0 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/AgentShellTests.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class AgentShellTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + [Fact] + public void PowerShell7IsPreferredBeforePreviewAndWindowsPowerShell() + { + AgentShell shell = AgentShellResolver.Resolve( + path => path == AgentShellResolver.PowerShell7Paths[0] || + path == AgentShellResolver.PowerShell7Paths[1]); + + Assert.Equal(AgentShellResolver.PowerShell7Paths[0], shell.ExecutablePath); + Assert.Equal("PowerShell 7", shell.DisplayName); + } + + [Fact] + public void WindowsPowerShellIsUsedWhenNoMachineWidePowerShell7Exists() + { + AgentShell shell = AgentShellResolver.Resolve(_ => false); + + Assert.Equal(AgentShellResolver.WindowsPowerShellPath, shell.ExecutablePath); + Assert.Equal("Windows PowerShell", shell.DisplayName); + } + + [Fact] + public async Task ProductionPowerShellPathStartsSuccessfully() + { + AgentShell shell = AgentShellResolver.Resolve(File.Exists); + Assert.True(File.Exists(shell.ExecutablePath), shell.ExecutablePath); + + using Process process = new() + { + StartInfo = new ProcessStartInfo + { + FileName = shell.ExecutablePath, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.StartInfo.ArgumentList.Add("-NoLogo"); + process.StartInfo.ArgumentList.Add("-NoProfile"); + process.StartInfo.ArgumentList.Add("-Command"); + process.StartInfo.ArgumentList.Add("exit 0"); + + Assert.True(process.Start()); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await process.WaitForExitAsync(timeout.Token); + Assert.Equal(0, process.ExitCode); + } + + [Fact] + public void ShellPreparationPutsToolsThenAgentNodeAheadOfExistingPath() + { + IReadOnlyList arguments = AgentShellResolver.BuildArguments( + @"C:\shared workspace", + "agent's name", + @"C:\shared workspace\.openclaw-tools", + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node-v24"); + + Assert.Equal(["-NoLogo", "-NoProfile", "-NoExit", "-Command"], arguments.Take(4)); + string preparation = arguments[4]; + Assert.Contains( + @"C:\shared workspace\.openclaw-tools' + [IO.Path]::PathSeparator + 'C:\Users\agent", + preparation, + StringComparison.Ordinal); + Assert.Contains("agent''s name", preparation, StringComparison.Ordinal); + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index bded9d3d..be523782 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -115,6 +115,59 @@ public async Task ApplicationExitCodeIsReturned() Assert.Equal(42, exitCode); } + [Fact] + public async Task CommandRequestCarriesInteractiveAndShimEnvironment() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + IReadOnlyDictionary environment = + SessionExecutor.MergeEnvironment( + OpenClawRuntimeEnvironment.Build( + isInteractive: true, + _ => null, + GatewayIsolationMode.Enabled), + AgentToolShim.BuildEnvironment( + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node.exe", + @"C:\Package\app")); + + int exitCode = await Create().ExecuteCommandAsync( + Record(), + new SessionCommandRequest( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Program Files\PowerShell\7\pwsh.exe", + ["-NoExit"], + Workspace) + { + AdditionalEnvironment = environment + }, + "Opening PowerShell 7 in the isolated session.", + "PowerShell 7", + CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.Equal("3", delivered!.Environment!["FORCE_COLOR"]); + Assert.Equal("1", delivered.Environment["WT_SESSION"]); + Assert.Equal( + "enabled", + delivered.Environment[OpenClawRuntimeEnvironment.GatewayIsolationVariable]); + Assert.Equal( + @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node.exe", + delivered.Environment[AgentToolShim.NodeVariable]); + Assert.Equal( + @"C:\Package\app\openclaw.mjs", + delivered.Environment[AgentToolShim.EntryPointVariable]); + } + [Fact] public async Task ArgumentsTravelAsDataNotOnTheCommandLine() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs index 8a0f8279..a141a899 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs @@ -71,6 +71,65 @@ public void RequireSetupRefusesAnUnmarkedInstallationWithoutContactingMxc() Assert.Empty(backend.Calls); } + [Fact] + public async Task CompletedSetupAuthorizesTheRecordedSessionAndAgentRuntime() + { + var backend = new FakeMxcSessionClient(); + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { }, + backend); + SessionRecord session = await host.Coordinator.EnsureStartedAsync(CancellationToken.None); + + host.CompleteSetup( + session, + new OpenClaw.SessionProtocol.SessionRuntimeInstallResult + { + ExecutablePath = @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip" + }, + startupEnabled: true); + + Assert.Equal(session.SandboxId, host.RequireSetup().SandboxId); + Assert.Equal( + @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + host.RequireAgentNodePath( + @"C:\package\runtime\node-v24.20.0-win-x64.zip")); + } + + [Fact] + public async Task SameVersionWrongArchitectureIsRejected() + { + var backend = new FakeMxcSessionClient(); + SessionRuntime host = SessionRuntime.Create( + HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"), + () => throw new InvalidOperationException("not reached"), + _root, + _ => { }, + backend); + SessionRecord session = await host.Coordinator.EnsureStartedAsync( + CancellationToken.None); + + host.CompleteSetup( + session, + new OpenClaw.SessionProtocol.SessionRuntimeInstallResult + { + ExecutablePath = @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip" + }, + startupEnabled: true); + + SessionException failure = Assert.Throws(() => + host.RequireAgentNodePath( + @"C:\package\runtime\node-v24.20.0-win-arm64.zip")); + + Assert.Contains("does not match", failure.Message, StringComparison.Ordinal); + } + [Fact] public void TheGuestHelperIsResolvedPerArchitectureBesideTheMxcRuntime() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs new file mode 100644 index 00000000..3a64b886 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionToolInstallerTests.cs @@ -0,0 +1,44 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionToolInstallerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + [Fact] + public void GuestInstallerCreatesTheCommandShimInItsWorkspace() + { + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(workspace); + string requestPath = Path.Combine(workspace, "tools.json"); + File.WriteAllText( + requestPath, + SessionRuntimeProtocol.SerializeToolInstallRequest(new SessionToolInstallRequest + { + RequestId = "tools1", + WorkspacePath = workspace + })); + + int exitCode = SessionToolInstaller.Run( + requestPath, + File.ReadAllText, + File.WriteAllText); + + Assert.Equal(0, exitCode); + SessionToolInstallResult result = SessionRuntimeProtocol.ReadToolInstallResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(requestPath))); + Assert.Equal("tools1", result.RequestId); + Assert.True(File.Exists(result.ShimPath)); + Assert.Equal( + Path.Combine(workspace, ".openclaw-tools", "openclaw.cmd"), + result.ShimPath); + } +} From bd7408f2ba5414c7996542c8878360788591b46d Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:50 -0700 Subject: [PATCH 58/76] feat: manage the OpenClaw gateway inside the isolated session (#48) * Add detached session supervision Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Observe and stop owned guest processes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Define gateway lifecycle contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Add gateway session adapter and controller Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Add manual gateway-service controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Harden gateway session launch behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Fix confirmed teardown regression Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Point gateway startup guidance to its status command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Restore the unconfirmed teardown regression Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Preserve gateway session exit diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 18 +- .../Gateway/GatewayConfigurationStore.cs | 302 +++++++++ .../Gateway/GatewayController.cs | 501 +++++++++++++++ .../Gateway/GatewayPersistenceContracts.cs | 95 +++ .../Gateway/GatewayRuntime.cs | 96 +++ .../Gateway/GatewayStateStore.cs | 231 +++++++ .../Gateway/SessionGatewayClient.cs | 330 ++++++++++ src/OpenClaw.Launcher/Program.cs | 42 +- .../Session/SessionRuntime.cs | 6 + .../WindowsKillOnCloseJob.cs | 148 ++++- .../GuestProcessObserver.cs | 368 +++++++++++ src/OpenClaw.SessionHost/Program.cs | 84 ++- src/OpenClaw.SessionHost/SessionInspector.cs | 161 +++++ .../SessionProcessLauncher.cs | 159 ++++- src/OpenClaw.SessionHost/SessionSupervisor.cs | 165 +++++ src/OpenClaw.SessionHost/SessionTerminator.cs | 111 ++++ .../SessionInspectProtocol.cs | 260 ++++++++ .../SessionLaunchProtocol.cs | 21 +- .../ClawCtlCommandLineTests.cs | 39 +- .../ClawCtlParserDefaultsTests.cs | 5 +- .../Gateway/GatewayConfigurationStoreTests.cs | 152 +++++ .../Gateway/GatewayControllerTests.cs | 605 ++++++++++++++++++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 26 + .../Session/GuestProcessObserverTests.cs | 375 +++++++++++ .../Session/SessionHostExecutableTests.cs | 244 +++++++ .../Session/SessionHostModeTests.cs | 17 +- .../Session/SessionHostProgramTests.cs | 274 ++++++++ .../Session/SessionSupervisorTests.cs | 326 ++++++++++ 28 files changed, 5100 insertions(+), 61 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayController.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs create mode 100644 src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs create mode 100644 src/OpenClaw.SessionHost/GuestProcessObserver.cs create mode 100644 src/OpenClaw.SessionHost/SessionInspector.cs create mode 100644 src/OpenClaw.SessionHost/SessionSupervisor.cs create mode 100644 src/OpenClaw.SessionHost/SessionTerminator.cs create mode 100644 src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionSupervisorTests.cs diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 9ac3ec96..a94bd108 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -10,6 +10,9 @@ internal sealed record ClawCtlHandlers public required Func> Status { get; init; } public required Func> Teardown { get; init; } public required Func> PowerShell { get; init; } + public required Func> GatewayStart { get; init; } + public required Func> GatewayStatus { get; init; } + public required Func> GatewayStop { get; init; } } // The clawctl command tree. Only the package-readiness surface belongs here: @@ -63,13 +66,26 @@ public static RootCommand Create(ClawCtlHandlers handlers) "pwsh", "Open an interactive PowerShell session inside the isolated agent."); powerShell.SetAction((_, cancellationToken) => handlers.PowerShell(cancellationToken)); + Command gateway = new( + "gateway-service", + "Manage the background OpenClaw gateway inside the isolated session."); + Command gatewayStart = new("start", "Start the gateway if it is not running."); + gatewayStart.SetAction((_, token) => handlers.GatewayStart(token)); + Command gatewayStatus = new("status", "Show the gateway state without changing it."); + gatewayStatus.SetAction((_, token) => handlers.GatewayStatus(token)); + Command gatewayStop = new("stop", "Stop the gateway, keeping the session and its data."); + gatewayStop.SetAction((_, token) => handlers.GatewayStop(token)); + gateway.Subcommands.Add(gatewayStart); + gateway.Subcommands.Add(gatewayStatus); + gateway.Subcommands.Add(gatewayStop); RootCommand root = new(RootDescription) { setup, status, teardown, - powerShell + powerShell, + gateway }; // Bare `clawctl` is a discovery request, not a usage error, so the root diff --git a/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs new file mode 100644 index 00000000..ec85bada --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs @@ -0,0 +1,302 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Gateway; + +/// +/// How the gateway should be launched. +/// +/// +/// Persisted rather than derived per launch, because the logon task starts the +/// gateway with no one watching. A port chosen interactively must be the same +/// port used at the next sign-in, or the user's clients would silently fail to +/// find it. +/// +internal sealed record GatewayLaunchConfiguration +{ + /// + /// OpenClaw's own default gateway port, reported so a user knows where to + /// look when nothing was configured. + /// + /// + /// Never passed on the command line. It is recorded here only to describe + /// what OpenClaw will choose; passing it would override the user's own + /// gateway.port setting, and a constant in this package would + /// silently diverge the moment upstream changed its default. + /// + public const int UpstreamDefaultPort = 18789; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// + /// The port to launch on, or null to let OpenClaw resolve its own. + /// + /// + /// Absent by default and absent from the launch command line when absent + /// here. The gateway's port belongs to OpenClaw's configuration, exactly + /// like every other OpenClaw-owned argument this package forwards rather + /// than invents. + /// + [JsonPropertyName("port")] + public int? Port { get; init; } + + /// + /// The directory the gateway runs in. Stated rather than inherited: at + /// logon a task's directory is the system directory, and an interactive + /// caller's directory would make the gateway's behavior depend on where it + /// happened to be started from. + /// + [JsonPropertyName("workingDirectory")] + public string? WorkingDirectory { get; init; } +} + +/// Why a stored launch configuration could not be used. +internal enum GatewayConfigurationFault +{ + /// Nothing has been configured; defaults apply. + Missing, + + /// The file exists but is not readable as a configuration. + Unreadable, + + /// Written by an incompatible newer version. + UnsupportedSchema, + + /// A stored value is outside what Windows or OpenClaw accepts. + Invalid, +} + +internal sealed record GatewayConfigurationResult( + GatewayLaunchConfiguration? Configuration, + GatewayConfigurationFault? Fault, + string? Detail); + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(GatewayLaunchConfiguration))] +internal sealed partial class GatewayConfigurationJsonContext : JsonSerializerContext; + +/// +/// Reads and writes the gateway's launch configuration. +/// +internal sealed class GatewayConfigurationStore +{ + public const int CurrentSchemaVersion = 1; + + /// + /// Overrides the configured port for one invocation, for diagnosing a port + /// conflict without changing what the next sign-in will use. + /// + public const string PortVariable = "OPENCLAW_GATEWAY_PORT"; + + private readonly string _filePath; + + public GatewayConfigurationStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = filePath; + } + + /// + /// Returns the configuration to launch with, applying a one-invocation + /// environment override if present. + /// + /// + /// A configuration that cannot be read is an error rather than a silent + /// fallback to defaults: starting on a different port than the user + /// configured would leave their clients unable to find the gateway, with + /// nothing explaining why. + /// + public GatewayLaunchConfiguration Resolve( + string defaultWorkingDirectory, + Func? readEnvironmentVariable = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(defaultWorkingDirectory); + + GatewayConfigurationResult result = Read(); + if (result.Configuration is null && + result.Fault != GatewayConfigurationFault.Missing) + { + throw new GatewayConfigurationException( + $"The gateway configuration could not be used: {result.Detail}"); + } + + GatewayLaunchConfiguration configuration = + result.Configuration ?? new GatewayLaunchConfiguration(); + + int? port = ReadPortOverride( + readEnvironmentVariable ?? Environment.GetEnvironmentVariable) + ?? configuration.Port; + + return configuration with + { + Port = port, + WorkingDirectory = string.IsNullOrWhiteSpace(configuration.WorkingDirectory) + ? defaultWorkingDirectory + : configuration.WorkingDirectory + }; + } + + public GatewayConfigurationResult Read() + { + string text; + try + { + text = File.ReadAllText(_filePath); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return new GatewayConfigurationResult( + null, + GatewayConfigurationFault.Missing, + null); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return new GatewayConfigurationResult( + null, + GatewayConfigurationFault.Unreadable, + $"The gateway configuration could not be read: {exception.Message}"); + } + + GatewayLaunchConfiguration? configuration; + try + { + configuration = JsonSerializer.Deserialize( + text, + GatewayConfigurationJsonContext.Default.GatewayLaunchConfiguration); + } + catch (JsonException exception) + { + return new GatewayConfigurationResult( + null, + GatewayConfigurationFault.Unreadable, + $"The gateway configuration is not valid JSON: {exception.Message}"); + } + + if (configuration is null) + { + return new GatewayConfigurationResult( + null, + GatewayConfigurationFault.Unreadable, + "The gateway configuration is empty."); + } + + if (configuration.SchemaVersion > CurrentSchemaVersion) + { + return new GatewayConfigurationResult( + null, + GatewayConfigurationFault.UnsupportedSchema, + $"The gateway configuration uses schema version " + + $"{configuration.SchemaVersion}, which is newer than this " + + $"installation supports ({CurrentSchemaVersion})."); + } + + if (configuration.Port is int configured && !IsUsablePort(configured)) + { + return new GatewayConfigurationResult( + null, + GatewayConfigurationFault.Invalid, + $"The configured port {configured} is not a usable " + + "TCP port (1-65535)."); + } + + if (configuration.WorkingDirectory is { Length: > 0 } workingDirectory && + !Path.IsPathFullyQualified(workingDirectory)) + { + return new GatewayConfigurationResult(null, GatewayConfigurationFault.Invalid, + "The configured working directory must be an absolute guest-visible path."); + } + + return new GatewayConfigurationResult(configuration, null, null); + } + + public void Write(GatewayLaunchConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + if (configuration.WorkingDirectory is { Length: > 0 } workingDirectory && + !Path.IsPathFullyQualified(workingDirectory)) + { + throw new GatewayConfigurationException("The working directory must be an absolute guest-visible path."); + } + + if (configuration.Port is int port && !IsUsablePort(port)) + { + throw new GatewayConfigurationException( + $"The port {port} is not a usable TCP port (1-65535)."); + } + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string text = JsonSerializer.Serialize( + configuration with { SchemaVersion = CurrentSchemaVersion }, + GatewayConfigurationJsonContext.Default.GatewayLaunchConfiguration); + + string temporaryPath = _filePath + ".tmp"; + File.WriteAllText(temporaryPath, text); + File.Move(temporaryPath, _filePath, overwrite: true); + } + + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } + + private static int? ReadPortOverride(Func readEnvironmentVariable) + { + string? value = readEnvironmentVariable(PortVariable)?.Trim(); + if (string.IsNullOrEmpty(value)) + { + return null; + } + + // An unusable value is refused rather than ignored. Falling back to the + // configured port would start the gateway somewhere the user did not + // ask for while appearing to honor the override. + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int port) || + !IsUsablePort(port)) + { + throw new GatewayConfigurationException( + $"{PortVariable} is set to '{value}', which is not a usable " + + "TCP port (1-65535)."); + } + + return port; + } + + private static bool IsUsablePort(int port) => port is > 0 and <= 65535; +} + +/// +/// The gateway's launch configuration could not be used. +/// +internal sealed class GatewayConfigurationException : Exception +{ + public GatewayConfigurationException() + { + } + + public GatewayConfigurationException(string message) + : base(message) + { + } + + public GatewayConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayController.cs b/src/OpenClaw.Launcher/Gateway/GatewayController.cs new file mode 100644 index 00000000..b089806f --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayController.cs @@ -0,0 +1,501 @@ +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +/// What the gateway is actually doing, as opposed to what was recorded. +internal enum GatewayState +{ + /// No gateway has been started by this installation. + NotStarted, + + /// A live, identity-matched gateway is serving. + Running, + + /// + /// A gateway was recorded but is no longer running. Stopping the session + /// terminates detached work silently, so this is an ordinary outcome rather + /// than a fault. + /// + Stopped, + + /// + /// The recorded process is alive but is not serving, so it is neither + /// usable nor safe to ignore. + /// + Unhealthy, + + /// + /// Liveness could not be established. Reported as unknown rather than + /// guessed: treating it as stopped would invite starting a second gateway + /// beside a healthy one. + /// + Unknown, + Starting, +} + +internal sealed record GatewayStatusReport( + GatewayState State, + GatewayRecord? Record, + string Message, + string? Detail = null); + +internal sealed record GatewayStartResult( + GatewayState State, + GatewayRecord Record, + bool AlreadyRunning, + string Message); + +internal sealed record GatewayStopResult( + bool Stopped, string Message, string? Detail = null, bool Succeeded = true); + +/// +/// Owns this installation's background gateway. +/// +/// +/// Nothing here infers liveness from the record. The record says what was +/// started; only an inspection inside the session says what is running. +/// +internal sealed class GatewayController +{ + private readonly SessionCoordinator _sessions; + private readonly ISessionGatewayClient _client; + private readonly GatewayStateStore _store; + private readonly Func> _startRequestFactory; + private readonly Func _requireSetup; + private readonly ISessionLock _lifecycleLock; + private readonly Action _log; + private readonly TimeProvider _clock; + + public GatewayController( + SessionCoordinator sessions, + ISessionGatewayClient client, + GatewayStateStore store, + Func> startRequestFactory, + Action log, + Func requireSetup, + ISessionLock lifecycleLock, + TimeProvider? clock = null) + { + ArgumentNullException.ThrowIfNull(sessions); + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(startRequestFactory); + ArgumentNullException.ThrowIfNull(log); + + _sessions = sessions; + _client = client; + _store = store; + _startRequestFactory = startRequestFactory; + _requireSetup = requireSetup; + _lifecycleLock = lifecycleLock; + _log = log; + _clock = clock ?? TimeProvider.System; + } + + /// + /// Reports the gateway without starting one, provisioning a session, or + /// writing anything. + /// + public async Task GetStatusAsync( + string helperPath, + CancellationToken cancellationToken) + { + GatewayStateResult state = _store.Read(); + if (state.Record is null) + { + return state.Fault == GatewayStateFault.Missing + ? new GatewayStatusReport( + GatewayState.NotStarted, + null, + "No gateway has been started.") + : new GatewayStatusReport( + GatewayState.Unknown, + null, + "The gateway record could not be used.", + state.Detail); + } + + if (state.Record.LaunchPending) + { + return new GatewayStatusReport(GatewayState.Unknown, state.Record, + "A previous gateway launch has not been confirmed.", + "Do not start a replacement. Inspect diagnostics; `clawctl teardown` removes the owned session."); + } + + SessionStatus session = _sessions.GetRecordedStatus(); + if (session.Record is null) + { + // The gateway cannot outlive the session it runs in, but saying so + // is still a claim about state we have not observed. + return new GatewayStatusReport( + GatewayState.Unknown, + state.Record, + "The gateway's session is no longer recorded.", + session.Detail); + } + + if (!string.Equals(session.Record.SandboxId, state.Record.SandboxId, StringComparison.Ordinal)) + { + return new GatewayStatusReport(GatewayState.Unknown, state.Record, + "The gateway record belongs to a different session.", + "Run `clawctl teardown` to reconcile the owned installation."); + } + + return Describe( + state.Record, + await InspectAsync(session.Record, state.Record, helperPath, cancellationToken) + .ConfigureAwait(false)); + } + + /// + /// Starts the gateway only in a previously configured session. + /// + public async Task StartAsync( + string helperPath, + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + SessionRecord configured = _requireSetup(); + GatewayStateResult existing = _store.Read(); + if (existing.Record?.LaunchPending == true) + { + throw new SessionException( + "A previous gateway launch was not confirmed. Inspect diagnostics before using `clawctl teardown` to remove the owned session."); + } + if (existing.Record is null && existing.Fault != GatewayStateFault.Missing) + { + throw new SessionException($"The gateway record could not be used: {existing.Detail}"); + } + if (existing.Record is not null && + !string.Equals(existing.Record.SandboxId, configured.SandboxId, StringComparison.Ordinal)) + { + throw new SessionException("The gateway record belongs to a different session."); + } + + SessionRecord session = await _sessions + .StartRecordedAsync(cancellationToken) + .ConfigureAwait(false); + + if (existing.Record is not null) + { + SessionInspectResult inspection = await InspectAsync( + session, + existing.Record, + helperPath, + cancellationToken).ConfigureAwait(false); + + if (inspection.IsOwnedAndHealthy) + { + _log("The gateway is already running."); + return new GatewayStartResult( + GatewayState.Running, + existing.Record, + AlreadyRunning: true, + "The gateway is already running."); + } + + if (inspection.Error is not null) + { + // Starting a second gateway beside one we merely failed to + // observe would leave two processes contending for one port. + throw new SessionException( + "The gateway's state could not be established, so a new " + + $"one was not started: {inspection.Error}"); + } + + if (inspection.ProcessFound && inspection.StartTimeMatches) + { + throw new SessionException( + "The owned gateway is alive but not serving. Inspect its log or run " + + "`clawctl gateway-service stop` before starting another."); + } + + _log("The recorded gateway is no longer running; starting a new one."); + } + else if (existing.Fault != GatewayStateFault.Missing) + { + throw new SessionException( + $"The gateway record could not be used: {existing.Detail}"); + } + + GatewayStartRequest request = await _startRequestFactory(cancellationToken).ConfigureAwait(false); + _store.Write(new GatewayRecord + { + SandboxId = session.SandboxId, + LaunchPending = true, + Port = request.Port, + ProcessStartTimeUtc = _clock.GetUtcNow(), + StartedUtc = _clock.GetUtcNow() + }); + GatewayStartOutcome started = await _client + .StartAsync(session, request, cancellationToken) + .ConfigureAwait(false); + + var record = new GatewayRecord + { + SandboxId = session.SandboxId, + ProcessId = started.ProcessId, + ProcessStartTimeUtc = started.ProcessStartTimeUtc, + HelperPath = started.HelperPath, + Port = request.Port, + StatusPath = started.StatusPath, + LogPath = started.LogPath, + StartedUtc = _clock.GetUtcNow(), + + // An explicit earlier choice to disable logon recovery survives a + // restart, so a later manual start does not quietly re-enable it. + AutostartDisabled = existing.Record?.AutostartDisabled ?? false + }; + + _store.Write(record); + + SessionInspectResult observed = await InspectAsync( + session, record, helperPath, cancellationToken).ConfigureAwait(false); + GatewayState resultState = observed.IsOwnedAndHealthy ? GatewayState.Running + : observed.Error is not null ? GatewayState.Unknown + : observed.ProcessFound && observed.StartTimeMatches ? GatewayState.Starting + : GatewayState.Stopped; + + if (observed.ListeningPorts is { Count: > 0 }) + { + record = record with { ObservedPorts = observed.ListeningPorts }; + _store.Write(record); + } + + return new GatewayStartResult( + resultState, + record, + AlreadyRunning: false, + resultState switch + { + GatewayState.Running => $"The gateway is running on {DescribePorts(record)}.", + GatewayState.Starting => "The gateway process started but is not listening yet. Run `clawctl gateway-service status`.", + GatewayState.Unknown => $"The gateway launch could not be verified: {observed.Error}", + _ => DescribeExitedDuringStartup(record, observed) + }); + } + + /// + /// Names the port a user should actually connect to. + /// + /// + /// Prefers what was observed over what was requested, because OpenClaw + /// resolves the port from its own configuration and the observed value is + /// the only one that is true. + /// + internal static string DescribePorts(GatewayRecord record) + { + if (record.ObservedPorts is { Count: > 0 } ports) + { + return ports.Count == 1 + ? $"port {ports[0]}" + : $"ports {string.Join(", ", ports)}"; + } + + return record.Port is int pinned + ? $"port {pinned}" + : "its configured port"; + } + + /// + /// Stops the gateway without removing the session or the user's data. + /// + public async Task StopAsync( + string helperPath, + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + return await StopUnderLockAsync(helperPath, cancellationToken).ConfigureAwait(false); + } + + internal async Task StopUnderLockAsync( + string helperPath, + CancellationToken cancellationToken) + { + GatewayStateResult state = _store.Read(); + if (state.Record?.LaunchPending == true) + { + return new GatewayStopResult(false, + "The previous launch was not confirmed; its ownership intent was retained.", + "Use `clawctl teardown` to stop/deprovision the owned session through MXC.", false); + } + if (state.Record is null) + { + return new GatewayStopResult( + Stopped: false, + "No gateway was recorded, so there was nothing to stop.", + state.Fault == GatewayStateFault.Missing ? null : state.Detail, + state.Fault == GatewayStateFault.Missing); + } + + SessionStatus session = _sessions.GetRecordedStatus(); + if (session.Record is null) + { + return new GatewayStopResult( + Stopped: false, + "The gateway's session is not readable; its record was retained.", + session.Detail, Succeeded: false); + } + + if (!string.Equals(session.Record.SandboxId, state.Record.SandboxId, StringComparison.Ordinal)) + { + return new GatewayStopResult(false, + "The gateway record belongs to a different session.", Succeeded: false); + } + + SessionInspectResult inspection = await InspectAsync( + session.Record, + state.Record, + helperPath, + cancellationToken).ConfigureAwait(false); + + if (inspection.Error is not null) + { + // Nothing is killed on a guess. Acting on an unverified identifier + // could stop an unrelated process that inherited it. + return new GatewayStopResult( + Stopped: false, + "The gateway could not be stopped because its state could not " + + "be established.", + inspection.Error, Succeeded: false); + } + + if (!inspection.ProcessFound || !inspection.StartTimeMatches) + { + _store.Clear(); + return new GatewayStopResult( + Stopped: false, + "The recorded gateway was no longer running, so its record was " + + "removed."); + } + + SessionInspectResult stopped = await _client + .StopAsync(session.Record, state.Record, helperPath, cancellationToken) + .ConfigureAwait(false); + + if (stopped.Error is not null || (stopped.ProcessFound && stopped.StartTimeMatches)) + { + return new GatewayStopResult(false, + "The gateway stop could not be verified; its record was retained.", + stopped.Error, Succeeded: false); + } + _store.Clear(); + return new GatewayStopResult(Stopped: true, "The gateway is stopped."); + } + + private async Task InspectAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) + { + try + { + return await _client + .InspectAsync(session, gateway, helperPath, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is SessionException or Mxc.MxcException or IOException or UnauthorizedAccessException) + { + return new SessionInspectResult { Error = exception.Message }; + } + } + + private static GatewayStatusReport Describe( + GatewayRecord record, + SessionInspectResult inspection) + { + if (inspection.Error is not null) + { + return new GatewayStatusReport( + GatewayState.Unknown, + record, + "The gateway's state could not be established.", + inspection.Error); + } + + if (inspection.IsOwnedAndHealthy) + { + return new GatewayStatusReport( + GatewayState.Running, + record with { ObservedPorts = inspection.ListeningPorts }, + $"The gateway is running on " + + $"{DescribePorts(record with { ObservedPorts = inspection.ListeningPorts })}."); + } + + if (!inspection.ProcessFound || !inspection.StartTimeMatches) + { + return new GatewayStatusReport( + GatewayState.Stopped, + record, + "The gateway is not running.", + inspection.ProcessFound + ? "The recorded process identifier now belongs to an " + + "unrelated process." + : DescribeStoppedGateway(record, inspection)); + } + + return new GatewayStatusReport( + GatewayState.Unhealthy, + record, + "The gateway is running but is not serving.", + DescribeUnhealthy(record, inspection)); + } + + private static string? DescribeStoppedGateway( + GatewayRecord record, + SessionInspectResult inspection) + { + if (string.IsNullOrWhiteSpace(inspection.SupervisorDetail)) + { + return null; + } + + return string.IsNullOrWhiteSpace(record.LogPath) + ? $"The supervisor reported: {inspection.SupervisorDetail}" + : $"The supervisor reported: {inspection.SupervisorDetail} " + + $"Inspect the gateway log at '{record.LogPath}'."; + } + + private static string DescribeExitedDuringStartup( + GatewayRecord record, + SessionInspectResult inspection) => + DescribeStoppedGateway(record, inspection) is string detail + ? $"The gateway process exited during startup. {detail}" + : "The gateway process exited during startup. Inspect its log."; + + /// + /// Explains an unhealthy gateway in terms of what was observed. + /// + /// + /// A pinned port that nothing in our process tree listens on is its own + /// diagnosis: OpenClaw's configuration moved the gateway somewhere else. + /// + private static string DescribeUnhealthy( + GatewayRecord record, SessionInspectResult inspection) + { + if (record.Port is int pinned && + inspection.ListeningPorts is { Count: > 0 } observed && + !observed.Contains(pinned)) + { + return $"The gateway is listening on {string.Join(", ", observed)} " + + $"rather than the configured port {pinned}. Check `gateway.port` " + + "in the agent's OpenClaw configuration."; + } + + if (record.Port is int configured) + { + return inspection.PortListening + ? $"Port {configured} is in use by something that is not the gateway." + : $"The gateway is not listening on port {configured}."; + } + + return "The gateway is not listening on any port. Inspect its log."; + } + + private ISessionLockHandle AcquireLock() => + _lifecycleLock.TryAcquire(SessionCoordinator.DefaultLockTimeout) + ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs new file mode 100644 index 00000000..5c9847b3 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs @@ -0,0 +1,95 @@ +namespace OpenClaw.Launcher.Gateway; + +/// Which mechanism is actually restarting the gateway at logon. +internal enum GatewayPersistenceLane +{ + /// Nothing is configured to restart the gateway. + None, + + /// The registered logon task, which is the intended lane. + TaskScheduler, + + /// + /// The Startup folder. Permitted only when the task could not be + /// registered, and always reported, never a silent substitute. + /// + StartupFolderFallback, +} + +internal enum GatewayPersistenceState +{ + /// Logon recovery is not configured. + NotInstalled, + + /// Logon recovery is configured and matches what this build registers. + Ready, + + /// + /// Something is configured but differs from, or cannot be reconciled with, + /// what this build registers. The user is told which command repairs it. + /// + ActionRequired, + + /// + /// The configured state could not be read. Reported as unknown rather than + /// guessed in either direction. + /// + Unknown, +} + +internal sealed record GatewayPersistenceStatus( + GatewayPersistenceState State, + GatewayPersistenceLane Lane, + string Message, + string? Detail = null, + string? Remediation = null); + +internal sealed record GatewayPersistenceInstallResult( + GatewayPersistenceState State, + GatewayPersistenceLane Lane, + string Message, + bool Changed, + string? Detail = null, + string? Remediation = null); + +internal sealed record GatewayPersistenceRemovalResult( + bool Succeeded, + bool Changed, + string Message, + string? Detail = null); + +/// +/// Everything the persistence manager needs about this user and installation. +/// +/// +/// The owning user's SID. The identifier is the SID and not the account name +/// because Task Scheduler names are machine-wide, SIDs are unique across local, +/// domain, and Entra accounts, and a SID survives an account rename. +/// +/// +/// Scopes the task name to this installation, so the public and internal +/// packages can never overwrite or delete each other's registration. +/// +/// The generated script both lanes invoke. +/// +/// Where the explicitly reported Startup-folder fallback is written. +/// +/// +/// The directory the gateway is given, rather than the system directory a +/// logon task would otherwise inherit. +/// +/// +/// The control command the launcher invokes. Held as configuration so the +/// launcher file can be regenerated without re-registering the task. +/// +/// +/// The absolute path to the inbox command processor the task runs. +/// +internal sealed record GatewayPersistenceOptions( + string UserSid, + string PackageFamilyName, + string LauncherPath, + string StartupFolderPath, + string WorkingDirectory, + string AliasCommand, + string CommandProcessorPath); diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs new file mode 100644 index 00000000..f63bace5 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -0,0 +1,96 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// Assembles gateway management from the running installation. +internal sealed class GatewayRuntime +{ + private GatewayRuntime(GatewayController controller, string helperPath) + { + Controller = controller; + HelperPath = helperPath; + } + + public GatewayController Controller { get; } + + public string HelperPath { get; } + + public static GatewayRuntime Create( + HostOptions options, + Action log, + Func>? resolveNode = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(log); + _ = resolveNode; + + HostPaths paths = HostPaths.Create(); + if (paths.PackageFamilyName is null) + { + throw new SessionException( + "OpenClaw is not running from its installed package, so it has no identity to manage a gateway with."); + } + + SessionRuntime session = SessionRuntime.Create(log); + var configuration = new GatewayConfigurationStore(paths.GatewayConfigurationPath); + Task CreateRequestAsync(CancellationToken cancellationToken) + { + string applicationDirectory = options.PackagedApplicationDirectory + ?? throw new SessionException( + "The packaged OpenClaw application was not found, so the gateway cannot be started."); + SessionRecord sessionRecord = session.RequireSetup(); + GatewayLaunchConfiguration launch = ResolveLaunchConfiguration( + configuration, + sessionRecord, + Environment.GetEnvironmentVariable); + return Task.FromResult(new GatewayStartRequest( + session.HelperPath, + session.RequireAgentNodePath( + options.PackagedNodeArchivePath + ?? throw new SessionException( + "The packaged Node.js runtime archive was not found.")), + applicationDirectory, + launch.WorkingDirectory ?? sessionRecord.WorkspacePath + ?? throw new SessionException( + "The isolated session has no shared workspace for the gateway."), + launch.Port)); + } + + return new GatewayRuntime( + new GatewayController( + session.Coordinator, + new SessionGatewayClient( + session.Backend, + log, + isCurrentRecord: IsCurrentSessionRecord), + new GatewayStateStore(paths.GatewayStatePath), + CreateRequestAsync, + log, + session.RequireSetup, + session.LifecycleLock), + session.HelperPath); + + bool IsCurrentSessionRecord(SessionRecord record) + { + SessionStatus status = session.Coordinator.GetRecordedStatus(); + return status.Record is not null && + string.Equals(status.Record.SandboxId, record.SandboxId, StringComparison.Ordinal) && + string.Equals(status.Record.Generation, record.Generation, StringComparison.Ordinal); + } + } + + internal static GatewayLaunchConfiguration ResolveLaunchConfiguration( + GatewayConfigurationStore configuration, + SessionRecord sessionRecord, + Func environmentVariable) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(sessionRecord); + ArgumentNullException.ThrowIfNull(environmentVariable); + + string workspacePath = sessionRecord.WorkspacePath + ?? throw new SessionException( + "The isolated session has no shared workspace for the gateway."); + return configuration.Resolve(workspacePath, environmentVariable); + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs b/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs new file mode 100644 index 00000000..d266d103 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs @@ -0,0 +1,231 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Gateway; + +/// +/// The durable record of the gateway this installation started. +/// +/// +/// +/// The record is evidence of what was started, never evidence that it is still +/// running. Stopping the session terminates detached work without notifying +/// anything, so a record routinely outlives the process it describes and +/// liveness must be re-established inside the session every time it is claimed. +/// +/// +/// The creation time is stored with the identifier because Windows reuses +/// process identifiers. Acting on the identifier alone would eventually claim, +/// and could be asked to stop, an unrelated process. +/// +/// +internal sealed record GatewayRecord +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// The session the gateway was started in. + [JsonPropertyName("sandboxId")] + public string SandboxId { get; init; } = string.Empty; + + [JsonPropertyName("processId")] + public int ProcessId { get; init; } + + [JsonPropertyName("launchPending")] + public bool LaunchPending { get; init; } + + [JsonPropertyName("processStartTimeUtc")] + public DateTimeOffset ProcessStartTimeUtc { get; init; } + + [JsonPropertyName("helperPath")] + public string? HelperPath { get; init; } + + /// + /// The port this launch pinned, or null when OpenClaw chose it. + /// + /// + /// Recorded rather than inferred, so a later status can tell a pinned port + /// apart from one that was observed. carries + /// what the gateway actually listens on. + /// + [JsonPropertyName("port")] + public int? Port { get; init; } + + /// The ports the running gateway was last observed listening on. + [JsonPropertyName("observedPorts")] + public IReadOnlyList? ObservedPorts { get; init; } + + /// + /// The per-launch status file written inside the session. Its path is + /// unguessable, so a process left behind by an earlier launch cannot write + /// to it and cannot present itself as the current generation. + /// + [JsonPropertyName("statusPath")] + public string? StatusPath { get; init; } + + [JsonPropertyName("logPath")] + public string? LogPath { get; init; } + + [JsonPropertyName("startedUtc")] + public DateTimeOffset StartedUtc { get; init; } + + /// + /// Set when the user explicitly turned logon recovery off, so a later + /// manual start does not quietly turn it back on. + /// + [JsonPropertyName("autostartDisabled")] + public bool AutostartDisabled { get; init; } +} + +/// Why a stored gateway record could not be used. +internal enum GatewayStateFault +{ + /// No gateway has been recorded. + Missing, + + /// The file exists but is not readable as a record. + Unreadable, + + /// The record was written by an incompatible newer version. + UnsupportedSchema, + + /// The record is missing values it cannot be used without. + Incomplete, +} + +internal sealed record GatewayStateResult( + GatewayRecord? Record, + GatewayStateFault? Fault, + string? Detail) +{ + public static GatewayStateResult Loaded(GatewayRecord record) => + new(record, null, null); + + public static GatewayStateResult Failed(GatewayStateFault fault, string detail) => + new(null, fault, detail); +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(GatewayRecord))] +internal sealed partial class GatewayStateJsonContext : JsonSerializerContext; + +/// +/// Reads and writes the gateway record. +/// +internal sealed class GatewayStateStore +{ + public const int CurrentSchemaVersion = 1; + + private readonly string _filePath; + + public GatewayStateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = filePath; + } + + /// + /// Reads the record, distinguishing "no gateway" from "unusable record". + /// + /// + /// A damaged record never degrades to . + /// Starting a replacement over a record we merely failed to read would + /// abandon a running gateway that still holds its port. + /// + public GatewayStateResult Read() + { + string text; + try + { + text = File.ReadAllText(_filePath); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return GatewayStateResult.Failed( + GatewayStateFault.Missing, + "No gateway has been recorded."); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return GatewayStateResult.Failed( + GatewayStateFault.Unreadable, + $"The gateway record could not be read: {exception.Message}"); + } + + GatewayRecord? record; + try + { + record = JsonSerializer.Deserialize( + text, + GatewayStateJsonContext.Default.GatewayRecord); + } + catch (JsonException exception) + { + return GatewayStateResult.Failed( + GatewayStateFault.Unreadable, + $"The gateway record is not valid JSON: {exception.Message}"); + } + + if (record is null) + { + return GatewayStateResult.Failed( + GatewayStateFault.Unreadable, + "The gateway record is empty."); + } + + if (record.SchemaVersion != CurrentSchemaVersion) + { + return GatewayStateResult.Failed( + GatewayStateFault.UnsupportedSchema, + $"The gateway record uses schema version {record.SchemaVersion}, " + + $"which is newer than this installation supports " + + $"({CurrentSchemaVersion}). A newer OpenClaw may be installed."); + } + + if ((!record.LaunchPending && record.ProcessId <= 0) || string.IsNullOrWhiteSpace(record.SandboxId) || + record.ProcessStartTimeUtc == default || record.Port is < 1 or > 65535) + { + return GatewayStateResult.Failed( + GatewayStateFault.Incomplete, + "The gateway record does not identify a process in a session."); + } + + return GatewayStateResult.Loaded(record); + } + + public void Write(GatewayRecord record) + { + ArgumentNullException.ThrowIfNull(record); + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string text = JsonSerializer.Serialize( + record with { SchemaVersion = CurrentSchemaVersion }, + GatewayStateJsonContext.Default.GatewayRecord); + + // Written through a temporary file so an interrupted write cannot leave + // a truncated record that reads as unusable. + string temporaryPath = _filePath + ".tmp"; + File.WriteAllText(temporaryPath, text); + File.Move(temporaryPath, _filePath, overwrite: true); + } + + /// Removes the record. Deleting nothing is success. + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs new file mode 100644 index 00000000..dd004740 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs @@ -0,0 +1,330 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +/// What to start inside the session. +/// +/// is null unless the user pinned one, in which case +/// OpenClaw resolves the port from its own configuration. +/// +internal sealed record GatewayStartRequest( + string HelperPath, + string NodePath, + string ApplicationDirectory, + string WorkingDirectory, + int? Port); + +/// The identity of a gateway that was started. +internal sealed record GatewayStartOutcome( + int ProcessId, + DateTimeOffset ProcessStartTimeUtc, + string StatusPath, + string LogPath) +{ + public string? HelperPath { get; init; } +} + +/// +/// Starts, inspects, and stops the gateway inside the owned session. +/// +internal interface ISessionGatewayClient +{ + Task StartAsync( + SessionRecord session, + GatewayStartRequest request, + CancellationToken cancellationToken); + + Task InspectAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken); + + Task StopAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken); +} + +/// +/// Starts and inspects the gateway inside the owned session. +/// +/// +/// Only controlled paths ever reach the backend's command line. Everything the +/// gateway is actually launched with travels as request data, for the same +/// reason ordinary OpenClaw invocations do: the pinned runtime flattens that +/// command line through cmd.exe. +/// +internal sealed class SessionGatewayClient : ISessionGatewayClient +{ + private readonly IMxcSessionClient _backend; + private readonly Action _log; + private readonly Func> _buildEnvironment; + private readonly Func _isCurrentRecord; + + public SessionGatewayClient(IMxcSessionClient backend, Action log, + Func>? buildEnvironment = null, + Func? isCurrentRecord = null) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(log); + _backend = backend; + _log = log; + _buildEnvironment = buildEnvironment ?? + (() => OpenClawRuntimeEnvironment.Build(GatewayIsolationMode.Enabled)); + _isCurrentRecord = isCurrentRecord ?? (_ => true); + } + + public async Task StartAsync( + SessionRecord session, + GatewayStartRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(request); + + string workspace = RequireWorkspace(session); + string helperPath = SessionHelperStager.RequireStaged( + request.HelperPath, + workspace); + using var operation = new SessionWorkspaceOperation(session, _isCurrentRecord); + + // Each launch gets its own unguessable evidence paths, so a process + // left behind by an earlier launch cannot write to them and cannot make + // itself look like this generation. + string generation = Guid.NewGuid().ToString("N"); + string requestPath = operation.FilePath("gateway", generation); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string statusPath = operation.FilePath("gateway", generation, ".status.json"); + string logPath = operation.FilePath("gateway", generation, ".log"); + + string entryPoint = Path.Combine(request.ApplicationDirectory, "openclaw.mjs"); + + // --port is added only when the user pinned one. Passing a port always + // would outrank `gateway.port` in OpenClaw's own configuration and + // silently move the gateway away from where its clients look. + List arguments = [entryPoint, "gateway", "run"]; + if (request.Port is int port) + { + arguments.Add("--port"); + arguments.Add(port.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + var launch = new SessionLaunchRequest + { + RequestId = generation, + Mode = SessionLaunchMode.Detached, + Executable = request.NodePath, + Arguments = arguments, + WorkingDirectory = request.WorkingDirectory, + Environment = _buildEnvironment(), + PathPrefix = Path.GetDirectoryName(request.NodePath) + ?? throw new SessionException( + "The agent's Node.js runtime has no parent directory."), + LogPath = logPath, + StatusPath = statusPath + }; + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionLaunchProtocol.SerializeRequest(launch), + cancellationToken).ConfigureAwait(false); + + _log(request.Port is int chosen + ? $"Starting the gateway in the isolated session on port {chosen}." + : "Starting the gateway in the isolated session on its configured port."); + + MxcExecutionResult execution = await _backend.ExecuteAsync( + session.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + SessionExecutor.BuildGuestCommandLine(helperPath, requestPath)), + null, + cancellationToken).ConfigureAwait(false); + + SessionLaunchResult result = await ReadLaunchResultAsync( + operation, + resultPath, + execution, + cancellationToken).ConfigureAwait(false); + + if (result.RequestId != generation || execution.ExitCode != 0 || + result.ProcessId is not > 0 || + result.ProcessStartTimeUtc is not { } startTime || startTime == default) + { + // Without both, the process could not be identified again, and a + // record carrying only an identifier would eventually name an + // unrelated process. + throw new SessionException( + "The isolated session started the gateway but did not " + + "report an identifiable process, so it was not recorded."); + } + + return new GatewayStartOutcome(result.ProcessId.Value, startTime, statusPath, logPath) + { + HelperPath = helperPath + }; + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + + /// + /// Ends the recorded gateway inside the session. + /// + /// + /// The guest verifies identity before terminating anything, so a recorded + /// identifier that now belongs to an unrelated process stops nothing. + /// + public Task StopAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) => + SendAsync(session, gateway, helperPath, "--stop", cancellationToken); + + /// + /// Re-establishes, inside the session, whether the recorded gateway is + /// still ours and still serving. + /// + public Task InspectAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) => + SendAsync(session, gateway, helperPath, "--inspect", cancellationToken); + + private async Task SendAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + string option, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(gateway); + + string workspace = RequireWorkspace(session); + string stagedHelperPath = SessionHelperStager.RequireStaged( + helperPath, + workspace); + string requestId = Guid.NewGuid().ToString("N"); + using var operation = new SessionWorkspaceOperation(session, _isCurrentRecord); + string requestPath = operation.FilePath("inspect", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionInspectProtocol.SerializeRequest(new SessionInspectRequest + { + RequestId = requestId, + ProcessId = gateway.ProcessId, + ProcessStartTimeUtc = gateway.ProcessStartTimeUtc, + HelperPath = gateway.HelperPath, + StatusPath = gateway.StatusPath, + Port = gateway.Port + }), + cancellationToken).ConfigureAwait(false); + + MxcExecutionResult execution = await _backend.ExecuteAsync( + session.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + SessionExecutor.BuildGuestCommandLine( + stagedHelperPath, + requestPath, + option)), + null, + cancellationToken).ConfigureAwait(false); + + try + { + SessionInspectResult result = SessionInspectProtocol.ReadResult( + await operation.ReadTextAsync(resultPath, cancellationToken) + .ConfigureAwait(false)); + if (result.RequestId != requestId || execution.ExitCode != 0) + { + return new SessionInspectResult + { + RequestId = requestId, + Error = $"The guest result did not match this invocation (executor exit {execution.ExitCode})." + }; + } + return result; + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or + IOException or UnauthorizedAccessException or SessionLaunchException) + { + // An unanswered request is reported as unknown. Treating it as + // "not running" would invite starting a second gateway beside a + // healthy one, or claiming a stop that never happened. + return new SessionInspectResult + { + RequestId = requestId, + Error = + "The isolated session did not report on the gateway: " + + exception.Message + }; + } + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + + private static string RequireWorkspace(SessionRecord session) => + string.IsNullOrWhiteSpace(session.WorkspacePath) + ? throw new SessionException( + "The recorded session has no shared workspace, so the gateway " + + "cannot be started or inspected.") + : session.WorkspacePath; + + private static async Task ReadLaunchResultAsync( + SessionWorkspaceOperation operation, + string resultPath, + MxcExecutionResult execution, + CancellationToken cancellationToken) + { + string text; + try + { + text = await operation.ReadTextAsync(resultPath, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or IOException) + { + throw new SessionException( + "The isolated session did not report a gateway launch result " + + $"(executor exit code {execution.ExitCode}). " + + Describe(execution)); + } + + SessionLaunchResult result = SessionLaunchProtocol.ReadResult(text); + if (!result.Launched || result.Error is not null) + { + throw new SessionException( + "The gateway could not be started in the isolated session: " + + (result.Error ?? "the session helper reported no detail.")); + } + + return result; + } + + private static string Describe(MxcExecutionResult execution) => + string.IsNullOrWhiteSpace(execution.StandardError) + ? "The session reported no diagnostics." + : $"Session diagnostics: {execution.StandardError.Trim()}"; + +} diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 3ec1658e..20d19130 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -350,6 +350,7 @@ await error.WriteLineAsync( runtime.AcquireLifecycleLock(); await runtime.Coordinator.RemoveAsync(cancellationToken) .ConfigureAwait(false); + runtime.GatewayState.Clear(); await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); return 0; @@ -358,7 +359,46 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") options, GetSessionRuntime(), output, - cancellationToken) + cancellationToken), + GatewayStart = async cancellationToken => + { + Gateway.GatewayStartResult result = await Gateway.GatewayRuntime + .Create(options, log, resolveNode) + .Controller + .StartAsync(GetSessionRuntime().HelperPath, cancellationToken) + .ConfigureAwait(false); + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + return result.State == Gateway.GatewayState.Running ? 0 : 1; + }, + GatewayStatus = async cancellationToken => + { + Gateway.GatewayStatusReport result = await Gateway.GatewayRuntime + .Create(options, log, resolveNode) + .Controller + .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) + .ConfigureAwait(false); + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(result.Detail)) + { + await output.WriteLineAsync(result.Detail).ConfigureAwait(false); + } + return result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted + ? 0 : 1; + }, + GatewayStop = async cancellationToken => + { + Gateway.GatewayStopResult result = await Gateway.GatewayRuntime + .Create(options, log, resolveNode) + .Controller + .StopAsync(GetSessionRuntime().HelperPath, cancellationToken) + .ConfigureAwait(false); + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(result.Detail)) + { + await output.WriteLineAsync(result.Detail).ConfigureAwait(false); + } + return result.Succeeded ? 0 : 1; + } }); InvocationConfiguration configuration = new() diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index b091f923..10ff0ffc 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -1,3 +1,4 @@ +using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Mxc; using OpenClaw.SessionProtocol; @@ -28,6 +29,7 @@ private SessionRuntime( string helperPath, string applicationId, SetupStateStore setupState, + GatewayStateStore gatewayState, string lifecycleLockScope) { Coordinator = coordinator; @@ -36,6 +38,7 @@ private SessionRuntime( HelperPath = helperPath; ApplicationId = applicationId; SetupState = setupState; + GatewayState = gatewayState; LifecycleLock = new NamedSessionLock(lifecycleLockScope); } @@ -55,6 +58,8 @@ private SessionRuntime( public SetupStateStore SetupState { get; } + public GatewayStateStore GatewayState { get; } + public ISessionLock LifecycleLock { get; } public ISessionLockHandle AcquireLifecycleLock() => @@ -134,6 +139,7 @@ internal static SessionRuntime Create( ResolveHelperPath(baseDirectory), applicationId, new SetupStateStore(paths.SetupStatePath), + new GatewayStateStore(paths.GatewayStatePath), paths.SessionStatePath + "_Installation"); bool IsCurrentSessionRecord(SessionRecord record) diff --git a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs index 5eaff088..6f50c3ae 100644 --- a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs +++ b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; @@ -21,6 +22,7 @@ internal sealed class WindowsKillOnCloseJob : IDisposable private const int StandardOutputHandle = -11; private const int StandardErrorHandle = -12; private readonly SafeJobHandle _handle; + private static readonly SearchValues QuoteCharacters = SearchValues.Create(" \t\r\n\v\""); private WindowsKillOnCloseJob(SafeJobHandle handle) { @@ -76,7 +78,14 @@ public static WindowsKillOnCloseJob Create() } } - public Process StartProcess(ProcessStartInfo startInfo) + public Process StartProcess(ProcessStartInfo startInfo) => StartProcess(startInfo, null, null, null); + + public Process StartProcess( + ProcessStartInfo startInfo, + SafeHandle? input, + SafeHandle? output, + SafeHandle? standardError, + bool assignToJob = true) { ArgumentNullException.ThrowIfNull(startInfo); if (startInfo.UseShellExecute) @@ -103,14 +112,23 @@ public Process StartProcess(ProcessStartInfo startInfo) char[] commandLineBuffer = new char[commandLine.Length + 1]; commandLine.CopyTo(0, commandLineBuffer, 0, commandLine.Length); + using SafeFileHandle? inputCopy = DuplicateForChild(input, StandardInputHandle); + using SafeFileHandle? outputCopy = DuplicateForChild(output, StandardOutputHandle); + using SafeFileHandle? errorCopy = DuplicateForChild(standardError, StandardErrorHandle); + using var handles = new InheritedHandles( + [inputCopy?.DangerousGetHandle() ?? 0, outputCopy?.DangerousGetHandle() ?? 0, errorCopy?.DangerousGetHandle() ?? 0]); IntPtr environment = BuildEnvironmentBlock(startInfo); - var startupInfo = new StartupInfo + var startupInfo = new ExtendedStartupInfo { - Size = Marshal.SizeOf(), - Flags = StartfUseStdHandles, - StandardInput = GetStdHandle(StandardInputHandle), - StandardOutput = GetStdHandle(StandardOutputHandle), - StandardError = GetStdHandle(StandardErrorHandle) + Startup = new StartupInfo + { + Size = handles.Attributes != 0 ? Marshal.SizeOf() : Marshal.SizeOf(), + Flags = StartfUseStdHandles, + StandardInput = inputCopy?.DangerousGetHandle() ?? 0, + StandardOutput = outputCopy?.DangerousGetHandle() ?? 0, + StandardError = errorCopy?.DangerousGetHandle() ?? 0 + }, + Attributes = handles.Attributes }; try @@ -120,8 +138,10 @@ public Process StartProcess(ProcessStartInfo startInfo) commandLineBuffer, IntPtr.Zero, IntPtr.Zero, - inheritHandles: true, - CreateSuspended | CreateUnicodeEnvironment, + inheritHandles: handles.Attributes != 0, + CreateSuspended | CreateUnicodeEnvironment | + (handles.Attributes != 0 ? 0x00080000u : 0u) | + (startInfo.CreateNoWindow ? 0x08000000u : 0u), environment, string.IsNullOrWhiteSpace(startInfo.WorkingDirectory) ? null @@ -136,7 +156,8 @@ public Process StartProcess(ProcessStartInfo startInfo) try { - if (!AssignProcessToJobObject( + if (assignToJob && + !AssignProcessToJobObject( _handle, processInformation.Process)) { @@ -175,6 +196,104 @@ public Process StartProcess(ProcessStartInfo startInfo) public void Dispose() => _handle.Dispose(); + private static SafeFileHandle? DuplicateForChild(SafeHandle? handle, int standardHandle) + { + using var borrowed = new SafeFileHandle(GetStdHandle(standardHandle), ownsHandle: false); + SafeHandle source = handle ?? borrowed; + if (source.IsInvalid) + { + return null; + } + if (!DuplicateHandle(new IntPtr(-1), source, new IntPtr(-1), out SafeFileHandle copy, 0, true, 2)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Unable to duplicate the child's stream handle."); + } + return copy; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DuplicateHandle( + IntPtr sourceProcess, SafeHandle source, IntPtr targetProcess, + out SafeFileHandle target, uint access, [MarshalAs(UnmanagedType.Bool)] bool inherit, uint options); + + [StructLayout(LayoutKind.Sequential)] + private struct ExtendedStartupInfo + { + public StartupInfo Startup; + public IntPtr Attributes; + } + + private sealed class InheritedHandles : IDisposable + { + private IntPtr _values; + public IntPtr Attributes { get; private set; } + + public InheritedHandles(IntPtr[] handles) + { + IntPtr[] valid = [.. handles.Where(handle => handle != 0 && handle != new IntPtr(-1)).Distinct()]; + if (valid.Length == 0) + { + return; + } + nuint size = 0; + InitializeProcThreadAttributeList(0, 1, 0, ref size); + if (size == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + IntPtr attributes = Marshal.AllocHGlobal(checked((nint)size)); + if (!InitializeProcThreadAttributeList(attributes, 1, 0, ref size)) + { + int error = Marshal.GetLastWin32Error(); + Marshal.FreeHGlobal(attributes); + throw new Win32Exception(error); + } + Attributes = attributes; + try + { + _values = Marshal.AllocHGlobal(valid.Length * IntPtr.Size); + Marshal.Copy(valid, 0, _values, valid.Length); + // Restrict inheritance to this launch's stdio. Otherwise a + // concurrent child can inherit another gateway's log/pipe. + if (!UpdateProcThreadAttribute(Attributes, 0, 0x00020002, _values, + (nuint)(valid.Length * IntPtr.Size), 0, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + catch + { + Dispose(); + throw; + } + } + + public void Dispose() + { + if (Attributes != 0) + { + DeleteProcThreadAttributeList(Attributes); + Marshal.FreeHGlobal(Attributes); + Attributes = 0; + } + Marshal.FreeHGlobal(_values); + _values = 0; + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, uint flags, ref nuint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + IntPtr list, uint flags, nuint attribute, IntPtr value, nuint size, IntPtr previous, IntPtr returnedSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr list); + private static string BuildCommandLine(ProcessStartInfo startInfo) { var commandLine = new StringBuilder(); @@ -190,6 +309,13 @@ private static string BuildCommandLine(ProcessStartInfo startInfo) private static void AppendArgument(StringBuilder commandLine, string argument) { + // Match ProcessStartInfo.ArgumentList: quoting tokens that do not need + // it can change the behavior of executables with non-CRT parsers. + if (argument.Length > 0 && argument.AsSpan().IndexOfAny(QuoteCharacters) < 0) + { + commandLine.Append(argument); + return; + } commandLine.Append('"'); int backslashes = 0; foreach (char character in argument) @@ -275,7 +401,7 @@ private static extern bool CreateProcessW( uint creationFlags, IntPtr environment, string? currentDirectory, - ref StartupInfo startupInfo, + ref ExtendedStartupInfo startupInfo, out ProcessInformation processInformation); [DllImport("kernel32.dll", SetLastError = true)] diff --git a/src/OpenClaw.SessionHost/GuestProcessObserver.cs b/src/OpenClaw.SessionHost/GuestProcessObserver.cs new file mode 100644 index 00000000..8f9be777 --- /dev/null +++ b/src/OpenClaw.SessionHost/GuestProcessObserver.cs @@ -0,0 +1,368 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace OpenClaw.SessionHost; + +/// +/// Maps a listening TCP port to the process that owns it. +/// +/// +/// IPGlobalProperties.GetActiveTcpListeners reports ports but not their +/// owners, and an open port with an unknown owner proves nothing: any program +/// in the session could have opened it. The extended table is the only way to +/// ask who is actually listening. +/// +internal static class TcpListenerOwnership +{ + private const int AfInet = 2; + private const int AfInet6 = 23; + private const int TcpTableOwnerPidListener = 3; + private const uint InsufficientBuffer = 122; + + /// + /// Returns the identifiers of processes listening on . + /// + public static IReadOnlyList GetListenerProcessIds(int port) + { + List owners = []; + foreach ((int rowPort, int owner) in GetListeners()) + { + if (rowPort == port && !owners.Contains(owner)) + { + owners.Add(owner); + } + } + + return owners; + } + + /// + /// Every listening port and the process that owns it. + /// + /// + /// Enumerating the whole table is what lets a caller ask which port a known + /// process listens on, rather than only whether it listens on a port the + /// caller already guessed. The gateway's port is chosen by OpenClaw from + /// its own configuration, so guessing it here would be a second opinion + /// about a value this package does not own. + /// + public static IReadOnlyList<(int Port, int Owner)> GetListeners() + { + List<(int Port, int Owner)> listeners = []; + Collect(AfInet, listeners); + Collect(AfInet6, listeners); + return listeners; + } + + private static void Collect(int addressFamily, List<(int Port, int Owner)> listeners) + { + uint size = 0; + uint status = GetExtendedTcpTable( + IntPtr.Zero, + ref size, + order: false, + addressFamily, + TcpTableOwnerPidListener, + reserved: 0); + + if (status != InsufficientBuffer && status != 0) + { + throw new System.ComponentModel.Win32Exception((int)status, "Unable to query TCP listener ownership."); + } + if (size == 0) + { + return; + } + + IntPtr buffer = Marshal.AllocHGlobal((int)size); + try + { + status = GetExtendedTcpTable( + buffer, + ref size, + order: false, + addressFamily, + TcpTableOwnerPidListener, + reserved: 0); + + if (status != 0) + { + throw new System.ComponentModel.Win32Exception((int)status, "Unable to read TCP listener ownership."); + } + + int count = Marshal.ReadInt32(buffer); + int rowSize = addressFamily == AfInet + ? Marshal.SizeOf() + : Marshal.SizeOf(); + IntPtr row = buffer + 4; + + for (int index = 0; index < count; index++) + { + (int rowPort, int owner) = addressFamily == AfInet + ? Read(Marshal.PtrToStructure(row)) + : Read(Marshal.PtrToStructure(row)); + + if (!listeners.Contains((rowPort, owner))) + { + listeners.Add((rowPort, owner)); + } + + row += rowSize; + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static (int Port, int Owner) Read(TcpRowOwnerPid row) => + (HostPort(row.LocalPort), row.OwningProcessId); + + private static (int Port, int Owner) Read(Tcp6RowOwnerPid row) => + (HostPort(row.LocalPort), row.OwningProcessId); + + /// + /// The table stores the port in network byte order inside a 32-bit field, + /// so the two low bytes have to be swapped. + /// + private static int HostPort(uint value) => + (int)(((value & 0xFF) << 8) | ((value & 0xFF00) >> 8)); + + [DllImport("iphlpapi.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.U4)] + private static extern uint GetExtendedTcpTable( + IntPtr table, + ref uint size, + [MarshalAs(UnmanagedType.Bool)] bool order, + int addressFamily, + int tableClass, + int reserved); + + [StructLayout(LayoutKind.Sequential)] + private struct TcpRowOwnerPid + { + public uint State; + public uint LocalAddress; + public uint LocalPort; + public uint RemoteAddress; + public uint RemotePort; + public int OwningProcessId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Tcp6RowOwnerPid + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] LocalAddress; + public uint LocalScopeId; + public uint LocalPort; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] RemoteAddress; + public uint RemoteScopeId; + public uint RemotePort; + public uint State; + public int OwningProcessId; + } +} + +/// +/// Answers whether a process is a descendant of another. +/// +internal static class ProcessAncestry +{ + private const int Th32CsSnapProcess = 2; + + /// + /// True when is or + /// is descended from it. + /// + /// + /// The gateway is a child of the supervisor rather than the supervisor + /// itself, so an ownership check that only compared identifiers would + /// reject every real gateway. + /// + public static bool IsSelfOrDescendant(int candidate, int ancestor) + { + if (candidate == ancestor) + { + return true; + } + + Dictionary parents = ReadParents(); + int current = candidate; + var seen = new HashSet(); + + // Bounded by the set of visited identifiers, because the snapshot can + // contain a cycle once identifiers are reused. + while (parents.TryGetValue(current, out int parent) && seen.Add(current)) + { + DateTimeOffset? childStart = GuestProcessObserver.GetStartTimeUtc(current); + DateTimeOffset? parentStart = GuestProcessObserver.GetStartTimeUtc(parent); + if (childStart is null || parentStart is null || parentStart > childStart) + { + return false; + } + if (parent == ancestor) + { + return true; + } + + current = parent; + } + + return false; + } + + private static Dictionary ReadParents() + { + Dictionary parents = []; + IntPtr snapshot = CreateToolhelp32Snapshot(Th32CsSnapProcess, 0); + if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), + "Unable to inspect the gateway process tree."); + } + + try + { + ProcessEntry32 entry = default; + entry.Size = Marshal.SizeOf(); + + if (!Process32FirstW(snapshot, ref entry)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), + "Unable to read the gateway process tree."); + } + + do + { + parents[entry.ProcessId] = entry.ParentProcessId; + } + while (Process32NextW(snapshot, ref entry)); + } + finally + { + CloseHandle(snapshot); + } + + return parents; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(int flags, int processId); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32FirstW( + IntPtr snapshot, + ref ProcessEntry32 entry); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32NextW( + IntPtr snapshot, + ref ProcessEntry32 entry); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry32 + { + public int Size; + public int Usage; + public int ProcessId; + public IntPtr DefaultHeapId; + public int ModuleId; + public int Threads; + public int ParentProcessId; + public int PriorityClassBase; + public int Flags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string ExeFile; + } +} + +/// +/// Observes a recorded gateway from inside the session. +/// +internal static class GuestProcessObserver +{ + /// + /// Returns the process's creation time, or null when no such process + /// exists or it cannot be examined. + /// + public static DateTimeOffset? GetStartTimeUtc(int processId) + { + try + { + using Process process = Process.GetProcessById(processId); + return process.StartTime.ToUniversalTime(); + } + catch (Exception exception) when ( + exception is ArgumentException or InvalidOperationException or + System.ComponentModel.Win32Exception or NotSupportedException) + { + return null; + } + } + + /// + /// True when the recorded supervisor, or a descendant of it, is listening + /// on the port. + /// + public static bool OwnsListenerOn(int port, int supervisorProcessId) + { + if (port <= 0) + { + return false; + } + + foreach (int owner in TcpListenerOwnership.GetListenerProcessIds(port)) + { + if (ProcessAncestry.IsSelfOrDescendant(owner, supervisorProcessId)) + { + return true; + } + } + + return false; + } + + /// + /// The ports the recorded supervisor, or a descendant of it, listens on. + /// + /// + /// OpenClaw chooses the gateway's port from its own configuration, so this + /// package observes the result rather than dictating it. Discovering the + /// port from the process is also the stronger ownership proof: a port this + /// package supplied only ever confirmed its own assumption. + /// + public static IReadOnlyList ListeningPortsOwnedBy(int supervisorProcessId) + { + List ports = []; + foreach ((int port, int owner) in TcpListenerOwnership.GetListeners()) + { + if (!ports.Contains(port) && + ProcessAncestry.IsSelfOrDescendant(owner, supervisorProcessId)) + { + ports.Add(port); + } + } + + ports.Sort(); + return ports; + } + + public static bool AnythingListeningOn(int port) + { + if (port <= 0) + { + return false; + } + + return TcpListenerOwnership.GetListenerProcessIds(port).Count > 0; + } +} diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs index fa540af0..326494a8 100644 --- a/src/OpenClaw.SessionHost/Program.cs +++ b/src/OpenClaw.SessionHost/Program.cs @@ -23,33 +23,29 @@ internal static int Run( Func readFile, Action writeResult) { - if (args.Count == 2 && args[0] == "--install-runtime") - { - return SessionRuntimeInstaller.Run( - args[1], - readFile, - File.WriteAllText); - } - - if (args.Count == 2 && args[0] == "--install-tools") - { - return SessionToolInstaller.Run( - args[1], - readFile, - File.WriteAllText); - } - - if (args.Count != 2 || args[0] != "--request") + if (!TryGetMode(args, out string? mode, out string? requestPath)) { // No request path means no control file to report through, so this // is the one failure that can only surface on stderr. errorOutput.WriteLine( "openclaw-session-host: usage: openclaw-session-host " + - "--request|--install-runtime|--install-tools "); + "--request|--supervise|--inspect|--stop|--install-runtime|--install-tools "); return SessionLaunchProtocol.HelperFailureExitCode; } - string requestPath = args[1]; + switch (mode) + { + case "--supervise": + return SessionSupervisor.Run(requestPath, readFile, File.WriteAllText); + case "--inspect": + return SessionInspector.Run(requestPath, readFile, File.WriteAllText); + case "--stop": + return SessionTerminator.Run(requestPath, readFile, File.WriteAllText); + case "--install-runtime": + return SessionRuntimeInstaller.Run(requestPath, readFile, File.WriteAllText); + case "--install-tools": + return SessionToolInstaller.Run(requestPath, readFile, File.WriteAllText); + } string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); string? requestId = null; @@ -59,12 +55,34 @@ internal static int Run( SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest(readFile(requestPath)); requestId = request.RequestId; - if (request.Mode != SessionLaunchMode.Attached) + if (request.Mode is not SessionLaunchMode.Attached and + not SessionLaunchMode.Detached) { throw new SessionLaunchException( $"Launch mode '{request.Mode}' is not supported by this helper."); } + if (request.Mode == SessionLaunchMode.Detached) + { + SessionDetachedProcess detached = launcher.Start( + request, + System.Environment.ProcessPath + ?? throw new SessionLaunchException( + "The session helper cannot determine its own path, " + + "so it cannot start a supervised process.")); + + writeResult( + resultPath, + new SessionLaunchResult + { + RequestId = requestId, + Launched = true, + ProcessId = detached.ProcessId, + ProcessStartTimeUtc = detached.StartTimeUtc + }); + return 0; + } + int exitCode = launcher.Run(request); writeResult( resultPath, @@ -86,6 +104,32 @@ exception is SessionLaunchException or IOException or } } + private static bool TryGetMode( + IReadOnlyList args, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? mode, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? requestPath) + { + mode = null; + requestPath = null; + + // Exactly one option and one path. The helper must never grow into a + // general-purpose runner reachable from inside the session. + if (args.Count != 2 || string.IsNullOrWhiteSpace(args[1])) + { + return false; + } + + if (args[0] is not ("--request" or "--supervise" or "--inspect" or "--stop" or + "--collect" or "--install-runtime" or "--install-tools")) + { + return false; + } + + mode = args[0]; + requestPath = args[1]; + return true; + } + private static void TryWriteFailure( Action writeResult, string resultPath, diff --git a/src/OpenClaw.SessionHost/SessionInspector.cs b/src/OpenClaw.SessionHost/SessionInspector.cs new file mode 100644 index 00000000..582296b6 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionInspector.cs @@ -0,0 +1,161 @@ +using System.ComponentModel; +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The --inspect mode: re-establishes whether a recorded gateway is +/// still ours and still serving. +/// +/// +/// Every answer is an observation made now, inside the session. Nothing is +/// inferred from the recorded state itself, because a record survives the +/// process it describes: stopping the sandbox terminates detached work without +/// notifying anything. +/// +internal static class SessionInspector +{ + public static int Run( + string requestPath, + Func readFile, + Action writeFile) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + SessionInspectRequest request; + + try + { + request = SessionInspectProtocol.ReadRequest(readFile(requestPath)); + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + Write( + writeFile, + resultPath, + new SessionInspectResult { Error = exception.Message }); + return SessionLaunchProtocol.HelperFailureExitCode; + } + + Write(writeFile, resultPath, Inspect(request, readFile)); + return 0; + } + + internal static SessionInspectResult Inspect( + SessionInspectRequest request, + Func readFile) + { + try + { + using Process process = Process.GetProcessById(request.ProcessId); + _ = process.Handle; + if (process.HasExited) + { + return NotFound(request, readFile); + } + bool matches = process.StartTime.ToUniversalTime() == request.ProcessStartTimeUtc; + string? error = matches ? ValidateImage(process, request) : null; + SessionSupervisorStatus? supervisor = ReadSupervisorStatus(request.StatusPath, readFile); + + // Ownership is established by finding a listener belonging to this + // process tree, not by confirming a port the host supplied. When + // the user pinned a port, it is additionally checked against what + // was observed, so a gateway on the wrong port is not reported as + // healthy. + IReadOnlyList owned = matches && error is null + ? GuestProcessObserver.ListeningPortsOwnedBy(request.ProcessId) + : []; + bool ownsConfigured = request.Port is not int configured || + owned.Contains(configured); + + return new SessionInspectResult + { + RequestId = request.RequestId, + ProcessFound = true, + StartTimeMatches = matches, + SupervisorState = supervisor?.State, + SupervisorDetail = supervisor?.Detail, + PortListening = request.Port is int probe + ? GuestProcessObserver.AnythingListeningOn(probe) + : owned.Count > 0, + ListeningPorts = owned, + ListenerOwned = owned.Count > 0 && ownsConfigured, + Error = error + }; + } + catch (ArgumentException) + { + return NotFound(request, readFile); + } + catch (Exception exception) when ( + exception is Win32Exception or InvalidOperationException or NotSupportedException) + { + return new SessionInspectResult { RequestId = request.RequestId, Error = exception.Message }; + } + } + + internal static string? ValidateImage(Process process, SessionInspectRequest request) + { + if (string.IsNullOrWhiteSpace(request.HelperPath)) + { + return "The recorded helper image is missing; ownership cannot be verified."; + } + return string.Equals(process.MainModule?.FileName, request.HelperPath, StringComparison.OrdinalIgnoreCase) + ? null : "The process image differs from the recorded gateway helper."; + } + + private static SessionInspectResult NotFound( + SessionInspectRequest request, + Func readFile) + { + SessionSupervisorStatus? supervisor = ReadSupervisorStatus(request.StatusPath, readFile); + return new SessionInspectResult + { + RequestId = request.RequestId, + SupervisorState = supervisor?.State, + SupervisorDetail = supervisor?.Detail + }; + } + + private static SessionSupervisorStatus? ReadSupervisorStatus( + string? statusPath, + Func readFile) + { + if (string.IsNullOrWhiteSpace(statusPath)) + { + return null; + } + + try + { + return SessionInspectProtocol.ReadStatus(readFile(statusPath)); + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + // A missing or unreadable status file is reported as no state, + // never as a failed inspection: the process evidence stands on its + // own. + return null; + } + } + + private static void Write( + Action writeFile, + string resultPath, + SessionInspectResult result) + { + try + { + writeFile(resultPath, SessionInspectProtocol.SerializeResult(result)); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + // The host reports a missing result as an unanswered inspection. + } + } +} diff --git a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs index e67f5471..fdc0dae8 100644 --- a/src/OpenClaw.SessionHost/SessionProcessLauncher.cs +++ b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs @@ -12,6 +12,11 @@ internal interface ISessionProcessLauncher /// Runs the request to completion and returns its exit code. int Run(SessionLaunchRequest request); + /// + /// Starts the request without waiting and returns the identity of the + /// process that supervises it. + /// + SessionDetachedProcess Start(SessionLaunchRequest request, string helperPath); } /// @@ -22,25 +27,14 @@ internal interface ISessionProcessLauncher /// process identifiers. An identifier alone would eventually name an unrelated /// process, which the gateway would then claim and could be asked to stop. /// +internal sealed record SessionDetachedProcess(int ProcessId, DateTimeOffset StartTimeUtc); + /// /// Launches the requested executable shell-free, so no quoting, metacharacter, /// or %VAR% interpretation can alter the arguments. /// internal sealed class SessionProcessLauncher : ISessionProcessLauncher { - internal static void PrependPath(ProcessStartInfo startInfo, string? runtimeDirectory) - { - if (string.IsNullOrEmpty(runtimeDirectory)) - { - return; - } - - string inheritedPath = startInfo.Environment["PATH"] ?? string.Empty; - startInfo.Environment["PATH"] = string.IsNullOrEmpty(inheritedPath) - ? runtimeDirectory - : $"{runtimeDirectory};{inheritedPath}"; - } - public int Run(SessionLaunchRequest request) { string workingDirectory = request.WorkingDirectory!; @@ -74,6 +68,8 @@ public int Run(SessionLaunchRequest request) } PrependPath(startInfo, Path.GetDirectoryName(request.Executable)); + PrependPath(startInfo, request.PathPrefix); + using Process process = new() { StartInfo = startInfo }; try { @@ -92,4 +88,141 @@ InvalidOperationException or return process.ExitCode; } + /// + /// Puts a directory at the front of the child's PATH. + /// + /// + /// + /// Only the guest can do this. The host supplies environment values that + /// are merged onto this account's own environment, and it has no way to + /// know what that account's PATH contains, so it names the directory + /// and the resolution happens here. + /// + /// + /// Prepending is the point: the packaged runtime has to win over any + /// machine-wide Node.js for tools that resolve node or npm by + /// name rather than by the path this package hands them. + /// + /// + internal static void PrependPath(ProcessStartInfo startInfo, string? directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return; + } + + startInfo.Environment.TryGetValue("PATH", out string? inherited); + startInfo.Environment["PATH"] = string.IsNullOrEmpty(inherited) + ? directory + : $"{directory}{Path.PathSeparator}{inherited}"; + } + + /// + /// Starts a detached launch by re-running this helper as a supervisor. + /// + /// + /// + /// The supervisor exists so that something owns the application's output. + /// A detached process started directly would inherit the handles of the + /// execution that launched it, and those close the moment that execution + /// returns; the application would then be writing into a dead pipe. The + /// supervisor instead opens the log itself and redirects the application + /// into it. + /// + /// + /// It is also the process whose identity is recorded. Ownership is then a + /// claim about a process this package controls, and the listener check can + /// ask whether the gateway port belongs to it or one of its descendants. + /// + /// + public SessionDetachedProcess Start(SessionLaunchRequest request, string helperPath) + { + string requestPath = SessionSupervisor.RequestPathFor(request.StatusPath!); + File.WriteAllText(requestPath, SessionLaunchProtocol.SerializeRequest(request)); + + ProcessStartInfo startInfo = new() + { + FileName = helperPath, + UseShellExecute = false, + + // No console of its own: at logon there is no desktop to show it on, + // and a window would appear over whatever the user is doing. + CreateNoWindow = true, + WorkingDirectory = request.WorkingDirectory! + }; + + startInfo.ArgumentList.Add("--supervise"); + startInfo.ArgumentList.Add(requestPath); + + Process process; + try + { + using FileStream input = new( + "NUL", + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite); + using FileStream output = new( + "NUL", + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite); + using FileStream error = new( + "NUL", + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite); + using WindowsKillOnCloseJob job = WindowsKillOnCloseJob.Create(); + process = job.StartProcess( + startInfo, + input.SafeFileHandle, + output.SafeFileHandle, + error.SafeFileHandle, + assignToJob: false); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + ArgumentException or + IOException or + InvalidOperationException or + PlatformNotSupportedException) + { + throw new SessionLaunchException( + $"Unable to start the gateway supervisor '{helperPath}': " + + exception.Message); + } + + using (process) + { + try + { + return new SessionDetachedProcess(process.Id, process.StartTime.ToUniversalTime()); + } + catch (Exception exception) when ( + exception is InvalidOperationException or + System.ComponentModel.Win32Exception) + { + // Without a creation time the process cannot be identified later, + // and the listener check could claim an unrelated process. + TryKill(process); + throw new SessionLaunchException( + "The gateway supervisor could not be identified after it " + + $"started, so it was stopped: {exception.Message}"); + } + } + } + + private static void TryKill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception exception) when ( + exception is InvalidOperationException or + System.ComponentModel.Win32Exception or + NotSupportedException) + { + } + } } diff --git a/src/OpenClaw.SessionHost/SessionSupervisor.cs b/src/OpenClaw.SessionHost/SessionSupervisor.cs new file mode 100644 index 00000000..3427a240 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionSupervisor.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The --supervise mode: owns one detached application and its log. +/// +/// +/// This process is deliberately long-lived and is the identity the host +/// records. It is not a host-side supervisor: it runs inside the session +/// alongside the application, so nothing on the host has to stay alive for the +/// gateway to keep running. +/// +internal static class SessionSupervisor +{ + public static string RequestPathFor(string statusPath) => + statusPath + ".request.json"; + + public static int Run( + string requestPath, + Func readFile, + Action writeFile) + { + SessionLaunchRequest request; + try + { + request = SessionLaunchProtocol.ReadRequest(readFile(requestPath)); + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + // Nothing is running yet and there is no console to complain to, + // so there is nowhere to report this beyond the exit code. + return SessionLaunchProtocol.HelperFailureExitCode; + } + + string statusPath = request.StatusPath!; + string logPath = request.LogPath!; + + try + { + Directory.CreateDirectory( + Path.GetDirectoryName(logPath) + ?? throw new SessionLaunchException( + $"The log path has no parent directory: {logPath}")); + + using FileStream log = new( + logPath, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite); + return Supervise(request, statusPath, log, writeFile); + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException or System.ComponentModel.Win32Exception) + { + WriteStatus( + writeFile, + statusPath, + request, + SessionSupervisorStatus.ExitedState, + exception.Message); + return SessionLaunchProtocol.HelperFailureExitCode; + } + finally + { + TryDelete(requestPath); + } + } + + private static int Supervise( + SessionLaunchRequest request, + string statusPath, + FileStream log, + Action writeFile) + { + ProcessStartInfo startInfo = new() + { + FileName = request.Executable!, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = request.WorkingDirectory! + }; + + foreach (string argument in request.Arguments!) + { + startInfo.ArgumentList.Add(argument); + } + + foreach ((string name, string value) in + request.Environment ?? new Dictionary()) + { + startInfo.Environment[name] = value; + } + SessionProcessLauncher.PrependPath(startInfo, request.PathPrefix); + + // The shared launcher creates suspended, assigns the job, then resumes. + // Assigning after Process.Start leaves a window for orphan descendants. + using WindowsKillOnCloseJob job = WindowsKillOnCloseJob.Create(); + using var input = new FileStream("NUL", FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using Process process = job.StartProcess(startInfo, input.SafeFileHandle, log.SafeFileHandle, log.SafeFileHandle); + + // Written only once the application is actually running, so a status of + // 'running' is evidence rather than intent. + WriteStatus( + writeFile, + statusPath, + request, + SessionSupervisorStatus.RunningState, + $"supervising process {process.Id}"); + + process.WaitForExit(); + + WriteStatus( + writeFile, + statusPath, + request, + SessionSupervisorStatus.ExitedState, + $"the application exited with code {process.ExitCode}"); + return process.ExitCode; + } + + private static void WriteStatus( + Action writeFile, + string statusPath, + SessionLaunchRequest request, + string state, + string detail) + { + try + { + writeFile( + statusPath, + SessionInspectProtocol.SerializeStatus(new SessionSupervisorStatus + { + State = state, + ProcessId = System.Environment.ProcessId, + Port = 0, + UpdatedAtUtc = DateTimeOffset.UtcNow, + Detail = detail + })); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + // The status file is corroborating evidence, not the gateway's + // reason to exist. + } + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + } + } +} diff --git a/src/OpenClaw.SessionHost/SessionTerminator.cs b/src/OpenClaw.SessionHost/SessionTerminator.cs new file mode 100644 index 00000000..27229f57 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionTerminator.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The --stop mode: ends a gateway this installation started. +/// +/// +/// Identity is verified before anything is terminated. Windows reuses process +/// identifiers, so acting on a recorded identifier alone would eventually kill +/// an unrelated process that merely inherited it. +/// +internal static class SessionTerminator +{ + public static int Run( + string requestPath, + Func readFile, + Action writeFile) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + SessionInspectRequest request; + + try + { + request = SessionInspectProtocol.ReadRequest(readFile(requestPath)); + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + Write( + writeFile, + resultPath, + new SessionInspectResult { Error = exception.Message }); + return SessionLaunchProtocol.HelperFailureExitCode; + } + + Write(writeFile, resultPath, Stop(request, readFile)); + return 0; + } + + internal static SessionInspectResult Stop( + SessionInspectRequest request, + Func readFile) + { + try + { + using Process process = Process.GetProcessById(request.ProcessId); + _ = process.Handle; + if (process.HasExited) + { + return new SessionInspectResult { RequestId = request.RequestId }; + } + if (process.StartTime.ToUniversalTime() != request.ProcessStartTimeUtc) + { + return new SessionInspectResult { RequestId = request.RequestId, ProcessFound = true }; + } + string? error = SessionInspector.ValidateImage(process, request); + if (error is not null) + { + return new SessionInspectResult + { + RequestId = request.RequestId, + ProcessFound = true, + StartTimeMatches = true, + Error = error + }; + } + + // Kill the same verified handle, not a reopened PID. Its job owns + // descendant lifetime; do not enumerate arbitrary reused child IDs. + process.Kill(); + if (!process.WaitForExit(30_000)) + { + return new SessionInspectResult { RequestId = request.RequestId, Error = "The gateway did not exit." }; + } + } + catch (ArgumentException) + { + // It exited between the observation and the kill, which is the + // requested end state. + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or NotSupportedException or InvalidOperationException) + { + return new SessionInspectResult + { + RequestId = request.RequestId, + Error = $"The gateway could not be stopped: {exception.Message}" + }; + } + + return SessionInspector.Inspect(request, readFile); + } + + private static void Write( + Action writeFile, + string resultPath, + SessionInspectResult result) + { + try + { + writeFile(resultPath, SessionInspectProtocol.SerializeResult(result)); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + } + } +} diff --git a/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs b/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs new file mode 100644 index 00000000..931f802e --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs @@ -0,0 +1,260 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// +/// Asks the guest whether a recorded gateway is still the process we started. +/// +/// +/// The backend offers no authoritative process enumeration and nothing can be +/// reattached across an execution boundary, so liveness has to be re-established +/// from inside the session every time it is claimed. +/// +public sealed record SessionInspectRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// The recorded supervising process. + [JsonPropertyName("processId")] + public int ProcessId { get; init; } + + /// + /// The recorded creation time. A live process whose creation time differs + /// is an unrelated process that inherited a reused identifier, never ours. + /// + [JsonPropertyName("processStartTimeUtc")] + public DateTimeOffset ProcessStartTimeUtc { get; init; } + + [JsonPropertyName("helperPath")] + public string? HelperPath { get; init; } + + /// + /// Where the supervising helper records this launch generation. Its path is + /// unguessable, so only the generation we started can have written it. + /// + [JsonPropertyName("statusPath")] + public string? StatusPath { get; init; } + + /// + /// The port the gateway was configured to listen on, when one was + /// configured at all. + /// + /// + /// Usually absent. OpenClaw resolves its own gateway port from its own + /// configuration, so this package supplies one only when the user set it + /// explicitly; otherwise the guest reports which port it observed. + /// + [JsonPropertyName("port")] + public int? Port { get; init; } +} + +/// +/// What the guest observed. Every field is an observation, never an inference. +/// +public sealed record SessionInspectResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// A process with the recorded identifier exists. + [JsonPropertyName("processFound")] + [JsonRequired] + public bool ProcessFound { get; init; } + + /// + /// That process's creation time matches the recorded one, so it is the + /// process we started rather than one that inherited a reused identifier. + /// + [JsonPropertyName("startTimeMatches")] + [JsonRequired] + public bool StartTimeMatches { get; init; } + + /// What the supervising helper last recorded, if anything. + [JsonPropertyName("supervisorState")] + public string? SupervisorState { get; init; } + + /// What the supervising helper last recorded about its state. + [JsonPropertyName("supervisorDetail")] + public string? SupervisorDetail { get; init; } + + /// Something is listening on the recorded port. + [JsonPropertyName("portListening")] + [JsonRequired] + public bool PortListening { get; init; } + + /// + /// The ports the recorded supervisor or its descendants are listening on. + /// + /// + /// Reported rather than assumed: the gateway's port comes from OpenClaw's + /// own configuration, so observing it is the only way to tell the user + /// where their gateway actually is. + /// + [JsonPropertyName("listeningPorts")] + public IReadOnlyList? ListeningPorts { get; init; } + + /// + /// The listener belongs to the recorded supervisor or one of its + /// descendants. Without this a gateway is only assumed from an open port, + /// which any unrelated program could have opened. + /// + [JsonPropertyName("listenerOwned")] + [JsonRequired] + public bool ListenerOwned { get; init; } + + /// Set when the inspection itself could not be completed. + [JsonPropertyName("error")] + public string? Error { get; init; } + + /// + /// The gateway is running and is ours. Every part is required: a live + /// process proves nothing without its creation time, and an open port + /// proves nothing without its owner. + /// + [JsonIgnore] + public bool IsOwnedAndHealthy => + Error is null && + ProcessFound && + StartTimeMatches && + PortListening && + ListenerOwned; +} + +/// +/// What the supervising helper writes about its own launch generation. +/// +public sealed record SessionSupervisorStatus +{ + public const string RunningState = "running"; + public const string ExitedState = "exited"; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("state")] + public string? State { get; init; } + + [JsonPropertyName("processId")] + public int ProcessId { get; init; } + + [JsonPropertyName("port")] + public int Port { get; init; } + + [JsonPropertyName("updatedAtUtc")] + public DateTimeOffset UpdatedAtUtc { get; init; } + + [JsonPropertyName("detail")] + public string? Detail { get; init; } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(SessionInspectRequest))] +[JsonSerializable(typeof(SessionInspectResult))] +[JsonSerializable(typeof(SessionSupervisorStatus))] +internal sealed partial class SessionInspectJsonContext : JsonSerializerContext; + +public static class SessionInspectProtocol +{ + public static string SerializeRequest(SessionInspectRequest request) => + JsonSerializer.Serialize( + request, + SessionInspectJsonContext.Default.SessionInspectRequest); + + public static string SerializeResult(SessionInspectResult result) => + JsonSerializer.Serialize( + result, + SessionInspectJsonContext.Default.SessionInspectResult); + + public static string SerializeStatus(SessionSupervisorStatus status) => + JsonSerializer.Serialize( + status, + SessionInspectJsonContext.Default.SessionSupervisorStatus); + + public static SessionInspectRequest ReadRequest(string json) + { + SessionInspectRequest? request = Deserialize( + json, + SessionInspectJsonContext.Default.SessionInspectRequest, + "inspect request"); + + if (request.SchemaVersion != SessionLaunchProtocol.CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Inspect request schema version {request.SchemaVersion} is not " + + "supported; this helper implements version " + + $"{SessionLaunchProtocol.CurrentSchemaVersion}."); + } + + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new SessionLaunchException("The inspect request has no request id."); + } + + if (request.ProcessId <= 0) + { + throw new SessionLaunchException( + "The inspect request has no process identifier."); + } + + if (request.ProcessStartTimeUtc == default || + string.IsNullOrWhiteSpace(request.HelperPath) || + !Path.IsPathFullyQualified(request.HelperPath) || + request.Port is < 0 or > 65535) + { + throw new SessionLaunchException("The inspect request lacks a valid process identity, helper image, or port."); + } + + return request; + } + + public static SessionInspectResult ReadResult(string json) + { + SessionInspectResult result = Deserialize( + json, + SessionInspectJsonContext.Default.SessionInspectResult, + "inspect result"); + + if (result.SchemaVersion != SessionLaunchProtocol.CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Inspect result schema version {result.SchemaVersion} is not " + + "supported; this launcher implements version " + + $"{SessionLaunchProtocol.CurrentSchemaVersion}."); + } + + return result; + } + + public static SessionSupervisorStatus ReadStatus(string json) => + Deserialize( + json, + SessionInspectJsonContext.Default.SessionSupervisorStatus, + "supervisor status"); + + private static T Deserialize( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + string description) + where T : class + { + try + { + return JsonSerializer.Deserialize(json, typeInfo) + ?? throw new SessionLaunchException($"The {description} is empty."); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The {description} is not valid JSON: {exception.Message}"); + } + } +} diff --git a/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs b/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs index a8546895..c79b5f4f 100644 --- a/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs +++ b/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs @@ -50,6 +50,19 @@ public sealed record SessionLaunchRequest [JsonPropertyName("environment")] public IReadOnlyDictionary? Environment { get; init; } + /// + /// A directory to put at the front of the launched process's PATH. + /// + /// + /// Named rather than resolved by the host: is + /// merged onto the guest account's own environment, and the host cannot + /// know what that account's PATH already contains. This is how the + /// packaged Node.js runtime wins over a machine-wide installation for tools + /// that resolve node by name. + /// + [JsonPropertyName("pathPrefix")] + public string? PathPrefix { get; init; } + /// /// Detached only. Where the application's output is written, because a /// detached process has no console to inherit and the pipe it was started @@ -167,10 +180,10 @@ internal sealed partial class SessionJsonContext : JsonSerializerContext; public static class SessionLaunchProtocol { /// - /// Incremented whenever the request or result shape changes. The helper and - /// the launcher ship in the same package, so a mismatch means a stale file - /// or a mixed installation and is always an error rather than something to - /// interpret leniently. + /// Version of the public launcher-to-guest helper launch contract. This is + /// independent of the MXC wire schema version; a mismatch means a stale + /// file or a mixed installation and is always an error rather than + /// something to interpret leniently. /// public const int CurrentSchemaVersion = 1; diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index b9f0627e..b4d488c0 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -63,25 +63,52 @@ public async Task HelpDescribesBundledRuntimePreparation() Assert.Contains("openclaw ", help, StringComparison.Ordinal); } - // clawctl owns package readiness only. If an upstream command name ever - // becomes a clawctl subcommand, the host has started shadowing the bundled - // CLI instead of forwarding through `openclaw`. + // clawctl owns package readiness and the managed gateway only. Upstream + // command names must continue flowing through `openclaw` unchanged. [Fact] - public void OnlyTheReadinessCommandIsExposed() + public void OnlyTheOwnedCommandsAreExposed() { RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers { Setup = _ => Task.FromResult(0), Status = _ => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), - PowerShell = _ => Task.FromResult(0) + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) }); Assert.Equal( - [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown", "pwsh"], + [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown", "pwsh", "gateway-service"], root.Subcommands.Select(command => command.Name)); } + [Fact] + public async Task GatewayServiceStartInvokesOnlyTheStartHandler() + { + int starts = 0; + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = _ => Task.FromResult(0), + Status = _ => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => + { + starts++; + return Task.FromResult(0); + }, + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }); + + int exitCode = await root.Parse("gateway-service start").InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(1, starts); + } + [Fact] public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() { diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 75edd7ea..0800cb42 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -75,7 +75,10 @@ public void CompletionSuggestsCommandsWithoutResolvingNode() "Completion started the readiness operation."), Status = _ => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), - PowerShell = _ => Task.FromResult(0) + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) }); IEnumerable completions = root diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs new file mode 100644 index 00000000..5bd167e8 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs @@ -0,0 +1,152 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class GatewayConfigurationStoreTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private string Path_ => Path.Combine(_root, "gateway-config.json"); + + private GatewayConfigurationStore Store => new(Path_); + + private static string? NoEnvironment(string name) => null; + + // An unconfigured installation must not name a port at all. Naming one + // would override `gateway.port` in OpenClaw's own configuration and move + // the gateway away from where its clients look. + [Fact] + public void AnUnconfiguredInstallationUsesTheSuppliedGuestWorkspace() + { + string workspace = Path.Combine(_root, "workspace"); + GatewayLaunchConfiguration resolved = Store.Resolve(workspace, NoEnvironment); + + Assert.Null(resolved.Port); + Assert.Equal(workspace, resolved.WorkingDirectory); + } + + [Fact] + public void AConfiguredPortSurvivesForTheNextSignIn() + { + // The logon task starts the gateway with no one watching, so a port + // chosen interactively has to still be in effect then. + Store.Write(new GatewayLaunchConfiguration { Port = 9100 }); + + Assert.Equal(9100, Store.Resolve(_root, NoEnvironment).Port); + } + + [Fact] + public void AConfiguredWorkingDirectoryWinsOverTheDefault() + { + Store.Write(new GatewayLaunchConfiguration + { + Port = 9100, + WorkingDirectory = @"C:\chosen" + }); + + Assert.Equal(@"C:\chosen", Store.Resolve(_root, NoEnvironment).WorkingDirectory); + } + + [Fact] + public void TheEnvironmentOverridesThePortForOneInvocationOnly() + { + Store.Write(new GatewayLaunchConfiguration { Port = 9100 }); + + GatewayLaunchConfiguration resolved = Store.Resolve( + _root, + name => name == GatewayConfigurationStore.PortVariable ? "9200" : null); + + Assert.Equal(9200, resolved.Port); + + // The stored configuration is untouched, so the next sign-in still uses + // what the user actually configured. + Assert.Equal(9100, Store.Read().Configuration!.Port); + } + + [Theory] + [InlineData("0")] + [InlineData("70000")] + [InlineData("-1")] + [InlineData("http")] + public void AnUnusableOverrideIsRefusedRatherThanIgnored(string value) + { + // Falling back to the configured port would start the gateway + // somewhere the user did not ask for while appearing to honor the + // override. + Assert.Throws( + () => Store.Resolve( + _root, + name => name == GatewayConfigurationStore.PortVariable ? value : null)); + } + + [Fact] + public void AnUnreadableConfigurationIsAnErrorRatherThanASilentDefault() + { + File.WriteAllText(Path_, "{ not json"); + + Assert.Throws( + () => Store.Resolve(_root, NoEnvironment)); + } + + [Fact] + public void ANewerSchemaIsRefusedRatherThanMisread() + { + File.WriteAllText(Path_, """{"schemaVersion":99,"port":9100}"""); + + Assert.Equal( + GatewayConfigurationFault.UnsupportedSchema, + Store.Read().Fault); + } + + [Fact] + public void AnOutOfRangeStoredPortIsReportedAsInvalid() + { + File.WriteAllText(Path_, """{"schemaVersion":1,"port":70000}"""); + + Assert.Equal(GatewayConfigurationFault.Invalid, Store.Read().Fault); + } + + [Fact] + public void AnUnusablePortCannotBeWritten() + { + Assert.Throws( + () => Store.Write(new GatewayLaunchConfiguration { Port = 0 })); + } + + [Fact] + public void AMissingConfigurationIsNotAFault() + { + GatewayConfigurationResult result = Store.Read(); + + Assert.Equal(GatewayConfigurationFault.Missing, result.Fault); + Assert.Null(result.Detail); + } + + [Fact] + public void TheConfigurationRoundTrips() + { + Store.Write(new GatewayLaunchConfiguration + { + Port = 9100, + WorkingDirectory = @"C:\chosen" + }); + + GatewayLaunchConfiguration? read = Store.Read().Configuration; + + Assert.NotNull(read); + Assert.Equal(9100, read.Port); + Assert.Equal(@"C:\chosen", read.WorkingDirectory); + Assert.Equal(GatewayConfigurationStore.CurrentSchemaVersion, read.SchemaVersion); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs new file mode 100644 index 00000000..f54ca5ea --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs @@ -0,0 +1,605 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.Launcher.Tests.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Gateway; + +/// +/// A gateway client whose observations are dictated by the test, so the +/// controller's decisions can be driven through every state the guest can +/// report. +/// +internal sealed class FakeSessionGatewayClient : ISessionGatewayClient +{ + public List Calls { get; } = []; + + public SessionInspectResult Inspection { get; set; } = new(); + + public SessionInspectResult? StopResult { get; set; } + + public GatewayStartOutcome StartOutcome { get; set; } = new( + 1234, + new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero), + "status.json", + "gateway.log"); + + public Exception? StartFailure { get; set; } + + public GatewayStartRequest? StartRequest { get; private set; } + + public Task StartAsync( + SessionRecord session, + GatewayStartRequest request, + CancellationToken cancellationToken) + { + Calls.Add("start"); + StartRequest = request; + return StartFailure is null + ? Task.FromResult(StartOutcome) + : Task.FromException(StartFailure); + } + + public Task InspectAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) + { + Calls.Add($"inspect:{gateway.ProcessId}"); + return Task.FromResult(Inspection); + } + + public Task StopAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) + { + Calls.Add($"stop:{gateway.ProcessId}"); + return Task.FromResult(StopResult ?? new SessionInspectResult()); + } +} + +public sealed class GatewayControllerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + [Fact] + public async Task SessionGatewayLaunchPrefixesPathWithTheAgentNodeDirectory() + { + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(workspace); + string packagedHelper = Path.Combine(_root, "openclaw-session-host.exe"); + await File.WriteAllTextAsync(packagedHelper, "helper"); + string stagedHelper = SessionHelperStager.Stage(packagedHelper, workspace); + SessionLaunchRequest? delivered = null; + var backend = new FakeMxcSessionClient(); + backend.ExecuteBehavior = request => + { + string requestPath = Directory.GetFiles(workspace, "gateway-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + delivered = SessionLaunchProtocol.ReadRequest(File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = delivered.RequestId, + Launched = true, + ProcessId = 1234, + ProcessStartTimeUtc = DateTimeOffset.UtcNow + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + var client = new SessionGatewayClient(backend, _ => { }); + string nodePath = Path.Combine( + workspace, "agent", "NodeJS", "node-v24", "node.exe"); + + await client.StartAsync( + new SessionRecord + { + SchemaVersion = SessionStateStore.CurrentSchemaVersion, + SandboxId = "iso:test", + ApplicationId = "PFN:test", + WorkspacePath = workspace, + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }, + new GatewayStartRequest( + stagedHelper, + nodePath, + Path.Combine(_root, "app"), + workspace, + null), + CancellationToken.None); + + Assert.NotNull(delivered); + Assert.Equal(Path.GetDirectoryName(nodePath), delivered.PathPrefix); + } + private readonly FakeSessionGatewayClient _client = new(); + private readonly FakeMxcSessionClient _backend = new(); + private const string ApplicationId = "PFN:OpenClaw.Gateway_test"; + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private GatewayStateStore Store => new(Path.Combine(_root, "gateway.json")); + + private SessionCoordinator CreateSessions() => + new( + _backend, + new SessionStateStore(Path.Combine(_root, "session.json")), + new AlwaysFreeLock(), + ApplicationId, + _ => { }); + + private GatewayController CreateController( + GatewayStateStore? store = null) + { + var sessionStore = new SessionStateStore(Path.Combine(_root, "session.json")); + if (sessionStore.Read(ApplicationId).Fault == SessionStateFault.Missing) + { + sessionStore.Write(new SessionRecord + { + ApplicationId = ApplicationId, + SandboxId = "iso:sandbox1", + WorkspacePath = _root, + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UnixEpoch + }); + } + SessionCoordinator sessions = CreateSessions(); + return new GatewayController( + sessions, + _client, + store ?? Store, + _ => Task.FromResult(new GatewayStartRequest( + HelperPath: Path.Combine(_root, "openclaw-session-host.exe"), + NodePath: @"C:\Program Files\nodejs\node.exe", + ApplicationDirectory: Path.Combine(_root, "app"), + WorkingDirectory: _root, + Port: null)), + _ => { }, + () => sessions.GetRecordedStatus().Record ?? throw new SessionException("Run setup."), + new AlwaysFreeLock()); + } + + private static SessionInspectResult Healthy() => new() + { + ProcessFound = true, + StartTimeMatches = true, + PortListening = true, + ListenerOwned = true, + ListeningPorts = [18789] + }; + + [Fact] + public void RuntimeDefaultsTheGatewayWorkingDirectoryToTheSessionWorkspace() + { + string workspace = Path.Combine(_root, "workspace"); + var configuration = new GatewayConfigurationStore(Path.Combine(_root, "gateway-config.json")); + + GatewayLaunchConfiguration launch = GatewayRuntime.ResolveLaunchConfiguration( + configuration, + new SessionRecord { WorkspacePath = workspace }, + static _ => null); + + Assert.Equal(workspace, launch.WorkingDirectory); + } + + // The user has to be told where their gateway actually is. OpenClaw picks + // the port from its own configuration, so only the observed value is true. + [Fact] + public async Task StartReportsTheObservedPortRatherThanAnAssumedOne() + { + _client.Inspection = Healthy(); + + GatewayStartResult result = await CreateController() + .StartAsync("helper.exe", CancellationToken.None); + + Assert.Null(result.Record.Port); + Assert.Equal([18789], result.Record.ObservedPorts); + Assert.Contains("port 18789", result.Message, StringComparison.Ordinal); + } + + // A pinned port the gateway is not on means OpenClaw's configuration moved + // it. Reporting that as healthy would send the user to the wrong place. + [Fact] + public async Task APinnedPortTheGatewayIsNotOnIsReportedAsUnhealthy() + { + RecordGateway(); + Store.Write(Store.Read().Record! with { Port = 9100 }); + _client.Inspection = Healthy() with { ListenerOwned = false }; + + GatewayStatusReport report = await CreateController() + .GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Unhealthy, report.State); + Assert.Contains("18789", report.Detail!, StringComparison.Ordinal); + Assert.Contains("gateway.port", report.Detail!, StringComparison.Ordinal); + } + + private void RecordGateway(int processId = 1234, bool autostartDisabled = false) => + Store.Write(new GatewayRecord + { + SandboxId = "iso:sandbox1", + ProcessId = processId, + ProcessStartTimeUtc = new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero), + Port = null, + StatusPath = "status.json", + AutostartDisabled = autostartDisabled + }); + + [Fact] + public async Task StatusReportsNotStartedWithoutProvisioningASession() + { + GatewayStatusReport report = await CreateController() + .GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.NotStarted, report.State); + Assert.Empty(_backend.Calls); + Assert.Empty(_client.Calls); + } + + [Fact] + public async Task StatusReportsARunningGateway() + { + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + + GatewayStatusReport report = + await controller.GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Running, report.State); + } + + [Fact] + public async Task ARecordedGatewayThatIsGoneIsReportedAsStopped() + { + // Stopping the session terminates detached work silently, so a record + // routinely outlives the process it describes. + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + _client.Inspection = new SessionInspectResult(); + + GatewayStatusReport report = + await controller.GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Stopped, report.State); + } + + [Fact] + public async Task AStoppedGatewayReportsTheSupervisorReasonAndLogPath() + { + RecordGateway(); + Store.Write(Store.Read().Record! with { LogPath = "gateway.log" }); + _client.Inspection = new SessionInspectResult + { + SupervisorDetail = "the application exited with code 78" + }; + + GatewayStatusReport report = await CreateController() + .GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Stopped, report.State); + Assert.Contains("the application exited with code 78", report.Detail!, StringComparison.Ordinal); + Assert.Contains("gateway.log", report.Detail!, StringComparison.Ordinal); + } + + [Fact] + public async Task AStartupExitReportsTheSupervisorReasonAndLogPath() + { + _client.Inspection = new SessionInspectResult + { + SupervisorDetail = "the application exited with code 78" + }; + + GatewayStartResult result = await CreateController() + .StartAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Stopped, result.State); + Assert.Contains("the application exited with code 78", result.Message, StringComparison.Ordinal); + Assert.Contains("gateway.log", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ALiveProcessThatIsNotServingIsReportedAsUnhealthy() + { + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + _client.Inspection = Healthy() with { PortListening = false, ListenerOwned = false }; + + GatewayStatusReport report = + await controller.GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Unhealthy, report.State); + Assert.NotNull(report.Detail); + } + + [Fact] + public async Task AnUnansweredInspectionIsReportedAsUnknown() + { + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + _client.Inspection = new SessionInspectResult { Error = "no answer" }; + + GatewayStatusReport report = + await controller.GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Unknown, report.State); + } + + [Fact] + public async Task AnUnreadableRecordIsUnknownRatherThanNotStarted() + { + string path = Path.Combine(_root, "gateway.json"); + await File.WriteAllTextAsync(path, "{ not json", CancellationToken.None); + + GatewayStatusReport report = await CreateController(new GatewayStateStore(path)) + .GetStatusAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Unknown, report.State); + } + + [Fact] + public async Task StartingRecordsTheSupervisorIdentityAndItsPort() + { + _client.Inspection = Healthy(); + + GatewayStartResult result = await CreateController() + .StartAsync("helper.exe", CancellationToken.None); + + Assert.Equal(GatewayState.Running, result.State); + Assert.False(result.AlreadyRunning); + Assert.Equal(_client.StartOutcome.ProcessId, result.Record.ProcessId); + Assert.Equal( + _client.StartOutcome.ProcessStartTimeUtc, + result.Record.ProcessStartTimeUtc); + Assert.Null(result.Record.Port); + } + + [Fact] + public async Task StartingUsesThePreparedSessionWithoutProvisioning() + { + _client.Inspection = Healthy(); + + await CreateController().StartAsync("helper.exe", CancellationToken.None); + + Assert.DoesNotContain("provision", _backend.Calls); + Assert.Contains("start:iso:sandbox1", _backend.Calls); + } + + [Fact] + public async Task ASecondStartDoesNotStartASecondGateway() + { + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + + GatewayStartResult result = + await controller.StartAsync("helper.exe", CancellationToken.None); + + Assert.True(result.AlreadyRunning); + Assert.Single(_client.Calls, call => call == "start"); + } + + [Fact] + public async Task AGatewayThatIsNoLongerRunningIsReplaced() + { + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + _client.Inspection = new SessionInspectResult(); + + GatewayStartResult result = + await controller.StartAsync("helper.exe", CancellationToken.None); + + Assert.False(result.AlreadyRunning); + Assert.Equal(2, _client.Calls.Count(call => call == "start")); + } + + [Fact] + public async Task ASecondGatewayIsNotStartedBesideOneThatCannotBeObserved() + { + // Two processes contending for one port is worse than refusing to act. + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + _client.Inspection = new SessionInspectResult { Error = "no answer" }; + + await Assert.ThrowsAsync( + () => controller.StartAsync("helper.exe", CancellationToken.None)); + Assert.Single(_client.Calls, call => call == "start"); + } + + [Fact] + public async Task AnExplicitAutostartChoiceSurvivesARestart() + { + // A later manual start must not quietly re-enable what the user turned + // off. + RecordGateway(autostartDisabled: true); + _client.Inspection = new SessionInspectResult(); + + GatewayStartResult result = await CreateController() + .StartAsync("helper.exe", CancellationToken.None); + + Assert.True(result.Record.AutostartDisabled); + } + + [Fact] + public async Task StoppingEndsTheGatewayAndClearsItsRecord() + { + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + + GatewayStopResult result = + await controller.StopAsync("helper.exe", CancellationToken.None); + + Assert.True(result.Stopped); + Assert.Contains( + _client.Calls, + call => call.StartsWith("stop:", StringComparison.Ordinal)); + Assert.Equal(GatewayStateFault.Missing, Store.Read().Fault); + } + + [Fact] + public async Task StoppingNothingIsNotAFailure() + { + GatewayStopResult result = await CreateController() + .StopAsync("helper.exe", CancellationToken.None); + + Assert.False(result.Stopped); + Assert.DoesNotContain( + _client.Calls, + call => call.StartsWith("stop:", StringComparison.Ordinal)); + } + + [Fact] + public async Task NothingIsTerminatedWhenIdentityCannotBeEstablished() + { + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + _client.Inspection = new SessionInspectResult { Error = "no answer" }; + + GatewayStopResult result = + await controller.StopAsync("helper.exe", CancellationToken.None); + + Assert.False(result.Stopped); + Assert.DoesNotContain( + _client.Calls, + call => call.StartsWith("stop:", StringComparison.Ordinal)); + + // The record survives, because the gateway it names may still be running. + Assert.NotNull(Store.Read().Record); + } + + [Fact] + public async Task AReusedIdentifierIsNotTerminated() + { + // The recorded identifier now belongs to an unrelated process. + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + _client.Inspection = new SessionInspectResult + { + ProcessFound = true, + StartTimeMatches = false + }; + + GatewayStopResult result = + await controller.StopAsync("helper.exe", CancellationToken.None); + + Assert.False(result.Stopped); + Assert.DoesNotContain( + _client.Calls, + call => call.StartsWith("stop:", StringComparison.Ordinal)); + Assert.Equal(GatewayStateFault.Missing, Store.Read().Fault); + } + + [Fact] + public async Task StoppingDoesNotRemoveTheSession() + { + // Stop preserves the user's provisioned session and guest profile. + _client.Inspection = Healthy(); + GatewayController controller = CreateController(); + await controller.StartAsync("helper.exe", CancellationToken.None); + + await controller.StopAsync("helper.exe", CancellationToken.None); + + Assert.DoesNotContain( + _backend.Calls, + call => call.StartsWith("deprovision:", StringComparison.Ordinal)); + } +} + +public sealed class GatewayStateStoreTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private GatewayStateStore Store => new(Path.Combine(_root, "gateway.json")); + + private static GatewayRecord Record() => new() + { + SandboxId = "sandbox-1", + ProcessId = 42, + ProcessStartTimeUtc = new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero), + Port = 9100, + StatusPath = "status.json" + }; + + [Fact] + public void ARecordRoundTripsWithItsProcessIdentity() + { + Store.Write(Record()); + + GatewayRecord? read = Store.Read().Record; + + Assert.NotNull(read); + Assert.Equal(42, read.ProcessId); + Assert.Equal(Record().ProcessStartTimeUtc, read.ProcessStartTimeUtc); + } + + [Fact] + public void AMissingRecordIsDistinctFromAnUnreadableOne() + { + Assert.Equal(GatewayStateFault.Missing, Store.Read().Fault); + + File.WriteAllText(Path.Combine(_root, "gateway.json"), "{ not json"); + + Assert.Equal(GatewayStateFault.Unreadable, Store.Read().Fault); + } + + [Fact] + public void ANewerSchemaIsRefusedRatherThanMisread() + { + File.WriteAllText( + Path.Combine(_root, "gateway.json"), + """{"schemaVersion":99,"sandboxId":"s","processId":1}"""); + + Assert.Equal(GatewayStateFault.UnsupportedSchema, Store.Read().Fault); + } + + [Fact] + public void ARecordWithoutAProcessIsIncomplete() + { + File.WriteAllText( + Path.Combine(_root, "gateway.json"), + """{"schemaVersion":1,"sandboxId":"s","processId":0}"""); + + Assert.Equal(GatewayStateFault.Incomplete, Store.Read().Fault); + } + + [Fact] + public void ClearingNothingSucceeds() + { + Store.Clear(); + + Assert.Equal(GatewayStateFault.Missing, Store.Read().Fault); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 2936d7a1..d766df68 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1,3 +1,4 @@ +using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Mxc; using OpenClaw.Launcher.Session; using OpenClaw.Launcher.Tests.Session; @@ -325,6 +326,31 @@ await Assert.ThrowsAsync( Assert.True(nodeResolutionAttempted); } + [Fact] + public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval() + { + SessionRuntime runtime = CreateSessionRuntime(); + runtime.GatewayState.Write(new GatewayRecord + { + SchemaVersion = GatewayStateStore.CurrentSchemaVersion, + SandboxId = "iso:pending", + LaunchPending = true, + ProcessStartTimeUtc = DateTimeOffset.UtcNow + }); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, null, []), + ["teardown", "--force"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + createSessionRuntime: () => runtime); + + Assert.Equal(0, exitCode); + GatewayStateResult state = runtime.GatewayState.Read(); + Assert.Equal(GatewayStateFault.Missing, state.Fault); + } + [Fact] public async Task AgentResolvesNodeBeforeReportingMissingApplication() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs b/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs new file mode 100644 index 00000000..8c892d5b --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs @@ -0,0 +1,375 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Exercises the guest-side ownership evidence against real processes and real +/// sockets. Fakes cannot cover this: the whole point of the interop is to +/// answer questions about the operating system's actual state. +/// +public sealed class GuestProcessObserverTests +{ + [Fact] + public void AListenerIsAttributedToTheProcessThatOpenedIt() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + + Assert.True(GuestProcessObserver.AnythingListeningOn(port)); + Assert.True(GuestProcessObserver.OwnsListenerOn(port, Environment.ProcessId)); + } + finally + { + listener.Stop(); + } + } + + [Fact] + public void AListenerIsNotCreditedToAnUnrelatedProcess() + { + // An open port proves only that something is listening. Without this + // distinction any program in the session could be mistaken for the + // gateway. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + using Process unrelated = StartLongRunningProcess(); + try + { + Assert.True(GuestProcessObserver.AnythingListeningOn(port)); + Assert.False(GuestProcessObserver.OwnsListenerOn(port, unrelated.Id)); + } + finally + { + Kill(unrelated); + } + } + finally + { + listener.Stop(); + } + } + + [Fact] + public void AClosedPortHasNoListener() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + Assert.False(GuestProcessObserver.AnythingListeningOn(port)); + } + + [Fact] + public void AChildProcessCountsAsOwnedByItsParent() + { + // The gateway is a child of the supervisor, not the supervisor itself, + // so an ownership check comparing identifiers alone would reject every + // real gateway. + using Process child = StartLongRunningProcess(); + try + { + Assert.True(ProcessAncestry.IsSelfOrDescendant(child.Id, Environment.ProcessId)); + Assert.True(ProcessAncestry.IsSelfOrDescendant( + Environment.ProcessId, + Environment.ProcessId)); + Assert.False(ProcessAncestry.IsSelfOrDescendant(Environment.ProcessId, child.Id)); + } + finally + { + Kill(child); + } + } + + [Fact] + public void ALiveProcessReportsItsCreationTime() + { + using Process child = StartLongRunningProcess(); + try + { + DateTimeOffset? observed = GuestProcessObserver.GetStartTimeUtc(child.Id); + + Assert.NotNull(observed); + Assert.Equal( + child.StartTime.ToUniversalTime(), + observed.Value, + TimeSpan.FromSeconds(1)); + } + finally + { + Kill(child); + } + } + + [Fact] + public void AnExitedProcessHasNoCreationTime() + { + using Process child = StartLongRunningProcess(); + Kill(child); + child.WaitForExit(); + + Assert.Null(GuestProcessObserver.GetStartTimeUtc(child.Id)); + } + + internal static Process StartLongRunningProcess() + { + // An inbox executable that simply waits, so the test needs no fixture + // binary of its own. + ProcessStartInfo startInfo = new() + { + FileName = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe"), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("echo OPENCLAW-CHILD-READY & set /p hold="); + + Process process = Process.Start(startInfo)!; + string? ready = process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(10)) + .GetAwaiter().GetResult(); + if (ready?.Trim() != "OPENCLAW-CHILD-READY") + { + Kill(process); + process.Dispose(); + throw new InvalidOperationException("The fixture process did not signal readiness."); + } + return process; + } + + internal static void Kill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + process.WaitForExit(TimeSpan.FromSeconds(10)); + } + catch (Exception exception) when ( + exception is InvalidOperationException or + System.ComponentModel.Win32Exception or NotSupportedException) + { + } + } +} + +public sealed class SessionInspectorTests +{ + [Fact] + public void EvenOneTickMismatchCannotAuthorizeStop() + { + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + try + { + SessionInspectRequest request = RequestFor(child) with + { + ProcessStartTimeUtc = child.StartTime.ToUniversalTime().AddTicks(1) + }; + SessionInspectResult result = SessionTerminator.Stop(request, _ => throw new FileNotFoundException()); + Assert.False(result.StartTimeMatches); + Assert.False(child.HasExited); + } + finally + { + GuestProcessObserverTests.Kill(child); + } + } + + [Fact] + public void MatchingPidAndTimeWithWrongImageCannotAuthorizeStop() + { + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + try + { + SessionInspectResult result = SessionTerminator.Stop( + RequestFor(child) with { HelperPath = @"C:\not-the-owned-helper.exe" }, + _ => throw new FileNotFoundException()); + Assert.NotNull(result.Error); + Assert.False(child.HasExited); + } + finally + { + GuestProcessObserverTests.Kill(child); + } + } + + private static SessionInspectRequest RequestFor( + Process process, + DateTimeOffset? startTimeOverride = null, + int port = 0, + string? statusPath = null) => + new() + { + RequestId = "r1", + ProcessId = process.Id, + HelperPath = process.StartInfo.FileName, + ProcessStartTimeUtc = + startTimeOverride ?? process.StartTime.ToUniversalTime(), + Port = port, + StatusPath = statusPath + }; + + [Fact] + public void ALiveRecordedProcessIsRecognized() + { + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + try + { + SessionInspectResult result = SessionInspector.Inspect( + RequestFor(child), + _ => throw new FileNotFoundException()); + + Assert.True(result.ProcessFound); + Assert.True(result.StartTimeMatches); + } + finally + { + GuestProcessObserverTests.Kill(child); + } + } + + [Fact] + public void AReusedIdentifierIsNotAcceptedAsTheRecordedProcess() + { + // Windows reuses process identifiers, so a live process with the + // recorded identifier but a different creation time is someone else's. + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + try + { + SessionInspectResult result = SessionInspector.Inspect( + RequestFor(child, startTimeOverride: DateTimeOffset.UnixEpoch), + _ => throw new FileNotFoundException()); + + Assert.True(result.ProcessFound); + Assert.False(result.StartTimeMatches); + Assert.False(result.IsOwnedAndHealthy); + } + finally + { + GuestProcessObserverTests.Kill(child); + } + } + + [Fact] + public void AListenerIsNotCreditedToAProcessThatFailedTheIdentityCheck() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + try + { + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + + SessionInspectResult result = SessionInspector.Inspect( + RequestFor(child, startTimeOverride: DateTimeOffset.UnixEpoch, port: port), + _ => throw new FileNotFoundException()); + + Assert.True(result.PortListening); + Assert.False(result.ListenerOwned); + } + finally + { + GuestProcessObserverTests.Kill(child); + listener.Stop(); + } + } + + [Fact] + public void AnExitedProcessReportsItsLastSupervisorObservation() + { + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + GuestProcessObserverTests.Kill(child); + child.WaitForExit(); + + SessionInspectResult result = SessionInspector.Inspect( + RequestFor( + child, + startTimeOverride: DateTimeOffset.UnixEpoch, + statusPath: "status.json"), + _ => SessionInspectProtocol.SerializeStatus(new SessionSupervisorStatus + { + State = SessionSupervisorStatus.ExitedState, + Detail = "the application exited with code 78" + })); + + Assert.False(result.ProcessFound); + Assert.False(result.IsOwnedAndHealthy); + Assert.Equal(SessionSupervisorStatus.ExitedState, result.SupervisorState); + Assert.Equal("the application exited with code 78", result.SupervisorDetail); + } + + [Fact] + public void AnUnreadableStatusFileDoesNotFailTheInspection() + { + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + try + { + SessionInspectResult result = SessionInspector.Inspect( + RequestFor(child, statusPath: @"C:\missing\status.json"), + _ => throw new FileNotFoundException()); + + Assert.Null(result.Error); + Assert.Null(result.SupervisorState); + Assert.True(result.ProcessFound); + } + finally + { + GuestProcessObserverTests.Kill(child); + } + } + + [Fact] + public void TheSupervisorStateIsReportedWhenItIsReadable() + { + using Process child = GuestProcessObserverTests.StartLongRunningProcess(); + try + { + SessionInspectResult result = SessionInspector.Inspect( + RequestFor(child, statusPath: "status.json"), + _ => SessionInspectProtocol.SerializeStatus(new SessionSupervisorStatus + { + State = SessionSupervisorStatus.RunningState, + ProcessId = child.Id, + UpdatedAtUtc = DateTimeOffset.UtcNow + })); + + Assert.Equal(SessionSupervisorStatus.RunningState, result.SupervisorState); + } + finally + { + GuestProcessObserverTests.Kill(child); + } + } + + [Fact] + public void HealthRequiresEveryPieceOfEvidence() + { + var healthy = new SessionInspectResult + { + ProcessFound = true, + StartTimeMatches = true, + PortListening = true, + ListenerOwned = true + }; + + Assert.True(healthy.IsOwnedAndHealthy); + Assert.False((healthy with { ProcessFound = false }).IsOwnedAndHealthy); + Assert.False((healthy with { StartTimeMatches = false }).IsOwnedAndHealthy); + Assert.False((healthy with { PortListening = false }).IsOwnedAndHealthy); + Assert.False((healthy with { ListenerOwned = false }).IsOwnedAndHealthy); + Assert.False((healthy with { Error = "unreadable" }).IsOwnedAndHealthy); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs new file mode 100644 index 00000000..b5f91a0f --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostExecutableTests.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Exercises the real openclaw-session-host.exe against real request and +/// result files, rather than only its internals. +/// +/// +/// The helper launches itself, so the test needs no external interpreter and +/// stays deterministic. The outer invocation proves a real shell-free start +/// with an explicit working directory; the inner one deliberately fails so the +/// two control results distinguish "the application exited 64" from "the +/// helper never started it". +/// +public sealed class SessionHostExecutableTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + private static string HelperPath + { + get + { + string path = Path.Combine( + AppContext.BaseDirectory, + "openclaw-session-host.exe"); + Assert.True( + File.Exists(path), + $"The guest helper was not copied next to the tests: {path}"); + return path; + } + } + + /// + /// A directory whose name contains a space and literal percent signs, so a + /// path that was expanded or split would not resolve. + /// + private string CreateHostileDirectory() + { + string path = Path.Combine(_testDirectory, "work dir %USERPROFILE%"); + Directory.CreateDirectory(path); + return path; + } + + private static int RunHelper(string requestPath, string workingDirectory) + { + ProcessStartInfo startInfo = new() + { + FileName = HelperPath, + UseShellExecute = false, + RedirectStandardError = true, + WorkingDirectory = workingDirectory + }; + startInfo.ArgumentList.Add("--request"); + startInfo.ArgumentList.Add(requestPath); + + using Process process = Process.Start(startInfo)!; + process.StandardError.ReadToEnd(); + Assert.True( + process.WaitForExit(TimeSpan.FromSeconds(30)), + "The guest helper did not exit."); + return process.ExitCode; + } + + private static SessionLaunchResult ReadResult(string requestPath) => + SessionLaunchProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(requestPath))); + + [Fact] + public void TheHelperStartsARealProcessAndDistinguishesItsFailureFromTheChildExitCode() + { + string workspace = CreateHostileDirectory(); + + // The inner request is deliberately unusable, so the inner helper + // fails and exits with the helper failure code. + string innerRequestPath = Path.Combine(workspace, "inner request.json"); + File.WriteAllText(innerRequestPath, "{ not a request"); + + string outerRequestPath = Path.Combine(workspace, "outer request.json"); + File.WriteAllText( + outerRequestPath, + SessionLaunchProtocol.SerializeRequest(new SessionLaunchRequest + { + RequestId = "outer", + Executable = HelperPath, + Arguments = ["--request", innerRequestPath], + WorkingDirectory = workspace + })); + + int exitCode = RunHelper(outerRequestPath, AppContext.BaseDirectory); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + + SessionLaunchResult outer = ReadResult(outerRequestPath); + Assert.Equal("outer", outer.RequestId); + Assert.True(outer.Launched); + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, outer.ExitCode); + + SessionLaunchResult inner = ReadResult(innerRequestPath); + Assert.False(inner.Launched); + Assert.Null(inner.ExitCode); + Assert.NotNull(inner.Error); + } + + [Fact] + public void AMissingWorkingDirectoryFailsInsteadOfRunningSomewhereElse() + { + // Execution does not inherit the caller's directory, so silently + // continuing would run the command in the system directory. + string requestPath = Path.Combine(_testDirectory, "request.json"); + File.WriteAllText( + requestPath, + SessionLaunchProtocol.SerializeRequest(new SessionLaunchRequest + { + RequestId = "missing-cwd", + Executable = HelperPath, + Arguments = [], + WorkingDirectory = Path.Combine(_testDirectory, "absent") + })); + + int exitCode = RunHelper(requestPath, AppContext.BaseDirectory); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + + SessionLaunchResult result = ReadResult(requestPath); + Assert.False(result.Launched); + Assert.Contains("working directory", result.Error!, StringComparison.Ordinal); + } + + [Fact] + public void AMissingRequestFileIsRecordedInTheControlResult() + { + string requestPath = Path.Combine(_testDirectory, "absent.json"); + + int exitCode = RunHelper(requestPath, AppContext.BaseDirectory); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.True(File.Exists(SessionLaunchProtocol.ResultPathFor(requestPath))); + + SessionLaunchResult result = ReadResult(requestPath); + Assert.False(result.Launched); + } + + [Fact] + public async Task DetachedLaunchClosesHelperOutputWhileSupervisorAndChildLive() + { + string workspace = CreateHostileDirectory(); + string requestPath = Path.Combine(workspace, "detached request.json"); + string logPath = Path.Combine(workspace, "gateway.log"); + string statusPath = Path.Combine(workspace, "gateway.status.json"); + string powershell = Path.Combine( + Environment.SystemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + File.WriteAllText( + requestPath, + SessionLaunchProtocol.SerializeRequest(new SessionLaunchRequest + { + RequestId = "detached", + Mode = SessionLaunchMode.Detached, + Executable = powershell, + Arguments = + [ + "-NoLogo", + "-NoProfile", + "-Command", + "Start-Sleep -Seconds 30" + ], + WorkingDirectory = workspace, + LogPath = logPath, + StatusPath = statusPath + })); + + ProcessStartInfo startInfo = new() + { + FileName = HelperPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = AppContext.BaseDirectory + }; + startInfo.ArgumentList.Add("--request"); + startInfo.ArgumentList.Add(requestPath); + + using Process helper = Process.Start(startInfo)!; + Task standardOutput = helper.StandardOutput.ReadToEndAsync(); + Task standardError = helper.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + Process? supervisor = null; + try + { + await helper.WaitForExitAsync(timeout.Token); + Assert.Equal(0, helper.ExitCode); + Assert.Empty(await standardOutput.WaitAsync(timeout.Token)); + Assert.Empty(await standardError.WaitAsync(timeout.Token)); + + SessionLaunchResult result = ReadResult(requestPath); + Assert.NotNull(result.ProcessId); + supervisor = Process.GetProcessById(result.ProcessId.Value); + Assert.False(supervisor.HasExited); + } + finally + { + if (supervisor is null && + File.Exists(SessionLaunchProtocol.ResultPathFor(requestPath))) + { + SessionLaunchResult result = ReadResult(requestPath); + if (result.ProcessId is int processId) + { + try + { + supervisor = Process.GetProcessById(processId); + } + catch (ArgumentException) + { + } + } + } + + if (supervisor is not null && !supervisor.HasExited) + { + supervisor.Kill(entireProcessTree: true); + await supervisor.WaitForExitAsync(); + } + + supervisor?.Dispose(); + } + } + + public void Dispose() + { + try + { + Directory.Delete(_testDirectory, recursive: true); + } + catch (IOException) + { + } + + GC.SuppressFinalize(this); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs index 1af19cb0..3428b179 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs @@ -6,11 +6,10 @@ namespace OpenClaw.Launcher.Tests.Session; public sealed class SessionHostModeTests { - [Theory] - [InlineData(SessionLaunchMode.Detached)] - [InlineData((SessionLaunchMode)42)] - public void UnsupportedModeIsRejectedBeforeLaunching(SessionLaunchMode mode) + [Fact] + public void UndefinedModeIsRejectedBeforeLaunching() { + const SessionLaunchMode mode = (SessionLaunchMode)42; var launcher = new RecordingLauncher(); SessionLaunchResult? result = null; var request = new SessionLaunchRequest @@ -33,6 +32,7 @@ public void UnsupportedModeIsRejectedBeforeLaunching(SessionLaunchMode mode) Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); Assert.Equal(0, launcher.RunCount); + Assert.Equal(0, launcher.StartCount); Assert.NotNull(result); Assert.False(result.Launched); Assert.Contains("mode", result.Error, StringComparison.OrdinalIgnoreCase); @@ -41,11 +41,20 @@ public void UnsupportedModeIsRejectedBeforeLaunching(SessionLaunchMode mode) private sealed class RecordingLauncher : ISessionProcessLauncher { public int RunCount { get; private set; } + public int StartCount { get; private set; } public int Run(SessionLaunchRequest request) { RunCount++; return 0; } + + public SessionDetachedProcess Start( + SessionLaunchRequest request, + string helperExecutablePath) + { + StartCount++; + return new SessionDetachedProcess(1, DateTimeOffset.UtcNow); + } } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs new file mode 100644 index 00000000..45f88f9b --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostProgramTests.cs @@ -0,0 +1,274 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; +using SessionHostProgram = OpenClaw.SessionHost.Program; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionHostProgramTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + private sealed class RecordingLauncher(int exitCode) : ISessionProcessLauncher + { + public SessionLaunchRequest? Request { get; private set; } + + public SessionLaunchRequest? DetachedRequest { get; private set; } + + public string? HelperPath { get; private set; } + + public SessionDetachedProcess Detached { get; set; } = + new(4321, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + public int Run(SessionLaunchRequest request) + { + Request = request; + return exitCode; + } + + public SessionDetachedProcess Start(SessionLaunchRequest request, string helperPath) + { + DetachedRequest = request; + HelperPath = helperPath; + return Detached; + } + } + + private sealed class ThrowingLauncher(string message) : ISessionProcessLauncher + { + public int Run(SessionLaunchRequest request) => + throw new SessionLaunchException(message); + + public SessionDetachedProcess Start(SessionLaunchRequest request, string helperPath) => + throw new SessionLaunchException(message); + } + + private sealed class ResultRecorder + { + public string? Path { get; private set; } + + public SessionLaunchResult? Result { get; private set; } + + public void Write(string path, SessionLaunchResult result) + { + Path = path; + Result = result; + } + } + + private static string RequestJson( + IReadOnlyList? arguments = null, + string requestId = "r-1") => + SessionLaunchProtocol.SerializeRequest(new SessionLaunchRequest + { + RequestId = requestId, + Executable = @"C:\node\node.exe", + Arguments = arguments ?? [@"C:\app\openclaw.mjs"], + WorkingDirectory = @"C:\work" + }); + + [Fact] + public void ToolInstallModeReachesTheGuestInstallerThroughTheHelperEntrypoint() + { + string workspace = Path.Combine(_root, "workspace"); + Directory.CreateDirectory(workspace); + string requestPath = Path.Combine(workspace, "tools.json"); + File.WriteAllText( + requestPath, + SessionRuntimeProtocol.SerializeToolInstallRequest(new SessionToolInstallRequest + { + RequestId = "tools1", + WorkspacePath = workspace + })); + + int exitCode = SessionHostProgram.Run( + ["--install-tools", requestPath], + new RecordingLauncher(0), + new StringWriter(), + File.ReadAllText, + (_, _) => throw new InvalidOperationException("The tool installer writes its own result.")); + + Assert.Equal(0, exitCode); + SessionToolInstallResult result = SessionRuntimeProtocol.ReadToolInstallResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(requestPath))); + Assert.Equal("tools1", result.RequestId); + Assert.True(File.Exists(result.ShimPath)); + } + + [Fact] + public void TheArgumentVectorReachesTheLauncherUnchanged() + { + string[] arguments = [@"C:\app\openclaw.mjs", "%USERPROFILE%", "q\"x", string.Empty]; + var launcher = new RecordingLauncher(0); + var errors = new StringWriter(); + var results = new ResultRecorder(); + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\ws\req.json"], + launcher, + errors, + _ => RequestJson(arguments), + results.Write); + + Assert.Equal(0, exitCode); + Assert.Equal(arguments, launcher.Request!.Arguments); + Assert.Empty(errors.ToString()); + } + + [Fact] + public void AVersionOneRequestWithoutAModeRunsAttached() + { + var launcher = new RecordingLauncher(42); + var results = new ResultRecorder(); + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\ws\req.json"], + launcher, + new StringWriter(), + _ => + """ + { + "schemaVersion": 1, + "requestId": "r-1", + "executable": "C:\\node\\node.exe", + "arguments": ["C:\\app\\openclaw.mjs"], + "workingDirectory": "C:\\work" + } + """, + results.Write); + + Assert.Equal(42, exitCode); + Assert.Equal(SessionLaunchMode.Attached, launcher.Request!.Mode); + Assert.Null(launcher.DetachedRequest); + Assert.True(results.Result!.Launched); + Assert.Equal(42, results.Result.ExitCode); + } + + [Fact] + public void TheApplicationExitCodeIsPropagatedAndRecordedAsLaunched() + { + var results = new ResultRecorder(); + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\ws\req.json"], + new RecordingLauncher(42), + new StringWriter(), + _ => RequestJson(), + results.Write); + + Assert.Equal(42, exitCode); + Assert.Equal(@"C:\ws\req.json.result.json", results.Path); + Assert.True(results.Result!.Launched); + Assert.Equal(42, results.Result.ExitCode); + Assert.Null(results.Result.Error); + } + + [Fact] + public void AnApplicationExitCodeMatchingTheHelperFailureCodeIsStillLaunched() + { + // The decisive ambiguity: without the control result, OpenClaw exiting + // 64 would be indistinguishable from the helper failing to start it. + var results = new ResultRecorder(); + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\ws\req.json"], + new RecordingLauncher(SessionLaunchProtocol.HelperFailureExitCode), + new StringWriter(), + _ => RequestJson(), + results.Write); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.True(results.Result!.Launched); + } + + [Fact] + public void AFailedLaunchIsRecordedAsNotLaunchedWithItsReason() + { + var results = new ResultRecorder(); + var errors = new StringWriter(); + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\ws\req.json"], + new ThrowingLauncher("node.exe is missing"), + errors, + _ => RequestJson(), + results.Write); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.False(results.Result!.Launched); + Assert.Null(results.Result.ExitCode); + Assert.Equal("node.exe is missing", results.Result.Error); + Assert.Contains("node.exe is missing", errors.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void AnUnreadableRequestIsRecordedWithoutLaunchingAnything() + { + var launcher = new RecordingLauncher(0); + var results = new ResultRecorder(); + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\ws\req.json"], + launcher, + new StringWriter(), + _ => throw new FileNotFoundException("the request file is missing"), + results.Write); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.Null(launcher.Request); + Assert.False(results.Result!.Launched); + } + + [Theory] + [InlineData("")] + [InlineData("--request")] + [InlineData("--request|")] + [InlineData(@"--run|C:\ws\req.json")] + [InlineData(@"--request|C:\ws\req.json|extra")] + public void OnlyAWellFormedRequestOptionIsAccepted(string packedArgs) + { + string[] args = packedArgs.Length == 0 + ? [] + : packedArgs.Split('|'); + // The helper is reachable from inside the session, so it must never + // become a general-purpose runner. + var launcher = new RecordingLauncher(0); + var errors = new StringWriter(); + + int exitCode = SessionHostProgram.Run( + args, + launcher, + errors, + _ => RequestJson(), + (_, _) => throw new InvalidOperationException( + "no result file exists without a request path")); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.Null(launcher.Request); + Assert.Contains("usage", errors.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void AnUnwritableResultStillReportsTheOriginalFailure() + { + var errors = new StringWriter(); + + int exitCode = SessionHostProgram.Run( + ["--request", @"C:\ws\req.json"], + new ThrowingLauncher("node.exe is missing"), + errors, + _ => RequestJson(), + (_, _) => throw new UnauthorizedAccessException("workspace is read-only")); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + + string written = errors.ToString(); + Assert.Contains("node.exe is missing", written, StringComparison.Ordinal); + Assert.Contains("workspace is read-only", written, StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionSupervisorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionSupervisorTests.cs new file mode 100644 index 00000000..8bf2c6e5 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionSupervisorTests.cs @@ -0,0 +1,326 @@ +using System.Diagnostics; +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Drives the real supervisor against a real child process, because its whole +/// job is to own output and lifetime that a fake would simply assert away. +/// +public sealed class SessionSupervisorTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private string LogPath => Path.Combine(_root, "gateway.log"); + + private string StatusPath => Path.Combine(_root, "gateway.status.json"); + + private string CommandProcessor => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe"); + + private SessionLaunchRequest Request(params string[] arguments) => + new() + { + RequestId = "r1", + Mode = SessionLaunchMode.Detached, + Executable = CommandProcessor, + Arguments = arguments, + WorkingDirectory = _root, + LogPath = LogPath, + StatusPath = StatusPath + }; + + private int RunSupervisor(SessionLaunchRequest request) + { + string requestPath = SessionSupervisor.RequestPathFor(StatusPath); + File.WriteAllText(requestPath, SessionLaunchProtocol.SerializeRequest(request)); + return SessionSupervisor.Run(requestPath, File.ReadAllText, File.WriteAllText); + } + + private string ReadLog() + { + using var stream = new FileStream( + LogPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + [Fact] + public void TheApplicationsOutputIsCapturedInTheLog() + { + // A detached process has no console to inherit, so without this its + // output would be written into a handle that closes as soon as the + // launching execution returns. + RunSupervisor(Request("/c", "echo", "hello-from-the-gateway")); + + Assert.Contains( + "hello-from-the-gateway", + ReadLog(), + StringComparison.Ordinal); + } + + [Fact] + public void StandardErrorIsCapturedToo() + { + RunSupervisor(Request("/c", "echo trouble 1>&2")); + + Assert.Contains("trouble", ReadLog(), StringComparison.Ordinal); + } + + [Fact] + public void TheApplicationsExitCodeIsReturned() + { + Assert.Equal(7, RunSupervisor(Request("/c", "exit 7"))); + } + + [Fact] + public void TheStatusFileRecordsThatTheLaunchEnded() + { + RunSupervisor(Request("/c", "exit 3")); + + SessionSupervisorStatus status = + SessionInspectProtocol.ReadStatus(File.ReadAllText(StatusPath)); + + Assert.Equal(SessionSupervisorStatus.ExitedState, status.State); + Assert.Contains("3", status.Detail, StringComparison.Ordinal); + } + + [Fact] + public void TheRequestFileIsRemovedAfterwards() + { + RunSupervisor(Request("/c", "exit 0")); + + Assert.False(File.Exists(SessionSupervisor.RequestPathFor(StatusPath))); + } + + [Fact] + public void AnApplicationThatCannotStartIsRecordedRatherThanLostSilently() + { + SessionLaunchRequest request = Request("/c", "exit 0") with + { + Executable = Path.Combine(_root, "does-not-exist.exe") + }; + + int exitCode = RunSupervisor(request); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + SessionSupervisorStatus status = + SessionInspectProtocol.ReadStatus(File.ReadAllText(StatusPath)); + Assert.Equal(SessionSupervisorStatus.ExitedState, status.State); + Assert.NotNull(status.Detail); + } + + [Fact] + public void TheEnvironmentContractReachesTheApplication() + { + SessionLaunchRequest request = Request("/c", "echo %OPENCLAW_SUPERVISOR_MODE%") with + { + Environment = new Dictionary + { + ["OPENCLAW_SUPERVISOR_MODE"] = "external" + } + }; + + RunSupervisor(request); + + Assert.Contains("external", ReadLog(), StringComparison.Ordinal); + } + + [Fact] + public void TheApplicationRunsInTheRequestedWorkingDirectory() + { + // At logon there is no meaningful inherited directory, so it must be + // stated rather than assumed. + RunSupervisor(Request("/c", "cd")); + + Assert.Contains(_root, ReadLog(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TheApplicationDoesNotOutliveItsSupervisor() + { + using var input = new System.IO.Pipes.AnonymousPipeServerStream( + System.IO.Pipes.PipeDirection.Out, HandleInheritability.None); + using FileStream log = File.Create(LogPath); + using OpenClaw.SessionHost.WindowsKillOnCloseJob job = OpenClaw.SessionHost.WindowsKillOnCloseJob.Create(); + ProcessStartInfo startInfo = new(CommandProcessor) + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = _root + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("set /p hold="); + using Process application = job.StartProcess( + startInfo, input.ClientSafePipeHandle, log.SafeFileHandle, log.SafeFileHandle); + try + { + Assert.False(application.HasExited); + job.Dispose(); + Assert.True(application.WaitForExit(TimeSpan.FromSeconds(10)), + "The application outlived its owning job."); + } + finally + { + if (!application.HasExited) + { + application.Kill(entireProcessTree: true); + application.WaitForExit(); + } + } + } + + [Fact] + public void AnUnreadableRequestFailsWithoutStartingAnything() + { + string requestPath = SessionSupervisor.RequestPathFor(StatusPath); + File.WriteAllText(requestPath, "{ not json"); + + int exitCode = SessionSupervisor.Run(requestPath, File.ReadAllText, File.WriteAllText); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + Assert.False(File.Exists(LogPath)); + } + + [Fact] + public void AppendingPreservesAnEarlierLaunchesOutput() + { + RunSupervisor(Request("/c", "echo first")); + RunSupervisor(Request("/c", "echo second")); + + string log = ReadLog(); + Assert.Contains("first", log, StringComparison.Ordinal); + Assert.Contains("second", log, StringComparison.Ordinal); + } +} + +public sealed class SessionLaunchProtocolDetachedTests +{ + private static SessionLaunchRequest Valid() => + new() + { + RequestId = "r1", + Mode = SessionLaunchMode.Detached, + Executable = @"C:\Windows\System32\cmd.exe", + Arguments = [], + WorkingDirectory = @"C:\", + LogPath = @"C:\state\gateway.log", + StatusPath = @"C:\state\gateway.status.json" + }; + + [Fact] + public void ADetachedRequestRoundTrips() + { + SessionLaunchRequest parsed = SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(Valid())); + + Assert.Equal(SessionLaunchMode.Detached, parsed.Mode); + Assert.Equal(@"C:\state\gateway.log", parsed.LogPath); + Assert.Equal(@"C:\state\gateway.status.json", parsed.StatusPath); + } + + [Fact] + public void TheModeIsWrittenByNameSoTheControlFileCanBeRead() + { + // These files are read by people diagnosing a gateway that did not + // start, and a bare number tells them nothing. + Assert.Contains( + "\"mode\":\"Detached\"", + SessionLaunchProtocol.SerializeRequest(Valid()), + StringComparison.Ordinal); + } + + [Fact] + public void ARequestWithoutAModeStaysAttached() + { + // Existing OpenClaw invocations must keep waiting for their process. + SessionLaunchRequest parsed = SessionLaunchProtocol.ReadRequest( + """ + { + "schemaVersion": 1, + "requestId": "r1", + "executable": "C:\\Windows\\System32\\cmd.exe", + "arguments": [], + "workingDirectory": "C:\\" + } + """); + + Assert.Equal(SessionLaunchMode.Attached, parsed.Mode); + } + + [Theory] + [InlineData("log")] + [InlineData("status")] + public void ADetachedRequestMissingItsEvidencePathsIsRejected(string missing) + { + SessionLaunchRequest request = missing == "log" + ? Valid() with { LogPath = null } + : Valid() with { StatusPath = null }; + + Assert.Throws( + () => SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request))); + } + + [Fact] + public void AnAttachedRequestNeedsNoEvidencePaths() + { + SessionLaunchRequest request = Valid() with + { + Mode = SessionLaunchMode.Attached, + LogPath = null, + StatusPath = null + }; + + SessionLaunchProtocol.ReadRequest(SessionLaunchProtocol.SerializeRequest(request)); + } + + [Fact] + public void AMismatchedSchemaVersionIsRejectedRatherThanInterpreted() + { + // The helper and the launcher ship together, so a mismatch means a + // stale file or a mixed installation. Expressed against the constant + // so this keeps testing a mismatch if the version ever moves. + Assert.Throws( + () => SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(Valid() with + { + SchemaVersion = SessionLaunchProtocol.CurrentSchemaVersion + 1 + }))); + } + + [Fact] + public void ADetachedResultCarriesTheSupervisorIdentity() + { + var result = new SessionLaunchResult + { + RequestId = "r1", + Launched = true, + ProcessId = 1234, + ProcessStartTimeUtc = new DateTimeOffset(2026, 2, 3, 4, 5, 6, TimeSpan.Zero) + }; + + SessionLaunchResult parsed = SessionLaunchProtocol.ReadResult( + SessionLaunchProtocol.SerializeResult(result)); + + Assert.Equal(1234, parsed.ProcessId); + Assert.Equal(result.ProcessStartTimeUtc, parsed.ProcessStartTimeUtc); + } +} From 318c65fc83a834d421fb84b35953d7469062ec1b Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:51 -0700 Subject: [PATCH 59/76] feat: restart the managed gateway after sign-in (#49) * Define gateway recovery task scheduling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Add gateway recovery reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Activate gateway sign-in recovery after setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Bind gateway recovery to the package executable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Preserve unowned gateway recovery scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Clean up cancelled gateway scheduler processes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Detect missing scheduled tasks without localized text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Inject recovery into the successful-setup fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Fix gateway logon recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- README.md | 11 +- scripts/Build-MSIX.ps1 | 15 +- .../Gateway/GatewayLauncherScript.cs | 189 ++++++ .../Gateway/GatewayPersistenceContracts.cs | 5 - .../Gateway/GatewayPersistenceManager.cs | 518 ++++++++++++++ .../Gateway/GatewayRuntime.cs | 77 +++ .../Gateway/GatewayTaskContracts.cs | 70 ++ .../Gateway/GatewayTaskDefinition.cs | 243 +++++++ .../Gateway/GatewayTaskIdentity.cs | 68 ++ .../Gateway/SchTasksGatewayScheduler.cs | 438 ++++++++++++ src/OpenClaw.Launcher/HostEntrypoint.cs | 13 +- src/OpenClaw.Launcher/HostPaths.cs | 33 +- src/OpenClaw.Launcher/Package.appxmanifest | 21 + src/OpenClaw.Launcher/Program.cs | 45 +- .../Gateway/FakeGatewayTaskScheduler.cs | 59 ++ .../Gateway/GatewayPersistenceManagerTests.cs | 630 ++++++++++++++++++ .../Gateway/GatewayTaskDefinitionTests.cs | 132 ++++ .../Gateway/SchTasksGatewaySchedulerTests.cs | 178 +++++ .../HostEntrypointResolverTests.cs | 16 + .../PackageManifestTests.cs | 32 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 37 +- .../Session/HostPathsTests.cs | 6 + 22 files changed, 2794 insertions(+), 42 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayTaskContracts.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayTaskIdentity.cs create mode 100644 src/OpenClaw.Launcher/Gateway/SchTasksGatewayScheduler.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/FakeGatewayTaskScheduler.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/SchTasksGatewaySchedulerTests.cs diff --git a/README.md b/README.md index d73701b2..d2aca671 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ This repository builds a Windows MSIX package containing: -- one .NET 10 NativeAOT launcher exposed through the `openclaw` and `clawctl` - app execution aliases; +- one .NET 10 NativeAOT launcher exposed through separate packaged + `openclaw` and `clawctl` application identities and app execution aliases; - a pinned, verified build of [`openclaw/openclaw`](https://github.com/openclaw/openclaw); - the official Node.js archive matching the upstream build's runtime version @@ -17,9 +17,10 @@ packages. ## Command model -Both aliases activate the same packaged `openclaw.exe`. The launcher recovers -the alias used to start it from the native process command line and selects one -of two deliberately separate surfaces. +Both application identities activate the same packaged `openclaw.exe`. The +launcher recovers either the package-qualified application identity or the alias +used to start it from the native process command line and selects one of two +deliberately separate surfaces. ### `openclaw` diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index 849869aa..82c823a7 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -560,17 +560,18 @@ try { "//*[local-name()='Extension' and @Category='windows.appExecutionAlias']" ) ) - if ($aliasExtension.Count -ne 1) { - throw 'The MSIX must contain one app execution alias extension.' + if ($aliasExtension.Count -ne 2) { + throw 'The MSIX must contain public and control app execution alias extensions.' } - if ($aliasExtension[0].Executable -ne 'openclaw.exe') { - throw 'Both command aliases must target openclaw.exe.' + foreach ($extension in $aliasExtension) { + if ($extension.Executable -ne 'openclaw.exe') { + throw 'Both command aliases must target openclaw.exe.' + } } $registeredAliases = @( - $aliasExtension[0].SelectNodes( - ".//*[local-name()='ExecutionAlias']" - ) | + $aliasExtension | + ForEach-Object { $_.SelectNodes(".//*[local-name()='ExecutionAlias']") } | ForEach-Object { $_.Alias } ) foreach ($requiredAlias in @('openclaw.exe', 'clawctl.exe')) { diff --git a/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs new file mode 100644 index 00000000..979b9c26 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayLauncherScript.cs @@ -0,0 +1,189 @@ +namespace OpenClaw.Launcher.Gateway; + +/// +/// Generates the launcher files both persistence lanes invoke. +/// +internal static class GatewayLauncherScript +{ + /// + /// Identifies a file this installation generated, so a file that happens to + /// share the name is reported rather than silently overwritten. + /// + public const string Marker = "@rem openclaw-gateway-launcher v1"; + + private const string PowerShellMarker = "# openclaw-gateway-launcher v1"; + + public const string ControlApplicationId = "Control"; + + public const string ControlArguments = "gateway-service start"; + + public static string Create(string workingDirectory, string activationScriptPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(activationScriptPath); + + // At logon a task's working directory is the system directory. The + // gateway is given an explicit, controlled one instead, so it never + // writes relative paths into a system location. + return string.Join( + "\r\n", + "@echo off", + Marker, + "@rem Generated by OpenClaw. Edits are overwritten on the next install.", + "chcp 65001 >nul", + "setlocal", + $"cd /d \"{workingDirectory}\" || exit /b 1", + $"\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"{activationScriptPath}\"", + "exit /b %ERRORLEVEL%", + string.Empty); + } + + public static string CreateActivationScript(string packageFamilyName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageFamilyName); + + string applicationUserModelId = + $"{packageFamilyName}!{ControlApplicationId}"; + return string.Join( + "\r\n", + PowerShellMarker, + "$ErrorActionPreference = 'Stop'", + $"$packageFamilyName = '{QuotePowerShell(packageFamilyName)}'", + $"$applicationId = '{ControlApplicationId}'", + $"$arguments = '{ControlArguments}'", + $"$applicationUserModelId = '{QuotePowerShell(applicationUserModelId)}'", + "$package = Get-AppxPackage -Name 'OpenClaw.Gateway' | " + + "Where-Object { $_.PackageFamilyName -eq $packageFamilyName } | " + + "Select-Object -First 1", + "if ($null -eq $package) { throw \"Package '$packageFamilyName' is not registered for the current user.\" }", + "if ($package.Status -inotmatch '^(Ok|Ready)$') { throw \"Package '$packageFamilyName' is not ready (status $($package.Status)).\" }", + "$manifestPath = Join-Path $package.InstallLocation 'AppxManifest.xml'", + "if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw \"Package '$packageFamilyName' has no AppxManifest.xml at $manifestPath.\" }", + "[xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw", + "$application = @($manifest.Package.Applications.Application | Where-Object { $_.Id -eq $applicationId }) | Select-Object -First 1", + "if ($null -eq $application) { throw \"Package '$packageFamilyName' does not declare application '$applicationId'.\" }", + "if ([string]$application.Executable -ne 'openclaw.exe') { throw \"Package '$packageFamilyName' application '$applicationId' does not target openclaw.exe.\" }", + "$source = @'", + "using System;", + "using System.ComponentModel;", + "using System.Runtime.InteropServices;", + "namespace OpenClaw.PackageActivation", + "{", + " [ComImport]", + " [Guid(\"2e941141-7f97-4756-ba1d-9decde894a3d\")]", + " [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]", + " public interface IApplicationActivationManager", + " {", + " [PreserveSig]", + " int ActivateApplication(", + " [MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,", + " [MarshalAs(UnmanagedType.LPWStr)] string arguments,", + " uint options,", + " out uint processId);", + " }", + "", + " [ComImport]", + " [Guid(\"45ba127d-10a8-46ea-8ab7-56ea9078943c\")]", + " public class ApplicationActivationManager", + " {", + " }", + "", + " public static class ApplicationActivator", + " {", + " private const uint Synchronize = 0x00100000;", + " private const uint QueryLimitedInformation = 0x1000;", + " private const uint Infinite = 0xffffffff;", + " private const uint WaitObject0 = 0;", + "", + " [DllImport(\"kernel32.dll\", SetLastError = true)]", + " private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId);", + "", + " [DllImport(\"kernel32.dll\", SetLastError = true)]", + " private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);", + "", + " [DllImport(\"kernel32.dll\", SetLastError = true)]", + " [return: MarshalAs(UnmanagedType.Bool)]", + " private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);", + "", + " [DllImport(\"kernel32.dll\")]", + " [return: MarshalAs(UnmanagedType.Bool)]", + " private static extern bool CloseHandle(IntPtr handle);", + "", + " public static int ActivateAndWait(string appUserModelId, string arguments)", + " {", + " var manager =", + " (IApplicationActivationManager)new ApplicationActivationManager();", + " uint processId;", + " int result = manager.ActivateApplication(", + " appUserModelId,", + " arguments,", + " 0,", + " out processId);", + " if (result != 0)", + " {", + " Marshal.ThrowExceptionForHR(result);", + " }", + "", + " IntPtr process = OpenProcess(", + " Synchronize | QueryLimitedInformation,", + " false,", + " processId);", + " if (process == IntPtr.Zero)", + " {", + " throw new Win32Exception(Marshal.GetLastWin32Error());", + " }", + "", + " try", + " {", + " uint exitCode;", + " if (WaitForSingleObject(process, Infinite) != WaitObject0 ||", + " !GetExitCodeProcess(process, out exitCode))", + " {", + " throw new Win32Exception(Marshal.GetLastWin32Error());", + " }", + "", + " return checked((int)exitCode);", + " }", + " finally", + " {", + " CloseHandle(process);", + " }", + " }", + " }", + "}", + "'@", + "Add-Type -TypeDefinition $source -Language CSharp", + "$exitCode = [OpenClaw.PackageActivation.ApplicationActivator]::ActivateAndWait($applicationUserModelId, $arguments)", + "exit $exitCode", + string.Empty); + } + + /// + /// The Startup-folder fallback calls the same launcher rather than + /// repeating its contents, so the two lanes cannot drift apart. + /// + public static string CreateFallback(string launcherPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(launcherPath); + + return string.Join( + "\r\n", + "@echo off", + Marker, + "@rem Generated by OpenClaw. Edits are overwritten on the next install.", + "chcp 65001 >nul", + $"call \"{launcherPath}\"", + "exit /b %ERRORLEVEL%", + string.Empty); + } + + public static bool LooksGenerated(string content) + { + ArgumentNullException.ThrowIfNull(content); + return content.Contains(Marker, StringComparison.Ordinal) || + content.Contains(PowerShellMarker, StringComparison.Ordinal); + } + + private static string QuotePowerShell(string value) => + value.Replace("'", "''", StringComparison.Ordinal); +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs index 5c9847b3..a8e138bc 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs @@ -78,10 +78,6 @@ internal sealed record GatewayPersistenceRemovalResult( /// The directory the gateway is given, rather than the system directory a /// logon task would otherwise inherit. /// -/// -/// The control command the launcher invokes. Held as configuration so the -/// launcher file can be regenerated without re-registering the task. -/// /// /// The absolute path to the inbox command processor the task runs. /// @@ -91,5 +87,4 @@ internal sealed record GatewayPersistenceOptions( string LauncherPath, string StartupFolderPath, string WorkingDirectory, - string AliasCommand, string CommandProcessorPath); diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs new file mode 100644 index 00000000..49185eea --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs @@ -0,0 +1,518 @@ +using System.Text; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// +/// Registers, inspects, and removes this installation's logon recovery. +/// +/// +/// +/// Persistence is three files' worth of state: a rewritable launcher script, a +/// logon task that calls it, and an optional Startup-folder fallback that calls +/// the same script. Only the task is the intended lane. +/// +/// +/// This type never elevates and never repairs behind the user's back. An +/// explicit install rewrites what it owns; status only reports, +/// and names the command that repairs what it found. +/// +/// +internal sealed class GatewayPersistenceManager +{ + /// The command a reported problem tells the user to run. + public const string RepairCommand = "clawctl gateway-service install"; + + private readonly IGatewayTaskScheduler _scheduler; + private readonly GatewayPersistenceOptions _options; + private readonly GatewayTaskIdentity _identity; + private readonly Action _log; + private readonly Func _resolveUserSid; + + public GatewayPersistenceManager( + IGatewayTaskScheduler scheduler, + GatewayPersistenceOptions options, + Action? log = null, + Func? resolveUserSid = null) + { + ArgumentNullException.ThrowIfNull(scheduler); + ArgumentNullException.ThrowIfNull(options); + + _scheduler = scheduler; + _options = options; + _identity = GatewayTaskIdentity.Create( + options.PackageFamilyName, + options.UserSid); + _log = log ?? (_ => { }); + _resolveUserSid = resolveUserSid ?? (_ => null); + _ = LauncherPath; + _ = FallbackPath; + } + + public string TaskName => _identity.Name; + + /// + /// The Startup-folder file this installation owns. Scoped by package family + /// name so the public and internal packages cannot overwrite each other's + /// fallback in a folder they both write to. + /// + public string FallbackPath => ResolveOwnedFilePath( + _options.StartupFolderPath, + $"{GatewayTaskIdentity.DisplayName} {_options.PackageFamilyName}.cmd"); + + private string LauncherPath => ResolveOwnedFilePath( + _options.WorkingDirectory, + _options.LauncherPath); + + private string ActivationScriptPath => ResolveOwnedFilePath( + _options.WorkingDirectory, + Path.ChangeExtension(LauncherPath, ".ps1")); + + public async Task GetStatusAsync( + CancellationToken cancellationToken) + { + GatewayTaskProbe probe = await _scheduler.QueryAsync( + _identity.Name, + cancellationToken).ConfigureAwait(false); + + if (probe.Presence == GatewayTaskPresence.Unreadable) + { + return new GatewayPersistenceStatus( + GatewayPersistenceState.Unknown, + GatewayPersistenceLane.None, + "Logon recovery could not be read.", + probe.Detail, + RepairCommand); + } + + bool fallbackPresent = FallbackMatches() && LauncherMatches(); + + if (probe.Presence == GatewayTaskPresence.Missing) + { + return fallbackPresent + ? new GatewayPersistenceStatus( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.StartupFolderFallback, + "Logon recovery is configured through the Startup folder.", + "The logon task is not registered, so the Startup-folder " + + "fallback is in use. It runs later than the task and only " + + "for interactive sign-ins.", + RepairCommand) + : new GatewayPersistenceStatus( + GatewayPersistenceState.NotInstalled, + GatewayPersistenceLane.None, + "Logon recovery is not configured.", + null, + RepairCommand); + } + + string? drift = DescribeDrift(probe.Snapshot!); + if (drift is not null) + { + return new GatewayPersistenceStatus( + GatewayPersistenceState.ActionRequired, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is registered but does not match this installation.", + drift, + RepairCommand); + } + + if (!LauncherMatches()) + { + return new GatewayPersistenceStatus( + GatewayPersistenceState.ActionRequired, + GatewayPersistenceLane.TaskScheduler, + "The logon task is registered but its launcher is missing or modified.", + $"'{_options.LauncherPath}' does not contain the expected " + + "generated launcher, so the task would not start the gateway.", + RepairCommand); + } + + return new GatewayPersistenceStatus( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured."); + } + + public async Task InstallAsync( + CancellationToken cancellationToken) + { + bool changed; + try + { + changed = WriteLauncher(); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return new GatewayPersistenceInstallResult( + GatewayPersistenceState.ActionRequired, + GatewayPersistenceLane.None, + "The gateway launcher could not be written.", + Changed: false, + $"'{_options.LauncherPath}': {exception.Message}", + RepairCommand); + } + + GatewayTaskProbe probe = await _scheduler.QueryAsync( + _identity.Name, + cancellationToken).ConfigureAwait(false); + + // An unreadable probe is not a missing task. Registering anyway is an + // unbounded retry: the write is as likely to be refused as the read was. + if (probe.Presence == GatewayTaskPresence.Unreadable) + { + return new GatewayPersistenceInstallResult( + GatewayPersistenceState.Unknown, + GatewayPersistenceLane.None, + "Logon recovery could not be read, so it was left unchanged.", + changed, + probe.Detail, + RepairCommand); + } + + bool alreadyCorrect = + probe.Presence == GatewayTaskPresence.Present && + DescribeDrift(probe.Snapshot!) is null; + + if (alreadyCorrect) + { + _ = RemoveFallback(); + return new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + changed); + } + + GatewayTaskOperation registration = await _scheduler.RegisterAsync( + _identity.Name, + GatewayTaskDefinition.CreateXml(DesiredSnapshot(), _identity.Name), + cancellationToken).ConfigureAwait(false); + + if (registration.Succeeded) + { + _log($"Registered the logon task '{_identity.Name}'."); + _ = RemoveFallback(); + return new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true); + } + + return InstallFallback(registration.Detail); + } + + public async Task UninstallAsync( + CancellationToken cancellationToken) + { + GatewayTaskOperation deletion = await _scheduler.DeleteAsync( + _identity.Name, + cancellationToken).ConfigureAwait(false); + + CleanupResult fallback = RemoveFallback(); + CleanupResult launcher = RemoveLauncher(); + CleanupResult activationScript = RemoveActivationScript(); + bool changed = + fallback == CleanupResult.Removed || + launcher == CleanupResult.Removed || + activationScript == CleanupResult.Removed; + + if (!deletion.Succeeded || + fallback == CleanupResult.Failed || + launcher == CleanupResult.Failed || + activationScript == CleanupResult.Failed) + { + return new GatewayPersistenceRemovalResult( + Succeeded: false, + changed, + "Logon recovery could not be fully removed.", + Combine( + deletion.Succeeded ? null : deletion.Detail, + Combine( + fallback == CleanupResult.Failed + ? $"'{FallbackPath}' could not be removed." + : null, + launcher == CleanupResult.Failed + ? $"'{_options.LauncherPath}' could not be removed." + : null, + activationScript == CleanupResult.Failed + ? $"'{ActivationScriptPath}' could not be removed." + : null))); + } + + return new GatewayPersistenceRemovalResult( + Succeeded: true, + changed, + "Logon recovery is removed."); + } + + private GatewayTaskSnapshot DesiredSnapshot() => + GatewayTaskDefinition.CreateSnapshot( + _options.UserSid, + _options.CommandProcessorPath, + _options.LauncherPath); + + private string? DescribeDrift(GatewayTaskSnapshot actual) + { + GatewayTaskSnapshot desired = DesiredSnapshot(); + List differences = []; + + if (!actual.Enabled) + { + differences.Add("The task is disabled."); + } + + if (!actual.HasSingleLogonTrigger) + { + differences.Add("The task does not have exactly one logon trigger."); + } + else if (!actual.LogonTriggerEnabled) + { + differences.Add("The logon trigger is disabled."); + } + else if (!MatchesUserSid(actual.LogonTriggerUserId, desired.LogonTriggerUserId)) + { + differences.Add("The logon trigger is scoped to a different user."); + } + + if (!Same(actual.UserId, desired.UserId)) + { + differences.Add("The task runs as a different user."); + } + + if (!Same(actual.LogonType, desired.LogonType)) + { + differences.Add($"The logon type is '{actual.LogonType}'."); + } + + if (!Same(actual.RunLevel, desired.RunLevel)) + { + differences.Add($"The task runs at '{actual.RunLevel}'."); + } + + if (!Same(actual.MultipleInstancesPolicy, desired.MultipleInstancesPolicy)) + { + differences.Add( + "A second sign-in would not be suppressed by the " + + "duplicate-instance policy."); + } + + if (actual.DisallowStartIfOnBatteries || actual.StopIfGoingOnBatteries) + { + differences.Add("The task is gated on AC power."); + } + + if (!Same(actual.ExecutionTimeLimit, desired.ExecutionTimeLimit)) + { + differences.Add( + $"The task would be terminated after '{actual.ExecutionTimeLimit}'."); + } + + if (!actual.HasSingleExecAction) + { + differences.Add("The task does not have exactly one action."); + } + else if (!Same(actual.Command, desired.Command) || + !Same(actual.Arguments, desired.Arguments)) + { + differences.Add("The task runs a different command."); + } + + return differences.Count == 0 ? null : string.Join(" ", differences); + } + + private GatewayPersistenceInstallResult InstallFallback(string? taskDetail) + { + try + { + Directory.CreateDirectory(_options.StartupFolderPath); + WriteGeneratedFile( + FallbackPath, + GatewayLauncherScript.CreateFallback(_options.LauncherPath)); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return new GatewayPersistenceInstallResult( + GatewayPersistenceState.ActionRequired, + GatewayPersistenceLane.None, + "Logon recovery could not be configured.", + Changed: true, + Combine(taskDetail, $"'{FallbackPath}': {exception.Message}"), + RepairCommand); + } + + _log($"Registered the Startup-folder fallback at '{FallbackPath}'."); + return new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.StartupFolderFallback, + "Logon recovery is configured through the Startup folder.", + Changed: true, + Combine( + taskDetail, + "The logon task could not be registered, so the Startup-folder " + + "fallback was used. It runs later than the task and only for " + + "interactive sign-ins."), + RepairCommand); + } + + private bool WriteLauncher() + { + string content = GatewayLauncherScript.Create( + _options.WorkingDirectory, + ActivationScriptPath); + string activationScript = + GatewayLauncherScript.CreateActivationScript(_options.PackageFamilyName); + + Directory.CreateDirectory( + Path.GetDirectoryName(LauncherPath) + ?? throw new InvalidOperationException( + $"'{LauncherPath}' has no parent directory.")); + bool launcherChanged = WriteGeneratedFile(LauncherPath, content); + bool activationChanged = WriteGeneratedFile(ActivationScriptPath, activationScript); + return launcherChanged || activationChanged; + } + + private static bool WriteGeneratedFile(string path, string content) + { + if (File.Exists(path)) + { + string existing = File.ReadAllText(path); + if (string.Equals(existing, content, StringComparison.Ordinal)) + { + return false; + } + + if (!GatewayLauncherScript.LooksGenerated(existing)) + { + throw new IOException( + $"'{path}' exists but was not generated by OpenClaw."); + } + } + + File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return true; + } + + private bool LauncherMatches() + { + try + { + return File.Exists(LauncherPath) && + string.Equals( + File.ReadAllText(LauncherPath), + GatewayLauncherScript.Create( + _options.WorkingDirectory, + ActivationScriptPath), + StringComparison.Ordinal) && + File.Exists(ActivationScriptPath) && + string.Equals( + File.ReadAllText(ActivationScriptPath), + GatewayLauncherScript.CreateActivationScript( + _options.PackageFamilyName), + StringComparison.Ordinal); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return false; + } + } + + private bool FallbackMatches() + { + try + { + return File.Exists(FallbackPath) && + string.Equals( + File.ReadAllText(FallbackPath), + GatewayLauncherScript.CreateFallback(_options.LauncherPath), + StringComparison.Ordinal); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return false; + } + } + + private CleanupResult RemoveFallback() => + RemoveGeneratedFile(FallbackPath); + + private CleanupResult RemoveLauncher() => + RemoveGeneratedFile(LauncherPath); + + private CleanupResult RemoveActivationScript() => + RemoveGeneratedFile(ActivationScriptPath); + + private static CleanupResult RemoveGeneratedFile(string path) + { + // Only a file this installation generated is deleted. A same-named file + // someone else placed there is left alone. + try + { + if (!File.Exists(path) || + !GatewayLauncherScript.LooksGenerated(File.ReadAllText(path))) + { + return CleanupResult.Absent; + } + + File.Delete(path); + return CleanupResult.Removed; + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return CleanupResult.Failed; + } + } + + private bool MatchesUserSid(string userId, string desiredSid) => + Same(userId, desiredSid) || + Same(_resolveUserSid(userId) ?? string.Empty, desiredSid); + + private static string ResolveOwnedFilePath(string directory, string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string root = Path.GetFullPath(directory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string candidate = Path.GetFullPath( + Path.IsPathRooted(path) ? path : Path.Combine(root, path)); + string? parent = Path.GetDirectoryName(candidate); + if (!string.Equals(parent, root, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"'{path}' is not a direct child of '{directory}'.", + nameof(path)); + } + + TrustedPath.EnsureNoReparsePoints(root, candidate); + return candidate; + } + + private static string? Combine(string? first, string? second) => + (string.IsNullOrWhiteSpace(first), string.IsNullOrWhiteSpace(second)) switch + { + (true, true) => null, + (true, false) => second, + (false, true) => first, + _ => $"{first} {second}" + }; + + private static string? Combine(string? first, string? second, string? third) => + Combine(first, Combine(second, third)); + + private static bool Same(string left, string right) => + string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + + private enum CleanupResult + { + Absent, + Removed, + Failed, + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index f63bace5..c0d00559 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; using OpenClaw.Launcher.Session; namespace OpenClaw.Launcher.Gateway; @@ -5,6 +7,9 @@ namespace OpenClaw.Launcher.Gateway; /// Assembles gateway management from the running installation. internal sealed class GatewayRuntime { + private static readonly Guid StartupFolderId = + new("B97D20BB-F46A-4C97-BA10-5E3608430854"); + private GatewayRuntime(GatewayController controller, string helperPath) { Controller = controller; @@ -15,6 +20,78 @@ private GatewayRuntime(GatewayController controller, string helperPath) public string HelperPath { get; } + public static GatewayPersistenceManager CreateRecoveryManager(Action log) + { + ArgumentNullException.ThrowIfNull(log); + + HostPaths paths = HostPaths.Create(); + string packageFamilyName = paths.PackageFamilyName + ?? throw new SessionException( + "OpenClaw is not running from its installed package, so it cannot configure gateway recovery."); + string userSid = WindowsIdentity.GetCurrent().User?.Value + ?? throw new SessionException( + "The signed-in user's security identifier is unavailable, so gateway recovery cannot be configured."); + + return new GatewayPersistenceManager( + new SchTasksGatewayScheduler(), + new GatewayPersistenceOptions( + userSid, + packageFamilyName, + paths.GatewayLauncherPath, + GetStartupFolderPath(), + paths.StateRoot, + Path.Combine(Environment.SystemDirectory, "cmd.exe")), + log, + ResolveUserSid); + } + + private static string GetStartupFolderPath() + { + int result = SHGetKnownFolderPath( + StartupFolderId, + flags: 0, + token: IntPtr.Zero, + out IntPtr path); + if (result < 0) + { + throw new SessionException( + $"Windows could not resolve the Startup folder " + + $"(HRESULT 0x{result:X8})."); + } + + try + { + return Marshal.PtrToStringUni(path) + ?? throw new SessionException( + "Windows returned an empty Startup folder path."); + } + finally + { + Marshal.FreeCoTaskMem(path); + } + } + + [DllImport("shell32.dll")] + private static extern int SHGetKnownFolderPath( + in Guid folderId, + uint flags, + IntPtr token, + out IntPtr path); + + private static string? ResolveUserSid(string accountName) + { + try + { + return new NTAccount(accountName) + .Translate(typeof(SecurityIdentifier)) + .Value; + } + catch (IdentityNotMappedException) + { + return null; + } + } + public static GatewayRuntime Create( HostOptions options, Action log, diff --git a/src/OpenClaw.Launcher/Gateway/GatewayTaskContracts.cs b/src/OpenClaw.Launcher/Gateway/GatewayTaskContracts.cs new file mode 100644 index 00000000..424c466a --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskContracts.cs @@ -0,0 +1,70 @@ +namespace OpenClaw.Launcher.Gateway; + +/// +/// What a task probe found. Deliberately three-valued. +/// +internal enum GatewayTaskPresence +{ + /// Task Scheduler reported no such task. + Missing, + + /// The task exists and its definition was read. + Present, + + /// + /// The task could not be read. This is not the same as missing: the query + /// may have been refused, and re-registering on a refused read is an + /// unbounded retry whose write is as likely to be refused as the read was. + /// + Unreadable, +} + +internal sealed record GatewayTaskProbe( + GatewayTaskPresence Presence, + GatewayTaskSnapshot? Snapshot, + string? Detail) +{ + public static GatewayTaskProbe Missing { get; } = + new(GatewayTaskPresence.Missing, null, null); + + public static GatewayTaskProbe Present(GatewayTaskSnapshot snapshot) => + new(GatewayTaskPresence.Present, snapshot, null); + + public static GatewayTaskProbe Unreadable(string detail) => + new(GatewayTaskPresence.Unreadable, null, detail); +} + +internal sealed record GatewayTaskOperation(bool Succeeded, string? Detail) +{ + public static GatewayTaskOperation Success { get; } = new(true, null); + + public static GatewayTaskOperation Failure(string detail) => new(false, detail); +} + +/// +/// The Task Scheduler operations gateway persistence needs. +/// +/// +/// Behind an interface so lifecycle behavior is testable without registering +/// anything on the developer's machine. Tests must never create, run, or delete +/// a real scheduled task. +/// +internal interface IGatewayTaskScheduler +{ + Task QueryAsync( + string taskName, + CancellationToken cancellationToken); + + Task RegisterAsync( + string taskName, + string taskXml, + CancellationToken cancellationToken); + + Task DeleteAsync( + string taskName, + CancellationToken cancellationToken); + + Task RunAsync( + string taskName, + CancellationToken cancellationToken); +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs b/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs new file mode 100644 index 00000000..819ebc1d --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs @@ -0,0 +1,243 @@ +using System.Xml; +using System.Xml.Linq; + +namespace OpenClaw.Launcher.Gateway; + +/// +/// The settings of a registered logon task that this installation cares about. +/// +/// +/// Drift is compared field by field rather than by comparing XML text, because +/// Task Scheduler normalizes what it stores: it reorders elements, fills in +/// defaults, and rewrites the principal. A text comparison would therefore +/// report drift on every single probe. +/// +internal sealed record GatewayTaskSnapshot( + string UserId, + string LogonType, + string RunLevel, + bool Enabled, + bool HasSingleLogonTrigger, + bool LogonTriggerEnabled, + string LogonTriggerUserId, + bool HasSingleExecAction, + string MultipleInstancesPolicy, + bool DisallowStartIfOnBatteries, + bool StopIfGoingOnBatteries, + string ExecutionTimeLimit, + string Command, + string Arguments); + +/// +/// Builds the logon task this installation registers. +/// +internal static class GatewayTaskDefinition +{ + private const string TaskNamespace = + "http://schemas.microsoft.com/windows/2004/02/mit/task"; + + /// + /// No execution time limit. The gateway is long-running, and Task + /// Scheduler's default would terminate it after 72 hours. + /// + public const string NoExecutionTimeLimit = "PT0S"; + + /// + /// A second logon trigger must not start a second gateway. The existing + /// instance is authoritative, so the new one is dropped rather than + /// queued or allowed to run beside it. + /// + public const string IgnoreNewInstances = "IgnoreNew"; + + public static GatewayTaskSnapshot CreateSnapshot( + string userSid, + string commandProcessorPath, + string launcherPath) => + new( + UserId: userSid, + // InteractiveToken runs with the user's interactive session and + // desktop but without storing a password, which is what a per-user + // logon task needs. + LogonType: "InteractiveToken", + RunLevel: "LeastPrivilege", + Enabled: true, + HasSingleLogonTrigger: true, + LogonTriggerEnabled: true, + LogonTriggerUserId: userSid, + HasSingleExecAction: true, + MultipleInstancesPolicy: IgnoreNewInstances, + // A gateway that refuses to start on battery, or dies when the + // charger is unplugged, is a laptop user losing their agent for a + // reason they will never connect to power. + DisallowStartIfOnBatteries: false, + StopIfGoingOnBatteries: false, + ExecutionTimeLimit: NoExecutionTimeLimit, + Command: commandProcessorPath, + Arguments: $"/d /c \"\"{launcherPath}\"\""); + + public static string CreateXml(GatewayTaskSnapshot snapshot, string taskName) + { + ArgumentNullException.ThrowIfNull(snapshot); + + XNamespace ns = TaskNamespace; + var document = new XDocument( + new XDeclaration("1.0", "UTF-16", null), + new XElement( + ns + "Task", + new XAttribute("version", "1.3"), + new XElement( + ns + "RegistrationInfo", + new XElement(ns + "URI", "\\" + taskName), + new XElement( + ns + "Description", + "Starts the OpenClaw gateway when this user signs in.")), + new XElement( + ns + "Triggers", + new XElement( + ns + "LogonTrigger", + new XElement(ns + "Enabled", Bool(snapshot.LogonTriggerEnabled)), + new XElement(ns + "UserId", snapshot.LogonTriggerUserId))), + new XElement( + ns + "Principals", + new XElement( + ns + "Principal", + new XAttribute("id", "Author"), + new XElement(ns + "UserId", snapshot.UserId), + new XElement(ns + "LogonType", snapshot.LogonType), + new XElement(ns + "RunLevel", snapshot.RunLevel))), + new XElement( + ns + "Settings", + new XElement(ns + "Enabled", Bool(snapshot.Enabled)), + new XElement(ns + "Hidden", "false"), + new XElement( + ns + "MultipleInstancesPolicy", + snapshot.MultipleInstancesPolicy), + new XElement( + ns + "DisallowStartIfOnBatteries", + Bool(snapshot.DisallowStartIfOnBatteries)), + new XElement( + ns + "StopIfGoingOnBatteries", + Bool(snapshot.StopIfGoingOnBatteries)), + new XElement(ns + "StartWhenAvailable", "true"), + new XElement(ns + "RunOnlyIfNetworkAvailable", "false"), + new XElement(ns + "AllowHardTerminate", "true"), + new XElement(ns + "AllowStartOnDemand", "true"), + new XElement( + ns + "ExecutionTimeLimit", + snapshot.ExecutionTimeLimit), + new XElement(ns + "Priority", "7"), + new XElement( + ns + "IdleSettings", + new XElement(ns + "StopOnIdleEnd", "false"), + new XElement(ns + "RestartOnIdle", "false"))), + new XElement( + ns + "Actions", + new XAttribute("Context", "Author"), + new XElement( + ns + "Exec", + new XElement(ns + "Command", snapshot.Command), + new XElement(ns + "Arguments", snapshot.Arguments))))); + + using var writer = new StringWriter(); + using (var xml = XmlWriter.Create( + writer, + new XmlWriterSettings { Indent = true, OmitXmlDeclaration = false })) + { + document.Save(xml); + } + + return writer.ToString(); + } + + /// + /// Reads a registered task back into a snapshot. Returns false when the XML + /// cannot be understood, which is reported as unreadable rather than as a + /// missing or mismatched task. + /// + public static bool TryParse( + string xml, + out GatewayTaskSnapshot? snapshot, + out string? detail) + { + snapshot = null; + detail = null; + + try + { + XNamespace ns = TaskNamespace; + XElement root = XDocument.Parse(xml).Root + ?? throw new InvalidOperationException("The task XML is empty."); + + XElement? principal = root + .Element(ns + "Principals")? + .Elements(ns + "Principal") + .FirstOrDefault(); + List logonTriggers = root + .Element(ns + "Triggers")? + .Elements(ns + "LogonTrigger") + .ToList() ?? []; + List actions = root + .Element(ns + "Actions")? + .Elements() + .ToList() ?? []; + List execActions = + [.. actions.Where(element => element.Name == ns + "Exec")]; + XElement? settings = root.Element(ns + "Settings"); + XElement? trigger = logonTriggers.Count == 1 ? logonTriggers[0] : null; + XElement? action = actions.Count == 1 && execActions.Count == 1 + ? execActions[0] + : null; + + snapshot = new GatewayTaskSnapshot( + UserId: Text(principal, ns + "UserId"), + LogonType: Text(principal, ns + "LogonType"), + RunLevel: Text(principal, ns + "RunLevel", "LeastPrivilege"), + Enabled: Flag(settings, ns + "Enabled", true), + HasSingleLogonTrigger: logonTriggers.Count == 1, + LogonTriggerEnabled: Flag(trigger, ns + "Enabled", true), + LogonTriggerUserId: Text(trigger, ns + "UserId"), + HasSingleExecAction: actions.Count == 1 && execActions.Count == 1, + MultipleInstancesPolicy: Text( + settings, + ns + "MultipleInstancesPolicy", + "IgnoreNew"), + DisallowStartIfOnBatteries: Flag( + settings, + ns + "DisallowStartIfOnBatteries", + true), + StopIfGoingOnBatteries: Flag( + settings, + ns + "StopIfGoingOnBatteries", + true), + ExecutionTimeLimit: Text( + settings, + ns + "ExecutionTimeLimit", + "PT72H"), + Command: Text(action, ns + "Command"), + Arguments: Text(action, ns + "Arguments", string.Empty)); + return true; + } + catch (Exception ex) when ( + ex is XmlException or InvalidOperationException or FormatException) + { + detail = ex.Message; + return false; + } + } + + private static string Bool(bool value) => value ? "true" : "false"; + + private static string Text( + XElement? parent, + XName name, + string fallback = "") => + parent?.Element(name)?.Value.Trim() ?? fallback; + + private static bool Flag(XElement? parent, XName name, bool fallback) + { + string? value = parent?.Element(name)?.Value.Trim(); + return value is null + ? fallback + : bool.TryParse(value, out bool parsed) ? parsed : fallback; + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayTaskIdentity.cs b/src/OpenClaw.Launcher/Gateway/GatewayTaskIdentity.cs new file mode 100644 index 00000000..9ce79106 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskIdentity.cs @@ -0,0 +1,68 @@ +namespace OpenClaw.Launcher.Gateway; + +/// +/// Identifies this installation's logon task. +/// +/// +/// +/// Task Scheduler names are machine-wide while gateway persistence is per-user +/// and per-installation. A name scoped by neither would let one user's install +/// overwrite another's task and one user's uninstall delete it; a name scoped +/// only by user would let this package and the internal MSIX fight over the +/// same entry. +/// +/// +/// The SID is the identifier, not the account name: it is the only one +/// guaranteed unique across local, domain, and Entra accounts, and it does not +/// change when an account is renamed. +/// +/// +internal sealed record GatewayTaskIdentity +{ + public const string DisplayName = "OpenClaw Gateway"; + + private GatewayTaskIdentity(string name, string packageFamilyName, string userSid) + { + Name = name; + PackageFamilyName = packageFamilyName; + UserSid = userSid; + } + + /// The Task Scheduler name, without a folder path. + public string Name { get; } + + public string PackageFamilyName { get; } + + public string UserSid { get; } + + public static GatewayTaskIdentity Create(string packageFamilyName, string userSid) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packageFamilyName); + ArgumentException.ThrowIfNullOrWhiteSpace(userSid); + + // Task Scheduler treats a backslash as a folder separator, so a name + // carrying one would silently address a different folder. + if (packageFamilyName.Contains('\\', StringComparison.Ordinal) || + userSid.Contains('\\', StringComparison.Ordinal)) + { + throw new ArgumentException( + "A task name component may not contain a backslash.", + nameof(packageFamilyName)); + } + + return new GatewayTaskIdentity( + $"{DisplayName} {packageFamilyName} {userSid}", + packageFamilyName, + userSid); + } + + /// + /// True when belongs to this installation, for + /// any user. Used to recognize our own tasks without assuming who + /// registered them, and never to claim another package's task. + /// + public static bool BelongsToPackage(string taskName, string packageFamilyName) => + taskName.StartsWith( + $"{DisplayName} {packageFamilyName} ", + StringComparison.OrdinalIgnoreCase); +} diff --git a/src/OpenClaw.Launcher/Gateway/SchTasksGatewayScheduler.cs b/src/OpenClaw.Launcher/Gateway/SchTasksGatewayScheduler.cs new file mode 100644 index 00000000..c56e84e7 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/SchTasksGatewayScheduler.cs @@ -0,0 +1,438 @@ +using System.Diagnostics; +using System.Text; + +namespace OpenClaw.Launcher.Gateway; + +/// +/// Drives Task Scheduler through the inbox schtasks.exe. +/// +/// +/// +/// The COM Task Scheduler API is not usable here: this launcher is published +/// with NativeAOT, and the interop that API needs is exactly what NativeAOT +/// cannot generate. The inbox executable is present on every supported Windows +/// installation and takes the same structured task XML. +/// +/// +/// Nothing in this type elevates. Registration must run as the signed-in user: +/// a task registered elevated is owned by BUILTIN\Administrators, and +/// the user can then neither overwrite nor delete their own gateway task. +/// +/// +internal sealed class SchTasksGatewayScheduler : IGatewayTaskScheduler +{ + private readonly string _executablePath; + private readonly Func>? _run; + + public SchTasksGatewayScheduler() + : this(ResolveDefaultExecutablePath()) + { + } + + internal SchTasksGatewayScheduler(string executablePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + _executablePath = executablePath; + } + + // Tests drive classification without launching schtasks.exe, which must + // never register, run, or delete a real task on a developer's machine. + internal SchTasksGatewayScheduler( + Func> run) + { + ArgumentNullException.ThrowIfNull(run); + _executablePath = "schtasks.exe"; + _run = run; + } + + public async Task QueryAsync( + string taskName, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskName); + + SchTasksOutcome outcome; + try + { + outcome = await RunAsync( + ["/Query", "/TN", taskName, "/XML", "ONE"], + cancellationToken).ConfigureAwait(false); + } + catch (SchTasksLaunchException exception) + { + return GatewayTaskProbe.Unreadable(exception.Message); + } + + if (outcome.ExitCode != 0) + { + // "Not found" and "refused" must not collapse into one answer. A + // caller that re-registers on a refused read retries forever: the + // write is as likely to be refused as the read was. + return await ClassifyQueryFailureAsync(taskName, outcome, cancellationToken) + .ConfigureAwait(false); + } + + return GatewayTaskDefinition.TryParse( + outcome.StandardOutput, + out GatewayTaskSnapshot? snapshot, + out string? detail) + ? GatewayTaskProbe.Present(snapshot!) + : GatewayTaskProbe.Unreadable( + $"The registered task definition could not be read: {detail}"); + } + + public async Task RegisterAsync( + string taskName, + string taskXml, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskName); + ArgumentNullException.ThrowIfNull(taskXml); + + string xmlPath = Path.Combine( + Path.GetTempPath(), + $"openclaw-gateway-task-{Guid.NewGuid():n}.xml"); + + try + { + // The definition declares UTF-16, so the file has to be UTF-16 with + // a byte-order mark or schtasks rejects it. + await File.WriteAllTextAsync( + xmlPath, + taskXml, + new UnicodeEncoding(bigEndian: false, byteOrderMark: true), + cancellationToken).ConfigureAwait(false); + + SchTasksOutcome outcome = await RunAsync( + ["/Create", "/TN", taskName, "/XML", xmlPath, "/F"], + cancellationToken).ConfigureAwait(false); + + return outcome.ExitCode == 0 + ? GatewayTaskOperation.Success + : GatewayTaskOperation.Failure(Describe(outcome)); + } + catch (SchTasksLaunchException exception) + { + return GatewayTaskOperation.Failure(exception.Message); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return GatewayTaskOperation.Failure( + $"The task definition could not be staged at '{xmlPath}': " + + exception.Message); + } + finally + { + TryDelete(xmlPath); + } + } + + public async Task DeleteAsync( + string taskName, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskName); + + SchTasksOutcome outcome; + try + { + outcome = await RunAsync( + ["/Delete", "/TN", taskName, "/F"], + cancellationToken).ConfigureAwait(false); + } + catch (SchTasksLaunchException exception) + { + return GatewayTaskOperation.Failure(exception.Message); + } + + // Deleting what is already gone is the requested end state. + if (outcome.ExitCode == 0) + { + return GatewayTaskOperation.Success; + } + + return await IsAbsentAsync(taskName, cancellationToken).ConfigureAwait(false) + ? GatewayTaskOperation.Success + : GatewayTaskOperation.Failure(Describe(outcome)); + } + + public async Task RunAsync( + string taskName, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskName); + + SchTasksOutcome outcome; + try + { + outcome = await RunAsync(["/Run", "/TN", taskName], cancellationToken) + .ConfigureAwait(false); + } + catch (SchTasksLaunchException exception) + { + return GatewayTaskOperation.Failure(exception.Message); + } + + return outcome.ExitCode == 0 + ? GatewayTaskOperation.Success + : GatewayTaskOperation.Failure(Describe(outcome)); + } + + private static string ResolveDefaultExecutablePath() + { + // An absolute path to the inbox executable, so a same-named program + // earlier on PATH can never be the one that registers a logon task. + string system = Environment.GetFolderPath(Environment.SpecialFolder.System); + return string.IsNullOrEmpty(system) + ? "schtasks.exe" + : Path.Combine(system, "schtasks.exe"); + } + + /// + /// Separates a missing task from a refused one without reading localized + /// diagnostics. + /// + /// + /// + /// schtasks.exe writes its errors in the console's display language + /// and returns exit code 1 for both "no such task" and "access denied", so + /// neither the text nor the exit code can classify the failure. Matching + /// English phrases made every absent task look unreadable on a localized + /// Windows installation, which suppressed both registration and the + /// Startup-folder fallback and left setup unable to reach Ready. + /// + /// + /// Enumeration answers the same question structurally. The listing reports + /// the task names this account can see, and the name being looked for is + /// one this package generated, so the comparison is ordinal and carries no + /// language. A successful listing that omits the name proves absence; a + /// listing that contains it proves the earlier read was refused rather + /// than empty; a listing that fails leaves the question open. + /// + /// + private async Task ClassifyQueryFailureAsync( + string taskName, + SchTasksOutcome queryOutcome, + CancellationToken cancellationToken) + { + SchTasksOutcome listing; + try + { + listing = await RunAsync( + ["/Query", "/FO", "CSV", "/NH"], + cancellationToken).ConfigureAwait(false); + } + catch (SchTasksLaunchException exception) + { + return GatewayTaskProbe.Unreadable( + Combine(Describe(queryOutcome), exception.Message)); + } + + if (listing.ExitCode != 0) + { + return GatewayTaskProbe.Unreadable( + Combine(Describe(queryOutcome), Describe(listing))); + } + + return ListingContains(listing.StandardOutput, taskName) + ? GatewayTaskProbe.Unreadable(Describe(queryOutcome)) + : GatewayTaskProbe.Missing; + } + + /// + /// Reports whether this account can see that the task is gone. + /// + /// + /// Answers false when the listing itself fails, because an unanswerable + /// question is not evidence of absence. + /// + private async Task IsAbsentAsync( + string taskName, + CancellationToken cancellationToken) + { + SchTasksOutcome listing; + try + { + listing = await RunAsync( + ["/Query", "/FO", "CSV", "/NH"], + cancellationToken).ConfigureAwait(false); + } + catch (SchTasksLaunchException) + { + return false; + } + + return listing.ExitCode == 0 && + !ListingContains(listing.StandardOutput, taskName); + } + + /// + /// Reports whether the CSV listing names this task. + /// + /// + /// Only the first column is considered. The remaining columns carry the + /// next run time and a localized status, neither of which identifies a + /// task. Task Scheduler reports names as absolute paths, so a caller's + /// leading separator is optional. + /// + internal static bool ListingContains(string listing, string taskName) + { + ArgumentNullException.ThrowIfNull(listing); + ArgumentException.ThrowIfNullOrWhiteSpace(taskName); + + string wanted = taskName.TrimStart('\\'); + foreach (string line in listing.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!line.StartsWith('"')) + { + continue; + } + + int closing = line.IndexOf('"', 1); + if (closing <= 1) + { + continue; + } + + string name = line[1..closing].TrimStart('\\'); + if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string Combine(string first, string second) => + string.IsNullOrWhiteSpace(second) ? first : $"{first} {second}"; + + private static string Describe(SchTasksOutcome outcome) + { + string message = FirstMeaningfulLine(outcome.StandardError) + ?? FirstMeaningfulLine(outcome.StandardOutput) + ?? "schtasks.exe reported no diagnostics."; + return $"schtasks.exe exited with code {outcome.ExitCode}. {message}"; + } + + private static string? FirstMeaningfulLine(string text) + { + foreach (string line in text.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + return line; + } + + return null; + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + // A leftover temporary file is not worth failing a registration + // that already succeeded. + } + } + + private async Task RunAsync( + string[] arguments, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_run is not null) + { + return await _run(arguments, cancellationToken).ConfigureAwait(false); + } + + ProcessStartInfo startInfo = new() + { + FileName = _executablePath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + + // schtasks uses the console output code page when redirected. + StandardOutputEncoding = Console.OutputEncoding, + StandardErrorEncoding = Console.OutputEncoding + }; + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + } + catch (Exception exception) when ( + exception is System.ComponentModel.Win32Exception or + InvalidOperationException) + { + throw new SchTasksLaunchException( + $"'{_executablePath}' could not be started: {exception.Message}", + exception); + } + + // Both streams are drained concurrently so a full pipe buffer on one + // cannot block the child before it exits. + Task standardOutput = process.StandardOutput.ReadToEndAsync( + cancellationToken); + Task standardError = process.StandardError.ReadToEndAsync( + cancellationToken); + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + + throw; + } + + return new SchTasksOutcome( + process.ExitCode, + await standardOutput.ConfigureAwait(false), + await standardError.ConfigureAwait(false)); + } + + internal sealed record SchTasksOutcome( + int ExitCode, + string StandardOutput, + string StandardError); + + private sealed class SchTasksLaunchException : Exception + { + public SchTasksLaunchException() + { + } + + public SchTasksLaunchException(string message) + : base(message) + { + } + + public SchTasksLaunchException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/OpenClaw.Launcher/HostEntrypoint.cs b/src/OpenClaw.Launcher/HostEntrypoint.cs index 5ee95594..d881f7f1 100644 --- a/src/OpenClaw.Launcher/HostEntrypoint.cs +++ b/src/OpenClaw.Launcher/HostEntrypoint.cs @@ -17,10 +17,19 @@ internal static class HostEntrypointResolver private static extern IntPtr GetCommandLineW(); public static HostEntrypoint Resolve() => - Resolve(TryGetNativeCommandLine()); + Resolve(TryGetNativeCommandLine(), PackageIdentity.TryGetApplicationUserModelId()); - internal static HostEntrypoint Resolve(string? commandLine) + internal static HostEntrypoint Resolve( + string? commandLine, + string? applicationUserModelId = null) { + if (applicationUserModelId?.EndsWith( + "!" + Gateway.GatewayLauncherScript.ControlApplicationId, + StringComparison.OrdinalIgnoreCase) == true) + { + return HostEntrypoint.Control; + } + return TryMatch(GetInvokedName(commandLine), out HostEntrypoint invoked) ? invoked : HostEntrypoint.Agent; diff --git a/src/OpenClaw.Launcher/HostPaths.cs b/src/OpenClaw.Launcher/HostPaths.cs index 886c9ebc..32c1dd1f 100644 --- a/src/OpenClaw.Launcher/HostPaths.cs +++ b/src/OpenClaw.Launcher/HostPaths.cs @@ -9,6 +9,7 @@ internal static class PackageIdentity { private const int ErrorInsufficientBuffer = 122; private const int AppModelErrorNoPackage = 15700; + private const int AppModelErrorNoApplication = 15703; /// /// Prefix required by MXC when a packaged caller provisions a sandbox. @@ -29,9 +30,25 @@ internal static class PackageIdentity return null; } + return TryGetPackageIdentity(GetCurrentPackageFamilyName); + } + + public static string? TryGetApplicationUserModelId() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + return TryGetPackageIdentity(GetCurrentApplicationUserModelId); + } + + private static string? TryGetPackageIdentity( + PackageIdentityReader readIdentity) + { uint length = 0; - int result = GetCurrentPackageFamilyName(ref length, null); - if (result == AppModelErrorNoPackage) + int result = readIdentity(ref length, null); + if (result is AppModelErrorNoPackage or AppModelErrorNoApplication) { return null; } @@ -43,7 +60,7 @@ internal static class PackageIdentity } var value = new char[length]; - result = GetCurrentPackageFamilyName(ref length, value); + result = readIdentity(ref length, value); if (result != 0) { throw new InvalidOperationException( @@ -69,6 +86,16 @@ private static extern int GetCurrentPackageFamilyName( ref uint packageFamilyNameLength, [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] char[]? packageFamilyName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentApplicationUserModelId( + ref uint applicationUserModelIdLength, + [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] + char[]? applicationUserModelId); + + private delegate int PackageIdentityReader( + ref uint length, + char[]? value); } /// diff --git a/src/OpenClaw.Launcher/Package.appxmanifest b/src/OpenClaw.Launcher/Package.appxmanifest index 5781f265..c686cbf0 100644 --- a/src/OpenClaw.Launcher/Package.appxmanifest +++ b/src/OpenClaw.Launcher/Package.appxmanifest @@ -47,6 +47,27 @@ EntryPoint="Windows.FullTrustApplication"> + + + + + + + + + diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 20d19130..2fea084c 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -255,7 +255,11 @@ internal static async Task RunControlAsync( TextWriter output, TextWriter error, Func>? resolveNode = null, - Func? createSessionRuntime = null) + Func? createSessionRuntime = null, + Func>? + installRecovery = null, + Func>? + removeRecovery = null) { Session.SessionRuntime? sessionRuntime = null; Session.SessionRuntime GetSessionRuntime() => @@ -305,10 +309,25 @@ await runtime.Executor.InstallRuntimeAsync( GetPackagedNodeArchivePath(options), cancellationToken) .ConfigureAwait(false); - runtime.CompleteSetup( - record, - agentRuntime, - startupEnabled: false); + Gateway.GatewayPersistenceInstallResult recovery = + installRecovery is null + ? await Gateway.GatewayRuntime.CreateRecoveryManager(log) + .InstallAsync(cancellationToken) + .ConfigureAwait(false) + : await installRecovery(cancellationToken) + .ConfigureAwait(false); + await output.WriteLineAsync(recovery.Message).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(recovery.Detail)) + { + await output.WriteLineAsync(recovery.Detail).ConfigureAwait(false); + } + + if (recovery.State != Gateway.GatewayPersistenceState.Ready) + { + return 1; + } + + runtime.CompleteSetup(record, agentRuntime, startupEnabled: true); await output.WriteLineAsync("OpenClaw isolated session is ready.") .ConfigureAwait(false); return 0; @@ -348,6 +367,22 @@ await error.WriteLineAsync( Session.SessionRuntime runtime = GetSessionRuntime(); using Session.ISessionLockHandle handle = runtime.AcquireLifecycleLock(); + Gateway.GatewayPersistenceRemovalResult recoveryRemoval = + removeRecovery is null + ? await Gateway.GatewayRuntime + .CreateRecoveryManager(log) + .UninstallAsync(cancellationToken) + .ConfigureAwait(false) + : await removeRecovery(cancellationToken) + .ConfigureAwait(false); + if (!recoveryRemoval.Succeeded) + { + await error.WriteLineAsync( + $"OpenClaw recovery could not be removed: {recoveryRemoval.Detail}") + .ConfigureAwait(false); + return 1; + } + await runtime.Coordinator.RemoveAsync(cancellationToken) .ConfigureAwait(false); runtime.GatewayState.Clear(); diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/FakeGatewayTaskScheduler.cs b/tests/OpenClaw.Launcher.Tests/Gateway/FakeGatewayTaskScheduler.cs new file mode 100644 index 00000000..de046edb --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/FakeGatewayTaskScheduler.cs @@ -0,0 +1,59 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +/// +/// A Task Scheduler that records what it was asked to do. Tests must never +/// register, run, or delete a real scheduled task on the developer's machine. +/// +internal sealed class FakeGatewayTaskScheduler : IGatewayTaskScheduler +{ + public List Calls { get; } = []; + + public GatewayTaskProbe Probe { get; set; } = GatewayTaskProbe.Missing; + + public GatewayTaskOperation RegisterResult { get; set; } = + GatewayTaskOperation.Success; + + public GatewayTaskOperation DeleteResult { get; set; } = + GatewayTaskOperation.Success; + + public GatewayTaskOperation RunResult { get; set; } = + GatewayTaskOperation.Success; + + public string? RegisteredXml { get; private set; } + + public Task QueryAsync( + string taskName, + CancellationToken cancellationToken) + { + Calls.Add($"query:{taskName}"); + return Task.FromResult(Probe); + } + + public Task RegisterAsync( + string taskName, + string taskXml, + CancellationToken cancellationToken) + { + Calls.Add($"register:{taskName}"); + RegisteredXml = taskXml; + return Task.FromResult(RegisterResult); + } + + public Task DeleteAsync( + string taskName, + CancellationToken cancellationToken) + { + Calls.Add($"delete:{taskName}"); + return Task.FromResult(DeleteResult); + } + + public Task RunAsync( + string taskName, + CancellationToken cancellationToken) + { + Calls.Add($"run:{taskName}"); + return Task.FromResult(RunResult); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs new file mode 100644 index 00000000..5125142a --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs @@ -0,0 +1,630 @@ +using System.Diagnostics; +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class GatewayPersistenceManagerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + private readonly FakeGatewayTaskScheduler _scheduler = new(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private string StateRoot => Path.Combine(_root, "state"); + + private string StartupFolder => Path.Combine(_root, "startup"); + + private string LauncherPath => Path.Combine(StateRoot, "gateway-launcher.cmd"); + + private string ActivationScriptPath => Path.ChangeExtension(LauncherPath, ".ps1"); + + private GatewayPersistenceManager CreateManager( + Func? resolveUserSid = null) => + new( + _scheduler, + new GatewayPersistenceOptions( + UserSid: "S-1-5-21-1", + PackageFamilyName: "OpenClaw.Gateway_test", + LauncherPath: LauncherPath, + StartupFolderPath: StartupFolder, + WorkingDirectory: StateRoot, + CommandProcessorPath: @"C:\Windows\System32\cmd.exe"), + resolveUserSid: resolveUserSid); + + private GatewayTaskSnapshot DesiredSnapshot() => + GatewayTaskDefinition.CreateSnapshot( + "S-1-5-21-1", + @"C:\Windows\System32\cmd.exe", + LauncherPath); + + [Theory] + [InlineData(@"C:\outside\gateway-launcher.cmd")] + [InlineData(@"state\child\gateway-launcher.cmd")] + public void RejectsLauncherPathOutsideTheStateRoot(string launcherPath) + { + GatewayPersistenceOptions options = new( + UserSid: "S-1-5-21-1", + PackageFamilyName: "OpenClaw.Gateway_test", + LauncherPath: Path.IsPathRooted(launcherPath) + ? launcherPath + : Path.Combine(_root, launcherPath), + StartupFolderPath: StartupFolder, + WorkingDirectory: StateRoot, + CommandProcessorPath: @"C:\Windows\System32\cmd.exe"); + + Assert.Throws(() => + new GatewayPersistenceManager(_scheduler, options)); + } + + [Fact] + public async Task InstallingWritesTheLauncherAndRegistersTheTask() + { + GatewayPersistenceManager manager = CreateManager(); + + GatewayPersistenceInstallResult result = + await manager.InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.Equal(GatewayPersistenceLane.TaskScheduler, result.Lane); + Assert.True(result.Changed); + Assert.True(File.Exists(LauncherPath)); + Assert.True(File.Exists(ActivationScriptPath)); + Assert.Contains($"register:{manager.TaskName}", _scheduler.Calls); + } + + [Fact] + public async Task TheRegisteredActionRunsTheLauncherNotTheAliasDirectly() + { + // The indirection is the point: the launcher can be rewritten + // unelevated, whereas changing the task's own action needs another + // registration and another consent prompt. + await CreateManager().InstallAsync(CancellationToken.None); + + Assert.NotNull(_scheduler.RegisteredXml); + Assert.Contains(LauncherPath, _scheduler.RegisteredXml, StringComparison.Ordinal); + Assert.DoesNotContain( + "clawctl.exe", + _scheduler.RegisteredXml, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TheGeneratedLauncherActivatesTheOwningPackageControlApplication() + { + await CreateManager().InstallAsync(CancellationToken.None); + + string launcher = await File.ReadAllTextAsync(LauncherPath, CancellationToken.None); + string activation = await File.ReadAllTextAsync(ActivationScriptPath, CancellationToken.None); + + Assert.Contains("chcp 65001", launcher, StringComparison.OrdinalIgnoreCase); + Assert.Contains("OpenClaw.Gateway_test!Control", activation, StringComparison.Ordinal); + Assert.Contains("ActivateApplication", activation, StringComparison.Ordinal); + Assert.Contains("gateway-service start", activation, StringComparison.Ordinal); + Assert.DoesNotContain("WindowsApps", launcher + activation, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TheControlActivationScriptFailsClosedOnMissingOrMismatchedTargets() + { + await CreateManager().InstallAsync(CancellationToken.None); + + string activation = await File.ReadAllTextAsync(ActivationScriptPath, CancellationToken.None); + + Assert.Contains("PackageFamilyName -eq $packageFamilyName", activation, StringComparison.Ordinal); + Assert.Contains("does not declare application '$applicationId'", activation, StringComparison.Ordinal); + Assert.Contains("does not target openclaw.exe", activation, StringComparison.Ordinal); + } + + [Fact] + public async Task AMatchingRegistrationIsNotRewritten() + { + _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot()); + GatewayPersistenceManager manager = CreateManager(); + + GatewayPersistenceInstallResult result = + await manager.InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.DoesNotContain( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AnAccountNameTriggerResolvingToTheOwnerSidIsNotRewritten() + { + GatewayTaskSnapshot scheduledTask = DesiredSnapshot() with + { + LogonTriggerUserId = @"CONTOSO\agent", + }; + _scheduler.Probe = GatewayTaskProbe.Present(scheduledTask); + + GatewayPersistenceInstallResult result = await CreateManager( + account => account == @"CONTOSO\agent" ? "S-1-5-21-1" : null) + .InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.DoesNotContain( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AnUnresolvableAccountNameTriggerIsRewritten() + { + GatewayTaskSnapshot scheduledTask = DesiredSnapshot() with + { + LogonTriggerUserId = @"CONTOSO\former-agent", + }; + _scheduler.Probe = GatewayTaskProbe.Present(scheduledTask); + + GatewayPersistenceInstallResult result = await CreateManager(_ => null) + .InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.Contains( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + } + + [Fact] + public async Task ATaskPointingAtAnOldPackageVersionIsRewritten() + { + _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot() with + { + Command = Path.Combine(_root, "old-package", "openclaw.exe"), + Arguments = "gateway-service start", + }); + + GatewayPersistenceInstallResult result = + await CreateManager().InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.Contains( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + } + + [Fact] + public async Task AnUnreadableProbeLeavesTheRegistrationAlone() + { + // Re-registering on a refused read is an unbounded retry: the write is + // as likely to be refused as the read was. + _scheduler.Probe = GatewayTaskProbe.Unreadable("Access is denied."); + + GatewayPersistenceInstallResult result = + await CreateManager().InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Unknown, result.State); + Assert.DoesNotContain( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + Assert.Equal(GatewayPersistenceManager.RepairCommand, result.Remediation); + } + + [Fact] + public async Task AFailedRegistrationFallsBackToTheStartupFolderAndSaysSo() + { + _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied."); + GatewayPersistenceManager manager = CreateManager(); + + GatewayPersistenceInstallResult result = + await manager.InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.Equal(GatewayPersistenceLane.StartupFolderFallback, result.Lane); + Assert.True(File.Exists(manager.FallbackPath)); + Assert.Contains("Access is denied.", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public async Task TheFallbackCallsTheSameLauncherAsTheTask() + { + _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied."); + GatewayPersistenceManager manager = CreateManager(); + + await manager.InstallAsync(CancellationToken.None); + + Assert.Contains( + LauncherPath, + await File.ReadAllTextAsync( + manager.FallbackPath, + CancellationToken.None), + StringComparison.Ordinal); + } + + [Fact] + public async Task FallbackStatusRequiresAnUnmodifiedLauncher() + { + _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied."); + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + await File.WriteAllTextAsync( + LauncherPath, + GatewayLauncherScript.Create(StateRoot, "stale.ps1"), + CancellationToken.None); + + GatewayPersistenceStatus status = + await manager.GetStatusAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.NotInstalled, status.State); + } + + [Fact] + public async Task FallbackStatusRejectsAGeneratedMarkerWithARedirectedTarget() + { + _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied."); + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + await File.WriteAllTextAsync( + manager.FallbackPath, + GatewayLauncherScript.CreateFallback(Path.Combine(_root, "redirected.cmd")), + CancellationToken.None); + + GatewayPersistenceStatus status = + await manager.GetStatusAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.NotInstalled, status.State); + } + + [Fact] + public async Task GeneratedCommandFilesUseUtf8WithoutABom() + { + _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied."); + GatewayPersistenceManager manager = CreateManager(); + + await manager.InstallAsync(CancellationToken.None); + + Assert.DoesNotContain((byte)0, await File.ReadAllBytesAsync(LauncherPath)); + Assert.DoesNotContain((byte)0, await File.ReadAllBytesAsync(manager.FallbackPath)); + Assert.NotEqual(new byte[] { 0xEF, 0xBB, 0xBF }, + (await File.ReadAllBytesAsync(LauncherPath))[..3]); + } + + [Fact] + public async Task ASucceedingRegistrationRemovesAPreviousFallback() + { + _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied."); + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + + _scheduler.RegisterResult = GatewayTaskOperation.Success; + await manager.InstallAsync(CancellationToken.None); + + Assert.False(File.Exists(manager.FallbackPath)); + } + + [Fact] + public async Task StatusReportsNotInstalledWithoutRegisteringAnything() + { + GatewayPersistenceManager manager = CreateManager(); + + GatewayPersistenceStatus status = + await manager.GetStatusAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.NotInstalled, status.State); + Assert.Equal(GatewayPersistenceLane.None, status.Lane); + Assert.DoesNotContain( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + Assert.False(File.Exists(LauncherPath)); + } + + [Fact] + public async Task StatusReportsAnUnreadableProbeAsUnknown() + { + _scheduler.Probe = GatewayTaskProbe.Unreadable("Access is denied."); + + GatewayPersistenceStatus status = + await CreateManager().GetStatusAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Unknown, status.State); + } + + [Theory] + [InlineData("disabled")] + [InlineData("battery")] + [InlineData("elevated")] + [InlineData("other-user")] + [InlineData("time-limit")] + [InlineData("other-command")] + public async Task StatusReportsDriftWithTheCommandThatRepairsIt(string kind) + { + await CreateManager().InstallAsync(CancellationToken.None); + GatewayTaskSnapshot desired = DesiredSnapshot(); + _scheduler.Probe = GatewayTaskProbe.Present(kind switch + { + "disabled" => desired with { Enabled = false }, + "battery" => desired with { DisallowStartIfOnBatteries = true }, + "elevated" => desired with { RunLevel = "HighestAvailable" }, + "other-user" => desired with { UserId = "S-1-5-21-999" }, + "time-limit" => desired with { ExecutionTimeLimit = "PT72H" }, + _ => desired with { Command = @"C:\Windows\System32\notepad.exe" } + }); + + GatewayPersistenceStatus status = + await CreateManager().GetStatusAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.ActionRequired, status.State); + Assert.Equal(GatewayPersistenceManager.RepairCommand, status.Remediation); + Assert.NotNull(status.Detail); + } + + [Fact] + public async Task AMissingLauncherIsReportedEvenWhenTheTaskIsCorrect() + { + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + File.Delete(LauncherPath); + _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot()); + + GatewayPersistenceStatus status = + await manager.GetStatusAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.ActionRequired, status.State); + } + + [Fact] + public async Task AnInstalledInstallationIsReportedAsReady() + { + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot()); + + GatewayPersistenceStatus status = + await manager.GetStatusAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, status.State); + Assert.Equal(GatewayPersistenceLane.TaskScheduler, status.Lane); + Assert.Null(status.Detail); + } + + [Fact] + public async Task UninstallingRemovesTheTaskLauncherAndFallback() + { + _scheduler.RegisterResult = GatewayTaskOperation.Failure("Access is denied."); + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + + GatewayPersistenceRemovalResult result = + await manager.UninstallAsync(CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.True(result.Changed); + Assert.False(File.Exists(LauncherPath)); + Assert.False(File.Exists(ActivationScriptPath)); + Assert.False(File.Exists(manager.FallbackPath)); + Assert.Contains($"delete:{manager.TaskName}", _scheduler.Calls); + } + + [Fact] + public async Task UninstallingLeavesAFileThisInstallationDidNotGenerate() + { + GatewayPersistenceManager manager = CreateManager(); + Directory.CreateDirectory(StartupFolder); + await File.WriteAllTextAsync( + manager.FallbackPath, + "@echo someone else's script", + CancellationToken.None); + + await manager.UninstallAsync(CancellationToken.None); + + Assert.True(File.Exists(manager.FallbackPath)); + } + + [Fact] + public async Task InstallingLeavesALauncherThisInstallationDidNotGenerate() + { + GatewayPersistenceManager manager = CreateManager(); + Directory.CreateDirectory(StateRoot); + await File.WriteAllTextAsync( + LauncherPath, + "@echo someone else's launcher", + CancellationToken.None); + + GatewayPersistenceInstallResult result = + await manager.InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.ActionRequired, result.State); + Assert.Equal( + "@echo someone else's launcher", + await File.ReadAllTextAsync(LauncherPath, CancellationToken.None)); + Assert.DoesNotContain( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + } + + [Fact] + public async Task UninstallingWithNoArtifactsIsIdempotent() + { + GatewayPersistenceRemovalResult result = + await CreateManager().UninstallAsync(CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.False(result.Changed); + Assert.Null(result.Detail); + } + + [Fact] + public async Task AFailedDeletionIsReportedRatherThanClaimedAsSuccess() + { + _scheduler.DeleteResult = GatewayTaskOperation.Failure("Access is denied."); + + GatewayPersistenceRemovalResult result = + await CreateManager().UninstallAsync(CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Contains("Access is denied.", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public async Task AGeneratedLauncherThatCannotBeDeletedIsReported() + { + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + + using (FileStream handle = new( + LauncherPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read)) + { + GatewayPersistenceRemovalResult result = + await manager.UninstallAsync(CancellationToken.None).ConfigureAwait(true); + + Assert.False(result.Succeeded); + Assert.Contains(LauncherPath, result.Detail, StringComparison.Ordinal); + } + } + + [Fact] + public async Task ThePackageActivationScriptCanChangeWithoutReRegisteringTheTask() + { + GatewayPersistenceManager manager = CreateManager(); + await manager.InstallAsync(CancellationToken.None); + _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot()); + string before = _scheduler.RegisteredXml!; + + await File.WriteAllTextAsync( + ActivationScriptPath, + GatewayLauncherScript.CreateActivationScript("OpenClaw.Gateway_stale"), + CancellationToken.None); + GatewayPersistenceInstallResult result = + await manager.InstallAsync(CancellationToken.None); + + Assert.True(result.Changed); + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.Equal(before, _scheduler.RegisteredXml); + Assert.Contains( + "OpenClaw.Gateway_test!Control", + await File.ReadAllTextAsync( + ActivationScriptPath, + CancellationToken.None), + StringComparison.Ordinal); + } + + [Fact] + public void ThePackageActivationSourceCompilesWithWindowsPowerShell() + { + string script = GatewayLauncherScript.CreateActivationScript( + "OpenClaw.Gateway_test"); + int sourceIndex = script.IndexOf( + "$source = @'", + StringComparison.Ordinal); + int addTypeIndex = script.IndexOf( + "Add-Type -TypeDefinition $source -Language CSharp", + sourceIndex, + StringComparison.Ordinal); + Assert.True(sourceIndex > 0); + Assert.True(addTypeIndex > sourceIndex); + int addTypeEnd = script.IndexOf("\r\n", addTypeIndex, StringComparison.Ordinal); + Assert.True(addTypeEnd > addTypeIndex); + string compileOnlyPath = Path.Combine(_root, "compile-activation.ps1"); + File.WriteAllText( + compileOnlyPath, + string.Concat( + "$ErrorActionPreference = 'Stop'\r\n", + script.AsSpan(sourceIndex, addTypeEnd - sourceIndex), + "\r\nexit 0\r\n")); + + using Process process = Process.Start(new ProcessStartInfo + { + FileName = Path.Combine( + Environment.SystemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + ArgumentList = + { + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + compileOnlyPath + } + })!; + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + Assert.True( + process.ExitCode == 0, + $"Windows PowerShell exited {process.ExitCode}.{Environment.NewLine}" + + $"{output}{Environment.NewLine}{error}"); + } + + [Fact] + public async Task TheLauncherEntersAControlledWorkingDirectory() + { + // At logon the task's working directory is the system directory. + await CreateManager().InstallAsync(CancellationToken.None); + + Assert.Contains( + $"cd /d \"{StateRoot}\"", + await File.ReadAllTextAsync( + LauncherPath, + CancellationToken.None), + StringComparison.Ordinal); + } + + + [Theory] + [InlineData("")] + [InlineData("C:\\Windows\\System32\\cmd.exe/d /c \"\"x\"\"C:\\Windows\\System32\\cmd.exe")] + [InlineData("{00000000-0000-0000-0000-000000000000}C:\\Windows\\System32\\cmd.exe/d /c \"\"x\"\"")] + [InlineData("{00000000-0000-0000-0000-000000000000}")] + public void TaskParsingRequiresExactlyOneActionAndItMustBeExec(string actions) + { + string xml = ReplaceActions( + GatewayTaskDefinition.CreateXml(DesiredSnapshot(), "fixture"), + actions); + + Assert.True(GatewayTaskDefinition.TryParse( + xml, + out GatewayTaskSnapshot? snapshot, + out string? detail), detail); + Assert.False(snapshot!.HasSingleExecAction); + } + + [Fact] + public async Task InstallingReRegistersATaskWithMixedExecAndComHandlerActions() + { + _scheduler.Probe = GatewayTaskProbe.Present(DesiredSnapshot() with + { + HasSingleExecAction = false + }); + + GatewayPersistenceInstallResult result = + await CreateManager().InstallAsync(CancellationToken.None); + + Assert.Equal(GatewayPersistenceState.Ready, result.State); + Assert.Contains( + _scheduler.Calls, + call => call.StartsWith("register:", StringComparison.Ordinal)); + } + + private static string ReplaceActions(string xml, string actions) + { + const string start = ""; + const string end = ""; + int startIndex = xml.IndexOf(start, StringComparison.Ordinal); + int contentStart = startIndex + start.Length; + int endIndex = xml.IndexOf(end, contentStart, StringComparison.Ordinal); + return xml[..contentStart] + actions + xml[endIndex..]; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs new file mode 100644 index 00000000..038d8b8c --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs @@ -0,0 +1,132 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class GatewayTaskIdentityTests +{ + [Fact] + public void TaskNameIsScopedByPackageAndUser() + { + GatewayTaskIdentity first = GatewayTaskIdentity.Create("Public_abc", "S-1-5-21-1"); + GatewayTaskIdentity second = GatewayTaskIdentity.Create("Public_abc", "S-1-5-21-2"); + GatewayTaskIdentity other = GatewayTaskIdentity.Create("Internal_abc", "S-1-5-21-1"); + + Assert.NotEqual(first.Name, second.Name); + Assert.NotEqual(first.Name, other.Name); + } + + [Theory] + [InlineData("Public\\abc", "S-1-5-21-1")] + [InlineData("Public_abc", "DOMAIN\\user")] + public void ABackslashInANameComponentIsRejected(string packageFamilyName, string sid) + { + // Task Scheduler reads a backslash as a folder separator, so such a + // name would silently address a different folder. + Assert.Throws( + () => GatewayTaskIdentity.Create(packageFamilyName, sid)); + } + + [Fact] + public void OnlyThisPackagesTasksAreRecognized() + { + GatewayTaskIdentity identity = + GatewayTaskIdentity.Create("Public_abc", "S-1-5-21-1"); + + Assert.True(GatewayTaskIdentity.BelongsToPackage(identity.Name, "Public_abc")); + Assert.False(GatewayTaskIdentity.BelongsToPackage(identity.Name, "Internal_abc")); + } +} + +public sealed class GatewayTaskDefinitionTests +{ + private static GatewayTaskSnapshot Desired() => + GatewayTaskDefinition.CreateSnapshot( + "S-1-5-21-1", + @"C:\Windows\System32\cmd.exe", + @"C:\state\gateway-launcher.cmd"); + + [Fact] + public void TheGeneratedDefinitionRoundTrips() + { + GatewayTaskSnapshot desired = Desired(); + string xml = GatewayTaskDefinition.CreateXml(desired, "OpenClaw Gateway test"); + + Assert.True(GatewayTaskDefinition.TryParse( + xml, + out GatewayTaskSnapshot? parsed, + out string? detail)); + Assert.Null(detail); + Assert.Equal(desired, parsed); + } + + [Fact] + public void TheGatewayIsNotGatedOnPowerOrATimeLimit() + { + GatewayTaskSnapshot desired = Desired(); + + Assert.False(desired.DisallowStartIfOnBatteries); + Assert.False(desired.StopIfGoingOnBatteries); + Assert.Equal(GatewayTaskDefinition.NoExecutionTimeLimit, desired.ExecutionTimeLimit); + } + + [Fact] + public void ASecondSignInDoesNotStartASecondGateway() + { + Assert.Equal(GatewayTaskDefinition.IgnoreNewInstances, Desired().MultipleInstancesPolicy); + } + + [Fact] + public void TheTaskRunsUnelevatedAsTheSignedInUser() + { + GatewayTaskSnapshot desired = Desired(); + + Assert.Equal("LeastPrivilege", desired.RunLevel); + Assert.Equal("InteractiveToken", desired.LogonType); + Assert.Equal("S-1-5-21-1", desired.UserId); + Assert.Equal("S-1-5-21-1", desired.LogonTriggerUserId); + } + + [Fact] + public void TheActionRunsTheLauncherThroughTheCommandProcessor() + { + GatewayTaskSnapshot desired = Desired(); + + Assert.Equal(@"C:\Windows\System32\cmd.exe", desired.Command); + Assert.Contains(@"C:\state\gateway-launcher.cmd", desired.Arguments, StringComparison.Ordinal); + } + + [Fact] + public void UnreadableXmlIsReportedRatherThanGuessed() + { + Assert.False(GatewayTaskDefinition.TryParse( + "", + out GatewayTaskSnapshot? parsed, + out string? detail)); + Assert.Null(parsed); + Assert.NotNull(detail); + } + + [Fact] + public void AbsentSettingsParseToTaskSchedulersOwnDefaults() + { + const string xml = """ + + + + + + + """; + + Assert.True(GatewayTaskDefinition.TryParse(xml, out GatewayTaskSnapshot? parsed, out _)); + Assert.NotNull(parsed); + + // A definition that omits these is not equivalent to ours, so drift + // must not be hidden by optimistic defaults. + Assert.True(parsed.DisallowStartIfOnBatteries); + Assert.True(parsed.StopIfGoingOnBatteries); + Assert.Equal("PT72H", parsed.ExecutionTimeLimit); + Assert.False(parsed.HasSingleLogonTrigger); + Assert.False(parsed.HasSingleExecAction); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/SchTasksGatewaySchedulerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/SchTasksGatewaySchedulerTests.cs new file mode 100644 index 00000000..b55a636b --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/SchTasksGatewaySchedulerTests.cs @@ -0,0 +1,178 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +/// +/// Covers the classification that separates a missing scheduled task from one +/// this account may not read. +/// +/// +/// The runner is injected, so nothing here registers, runs, or deletes a real +/// scheduled task. The diagnostics are deliberately German: the point of these +/// tests is that classification never reads the message. +/// +public sealed class SchTasksGatewaySchedulerTests +{ + private const string TaskName = @"\OpenClaw Gateway OpenClaw.Gateway_test"; + + private const string GermanNotFound = + "FEHLER: Das System kann die angegebene Datei nicht finden."; + + private const string GermanAccessDenied = + "FEHLER: Der Zugriff wurde verweigert."; + + private static string Listing(params string[] taskNames) => + string.Join( + "\r\n", + taskNames.Select(name => $"\"{name}\",\"N/A\",\"Bereit\"")); + + private static SchTasksGatewayScheduler CreateScheduler( + SchTasksGatewayScheduler.SchTasksOutcome query, + SchTasksGatewayScheduler.SchTasksOutcome listing, + List? invocations = null) => + new((arguments, _) => + { + invocations?.Add(string.Join(' ', arguments)); + bool isListing = arguments.Contains("CSV"); + return Task.FromResult(isListing ? listing : query); + }); + + [Fact] + public async Task AnAlreadyCancelledRequestDoesNotInvokeTheScheduler() + { + bool invoked = false; + SchTasksGatewayScheduler scheduler = new((_, _) => + { + invoked = true; + return Task.FromResult(new SchTasksGatewayScheduler.SchTasksOutcome(0, string.Empty, string.Empty)); + }); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => scheduler.QueryAsync(TaskName, cancellation.Token)); + + Assert.False(invoked); + } + + [Fact] + public async Task AnAbsentTaskIsMissingWhenDiagnosticsAreNotEnglish() + { + SchTasksGatewayScheduler scheduler = CreateScheduler( + new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanNotFound), + new SchTasksGatewayScheduler.SchTasksOutcome( + 0, + Listing(@"\SomeoneElsesTask"), + string.Empty)); + + GatewayTaskProbe probe = await scheduler.QueryAsync(TaskName, CancellationToken.None); + + Assert.Equal(GatewayTaskPresence.Missing, probe.Presence); + } + + // A refused read must never be reported as missing: the caller re-registers + // on missing, and that write would be refused too. + [Fact] + public async Task ARefusedReadOfAnExistingTaskStaysUnreadable() + { + SchTasksGatewayScheduler scheduler = CreateScheduler( + new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanAccessDenied), + new SchTasksGatewayScheduler.SchTasksOutcome( + 0, + Listing(TaskName), + string.Empty)); + + GatewayTaskProbe probe = await scheduler.QueryAsync(TaskName, CancellationToken.None); + + Assert.Equal(GatewayTaskPresence.Unreadable, probe.Presence); + } + + [Fact] + public async Task AFailedListingLeavesTheQuestionOpen() + { + SchTasksGatewayScheduler scheduler = CreateScheduler( + new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanNotFound), + new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanAccessDenied)); + + GatewayTaskProbe probe = await scheduler.QueryAsync(TaskName, CancellationToken.None); + + Assert.Equal(GatewayTaskPresence.Unreadable, probe.Presence); + } + + // The listing costs a second process launch, so it must not run when the + // task was read successfully. + [Fact] + public async Task ASuccessfulReadDoesNotEnumerate() + { + List invocations = []; + SchTasksGatewayScheduler scheduler = CreateScheduler( + new SchTasksGatewayScheduler.SchTasksOutcome(0, "", string.Empty), + new SchTasksGatewayScheduler.SchTasksOutcome(0, string.Empty, string.Empty), + invocations); + + await scheduler.QueryAsync(TaskName, CancellationToken.None); + + Assert.DoesNotContain(invocations, call => call.Contains("CSV", StringComparison.Ordinal)); + } + + [Fact] + public async Task DeletingAnAlreadyAbsentTaskSucceedsOnLocalizedWindows() + { + SchTasksGatewayScheduler scheduler = CreateScheduler( + new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanNotFound), + new SchTasksGatewayScheduler.SchTasksOutcome( + 0, + Listing(@"\SomeoneElsesTask"), + string.Empty)); + + GatewayTaskOperation result = await scheduler.DeleteAsync(TaskName, CancellationToken.None); + + Assert.True(result.Succeeded); + } + + [Fact] + public async Task DeletingATaskThatStillExistsReportsFailure() + { + SchTasksGatewayScheduler scheduler = CreateScheduler( + new SchTasksGatewayScheduler.SchTasksOutcome(1, string.Empty, GermanAccessDenied), + new SchTasksGatewayScheduler.SchTasksOutcome( + 0, + Listing(TaskName), + string.Empty)); + + GatewayTaskOperation result = await scheduler.DeleteAsync(TaskName, CancellationToken.None); + + Assert.False(result.Succeeded); + } + + // Task Scheduler reports absolute names; a caller's leading separator is + // incidental and must not change the answer. + [Theory] + [InlineData(@"\OpenClaw Gateway", @"\OpenClaw Gateway")] + [InlineData(@"OpenClaw Gateway", @"\OpenClaw Gateway")] + [InlineData(@"\OpenClaw Gateway", @"OpenClaw Gateway")] + public void ListingMatchesRegardlessOfLeadingSeparator(string listed, string wanted) + { + Assert.True(SchTasksGatewayScheduler.ListingContains(Listing(listed), wanted)); + } + + // Only the first column identifies a task. The localized status column must + // never produce a match. + [Fact] + public void ListingIgnoresColumnsOtherThanTheName() + { + string listing = "\"\\Other\",\"N/A\",\"OpenClaw Gateway\""; + + Assert.False( + SchTasksGatewayScheduler.ListingContains(listing, @"\OpenClaw Gateway")); + } + + [Fact] + public void ListingDoesNotMatchAPrefixOfAnotherTask() + { + Assert.False( + SchTasksGatewayScheduler.ListingContains( + Listing(@"\OpenClaw Gateway Extra"), + @"\OpenClaw Gateway")); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs b/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs index ef7b7542..3b090add 100644 --- a/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostEntrypointResolverTests.cs @@ -16,6 +16,22 @@ public void AgentAliasSelectsThePassthroughEntrypoint() => HostEntrypointResolver.Resolve( "\"C:\\Users\\someone\\AppData\\Local\\Microsoft\\WindowsApps\\openclaw.exe\" setup")); + [Fact] + public void ControlApplicationUserModelIdSelectsTheManagementEntrypoint() => + Assert.Equal( + HostEntrypoint.Control, + HostEntrypointResolver.Resolve( + "\"C:\\Program Files\\WindowsApps\\OpenClaw\\openclaw.exe\" setup", + "OpenClaw.Gateway_abc123!Control")); + + [Fact] + public void AgentApplicationUserModelIdDoesNotSelectTheManagementEntrypoint() => + Assert.Equal( + HostEntrypoint.Agent, + HostEntrypointResolver.Resolve( + "\"C:\\Program Files\\WindowsApps\\OpenClaw\\openclaw.exe\" setup", + "OpenClaw.Gateway_abc123!App")); + [Fact] public void UnknownOrMalformedInvocationDefaultsToAgent() { diff --git a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs index f4619e24..d59fa78f 100644 --- a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs +++ b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs @@ -5,25 +5,37 @@ namespace OpenClaw.Launcher.Tests; public sealed class PackageManifestTests { [Fact] - public void ManifestRegistersBothAliasesToTheSingleExecutable() + public void ManifestRegistersThePublicAndControlApplications() { XDocument manifest = XDocument.Load( Path.Combine(AppContext.BaseDirectory, "Package.appxmanifest")); - XElement extension = Assert.Single( + XElement publicApplication = Assert.Single( manifest.Descendants(), - element => - element.Name.LocalName == "Extension" && - (string?)element.Attribute("Category") == - "windows.appExecutionAlias"); + element => element.Name.LocalName == "Application" && + (string?)element.Attribute("Id") == "App"); + XElement controlApplication = Assert.Single( + manifest.Descendants(), + element => element.Name.LocalName == "Application" && + (string?)element.Attribute("Id") == "Control"); + Assert.Equal( + "openclaw.exe", + (string?)publicApplication.Attribute("Executable")); Assert.Equal( "openclaw.exe", - (string?)extension.Attribute("Executable")); + (string?)controlApplication.Attribute("Executable")); + + Assert.Equal( + ["openclaw.exe"], + Aliases(publicApplication)); + Assert.Equal( + ["clawctl.exe"], + Aliases(controlApplication)); + } - string[] aliases = [.. extension.Descendants() + private static string[] Aliases(XElement application) => + [.. application.Descendants() .Where(element => element.Name.LocalName == "ExecutionAlias") .Select(element => (string?)element.Attribute("Alias")) .OfType() .OrderBy(value => value, StringComparer.Ordinal)]; - Assert.Equal(["clawctl.exe", "openclaw.exe"], aliases); - } } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index d766df68..0e9e12ad 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -85,7 +85,12 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() output, TextWriter.Null, _ => Task.FromResult(nodeRuntime), - () => runtime); + () => runtime, + _ => Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true))); Assert.Equal(0, exitCode); Assert.True(File.Exists(entryPoint)); @@ -100,7 +105,7 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() StringComparison.Ordinal); SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; Assert.Equal(SetupPhase.Ready, setup.Phase); - Assert.False(setup.StartupEnabled); + Assert.True(setup.StartupEnabled); Assert.Equal("24.15.0", setup.AgentNodeVersion); Assert.Contains( _lastSessionBackend!.Calls, @@ -134,7 +139,14 @@ await File.WriteAllTextAsync( TextWriter.Null, TextWriter.Null, _ => Task.FromResult(hostNode), - () => runtime); + () => runtime, + // Setup only reaches Ready once logon recovery is configured, and + // a test must never register a real scheduled task. + _ => Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true))); Assert.Equal(0, setupExitCode); string expectedAgentNode = runtime.SetupState @@ -330,6 +342,7 @@ await Assert.ThrowsAsync( public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval() { SessionRuntime runtime = CreateSessionRuntime(); + bool recoveryRemoved = false; runtime.GatewayState.Write(new GatewayRecord { SchemaVersion = GatewayStateStore.CurrentSchemaVersion, @@ -344,9 +357,16 @@ public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval() _ => { }, TextWriter.Null, TextWriter.Null, - createSessionRuntime: () => runtime); + createSessionRuntime: () => runtime, + removeRecovery: _ => + { + recoveryRemoved = true; + return Task.FromResult( + new GatewayPersistenceRemovalResult(true, false, "removed")); + }); Assert.Equal(0, exitCode); + Assert.True(recoveryRemoved); GatewayStateResult state = runtime.GatewayState.Read(); Assert.Equal(GatewayStateFault.Missing, state.Fault); } @@ -451,7 +471,14 @@ await File.WriteAllTextAsync( "node.exe", new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), - () => runtime).ConfigureAwait(false); + () => runtime, + // Setup only reaches Ready once logon recovery is configured, and + // a test must never register a real scheduled task. + _ => Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true))).ConfigureAwait(false); Assert.Equal(0, exitCode); return runtime; diff --git a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs index b1946fd9..3823f555 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs @@ -69,4 +69,10 @@ public void PackageFamilyNameIsAbsentWhenRunningUnpackaged() // is reported rather than thrown. Assert.Null(PackageIdentity.TryGetPackageFamilyName()); } + + [Fact] + public void ApplicationUserModelIdIsAbsentWithoutAnApplicationIdentity() + { + Assert.Null(PackageIdentity.TryGetApplicationUserModelId()); + } } From 272a3326d67802844a8adcb02e32fda31c83a68e Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:51 -0700 Subject: [PATCH 60/76] feat: stage guest diagnostics (#50) * Add guest diagnostic collection plumbing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Handle deferred guest diagnostic enumeration failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Constrain guest diagnostics staging paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- .../Session/SessionExecutor.cs | 75 ++ src/OpenClaw.SessionHost/Program.cs | 7 +- src/OpenClaw.SessionHost/SessionCollector.cs | 829 ++++++++++++++++++ .../SessionCollectProtocol.cs | 270 ++++++ .../Session/SessionCollectorTests.cs | 481 ++++++++++ .../Session/SessionExecutorTests.cs | 68 ++ 6 files changed, 1729 insertions(+), 1 deletion(-) create mode 100644 src/OpenClaw.SessionHost/SessionCollector.cs create mode 100644 src/OpenClaw.SessionProtocol/SessionCollectProtocol.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionCollectorTests.cs diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index c1fc7109..b1eaaedf 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -162,6 +162,81 @@ await operation.WriteTextNewAsync( } } + /// Asks the guest to stage diagnostics into the shared workspace. + public async Task CollectAsync( + SessionRecord record, + string helperPath, + string destinationDirectory, + IReadOnlyList sources, + IReadOnlyList deniedNames, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + + string requestId = _createRequestId(); + using var operation = new SessionWorkspaceOperation(record, _isCurrentRecord); + string requestPath = operation.FilePath("collect", requestId); + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + + try + { + await operation.WriteTextNewAsync( + requestPath, + SessionCollectProtocol.SerializeRequest(new SessionCollectRequest + { + RequestId = requestId, + DestinationDirectory = destinationDirectory, + Sources = sources, + DeniedNames = deniedNames + }), + cancellationToken).ConfigureAwait(false); + + _log("Collecting agent-side diagnostics from the isolated session."); + + int executorExitCode = await _backend.ExecuteAttachedAsync( + record.ToSandboxIdOrThrow(), + new MxcExecutionRequest( + BuildGuestCommandLine(helperPath, requestPath, "--collect")), + null, + cancellationToken).ConfigureAwait(false); + + string resultText; + try + { + resultText = await operation.ReadTextAsync(resultPath, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException or IOException) + { + throw new SessionException( + "The isolated session did not report a collection result " + + $"(executor exit code {executorExitCode})."); + } + + SessionCollectResult result = SessionCollectProtocol.ReadResult(resultText); + if (result.Error is { Length: > 0 } error) + { + throw new SessionException( + $"The isolated session could not collect diagnostics: {error}"); + } + + if (!string.Equals(result.RequestId, requestId, StringComparison.Ordinal)) + { + throw new SessionException( + "The isolated session reported a collection result for a " + + "different request."); + } + + return result; + } + finally + { + operation.Delete(requestPath); + operation.Delete(resultPath); + } + } + /// Installs the package's Node.js runtime in the agent profile. public async Task InstallRuntimeAsync( SessionRecord record, diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs index 326494a8..930c8b26 100644 --- a/src/OpenClaw.SessionHost/Program.cs +++ b/src/OpenClaw.SessionHost/Program.cs @@ -29,7 +29,7 @@ internal static int Run( // is the one failure that can only surface on stderr. errorOutput.WriteLine( "openclaw-session-host: usage: openclaw-session-host " + - "--request|--supervise|--inspect|--stop|--install-runtime|--install-tools "); + "--request|--supervise|--inspect|--stop|--collect|--install-runtime|--install-tools "); return SessionLaunchProtocol.HelperFailureExitCode; } @@ -47,6 +47,11 @@ internal static int Run( return SessionToolInstaller.Run(requestPath, readFile, File.WriteAllText); } + if (args[0] == "--collect") + { + return SessionCollector.Run(requestPath, readFile, File.WriteAllText); + } + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); string? requestId = null; diff --git a/src/OpenClaw.SessionHost/SessionCollector.cs b/src/OpenClaw.SessionHost/SessionCollector.cs new file mode 100644 index 00000000..9b797d22 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionCollector.cs @@ -0,0 +1,829 @@ +using Microsoft.Win32.SafeHandles; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// +/// The --collect mode: stages named diagnostic files into the shared +/// workspace so the host can read them. +/// +/// +/// +/// The agent profile is ACL'd against the invoking user. Without this the host +/// cannot read the agent's own OpenClaw logs at all, which is exactly the state +/// someone is in when they need to report a problem. +/// +/// +/// Every source is named by the host. A missing source is reported, never +/// treated as a failure, because collection is most useful precisely when an +/// installation is incomplete. +/// +/// +internal static class SessionCollector +{ + private static readonly Guid ProfileFolderId = + new("5E6C858F-0E22-4760-9AFE-EA3317B67173"); + + public static int Run( + string requestPath, + Func readFile, + Action writeFile, + string? profileRoot = null, + Func>? enumerateFiles = null, + Action? beforeDestinationOpen = null) + { + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + string profile = profileRoot ?? GetProfilePath(); + + try + { + SessionCollectRequest request = + SessionCollectProtocol.ReadRequest(readFile(requestPath)); + requestId = request.RequestId; + string destination = ResolveInWorkspace( + Path.GetDirectoryName(Path.GetFullPath(requestPath)) + ?? throw new SessionLaunchException( + "has no shared workspace"), + request.DestinationDirectory!); + request = request with { DestinationDirectory = destination }; + + using (ProtectDirectoryBelow( + Path.GetDirectoryName(Path.GetFullPath(requestPath))!, + destination, + createMissing: true)) + { + } + List entries = []; + + foreach (SessionCollectSource source in request.Sources ?? []) + { + Collect( + request, + source, + profile, + entries, + enumerateFiles ?? Directory.EnumerateFiles, + beforeDestinationOpen); + } + + writeFile( + resultPath, + SessionCollectProtocol.SerializeResult(new SessionCollectResult + { + RequestId = requestId, + Entries = entries + })); + return 0; + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + TryWriteFailure(writeFile, resultPath, requestId, exception.Message); + return SessionLaunchProtocol.HelperFailureExitCode; + } + } + + private static void Collect( + SessionCollectRequest request, + SessionCollectSource source, + string profile, + List entries, + Func> enumerateFiles, + Action? beforeDestinationOpen) + { + string path; + try + { + path = ResolveInProfile(profile, source.RelativePath!); + } + catch (SessionLaunchException exception) + { + entries.Add(new SessionCollectEntry + { + Name = source.Name, + Copied = false, + Detail = exception.Message + }); + return; + } + + if (Directory.Exists(path)) + { + CollectDirectory( + request, + source, + profile, + path, + entries, + enumerateFiles, + beforeDestinationOpen); + return; + } + + if (!File.Exists(path)) + { + entries.Add(new SessionCollectEntry + { + Name = source.Name, + Copied = false, + Detail = "not present" + }); + return; + } + + CopyOne( + request, + profile, + path, + source.Name!, + entries, + beforeDestinationOpen); + } + + private static void CollectDirectory( + SessionCollectRequest request, + SessionCollectSource source, + string profile, + string root, + List entries, + Func> enumerateFiles, + Action? beforeDestinationOpen) + { + IEnumerable files; + try + { + files = enumerateFiles( + root, + string.IsNullOrWhiteSpace(source.Pattern) ? "*" : source.Pattern, + source.Recursive + ? SearchOption.AllDirectories + : SearchOption.TopDirectoryOnly); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + entries.Add(new SessionCollectEntry + { + Name = source.Name, + Copied = false, + Detail = $"unreadable: {exception.Message}" + }); + return; + } + + bool any = false; + try + { + foreach (string file in files) + { + any = true; + string relative = Path.GetRelativePath(root, file); + CopyOne( + request, + profile, + file, + Path.Combine(source.Name!, relative).Replace('\\', '/'), + entries, + beforeDestinationOpen); + } + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + entries.Add(new SessionCollectEntry + { + Name = source.Name, + Copied = false, + Detail = $"unreadable: {exception.Message}" + }); + return; + } + + if (!any) + { + entries.Add(new SessionCollectEntry + { + Name = source.Name, + Copied = false, + Detail = "empty" + }); + } + } + + /// + /// Resolves a source against this account's profile. + /// + /// + /// Only the guest can do this: the profile directory is named after a + /// generated account and is ACL'd against the host user. The resolved path + /// is re-checked against the profile root because Path.Combine + /// on a rooted segment silently discards the root it was given. + /// + internal static string ResolveInProfile(string profile, string relativePath) + { + string resolved = Path.GetFullPath(Path.Combine(profile, relativePath)); + string root = Path.GetFullPath(profile).TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + return resolved.StartsWith(root, StringComparison.OrdinalIgnoreCase) + ? resolved + : throw new SessionLaunchException( + "resolves outside the agent profile"); + } + + private static string GetProfilePath() + { + int result = SHGetKnownFolderPath( + ProfileFolderId, + flags: 0, + token: IntPtr.Zero, + out IntPtr path); + if (result < 0) + { + throw new SessionLaunchException( + $"Windows could not resolve the user profile directory " + + $"(HRESULT 0x{result:X8})."); + } + + try + { + return Marshal.PtrToStringUni(path) + ?? throw new SessionLaunchException( + "Windows returned an empty user profile directory."); + } + finally + { + Marshal.FreeCoTaskMem(path); + } + } + + private static string ResolveInWorkspace(string workspace, string destination) + { + string root = Path.GetFullPath(workspace) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string resolved = Path.GetFullPath(destination); + string prefix = root + Path.DirectorySeparatorChar; + + if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionLaunchException( + "destination resolves outside the shared workspace"); + } + + string relative = Path.GetRelativePath(root, resolved); + string current = root; + foreach (string segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (Directory.Exists(current) && + (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionLaunchException( + "destination contains a reparse point"); + } + } + + return resolved; + } + + private static void CopyOne( + SessionCollectRequest request, + string profile, + string sourcePath, + string name, + List entries, + Action? beforeDestinationOpen) + { + string destination = Path.Combine( + request.DestinationDirectory!, + name.Replace('/', Path.DirectorySeparatorChar)); + + try + { + using FileStream input = OpenReadBelow(profile, sourcePath, out string openedSource); + if (SessionCollectProtocol.IsDenied( + Path.GetFileName(openedSource), + request.DeniedNames)) + { + entries.Add(new SessionCollectEntry + { + Name = name, + Copied = false, + Detail = "excluded by policy" + }); + return; + } + + // Shared read/write/delete because the agent's own OpenClaw + // processes hold their logs open while they run; an exclusive open + // would fail on exactly the live installation worth collecting. + using ProtectedDirectoryChain destinationAuthority = ProtectDirectoryBelow( + request.DestinationDirectory!, + Path.GetDirectoryName(destination)!, + createMissing: true); + beforeDestinationOpen?.Invoke(); + RequireOpenedDirectoryBelow( + destinationAuthority.Leaf, + request.DestinationDirectory!); + using FileStream output = OpenWriteBelow( + destinationAuthority.Leaf, + request.DestinationDirectory!, + destination); + input.CopyTo(output); + + entries.Add(new SessionCollectEntry + { + Name = name, + Copied = true, + Length = output.Length + }); + } + catch (Exception exception) when ( + exception is SessionLaunchException or IOException or + UnauthorizedAccessException) + { + entries.Add(new SessionCollectEntry + { + Name = name, + Copied = false, + Detail = exception.Message + }); + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The returned FileStream takes ownership of the SafeFileHandle.")] + private static FileStream OpenReadBelow( + string root, + string path, + out string openedPath) + { + SafeFileHandle handle = CreateFile( + path, + GenericRead, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new IOException( + $"The diagnostic source could not be opened: {path}", + new System.ComponentModel.Win32Exception(error)); + } + + try + { + openedPath = RequireOpenedFileBelow(handle, root, path); + return new FileStream(handle, FileAccess.Read, 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The returned FileStream takes ownership of the SafeFileHandle.")] + private static FileStream OpenWriteBelow( + SafeFileHandle parent, + string root, + string path) + { + SafeFileHandle handle = CreateRelative( + parent, + Path.GetFileName(path), + GenericWrite | FileReadAttributes, + shareAccess: 0, + FileCreate, + FileNonDirectoryFile | FileOpenReparsePoint); + + try + { + _ = RequireOpenedFileBelow(handle, root, path); + return new FileStream(handle, FileAccess.Write, 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + + private static void RequireOpenedDirectoryBelow( + SafeFileHandle handle, + string root) + { + string opened = GetFinalPath(handle) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normalizedRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string prefix = normalizedRoot + Path.DirectorySeparatorChar; + if (!opened.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) && + !opened.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionLaunchException( + "diagnostic destination resolves outside its staging directory"); + } + } + + private static string RequireOpenedFileBelow( + SafeFileHandle handle, + string root, + string path) + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionLaunchException( + $"The diagnostic path is a filesystem link: {path}"); + } + + string opened = GetFinalPath(handle); + string normalizedRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string prefix = normalizedRoot + Path.DirectorySeparatorChar; + if (!opened.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionLaunchException( + $"The diagnostic path resolves outside its trusted root: {path}"); + } + + return opened; + } + + private sealed class ProtectedDirectoryChain : IDisposable + { + private readonly List _handles = []; + + public SafeFileHandle Leaf => _handles[^1]; + + public void Add(SafeFileHandle handle) => _handles.Add(handle); + + public void Dispose() + { + for (int index = _handles.Count - 1; index >= 0; index--) + { + _handles[index].Dispose(); + } + } + } + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Each validated directory handle is transferred to the returned chain.")] + private static ProtectedDirectoryChain ProtectDirectoryBelow( + string root, + string path, + bool createMissing) + { + string normalizedRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string relative = Path.GetRelativePath(normalizedRoot, Path.GetFullPath(path)); + if (relative.StartsWith("..", StringComparison.Ordinal) || + Path.IsPathRooted(relative)) + { + throw new SessionLaunchException( + "diagnostic destination resolves outside its staging directory"); + } + + var chain = new ProtectedDirectoryChain(); + try + { + chain.Add(OpenProtectedDirectory(normalizedRoot, normalizedRoot)); + if (relative == ".") + { + return chain; + } + + foreach (string segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + chain.Add(OpenRelativeDirectory( + chain.Leaf, + segment, + normalizedRoot, + createMissing)); + } + + return chain; + } + catch + { + chain.Dispose(); + throw; + } + } + + private static SafeFileHandle OpenProtectedDirectory(string path, string root) => + TryOpenProtectedDirectory(path, root) ?? throw new SessionLaunchException( + "diagnostic destination contains a filesystem link"); + + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The caller owns the returned SafeFileHandle.")] + private static SafeFileHandle? TryOpenProtectedDirectory(string path, string root) + { + SafeFileHandle handle = CreateFile( + path, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + return null; + } + + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + handle.Dispose(); + return null; + } + + string opened = GetFinalPath(handle) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normalizedRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string prefix = normalizedRoot + Path.DirectorySeparatorChar; + if (!opened.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) && + !opened.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + handle.Dispose(); + return null; + } + + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + private static SafeFileHandle OpenRelativeDirectory( + SafeFileHandle parent, + string name, + string root, + bool createMissing) + { + SafeFileHandle handle = CreateRelative( + parent, + name, + GenericRead | FileReadAttributes, + FileShareRead | FileShareWrite, + createMissing ? FileOpenIf : FileOpen, + FileDirectoryFile | FileOpenReparsePoint); + try + { + if ((File.GetAttributes(handle) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionLaunchException( + "diagnostic destination contains a filesystem link"); + } + + string opened = GetFinalPath(handle) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normalizedRoot = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string prefix = normalizedRoot + Path.DirectorySeparatorChar; + if (!opened.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) && + !opened.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionLaunchException( + "diagnostic destination resolves outside its staging directory"); + } + + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + private static SafeFileHandle CreateRelative( + SafeFileHandle parent, + string name, + uint desiredAccess, + uint shareAccess, + uint createDisposition, + uint createOptions) + { + IntPtr nameBuffer = Marshal.StringToHGlobalUni(name); + try + { + var unicodeName = new UnicodeString + { + Length = checked((ushort)(name.Length * sizeof(char))), + MaximumLength = checked((ushort)((name.Length + 1) * sizeof(char))), + Buffer = nameBuffer + }; + IntPtr unicodeNamePointer = Marshal.AllocHGlobal( + Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(unicodeName, unicodeNamePointer, fDeleteOld: false); + var attributes = new ObjectAttributes + { + Length = Marshal.SizeOf(), + RootDirectory = parent.DangerousGetHandle(), + ObjectName = unicodeNamePointer, + Attributes = ObjectCaseInsensitive + }; + uint status = NtCreateFile( + out SafeFileHandle handle, + desiredAccess | Synchronize, + ref attributes, + out _, + IntPtr.Zero, + FileAttributeNormal, + shareAccess, + createDisposition, + createOptions | FileSynchronousIoNonAlert, + IntPtr.Zero, + 0); + GC.KeepAlive(parent); + if (unchecked((int)status) < 0) + { + handle.Dispose(); + int error = checked((int)RtlNtStatusToDosError(status)); + throw new IOException( + $"The diagnostic relative path could not be opened: {name}", + new System.ComponentModel.Win32Exception(error)); + } + + return handle; + } + finally + { + Marshal.FreeHGlobal(unicodeNamePointer); + } + } + finally + { + Marshal.FreeHGlobal(nameBuffer); + } + } + + private static string GetFinalPath(SafeFileHandle handle) + { + var buffer = new char[512]; + uint length = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Length, 0); + if (length == 0) + { + throw new IOException( + "The diagnostic path could not be resolved.", + new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error())); + } + + if (length >= buffer.Length) + { + buffer = new char[checked((int)length + 1)]; + length = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Length, 0); + if (length == 0 || length >= buffer.Length) + { + throw new IOException("The diagnostic path could not be resolved."); + } + } + + const string extendedPrefix = @"\\?\"; + string path = new(buffer, 0, checked((int)length)); + return path.StartsWith(extendedPrefix, StringComparison.Ordinal) + ? path[extendedPrefix.Length..] + : path; + } + + private static void TryWriteFailure( + Action writeFile, + string resultPath, + string? requestId, + string error) + { + try + { + writeFile( + resultPath, + SessionCollectProtocol.SerializeResult(new SessionCollectResult + { + RequestId = requestId, + Error = error + })); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + // The exit code is the only remaining channel. + } + } + + private const uint GenericRead = 0x80000000; + private const uint GenericWrite = 0x40000000; + private const uint FileReadAttributes = 0x00000080; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileCreate = 2; + private const uint FileAttributeNormal = 0x00000080; + private const uint Synchronize = 0x00100000; + private const uint FileOpen = 1; + private const uint FileOpenIf = 3; + private const uint FileDirectoryFile = 0x00000001; + private const uint FileNonDirectoryFile = 0x00000040; + private const uint FileSynchronousIoNonAlert = 0x00000020; + private const uint FileOpenReparsePoint = 0x00200000; + private const uint ObjectCaseInsensitive = 0x00000040; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagOpenReparsePoint = 0x00200000; + + [StructLayout(LayoutKind.Sequential)] + private struct UnicodeString + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ObjectAttributes + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoStatusBlock + { + public IntPtr Status; + public nuint Information; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("ntdll.dll")] + private static extern uint NtCreateFile( + out SafeFileHandle fileHandle, + uint desiredAccess, + ref ObjectAttributes objectAttributes, + out IoStatusBlock ioStatusBlock, + IntPtr allocationSize, + uint fileAttributes, + uint shareAccess, + uint createDisposition, + uint createOptions, + IntPtr eaBuffer, + uint eaLength); + + [DllImport("ntdll.dll")] + private static extern uint RtlNtStatusToDosError(uint status); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + [Out] char[] path, + uint pathLength, + uint flags); + + [DllImport("shell32.dll")] + private static extern int SHGetKnownFolderPath( + in Guid folderId, + uint flags, + IntPtr token, + out IntPtr path); +} diff --git a/src/OpenClaw.SessionProtocol/SessionCollectProtocol.cs b/src/OpenClaw.SessionProtocol/SessionCollectProtocol.cs new file mode 100644 index 00000000..398932b0 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionCollectProtocol.cs @@ -0,0 +1,270 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// +/// Asks the guest to stage diagnostic files into the shared workspace. +/// +/// +/// +/// The agent profile is ACL'd against the invoking user, so agent-side logs are +/// unreadable from the host even though the host owns the machine. Staging +/// through the shared workspace is what makes them readable without elevation +/// or any ACL change. +/// +/// +/// The host names every source explicitly. The guest never decides what is +/// interesting, so a future host change cannot silently widen what leaves the +/// session. +/// +/// +public sealed record SessionCollectRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// + /// Where the guest writes the staged copies. The host owns this directory + /// and removes it once the bundle is written. + /// + [JsonPropertyName("destinationDirectory")] + public string? DestinationDirectory { get; init; } + + [JsonPropertyName("sources")] + public IReadOnlyList? Sources { get; init; } + + /// + /// File names the guest must never copy, matched case-insensitively against + /// the file name with * as a trailing wildcard. + /// + /// + /// Credential stores live beside the logs worth collecting, so the deny + /// list travels with the request and is enforced again by the host when the + /// bundle is written. Either check alone would be a single point of failure. + /// + [JsonPropertyName("deniedNames")] + public IReadOnlyList? DeniedNames { get; init; } +} + +/// One file or directory to stage, and the name to stage it under. +public sealed record SessionCollectSource +{ + /// + /// Where to read from, relative to the agent's user profile. + /// + /// + /// Deliberately not an absolute path. The host cannot know the agent's + /// profile directory — the account name is generated and the directory is + /// ACL'd against the host user — so an absolute path built on the host + /// names the host's own profile and silently collects nothing. Only the + /// guest can resolve this, and it resolves it against its own profile. + /// + [JsonPropertyName("relativePath")] + public string? RelativePath { get; init; } + + /// + /// The relative name inside the staging directory, which keeps guest paths + /// (and the agent account name in them) out of the bundle layout. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } + + /// + /// Which files in a directory source to take. Defaults to all of them. + /// + /// + /// The directories worth collecting also hold content that is large, + /// uninteresting, or private: shell completions run to megabytes and the + /// agent's workspace is its own content. Narrowing here rather than on the + /// host keeps that content from ever leaving the session. + /// + [JsonPropertyName("pattern")] + public string? Pattern { get; init; } + + /// Whether a directory source is copied recursively. + [JsonPropertyName("recursive")] + public bool Recursive { get; init; } +} + +/// What the guest actually staged. +/// +/// A missing source is ordinary: collection runs precisely when an installation +/// is broken. Each outcome is reported per entry so the bundle manifest can say +/// what was attempted rather than only what succeeded. +/// +public sealed record SessionCollectResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("entries")] + public IReadOnlyList? Entries { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +/// The outcome for one staged file. +public sealed record SessionCollectEntry +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("copied")] + public bool Copied { get; init; } + + [JsonPropertyName("length")] + public long Length { get; init; } + + /// Why this entry was not copied, when it was not. + [JsonPropertyName("detail")] + public string? Detail { get; init; } +} + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(SessionCollectRequest))] +[JsonSerializable(typeof(SessionCollectResult))] +internal sealed partial class SessionCollectJsonContext : JsonSerializerContext; + +public static class SessionCollectProtocol +{ + public static string SerializeRequest(SessionCollectRequest request) => + JsonSerializer.Serialize( + request, + SessionCollectJsonContext.Default.SessionCollectRequest); + + public static string SerializeResult(SessionCollectResult result) => + JsonSerializer.Serialize( + result, + SessionCollectJsonContext.Default.SessionCollectResult); + + public static SessionCollectRequest ReadRequest(string json) + { + SessionCollectRequest request = Deserialize( + json, + SessionCollectJsonContext.Default.SessionCollectRequest, + "collect request"); + + if (request.SchemaVersion != SessionLaunchProtocol.CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Collect request schema version {request.SchemaVersion} is not " + + "supported; this helper implements version " + + $"{SessionLaunchProtocol.CurrentSchemaVersion}."); + } + + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new SessionLaunchException("The collect request has no request id."); + } + + if (string.IsNullOrWhiteSpace(request.DestinationDirectory) || + !Path.IsPathFullyQualified(request.DestinationDirectory)) + { + throw new SessionLaunchException( + "The collect request has no fully qualified destination directory."); + } + + foreach (SessionCollectSource source in request.Sources ?? []) + { + // Rooted or traversing paths would read outside the agent profile, + // which is the one place a collect source is allowed to name. + if (string.IsNullOrWhiteSpace(source.RelativePath) || + Path.IsPathRooted(source.RelativePath) || + source.RelativePath.Contains("..", StringComparison.Ordinal)) + { + throw new SessionLaunchException( + "A collect source is not a safe profile-relative path: " + + $"{source.RelativePath}"); + } + + // A name that escapes the staging directory would let a malformed + // request write anywhere the agent can reach. + if (string.IsNullOrWhiteSpace(source.Name) || + Path.IsPathRooted(source.Name) || + source.Name.Contains("..", StringComparison.Ordinal)) + { + throw new SessionLaunchException( + $"The collect source name is not a safe relative name: {source.Name}"); + } + } + + return request; + } + + public static SessionCollectResult ReadResult(string json) + { + SessionCollectResult result = Deserialize( + json, + SessionCollectJsonContext.Default.SessionCollectResult, + "collect result"); + + if (result.SchemaVersion != SessionLaunchProtocol.CurrentSchemaVersion) + { + throw new SessionLaunchException( + $"Collect result schema version {result.SchemaVersion} is not " + + "supported; this launcher implements version " + + $"{SessionLaunchProtocol.CurrentSchemaVersion}."); + } + + return result; + } + + /// + /// Whether a file name is denied, with * supported only as a + /// trailing wildcard. + /// + /// + /// Deliberately not a glob engine. The deny list guards credential stores, + /// so it has to behave identically in the guest and the host, and a shared + /// literal-or-prefix rule is small enough to be obviously the same. + /// + public static bool IsDenied(string fileName, IReadOnlyList? deniedNames) + { + ArgumentNullException.ThrowIfNull(fileName); + + foreach (string denied in deniedNames ?? []) + { + if (denied.EndsWith('*')) + { + if (fileName.StartsWith( + denied[..^1], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + else if (string.Equals(fileName, denied, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static T Deserialize( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + string description) + where T : class + { + try + { + return JsonSerializer.Deserialize(json, typeInfo) + ?? throw new SessionLaunchException($"The {description} is empty."); + } + catch (JsonException exception) + { + throw new SessionLaunchException( + $"The {description} is not valid JSON: {exception.Message}"); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionCollectorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionCollectorTests.cs new file mode 100644 index 00000000..542d70a8 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionCollectorTests.cs @@ -0,0 +1,481 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// +/// Drives the real guest collector against real files, because its whole job is +/// copying files the host cannot otherwise reach. +/// +public sealed class SessionCollectorTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + private string Source => Path.Combine(_root, "profile"); + + private string Workspace => Path.Combine(_root, "workspace"); + + private string Destination => Path.Combine(Workspace, "staged"); + + private string RequestPath => Path.Combine(Workspace, "collect.json"); + + private static IReadOnlyList Denied => + ["openclaw-agent.sqlite*", "openclaw.sqlite*"]; + + private string Write(string relativePath, string content) + { + string path = Path.Combine(Source, relativePath); + _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + private SessionCollectResult Run(params SessionCollectSource[] sources) + { + return Run(beforeDestinationOpen: null, sources); + } + + private SessionCollectResult Run( + Action? beforeDestinationOpen, + params SessionCollectSource[] sources) + { + var request = new SessionCollectRequest + { + RequestId = "c1", + DestinationDirectory = Destination, + Sources = sources, + DeniedNames = Denied + }; + _ = Directory.CreateDirectory(Workspace); + File.WriteAllText(RequestPath, SessionCollectProtocol.SerializeRequest(request)); + + int exitCode = SessionCollector.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + Source, + beforeDestinationOpen: beforeDestinationOpen); + Assert.Equal(0, exitCode); + + return SessionCollectProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + } + + [Fact] + public void NamedFilesAreStagedUnderTheRequestedName() + { + _ = Write("logs\\openclaw.log", "hello-from-the-agent"); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = @"logs\openclaw.log", + Name = "agent/openclaw.log" + }); + + Assert.Equal( + "hello-from-the-agent", + File.ReadAllText(Path.Combine(Destination, "agent", "openclaw.log"))); + SessionCollectEntry entry = Assert.Single(result.Entries!); + Assert.True(entry.Copied, entry.Detail); + } + + [Fact] + public void DirectoriesAreStagedRecursivelyWhenRequested() + { + _ = Write("logs\\openclaw.log", "top"); + _ = Write("logs\\nested\\worker.log", "nested"); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = "logs", + Name = "agent-logs", + Recursive = true + }); + + Assert.Equal("top", File.ReadAllText(Path.Combine(Destination, "agent-logs", "openclaw.log"))); + Assert.Equal( + "nested", + File.ReadAllText(Path.Combine(Destination, "agent-logs", "nested", "worker.log"))); + Assert.Equal(2, result.Entries!.Count); + } + + // The credential stores sit in the same directory as the logs worth + // collecting, so the deny list is the only thing keeping them out. + [Theory] + [InlineData("openclaw-agent.sqlite")] + [InlineData("openclaw-agent.sqlite-wal")] + [InlineData("openclaw-agent.sqlite-shm")] + public void CredentialStoresAreNeverStaged(string fileName) + { + _ = Write($"agent\\{fileName}", "secret-bearing"); + _ = Write("agent\\openclaw.log", "safe"); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = "agent", + Name = "agent", + Recursive = true + }); + + Assert.False(File.Exists(Path.Combine(Destination, "agent", fileName))); + Assert.True(File.Exists(Path.Combine(Destination, "agent", "openclaw.log"))); + Assert.Contains( + result.Entries!, + entry => entry.Name!.EndsWith(fileName, StringComparison.Ordinal) && + !entry.Copied && + entry.Detail == "excluded by policy"); + } + + [Fact] + public void SourceLinksAreRejectedBeforeReadingTheirTargets() + { + string target = Write("agent\\openclaw.sqlite", "secret-bearing"); + string link = Path.Combine(Source, "logs", "safe.log"); + Directory.CreateDirectory(Path.GetDirectoryName(link)!); + File.CreateSymbolicLink(link, target); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = @"logs\safe.log", + Name = "safe.log" + }); + + SessionCollectEntry entry = Assert.Single(result.Entries!); + Assert.False(entry.Copied); + Assert.Contains("filesystem link", entry.Detail, StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(Destination, "safe.log"))); + } + + [Fact] + public void DestinationLinksAreRejectedBeforeWritingOutsideStaging() + { + _ = Write("logs\\openclaw.log", "safe"); + string outside = Path.Combine(_root, "outside"); + Directory.CreateDirectory(outside); + Directory.CreateDirectory(Destination); + Directory.CreateSymbolicLink(Path.Combine(Destination, "agent"), outside); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = @"logs\openclaw.log", + Name = "agent/openclaw.log" + }); + + SessionCollectEntry entry = Assert.Single(result.Entries!); + Assert.False(entry.Copied); + Assert.Contains("filesystem link", entry.Detail, StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(outside, "openclaw.log"))); + } + + [Fact] + public void DestinationAuthorityPreventsReplacementBeforeOpeningTheFile() + { + _ = Write("logs\\openclaw.log", "safe"); + string outside = Path.Combine(_root, "outside"); + string destinationDirectory = Path.Combine(Destination, "agent"); + string relocatedDirectory = Path.Combine(outside, "agent"); + string outsideFile = Path.Combine(relocatedDirectory, "sentinel.log"); + Directory.CreateDirectory(outside); + Directory.CreateDirectory(destinationDirectory); + File.WriteAllText( + Path.Combine(destinationDirectory, "sentinel.log"), + "sentinel"); + Exception? replacementFailure = null; + + SessionCollectResult result = Run( + () => + { + try + { + Directory.Move(destinationDirectory, relocatedDirectory); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + replacementFailure = exception; + } + }, + new SessionCollectSource + { + RelativePath = @"logs\openclaw.log", + Name = "agent/openclaw.log" + }); + + SessionCollectEntry entry = Assert.Single(result.Entries!); + if (replacementFailure is null) + { + Assert.False(entry.Copied); + Assert.Equal("sentinel", File.ReadAllText(outsideFile)); + } + else + { + Assert.True(entry.Copied, entry.Detail); + Assert.False(File.Exists(outsideFile)); + Assert.Equal( + "safe", + File.ReadAllText(Path.Combine(destinationDirectory, "openclaw.log"))); + } + } + + [Fact] + public void ExistingHardLinkedOutputIsRejectedWithoutChangingItsTarget() + { + _ = Write("logs\\openclaw.log", "safe"); + string outside = Path.Combine(_root, "outside"); + string sentinel = Path.Combine(outside, "sentinel.log"); + string output = Path.Combine(Destination, "agent", "openclaw.log"); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + Directory.CreateDirectory(outside); + File.WriteAllText(sentinel, "sentinel"); + Assert.True( + CreateHardLink(output, sentinel, IntPtr.Zero), + new System.ComponentModel.Win32Exception( + System.Runtime.InteropServices.Marshal.GetLastWin32Error()).Message); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = @"logs\openclaw.log", + Name = "agent/openclaw.log" + }); + + Assert.Equal("sentinel", File.ReadAllText(sentinel)); + Assert.Equal("sentinel", File.ReadAllText(output)); + SessionCollectEntry entry = Assert.Single(result.Entries!); + Assert.False(entry.Copied); + } + + [System.Runtime.InteropServices.DllImport( + "kernel32.dll", + CharSet = System.Runtime.InteropServices.CharSet.Unicode, + SetLastError = true)] + [System.Runtime.InteropServices.DefaultDllImportSearchPaths( + System.Runtime.InteropServices.DllImportSearchPath.System32)] + [return: System.Runtime.InteropServices.MarshalAs( + System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool CreateHardLink( + string fileName, + string existingFileName, + IntPtr securityAttributes); + + // Collection exists for broken installations, so an absent source has to be + // reported rather than treated as a failure. + [Fact] + public void MissingSourcesAreReportedWithoutFailing() + { + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = "does-not-exist.log", + Name = "missing.log" + }); + + SessionCollectEntry entry = Assert.Single(result.Entries!); + Assert.False(entry.Copied); + Assert.Equal("not present", entry.Detail); + Assert.Null(result.Error); + } + + [Fact] + public void FilesHeldOpenByTheAgentAreStillStaged() + { + string path = Write("logs\\gateway.log", "live-gateway-output"); + using var held = new FileStream( + path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = @"logs\gateway.log", + Name = "gateway.log" + }); + + SessionCollectEntry entry = Assert.Single(result.Entries!); + Assert.True(entry.Copied, entry.Detail); + Assert.Equal( + "live-gateway-output", + File.ReadAllText(Path.Combine(Destination, "gateway.log"))); + } + + [Fact] + public void AnUnreadableRequestIsReportedThroughTheResultFile() + { + _ = Directory.CreateDirectory(Workspace); + File.WriteAllText(RequestPath, "{ not json"); + + int exitCode = SessionCollector.Run( + RequestPath, File.ReadAllText, File.WriteAllText); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + SessionCollectResult result = SessionCollectProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + Assert.NotNull(result.Error); + } + + [Theory] + [InlineData("..\\escape.log")] + [InlineData("C:\\Windows\\escape.log")] + public void SourceNamesThatEscapeTheStagingDirectoryAreRejected(string name) + { + var request = new SessionCollectRequest + { + RequestId = "c1", + DestinationDirectory = Destination, + Sources = [new SessionCollectSource { RelativePath = "a.log", Name = name }] + }; + + Assert.Throws(() => + SessionCollectProtocol.ReadRequest( + SessionCollectProtocol.SerializeRequest(request))); + } + + [Fact] + public void DestinationOutsideTheSharedWorkspaceIsRejected() + { + string outside = Path.Combine(_root, "outside"); + string sentinel = Path.Combine(outside, "sentinel.txt"); + _ = Directory.CreateDirectory(Workspace); + _ = Directory.CreateDirectory(outside); + File.WriteAllText(sentinel, "unchanged"); + { + var request = new SessionCollectRequest + { + RequestId = "c1", + DestinationDirectory = outside, + Sources = [] + }; + File.WriteAllText(RequestPath, SessionCollectProtocol.SerializeRequest(request)); + + int exitCode = SessionCollector.Run( + RequestPath, File.ReadAllText, File.WriteAllText, Source); + + Assert.Equal(SessionLaunchProtocol.HelperFailureExitCode, exitCode); + SessionCollectResult result = SessionCollectProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + Assert.Equal("destination resolves outside the shared workspace", result.Error); + Assert.Equal("unchanged", File.ReadAllText(sentinel)); + } + } + + // The host cannot name the agent's profile: the account is generated and + // its directory is ACL'd against the host user. An absolute path built on + // the host names the host's own profile, which is how a bundle came back + // reporting every agent source as "not present". + [Theory] + [InlineData(@"C:\Users\someone-else\.openclaw")] + [InlineData(@"..\..\Windows")] + public void SourcesThatLeaveTheAgentProfileAreRejected(string relativePath) + { + var request = new SessionCollectRequest + { + RequestId = "c1", + DestinationDirectory = Destination, + Sources = [new SessionCollectSource { RelativePath = relativePath, Name = "x" }] + }; + + Assert.Throws(() => + SessionCollectProtocol.ReadRequest( + SessionCollectProtocol.SerializeRequest(request))); + } + + // Only the guest knows where the agent profile is, so the same request has + // to resolve to whichever profile it runs under. + [Fact] + public void SourcesResolveAgainstTheProfileTheGuestRunsUnder() + { + _ = Write("logs\\openclaw.log", "agent-side"); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = @"logs\openclaw.log", + Name = "openclaw.log" + }); + + Assert.True(Assert.Single(result.Entries!).Copied); + Assert.Equal( + "agent-side", + File.ReadAllText(Path.Combine(Destination, "openclaw.log"))); + } + + // The directories worth collecting also hold megabytes of shell completions + // and the agent's own workspace content, none of which belongs in a bundle + // handed to someone else. + [Fact] + public void ADirectorySourceTakesOnlyItsPattern() + { + _ = Write("config\\openclaw.json", "live"); + _ = Write("config\\openclaw.json.bak", "previous"); + _ = Write("config\\completions.ps1", "enormous"); + + SessionCollectResult result = Run(new SessionCollectSource + { + RelativePath = "config", + Name = "config", + Pattern = "openclaw.json*" + }); + + Assert.True(File.Exists(Path.Combine(Destination, "config", "openclaw.json"))); + Assert.True(File.Exists(Path.Combine(Destination, "config", "openclaw.json.bak"))); + Assert.False(File.Exists(Path.Combine(Destination, "config", "completions.ps1"))); + Assert.Equal(2, result.Entries!.Count); + } + + [Fact] + public void DirectoryEnumerationFailureIsReportedInsteadOfFailingCollection() + { + _ = Directory.CreateDirectory(Path.Combine(Source, "logs")); + var request = new SessionCollectRequest + { + RequestId = "c1", + DestinationDirectory = Destination, + Sources = [new SessionCollectSource + { + RelativePath = "logs", + Name = "logs", + Recursive = true + }] + }; + _ = Directory.CreateDirectory(Workspace); + File.WriteAllText(RequestPath, SessionCollectProtocol.SerializeRequest(request)); + string retained = Write("logs\\retained.log", "retained"); + + int exitCode = SessionCollector.Run( + RequestPath, + File.ReadAllText, + File.WriteAllText, + Source, + (_, _, _) => ThrowAfterYield(retained)); + + Assert.Equal(0, exitCode); + SessionCollectResult result = SessionCollectProtocol.ReadResult( + File.ReadAllText(SessionLaunchProtocol.ResultPathFor(RequestPath))); + Assert.Collection( + result.Entries!, + entry => + { + Assert.True(entry.Copied); + Assert.Equal("logs/retained.log", entry.Name); + Assert.Equal("retained", File.ReadAllText(Path.Combine(Destination, "logs", "retained.log"))); + }, + entry => + { + Assert.False(entry.Copied); + Assert.Equal("logs", entry.Name); + Assert.StartsWith("unreadable:", entry.Detail, StringComparison.Ordinal); + }); + + static IEnumerable ThrowAfterYield(string first) + { + yield return first; + throw new UnauthorizedAccessException("fixture access denied"); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index be523782..d4f6cc3a 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -102,6 +102,74 @@ private void RespondLaunched(int exitCode) => ExitCode = exitCode, }); + private void RespondAsCollector( + Func respond) + { + _backend.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "collect-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + SessionCollectRequest request = SessionCollectProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionCollectProtocol.SerializeResult(respond(request))); + return Task.FromResult(0); + }; + } + + [Fact] + public async Task CollectionUsesTheGuestHelperAndReturnsItsResult() + { + SessionCollectRequest? delivered = null; + RespondAsCollector(request => + { + delivered = request; + return new SessionCollectResult + { + RequestId = request.RequestId, + Entries = [new SessionCollectEntry { Name = "agent/openclaw.log", Copied = true }], + }; + }); + + SessionCollectResult result = await Create().CollectAsync( + Record(), + @"C:\Package\session-host\x64\openclaw-session-host.exe", + Path.Combine(Workspace, "staged"), + [new SessionCollectSource { RelativePath = @"AppData\Roaming\openclaw\logs", Name = "agent" }], + ["openclaw-agent.sqlite*"], + CancellationToken.None); + + Assert.Equal(["execute-attached:iso:sandbox1"], _backend.Calls); + Assert.Equal( + @"AppData\Roaming\openclaw\logs", + Assert.Single(delivered!.Sources!).RelativePath); + Assert.True(Assert.Single(result.Entries!).Copied); + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task CollectionFailureResultIsReported() + { + RespondAsCollector(request => new SessionCollectResult + { + RequestId = request.RequestId, + Error = "access denied", + }); + + SessionException exception = await Assert.ThrowsAsync( + () => Create().CollectAsync( + Record(), + @"C:\Package\session-host\x64\openclaw-session-host.exe", + Path.Combine(Workspace, "staged"), + [], + [], + CancellationToken.None)); + + Assert.Contains("access denied", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(Workspace)); + } + [Fact] public async Task ApplicationExitCodeIsReturned() { From e2a07280b0b689785b90f2b1bfb4a687dbad9c7c Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:51 -0700 Subject: [PATCH 61/76] fix: complete setup and recover interrupted session state (#51) * Add redacted diagnostics bundle command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Complete setup and recover stale sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Reconcile gateway lifecycle after session replacement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Use the agent runtime across the session lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Validate session support before session-free setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Allow teardown after an unconfirmed gateway launch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Bind agent diagnostic reads to opened files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Avoid an unnecessary gateway request state machine Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Persist superseded session reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Preserve configured gateway working directories Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * fix: recover session lifecycle safely Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Use the interactive user for gateway recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Stabilize collection reparse test paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Update startup launch test seam Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 29 +- .../Gateway/DiagnosticsBundle.cs | 428 ++++++++++++++++++ .../Gateway/DiagnosticsRedactor.cs | 40 ++ .../Gateway/GatewayConfigurationStore.cs | 22 +- .../Gateway/GatewayController.cs | 65 ++- .../Gateway/GatewayPersistenceContracts.cs | 10 +- .../Gateway/GatewayPersistenceManager.cs | 66 +-- .../Gateway/GatewayRuntime.cs | 118 ++++- .../Gateway/GatewayStateStore.cs | 31 ++ .../Gateway/GatewayTaskDefinition.cs | 5 +- .../Gateway/SessionGatewayClient.cs | 13 +- src/OpenClaw.Launcher/HostStartup.cs | 7 +- src/OpenClaw.Launcher/Program.cs | 264 +++++++---- .../Session/SessionCoordinator.cs | 134 +++++- .../Session/SessionExecutor.cs | 38 +- .../Session/SessionRuntime.cs | 2 +- .../Session/SessionStateStore.cs | 11 + .../Session/TeardownOrchestrator.cs | 75 +++ src/OpenClaw.SessionHost/SessionInspector.cs | 14 + .../SessionInspectProtocol.cs | 13 + .../ClawCtlCommandLineTests.cs | 15 +- .../ClawCtlParserDefaultsTests.cs | 3 +- .../Gateway/DiagnosticsBundleTests.cs | 165 +++++++ .../Gateway/DiagnosticsRedactorTests.cs | 51 +++ .../Gateway/GatewayConfigurationStoreTests.cs | 16 +- .../Gateway/GatewayControllerTests.cs | 50 +- .../Gateway/GatewayPersistenceManagerTests.cs | 38 +- .../Gateway/GatewayTaskDefinitionTests.cs | 13 + .../Gateway/SessionGatewayClientTests.cs | 176 +++++++ .../ProgramStartupTests.cs | 58 +++ tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 220 +++++++-- .../Session/FakeMxcSessionClient.cs | 7 +- .../Session/GuestProcessObserverTests.cs | 19 + .../Session/SessionCoordinatorTests.cs | 128 ++++++ .../Session/SessionExecutorTests.cs | 125 ++++- 35 files changed, 2190 insertions(+), 279 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs create mode 100644 src/OpenClaw.Launcher/Gateway/DiagnosticsRedactor.cs create mode 100644 src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsRedactorTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index a94bd108..1a383a78 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -6,8 +6,9 @@ namespace OpenClaw.Launcher; internal sealed record ClawCtlHandlers { - public required Func> Setup { get; init; } + public required Func> Setup { get; init; } public required Func> Status { get; init; } + public required Func> CollectLogs { get; init; } public required Func> Teardown { get; init; } public required Func> PowerShell { get; init; } public required Func> GatewayStart { get; init; } @@ -15,6 +16,8 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStop { get; init; } } +internal sealed record SetupOptions(bool NoIsolation); + // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the // bundled CLI and reached through `openclaw`, which forwards its arguments @@ -23,6 +26,7 @@ internal static class ClawCtlCommandLine { public const string SetupCommandName = "setup"; public const string StatusCommandName = "status"; + public const string CollectLogsCommandName = "collect-logs"; // Response-file expansion is off. A leading `@` means nothing to clawctl, // so it is reported as an unrecognized argument instead of silently reading @@ -54,9 +58,27 @@ public static RootCommand Create(ClawCtlHandlers handlers) { ArgumentNullException.ThrowIfNull(handlers); Command setup = new(SetupCommandName, SetupDescription); - setup.SetAction((_, cancellationToken) => handlers.Setup(cancellationToken)); - Command status = new(StatusCommandName, "Show the recorded isolated session without changing it."); + Option noIsolation = new("--no-isolation") + { + Description = "Prepare the bundled runtime without provisioning an isolated session." + }; + setup.Options.Add(noIsolation); + setup.SetAction((parsed, cancellationToken) => + handlers.Setup(new SetupOptions(parsed.GetValue(noIsolation)), cancellationToken)); + Command status = new( + StatusCommandName, + "Show the isolated-session record and MXC-observed provision state without provisioning a replacement."); status.SetAction((_, cancellationToken) => handlers.Status(cancellationToken)); + Option outputPath = new("--output") + { + Description = "Path for the diagnostics ZIP file." + }; + Command collectLogs = new( + CollectLogsCommandName, + "Create a redacted diagnostics bundle, including session files when reachable."); + collectLogs.Options.Add(outputPath); + collectLogs.SetAction((parsed, cancellationToken) => + handlers.CollectLogs(parsed.GetValue(outputPath), cancellationToken)); Option force = new("--force") { Description = "Skip confirmation and remove the owned session." }; Command teardown = new("teardown", "Stop and remove the owned isolated session."); teardown.Options.Add(force); @@ -83,6 +105,7 @@ public static RootCommand Create(ClawCtlHandlers handlers) { setup, status, + collectLogs, teardown, powerShell, gateway diff --git a/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs b/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs new file mode 100644 index 00000000..766cfb0c --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs @@ -0,0 +1,428 @@ +using System.IO.Compression; +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +/// What a diagnostics run produced. +internal sealed record DiagnosticsBundleResult( + string? BundlePath, + bool SessionReached, + IReadOnlyList Notes); + +internal sealed partial class GatewayRuntime +{ + /// + /// File names that must never reach a bundle, matched with a trailing + /// wildcard. + /// + /// + /// These are the agent and shared credential stores. Provider tokens live + /// in them and in their -wal sidecars, so excluding only the primary + /// database would still ship the secret. The guest enforces this list too; + /// keeping both checks means neither is a single point of failure. + /// + internal static readonly string[] DeniedBundleNames = + [ + "openclaw-agent.sqlite*", + "openclaw.sqlite*", + "auth-profiles*", + ]; + + /// Directory in the shared workspace used to stage guest files. + internal const string StagingDirectoryName = "openclaw-diagnostics"; + + /// + /// Opens an interactive shell inside the recorded session. + /// + /// + /// Requires a recorded session but deliberately not completed setup: a + /// half-configured installation is exactly what someone needs a shell to + /// inspect. It never provisions, because a command whose job is diagnosis + /// must not change what is being diagnosed. + /// + public async Task RunShellAsync( + TextWriter output, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(output); + + SessionStatus status = Session.Coordinator.GetRecordedStatus(); + if (status.Record is null) + { + throw new SessionException( + status.Detail ?? + "No agent session is recorded, so there is nothing to open a shell in. " + + "Run `clawctl setup` first."); + } + + SetupStateResult setup = Session.SetupState.Read(Session.ApplicationId); + if (setup.Record?.Phase != SetupPhase.Ready) + { + await output.WriteLineAsync( + "Warning: setup is not complete, so the session may lack its configuration. " + + "Run `clawctl setup` to finish it.").ConfigureAwait(false); + } + + SessionRecord record = await Session.Coordinator + .StartRecordedAsync(cancellationToken).ConfigureAwait(false); + string helperPath = Session.RequireStagedHelper(record); + AgentShell shell = AgentShellResolver.Resolve(FileExists); + + await output.WriteLineAsync( + $"Opening {shell.DisplayName} as {record.AgentUserName ?? "the agent account"} " + + "in the isolated session.").ConfigureAwait(false); + await output.WriteLineAsync("Exit the shell to return.").ConfigureAwait(false); + + return await Session.Executor.ExecuteCommandAsync( + record, + new SessionCommandRequest( + helperPath, + shell.ExecutablePath, + AgentShellResolver.BuildArguments( + record.WorkspacePath!, + record.AgentUserName ?? "agent", + string.Empty, + string.Empty), + record.WorkspacePath!), + $"Opening {shell.DisplayName} in the isolated session.", + shell.DisplayName, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Bundles host-side, and where reachable agent-side, diagnostics. + /// + /// + /// Producing a file is the point of the command, so reaching the session is + /// best-effort: someone runs this because something is broken, and a + /// host-only bundle is still worth handing over. + /// + public async Task CollectLogsAsync( + string? requestedPath, + CancellationToken cancellationToken) + { + string bundlePath = ResolveBundlePath( + requestedPath, + _paths, + Environment.CurrentDirectory, + _clock); + + List notes = []; + List<(string Name, string Path)> hostFiles = CollectHostFiles(); + string? staged = null; + SessionWorkspaceOperation? stagingOperation = null; + + try + { + (staged, stagingOperation, bool sessionReached) = + await TryStageAgentFilesAsync(notes, cancellationToken).ConfigureAwait(false); + + if (hostFiles.Count == 0 && staged is null) + { + return new DiagnosticsBundleResult(null, sessionReached, notes); + } + + try + { + WriteBundle(bundlePath, hostFiles, staged, stagingOperation, notes); + } + catch (IOException exception) when (File.Exists(bundlePath)) + { + throw new IOException( + $"The diagnostics bundle already exists: {bundlePath}. " + + "Choose another path with --output.", + exception); + } + return new DiagnosticsBundleResult(bundlePath, sessionReached, notes); + } + finally + { + TryDeleteStaging(stagingOperation, staged); + stagingOperation?.Dispose(); + } + } + + internal static string ResolveBundlePath( + string? requestedPath, + HostPaths paths, + string currentDirectory, + TimeProvider? clock = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(currentDirectory); + return requestedPath is null + ? Path.Combine( + paths.StateRoot, + $"openclaw-diagnostics-{(clock ?? TimeProvider.System).GetUtcNow():yyyyMMdd-HHmmss}.zip") + : Path.GetFullPath(requestedPath, currentDirectory); + } + + private List<(string Name, string Path)> CollectHostFiles() + { + List<(string Name, string Path)> files = []; + Add("host/openclaw.log", _paths.LogPath); + Add("host/setup.json", _paths.SetupStatePath); + Add("host/session.json", _paths.SessionStatePath); + Add("host/gateway-config.json", _paths.GatewayConfigurationPath); + Add("host/gateway-state.json", _paths.GatewayStatePath); + Add("host/gateway-launcher.cmd", _paths.GatewayLauncherPath); + return files; + + void Add(string name, string? path) + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + { + files.Add((name, path)); + } + } + } + + private async Task<( + string? Staged, + SessionWorkspaceOperation? Operation, + bool Reached)> + TryStageAgentFilesAsync( + List notes, + CancellationToken cancellationToken) + { + SessionStatus status = Session.Coordinator.GetRecordedStatus(); + if (status.Record?.WorkspacePath is not { Length: > 0 } workspace) + { + notes.Add( + status.Detail ?? + "No agent session is recorded, so agent-side logs were not collected."); + return (null, null, false); + } + + string staged = Path.Combine( + workspace, StagingDirectoryName, Guid.NewGuid().ToString("N")); + SessionWorkspaceOperation? operation = null; + + try + { + string helperPath = Session.RequireStagedHelper(status.Record); + SessionRecord record = await Session.Coordinator + .StartRecordedAsync(cancellationToken).ConfigureAwait(false); + operation = Session.Executor.CreateWorkspaceOperation(record); + + SessionCollectResult result = await Session.Executor + .CollectAsync( + record, + helperPath, + staged, + BuildAgentSources(), + DeniedBundleNames, + cancellationToken) + .ConfigureAwait(false); + + foreach (SessionCollectEntry entry in result.Entries ?? []) + { + if (!entry.Copied) + { + notes.Add($"agent/{entry.Name}: {entry.Detail ?? "not collected"}"); + } + } + + return ( + staged, + operation, + true); + } + catch (Exception exception) when ( + exception is SessionException or MxcException or SessionLaunchException or + IOException or UnauthorizedAccessException) + { + notes.Add( + $"Agent-side logs could not be collected ({exception.Message}). " + + "The bundle contains host-side diagnostics only."); + TryDeleteStaging(operation, staged); + operation?.Dispose(); + return (null, null, false); + } + } + + /// + /// The agent-side artifacts worth collecting, named relative to the agent + /// profile so the bundle layout never carries the account name. + /// + private static IReadOnlyList BuildAgentSources() + { + return + [ + new SessionCollectSource + { + RelativePath = @"AppData\Local\Temp\openclaw", + Name = "logs", + Pattern = "*.log", + Recursive = true + }, + new SessionCollectSource + { + RelativePath = ".openclaw", + Name = "config", + Pattern = "openclaw.json*" + }, + ]; + } + + private static void WriteBundle( + string bundlePath, + List<(string Name, string Path)> hostFiles, + string? staged, + SessionWorkspaceOperation? stagingOperation, + List notes) + { + Directory.CreateDirectory(Path.GetDirectoryName(bundlePath)!); + + using FileStream stream = new( + bundlePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using ZipArchive archive = new(stream, ZipArchiveMode.Create); + + foreach ((string name, string path) in hostFiles) + { + AddEntry(archive, name, path, notes); + } + + if (staged is not null && + stagingOperation is not null && + Directory.Exists(staged)) + { + stagingOperation.EnsureCurrent(); + foreach (string file in EnumerateStagedFiles( + stagingOperation.WorkspacePath, + staged, + notes)) + { + string relative = Path.GetRelativePath(staged, file).Replace('\\', '/'); + AddEntry( + archive, + $"agent/{relative}", + file, + notes, + stagingOperation); + } + } + + ZipArchiveEntry manifest = archive.CreateEntry("manifest.txt"); + using StreamWriter writer = new(manifest.Open()); + writer.WriteLine($"Collected: {DateTimeOffset.UtcNow:u}"); + writer.WriteLine( + "Redaction is best-effort and targets credential-shaped values. " + + "Review before sharing."); + foreach (string note in notes) + { + writer.WriteLine(note); + } + } + + private static List EnumerateStagedFiles( + string workspace, + string staged, + List notes) + { + List files = []; + try + { + TrustedPath.EnsureNoReparsePoints(workspace, staged); + Stack pending = new(); + pending.Push(staged); + while (pending.TryPop(out string? directory)) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + notes.Add( + $"agent/{Path.GetRelativePath(staged, entry).Replace('\\', '/')}: " + + "excluded because it is a reparse point"); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + pending.Push(entry); + } + else + { + TrustedPath.EnsureNoReparsePoints(workspace, entry); + files.Add(entry); + } + } + } + } + catch (Exception exception) when ( + exception is SessionException or IOException or UnauthorizedAccessException) + { + notes.Add( + $"Agent-side staging could not be read safely ({exception.Message})."); + } + + return files; + } + + private static void AddEntry( + ZipArchive archive, + string name, + string path, + List notes, + SessionWorkspaceOperation? operation = null) + { + string fileName = Path.GetFileName(path); + + // The guest already refused these. Re-checking here covers host-side + // sources and any future caller that stages files another way. + if (SessionCollectProtocol.IsDenied(fileName, DeniedBundleNames)) + { + notes.Add($"{name}: excluded by policy"); + return; + } + + try + { + string text; + using FileStream input = operation is null + ? new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete) + : operation.OpenRead(path); + using (StreamReader reader = new(input)) + { + text = reader.ReadToEnd(); + } + + ZipArchiveEntry entry = archive.CreateEntry(name); + using StreamWriter writer = new(entry.Open()); + writer.Write(DiagnosticsRedactor.Redact(text)); + } + catch (Exception exception) when ( + exception is SessionException or IOException or UnauthorizedAccessException) + { + notes.Add($"{name}: unreadable ({exception.Message})"); + } + } + + private static void TryDeleteStaging( + SessionWorkspaceOperation? operation, + string? staged) + { + if (operation is null || staged is null) + { + return; + } + + try + { + operation.Delete(staged); + } + catch (Exception exception) when ( + exception is SessionException or IOException or UnauthorizedAccessException) + { + } + } + +} diff --git a/src/OpenClaw.Launcher/Gateway/DiagnosticsRedactor.cs b/src/OpenClaw.Launcher/Gateway/DiagnosticsRedactor.cs new file mode 100644 index 00000000..ff5dc46c --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/DiagnosticsRedactor.cs @@ -0,0 +1,40 @@ +using System.Text.RegularExpressions; + +namespace OpenClaw.Launcher.Gateway; + +/// +/// Removes credential-shaped values from text before it enters a bundle. +/// +/// +/// +/// A bundle exists to be handed to someone else, which is the entire reason +/// this is needed. Credential stores are excluded outright rather than +/// redacted; this covers configuration files that legitimately belong in a +/// bundle but may carry an inline key. +/// +/// +/// This is deliberately described as best-effort. It matches JSON members whose +/// name looks like a secret, so a credential stored under an unexpected name, +/// or in a format other than JSON, still gets through. Promising more than that +/// would encourage sharing bundles without reading them. +/// +/// +internal static partial class DiagnosticsRedactor +{ + public const string Placeholder = ""; + + public static string Redact(string text) + { + ArgumentNullException.ThrowIfNull(text); + return SecretMember().Replace(text, $"$1\"{Placeholder}\""); + } + + /// + /// Matches a JSON member whose name contains a credential word, capturing + /// everything up to its value so only the value is replaced. + /// + [GeneratedRegex( + """"("[^"]*(?:key|token|secret|password|credential)[^"]*"\s*:\s*)"(?:[^"\\]|\\.)*["]"""", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex SecretMember(); +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs index ec85bada..1dd34df1 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs @@ -43,13 +43,14 @@ internal sealed record GatewayLaunchConfiguration public int? Port { get; init; } /// - /// The directory the gateway runs in. Stated rather than inherited: at - /// logon a task's directory is the system directory, and an interactive - /// caller's directory would make the gateway's behavior depend on where it - /// happened to be started from. + /// The guest-visible directory the gateway runs in. Stated rather than + /// inherited: at logon a task's directory is the system directory, and an + /// interactive caller's directory would make the gateway's behavior depend + /// on where it happened to be started from. /// [JsonPropertyName("workingDirectory")] public string? WorkingDirectory { get; init; } + } /// Why a stored launch configuration could not be used. @@ -205,25 +206,12 @@ public GatewayConfigurationResult Read() "TCP port (1-65535)."); } - if (configuration.WorkingDirectory is { Length: > 0 } workingDirectory && - !Path.IsPathFullyQualified(workingDirectory)) - { - return new GatewayConfigurationResult(null, GatewayConfigurationFault.Invalid, - "The configured working directory must be an absolute guest-visible path."); - } - return new GatewayConfigurationResult(configuration, null, null); } public void Write(GatewayLaunchConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); - if (configuration.WorkingDirectory is { Length: > 0 } workingDirectory && - !Path.IsPathFullyQualified(workingDirectory)) - { - throw new GatewayConfigurationException("The working directory must be an absolute guest-visible path."); - } - if (configuration.Port is int port && !IsUsablePort(port)) { throw new GatewayConfigurationException( diff --git a/src/OpenClaw.Launcher/Gateway/GatewayController.cs b/src/OpenClaw.Launcher/Gateway/GatewayController.cs index b089806f..0d2063fc 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayController.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayController.cs @@ -158,11 +158,6 @@ public async Task StartAsync( using ISessionLockHandle handle = AcquireLock(); SessionRecord configured = _requireSetup(); GatewayStateResult existing = _store.Read(); - if (existing.Record?.LaunchPending == true) - { - throw new SessionException( - "A previous gateway launch was not confirmed. Inspect diagnostics before using `clawctl teardown` to remove the owned session."); - } if (existing.Record is null && existing.Fault != GatewayStateFault.Missing) { throw new SessionException($"The gateway record could not be used: {existing.Detail}"); @@ -172,6 +167,13 @@ public async Task StartAsync( { throw new SessionException("The gateway record belongs to a different session."); } + if (existing.Record?.LaunchPending == true) + { + throw new SessionException( + "A previous gateway launch was not confirmed. The launch intent was retained; " + + "do not start a replacement. Inspect diagnostics or run `clawctl teardown --force` " + + "to recover the owned session."); + } SessionRecord session = await _sessions .StartRecordedAsync(cancellationToken) @@ -187,10 +189,11 @@ public async Task StartAsync( if (inspection.IsOwnedAndHealthy) { + GatewayRecord confirmedRecord = existing.Record; _log("The gateway is already running."); return new GatewayStartResult( GatewayState.Running, - existing.Record, + confirmedRecord, AlreadyRunning: true, "The gateway is already running."); } @@ -211,6 +214,13 @@ public async Task StartAsync( "`clawctl gateway-service stop` before starting another."); } + if (existing.Record.LaunchPending) + { + // The guest established that the pending process either exited + // or its identifier was reused, so removing this intent cannot + // affect an unrelated process. + _store.Clear(); + } _log("The recorded gateway is no longer running; starting a new one."); } else if (existing.Fault != GatewayStateFault.Missing) @@ -311,11 +321,26 @@ public async Task StopAsync( internal async Task StopUnderLockAsync( string helperPath, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool clearRecord = true, + bool allowUnconfirmedLaunch = false, + bool allowUnavailableInspection = false) { GatewayStateResult state = _store.Read(); if (state.Record?.LaunchPending == true) { + if (allowUnconfirmedLaunch) + { + if (clearRecord) + { + _store.Clear(); + } + + return new GatewayStopResult( + Stopped: false, + "The unconfirmed gateway launch record was removed before session teardown."); + } + return new GatewayStopResult(false, "The previous launch was not confirmed; its ownership intent was retained.", "Use `clawctl teardown` to stop/deprovision the owned session through MXC.", false); @@ -354,16 +379,24 @@ internal async Task StopUnderLockAsync( { // Nothing is killed on a guess. Acting on an unverified identifier // could stop an unrelated process that inherited it. - return new GatewayStopResult( - Stopped: false, - "The gateway could not be stopped because its state could not " + - "be established.", - inspection.Error, Succeeded: false); + return allowUnavailableInspection + ? new GatewayStopResult( + Stopped: false, + "Guest gateway inspection was unavailable; session removal will proceed without guest confirmation.", + inspection.Error) + : new GatewayStopResult( + Stopped: false, + "The gateway could not be stopped because its state could not " + + "be established. Re-run `clawctl teardown --force` to remove the owned session without guest confirmation.", + inspection.Error, Succeeded: false); } if (!inspection.ProcessFound || !inspection.StartTimeMatches) { - _store.Clear(); + if (clearRecord) + { + _store.Clear(); + } return new GatewayStopResult( Stopped: false, "The recorded gateway was no longer running, so its record was " + @@ -380,7 +413,10 @@ internal async Task StopUnderLockAsync( "The gateway stop could not be verified; its record was retained.", stopped.Error, Succeeded: false); } - _store.Clear(); + if (clearRecord) + { + _store.Clear(); + } return new GatewayStopResult(Stopped: true, "The gateway is stopped."); } @@ -396,6 +432,7 @@ private async Task InspectAsync( .InspectAsync(session, gateway, helperPath, cancellationToken) .ConfigureAwait(false); } + catch (Exception exception) when ( exception is SessionException or Mxc.MxcException or IOException or UnauthorizedAccessException) { diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs index a8e138bc..01867369 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs @@ -81,10 +81,18 @@ internal sealed record GatewayPersistenceRemovalResult( /// /// The absolute path to the inbox command processor the task runs. /// +/// +/// Optional file-delete seam used to make persistence failures observable in tests. +/// +/// +/// The interactive user whose logon starts recovery. Defaults to . +/// internal sealed record GatewayPersistenceOptions( string UserSid, string PackageFamilyName, string LauncherPath, string StartupFolderPath, string WorkingDirectory, - string CommandProcessorPath); + string CommandProcessorPath, + Action? DeleteFile = null, + string? LogonTriggerUserSid = null); diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs index 49185eea..ce2788f8 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs @@ -211,40 +211,25 @@ public async Task UninstallAsync( _identity.Name, cancellationToken).ConfigureAwait(false); - CleanupResult fallback = RemoveFallback(); - CleanupResult launcher = RemoveLauncher(); - CleanupResult activationScript = RemoveActivationScript(); - bool changed = - fallback == CleanupResult.Removed || - launcher == CleanupResult.Removed || - activationScript == CleanupResult.Removed; - - if (!deletion.Succeeded || - fallback == CleanupResult.Failed || - launcher == CleanupResult.Failed || - activationScript == CleanupResult.Failed) + GatewayGeneratedFileRemoval fallback = RemoveFallback(); + GatewayGeneratedFileRemoval launcher = RemoveLauncher(); + GatewayGeneratedFileRemoval activationScript = RemoveActivationScript(); + string? detail = Combine( + deletion.Detail, + Combine(fallback.Detail, launcher.Detail, activationScript.Detail)); + + if (!deletion.Succeeded || detail is not null) { return new GatewayPersistenceRemovalResult( Succeeded: false, - changed, + fallback.Changed || launcher.Changed || activationScript.Changed, "Logon recovery could not be fully removed.", - Combine( - deletion.Succeeded ? null : deletion.Detail, - Combine( - fallback == CleanupResult.Failed - ? $"'{FallbackPath}' could not be removed." - : null, - launcher == CleanupResult.Failed - ? $"'{_options.LauncherPath}' could not be removed." - : null, - activationScript == CleanupResult.Failed - ? $"'{ActivationScriptPath}' could not be removed." - : null))); + detail); } return new GatewayPersistenceRemovalResult( Succeeded: true, - changed, + fallback.Changed || launcher.Changed || activationScript.Changed, "Logon recovery is removed."); } @@ -252,7 +237,8 @@ private GatewayTaskSnapshot DesiredSnapshot() => GatewayTaskDefinition.CreateSnapshot( _options.UserSid, _options.CommandProcessorPath, - _options.LauncherPath); + LauncherPath, + _options.LogonTriggerUserSid); private string? DescribeDrift(GatewayTaskSnapshot actual) { @@ -438,16 +424,16 @@ private bool FallbackMatches() } } - private CleanupResult RemoveFallback() => + private GatewayGeneratedFileRemoval RemoveFallback() => RemoveGeneratedFile(FallbackPath); - private CleanupResult RemoveLauncher() => + private GatewayGeneratedFileRemoval RemoveLauncher() => RemoveGeneratedFile(LauncherPath); - private CleanupResult RemoveActivationScript() => + private GatewayGeneratedFileRemoval RemoveActivationScript() => RemoveGeneratedFile(ActivationScriptPath); - private static CleanupResult RemoveGeneratedFile(string path) + private GatewayGeneratedFileRemoval RemoveGeneratedFile(string path) { // Only a file this installation generated is deleted. A same-named file // someone else placed there is left alone. @@ -456,16 +442,18 @@ private static CleanupResult RemoveGeneratedFile(string path) if (!File.Exists(path) || !GatewayLauncherScript.LooksGenerated(File.ReadAllText(path))) { - return CleanupResult.Absent; + return new GatewayGeneratedFileRemoval(false, null); } - File.Delete(path); - return CleanupResult.Removed; + (_options.DeleteFile ?? File.Delete)(path); + return new GatewayGeneratedFileRemoval(true, null); } catch (Exception exception) when ( exception is IOException or UnauthorizedAccessException) { - return CleanupResult.Failed; + return new GatewayGeneratedFileRemoval( + false, + $"Could not remove generated recovery file '{path}': {exception.Message}"); } } @@ -494,6 +482,8 @@ private static string ResolveOwnedFilePath(string directory, string path) return candidate; } + private sealed record GatewayGeneratedFileRemoval(bool Changed, string? Detail); + private static string? Combine(string? first, string? second) => (string.IsNullOrWhiteSpace(first), string.IsNullOrWhiteSpace(second)) switch { @@ -509,10 +499,4 @@ private static string ResolveOwnedFilePath(string directory, string path) private static bool Same(string left, string right) => string.Equals(left, right, StringComparison.OrdinalIgnoreCase); - private enum CleanupResult - { - Absent, - Removed, - Failed, - } } diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index c0d00559..d6670747 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -5,21 +5,37 @@ namespace OpenClaw.Launcher.Gateway; /// Assembles gateway management from the running installation. -internal sealed class GatewayRuntime +internal sealed partial class GatewayRuntime { private static readonly Guid StartupFolderId = new("B97D20BB-F46A-4C97-BA10-5E3608430854"); - private GatewayRuntime(GatewayController controller, string helperPath) + private readonly HostPaths _paths; + private readonly SessionRuntime _session; + private readonly TimeProvider _clock; + + private GatewayRuntime( + GatewayController controller, + string helperPath, + HostPaths paths, + SessionRuntime session, + TimeProvider clock) { Controller = controller; HelperPath = helperPath; + _paths = paths; + _session = session; + _clock = clock; } public GatewayController Controller { get; } public string HelperPath { get; } + private SessionRuntime Session => _session; + + private static bool FileExists(string path) => File.Exists(path); + public static GatewayPersistenceManager CreateRecoveryManager(Action log) { ArgumentNullException.ThrowIfNull(log); @@ -31,6 +47,10 @@ public static GatewayPersistenceManager CreateRecoveryManager(Action log string userSid = WindowsIdentity.GetCurrent().User?.Value ?? throw new SessionException( "The signed-in user's security identifier is unavailable, so gateway recovery cannot be configured."); + string logonUserSid = ResolveUserSid( + $@"{Environment.UserDomainName}\{Environment.UserName}") + ?? throw new SessionException( + "The signed-in user's logon identity could not be resolved, so gateway recovery cannot be configured."); return new GatewayPersistenceManager( new SchTasksGatewayScheduler(), @@ -40,7 +60,8 @@ public static GatewayPersistenceManager CreateRecoveryManager(Action log paths.GatewayLauncherPath, GetStartupFolderPath(), paths.StateRoot, - Path.Combine(Environment.SystemDirectory, "cmd.exe")), + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + LogonTriggerUserSid: logonUserSid), log, ResolveUserSid); } @@ -94,12 +115,10 @@ private static extern int SHGetKnownFolderPath( public static GatewayRuntime Create( HostOptions options, - Action log, - Func>? resolveNode = null) + Action log) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(log); - _ = resolveNode; HostPaths paths = HostPaths.Create(); if (paths.PackageFamilyName is null) @@ -109,6 +128,55 @@ public static GatewayRuntime Create( } SessionRuntime session = SessionRuntime.Create(log); + return Create(options, paths, session, log); + } + + internal static GatewayRuntime Create( + HostOptions options, + HostPaths paths, + SessionRuntime session, + Action log, + TimeProvider? clock = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(log); + + return new GatewayRuntime( + CreateController(options, paths, session, log), + session.HelperPath, + paths, + session, + clock ?? TimeProvider.System); + } + + internal static TeardownOrchestrator CreateTeardownOrchestrator( + HostOptions options, + SessionRuntime session, + Action log) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(log); + + HostPaths paths = HostPaths.Create(); + return new TeardownOrchestrator( + session.LifecycleLock, + CreateRecoveryManager(log), + CreateController(options, paths, session, log), + session.Coordinator, + session.GatewayState, + new GatewayConfigurationStore(paths.GatewayConfigurationPath), + session.SetupState); + } + + private static GatewayController CreateController( + HostOptions options, + HostPaths paths, + SessionRuntime session, + Action log) + { var configuration = new GatewayConfigurationStore(paths.GatewayConfigurationPath); Task CreateRequestAsync(CancellationToken cancellationToken) { @@ -120,32 +188,32 @@ Task CreateRequestAsync(CancellationToken cancellationToken configuration, sessionRecord, Environment.GetEnvironmentVariable); + string archivePath = options.PackagedNodeArchivePath + ?? throw new SessionException( + "The packaged Node.js runtime archive was not found."); return Task.FromResult(new GatewayStartRequest( session.HelperPath, - session.RequireAgentNodePath( - options.PackagedNodeArchivePath - ?? throw new SessionException( - "The packaged Node.js runtime archive was not found.")), + session.RequireAgentNodePath(archivePath), applicationDirectory, - launch.WorkingDirectory ?? sessionRecord.WorkspacePath + launch.Port) + { + WorkingDirectory = launch.WorkingDirectory ?? sessionRecord.WorkspacePath ?? throw new SessionException( - "The isolated session has no shared workspace for the gateway."), - launch.Port)); + "The isolated session has no shared workspace for the gateway.") + }); } - return new GatewayRuntime( - new GatewayController( - session.Coordinator, - new SessionGatewayClient( - session.Backend, - log, - isCurrentRecord: IsCurrentSessionRecord), - new GatewayStateStore(paths.GatewayStatePath), - CreateRequestAsync, + return new GatewayController( + session.Coordinator, + new SessionGatewayClient( + session.Backend, log, - session.RequireSetup, - session.LifecycleLock), - session.HelperPath); + isCurrentRecord: IsCurrentSessionRecord), + session.GatewayState, + CreateRequestAsync, + log, + session.RequireSetup, + session.LifecycleLock); bool IsCurrentSessionRecord(SessionRecord record) { diff --git a/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs b/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs index d266d103..726a2d7a 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using OpenClaw.Launcher.Session; namespace OpenClaw.Launcher.Gateway; @@ -228,4 +229,34 @@ public void Clear() { } } + + /// + /// Removes a record only when it names a session MXC explicitly reported + /// stale and setup has replaced. + /// + /// True when the matching stale record was removed. + public bool ClearForSupersededSession(string sandboxId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sandboxId); + + GatewayStateResult state = Read(); + if (state.Fault == GatewayStateFault.Missing) + { + return false; + } + + if (state.Record is null) + { + throw new SessionException( + $"The gateway record could not be reconciled: {state.Detail}"); + } + + if (!string.Equals(state.Record.SandboxId, sandboxId, StringComparison.Ordinal)) + { + return false; + } + + Clear(); + return true; + } } diff --git a/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs b/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs index 819ebc1d..6fedfb32 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs @@ -52,7 +52,8 @@ internal static class GatewayTaskDefinition public static GatewayTaskSnapshot CreateSnapshot( string userSid, string commandProcessorPath, - string launcherPath) => + string launcherPath, + string? logonTriggerUserSid = null) => new( UserId: userSid, // InteractiveToken runs with the user's interactive session and @@ -63,7 +64,7 @@ public static GatewayTaskSnapshot CreateSnapshot( Enabled: true, HasSingleLogonTrigger: true, LogonTriggerEnabled: true, - LogonTriggerUserId: userSid, + LogonTriggerUserId: logonTriggerUserSid ?? userSid, HasSingleExecAction: true, MultipleInstancesPolicy: IgnoreNewInstances, // A gateway that refuses to start on battery, or dies when the diff --git a/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs index dd004740..ec67ef39 100644 --- a/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs +++ b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs @@ -13,8 +13,14 @@ internal sealed record GatewayStartRequest( string HelperPath, string NodePath, string ApplicationDirectory, - string WorkingDirectory, - int? Port); + int? Port) +{ + /// + /// The guest-visible directory to run in. The shared workspace is used when + /// the user configured nothing. + /// + public string? WorkingDirectory { get; init; } +} /// The identity of a gateway that was started. internal sealed record GatewayStartOutcome( @@ -119,7 +125,7 @@ public async Task StartAsync( Mode = SessionLaunchMode.Detached, Executable = request.NodePath, Arguments = arguments, - WorkingDirectory = request.WorkingDirectory, + WorkingDirectory = request.WorkingDirectory ?? workspace, Environment = _buildEnvironment(), PathPrefix = Path.GetDirectoryName(request.NodePath) ?? throw new SessionException( @@ -228,6 +234,7 @@ await operation.WriteTextNewAsync( { RequestId = requestId, ProcessId = gateway.ProcessId, + LaunchPending = gateway.LaunchPending, ProcessStartTimeUtc = gateway.ProcessStartTimeUtc, HelperPath = gateway.HelperPath, StatusPath = gateway.StatusPath, diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index 1a815fa0..4233eef6 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -21,16 +21,21 @@ internal sealed class HostStartup public Func>? ResolveNode { get; init; } + public Func, NodeRuntime>? InstallNodeRuntime { get; init; } + public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; } public Func, Session.SessionRuntime>? CreateSessionRuntime { get; init; } + public Func? ReadEnvironmentVariable { get; init; } + public static HostStartup CreateProduction() => new() { Entrypoint = HostEntrypointResolver.Resolve(), CreateDiagnostics = HostDiagnosticLog.Create, BaseDirectory = AppContext.BaseDirectory, Output = Console.Out, - Error = Console.Error + Error = Console.Error, + InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled }; } diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 2fea084c..d17796af 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -113,10 +113,13 @@ PlatformNotSupportedException or : await RunAgentAsync( options, WriteDiagnostic, - startup.ResolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( - GetPackagedNodeArchivePath(options)))), + startup.ResolveNode ?? (_ => Task.FromResult( + (startup.InstallNodeRuntime ?? NodeRuntimeInstaller.EnsureInstalled)( + GetPackagedNodeArchivePath(options), + WriteDiagnostic))), startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, - startup.CreateSessionRuntime) + startup.CreateSessionRuntime, + readEnvironmentVariable: startup.ReadEnvironmentVariable) .ConfigureAwait(false); } catch (Exception exception) @@ -144,8 +147,9 @@ internal static async Task RunAgentAsync( await RunAgentAsync( options, log, - resolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( - GetPackagedNodeArchivePath(options)))), + resolveNode ?? (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( + GetPackagedNodeArchivePath(options), + log))), GatewayLauncher.RunAsync).ConfigureAwait(false); // launchOpenClaw is a test seam: tests substitute a fake in place of @@ -157,12 +161,16 @@ internal static async Task RunAgentAsync( Func> resolveNode, LaunchOpenClawAsync launchOpenClaw, Func, Session.SessionRuntime>? createSessionRuntime = null, - Func? isInteractive = null, Func>? probeReadiness = null, - Func? getPackageFamilyName = null) + Func? getPackageFamilyName = null, + Func? readEnvironmentVariable = null, + Func? isInteractive = null) { + string applicationDirectory = GetPackagedApplicationDirectory(options); + log("Using the OpenClaw application directly from the package."); + Session.SessionMode mode = Session.SessionRoutingPolicy.ReadMode( - Environment.GetEnvironmentVariable); + readEnvironmentVariable ?? Environment.GetEnvironmentVariable); if (mode == Session.SessionMode.Disabled) { return await RunDirectAsync().ConfigureAwait(false); @@ -208,8 +216,6 @@ record = await runtime.StartForExecutionAsync(CancellationToken.None) string agentNodePath = runtime.RequireAgentNodePath( GetPackagedNodeArchivePath(options)); - string applicationDirectory = GetPackagedApplicationDirectory(options); - log("Using the OpenClaw application directly from the package."); return await runtime.Executor.ExecuteAsync( record, new Session.SessionExecutionRequest( @@ -221,7 +227,7 @@ record = await runtime.StartForExecutionAsync(CancellationToken.None) { AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), - Environment.GetEnvironmentVariable, + readEnvironmentVariable ?? Environment.GetEnvironmentVariable, GatewayIsolationMode.Enabled) }, CancellationToken.None).ConfigureAwait(false); @@ -255,35 +261,54 @@ internal static async Task RunControlAsync( TextWriter output, TextWriter error, Func>? resolveNode = null, - Func? createSessionRuntime = null, - Func>? + Func, Session.SessionRuntime>? createSessionRuntime = null, + Func, Session.TeardownOrchestrator>? + createTeardownOrchestrator = null, + Func, CancellationToken, Task>? installRecovery = null, + Func>? probeReadiness = null, + Func? getPackageFamilyName = null, Func>? removeRecovery = null) { Session.SessionRuntime? sessionRuntime = null; Session.SessionRuntime GetSessionRuntime() => - sessionRuntime ??= createSessionRuntime?.Invoke() ?? - Session.SessionRuntime.Create(log); + sessionRuntime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); RootCommand command = ClawCtlCommandLine.Create( new ClawCtlHandlers { - Setup = async cancellationToken => + Setup = async (setupOptions, cancellationToken) => { - int result = await RunSetupAsync( - options, log, output, resolveNode, cancellationToken) - .ConfigureAwait(false); - if (result != 0) + string applicationDirectory = GetPackagedApplicationDirectory(options); + log("Confirmed the packaged OpenClaw application is present."); + ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); + + Mxc.MxcReadinessReport readiness = await (probeReadiness ?? + Mxc.MxcReadiness.ProbeAsync)(cancellationToken).ConfigureAwait(false); + Session.SessionRoutingDecision routing = Session.SessionRoutingPolicy.Decide( + Session.SessionMode.Automatic, + (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(), + readiness); + if (routing.Routing != Session.SessionRouting.Session) { - return result; + await output.WriteLineAsync( + $"OpenClaw setup requires isolated-session support. {routing.Reason}") + .ConfigureAwait(false); + return 1; } - if (Session.SessionRoutingPolicy.ReadMode( - Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) + if (setupOptions.NoIsolation || + Session.SessionRoutingPolicy.ReadMode( + Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) { + NodeRuntime nodeRuntime = await (resolveNode ?? + (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( + GetPackagedNodeArchivePath(options), + log))))(cancellationToken).ConfigureAwait(false); + ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); await output.WriteLineAsync( - "Isolated session setup was skipped because OPENCLAW_SESSION is disabled.") + "OpenClaw setup completed without isolated-session provisioning.") .ConfigureAwait(false); return 0; } @@ -291,31 +316,56 @@ await output.WriteLineAsync( try { Session.SessionRuntime runtime = GetSessionRuntime(); - using Session.ISessionLockHandle handle = - runtime.AcquireLifecycleLock(); + using Session.ISessionLockHandle handle = runtime.AcquireLifecycleLock(); runtime.SetupState.Write(new Session.SetupRecord { ApplicationId = runtime.ApplicationId, Phase = Session.SetupPhase.Preparing }); - Session.SessionRecord record = - await runtime.Coordinator.EnsureStartedAsync(cancellationToken) - .ConfigureAwait(false); + Session.SessionStartResult session = await runtime.Coordinator + .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); + + // A replacement whose first start failed leaves the + // superseded identity only in the persisted record, so + // a retry still reconciles the stale gateway. + IEnumerable supersededSandboxIds = + (session.SupersededRecord is null + ? Enumerable.Empty() + : [session.SupersededRecord.SandboxId]) + .Concat(session.Record.SupersededSandboxIds ?? []) + .Append(session.Record.SupersededSandboxId) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Select(static id => id!) + .Distinct(StringComparer.Ordinal); + foreach (string supersededSandboxId in supersededSandboxIds) + { + if (runtime.GatewayState.ClearForSupersededSession( + supersededSandboxId)) + { + log("Removed the gateway record for the superseded session."); + } + } + + Session.SessionRecord record = session.Record; string helperPath = runtime.StageHelper(record); - SessionRuntimeInstallResult agentRuntime = - await runtime.Executor.InstallRuntimeAsync( + SessionRuntimeInstallResult agentRuntime = await runtime.Executor + .InstallRuntimeAsync( record, helperPath, GetPackagedNodeArchivePath(options), cancellationToken) .ConfigureAwait(false); - Gateway.GatewayPersistenceInstallResult recovery = - installRecovery is null - ? await Gateway.GatewayRuntime.CreateRecoveryManager(log) - .InstallAsync(cancellationToken) - .ConfigureAwait(false) - : await installRecovery(cancellationToken) - .ConfigureAwait(false); + await output.WriteLineAsync( + $"Node.js {agentRuntime.Version} is ready in the isolated agent session.") + .ConfigureAwait(false); + await output.WriteLineAsync("OpenClaw isolated session is ready.") + .ConfigureAwait(false); + + Gateway.GatewayPersistenceInstallResult recovery = await ( + installRecovery ?? ((writeLog, token) => + Gateway.GatewayRuntime.CreateRecoveryManager(writeLog) + .InstallAsync(token)))(log, cancellationToken) + .ConfigureAwait(false); await output.WriteLineAsync(recovery.Message).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(recovery.Detail)) { @@ -328,8 +378,6 @@ installRecovery is null } runtime.CompleteSetup(record, agentRuntime, startupEnabled: true); - await output.WriteLineAsync("OpenClaw isolated session is ready.") - .ConfigureAwait(false); return 0; } catch (Session.SessionException exception) @@ -343,14 +391,43 @@ await output.WriteLineAsync( }, Status = async cancellationToken => { - Session.SessionStatus status = GetSessionRuntime() - .Coordinator.GetRecordedStatus(); - await output.WriteLineAsync(status.Availability.ToString()) + Session.SessionStatus status = await GetSessionRuntime() + .Coordinator.ProbeRecordedStatusAsync(cancellationToken) + .ConfigureAwait(false); + await output.WriteLineAsync(DescribeSessionStatus(status)) + .ConfigureAwait(false); + return status.Availability is Session.SessionAvailability.Stale or + Session.SessionAvailability.BackendUnavailable or + Session.SessionAvailability.BackendError or + Session.SessionAvailability.Unusable + ? 1 + : 0; + }, + CollectLogs = async (requestedPath, cancellationToken) => + { + HostPaths paths = HostPaths.Create(); + Gateway.DiagnosticsBundleResult result = + await Gateway.GatewayRuntime.Create( + options, + paths, + GetSessionRuntime(), + log) + .CollectLogsAsync(requestedPath, cancellationToken) .ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(status.Detail)) + if (result.BundlePath is not null) { - await output.WriteLineAsync(status.Detail) - .ConfigureAwait(false); + await output.WriteLineAsync( + $"Diagnostics bundle created: {result.BundlePath}").ConfigureAwait(false); + } + else + { + await output.WriteLineAsync( + "No diagnostic files were available to bundle.").ConfigureAwait(false); + } + + foreach (string warning in result.Notes) + { + await output.WriteLineAsync($"Warning: {warning}").ConfigureAwait(false); } return 0; }, @@ -365,30 +442,40 @@ await error.WriteLineAsync( } Session.SessionRuntime runtime = GetSessionRuntime(); - using Session.ISessionLockHandle handle = - runtime.AcquireLifecycleLock(); - Gateway.GatewayPersistenceRemovalResult recoveryRemoval = - removeRecovery is null - ? await Gateway.GatewayRuntime - .CreateRecoveryManager(log) - .UninstallAsync(cancellationToken) - .ConfigureAwait(false) - : await removeRecovery(cancellationToken) - .ConfigureAwait(false); - if (!recoveryRemoval.Succeeded) + if (removeRecovery is not null) { - await error.WriteLineAsync( - $"OpenClaw recovery could not be removed: {recoveryRemoval.Detail}") + using Session.ISessionLockHandle handle = + runtime.AcquireLifecycleLock(); + Gateway.GatewayPersistenceRemovalResult recovery = + await removeRecovery(cancellationToken).ConfigureAwait(false); + if (!recovery.Succeeded) + { + await error.WriteLineAsync( + $"OpenClaw recovery could not be removed: {recovery.Detail}") + .ConfigureAwait(false); + return 1; + } + + await runtime.Coordinator.RemoveAsync(cancellationToken) .ConfigureAwait(false); - return 1; + runtime.GatewayState.Clear(); + await output.WriteLineAsync("OpenClaw isolated session was removed.") + .ConfigureAwait(false); + return 0; } - await runtime.Coordinator.RemoveAsync(cancellationToken) - .ConfigureAwait(false); - runtime.GatewayState.Clear(); - await output.WriteLineAsync("OpenClaw isolated session was removed.") - .ConfigureAwait(false); - return 0; + Session.TeardownOrchestrator teardown = (createTeardownOrchestrator ?? + ((current, writeLog) => Gateway.GatewayRuntime + .CreateTeardownOrchestrator(options, current, writeLog)))(runtime, log); + Session.TeardownResult result = await teardown.RunAsync( + runtime.HelperPath, force, cancellationToken).ConfigureAwait(false); + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(result.Detail)) + { + await output.WriteLineAsync(result.Detail).ConfigureAwait(false); + } + + return result.Succeeded ? 0 : 1; }, PowerShell = cancellationToken => RunPowerShellAsync( options, @@ -398,7 +485,7 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") GatewayStart = async cancellationToken => { Gateway.GatewayStartResult result = await Gateway.GatewayRuntime - .Create(options, log, resolveNode) + .Create(options, log) .Controller .StartAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); @@ -408,7 +495,7 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") GatewayStatus = async cancellationToken => { Gateway.GatewayStatusReport result = await Gateway.GatewayRuntime - .Create(options, log, resolveNode) + .Create(options, log) .Controller .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); @@ -423,7 +510,7 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") GatewayStop = async cancellationToken => { Gateway.GatewayStopResult result = await Gateway.GatewayRuntime - .Create(options, log, resolveNode) + .Create(options, log) .Controller .StopAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); @@ -517,30 +604,23 @@ await output.WriteLineAsync( cancellationToken).ConfigureAwait(false); } - private static async Task RunSetupAsync( - HostOptions options, - Action log, - TextWriter output, - Func>? resolveNode, - CancellationToken cancellationToken) - { - NodeRuntime nodeRuntime; - if (resolveNode is not null) + private static string DescribeSessionStatus(Session.SessionStatus status) => + status.Availability switch { - nodeRuntime = await resolveNode(cancellationToken).ConfigureAwait(false); - } - else - { - nodeRuntime = NodeRuntimeInstaller.EnsureInstalled( - GetPackagedNodeArchivePath(options), - log); - } - ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); - string applicationDirectory = GetPackagedApplicationDirectory(options); - log("Confirmed the packaged OpenClaw application is present."); - ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); - return 0; - } + Session.SessionAvailability.None => + "No isolated session is recorded. Run `clawctl setup` first.", + Session.SessionAvailability.Running => + $"Isolated session is running: {status.Record!.SandboxId}.", + Session.SessionAvailability.Stale => + $"Recorded isolated session is stale: {status.Record!.SandboxId}. {status.Detail}", + Session.SessionAvailability.BackendUnavailable => + $"MXC backend is unavailable for recorded session {status.Record!.SandboxId}: {status.Detail}", + Session.SessionAvailability.BackendError => + $"MXC could not verify recorded session {status.Record!.SandboxId}: {status.Detail}", + Session.SessionAvailability.Unusable => + $"The isolated-session record is unusable: {status.Detail}", + _ => $"Isolated session is recorded: {status.Record!.SandboxId}." + }; internal delegate Task LaunchOpenClawAsync( string nodePath, diff --git a/src/OpenClaw.Launcher/Session/SessionCoordinator.cs b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs index 3a395e7d..102fb3d0 100644 --- a/src/OpenClaw.Launcher/Session/SessionCoordinator.cs +++ b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs @@ -84,18 +84,30 @@ public SessionBusyException(TimeSpan timeout) } /// -/// Whether a usable session is recorded, without consulting the backend. +/// What status established about the owned session. /// internal enum SessionAvailability { /// No session has been recorded; a first one may be created. None, - /// A usable record exists. It says nothing about liveness. + /// A usable record exists but its backend state is not known. Recorded, /// A record exists but cannot be used. Recovery is required. Unusable, + + /// The backend accepted the recorded provision and started it. + Running, + + /// The backend explicitly reported that the recorded provision is missing. + Stale, + + /// The MXC runtime or backend could not be reached. + BackendUnavailable, + + /// The MXC backend returned an error other than a missing provision. + BackendError, } /// @@ -119,6 +131,18 @@ internal sealed record SessionStatus( /// internal sealed record SessionRemovalResult(bool Removed, string? StopFailure); +/// +/// The result of ensuring that an owned session is started. +/// +/// The started session record. +/// +/// The previously owned record MXC explicitly reported as stale, when setup +/// had to replace it. +/// +internal sealed record SessionStartResult( + SessionRecord Record, + SessionRecord? SupersededRecord); + /// /// Owns this installation's isolated session across processes. /// @@ -180,10 +204,73 @@ public SessionStatus GetRecordedStatus() return new SessionStatus(availability, null, result.Fault, result.Detail); } + /// + /// Establishes whether the recorded provision can be started by MXC without + /// creating or replacing a session. + /// + /// + /// IsolationSession has no separate per-provision read API. Starting a + /// recorded provision is its state-aware probe: it succeeds only when that + /// exact backend provision exists and leaves no host record changes. + /// + public async Task ProbeRecordedStatusAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionStatus status = GetRecordedStatus(); + if (status.Record is null) + { + return status; + } + + try + { + await StartAsync(status.Record, cancellationToken).ConfigureAwait(false); + return new SessionStatus( + SessionAvailability.Running, + status.Record, + null, + "MXC started the recorded provision."); + } + catch (MxcException exception) when (exception.Code == MxcErrorCode.StaleId) + { + return new SessionStatus( + SessionAvailability.Stale, + status.Record, + null, + "MXC reported that the recorded provision is missing. Run `clawctl setup` to replace it."); + } + catch (MxcException exception) when (exception.Code == MxcErrorCode.RuntimeUnavailable) + { + return new SessionStatus( + SessionAvailability.BackendUnavailable, + status.Record, + null, + exception.Message); + } + catch (MxcException exception) + { + return new SessionStatus( + SessionAvailability.BackendError, + status.Record, + null, + exception.Message); + } + } + /// /// Returns the owned, started session, creating it only on first use. /// public async Task EnsureStartedAsync( + CancellationToken cancellationToken) => + (await EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false)).Record; + + /// + /// Returns the owned, started session and identifies an explicitly stale + /// record when setup replaced it. + /// + internal async Task EnsureStartedWithResultAsync( CancellationToken cancellationToken) { using ISessionLockHandle handle = AcquireLock(); @@ -192,9 +279,23 @@ public async Task EnsureStartedAsync( if (state.Record is not null) { _log("Reusing the recorded OpenClaw session."); - await StartAsync(state.Record, cancellationToken) - .ConfigureAwait(false); - return state.Record; + try + { + await StartAsync(state.Record, cancellationToken) + .ConfigureAwait(false); + return new SessionStartResult(state.Record, null); + } + catch (MxcException exception) when (exception.Code == MxcErrorCode.StaleId) + { + _log( + "The recorded OpenClaw provision no longer exists. " + + "Provisioning a replacement for this installation."); + SessionRecord replacement = await ProvisionAndStartAsync( + GetSupersededSandboxIds(state.Record), + cancellationToken) + .ConfigureAwait(false); + return new SessionStartResult(replacement, state.Record); + } } if (state.Fault != SessionStateFault.Missing) @@ -205,6 +306,15 @@ await StartAsync(state.Record, cancellationToken) } _log("Creating the first OpenClaw session for this installation."); + SessionRecord provisioned = await ProvisionAndStartAsync([], cancellationToken) + .ConfigureAwait(false); + return new SessionStartResult(provisioned, null); + } + + private async Task ProvisionAndStartAsync( + IReadOnlyList supersededSandboxIds, + CancellationToken cancellationToken) + { MxcProvisionResult provisioned = await _backend .ProvisionAsync(new MxcProvisionRequest(_applicationId), cancellationToken) .ConfigureAwait(false); @@ -221,6 +331,7 @@ await StartAsync(state.Record, cancellationToken) WorkspacePath = provisioned.Metadata?.EphemeralWorkspacePath, Generation = Guid.NewGuid().ToString("N"), CreatedUtc = _clock.GetUtcNow(), + SupersededSandboxIds = [.. supersededSandboxIds], }; try { @@ -248,6 +359,14 @@ await _backend.DeprovisionAsync(provisioned.SandboxId, null, CancellationToken.N return record; } + private static IReadOnlyList GetSupersededSandboxIds(SessionRecord record) => + [.. (record.SupersededSandboxIds ?? []) + .Append(record.SupersededSandboxId) + .Append(record.SandboxId) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Select(static id => id!) + .Distinct(StringComparer.Ordinal)]; + /// /// Starts the recorded session without provisioning a replacement. /// @@ -302,7 +421,12 @@ public async Task RemoveAsync( CancellationToken cancellationToken) { using ISessionLockHandle handle = AcquireLock(); + return await RemoveUnderLockAsync(cancellationToken).ConfigureAwait(false); + } + internal async Task RemoveUnderLockAsync( + CancellationToken cancellationToken) + { SessionRecord? record = RequireUsableRecordOrNull(); if (record is null) { diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index b1eaaedf..42380549 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -34,6 +34,9 @@ internal sealed record SessionCommandRequest( IReadOnlyList Arguments, string WorkingDirectory) { + /// Guest directory that must lead the child process PATH. + public string? PathPrefix { get; init; } + /// /// Values merged over the shared runtime environment for this command only. /// @@ -77,6 +80,9 @@ public SessionExecutor(IMxcSessionClient backend, Action log, _isCurrentRecord = isCurrentRecord ?? (_ => true); } + internal SessionWorkspaceOperation CreateWorkspaceOperation(SessionRecord record) => + new(record, _isCurrentRecord); + public async Task ExecuteAsync( SessionRecord record, SessionExecutionRequest request, @@ -85,6 +91,11 @@ public async Task ExecuteAsync( ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(request); + string nodeDirectory = Path.GetDirectoryName(request.NodePath) + ?? throw new SessionException( + "The agent Node.js executable has no parent directory."); + IReadOnlyDictionary environment = _buildEnvironment(); + return await ExecuteCommandAsync( record, new SessionCommandRequest( @@ -93,7 +104,10 @@ public async Task ExecuteAsync( BuildNodeArguments(request), request.WorkingDirectory) { - AdditionalEnvironment = request.AdditionalEnvironment + PathPrefix = nodeDirectory, + AdditionalEnvironment = MergeEnvironment( + environment, + request.AdditionalEnvironment) }, "Running OpenClaw in the isolated session.", "OpenClaw", @@ -132,6 +146,7 @@ public async Task ExecuteCommandAsync( WorkingDirectory = request.WorkingDirectory, Environment = MergeEnvironment( _buildEnvironment(), request.AdditionalEnvironment), + PathPrefix = request.PathPrefix, }; try @@ -162,7 +177,14 @@ await operation.WriteTextNewAsync( } } - /// Asks the guest to stage diagnostics into the shared workspace. + /// + /// Asks the guest to stage diagnostic files into the shared workspace. + /// + /// + /// Uses the helper's collect mode rather than the launch mode, because + /// nothing is being run inside the session: the guest only copies files the + /// host's own account cannot open. + /// public async Task CollectAsync( SessionRecord record, string helperPath, @@ -193,7 +215,7 @@ await operation.WriteTextNewAsync( _log("Collecting agent-side diagnostics from the isolated session."); - int executorExitCode = await _backend.ExecuteAttachedAsync( + MxcExecutionResult execution = await _backend.ExecuteAsync( record.ToSandboxIdOrThrow(), new MxcExecutionRequest( BuildGuestCommandLine(helperPath, requestPath, "--collect")), @@ -211,7 +233,7 @@ await operation.WriteTextNewAsync( { throw new SessionException( "The isolated session did not report a collection result " + - $"(executor exit code {executorExitCode})."); + DescribeMissingResult(execution)); } SessionCollectResult result = SessionCollectProtocol.ReadResult(resultText); @@ -237,7 +259,13 @@ await operation.WriteTextNewAsync( } } - /// Installs the package's Node.js runtime in the agent profile. + /// + /// Installs the packaged Node.js runtime into the agent's profile. + /// + /// + /// Setup invokes this explicitly rather than leaving the first gateway + /// launch to discover a missing agent runtime. + /// public async Task InstallRuntimeAsync( SessionRecord record, string helperPath, diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 10ff0ffc..598b9994 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -1,5 +1,5 @@ -using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Gateway; using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher.Session; diff --git a/src/OpenClaw.Launcher/Session/SessionStateStore.cs b/src/OpenClaw.Launcher/Session/SessionStateStore.cs index b7b0f5a3..7a964609 100644 --- a/src/OpenClaw.Launcher/Session/SessionStateStore.cs +++ b/src/OpenClaw.Launcher/Session/SessionStateStore.cs @@ -48,6 +48,17 @@ internal sealed record SessionRecord [JsonPropertyName("createdUtc")] public DateTimeOffset CreatedUtc { get; init; } + + [JsonPropertyName("supersededSandboxId")] + public string? SupersededSandboxId { get; init; } + + /// + /// Every prior owned session whose gateway record still may need + /// reconciliation. The singular property remains for records written by + /// earlier package versions. + /// + [JsonPropertyName("supersededSandboxIds")] + public IReadOnlyList? SupersededSandboxIds { get; init; } } /// diff --git a/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs new file mode 100644 index 00000000..fa07dcc3 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs @@ -0,0 +1,75 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Session; + +/// Runs the owned teardown in one lifecycle-lock acquisition. +internal sealed class TeardownOrchestrator +{ + private readonly ISessionLock _lock; + private readonly GatewayPersistenceManager _recovery; + private readonly GatewayController _gateway; + private readonly SessionCoordinator _sessions; + private readonly GatewayStateStore _gatewayState; + private readonly GatewayConfigurationStore _configuration; + private readonly SetupStateStore _setup; + + public TeardownOrchestrator( + ISessionLock lifecycleLock, + GatewayPersistenceManager recovery, + GatewayController gateway, + SessionCoordinator sessions, + GatewayStateStore gatewayState, + GatewayConfigurationStore configuration, + SetupStateStore setup) + { + _lock = lifecycleLock; + _recovery = recovery; + _gateway = gateway; + _sessions = sessions; + _gatewayState = gatewayState; + _configuration = configuration; + _setup = setup; + } + + public async Task RunAsync( + string helperPath, + bool force, + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = _lock.TryAcquire(SessionCoordinator.DefaultLockTimeout) + ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); + + GatewayPersistenceRemovalResult recovery = await _recovery.UninstallAsync(cancellationToken) + .ConfigureAwait(false); + if (!recovery.Succeeded) + { + return new TeardownResult(false, recovery.Message, recovery.Detail); + } + + GatewayStopResult gateway = await _gateway.StopUnderLockAsync( + helperPath, + cancellationToken, + clearRecord: false, + allowUnconfirmedLaunch: force, + allowUnavailableInspection: force).ConfigureAwait(false); + if (!gateway.Succeeded) + { + return new TeardownResult(false, gateway.Message, gateway.Detail); + } + + SessionRemovalResult session = await _sessions.RemoveUnderLockAsync(cancellationToken) + .ConfigureAwait(false); + + _gatewayState.Clear(); + _configuration.Clear(); + _setup.Clear(); + return new TeardownResult( + true, + session.Removed + ? "OpenClaw isolated session and gateway service were removed." + : "No isolated session was recorded; gateway service records were removed.", + session.StopFailure); + } +} + +internal sealed record TeardownResult(bool Succeeded, string Message, string? Detail = null); diff --git a/src/OpenClaw.SessionHost/SessionInspector.cs b/src/OpenClaw.SessionHost/SessionInspector.cs index 582296b6..47f567cf 100644 --- a/src/OpenClaw.SessionHost/SessionInspector.cs +++ b/src/OpenClaw.SessionHost/SessionInspector.cs @@ -47,6 +47,20 @@ internal static SessionInspectResult Inspect( SessionInspectRequest request, Func readFile) { + if (request.LaunchPending) + { + // No verified PID was recorded before the interrupted launch, so + // this reconciliation must never select or act on a process. The + // caller must retain the launch intent and use owned teardown for + // recovery rather than treating the absence of a PID as proof + // that no process exists. + return new SessionInspectResult + { + RequestId = request.RequestId, + Error = "The gateway launch was not confirmed; process ownership cannot be established." + }; + } + try { using Process process = Process.GetProcessById(request.ProcessId); diff --git a/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs b/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs index 931f802e..61b06b41 100644 --- a/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs +++ b/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs @@ -23,6 +23,14 @@ public sealed record SessionInspectRequest [JsonPropertyName("processId")] public int ProcessId { get; init; } + /// + /// Requests a safe reconciliation of a launch whose process identity was + /// never persisted. The guest must not inspect or stop a process for this + /// request. + /// + [JsonPropertyName("launchPending")] + public bool LaunchPending { get; init; } + /// /// The recorded creation time. A live process whose creation time differs /// is an unrelated process that inherited a reused identifier, never ours. @@ -199,6 +207,11 @@ public static SessionInspectRequest ReadRequest(string json) throw new SessionLaunchException("The inspect request has no request id."); } + if (request.LaunchPending) + { + return request; + } + if (request.ProcessId <= 0) { throw new SessionLaunchException( diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index b4d488c0..d5f1632d 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -70,8 +70,9 @@ public void OnlyTheOwnedCommandsAreExposed() { RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers { - Setup = _ => Task.FromResult(0), + Setup = (_, _) => Task.FromResult(0), Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), GatewayStart = _ => Task.FromResult(0), @@ -80,7 +81,14 @@ public void OnlyTheOwnedCommandsAreExposed() }); Assert.Equal( - [ClawCtlCommandLine.SetupCommandName, ClawCtlCommandLine.StatusCommandName, "teardown", "pwsh", "gateway-service"], + [ + ClawCtlCommandLine.SetupCommandName, + ClawCtlCommandLine.StatusCommandName, + ClawCtlCommandLine.CollectLogsCommandName, + "teardown", + "pwsh", + "gateway-service" + ], root.Subcommands.Select(command => command.Name)); } @@ -90,8 +98,9 @@ public async Task GatewayServiceStartInvokesOnlyTheStartHandler() int starts = 0; RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers { - Setup = _ => Task.FromResult(0), + Setup = (_, _) => Task.FromResult(0), Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), GatewayStart = _ => diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index 0800cb42..b1442cef 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -71,9 +71,10 @@ public void CompletionSuggestsCommandsWithoutResolvingNode() { RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers { - Setup = _ => throw new InvalidOperationException( + Setup = (_, _) => throw new InvalidOperationException( "Completion started the readiness operation."), Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), Teardown = (_, _) => Task.FromResult(0), PowerShell = _ => Task.FromResult(0), GatewayStart = _ => Task.FromResult(0), diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs new file mode 100644 index 00000000..19992569 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs @@ -0,0 +1,165 @@ +using System.IO.Compression; +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Session; +using OpenClaw.Launcher.Tests.Session; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class DiagnosticsBundleTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + [Fact] + public async Task CollectionPreservesHostLogsAndRedactsEveryTextSource() + { + (GatewayRuntime runtime, HostPaths paths) = CreateRuntime(); + Directory.CreateDirectory(Path.GetDirectoryName(paths.LogPath)!); + await File.WriteAllTextAsync( + paths.LogPath, + """gateway failed with {"apiKey":"do-not-share"}"""); + string bundlePath = Path.Combine(_root, "diagnostics.zip"); + + DiagnosticsBundleResult result = await runtime.CollectLogsAsync( + bundlePath, + CancellationToken.None); + + Assert.Equal(bundlePath, result.BundlePath); + using ZipArchive archive = ZipFile.OpenRead(bundlePath); + ZipArchiveEntry entry = Assert.Single( + archive.Entries, + candidate => candidate.FullName == "host/openclaw.log"); + using var reader = new StreamReader(entry.Open()); + string text = await reader.ReadToEndAsync(); + Assert.Contains(DiagnosticsRedactor.Placeholder, text, StringComparison.Ordinal); + Assert.DoesNotContain("do-not-share", text, StringComparison.Ordinal); + } + + [Fact] + public async Task CollectionReadsALiveLogWithWriteAndDeleteSharing() + { + (GatewayRuntime runtime, HostPaths paths) = CreateRuntime(); + Directory.CreateDirectory(Path.GetDirectoryName(paths.LogPath)!); + using FileStream liveLog = new( + paths.LogPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.ReadWrite | FileShare.Delete); + using (var writer = new StreamWriter( + liveLog, + leaveOpen: true)) + { + writer.Write("still running"); + writer.Flush(); + } + + string bundlePath = Path.Combine(_root, "live.zip"); + DiagnosticsBundleResult result = await runtime.CollectLogsAsync( + bundlePath, + CancellationToken.None); + + Assert.Equal(bundlePath, result.BundlePath); + using ZipArchive archive = ZipFile.OpenRead(bundlePath); + Assert.Contains(archive.Entries, entry => entry.FullName == "host/openclaw.log"); + } + + [Fact] + public async Task CorruptSessionStateStillProducesAHostOnlyBundle() + { + (GatewayRuntime runtime, HostPaths paths) = CreateRuntime(); + Directory.CreateDirectory(Path.GetDirectoryName(paths.LogPath)!); + await File.WriteAllTextAsync(paths.LogPath, "host evidence"); + await File.WriteAllTextAsync(paths.SessionStatePath, "{not-json"); + string bundlePath = Path.Combine(_root, "host-only.zip"); + + DiagnosticsBundleResult result = await runtime.CollectLogsAsync( + bundlePath, + CancellationToken.None); + + Assert.Equal(bundlePath, result.BundlePath); + Assert.False(result.SessionReached); + Assert.Contains( + result.Notes, + note => note.Contains("not valid JSON", StringComparison.OrdinalIgnoreCase)); + using ZipArchive archive = ZipFile.OpenRead(bundlePath); + Assert.Contains(archive.Entries, entry => entry.FullName == "host/openclaw.log"); + } + + [Fact] + public void FilenameOnlyOutputResolvesAgainstTheWorkingDirectory() + { + HostPaths paths = HostPaths.ForRoot(Path.Combine(_root, "state")); + + string resolved = GatewayRuntime.ResolveBundlePath( + "diagnostics.zip", + paths, + _root); + + Assert.Equal(Path.Combine(_root, "diagnostics.zip"), resolved); + } + + [Fact] + public void DefaultOutputUsesTheInjectedCollectionTime() + { + HostPaths paths = HostPaths.ForRoot(Path.Combine(_root, "state")); + + string resolved = GatewayRuntime.ResolveBundlePath( + null, + paths, + _root, + new FixedTimeProvider( + new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero))); + + Assert.Equal( + Path.Combine(paths.StateRoot, "openclaw-diagnostics-20250102-030405.zip"), + resolved); + } + + [Fact] + public async Task ExplicitExistingOutputNamesThePathAndOutputOption() + { + (GatewayRuntime runtime, HostPaths paths) = CreateRuntime(); + string bundlePath = Path.Combine(_root, "existing.zip"); + Directory.CreateDirectory(Path.GetDirectoryName(paths.LogPath)!); + await File.WriteAllTextAsync(paths.LogPath, "host evidence"); + await File.WriteAllTextAsync(bundlePath, "existing"); + + IOException exception = await Assert.ThrowsAsync( + () => runtime.CollectLogsAsync(bundlePath, CancellationToken.None)); + + Assert.Contains(bundlePath, exception.Message, StringComparison.Ordinal); + Assert.Contains("--output", exception.Message, StringComparison.Ordinal); + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + private (GatewayRuntime Runtime, HostPaths Paths) CreateRuntime() + { + HostPaths paths = HostPaths.ForRoot( + Path.Combine(_root, Guid.NewGuid().ToString("N"), "state"), + "OpenClaw.Gateway_test"); + string baseDirectory = Path.Combine(_root, Guid.NewGuid().ToString("N"), "base"); + string applicationDirectory = Path.Combine(baseDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllText(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + File.WriteAllText(helperPath, "fixture"); + var backend = new FakeMxcSessionClient(); + SessionRuntime session = SessionRuntime.Create( + paths, + () => throw new InvalidOperationException("The test supplies its backend."), + baseDirectory, + _ => { }, + backend); + GatewayRuntime runtime = GatewayRuntime.Create( + new HostOptions(applicationDirectory, null, []), + paths, + session, + _ => { }); + return (runtime, paths); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsRedactorTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsRedactorTests.cs new file mode 100644 index 00000000..302afabe --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsRedactorTests.cs @@ -0,0 +1,51 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class DiagnosticsRedactorTests +{ + [Theory] + [InlineData("apiKey")] + [InlineData("apikey")] + [InlineData("access_token")] + [InlineData("clientSecret")] + [InlineData("Password")] + [InlineData("credential")] + public void CredentialShapedMembersLoseTheirValue(string member) + { + string redacted = DiagnosticsRedactor.Redact( + $$"""{"{{member}}": "ghu_verysecretvalue"}"""); + + Assert.DoesNotContain("ghu_verysecretvalue", redacted, StringComparison.Ordinal); + Assert.Contains(DiagnosticsRedactor.Placeholder, redacted, StringComparison.Ordinal); + Assert.Contains(member, redacted, StringComparison.Ordinal); + } + + [Fact] + public void OrdinaryConfigurationSurvivesUnchanged() + { + const string json = """{"gateway": {"port": 18789}, "logLevel": "debug"}"""; + + Assert.Equal(json, DiagnosticsRedactor.Redact(json)); + } + + [Fact] + public void EscapedQuotesInsideASecretDoNotEndTheRedaction() + { + string redacted = DiagnosticsRedactor.Redact( + """{"token": "ab\"cd", "port": 1}"""); + + Assert.DoesNotContain("ab", redacted, StringComparison.Ordinal); + Assert.Contains("\"port\": 1", redacted, StringComparison.Ordinal); + } + + [Fact] + public void EveryCredentialMemberInADocumentIsRedacted() + { + string redacted = DiagnosticsRedactor.Redact( + """{"a": {"apiKey": "one"}, "b": {"apiKey": "two"}}"""); + + Assert.DoesNotContain("one", redacted, StringComparison.Ordinal); + Assert.DoesNotContain("two", redacted, StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs index 5bd167e8..bd0cf72d 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs @@ -46,18 +46,6 @@ public void AConfiguredPortSurvivesForTheNextSignIn() Assert.Equal(9100, Store.Resolve(_root, NoEnvironment).Port); } - [Fact] - public void AConfiguredWorkingDirectoryWinsOverTheDefault() - { - Store.Write(new GatewayLaunchConfiguration - { - Port = 9100, - WorkingDirectory = @"C:\chosen" - }); - - Assert.Equal(@"C:\chosen", Store.Resolve(_root, NoEnvironment).WorkingDirectory); - } - [Fact] public void TheEnvironmentOverridesThePortForOneInvocationOnly() { @@ -138,15 +126,13 @@ public void TheConfigurationRoundTrips() { Store.Write(new GatewayLaunchConfiguration { - Port = 9100, - WorkingDirectory = @"C:\chosen" + Port = 9100 }); GatewayLaunchConfiguration? read = Store.Read().Configuration; Assert.NotNull(read); Assert.Equal(9100, read.Port); - Assert.Equal(@"C:\chosen", read.WorkingDirectory); Assert.Equal(GatewayConfigurationStore.CurrentSchemaVersion, read.SchemaVersion); } } diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs index f54ca5ea..213f2043 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs @@ -110,7 +110,6 @@ await client.StartAsync( stagedHelper, nodePath, Path.Combine(_root, "app"), - workspace, null), CancellationToken.None); @@ -166,7 +165,6 @@ private GatewayController CreateController( HelperPath: Path.Combine(_root, "openclaw-session-host.exe"), NodePath: @"C:\Program Files\nodejs\node.exe", ApplicationDirectory: Path.Combine(_root, "app"), - WorkingDirectory: _root, Port: null)), _ => { }, () => sessions.GetRecordedStatus().Record ?? throw new SessionException("Run setup."), @@ -239,6 +237,39 @@ private void RecordGateway(int processId = 1234, bool autostartDisabled = false) AutostartDisabled = autostartDisabled }); + private void RecordPendingGateway(int processId = 0) => + Store.Write(new GatewayRecord + { + SandboxId = "iso:sandbox1", + ProcessId = processId, + LaunchPending = true, + ProcessStartTimeUtc = new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero), + Port = null, + StatusPath = "status.json" + }); + + [Fact] + public void SupersededSessionReconciliationRemovesOnlyTheMatchingGatewayRecord() + { + RecordGateway(); + + bool removed = Store.ClearForSupersededSession("iso:sandbox1"); + + Assert.True(removed); + Assert.Equal(GatewayStateFault.Missing, Store.Read().Fault); + } + + [Fact] + public void SupersededSessionReconciliationPreservesAnUnknownGatewayRecord() + { + RecordGateway(); + + bool removed = Store.ClearForSupersededSession("iso:stale-sandbox"); + + Assert.False(removed); + Assert.Equal("iso:sandbox1", Store.Read().Record!.SandboxId); + } + [Fact] public async Task StatusReportsNotStartedWithoutProvisioningASession() { @@ -396,6 +427,21 @@ public async Task ASecondStartDoesNotStartASecondGateway() Assert.Single(_client.Calls, call => call == "start"); } + [Fact] + public async Task AnAmbiguousPendingGatewayIsNotReplacedOrStopped() + { + RecordPendingGateway(processId: 1234); + + SessionException exception = await Assert.ThrowsAsync( + () => CreateController().StartAsync("helper.exe", CancellationToken.None)); + + Assert.Contains("teardown --force", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain( + _client.Calls, + call => call == "start" || call.StartsWith("stop:", StringComparison.Ordinal)); + Assert.True(Store.Read().Record!.LaunchPending); + } + [Fact] public async Task AGatewayThatIsNoLongerRunningIsReplaced() { diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs index 5125142a..bbecf7b2 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs @@ -28,7 +28,8 @@ public void Dispose() private string ActivationScriptPath => Path.ChangeExtension(LauncherPath, ".ps1"); private GatewayPersistenceManager CreateManager( - Func? resolveUserSid = null) => + Func? resolveUserSid = null, + Action? deleteFile = null) => new( _scheduler, new GatewayPersistenceOptions( @@ -37,7 +38,8 @@ private GatewayPersistenceManager CreateManager( LauncherPath: LauncherPath, StartupFolderPath: StartupFolder, WorkingDirectory: StateRoot, - CommandProcessorPath: @"C:\Windows\System32\cmd.exe"), + CommandProcessorPath: @"C:\Windows\System32\cmd.exe", + DeleteFile: deleteFile), resolveUserSid: resolveUserSid); private GatewayTaskSnapshot DesiredSnapshot() => @@ -466,6 +468,38 @@ public async Task AFailedDeletionIsReportedRatherThanClaimedAsSuccess() Assert.Contains("Access is denied.", result.Detail, StringComparison.Ordinal); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UninstallingReportsGeneratedArtifactDeletionFailure(bool fallback) + { + string fallbackPath = Path.Combine( + StartupFolder, + "OpenClaw Gateway OpenClaw.Gateway_test.cmd"); + GatewayPersistenceManager manager = CreateManager(deleteFile: path => + { + if (string.Equals(path, fallback ? fallbackPath : LauncherPath, + StringComparison.Ordinal)) + { + throw new IOException("The generated file is locked."); + } + + File.Delete(path); + }); + string target = fallback ? fallbackPath : LauncherPath; + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + await File.WriteAllTextAsync( + target, + GatewayLauncherScript.Create(StateRoot, "clawctl.exe")); + + GatewayPersistenceRemovalResult result = + await manager.UninstallAsync(CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Contains("locked", result.Detail, StringComparison.Ordinal); + Assert.True(File.Exists(target)); + } + [Fact] public async Task AGeneratedLauncherThatCannotBeDeletedIsReported() { diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs index 038d8b8c..03e45f7e 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs @@ -59,6 +59,19 @@ public void TheGeneratedDefinitionRoundTrips() Assert.Equal(desired, parsed); } + [Fact] + public void TheLogonTriggerCanUseTheInteractiveUsersSid() + { + GatewayTaskSnapshot snapshot = GatewayTaskDefinition.CreateSnapshot( + "S-1-12-1-1-2-3-4", + @"C:\Windows\System32\cmd.exe", + @"C:\state\gateway-launcher.cmd", + "S-1-5-21-1-2-3-4"); + + Assert.Equal("S-1-12-1-1-2-3-4", snapshot.UserId); + Assert.Equal("S-1-5-21-1-2-3-4", snapshot.LogonTriggerUserId); + } + [Fact] public void TheGatewayIsNotGatedOnPowerOrATimeLimit() { diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs new file mode 100644 index 00000000..a27babcd --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/SessionGatewayClientTests.cs @@ -0,0 +1,176 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.Launcher.Tests.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class SessionGatewayClientTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + private readonly FakeMxcSessionClient _backend = new(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + // Pending intent remains serializable for explicit teardown recovery; the + // guest inspector returns an error rather than treating it as absence. + [Fact] + public void PendingInspectionRequestIsAcceptedWithoutAProcessIdentity() + { + SessionInspectRequest request = SessionInspectProtocol.ReadRequest( + SessionInspectProtocol.SerializeRequest(new SessionInspectRequest + { + RequestId = "request", + LaunchPending = true + })); + + Assert.True(request.LaunchPending); + Assert.Equal(0, request.ProcessId); + } + + [Fact] + public async Task StartUsesTheSharedWorkspaceRatherThanTheHostWorkingDirectory() + { + string workspace = Path.Combine(_root, "shared"); + string helperPath = Path.Combine(_root, "package", "openclaw-session-host.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + File.WriteAllText(helperPath, string.Empty); + + string stagedHelperPath = SessionHelperStager.ResolveStagedPath(workspace); + Directory.CreateDirectory(Path.GetDirectoryName(stagedHelperPath)!); + File.WriteAllText(stagedHelperPath, string.Empty); + + SessionLaunchRequest? delivered = null; + _backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles(workspace, "gateway-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + delivered = SessionLaunchProtocol.ReadRequest(File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = delivered.RequestId, + Launched = true, + ProcessId = 1234, + ProcessStartTimeUtc = DateTimeOffset.UnixEpoch, + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + + await new SessionGatewayClient(_backend, _ => { }).StartAsync( + new SessionRecord + { + SandboxId = "iso:sandbox1", + WorkspacePath = workspace, + Generation = "test-generation", + }, + new GatewayStartRequest( + helperPath, + @"C:\agent-node\node.exe", + @"C:\Package\app", + Port: null), + CancellationToken.None); + + Assert.NotNull(delivered); + Assert.Equal(workspace, delivered.WorkingDirectory); + } + + [Fact] + public async Task RuntimeStartUsesSetupRecordedAgentNodeWithoutHostNodeResolution() + { + string applicationDirectory = Path.Combine(_root, "app"); + string runtimeDirectory = Path.Combine(_root, "runtime"); + string archivePath = Path.Combine(runtimeDirectory, "node-v24.20.0-win-x64.zip"); + string agentNodePath = Path.Combine(_root, "agent-node", "node.exe"); + Directory.CreateDirectory(applicationDirectory); + Directory.CreateDirectory(runtimeDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + await File.WriteAllTextAsync(archivePath, string.Empty); + + HostPaths paths = HostPaths.ForRoot(_root, "OpenClaw.Gateway_abc123"); + _backend.Metadata = new MxcProvisionMetadata( + "agent_1", + "S-1-5-21-0-0-0-1001", + Path.Combine(_root, "shared")); + SessionRuntime session = SessionRuntime.Create( + paths, + () => throw new InvalidOperationException("Host Node/runtime resolution is not expected."), + _root, + _ => { }, + _backend); + SessionRecord record = await session.Coordinator.EnsureStartedAsync(CancellationToken.None); + session.CompleteSetup( + record, + new SessionRuntimeInstallResult + { + ExecutablePath = agentNodePath, + Version = "24.20.0", + ArchiveName = Path.GetFileName(archivePath), + }, + startupEnabled: true); + Directory.CreateDirectory(Path.GetDirectoryName(session.HelperPath)!); + await File.WriteAllTextAsync(session.HelperPath, string.Empty); + string stagedHelperPath = SessionHelperStager.ResolveStagedPath(record.WorkspacePath!); + Directory.CreateDirectory(Path.GetDirectoryName(stagedHelperPath)!); + await File.WriteAllTextAsync(stagedHelperPath, string.Empty); + + SessionLaunchRequest? delivered = null; + _backend.ExecuteBehavior = _ => + { + string? inspectPath = Directory.GetFiles(record.WorkspacePath!, "inspect-*.json") + .SingleOrDefault(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + if (inspectPath is not null) + { + SessionInspectRequest inspection = SessionInspectProtocol.ReadRequest( + File.ReadAllText(inspectPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(inspectPath), + SessionInspectProtocol.SerializeResult(new SessionInspectResult + { + RequestId = inspection.RequestId, + ProcessFound = true, + StartTimeMatches = true, + PortListening = true, + ListenerOwned = true, + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + } + + string requestPath = Directory.GetFiles(record.WorkspacePath!, "gateway-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + delivered = SessionLaunchProtocol.ReadRequest(File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = delivered.RequestId, + Launched = true, + ProcessId = 1234, + ProcessStartTimeUtc = DateTimeOffset.UnixEpoch, + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + + GatewayRuntime runtime = GatewayRuntime.Create( + new HostOptions(applicationDirectory, archivePath, []), + paths, + session, + _ => { }); + await runtime.Controller.StartAsync(runtime.HelperPath, CancellationToken.None); + + Assert.NotNull(delivered); + Assert.Equal(agentNodePath, delivered.Executable); + Assert.Equal(Path.GetDirectoryName(agentNodePath), delivered.PathPrefix); + Assert.Equal( + "enabled", + delivered.Environment![OpenClawRuntimeEnvironment.GatewayIsolationVariable]); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index d4888c39..76a10ca8 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -120,6 +120,64 @@ await File.WriteAllTextAsync( Assert.Empty(output.ToString()); } + // This catches production startup bypassing the installer after setup put + // Node.js only in the agent profile. + [Fact] + public async Task AgentStartupInstallsTheHostRuntimeBeforeLaunching() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); + Directory.CreateDirectory(applicationDirectory); + Directory.CreateDirectory(runtimeDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + string archivePath = Path.Combine( + runtimeDirectory, + $"node-v24.15.0-win-{GetArchitectureName()}.zip"); + await File.WriteAllTextAsync(archivePath, "fixture"); + string? installedArchive = null; + bool launched = false; + using var output = new StringWriter(); + using var error = new StringWriter(); + + HostStartup startup = new() + { + Entrypoint = HostEntrypoint.Agent, + CreateDiagnostics = () => HostDiagnosticLog.Create( + Path.Combine(_testDirectory, "logs", "openclaw.log")), + BaseDirectory = _testDirectory, + Output = output, + Error = error, + ReadEnvironmentVariable = name => + name == OpenClaw.Launcher.Session.SessionRoutingPolicy.ModeVariable + ? "0" + : null, + InstallNodeRuntime = (archive, _) => + { + installedArchive = archive; + return new NodeRuntime( + Path.Combine(_testDirectory, "node.exe"), + new Version(24, 15, 0), + RuntimeInformation.ProcessArchitecture); + }, + LaunchOpenClaw = (_, _, _, _, _, _) => + { + launched = true; + return Task.FromResult(0); + } + }; + + int exitCode = await Program.RunAsync(["--help"], startup); + + Assert.Equal(0, exitCode); + Assert.Equal(archivePath, installedArchive); + Assert.True(launched); + } + + private static string GetArchitectureName() => + RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + private HostStartup CreateStartup( string logPath, TextWriter output, diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 0e9e12ad..63a92dad 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -85,17 +85,15 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() output, TextWriter.Null, _ => Task.FromResult(nodeRuntime), - () => runtime, - _ => Task.FromResult(new GatewayPersistenceInstallResult( - GatewayPersistenceState.Ready, - GatewayPersistenceLane.TaskScheduler, - "Logon recovery is configured.", - Changed: true))); + _ => runtime, + installRecovery: RecoveryConfigured, + probeReadiness: SupportedHost, + getPackageFamilyName: () => "OpenClaw.Gateway_test"); Assert.Equal(0, exitCode); Assert.True(File.Exists(entryPoint)); Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(entryPoint)); - Assert.Contains( + Assert.DoesNotContain( nodeRuntime.ExecutablePath, output.ToString(), StringComparison.Ordinal); @@ -139,14 +137,12 @@ await File.WriteAllTextAsync( TextWriter.Null, TextWriter.Null, _ => Task.FromResult(hostNode), - () => runtime, + _ => runtime, // Setup only reaches Ready once logon recovery is configured, and // a test must never register a real scheduled task. - _ => Task.FromResult(new GatewayPersistenceInstallResult( - GatewayPersistenceState.Ready, - GatewayPersistenceLane.TaskScheduler, - "Logon recovery is configured.", - Changed: true))); + installRecovery: RecoveryConfigured, + probeReadiness: SupportedHost, + getPackageFamilyName: () => "OpenClaw.Gateway_test"); Assert.Equal(0, setupExitCode); string expectedAgentNode = runtime.SetupState @@ -178,7 +174,16 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( directLaunchAttempted = true; return Task.FromResult(0); }, - _ => runtime)); + _ => runtime, + _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)), + () => "OpenClaw.Gateway_test", + _ => null)); Assert.False(directLaunchAttempted); } @@ -216,7 +221,7 @@ public async Task AutomaticDirectRoutingValidatesExistingOwnershipFirst() SessionException failure = await Assert.ThrowsAsync( () => Program.RunAgentAsync( - new HostOptions(null, null, ["--version"]), + CreateAgentOptions(), _ => { }, _ => throw new InvalidOperationException("Host Node must not be resolved."), (_, _, _, _, _, _) => @@ -303,7 +308,7 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() _ => { }, TextWriter.Null, error, - createSessionRuntime: () => runtime); + createSessionRuntime: _ => runtime); Assert.Equal(1, exitCode); Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); @@ -313,7 +318,7 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() } [Fact] - public async Task SetupResolvesNodeBeforeReportingMissingApplication() + public async Task SetupReportsMissingApplicationBeforeResolvingNode() { bool nodeResolutionAttempted = false; @@ -335,14 +340,87 @@ await Assert.ThrowsAsync( .ProcessArchitecture)); })); - Assert.True(nodeResolutionAttempted); + Assert.False(nodeResolutionAttempted); + } + + [Fact] + public async Task NoIsolationSetupPreparesTheHostRuntimeOnASupportedMachine() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + bool resolvedNode = false; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(applicationDirectory, "node.zip", []), + ["setup", "--no-isolation"], + _ => { }, + output, + TextWriter.Null, + _ => + { + resolvedNode = true; + return Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 20, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => "OpenClaw.Gateway_test"); + + Assert.Equal(0, exitCode); + Assert.True(resolvedNode); + Assert.Contains("without isolated-session", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task NoIsolationSetupStillFailsOnAnUnsupportedMachine() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + bool resolvedNode = false; + + int exitCode = await Program.RunControlAsync( + new HostOptions(applicationDirectory, "node.zip", []), + ["setup", "--no-isolation"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + _ => + { + resolvedNode = true; + return Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 20, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild, + null, + "Windows build does not support sessions.")), + getPackageFamilyName: () => "OpenClaw.Gateway_test"); + + Assert.Equal(1, exitCode); + Assert.False(resolvedNode); } [Fact] public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval() { SessionRuntime runtime = CreateSessionRuntime(); - bool recoveryRemoved = false; runtime.GatewayState.Write(new GatewayRecord { SchemaVersion = GatewayStateStore.CurrentSchemaVersion, @@ -357,22 +435,18 @@ public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval() _ => { }, TextWriter.Null, TextWriter.Null, - createSessionRuntime: () => runtime, - removeRecovery: _ => - { - recoveryRemoved = true; - return Task.FromResult( - new GatewayPersistenceRemovalResult(true, false, "removed")); - }); + createSessionRuntime: _ => runtime, + createTeardownOrchestrator: CreateTeardownOrchestrator); Assert.Equal(0, exitCode); - Assert.True(recoveryRemoved); GatewayStateResult state = runtime.GatewayState.Read(); Assert.Equal(GatewayStateFault.Missing, state.Fault); } + // Preparing a runtime for an application that is not there wastes an + // extraction; the missing application is reported first. [Fact] - public async Task AgentResolvesNodeBeforeReportingMissingApplication() + public async Task AgentReportsMissingApplicationBeforeResolvingHostNode() { bool nodeResolutionAttempted = false; @@ -391,7 +465,7 @@ await Assert.ThrowsAsync( .ProcessArchitecture)); })); - Assert.True(nodeResolutionAttempted); + Assert.False(nodeResolutionAttempted); } public void Dispose() @@ -400,6 +474,64 @@ public void Dispose() GC.SuppressFinalize(this); } + // Setup refuses to provision on a host that cannot isolate, so every test + // that drives setup to completion has to state that this one can. + private static Task SupportedHost(CancellationToken _) => + Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)); + + private static Task RecoveryConfigured( + Action _, + CancellationToken __) => + Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true)); + + // Teardown removes logon recovery as well as the session, so the test has + // to supply a scheduler fixture rather than let it reach Task Scheduler. + private TeardownOrchestrator CreateTeardownOrchestrator( + SessionRuntime runtime, + Action log) + { + string stateRoot = Path.Combine(_testDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stateRoot); + var recovery = new GatewayPersistenceManager( + new Gateway.FakeGatewayTaskScheduler(), + new GatewayPersistenceOptions( + UserSid: "S-1-5-21-1", + PackageFamilyName: "OpenClaw.Gateway_test", + LauncherPath: Path.Combine(stateRoot, "gateway-launcher.cmd"), + StartupFolderPath: Path.Combine(stateRoot, "startup"), + WorkingDirectory: stateRoot, + CommandProcessorPath: @"C:\Windows\System32\cmd.exe"), + log); + var controller = new GatewayController( + runtime.Coordinator, + new SessionGatewayClient(runtime.Backend, log), + runtime.GatewayState, + _ => throw new InvalidOperationException( + "Teardown must never start the gateway."), + log, + runtime.RequireSetup, + runtime.LifecycleLock); + + return new TeardownOrchestrator( + runtime.LifecycleLock, + recovery, + controller, + runtime.Coordinator, + runtime.GatewayState, + new GatewayConfigurationStore(Path.Combine(stateRoot, "gateway-config.json")), + runtime.SetupState); + } + private SessionRuntime CreateSessionRuntime() { string stateRoot = Path.Combine(_testDirectory, "state"); @@ -441,7 +573,7 @@ private SessionRuntime CreateSessionRuntime() return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); }; _lastSessionBackend = backend; - var paths = HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"); + HostPaths paths = HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"); _sessionStatePath = paths.SessionStatePath; return SessionRuntime.Create( paths, @@ -471,14 +603,12 @@ await File.WriteAllTextAsync( "node.exe", new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), - () => runtime, + createSessionRuntime: _ => runtime, // Setup only reaches Ready once logon recovery is configured, and // a test must never register a real scheduled task. - _ => Task.FromResult(new GatewayPersistenceInstallResult( - GatewayPersistenceState.Ready, - GatewayPersistenceLane.TaskScheduler, - "Logon recovery is configured.", - Changed: true))).ConfigureAwait(false); + installRecovery: RecoveryConfigured, + probeReadiness: SupportedHost, + getPackageFamilyName: () => "OpenClaw.Gateway_test").ConfigureAwait(false); Assert.Equal(0, exitCode); return runtime; @@ -500,14 +630,14 @@ private static MxcReadinessReport UnavailableReadiness() => BackendProbe: new MxcBackendProbe(false, "base-container", []), BackendProbeFailureReason: null); - private static Task RunAgentWithDirectLaunchProbeAsync( + private Task RunAgentWithDirectLaunchProbeAsync( SessionRuntime runtime, List directLaunches) { ArgumentNullException.ThrowIfNull(directLaunches); return Program.RunAgentAsync( - new HostOptions(null, null, ["--version"]), + CreateAgentOptions(), _ => { }, _ => throw new InvalidOperationException("Host Node must not be resolved."), (_, _, _, _, _, _) => @@ -515,6 +645,18 @@ private static Task RunAgentWithDirectLaunchProbeAsync( directLaunches.Add("direct"); return Task.FromResult(0); }, - _ => runtime); + _ => runtime, + probeReadiness: _ => Task.FromResult(UnavailableReadiness()), + getPackageFamilyName: () => "OpenClaw.Gateway_test"); + } + + private HostOptions CreateAgentOptions() + { + string applicationDirectory = Path.Combine(_testDirectory, "agent-app"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllText( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + return new HostOptions(applicationDirectory, null, ["--version"]); } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs index 390394d0..e080c9d9 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs @@ -24,6 +24,8 @@ internal sealed class FakeMxcSessionClient : IMxcSessionClient public Exception? StartFailure { get; set; } + public Func? StartFailureForSandbox { get; set; } + public Exception? StopFailure { get; set; } public Exception? DeprovisionFailure { get; set; } @@ -61,8 +63,9 @@ public Task StartAsync( CancellationToken cancellationToken) { Calls.Add($"start:{sandboxId.Value}"); - return StartFailure is not null - ? Task.FromException(StartFailure) + Exception? failure = StartFailureForSandbox?.Invoke(sandboxId) ?? StartFailure; + return failure is not null + ? Task.FromException(failure) : Task.CompletedTask; } diff --git a/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs b/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs index 8c892d5b..e548beb7 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs @@ -168,6 +168,25 @@ exception is InvalidOperationException or public sealed class SessionInspectorTests { + [Fact] + public void AnUnconfirmedLaunchIsUnknownRatherThanSuccessfulAbsence() + { + SessionInspectResult result = SessionInspector.Inspect( + new SessionInspectRequest + { + RequestId = "pending", + LaunchPending = true, + ProcessId = 0, + ProcessStartTimeUtc = DateTimeOffset.UtcNow + }, + _ => throw new InvalidOperationException("A pending launch must not inspect a process.")); + + Assert.Equal("pending", result.RequestId); + Assert.False(result.ProcessFound); + Assert.NotNull(result.Error); + Assert.Contains("not confirmed", result.Error, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void EvenOneTickMismatchCannotAuthorizeStop() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs index 788bb95a..b2a85a61 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs @@ -55,6 +55,64 @@ public async Task FirstUseProvisionsWithThisInstallationsIdentity() Assert.Equal([ApplicationId], _backend.ProvisionedAppIds); } + [Fact] + public async Task StatusProbeReportsRunningOnlyAfterMxcAcceptsTheRecordedProvision() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + + SessionStatus status = await Create().ProbeRecordedStatusAsync(CancellationToken.None); + + Assert.Equal(SessionAvailability.Running, status.Availability); + Assert.Equal("iso:sandbox1", status.Record!.SandboxId); + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task StatusProbeReportsAStaleProvisionWithoutReplacingIt() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + _backend.StartFailure = new MxcException(MxcErrorCode.StaleId, "provision was not found"); + + SessionStatus status = await Create().ProbeRecordedStatusAsync(CancellationToken.None); + + Assert.Equal(SessionAvailability.Stale, status.Availability); + Assert.Equal("iso:sandbox1", status.Record!.SandboxId); + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + Assert.Equal("iso:sandbox1", Store().Read(ApplicationId).Record!.SandboxId); + } + + [Fact] + public async Task StatusProbeReportsAnUnavailableBackend() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + _backend.StartFailure = new MxcException( + MxcErrorCode.RuntimeUnavailable, + "MXC is unavailable"); + + SessionStatus status = await Create().ProbeRecordedStatusAsync(CancellationToken.None); + + Assert.Equal(SessionAvailability.BackendUnavailable, status.Availability); + Assert.Equal("MXC is unavailable", status.Detail); + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + } + + [Fact] + public async Task StatusProbeReportsOtherBackendFailures() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + _backend.StartFailure = new MxcException(MxcErrorCode.BackendError, "MXC is unavailable"); + + SessionStatus status = await Create().ProbeRecordedStatusAsync(CancellationToken.None); + + Assert.Equal(SessionAvailability.BackendError, status.Availability); + Assert.Equal("MXC is unavailable", status.Detail); + Assert.Equal(["start:iso:sandbox1"], _backend.Calls); + } + [Fact] public async Task ProvisionMetadataIsPersisted() { @@ -107,6 +165,76 @@ public async Task SecondUseReusesTheRecordedSessionWithoutProvisioning() Assert.Equal("iso:sandbox1", record.SandboxId); } + [Fact] + public async Task StaleRecordedProvisionIsReplacedOnlyAfterTheBackendReportsItMissing() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + _backend.StartFailureForSandbox = sandboxId => sandboxId.Value == "iso:sandbox1" + ? new MxcException(MxcErrorCode.StaleId, "provision was not found") + : null; + + SessionRecord record = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal( + ["start:iso:sandbox1", "provision", "start:iso:sandbox2"], + _backend.Calls); + Assert.Equal("iso:sandbox2", record.SandboxId); + Assert.Equal("iso:sandbox2", Store().Read(ApplicationId).Record!.SandboxId); + Assert.Equal([ApplicationId, ApplicationId], _backend.ProvisionedAppIds); + } + + [Fact] + public async Task StaleRecordedProvisionReplacementIdentifiesTheSupersededSession() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.StartFailureForSandbox = sandboxId => sandboxId.Value == "iso:sandbox1" + ? new MxcException(MxcErrorCode.StaleId, "provision was not found") + : null; + + SessionStartResult result = await Create() + .EnsureStartedWithResultAsync(CancellationToken.None); + + Assert.Equal("iso:sandbox2", result.Record.SandboxId); + Assert.Equal("iso:sandbox1", result.SupersededRecord!.SandboxId); + } + + // This catches a second failed/retried replacement dropping the first + // superseded identity before its gateway record can be reconciled. + [Fact] + public async Task ReplacementsRetainEverySupersededSandboxIdentity() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.StartFailureForSandbox = sandboxId => sandboxId.Value is "iso:sandbox1" or "iso:sandbox2" + ? new MxcException(MxcErrorCode.StaleId, "provision was not found") + : null; + + await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + SessionRecord replacement = await Create().EnsureStartedAsync(CancellationToken.None); + + Assert.Equal("iso:sandbox3", replacement.SandboxId); + Assert.Equal(["iso:sandbox1", "iso:sandbox2"], replacement.SupersededSandboxIds); + } + + [Fact] + public async Task FailedStaleProvisionRecoveryRetainsTheRecordedOwnership() + { + await Create().EnsureStartedAsync(CancellationToken.None); + _backend.Calls.Clear(); + _backend.StartFailureForSandbox = _ => + new MxcException(MxcErrorCode.StaleId, "provision was not found"); + _backend.ProvisionFailure = new MxcException( + MxcErrorCode.BackendError, + "replacement provision failed"); + + await Assert.ThrowsAsync( + () => Create().EnsureStartedAsync(CancellationToken.None)); + + Assert.Equal(["start:iso:sandbox1", "provision"], _backend.Calls); + Assert.Equal("iso:sandbox1", Store().Read(ApplicationId).Record!.SandboxId); + } + [Fact] public async Task SeparateCoordinatorsConvergeOnOneSession() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index d4f6cc3a..38f43fcf 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -37,7 +37,8 @@ private SessionExecutionRequest Request(params string[] arguments) => arguments, @"C:\work"); - private SessionExecutor Create() => new(_backend, _log.Add); + private SessionExecutor Create(Func? createRequestId = null) => + new(_backend, _log.Add, createRequestId: createRequestId); [Fact] public async Task IsolatedLaunchRetainsTheHostInteractiveEnvironment() @@ -58,7 +59,7 @@ await Create().ExecuteAsync( Record(), new SessionExecutionRequest( @"C:\Package\session-host\x64\openclaw-session-host.exe", - @"C:\Program Files\nodejs\node.exe", + @"C:\Users\agent\AppData\Local\OpenClawGatewayMSIX\agent-node\node-v24.20.0-win-x64\node.exe", @"C:\Package\app", [], Workspace) @@ -105,7 +106,7 @@ private void RespondLaunched(int exitCode) => private void RespondAsCollector( Func respond) { - _backend.AttachedBehavior = _ => + _backend.ExecuteBehavior = _ => { string requestPath = Directory.GetFiles(Workspace, "collect-*.json") .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); @@ -114,7 +115,23 @@ private void RespondAsCollector( File.WriteAllText( SessionLaunchProtocol.ResultPathFor(requestPath), SessionCollectProtocol.SerializeResult(respond(request))); - return Task.FromResult(0); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + } + + private void RespondAsRuntimeInstaller( + Func respond) + { + _backend.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles(Workspace, "runtime-*.json") + .Single(path => !path.EndsWith(".result.json", StringComparison.Ordinal)); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(respond(request))); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); }; } @@ -140,11 +157,77 @@ public async Task CollectionUsesTheGuestHelperAndReturnsItsResult() ["openclaw-agent.sqlite*"], CancellationToken.None); - Assert.Equal(["execute-attached:iso:sandbox1"], _backend.Calls); + Assert.Equal(["execute:iso:sandbox1"], _backend.Calls); Assert.Equal( @"AppData\Roaming\openclaw\logs", Assert.Single(delivered!.Sources!).RelativePath); Assert.True(Assert.Single(result.Entries!).Copied); + } + + // This catches collection following a guest-writable result link to a + // host-readable file before checking the response's request identity. + [Fact] + public async Task CollectionRefusesAReparsePointResultOutsideTheWorkspace() + { + string collectionRoot = Path.Combine(Workspace, "collection"); + string nestedWorkspace = Path.Combine(collectionRoot, "workspace"); + string outsideTarget = Path.Combine(collectionRoot, "outside", "result.json"); + Directory.CreateDirectory(nestedWorkspace); + Directory.CreateDirectory(Path.GetDirectoryName(outsideTarget)!); + await File.WriteAllTextAsync(outsideTarget, """{"requestId":"other"}"""); + _backend.ExecuteBehavior = _ => + { + string requestPath = Path.Combine( + nestedWorkspace, + "collect-test-generation-reparse-test.json"); + File.CreateSymbolicLink( + SessionLaunchProtocol.ResultPathFor(requestPath), + outsideTarget); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + + SessionException exception = await Assert.ThrowsAsync( + () => Create(() => "reparse-test").CollectAsync( + Record(nestedWorkspace), + @"C:\Package\session-host\x64\openclaw-session-host.exe", + Path.Combine(nestedWorkspace, "staged"), + [], + [], + CancellationToken.None)); + + Assert.Contains("reparse", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFiles(nestedWorkspace)); + Assert.Empty(Directory.GetFiles(nestedWorkspace, "*.result.json")); + Assert.True(File.Exists(outsideTarget)); + } + + [Fact] + public async Task RuntimeInstallUsesTheGuestHelperAndReturnsItsResult() + { + SessionRuntimeInstallRequest? delivered = null; + RespondAsRuntimeInstaller(request => + { + delivered = request; + return new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + ExecutablePath = @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip" + }; + }); + + SessionRuntimeInstallResult result = await Create().InstallRuntimeAsync( + Record(), + @"C:\Package\session-host\x64\openclaw-session-host.exe", + @"C:\Package\runtime\node-v24.20.0-win-x64.zip", + CancellationToken.None); + + Assert.Equal( + @"C:\Package\runtime\node-v24.20.0-win-x64.zip", + delivered!.ArchivePath); + Assert.Equal("24.20.0", result.Version); + Assert.Equal(["execute:iso:sandbox1"], _backend.Calls); Assert.Empty(Directory.GetFiles(Workspace)); } @@ -183,6 +266,38 @@ public async Task ApplicationExitCodeIsReturned() Assert.Equal(42, exitCode); } + [Fact] + public async Task IsolatedLaunchPrependsTheSelectedAgentNodeDirectory() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + string node = @"C:\Users\agent\AppData\Local\OpenClawGatewayMSIX\agent-node\node-v24.20.0-win-x64\node.exe"; + + await Create().ExecuteAsync( + Record(), + new SessionExecutionRequest( + @"C:\Package\session-host\x64\openclaw-session-host.exe", + node, + @"C:\Package\app", + [], + Workspace), + CancellationToken.None); + + Assert.Equal(Path.GetDirectoryName(node), delivered!.PathPrefix); + Assert.False( + delivered.Environment!.ContainsKey("PATH"), + "The host must not send a PATH that would replace the agent's own."); + } + [Fact] public async Task CommandRequestCarriesInteractiveAndShimEnvironment() { From 1989f97fc0312c0fe854b428e362504bec7a86a7 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:52 -0700 Subject: [PATCH 62/76] feat: reset an installation with clawctl setup --fresh (#52) * Add fresh setup reset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Add forced fresh setup recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Resolve lifecycle stack integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Gate setup on session support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Keep ordinary setup cancellation guidance non-destructive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Document session-free setup requirements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Reject redirected installation state ancestors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Install agent command tools inside the session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Persist superseded session reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Detect missing scheduled tasks without localized text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Fix session reset and gateway failure output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Harden installation state cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Keep session fixtures isolated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Resolve session lifecycle integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Preserve host runtime startup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Read the gateway log from the session workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Update fresh reset launch test seams Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- README.md | 3 +- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 32 +- .../Gateway/GatewayControlOutput.cs | 145 +++ .../Gateway/GatewayRuntime.cs | 9 +- src/OpenClaw.Launcher/HostDiagnosticLog.cs | 3 + src/OpenClaw.Launcher/HostStartup.cs | 5 +- src/OpenClaw.Launcher/Program.cs | 568 +++++---- .../Session/InstallationLifecycle.cs | 120 ++ .../Session/InstallationStateCleaner.cs | 192 ++++ .../Session/SessionRuntime.cs | 52 +- .../Session/TeardownOrchestrator.cs | 8 + .../SmokeProgram.cs | 12 + .../ClawCtlCommandLineTests.cs | 55 + .../Gateway/GatewayControlOutputTests.cs | 92 ++ .../HostDiagnosticLogTests.cs | 14 + .../ProgramStartupTests.cs | 6 + tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 1010 ++++++++++++++--- .../Session/InstallationStateCleanerTests.cs | 161 +++ .../Session/SessionExecutorTests.cs | 3 + 19 files changed, 2094 insertions(+), 396 deletions(-) create mode 100644 src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs create mode 100644 src/OpenClaw.Launcher/Session/InstallationLifecycle.cs create mode 100644 src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs diff --git a/README.md b/README.md index d2aca671..7ba28568 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ the read-only application directory the workspace. | Command | Behavior | |---|---| -| `clawctl setup` | Extract the bundled Node.js runtime when needed and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl setup` | On a session-capable Windows build, provision the isolated agent session, install its bundled Node.js runtime, and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl setup --no-isolation` | On a session-capable Windows build, prepare the host runtime without provisioning the isolated session. It still refuses unsupported Windows builds. | | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 1a383a78..0737bcb6 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -16,7 +16,7 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStop { get; init; } } -internal sealed record SetupOptions(bool NoIsolation); +internal sealed record SetupOptions(bool Fresh, bool Force, bool NoIsolation = false); // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the @@ -62,9 +62,31 @@ public static RootCommand Create(ClawCtlHandlers handlers) { Description = "Prepare the bundled runtime without provisioning an isolated session." }; + Option fresh = new("--fresh") + { + Description = "Remove this installation's owned session and local state before setting it up again." + }; setup.Options.Add(noIsolation); + Option force = new("--force") + { + Description = "Continue with package-local cleanup when owned external cleanup cannot be confirmed. Requires --fresh." + }; + setup.Options.Add(fresh); + setup.Options.Add(force); + setup.Validators.Add(result => + { + if (result.GetValue(force) && !result.GetValue(fresh)) + { + result.AddError("Option '--force' requires option '--fresh'."); + } + }); setup.SetAction((parsed, cancellationToken) => - handlers.Setup(new SetupOptions(parsed.GetValue(noIsolation)), cancellationToken)); + handlers.Setup( + new SetupOptions( + parsed.GetValue(fresh), + parsed.GetValue(force), + parsed.GetValue(noIsolation)), + cancellationToken)); Command status = new( StatusCommandName, "Show the isolated-session record and MXC-observed provision state without provisioning a replacement."); @@ -79,11 +101,11 @@ public static RootCommand Create(ClawCtlHandlers handlers) collectLogs.Options.Add(outputPath); collectLogs.SetAction((parsed, cancellationToken) => handlers.CollectLogs(parsed.GetValue(outputPath), cancellationToken)); - Option force = new("--force") { Description = "Skip confirmation and remove the owned session." }; + Option teardownForce = new("--force") { Description = "Skip confirmation and remove the owned session." }; Command teardown = new("teardown", "Stop and remove the owned isolated session."); - teardown.Options.Add(force); + teardown.Options.Add(teardownForce); teardown.SetAction((parsed, cancellationToken) => - handlers.Teardown(parsed.GetValue(force), cancellationToken)); + handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken)); Command powerShell = new( "pwsh", "Open an interactive PowerShell session inside the isolated agent."); diff --git a/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs new file mode 100644 index 00000000..60b3cf9d --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs @@ -0,0 +1,145 @@ +using System.Text; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// Writes gateway command results without trusting guest-produced log text. +internal static class GatewayControlOutput +{ + private const int MaximumLogTailBytes = 16 * 1024; + private const int LogTailLineCount = 10; + + public static async Task WriteStatusAsync( + TextWriter output, + GatewayStatusReport result, + string? workspacePath, + CancellationToken cancellationToken) + { + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + await WriteDetailAsync(output, result.Detail).ConfigureAwait(false); + if (result.State is GatewayState.Stopped or GatewayState.Unhealthy) + { + await WriteLogTailAsync( + output, + workspacePath, + result.Record?.LogPath, + cancellationToken).ConfigureAwait(false); + } + } + + public static async Task WriteStopAsync( + TextWriter output, + GatewayStopResult result) + { + await output.WriteLineAsync(result.Message).ConfigureAwait(false); + await WriteDetailAsync(output, result.Detail).ConfigureAwait(false); + } + + private static async Task WriteDetailAsync(TextWriter output, string? detail) + { + if (!string.IsNullOrWhiteSpace(detail)) + { + await output.WriteLineAsync(detail).ConfigureAwait(false); + } + } + + private static async Task WriteLogTailAsync( + TextWriter output, + string? workspacePath, + string? logPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workspacePath) || + string.IsNullOrWhiteSpace(logPath)) + { + return; + } + + try + { + using FileStream stream = TrustedPath.OpenRead(workspacePath, logPath); + if (stream.Length == 0) + { + return; + } + + stream.Seek(Math.Max(0, stream.Length - MaximumLogTailBytes), SeekOrigin.Begin); + byte[] bytes = new byte[checked((int)Math.Min(MaximumLogTailBytes, stream.Length - stream.Position))]; + int offset = 0; + while (offset < bytes.Length) + { + int read = await stream.ReadAsync(bytes.AsMemory(offset), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + offset += read; + } + + string text = Encoding.UTF8.GetString(bytes, 0, offset); + string[] lines = text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + string[] tail = + [ + .. lines.TakeLast(LogTailLineCount) + .Select(Sanitize) + .Where(line => line.Length > 0) + ]; + if (tail.Length == 0) + { + return; + } + + await output.WriteLineAsync($"Gateway log tail ({logPath}):").ConfigureAwait(false); + foreach (string line in tail) + { + await output.WriteLineAsync(line).ConfigureAwait(false); + } + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or SessionException) + { + // A guest-writable diagnostic must not change a status command's result. + await output.WriteLineAsync($"Gateway log unavailable: {logPath}").ConfigureAwait(false); + } + } + + private static string Sanitize(string value) + { + StringBuilder builder = new(value.Length); + bool afterEscape = false; + bool inControlSequence = false; + foreach (char character in value) + { + if (afterEscape) + { + inControlSequence = character == '['; + afterEscape = false; + continue; + } + + if (inControlSequence) + { + if (character is >= '@' and <= '~') + { + inControlSequence = false; + } + + continue; + } + + if (character == '\x1b') + { + afterEscape = true; + } + else if (!char.IsControl(character)) + { + builder.Append(character); + } + } + + return builder.ToString(); + } +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs index d6670747..0f58094f 100644 --- a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -32,6 +32,11 @@ private GatewayRuntime( public string HelperPath { get; } + internal HostPaths Paths => _paths; + + internal string? GetRecordedWorkspacePath() => + _session.Coordinator.GetRecordedStatus().Record?.WorkspacePath; + private SessionRuntime Session => _session; private static bool FileExists(string path) => File.Exists(path); @@ -197,9 +202,7 @@ Task CreateRequestAsync(CancellationToken cancellationToken applicationDirectory, launch.Port) { - WorkingDirectory = launch.WorkingDirectory ?? sessionRecord.WorkspacePath - ?? throw new SessionException( - "The isolated session has no shared workspace for the gateway.") + WorkingDirectory = launch.WorkingDirectory }); } diff --git a/src/OpenClaw.Launcher/HostDiagnosticLog.cs b/src/OpenClaw.Launcher/HostDiagnosticLog.cs index 103866e2..79b68c09 100644 --- a/src/OpenClaw.Launcher/HostDiagnosticLog.cs +++ b/src/OpenClaw.Launcher/HostDiagnosticLog.cs @@ -80,6 +80,9 @@ public void Write(string message) "Timed out waiting to append to the diagnostic log."); } + // A fresh setup deliberately clears the installation state + // root. Recreate the log directory before reopening the file. + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); using var stream = new FileStream( Path, FileMode.Append, diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index 4233eef6..89713e4f 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -25,7 +25,7 @@ internal sealed class HostStartup public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; } - public Func, Session.SessionRuntime>? CreateSessionRuntime { get; init; } + public Session.IInstallationLifecycle? InstallationLifecycle { get; init; } public Func? ReadEnvironmentVariable { get; init; } @@ -36,6 +36,7 @@ internal sealed class HostStartup BaseDirectory = AppContext.BaseDirectory, Output = Console.Out, Error = Console.Error, - InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled + InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled, + InstallationLifecycle = Session.InstallationLifecycle.Production }; } diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index d17796af..b0f87568 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -109,7 +109,8 @@ PlatformNotSupportedException or WriteDiagnostic, startup.Output, startup.Error, - startup.ResolveNode).ConfigureAwait(false) + startup.ResolveNode, + startup.InstallationLifecycle).ConfigureAwait(false) : await RunAgentAsync( options, WriteDiagnostic, @@ -118,7 +119,9 @@ PlatformNotSupportedException or GetPackagedNodeArchivePath(options), WriteDiagnostic))), startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, - startup.CreateSessionRuntime, + startup.InstallationLifecycle is null + ? null + : startup.InstallationLifecycle.CreateRuntime, readEnvironmentVariable: startup.ReadEnvironmentVariable) .ConfigureAwait(false); } @@ -147,9 +150,8 @@ internal static async Task RunAgentAsync( await RunAgentAsync( options, log, - resolveNode ?? (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( - GetPackagedNodeArchivePath(options), - log))), + resolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( + GetPackagedNodeArchivePath(options)))), GatewayLauncher.RunAsync).ConfigureAwait(false); // launchOpenClaw is a test seam: tests substitute a fake in place of @@ -171,84 +173,74 @@ internal static async Task RunAgentAsync( Session.SessionMode mode = Session.SessionRoutingPolicy.ReadMode( readEnvironmentVariable ?? Environment.GetEnvironmentVariable); + Session.SessionRoutingDecision routing; + string? packageFamilyName = null; if (mode == Session.SessionMode.Disabled) { - return await RunDirectAsync().ConfigureAwait(false); + routing = new Session.SessionRoutingDecision( + Session.SessionRouting.Direct, + $"{Session.SessionRoutingPolicy.ModeVariable} is set to 0."); } - - Session.SessionRuntime? runtime = null; - if (createSessionRuntime is null || probeReadiness is not null) + else { Mxc.MxcReadinessReport readiness = await (probeReadiness ?? Mxc.MxcReadiness.ProbeAsync)(CancellationToken.None).ConfigureAwait(false); - string? packageFamilyName = + packageFamilyName = (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(); - Session.SessionRoutingDecision routing = Session.SessionRoutingPolicy.Decide( + routing = Session.SessionRoutingPolicy.Decide( mode, packageFamilyName, readiness); - log(routing.Reason); - if (routing.Routing == Session.SessionRouting.Direct) - { - if (packageFamilyName is not null) - { - runtime = (createSessionRuntime ?? Session.SessionRuntime.Create)(log); - runtime.ValidateSavedOwnershipForHostFallback(); - } - - return await RunDirectAsync().ConfigureAwait(false); - } } - Session.SessionRecord record; - try + log(routing.Reason); + if (routing.Routing == Session.SessionRouting.Session) { - runtime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); - record = await runtime.StartForExecutionAsync(CancellationToken.None) - .ConfigureAwait(false); - } - catch (Session.SessionCapabilityUnavailableException) - when (mode == Session.SessionMode.Automatic) - { - log("Isolated session unavailable; running OpenClaw directly."); - return await RunDirectAsync().ConfigureAwait(false); + Session.SessionRuntime runtime = (createSessionRuntime ?? + Session.SessionRuntime.Create)(log); + Session.SessionRecord record = + await runtime.StartForExecutionAsync(CancellationToken.None) + .ConfigureAwait(false); + string agentNodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); + return await runtime.Executor.ExecuteAsync( + record, + new Session.SessionExecutionRequest( + runtime.RequireStagedHelper(record), + agentNodePath, + applicationDirectory, + options.OpenClawArguments, + record.WorkspacePath!) + { + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), + readEnvironmentVariable ?? Environment.GetEnvironmentVariable, + GatewayIsolationMode.Enabled) + }, + CancellationToken.None).ConfigureAwait(false); } - string agentNodePath = runtime.RequireAgentNodePath( - GetPackagedNodeArchivePath(options)); - return await runtime.Executor.ExecuteAsync( - record, - new Session.SessionExecutionRequest( - runtime.RequireStagedHelper(record), - agentNodePath, - applicationDirectory, - options.OpenClawArguments, - record.WorkspacePath!) - { - AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( - (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), - readEnvironmentVariable ?? Environment.GetEnvironmentVariable, - GatewayIsolationMode.Enabled) - }, - CancellationToken.None).ConfigureAwait(false); - - async Task RunDirectAsync() + if (mode == Session.SessionMode.Automatic && + routing.Routing == Session.SessionRouting.Direct && + packageFamilyName is not null) { - NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) - .ConfigureAwait(false); - log( - $"Using Node.js {nodeRuntime.Version} from " + - $"{nodeRuntime.ExecutablePath}."); - string directApplicationDirectory = GetPackagedApplicationDirectory(options); - log("Using the OpenClaw application directly from the package."); - return await launchOpenClaw( - nodeRuntime.ExecutablePath, - directApplicationDirectory, - options.OpenClawArguments, - GatewayIsolationMode.Disabled, - CancellationToken.None, - log).ConfigureAwait(false); + Session.SessionRuntime runtime = (createSessionRuntime ?? + Session.SessionRuntime.Create)(log); + runtime.ValidateSavedOwnershipForHostFallback(); } + + NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) + .ConfigureAwait(false); + log( + $"Using Node.js {nodeRuntime.Version} from " + + $"{nodeRuntime.ExecutablePath}."); + return await launchOpenClaw( + nodeRuntime.ExecutablePath, + applicationDirectory, + options.OpenClawArguments, + GatewayIsolationMode.Disabled, + CancellationToken.None, + log).ConfigureAwait(false); } // output and error are required parameters (not Console defaults) so tests @@ -261,134 +253,28 @@ internal static async Task RunControlAsync( TextWriter output, TextWriter error, Func>? resolveNode = null, - Func, Session.SessionRuntime>? createSessionRuntime = null, - Func, Session.TeardownOrchestrator>? - createTeardownOrchestrator = null, - Func, CancellationToken, Task>? - installRecovery = null, - Func>? probeReadiness = null, - Func? getPackageFamilyName = null, - Func>? - removeRecovery = null) + Session.IInstallationLifecycle? installationLifecycle = null, + Func? readEnvironmentVariable = null) { + _ = resolveNode; + Session.IInstallationLifecycle lifecycle = + installationLifecycle ?? Session.InstallationLifecycle.Production; Session.SessionRuntime? sessionRuntime = null; Session.SessionRuntime GetSessionRuntime() => - sessionRuntime ??= (createSessionRuntime ?? Session.SessionRuntime.Create)(log); + sessionRuntime ??= lifecycle.CreateRuntime(log); RootCommand command = ClawCtlCommandLine.Create( new ClawCtlHandlers { - Setup = async (setupOptions, cancellationToken) => - { - string applicationDirectory = GetPackagedApplicationDirectory(options); - log("Confirmed the packaged OpenClaw application is present."); - ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); - - Mxc.MxcReadinessReport readiness = await (probeReadiness ?? - Mxc.MxcReadiness.ProbeAsync)(cancellationToken).ConfigureAwait(false); - Session.SessionRoutingDecision routing = Session.SessionRoutingPolicy.Decide( - Session.SessionMode.Automatic, - (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(), - readiness); - if (routing.Routing != Session.SessionRouting.Session) - { - await output.WriteLineAsync( - $"OpenClaw setup requires isolated-session support. {routing.Reason}") - .ConfigureAwait(false); - return 1; - } - - if (setupOptions.NoIsolation || - Session.SessionRoutingPolicy.ReadMode( - Environment.GetEnvironmentVariable) == Session.SessionMode.Disabled) - { - NodeRuntime nodeRuntime = await (resolveNode ?? - (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( - GetPackagedNodeArchivePath(options), - log))))(cancellationToken).ConfigureAwait(false); - ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); - await output.WriteLineAsync( - "OpenClaw setup completed without isolated-session provisioning.") - .ConfigureAwait(false); - return 0; - } - - try - { - Session.SessionRuntime runtime = GetSessionRuntime(); - using Session.ISessionLockHandle handle = runtime.AcquireLifecycleLock(); - runtime.SetupState.Write(new Session.SetupRecord - { - ApplicationId = runtime.ApplicationId, - Phase = Session.SetupPhase.Preparing - }); - Session.SessionStartResult session = await runtime.Coordinator - .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); - - // A replacement whose first start failed leaves the - // superseded identity only in the persisted record, so - // a retry still reconciles the stale gateway. - IEnumerable supersededSandboxIds = - (session.SupersededRecord is null - ? Enumerable.Empty() - : [session.SupersededRecord.SandboxId]) - .Concat(session.Record.SupersededSandboxIds ?? []) - .Append(session.Record.SupersededSandboxId) - .Where(static id => !string.IsNullOrWhiteSpace(id)) - .Select(static id => id!) - .Distinct(StringComparer.Ordinal); - foreach (string supersededSandboxId in supersededSandboxIds) - { - if (runtime.GatewayState.ClearForSupersededSession( - supersededSandboxId)) - { - log("Removed the gateway record for the superseded session."); - } - } - - Session.SessionRecord record = session.Record; - string helperPath = runtime.StageHelper(record); - SessionRuntimeInstallResult agentRuntime = await runtime.Executor - .InstallRuntimeAsync( - record, - helperPath, - GetPackagedNodeArchivePath(options), - cancellationToken) - .ConfigureAwait(false); - await output.WriteLineAsync( - $"Node.js {agentRuntime.Version} is ready in the isolated agent session.") - .ConfigureAwait(false); - await output.WriteLineAsync("OpenClaw isolated session is ready.") - .ConfigureAwait(false); - - Gateway.GatewayPersistenceInstallResult recovery = await ( - installRecovery ?? ((writeLog, token) => - Gateway.GatewayRuntime.CreateRecoveryManager(writeLog) - .InstallAsync(token)))(log, cancellationToken) - .ConfigureAwait(false); - await output.WriteLineAsync(recovery.Message).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(recovery.Detail)) - { - await output.WriteLineAsync(recovery.Detail).ConfigureAwait(false); - } - - if (recovery.State != Gateway.GatewayPersistenceState.Ready) - { - return 1; - } - - runtime.CompleteSetup(record, agentRuntime, startupEnabled: true); - return 0; - } - catch (Session.SessionException exception) - { - log($"Isolated session setup is unavailable: {exception.Message}"); - await output.WriteLineAsync( - $"OpenClaw setup could not complete: {exception.Message}") - .ConfigureAwait(false); - return 1; - } - }, + Setup = (setupOptions, cancellationToken) => RunSetupAsync( + setupOptions, + options, + GetSessionRuntime, + lifecycle, + log, + output, + readEnvironmentVariable ?? Environment.GetEnvironmentVariable, + cancellationToken), Status = async cancellationToken => { Session.SessionStatus status = await GetSessionRuntime() @@ -442,33 +328,9 @@ await error.WriteLineAsync( } Session.SessionRuntime runtime = GetSessionRuntime(); - if (removeRecovery is not null) - { - using Session.ISessionLockHandle handle = - runtime.AcquireLifecycleLock(); - Gateway.GatewayPersistenceRemovalResult recovery = - await removeRecovery(cancellationToken).ConfigureAwait(false); - if (!recovery.Succeeded) - { - await error.WriteLineAsync( - $"OpenClaw recovery could not be removed: {recovery.Detail}") - .ConfigureAwait(false); - return 1; - } - - await runtime.Coordinator.RemoveAsync(cancellationToken) - .ConfigureAwait(false); - runtime.GatewayState.Clear(); - await output.WriteLineAsync("OpenClaw isolated session was removed.") - .ConfigureAwait(false); - return 0; - } - - Session.TeardownOrchestrator teardown = (createTeardownOrchestrator ?? - ((current, writeLog) => Gateway.GatewayRuntime - .CreateTeardownOrchestrator(options, current, writeLog)))(runtime, log); - Session.TeardownResult result = await teardown.RunAsync( - runtime.HelperPath, force, cancellationToken).ConfigureAwait(false); + Session.TeardownResult result = await lifecycle.TeardownAsync( + options, runtime, log, lockAlreadyHeld: false, cancellationToken) + .ConfigureAwait(false); await output.WriteLineAsync(result.Message).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(result.Detail)) { @@ -494,16 +356,15 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") }, GatewayStatus = async cancellationToken => { - Gateway.GatewayStatusReport result = await Gateway.GatewayRuntime - .Create(options, log) - .Controller + Gateway.GatewayRuntime runtime = Gateway.GatewayRuntime.Create(options, log); + Gateway.GatewayStatusReport result = await runtime.Controller .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(result.Message).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(result.Detail)) - { - await output.WriteLineAsync(result.Detail).ConfigureAwait(false); - } + await Gateway.GatewayControlOutput.WriteStatusAsync( + output, + result, + runtime.GetRecordedWorkspacePath(), + cancellationToken).ConfigureAwait(false); return result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted ? 0 : 1; }, @@ -514,11 +375,8 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .Controller .StopAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(result.Message).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(result.Detail)) - { - await output.WriteLineAsync(result.Detail).ConfigureAwait(false); - } + await Gateway.GatewayControlOutput.WriteStopAsync(output, result) + .ConfigureAwait(false); return result.Succeeded ? 0 : 1; } }); @@ -545,6 +403,262 @@ await output.WriteLineAsync("OpenClaw isolated session was removed.") .ConfigureAwait(false); } + private static async Task RunSetupAsync( + SetupOptions setupOptions, + HostOptions options, + Func getSessionRuntime, + Session.IInstallationLifecycle lifecycle, + Action log, + TextWriter output, + Func readEnvironmentVariable, + CancellationToken cancellationToken) + { + string applicationDirectory = GetPackagedApplicationDirectory(options); + log("Confirmed the packaged OpenClaw application is present."); + ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); + + Session.SessionRoutingDecision routing = await lifecycle.CheckSessionSupportAsync( + cancellationToken).ConfigureAwait(false); + if (routing.Routing != Session.SessionRouting.Session) + { + await output.WriteLineAsync( + $"OpenClaw setup requires isolated-session support. {routing.Reason}") + .ConfigureAwait(false); + return 1; + } + + if (setupOptions.NoIsolation || + Session.SessionRoutingPolicy.ReadMode( + readEnvironmentVariable) == Session.SessionMode.Disabled) + { + if (setupOptions.Fresh) + { + await output.WriteLineAsync( + "OpenClaw setup --fresh requires isolated-session provisioning. Remove --fresh or enable isolation before retrying.") + .ConfigureAwait(false); + return 1; + } + + NodeRuntime nodeRuntime = lifecycle.PrepareHostRuntime(options, log); + ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); + await output.WriteLineAsync( + "OpenClaw setup completed without isolated-session provisioning.") + .ConfigureAwait(false); + return 0; + } + + try + { + Session.SessionRuntime runtime = getSessionRuntime(); + if (setupOptions.Fresh) + { + // Resolve every required package input before removing state. + _ = lifecycle.ValidatePackageRuntime(options, runtime); + + using Session.ISessionLockHandle handle = lifecycle.AcquireLifecycleLock(runtime); + string reportPath = WriteFreshDiagnosticReport(runtime, log); + await output.WriteLineAsync($"Pre-reset diagnostic report: {reportPath}") + .ConfigureAwait(false); + Session.TeardownResult teardownResult; + try + { + teardownResult = await lifecycle.TeardownAsync( + options, runtime, log, lockAlreadyHeld: true, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + setupOptions.Force && + exception is Session.SessionException or Mxc.MxcException) + { + teardownResult = new Session.TeardownResult( + false, + "Teardown failed before external cleanup could be confirmed.", + exception.Message); + } + if (!teardownResult.Succeeded) + { + if (!setupOptions.Force) + { + await output.WriteLineAsync( + $"Warning: Fresh setup stopped because teardown is incomplete: {teardownResult.Message}") + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(teardownResult.Detail)) + { + await output.WriteLineAsync(teardownResult.Detail).ConfigureAwait(false); + } + + return 1; + } + + await output.WriteLineAsync( + $"WARNING: Forced fresh setup will continue without confirming external cleanup: {teardownResult.Message}") + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(teardownResult.Detail)) + { + await output.WriteLineAsync(teardownResult.Detail).ConfigureAwait(false); + } + } + + Session.IInstallationStateCleaner cleaner = lifecycle.CreateStateCleaner(runtime); + cleaner.Clear(); + log("Fresh setup cleared package-owned local state."); + if (!teardownResult.Succeeded) + { + const string residualWarning = + "WARNING: Forced fresh setup did not prove a pristine machine because owned external cleanup remains unresolved. " + + "Review the pre-reset report for residual sandbox or gateway identifiers. " + + "A later setup reset cannot remove resources whose ownership record was cleared; " + + "remove them through the backend's administrative cleanup path before treating this machine as pristine."; + log(residualWarning); + await output.WriteLineAsync(residualWarning).ConfigureAwait(false); + } + + return await RunSetupCoreAsync( + runtime, options, lifecycle, output, log, lockAlreadyHeld: true, cancellationToken) + .ConfigureAwait(false); + } + + return await RunSetupCoreAsync( + runtime, options, lifecycle, output, log, lockAlreadyHeld: false, cancellationToken) + .ConfigureAwait(false); + } + catch (Session.SessionException exception) + { + log($"Isolated session setup is unavailable: {exception.Message}"); + await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + catch (IOException exception) + { + log($"Fresh setup local cleanup failed: {exception.Message}"); + await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + catch (UnauthorizedAccessException exception) + { + log($"Fresh setup local cleanup was denied: {exception.Message}"); + await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") + .ConfigureAwait(false); + return 1; + } + catch (OperationCanceledException) + { + string retryCommand = setupOptions.Fresh + ? "clawctl setup --fresh" + : "clawctl setup"; + log($"Setup was cancelled before it completed. Retry `{retryCommand}`."); + await output.WriteLineAsync( + $"OpenClaw setup was cancelled; setup may be incomplete. Rerun `{retryCommand}` to retry.") + .ConfigureAwait(false); + return 1; + } + } + + internal static async Task RunSetupCoreAsync( + Session.SessionRuntime runtime, + HostOptions options, + Session.IInstallationLifecycle lifecycle, + TextWriter output, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) + { + using Session.ISessionLockHandle? handle = lockAlreadyHeld + ? null + : runtime.AcquireLifecycleLock(); + runtime.SetupState.Write(new Session.SetupRecord + { + ApplicationId = runtime.ApplicationId, + Phase = Session.SetupPhase.Preparing + }); + Session.SessionStartResult session = await runtime.Coordinator + .EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false); + IEnumerable supersededSandboxIds = (session.Record.SupersededSandboxIds ?? []) + .Append(session.Record.SupersededSandboxId) + .Append(session.SupersededRecord?.SandboxId) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Select(static id => id!) + .Where(id => !string.Equals( + id, + session.Record.SandboxId, + StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal); + foreach (string supersededSandboxId in supersededSandboxIds) + { + if (runtime.GatewayState.ClearForSupersededSession(supersededSandboxId)) + { + log($"Removed the gateway record for superseded session '{supersededSandboxId}'."); + } + } + + Session.SessionRecord record = session.Record; + string helperPath = runtime.StageHelper(record); + SessionRuntimeInstallResult agentRuntime = await runtime.Executor.InstallRuntimeAsync( + record, helperPath, GetPackagedNodeArchivePath(options), cancellationToken).ConfigureAwait(false); + await output.WriteLineAsync($"Node.js {agentRuntime.Version} is ready in the isolated agent session.") + .ConfigureAwait(false); + Gateway.GatewayPersistenceInstallResult recovery = await lifecycle + .InstallRecoveryAsync(log, cancellationToken).ConfigureAwait(false); + await output.WriteLineAsync(recovery.Message).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(recovery.Detail)) + { + await output.WriteLineAsync(recovery.Detail).ConfigureAwait(false); + } + + if (recovery.State != Gateway.GatewayPersistenceState.Ready) + { + return 1; + } + + runtime.CompleteSetup(record, agentRuntime, startupEnabled: true); + await output.WriteLineAsync("OpenClaw isolated session is ready.").ConfigureAwait(false); + return 0; + } + + private static string WriteFreshDiagnosticReport( + Session.SessionRuntime runtime, + Action log) + { + Session.SessionStatus session = runtime.Coordinator.GetRecordedStatus(); + Gateway.GatewayStateResult gateway = runtime.GatewayState.Read(); + string directory = Path.Combine(Path.GetTempPath(), "OpenClawGatewayMSIX", "fresh-reset"); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, $"pre-reset-{Guid.NewGuid():N}.log"); + List lines = + [ + $"timestampUtc={DateTimeOffset.UtcNow:O}", + $"applicationId={runtime.ApplicationId}", + "report=pre-reset diagnostic metadata; credentials and local file contents are excluded" + ]; + if (session.Record is not null) + { + lines.Add($"sessionSandboxId={session.Record.SandboxId}"); + lines.Add($"sessionAgentUserName={session.Record.AgentUserName ?? string.Empty}"); + lines.Add($"sessionAgentUserSid={session.Record.AgentUserSid ?? string.Empty}"); + } + else + { + lines.Add($"sessionRecordFault={session.Fault?.ToString() ?? "none"}"); + } + + if (gateway.Record is not null) + { + lines.Add($"gatewaySandboxId={gateway.Record.SandboxId}"); + lines.Add($"gatewayProcessId={gateway.Record.ProcessId}"); + lines.Add($"gatewayLaunchPending={gateway.Record.LaunchPending}"); + } + else + { + lines.Add($"gatewayRecordFault={gateway.Fault?.ToString() ?? "none"}"); + } + + File.WriteAllLines(path, lines); + log($"Captured redacted pre-reset diagnostic report at {path}."); + return path; + } + private static async Task RunPowerShellAsync( HostOptions options, Session.SessionRuntime runtime, diff --git a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs new file mode 100644 index 00000000..eb0aac2d --- /dev/null +++ b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs @@ -0,0 +1,120 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Composes the package-owned dependencies used while installing or resetting +/// the isolated OpenClaw session. +/// +internal interface IInstallationLifecycle +{ + SessionRuntime CreateRuntime(Action log); + + Task CheckSessionSupportAsync( + CancellationToken cancellationToken); + + NodeRuntime PrepareHostRuntime(HostOptions options, Action log); + + PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime); + + ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime); + + Task TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken); + + IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime); + + Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken); +} + +internal sealed class InstallationLifecycle : IInstallationLifecycle +{ + public static InstallationLifecycle Production { get; } = new(); + + public SessionRuntime CreateRuntime(Action log) => SessionRuntime.Create(log); + + public async Task CheckSessionSupportAsync( + CancellationToken cancellationToken) + { + MxcReadinessReport readiness = await MxcReadiness.ProbeAsync(cancellationToken) + .ConfigureAwait(false); + return SessionRoutingPolicy.Decide( + SessionMode.Automatic, + HostPaths.Create().PackageFamilyName, + readiness); + } + + public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => + NodeRuntimeInstaller.EnsureInstalled( + options.PackagedNodeArchivePath + ?? throw new FileNotFoundException("The packaged Node.js runtime archive was not found."), + log); + + public PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime) + { + string nodeArchive = options.PackagedNodeArchivePath + ?? throw new FileNotFoundException("The packaged Node.js runtime archive was not found."); + Version version = NodeRuntimeInstaller.GetArchiveVersion( + nodeArchive, + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + if (!File.Exists(runtime.HelperPath)) + { + throw new FileNotFoundException( + "The packaged session helper was not found.", + runtime.HelperPath); + } + + return new PackageRuntimeMetadata(nodeArchive, version, runtime.HelperPath); + } + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime) => + runtime.AcquireLifecycleLock(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + RunTeardownAsync(options, runtime, log, lockAlreadyHeld, cancellationToken); + + private static Task RunTeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) + { + TeardownOrchestrator teardown = + GatewayRuntime.CreateTeardownOrchestrator(options, runtime, log); + return lockAlreadyHeld + ? teardown.RunUnderLockAsync(runtime.HelperPath, force: true, cancellationToken) + : teardown.RunAsync(runtime.HelperPath, force: true, cancellationToken); + } + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) => + new InstallationStateCleaner( + [runtime.Paths.StateRoot, HostDataPaths.GetProductLocalStateRoot()]); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => + GatewayRuntime.CreateRecoveryManager(log).InstallAsync(cancellationToken); +} + +internal sealed record PackageRuntimeMetadata( + string NodeArchivePath, + Version NodeVersion, + string HelperPath); + +internal interface IInstallationStateCleaner +{ + void Clear(); +} diff --git a/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs new file mode 100644 index 00000000..220c466e --- /dev/null +++ b/src/OpenClaw.Launcher/Session/InstallationStateCleaner.cs @@ -0,0 +1,192 @@ +namespace OpenClaw.Launcher.Session; + +/// Deletes only the contents of the two package-owned state roots. +internal sealed class InstallationStateCleaner : IInstallationStateCleaner +{ + private readonly string[] _roots; + private readonly Dictionary _rootIdentities; + private readonly IInstallationFileSystem _fileSystem; + private readonly Action? _beforeTraversal; + private readonly Action? _beforeDeleteEntry; + + public InstallationStateCleaner(HostPaths paths, string productLocalStateRoot) + : this([paths.StateRoot, productLocalStateRoot], paths.PackageFamilyName) + { + } + + internal InstallationStateCleaner( + IReadOnlyList trustedRoots, + string? packageFamilyName = "test", + IInstallationFileSystem? fileSystem = null, + Action? beforeTraversal = null, + Action? beforeDeleteEntry = null) + { + ArgumentNullException.ThrowIfNull(trustedRoots); + _fileSystem = fileSystem ?? PhysicalInstallationFileSystem.Instance; + _beforeTraversal = beforeTraversal; + _beforeDeleteEntry = beforeDeleteEntry; + if (packageFamilyName is null) + { + throw new SessionException( + "OpenClaw is not running from its installed package, so --fresh is unavailable."); + } + + _roots = [.. trustedRoots.Select(root => ValidateRoot(root, _fileSystem))]; + _rootIdentities = _fileSystem is PhysicalInstallationFileSystem + ? _roots.ToDictionary( + root => root, + root => TrustedPath.TryGetDirectoryIdentity(root), + StringComparer.OrdinalIgnoreCase) + : []; + if (_roots[0].StartsWith(_roots[1] + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + _roots[1].StartsWith(_roots[0] + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException("The installation state roots overlap and are unsafe to clear."); + } + } + + public void Clear() + { + foreach (string root in _roots.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!_fileSystem.DirectoryExists(root)) + { + continue; + } + + _beforeTraversal?.Invoke(root); + if (_fileSystem is PhysicalInstallationFileSystem) + { + using TrustedPath.ValidatedDirectory? directory = + TrustedPath.TryOpenValidatedDirectory(root, _rootIdentities[root]); + if (directory is null) + { + if (_fileSystem.DirectoryExists(root)) + { + throw new SessionException( + $"The installation state root could not be opened safely: {root}"); + } + + continue; + } + + foreach (string entry in _fileSystem.EnumerateFileSystemEntries(root)) + { + _beforeDeleteEntry?.Invoke(entry); + if (!TrustedPath.TryDeleteOwnedEntry( + directory, + entry, + deleteReparsePointLeaf: true) && + EntryExists(entry)) + { + throw new SessionException( + $"The installation state entry could not be deleted: {entry}"); + } + } + + continue; + } + + if (!_fileSystem.DirectoryExists(root) || + (_fileSystem.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + foreach (string entry in _fileSystem.EnumerateFileSystemEntries(root)) + { + DeleteEntry(entry); + } + } + } + + private static string ValidateRoot(string root, IInstallationFileSystem fileSystem) + { + string fullRoot = Path.GetFullPath(root); + string? parent = Directory.GetParent(fullRoot)?.FullName; + if (parent is null || string.Equals(fullRoot, parent, StringComparison.OrdinalIgnoreCase)) + { + throw new SessionException("The installation state root is unsafe to clear."); + } + + for (DirectoryInfo? current = new DirectoryInfo(fullRoot); + current is not null; + current = current.Parent) + { + if (fileSystem.DirectoryExists(current.FullName) && + (fileSystem.GetAttributes(current.FullName) & FileAttributes.ReparsePoint) != 0) + { + throw new SessionException( + "The installation state root has a reparse-point ancestor and cannot be cleared."); + } + } + + return fullRoot; + } + + private void DeleteEntry(string path) + { + FileAttributes attributes; + try + { + attributes = _fileSystem.GetAttributes(path); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + return; + } + + _beforeDeleteEntry?.Invoke(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + if ((attributes & FileAttributes.Directory) != 0) + { + _fileSystem.DeleteDirectory(path); + } + else + { + _fileSystem.DeleteFile(path); + } + + return; + } + + if ((attributes & FileAttributes.Directory) == 0) + { + _fileSystem.DeleteFile(path); + return; + } + + foreach (string child in _fileSystem.EnumerateFileSystemEntries(path)) + { + DeleteEntry(child); + } + + _fileSystem.DeleteDirectory(path); + } + + private static bool EntryExists(string path) => + File.Exists(path) || Directory.Exists(path); +} + +internal interface IInstallationFileSystem +{ + bool DirectoryExists(string path); + IEnumerable EnumerateFileSystemEntries(string path); + FileAttributes GetAttributes(string path); + void DeleteFile(string path); + void DeleteDirectory(string path); +} + +internal sealed class PhysicalInstallationFileSystem : IInstallationFileSystem +{ + public static PhysicalInstallationFileSystem Instance { get; } = new(); + + public bool DirectoryExists(string path) => Directory.Exists(path); + public IEnumerable EnumerateFileSystemEntries(string path) => + Directory.EnumerateFileSystemEntries(path); + public FileAttributes GetAttributes(string path) => File.GetAttributes(path); + public void DeleteFile(string path) => File.Delete(path); + public void DeleteDirectory(string path) => Directory.Delete(path); +} diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 598b9994..295697d4 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -28,6 +28,7 @@ private SessionRuntime( IMxcSessionClient backend, string helperPath, string applicationId, + HostPaths paths, SetupStateStore setupState, GatewayStateStore gatewayState, string lifecycleLockScope) @@ -37,6 +38,7 @@ private SessionRuntime( Backend = backend; HelperPath = helperPath; ApplicationId = applicationId; + Paths = paths; SetupState = setupState; GatewayState = gatewayState; LifecycleLock = new NamedSessionLock(lifecycleLockScope); @@ -56,6 +58,8 @@ private SessionRuntime( public string ApplicationId { get; } + public HostPaths Paths { get; } + public SetupStateStore SetupState { get; } public GatewayStateStore GatewayState { get; } @@ -138,6 +142,7 @@ internal static SessionRuntime Create( client, ResolveHelperPath(baseDirectory), applicationId, + paths, new SetupStateStore(paths.SetupStatePath), new GatewayStateStore(paths.GatewayStatePath), paths.SessionStatePath + "_Installation"); @@ -197,17 +202,56 @@ session.Detail is null return session.Record; } + /// + /// Validates local ownership before automatic host fallback. + /// + /// + /// Automatic fallback is allowed only when this installation has no saved + /// session or setup state. A foreign, corrupt, or mismatched record is + /// evidence that replacement or recovery is required, not permission to + /// run the user's workload under a different profile. A session record + /// without its setup marker must be completed with clawctl setup. + /// public void ValidateSavedOwnershipForHostFallback() { - SetupStateResult setup = SetupState.Read(ApplicationId); SessionStatus session = Coordinator.GetRecordedStatus(); - if (setup.Fault == SetupStateFault.Missing && - session.Availability == SessionAvailability.None) + SetupStateResult setup = SetupState.Read(ApplicationId); + if (session.Availability == SessionAvailability.None && + setup.Fault == SetupStateFault.Missing) { return; } - RequireSetup(); + if (session.Record is null) + { + throw new SessionException( + $"{session.Detail ?? "The saved isolated-session record could not be used."} " + + "Run `clawctl setup` to repair it."); + } + + if (setup.Record is null) + { + throw new SessionException( + "The isolated session is recorded but explicit setup has not completed. " + + "Run `clawctl setup` before using automatic host fallback."); + } + + if (setup.Record.Phase != SetupPhase.Ready) + { + throw new SessionException( + "The saved isolated-session setup is incomplete. " + + "Run `clawctl setup` before using automatic host fallback."); + } + + if (!string.Equals( + setup.Record.SandboxId, + session.Record.SandboxId, + StringComparison.Ordinal)) + { + throw new SessionException( + "The saved isolated-session records name different sessions. " + + "Run `clawctl setup` to reconcile them."); + } } /// diff --git a/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs index fa07dcc3..7484e86f 100644 --- a/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs +++ b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs @@ -38,7 +38,15 @@ public async Task RunAsync( { using ISessionLockHandle handle = _lock.TryAcquire(SessionCoordinator.DefaultLockTimeout) ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); + return await RunUnderLockAsync(helperPath, force, cancellationToken).ConfigureAwait(false); + } + /// Runs teardown while the caller owns the installation lifecycle lock. + public async Task RunUnderLockAsync( + string helperPath, + bool force, + CancellationToken cancellationToken) + { GatewayPersistenceRemovalResult recovery = await _recovery.UninstallAsync(cancellationToken) .ConfigureAwait(false); if (!recovery.Succeeded) diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index d4d51611..5de0b177 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -38,6 +38,7 @@ private static async Task Main() ("--version reports the launcher", VersionReportsLauncherAssemblyAsync), ("--version wins over trailing arguments", VersionWinsOverTrailingAsync), ("unknown command fails", UnknownCommandFailsAsync), + ("setup --force requires --fresh", SetupForceRequiresFreshAsync), ("response-file token is not expanded", ResponseFileTokenIsNotExpandedAsync), ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), @@ -177,6 +178,17 @@ private static async Task UnknownCommandFailsAsync() fixture.AssertNodeWasNotResolved(); } + private static async Task SetupForceRequiresFreshAsync() + { + using Fixture fixture = Fixture.CreateWithoutApplication(); + + int exitCode = await fixture.RunAsync(["setup", "--force"]).ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + AssertContains(fixture.Error.ToString(), "requires option '--fresh'", fixture); + fixture.AssertNodeWasNotResolved(); + } + // Response-file expansion is disabled, so a readable file behind an `@` // token must still be rejected as an unrecognized argument. private static async Task ResponseFileTokenIsNotExpandedAsync() diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index d5f1632d..5110948f 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -132,6 +132,60 @@ public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() Normalize(ClawCtlCommandLine.SetupDescription), help, StringComparison.Ordinal); + Assert.Contains("--fresh", help, StringComparison.Ordinal); + Assert.Contains("Requires --fresh", help, StringComparison.Ordinal); + } + + [Fact] + public async Task SetupFreshPassesTheExplicitDestructiveAuthorization() + { + SetupOptions? received = null; + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (options, _) => + { + received = options; + return Task.FromResult(0); + }, + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }); + + int exitCode = await root.Parse("setup --fresh").InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(new SetupOptions(Fresh: true, Force: false), received); + } + + [Fact] + public async Task SetupFreshForcePassesTheExplicitRecoveryOverride() + { + SetupOptions? received = null; + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (options, _) => + { + received = options; + return Task.FromResult(0); + }, + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }); + + int exitCode = await root.Parse("setup --fresh --force").InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.Equal(new SetupOptions(Fresh: true, Force: true), received); } [Fact] @@ -191,6 +245,7 @@ public async Task VersionWinsOverTrailingArguments() [InlineData("gateway-service")] [InlineData("setup", "extra")] [InlineData("setup", "--bogus")] + [InlineData("setup", "--force")] public async Task RejectedInputFailsWithoutStartingSetup(params string[] args) { (int exitCode, string output, string error) = await RunAsync(args).ConfigureAwait(true); diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs new file mode 100644 index 00000000..a30b3bd0 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControlOutputTests.cs @@ -0,0 +1,92 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Tests.Gateway; + +public sealed class GatewayControlOutputTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task StatusIncludesDetailAndSanitizedBoundedLogTailForAStoppedGateway() + { + string workspace = Path.Combine(_root, "workspace"); + string logPath = Path.Combine(workspace, "gateway.log"); + Directory.CreateDirectory(workspace); + string[] lines = + [ + .. Enumerable.Range(1, 12).Select(index => + index == 12 ? "\u001b[31mfinal\u0001 line\u001b[0m" : $"line {index}") + ]; + await File.WriteAllLinesAsync(logPath, lines).ConfigureAwait(true); + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStatusAsync( + output, + new GatewayStatusReport( + GatewayState.Stopped, + new GatewayRecord { LogPath = logPath }, + "The gateway is not running.", + "the application exited with code 78"), + workspace, + CancellationToken.None).ConfigureAwait(true); + + string rendered = output.ToString(); + Assert.Contains("The gateway is not running.", rendered, StringComparison.Ordinal); + Assert.Contains("the application exited with code 78", rendered, StringComparison.Ordinal); + Assert.Contains($"Gateway log tail ({logPath}):", rendered, StringComparison.Ordinal); + Assert.DoesNotContain($"line 1{Environment.NewLine}", rendered, StringComparison.Ordinal); + Assert.Contains("line 3", rendered, StringComparison.Ordinal); + Assert.Contains("final line", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("31m", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("0m", rendered, StringComparison.Ordinal); + Assert.DoesNotContain('\u001b', rendered); + Assert.DoesNotContain('\u0001', rendered); + } + + [Fact] + public async Task StatusKeepsFailureOutputWhenTheGuestLogIsUnavailable() + { + string workspace = Path.Combine(_root, "workspace"); + string logPath = Path.Combine(workspace, "gateway.log"); + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStatusAsync( + output, + new GatewayStatusReport( + GatewayState.Unhealthy, + new GatewayRecord { LogPath = logPath }, + "The gateway is not serving.", + "Inspect diagnostics before retrying."), + workspace, + CancellationToken.None).ConfigureAwait(true); + + Assert.Equal( + $"The gateway is not serving.{Environment.NewLine}Inspect diagnostics before retrying.{Environment.NewLine}" + + $"Gateway log unavailable: {logPath}{Environment.NewLine}", + output.ToString()); + } + + [Fact] + public async Task StopIncludesTheUnconfirmedLaunchRecoveryInstruction() + { + using var output = new StringWriter(); + + await GatewayControlOutput.WriteStopAsync( + output, + new GatewayStopResult( + Stopped: false, + Message: "The gateway was not stopped.", + Detail: "Do not start a replacement. Run `clawctl teardown` to recover.")) + .ConfigureAwait(true); + + Assert.Contains("Do not start a replacement. Run `clawctl teardown` to recover.", output.ToString(), StringComparison.Ordinal); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs index 5f28c81c..cd7291c4 100644 --- a/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs @@ -47,6 +47,20 @@ public void ConcurrentInvocationsSerializeCompleteLogRecords() Assert.All(lines, line => Assert.Contains(" invocation ", line, StringComparison.Ordinal)); } + [Fact] + public void WriteRecreatesItsDirectoryAfterInstallationCleanup() + { + string directory = Path.Combine(_testDirectory, "logs"); + string path = Path.Combine(directory, "host.log"); + using HostDiagnosticLog log = HostDiagnosticLog.Create(path); + log.Write("Before cleanup."); + Directory.Delete(directory, recursive: true); + + log.Write("After cleanup."); + + Assert.Contains("After cleanup.", File.ReadAllText(path), StringComparison.Ordinal); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index 76a10ca8..3141b942 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -10,6 +10,12 @@ public sealed class ProgramStartupTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); + [Fact] + public void ProductionStartupProvidesTheLifecycleFactoryForFallbackValidation() + { + Assert.NotNull(HostStartup.CreateProduction().InstallationLifecycle); + } + [Fact] public async Task StartupWritesRecordsToTheSuppliedDiagnosticLog() { diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 63a92dad..58ae4219 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1,5 +1,5 @@ -using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Session; using OpenClaw.Launcher.Tests.Session; using OpenClaw.SessionProtocol; @@ -10,7 +10,6 @@ public sealed class ProgramTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); private FakeMxcSessionClient? _lastSessionBackend; - private string? _sessionStatePath; [Fact] public async Task AgentLaunchResolvesNodeAndRunsPackagedApplication() @@ -61,22 +60,19 @@ await File.WriteAllTextAsync( } [Fact] - public async Task SetupPreparesNodeAndChecksPackagedApplication() + public async Task SetupReportsAnUnavailableIsolatedSessionWithoutResolvingHostNode() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); await File.WriteAllTextAsync(entryPoint, "console.log('fixture');"); - string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); - await File.WriteAllTextAsync(archivePath, "fixture"); DateTime lastWriteTime = File.GetLastWriteTimeUtc(entryPoint); - var options = new HostOptions(applicationDirectory, archivePath, []); + var options = new HostOptions(applicationDirectory, null, []); var nodeRuntime = new NodeRuntime( Path.Combine(_testDirectory, "node.exe"), new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); using var output = new StringWriter(); - SessionRuntime runtime = CreateSessionRuntime(); int exitCode = await Program.RunControlAsync( options, @@ -84,13 +80,9 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() _ => { }, output, TextWriter.Null, - _ => Task.FromResult(nodeRuntime), - _ => runtime, - installRecovery: RecoveryConfigured, - probeReadiness: SupportedHost, - getPackageFamilyName: () => "OpenClaw.Gateway_test"); + _ => Task.FromResult(nodeRuntime)); - Assert.Equal(0, exitCode); + Assert.Equal(1, exitCode); Assert.True(File.Exists(entryPoint)); Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(entryPoint)); Assert.DoesNotContain( @@ -101,34 +93,19 @@ public async Task SetupPreparesNodeAndChecksPackagedApplication() applicationDirectory, output.ToString(), StringComparison.Ordinal); - SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; - Assert.Equal(SetupPhase.Ready, setup.Phase); - Assert.True(setup.StartupEnabled); - Assert.Equal("24.15.0", setup.AgentNodeVersion); Assert.Contains( - _lastSessionBackend!.Calls, - call => call.StartsWith("execute:", StringComparison.Ordinal)); - Assert.DoesNotContain( - _lastSessionBackend.Calls, - call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); + "requires isolated-session support", + output.ToString(), + StringComparison.OrdinalIgnoreCase); } [Fact] public async Task AgentUsesTheRuntimeInstalledForTheSessionWithoutHostFallback() { - string applicationDirectory = Path.Combine(_testDirectory, "app"); - Directory.CreateDirectory(applicationDirectory); - await File.WriteAllTextAsync( - Path.Combine(applicationDirectory, "openclaw.mjs"), - "console.log('fixture');"); - string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); - await File.WriteAllTextAsync(archivePath, "fixture"); - var options = new HostOptions(applicationDirectory, archivePath, ["gateway"]); - var hostNode = new NodeRuntime( - Path.Combine(_testDirectory, "host-node.exe"), - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions options = CreateSetupOptions(applicationDirectory); SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }; int setupExitCode = await Program.RunControlAsync( options, @@ -136,13 +113,7 @@ await File.WriteAllTextAsync( _ => { }, TextWriter.Null, TextWriter.Null, - _ => Task.FromResult(hostNode), - _ => runtime, - // Setup only reaches Ready once logon recovery is configured, and - // a test must never register a real scheduled task. - installRecovery: RecoveryConfigured, - probeReadiness: SupportedHost, - getPackageFamilyName: () => "OpenClaw.Gateway_test"); + installationLifecycle: lifecycle); Assert.Equal(0, setupExitCode); string expectedAgentNode = runtime.SetupState @@ -188,14 +159,153 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( Assert.False(directLaunchAttempted); } + [Theory] + [InlineData("corrupt")] + [InlineData("foreign")] + [InlineData("mismatched")] + public async Task AutomaticHostFallbackRefusesSavedOwnershipFailures( + string stateKind) + { + SessionRuntime runtime = CreateSessionRuntime(); + string applicationDirectory = Path.Combine(_testDirectory, "fallback-app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "fixture").ConfigureAwait(true); + Directory.CreateDirectory(Path.GetDirectoryName(runtime.Paths.SessionStatePath)!); + SessionRecord session = new() + { + ApplicationId = runtime.ApplicationId, + SandboxId = "iso:saved", + WorkspacePath = Path.Combine(_testDirectory, "workspace"), + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }; + Directory.CreateDirectory(session.WorkspacePath!); + + switch (stateKind) + { + case "corrupt": + await File.WriteAllTextAsync( + runtime.Paths.SessionStatePath, + "{ not json").ConfigureAwait(true); + break; + case "foreign": + await File.WriteAllTextAsync( + runtime.Paths.SessionStatePath, + """ + {"schemaVersion":1,"sandboxId":"iso:foreign","applicationId":"PFN:Other","createdUtc":"2026-01-01T00:00:00Z"} + """).ConfigureAwait(true); + break; + default: + new SessionStateStore(runtime.Paths.SessionStatePath).Write(session); + runtime.SetupState.Write(new SetupRecord + { + ApplicationId = runtime.ApplicationId, + SandboxId = "iso:different", + Phase = SetupPhase.Ready + }); + break; + } + + bool hostPrepared = false; + bool hostLaunched = false; + + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, ["--version"]), + _ => { }, + _ => + { + hostPrepared = true; + return Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); + }, + (_, _, _, _, _, _) => + { + hostLaunched = true; + return Task.FromResult(0); + }, + _ => runtime, + _ => Task.FromResult(new MxcReadinessReport( + null, + null, + "backend unavailable", + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + () => runtime.Paths.PackageFamilyName, + _ => null)); + + Assert.False(hostPrepared); + Assert.False(hostLaunched); + } + + [Fact] + public async Task AutomaticHostFallbackDoesNotCreateASessionRuntimeWithoutPackageIdentity() + { + string applicationDirectory = Path.Combine(_testDirectory, "unpackaged-app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "fixture").ConfigureAwait(true); + bool runtimeCreated = false; + + int exitCode = await Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, ["--version"]), + _ => { }, + _ => Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), + (_, _, _, _, _, _) => Task.FromResult(17), + createSessionRuntime: _ => + { + runtimeCreated = true; + return CreateSessionRuntime(); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + null, + null, + "backend unavailable", + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => null, + readEnvironmentVariable: _ => null); + + Assert.False(runtimeCreated); + Assert.Equal(17, exitCode); + } + + [Fact] + public void SavedSessionWithoutSetupExplainsTheRequiredRecoveryCommand() + { + SessionRuntime runtime = CreateSessionRuntime(); + new SessionStateStore(runtime.Paths.SessionStatePath).Write(new SessionRecord + { + ApplicationId = runtime.ApplicationId, + SandboxId = "iso:saved", + WorkspacePath = Path.Combine(_testDirectory, "workspace"), + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }); + + SessionException exception = Assert.Throws( + runtime.ValidateSavedOwnershipForHostFallback); + + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); + } + [Fact] public async Task AgentRefusesForeignSessionRecordWithoutHostFallback() { SessionRuntime runtime = await SetUpSessionAsync(); var directLaunches = new List(); - SessionRecord record = new SessionStateStore(_sessionStatePath!) + SessionRecord record = new SessionStateStore(runtime.Paths.SessionStatePath) .Read(runtime.ApplicationId).Record!; - new SessionStateStore(_sessionStatePath!).Write(record with + new SessionStateStore(runtime.Paths.SessionStatePath).Write(record with { SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") }); @@ -212,9 +322,9 @@ public async Task AutomaticDirectRoutingValidatesExistingOwnershipFirst() { SessionRuntime runtime = await SetUpSessionAsync(); var directLaunches = new List(); - SessionRecord record = new SessionStateStore(_sessionStatePath!) + SessionRecord record = new SessionStateStore(runtime.Paths.SessionStatePath) .Read(runtime.ApplicationId).Record!; - new SessionStateStore(_sessionStatePath!).Write(record with + new SessionStateStore(runtime.Paths.SessionStatePath).Write(record with { SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") }); @@ -257,7 +367,7 @@ public async Task AgentRefusesUnreadableSessionRecordWithoutHostFallback() { SessionRuntime runtime = await SetUpSessionAsync(); var directLaunches = new List(); - await File.WriteAllTextAsync(_sessionStatePath!, "{ not json"); + await File.WriteAllTextAsync(runtime.Paths.SessionStatePath, "{ not json"); SessionException failure = await Assert.ThrowsAsync( () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); @@ -308,17 +418,405 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() _ => { }, TextWriter.Null, error, - createSessionRuntime: _ => runtime); + installationLifecycle: new FailingFreshLifecycle(runtime)); Assert.Equal(1, exitCode); Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("--no-isolation", null)] + [InlineData(null, "0")] + public async Task FreshSetupRejectsDisabledIsolationBeforePreparingTheHostRuntime( + string? option, + string? sessionMode) + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + var lifecycle = new FailingFreshLifecycle(CreateSessionRuntime()); + using var output = new StringWriter(); + string[] arguments = option is null + ? ["setup", "--fresh"] + : ["setup", "--fresh", option]; + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + arguments, + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle, + readEnvironmentVariable: name => + name == SessionRoutingPolicy.ModeVariable ? sessionMode : null); + + Assert.Equal(1, exitCode); + Assert.Empty(lifecycle.Calls); + Assert.Contains("--fresh requires isolated-session provisioning", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task FreshSetupStopsBeforeClearingStateWhenTeardownFails() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + var lifecycle = new FailingFreshLifecycle(CreateSessionRuntime()); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session"], lifecycle.Calls); + Assert.False(lifecycle.Cleaner.Cleared); + Assert.Contains("Pre-reset diagnostic report:", output.ToString(), StringComparison.Ordinal); + Assert.Contains("teardown is incomplete", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task FreshSetupResetsInOrderBeforeProvisioningAndRecordsConsistentNewState() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + Assert.Equal( + ["validate", "lock", "recovery", "gateway", "session", "clean"], + lifecycle.Calls); + Assert.Equal(SetupPhase.Ready, runtime.SetupState.Read(runtime.ApplicationId).Record!.Phase); + Assert.NotNull(runtime.Coordinator.GetRecordedStatus().Record); + Assert.Contains( + ((FakeMxcSessionClient)runtime.Backend).Calls, + call => call.StartsWith("execute:", StringComparison.Ordinal)); Assert.DoesNotContain( - _lastSessionBackend!.Calls, - call => call.StartsWith("deprovision:", StringComparison.Ordinal)); + ((FakeMxcSessionClient)runtime.Backend).Calls, + call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); + Assert.Contains("OpenClaw isolated session is ready.", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task FreshSetupCleanerFailurePreventsProvisionAndReady() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + var lifecycle = new FailingFreshLifecycle(CreateSessionRuntime()) + { + TeardownSucceeds = true, + CleanerException = new IOException("state file is locked") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, output, TextWriter.Null, installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session", "clean"], lifecycle.Calls); + Assert.DoesNotContain( + "OpenClaw isolated session is ready.", + output.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public async Task ForcedFreshSetupContinuesAfterUnresolvedOwnedCleanupAndKeepsTheWarning() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult( + Succeeded: false, + Message: "MXC backend is unavailable.", + Detail: "The owned sandbox record could not be verified.") + }; + List diagnostics = []; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + diagnostics.Add, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session", "clean"], lifecycle.Calls); + Assert.True(lifecycle.Cleaner.Cleared); + Assert.Equal(SetupPhase.Ready, runtime.SetupState.Read(runtime.ApplicationId).Record!.Phase); + Assert.Contains( + "did not prove a pristine machine", + output.ToString(), + StringComparison.Ordinal); + Assert.Contains( + diagnostics, + text => text.Contains("did not prove a pristine machine", StringComparison.Ordinal)); + } + + [Fact] + public async Task ForcedFreshSetupContinuesWhenTeardownThrowsARecoverableFailure() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownException = new SessionStateException( + SessionStateFault.Unreadable, + "session.json cannot be read") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + Assert.True(lifecycle.Cleaner.Cleared); + Assert.Contains( + "session.json cannot be read", + output.ToString(), + StringComparison.Ordinal); + Assert.Contains( + "cannot remove resources whose ownership record was cleared", + output.ToString(), + StringComparison.Ordinal); } [Fact] - public async Task SetupReportsMissingApplicationBeforeResolvingNode() + public async Task FreshResetReportPreservesReadableResidualIdentityOutsideClearedState() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + const string oldSandboxId = "iso:residual-session"; + new SessionStateStore(runtime.Paths.SessionStatePath).Write(new SessionRecord + { + SchemaVersion = SessionStateStore.CurrentSchemaVersion, + SandboxId = oldSandboxId, + ApplicationId = runtime.ApplicationId, + AgentUserName = "agent_old", + AgentUserSid = "S-1-5-21-0-0-0-1010", + WorkspacePath = Path.Combine(_testDirectory, "old-workspace"), + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Backend unavailable.") + }; + lifecycle.Cleaner.Cleanup = () => File.Delete(runtime.Paths.SessionStatePath); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + string reportLine = output.ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Single(line => line.StartsWith("Pre-reset diagnostic report:", StringComparison.Ordinal)); + string reportPath = reportLine["Pre-reset diagnostic report:".Length..].Trim(); + try + { + string report = await File.ReadAllTextAsync(reportPath); + Assert.Contains($"sessionSandboxId={oldSandboxId}", report, StringComparison.Ordinal); + } + finally + { + File.Delete(reportPath); + } + } + + [Fact] + public async Task ForcedFreshSetupRecreatesDiagnosticsWithTheResidualWarning() + { + SessionRuntime runtime = CreateSessionRuntime(); + string baseDirectory = Path.Combine(_testDirectory, "base"); + string applicationDirectory = Path.Combine(baseDirectory, "app"); + string runtimeDirectory = Path.Combine(baseDirectory, "runtime"); + Directory.CreateDirectory(applicationDirectory); + Directory.CreateDirectory(runtimeDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); + await File.WriteAllTextAsync( + Path.Combine(runtimeDirectory, "node-v24.20.0-win-x64.zip"), + "fixture"); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Owned backend cleanup remains unresolved.") + }; + lifecycle.Cleaner.Cleanup = () => Directory.Delete(runtime.Paths.StateRoot, recursive: true); + string logPath = Path.Combine(runtime.Paths.StateRoot, "Logs", "openclaw.log"); + using var diagnostics = HostDiagnosticLog.Create(logPath); + diagnostics.Write("Host started through the clawctl entrypoint."); + using var output = new StringWriter(); + HostStartup startup = new() + { + Entrypoint = HostEntrypoint.Control, + CreateDiagnostics = () => diagnostics, + BaseDirectory = baseDirectory, + Output = output, + Error = TextWriter.Null, + InstallationLifecycle = lifecycle + }; + + int exitCode = await Program.RunAsync(["setup", "--fresh", "--force"], startup); + + Assert.True(exitCode == 0, output.ToString()); + string diagnosticsText = await File.ReadAllTextAsync(logPath); + Assert.Contains("did not prove a pristine machine", diagnosticsText, StringComparison.Ordinal); + Assert.DoesNotContain("Host started through", diagnosticsText, StringComparison.Ordinal); + } + + [Fact] + public async Task ForcedFreshSetupStillStopsWhenLocalCleanupFails() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Owned backend cleanup could not be confirmed."), + CleanerException = new UnauthorizedAccessException("state root is denied") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal(["validate", "lock", "recovery", "gateway", "session", "clean"], lifecycle.Calls); + Assert.Null(runtime.SetupState.Read(runtime.ApplicationId).Record); + Assert.DoesNotContain("isolated session is ready", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ForcedFreshSetupCancellationDoesNotClearOrProvision() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownException = new OperationCanceledException() + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.False(lifecycle.Cleaner.Cleared); + Assert.Null(runtime.SetupState.Read(runtime.ApplicationId).Record); + Assert.Contains("cancelled", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task FreshSetupProvisionFailureDoesNotClaimReady() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + ((FakeMxcSessionClient)runtime.Backend).ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles( + ((FakeMxcSessionClient)runtime.Backend).Metadata!.EphemeralWorkspacePath, + "runtime-*.json").Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + Error = "installer failed" + })); + return Task.FromResult(new MxcExecutionResult(1, string.Empty, string.Empty)); + }; + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownSucceeds = true + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, output, TextWriter.Null, installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Equal( + ["validate", "lock", "recovery", "gateway", "session", "clean"], + lifecycle.Calls); + Assert.DoesNotContain("OpenClaw isolated session is ready.", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task ConcurrentFreshSetupReportsBusyWithoutStartingAnotherReset() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + FakeMxcSessionClient backend = (FakeMxcSessionClient)runtime.Backend; + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownSucceeds = true, + }; + backend.ExecuteBehavior = async _ => + { + lifecycle.ProvisionStarted.TrySetResult(); + await lifecycle.AllowProvision.Task.ConfigureAwait(false); + WriteRuntimeInstallResult(backend, 0); + return new MxcExecutionResult(0, string.Empty, string.Empty); + }; + var firstOutput = new StringWriter(); + Task first = Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, firstOutput, TextWriter.Null, installationLifecycle: lifecycle); + await lifecycle.ProvisionStarted.Task + .WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(true); + using var secondOutput = new StringWriter(); + + int second = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh"], _ => { }, secondOutput, TextWriter.Null, installationLifecycle: lifecycle); + lifecycle.AllowProvision.TrySetResult(); + + Assert.Equal(0, await first.ConfigureAwait(true)); + firstOutput.Dispose(); + Assert.Equal(1, second); + Assert.Equal(1, lifecycle.TeardownCount); + Assert.Contains("Another OpenClaw process", secondOutput.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task SetupReportsMissingApplicationBeforeResolvingHostNode() { bool nodeResolutionAttempted = false; @@ -344,128 +842,141 @@ await Assert.ThrowsAsync( } [Fact] - public async Task NoIsolationSetupPreparesTheHostRuntimeOnASupportedMachine() + public async Task AgentReportsMissingApplicationBeforeResolvingHostNode() + { + bool nodeResolutionAttempted = false; + + await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(null, null, []), + _ => { }, + _ => + { + nodeResolutionAttempted = true; + return Task.FromResult( + new NodeRuntime( + "node.exe", + new Version(24, 15, 0), + System.Runtime.InteropServices.RuntimeInformation + .ProcessArchitecture)); + })); + + Assert.False(nodeResolutionAttempted); + } + + [Fact] + public async Task AutomaticAgentLaunchUsesHostOnlyWhenReadinessReportsIsolationUnsupported() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); - await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); - bool resolvedNode = false; - using var output = new StringWriter(); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool resolvedHostNode = false; + bool launchedHost = false; - int exitCode = await Program.RunControlAsync( - new HostOptions(applicationDirectory, "node.zip", []), - ["setup", "--no-isolation"], + int exitCode = await Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), _ => { }, - output, - TextWriter.Null, _ => { - resolvedNode = true; + resolvedHostNode = true; return Task.FromResult(new NodeRuntime( "node.exe", - new Version(24, 20, 0), + new Version(24, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); }, + (_, _, _, _, _, _) => + { + launchedHost = true; + return Task.FromResult(17); + }, probeReadiness: _ => Task.FromResult(new MxcReadinessReport( "runtime", null, null, - MxcHostSupport.Supported, + MxcHostSupport.Unsupported, null, MxcSupportEvidence.HostBuild)), - getPackageFamilyName: () => "OpenClaw.Gateway_test"); + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null, + createSessionRuntime: _ => CreateSessionRuntime()); - Assert.Equal(0, exitCode); - Assert.True(resolvedNode); - Assert.Contains("without isolated-session", output.ToString(), StringComparison.Ordinal); + Assert.Equal(17, exitCode); + Assert.True(resolvedHostNode); + Assert.True(launchedHost); } [Fact] - public async Task NoIsolationSetupStillFailsOnAnUnsupportedMachine() + public async Task RequiredAgentLaunchFailsWhenReadinessReportsIsolationUnsupported() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); - await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture"); - bool resolvedNode = false; + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool resolvedHostNode = false; - int exitCode = await Program.RunControlAsync( - new HostOptions(applicationDirectory, "node.zip", []), - ["setup", "--no-isolation"], + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), _ => { }, - TextWriter.Null, - TextWriter.Null, _ => { - resolvedNode = true; + resolvedHostNode = true; return Task.FromResult(new NodeRuntime( "node.exe", - new Version(24, 20, 0), + new Version(24, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); }, + (_, _, _, _, _, _) => Task.FromResult(0), probeReadiness: _ => Task.FromResult(new MxcReadinessReport( "runtime", null, null, MxcHostSupport.Unsupported, null, - MxcSupportEvidence.HostBuild, - null, - "Windows build does not support sessions.")), - getPackageFamilyName: () => "OpenClaw.Gateway_test"); - - Assert.Equal(1, exitCode); - Assert.False(resolvedNode); - } - - [Fact] - public async Task TeardownClearsPendingGatewayStateAfterSessionRemoval() - { - SessionRuntime runtime = CreateSessionRuntime(); - runtime.GatewayState.Write(new GatewayRecord - { - SchemaVersion = GatewayStateStore.CurrentSchemaVersion, - SandboxId = "iso:pending", - LaunchPending = true, - ProcessStartTimeUtc = DateTimeOffset.UtcNow - }); - - int exitCode = await Program.RunControlAsync( - new HostOptions(null, null, []), - ["teardown", "--force"], - _ => { }, - TextWriter.Null, - TextWriter.Null, - createSessionRuntime: _ => runtime, - createTeardownOrchestrator: CreateTeardownOrchestrator); + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: name => + name == SessionRoutingPolicy.ModeVariable ? "1" : null)); - Assert.Equal(0, exitCode); - GatewayStateResult state = runtime.GatewayState.Read(); - Assert.Equal(GatewayStateFault.Missing, state.Fault); + Assert.False(resolvedHostNode); } - // Preparing a runtime for an application that is not there wastes an - // extraction; the missing application is reported first. [Fact] - public async Task AgentReportsMissingApplicationBeforeResolvingHostNode() + public async Task SelectedSessionFailureDoesNotResolveOrLaunchHostNode() { - bool nodeResolutionAttempted = false; + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool resolvedHostNode = false; + bool launchedHost = false; - await Assert.ThrowsAsync( - () => Program.RunAgentAsync( - new HostOptions(null, null, []), + await Assert.ThrowsAsync(() => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), _ => { }, _ => { - nodeResolutionAttempted = true; - return Task.FromResult( - new NodeRuntime( - "node.exe", - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); - })); + resolvedHostNode = true; + return Task.FromResult(new NodeRuntime( + "node.exe", + new Version(24, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); + }, + (_, _, _, _, _, _) => + { + launchedHost = true; + return Task.FromResult(0); + }, + _ => throw new SessionException("setup failed"), + _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)), + () => "OpenClaw.Gateway_test", + _ => null)); - Assert.False(nodeResolutionAttempted); + Assert.False(resolvedHostNode); + Assert.False(launchedHost); } public void Dispose() @@ -536,8 +1047,8 @@ private SessionRuntime CreateSessionRuntime() { string stateRoot = Path.Combine(_testDirectory, "state"); string baseDirectory = Path.Combine(_testDirectory, "base"); - string workspace = Path.Combine(_testDirectory, "workspace"); Directory.CreateDirectory(baseDirectory); + string workspace = Path.Combine(_testDirectory, "workspace"); Directory.CreateDirectory(workspace); string helperPath = SessionRuntime.ResolveHelperPath(baseDirectory); Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); @@ -551,33 +1062,13 @@ private SessionRuntime CreateSessionRuntime() }; backend.ExecuteBehavior = _ => { - string requestPath = Directory.GetFiles(workspace, "runtime-*.json").Single(); - SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( - File.ReadAllText(requestPath)); - File.WriteAllText( - SessionLaunchProtocol.ResultPathFor(requestPath), - SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult - { - RequestId = request.RequestId, - ExecutablePath = Path.Combine( - workspace, - "AppData", - "Local", - "OpenClaw", - "NodeJS", - "node-v24.15.0-win-x64", - "node.exe"), - Version = "24.15.0", - ArchiveName = "node-v24.15.0-win-x64.zip" - })); + WriteRuntimeInstallResult(backend, 0); return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); }; _lastSessionBackend = backend; - HostPaths paths = HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"); - _sessionStatePath = paths.SessionStatePath; return SessionRuntime.Create( - paths, - () => throw new InvalidOperationException("The test supplies its backend."), + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + () => throw new InvalidOperationException("The test backend must be supplied."), baseDirectory, _ => { }, backend); @@ -603,17 +1094,60 @@ await File.WriteAllTextAsync( "node.exe", new Version(24, 15, 0), System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), - createSessionRuntime: _ => runtime, - // Setup only reaches Ready once logon recovery is configured, and - // a test must never register a real scheduled task. - installRecovery: RecoveryConfigured, - probeReadiness: SupportedHost, - getPackageFamilyName: () => "OpenClaw.Gateway_test").ConfigureAwait(false); + new StubbedRecoveryLifecycle(runtime)).ConfigureAwait(false); Assert.Equal(0, exitCode); return runtime; } + // Setup only reaches Ready once logon recovery is configured, and a test + // must never register a real scheduled task. Everything else is left to + // production behaviour so the fixture still exercises the real path. + private sealed class StubbedRecoveryLifecycle(SessionRuntime runtime) + : IInstallationLifecycle + { + private static readonly InstallationLifecycle Inner = + InstallationLifecycle.Production; + + public SessionRuntime CreateRuntime(Action log) => runtime; + + public Task CheckSessionSupportAsync( + CancellationToken cancellationToken) => + Task.FromResult(new SessionRoutingDecision( + SessionRouting.Session, + "Test session support is available.")); + + public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => + Inner.PrepareHostRuntime(options, log); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, SessionRuntime sessionRuntime) => + Inner.ValidatePackageRuntime(options, sessionRuntime); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + Inner.AcquireLifecycleLock(sessionRuntime); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + Inner.TeardownAsync(options, sessionRuntime, log, lockAlreadyHeld, cancellationToken); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + Inner.CreateStateCleaner(sessionRuntime); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.", + Changed: true)); + } + private static string SandboxIdFor(string applicationId) => $"iso:{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( $"{{\"appId\":\"{applicationId}\"}}")) @@ -650,6 +1184,24 @@ private Task RunAgentWithDirectLaunchProbeAsync( getPackageFamilyName: () => "OpenClaw.Gateway_test"); } + private static void WriteRuntimeInstallResult(FakeMxcSessionClient backend, int exitCode) + { + string requestPath = Directory.GetFiles(backend.Metadata!.EphemeralWorkspacePath, "runtime-*.json") + .Single(); + SessionRuntimeInstallRequest request = SessionRuntimeProtocol.ReadRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeResult(new SessionRuntimeInstallResult + { + RequestId = request.RequestId, + ExecutablePath = @"C:\Users\agent_1\AppData\Local\OpenClawGatewayMSIX\agent-node\node.exe", + Version = "24.20.0", + ArchiveName = "node-v24.20.0-win-x64.zip", + Error = exitCode == 0 ? null : "installer failed" + })); + } + private HostOptions CreateAgentOptions() { string applicationDirectory = Path.Combine(_testDirectory, "agent-app"); @@ -659,4 +1211,154 @@ private HostOptions CreateAgentOptions() "console.log('fixture');"); return new HostOptions(applicationDirectory, null, ["--version"]); } + + private async Task CreateApplicationAsync() + { + string applicationDirectory = Path.Combine(_testDirectory, Guid.NewGuid().ToString("N"), "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture") + .ConfigureAwait(false); + return applicationDirectory; + } + + private HostOptions CreateSetupOptions(string applicationDirectory) + { + string archivePath = Path.Combine(_testDirectory, "node-v24.20.0-win-x64.zip"); + File.WriteAllText(archivePath, "fixture"); + return new HostOptions(applicationDirectory, archivePath, []); + } + + private sealed class FailingFreshLifecycle : IInstallationLifecycle + { + private readonly SessionRuntime _runtime; + + public FailingFreshLifecycle(SessionRuntime runtime) + { + _runtime = runtime; + } + + public List Calls { get; } = []; + + public RecordingCleaner Cleaner { get; } = new(); + + public Exception? CleanerException { get; init; } + + public Exception? TeardownException { get; init; } + + public TeardownResult? TeardownResult { get; init; } + + public bool TeardownSucceeds { get; init; } + + public TaskCompletionSource ProvisionStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource AllowProvision { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int TeardownCount { get; private set; } + + public SessionRuntime CreateRuntime(Action log) => _runtime; + + public Task CheckSessionSupportAsync( + CancellationToken cancellationToken) => + Task.FromResult(new SessionRoutingDecision( + SessionRouting.Session, + "The isolated-session runtime is available.")); + + public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => + new( + "node.exe", + new Version(24, 20, 0), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime runtime) + { + Calls.Add("validate"); + return new PackageRuntimeMetadata("node.zip", new Version(24, 0), runtime.HelperPath); + } + + public Task TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) + { + Calls.Add("recovery"); + Calls.Add("gateway"); + Calls.Add("session"); + TeardownCount++; + Assert.True(lockAlreadyHeld); + if (TeardownException is not null) + { + throw TeardownException; + } + + if (TeardownResult is not null) + { + return Task.FromResult(TeardownResult); + } + + return Task.FromResult(TeardownSucceeds + ? new TeardownResult(Succeeded: true, Message: "Removed prior session.") + : new TeardownResult(Succeeded: false, Message: "Recovery removal failed.")); + } + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime) + { + Calls.Add("lock"); + if (Calls.Count(static call => call == "lock") > 1) + { + throw new SessionBusyException(TimeSpan.Zero); + } + + return new RecordingLockHandle(); + } + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) + { + Calls.Add("clean"); + Cleaner.Exception = CleanerException; + return Cleaner; + } + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready", + false)); + } + + private sealed class RecordingCleaner : IInstallationStateCleaner + { + public bool Cleared { get; private set; } + + public Exception? Exception { get; set; } + + public Action? Cleanup { get; set; } + + public void Clear() + { + if (Exception is not null) + { + throw Exception; + } + + Cleared = true; + Cleanup?.Invoke(); + } + + } + + private sealed class RecordingLockHandle : ISessionLockHandle + { + public void Dispose() + { + } + } } diff --git a/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs new file mode 100644 index 00000000..9ca38b49 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/InstallationStateCleanerTests.cs @@ -0,0 +1,161 @@ +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class InstallationStateCleanerTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void ClearRemovesKnownCorruptAndUnknownInstallationFiles() + { + string stateRoot = Path.Combine(_root, "state"); + string dataRoot = Path.Combine(_root, "data"); + Directory.CreateDirectory(Path.Combine(stateRoot, "Logs")); + Directory.CreateDirectory(Path.Combine(dataRoot, "NodeJS")); + File.WriteAllText(Path.Combine(stateRoot, "session.json"), "{not-json"); + File.WriteAllText(Path.Combine(stateRoot, "unknown.bin"), "leftover"); + File.WriteAllText(Path.Combine(stateRoot, "Logs", "openclaw.log"), "log"); + File.WriteAllText(Path.Combine(dataRoot, "NodeJS", "node.exe"), "runtime"); + + var cleaner = new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + dataRoot); + + cleaner.Clear(); + + Assert.Empty(Directory.EnumerateFileSystemEntries(stateRoot)); + Assert.Empty(Directory.EnumerateFileSystemEntries(dataRoot)); + } + + [Fact] + public void ClearRejectsAReparsePointRoot() + { + string stateRoot = Path.Combine(_root, "state"); + string target = Path.Combine(_root, "external"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "must-survive.txt"), "outside"); + Directory.CreateSymbolicLink(stateRoot, target); + + Assert.Throws(() => new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + Path.Combine(_root, "data"))); + Assert.True(File.Exists(Path.Combine(target, "must-survive.txt"))); + } + + [Fact] + public void ClearRejectsAReplacedRootWithoutTouchingTheReplacement() + { + string stateRoot = Path.Combine(_root, "state"); + string dataRoot = Path.Combine(_root, "data"); + string movedStateRoot = Path.Combine(_root, "state-before-replacement"); + string externalRoot = Path.Combine(_root, "external"); + Directory.CreateDirectory(stateRoot); + Directory.CreateDirectory(dataRoot); + Directory.CreateDirectory(externalRoot); + File.WriteAllText(Path.Combine(stateRoot, "owned.txt"), "owned"); + File.WriteAllText(Path.Combine(dataRoot, "owned-runtime.txt"), "owned"); + File.WriteAllText(Path.Combine(externalRoot, "must-survive.txt"), "outside"); + bool replaced = false; + + var cleaner = new InstallationStateCleaner( + [stateRoot, dataRoot], + beforeTraversal: root => + { + if (!replaced && string.Equals(root, stateRoot, StringComparison.OrdinalIgnoreCase)) + { + replaced = true; + Directory.Move(stateRoot, movedStateRoot); + Directory.CreateSymbolicLink(stateRoot, externalRoot); + } + }); + + Assert.Throws(() => cleaner.Clear()); + + Assert.True(replaced); + Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); + Assert.True(File.Exists(Path.Combine(dataRoot, "owned-runtime.txt"))); + Assert.True(File.Exists(Path.Combine(movedStateRoot, "owned.txt"))); + } + + [Fact] + public void ClearRefusesADescendantReplacedAfterEnumeration() + { + string stateRoot = Path.Combine(_root, "state"); + string dataRoot = Path.Combine(_root, "data"); + string externalRoot = Path.Combine(_root, "external"); + string movedOwnedDirectory = Path.Combine(_root, "owned-before-replacement"); + string ownedDirectory = Path.Combine(stateRoot, "owned"); + string ownedFile = Path.Combine(stateRoot, "delete-me.txt"); + Directory.CreateDirectory(ownedDirectory); + Directory.CreateDirectory(externalRoot); + File.WriteAllText(Path.Combine(ownedDirectory, "owned.txt"), "owned"); + File.WriteAllText(ownedFile, "owned"); + File.WriteAllText(Path.Combine(externalRoot, "must-survive.txt"), "outside"); + bool replaced = false; + + var cleaner = new InstallationStateCleaner( + [stateRoot, dataRoot], + beforeDeleteEntry: entry => + { + if (!replaced && string.Equals(entry, ownedDirectory, StringComparison.OrdinalIgnoreCase)) + { + replaced = true; + Directory.Move(ownedDirectory, movedOwnedDirectory); + Directory.CreateSymbolicLink(ownedDirectory, externalRoot); + } + }); + + cleaner.Clear(); + + Assert.True(replaced); + Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); + Assert.True(File.Exists(Path.Combine(movedOwnedDirectory, "owned.txt"))); + Assert.False(File.Exists(ownedFile)); + Assert.False(Directory.Exists(ownedDirectory)); + } + + [Fact] + public void ClearRejectsAReparsePointAncestor() + { + string external = Path.Combine(_root, "external"); + string redirectedParent = Path.Combine(_root, "redirected"); + string stateRoot = Path.Combine(redirectedParent, "state"); + Directory.CreateDirectory(external); + Directory.CreateSymbolicLink(redirectedParent, external); + Directory.CreateDirectory(stateRoot); + File.WriteAllText(Path.Combine(stateRoot, "must-survive.txt"), "outside"); + + Assert.Throws(() => new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + Path.Combine(_root, "data"))); + Assert.True(File.Exists(Path.Combine(stateRoot, "must-survive.txt"))); + } + + [Fact] + public void ClearUnlinksAnOwnedReparsePointWithoutFollowingIt() + { + string stateRoot = Path.Combine(_root, "state"); + string externalRoot = Path.Combine(_root, "external"); + Directory.CreateDirectory(stateRoot); + Directory.CreateDirectory(externalRoot); + File.WriteAllText(Path.Combine(externalRoot, "must-survive.txt"), "outside"); + string link = Path.Combine(stateRoot, "link"); + Directory.CreateSymbolicLink(link, externalRoot); + + new InstallationStateCleaner( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + Path.Combine(_root, "data")).Clear(); + + Assert.False(File.Exists(link)); + Assert.True(File.Exists(Path.Combine(externalRoot, "must-survive.txt"))); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 38f43fcf..4c3bdd91 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -266,6 +266,9 @@ public async Task ApplicationExitCodeIsReturned() Assert.Equal(42, exitCode); } + // The host cannot know what the agent account's PATH contains, so it names + // the directory and lets the guest prepend it. Sending a host-built PATH + // would replace the guest's own. [Fact] public async Task IsolatedLaunchPrependsTheSelectedAgentNodeDirectory() { From d579e7a52524252445edbf3a3dd08b148330eea2 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:52 -0700 Subject: [PATCH 63/76] docs: document the isolated session runtime (#53) * Document isolated session runtime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * docs: correct session runtime behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Clarify host and session runtime operations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc * Separate host and session setup guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --------- Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- README.md | 32 +++++-- docs/mxc-compatibility-evidence.md | 148 +++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 docs/mxc-compatibility-evidence.md diff --git a/README.md b/README.md index 7ba28568..d31ac08b 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ Every OpenClaw child process runs with `OPENCLAW_NO_AUTO_UPDATE=1`. It also reports the selected Windows Gateway session mode through the process-stable `CLAWCTL_GATEWAY_ISOLATION=enabled|disabled` environment variable. The current -interactive-session launch path reports `disabled`; the future isolated-session -launch path will select `enabled` when that session switch is implemented. +direct host launch path reports `disabled`; the isolated-session launch path +reports `enabled`. These values declare external lifecycle ownership, prevent doctor-owned service repair, disable configured background auto-updates, and expose diagnostic isolation status without claiming independent attestation. The selected OpenClaw runtime honors external supervisor mode by refusing native service @@ -60,12 +60,20 @@ the read-only application directory the workspace. ### `clawctl` -`clawctl` exposes package readiness and launcher version information: +`clawctl` owns setup and the isolated-session operations: | Command | Behavior | |---|---| -| `clawctl setup` | On a session-capable Windows build, provision the isolated agent session, install its bundled Node.js runtime, and confirm packaged `app\openclaw.mjs` exists. | -| `clawctl setup --no-isolation` | On a session-capable Windows build, prepare the host runtime without provisioning the isolated session. It still refuses unsupported Windows builds. | +| `clawctl setup` | On a session-capable Windows build, confirm packaged `app\openclaw.mjs` exists, provision or reuse the owned isolated session, and install the bundled Node.js runtime in the agent profile. It does not prepare the invoking user's host runtime. It also configures gateway sign-in recovery without starting a gateway. | +| `clawctl setup --no-isolation` | On a session-capable Windows build, prepare the invoking user's host runtime without provisioning the isolated session. Direct host execution also installs that runtime on demand, so it does not require prior setup. | +| `clawctl setup --fresh [--force]` | Remove this installation's owned session and package-local state, then run setup again. Without `--force`, incomplete external cleanup stops before local state is erased. `--force` is valid only with `--fresh`; it preserves an explicit warning when cleanup of owned external resources cannot be confirmed, but still stops if bounded local deletion fails. | +| `clawctl status` | Report the recorded isolated-session state without provisioning or replacing it. It asks the backend to start the recorded provision as its status probe, so it is not a passive diagnostic. Use `clawctl gateway-service status` to inspect the gateway. | +| `clawctl teardown --force` | Confirm deletion, then stop and deprovision the owned session and remove its data and setup state. The MSIX remains installed. | +| `clawctl pwsh` | Open an interactive PowerShell session inside the agent session. | +| `clawctl collect-logs [--output ]` | Create a redacted host-and-agent diagnostics ZIP. | +| `clawctl gateway-service start` | Start the OpenClaw gateway in the isolated session. Requires setup. | +| `clawctl gateway-service status` | Inspect the gateway without starting it. | +| `clawctl gateway-service stop` | Stop the gateway while retaining the session and its data. | | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing @@ -92,14 +100,24 @@ token reaches that CLI uninterpreted. Commands such as `doctor`, `gateway`, and `uninstall` belong to the OpenClaw CLI and must be invoked through `openclaw`. -`setup` extracts the architecture-specific runtime archive from the immutable -MSIX into the package's writable LocalState: +Host-mode setup (`clawctl setup --no-isolation`) and direct host execution +extract the architecture-specific runtime archive from the immutable MSIX into +the invoking user's writable LocalState: `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v-win-`. Extraction is idempotent, versioned, and serialized across concurrent setup processes, including different Windows sessions. Setup validates existing runtimes before reuse, replaces invalid runtimes, and validates extraction before publishing it. +On a session-capable machine, ordinary `clawctl setup` instead provisions an +explicitly owned agent session. Its separate profile receives the bundled +Node.js runtime and command environment; it does not prepare the invoking +user's host runtime. Run setup before using `clawctl pwsh` or gateway-service +start; `openclaw` installs the host runtime on demand when it runs directly. +See +[MXC compatibility evidence](docs/mxc-compatibility-evidence.md) for the +session model, routing, gateway health criteria, and diagnostics limits. + The launcher places Node.js in a Windows job configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The launcher remains alive while Node.js runs; if the launcher exits or is terminated, Windows terminates Node.js and diff --git a/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md new file mode 100644 index 00000000..7f868cd3 --- /dev/null +++ b/docs/mxc-compatibility-evidence.md @@ -0,0 +1,148 @@ +# MXC session runtime and operational evidence + +This document describes the packaged isolated-session implementation and the +observable guarantees it makes. It does not describe an external MXC protocol +as a stable public API. + +## Runtime boundary and MXC seam + +The launcher owns a small session contract in +`OpenClaw.Launcher\Mxc\IMxcSessionClient`. `MxcCliSessionClient` is the current +implementation: it invokes the pinned `@microsoft/mxc-sdk` CLI and confines +its preview wire details to `MxcWireProtocol` and `MxcWireModels`. The rest of +the launcher depends on the project-owned contract, so the CLI transport can +be replaced by the official .NET SDK without changing lifecycle, routing, or +gateway callers. + +`OpenClaw.SessionProtocol` is separate from that backend seam. It is the +versioned, launcher-to-guest request/result contract used for execution, +inspection, runtime installation, and collection. A backend execution request +uses a controlled `cmd.exe` command line only to start the staged helper; the +requested executable and argument vector are JSON data rather than interpolated +into that command line. + +## Session ownership and routing + +`clawctl setup` is the required lifecycle entry point. It writes package-local +setup state and records the session that this installation owns in +`session.json`. Ownership is never inferred from a machine account or profile: +unrelated agent accounts may exist, and teardown must remain safe and +idempotent. When MXC reports that this recorded provision is missing, setup +reprovisions a replacement for this installation and removes only a gateway +record that names that explicitly stale session; it neither adopts unrelated +machine agents nor disturbs a gateway record for any other session. +`clawctl teardown --force` explicitly confirms deletion of the recorded owned +session, its data, and local setup state; it does not uninstall the package. + +`clawctl setup --fresh` explicitly authorizes a reset of this installation. +It captures a redacted pre-reset report, performs the normal owned teardown, +then clears only the contents of the package-owned state roots before running +the ordinary setup route. It refuses unpackaged execution, never follows +reparse points, and stops without a wipe or replacement setup when teardown is +incomplete. It does not onboard OpenClaw or start the gateway. + +`openclaw` uses `OPENCLAW_SESSION` to select a route: + +| Value | Result | +|---|---| +| Unset | Use the isolated session when the MXC backend reports support; otherwise run directly on the host. | +| `0`, `false`, `off`, or `no` | Run directly on the host. | +| `1`, `true`, `on`, or `yes` | Require the isolated session. A session failure is reported; the launcher does not silently fall back to the host. | + +Unrecognized values are rejected rather than interpreted as disabled. +`clawctl status` preserves the recorded ownership record and does not provision +or replace a session. It is not passive: `ProbeRecordedStatusAsync` invokes the +backend `StartAsync` operation for the recorded provision as its status probe. + +## Agent runtime and helper + +The package application tree is immutable and runs directly from the MSIX; the +launcher does not extract, copy, hash, or repair it at runtime. Direct host +execution installs the bundled Node.js runtime on demand in the invoking +user's LocalState before launching; it does not require a prior `clawctl` +setup. Isolated-session setup is a separate route: +`RunSetupCoreAsync` asks the guest helper to install the runtime under the +agent's own profile, without preparing the invoking user's host runtime. +`clawctl setup --no-isolation` selects the session-free host setup on a +session-capable build without provisioning the isolated session. + +The agent cannot execute the package's WindowsApps helper binary directly. +During setup, the launcher stages only `openclaw-session-host.exe` into the +shared session workspace. It does not stage the application tree. The helper +has distinct modes for launching a request, inspecting processes/listeners, +installing the agent runtime, and collecting requested diagnostics. + +Opening the agent shell with `clawctl pwsh` installs an ASCII `openclaw.cmd` +shim in the shared workspace: `RunPowerShellAsync` calls `InstallToolsAsync`. +Setup alone does not guarantee that shim exists. The shim reads its Node.js and +entry-point paths from environment variables rather than embedding profile +paths, which avoids batch-file code-page corruption. +The agent's persistent user PATH is prefixed with its bundled Node.js runtime +for independently started processes; each helper launch also supplies a +request-level PATH prefix. Together these ensure the agent resolves its own +bundled `node`, `npm`, and `npx`, not a device-installed Node.js. + +`clawctl pwsh` starts an interactive shell in the owned session. Its +environment is built using the interactive runtime policy so terminal-related +variables are retained when appropriate; redirected output does not synthesize +interactive defaults. + +## Gateway lifecycle and recovery + +`clawctl gateway-service start` starts the gateway inside the owned session; +`status` observes it without starting it; and `stop` stops it while leaving the +session and agent data intact. All require the setup record where appropriate. +The launcher does not impose an invented port: the OpenClaw configuration and +upstream default choose it unless configuration explicitly supplies one. + +Setup configures sign-in recovery for the managed gateway but does not start a +gateway. Recovery is reconciled against the package identity and records its +own failures for status reporting. It is not evidence that a gateway is +currently healthy. + +A stored gateway record is not liveness proof. Health requires successful guest +inspection, a live process with the recorded creation time, and a listener +owned by that process or one of its descendants. A missing record is +`NotStarted`; a stale record is `Stopped`; a live but non-serving recorded +process is `Unhealthy`; and failed inspection is `Unknown` to avoid starting a +second gateway beside one that could still be healthy. + +## Diagnostics and safe collection + +`clawctl collect-logs [--output ]` creates a ZIP at the supplied path or +in package state by default. It collects host diagnostics and, when the owned +session can be reached, stages selected agent diagnostics through the guest +helper. A session collection failure is a warning rather than a reason to +discard available host diagnostics. + +Agent paths are relative to the agent profile: OpenClaw logs come from +`AppData\Local\Temp\openclaw`, and configuration candidates are +`.openclaw\openclaw.json*`. The collector excludes SQLite databases and +authentication-profile files by name. JSON entries are passed through +credential-shaped-value redaction before being added. Collection is +best-effort, and the ZIP manifest warns that redaction is not a substitute for +review before sharing. + +The helper accepts only host-named sources and writes collected files to the +shared workspace. Missing sources are reported as entries rather than treated +as a collection failure, which makes a bundle useful for partial or failed +setup. The collector does not enumerate arbitrary agent-profile files. + +## Supported operational flow + +1. On a session-capable build, run `clawctl setup` after installing or updating + the package to prepare the isolated agent session. For host-only use, + `clawctl setup --no-isolation` is optional because direct host execution + installs its runtime on demand. +2. Run `openclaw ` for the upstream OpenClaw CLI, or + `clawctl pwsh` for an interactive agent shell. +3. Use `clawctl gateway-service start`, `status`, and `stop` for the managed + gateway. Use `clawctl status` to inspect session ownership and state. +4. Run `clawctl collect-logs` when reporting a problem, then review the + resulting ZIP before sharing it. +5. Run `clawctl teardown --force` to confirm removal of the owned isolated + session and its data while retaining the installed package. + +The `clawctl` command tree intentionally owns only these package-management +operations. Upstream commands such as `doctor`, `gateway`, and `uninstall` +remain `openclaw` arguments and are forwarded unchanged. From e8c1e7de54b33fdfe243a7d99042f4e031192820 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 13:04:53 -0700 Subject: [PATCH 64/76] Stage session dependencies in local deployment (#54) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 97c31f3f-0c7b-43e8-b979-6418dcb7fedc --- scripts/Deploy-LocalPackage.ps1 | 5 +- scripts/LocalPackage.psm1 | 223 +++++++++++++++++++-- scripts/Test-Deploy-LocalPackage.Tests.ps1 | 86 +++++++- src/OpenClaw.Launcher/Program.cs | 5 +- 4 files changed, 297 insertions(+), 22 deletions(-) diff --git a/scripts/Deploy-LocalPackage.ps1 b/scripts/Deploy-LocalPackage.ps1 index 921a4459..08031a79 100644 --- a/scripts/Deploy-LocalPackage.ps1 +++ b/scripts/Deploy-LocalPackage.ps1 @@ -4,8 +4,9 @@ Builds and registers a local development OpenClaw Gateway package, ready to run. .DESCRIPTION Takes a clean checkout to a registered, runnable OpenClaw.Gateway package without producing, signing, or installing an MSIX. It acquires the OpenClaw payload and -the bundled Node.js runtime, publishes the NativeAOT launcher, assembles a -Developer Mode layout, and registers it with Add-AppxPackage -Register. +the bundled Node.js and MXC runtimes, publishes the NativeAOT launcher and +session host, assembles a Developer Mode layout, and registers it with +Add-AppxPackage -Register. The command is idempotent. Re-running with nothing changed reports that the package is already up to date and does nothing; re-running after a source or diff --git a/scripts/LocalPackage.psm1 b/scripts/LocalPackage.psm1 index 2b3dd5a7..b2eb4c47 100644 --- a/scripts/LocalPackage.psm1 +++ b/scripts/LocalPackage.psm1 @@ -3,6 +3,138 @@ $ErrorActionPreference = 'Stop' $script:PackageName = 'OpenClaw.Gateway' $script:StateSchema = 1 +$script:ControlApplicationId = 'Control' + +function Invoke-LocalPackageControlCommand { + param( + [Parameter(Mandatory)][string]$PackageFamilyName, + [Parameter(Mandatory)][string]$Arguments + ) + + $package = Get-AppxPackage -Name $script:PackageName -ErrorAction Stop | + Where-Object { $_.PackageFamilyName -eq $PackageFamilyName } | + Select-Object -First 1 + if ($null -eq $package) { + throw "The owning package was not registered for this user: $PackageFamilyName." + } + if ($package.Status.ToString() -notmatch '^(Ok|Ready)$') { + throw "The owning package is not ready: $PackageFamilyName ($($package.Status))." + } + + $manifestPath = Join-Path $package.InstallLocation 'AppxManifest.xml' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "The owning package manifest was not found: $manifestPath." + } + [xml]$manifest = Get-Content -LiteralPath $manifestPath -Raw + $application = @( + $manifest.Package.Applications.Application | + Where-Object { $_.Id -eq $script:ControlApplicationId } + ) | Select-Object -First 1 + if ($null -eq $application -or [string]$application.Executable -ne 'openclaw.exe') { + throw "The owning package does not expose the control application: $PackageFamilyName!$script:ControlApplicationId." + } + + if (-not ('OpenClaw.PackageActivation.IApplicationActivationManager' -as [type])) { + $source = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +namespace OpenClaw.PackageActivation +{ + [ComImport] + [Guid("2e941141-7f97-4756-ba1d-9decde894a3d")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IApplicationActivationManager + { + [PreserveSig] + int ActivateApplication( + [MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, + [MarshalAs(UnmanagedType.LPWStr)] string arguments, + uint options, + out uint processId); + } + + [ComImport] + [Guid("45ba127d-10a8-46ea-8ab7-56ea9078943c")] + public class ApplicationActivationManager + { + } + + public static class ApplicationActivator + { + private const uint Synchronize = 0x00100000; + private const uint QueryLimitedInformation = 0x1000; + private const uint Infinite = 0xffffffff; + private const uint WaitObject0 = 0; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess( + uint desiredAccess, + bool inheritHandle, + uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + public static int ActivateAndWait(string appUserModelId, string arguments) + { + var manager = + (IApplicationActivationManager)new ApplicationActivationManager(); + int result = manager.ActivateApplication( + appUserModelId, + arguments, + 0, + out uint processId); + if (result != 0) + { + Marshal.ThrowExceptionForHR(result); + } + + IntPtr process = OpenProcess( + Synchronize | QueryLimitedInformation, + false, + processId); + if (process == IntPtr.Zero) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + if (WaitForSingleObject(process, Infinite) != WaitObject0 || + !GetExitCodeProcess(process, out uint exitCode)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return checked((int)exitCode); + } + finally + { + CloseHandle(process); + } + } + } +} +'@ + Add-Type -TypeDefinition $source -Language CSharp + } + + $exitCode = [OpenClaw.PackageActivation.ApplicationActivator]::ActivateAndWait( + "$PackageFamilyName!$script:ControlApplicationId", + $Arguments) + if ($exitCode -ne 0) { + throw "clawctl $Arguments failed (exit $exitCode)." + } +} function Read-LocalPackageRecord { param([string]$Path) @@ -227,6 +359,8 @@ function New-LocalPackageLayout { [string]$LayoutDirectory, [string]$RepositoryRoot, [string]$HostExecutable, + [string]$SessionHostExecutable, + [string]$MxcRuntimeDirectory, [string]$PayloadDirectory, [string]$RuntimeArchive, [string]$Architecture, @@ -289,6 +423,26 @@ function New-LocalPackageLayout { Remove-Item -LiteralPath $runtimeStaging -Force } } + + $sessionHost = Join-Path (Join-Path $LayoutDirectory 'session-host') $Architecture + if (Test-Path -LiteralPath $sessionHost) { + Remove-Item -LiteralPath $sessionHost -Recurse -Force + } + New-Item -Path $sessionHost -ItemType Directory -Force | Out-Null + Copy-Item ` + -LiteralPath $SessionHostExecutable ` + -Destination (Join-Path $sessionHost 'openclaw-session-host.exe') ` + -Force + + $mxc = Join-Path (Join-Path $LayoutDirectory 'mxc') $Architecture + if (Test-Path -LiteralPath $mxc) { + Remove-Item -LiteralPath $mxc -Recurse -Force + } + New-Item -Path $mxc -ItemType Directory -Force | Out-Null + foreach ($item in Get-ChildItem -LiteralPath $MxcRuntimeDirectory -Force) { + Copy-Item -LiteralPath $item.FullName -Destination $mxc -Recurse -Force + } + return $manifestPath } @@ -309,12 +463,21 @@ function Test-LocalPackageLayout { param( [string]$LayoutDirectory, [string]$PayloadDirectory, - [string]$RuntimeArchiveName + [string]$RuntimeArchiveName, + [string]$Architecture ) # The registered package serves these files directly, so a short circuit # must confirm the live layout, not just that a previous run wrote one. - foreach ($relative in @('AppxManifest.xml', 'openclaw.exe', "runtime\$RuntimeArchiveName")) { + foreach ($relative in @( + 'AppxManifest.xml', + 'openclaw.exe', + "runtime\$RuntimeArchiveName", + "session-host\$Architecture\openclaw-session-host.exe", + "mxc\$Architecture\wxc-exec.exe", + "mxc\$Architecture\plm.exe", + "mxc\$Architecture\mxc-runtime.json" + )) { if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory $relative) -PathType Leaf)) { return $false } @@ -404,6 +567,13 @@ function Get-LocalPackageOperations { finally { $archive.Dispose() } return $null } + StageMxcRuntime = { + param($repositoryRoot, $architecture, $output) + & (Join-Path $repositoryRoot 'scripts\Get-MxcRuntime.ps1') ` + -Architecture $architecture ` + -OutputDirectory $output + return $null + } Publish = { param($project, $architecture, $output) & dotnet publish $project --configuration Release --runtime "win-$architecture" ` @@ -424,6 +594,7 @@ function Get-LocalPackageOperations { return [pscustomobject]@{ Version = $package.Version.ToString() PackageFullName = $package.PackageFullName + PackageFamilyName = $package.PackageFamilyName InstallLocation = $package.InstallLocation IsDevelopmentMode = [bool]$package.IsDevelopmentMode Status = $package.Status.ToString() @@ -446,16 +617,8 @@ function Get-LocalPackageOperations { } TestPath = { param($path) Test-Path -LiteralPath $path } RunSetup = { - # Control mode is selected by the alias name, so setup must go - # through clawctl.exe rather than the layout executable. - $alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\clawctl.exe' - if (-not (Test-Path -LiteralPath $alias -PathType Leaf)) { - throw "The clawctl alias was not created at $alias." - } - & $alias setup | ForEach-Object { Write-Host $_ } - if ($LASTEXITCODE -ne 0) { - throw "clawctl setup failed (exit $LASTEXITCODE)." - } + param($packageFamilyName) + Invoke-LocalPackageControlCommand -PackageFamilyName $packageFamilyName -Arguments 'setup' return $null } } @@ -594,22 +757,46 @@ function Invoke-LocalPackageDeployment { Resolve-LocalPackageRuntime -RuntimeDirectory (Join-Path $stateRoot 'runtime') ` -Architecture $Architecture -NodeVersion $payload.NodeVersion -Operations $services } + $mxcRuntimeDirectory = Join-Path $stateRoot 'mxc' + Invoke-LocalPackagePhase $progress 'Stage MXC runtime' { + & $services.StageMxcRuntime $root $Architecture $mxcRuntimeDirectory + } | Out-Null $hostDirectory = Join-Path $stateRoot 'host' Invoke-LocalPackagePhase $progress 'Build launcher (NativeAOT)' { & $services.Publish (Join-Path $root 'src\OpenClaw.Launcher\OpenClaw.Launcher.csproj') ` $Architecture $hostDirectory } | Out-Null + $sessionHostDirectory = Join-Path $stateRoot 'session-host' + Invoke-LocalPackagePhase $progress 'Build session host (NativeAOT)' { + & $services.Publish (Join-Path $root 'src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj') ` + $Architecture $sessionHostDirectory + } | Out-Null $hostExecutable = Join-Path $hostDirectory 'openclaw.exe' if (-not (& $services.TestPath $hostExecutable)) { throw "The publish did not produce $hostExecutable." } + $sessionHostExecutable = Join-Path $sessionHostDirectory 'openclaw-session-host.exe' + if (-not (& $services.TestPath $sessionHostExecutable)) { + throw "The publish did not produce $sessionHostExecutable." + } $hostInfo = Get-Item -LiteralPath $hostExecutable + $sessionHostInfo = Get-Item -LiteralPath $sessionHostExecutable $manifestSource = Join-Path $root 'src\OpenClaw.Launcher\Package.appxmanifest' # Hash the launcher rather than trusting its timestamp: publish copies # into the output directory and can refresh timestamps with no source # change, which would defeat the up-to-date check on every run. $hostHash = (Get-FileHash -LiteralPath $hostExecutable -Algorithm SHA256).Hash + $sessionHostHash = ( + Get-FileHash -LiteralPath $sessionHostExecutable -Algorithm SHA256 + ).Hash + $mxcHashes = @( + Get-ChildItem -LiteralPath $mxcRuntimeDirectory -File -Recurse | + Sort-Object FullName | + ForEach-Object { + "$($_.Name):$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" + } + ) $imageHashes = @( Get-ChildItem -LiteralPath (Join-Path $root 'src\OpenClaw.Launcher\Images') -File -Recurse | Sort-Object FullName | @@ -621,8 +808,10 @@ function Invoke-LocalPackageDeployment { [IO.Path]::GetFileName($runtimeArchive) $hostInfo.Length.ToString() $hostHash + $sessionHostInfo.Length.ToString() + $sessionHostHash (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash - ) + $imageHashes) + ) + $imageHashes + $mxcHashes) $previous = Read-LocalPackageRecord $statePath $setupSatisfied = $SkipSetup -or ($null -ne $previous -and $previous['setupComplete'] -eq $true) if (-not $Force -and $null -ne $previous -and $null -ne $installed -and @@ -634,7 +823,8 @@ function Invoke-LocalPackageDeployment { $setupSatisfied -and (Test-LocalPackageLayout -LayoutDirectory $layoutDirectory ` -PayloadDirectory $payload.Directory ` - -RuntimeArchiveName ([IO.Path]::GetFileName($runtimeArchive)))) { + -RuntimeArchiveName ([IO.Path]::GetFileName($runtimeArchive)) ` + -Architecture $Architecture)) { $total.Stop() Write-Host "`nAlready up to date: $($installed.PackageFullName)" Write-Host ('Total {0:0.00}s. Use -Force to re-register anyway.' -f $total.Elapsed.TotalSeconds) @@ -653,7 +843,8 @@ function Invoke-LocalPackageDeployment { $manifestPath = Invoke-LocalPackagePhase $progress 'Assemble layout' { New-LocalPackageLayout -LayoutDirectory $layoutDirectory -RepositoryRoot $root ` - -HostExecutable $hostExecutable -PayloadDirectory $payload.Directory ` + -HostExecutable $hostExecutable -SessionHostExecutable $sessionHostExecutable ` + -MxcRuntimeDirectory $mxcRuntimeDirectory -PayloadDirectory $payload.Directory ` -RuntimeArchive $runtimeArchive -Architecture $Architecture -Version $version } Invoke-LocalPackagePhase $progress 'Register package' { @@ -685,7 +876,7 @@ function Invoke-LocalPackageDeployment { if (-not $SkipSetup) { Invoke-LocalPackagePhase $progress 'Prepare bundled Node.js runtime' { - & $services.RunSetup + & $services.RunSetup $registered.PackageFamilyName } | Out-Null } diff --git a/scripts/Test-Deploy-LocalPackage.Tests.ps1 b/scripts/Test-Deploy-LocalPackage.Tests.ps1 index 59fab1cb..fd99515c 100644 --- a/scripts/Test-Deploy-LocalPackage.Tests.ps1 +++ b/scripts/Test-Deploy-LocalPackage.Tests.ps1 @@ -20,11 +20,53 @@ function Assert-Fails { if ($_.Exception.Message -notmatch $Pattern) { throw "Expected '$Pattern'; received '$($_.Exception.Message)'." } + return } throw "Expected failure matching '$Pattern'." } +function Test-ProductionMxcStageAdapterIgnoresStaleNativeExitState { + $fixture = Join-Path $testRoot "mxc adapter $([guid]::NewGuid().ToString('N'))" + $scripts = Join-Path $fixture 'scripts' + $output = Join-Path $fixture 'staged' + New-Item -Path $scripts -ItemType Directory -Force | Out-Null + New-Item -Path $output -ItemType Directory -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Get-MxcRuntime.ps1') ` + -Destination (Join-Path $scripts 'Get-MxcRuntime.ps1') + + $runtimeFile = Join-Path $output 'fixture.bin' + [IO.File]::WriteAllText($runtimeFile, 'cached runtime') + [IO.File]::WriteAllText((Join-Path $output 'mxc-runtime.json'), '{}') + $sha = (Get-FileHash -LiteralPath $runtimeFile -Algorithm SHA256).Hash.ToLowerInvariant() + $length = (Get-Item -LiteralPath $runtimeFile).Length + $lock = @{ + version = 'test' + architectures = @{ + x64 = @{ + files = @(@{ + stagedPath = 'fixture.bin' + archivePath = 'package/fixture.bin' + length = $length + sha256 = $sha + }) + } + } + licenseFiles = @() + } | ConvertTo-Json -Depth 8 + [IO.File]::WriteAllText((Join-Path $fixture 'mxc-runtime.lock.json'), $lock) + + $module = Get-Module LocalPackage + $adapter = & $module { (Get-LocalPackageOperations).StageMxcRuntime } + & $env:ComSpec /d /c 'exit 23' + Assert-True ($LASTEXITCODE -eq 23) 'The test must establish stale native failure state.' + + & $adapter $fixture 'x64' $output + Assert-True ((Test-Path -LiteralPath $runtimeFile -PathType Leaf)) ` + 'Cached MXC staging should succeed regardless of prior native exit state.' + $global:LASTEXITCODE = 0 +} + function New-Fixture { $root = Join-Path $testRoot "repo with spaces $([guid]::NewGuid().ToString('N'))" $project = Join-Path $root 'src\OpenClaw.Launcher' @@ -54,9 +96,11 @@ function New-Fixture { Queries = 0 Downloads = 0 RuntimeDownloads = 0 + MxcStages = 0 Publishes = 0 Registrations = 0 Setups = 0 + SetupPackageFamilyNames = @() SetupFailure = $false Removals = @() PreserveFlags = @() @@ -93,12 +137,27 @@ function New-Fixture { return $null }.GetNewClosure() TestArchive = { param($path, $expectedRoot) return $null } + StageMxcRuntime = { + param($repositoryRoot, $architecture, $output) + $state.MxcStages++ + New-Item -Path $output -ItemType Directory -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $output 'wxc-exec.exe'), 'fixture executor') + [IO.File]::WriteAllText((Join-Path $output 'plm.exe'), 'fixture lifecycle') + [IO.File]::WriteAllText((Join-Path $output 'mxc-runtime.json'), '{}') + return $null + }.GetNewClosure() Publish = { param($project, $architecture, $output) $state.Publishes++ if ($state.PublishFailure) { throw 'NativeAOT publish failed.' } New-Item -Path $output -ItemType Directory -Force | Out-Null - if (-not $state.SkipHost) { + $isSessionHost = $project -like '*OpenClaw.SessionHost*' + if ($isSessionHost) { + [IO.File]::WriteAllText( + (Join-Path $output 'openclaw-session-host.exe'), + "session host $($state.HostVersion)") + } + elseif (-not $state.SkipHost) { # Unchanged source must produce identical bytes, as a real # incremental publish does; HostVersion models a source edit. [IO.File]::WriteAllText((Join-Path $output 'openclaw.exe'), "host $($state.HostVersion)") @@ -114,6 +173,7 @@ function New-Fixture { $state.Installed = [pscustomobject]@{ Version = if ($state.BadRegistration) { '9.9.9.9' } else { $m.Package.Identity.Version } PackageFullName = "OpenClaw.Gateway_$($m.Package.Identity.Version)_fixture" + PackageFamilyName = 'OpenClaw.Gateway_fixture' InstallLocation = Split-Path $manifestPath -Parent IsDevelopmentMode = $true Status = 'Ok' @@ -129,7 +189,9 @@ function New-Fixture { }.GetNewClosure() TestPath = { param($path) Test-Path -LiteralPath $path } RunSetup = { + param($packageFamilyName) $state.Setups++ + $state.SetupPackageFamilyNames += $packageFamilyName if ($state.SetupFailure) { throw 'clawctl setup failed (exit 1).' } return $null }.GetNewClosure() @@ -147,13 +209,27 @@ try { $f = New-Fixture $first = Invoke-Fixture $f Assert-True ($first.Changed -and $f.Downloads -eq 1 -and $f.RuntimeDownloads -eq 1 -and - $f.Publishes -eq 1 -and $f.Registrations -eq 1) 'First deployment did not acquire and register exactly once.' + $f.MxcStages -eq 1 -and $f.Publishes -eq 2 -and + $f.Registrations -eq 1) 'First deployment did not acquire, build, and register exactly once.' Assert-True ($f.Setups -eq 1) 'Deployment did not leave the package runnable by preparing the runtime.' + Assert-True (@($f.SetupPackageFamilyNames)[0] -eq 'OpenClaw.Gateway_fixture') 'Setup did not target the owning package family.' Assert-True (@($first).Count -eq 1 -and $first.PackageFullName) 'Deployment did not return a single registration record.' $layout = $first.LayoutDirectory Assert-True ((Get-Content (Join-Path $layout 'app\openclaw.mjs') -Raw) -eq 'first payload') 'Layout does not expose the payload application.' Assert-True ((Get-Item (Join-Path $layout 'app')).Attributes -band [IO.FileAttributes]::ReparsePoint) 'The layout copied the application instead of linking it.' Assert-True (Test-Path (Join-Path $layout 'openclaw.exe')) 'Layout is missing the launcher.' + Assert-True ( + Test-Path (Join-Path $layout 'session-host\x64\openclaw-session-host.exe') + ) 'Layout is missing the session host.' + Assert-True ( + Test-Path (Join-Path $layout 'mxc\x64\wxc-exec.exe') + ) 'Layout is missing the MXC executor.' + Assert-True ( + Test-Path (Join-Path $layout 'mxc\x64\plm.exe') + ) 'Layout is missing the MXC lifecycle tool.' + Assert-True ( + Test-Path (Join-Path $layout 'mxc\x64\mxc-runtime.json') + ) 'Layout is missing MXC provenance.' Assert-True (Test-Path (Join-Path $layout 'Images\StoreLogo.png')) 'Layout is missing package images.' Assert-True (@(Get-ChildItem (Join-Path $layout 'runtime') -File).Name -eq 'node-v24.20.0-win-x64.zip') 'Layout is missing the bundled Node.js runtime.' [xml]$m = Get-Content (Join-Path $layout 'AppxManifest.xml') -Raw @@ -204,6 +280,7 @@ try { $g = New-Fixture $g.Installed = [pscustomobject]@{ Version = '1.2.3.4'; PackageFullName = 'OpenClaw.Gateway_1.2.3.4_x64__pkg' + PackageFamilyName = 'OpenClaw.Gateway_pkg' InstallLocation = 'C:\Program Files\WindowsApps\fake'; IsDevelopmentMode = $false; Status = 'Ok' } Assert-Fails { Invoke-Fixture $g } 'already installed from a package' @@ -270,6 +347,7 @@ try { Assert-True (Test-Path (Join-Path $k.Root 'artifacts\local-package\x64\payloads')) 'Unregister discarded the payload cache.' $k.Installed = [pscustomobject]@{ Version = '1.0.0.0'; PackageFullName = 'pkg'; InstallLocation = 'x' + PackageFamilyName = 'OpenClaw.Gateway_pkg' IsDevelopmentMode = $false; Status = 'Ok' } Assert-Fails { Remove-LocalPackageRegistration -RepositoryRoot $k.Root -Operations $k.Operations } 'not a local layout' @@ -318,6 +396,7 @@ try { $o = New-Fixture $o.Installed = [pscustomobject]@{ Version = '0.1.0.0'; PackageFullName = 'OpenClaw.Gateway_0.1.0.0_x64__other' + PackageFamilyName = 'OpenClaw.Gateway_other' InstallLocation = (Join-Path $testRoot 'someone elses layout') IsDevelopmentMode = $true; Status = 'Ok' } @@ -339,6 +418,7 @@ try { $fo.Installed = [pscustomobject]@{ Version = $mine.Version PackageFullName = "OpenClaw.Gateway_$($mine.Version)_x64__other" + PackageFamilyName = 'OpenClaw.Gateway_other' InstallLocation = $foreignLayout IsDevelopmentMode = $true Status = 'Ok' @@ -353,6 +433,8 @@ try { [IO.Path]::GetFullPath($mine.LayoutDirectory).TrimEnd('\') ) 'The aliases still resolve to the other checkout after take-over.' + Test-ProductionMxcStageAdapterIgnoresStaleNativeExitState + Write-Host 'Local package deployment scenarios passed.' } finally { diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index b0f87568..21de778d 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -150,8 +150,9 @@ internal static async Task RunAgentAsync( await RunAgentAsync( options, log, - resolveNode ?? (_ => Task.FromResult(NodeRuntimeResolver.Resolve( - GetPackagedNodeArchivePath(options)))), + resolveNode ?? (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( + GetPackagedNodeArchivePath(options), + log))), GatewayLauncher.RunAsync).ConfigureAwait(false); // launchOpenClaw is a test seam: tests substitute a fake in place of From b0ec7830bef69c8d7b3d84fac3ea60d9ac8e56b6 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 17 Sep 2026 14:52:26 -0700 Subject: [PATCH 65/76] chore(release): prepare Gateway MSIX v2026.9.4-msix.1 (#68) --- release-policy.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-policy.json b/release-policy.json index 12ad1d78..3c536cd8 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,7 +1,7 @@ { "repository": "https://github.com/openclaw/openclaw", "gatewayTag": "v2026.9.4", - "msixRevision": 0, + "msixRevision": 1, "payloadPackageVersion": "2026.9.4", "approvedCommit": "3a9d69db306cd7f081e06254cb89c4bcc14a7107", "publisher": "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" From ad5df933da4f11177c33ee844a175992dae7efb1 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 15:04:32 -0700 Subject: [PATCH 66/76] Make isolated sessions mandatory (#67) Remove the setup and environment opt-outs for session containment, delete the direct host launch path, and require both clawctl setup and openclaw to fail loudly when isolated sessions are unsupported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e811db2-bbae-462e-a61f-ee0373b945c5 --- README.md | 51 +- docs/mxc-compatibility-evidence.md | 38 +- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 16 +- .../Gateway/SessionGatewayClient.cs | 2 +- src/OpenClaw.Launcher/GatewayIsolationMode.cs | 18 - src/OpenClaw.Launcher/GatewayLauncher.cs | 95 --- src/OpenClaw.Launcher/HostStartup.cs | 7 - .../OpenClawRuntimeEnvironment.cs | 17 +- src/OpenClaw.Launcher/Program.cs | 179 +---- .../Session/InstallationLifecycle.cs | 15 +- .../Session/SessionRoutingPolicy.cs | 152 ---- .../Session/SessionRuntime.cs | 52 -- .../Session/SessionSupportPolicy.cs | 82 ++ .../SmokeProgram.cs | 140 ++-- .../ClawCtlCommandLineTests.cs | 8 +- .../ClawCtlParserDefaultsTests.cs | 6 +- .../FailIfWorkStartsLifecycle.cs | 38 + .../GatewayLauncherTests.cs | 171 ----- .../ProgramStartupTests.cs | 97 +-- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 713 ++++++++---------- .../Session/SessionExecutorTests.cs | 29 +- .../Session/SessionRoutingPolicyTests.cs | 241 ------ .../Session/SessionSupportPolicyTests.cs | 126 ++++ 23 files changed, 762 insertions(+), 1531 deletions(-) delete mode 100644 src/OpenClaw.Launcher/GatewayIsolationMode.cs delete mode 100644 src/OpenClaw.Launcher/GatewayLauncher.cs delete mode 100644 src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs create mode 100644 src/OpenClaw.Launcher/Session/SessionSupportPolicy.cs create mode 100644 tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs delete mode 100644 tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs delete mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs create mode 100644 tests/OpenClaw.Launcher.Tests/Session/SessionSupportPolicyTests.cs diff --git a/README.md b/README.md index d31ac08b..ce8d1df1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,17 @@ and uses a separate `OpenClaw.Gateway` package identity. Both packages use the OpenClaw Foundation publisher metadata established for OpenClaw's Windows packages. +## Requirements + +- Windows 11 on a build that supports isolated agent sessions, on x64 or ARM64. + OpenClaw always runs inside an isolated session, so a machine that cannot host + one is not supported: `clawctl setup` and `openclaw` both fail with a message + naming this requirement and the diagnostic log path. Install the latest + Windows updates (Settings > Windows Update), or install a newer Windows + version, and run `clawctl setup` again. +- Developer Mode is required only for the local loose-layout development loop, + not for the signed MSIX. + ## Command model Both application identities activate the same packaged `openclaw.exe`. The @@ -29,6 +40,11 @@ own package-management commands. Every argument, including an empty argument list, is forwarded unchanged to `node openclaw.mjs`, and the launcher returns the exact child exit code. +`openclaw` runs only inside the isolated agent session recorded by +`clawctl setup`. It does not run OpenClaw on the host, and it fails rather than +falling back when the recorded session is missing, owned by another +installation, or unavailable. + Before launching, the host resolves the bundled Node.js executable previously prepared by `clawctl setup` and checks its PE product version and executable architecture against the packaged archive without a separate Node.js process. @@ -44,11 +60,10 @@ copy, repair, or otherwise change package files at runtime. Every OpenClaw child process runs with `OPENCLAW_SUPERVISOR_MODE=external`, `OPENCLAW_SERVICE_REPAIR_POLICY=external`, and -`OPENCLAW_NO_AUTO_UPDATE=1`. It also reports the selected Windows Gateway -session mode through the process-stable -`CLAWCTL_GATEWAY_ISOLATION=enabled|disabled` environment variable. The current -direct host launch path reports `disabled`; the isolated-session launch path -reports `enabled`. +`OPENCLAW_NO_AUTO_UPDATE=1`. It also reports the Windows Gateway session mode +through the process-stable `CLAWCTL_GATEWAY_ISOLATION=enabled` environment +variable. OpenClaw always runs inside the isolated session, so this value is +always `enabled`. These values declare external lifecycle ownership, prevent doctor-owned service repair, disable configured background auto-updates, and expose diagnostic isolation status without claiming independent attestation. The selected OpenClaw runtime honors external supervisor mode by refusing native service @@ -64,8 +79,7 @@ the read-only application directory the workspace. | Command | Behavior | |---|---| -| `clawctl setup` | On a session-capable Windows build, confirm packaged `app\openclaw.mjs` exists, provision or reuse the owned isolated session, and install the bundled Node.js runtime in the agent profile. It does not prepare the invoking user's host runtime. It also configures gateway sign-in recovery without starting a gateway. | -| `clawctl setup --no-isolation` | On a session-capable Windows build, prepare the invoking user's host runtime without provisioning the isolated session. Direct host execution also installs that runtime on demand, so it does not require prior setup. | +| `clawctl setup` | Confirm packaged `app\openclaw.mjs` exists, provision or reuse the owned isolated session, and install the bundled Node.js runtime in the agent profile. It also configures gateway sign-in recovery without starting a gateway. On a machine that cannot host a session it fails with the Windows requirement described under [Requirements](#requirements). | | `clawctl setup --fresh [--force]` | Remove this installation's owned session and package-local state, then run setup again. Without `--force`, incomplete external cleanup stops before local state is erased. `--force` is valid only with `--fresh`; it preserves an explicit warning when cleanup of owned external resources cannot be confirmed, but still stops if bounded local deletion fails. | | `clawctl status` | Report the recorded isolated-session state without provisioning or replacing it. It asks the backend to start the recorded provision as its status probe, so it is not a passive diagnostic. Use `clawctl gateway-service status` to inspect the gateway. | | `clawctl teardown --force` | Confirm deletion, then stop and deprovision the owned session and remove its data and setup state. The MSIX remains installed. | @@ -100,23 +114,22 @@ token reaches that CLI uninterpreted. Commands such as `doctor`, `gateway`, and `uninstall` belong to the OpenClaw CLI and must be invoked through `openclaw`. -Host-mode setup (`clawctl setup --no-isolation`) and direct host execution -extract the architecture-specific runtime archive from the immutable MSIX into -the invoking user's writable LocalState: +`clawctl setup` provisions an explicitly owned agent session and extracts the +architecture-specific runtime archive from the immutable MSIX into that agent's +writable LocalState: `%LOCALAPPDATA%\Packages\\LocalState\OpenClaw\NodeJS\node-v-win-`. Extraction is idempotent, versioned, and serialized across concurrent setup processes, including different Windows sessions. Setup validates existing runtimes before reuse, replaces invalid runtimes, and validates extraction -before publishing it. - -On a session-capable machine, ordinary `clawctl setup` instead provisions an -explicitly owned agent session. Its separate profile receives the bundled -Node.js runtime and command environment; it does not prepare the invoking -user's host runtime. Run setup before using `clawctl pwsh` or gateway-service -start; `openclaw` installs the host runtime on demand when it runs directly. -See +before publishing it. It does not prepare the invoking user's host runtime, +because nothing runs on the host. + +Run setup before using `openclaw`, `clawctl pwsh`, or gateway-service start. +There is no session-free mode: `openclaw` runs inside the session recorded by +setup, and both entry points fail with the same message on a machine that +cannot host one. See [MXC compatibility evidence](docs/mxc-compatibility-evidence.md) for the -session model, routing, gateway health criteria, and diagnostics limits. +session model, gateway health criteria, and diagnostics limits. The launcher places Node.js in a Windows job configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The launcher remains alive while Node.js diff --git a/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md index 7f868cd3..398d83e8 100644 --- a/docs/mxc-compatibility-evidence.md +++ b/docs/mxc-compatibility-evidence.md @@ -41,15 +41,16 @@ the ordinary setup route. It refuses unpackaged execution, never follows reparse points, and stops without a wipe or replacement setup when teardown is incomplete. It does not onboard OpenClaw or start the gateway. -`openclaw` uses `OPENCLAW_SESSION` to select a route: - -| Value | Result | -|---|---| -| Unset | Use the isolated session when the MXC backend reports support; otherwise run directly on the host. | -| `0`, `false`, `off`, or `no` | Run directly on the host. | -| `1`, `true`, `on`, or `yes` | Require the isolated session. A session failure is reported; the launcher does not silently fall back to the host. | - -Unrecognized values are rejected rather than interpreted as disabled. +`openclaw` always runs inside the isolated session recorded by `clawctl setup`. +There is no environment variable, option, or automatic fallback that runs +OpenClaw on the host. Before touching any recorded state, both `clawctl setup` +and `openclaw` require that this machine can host a session: an MXC runtime +that is unavailable, a backend that reports no isolation-session support, a +host build measured as unsupported, or a missing package identity all fail with +the same message, which names Windows Update or a newer Windows version as the +remedy and prints the diagnostic log path. Support that cannot be determined +(the host build is unreadable and the backend probe failed) is not a refusal: +provisioning is attempted so the backend's own error surfaces. `clawctl status` preserves the recorded ownership record and does not provision or replace a session. It is not passive: `ProbeRecordedStatusAsync` invokes the backend `StartAsync` operation for the recorded provision as its status probe. @@ -57,14 +58,10 @@ backend `StartAsync` operation for the recorded provision as its status probe. ## Agent runtime and helper The package application tree is immutable and runs directly from the MSIX; the -launcher does not extract, copy, hash, or repair it at runtime. Direct host -execution installs the bundled Node.js runtime on demand in the invoking -user's LocalState before launching; it does not require a prior `clawctl` -setup. Isolated-session setup is a separate route: -`RunSetupCoreAsync` asks the guest helper to install the runtime under the -agent's own profile, without preparing the invoking user's host runtime. -`clawctl setup --no-isolation` selects the session-free host setup on a -session-capable build without provisioning the isolated session. +launcher does not extract, copy, hash, or repair it at runtime. Setup is the +only route that installs a Node.js runtime: `RunSetupCoreAsync` asks the guest +helper to install the runtime under the agent's own profile. The invoking +user's host runtime is never prepared, because nothing runs on the host. The agent cannot execute the package's WindowsApps helper binary directly. During setup, the launcher stages only `openclaw-session-host.exe` into the @@ -130,10 +127,9 @@ setup. The collector does not enumerate arbitrary agent-profile files. ## Supported operational flow -1. On a session-capable build, run `clawctl setup` after installing or updating - the package to prepare the isolated agent session. For host-only use, - `clawctl setup --no-isolation` is optional because direct host execution - installs its runtime on demand. +1. Run `clawctl setup` after installing or updating the package to prepare the + isolated agent session. It fails on a machine that cannot host a session, + naming Windows Update or a newer Windows version as the remedy. 2. Run `openclaw ` for the upstream OpenClaw CLI, or `clawctl pwsh` for an interactive agent shell. 3. Use `clawctl gateway-service start`, `status`, and `stop` for the managed diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 0737bcb6..eec47bb8 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -16,7 +16,7 @@ internal sealed record ClawCtlHandlers public required Func> GatewayStop { get; init; } } -internal sealed record SetupOptions(bool Fresh, bool Force, bool NoIsolation = false); +internal sealed record SetupOptions(bool Fresh, bool Force); // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the @@ -49,8 +49,10 @@ internal static class ClawCtlCommandLine "Run `openclaw ` to invoke the OpenClaw CLI."; public static string SetupDescription => - "Extract or repair the bundled Node.js runtime in package LocalState " + - "and confirm the packaged OpenClaw application is present."; + "Provision or reuse the owned isolated session, extract or repair the " + + "bundled Node.js runtime, and confirm the packaged OpenClaw " + + "application is present. Requires a Windows build that supports " + + "isolated agent sessions."; // runSetup stays a delegate so the command tree owns parsing and help while // Program keeps the readiness operation and its test seams. @@ -58,15 +60,10 @@ public static RootCommand Create(ClawCtlHandlers handlers) { ArgumentNullException.ThrowIfNull(handlers); Command setup = new(SetupCommandName, SetupDescription); - Option noIsolation = new("--no-isolation") - { - Description = "Prepare the bundled runtime without provisioning an isolated session." - }; Option fresh = new("--fresh") { Description = "Remove this installation's owned session and local state before setting it up again." }; - setup.Options.Add(noIsolation); Option force = new("--force") { Description = "Continue with package-local cleanup when owned external cleanup cannot be confirmed. Requires --fresh." @@ -84,8 +81,7 @@ public static RootCommand Create(ClawCtlHandlers handlers) handlers.Setup( new SetupOptions( parsed.GetValue(fresh), - parsed.GetValue(force), - parsed.GetValue(noIsolation)), + parsed.GetValue(force)), cancellationToken)); Command status = new( StatusCommandName, diff --git a/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs index ec67ef39..94b83776 100644 --- a/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs +++ b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs @@ -80,7 +80,7 @@ public SessionGatewayClient(IMxcSessionClient backend, Action log, _backend = backend; _log = log; _buildEnvironment = buildEnvironment ?? - (() => OpenClawRuntimeEnvironment.Build(GatewayIsolationMode.Enabled)); + OpenClawRuntimeEnvironment.Build; _isCurrentRecord = isCurrentRecord ?? (_ => true); } diff --git a/src/OpenClaw.Launcher/GatewayIsolationMode.cs b/src/OpenClaw.Launcher/GatewayIsolationMode.cs deleted file mode 100644 index f0b5e027..00000000 --- a/src/OpenClaw.Launcher/GatewayIsolationMode.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace OpenClaw.Launcher; - -internal enum GatewayIsolationMode -{ - Disabled, - Enabled -} - -internal static class GatewayIsolationModeExtensions -{ - public static string ToEnvironmentValue(this GatewayIsolationMode mode) => - mode switch - { - GatewayIsolationMode.Disabled => "disabled", - GatewayIsolationMode.Enabled => "enabled", - _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) - }; -} diff --git a/src/OpenClaw.Launcher/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs deleted file mode 100644 index e75d23ea..00000000 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Diagnostics; - -namespace OpenClaw.Launcher; - -internal static class GatewayLauncher -{ - public static async Task RunAsync( - string nodePath, - string payloadDirectory, - IReadOnlyList openClawArguments, - GatewayIsolationMode gatewayIsolationMode, - CancellationToken cancellationToken, - Action? log = null) - { - ProcessStartInfo startInfo = CreateStartInfo( - nodePath, - payloadDirectory, - openClawArguments, - gatewayIsolationMode: gatewayIsolationMode); - log?.Invoke("Launching OpenClaw with forwarded command arguments."); - using WindowsKillOnCloseJob job = WindowsKillOnCloseJob.Create(); - using Process process = job.StartProcess(startInfo); - log?.Invoke($"OpenClaw child process started with PID {process.Id}."); - - try - { - await process.WaitForExitAsync(cancellationToken) - .ConfigureAwait(false); - log?.Invoke($"OpenClaw child process exited with code {process.ExitCode}."); - return process.ExitCode; - } - catch (OperationCanceledException) - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(CancellationToken.None) - .ConfigureAwait(false); - } - - throw; - } - } - - public static ProcessStartInfo CreateStartInfo( - string nodePath, - string applicationDirectory, - IReadOnlyList openClawArguments, - string? workingDirectory = null, - GatewayIsolationMode gatewayIsolationMode = - GatewayIsolationMode.Disabled) - { - string entryPoint = Path.Combine(applicationDirectory, "openclaw.mjs"); - if (!File.Exists(entryPoint)) - { - throw new FileNotFoundException( - "The packaged OpenClaw entry point was not found.", - entryPoint); - } - - var startInfo = new ProcessStartInfo - { - FileName = nodePath, - // Default to the caller's directory, not applicationDirectory: - // the package root is read-only, so OpenClaw's relative-path - // writes need a writable working directory. - WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory, - UseShellExecute = false, - RedirectStandardInput = false, - RedirectStandardOutput = false, - RedirectStandardError = false - }; - startInfo.Environment["OPENCLAW_SUPERVISOR_MODE"] = "external"; - startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"] = "external"; - startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"] = "1"; - string? nodeDirectory = Path.GetDirectoryName(nodePath); - if (!string.IsNullOrEmpty(nodeDirectory)) - { - startInfo.Environment.TryGetValue("PATH", out string? inheritedPath); - startInfo.Environment["PATH"] = string.IsNullOrEmpty(inheritedPath) - ? nodeDirectory - : $"{nodeDirectory}{Path.PathSeparator}{inheritedPath}"; - } - startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"] = - gatewayIsolationMode.ToEnvironmentValue(); - startInfo.ArgumentList.Add(entryPoint); - - foreach (string argument in openClawArguments) - { - startInfo.ArgumentList.Add(argument); - } - - return startInfo; - } -} diff --git a/src/OpenClaw.Launcher/HostStartup.cs b/src/OpenClaw.Launcher/HostStartup.cs index 89713e4f..9b621899 100644 --- a/src/OpenClaw.Launcher/HostStartup.cs +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -19,12 +19,6 @@ internal sealed class HostStartup public required TextWriter Error { get; init; } - public Func>? ResolveNode { get; init; } - - public Func, NodeRuntime>? InstallNodeRuntime { get; init; } - - public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; } - public Session.IInstallationLifecycle? InstallationLifecycle { get; init; } public Func? ReadEnvironmentVariable { get; init; } @@ -36,7 +30,6 @@ internal sealed class HostStartup BaseDirectory = AppContext.BaseDirectory, Output = Console.Out, Error = Console.Error, - InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled, InstallationLifecycle = Session.InstallationLifecycle.Production }; } diff --git a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs index 79f5fb2f..e4171a66 100644 --- a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs +++ b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs @@ -18,6 +18,13 @@ internal static class OpenClawRuntimeEnvironment public const string ExternalValue = "external"; public const string NoAutoUpdateValue = "1"; + + /// + /// Every OpenClaw process this package starts runs inside the isolated + /// session, so the reported isolation state is constant. + /// + public const string GatewayIsolationValue = "enabled"; + private const string ForceColorVariable = "FORCE_COLOR"; private const string WindowsTerminalSessionVariable = "WT_SESSION"; private const string NoColorVariable = "NO_COLOR"; @@ -31,25 +38,23 @@ internal static class OpenClawRuntimeEnvironment /// /// The variables to apply, as an ordinary dictionary. /// - public static IReadOnlyDictionary Build( - GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled) => + public static IReadOnlyDictionary Build() => new Dictionary(StringComparer.OrdinalIgnoreCase) { [SupervisorModeVariable] = ExternalValue, [ServiceRepairPolicyVariable] = ExternalValue, [NoAutoUpdateVariable] = NoAutoUpdateValue, - [GatewayIsolationVariable] = gatewayIsolationMode.ToEnvironmentValue(), + [GatewayIsolationVariable] = GatewayIsolationValue, }; public static IReadOnlyDictionary Build( bool isInteractive, - Func readEnvironmentVariable, - GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled) + Func readEnvironmentVariable) { ArgumentNullException.ThrowIfNull(readEnvironmentVariable); Dictionary result = new( - Build(gatewayIsolationMode), + Build(), StringComparer.OrdinalIgnoreCase); string? forceColor = readEnvironmentVariable(ForceColorVariable); string? wtSession = readEnvironmentVariable(WindowsTerminalSessionVariable); diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index 21de778d..aeab158a 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -109,16 +109,11 @@ PlatformNotSupportedException or WriteDiagnostic, startup.Output, startup.Error, - startup.ResolveNode, - startup.InstallationLifecycle).ConfigureAwait(false) + startup.InstallationLifecycle, + startup.ReadEnvironmentVariable).ConfigureAwait(false) : await RunAgentAsync( options, WriteDiagnostic, - startup.ResolveNode ?? (_ => Task.FromResult( - (startup.InstallNodeRuntime ?? NodeRuntimeInstaller.EnsureInstalled)( - GetPackagedNodeArchivePath(options), - WriteDiagnostic))), - startup.LaunchOpenClaw ?? GatewayLauncher.RunAsync, startup.InstallationLifecycle is null ? null : startup.InstallationLifecycle.CreateRuntime, @@ -143,26 +138,12 @@ startup.InstallationLifecycle is null } } + // Every collaborator after the readiness probe is a test seam: tests + // substitute fakes so they can assert launch behavior without provisioning + // a real isolated session. internal static async Task RunAgentAsync( HostOptions options, Action log, - Func>? resolveNode = null) => - await RunAgentAsync( - options, - log, - resolveNode ?? (_ => Task.FromResult(NodeRuntimeInstaller.EnsureInstalled( - GetPackagedNodeArchivePath(options), - log))), - GatewayLauncher.RunAsync).ConfigureAwait(false); - - // launchOpenClaw is a test seam: tests substitute a fake in place of - // GatewayLauncher.RunAsync so they can assert launch behavior without - // starting a real Node child process or Windows job object. - internal static async Task RunAgentAsync( - HostOptions options, - Action log, - Func> resolveNode, - LaunchOpenClawAsync launchOpenClaw, Func, Session.SessionRuntime>? createSessionRuntime = null, Func>? probeReadiness = null, Func? getPackageFamilyName = null, @@ -172,76 +153,34 @@ internal static async Task RunAgentAsync( string applicationDirectory = GetPackagedApplicationDirectory(options); log("Using the OpenClaw application directly from the package."); - Session.SessionMode mode = Session.SessionRoutingPolicy.ReadMode( - readEnvironmentVariable ?? Environment.GetEnvironmentVariable); - Session.SessionRoutingDecision routing; - string? packageFamilyName = null; - if (mode == Session.SessionMode.Disabled) - { - routing = new Session.SessionRoutingDecision( - Session.SessionRouting.Direct, - $"{Session.SessionRoutingPolicy.ModeVariable} is set to 0."); - } - else - { - Mxc.MxcReadinessReport readiness = await (probeReadiness ?? - Mxc.MxcReadiness.ProbeAsync)(CancellationToken.None).ConfigureAwait(false); - packageFamilyName = - (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(); - routing = Session.SessionRoutingPolicy.Decide( - mode, - packageFamilyName, - readiness); - } - - log(routing.Reason); - if (routing.Routing == Session.SessionRouting.Session) - { - Session.SessionRuntime runtime = (createSessionRuntime ?? - Session.SessionRuntime.Create)(log); - Session.SessionRecord record = - await runtime.StartForExecutionAsync(CancellationToken.None) - .ConfigureAwait(false); - string agentNodePath = runtime.RequireAgentNodePath( - GetPackagedNodeArchivePath(options)); - return await runtime.Executor.ExecuteAsync( - record, - new Session.SessionExecutionRequest( - runtime.RequireStagedHelper(record), - agentNodePath, - applicationDirectory, - options.OpenClawArguments, - record.WorkspacePath!) - { - AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( - (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), - readEnvironmentVariable ?? Environment.GetEnvironmentVariable, - GatewayIsolationMode.Enabled) - }, - CancellationToken.None).ConfigureAwait(false); - } - - if (mode == Session.SessionMode.Automatic && - routing.Routing == Session.SessionRouting.Direct && - packageFamilyName is not null) - { - Session.SessionRuntime runtime = (createSessionRuntime ?? - Session.SessionRuntime.Create)(log); - runtime.ValidateSavedOwnershipForHostFallback(); - } - - NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None) - .ConfigureAwait(false); - log( - $"Using Node.js {nodeRuntime.Version} from " + - $"{nodeRuntime.ExecutablePath}."); - return await launchOpenClaw( - nodeRuntime.ExecutablePath, - applicationDirectory, - options.OpenClawArguments, - GatewayIsolationMode.Disabled, - CancellationToken.None, - log).ConfigureAwait(false); + Mxc.MxcReadinessReport readiness = await (probeReadiness ?? + Mxc.MxcReadiness.ProbeAsync)(CancellationToken.None).ConfigureAwait(false); + Session.SessionSupportPolicy.EnsureSupported( + (getPackageFamilyName ?? (() => HostPaths.Create().PackageFamilyName))(), + readiness); + log("The isolated-session backend is available."); + + Session.SessionRuntime runtime = (createSessionRuntime ?? + Session.SessionRuntime.Create)(log); + Session.SessionRecord record = + await runtime.StartForExecutionAsync(CancellationToken.None) + .ConfigureAwait(false); + string agentNodePath = runtime.RequireAgentNodePath( + GetPackagedNodeArchivePath(options)); + return await runtime.Executor.ExecuteAsync( + record, + new Session.SessionExecutionRequest( + runtime.RequireStagedHelper(record), + agentNodePath, + applicationDirectory, + options.OpenClawArguments, + record.WorkspacePath!) + { + AdditionalEnvironment = OpenClawRuntimeEnvironment.Build( + (isInteractive ?? (() => WindowsHostConsole.Instance.IsInteractive))(), + readEnvironmentVariable ?? Environment.GetEnvironmentVariable) + }, + CancellationToken.None).ConfigureAwait(false); } // output and error are required parameters (not Console defaults) so tests @@ -253,11 +192,9 @@ internal static async Task RunControlAsync( Action log, TextWriter output, TextWriter error, - Func>? resolveNode = null, Session.IInstallationLifecycle? installationLifecycle = null, Func? readEnvironmentVariable = null) { - _ = resolveNode; Session.IInstallationLifecycle lifecycle = installationLifecycle ?? Session.InstallationLifecycle.Production; Session.SessionRuntime? sessionRuntime = null; @@ -274,7 +211,6 @@ Session.SessionRuntime GetSessionRuntime() => lifecycle, log, output, - readEnvironmentVariable ?? Environment.GetEnvironmentVariable, cancellationToken), Status = async cancellationToken => { @@ -392,7 +328,7 @@ await Gateway.GatewayControlOutput.WriteStopAsync(output, result) // losing the diagnostic log entry and the log path Main reports. EnableDefaultExceptionHandler = false, - // Node lifetime is owned by the job object in GatewayLauncher. The + // Node lifetime is owned by the job object in the session host. The // library's termination timeout would add a second, conflicting // forced-exit policy and process-wide signal handlers. ProcessTerminationTimeout = null @@ -411,42 +347,18 @@ private static async Task RunSetupAsync( Session.IInstallationLifecycle lifecycle, Action log, TextWriter output, - Func readEnvironmentVariable, CancellationToken cancellationToken) { string applicationDirectory = GetPackagedApplicationDirectory(options); log("Confirmed the packaged OpenClaw application is present."); - ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); - - Session.SessionRoutingDecision routing = await lifecycle.CheckSessionSupportAsync( - cancellationToken).ConfigureAwait(false); - if (routing.Routing != Session.SessionRouting.Session) - { - await output.WriteLineAsync( - $"OpenClaw setup requires isolated-session support. {routing.Reason}") - .ConfigureAwait(false); - return 1; - } - if (setupOptions.NoIsolation || - Session.SessionRoutingPolicy.ReadMode( - readEnvironmentVariable) == Session.SessionMode.Disabled) - { - if (setupOptions.Fresh) - { - await output.WriteLineAsync( - "OpenClaw setup --fresh requires isolated-session provisioning. Remove --fresh or enable isolation before retrying.") - .ConfigureAwait(false); - return 1; - } + // Throws with the reason and its remediation when this machine cannot + // host a session. There is no session-free setup to fall back to, so + // nothing is reported as ready before this succeeds. + await lifecycle.EnsureSessionSupportedAsync(cancellationToken) + .ConfigureAwait(false); - NodeRuntime nodeRuntime = lifecycle.PrepareHostRuntime(options, log); - ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); - await output.WriteLineAsync( - "OpenClaw setup completed without isolated-session provisioning.") - .ConfigureAwait(false); - return 0; - } + ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); try { @@ -710,8 +622,7 @@ await output.WriteLineAsync( AdditionalEnvironment = Session.SessionExecutor.MergeEnvironment( OpenClawRuntimeEnvironment.Build( WindowsHostConsole.Instance.IsInteractive, - Environment.GetEnvironmentVariable, - GatewayIsolationMode.Enabled), + Environment.GetEnvironmentVariable), Session.AgentToolShim.BuildEnvironment(agentNodePath, applicationDirectory)) }, $"Opening {shell.DisplayName} in the isolated session.", @@ -737,14 +648,6 @@ private static string DescribeSessionStatus(Session.SessionStatus status) => _ => $"Isolated session is recorded: {status.Record!.SandboxId}." }; - internal delegate Task LaunchOpenClawAsync( - string nodePath, - string applicationDirectory, - IReadOnlyList openClawArguments, - GatewayIsolationMode gatewayIsolationMode, - CancellationToken cancellationToken, - Action? log); - private static string GetPackagedApplicationDirectory(HostOptions options) { // Re-check File.Exists here (HostOptions.Parse already checked it) diff --git a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs index eb0aac2d..5b3f1b40 100644 --- a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs +++ b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs @@ -11,11 +11,9 @@ internal interface IInstallationLifecycle { SessionRuntime CreateRuntime(Action log); - Task CheckSessionSupportAsync( + Task EnsureSessionSupportedAsync( CancellationToken cancellationToken); - NodeRuntime PrepareHostRuntime(HostOptions options, Action log); - PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime); ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime); @@ -40,23 +38,16 @@ internal sealed class InstallationLifecycle : IInstallationLifecycle public SessionRuntime CreateRuntime(Action log) => SessionRuntime.Create(log); - public async Task CheckSessionSupportAsync( + public async Task EnsureSessionSupportedAsync( CancellationToken cancellationToken) { MxcReadinessReport readiness = await MxcReadiness.ProbeAsync(cancellationToken) .ConfigureAwait(false); - return SessionRoutingPolicy.Decide( - SessionMode.Automatic, + SessionSupportPolicy.EnsureSupported( HostPaths.Create().PackageFamilyName, readiness); } - public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => - NodeRuntimeInstaller.EnsureInstalled( - options.PackagedNodeArchivePath - ?? throw new FileNotFoundException("The packaged Node.js runtime archive was not found."), - log); - public PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime) { string nodeArchive = options.PackagedNodeArchivePath diff --git a/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs b/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs deleted file mode 100644 index a4c917a2..00000000 --- a/src/OpenClaw.Launcher/Session/SessionRoutingPolicy.cs +++ /dev/null @@ -1,152 +0,0 @@ -using OpenClaw.Launcher.Mxc; - -namespace OpenClaw.Launcher.Session; - -/// -/// Whether the user has asked for, or ruled out, isolated-session execution. -/// -internal enum SessionMode -{ - /// Use a session wherever the backend reports support. - Automatic, - - /// Never use a session. - Disabled, - - /// Use a session, and fail rather than run outside one. - Required, -} - -/// -/// Where an openclaw invocation will run. -/// -internal enum SessionRouting -{ - /// Inside the owned isolated session. - Session, - - /// Directly on the host, as before this feature existed. - Direct, -} - -/// -/// The routing choice and the reason for it, so diagnostics can explain it. -/// -internal sealed record SessionRoutingDecision(SessionRouting Routing, string Reason); - -/// -/// Chooses between isolated-session and direct execution. -/// -internal static class SessionRoutingPolicy -{ - /// - /// Overrides the automatic choice. Unset means automatic. - /// - public const string ModeVariable = "OPENCLAW_SESSION"; - - public static SessionMode ReadMode(Func readEnvironmentVariable) - { - ArgumentNullException.ThrowIfNull(readEnvironmentVariable); - - string? value = readEnvironmentVariable(ModeVariable)?.Trim(); - if (string.IsNullOrEmpty(value)) - { - return SessionMode.Automatic; - } - - // Upper-case normalization, because CA1308 warns that lower-casing can - // lose information for some cultures. The comparison set is ASCII, so - // either direction matches; upper-case is the safe convention. - return value.ToUpperInvariant() switch - { - "0" or "FALSE" or "OFF" or "NO" => SessionMode.Disabled, - "1" or "TRUE" or "ON" or "YES" => SessionMode.Required, - - // An unrecognized value is not treated as "off". Silently ignoring - // it would run outside the session the user was trying to request. - _ => throw new SessionException( - $"{ModeVariable} is set to '{value}', which is not one of " + - "1, 0, true, false, on, off, yes, or no."), - }; - } - - /// - /// Decides where to run. - /// - /// - /// - /// A machine that cannot host sessions runs directly, exactly as it did - /// before this feature existed. That is a capability of the machine, not a - /// failure to hide. - /// - /// - /// There is deliberately no fallback once a session is chosen. A backend - /// that breaks on a supported machine must surface, not quietly relocate - /// the user's work onto the host with a different profile and different - /// isolation. - /// - /// - public static SessionRoutingDecision Decide( - SessionMode mode, - string? packageFamilyName, - MxcReadinessReport readiness) - { - ArgumentNullException.ThrowIfNull(readiness); - - if (mode == SessionMode.Disabled) - { - return new SessionRoutingDecision( - SessionRouting.Direct, - $"{ModeVariable} is set to 0."); - } - - if (packageFamilyName is null) - { - return Unavailable( - mode, - "OpenClaw is not running from its installed package, so it has " + - "no identity to provision an isolated session with."); - } - - if (!readiness.RuntimeAvailable) - { - return Unavailable( - mode, - "The isolated-session runtime is unavailable: " + - (readiness.RuntimeUnavailableReason ?? "no reason was reported.")); - } - - if (readiness.BackendProbe is { IsolationSessionAvailable: false }) - { - return Unavailable( - mode, - "This machine's isolated-session backend reported that it is " + - "not available."); - } - - if (readiness.BackendProbe is null && - readiness.HostSupport != MxcHostSupport.Supported) - { - string detail = readiness.HostSupport == MxcHostSupport.Unsupported - ? "This Windows build does not support isolated agent sessions." - : "Isolated-session support could not be determined on this machine."; - return Unavailable( - mode, - readiness.BackendProbeFailureReason is null - ? detail - : $"{detail} The backend probe failed: " + - readiness.BackendProbeFailureReason); - } - - return new SessionRoutingDecision( - SessionRouting.Session, - "The isolated-session backend is available."); - } - - private static SessionRoutingDecision Unavailable(SessionMode mode, string reason) => - mode == SessionMode.Required - ? throw new SessionException( - $"{ModeVariable} requires an isolated session, but one cannot " + - $"be used. {reason}") - : new SessionRoutingDecision(SessionRouting.Direct, reason); -} diff --git a/src/OpenClaw.Launcher/Session/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs index 295697d4..6d86d41a 100644 --- a/src/OpenClaw.Launcher/Session/SessionRuntime.cs +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -202,58 +202,6 @@ session.Detail is null return session.Record; } - /// - /// Validates local ownership before automatic host fallback. - /// - /// - /// Automatic fallback is allowed only when this installation has no saved - /// session or setup state. A foreign, corrupt, or mismatched record is - /// evidence that replacement or recovery is required, not permission to - /// run the user's workload under a different profile. A session record - /// without its setup marker must be completed with clawctl setup. - /// - public void ValidateSavedOwnershipForHostFallback() - { - SessionStatus session = Coordinator.GetRecordedStatus(); - SetupStateResult setup = SetupState.Read(ApplicationId); - if (session.Availability == SessionAvailability.None && - setup.Fault == SetupStateFault.Missing) - { - return; - } - - if (session.Record is null) - { - throw new SessionException( - $"{session.Detail ?? "The saved isolated-session record could not be used."} " + - "Run `clawctl setup` to repair it."); - } - - if (setup.Record is null) - { - throw new SessionException( - "The isolated session is recorded but explicit setup has not completed. " + - "Run `clawctl setup` before using automatic host fallback."); - } - - if (setup.Record.Phase != SetupPhase.Ready) - { - throw new SessionException( - "The saved isolated-session setup is incomplete. " + - "Run `clawctl setup` before using automatic host fallback."); - } - - if (!string.Equals( - setup.Record.SandboxId, - session.Record.SandboxId, - StringComparison.Ordinal)) - { - throw new SessionException( - "The saved isolated-session records name different sessions. " + - "Run `clawctl setup` to reconcile them."); - } - } - /// /// Returns the Node.js executable extracted by the agent for the currently /// packaged runtime. diff --git a/src/OpenClaw.Launcher/Session/SessionSupportPolicy.cs b/src/OpenClaw.Launcher/Session/SessionSupportPolicy.cs new file mode 100644 index 00000000..cf7921c0 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionSupportPolicy.cs @@ -0,0 +1,82 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// +/// Decides whether this machine can host the isolated agent session that every +/// OpenClaw invocation now requires. +/// +/// +/// +/// There is exactly one execution path. A machine that cannot host a session +/// fails loudly instead of quietly relocating the user's work onto the host +/// with a different profile and different isolation. +/// +/// +/// Indeterminate support is not a refusal. When the Windows build cannot be +/// read and the backend probe could not run, provisioning is attempted so the +/// backend's own error surfaces rather than a guess made from missing evidence. +/// +/// +internal static class SessionSupportPolicy +{ + /// + /// What the user can do about a machine that cannot host a session. + /// + public const string Remediation = + "Isolated agent sessions require a newer version of Windows. Install " + + "the latest Windows updates (Settings > Windows Update), or install a " + + "newer Windows version, and run `clawctl setup` again. See the " + + "requirements section of the OpenClaw Gateway MSIX README: " + + "https://github.com/openclaw/openclaw-windows-packaging#requirements"; + + /// + /// Throws when this machine cannot host an isolated session. + /// + /// + /// The machine cannot host a session. The message carries both the reason + /// and . + /// + public static void EnsureSupported( + string? packageFamilyName, + MxcReadinessReport readiness) + { + ArgumentNullException.ThrowIfNull(readiness); + + if (packageFamilyName is null) + { + throw Unsupported( + "OpenClaw is not running from its installed package, so it has " + + "no identity to provision an isolated session with."); + } + + if (!readiness.RuntimeAvailable) + { + throw Unsupported( + "The isolated-session runtime is unavailable: " + + (readiness.RuntimeUnavailableReason ?? "no reason was recorded.")); + } + + if (readiness.BackendProbe is { IsolationSessionAvailable: false }) + { + throw Unsupported( + "This machine's isolated-session backend reported that it is " + + "not available."); + } + + if (readiness.BackendProbe is null && + readiness.HostSupport == MxcHostSupport.Unsupported) + { + string detail = + "This Windows build does not support isolated agent sessions."; + throw Unsupported( + readiness.BackendProbeFailureReason is null + ? detail + : $"{detail} The backend probe failed: " + + readiness.BackendProbeFailureReason); + } + } + + private static SessionException Unsupported(string reason) => + new($"{reason} {Remediation}"); +} diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 5de0b177..27008d39 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Session; using LauncherProgram = OpenClaw.Launcher.Program; namespace OpenClaw.Launcher.AotSmoke; @@ -43,7 +44,7 @@ private static async Task Main() ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), ("missing application reports diagnostics", MissingApplicationReportsAsync), - ("openclaw forwards arguments verbatim", AgentForwardsArgumentsAsync) + ("openclaw never parses its arguments", AgentNeverParsesItsArgumentsAsync) ]; int failures = 0; @@ -104,7 +105,7 @@ private static async Task BareInvocationPrintsHelpAsync() AssertContains(fixture.Output.ToString(), "clawctl", fixture); AssertContains(fixture.Output.ToString(), "setup", fixture); AssertContains(fixture.Output.ToString(), "Node.js", fixture); - fixture.AssertNodeWasNotResolved(); + fixture.AssertNoInstallationWorkStarted(); fixture.AssertLogRecordsStartupAndExit(); } @@ -117,7 +118,7 @@ private static async Task HelpOptionPrintsHelpAsync() AssertExitCode(0, exitCode, fixture); AssertContains(fixture.Output.ToString(), "setup", fixture); AssertContains(fixture.Output.ToString(), "--version", fixture); - fixture.AssertNodeWasNotResolved(); + fixture.AssertNoInstallationWorkStarted(); } private static async Task SetupHelpPrintsCommandHelpAsync() @@ -128,7 +129,7 @@ private static async Task SetupHelpPrintsCommandHelpAsync() AssertExitCode(0, exitCode, fixture); AssertContains(fixture.Output.ToString(), "clawctl setup", fixture); - fixture.AssertNodeWasNotResolved(); + fixture.AssertNoInstallationWorkStarted(); } // This driver's assembly version is 9.9.9.9. The library's built-in action @@ -175,7 +176,7 @@ private static async Task UnknownCommandFailsAsync() AssertExitCode(1, exitCode, fixture); AssertContains(fixture.Error.ToString(), "bogus", fixture); - fixture.AssertNodeWasNotResolved(); + fixture.AssertNoInstallationWorkStarted(); } private static async Task SetupForceRequiresFreshAsync() @@ -186,7 +187,7 @@ private static async Task SetupForceRequiresFreshAsync() AssertExitCode(1, exitCode, fixture); AssertContains(fixture.Error.ToString(), "requires option '--fresh'", fixture); - fixture.AssertNodeWasNotResolved(); + fixture.AssertNoInstallationWorkStarted(); } // Response-file expansion is disabled, so a readable file behind an `@` @@ -200,7 +201,7 @@ private static async Task ResponseFileTokenIsNotExpandedAsync() int exitCode = await fixture.RunAsync([$"@{responseFile}"]).ConfigureAwait(false); AssertExitCode(1, exitCode, fixture); - fixture.AssertNodeWasNotResolved(); + fixture.AssertNoInstallationWorkStarted(); } private static async Task CompletionDirectiveSuggestsAsync() @@ -211,21 +212,29 @@ private static async Task CompletionDirectiveSuggestsAsync() AssertExitCode(0, exitCode, fixture); AssertContains(fixture.Output.ToString(), "setup", fixture); - fixture.AssertNodeWasNotResolved(); + fixture.AssertNoInstallationWorkStarted(); } private static async Task SetupReportsReadinessAsync() { - using Fixture fixture = await Fixture.CreateWithApplicationAsync().ConfigureAwait(false); + using Fixture fixture = await Fixture + .CreateWithApplicationAsync(allowInstallationWork: true) + .ConfigureAwait(false); int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); AssertExitCode(1, exitCode, fixture); - AssertContains(fixture.Output.ToString(), fixture.ApplicationDirectory, fixture); AssertContains( - fixture.Output.ToString(), + fixture.Error.ToString(), "not running from its installed package", fixture); + AssertContains(fixture.Error.ToString(), "newer version of Windows", fixture); + AssertContains(fixture.Error.ToString(), fixture.LogPath, fixture); + + // Nothing is reported as ready when the support check refuses. + Assert( + fixture.Output.ToString().Length == 0, + "A failed support check still reported readiness on standard output."); fixture.AssertLogRecordsStartupAndExit(); Assert( File.Exists(fixture.EntryPoint), @@ -249,30 +258,22 @@ private static async Task MissingApplicationReportsAsync() } // The agent entrypoint must never consult System.CommandLine. These tokens - // are all meaningful to the clawctl parser and must survive untouched. - private static async Task AgentForwardsArgumentsAsync() + // are all meaningful to the clawctl parser, so if the parser ever saw them + // this scenario would render help and exit zero. + private static async Task AgentNeverParsesItsArgumentsAsync() { string[] arguments = ["--help", "--version", "--", "@response.rsp", "[suggest:1]", "", "a b"]; - using Fixture fixture = await Fixture - .CreateWithApplicationAsync(HostEntrypoint.Agent) - .ConfigureAwait(false); + using Fixture fixture = Fixture.CreateWithoutApplication(HostEntrypoint.Agent); int exitCode = await fixture.RunAsync(arguments).ConfigureAwait(false); - AssertExitCode(23, exitCode, fixture); - string[]? forwarded = fixture.ForwardedArguments; - Assert(forwarded is not null, "The agent path never reached the launch delegate."); + AssertExitCode(1, exitCode, fixture); Assert( - forwarded!.Length == arguments.Length, - $"Expected {arguments.Length} forwarded arguments but got {forwarded.Length}."); - for (int index = 0; index < arguments.Length; index++) - { - Assert( - string.Equals(forwarded[index], arguments[index], StringComparison.Ordinal), - $"Argument {index} was rewritten from '{arguments[index]}' to " + - $"'{forwarded[index]}'."); - } + fixture.Output.ToString().Length == 0, + "The agent entrypoint rendered clawctl output for forwarded arguments."); + AssertContains(fixture.Error.ToString(), fixture.LogPath, fixture); + fixture.AssertLogRecordsStartupAndExit(); } private static string LauncherVersion() => @@ -309,15 +310,14 @@ private static void Assert(bool condition, string message) private sealed class Fixture : IDisposable { - public const string NodePath = @"C:\fixture\node.exe"; - private readonly HostEntrypoint _entrypoint; - private bool _nodeResolved; + private readonly WorkTrackingLifecycle? _lifecycle; - private Fixture(string root, HostEntrypoint entrypoint) + private Fixture(string root, HostEntrypoint entrypoint, bool allowInstallationWork) { Root = root; _entrypoint = entrypoint; + _lifecycle = allowInstallationWork ? null : new WorkTrackingLifecycle(); LogPath = Path.Combine(root, "diagnostics", "openclaw.log"); ApplicationDirectory = Path.Combine(root, "app"); EntryPoint = Path.Combine(ApplicationDirectory, "openclaw.mjs"); @@ -335,16 +335,17 @@ private Fixture(string root, HostEntrypoint entrypoint) public StringWriter Error { get; } = new(); - public string[]? ForwardedArguments { get; private set; } - public static Fixture CreateWithoutApplication( HostEntrypoint entrypoint = HostEntrypoint.Control) => - new(CreateRoot(), entrypoint); + new(CreateRoot(), entrypoint, allowInstallationWork: false); + // Scenarios that must reach the production support check opt in; every + // other scenario gets a lifecycle that refuses to do real work. public static async Task CreateWithApplicationAsync( - HostEntrypoint entrypoint = HostEntrypoint.Control) + HostEntrypoint entrypoint = HostEntrypoint.Control, + bool allowInstallationWork = false) { - Fixture fixture = new(CreateRoot(), entrypoint); + Fixture fixture = new(CreateRoot(), entrypoint, allowInstallationWork); Directory.CreateDirectory(fixture.ApplicationDirectory); await File.WriteAllTextAsync(fixture.EntryPoint, "console.log('fixture');") .ConfigureAwait(false); @@ -360,29 +361,17 @@ public async Task RunAsync(string[] args) BaseDirectory = Root, Output = Output, Error = Error, - ResolveNode = _ => - { - _nodeResolved = true; - return Task.FromResult( - new NodeRuntime( - NodePath, - new Version(24, 15, 0), - RuntimeInformation.ProcessArchitecture)); - }, - LaunchOpenClaw = (_, _, forwarded, _, _, _) => - { - ForwardedArguments = [.. forwarded]; - return Task.FromResult(23); - } + InstallationLifecycle = + _lifecycle ?? (IInstallationLifecycle)InstallationLifecycle.Production }; return await LauncherProgram.RunAsync(args, startup).ConfigureAwait(false); } - public void AssertNodeWasNotResolved() => + public void AssertNoInstallationWorkStarted() => Assert( - !_nodeResolved, - "Node resolution ran for an invocation that must not need a runtime."); + _lifecycle is not null && !_lifecycle.WorkStarted, + "Installation work ran for an invocation that must not start it."); public void AssertLogRecordsStartupAndExit() { @@ -406,6 +395,47 @@ public void Dispose() } } + // Records whether the command reached real installation work, and + // refuses to perform any. Help, version, rejected input, and a missing + // application must all fail or finish before touching this. + private sealed class WorkTrackingLifecycle : IInstallationLifecycle + { + public bool WorkStarted { get; private set; } + + private InvalidOperationException Started() + { + WorkStarted = true; + return new InvalidOperationException( + "The scenario driver never performs installation work."); + } + + public SessionRuntime CreateRuntime(Action log) => throw Started(); + + public Task EnsureSessionSupportedAsync(CancellationToken cancellationToken) => + throw Started(); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime sessionRuntime) => throw Started(); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + throw Started(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => throw Started(); + + public IInstallationStateCleaner CreateStateCleaner( + SessionRuntime sessionRuntime) => throw Started(); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => throw Started(); + } + private static string CreateRoot() { // Scenario state lives under the driver's own output directory diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index 5110948f..95077a10 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -5,12 +5,6 @@ namespace OpenClaw.Launcher.Tests; public sealed class ClawCtlCommandLineTests { - // Any invocation that reaches this resolver has started the readiness - // operation, which help, version, and rejected input must never do. - private static Task FailIfSetupRuns(CancellationToken _) => - throw new InvalidOperationException( - "Setup ran for an invocation that should not have started it."); - private static async Task<(int ExitCode, string Output, string Error)> RunAsync( params string[] args) { @@ -23,7 +17,7 @@ private static Task FailIfSetupRuns(CancellationToken _) => _ => { }, output, error, - FailIfSetupRuns).ConfigureAwait(false); + new FailIfWorkStartsLifecycle()).ConfigureAwait(false); return (exitCode, output.ToString(), error.ToString()); } diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs index b1442cef..0ff7212a 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -10,10 +10,6 @@ public sealed class ClawCtlParserDefaultsTests : IDisposable { private readonly string _testDirectory = TestDirectory.Create(); - private static Task FailIfSetupRuns(CancellationToken _) => - throw new InvalidOperationException( - "Setup ran for an invocation that should not have started it."); - private static async Task<(int ExitCode, string Output)> RunAsync( params string[] args) { @@ -25,7 +21,7 @@ private static Task FailIfSetupRuns(CancellationToken _) => _ => { }, output, TextWriter.Null, - FailIfSetupRuns).ConfigureAwait(false); + new FailIfWorkStartsLifecycle()).ConfigureAwait(false); return (exitCode, output.ToString()); } diff --git a/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs b/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs new file mode 100644 index 00000000..53ca57ce --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs @@ -0,0 +1,38 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests; + +// Any invocation that reaches this lifecycle has started real installation +// work, which help, version, and rejected input must never do. +internal sealed class FailIfWorkStartsLifecycle : IInstallationLifecycle +{ + private static InvalidOperationException Fail() => + new("Setup ran for an invocation that should not have started it."); + + public SessionRuntime CreateRuntime(Action log) => throw Fail(); + + public Task EnsureSessionSupportedAsync(CancellationToken cancellationToken) => + throw Fail(); + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime sessionRuntime) => throw Fail(); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + throw Fail(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => throw Fail(); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + throw Fail(); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => throw Fail(); +} diff --git a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs deleted file mode 100644 index 8f26d62c..00000000 --- a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs +++ /dev/null @@ -1,171 +0,0 @@ -namespace OpenClaw.Launcher.Tests; - -public sealed class GatewayLauncherTests : IDisposable -{ - private readonly string _payloadDirectory = TestDirectory.Create(); - private readonly string _workingDirectory = TestDirectory.Create(); - - public GatewayLauncherTests() - { - File.WriteAllText( - Path.Combine(_payloadDirectory, "openclaw.mjs"), - "console.log('fixture');"); - } - - [Fact] - public void CreateStartInfoForwardsNoArgumentsUnchanged() - { - var startInfo = GatewayLauncher.CreateStartInfo( - "node", - _payloadDirectory, - [], - _workingDirectory); - - Assert.False(startInfo.UseShellExecute); - Assert.False(startInfo.RedirectStandardError); - Assert.Equal(_workingDirectory, startInfo.WorkingDirectory); - Assert.Equal( - "external", - startInfo.Environment["OPENCLAW_SUPERVISOR_MODE"]); - Assert.Equal( - "external", - startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); - Assert.Equal("1", startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"]); - Assert.Equal( - "disabled", - startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); - Assert.Equal( - [Path.Combine(_payloadDirectory, "openclaw.mjs")], - startInfo.ArgumentList); - } - - [Fact] - public void CreateStartInfoDefaultsToCurrentWorkingDirectory() - { - var startInfo = GatewayLauncher.CreateStartInfo( - "node", - _payloadDirectory, - []); - - Assert.Equal(Environment.CurrentDirectory, startInfo.WorkingDirectory); - Assert.NotEqual(_payloadDirectory, startInfo.WorkingDirectory); - } - - [Fact] - public void CreateStartInfoPrependsBundledRuntimeOnlyToChildPath() - { - string? inheritedPath = Environment.GetEnvironmentVariable("PATH"); - string nodeDirectory = Path.Combine(_payloadDirectory, "runtime"); - - var startInfo = GatewayLauncher.CreateStartInfo( - Path.Combine(nodeDirectory, "node.exe"), - _payloadDirectory, - []); - - string expectedPath = string.IsNullOrEmpty(inheritedPath) - ? nodeDirectory - : $"{nodeDirectory}{Path.PathSeparator}{inheritedPath}"; - Assert.Equal(expectedPath, startInfo.Environment["PATH"]); - Assert.Equal(inheritedPath, Environment.GetEnvironmentVariable("PATH")); - } - - [Fact] - public void CreateStartInfoPreservesExplicitArguments() - { - string[] arguments = ["status", "--json", "value with spaces"]; - - var startInfo = GatewayLauncher.CreateStartInfo( - "node", - _payloadDirectory, - arguments); - - Assert.False(startInfo.RedirectStandardError); - Assert.Equal(Environment.CurrentDirectory, startInfo.WorkingDirectory); - Assert.Equal( - "external", - startInfo.Environment["OPENCLAW_SUPERVISOR_MODE"]); - Assert.Equal( - "external", - startInfo.Environment["OPENCLAW_SERVICE_REPAIR_POLICY"]); - Assert.Equal("1", startInfo.Environment["OPENCLAW_NO_AUTO_UPDATE"]); - Assert.Equal( - "disabled", - startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); - Assert.Equal( - [Path.Combine(_payloadDirectory, "openclaw.mjs"), .. arguments], - startInfo.ArgumentList); - } - - [Theory] - [InlineData((int)GatewayIsolationMode.Disabled, "disabled")] - [InlineData((int)GatewayIsolationMode.Enabled, "enabled")] - public void CreateStartInfoReportsExactGatewayIsolationMode( - int mode, - string expected) - { - var startInfo = GatewayLauncher.CreateStartInfo( - "node", - _payloadDirectory, - [], - gatewayIsolationMode: (GatewayIsolationMode)mode); - - Assert.Equal( - expected, - startInfo.Environment["CLAWCTL_GATEWAY_ISOLATION"]); - } - - [Theory] - [InlineData(-1)] - [InlineData(2)] - [InlineData(int.MaxValue)] - public void CreateStartInfoRejectsInvalidGatewayIsolationMode(int mode) - { - Assert.Throws(() => - GatewayLauncher.CreateStartInfo( - "node", - _payloadDirectory, - [], - gatewayIsolationMode: (GatewayIsolationMode)mode)); - } - - [Theory] - [InlineData("update", "--yes")] - [InlineData("--update")] - [InlineData("gateway", "call", "update.run")] - [InlineData("gateway", "install")] - [InlineData("setup", "--install-daemon")] - [InlineData("onboard", "--mode", "local")] - - // clawctl now parses its own arguments with System.CommandLine. These - // tokens are the ones that library would claim, so they prove the openclaw - // entrypoint still forwards instead of parsing. - [InlineData("--help")] - [InlineData("-h")] - [InlineData("-?")] - [InlineData("--version")] - [InlineData("--")] - [InlineData("--", "--version")] - [InlineData("@arguments.rsp")] - [InlineData("[suggest:3]", "se")] - [InlineData("")] - [InlineData("chat", "", "--prompt", "value with spaces")] - public void CreateStartInfoForwardsCommandsWithoutInterpretation( - params string[] arguments) - { - var startInfo = GatewayLauncher.CreateStartInfo( - "node", - _payloadDirectory, - arguments); - - Assert.Equal( - [Path.Combine(_payloadDirectory, "openclaw.mjs"), .. arguments], - startInfo.ArgumentList); - } - - public void Dispose() - { - Directory.Delete(_payloadDirectory, recursive: true); - Directory.Delete(_workingDirectory, recursive: true); - GC.SuppressFinalize(this); - } -} diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index 3141b942..875e21d9 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -1,5 +1,3 @@ -using System.Runtime.InteropServices; - namespace OpenClaw.Launcher.Tests; // Startup is the part of the host that owns diagnostics, argument routing, the @@ -85,105 +83,41 @@ public async Task UnavailableDiagnosticsWarnsOnceAndStillRunsTheCommand() } // Startup routes the agent alias without letting the clawctl parser see - // the arguments, and the child's exit code is the host's exit code. + // the arguments. On a machine that cannot host a session it fails loudly, + // and the failure is the host's, not the parser's. [Fact] - public async Task AgentStartupForwardsArgumentsAndChildExitCode() + public async Task AgentStartupFailsLoudlyWithoutParsingItsArguments() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); await File.WriteAllTextAsync( Path.Combine(applicationDirectory, "openclaw.mjs"), "console.log('fixture');"); + string logPath = Path.Combine(_testDirectory, "logs", "openclaw.log"); string[] arguments = ["--help", "--", "@file.rsp", "[suggest:1]", "", "a b"]; - string[]? forwarded = null; using var output = new StringWriter(); using var error = new StringWriter(); HostStartup startup = new() { Entrypoint = HostEntrypoint.Agent, - CreateDiagnostics = () => HostDiagnosticLog.Create( - Path.Combine(_testDirectory, "logs", "openclaw.log")), + CreateDiagnostics = () => HostDiagnosticLog.Create(logPath), BaseDirectory = _testDirectory, Output = output, - Error = error, - ResolveNode = _ => Task.FromResult( - new NodeRuntime( - Path.Combine(_testDirectory, "node.exe"), - new Version(24, 15, 0), - RuntimeInformation.ProcessArchitecture)), - LaunchOpenClaw = (_, _, launchArguments, _, _, _) => - { - forwarded = [.. launchArguments]; - return Task.FromResult(23); - } + Error = error }; int exitCode = await Program.RunAsync(arguments, startup); - Assert.Equal(23, exitCode); - Assert.Equal(arguments, forwarded); + Assert.Equal(1, exitCode); Assert.Empty(output.ToString()); + Assert.Contains(logPath, error.ToString(), StringComparison.Ordinal); + Assert.Contains( + "newer version of Windows", + error.ToString(), + StringComparison.Ordinal); } - // This catches production startup bypassing the installer after setup put - // Node.js only in the agent profile. - [Fact] - public async Task AgentStartupInstallsTheHostRuntimeBeforeLaunching() - { - string applicationDirectory = Path.Combine(_testDirectory, "app"); - string runtimeDirectory = Path.Combine(_testDirectory, "runtime"); - Directory.CreateDirectory(applicationDirectory); - Directory.CreateDirectory(runtimeDirectory); - await File.WriteAllTextAsync( - Path.Combine(applicationDirectory, "openclaw.mjs"), - "console.log('fixture');"); - string archivePath = Path.Combine( - runtimeDirectory, - $"node-v24.15.0-win-{GetArchitectureName()}.zip"); - await File.WriteAllTextAsync(archivePath, "fixture"); - string? installedArchive = null; - bool launched = false; - using var output = new StringWriter(); - using var error = new StringWriter(); - - HostStartup startup = new() - { - Entrypoint = HostEntrypoint.Agent, - CreateDiagnostics = () => HostDiagnosticLog.Create( - Path.Combine(_testDirectory, "logs", "openclaw.log")), - BaseDirectory = _testDirectory, - Output = output, - Error = error, - ReadEnvironmentVariable = name => - name == OpenClaw.Launcher.Session.SessionRoutingPolicy.ModeVariable - ? "0" - : null, - InstallNodeRuntime = (archive, _) => - { - installedArchive = archive; - return new NodeRuntime( - Path.Combine(_testDirectory, "node.exe"), - new Version(24, 15, 0), - RuntimeInformation.ProcessArchitecture); - }, - LaunchOpenClaw = (_, _, _, _, _, _) => - { - launched = true; - return Task.FromResult(0); - } - }; - - int exitCode = await Program.RunAsync(["--help"], startup); - - Assert.Equal(0, exitCode); - Assert.Equal(archivePath, installedArchive); - Assert.True(launched); - } - - private static string GetArchitectureName() => - RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64"; - private HostStartup CreateStartup( string logPath, TextWriter output, @@ -193,12 +127,7 @@ private HostStartup CreateStartup( CreateDiagnostics = () => HostDiagnosticLog.Create(logPath), BaseDirectory = _testDirectory, Output = output, - Error = error, - ResolveNode = _ => Task.FromResult( - new NodeRuntime( - Path.Combine(_testDirectory, "node.exe"), - new Version(24, 15, 0), - RuntimeInformation.ProcessArchitecture)) + Error = error }; public void Dispose() diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 58ae4219..02f57b0e 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -12,55 +12,87 @@ public sealed class ProgramTests : IDisposable private FakeMxcSessionClient? _lastSessionBackend; [Fact] - public async Task AgentLaunchResolvesNodeAndRunsPackagedApplication() + public async Task AgentForwardsEveryArgumentIntoTheSession() { - string applicationDirectory = Path.Combine(_testDirectory, "app"); - Directory.CreateDirectory(applicationDirectory); - await File.WriteAllTextAsync( - Path.Combine(applicationDirectory, "openclaw.mjs"), - "console.log('fixture');"); - string[] arguments = ["gateway", "run", "--port", "12345"]; - var options = new HostOptions(applicationDirectory, null, arguments); - var nodeRuntime = new NodeRuntime( - Path.Combine(_testDirectory, "node.exe"), - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); - bool nodeResolutionAttempted = false; - bool launchAttempted = false; + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions setupOptions = CreateSetupOptions(applicationDirectory); + SessionRuntime runtime = CreateSessionRuntime(); + int setupExitCode = await Program.RunControlAsync( + setupOptions, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }); + Assert.Equal(0, setupExitCode); + + string[] arguments = ["gateway", "run", "--port", "12345", "--", "a b"]; + string[]? forwarded = null; + _lastSessionBackend!.AttachedBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "launch-*.json").Single(); + SessionLaunchRequest request = SessionLaunchProtocol.ReadRequest( + File.ReadAllText(requestPath)); + forwarded = [.. request.Arguments!.Skip(1)]; + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionLaunchProtocol.SerializeResult(new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 7, + })); + return Task.FromResult(0); + }; int exitCode = await Program.RunAgentAsync( - options, + new HostOptions( + applicationDirectory, + setupOptions.PackagedNodeArchivePath, + arguments), _ => { }, - _ => - { - nodeResolutionAttempted = true; - return Task.FromResult(nodeRuntime); - }, - ( - nodePath, - appDirectory, - forwardedArguments, - gatewayIsolationMode, - _, - _) => - { - launchAttempted = true; - Assert.Equal(nodeRuntime.ExecutablePath, nodePath); - Assert.Equal(applicationDirectory, appDirectory); - Assert.Equal(arguments, forwardedArguments); - Assert.Equal( - GatewayIsolationMode.Disabled, - gatewayIsolationMode); - return Task.FromResult(23); - }); - - Assert.True(nodeResolutionAttempted); - Assert.True(launchAttempted); - Assert.Equal(23, exitCode); + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: _ => null); + + Assert.Equal(arguments, forwarded); + Assert.Equal(7, exitCode); } [Fact] - public async Task SetupReportsAnUnavailableIsolatedSessionWithoutResolvingHostNode() + public async Task AgentFailsLoudlyWhenTheMachineCannotHostASession() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + bool runtimeCreated = false; + + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, ["--version"]), + _ => { }, + _ => + { + runtimeCreated = true; + return CreateSessionRuntime(); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null)); + + Assert.False(runtimeCreated); + AssertRecommendsNewerWindows(failure.Message); + } + + [Fact] + public async Task SetupFailsLoudlyWhenTheMachineCannotHostASession() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); @@ -68,35 +100,24 @@ public async Task SetupReportsAnUnavailableIsolatedSessionWithoutResolvingHostNo await File.WriteAllTextAsync(entryPoint, "console.log('fixture');"); DateTime lastWriteTime = File.GetLastWriteTimeUtc(entryPoint); var options = new HostOptions(applicationDirectory, null, []); - var nodeRuntime = new NodeRuntime( - Path.Combine(_testDirectory, "node.exe"), - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); using var output = new StringWriter(); - int exitCode = await Program.RunControlAsync( - options, - ["setup"], - _ => { }, - output, - TextWriter.Null, - _ => Task.FromResult(nodeRuntime)); + // The test host is unpackaged, so production readiness reports a + // machine that cannot own a session. + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunControlAsync( + options, + ["setup"], + _ => { }, + output, + TextWriter.Null)); - Assert.Equal(1, exitCode); + AssertRecommendsNewerWindows(failure.Message); Assert.True(File.Exists(entryPoint)); Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(entryPoint)); - Assert.DoesNotContain( - nodeRuntime.ExecutablePath, - output.ToString(), - StringComparison.Ordinal); - Assert.Contains( - applicationDirectory, - output.ToString(), - StringComparison.Ordinal); - Assert.Contains( - "requires isolated-session support", - output.ToString(), - StringComparison.OrdinalIgnoreCase); + + // Nothing is reported as ready on a machine that cannot host a session. + Assert.Equal(string.Empty, output.ToString()); } [Fact] @@ -118,7 +139,6 @@ public async Task AgentUsesTheRuntimeInstalledForTheSessionWithoutHostFallback() string expectedAgentNode = runtime.SetupState .Read(runtime.ApplicationId).Record!.AgentNodePath!; - bool directLaunchAttempted = false; _lastSessionBackend!.AttachedBehavior = _ => { string requestPath = Directory.GetFiles( @@ -139,12 +159,6 @@ public async Task AgentUsesTheRuntimeInstalledForTheSessionWithoutHostFallback() await Assert.ThrowsAsync(() => Program.RunAgentAsync( options, _ => { }, - _ => throw new InvalidOperationException("Host Node must not be resolved."), - (_, _, _, _, _, _) => - { - directLaunchAttempted = true; - return Task.FromResult(0); - }, _ => runtime, _ => Task.FromResult(new MxcReadinessReport( "runtime", @@ -155,16 +169,10 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( MxcSupportEvidence.HostBuild)), () => "OpenClaw.Gateway_test", _ => null)); - - Assert.False(directLaunchAttempted); } - [Theory] - [InlineData("corrupt")] - [InlineData("foreign")] - [InlineData("mismatched")] - public async Task AutomaticHostFallbackRefusesSavedOwnershipFailures( - string stateKind) + [Fact] + public async Task UnsupportedMachineFailsBeforeTouchingSavedSessionState() { SessionRuntime runtime = CreateSessionRuntime(); string applicationDirectory = Path.Combine(_testDirectory, "fallback-app"); @@ -173,77 +181,30 @@ await File.WriteAllTextAsync( Path.Combine(applicationDirectory, "openclaw.mjs"), "fixture").ConfigureAwait(true); Directory.CreateDirectory(Path.GetDirectoryName(runtime.Paths.SessionStatePath)!); - SessionRecord session = new() - { - ApplicationId = runtime.ApplicationId, - SandboxId = "iso:saved", - WorkspacePath = Path.Combine(_testDirectory, "workspace"), - Generation = "test-generation", - CreatedUtc = DateTimeOffset.UtcNow - }; - Directory.CreateDirectory(session.WorkspacePath!); - - switch (stateKind) - { - case "corrupt": - await File.WriteAllTextAsync( - runtime.Paths.SessionStatePath, - "{ not json").ConfigureAwait(true); - break; - case "foreign": - await File.WriteAllTextAsync( - runtime.Paths.SessionStatePath, - """ - {"schemaVersion":1,"sandboxId":"iso:foreign","applicationId":"PFN:Other","createdUtc":"2026-01-01T00:00:00Z"} - """).ConfigureAwait(true); - break; - default: - new SessionStateStore(runtime.Paths.SessionStatePath).Write(session); - runtime.SetupState.Write(new SetupRecord - { - ApplicationId = runtime.ApplicationId, - SandboxId = "iso:different", - Phase = SetupPhase.Ready - }); - break; - } - - bool hostPrepared = false; - bool hostLaunched = false; - - await Assert.ThrowsAsync(() => Program.RunAgentAsync( - new HostOptions(applicationDirectory, null, ["--version"]), - _ => { }, - _ => - { - hostPrepared = true; - return Task.FromResult(new NodeRuntime( - "node.exe", - new Version(24, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); - }, - (_, _, _, _, _, _) => - { - hostLaunched = true; - return Task.FromResult(0); - }, - _ => runtime, - _ => Task.FromResult(new MxcReadinessReport( - null, - null, - "backend unavailable", - MxcHostSupport.Unsupported, - null, - MxcSupportEvidence.HostBuild)), - () => runtime.Paths.PackageFamilyName, - _ => null)); + await File.WriteAllTextAsync( + runtime.Paths.SessionStatePath, + "{ not json").ConfigureAwait(true); - Assert.False(hostPrepared); - Assert.False(hostLaunched); + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, ["--version"]), + _ => { }, + _ => runtime, + _ => Task.FromResult(new MxcReadinessReport( + null, + null, + "backend unavailable", + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + () => runtime.Paths.PackageFamilyName, + _ => null)); + + AssertRecommendsNewerWindows(failure.Message); } [Fact] - public async Task AutomaticHostFallbackDoesNotCreateASessionRuntimeWithoutPackageIdentity() + public async Task UnpackagedHostFailsWithoutCreatingASessionRuntime() { string applicationDirectory = Path.Combine(_testDirectory, "unpackaged-app"); Directory.CreateDirectory(applicationDirectory); @@ -252,76 +213,34 @@ await File.WriteAllTextAsync( "fixture").ConfigureAwait(true); bool runtimeCreated = false; - int exitCode = await Program.RunAgentAsync( - new HostOptions(applicationDirectory, null, ["--version"]), - _ => { }, - _ => Task.FromResult(new NodeRuntime( - "node.exe", - new Version(24, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), - (_, _, _, _, _, _) => Task.FromResult(17), - createSessionRuntime: _ => - { - runtimeCreated = true; - return CreateSessionRuntime(); - }, - probeReadiness: _ => Task.FromResult(new MxcReadinessReport( - null, - null, - "backend unavailable", - MxcHostSupport.Unsupported, - null, - MxcSupportEvidence.HostBuild)), - getPackageFamilyName: () => null, - readEnvironmentVariable: _ => null); + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, ["--version"]), + _ => { }, + createSessionRuntime: _ => + { + runtimeCreated = true; + return CreateSessionRuntime(); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + null, + null, + "backend unavailable", + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => null, + readEnvironmentVariable: _ => null)); Assert.False(runtimeCreated); - Assert.Equal(17, exitCode); + Assert.Contains("installed package", failure.Message, StringComparison.Ordinal); } [Fact] - public void SavedSessionWithoutSetupExplainsTheRequiredRecoveryCommand() - { - SessionRuntime runtime = CreateSessionRuntime(); - new SessionStateStore(runtime.Paths.SessionStatePath).Write(new SessionRecord - { - ApplicationId = runtime.ApplicationId, - SandboxId = "iso:saved", - WorkspacePath = Path.Combine(_testDirectory, "workspace"), - Generation = "test-generation", - CreatedUtc = DateTimeOffset.UtcNow - }); - - SessionException exception = Assert.Throws( - runtime.ValidateSavedOwnershipForHostFallback); - - Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public async Task AgentRefusesForeignSessionRecordWithoutHostFallback() + public async Task AgentRefusesForeignSessionRecord() { SessionRuntime runtime = await SetUpSessionAsync(); - var directLaunches = new List(); - SessionRecord record = new SessionStateStore(runtime.Paths.SessionStatePath) - .Read(runtime.ApplicationId).Record!; - new SessionStateStore(runtime.Paths.SessionStatePath).Write(record with - { - SandboxId = SandboxIdFor("PFN:Some.Other.App_abc123") - }); - SessionException failure = await Assert.ThrowsAsync( - () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); - - Assert.Contains("Some.Other.App", failure.Message, StringComparison.Ordinal); - Assert.Empty(directLaunches); - } - - [Fact] - public async Task AutomaticDirectRoutingValidatesExistingOwnershipFirst() - { - SessionRuntime runtime = await SetUpSessionAsync(); - var directLaunches = new List(); SessionRecord record = new SessionStateStore(runtime.Paths.SessionStatePath) .Read(runtime.ApplicationId).Record!; new SessionStateStore(runtime.Paths.SessionStatePath).Write(record with @@ -330,80 +249,50 @@ public async Task AutomaticDirectRoutingValidatesExistingOwnershipFirst() }); SessionException failure = await Assert.ThrowsAsync( - () => Program.RunAgentAsync( - CreateAgentOptions(), - _ => { }, - _ => throw new InvalidOperationException("Host Node must not be resolved."), - (_, _, _, _, _, _) => - { - directLaunches.Add("direct"); - return Task.FromResult(0); - }, - _ => runtime, - probeReadiness: _ => Task.FromResult(UnavailableReadiness()), - getPackageFamilyName: () => "OpenClaw.Gateway_test")); + () => RunAgentOnSupportedHostAsync(runtime)); Assert.Contains("Some.Other.App", failure.Message, StringComparison.Ordinal); - Assert.Empty(directLaunches); } [Fact] - public async Task AgentRefusesSetupSessionMismatchWithoutHostFallback() + public async Task AgentRefusesSetupSessionMismatch() { SessionRuntime runtime = await SetUpSessionAsync(); - var directLaunches = new List(); SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; runtime.SetupState.Write(setup with { SandboxId = "iso:different" }); SessionException failure = await Assert.ThrowsAsync( - () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + () => RunAgentOnSupportedHostAsync(runtime)); Assert.Contains("different session", failure.Message, StringComparison.Ordinal); - Assert.Empty(directLaunches); } [Fact] - public async Task AgentRefusesUnreadableSessionRecordWithoutHostFallback() + public async Task AgentRefusesUnreadableSessionRecord() { SessionRuntime runtime = await SetUpSessionAsync(); - var directLaunches = new List(); await File.WriteAllTextAsync(runtime.Paths.SessionStatePath, "{ not json"); SessionException failure = await Assert.ThrowsAsync( - () => RunAgentWithDirectLaunchProbeAsync(runtime, directLaunches)); + () => RunAgentOnSupportedHostAsync(runtime)); Assert.Contains("not valid JSON", failure.Message, StringComparison.Ordinal); Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); - Assert.Empty(directLaunches); } [Fact] - public async Task AgentFallsBackToHostWhenSessionRuntimeIsUnavailable() + public async Task AgentSurfacesAnUnavailableSessionRuntimeInsteadOfRunningOnTheHost() { SessionRuntime runtime = await SetUpSessionAsync(); _lastSessionBackend!.StartFailure = new MxcException( MxcErrorCode.RuntimeUnavailable, "The MXC runtime is not available."); - bool directLaunchAttempted = false; - string applicationDirectory = Path.Combine(_testDirectory, "app"); - string archivePath = Path.Combine(_testDirectory, "node-v24.15.0-win-x64.zip"); - int exitCode = await Program.RunAgentAsync( - new HostOptions(applicationDirectory, archivePath, ["--version"]), - _ => { }, - _ => Task.FromResult(new NodeRuntime( - "node.exe", - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), - (_, _, _, _, _, _) => - { - directLaunchAttempted = true; - return Task.FromResult(17); - }, - _ => runtime); + SessionCapabilityUnavailableException failure = + await Assert.ThrowsAsync( + () => RunAgentOnSupportedHostAsync(runtime)); - Assert.True(directLaunchAttempted); - Assert.Equal(17, exitCode); + Assert.Contains("not available", failure.Message, StringComparison.Ordinal); } [Fact] @@ -424,33 +313,46 @@ public async Task TeardownRequiresForceBeforeRemovingTheSession() Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); } - [Theory] - [InlineData("--no-isolation", null)] - [InlineData(null, "0")] - public async Task FreshSetupRejectsDisabledIsolationBeforePreparingTheHostRuntime( - string? option, - string? sessionMode) + [Fact] + public async Task SetupRejectsTheRemovedNoIsolationOption() { string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); var lifecycle = new FailingFreshLifecycle(CreateSessionRuntime()); + using var error = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--no-isolation"], + _ => { }, + TextWriter.Null, + error, + installationLifecycle: lifecycle); + + Assert.Equal(1, exitCode); + Assert.Empty(lifecycle.Calls); + Assert.Contains("--no-isolation", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task SetupIgnoresTheRemovedSessionOptOutVariable() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }; using var output = new StringWriter(); - string[] arguments = option is null - ? ["setup", "--fresh"] - : ["setup", "--fresh", option]; int exitCode = await Program.RunControlAsync( CreateSetupOptions(applicationDirectory), - arguments, + ["setup"], _ => { }, output, TextWriter.Null, installationLifecycle: lifecycle, readEnvironmentVariable: name => - name == SessionRoutingPolicy.ModeVariable ? sessionMode : null); + name == "OPENCLAW_SESSION" ? "0" : null); - Assert.Equal(1, exitCode); - Assert.Empty(lifecycle.Calls); - Assert.Contains("--fresh requires isolated-session provisioning", output.ToString(), StringComparison.Ordinal); + Assert.Equal(0, exitCode); + Assert.NotNull(runtime.SetupState.Read(runtime.ApplicationId).Record); } [Fact] @@ -816,115 +718,90 @@ await lifecycle.ProvisionStarted.Task } [Fact] - public async Task SetupReportsMissingApplicationBeforeResolvingHostNode() + public async Task SetupReportsMissingApplicationBeforeProbingSupport() { - bool nodeResolutionAttempted = false; + bool supportProbed = false; + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new ProbeCountingLifecycle(runtime, () => supportProbed = true); await Assert.ThrowsAsync( () => Program.RunControlAsync( - new HostOptions(null, null, []), - ["setup"], - _ => { }, - TextWriter.Null, - TextWriter.Null, - _ => - { - nodeResolutionAttempted = true; - return Task.FromResult( - new NodeRuntime( - "node.exe", - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); - })); + new HostOptions(null, null, []), + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: lifecycle)); - Assert.False(nodeResolutionAttempted); + Assert.False(supportProbed); } [Fact] - public async Task AgentReportsMissingApplicationBeforeResolvingHostNode() + public async Task AgentReportsMissingApplicationBeforeProbingSupport() { - bool nodeResolutionAttempted = false; + bool supportProbed = false; await Assert.ThrowsAsync( () => Program.RunAgentAsync( - new HostOptions(null, null, []), - _ => { }, - _ => - { - nodeResolutionAttempted = true; - return Task.FromResult( - new NodeRuntime( - "node.exe", - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); - })); + new HostOptions(null, null, []), + _ => { }, + probeReadiness: token => + { + supportProbed = true; + return SupportedHost(token); + })); - Assert.False(nodeResolutionAttempted); + Assert.False(supportProbed); } [Fact] - public async Task AutomaticAgentLaunchUsesHostOnlyWhenReadinessReportsIsolationUnsupported() + public async Task AgentFailsLoudlyOnAnUnsupportedWindowsBuild() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); - bool resolvedHostNode = false; - bool launchedHost = false; - - int exitCode = await Program.RunAgentAsync( - new HostOptions(applicationDirectory, null, []), - _ => { }, - _ => - { - resolvedHostNode = true; - return Task.FromResult(new NodeRuntime( - "node.exe", - new Version(24, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); - }, - (_, _, _, _, _, _) => - { - launchedHost = true; - return Task.FromResult(17); - }, - probeReadiness: _ => Task.FromResult(new MxcReadinessReport( - "runtime", - null, - null, - MxcHostSupport.Unsupported, - null, - MxcSupportEvidence.HostBuild)), - getPackageFamilyName: () => "OpenClaw.Gateway_test", - readEnvironmentVariable: _ => null, - createSessionRuntime: _ => CreateSessionRuntime()); + bool sessionRuntimeCreated = false; - Assert.Equal(17, exitCode); - Assert.True(resolvedHostNode); - Assert.True(launchedHost); + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), + _ => { }, + _ => + { + sessionRuntimeCreated = true; + return CreateSessionRuntime(); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null)); + + Assert.False(sessionRuntimeCreated); + AssertRecommendsNewerWindows(failure.Message); } [Fact] - public async Task RequiredAgentLaunchFailsWhenReadinessReportsIsolationUnsupported() + public async Task AgentIgnoresTheRemovedSessionOptOutVariable() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); - bool resolvedHostNode = false; + bool sessionRuntimeCreated = false; + // A stale OPENCLAW_SESSION=0 must not reopen the removed host path. await Assert.ThrowsAsync(() => Program.RunAgentAsync( new HostOptions(applicationDirectory, null, []), _ => { }, _ => { - resolvedHostNode = true; - return Task.FromResult(new NodeRuntime( - "node.exe", - new Version(24, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); + sessionRuntimeCreated = true; + return CreateSessionRuntime(); }, - (_, _, _, _, _, _) => Task.FromResult(0), probeReadiness: _ => Task.FromResult(new MxcReadinessReport( "runtime", null, @@ -934,49 +811,65 @@ await Assert.ThrowsAsync(() => Program.RunAgentAsync( MxcSupportEvidence.HostBuild)), getPackageFamilyName: () => "OpenClaw.Gateway_test", readEnvironmentVariable: name => - name == SessionRoutingPolicy.ModeVariable ? "1" : null)); + name == "OPENCLAW_SESSION" ? "0" : null)); - Assert.False(resolvedHostNode); + Assert.False(sessionRuntimeCreated); } [Fact] - public async Task SelectedSessionFailureDoesNotResolveOrLaunchHostNode() + public async Task UndeterminedSupportStillProvisionsTheSession() { string applicationDirectory = Path.Combine(_testDirectory, "app"); Directory.CreateDirectory(applicationDirectory); await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); - bool resolvedHostNode = false; - bool launchedHost = false; + bool sessionRuntimeCreated = false; await Assert.ThrowsAsync(() => Program.RunAgentAsync( new HostOptions(applicationDirectory, null, []), _ => { }, _ => { - resolvedHostNode = true; - return Task.FromResult(new NodeRuntime( - "node.exe", - new Version(24, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)); - }, - (_, _, _, _, _, _) => - { - launchedHost = true; - return Task.FromResult(0); + sessionRuntimeCreated = true; + throw new SessionException("the backend decided"); }, - _ => throw new SessionException("setup failed"), - _ => Task.FromResult(new MxcReadinessReport( + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( "runtime", null, null, - MxcHostSupport.Supported, + MxcHostSupport.Unknown, null, - MxcSupportEvidence.HostBuild)), - () => "OpenClaw.Gateway_test", - _ => null)); + MxcSupportEvidence.None, + null, + "the host detector could not run")), + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null)); - Assert.False(resolvedHostNode); - Assert.False(launchedHost); + Assert.True(sessionRuntimeCreated); + } + + [Fact] + public async Task SessionFailureSurfacesInsteadOfFallingBack() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + + SessionException failure = await Assert.ThrowsAsync( + () => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), + _ => { }, + _ => throw new SessionException("setup failed"), + _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)), + () => "OpenClaw.Gateway_test", + _ => null)); + + Assert.Equal("setup failed", failure.Message); } public void Dispose() @@ -1090,10 +983,6 @@ await File.WriteAllTextAsync( _ => { }, TextWriter.Null, TextWriter.Null, - _ => Task.FromResult(new NodeRuntime( - "node.exe", - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)), new StubbedRecoveryLifecycle(runtime)).ConfigureAwait(false); Assert.Equal(0, exitCode); @@ -1111,14 +1000,8 @@ private sealed class StubbedRecoveryLifecycle(SessionRuntime runtime) public SessionRuntime CreateRuntime(Action log) => runtime; - public Task CheckSessionSupportAsync( - CancellationToken cancellationToken) => - Task.FromResult(new SessionRoutingDecision( - SessionRouting.Session, - "Test session support is available.")); - - public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => - Inner.PrepareHostRuntime(options, log); + public Task EnsureSessionSupportedAsync( + CancellationToken cancellationToken) => Task.CompletedTask; public PackageRuntimeMetadata ValidatePackageRuntime( HostOptions options, SessionRuntime sessionRuntime) => @@ -1153,36 +1036,21 @@ private static string SandboxIdFor(string applicationId) => $"{{\"appId\":\"{applicationId}\"}}")) .TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; - private static MxcReadinessReport UnavailableReadiness() => - new( - RuntimeDirectory: null, - Provenance: null, - RuntimeUnavailableReason: "The runtime is unavailable.", - HostSupport: MxcHostSupport.Supported, - HostBuild: null, - SupportEvidence: MxcSupportEvidence.BackendProbe, - BackendProbe: new MxcBackendProbe(false, "base-container", []), - BackendProbeFailureReason: null); - - private Task RunAgentWithDirectLaunchProbeAsync( - SessionRuntime runtime, - List directLaunches) + private static void AssertRecommendsNewerWindows(string message) { - ArgumentNullException.ThrowIfNull(directLaunches); + Assert.Contains("newer version of Windows", message, StringComparison.Ordinal); + Assert.Contains("Windows Update", message, StringComparison.Ordinal); + Assert.Contains("#requirements", message, StringComparison.Ordinal); + } - return Program.RunAgentAsync( + private Task RunAgentOnSupportedHostAsync(SessionRuntime runtime) => + Program.RunAgentAsync( CreateAgentOptions(), _ => { }, - _ => throw new InvalidOperationException("Host Node must not be resolved."), - (_, _, _, _, _, _) => - { - directLaunches.Add("direct"); - return Task.FromResult(0); - }, _ => runtime, - probeReadiness: _ => Task.FromResult(UnavailableReadiness()), - getPackageFamilyName: () => "OpenClaw.Gateway_test"); - } + probeReadiness: SupportedHost, + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null); private static void WriteRuntimeInstallResult(FakeMxcSessionClient backend, int exitCode) { @@ -1259,17 +1127,8 @@ public FailingFreshLifecycle(SessionRuntime runtime) public SessionRuntime CreateRuntime(Action log) => _runtime; - public Task CheckSessionSupportAsync( - CancellationToken cancellationToken) => - Task.FromResult(new SessionRoutingDecision( - SessionRouting.Session, - "The isolated-session runtime is available.")); - - public NodeRuntime PrepareHostRuntime(HostOptions options, Action log) => - new( - "node.exe", - new Version(24, 20, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); + public Task EnsureSessionSupportedAsync( + CancellationToken cancellationToken) => Task.CompletedTask; public PackageRuntimeMetadata ValidatePackageRuntime( HostOptions options, @@ -1334,6 +1193,42 @@ public Task InstallRecoveryAsync( false)); } + private sealed class ProbeCountingLifecycle(SessionRuntime runtime, Action onProbe) + : IInstallationLifecycle + { + public SessionRuntime CreateRuntime(Action log) => runtime; + + public Task EnsureSessionSupportedAsync(CancellationToken cancellationToken) + { + onProbe(); + return Task.CompletedTask; + } + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public Task TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public Task InstallRecoveryAsync( + Action log, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + private sealed class RecordingCleaner : IInstallationStateCleaner { public bool Cleared { get; private set; } diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index 4c3bdd91..b2990c7b 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -320,8 +320,7 @@ public async Task CommandRequestCarriesInteractiveAndShimEnvironment() SessionExecutor.MergeEnvironment( OpenClawRuntimeEnvironment.Build( isInteractive: true, - _ => null, - GatewayIsolationMode.Enabled), + _ => null), AgentToolShim.BuildEnvironment( @"C:\Users\agent\AppData\Local\OpenClaw\NodeJS\node.exe", @"C:\Package\app")); @@ -689,32 +688,6 @@ public void ExternalSupervisionIsDeclared() Assert.Equal("1", environment["OPENCLAW_NO_AUTO_UPDATE"]); } - [Fact] - public void ForegroundLaunchUsesTheSameVariables() - { - // Both launch paths must agree; a path that supervises or updates - // itself would be a silent behavior difference. - string application = TestDirectory.Create(); - try - { - File.WriteAllText(Path.Combine(application, "openclaw.mjs"), "// test"); - System.Diagnostics.ProcessStartInfo startInfo = - GatewayLauncher.CreateStartInfo( - @"C:\node.exe", - application, - []); - - foreach ((string name, string value) in OpenClawRuntimeEnvironment.Build()) - { - Assert.Equal(value, startInfo.Environment[name]); - } - } - finally - { - Directory.Delete(application, recursive: true); - } - } - [Fact] public void ApplyToOverwritesAnInheritedValue() { diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs deleted file mode 100644 index bd30c6f5..00000000 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionRoutingPolicyTests.cs +++ /dev/null @@ -1,241 +0,0 @@ -using OpenClaw.Launcher.Mxc; -using OpenClaw.Launcher.Session; - -namespace OpenClaw.Launcher.Tests.Session; - -public sealed class SessionRoutingPolicyTests -{ - private const string PackageFamilyName = "OpenClaw.Gateway_abc123"; - - private static MxcReadinessReport Ready( - bool? backendAvailable = true, - string? runtimeUnavailableReason = null, - MxcHostSupport hostSupport = MxcHostSupport.Supported, - string? probeFailureReason = null) => - new( - runtimeUnavailableReason is null ? @"C:\Package\mxc\x64" : null, - null, - runtimeUnavailableReason, - hostSupport, - null, - backendAvailable is null - ? MxcSupportEvidence.HostBuild - : MxcSupportEvidence.BackendProbe, - backendAvailable is null - ? null - : new MxcBackendProbe(backendAvailable.Value, "base-container", []), - probeFailureReason); - - private static Func Environment(string? value) => - name => name == SessionRoutingPolicy.ModeVariable ? value : null; - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void UnsetModeIsAutomatic(string? value) - { - Assert.Equal( - SessionMode.Automatic, - SessionRoutingPolicy.ReadMode(Environment(value))); - } - - [Theory] - [InlineData("0")] - [InlineData("false")] - [InlineData("FALSE")] - [InlineData("off")] - [InlineData("no")] - public void FalsyValuesDisableSessions(string value) - { - Assert.Equal( - SessionMode.Disabled, - SessionRoutingPolicy.ReadMode(Environment(value))); - } - - [Theory] - [InlineData("1")] - [InlineData("true")] - [InlineData("On")] - [InlineData("yes")] - public void TruthyValuesRequireSessions(string value) - { - Assert.Equal( - SessionMode.Required, - SessionRoutingPolicy.ReadMode(Environment(value))); - } - - [Fact] - public void UnrecognizedValueIsRejectedRatherThanTreatedAsOff() - { - // Ignoring it would run outside the session the user asked for. - SessionException exception = Assert.Throws( - () => SessionRoutingPolicy.ReadMode(Environment("maybe"))); - - Assert.Contains("maybe", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void SupportedMachineUsesTheSessionByDefault() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready()); - - Assert.Equal(SessionRouting.Session, decision.Routing); - } - - [Fact] - public void ProbeOverridesAnUnsupportedBuildVerdict() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready(backendAvailable: true, hostSupport: MxcHostSupport.Unsupported)); - - Assert.Equal(SessionRouting.Session, decision.Routing); - } - - [Fact] - public void ProbeSayingUnavailableRunsDirectly() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready(backendAvailable: false)); - - Assert.Equal(SessionRouting.Direct, decision.Routing); - Assert.Contains("not available", decision.Reason, StringComparison.Ordinal); - } - - [Fact] - public void UnsupportedBuildWithoutAProbeRunsDirectly() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unsupported)); - - Assert.Equal(SessionRouting.Direct, decision.Routing); - } - - [Fact] - public void UndeterminableSupportRunsDirectlyRatherThanGuessing() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unknown)); - - Assert.Equal(SessionRouting.Direct, decision.Routing); - } - - [Fact] - public void ProbeFailureReasonIsCarriedIntoTheExplanation() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready( - backendAvailable: null, - hostSupport: MxcHostSupport.Unknown, - probeFailureReason: "the executor crashed")); - - Assert.Contains("the executor crashed", decision.Reason, StringComparison.Ordinal); - } - - [Fact] - public void MissingRuntimeRunsDirectly() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready(runtimeUnavailableReason: "the runtime directory is absent")); - - Assert.Equal(SessionRouting.Direct, decision.Routing); - Assert.Contains( - "the runtime directory is absent", - decision.Reason, - StringComparison.Ordinal); - } - - [Fact] - public void UnpackagedBuildRunsDirectly() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - null, - Ready()); - - Assert.Equal(SessionRouting.Direct, decision.Routing); - Assert.Contains("installed package", decision.Reason, StringComparison.Ordinal); - } - - [Fact] - public void DisabledModeRunsDirectlyEvenWhenSupported() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Disabled, - PackageFamilyName, - Ready()); - - Assert.Equal(SessionRouting.Direct, decision.Routing); - } - - [Fact] - public void RequiredModeSucceedsWhenSupported() - { - SessionRoutingDecision decision = SessionRoutingPolicy.Decide( - SessionMode.Required, - PackageFamilyName, - Ready()); - - Assert.Equal(SessionRouting.Session, decision.Routing); - } - - [Theory] - [InlineData("unpackaged")] - [InlineData("no-runtime")] - [InlineData("backend-unavailable")] - [InlineData("unsupported-build")] - public void RequiredModeFailsLoudlyRatherThanFallingBack(string scenario) - { - (string? packageFamilyName, MxcReadinessReport readiness) = scenario switch - { - "unpackaged" => (null, Ready()), - "no-runtime" => (PackageFamilyName, Ready(runtimeUnavailableReason: "absent")), - "backend-unavailable" => (PackageFamilyName, Ready(backendAvailable: false)), - _ => ( - PackageFamilyName, - Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unsupported)), - }; - - SessionException exception = Assert.Throws( - () => SessionRoutingPolicy.Decide( - SessionMode.Required, - packageFamilyName, - readiness)); - - Assert.Contains( - SessionRoutingPolicy.ModeVariable, - exception.Message, - StringComparison.Ordinal); - } - - [Fact] - public void EveryDecisionCarriesAReason() - { - SessionRoutingDecision session = SessionRoutingPolicy.Decide( - SessionMode.Automatic, - PackageFamilyName, - Ready()); - SessionRoutingDecision direct = SessionRoutingPolicy.Decide( - SessionMode.Disabled, - PackageFamilyName, - Ready()); - - Assert.False(string.IsNullOrWhiteSpace(session.Reason)); - Assert.False(string.IsNullOrWhiteSpace(direct.Reason)); - } -} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionSupportPolicyTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionSupportPolicyTests.cs new file mode 100644 index 00000000..145e53b2 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionSupportPolicyTests.cs @@ -0,0 +1,126 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionSupportPolicyTests +{ + private const string PackageFamilyName = "OpenClaw.Gateway_abc123"; + + private static MxcReadinessReport Ready( + bool? backendAvailable = true, + string? runtimeUnavailableReason = null, + MxcHostSupport hostSupport = MxcHostSupport.Supported, + string? probeFailureReason = null) => + new( + runtimeUnavailableReason is null ? @"C:\Package\mxc\x64" : null, + null, + runtimeUnavailableReason, + hostSupport, + null, + backendAvailable is null + ? MxcSupportEvidence.HostBuild + : MxcSupportEvidence.BackendProbe, + backendAvailable is null + ? null + : new MxcBackendProbe(backendAvailable.Value, "base-container", []), + probeFailureReason); + + [Fact] + public void SupportedMachineIsAccepted() => + SessionSupportPolicy.EnsureSupported(PackageFamilyName, Ready()); + + [Fact] + public void ProbeOverridesAnUnsupportedBuildVerdict() => + SessionSupportPolicy.EnsureSupported( + PackageFamilyName, + Ready(backendAvailable: true, hostSupport: MxcHostSupport.Unsupported)); + + [Fact] + public void UndeterminableSupportIsAttemptedRatherThanRefused() + { + // Nothing was measured, so refusing would deny a machine that may well + // work. The backend's own error is the honest verdict. + SessionSupportPolicy.EnsureSupported( + PackageFamilyName, + Ready( + backendAvailable: null, + hostSupport: MxcHostSupport.Unknown, + probeFailureReason: "the executor crashed")); + } + + [Fact] + public void ProbeSayingUnavailableFailsWithRemediation() + { + SessionException exception = Assert.Throws( + () => SessionSupportPolicy.EnsureSupported( + PackageFamilyName, + Ready(backendAvailable: false))); + + Assert.Contains("not available", exception.Message, StringComparison.Ordinal); + AssertRecommendsNewerWindows(exception); + } + + [Fact] + public void UnsupportedBuildWithoutAProbeFails() + { + SessionException exception = Assert.Throws( + () => SessionSupportPolicy.EnsureSupported( + PackageFamilyName, + Ready(backendAvailable: null, hostSupport: MxcHostSupport.Unsupported))); + + Assert.Contains( + "does not support isolated agent sessions", + exception.Message, + StringComparison.Ordinal); + AssertRecommendsNewerWindows(exception); + } + + [Fact] + public void ProbeFailureReasonIsCarriedIntoTheExplanation() + { + SessionException exception = Assert.Throws( + () => SessionSupportPolicy.EnsureSupported( + PackageFamilyName, + Ready( + backendAvailable: null, + hostSupport: MxcHostSupport.Unsupported, + probeFailureReason: "the executor crashed"))); + + Assert.Contains("the executor crashed", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void MissingRuntimeFails() + { + SessionException exception = Assert.Throws( + () => SessionSupportPolicy.EnsureSupported( + PackageFamilyName, + Ready(runtimeUnavailableReason: "the runtime directory is absent"))); + + Assert.Contains( + "the runtime directory is absent", + exception.Message, + StringComparison.Ordinal); + AssertRecommendsNewerWindows(exception); + } + + [Fact] + public void UnpackagedBuildFails() + { + SessionException exception = Assert.Throws( + () => SessionSupportPolicy.EnsureSupported(null, Ready())); + + Assert.Contains("installed package", exception.Message, StringComparison.Ordinal); + } + + private static void AssertRecommendsNewerWindows(SessionException exception) + { + Assert.Contains( + "newer version of Windows", + exception.Message, + StringComparison.Ordinal); + Assert.Contains("Windows Update", exception.Message, StringComparison.Ordinal); + Assert.Contains("#requirements", exception.Message, StringComparison.Ordinal); + } +} From b904c90c14394ae0d9eedb8f85959ed25e93bc78 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 17 Sep 2026 18:00:20 -0700 Subject: [PATCH 67/76] improve: make clawctl output readable and colour it (#66) clawctl described the machine rather than the person using it: passive wording, package paths and sandbox identifiers in ordinary output, and inconsistent severity words. Each command also built its own presentation strings, so alignment came from per-command width constants and severity vocabulary drifted between commands. Commands now return semantic results and a single renderer owns presentation. - Add ClawCtlResults, so operations describe what happened and the renderer decides how it reads. - Compose output from Spectre.Console renderables. A Grid derives the label column from the widest label, which removes the per-command widths, and a Panel renders the note callout for an unexpected fault. - Colour is an event, not a wash: labels stay in the terminal's own foreground and only the leading state word carries a hue. This follows openclaw's styleHealthChannelLine, which colours the state word of a "label: detail" row and leaves the label alone. - Add ClawCtlColorPolicy for --no-color, NO_COLOR, FORCE_COLOR, CI, and redirected output, and enable virtual terminal processing only when the selected stream is really an interactive console, so FORCE_COLOR still produces ANSI when output is redirected. - Report gateway and sign-in recovery state in clawctl status, alongside the Node.js version recorded at setup. - Keep a successful clawctl pwsh silent so the user reaches the shell prompt. - Persist the pre-reset report and include it in collect-logs, instead of printing a log path the user has to find. Values are built with Paragraph.Append rather than interpolated into markup, because package paths and error messages contain characters Spectre would otherwise parse as markup. Verified with the managed suite, static analysis, and the NativeAOT scenario driver, which now renders through Spectre ahead of time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f472f437-3ee7-41f3-a501-81ae01590103 --- Directory.Packages.props | 3 +- README.md | 7 +- docs/clawctl-output-style.md | 90 +++ src/OpenClaw.Launcher/ClawCtlColorPolicy.cs | 52 ++ src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 87 +- src/OpenClaw.Launcher/ClawCtlConsole.cs | 743 +++++++++++++++++- src/OpenClaw.Launcher/ClawCtlResults.cs | 84 ++ .../Gateway/DiagnosticsBundle.cs | 1 + src/OpenClaw.Launcher/HostPaths.cs | 3 + .../OpenClaw.Launcher.csproj | 1 + src/OpenClaw.Launcher/Program.cs | 508 ++++++++---- .../Session/InstallationLifecycle.cs | 9 + .../Session/SessionExecutor.cs | 8 + .../Session/TeardownOrchestrator.cs | 9 +- src/OpenClaw.Launcher/WindowsHostConsole.cs | 110 ++- .../SmokeProgram.cs | 91 ++- .../ClawCtlColorPolicyTests.cs | 98 +++ .../ClawCtlCommandLineTests.cs | 33 +- .../ClawCtlConsoleTests.cs | 212 ++++- .../FailIfWorkStartsLifecycle.cs | 4 + .../Gateway/DiagnosticsBundleTests.cs | 24 + .../ProgramStartupTests.cs | 52 +- tests/OpenClaw.Launcher.Tests/ProgramTests.cs | 391 ++++++++- .../Session/SessionExecutorTests.cs | 14 + .../WindowsHostConsoleTests.cs | 39 +- 25 files changed, 2443 insertions(+), 230 deletions(-) create mode 100644 docs/clawctl-output-style.md create mode 100644 src/OpenClaw.Launcher/ClawCtlColorPolicy.cs create mode 100644 src/OpenClaw.Launcher/ClawCtlResults.cs create mode 100644 tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 4ea58b06..ce90b18a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,9 +9,10 @@ + - + \ No newline at end of file diff --git a/README.md b/README.md index ce8d1df1..4ae97a23 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ the read-only application directory the workspace. |---|---| | `clawctl setup` | Confirm packaged `app\openclaw.mjs` exists, provision or reuse the owned isolated session, and install the bundled Node.js runtime in the agent profile. It also configures gateway sign-in recovery without starting a gateway. On a machine that cannot host a session it fails with the Windows requirement described under [Requirements](#requirements). | | `clawctl setup --fresh [--force]` | Remove this installation's owned session and package-local state, then run setup again. Without `--force`, incomplete external cleanup stops before local state is erased. `--force` is valid only with `--fresh`; it preserves an explicit warning when cleanup of owned external resources cannot be confirmed, but still stops if bounded local deletion fails. | -| `clawctl status` | Report the recorded isolated-session state without provisioning or replacing it. It asks the backend to start the recorded provision as its status probe, so it is not a passive diagnostic. Use `clawctl gateway-service status` to inspect the gateway. | +| `clawctl status` | Report the recorded isolated session, installed Node.js runtime, gateway, and sign-in recovery state without provisioning or replacing the session. It asks the backend to start the recorded provision as its status probe, so it is not a passive diagnostic. Use `clawctl gateway-service status` to inspect the gateway alone. | | `clawctl teardown --force` | Confirm deletion, then stop and deprovision the owned session and remove its data and setup state. The MSIX remains installed. | | `clawctl pwsh` | Open an interactive PowerShell session inside the agent session. | | `clawctl collect-logs [--output ]` | Create a redacted host-and-agent diagnostics ZIP. | @@ -97,6 +97,11 @@ and completion come from Invalid management input is rejected with exit code `1` and a parse diagnostic on standard error; no readiness check runs. +Interactive terminals use color for headings and status marks. `--no-color`, +the `NO_COLOR` environment variable, redirected output, and CI disable color; +`FORCE_COLOR` enables it for redirected output or CI unless color was +explicitly disabled. + Help and version requests take precedence over the rest of the command line. `clawctl --version bogus` prints the launcher version and exits `0` rather than reporting `bogus`, because the version request is satisfied before the diff --git a/docs/clawctl-output-style.md b/docs/clawctl-output-style.md new file mode 100644 index 00000000..4f48a851 --- /dev/null +++ b/docs/clawctl-output-style.md @@ -0,0 +1,90 @@ +# `clawctl` output style + +`clawctl` manages the packaged Windows environment around OpenClaw. Its output +should describe that environment in terms an operator can act on, while +`openclaw` remains the entry point for the application itself. + +## Human-readable output + +- Start command results with `🦀 clawctl ` when Unicode is available, + followed by aligned `Label: value` rows. Fall back to `clawctl ` for + legacy or non-Unicode output. +- Use lowercase status words such as `ready`, `running`, `stopped`, and + `not configured`. +- Use a check mark for success, an exclamation mark for attention, and a cross + for failure. Non-Unicode output uses `[ok]`, `[!]`, and `[x]`. +- Narrate long-running human operations at their real lifecycle boundaries. + Use one updating status in an interactive terminal and durable stage lines + when output is redirected. Structured output remains exactly one document. +- End successful setup with a tentative `openclaw onboard` suggestion. The + user may need flags or may choose a different advanced path, so setup neither + presents onboarding as mandatory nor launches it automatically. +- End neutral states with the command that moves the user forward, such as + `Run: clawctl setup`. +- Keep a successful `clawctl pwsh` launch silent so the user reaches the shell + prompt directly. Its help text explains which commands are available inside + the session. +- Treat Windows Ctrl-C termination of an attached foreground command as user + cancellation: emit no failure guidance and return portable exit code 130. + +Prefer keeping package paths, sandbox identifiers, process identifiers, and +other non-actionable implementation details out of human output. Preserve them +in structured output and diagnostics when they are useful for automation or +support. + +## Failures and diagnostics + +Expected refusals should state the condition and the next usable action without +asking the user to file an issue. Unexpected faults should explain how to +capture diagnostics and where to report the problem: + +1. Run `clawctl collect-logs`. +2. Review the resulting bundle before sharing it. +3. File an issue at + . + +`clawctl collect-logs` is the user-facing diagnostics entry point. Other +commands should not expose individual log paths when the bundle can collect the +same evidence. Its final bundle path is emitted as an exact standalone line, +outside a width-constrained grid, so it remains copyable. + +## Color + +Output is composed from Spectre.Console renderables — a `Grid` for +`Label: value` rows and a `Panel` for the note callout — rather than from +hand-padded strings. Let the grid own column alignment instead of reintroducing +per-command width constants. + +Color is an event, not a wash. Labels stay in the terminal's own foreground, +which is legible on whatever background the user runs, and only the leading +status word carries a hue. This follows OpenClaw's `styleHealthChannelLine`, +which colors the status word of a `label: detail` row and deliberately leaves +the label alone. + +| Role | Color | +|---|---| +| Accent (heading, next-action command) | `#1687ff` | +| Muted (labels' detail lines, neutral states) | `#697a8b` | +| Success | `#048b41` | +| Warning | `#b26501` | +| Error, note border | `#ed1805` | + +Color supplements text and status marks; it never carries meaning by itself. +Keep the text obtained after removing ANSI escape sequences identical to +ordinary non-color output. + +Build values with `Paragraph.Append`, never by interpolating caller text into +markup: package paths and error messages contain `[` and `\`, which Spectre +would otherwise parse as markup. Use `Text` or `Markup.Escape` for any string +the user or the system supplied. + +Reserve the note callout for a genuine fault, and cap it at 88 columns the way +OpenClaw caps its own notes. Unicode terminals get a rounded border; everything +else gets the ASCII border. + +The status vocabulary intentionally follows OpenClaw's terminal output, while +the crab mark and blue palette give `clawctl` its own identity. The accent +favors the dark terminals most users run while retaining readable contrast on +light backgrounds. When OpenClaw changes its status vocabulary or semantic +colors, review this guide for useful parity rather than copying its +dark-terminal palette mechanically. diff --git a/src/OpenClaw.Launcher/ClawCtlColorPolicy.cs b/src/OpenClaw.Launcher/ClawCtlColorPolicy.cs new file mode 100644 index 00000000..32a7ef41 --- /dev/null +++ b/src/OpenClaw.Launcher/ClawCtlColorPolicy.cs @@ -0,0 +1,52 @@ +namespace OpenClaw.Launcher; + +internal static class ClawCtlColorPolicy +{ + internal static bool PrepareOutput( + bool noColor, + bool outputIsProcessConsoleWriter, + bool consoleIsInteractive, + Func readEnvironmentVariable, + Func enableVirtualTerminalProcessing) + { + ArgumentNullException.ThrowIfNull(enableVirtualTerminalProcessing); + + bool useColor = ShouldUseColor( + noColor, + outputIsProcessConsoleWriter, + consoleIsInteractive, + readEnvironmentVariable); + + return useColor && + (!outputIsProcessConsoleWriter || + !consoleIsInteractive || + enableVirtualTerminalProcessing()); + } + + internal static bool ShouldUseColor( + bool noColor, + bool outputIsConsole, + bool consoleIsInteractive, + Func readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + + if (noColor || readEnvironmentVariable("NO_COLOR") is not null) + { + return false; + } + + string? forceColor = readEnvironmentVariable("FORCE_COLOR"); + if (forceColor is not null) + { + return !string.Equals(forceColor, "0", StringComparison.Ordinal); + } + + if (readEnvironmentVariable("CI") is not null) + { + return false; + } + + return outputIsConsole && consoleIsInteractive; + } +} diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index eec47bb8..de724ba0 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -18,6 +18,11 @@ internal sealed record ClawCtlHandlers internal sealed record SetupOptions(bool Fresh, bool Force); +internal sealed class ClawCtlOutputOptions +{ + public bool NoColor { get; set; } +} + // The clawctl command tree. Only the package-readiness surface belongs here: // doctor, gateway, uninstall, and every other OpenClaw command is owned by the // bundled CLI and reached through `openclaw`, which forwards its arguments @@ -40,10 +45,10 @@ internal static class ClawCtlCommandLine // Setup guidance is available without preparing the runtime. public static string RootDescription => - "Set up and manage the packaged OpenClaw application." + + "Get packaged OpenClaw and its bundled Node.js runtime ready, and manage its isolated session." + Environment.NewLine + Environment.NewLine + - "Run `clawctl setup` to prepare the bundled runtime and isolated session." + + "Run `clawctl setup` before using OpenClaw for the first time." + Environment.NewLine + Environment.NewLine + "Run `openclaw ` to invoke the OpenClaw CLI."; @@ -56,9 +61,17 @@ internal static class ClawCtlCommandLine // runSetup stays a delegate so the command tree owns parsing and help while // Program keeps the readiness operation and its test seams. - public static RootCommand Create(ClawCtlHandlers handlers) + public static RootCommand Create( + ClawCtlHandlers handlers, + ClawCtlOutputOptions? outputOptions = null) { ArgumentNullException.ThrowIfNull(handlers); + outputOptions ??= new ClawCtlOutputOptions(); + Option noColor = new("--no-color") + { + Description = "Disable colored output.", + Recursive = true + }; Command setup = new(SetupCommandName, SetupDescription); Option fresh = new("--fresh") { @@ -78,43 +91,76 @@ public static RootCommand Create(ClawCtlHandlers handlers) } }); setup.SetAction((parsed, cancellationToken) => - handlers.Setup( + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.Setup( new SetupOptions( parsed.GetValue(fresh), parsed.GetValue(force)), - cancellationToken)); + cancellationToken); + }); Command status = new( StatusCommandName, - "Show the isolated-session record and MXC-observed provision state without provisioning a replacement."); - status.SetAction((_, cancellationToken) => handlers.Status(cancellationToken)); + "Show whether the session, gateway, and sign-in recovery are ready."); + status.SetAction((parsed, cancellationToken) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.Status(cancellationToken); + }); Option outputPath = new("--output") { Description = "Path for the diagnostics ZIP file." }; Command collectLogs = new( CollectLogsCommandName, - "Create a redacted diagnostics bundle, including session files when reachable."); + "Gather redacted diagnostics into one ZIP file for troubleshooting."); collectLogs.Options.Add(outputPath); collectLogs.SetAction((parsed, cancellationToken) => - handlers.CollectLogs(parsed.GetValue(outputPath), cancellationToken)); - Option teardownForce = new("--force") { Description = "Skip confirmation and remove the owned session." }; - Command teardown = new("teardown", "Stop and remove the owned isolated session."); + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.CollectLogs(parsed.GetValue(outputPath), cancellationToken); + }); + Option teardownForce = new("--force") + { + Description = "Remove the session without asking for confirmation." + }; + Command teardown = new("teardown", "Remove OpenClaw's isolated session and gateway."); teardown.Options.Add(teardownForce); teardown.SetAction((parsed, cancellationToken) => - handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken)); + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken); + }); Command powerShell = new( "pwsh", - "Open an interactive PowerShell session inside the isolated agent."); - powerShell.SetAction((_, cancellationToken) => handlers.PowerShell(cancellationToken)); + "Open PowerShell inside the isolated agent. `openclaw` and `node` " + + "are available there; `clawctl` manages the session from outside it."); + powerShell.SetAction((parsed, cancellationToken) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.PowerShell(cancellationToken); + }); Command gateway = new( "gateway-service", "Manage the background OpenClaw gateway inside the isolated session."); - Command gatewayStart = new("start", "Start the gateway if it is not running."); - gatewayStart.SetAction((_, token) => handlers.GatewayStart(token)); - Command gatewayStatus = new("status", "Show the gateway state without changing it."); - gatewayStatus.SetAction((_, token) => handlers.GatewayStatus(token)); - Command gatewayStop = new("stop", "Stop the gateway, keeping the session and its data."); - gatewayStop.SetAction((_, token) => handlers.GatewayStop(token)); + Command gatewayStart = new("start", "Start the gateway if needed."); + gatewayStart.SetAction((parsed, token) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.GatewayStart(token); + }); + Command gatewayStatus = new("status", "Show whether the gateway is running."); + gatewayStatus.SetAction((parsed, token) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.GatewayStatus(token); + }); + Command gatewayStop = new("stop", "Stop the gateway but keep the session and its data."); + gatewayStop.SetAction((parsed, token) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.GatewayStop(token); + }); gateway.Subcommands.Add(gatewayStart); gateway.Subcommands.Add(gatewayStatus); gateway.Subcommands.Add(gatewayStop); @@ -128,6 +174,7 @@ public static RootCommand Create(ClawCtlHandlers handlers) powerShell, gateway }; + root.Options.Add(noColor); // Bare `clawctl` is a discovery request, not a usage error, so the root // prints help and succeeds instead of reporting a missing command. diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 00f3ab3c..4d3b3cac 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -1,19 +1,744 @@ +using System.Globalization; +using Spectre.Console; +using Spectre.Console.Rendering; + namespace OpenClaw.Launcher; internal static class ClawCtlConsole { - internal static void WriteNodeRuntimeSummary( + private const int Indent = 2; + private const int FallbackWidth = 100; + private const string IdentityMark = "\U0001f980"; + + private const string IssueUrl = + "https://github.com/openclaw/openclaw-windows-packaging/issues"; + + // Colour is an event, not a wash. Labels stay in the terminal's own + // foreground -- which is by definition legible on the user's background -- + // and only the state word carries a hue. This mirrors openclaw's + // styleHealthChannelLine, which colours the leading status word of a + // "label: detail" row and deliberately leaves the label uncoloured. + private static readonly Style AccentStyle = new(new Color(0x16, 0x87, 0xff)); + private static readonly Style MutedStyle = new(new Color(0x69, 0x7a, 0x8b)); + private static readonly Style SuccessStyle = new(new Color(0x04, 0x8b, 0x41)); + private static readonly Style WarningStyle = new(new Color(0xb2, 0x65, 0x01)); + private static readonly Style FailureStyle = new(new Color(0xed, 0x18, 0x05)); + + private enum StatusKind + { + Neutral, + Success, + Warning, + Failure + } + + internal static void WriteResult( TextWriter output, - NodeRuntime runtime) => - output.WriteLine( - $"Bundled Node.js {runtime.Version} is ready at {runtime.ExecutablePath}"); + IClawCtlResult result, + bool useColor = false) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(result); - public static void WriteReadinessSummary( + var view = new ResultView( + SupportsUnicode(output), + result.Command, + ResolveWidth(output)); + string? literalLine = null; + switch (result) + { + case SetupCommandResult setup: + WriteSetup(view, setup); + break; + case StatusCommandResult status: + WriteStatus(view, status); + break; + case CollectLogsCommandResult logs: + WriteCollectLogs(view, logs); + literalLine = logs.Bundle.BundlePath; + break; + case TeardownCommandResult teardown: + WriteTeardown(view, teardown); + break; + case GatewayCommandResult gateway: + WriteGateway(view, gateway); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(result), + result.GetType().FullName, + "Unknown clawctl result type."); + } + + Render(output, view.Build(), useColor, view.Unicode); + if (literalLine is not null) + { + output.WriteLine(literalLine); + } + } + + internal static void WriteUnexpectedFailure( + TextWriter error, + string command, + string message, + bool useColor = false) + { + ArgumentNullException.ThrowIfNull(error); + ArgumentException.ThrowIfNullOrWhiteSpace(command); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + var view = new ResultView( + SupportsUnicode(error), + command, + ResolveWidth(error)); + view.Note(message); + view.Blank(); + view.IssueGuidance(); + Render(error, view.Build(), useColor, view.Unicode); + } + + internal static async Task NarrateAsync( TextWriter output, - string applicationDirectory) + bool useColor, + bool narrate, + ClawCtlProgress initial, + Func, Task> operation) { - output.WriteLine(); - output.WriteLine("OpenClaw package is ready."); - output.WriteLine($"Read-only application files: {applicationDirectory}"); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(initial); + ArgumentNullException.ThrowIfNull(operation); + ArgumentException.ThrowIfNullOrWhiteSpace(initial.Message); + + if (!narrate) + { + return await operation(NullProgress.Instance).ConfigureAwait(false); + } + + if (!IsInteractiveConsole(output)) + { + await output.WriteLineAsync($"{new string(' ', Indent)}{initial.Message}") + .ConfigureAwait(false); + string currentMessage = initial.Message; + return await operation(new InlineProgress(p => + { + if (!string.Equals(p.Message, currentMessage, StringComparison.Ordinal)) + { + WriteProgressLine(output, p.Message); + currentMessage = p.Message; + } + })).ConfigureAwait(false); + } + + IAnsiConsole console = CreateConsole( + output, + useColor, + SupportsUnicode(output), + ResolveWidth(output), + InteractionSupport.Yes); + return await NarrateWithStatusAsync(console, initial, operation) + .ConfigureAwait(false); + } + + internal static async Task NarrateWithStatusAsync( + IAnsiConsole console, + ClawCtlProgress initial, + Func, Task> operation) + { + ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(initial); + ArgumentNullException.ThrowIfNull(operation); + ArgumentException.ThrowIfNullOrWhiteSpace(initial.Message); + + T result = default!; + await console.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(AccentStyle) + .StartAsync(Markup.Escape(initial.Message), async context => + { + result = await operation(new InlineProgress(progress => + context.Status(Markup.Escape(progress.Message)))) + .ConfigureAwait(false); + }).ConfigureAwait(false); + return result; + } + + private static bool IsInteractiveConsole(TextWriter output) => + (ReferenceEquals(output, Console.Out) || ReferenceEquals(output, Console.Error)) && + WindowsHostConsole.Instance.IsInteractive; + + private static void WriteProgressLine(TextWriter output, string message) => + output.WriteLine($"{new string(' ', Indent)}{message}"); + + private sealed class InlineProgress(Action report) + : IProgress + { + public void Report(ClawCtlProgress value) => report(value); + } + + private sealed class NullProgress : IProgress + { + internal static readonly NullProgress Instance = new(); + + public void Report(ClawCtlProgress value) + { + } + } + + private static void WriteSetup(ResultView view, SetupCommandResult result) + { + if (result.Warning is not null) + { + view.Row( + "Teardown", + Status(view, StatusKind.Warning, "could not confirm external cleanup")); + view.Detail(result.Warning.Message); + if (!string.IsNullOrWhiteSpace(result.Warning.Detail)) + { + view.Detail(result.Warning.Detail); + } + + if (result.LocalStateCleared) + { + view.Detail("--force was set, so setup continued"); + view.Row( + "State", + Status(view, StatusKind.Success, "package-local state cleared")); + } + } + + view.Row("Package", Status(view, StatusKind.Success, "ready")); + if (result.Error is not null) + { + view.Row("Session", Status(view, StatusKind.Failure, "failed")); + view.Detail(result.Error); + return; + } + + if (result.NodeVersion is not null) + { + string runtimeLocation = result.RuntimeLocation switch + { + SetupRuntimeLocation.Host => " in the host", + SetupRuntimeLocation.IsolatedSession => " in the isolated session", + _ => string.Empty + }; + view.Row( + "Runtime", + Status( + view, + StatusKind.Success, + $"Node.js {result.NodeVersion} installed{runtimeLocation}")); + } + + if (result.Recovery is not null) + { + view.Row( + "Recovery", + result.Recovery.State == Gateway.GatewayPersistenceState.Ready + ? Status( + view, + StatusKind.Success, + "configured", + " - the gateway starts when you sign in") + : Status( + view, + StatusKind.Failure, + DescribeRecoveryState(result.Recovery.State))); + if (!string.IsNullOrWhiteSpace(result.Recovery.Detail)) + { + view.Detail(result.Recovery.Detail); + } + } + + if (result.SessionReady) + { + view.Row("Session", Status(view, StatusKind.Success, "ready")); + } + + if (result.Warning is not null) + { + view.Blank(); + view.Note( + "This machine is not proven clean. Some sandbox and gateway resources " + + "may still exist, and a later reset cannot remove them, because the " + + "record of what we owned is gone."); + view.Blank(); + view.IssueGuidance(); + return; + } + + if (result.ExitCode == 0) + { + view.Blank(); + view.Line("When you're ready, you may want to configure OpenClaw:"); + view.Command("Optional", "openclaw onboard"); + } + } + + private static void WriteStatus(ResultView view, StatusCommandResult result) + { + view.Row("Session", DescribeSession(view, result.Session.Availability)); + if (result.Session.Availability is Session.SessionAvailability.Stale) + { + view.Detail("the recorded session is gone from the isolation backend"); + } + else if (!string.IsNullOrWhiteSpace(result.Session.Detail) && + result.Session.Availability is + Session.SessionAvailability.BackendUnavailable or + Session.SessionAvailability.BackendError or + Session.SessionAvailability.Unusable) + { + view.Detail(result.Session.Detail); + } + + if (!string.IsNullOrWhiteSpace(result.NodeVersion)) + { + view.Row("Runtime", new Text($"Node.js {result.NodeVersion}")); + } + + view.Row("Gateway", DescribeGateway(view, result.Gateway)); + if (!string.IsNullOrWhiteSpace(result.Gateway.Detail)) + { + view.Detail(result.Gateway.Detail); + } + + view.Row("Recovery", DescribeRecovery(view, result.Recovery)); + if (!string.IsNullOrWhiteSpace(result.Recovery.Detail)) + { + view.Detail(result.Recovery.Detail); + } + + if (result.Session.Availability == Session.SessionAvailability.None) + { + view.Blank(); + view.Command("Run", "clawctl setup"); + } + else if (result.Session.Availability == Session.SessionAvailability.Stale) + { + view.Blank(); + view.IssueGuidance(); + view.Blank(); + view.Command("Recover with", "clawctl setup --fresh"); + } + } + + private static void WriteCollectLogs(ResultView view, CollectLogsCommandResult result) + { + if (result.Bundle.BundlePath is null) + { + view.Row( + "Bundle", + Status(view, StatusKind.Warning, "no diagnostic files were available")); + return; + } + + view.Row( + "Included", + new Text(result.Bundle.SessionReached + ? "host and session diagnostics" + : "host diagnostics only")); + foreach (string note in result.Bundle.Notes) + { + view.Row("Session", Status(view, StatusKind.Warning, note)); + } + + view.Blank(); + view.Line("Redaction is best-effort and targets credential-shaped values."); + view.Line("Review the bundle before sharing it."); + view.Blank(); + view.Command("Report an issue", IssueUrl); + view.Blank(); + view.Line("Bundle:"); + } + + private static void WriteTeardown(ResultView view, TeardownCommandResult result) + { + if (!result.Teardown.Succeeded) + { + view.Row("Teardown", Status(view, StatusKind.Failure, "failed")); + view.Detail(result.Teardown.Message); + if (!string.IsNullOrWhiteSpace(result.Teardown.Detail)) + { + view.Detail(result.Teardown.Detail); + } + + return; + } + + view.Row( + "Session", + result.Teardown.SessionRemoved + ? Status(view, StatusKind.Success, "removed") + : Status(view, StatusKind.Neutral, "not configured")); + view.Row("Gateway", Status(view, StatusKind.Success, "records removed")); + if (result.Teardown.SessionRemoved) + { + view.Row("Profile", Status(view, StatusKind.Success, "guest profile deleted")); + } + + if (!string.IsNullOrWhiteSpace(result.Teardown.Detail)) + { + view.Detail(result.Teardown.Detail); + } + } + + private static void WriteGateway(ResultView view, GatewayCommandResult result) + { + view.Row("Gateway", result.State switch + { + Gateway.GatewayState.Running => Status(view, StatusKind.Success, "running"), + Gateway.GatewayState.NotStarted => Status(view, StatusKind.Neutral, "not started"), + Gateway.GatewayState.Stopped => Status(view, StatusKind.Success, "stopped"), + Gateway.GatewayState.Starting => Status(view, StatusKind.Warning, "starting"), + Gateway.GatewayState.Unhealthy => Status(view, StatusKind.Failure, "unhealthy"), + _ => Status(view, StatusKind.Warning, "unknown") + }); + + if (result.Port is not null) + { + view.Row( + "Port", + new Text(result.Port.Value.ToString(CultureInfo.InvariantCulture))); + } + + string? explanation = string.IsNullOrWhiteSpace(result.Detail) + ? result.State == Gateway.GatewayState.Running ? null : result.Message + : result.Detail; + if (!string.IsNullOrWhiteSpace(explanation)) + { + view.Detail(explanation); + } + + if (result.State == Gateway.GatewayState.NotStarted && + result.Action == "status") + { + view.Blank(); + view.Command("Run", "clawctl gateway-service start"); + } + } + + private static Paragraph DescribeSession( + ResultView view, + Session.SessionAvailability availability) => + availability switch + { + Session.SessionAvailability.None => + Status(view, StatusKind.Neutral, "not configured"), + Session.SessionAvailability.Running => + Status(view, StatusKind.Success, "running"), + Session.SessionAvailability.Stale => + Status(view, StatusKind.Failure, "stale"), + Session.SessionAvailability.BackendUnavailable => + Status(view, StatusKind.Failure, "unavailable"), + Session.SessionAvailability.BackendError => + Status(view, StatusKind.Failure, "failed"), + Session.SessionAvailability.Unusable => + Status(view, StatusKind.Failure, "unusable"), + _ => Status(view, StatusKind.Warning, "unknown") + }; + + private static Paragraph DescribeGateway( + ResultView view, + Gateway.GatewayStatusReport status) + { + if (status.State == Gateway.GatewayState.Running) + { + IReadOnlyList ports = status.Record?.ObservedPorts ?? []; + return ports.Count == 1 + ? Status( + view, + StatusKind.Success, + "running", + $" on port {ports[0].ToString(CultureInfo.InvariantCulture)}") + : Status(view, StatusKind.Success, "running"); + } + + return status.State switch + { + Gateway.GatewayState.NotStarted => + Status(view, StatusKind.Neutral, "not started"), + Gateway.GatewayState.Stopped => + Status(view, StatusKind.Neutral, "stopped"), + Gateway.GatewayState.Starting => + Status(view, StatusKind.Warning, "starting"), + Gateway.GatewayState.Unhealthy => + Status(view, StatusKind.Failure, "unavailable"), + _ => Status(view, StatusKind.Warning, "unknown") + }; + } + + private static Paragraph DescribeRecovery( + ResultView view, + Gateway.GatewayPersistenceStatus status) => + status.State switch + { + Gateway.GatewayPersistenceState.Ready => + Status(view, StatusKind.Success, "configured"), + Gateway.GatewayPersistenceState.NotInstalled => + Status(view, StatusKind.Neutral, "not configured"), + Gateway.GatewayPersistenceState.ActionRequired => + Status(view, StatusKind.Warning, "needs attention"), + _ => Status(view, StatusKind.Warning, "unknown") + }; + + private static string DescribeRecoveryState( + Gateway.GatewayPersistenceState state) => + state switch + { + Gateway.GatewayPersistenceState.NotInstalled => "not configured", + Gateway.GatewayPersistenceState.ActionRequired => "needs attention", + _ => "unknown" + }; + + // A state row is " ". Only the mark and the state + // word are coloured; the trailing clause stays in the default foreground + // so one row never turns into a coloured sentence. Built with Paragraph + // rather than Markup so caller text is never parsed as markup. + private static Paragraph Status( + ResultView view, + StatusKind kind, + string word, + string? trailing = null) + { + Style style = kind switch + { + StatusKind.Success => SuccessStyle, + StatusKind.Warning => WarningStyle, + StatusKind.Failure => FailureStyle, + _ => MutedStyle + }; + + var paragraph = new Paragraph(); + string? mark = Mark(kind, view.Unicode); + if (mark is not null) + { + paragraph.Append(mark, style); + paragraph.Append(" "); + } + + paragraph.Append(word, kind == StatusKind.Neutral ? MutedStyle : style); + if (!string.IsNullOrEmpty(trailing)) + { + paragraph.Append(trailing); + } + + return paragraph; + } + + private static string? Mark(StatusKind kind, bool unicode) => kind switch + { + StatusKind.Success => unicode ? "\u2713" : "[ok]", + StatusKind.Warning => unicode ? "!" : "[!]", + StatusKind.Failure => unicode ? "\u2717" : "[x]", + _ => null + }; + + internal static string FormatHeading(string command, bool useUnicode) => + useUnicode + ? $"{IdentityMark} clawctl {command}" + : $"clawctl {command}"; + + private static void Render( + TextWriter output, + IRenderable renderable, + bool useColor, + bool unicode) + { + var buffer = new StringWriter(CultureInfo.InvariantCulture); + IAnsiConsole console = CreateConsole( + buffer, + useColor, + unicode, + ResolveWidth(output), + InteractionSupport.No); + console.Write(renderable); + + // Grid pads every cell to its column width, so rows arrive with + // trailing spaces. Trimming keeps redirected output byte-clean and + // applies identically to the coloured and plain renderings, which is + // what keeps the two textually equal once ANSI is stripped. + string[] lines = buffer.ToString().Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + string line = lines[index].TrimEnd(); + if (index == lines.Length - 1 && line.Length == 0) + { + continue; + } + + output.WriteLine(line); + } + } + + private static IAnsiConsole CreateConsole( + TextWriter target, + bool useColor, + bool unicode, + int width, + InteractionSupport interaction) + { + IAnsiConsole console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = useColor ? AnsiSupport.Yes : AnsiSupport.No, + ColorSystem = useColor ? ColorSystemSupport.TrueColor : ColorSystemSupport.NoColors, + Interactive = interaction, + Out = new AnsiConsoleOutput(target), + }); + console.Profile.Width = width; + console.Profile.Capabilities.Unicode = unicode; + return console; + } + + private static int ResolveWidth(TextWriter output) + { + if (!ReferenceEquals(output, Console.Out) && !ReferenceEquals(output, Console.Error)) + { + return FallbackWidth; + } + + try + { + int width = Console.WindowWidth; + return width > 20 ? width : FallbackWidth; + } + catch (Exception exception) when ( + exception is IOException or PlatformNotSupportedException or + ArgumentOutOfRangeException) + { + return FallbackWidth; + } + } + + private static string LowercaseFirst(string value) => + value.Length == 0 || + (value.Length > 1 && char.IsUpper(value[0]) && char.IsUpper(value[1])) + ? value + : char.ToLowerInvariant(value[0]) + value[1..]; + + private static bool SupportsUnicode(TextWriter output) => + WindowsHostConsole.Instance.IsInteractiveOutput(output) && + Console.OutputEncoding.CodePage == 65001; + + // Collects the whole result before rendering so one grid can own every + // row. Fixing the label column from the widest label is what replaces the + // per-command magic widths the string renderer needed. + private sealed class ResultView(bool unicode, string command, int width) + { + // openclaw caps a note at min(88, columns - 10) rather than letting it + // span the terminal, and a full-width box reads as a wall. Match it. + private const int MaxNoteWidth = 88; + + private readonly List _items = []; + + public bool Unicode { get; } = unicode; + + public void Row(string label, IRenderable value) => + _items.Add(Item.ForRow(label, value)); + + public void Command(string label, string command) => + Row(label, new Text(command, AccentStyle)); + + public void Detail(string detail) + { + foreach (string line in detail.Split( + ['\r', '\n'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + _items.Add(Item.ForRow(null, new Text(LowercaseFirst(line), MutedStyle))); + } + } + + public void Blank() => _items.Add(Item.ForFree(Text.Empty)); + + public void Line(string text) => _items.Add(Item.ForFree(new Text(text))); + + public void Note(string message) => + _items.Add(Item.ForFree(new Panel(new Text(message)) + { + Header = new PanelHeader(" note "), + Border = Unicode ? BoxBorder.Rounded : BoxBorder.Ascii, + BorderStyle = FailureStyle, + Padding = new Padding(1, 0, 1, 0), + Width = Math.Max(40, Math.Min(MaxNoteWidth, width - Indent)), + })); + + public void IssueGuidance() + { + Line("This shouldn't happen. Please report it so we can fix the cause:"); + Line(" 1. clawctl collect-logs"); + Line($" 2. {IssueUrl}"); + } + + public Rows Build() + { + int labelWidth = 0; + foreach (Item item in _items) + { + if (item.Label is not null) + { + labelWidth = Math.Max(labelWidth, item.Label.Length + 1); + } + } + + var blocks = new List(); + Grid? grid = null; + foreach (Item item in _items) + { + if (item.Free is not null) + { + grid = null; + blocks.Add(item.Free); + continue; + } + + if (grid is null) + { + grid = new Grid(); + grid.AddColumn(new GridColumn().NoWrap().Width(labelWidth + 1)); + grid.AddColumn(new GridColumn().PadRight(0)); + blocks.Add(grid); + } + + grid.AddRow( + item.Label is null ? Text.Empty : new Text($"{item.Label}:"), + item.Value!); + } + + var heading = new Paragraph(); + if (Unicode) + { + heading.Append($"{IdentityMark} "); + } + + heading.Append("clawctl ", MutedStyle); + heading.Append(command, AccentStyle); + + return new Rows( + heading, + Text.Empty, + new Padder(new Rows(blocks), new Padding(Indent, 0, 0, 0))); + } + + private readonly struct Item + { + private Item(string? label, IRenderable? value, IRenderable? free) + { + Label = label; + Value = value; + Free = free; + } + + public string? Label { get; } + + public IRenderable? Value { get; } + + public IRenderable? Free { get; } + + public static Item ForRow(string? label, IRenderable value) => + new(label, value, null); + + public static Item ForFree(IRenderable free) => new(null, null, free); + } } } diff --git a/src/OpenClaw.Launcher/ClawCtlResults.cs b/src/OpenClaw.Launcher/ClawCtlResults.cs new file mode 100644 index 00000000..8e26818d --- /dev/null +++ b/src/OpenClaw.Launcher/ClawCtlResults.cs @@ -0,0 +1,84 @@ +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher; + +internal interface IClawCtlResult +{ + string Command { get; } + + int ExitCode { get; } +} + +internal record ClawCtlProgress(string Message); + +internal sealed record FreshSetupWarning(string Message, string? Detail); + +internal enum SetupRuntimeLocation +{ + Host, + IsolatedSession +} + +internal sealed record SetupCommandResult( + int ExitCode, + string ApplicationDirectory, + string? NodeVersion, + GatewayPersistenceInstallResult? Recovery, + bool SessionReady, + FreshSetupWarning? Warning = null, + string? Error = null, + string? SandboxId = null, + bool Fresh = false, + SetupRuntimeLocation? RuntimeLocation = null, + bool LocalStateCleared = false) : IClawCtlResult +{ + public string Command => Fresh ? "setup --fresh" : "setup"; +} + +internal sealed record StatusCommandResult( + SessionStatus Session, + GatewayStatusReport Gateway, + GatewayPersistenceStatus Recovery, + string? NodeVersion) : IClawCtlResult +{ + public string Command => "status"; + + public int ExitCode => + Session.Availability is SessionAvailability.Stale or + SessionAvailability.BackendUnavailable or + SessionAvailability.BackendError or + SessionAvailability.Unusable || + Gateway.State is GatewayState.Unhealthy or GatewayState.Unknown || + Recovery.State is GatewayPersistenceState.ActionRequired or + GatewayPersistenceState.Unknown + ? 1 + : 0; +} + +internal sealed record CollectLogsCommandResult( + DiagnosticsBundleResult Bundle) : IClawCtlResult +{ + public string Command => "collect-logs"; + + public int ExitCode => 0; +} + +internal sealed record TeardownCommandResult( + TeardownResult Teardown) : IClawCtlResult +{ + public string Command => "teardown"; + + public int ExitCode => Teardown.Succeeded ? 0 : 1; +} + +internal sealed record GatewayCommandResult( + string Action, + GatewayState State, + string Message, + string? Detail, + int ExitCode, + int? Port = null) : IClawCtlResult +{ + public string Command => $"gateway-service {Action}"; +} diff --git a/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs b/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs index 766cfb0c..738486f1 100644 --- a/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs +++ b/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs @@ -163,6 +163,7 @@ internal static string ResolveBundlePath( { List<(string Name, string Path)> files = []; Add("host/openclaw.log", _paths.LogPath); + Add("host/pre-reset.log", _paths.PreResetReportPath); Add("host/setup.json", _paths.SetupStatePath); Add("host/session.json", _paths.SessionStatePath); Add("host/gateway-config.json", _paths.GatewayConfigurationPath); diff --git a/src/OpenClaw.Launcher/HostPaths.cs b/src/OpenClaw.Launcher/HostPaths.cs index 32c1dd1f..57a98614 100644 --- a/src/OpenClaw.Launcher/HostPaths.cs +++ b/src/OpenClaw.Launcher/HostPaths.cs @@ -122,6 +122,9 @@ private HostPaths(string stateRoot, string? packageFamilyName) public string LogPath => Path.Combine(StateRoot, "Logs", "openclaw.log"); + public string PreResetReportPath => + Path.Combine(StateRoot, "Logs", "pre-reset.log"); + public string SessionStatePath => Path.Combine(StateRoot, "session.json"); /// diff --git a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj index b5ef09c8..5073c2d1 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -33,6 +33,7 @@ + diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index aeab158a..2f7d2975 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -29,12 +29,15 @@ internal static async Task RunAsync(string[] args, HostStartup startup) HostDiagnosticLog? diagnostics = null; bool diagnosticWarningWritten = false; bool consoleWarningWritten = false; + IDisposable? consoleRestore = null; + TextWriter output = startup.Output; + TextWriter error = startup.Error; void WriteConsoleError(string message) { try { - startup.Error.WriteLine(message); + error.WriteLine(message); } catch (Exception exception) when ( exception is IOException or ObjectDisposedException) @@ -87,6 +90,21 @@ UnauthorizedAccessException or } } + void WriteFailureOutput(Action write, string destination) + { + try + { + write(); + } + catch (Exception exception) when ( + exception is IOException or ObjectDisposedException) + { + WriteDiagnostic( + $"Failure output to {destination} failed: " + + $"{exception.GetType().Name}."); + } + } + static string GetDiagnosticFailure(Exception exception) => exception switch { @@ -101,16 +119,25 @@ PlatformNotSupportedException or try { WriteDiagnostic($"Host started through the {commandName} entrypoint."); + if (startup.Entrypoint == HostEntrypoint.Control && + ReferenceEquals(startup.Output, Console.Out) && + WindowsHostConsole.Instance.IsInteractive) + { + consoleRestore = WindowsHostConsole.Instance.Capture(WriteDiagnostic); + WindowsHostConsole.Instance.InitializeUtf8(); + output = Console.Out; + error = Console.Error; + } + HostOptions options = HostOptions.Parse(args, startup.BaseDirectory); return startup.Entrypoint == HostEntrypoint.Control ? await RunControlAsync( options, args, WriteDiagnostic, - startup.Output, - startup.Error, - startup.InstallationLifecycle, - startup.ReadEnvironmentVariable).ConfigureAwait(false) + output, + error, + startup.InstallationLifecycle).ConfigureAwait(false) : await RunAgentAsync( options, WriteDiagnostic, @@ -123,17 +150,52 @@ startup.InstallationLifecycle is null catch (Exception exception) { WriteDiagnostic($"Unhandled failure: {GetDiagnosticFailure(exception)}"); - WriteConsoleError($"{commandName}: {exception.Message}"); - if (diagnostics is not null) + if (startup.Entrypoint == HostEntrypoint.Control) + { + string command = args.Length == 0 ? "command" : args[0]; + WriteFailureOutput(() => + { + bool outputIsProcessConsoleWriter = + ReferenceEquals(error, Console.Error); + bool consoleIsInteractive = + WindowsHostConsole.Instance.IsInteractiveOutput(error); + IDisposable? restore = null; + bool useColor = ClawCtlColorPolicy.PrepareOutput( + args.Contains("--no-color", StringComparer.Ordinal), + outputIsProcessConsoleWriter, + consoleIsInteractive, + Environment.GetEnvironmentVariable, + () => WindowsHostConsole.Instance + .TryEnableVirtualTerminalProcessing( + error, + WriteDiagnostic, + out restore)); + + using (restore) + { + ClawCtlConsole.WriteUnexpectedFailure( + error, + command, + exception.Message, + useColor); + } + }, "standard error"); + } + else { - WriteConsoleError( - $"{commandName}: See diagnostics: {diagnostics.Path}"); + WriteConsoleError($"{commandName}: {exception.Message}"); + if (diagnostics is not null) + { + WriteConsoleError( + $"{commandName}: See diagnostics: {diagnostics.Path}"); + } } return 1; } finally { WriteDiagnostic("Host exiting."); + consoleRestore?.Dispose(); diagnostics?.Dispose(); } } @@ -197,34 +259,86 @@ internal static async Task RunControlAsync( { Session.IInstallationLifecycle lifecycle = installationLifecycle ?? Session.InstallationLifecycle.Production; + var outputOptions = new ClawCtlOutputOptions(); Session.SessionRuntime? sessionRuntime = null; Session.SessionRuntime GetSessionRuntime() => sessionRuntime ??= lifecycle.CreateRuntime(log); + (bool UseColor, IDisposable? Restore) PrepareColor() + { + bool outputIsProcessConsoleWriter = + ReferenceEquals(output, Console.Out); + bool consoleIsInteractive = + WindowsHostConsole.Instance.IsInteractive; + IDisposable? restore = null; + bool useColor = ClawCtlColorPolicy.PrepareOutput( + outputOptions.NoColor, + outputIsProcessConsoleWriter, + consoleIsInteractive, + Environment.GetEnvironmentVariable, + () => WindowsHostConsole.Instance + .TryEnableVirtualTerminalProcessing(output, log, out restore)); + return (useColor, restore); + } + + int WriteResult(IClawCtlResult result) + { + (bool useColor, IDisposable? restore) = PrepareColor(); + using (restore) + { + ClawCtlConsole.WriteResult(output, result, useColor); + } + + return result.ExitCode; + } RootCommand command = ClawCtlCommandLine.Create( new ClawCtlHandlers { - Setup = (setupOptions, cancellationToken) => RunSetupAsync( - setupOptions, - options, - GetSessionRuntime, - lifecycle, - log, - output, - cancellationToken), + Setup = async (setupOptions, cancellationToken) => + { + (bool useColor, IDisposable? restore) = PrepareColor(); + SetupCommandResult result; + using (restore) + { + result = await ClawCtlConsole.NarrateAsync( + output, + useColor, + narrate: true, + new ClawCtlProgress("Checking isolated-session support."), + progress => RunSetupAsync( + setupOptions, + options, + GetSessionRuntime, + lifecycle, + log, + progress, + cancellationToken)) + .ConfigureAwait(false); + } + + return WriteResult(result); + }, Status = async cancellationToken => { - Session.SessionStatus status = await GetSessionRuntime() + Session.SessionRuntime runtime = GetSessionRuntime(); + Session.SessionStatus status = await runtime .Coordinator.ProbeRecordedStatusAsync(cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(DescribeSessionStatus(status)) + Gateway.GatewayStatusReport gateway = await Gateway.GatewayRuntime + .Create(options, runtime.Paths, runtime, log) + .Controller + .GetStatusAsync(runtime.HelperPath, cancellationToken) + .ConfigureAwait(false); + Gateway.GatewayPersistenceStatus recovery = await lifecycle + .GetRecoveryStatusAsync(log, cancellationToken) .ConfigureAwait(false); - return status.Availability is Session.SessionAvailability.Stale or - Session.SessionAvailability.BackendUnavailable or - Session.SessionAvailability.BackendError or - Session.SessionAvailability.Unusable - ? 1 - : 0; + Session.SetupStateResult setup = + runtime.SetupState.Read(runtime.ApplicationId); + return WriteResult(new StatusCommandResult( + status, + gateway, + recovery, + setup.Record?.AgentNodeVersion)); }, CollectLogs = async (requestedPath, cancellationToken) => { @@ -237,22 +351,7 @@ await Gateway.GatewayRuntime.Create( log) .CollectLogsAsync(requestedPath, cancellationToken) .ConfigureAwait(false); - if (result.BundlePath is not null) - { - await output.WriteLineAsync( - $"Diagnostics bundle created: {result.BundlePath}").ConfigureAwait(false); - } - else - { - await output.WriteLineAsync( - "No diagnostic files were available to bundle.").ConfigureAwait(false); - } - - foreach (string warning in result.Notes) - { - await output.WriteLineAsync($"Warning: {warning}").ConfigureAwait(false); - } - return 0; + return WriteResult(new CollectLogsCommandResult(result)); }, Teardown = async (force, cancellationToken) => { @@ -268,28 +367,30 @@ await error.WriteLineAsync( Session.TeardownResult result = await lifecycle.TeardownAsync( options, runtime, log, lockAlreadyHeld: false, cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(result.Message).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(result.Detail)) - { - await output.WriteLineAsync(result.Detail).ConfigureAwait(false); - } - - return result.Succeeded ? 0 : 1; + return WriteResult(new TeardownCommandResult(result)); }, PowerShell = cancellationToken => RunPowerShellAsync( options, GetSessionRuntime(), - output, cancellationToken), GatewayStart = async cancellationToken => { + Session.SessionRuntime runtime = GetSessionRuntime(); Gateway.GatewayStartResult result = await Gateway.GatewayRuntime - .Create(options, log) + .Create(options, runtime.Paths, runtime, log) .Controller - .StartAsync(GetSessionRuntime().HelperPath, cancellationToken) + .StartAsync(runtime.HelperPath, cancellationToken) .ConfigureAwait(false); - await output.WriteLineAsync(result.Message).ConfigureAwait(false); - return result.State == Gateway.GatewayState.Running ? 0 : 1; + int? port = result.Record.ObservedPorts is { Count: 1 } + ? result.Record.ObservedPorts[0] + : null; + return WriteResult(new GatewayCommandResult( + "start", + result.State, + result.Message, + null, + result.State == Gateway.GatewayState.Running ? 0 : 1, + port)); }, GatewayStatus = async cancellationToken => { @@ -297,26 +398,41 @@ await error.WriteLineAsync( Gateway.GatewayStatusReport result = await runtime.Controller .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) .ConfigureAwait(false); - await Gateway.GatewayControlOutput.WriteStatusAsync( - output, - result, - runtime.GetRecordedWorkspacePath(), - cancellationToken).ConfigureAwait(false); - return result.State is Gateway.GatewayState.Running or Gateway.GatewayState.NotStarted - ? 0 : 1; + int? port = result.Record?.ObservedPorts is { Count: 1 } + ? result.Record.ObservedPorts[0] + : null; + return WriteResult(new GatewayCommandResult( + "status", + result.State, + result.Message, + result.Detail, + result.State is Gateway.GatewayState.Running or + Gateway.GatewayState.NotStarted + ? 0 + : 1, + port)); }, GatewayStop = async cancellationToken => { + Session.SessionRuntime runtime = GetSessionRuntime(); Gateway.GatewayStopResult result = await Gateway.GatewayRuntime - .Create(options, log) + .Create(options, runtime.Paths, runtime, log) .Controller - .StopAsync(GetSessionRuntime().HelperPath, cancellationToken) - .ConfigureAwait(false); - await Gateway.GatewayControlOutput.WriteStopAsync(output, result) + .StopAsync(runtime.HelperPath, cancellationToken) .ConfigureAwait(false); - return result.Succeeded ? 0 : 1; - } - }); + return WriteResult(new GatewayCommandResult( + "stop", + result.Stopped + ? Gateway.GatewayState.Stopped + : result.Succeeded + ? Gateway.GatewayState.NotStarted + : Gateway.GatewayState.Unknown, + result.Message, + result.Detail, + result.Succeeded ? 0 : 1)); + }, + }, + outputOptions); InvocationConfiguration configuration = new() { @@ -340,41 +456,40 @@ await Gateway.GatewayControlOutput.WriteStopAsync(output, result) .ConfigureAwait(false); } - private static async Task RunSetupAsync( + private static async Task RunSetupAsync( SetupOptions setupOptions, HostOptions options, Func getSessionRuntime, Session.IInstallationLifecycle lifecycle, Action log, - TextWriter output, + IProgress progress, CancellationToken cancellationToken) { string applicationDirectory = GetPackagedApplicationDirectory(options); log("Confirmed the packaged OpenClaw application is present."); - // Throws with the reason and its remediation when this machine cannot // host a session. There is no session-free setup to fall back to, so // nothing is reported as ready before this succeeds. await lifecycle.EnsureSessionSupportedAsync(cancellationToken) .ConfigureAwait(false); - ClawCtlConsole.WriteReadinessSummary(output, applicationDirectory); - + FreshSetupWarning? warning = null; + bool localStateCleared = false; try { Session.SessionRuntime runtime = getSessionRuntime(); if (setupOptions.Fresh) { + progress.Report(new ClawCtlProgress("Inspecting the existing installation.")); // Resolve every required package input before removing state. _ = lifecycle.ValidatePackageRuntime(options, runtime); using Session.ISessionLockHandle handle = lifecycle.AcquireLifecycleLock(runtime); - string reportPath = WriteFreshDiagnosticReport(runtime, log); - await output.WriteLineAsync($"Pre-reset diagnostic report: {reportPath}") - .ConfigureAwait(false); + FreshDiagnosticReport report = WriteFreshDiagnosticReport(runtime, log); Session.TeardownResult teardownResult; try { + progress.Report(new ClawCtlProgress("Removing existing OpenClaw resources.")); teardownResult = await lifecycle.TeardownAsync( options, runtime, log, lockAlreadyHeld: true, cancellationToken) .ConfigureAwait(false); @@ -392,28 +507,47 @@ await output.WriteLineAsync($"Pre-reset diagnostic report: {reportPath}") { if (!setupOptions.Force) { - await output.WriteLineAsync( - $"Warning: Fresh setup stopped because teardown is incomplete: {teardownResult.Message}") - .ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(teardownResult.Detail)) - { - await output.WriteLineAsync(teardownResult.Detail).ConfigureAwait(false); - } - - return 1; + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + Error: teardownResult.Detail ?? teardownResult.Message, + Fresh: true); } - await output.WriteLineAsync( - $"WARNING: Forced fresh setup will continue without confirming external cleanup: {teardownResult.Message}") - .ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(teardownResult.Detail)) + warning = new FreshSetupWarning( + teardownResult.Message, + teardownResult.Detail); + } + + Session.IInstallationStateCleaner cleaner = lifecycle.CreateStateCleaner(runtime); + try + { + progress.Report(new ClawCtlProgress("Clearing package-local state.")); + cleaner.Clear(); + localStateCleared = true; + } + catch (Exception cleanupException) + { + try + { + RestoreFreshDiagnosticReport(report, log); + } + catch (Exception restoreException) { - await output.WriteLineAsync(teardownResult.Detail).ConfigureAwait(false); + throw new Session.SessionException( + "Fresh setup local cleanup failed and the pre-reset " + + $"diagnostic report could not be restored. Cleanup: {cleanupException.Message} " + + $"Report: {restoreException.Message}", + new AggregateException(cleanupException, restoreException)); } + + throw; } - Session.IInstallationStateCleaner cleaner = lifecycle.CreateStateCleaner(runtime); - cleaner.Clear(); + RestoreFreshDiagnosticReport(report, log); log("Fresh setup cleared package-owned local state."); if (!teardownResult.Succeeded) { @@ -423,38 +557,78 @@ await output.WriteLineAsync( "A later setup reset cannot remove resources whose ownership record was cleared; " + "remove them through the backend's administrative cleanup path before treating this machine as pristine."; log(residualWarning); - await output.WriteLineAsync(residualWarning).ConfigureAwait(false); } return await RunSetupCoreAsync( - runtime, options, lifecycle, output, log, lockAlreadyHeld: true, cancellationToken) + runtime, + options, + lifecycle, + applicationDirectory, + log, + lockAlreadyHeld: true, + warning, + localStateCleared, + fresh: true, + progress, + cancellationToken) .ConfigureAwait(false); } return await RunSetupCoreAsync( - runtime, options, lifecycle, output, log, lockAlreadyHeld: false, cancellationToken) + runtime, + options, + lifecycle, + applicationDirectory, + log, + lockAlreadyHeld: false, + warning, + localStateCleared, + fresh: false, + progress, + cancellationToken) .ConfigureAwait(false); } catch (Session.SessionException exception) { log($"Isolated session setup is unavailable: {exception.Message}"); - await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") - .ConfigureAwait(false); - return 1; + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: exception.Message, + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); } catch (IOException exception) { log($"Fresh setup local cleanup failed: {exception.Message}"); - await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") - .ConfigureAwait(false); - return 1; + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: exception.Message, + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); } catch (UnauthorizedAccessException exception) { log($"Fresh setup local cleanup was denied: {exception.Message}"); - await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Message}") - .ConfigureAwait(false); - return 1; + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: exception.Message, + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); } catch (OperationCanceledException) { @@ -462,22 +636,35 @@ await output.WriteLineAsync($"OpenClaw setup could not complete: {exception.Mess ? "clawctl setup --fresh" : "clawctl setup"; log($"Setup was cancelled before it completed. Retry `{retryCommand}`."); - await output.WriteLineAsync( - $"OpenClaw setup was cancelled; setup may be incomplete. Rerun `{retryCommand}` to retry.") - .ConfigureAwait(false); - return 1; + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: + "Setup was cancelled and may be incomplete. " + + $"Run `{retryCommand}` to retry.", + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); } } - internal static async Task RunSetupCoreAsync( + internal static async Task RunSetupCoreAsync( Session.SessionRuntime runtime, HostOptions options, Session.IInstallationLifecycle lifecycle, - TextWriter output, + string applicationDirectory, Action log, bool lockAlreadyHeld, + FreshSetupWarning? warning, + bool localStateCleared, + bool fresh, + IProgress progress, CancellationToken cancellationToken) { + progress.Report(new ClawCtlProgress("Preparing the isolated session.")); using Session.ISessionLockHandle? handle = lockAlreadyHeld ? null : runtime.AcquireLifecycleLock(); @@ -508,43 +695,70 @@ internal static async Task RunSetupCoreAsync( Session.SessionRecord record = session.Record; string helperPath = runtime.StageHelper(record); + progress.Report(new ClawCtlProgress( + "Installing Node.js in the isolated session.")); SessionRuntimeInstallResult agentRuntime = await runtime.Executor.InstallRuntimeAsync( record, helperPath, GetPackagedNodeArchivePath(options), cancellationToken).ConfigureAwait(false); - await output.WriteLineAsync($"Node.js {agentRuntime.Version} is ready in the isolated agent session.") - .ConfigureAwait(false); + progress.Report(new ClawCtlProgress("Enabling gateway startup at sign-in.")); Gateway.GatewayPersistenceInstallResult recovery = await lifecycle .InstallRecoveryAsync(log, cancellationToken).ConfigureAwait(false); - await output.WriteLineAsync(recovery.Message).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(recovery.Detail)) - { - await output.WriteLineAsync(recovery.Detail).ConfigureAwait(false); - } if (recovery.State != Gateway.GatewayPersistenceState.Ready) { - return 1; + return new SetupCommandResult( + 1, + applicationDirectory, + $"{agentRuntime.Version}", + recovery, + false, + warning, + SandboxId: record.SandboxId, + Fresh: fresh, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession, + LocalStateCleared: localStateCleared); } + progress.Report(new ClawCtlProgress("Finalizing setup.")); runtime.CompleteSetup(record, agentRuntime, startupEnabled: true); - await output.WriteLineAsync("OpenClaw isolated session is ready.").ConfigureAwait(false); - return 0; + return new SetupCommandResult( + 0, + applicationDirectory, + $"{agentRuntime.Version}", + recovery, + true, + warning, + SandboxId: record.SandboxId, + Fresh: fresh, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession, + LocalStateCleared: localStateCleared); } - private static string WriteFreshDiagnosticReport( + private static FreshDiagnosticReport WriteFreshDiagnosticReport( Session.SessionRuntime runtime, Action log) { Session.SessionStatus session = runtime.Coordinator.GetRecordedStatus(); Gateway.GatewayStateResult gateway = runtime.GatewayState.Read(); - string directory = Path.Combine(Path.GetTempPath(), "OpenClawGatewayMSIX", "fresh-reset"); + string path = runtime.Paths.PreResetReportPath; + string directory = Path.GetDirectoryName(path) + ?? throw new Session.SessionException( + "The pre-reset diagnostic report path has no parent directory."); Directory.CreateDirectory(directory); - string path = Path.Combine(directory, $"pre-reset-{Guid.NewGuid():N}.log"); - List lines = + var lines = File.Exists(path) + ? new List(File.ReadAllLines(path)) + : []; + if (lines.Count > 0) + { + lines.Add(string.Empty); + } + + lines.AddRange( [ + "--- pre-reset snapshot ---", $"timestampUtc={DateTimeOffset.UtcNow:O}", $"applicationId={runtime.ApplicationId}", "report=pre-reset diagnostic metadata; credentials and local file contents are excluded" - ]; + ]); if (session.Record is not null) { lines.Add($"sessionSandboxId={session.Record.SandboxId}"); @@ -566,16 +780,32 @@ private static string WriteFreshDiagnosticReport( { lines.Add($"gatewayRecordFault={gateway.Fault?.ToString() ?? "none"}"); } + lines.Add("--- end pre-reset snapshot ---"); File.WriteAllLines(path, lines); log($"Captured redacted pre-reset diagnostic report at {path}."); - return path; + return new FreshDiagnosticReport(path, lines); + } + + private static void RestoreFreshDiagnosticReport( + FreshDiagnosticReport report, + Action log) + { + string directory = Path.GetDirectoryName(report.Path) + ?? throw new Session.SessionException( + "The pre-reset diagnostic report path has no parent directory."); + Directory.CreateDirectory(directory); + File.WriteAllLines(report.Path, report.Lines); + log($"Preserved the redacted pre-reset diagnostic report at {report.Path}."); } + private sealed record FreshDiagnosticReport( + string Path, + IReadOnlyList Lines); + private static async Task RunPowerShellAsync( HostOptions options, Session.SessionRuntime runtime, - TextWriter output, CancellationToken cancellationToken) { Session.SessionRecord record = runtime.RequireSetup(); @@ -599,14 +829,6 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken) installedTools.ShimPath!); Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists); - await output.WriteLineAsync( - $"Opening {shell.DisplayName} as {record.AgentUserName ?? "the agent account"} " + - "in the isolated session.").ConfigureAwait(false); - await output.WriteLineAsync( - "`openclaw` and `node` are on PATH; `clawctl` is not, because it " + - "manages this session from outside it.").ConfigureAwait(false); - await output.WriteLineAsync("Exit the shell to return.").ConfigureAwait(false); - return await runtime.Executor.ExecuteCommandAsync( record, new Session.SessionCommandRequest( @@ -630,24 +852,6 @@ await output.WriteLineAsync( cancellationToken).ConfigureAwait(false); } - private static string DescribeSessionStatus(Session.SessionStatus status) => - status.Availability switch - { - Session.SessionAvailability.None => - "No isolated session is recorded. Run `clawctl setup` first.", - Session.SessionAvailability.Running => - $"Isolated session is running: {status.Record!.SandboxId}.", - Session.SessionAvailability.Stale => - $"Recorded isolated session is stale: {status.Record!.SandboxId}. {status.Detail}", - Session.SessionAvailability.BackendUnavailable => - $"MXC backend is unavailable for recorded session {status.Record!.SandboxId}: {status.Detail}", - Session.SessionAvailability.BackendError => - $"MXC could not verify recorded session {status.Record!.SandboxId}: {status.Detail}", - Session.SessionAvailability.Unusable => - $"The isolated-session record is unusable: {status.Detail}", - _ => $"Isolated session is recorded: {status.Record!.SandboxId}." - }; - private static string GetPackagedApplicationDirectory(HostOptions options) { // Re-check File.Exists here (HostOptions.Parse already checked it) diff --git a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs index 5b3f1b40..dbdabdee 100644 --- a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs +++ b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs @@ -30,6 +30,10 @@ Task TeardownAsync( Task InstallRecoveryAsync( Action log, CancellationToken cancellationToken); + + Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken); } internal sealed class InstallationLifecycle : IInstallationLifecycle @@ -98,6 +102,11 @@ public Task InstallRecoveryAsync( Action log, CancellationToken cancellationToken) => GatewayRuntime.CreateRecoveryManager(log).InstallAsync(cancellationToken); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => + GatewayRuntime.CreateRecoveryManager(log).GetStatusAsync(cancellationToken); } internal sealed record PackageRuntimeMetadata( diff --git a/src/OpenClaw.Launcher/Session/SessionExecutor.cs b/src/OpenClaw.Launcher/Session/SessionExecutor.cs index 42380549..e6658d5a 100644 --- a/src/OpenClaw.Launcher/Session/SessionExecutor.cs +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -60,6 +60,9 @@ internal sealed record SessionCommandRequest( /// internal sealed class SessionExecutor { + private const int ControlCExitCode = unchecked((int)0xc000013a); + private const int PortableInterruptedExitCode = 130; + private readonly IMxcSessionClient _backend; private readonly Action _log; private readonly Func> _buildEnvironment; @@ -519,6 +522,11 @@ private static int ReadOutcome( catch (Exception exception) when ( exception is FileNotFoundException or DirectoryNotFoundException or IOException) { + if (executorExitCode == ControlCExitCode) + { + return PortableInterruptedExitCode; + } + throw new SessionException( "The isolated session did not report a launch result " + $"(executor exit code {executorExitCode}). {subject} may not " + diff --git a/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs index 7484e86f..e5793b01 100644 --- a/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs +++ b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs @@ -76,8 +76,13 @@ public async Task RunUnderLockAsync( session.Removed ? "OpenClaw isolated session and gateway service were removed." : "No isolated session was recorded; gateway service records were removed.", - session.StopFailure); + session.StopFailure, + session.Removed); } } -internal sealed record TeardownResult(bool Succeeded, string Message, string? Detail = null); +internal sealed record TeardownResult( + bool Succeeded, + string Message, + string? Detail = null, + bool SessionRemoved = false); diff --git a/src/OpenClaw.Launcher/WindowsHostConsole.cs b/src/OpenClaw.Launcher/WindowsHostConsole.cs index 838f5680..bc03790e 100644 --- a/src/OpenClaw.Launcher/WindowsHostConsole.cs +++ b/src/OpenClaw.Launcher/WindowsHostConsole.cs @@ -57,14 +57,25 @@ internal WindowsHostConsole( _encoding = encoding ?? new ConsoleEncoding(); } - public bool IsInteractive + public bool IsInteractive => IsInteractiveOutput(Console.Out); + + internal bool IsInteractiveOutput(TextWriter output) { - get + ArgumentNullException.ThrowIfNull(output); + + uint standardHandle = ReferenceEquals(output, Console.Out) + ? StdOutput + : ReferenceEquals(output, Console.Error) + ? StdError + : 0; + if (standardHandle == 0) { - nint output = _native.GetStdHandle(StdOutput); - return output != 0 && output != InvalidHandle && - _native.GetConsoleMode(output, out _); + return false; } + + nint handle = _native.GetStdHandle(standardHandle); + return handle != 0 && handle != InvalidHandle && + _native.GetConsoleMode(handle, out _); } public void InitializeUtf8() @@ -79,6 +90,55 @@ public void InitializeUtf8() new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } + internal bool TryEnableVirtualTerminalProcessing( + TextWriter output, + Action log, + out IDisposable? restore) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(log); + restore = null; + + uint standardHandle = ReferenceEquals(output, Console.Out) + ? StdOutput + : ReferenceEquals(output, Console.Error) + ? StdError + : 0; + if (standardHandle == 0) + { + return false; + } + + nint handle = _native.GetStdHandle(standardHandle); + if (handle == 0 || handle == InvalidHandle || + !_native.GetConsoleMode(handle, out uint mode)) + { + return false; + } + + if ((mode & EnableVirtualTerminalProcessing) != 0) + { + return true; + } + + try + { + if (!_native.SetConsoleMode(handle, mode | EnableVirtualTerminalProcessing)) + { + return false; + } + + restore = new VirtualTerminalScope(_native, handle, mode, log); + return true; + } + catch (Exception exception) when (exception is InvalidOperationException or + System.ComponentModel.Win32Exception) + { + log($"Could not enable colored console output: {exception.Message}"); + return false; + } + } + public IDisposable Capture(Action log) { ArgumentNullException.ThrowIfNull(log); @@ -234,6 +294,46 @@ private void TryRestore(string name, Action restore) } } + private sealed class VirtualTerminalScope : IDisposable + { + private readonly IConsoleNativeApi _native; + private readonly nint _handle; + private readonly uint _mode; + private readonly Action _log; + private int _disposed; + + public VirtualTerminalScope( + IConsoleNativeApi native, + nint handle, + uint mode, + Action log) + { + _native = native; + _handle = handle; + _mode = mode; + _log = log; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + _native.WriteConsole(_handle, "\u001b[0m"); + _native.SetConsoleMode(_handle, _mode); + } + catch (Exception exception) when (exception is InvalidOperationException or + System.ComponentModel.Win32Exception) + { + _log($"Could not restore console output mode: {exception.Message}"); + } + } + } + private sealed class ConsoleNativeApi : IConsoleNativeApi { public nint GetStdHandle(uint standardHandle) => Native.GetStdHandle(standardHandle); diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs index 27008d39..4d329521 100644 --- a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -43,6 +43,7 @@ private static async Task Main() ("response-file token is not expanded", ResponseFileTokenIsNotExpandedAsync), ("completion directive suggests commands", CompletionDirectiveSuggestsAsync), ("unpackaged setup reports identity failure", SetupReportsReadinessAsync), + ("Spectre renders clawctl output under NativeAOT", SpectreOutputRenders), ("missing application reports diagnostics", MissingApplicationReportsAsync), ("openclaw never parses its arguments", AgentNeverParsesItsArgumentsAsync) ]; @@ -224,16 +225,21 @@ private static async Task SetupReportsReadinessAsync() int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); AssertExitCode(1, exitCode, fixture); + string error = FlattenRendered(fixture.Error.ToString()); AssertContains( - fixture.Error.ToString(), + error, "not running from its installed package", fixture); - AssertContains(fixture.Error.ToString(), "newer version of Windows", fixture); - AssertContains(fixture.Error.ToString(), fixture.LogPath, fixture); + AssertContains(error, "newer version of Windows", fixture); + AssertContains(error, "clawctl collect-logs", fixture); - // Nothing is reported as ready when the support check refuses. + // The attempted requirement check is visible, but nothing is reported + // as ready when the support check refuses. Assert( - fixture.Output.ToString().Length == 0, + fixture.Output.ToString().Contains( + "Checking isolated-session support.", + StringComparison.Ordinal) && + !fixture.Output.ToString().Contains("ready", StringComparison.Ordinal), "A failed support check still reported readiness on standard output."); fixture.AssertLogRecordsStartupAndExit(); Assert( @@ -241,8 +247,70 @@ private static async Task SetupReportsReadinessAsync() "The readiness check removed or replaced the fixture entry point."); } + // Spectre.Console composes the renderables; this proves the composition, + // the ANSI writer, and the no-colour writer all survive trimming and + // ahead-of-time compilation, and that the two stay textually identical. + private static Task SpectreOutputRenders() + { + var result = new SetupCommandResult( + 0, + @"C:\package\app", + "24.20.0", + new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready", + Changed: true), + SessionReady: true, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession); + + using var colored = new StringWriter(); + using var plain = new StringWriter(); + ClawCtlConsole.WriteResult(colored, result, useColor: true); + ClawCtlConsole.WriteResult(plain, result); + + string coloredText = colored.ToString(); + Assert( + coloredText.Contains('\u001b', StringComparison.Ordinal), + "Spectre did not emit an ANSI color sequence."); + Assert( + !plain.ToString().Contains('\u001b', StringComparison.Ordinal), + "The plain renderer leaked an ANSI color sequence."); + Assert( + StripAnsi(coloredText) == plain.ToString(), + "Colored output did not match plain output once ANSI was stripped."); + + using var failure = new StringWriter(); + ClawCtlConsole.WriteUnexpectedFailure(failure, "setup", "no package identity."); + Assert( + failure.ToString().Contains("no package identity.", StringComparison.Ordinal), + "The note callout did not render its message."); + return Task.CompletedTask; + } + + private static string StripAnsi(string value) + { + var builder = new System.Text.StringBuilder(value.Length); + for (int index = 0; index < value.Length; index++) + { + if (value[index] != '\u001b') + { + builder.Append(value[index]); + continue; + } + + while (index < value.Length && value[index] != 'm') + { + index++; + } + } + + return builder.ToString(); + } + // The operational error boundary is part of startup, not of the parser. - // It must still name the diagnostic log, which here is the fixture's. + // It directs users to the diagnostics command instead of exposing an + // implementation-specific log path. private static async Task MissingApplicationReportsAsync() { using Fixture fixture = Fixture.CreateWithoutApplication(); @@ -250,7 +318,7 @@ private static async Task MissingApplicationReportsAsync() int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); AssertExitCode(1, exitCode, fixture); - AssertContains(fixture.Error.ToString(), fixture.LogPath, fixture); + AssertContains(fixture.Error.ToString(), "clawctl collect-logs", fixture); Assert( !fixture.Output.ToString().Contains("package is ready", StringComparison.Ordinal), "A failed readiness check still reported success."); @@ -279,6 +347,11 @@ private static async Task AgentNeverParsesItsArgumentsAsync() private static string LauncherVersion() => typeof(HostStartup).Assembly.GetName().Version?.ToString() ?? "unknown"; + private static string FlattenRendered(string value) => + string.Join(' ', value.Replace('|', ' ').Split( + (char[]?)null, + StringSplitOptions.RemoveEmptyEntries)); + private static string DriverVersion() => typeof(SmokeProgram).Assembly.GetName().Version?.ToString() ?? "unknown"; @@ -434,6 +507,10 @@ public IInstallationStateCleaner CreateStateCleaner( public Task InstallRecoveryAsync( Action log, CancellationToken cancellationToken) => throw Started(); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => throw Started(); } private static string CreateRoot() diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs new file mode 100644 index 00000000..168d4f0c --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlColorPolicyTests.cs @@ -0,0 +1,98 @@ +namespace OpenClaw.Launcher.Tests; + +public sealed class ClawCtlColorPolicyTests +{ + [Fact] + public void InteractiveConsoleUsesColorByDefault() + { + bool enabled = ClawCtlColorPolicy.ShouldUseColor( + noColor: false, + outputIsConsole: true, + consoleIsInteractive: true, + _ => null); + + Assert.True(enabled); + } + + [Theory] + [InlineData(true, null, null)] + [InlineData(false, "1", null)] + [InlineData(false, null, "true")] + public void ExplicitAndNonInteractiveConditionsDisableColor( + bool noColor, + string? noColorEnvironment, + string? continuousIntegration) + { + string? ReadEnvironment(string name) => name switch + { + "NO_COLOR" => noColorEnvironment, + "CI" => continuousIntegration, + _ => null + }; + + bool enabled = ClawCtlColorPolicy.ShouldUseColor( + noColor, + outputIsConsole: true, + consoleIsInteractive: true, + ReadEnvironment); + + Assert.False(enabled); + } + + [Fact] + public void ForceColorEnablesRedirectedOutput() + { + static string? ReadEnvironment(string name) => + name == "FORCE_COLOR" ? "1" : null; + + bool enabled = ClawCtlColorPolicy.ShouldUseColor( + noColor: false, + outputIsConsole: false, + consoleIsInteractive: false, + ReadEnvironment); + + Assert.True(enabled); + } + + [Fact] + public void ForceColorDoesNotRequireVirtualTerminalSetupForRedirectedOutput() + { + bool enableCalled = false; + static string? ReadEnvironment(string name) => + name == "FORCE_COLOR" ? "1" : null; + + bool enabled = ClawCtlColorPolicy.PrepareOutput( + noColor: false, + outputIsProcessConsoleWriter: true, + consoleIsInteractive: false, + ReadEnvironment, + () => + { + enableCalled = true; + return false; + }); + + Assert.True(enabled); + Assert.False(enableCalled); + } + + [Fact] + public void InteractiveColorRequiresVirtualTerminalSetup() + { + bool enableCalled = false; + + bool enabled = ClawCtlColorPolicy.PrepareOutput( + noColor: false, + outputIsProcessConsoleWriter: true, + consoleIsInteractive: true, + _ => null, + () => + { + enableCalled = true; + return false; + }); + + Assert.False(enabled); + Assert.True(enableCalled); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index 95077a10..aab387be 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -52,7 +52,7 @@ public async Task HelpDescribesBundledRuntimePreparation() Assert.Contains("setup", help, StringComparison.Ordinal); Assert.Contains("--version", help, StringComparison.Ordinal); - Assert.Contains("bundled Node.js", help, StringComparison.Ordinal); + Assert.Contains("ready", help, StringComparison.Ordinal); Assert.Contains("clawctl setup", help, StringComparison.Ordinal); Assert.Contains("openclaw ", help, StringComparison.Ordinal); } @@ -112,6 +112,37 @@ public async Task GatewayServiceStartInvokesOnlyTheStartHandler() Assert.Equal(1, starts); } + [Theory] + [InlineData("setup --no-color")] + [InlineData("--no-color status")] + [InlineData("status --no-color")] + [InlineData("collect-logs --no-color")] + [InlineData("teardown --no-color")] + [InlineData("pwsh --no-color")] + [InlineData("gateway-service start --no-color")] + [InlineData("gateway-service status --no-color")] + [InlineData("gateway-service stop --no-color")] + public async Task NoColorIsAvailableToEveryCommand(string commandLine) + { + var outputOptions = new ClawCtlOutputOptions(); + RootCommand root = ClawCtlCommandLine.Create(new ClawCtlHandlers + { + Setup = (_, _) => Task.FromResult(0), + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }, outputOptions); + + int exitCode = await root.Parse(commandLine).InvokeAsync(); + + Assert.Equal(0, exitCode); + Assert.True(outputOptions.NoColor); + } + [Fact] public async Task SetupHelpDescribesRuntimePreparationWithoutRunningIt() { diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs index 1e0745a1..1c802229 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -1,27 +1,217 @@ +using OpenClaw.Launcher.Gateway; +using Spectre.Console; +using System.Text.RegularExpressions; + namespace OpenClaw.Launcher.Tests; -public sealed class ClawCtlConsoleTests : IDisposable +public sealed class ClawCtlConsoleTests { - private readonly string _testDirectory = TestDirectory.Create(); + [Theory] + [InlineData(true, "\U0001f980 clawctl status")] + [InlineData(false, "clawctl status")] + public void HeadingUsesCrabIdentityWhenUnicodeIsAvailable( + bool useUnicode, + string expected) + { + Assert.Equal(expected, ClawCtlConsole.FormatHeading("status", useUnicode)); + } + + [Fact] + public void WriteSetupResultShowsReadyStateAndNextAction() + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteResult( + output, + new SetupCommandResult( + 0, + @"C:\package\app", + "24.20.0", + new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready", + Changed: true), + SessionReady: true, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession)); + + string summary = output.ToString(); + Assert.Contains("clawctl setup", summary, StringComparison.Ordinal); + Assert.Contains("Package:", summary, StringComparison.Ordinal); + Assert.Contains("Runtime:", summary, StringComparison.Ordinal); + Assert.Contains("Recovery:", summary, StringComparison.Ordinal); + Assert.Contains("Session:", summary, StringComparison.Ordinal); + Assert.Contains("may want to", summary, StringComparison.Ordinal); + Assert.Contains("Optional:", summary, StringComparison.Ordinal); + Assert.Contains("openclaw onboard", summary, StringComparison.Ordinal); + Assert.DoesNotContain("Run: openclaw", summary, StringComparison.Ordinal); + } [Fact] - public void WriteReadinessSummaryDescribesPackagedApplication() + public void WriteSetupWithoutIsolationReportsHostRuntime() { using var output = new StringWriter(); - string applicationDirectory = Path.Combine(_testDirectory, "app"); - ClawCtlConsole.WriteReadinessSummary( + ClawCtlConsole.WriteResult( output, - applicationDirectory); + new SetupCommandResult( + 0, + @"C:\package\app", + "24.20.0", + null, + SessionReady: false, + RuntimeLocation: SetupRuntimeLocation.Host)); + + string summary = output.ToString(); + Assert.Contains( + "Node.js 24.20.0 installed in the host", + summary, + StringComparison.Ordinal); + Assert.DoesNotContain( + "installed in the isolated session", + summary, + StringComparison.Ordinal); + } + + [Fact] + public void StoppingAnAbsentGatewayDoesNotRecommendStartingIt() + { + using var output = new StringWriter(); + + ClawCtlConsole.WriteResult(output, new GatewayCommandResult( + "stop", + GatewayState.NotStarted, + "No gateway was recorded, so there was nothing to stop.", + null, + 0)); string summary = output.ToString(); - Assert.Contains("package is ready", summary, StringComparison.Ordinal); - Assert.Contains(applicationDirectory, summary, StringComparison.Ordinal); + Assert.Contains("nothing to stop", summary, StringComparison.Ordinal); + Assert.DoesNotContain( + "clawctl gateway-service start", + summary, + StringComparison.Ordinal); } - public void Dispose() + [Fact] + public void SuccessfulTeardownPreservesStopFailureDetail() { - Directory.Delete(_testDirectory, recursive: true); - GC.SuppressFinalize(this); + using var output = new StringWriter(); + + ClawCtlConsole.WriteResult( + output, + new TeardownCommandResult(new global::OpenClaw.Launcher.Session.TeardownResult( + true, + "OpenClaw resources were removed.", + "Stopping the session failed before deprovisioning succeeded.", + SessionRemoved: true))); + + string summary = output.ToString(); + Assert.Contains("Session:", summary, StringComparison.Ordinal); + Assert.Contains("removed", summary, StringComparison.Ordinal); + Assert.Contains( + "stopping the session failed before deprovisioning succeeded", + summary, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RedirectedNarrationWritesTheInitialAndReportedStages() + { + using var output = new StringWriter(); + + int result = await ClawCtlConsole.NarrateAsync( + output, + useColor: false, + narrate: true, + new ClawCtlProgress("Checking requirements."), + progress => + { + progress.Report(new ClawCtlProgress("Checking requirements.")); + progress.Report(new ClawCtlProgress("Installing the runtime.")); + return Task.FromResult(42); + }); + + Assert.Equal(42, result); + string rendered = output.ToString(); + Assert.DoesNotContain( + $"Checking requirements.{Environment.NewLine} Checking requirements.", + rendered, + StringComparison.Ordinal); + Assert.Contains("Installing the runtime.", rendered, StringComparison.Ordinal); + } + + [Fact] + public async Task LiveNarrationRequiresAndRendersANonEmptyInitialStage() + { + using var output = new StringWriter(); + IAnsiConsole console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.Yes, + Out = new AnsiConsoleOutput(output), + }); + + int result = await ClawCtlConsole.NarrateWithStatusAsync( + console, + new ClawCtlProgress("Preparing the isolated session."), + progress => + { + progress.Report(new ClawCtlProgress("Installing the runtime.")); + return Task.FromResult(42); + }); + + Assert.Equal(42, result); + Assert.Contains("Installing the runtime.", output.ToString(), StringComparison.Ordinal); + await Assert.ThrowsAsync(() => + ClawCtlConsole.NarrateWithStatusAsync( + console, + new ClawCtlProgress(" "), + _ => Task.FromResult(0))); + } + + [Fact] + public void DiagnosticsBundlePathRemainsAnExactStandaloneLine() + { + string bundlePath = @"C:\diagnostics\" + new string('a', 120) + ".zip"; + using var output = new StringWriter(); + + ClawCtlConsole.WriteResult( + output, + new CollectLogsCommandResult(new DiagnosticsBundleResult( + bundlePath, + SessionReached: true, + Notes: []))); + + string rendered = output.ToString(); + Assert.Contains($"Bundle:{Environment.NewLine}{bundlePath}", rendered, StringComparison.Ordinal); + Assert.Contains(bundlePath, rendered, StringComparison.Ordinal); + } + + [Fact] + public void ColorChangesOnlyTerminalFormatting() + { + var result = new SetupCommandResult( + 0, + @"C:\package\app", + "24.20.0", + new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready", + Changed: true), + SessionReady: true, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession); + using var plain = new StringWriter(); + using var colored = new StringWriter(); + + ClawCtlConsole.WriteResult(plain, result); + ClawCtlConsole.WriteResult(colored, result, useColor: true); + + Assert.Contains("\u001b[", colored.ToString(), StringComparison.Ordinal); + Assert.Equal( + plain.ToString(), + Regex.Replace(colored.ToString(), "\u001b\\[[0-9;]*m", string.Empty)); } } diff --git a/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs b/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs index 53ca57ce..d5314f02 100644 --- a/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs +++ b/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs @@ -35,4 +35,8 @@ public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntim public Task InstallRecoveryAsync( Action log, CancellationToken cancellationToken) => throw Fail(); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => throw Fail(); } diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs index 19992569..7c831101 100644 --- a/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs @@ -62,6 +62,30 @@ public async Task CollectionReadsALiveLogWithWriteAndDeleteSharing() Assert.Contains(archive.Entries, entry => entry.FullName == "host/openclaw.log"); } + [Fact] + public async Task CollectionIncludesThePreResetDiagnosticReport() + { + (GatewayRuntime runtime, HostPaths paths) = CreateRuntime(); + Directory.CreateDirectory(Path.GetDirectoryName(paths.PreResetReportPath)!); + await File.WriteAllTextAsync(paths.PreResetReportPath, "sessionSandboxId=fixture"); + string bundlePath = Path.Combine(_root, "pre-reset.zip"); + + DiagnosticsBundleResult result = await runtime.CollectLogsAsync( + bundlePath, + CancellationToken.None); + + Assert.Equal(bundlePath, result.BundlePath); + using ZipArchive archive = ZipFile.OpenRead(bundlePath); + ZipArchiveEntry entry = Assert.Single( + archive.Entries, + candidate => candidate.FullName == "host/pre-reset.log"); + using var reader = new StreamReader(entry.Open()); + Assert.Contains( + "sessionSandboxId=fixture", + await reader.ReadToEndAsync(), + StringComparison.Ordinal); + } + [Fact] public async Task CorruptSessionStateStillProducesAHostOnlyBundle() { diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs index 875e21d9..02718556 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace OpenClaw.Launcher.Tests; // Startup is the part of the host that owns diagnostics, argument routing, the @@ -35,7 +37,7 @@ public async Task StartupWritesRecordsToTheSuppliedDiagnosticLog() // The library's default exception handler is disabled so this boundary, // not System.CommandLine, reports operational failures. [Fact] - public async Task OperationalFailureReportsTheDiagnosticPathAndExitsNonZero() + public async Task OperationalFailureRecommendsCollectingLogsAndExitsNonZero() { using var output = new StringWriter(); using var error = new StringWriter(); @@ -46,7 +48,11 @@ public async Task OperationalFailureReportsTheDiagnosticPathAndExitsNonZero() CreateStartup(logPath, output, error)); Assert.Equal(1, exitCode); - Assert.Contains(logPath, error.ToString(), StringComparison.Ordinal); + Assert.Contains("clawctl collect-logs", error.ToString(), StringComparison.Ordinal); + Assert.Contains( + "https://github.com/openclaw/openclaw-windows-packaging/issues", + error.ToString(), + StringComparison.Ordinal); Assert.DoesNotContain("package is ready", output.ToString(), StringComparison.Ordinal); Assert.Contains( "Unhandled failure", @@ -54,6 +60,41 @@ await File.ReadAllTextAsync(logPath), StringComparison.Ordinal); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OperationalFailureStillExitsAndDisposesDiagnosticsWhenRenderingFails( + bool disposed) + { + using var output = new StringWriter(); + TextWriter error = disposed + ? new UnavailableTextWriter(new ObjectDisposedException("stderr")) + : new UnavailableTextWriter(new IOException("stderr is unavailable")); + string logPath = Path.Combine(_testDirectory, "logs", "openclaw.log"); + HostDiagnosticLog diagnostics = HostDiagnosticLog.Create(logPath); + HostStartup startup = new() + { + Entrypoint = HostEntrypoint.Control, + CreateDiagnostics = () => diagnostics, + BaseDirectory = _testDirectory, + Output = output, + Error = error + }; + + int exitCode = await Program.RunAsync(["setup"], startup); + + Assert.Equal(1, exitCode); + string log = await File.ReadAllTextAsync(logPath); + Assert.Contains("Unhandled failure", log, StringComparison.Ordinal); + Assert.Contains( + disposed + ? "Failure output to standard error failed: ObjectDisposedException." + : "Failure output to standard error failed: IOException.", + log, + StringComparison.Ordinal); + Assert.Throws(() => diagnostics.Write("after return")); + } + // Losing the log must not take the command down with it: the host warns // once and keeps running. [Fact] @@ -135,4 +176,11 @@ public void Dispose() Directory.Delete(_testDirectory, recursive: true); GC.SuppressFinalize(this); } + + private sealed class UnavailableTextWriter(Exception exception) : TextWriter + { + public override Encoding Encoding => Encoding.UTF8; + + public override void WriteLine(string? value) => throw exception; + } } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs index 02f57b0e..15a962cb 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using OpenClaw.Launcher.Mxc; using OpenClaw.Launcher.Gateway; using OpenClaw.Launcher.Session; @@ -62,6 +63,83 @@ public async Task AgentForwardsEveryArgumentIntoTheSession() Assert.Equal(7, exitCode); } + [Fact] + public async Task AgentControlCExitsSilentlyWithPortableInterruptedCode() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions setupOptions = CreateSetupOptions(applicationDirectory); + SessionRuntime runtime = CreateSessionRuntime(); + int setupExitCode = await Program.RunControlAsync( + setupOptions, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }); + Assert.Equal(0, setupExitCode); + _lastSessionBackend!.AttachedBehavior = _ => + Task.FromResult(unchecked((int)0xc000013a)); + + int exitCode = await Program.RunAgentAsync( + new HostOptions(applicationDirectory, setupOptions.PackagedNodeArchivePath, ["status"]), + _ => { }, + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: _ => null); + + Assert.Equal(130, exitCode); + } + + [Fact] + public async Task PowerShellControlCExitsSilentlyWithPortableInterruptedCode() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + HostOptions options = CreateSetupOptions(applicationDirectory); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) { TeardownSucceeds = true }; + int setupExitCode = await Program.RunControlAsync( + options, + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: lifecycle); + Assert.Equal(0, setupExitCode); + _lastSessionBackend!.ExecuteBehavior = _ => + { + string requestPath = Directory.GetFiles( + _lastSessionBackend.Metadata!.EphemeralWorkspacePath, + "tools-*.json").Single(); + SessionToolInstallRequest request = SessionRuntimeProtocol.ReadToolInstallRequest( + File.ReadAllText(requestPath)); + File.WriteAllText( + SessionLaunchProtocol.ResultPathFor(requestPath), + SessionRuntimeProtocol.SerializeToolInstallResult(new SessionToolInstallResult + { + RequestId = request.RequestId, + ShimPath = @"C:\Users\agent\Shared\.openclaw-tools\openclaw.cmd" + })); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + _lastSessionBackend!.AttachedBehavior = _ => + Task.FromResult(unchecked((int)0xc000013a)); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + options, + ["pwsh"], + _ => { }, + output, + error, + installationLifecycle: lifecycle); + + Assert.Equal(130, exitCode); + Assert.Equal(string.Empty, output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + [Fact] public async Task AgentFailsLoudlyWhenTheMachineCannotHostASession() { @@ -116,10 +194,16 @@ public async Task SetupFailsLoudlyWhenTheMachineCannotHostASession() Assert.True(File.Exists(entryPoint)); Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(entryPoint)); - // Nothing is reported as ready on a machine that cannot host a session. - Assert.Equal(string.Empty, output.ToString()); + // The attempted requirement check is visible, but nothing is reported + // as ready on a machine that cannot host a session. + Assert.Contains( + "Checking isolated-session support.", + output.ToString(), + StringComparison.Ordinal); + Assert.DoesNotContain("ready", output.ToString(), StringComparison.Ordinal); } + [Fact] public async Task AgentUsesTheRuntimeInstalledForTheSessionWithoutHostFallback() { @@ -353,6 +437,16 @@ public async Task SetupIgnoresTheRemovedSessionOptOutVariable() Assert.Equal(0, exitCode); Assert.NotNull(runtime.SetupState.Read(runtime.ApplicationId).Record); + string rendered = output.ToString(); + Assert.Contains("Checking isolated-session support.", rendered, StringComparison.Ordinal); + Assert.Contains("Preparing the isolated session.", rendered, StringComparison.Ordinal); + Assert.Contains( + "Installing Node.js in the isolated session.", + rendered, + StringComparison.Ordinal); + Assert.Contains("Enabling gateway startup at sign-in.", rendered, StringComparison.Ordinal); + Assert.Contains("Finalizing setup.", rendered, StringComparison.Ordinal); + Assert.Contains("openclaw onboard", rendered, StringComparison.Ordinal); } [Fact] @@ -375,8 +469,11 @@ public async Task FreshSetupStopsBeforeClearingStateWhenTeardownFails() Assert.Equal(1, exitCode); Assert.Equal(["validate", "lock", "recovery", "gateway", "session"], lifecycle.Calls); Assert.False(lifecycle.Cleaner.Cleared); - Assert.Contains("Pre-reset diagnostic report:", output.ToString(), StringComparison.Ordinal); - Assert.Contains("teardown is incomplete", output.ToString(), StringComparison.Ordinal); + Assert.Contains("Session:", output.ToString(), StringComparison.Ordinal); + Assert.Contains( + "recovery removal failed", + output.ToString(), + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -407,9 +504,47 @@ public async Task FreshSetupResetsInOrderBeforeProvisioningAndRecordsConsistentN Assert.DoesNotContain( ((FakeMxcSessionClient)runtime.Backend).Calls, call => call.StartsWith("execute-attached:", StringComparison.Ordinal)); - Assert.Contains("OpenClaw isolated session is ready.", output.ToString(), StringComparison.Ordinal); + Assert.Contains("Session:", output.ToString(), StringComparison.Ordinal); + Assert.Contains("ready", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task StatusReportsSessionRuntimeGatewayAndRecovery() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime); + using var setupOutput = new StringWriter(); + int setupExitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup"], + _ => { }, + setupOutput, + TextWriter.Null, + installationLifecycle: lifecycle); + using var statusOutput = new StringWriter(); + + int statusExitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["status"], + _ => { }, + statusOutput, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.Equal(0, setupExitCode); + Assert.Equal(0, statusExitCode); + string status = statusOutput.ToString(); + Assert.Contains("Session:", status, StringComparison.Ordinal); + Assert.Contains("Runtime:", status, StringComparison.Ordinal); + Assert.Contains("Node.js 24.20.0", status, StringComparison.Ordinal); + Assert.Contains("Gateway:", status, StringComparison.Ordinal); + Assert.Contains("not started", status, StringComparison.Ordinal); + Assert.Contains("Recovery:", status, StringComparison.Ordinal); + Assert.Contains("configured", status, StringComparison.Ordinal); } + [Fact] public async Task FreshSetupCleanerFailurePreventsProvisionAndReady() { @@ -461,7 +596,7 @@ public async Task ForcedFreshSetupContinuesAfterUnresolvedOwnedCleanupAndKeepsTh Assert.True(lifecycle.Cleaner.Cleared); Assert.Equal(SetupPhase.Ready, runtime.SetupState.Read(runtime.ApplicationId).Record!.Phase); Assert.Contains( - "did not prove a pristine machine", + "not proven clean", output.ToString(), StringComparison.Ordinal); Assert.Contains( @@ -496,12 +631,23 @@ public async Task ForcedFreshSetupContinuesWhenTeardownThrowsARecoverableFailure "session.json cannot be read", output.ToString(), StringComparison.Ordinal); + + // The warning is rendered inside a note callout, so the renderer owns + // where it breaks lines. Flatten the box gutters and wrapping before + // asserting, and assert the whole sentence rather than a fragment that + // happens to survive a particular line break. Assert.Contains( - "cannot remove resources whose ownership record was cleared", - output.ToString(), + "because the record of what we owned is gone.", + FlattenRenderedText(output.ToString()), StringComparison.Ordinal); } + private static string FlattenRenderedText(string value) => + System.Text.RegularExpressions.Regex.Replace( + value.Replace('|', ' ').Replace('\u2502', ' '), + @"\s+", + " "); + [Fact] public async Task FreshResetReportPreservesReadableResidualIdentityOutsideClearedState() { @@ -535,10 +681,7 @@ public async Task FreshResetReportPreservesReadableResidualIdentityOutsideCleare installationLifecycle: lifecycle); Assert.True(exitCode == 0, output.ToString()); - string reportLine = output.ToString() - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) - .Single(line => line.StartsWith("Pre-reset diagnostic report:", StringComparison.Ordinal)); - string reportPath = reportLine["Pre-reset diagnostic report:".Length..].Trim(); + string reportPath = runtime.Paths.PreResetReportPath; try { string report = await File.ReadAllTextAsync(reportPath); @@ -550,6 +693,93 @@ public async Task FreshResetReportPreservesReadableResidualIdentityOutsideCleare } } + [Fact] + public async Task ProductionCleanupPreservesEveryResetReportForCollectLogs() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + const string oldSandboxId = "iso:production-cleaner-residual"; + new SessionStateStore(runtime.Paths.SessionStatePath).Write(new SessionRecord + { + SchemaVersion = SessionStateStore.CurrentSchemaVersion, + SandboxId = oldSandboxId, + ApplicationId = runtime.ApplicationId, + AgentUserName = "agent_old", + AgentUserSid = "S-1-5-21-0-0-0-1010", + WorkspacePath = Path.Combine(_testDirectory, "old-workspace"), + Generation = "test-generation", + CreatedUtc = DateTimeOffset.UtcNow + }); + string productStateRoot = Path.Combine(_testDirectory, "product-state"); + Directory.CreateDirectory(productStateRoot); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Backend unavailable."), + StateCleaner = new InstallationStateCleaner(runtime.Paths, productStateRoot) + }; + HostOptions options = CreateSetupOptions(applicationDirectory); + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + options, + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + string replacementSandboxId = runtime.SetupState + .Read(runtime.ApplicationId) + .Record! + .SandboxId!; + + var secondLifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Backend unavailable."), + StateCleaner = new InstallationStateCleaner(runtime.Paths, productStateRoot) + }; + using var secondOutput = new StringWriter(); + int secondExitCode = await Program.RunControlAsync( + options, + ["setup", "--fresh", "--force"], + _ => { }, + secondOutput, + TextWriter.Null, + installationLifecycle: secondLifecycle); + + Assert.True(secondExitCode == 0, secondOutput.ToString()); + ((FakeMxcSessionClient)runtime.Backend).ExecuteBehavior = _ => + Task.FromResult(new MxcExecutionResult(1, string.Empty, "guest collection unavailable")); + string bundlePath = Path.Combine(_testDirectory, "post-reset.zip"); + DiagnosticsBundleResult bundle = await GatewayRuntime.Create( + options, + runtime.Paths, + runtime, + _ => { }) + .CollectLogsAsync(bundlePath, CancellationToken.None); + Assert.Equal(bundlePath, bundle.BundlePath); + using ZipArchive archive = ZipFile.OpenRead(bundlePath); + ZipArchiveEntry report = Assert.Single( + archive.Entries, + entry => entry.FullName == "host/pre-reset.log"); + using var reader = new StreamReader(report.Open()); + string reportText = await reader.ReadToEndAsync(); + Assert.Contains( + $"sessionSandboxId={oldSandboxId}", + reportText, + StringComparison.Ordinal); + Assert.Contains( + $"sessionSandboxId={replacementSandboxId}", + reportText, + StringComparison.Ordinal); + Assert.Equal( + 2, + reportText.Split( + "--- pre-reset snapshot ---", + StringSplitOptions.None).Length - 1); + } + [Fact] public async Task ForcedFreshSetupRecreatesDiagnosticsWithTheResidualWarning() { @@ -613,7 +843,11 @@ public async Task ForcedFreshSetupStillStopsWhenLocalCleanupFails() Assert.Equal(1, exitCode); Assert.Equal(["validate", "lock", "recovery", "gateway", "session", "clean"], lifecycle.Calls); Assert.Null(runtime.SetupState.Read(runtime.ApplicationId).Record); - Assert.DoesNotContain("isolated session is ready", output.ToString(), StringComparison.OrdinalIgnoreCase); + string text = FlattenRenderedText(output.ToString()); + Assert.Contains("could not confirm external cleanup", text, StringComparison.Ordinal); + Assert.Contains("state root is denied", text, StringComparison.Ordinal); + Assert.DoesNotContain("package-local state cleared", text, StringComparison.Ordinal); + Assert.DoesNotContain("isolated session is ready", text, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -679,6 +913,93 @@ public async Task FreshSetupProvisionFailureDoesNotClaimReady() Assert.DoesNotContain("OpenClaw isolated session is ready.", output.ToString(), StringComparison.Ordinal); } + [Fact] + public async Task ForcedFreshProvisionFailureRetainsResidualCleanupWarning() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + ((FakeMxcSessionClient)runtime.Backend).ExecuteBehavior = _ => + { + WriteRuntimeInstallResult((FakeMxcSessionClient)runtime.Backend, 1); + return Task.FromResult(new MxcExecutionResult(1, string.Empty, string.Empty)); + }; + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Backend cleanup remains unresolved.") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + string text = FlattenRenderedText(output.ToString()); + Assert.Equal(1, exitCode); + Assert.Contains("could not confirm external cleanup", text, StringComparison.Ordinal); + Assert.Contains("package-local state cleared", text, StringComparison.Ordinal); + } + + [Fact] + public async Task ForcedFreshCancellationRetainsResidualCleanupWarning() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + ((FakeMxcSessionClient)runtime.Backend).ExecuteBehavior = _ => + throw new OperationCanceledException(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + TeardownResult = new TeardownResult(false, "Backend cleanup remains unresolved.") + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + string text = FlattenRenderedText(output.ToString()); + Assert.Equal(1, exitCode); + Assert.Contains("could not confirm external cleanup", text, StringComparison.Ordinal); + Assert.Contains("cancelled", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("package-local state cleared", text, StringComparison.Ordinal); + } + + [Fact] + public async Task RecoveryFailureReportsRuntimeInIsolatedSession() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new FailingFreshLifecycle(runtime) + { + RecoveryResult = new GatewayPersistenceInstallResult( + GatewayPersistenceState.ActionRequired, + GatewayPersistenceLane.TaskScheduler, + "Recovery registration failed.", + Changed: false) + }; + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + string text = output.ToString(); + Assert.Equal(1, exitCode); + Assert.Contains("installed in the isolated session", text, StringComparison.Ordinal); + Assert.DoesNotContain("installed in the host", text, StringComparison.Ordinal); + } + [Fact] public async Task ConcurrentFreshSetupReportsBusyWithoutStartingAnotherReset() { @@ -714,7 +1035,10 @@ await lifecycle.ProvisionStarted.Task firstOutput.Dispose(); Assert.Equal(1, second); Assert.Equal(1, lifecycle.TeardownCount); - Assert.Contains("Another OpenClaw process", secondOutput.ToString(), StringComparison.Ordinal); + Assert.Contains( + "another OpenClaw process", + secondOutput.ToString(), + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -1029,6 +1353,16 @@ public Task InstallRecoveryAsync( GatewayPersistenceLane.TaskScheduler, "Logon recovery is configured.", Changed: true)); + + // Reported rather than queried, for the same reason the install is + // stubbed: a test must not touch a real scheduled task. + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceStatus( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.")); } private static string SandboxIdFor(string applicationId) => @@ -1109,6 +1443,8 @@ public FailingFreshLifecycle(SessionRuntime runtime) public RecordingCleaner Cleaner { get; } = new(); + public IInstallationStateCleaner? StateCleaner { get; init; } + public Exception? CleanerException { get; init; } public Exception? TeardownException { get; init; } @@ -1117,6 +1453,13 @@ public FailingFreshLifecycle(SessionRuntime runtime) public bool TeardownSucceeds { get; init; } + public GatewayPersistenceInstallResult RecoveryResult { get; init; } = + new( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready", + false); + public TaskCompletionSource ProvisionStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -1179,6 +1522,11 @@ public ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime) public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) { Calls.Add("clean"); + if (StateCleaner is not null) + { + return StateCleaner; + } + Cleaner.Exception = CleanerException; return Cleaner; } @@ -1186,11 +1534,15 @@ public IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime) public Task InstallRecoveryAsync( Action log, CancellationToken cancellationToken) => - Task.FromResult(new GatewayPersistenceInstallResult( + Task.FromResult(RecoveryResult); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceStatus( GatewayPersistenceState.Ready, GatewayPersistenceLane.TaskScheduler, - "ready", - false)); + "ready")); } private sealed class ProbeCountingLifecycle(SessionRuntime runtime, Action onProbe) @@ -1227,6 +1579,11 @@ public Task InstallRecoveryAsync( Action log, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task GetRecoveryStatusAsync( + Action log, + CancellationToken cancellationToken) => + throw new NotSupportedException(); } private sealed class RecordingCleaner : IInstallationStateCleaner diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs index b2990c7b..92d34612 100644 --- a/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -571,6 +571,20 @@ public async Task MissingControlResultIsReportedRatherThanTrusted() Assert.Contains("did not report a launch result", exception.Message, StringComparison.Ordinal); } + [Fact] + public async Task ControlCWithoutAControlResultReturnsPortableInterruptedExitCode() + { + _backend.AttachedBehavior = _ => + Task.FromResult(unchecked((int)0xc000013a)); + + int exitCode = await Create().ExecuteAsync( + Record(), + Request(), + CancellationToken.None); + + Assert.Equal(130, exitCode); + } + [Fact] public async Task UnreadableControlResultIsReported() { diff --git a/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs index 6c312dab..97d781be 100644 --- a/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs @@ -55,7 +55,8 @@ public void CaptureDoesNotWriteCleanupForRedirectedOutputOrZeroCodePages() { InputCodePage = 0, OutputCodePage = 0, - OutputIsConsole = false + OutputIsConsole = false, + ErrorIsConsole = false }; var console = new WindowsHostConsole(native); @@ -110,6 +111,39 @@ public void CaptureContinuesRestoringCodePagesWhenEncodingRestorationFails() Assert.Equal(1252u, native.OutputCodePage); } + [Fact] + public void VirtualTerminalScopeEnablesAndRestoresOutputMode() + { + var native = new FakeConsoleNativeApi { OutputMode = 0x20 }; + var console = new WindowsHostConsole(native); + + bool enabled = console.TryEnableVirtualTerminalProcessing( + Console.Out, + _ => { }, + out IDisposable? restore); + + Assert.True(enabled); + Assert.Equal(0x24u, native.OutputMode); + restore!.Dispose(); + Assert.Equal(0x20u, native.OutputMode); + Assert.Equal("\u001b[0m", Assert.Single(native.Writes)); + } + + [Fact] + public void InteractivityIsResolvedForTheSelectedOutputStream() + { + var native = new FakeConsoleNativeApi + { + OutputIsConsole = false, + ErrorIsConsole = true + }; + var console = new WindowsHostConsole(native); + + Assert.False(console.IsInteractiveOutput(Console.Out)); + Assert.True(console.IsInteractiveOutput(Console.Error)); + Assert.False(console.IsInteractiveOutput(new StringWriter())); + } + private sealed class FakeConsoleNativeApi : IConsoleNativeApi { public uint InputMode { get; set; } @@ -117,6 +151,7 @@ private sealed class FakeConsoleNativeApi : IConsoleNativeApi public uint InputCodePage { get; set; } public uint OutputCodePage { get; set; } public bool OutputIsConsole { get; set; } = true; + public bool ErrorIsConsole { get; set; } = true; public List Writes { get; } = []; public List<(nint Handle, uint Mode)> SetModes { get; } = []; public List SetCodePages { get; } = []; @@ -138,7 +173,7 @@ public bool GetConsoleMode(nint handle, out uint mode) } mode = OutputMode; - return OutputIsConsole; + return handle == 2 ? OutputIsConsole : ErrorIsConsole; } public bool SetConsoleMode(nint handle, uint mode) From a86309ec73058b534dbc5011516bf1552796b7bb Mon Sep 17 00:00:00 2001 From: Chaz Gordish Date: Thu, 17 Sep 2026 12:23:47 -0700 Subject: [PATCH 68/76] fix: make Windows Launcher isolation status informational Remove unsupported isolation commands and copy controls. Show Running and Active only with the captured enabled report, and report all other isolation signals as invalid with HTTP 503. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 21 ++- plugins/gateway-isolation/index.js | 192 ++++-------------------- plugins/gateway-isolation/index.test.js | 169 ++++++++++++--------- 3 files changed, 141 insertions(+), 241 deletions(-) diff --git a/README.md b/README.md index 4ae97a23..95b5847b 100644 --- a/README.md +++ b/README.md @@ -220,16 +220,21 @@ provisions the packaging-owned Windows Launcher plugin into the payload copy's bundled plugin directory. Its internal package, path, and plugin ID remain `gateway-isolation`. The plugin is disabled by default, so normal installs do not activate it, register its route, or show the **Windows Launcher** tab. When -explicitly enabled for validation or by the future launcher command -implementation, it adds the read-only tab to the Control group and serves it +explicitly enabled, it adds the read-only tab to the Control group and serves it through an authenticated, sandboxed plugin route. It reads only the launch-time `CLAWCTL_GATEWAY_ISOLATION` value and registers no mutation RPC or process -control. - -The page preserves the planned `clawctl gateway-isolation enable|disable` -command and Copy control for the paired launcher command update. This package -does not register those `clawctl` commands yet, so the page explicitly tells -users to run the command only after that support is installed. +control. The informational page shows **Gateway: Running**, with **Isolation: +Active** only for the exact `enabled` report (HTTP 200). Missing, malformed, +or unsupported reports, including `disabled`, show **Isolation: Invalid** +and neutral status-unavailable text (HTTP 503), never a supported off state. +The live route establishes Gateway availability; disconnected-Gateway messaging +belongs to the Control UI. There are no isolation controls or CLI instructions. + +The report is captured once at plugin creation. It is a launcher-provided +diagnostic, not independent isolation attestation. `OPENCLAW_SESSION` is a +session-routing preference, not this plugin's runtime-status contract, and is +not accepted as a substitute. Launcher activation and a reliable isolated-launch +report remain separate integration dependencies; this change enables neither. The screenshots attached to the pull request are design and behavior proof captured with the plugin explicitly enabled in an isolated validation profile; diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index 54522241..a2cbf071 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -5,19 +5,14 @@ const THEME_BRIDGE_SCRIPT = ``; export function readGatewayIsolationMode(env) { const value = env[ISOLATION_ENVIRONMENT_VARIABLE]; - return value === "enabled" || value === "disabled" ? value : null; + return value === "enabled" ? value : null; } export function renderGatewayIsolationPage(mode) { - if (mode !== "enabled" && mode !== "disabled") { - throw new TypeError("Gateway isolation mode must be enabled or disabled."); + if (mode !== "enabled") { + throw new TypeError("Gateway isolation mode must be enabled."); } - const enabled = mode === "enabled"; - const status = enabled ? "Enabled" : "Disabled"; - const command = `clawctl gateway-isolation ${enabled ? "disable" : "enable"}`; - const tone = enabled ? "ok" : "warn"; + return renderStatusPage(true); +} +function renderStatusPage(enabled) { + const description = enabled + ? "This Gateway is running in Windows isolation." + : "Isolation status is unavailable. The Windows launcher did not provide a valid isolation report."; return ` @@ -77,38 +70,27 @@ export function renderGatewayIsolationPage(mode) { :root { color-scheme: light dark; --font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Liberation Mono", monospace; --radius: 10px; --radius-full: 9999px; --bg: #ffffff; --card: #f6f8fa; --border: #d0d7de; - --border-strong: #afb8c1; --text: #1f2328; --text-strong: #1f2328; --muted: #59636e; --ok-bg: #dafbe1; --ok-text: #116329; - --warn-bg: #fff8c5; - --warn-text: #7d4e00; - --button-bg: #f6f8fa; - --focus: #0969da; } @media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --card: #161b22; --border: #30363d; - --border-strong: #484f58; --text: #f0f6fc; --text-strong: #f0f6fc; --muted: #8b949e; --ok-bg: #12261e; --ok-text: #56d364; - --warn-bg: #2e240d; - --warn-text: #e3b341; - --button-bg: #21262d; - --focus: #58a6ff; } } * { box-sizing: border-box; } @@ -135,28 +117,22 @@ export function renderGatewayIsolationPage(mode) { color: var(--muted); line-height: 1.5; } - .settings-section { + .status-section { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); } - .settings-row { + .status-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.3fr); gap: 20px; align-items: center; padding: 18px; } - .settings-row + .settings-row { border-top: 1px solid var(--border); } - .settings-row--stacked { align-items: start; } - .settings-row__title { color: var(--text-strong); font-weight: 600; } - .settings-row__description { - margin-top: 5px; - color: var(--muted); - line-height: 1.45; - } - .settings-row__control { justify-self: end; min-width: 0; } + .status-row + .status-row { border-top: 1px solid var(--border); } + dt { color: var(--text-strong); font-weight: 600; } + dd { margin: 0; justify-self: end; min-width: 0; } .status { display: inline-flex; align-items: center; @@ -173,43 +149,11 @@ export function renderGatewayIsolationPage(mode) { content: ""; } .status--ok { color: var(--ok-text); background: var(--ok-bg); } - .status--warn { color: var(--warn-text); background: var(--warn-bg); } - .command { - display: flex; - min-width: 0; - align-items: center; - gap: 8px; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 8px 9px; - background: var(--bg); - } - code { - min-width: 0; - overflow-wrap: anywhere; - font-family: var(--font-mono); - font-size: 13px; - } - button { - flex: none; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 5px 9px; - background: var(--button-bg); - color: var(--text); - cursor: pointer; - font: inherit; - } - .copy-status { - margin-top: 6px; - color: var(--muted); - font-size: 12px; - } - button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + .status--neutral { color: var(--muted); } @media (max-width: 620px) { main { padding: 16px; } - .settings-row { grid-template-columns: 1fr; gap: 12px; } - .settings-row__control { justify-self: stretch; } + .status-row { grid-template-columns: 1fr; gap: 12px; } + dd { justify-self: start; } .status { width: fit-content; } } @@ -217,107 +161,25 @@ export function renderGatewayIsolationPage(mode) {

Windows Launcher

-

Diagnostic launch mode reported by the Windows launcher.

-
-
-
Gateway Isolation
-
- ${status} -
+

${description}

+
+
+
Gateway
+
Running
-
-
-
Change with CLI
-
Command support is expected in a paired launcher update. Run from the signed-in user session on the Gateway host after that support is installed.
-
-
- ${command} - -
-
+
+
Isolation
+
${enabled ? "Active" : "Invalid"}
-
+
${THEME_BRIDGE_SCRIPT} - `; } function renderGatewayIsolationUnavailablePage() { - return ` - - - - - Windows Launcher unavailable - - - -

Windows Launcher unavailable

-

The Windows launcher did not provide a valid Gateway isolation mode.

- ${THEME_BRIDGE_SCRIPT} - -`; + return renderStatusPage(false); } function writeHtmlResponse(response, statusCode, html) { diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 10c3db1b..20a30e29 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -64,7 +64,7 @@ function invokeRoute(route) { function runThemeBridge(html) { const scripts = [...html.matchAll(/", +]; + +function assertInformationalPage(html) { + assert.match(html, /Windows Launcher<\/title>/); + assert.match(html, /<h1>Windows Launcher<\/h1>/); + assert.match(html, /<dl[^>]+aria-label="Windows Launcher status"/); + assert.match(html, /<dt>Gateway<\/dt>\s*<dd><span class="status status--ok">Running<\/span><\/dd>/); + assert.doesNotMatch( + html, + /<button|<input|<select|<form|<code|clipboard|execCommand|getSelection|createRange|aria-live|copy-status|Change with CLI|clawctl|--no-isolation|Disabled|Not running|--button-bg|--font-mono|--focus/i, + ); +} + +function assertHeaders(response) { + assert.deepEqual(response.headers, { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'self'", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }); +} + +test("accepts only the exact enabled launcher report", () => { assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: "enabled" }), "enabled"); - assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: "disabled" }), "disabled"); - for (const value of [undefined, "", "ENABLED", "unknown"]) { + for (const value of invalidModes) { assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: value }), null); } }); -for (const expected of [ - { - mode: "enabled", - status: "Enabled", - tone: "status--ok", - command: "clawctl gateway-isolation disable", - }, - { - mode: "disabled", - status: "Disabled", - tone: "status--warn", - command: "clawctl gateway-isolation enable", - }, -]) { - test(`renders the exact ${expected.mode} read-only status`, () => { - const html = renderGatewayIsolationPage(expected.mode); - assert.match(html, /Windows Launcher/); - assert.match(html, /Gateway Isolation/); - assert.match(html, new RegExp(`>${expected.status}<`)); - assert.match(html, new RegExp(expected.tone)); - assert.match(html, /Change with CLI/); - assert.match( - html, - /Command support is expected in a paired launcher update\./, - ); - assert.match( - html, - /Run from the signed-in user session on the Gateway host after that support is installed\./, - ); - assert.match(html, new RegExp(expected.command)); - assert.match(html, /aria-label="Copy command"/); - assert.match(html, /Copy the selected command manually\./); - assert.match(html, /copied = document\.execCommand\("copy"\)/); - assert.match(html, /openclaw:widget-theme/); - assert.match( - html, - /--font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Liberation Mono", monospace;/, - ); - assert.doesNotMatch(html, /next manual Gateway restart/i); - }); -} +test("does not use session-routing preference as isolation evidence", () => { + for (const mode of invalidModes) { + assert.equal(readGatewayIsolationMode({ + CLAWCTL_GATEWAY_ISOLATION: mode, + OPENCLAW_SESSION: "1", + }), null); + } + assert.equal(readGatewayIsolationMode({ + CLAWCTL_GATEWAY_ISOLATION: "enabled", + get OPENCLAW_SESSION() { + throw new Error("Routing preference must not be read."); + }, + }), "enabled"); +}); + +test("renders only informational running and active status", () => { + const html = renderGatewayIsolationPage("enabled"); + assertInformationalPage(html); + assert.match(html, /<dt>Isolation<\/dt>\s*<dd><span class="status status--ok">Active<\/span><\/dd>/); + assert.match(html, /This Gateway is running in Windows isolation\./); + assert.doesNotMatch(html, /Invalid|unavailable/); +}); test("applies recognized host theme tokens from the parent frame", () => { const bridge = runThemeBridge(renderGatewayIsolationPage("enabled")); @@ -179,20 +186,32 @@ test("applies recognized host theme tokens from the parent frame", () => { assert.equal(bridge.root.style.colorScheme, "dark"); assert.equal(bridge.properties.get("--bg"), "#101010"); assert.equal(bridge.properties.get("--card"), "#202020"); - assert.equal(bridge.properties.get("--button-bg"), "#303030"); assert.equal(bridge.properties.get("--text"), "#fefefe"); assert.equal(bridge.properties.get("--text-strong"), "#ffffff"); - assert.equal(bridge.properties.get("--focus"), "#55aaff"); assert.equal(bridge.properties.get("--radius"), "14px"); assert.equal(bridge.properties.get("--radius-full"), "9999px"); assert.equal(bridge.properties.get("--font-body"), "Georgia, serif"); - assert.equal(bridge.properties.get("--font-mono"), "Consolas, monospace"); assert.match(bridge.properties.get("--ok-bg"), /#44cc77 18%, #202020/); - assert.match(bridge.properties.get("--warn-bg"), /#e0a020 18%, #202020/); + for (const unused of ["--button-bg", "--focus", "--font-mono", "--warn-text", "--warn-bg"]) { + assert.equal(bridge.properties.has(unused), false); + } + + bridge.listener({ + source: bridge.parent, + data: { + type: "openclaw:widget-theme", + mode: "light", + tokens: { surface: "#fafafa", card: "#ffffff", ok: "#116329" }, + }, + }); + assert.equal(bridge.root.dataset.themeMode, "light"); + assert.equal(bridge.root.style.colorScheme, "light"); + assert.equal(bridge.properties.get("--bg"), "#fafafa"); + assert.match(bridge.properties.get("--ok-bg"), /#116329 18%, #ffffff/); }); test("ignores theme messages from other frames and malformed host values", () => { - const bridge = runThemeBridge(renderGatewayIsolationPage("disabled")); + const bridge = runThemeBridge(renderGatewayIsolationPage("enabled")); for (const event of [ { source: {}, @@ -219,6 +238,22 @@ test("ignores theme messages from other frames and malformed host values", () => } assert.equal(bridge.root.style.colorScheme, ""); assert.deepEqual([...bridge.properties], []); + bridge.listener({ + source: bridge.parent, + data: { + type: "openclaw:widget-theme", + mode: "light", + tokens: { + surface: "", + card: " ", + text: 123, + muted: "x".repeat(257), + radius: null, + unknown: "red", + }, + }, + }); + assert.deepEqual([...bridge.properties], []); }); test("registers one read-only Control tab and one authenticated sandbox route", () => { @@ -240,12 +275,12 @@ test("registers one read-only Control tab and one authenticated sandbox route", const response = invokeRoute(routes[0]); assert.equal(response.statusCode, 200); - assert.equal(response.headers["Cache-Control"], "no-store"); - assert.match(response.headers["Content-Security-Policy"], /frame-ancestors 'self'/); - assert.match(response.body, />Enabled</); + assertHeaders(response); + assertInformationalPage(response.body); + assert.match(response.body, />Active</); }); -for (const initial of ["enabled", "disabled", undefined, "", "invalid", "ENABLED", " enabled "]) { +for (const initial of ["enabled", ...invalidModes]) { test(`reads ${JSON.stringify(initial) ?? "missing"} exactly once and keeps every response stable`, () => { let reads = 0; let value = initial; @@ -266,13 +301,10 @@ for (const initial of ["enabled", "disabled", undefined, "", "invalid", "ENABLED }); const first = invokeRoute(routes[0]); - assert.equal(first.statusCode, initial === "enabled" || initial === "disabled" ? 200 : 503); - if (initial === "enabled") assert.match(first.body, />Enabled</); - if (initial === "disabled") assert.match(first.body, />Disabled</); - assert.match( - first.body, - /--font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Liberation Mono", monospace;/, - ); + assert.equal(first.statusCode, initial === "enabled" ? 200 : 503); + assert.match(first.body, initial === "enabled" ? />Active</ : />Invalid</); + assertInformationalPage(first.body); + assertHeaders(first); for (value of ["enabled", "disabled", "invalid", undefined]) { assert.deepEqual(invokeRoute(routes[0]), first); } @@ -280,16 +312,19 @@ for (const initial of ["enabled", "disabled", undefined, "", "invalid", "ENABLED }); } -for (const mode of [undefined, "", "invalid", "ENABLED", " enabled "]) { +for (const mode of invalidModes) { const label = JSON.stringify(mode) ?? "missing"; - test(`fails closed for ${label} with no status or mutation guidance`, () => { + test(`fails closed for ${label} with neutral invalid status and no mutation guidance`, () => { const { routes } = registerPlugin(mode); const response = invokeRoute(routes[0]); assert.equal(response.statusCode, 503); - assert.match(response.body, /did not provide a valid Gateway isolation mode/); + assertInformationalPage(response.body); + assert.match(response.body, /Isolation status is unavailable\./); + assert.match(response.body, /did not provide a valid isolation report/); + assert.match(response.body, /<dt>Isolation<\/dt>\s*<dd><span class="status status--neutral">Invalid<\/span><\/dd>/); assert.doesNotMatch( response.body, - /status--(?:ok|warn)|<button|Change with CLI|clawctl gateway-isolation/, + />Active<|status--warn|<script>alert/, ); const bridge = runThemeBridge(response.body); bridge.listener({ @@ -304,9 +339,7 @@ for (const mode of [undefined, "", "invalid", "ENABLED", " enabled "]) { assert.equal(bridge.properties.get("--bg"), "#101010"); assert.equal(bridge.properties.get("--text"), "#fefefe"); assert.equal(bridge.properties.get("--muted"), "#aaaaaa"); - assert.equal(response.headers["Cache-Control"], "no-store"); - assert.equal(response.headers["X-Content-Type-Options"], "nosniff"); - assert.equal(response.headers["Referrer-Policy"], "no-referrer"); + assertHeaders(response); }); test(`renderer rejects ${label}`, () => { assert.throws(() => renderGatewayIsolationPage(mode), TypeError); From 8aa825200d1f8ec1d8ebe543f54ca6f3c917b9ae Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Thu, 17 Sep 2026 15:00:38 -0700 Subject: [PATCH 69/76] Restore useful copy-only Windows Launcher command references Keep isolation reports informational, replace unsupported controls with verified general command references, and cover sandbox clipboard fallback and accessible feedback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 21 +++- plugins/gateway-isolation/index.js | 152 ++++++++++++++++++++++++ plugins/gateway-isolation/index.test.js | 132 +++++++++++++++++++- 3 files changed, 297 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 95b5847b..53f28df2 100644 --- a/README.md +++ b/README.md @@ -228,13 +228,28 @@ Active** only for the exact `enabled` report (HTTP 200). Missing, malformed, or unsupported reports, including `disabled`, show **Isolation: Invalid** and neutral status-unavailable text (HTTP 503), never a supported off state. The live route establishes Gateway availability; disconnected-Gateway messaging -belongs to the Control UI. There are no isolation controls or CLI instructions. +belongs to the Control UI. There are no isolation controls. + +The active page also provides copy-only command references for `openclaw tui`, +`clawctl gateway-service status`, a PowerShell 7 restart sequence +(`clawctl gateway-service stop && clawctl gateway-service start`), +`openclaw --help`, and `clawctl --help`. The restart sequence manages this +installation's background Gateway and starts it only after a successful stop; +an already-stopped Gateway is a successful stop. It is not a native `restart` +command. OpenClaw help lists top-level commands; individual subcommands have +their own `--help`, not a recursive `--all` option. The page never executes +commands or sends mutation requests. Copy controls announce success only after +a clipboard operation succeeds; otherwise they offer manual-copy guidance, +leaving the command selected when selection is available. Invalid isolation +reports show no command references. The report is captured once at plugin creation. It is a launcher-provided diagnostic, not independent isolation attestation. `OPENCLAW_SESSION` is a session-routing preference, not this plugin's runtime-status contract, and is -not accepted as a substitute. Launcher activation and a reliable isolated-launch -report remain separate integration dependencies; this change enables neither. +not accepted as a substitute. Isolated-session launch paths supply `enabled`; +direct non-isolated launches supply `disabled` and therefore receive an invalid +report here. This plugin change does not enable the tab or alter launcher +routing. The screenshots attached to the pull request are design and behavior proof captured with the plugin explicitly enabled in an isolated validation profile; diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index a2cbf071..3c39df83 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -1,18 +1,53 @@ const ISOLATION_ENVIRONMENT_VARIABLE = "CLAWCTL_GATEWAY_ISOLATION"; const STATUS_PATH = "/plugins/gateway-isolation/status"; const THEME_MESSAGE_TYPE = "openclaw:widget-theme"; +const COMMAND_REFERENCES = [ + { + id: "terminal", + title: "Terminal UI", + description: "Open an interactive terminal UI connected to the Gateway.", + command: "openclaw tui", + }, + { + id: "gateway-status", + title: "Gateway status", + description: "Inspect this installation's background Gateway without changing it.", + command: "clawctl gateway-service status", + }, + { + id: "gateway-restart", + title: "Restart Gateway", + description: "Stop the background Gateway, then start it only if stopping succeeds. Keeps the session and its data.", + command: "clawctl gateway-service stop && clawctl gateway-service start", + }, + { + id: "openclaw-help", + title: "OpenClaw help", + description: "List top-level OpenClaw commands. Add --help to a subcommand for details.", + command: "openclaw --help", + }, + { + id: "launcher-help", + title: "Launcher help", + description: "List ClawCtl commands for managing this installation.", + command: "clawctl --help", + }, +]; const THEME_BRIDGE_SCRIPT = `<script> const themeTokenProperties = { surface: "--bg", card: "--card", + elevated: "--button-bg", text: "--text", "text-strong": "--text-strong", muted: "--muted", border: "--border", + accent: "--focus", ok: "--ok-text", radius: "--radius", "radius-full": "--radius-full", "font-body": "--font-body", + "font-mono": "--font-mono", }; window.addEventListener("message", (event) => { if (event.source !== window.parent) return; @@ -42,6 +77,80 @@ const THEME_BRIDGE_SCRIPT = `<script> } }); </script>`; +const COPY_COMMAND_SCRIPT = `<script> + let copyRequest = 0; + const feedback = document.getElementById("copy-status"); + for (const button of document.querySelectorAll("[data-copy-command]")) { + button.addEventListener("click", async () => { + const request = ++copyRequest; + const command = document.getElementById(button.dataset.copyCommand); + const title = button.dataset.commandTitle; + let copied = false; + let selected = false; + let selection; + const previousRanges = []; + feedback.textContent = ""; + // Copy synchronously while the click has user activation. The opaque + // sandbox cannot rely on permission to use the async Clipboard API. + try { + selection = window.getSelection(); + for (let i = 0; i < selection.rangeCount; i++) { + previousRanges.push(selection.getRangeAt(i).cloneRange()); + } + const range = document.createRange(); + range.selectNodeContents(command); + selection.removeAllRanges(); + selection.addRange(range); + selected = true; + copied = document.execCommand("copy"); + } catch { + copied = false; + } + if (!copied) { + try { + await navigator.clipboard.writeText(command.textContent); + copied = true; + } catch { + copied = false; + } + } + if (request !== copyRequest) return; + if (copied) { + if (selected) { + selection.removeAllRanges(); + for (const range of previousRanges) selection.addRange(range); + } + feedback.textContent = "Copied " + title + " command."; + } else { + feedback.textContent = selected + ? "Copy unavailable. " + title + " command selected; press Ctrl+C to copy." + : "Could not copy " + title + " command. Select the command and copy it manually."; + } + }); + } +</script>`; + +function renderCommandReferences() { + const rows = COMMAND_REFERENCES.map(({ id, title, description, command }) => ` + <div class="command-row"> + <div> + <h3>${title}</h3> + <p class="command-description">${description}</p> + </div> + <div class="command"> + <code id="command-${id}">${command.replaceAll("&", "&")}</code> + <button type="button" data-copy-command="command-${id}" data-command-title="${title}" aria-label="Copy ${title} command">Copy</button> + </div> + </div>`).join(""); + return ` + <section aria-labelledby="commands-title"> + <h2 id="commands-title">Command reference</h2> + <p class="intro">Copy a command to run yourself in PowerShell 7 on the Gateway host. This page never runs commands.</p> + <div class="status-section">${rows} + </div> + <p id="copy-status" class="copy-status" role="status" aria-live="polite" aria-atomic="true"></p> + </section>`; +} export function readGatewayIsolationMode(env) { const value = env[ISOLATION_ENVIRONMENT_VARIABLE]; @@ -70,6 +179,7 @@ function renderStatusPage(enabled) { :root { color-scheme: light dark; --font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Liberation Mono", monospace; --radius: 10px; --radius-full: 9999px; --bg: #ffffff; @@ -80,6 +190,8 @@ function renderStatusPage(enabled) { --muted: #59636e; --ok-bg: #dafbe1; --ok-text: #116329; + --button-bg: #f6f8fa; + --focus: #0969da; } @media (prefers-color-scheme: dark) { :root { @@ -91,6 +203,8 @@ function renderStatusPage(enabled) { --muted: #8b949e; --ok-bg: #12261e; --ok-text: #56d364; + --button-bg: #21262d; + --focus: #58a6ff; } } * { box-sizing: border-box; } @@ -150,11 +264,47 @@ function renderStatusPage(enabled) { } .status--ok { color: var(--ok-text); background: var(--ok-bg); } .status--neutral { color: var(--muted); } + h2 { margin: 28px 0 8px; color: var(--text-strong); font-size: 17px; } + h3 { margin: 0; color: var(--text-strong); font-size: 14px; font-weight: 600; } + .command-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr); + align-items: center; + gap: 20px; + padding: 16px 18px; + } + .command-row + .command-row { border-top: 1px solid var(--border); } + .command-description { margin: 5px 0 0; color: var(--muted); line-height: 1.45; } + .command { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + } + code { flex: 1; min-width: 0; overflow-wrap: anywhere; font: 13px/1.5 var(--font-mono); } + button { + flex: none; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 10px; + background: var(--button-bg); + color: var(--text); + cursor: pointer; + font: inherit; + } + button:hover { border-color: var(--text); } + button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + .copy-status { min-height: 1.5em; margin: 8px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; } @media (max-width: 620px) { main { padding: 16px; } .status-row { grid-template-columns: 1fr; gap: 12px; } dd { justify-self: start; } .status { width: fit-content; } + .command-row { grid-template-columns: 1fr; gap: 12px; } } </style> </head> @@ -172,8 +322,10 @@ function renderStatusPage(enabled) { <dd><span class="status status--${enabled ? "ok" : "neutral"}">${enabled ? "Active" : "Invalid"}</span></dd> </div> </dl> + ${enabled ? renderCommandReferences() : ""} </main> ${THEME_BRIDGE_SCRIPT} + ${enabled ? COPY_COMMAND_SCRIPT : ""} </body> </html>`; } diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 20a30e29..48a5ce4c 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -64,7 +64,7 @@ function invokeRoute(route) { function runThemeBridge(html) { const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)]; - assert.equal(scripts.length, 1); + assert.ok(scripts.length >= 1); const properties = new Map(); const root = { dataset: {}, @@ -110,7 +110,7 @@ function assertInformationalPage(html) { assert.match(html, /<dt>Gateway<\/dt>\s*<dd><span class="status status--ok">Running<\/span><\/dd>/); assert.doesNotMatch( html, - /<button|<input|<select|<form|<code|clipboard|execCommand|getSelection|createRange|aria-live|copy-status|Change with CLI|clawctl|--no-isolation|Disabled|Not running|--button-bg|--font-mono|--focus/i, + /<input|<select|<form|Change with CLI|--no-isolation|Disabled|Not running|clawctl gateway isolation|fetch\(|XMLHttpRequest|WebSocket/i, ); } @@ -152,7 +152,126 @@ test("renders only informational running and active status", () => { assertInformationalPage(html); assert.match(html, /<dt>Isolation<\/dt>\s*<dd><span class="status status--ok">Active<\/span><\/dd>/); assert.match(html, /This Gateway is running in Windows isolation\./); - assert.doesNotMatch(html, /Invalid|unavailable/); + assert.doesNotMatch(html, />Invalid<|Isolation status is unavailable/); +}); + +const expectedCommands = [ + ["Terminal UI", "openclaw tui"], + ["Gateway status", "clawctl gateway-service status"], + ["Restart Gateway", "clawctl gateway-service stop && clawctl gateway-service start"], + ["OpenClaw help", "openclaw --help"], + ["Launcher help", "clawctl --help"], +]; + +test("offers only supported general commands with individually named copy controls", () => { + const html = renderGatewayIsolationPage("enabled"); + const commands = [...html.matchAll(/<code id="([^"]+)">([^<]+)<\/code>/g)]; + assert.deepEqual(commands.map(match => match[2].replaceAll("&", "&")), expectedCommands.map(([, command]) => command)); + assert.equal([...html.matchAll(/<button /g)].length, expectedCommands.length); + for (const [index, [title]] of expectedCommands.entries()) { + assert.ok(html.includes(`data-copy-command="${commands[index][1]}" data-command-title="${title}" aria-label="Copy ${title} command"`)); + } + assert.match(html, /PowerShell 7 on the Gateway host/); + assert.match(html, /This page never runs commands/); + assert.match(html, /only if stopping succeeds/); + assert.match(html, /role="status" aria-live="polite" aria-atomic="true"/); + assert.match(html, /button:focus-visible/); + assert.match(html, /@media \(max-width: 620px\)/); + assert.doesNotMatch(html, /clawctl tui|clawctl tty|gateway-service restart|help --all|--help --all/); +}); + +function runCopyScript({ legacy = true, selectionAvailable = true, modern = "missing" } = {}) { + const html = renderGatewayIsolationPage("enabled"); + const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)]; + assert.equal(scripts.length, 2); + const feedback = { textContent: "" }; + const commands = [...html.matchAll(/<code id="([^"]+)">([^<]+)<\/code>/g)].map(match => ({ + id: match[1], textContent: match[2].replaceAll("&", "&"), + })); + const buttons = commands.map((command, index) => ({ + dataset: { copyCommand: command.id, commandTitle: expectedCommands[index][0] }, + addEventListener(event, handler) { + assert.equal(event, "click"); + this.click = handler; + }, + })); + let selected; + const calls = []; + const selection = { + rangeCount: 0, + removeAllRanges() { selected = undefined; }, + addRange(range) { selected = range.command; }, + }; + vm.runInNewContext(scripts[1][1], { + document: { + getElementById(id) { return id === "copy-status" ? feedback : commands.find(command => command.id === id); }, + querySelectorAll(selector) { + assert.equal(selector, "[data-copy-command]"); + return buttons; + }, + createRange() { return { selectNodeContents(command) { this.command = command; } }; }, + execCommand(command) { + assert.equal(command, "copy"); + calls.push(["legacy", selected.textContent]); + if (legacy === "throw") throw new Error("Copy denied"); + return legacy; + }, + }, + window: { getSelection() { return selectionAvailable ? selection : null; } }, + navigator: modern === "missing" ? {} : { + clipboard: { + async writeText(text) { + calls.push(["modern", text]); + if (modern === "reject") throw new Error("Permission denied"); + }, + }, + }, + }); + return { buttons, feedback, calls, get selected() { return selected; } }; +} + +test("copies every exact command synchronously and announces only successful copies", async () => { + const copy = runCopyScript(); + for (const [index, [title, command]] of expectedCommands.entries()) { + await copy.buttons[index].click(); + assert.equal(copy.feedback.textContent, `Copied ${title} command.`); + assert.deepEqual(copy.calls.at(-1), ["legacy", command]); + assert.equal(copy.selected, undefined); + } +}); + +for (const legacy of [false, "throw"]) { + test(`uses Clipboard API when legacy copy returns ${legacy}`, async () => { + const copy = runCopyScript({ legacy, modern: "success" }); + await copy.buttons[2].click(); + assert.equal(copy.feedback.textContent, "Copied Restart Gateway command."); + assert.deepEqual(copy.calls.at(-1), ["modern", expectedCommands[2][1]]); + }); + for (const modern of ["missing", "reject"]) { + test(`offers selected manual copy when legacy=${legacy} and modern=${modern}`, async () => { + const copy = runCopyScript({ legacy, modern }); + for (const index of [0, 2, 0]) { + await copy.buttons[index].click(); + assert.equal(copy.feedback.textContent, `Copy unavailable. ${expectedCommands[index][0]} command selected; press Ctrl+C to copy.`); + assert.equal(copy.selected.textContent, expectedCommands[index][1]); + assert.doesNotMatch(copy.feedback.textContent, /Copied/); + } + }); + } +} + +test("reports inability to select or copy without claiming manual selection succeeded", async () => { + const copy = runCopyScript({ selectionAvailable: false }); + await copy.buttons[0].click(); + assert.equal(copy.feedback.textContent, "Could not copy Terminal UI command. Select the command and copy it manually."); + assert.equal(copy.selected, undefined); +}); + +test("can use Clipboard API without DOM selection support", async () => { + const copy = runCopyScript({ selectionAvailable: false, modern: "success" }); + await copy.buttons[0].click(); + assert.equal(copy.feedback.textContent, "Copied Terminal UI command."); + assert.deepEqual(copy.calls, [["modern", "openclaw tui"]]); }); test("applies recognized host theme tokens from the parent frame", () => { @@ -192,7 +311,10 @@ test("applies recognized host theme tokens from the parent frame", () => { assert.equal(bridge.properties.get("--radius-full"), "9999px"); assert.equal(bridge.properties.get("--font-body"), "Georgia, serif"); assert.match(bridge.properties.get("--ok-bg"), /#44cc77 18%, #202020/); - for (const unused of ["--button-bg", "--focus", "--font-mono", "--warn-text", "--warn-bg"]) { + assert.equal(bridge.properties.get("--button-bg"), "#303030"); + assert.equal(bridge.properties.get("--focus"), "#55aaff"); + assert.equal(bridge.properties.get("--font-mono"), "Consolas, monospace"); + for (const unused of ["--warn-text", "--warn-bg"]) { assert.equal(bridge.properties.has(unused), false); } @@ -324,7 +446,7 @@ for (const mode of invalidModes) { assert.match(response.body, /<dt>Isolation<\/dt>\s*<dd><span class="status status--neutral">Invalid<\/span><\/dd>/); assert.doesNotMatch( response.body, - />Active<|status--warn|<script>alert/, + />Active<|status--warn|<script>alert|<button|<code|clipboard|execCommand|getSelection|createRange|aria-live|id="copy-status"|clawctl|Command reference/, ); const bridge = runThemeBridge(response.body); bridge.listener({ From e3ae00a35484e5d6eb67c30d0d26c2b907736ff6 Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Thu, 17 Sep 2026 15:12:36 -0700 Subject: [PATCH 70/76] Add agent session PowerShell and consolidate Gateway Isolation status Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 26 +++++++++++++---------- plugins/gateway-isolation/index.js | 17 ++++++++------- plugins/gateway-isolation/index.test.js | 28 +++++++++++++++---------- 3 files changed, 41 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 53f28df2..77e28310 100644 --- a/README.md +++ b/README.md @@ -223,14 +223,15 @@ not activate it, register its route, or show the **Windows Launcher** tab. When explicitly enabled, it adds the read-only tab to the Control group and serves it through an authenticated, sandboxed plugin route. It reads only the launch-time `CLAWCTL_GATEWAY_ISOLATION` value and registers no mutation RPC or process -control. The informational page shows **Gateway: Running**, with **Isolation: -Active** only for the exact `enabled` report (HTTP 200). Missing, malformed, -or unsupported reports, including `disabled`, show **Isolation: Invalid** +control. The informational page shows one **Gateway Isolation** row with an +**Active** badge only for the exact `enabled` report (HTTP 200). Missing, malformed, +or unsupported reports, including `disabled`, show **Invalid** and neutral status-unavailable text (HTTP 503), never a supported off state. The live route establishes Gateway availability; disconnected-Gateway messaging belongs to the Control UI. There are no isolation controls. -The active page also provides copy-only command references for `openclaw tui`, +The active page also provides copy-only command references for `clawctl pwsh`, +`openclaw tui`, `clawctl gateway-service status`, a PowerShell 7 restart sequence (`clawctl gateway-service stop && clawctl gateway-service start`), `openclaw --help`, and `clawctl --help`. The restart sequence manages this @@ -241,15 +242,18 @@ their own `--help`, not a recursive `--all` option. The page never executes commands or sends mutation requests. Copy controls announce success only after a clipboard operation succeeds; otherwise they offer manual-copy guidance, leaving the command selected when selection is available. Invalid isolation -reports show no command references. +reports show no command references. `clawctl pwsh` opens PowerShell inside the +configured agent session after setup, with `openclaw` and `node` on PATH; exit +returns to the host. It prefers machine-wide PowerShell 7 and otherwise uses +Windows PowerShell. Run ClawCtl from the host, not from inside that shell. +`openclaw tui` is the Gateway chat terminal UI, not an agent shell. The report is captured once at plugin creation. It is a launcher-provided -diagnostic, not independent isolation attestation. `OPENCLAW_SESSION` is a -session-routing preference, not this plugin's runtime-status contract, and is -not accepted as a substitute. Isolated-session launch paths supply `enabled`; -direct non-isolated launches supply `disabled` and therefore receive an invalid -report here. This plugin change does not enable the tab or alter launcher -routing. +diagnostic, not independent isolation attestation. The launcher now requires an +isolated session and supplies `CLAWCTL_GATEWAY_ISOLATION=enabled` to its guest +processes. It no longer supports host execution or the old `OPENCLAW_SESSION` +routing preference; that variable is not accepted as a substitute report here. +This plugin change does not enable the tab or alter launcher execution. The screenshots attached to the pull request are design and behavior proof captured with the plugin explicitly enabled in an isolated validation profile; diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index 3c39df83..9b9566d5 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -2,10 +2,16 @@ const ISOLATION_ENVIRONMENT_VARIABLE = "CLAWCTL_GATEWAY_ISOLATION"; const STATUS_PATH = "/plugins/gateway-isolation/status"; const THEME_MESSAGE_TYPE = "openclaw:widget-theme"; const COMMAND_REFERENCES = [ + { + id: "agent-shell", + title: "Agent session PowerShell", + description: "Open PowerShell inside the agent session. Requires setup; exit to return.", + command: "clawctl pwsh", + }, { id: "terminal", - title: "Terminal UI", - description: "Open an interactive terminal UI connected to the Gateway.", + title: "Gateway chat TUI", + description: "Chat with the Gateway in a terminal. This is not an agent shell.", command: "openclaw tui", }, { @@ -244,7 +250,6 @@ function renderStatusPage(enabled) { align-items: center; padding: 18px; } - .status-row + .status-row { border-top: 1px solid var(--border); } dt { color: var(--text-strong); font-weight: 600; } dd { margin: 0; justify-self: end; min-width: 0; } .status { @@ -314,11 +319,7 @@ function renderStatusPage(enabled) { <p class="intro">${description}</p> <dl class="status-section" aria-label="Windows Launcher status"> <div class="status-row"> - <dt>Gateway</dt> - <dd><span class="status status--ok">Running</span></dd> - </div> - <div class="status-row"> - <dt>Isolation</dt> + <dt>Gateway Isolation</dt> <dd><span class="status status--${enabled ? "ok" : "neutral"}">${enabled ? "Active" : "Invalid"}</span></dd> </div> </dl> diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 48a5ce4c..b916dfb0 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -107,7 +107,9 @@ function assertInformationalPage(html) { assert.match(html, /<title>Windows Launcher<\/title>/); assert.match(html, /<h1>Windows Launcher<\/h1>/); assert.match(html, /<dl[^>]+aria-label="Windows Launcher status"/); - assert.match(html, /<dt>Gateway<\/dt>\s*<dd><span class="status status--ok">Running<\/span><\/dd>/); + assert.equal([...html.matchAll(/<dt>/g)].length, 1); + assert.match(html, /<dt>Gateway Isolation<\/dt>/); + assert.doesNotMatch(html, />Running<|<dt>Gateway<\/dt>|<dt>Isolation<\/dt>/); assert.doesNotMatch( html, /<input|<select|<form|Change with CLI|--no-isolation|Disabled|Not running|clawctl gateway isolation|fetch\(|XMLHttpRequest|WebSocket/i, @@ -147,16 +149,18 @@ test("does not use session-routing preference as isolation evidence", () => { }), "enabled"); }); -test("renders only informational running and active status", () => { +test("renders one informational Gateway Isolation row with a single active badge", () => { const html = renderGatewayIsolationPage("enabled"); assertInformationalPage(html); - assert.match(html, /<dt>Isolation<\/dt>\s*<dd><span class="status status--ok">Active<\/span><\/dd>/); + assert.match(html, /<dt>Gateway Isolation<\/dt>\s*<dd><span class="status status--ok">Active<\/span><\/dd>/); + assert.equal([...html.matchAll(/class="status status--ok"/g)].length, 1); assert.match(html, /This Gateway is running in Windows isolation\./); assert.doesNotMatch(html, />Invalid<|Isolation status is unavailable/); }); const expectedCommands = [ - ["Terminal UI", "openclaw tui"], + ["Agent session PowerShell", "clawctl pwsh"], + ["Gateway chat TUI", "openclaw tui"], ["Gateway status", "clawctl gateway-service status"], ["Restart Gateway", "clawctl gateway-service stop && clawctl gateway-service start"], ["OpenClaw help", "openclaw --help"], @@ -174,6 +178,8 @@ test("offers only supported general commands with individually named copy contro assert.match(html, /PowerShell 7 on the Gateway host/); assert.match(html, /This page never runs commands/); assert.match(html, /only if stopping succeeds/); + assert.match(html, /Requires setup; exit to return/); + assert.match(html, /This is not an agent shell/); assert.match(html, /role="status" aria-live="polite" aria-atomic="true"/); assert.match(html, /button:focus-visible/); assert.match(html, /@media \(max-width: 620px\)/); @@ -243,9 +249,9 @@ test("copies every exact command synchronously and announces only successful cop for (const legacy of [false, "throw"]) { test(`uses Clipboard API when legacy copy returns ${legacy}`, async () => { const copy = runCopyScript({ legacy, modern: "success" }); - await copy.buttons[2].click(); + await copy.buttons[3].click(); assert.equal(copy.feedback.textContent, "Copied Restart Gateway command."); - assert.deepEqual(copy.calls.at(-1), ["modern", expectedCommands[2][1]]); + assert.deepEqual(copy.calls.at(-1), ["modern", expectedCommands[3][1]]); }); for (const modern of ["missing", "reject"]) { test(`offers selected manual copy when legacy=${legacy} and modern=${modern}`, async () => { @@ -263,15 +269,15 @@ for (const legacy of [false, "throw"]) { test("reports inability to select or copy without claiming manual selection succeeded", async () => { const copy = runCopyScript({ selectionAvailable: false }); await copy.buttons[0].click(); - assert.equal(copy.feedback.textContent, "Could not copy Terminal UI command. Select the command and copy it manually."); + assert.equal(copy.feedback.textContent, "Could not copy Agent session PowerShell command. Select the command and copy it manually."); assert.equal(copy.selected, undefined); }); test("can use Clipboard API without DOM selection support", async () => { const copy = runCopyScript({ selectionAvailable: false, modern: "success" }); await copy.buttons[0].click(); - assert.equal(copy.feedback.textContent, "Copied Terminal UI command."); - assert.deepEqual(copy.calls, [["modern", "openclaw tui"]]); + assert.equal(copy.feedback.textContent, "Copied Agent session PowerShell command."); + assert.deepEqual(copy.calls, [["modern", "clawctl pwsh"]]); }); test("applies recognized host theme tokens from the parent frame", () => { @@ -443,10 +449,10 @@ for (const mode of invalidModes) { assertInformationalPage(response.body); assert.match(response.body, /Isolation status is unavailable\./); assert.match(response.body, /did not provide a valid isolation report/); - assert.match(response.body, /<dt>Isolation<\/dt>\s*<dd><span class="status status--neutral">Invalid<\/span><\/dd>/); + assert.match(response.body, /<dt>Gateway Isolation<\/dt>\s*<dd><span class="status status--neutral">Invalid<\/span><\/dd>/); assert.doesNotMatch( response.body, - />Active<|status--warn|<script>alert|<button|<code|clipboard|execCommand|getSelection|createRange|aria-live|id="copy-status"|clawctl|Command reference/, + />Active<|class="status status--ok"|status--warn|<script>alert|<button|<code|clipboard|execCommand|getSelection|createRange|aria-live|id="copy-status"|clawctl|Command reference/, ); const bridge = runThemeBridge(response.body); bridge.listener({ From f7a2e1c2ed0e1be4cfecbd4d424d5210bc16ad86 Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Thu, 17 Sep 2026 15:32:07 -0700 Subject: [PATCH 71/76] Add verified dashboard access reference Keep the agent shell and Gateway chat UI distinct, preserve copy-only behavior, and fit all seven references without clipping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +++- plugins/gateway-isolation/index.js | 10 ++++++++-- plugins/gateway-isolation/index.test.js | 7 ++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 77e28310..97bcf220 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ The live route establishes Gateway availability; disconnected-Gateway messaging belongs to the Control UI. There are no isolation controls. The active page also provides copy-only command references for `clawctl pwsh`, -`openclaw tui`, +`openclaw dashboard --no-open`, `openclaw tui`, `clawctl gateway-service status`, a PowerShell 7 restart sequence (`clawctl gateway-service stop && clawctl gateway-service start`), `openclaw --help`, and `clawctl --help`. The restart sequence manages this @@ -247,6 +247,8 @@ configured agent session after setup, with `openclaw` and `node` on PATH; exit returns to the host. It prefers machine-wide PowerShell 7 and otherwise uses Windows PowerShell. Run ClawCtl from the host, not from inside that shell. `openclaw tui` is the Gateway chat terminal UI, not an agent shell. +ClawCtl has no dashboard command. `openclaw dashboard --no-open` shows dashboard +access details without trying to launch a browser inside the agent session. The report is captured once at plugin creation. It is a launcher-provided diagnostic, not independent isolation attestation. The launcher now requires an diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index 9b9566d5..d4435a2b 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -8,6 +8,12 @@ const COMMAND_REFERENCES = [ description: "Open PowerShell inside the agent session. Requires setup; exit to return.", command: "clawctl pwsh", }, + { + id: "dashboard", + title: "Dashboard access", + description: "Show dashboard access details without opening a browser inside the agent session.", + command: "openclaw dashboard --no-open", + }, { id: "terminal", title: "Gateway chat TUI", @@ -276,10 +282,10 @@ function renderStatusPage(enabled) { grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr); align-items: center; gap: 20px; - padding: 16px 18px; + padding: 12px 18px; } .command-row + .command-row { border-top: 1px solid var(--border); } - .command-description { margin: 5px 0 0; color: var(--muted); line-height: 1.45; } + .command-description { margin: 5px 0 0; color: var(--muted); font-size: 13px; line-height: 1.45; } .command { display: flex; align-items: center; diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index b916dfb0..46bfa224 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -160,6 +160,7 @@ test("renders one informational Gateway Isolation row with a single active badge const expectedCommands = [ ["Agent session PowerShell", "clawctl pwsh"], + ["Dashboard access", "openclaw dashboard --no-open"], ["Gateway chat TUI", "openclaw tui"], ["Gateway status", "clawctl gateway-service status"], ["Restart Gateway", "clawctl gateway-service stop && clawctl gateway-service start"], @@ -183,7 +184,7 @@ test("offers only supported general commands with individually named copy contro assert.match(html, /role="status" aria-live="polite" aria-atomic="true"/); assert.match(html, /button:focus-visible/); assert.match(html, /@media \(max-width: 620px\)/); - assert.doesNotMatch(html, /clawctl tui|clawctl tty|gateway-service restart|help --all|--help --all/); + assert.doesNotMatch(html, /clawctl tui|clawctl tty|clawctl dashboard|gateway-service restart|help --all|--help --all/); }); function runCopyScript({ legacy = true, selectionAvailable = true, modern = "missing" } = {}) { @@ -249,9 +250,9 @@ test("copies every exact command synchronously and announces only successful cop for (const legacy of [false, "throw"]) { test(`uses Clipboard API when legacy copy returns ${legacy}`, async () => { const copy = runCopyScript({ legacy, modern: "success" }); - await copy.buttons[3].click(); + await copy.buttons[4].click(); assert.equal(copy.feedback.textContent, "Copied Restart Gateway command."); - assert.deepEqual(copy.calls.at(-1), ["modern", expectedCommands[3][1]]); + assert.deepEqual(copy.calls.at(-1), ["modern", expectedCommands[4][1]]); }); for (const modern of ["missing", "reject"]) { test(`offers selected manual copy when legacy=${legacy} and modern=${modern}`, async () => { From a8fc29e99f1d3a2298ded2ffe5c5ef5a28d3d341 Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Thu, 17 Sep 2026 17:09:30 -0700 Subject: [PATCH 72/76] Pin theme-capable development runtime without expanding release approval Keep the stable signing policy separate and reject incompatible official workflow inputs before building. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 4 ++ .github/workflows/gateway-msix.yml | 21 ++++++- README.md | 34 +++++++---- release-policy.json | 1 + scripts/Test-OpenClawWorkflowInputs.Tests.ps1 | 59 +++++++++++++++++++ scripts/Test-OpenClawWorkflowInputs.ps1 | 39 ++++++++++++ scripts/Test-SigningInputs.Tests.ps1 | 21 ++++++- scripts/Test-WorkflowSigningConfiguration.ps1 | 37 +++++++++++- 8 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 scripts/Test-OpenClawWorkflowInputs.Tests.ps1 create mode 100644 scripts/Test-OpenClawWorkflowInputs.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 59d3642d..bfee5286 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -169,6 +169,10 @@ package. the reviewed immutable commit and stable or correction tag in `release-policy.json`. The tag determines the four-part MSIX identity version and the permanent GitHub Release tag. + An explicit `developmentCommit` may instead own both workflow defaults; + it never authorizes official signing. Official runs must explicitly select + `approvedCommit`, and qualification of a development runtime is not proof of + officially signed production readiness. - The launcher is NativeAOT. `dotnet build` and the xUnit suite exercise a JIT build, so run the NativeAOT publish path when changing reflection, interop, or trimming-sensitive code. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index ebed3387..a22edc25 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -8,9 +8,9 @@ on: workflow_dispatch: inputs: openclaw_ref: - description: openclaw/openclaw tag, branch, or commit to package + description: OpenClaw ref (default is unreleased; official signing requires release-policy.json approvedCommit) required: true - default: 3a9d69db306cd7f081e06254cb89c4bcc14a7107 + default: 44e9347d3342cd8b5e27fec78df40b8dff0dca34 type: string signing_mode: description: Package signing mode @@ -31,7 +31,7 @@ permissions: pull-requests: read env: - OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '3a9d69db306cd7f081e06254cb89c4bcc14a7107' }} + OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '44e9347d3342cd8b5e27fec78df40b8dff0dca34' }} PACKAGING_ROOT: . jobs: @@ -146,6 +146,10 @@ jobs: run: > .\scripts\Test-SigningInputs.Tests.ps1 + - name: Test OpenClaw workflow inputs + shell: pwsh + run: .\scripts\Test-OpenClawWorkflowInputs.Tests.ps1 + - name: Test Node.js packaging inputs shell: pwsh run: .\scripts\Test-NodeRuntimeInputs.Tests.ps1 @@ -226,6 +230,17 @@ jobs: with: persist-credentials: false + - name: Validate OpenClaw source and signing mode + shell: pwsh + env: + SIGNING_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }} + run: > + .\scripts\Test-OpenClawWorkflowInputs.ps1 + -SigningMode $env:SIGNING_MODE + -RequestedRef $env:OPENCLAW_REF + -EventName $env:GITHUB_EVENT_NAME + -GitRef $env:GITHUB_REF + - name: Resolve immutable OpenClaw source id: resolve shell: pwsh diff --git a/README.md b/README.md index 97bcf220..4e93a8b8 100644 --- a/README.md +++ b/README.md @@ -257,21 +257,22 @@ processes. It no longer supports host execution or the old `OPENCLAW_SESSION` routing preference; that variable is not accepted as a substitute report here. This plugin change does not enable the tab or alter launcher execution. -The screenshots attached to the pull request are design and behavior proof -captured with the plugin explicitly enabled in an isolated validation profile; -they do not represent the default-disabled state of a normal install. +Validation explicitly enables the plugin in an isolated profile. Direct-Node +UI fixtures verify rendering and interactions, not packaged-launcher isolation +or the default-disabled state of a normal install. Full selected-theme cohesion requires the generic plugin-frame theme forwarding merged by [`openclaw/openclaw#145409`](https://github.com/openclaw/openclaw/pull/145409). -The current workflow remains on the release-approved OpenClaw `v2026.9.4` -baseline (`3a9d69db306cd7f081e06254cb89c4bcc14a7107`) while this plugin is disabled by -default. That baseline packages and inspects the plugin safely but does not -forward selected Control UI themes into plugin frames. The future launcher -enablement change must also advance and qualify the runtime to the merged theme -forwarding commit `f65ecca89667b8a55d9f88d76c487f0a0ab11da8` or newer. Until -then, the page uses the browser or operating system light/dark preference with -a safe built-in palette. +The workflow defaults to the explicitly unreleased development commit +`44e9347d3342cd8b5e27fec78df40b8dff0dca34`, which includes that forwarding. +Its package version is still `2026.9.4`; this does not make it a stable release. +The official approval remains `v2026.9.4` at +`3a9d69db306cd7f081e06254cb89c4bcc14a7107`, which lacks theme forwarding. +Qualifying the development runtime does not qualify officially signed production +packages: those still need a separate stable runtime approval, and the plugin +remains disabled by default. Without forwarding, the page uses the browser or +operating system light/dark preference with a safe built-in palette. `scripts\Build-MSIX.ps1` downloads the official Node.js archive matching the payload's recorded build version and architecture, copies both inputs into @@ -356,6 +357,13 @@ Test-signing private keys are generated only on the temporary GitHub runner and are deleted before artifacts are uploaded. No signing secret or private key is stored in the repository. +`release-policy.json` may declare a separate immutable `developmentCommit` for +both workflow defaults. It is not an official signing authorization. +An `official` run must explicitly override `openclaw_ref` with `approvedCommit`; +using the unreleased default fails before source resolution or build, with the +required input shown in the error. Unsigned and test-signed runs can use the +development default or another explicitly selected ref. + Official releases derive their GitHub tag and four-part numeric MSIX identity from `gatewayTag` and `msixRevision` in `release-policy.json`. The GitHub tag is `<gateway-tag>-msix.<revision>`. The MSIX identity is @@ -384,8 +392,8 @@ reviewed pull request: 3. `payloadPackageVersion` to the version reported by the pinned payload; 4. `msixRevision` to `0`, or increment it for a packaging-only rebuild of the same Gateway tag; -5. the workflow's `openclaw_ref` default and non-manual fallback to the same - `approvedCommit`. +5. remove the temporary `developmentCommit` override and set the workflow's + `openclaw_ref` default and non-manual fallback to the same `approvedCommit`. After that pull request merges, manually run **Build OpenClaw Gateway MSIX** on `main` with `openclaw_ref` set to the approved commit and `signing_mode` set to diff --git a/release-policy.json b/release-policy.json index 3c536cd8..0fe821f9 100644 --- a/release-policy.json +++ b/release-policy.json @@ -4,5 +4,6 @@ "msixRevision": 1, "payloadPackageVersion": "2026.9.4", "approvedCommit": "3a9d69db306cd7f081e06254cb89c4bcc14a7107", + "developmentCommit": "44e9347d3342cd8b5e27fec78df40b8dff0dca34", "publisher": "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" } diff --git a/scripts/Test-OpenClawWorkflowInputs.Tests.ps1 b/scripts/Test-OpenClawWorkflowInputs.Tests.ps1 new file mode 100644 index 00000000..8530b961 --- /dev/null +++ b/scripts/Test-OpenClawWorkflowInputs.Tests.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$scriptPath = Join-Path $PSScriptRoot 'Test-OpenClawWorkflowInputs.ps1' +$policy = Get-Content (Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json') -Raw | + ConvertFrom-Json +$approved = [string]$policy.approvedCommit +$development = if ($policy.PSObject.Properties.Name -contains 'developmentCommit') { + [string]$policy.developmentCommit +} +else { + ('b' * 40) +} + +function Assert-Rejected { + param([hashtable]$Inputs, [string]$Message) + try { + & $scriptPath @Inputs + } + catch { + if ($_.Exception.Message -notmatch $Message) { + throw "Expected '$Message'; received: $($_.Exception.Message)" + } + return + } + throw 'Unapproved workflow inputs were accepted.' +} + +foreach ($mode in @('unsigned', 'test')) { + foreach ($ref in @($approved, $development, 'main')) { + & $scriptPath -SigningMode $mode -RequestedRef $ref ` + -EventName workflow_dispatch -GitRef refs/heads/topic + } +} +& $scriptPath -SigningMode unsigned -RequestedRef $development ` + -EventName push -GitRef refs/heads/main +& $scriptPath -SigningMode unsigned -RequestedRef $development ` + -EventName pull_request -GitRef refs/pull/1/merge +& $scriptPath -SigningMode official -RequestedRef $approved ` + -EventName workflow_dispatch -GitRef refs/heads/main + +foreach ($ref in @($development, [string]$policy.gatewayTag, 'main')) { + Assert-Rejected @{ + SigningMode = 'official'; RequestedRef = $ref + EventName = 'workflow_dispatch'; GitRef = 'refs/heads/main' + } 'Set openclaw_ref.*unsigned.*test' +} +Assert-Rejected @{ + SigningMode = 'official'; RequestedRef = $approved + EventName = 'workflow_dispatch'; GitRef = 'refs/heads/topic' +} 'manual workflow_dispatch run from main' +Assert-Rejected @{ + SigningMode = 'official'; RequestedRef = $approved + EventName = 'push'; GitRef = 'refs/heads/main' +} 'manual workflow_dispatch run from main' + +Write-Host 'OpenClaw workflow input tests passed.' diff --git a/scripts/Test-OpenClawWorkflowInputs.ps1 b/scripts/Test-OpenClawWorkflowInputs.ps1 new file mode 100644 index 00000000..cc68235a --- /dev/null +++ b/scripts/Test-OpenClawWorkflowInputs.ps1 @@ -0,0 +1,39 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('unsigned', 'test', 'official')] + [string]$SigningMode, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$RequestedRef, + + [Parameter(Mandatory)] + [string]$EventName, + + [Parameter(Mandatory)] + [string]$GitRef, + + [string]$PolicyPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'release-policy.json') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json +$approvedCommit = [string]$policy.approvedCommit +if ($approvedCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw 'The official OpenClaw commit approval is invalid.' +} +if ($SigningMode -eq 'official') { + if ($EventName -ne 'workflow_dispatch' -or $GitRef -ne 'refs/heads/main') { + throw 'Official signing requires a manual workflow_dispatch run from main.' + } + if ($RequestedRef -ine $approvedCommit) { + throw ( + 'Official signing cannot use an unapproved or development ref. ' + + "Set openclaw_ref to $approvedCommit, or select signing_mode " + + "'unsigned' or 'test' to qualify the unreleased development runtime." + ) + } +} +Write-Host "Validated OpenClaw workflow inputs for $SigningMode signing." diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index c52c6ba3..b5dcc76e 100644 --- a/scripts/Test-SigningInputs.Tests.ps1 +++ b/scripts/Test-SigningInputs.Tests.ps1 @@ -421,14 +421,16 @@ function Update-TestMsix { } function Reset-TestArtifacts { + param([string]$PayloadCommit = $approvedCommit) + Remove-Item ` -LiteralPath $testRoot ` -Recurse ` -Force ` -ErrorAction SilentlyContinue New-Item -Path $testRoot -ItemType Directory | Out-Null - New-TestArtifact -Root $testRoot -Architecture x64 - New-TestArtifact -Root $testRoot -Architecture arm64 + New-TestArtifact -Root $testRoot -Architecture x64 -PayloadCommit $PayloadCommit + New-TestArtifact -Root $testRoot -Architecture arm64 -PayloadCommit $PayloadCommit } try { @@ -458,6 +460,21 @@ try { Invoke-PolicyValidation -Root $testRoot -PreserveBundle } + Reset-TestArtifacts + if ($policy.PSObject.Properties.Name -contains 'developmentCommit') { + Assert-Fails ` + -MessagePattern 'approved immutable OpenClaw commit' ` + -Action { + Invoke-PolicyValidation ` + -Root $testRoot ` + -RequestedRef ([string]$policy.developmentCommit) + } + Reset-TestArtifacts -PayloadCommit ([string]$policy.developmentCommit) + Assert-Fails ` + -MessagePattern 'MSIX metadata is not eligible for signing' ` + -Action { Invoke-PolicyValidation -Root $testRoot } + } + Reset-TestArtifacts Update-TestMsix -Root $testRoot -Architecture x64 -Mutator { param($Expanded) diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index aae8fced..df432434 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -69,6 +69,7 @@ $requiredFragments = @( 'overwrite_files: false' 'fail_on_unmatched_files: true' 'release-assets/*.msixbundle' + '.\scripts\Test-OpenClawWorkflowInputs.Tests.ps1' ) foreach ($fragment in $requiredFragments) { @@ -91,6 +92,27 @@ if ($buildMsixJob.Contains( throw 'The build-msix job must compose the locally built payload directly.' } +$packageJob = [regex]::Match( + $workflow, + '(?ms)^ build-package:\s*(?<job>.*?)(?=^ [a-z][a-z0-9-]+:)' +).Groups['job'].Value +foreach ($fragment in @( + "SIGNING_MODE: `${{ github.event_name == 'workflow_dispatch' && inputs.signing_mode || 'unsigned' }}" + '.\scripts\Test-OpenClawWorkflowInputs.ps1' + '-SigningMode $env:SIGNING_MODE' + '-RequestedRef $env:OPENCLAW_REF' + '-EventName $env:GITHUB_EVENT_NAME' + '-GitRef $env:GITHUB_REF' +)) { + if (-not $packageJob.Contains($fragment, [StringComparison]::Ordinal)) { + throw "The source build must validate actual workflow inputs: $fragment" + } +} +if ($packageJob.IndexOf('Test-OpenClawWorkflowInputs.ps1', [StringComparison]::Ordinal) -ge + $packageJob.IndexOf('name: Resolve immutable OpenClaw source', [StringComparison]::Ordinal)) { + throw 'Validate source/signing inputs before resolving or building OpenClaw.' +} + $dispatchDefaultMatch = [regex]::Match( $workflow, '(?ms)openclaw_ref:\s+description:.*?default:\s*(?<sha>[0-9a-f]{40})' @@ -107,17 +129,26 @@ $releasePolicy = Get-Content ` -LiteralPath (Join-Path $repositoryRoot 'release-policy.json') ` -Raw | ConvertFrom-Json +$expectedDefault = [string]$releasePolicy.approvedCommit +if ($releasePolicy.PSObject.Properties.Name -contains 'developmentCommit') { + $expectedDefault = [string]$releasePolicy.developmentCommit + if ($expectedDefault -cnotmatch '^[0-9a-f]{40}$' -or + $expectedDefault -ieq [string]$releasePolicy.approvedCommit) { + throw 'The development pin must be an immutable commit distinct from the official approval.' + } +} $pinnedRevisions = @( @( $dispatchDefaultMatch.Groups['sha'].Value $automaticFallbackMatch.Groups['sha'].Value - [string]$releasePolicy.approvedCommit + $expectedDefault ) | Select-Object -Unique ) if ($pinnedRevisions.Count -ne 1) { throw ( - 'The workflow defaults and official release policy must pin the same ' + - "OpenClaw commit; found: $($pinnedRevisions -join ', ')." + 'The workflow defaults must match the declared development pin, or ' + + 'the official approval when no development pin is declared. ' + + "Found: $($pinnedRevisions -join ', ')." ) } From 14c75c5149483cf247960d569d80f0bf24a56e67 Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Thu, 17 Sep 2026 18:36:42 -0700 Subject: [PATCH 73/76] Validate Windows payloads with architecture-matched Node Use a native ARM64 runner, reject incompatible inspection hosts before staging, and isolate the plugin snapshot cache for every CLI probe. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 4 + .github/workflows/gateway-msix.yml | 11 ++- README.md | 8 ++ scripts/Build-Payload.ps1 | 34 ++++--- scripts/Test-GatewayIsolationPlugin.Tests.ps1 | 47 +++++++++- scripts/Test-NodeRuntimeInputs.Tests.ps1 | 90 +++++++++++++++---- scripts/Test-WorkflowSigningConfiguration.ps1 | 18 ++++ 7 files changed, 176 insertions(+), 36 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bfee5286..872ed3cf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -154,6 +154,10 @@ package. - Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts, project runtime identifiers, manifest content, payload metadata, and signing validation. + `Build-Payload.ps1` must install and inspect with matching Windows Node + architecture; do not skip activated-plugin checks for cross-builds. + Its temporary profile also owns `XDG_CACHE_HOME`, which must be restored. + Already-qualified payloads can still be cross-composed into MSIX packages. - Do not add a packaging-side Node.js version pin or support-range policy. The selected upstream toolchain owns version selection; package composition supplies `NodeRuntimeArchiveFileName`, and the host reads the archive name. diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index a22edc25..045ffc3d 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -376,13 +376,15 @@ jobs: - changes - test-host - build-package - runs-on: windows-latest + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - architecture: - - x64 - - arm64 + include: + - architecture: x64 + runner: windows-latest + - architecture: arm64 + runner: windows-11-vs2026-arm steps: - name: Check out repository uses: actions/checkout@v7 @@ -393,6 +395,7 @@ jobs: uses: actions/setup-node@v6 with: node-version: ${{ needs.build-package.outputs.node_version }} + architecture: ${{ matrix.architecture }} - name: Download intermediate package uses: actions/download-artifact@v8 diff --git a/README.md b/README.md index 4e93a8b8..2e227362 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,14 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` --no-restore ``` +Payload installation and runtime inspection require Windows and Node.js matching +the target architecture. CI uses `windows-latest` for x64 and +`windows-11-vs2026-arm` for ARM64; loading target-native dependencies cannot be +qualified by cross-compiling them on an x64 host. These jobs validate payload +loading and packaging, not installed agent-session E2E on a supported host. +`Build-MSIX.ps1` and `Build-LocalMSIX.ps1 -PayloadDirectory` can still cross-compose +an already-qualified payload. + `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, architecture-specific application tree. It validates the Gateway and Control UI build identities on the installed tree, including reused staged installs, then diff --git a/scripts/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index 1e768ebb..f19b7dff 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -37,6 +37,19 @@ if ($sourceMetadata.nodeVersion -cne $nodeVersion) { "version '$($sourceMetadata.nodeVersion)'." ) } +$nodeTarget = & node -p 'process.platform + "/" + process.arch' +if ($LASTEXITCODE -ne 0) { + throw 'Unable to determine the payload inspection Node.js platform and architecture.' +} +if (-not $IsWindows -or $nodeTarget -cne "win32/$Architecture") { + throw ( + "Payload runtime inspection requires win32/$Architecture Node.js; " + + "found '$nodeTarget'. Build and inspect on the matching Windows " + + 'architecture (windows-11-vs2026-arm for ARM64). To cross-compose, ' + + 'supply an already-qualified payload to Build-MSIX.ps1 or ' + + 'Build-LocalMSIX.ps1 -PayloadDirectory instead.' + ) +} $npmVersion = & npm --version if ($LASTEXITCODE -ne 0) { throw 'Unable to determine the payload build npm version.' @@ -185,6 +198,7 @@ foreach ($pluginFile in $pluginFiles) { $previousStateDirectory = $env:OPENCLAW_STATE_DIR $previousConfigPath = $env:OPENCLAW_CONFIG_PATH $previousIsolationMode = $env:CLAWCTL_GATEWAY_ISOLATION +$previousCacheHome = $env:XDG_CACHE_HOME $validationStateDirectory = Join-Path ` $stagingDirectory ` 'gateway-isolation-validation' @@ -195,6 +209,7 @@ try { $env:OPENCLAW_STATE_DIR = $validationStateDirectory $env:OPENCLAW_CONFIG_PATH = $validationConfigPath $env:CLAWCTL_GATEWAY_ISOLATION = 'disabled' + $env:XDG_CACHE_HOME = Join-Path $validationStateDirectory 'cache' Push-Location $installedPackage try { $defaultInspectionOutput = ( @@ -283,6 +298,11 @@ try { "isolation plugin. Exit code: $LASTEXITCODE." ) } + + & node .\openclaw.mjs --version + if ($LASTEXITCODE -ne 0) { + throw "OpenClaw payload smoke test failed with exit code $LASTEXITCODE." + } } finally { Pop-Location @@ -314,6 +334,7 @@ finally { $env:OPENCLAW_STATE_DIR = $previousStateDirectory $env:OPENCLAW_CONFIG_PATH = $previousConfigPath $env:CLAWCTL_GATEWAY_ISOLATION = $previousIsolationMode + $env:XDG_CACHE_HOME = $previousCacheHome if (Test-Path -LiteralPath $validationStateDirectory) { Remove-Item ` -LiteralPath $validationStateDirectory ` @@ -344,19 +365,6 @@ if ($bundledNodeFiles.Count -ne 0) { ) } -if ($Architecture -eq 'x64') { - Push-Location $installedPackage - try { - & node .\openclaw.mjs --version - if ($LASTEXITCODE -ne 0) { - throw "OpenClaw payload smoke test failed with exit code $LASTEXITCODE." - } - } - finally { - Pop-Location - } -} - [ordered]@{ repository = $sourceMetadata.repository requestedRef = $sourceMetadata.requestedRef diff --git a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 index 31690c6b..4d077700 100644 --- a/scripts/Test-GatewayIsolationPlugin.Tests.ps1 +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -12,6 +12,10 @@ $testRoot = Join-Path $env:TEMP ( $packageSource = Join-Path $testRoot 'package-source' $packageDirectory = Join-Path $testRoot 'package' $payloadDirectory = Join-Path $testRoot 'payload' +$fixtureArchitecture = & node -p 'process.arch' +if ($LASTEXITCODE -ne 0 -or $fixtureArchitecture -notin @('x64', 'arm64')) { + throw 'The plugin payload fixture requires x64 or ARM64 Node.js.' +} function Assert-Path { param( @@ -72,6 +76,11 @@ try { const args = process.argv.slice(2); const fs = await import("node:fs"); const path = await import("node:path"); +if (process.env.XDG_CACHE_HOME !== path.join(process.env.OPENCLAW_STATE_DIR, "cache")) { + throw new Error("Expected an isolated plugin snapshot cache."); +} +fs.mkdirSync(process.env.XDG_CACHE_HOME, { recursive: true }); +fs.writeFileSync(path.join(process.env.XDG_CACHE_HOME, "fixture-cache"), "owned"); const configPath = process.env.OPENCLAW_CONFIG_PATH ?? (process.env.OPENCLAW_STATE_DIR @@ -115,6 +124,9 @@ const enabled = Boolean(configPath) && fs.existsSync(configPath) && JSON.parse(fs.readFileSync(configPath, "utf8")) .plugins?.entries?.["gateway-isolation"]?.enabled === true; const runtime = args.includes("--runtime"); +if (runtime && process.env.OPENCLAW_FIXTURE_FAIL_RUNTIME === "1") { + throw new Error("Requested runtime inspection fixture failure."); +} console.log(JSON.stringify({ plugin: { id: "gateway-isolation", @@ -191,15 +203,46 @@ console.log(JSON.stringify({ -Encoding utf8 $previousRunnerTemp = $env:RUNNER_TEMP + $previousCacheHome = $env:XDG_CACHE_HOME + $previousFailureFixture = $env:OPENCLAW_FIXTURE_FAIL_RUNTIME try { $env:RUNNER_TEMP = $testRoot + $env:XDG_CACHE_HOME = Join-Path $testRoot 'caller-cache' + $env:OPENCLAW_FIXTURE_FAIL_RUNTIME = $null & (Join-Path $PSScriptRoot 'Build-Payload.ps1') ` -PackageDirectory $packageDirectory ` - -Architecture arm64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory $payloadDirectory + if ($env:XDG_CACHE_HOME -cne (Join-Path $testRoot 'caller-cache') -or + (Test-Path (Join-Path $testRoot 'caller-cache')) -or + (Test-Path (Join-Path $testRoot "openclaw-stage-$fixtureArchitecture\gateway-isolation-validation"))) { + throw 'Successful payload inspection did not isolate, restore and clean its cache.' + } + $env:OPENCLAW_FIXTURE_FAIL_RUNTIME = '1' + $inspectionFailed = $false + try { + & (Join-Path $PSScriptRoot 'Build-Payload.ps1') ` + -PackageDirectory $packageDirectory ` + -Architecture $fixtureArchitecture ` + -OutputDirectory (Join-Path $testRoot 'failed-payload') ` + -ReuseStagedInstall + } + catch { + if ($_.Exception.Message -notmatch 'cannot load the Gateway isolation plugin') { + throw + } + $inspectionFailed = $true + } + if (-not $inspectionFailed -or + $env:XDG_CACHE_HOME -cne (Join-Path $testRoot 'caller-cache') -or + (Test-Path (Join-Path $testRoot "openclaw-stage-$fixtureArchitecture\gateway-isolation-validation"))) { + throw 'Failed payload inspection must restore and remove only its isolated cache.' + } } finally { $env:RUNNER_TEMP = $previousRunnerTemp + $env:XDG_CACHE_HOME = $previousCacheHome + $env:OPENCLAW_FIXTURE_FAIL_RUNTIME = $previousFailureFixture } $packagedPlugin = Join-Path ` @@ -213,7 +256,7 @@ console.log(JSON.stringify({ } $stagedPlugin = Join-Path ` $testRoot ` - 'openclaw-stage-arm64\node_modules\openclaw\dist\extensions\gateway-isolation' + "openclaw-stage-$fixtureArchitecture\node_modules\openclaw\dist\extensions\gateway-isolation" if (Test-Path -LiteralPath $stagedPlugin) { throw 'Plugin provisioning must not mutate the reusable staged install.' } diff --git a/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 index 490822c8..86b2ad07 100644 --- a/scripts/Test-NodeRuntimeInputs.Tests.ps1 +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -8,6 +8,10 @@ $testRoot = Join-Path ([IO.Path]::GetTempPath()) ( ) $previousRunnerTemp = $env:RUNNER_TEMP $previousNpmCache = $env:npm_config_cache +$fixtureArchitecture = & node -p 'process.arch' +if ($LASTEXITCODE -ne 0 -or $fixtureArchitecture -notin @('x64', 'arm64')) { + throw 'The payload input fixture requires x64 or ARM64 Node.js.' +} function Assert-Fails { param([scriptblock]$Action, [string]$MessagePattern) @@ -117,8 +121,54 @@ console.log(JSON.stringify({ } $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" + $otherArchitecture = if ($fixtureArchitecture -eq 'x64') { 'arm64' } else { 'x64' } + $incompatiblePayload = Join-Path $testRoot 'incompatible-payload' + Assert-Fails -MessagePattern "requires win32/$otherArchitecture Node.js" -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture $otherArchitecture ` + -OutputDirectory $incompatiblePayload + } + if ((Test-Path $incompatiblePayload) -or + (Test-Path (Join-Path $testRoot "openclaw-stage-$otherArchitecture"))) { + throw 'Incompatible runtime inspection must fail before staging or installation.' + } + + $nodeExecutable = @(Get-Command node -CommandType Application)[0].Source + $probeExitCode = 0 + function node { + if ($args.Count -eq 2 -and + $args[0] -ceq '-p' -and + $args[1] -ceq 'process.platform + "/" + process.arch') { + $global:LASTEXITCODE = $probeExitCode + $probeTarget + return + } + & $nodeExecutable @args + } + try { + foreach ($probeTarget in @("linux/$fixtureArchitecture", 'unknown')) { + Assert-Fails -MessagePattern "requires win32/$fixtureArchitecture Node.js" -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture $fixtureArchitecture ` + -OutputDirectory $incompatiblePayload + } + } + $probeExitCode = 1 + Assert-Fails -MessagePattern 'Unable to determine.*platform and architecture' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture $fixtureArchitecture ` + -OutputDirectory $incompatiblePayload + } + if (Test-Path $incompatiblePayload) { + throw 'An invalid or failed Node target probe must not produce a payload.' + } + } + finally { + Remove-Item Function:\node + } + & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + -PackageDirectory $package -Architecture $fixtureArchitecture -OutputDirectory $payload $metadataPath = Join-Path $payload 'payload-metadata.json' $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json if ($metadata.nodeVersion -cne $nodeVersion) { @@ -128,7 +178,7 @@ console.log(JSON.stringify({ $reusedPayload = Join-Path $testRoot 'payload-reused' & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory $reusedPayload ` -ReuseStagedInstall if (-not (Test-Path -LiteralPath "$reusedPayload\app\openclaw.mjs")) { @@ -143,7 +193,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'does not match the requested packageSha256' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory (Join-Path $testRoot 'wrong-package-reuse') ` -ReuseStagedInstall } @@ -154,7 +204,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'does not match the requested resolvedCommit' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory (Join-Path $testRoot 'wrong-source-reuse') ` -ReuseStagedInstall } @@ -162,14 +212,14 @@ console.log(JSON.stringify({ $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" $stagingMetadataPath = Join-Path ( - Join-Path $testRoot 'openclaw-stage-x64' + Join-Path $testRoot "openclaw-stage-$fixtureArchitecture" ) '.openclaw-install.json' $stagingMetadata = Get-Content -LiteralPath $stagingMetadataPath -Raw Remove-Item -LiteralPath $stagingMetadataPath -Force Assert-Fails -MessagePattern 'missing provenance' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory (Join-Path $testRoot 'missing-provenance-reuse') ` -ReuseStagedInstall } @@ -177,7 +227,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'provenance is invalid' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory (Join-Path $testRoot 'invalid-provenance-reuse') ` -ReuseStagedInstall } @@ -187,20 +237,26 @@ console.log(JSON.stringify({ [Text.UTF8Encoding]::new($false) ) - Assert-Fails -MessagePattern 'staged OpenClaw install does not exist' -Action { - & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package ` - -Architecture arm64 ` - -OutputDirectory (Join-Path $testRoot 'missing-reuse') ` - -ReuseStagedInstall + try { + $env:RUNNER_TEMP = Join-Path $testRoot 'empty-stage' + Assert-Fails -MessagePattern 'staged OpenClaw install does not exist' -Action { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture $fixtureArchitecture ` + -OutputDirectory (Join-Path $testRoot 'missing-reuse') ` + -ReuseStagedInstall + } + } + finally { + $env:RUNNER_TEMP = $testRoot } - $stagedPackage = Join-Path $testRoot 'openclaw-stage-x64\node_modules\openclaw' + $stagedPackage = Join-Path $testRoot "openclaw-stage-$fixtureArchitecture\node_modules\openclaw" Set-Content -LiteralPath (Join-Path $stagedPackage 'node.exe') -Value 'unsafe' Assert-Fails -MessagePattern 'must not bundle Node.js' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory (Join-Path $testRoot 'unsafe-reuse') ` -ReuseStagedInstall } @@ -213,7 +269,7 @@ console.log(JSON.stringify({ Assert-Fails -MessagePattern 'build identity mismatch' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` -PackageDirectory $package ` - -Architecture x64 ` + -Architecture $fixtureArchitecture ` -OutputDirectory (Join-Path $testRoot 'identity-reuse') ` -ReuseStagedInstall } @@ -222,7 +278,7 @@ console.log(JSON.stringify({ $sourceMetadata | ConvertTo-Json | Set-Content -LiteralPath "$package\source.json" Assert-Fails -MessagePattern 'does not match the source build' -Action { & "$PSScriptRoot\Build-Payload.ps1" ` - -PackageDirectory $package -Architecture x64 -OutputDirectory $payload + -PackageDirectory $package -Architecture $fixtureArchitecture -OutputDirectory $payload } foreach ($architecture in @('x64', 'arm64')) { diff --git a/scripts/Test-WorkflowSigningConfiguration.ps1 b/scripts/Test-WorkflowSigningConfiguration.ps1 index df432434..4fc5f111 100644 --- a/scripts/Test-WorkflowSigningConfiguration.ps1 +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -86,6 +86,24 @@ if (-not $buildMsixJobMatch.Success) { throw 'Unable to locate the build-msix workflow job.' } $buildMsixJob = $buildMsixJobMatch.Groups['job'].Value +foreach ($fragment in @( + 'runs-on: ${{ matrix.runner }}' + 'architecture: ${{ matrix.architecture }}' +)) { + if (-not $buildMsixJob.Contains($fragment, [StringComparison]::Ordinal)) { + throw "Payload activation must use the matching runtime architecture: $fragment" + } +} +foreach ($entry in @( + @{ Architecture = 'x64'; Runner = 'windows-latest' } + @{ Architecture = 'arm64'; Runner = 'windows-11-vs2026-arm' } +)) { + $pattern = 'architecture:\s*' + $entry.Architecture + + '\s+runner:\s*' + [regex]::Escape($entry.Runner) + '\s' + if ($buildMsixJob -cnotmatch $pattern) { + throw "Missing native $($entry.Architecture) payload runner $($entry.Runner)." + } +} if ($buildMsixJob.Contains( 'name: Download payload', [StringComparison]::Ordinal)) { From 875f4c97378258c18f8d82281b744aafd47c5f87 Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Thu, 17 Sep 2026 20:48:58 -0700 Subject: [PATCH 74/76] Group Windows Launcher commands into a concise cheat sheet Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 33 ++++---- plugins/gateway-isolation/index.js | 106 ++++++++++++++---------- plugins/gateway-isolation/index.test.js | 45 +++++++--- 3 files changed, 109 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 2e227362..c180327f 100644 --- a/README.md +++ b/README.md @@ -238,25 +238,20 @@ and neutral status-unavailable text (HTTP 503), never a supported off state. The live route establishes Gateway availability; disconnected-Gateway messaging belongs to the Control UI. There are no isolation controls. -The active page also provides copy-only command references for `clawctl pwsh`, -`openclaw dashboard --no-open`, `openclaw tui`, -`clawctl gateway-service status`, a PowerShell 7 restart sequence -(`clawctl gateway-service stop && clawctl gateway-service start`), -`openclaw --help`, and `clawctl --help`. The restart sequence manages this -installation's background Gateway and starts it only after a successful stop; -an already-stopped Gateway is a successful stop. It is not a native `restart` -command. OpenClaw help lists top-level commands; individual subcommands have -their own `--help`, not a recursive `--all` option. The page never executes -commands or sends mutation requests. Copy controls announce success only after -a clipboard operation succeeds; otherwise they offer manual-copy guidance, -leaving the command selected when selection is available. Invalid isolation -reports show no command references. `clawctl pwsh` opens PowerShell inside the -configured agent session after setup, with `openclaw` and `node` on PATH; exit -returns to the host. It prefers machine-wide PowerShell 7 and otherwise uses -Windows PowerShell. Run ClawCtl from the host, not from inside that shell. -`openclaw tui` is the Gateway chat terminal UI, not an agent shell. -ClawCtl has no dashboard command. `openclaw dashboard --no-open` shows dashboard -access details without trying to launch a browser inside the agent session. +The active page provides a brief, copy-only cheat sheet: + +- **ClawCtl:** `clawctl pwsh` first, then `clawctl gateway-service status`, + `clawctl gateway-service stop && clawctl gateway-service start`, and + `clawctl --help`. Run ClawCtl on the host. The restart sequence requires + PowerShell 7 and starts the Gateway only after a successful stop. +- **OpenClaw:** `openclaw tui`, `openclaw dashboard --no-open`, and + `openclaw --help`. The packaged openclaw command runs the OpenClaw CLI + inside your agent session. + +The page never executes commands or sends mutation requests. Copy controls +announce success only after a clipboard operation succeeds; otherwise they +offer manual-copy guidance, leaving the command selected when possible. +Invalid isolation reports show no command references. The report is captured once at plugin creation. It is a launcher-provided diagnostic, not independent isolation attestation. The launcher now requires an diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index d4435a2b..fba4dffb 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -1,48 +1,61 @@ const ISOLATION_ENVIRONMENT_VARIABLE = "CLAWCTL_GATEWAY_ISOLATION"; const STATUS_PATH = "/plugins/gateway-isolation/status"; const THEME_MESSAGE_TYPE = "openclaw:widget-theme"; -const COMMAND_REFERENCES = [ +const COMMAND_GROUPS = [ { - id: "agent-shell", - title: "Agent session PowerShell", - description: "Open PowerShell inside the agent session. Requires setup; exit to return.", - command: "clawctl pwsh", + id: "clawctl", + title: "ClawCtl", + commands: [ + { + id: "agent-shell", + title: "Agent session PowerShell", + description: "Open the agent shell.", + command: "clawctl pwsh", + }, + { + id: "gateway-status", + title: "Gateway status", + description: "Check the background Gateway.", + command: "clawctl gateway-service status", + }, + { + id: "gateway-restart", + title: "Restart Gateway", + description: "Requires PowerShell 7 on the Gateway host.", + command: "clawctl gateway-service stop && clawctl gateway-service start", + }, + { + id: "launcher-help", + title: "Launcher help", + description: "List launcher commands.", + command: "clawctl --help", + }, + ], }, { - id: "dashboard", - title: "Dashboard access", - description: "Show dashboard access details without opening a browser inside the agent session.", - command: "openclaw dashboard --no-open", - }, - { - id: "terminal", - title: "Gateway chat TUI", - description: "Chat with the Gateway in a terminal. This is not an agent shell.", - command: "openclaw tui", - }, - { - id: "gateway-status", - title: "Gateway status", - description: "Inspect this installation's background Gateway without changing it.", - command: "clawctl gateway-service status", - }, - { - id: "gateway-restart", - title: "Restart Gateway", - description: "Stop the background Gateway, then start it only if stopping succeeds. Keeps the session and its data.", - command: "clawctl gateway-service stop && clawctl gateway-service start", - }, - { - id: "openclaw-help", - title: "OpenClaw help", - description: "List top-level OpenClaw commands. Add --help to a subcommand for details.", - command: "openclaw --help", - }, - { - id: "launcher-help", - title: "Launcher help", - description: "List ClawCtl commands for managing this installation.", - command: "clawctl --help", + id: "openclaw", + title: "OpenClaw", + description: "The packaged openclaw command runs the OpenClaw CLI inside your agent session.", + commands: [ + { + id: "terminal", + title: "Gateway chat TUI", + description: "Chat in the terminal.", + command: "openclaw tui", + }, + { + id: "dashboard", + title: "Dashboard access", + description: "Show dashboard access details.", + command: "openclaw dashboard --no-open", + }, + { + id: "openclaw-help", + title: "OpenClaw help", + description: "List OpenClaw commands.", + command: "openclaw --help", + }, + ], }, ]; const THEME_BRIDGE_SCRIPT = `<script> @@ -143,7 +156,8 @@ const COPY_COMMAND_SCRIPT = `<script> </script>`; function renderCommandReferences() { - const rows = COMMAND_REFERENCES.map(({ id, title, description, command }) => ` + const groups = COMMAND_GROUPS.map((group) => { + const rows = group.commands.map(({ id, title, description, command }) => ` <div class="command-row"> <div> <h3>${title}</h3> @@ -154,14 +168,16 @@ function renderCommandReferences() { <button type="button" data-copy-command="command-${id}" data-command-title="${title}" aria-label="Copy ${title} command">Copy</button> </div> </div>`).join(""); - return ` - <section aria-labelledby="commands-title"> - <h2 id="commands-title">Command reference</h2> - <p class="intro">Copy a command to run yourself in PowerShell 7 on the Gateway host. This page never runs commands.</p> + return ` + <section aria-labelledby="${group.id}-title"> + <h2 id="${group.id}-title">${group.title}</h2> + ${group.description ? `<p class="intro">${group.description}</p>` : ""} <div class="status-section">${rows} </div> - <p id="copy-status" class="copy-status" role="status" aria-live="polite" aria-atomic="true"></p> </section>`; + }).join(""); + return `${groups} + <p id="copy-status" class="copy-status" role="status" aria-live="polite" aria-atomic="true"></p>`; } export function readGatewayIsolationMode(env) { diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index 46bfa224..cb0e9dfb 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -160,14 +160,40 @@ test("renders one informational Gateway Isolation row with a single active badge const expectedCommands = [ ["Agent session PowerShell", "clawctl pwsh"], - ["Dashboard access", "openclaw dashboard --no-open"], - ["Gateway chat TUI", "openclaw tui"], ["Gateway status", "clawctl gateway-service status"], ["Restart Gateway", "clawctl gateway-service stop && clawctl gateway-service start"], - ["OpenClaw help", "openclaw --help"], ["Launcher help", "clawctl --help"], + ["Gateway chat TUI", "openclaw tui"], + ["Dashboard access", "openclaw dashboard --no-open"], + ["OpenClaw help", "openclaw --help"], ]; +test("groups the brief cheat sheet with ClawCtl first and packaged OpenClaw second", () => { + const html = renderGatewayIsolationPage("enabled"); + const groups = [...html.matchAll(/<section aria-labelledby="([^"]+)">([\s\S]*?)<\/section>/g)]; + assert.deepEqual(groups.map(match => match[1]), ["clawctl-title", "openclaw-title"]); + assert.match(groups[0][2], /<h2 id="clawctl-title">ClawCtl<\/h2>/); + assert.match(groups[1][2], /<h2 id="openclaw-title">OpenClaw<\/h2>\s*<p class="intro">The packaged openclaw command runs the OpenClaw CLI inside your agent session\.<\/p>/); + for (const [index, expected] of [expectedCommands.slice(0, 4), expectedCommands.slice(4)].entries()) { + assert.deepEqual( + [...groups[index][2].matchAll(/<h3>([^<]+)<\/h3>/g)].map(match => match[1]), + expected.map(([title]) => title), + ); + } + assert.deepEqual( + [...html.matchAll(/<p class="command-description">([^<]+)<\/p>/g)].map(match => match[1]), + [ + "Open the agent shell.", + "Check the background Gateway.", + "Requires PowerShell 7 on the Gateway host.", + "List launcher commands.", + "Chat in the terminal.", + "Show dashboard access details.", + "List OpenClaw commands.", + ], + ); +}); + test("offers only supported general commands with individually named copy controls", () => { const html = renderGatewayIsolationPage("enabled"); const commands = [...html.matchAll(/<code id="([^"]+)">([^<]+)<\/code>/g)]; @@ -176,12 +202,8 @@ test("offers only supported general commands with individually named copy contro for (const [index, [title]] of expectedCommands.entries()) { assert.ok(html.includes(`data-copy-command="${commands[index][1]}" data-command-title="${title}" aria-label="Copy ${title} command"`)); } - assert.match(html, /PowerShell 7 on the Gateway host/); - assert.match(html, /This page never runs commands/); - assert.match(html, /only if stopping succeeds/); - assert.match(html, /Requires setup; exit to return/); - assert.match(html, /This is not an agent shell/); assert.match(html, /role="status" aria-live="polite" aria-atomic="true"/); + assert.equal([...html.matchAll(/id="copy-status"/g)].length, 1); assert.match(html, /button:focus-visible/); assert.match(html, /@media \(max-width: 620px\)/); assert.doesNotMatch(html, /clawctl tui|clawctl tty|clawctl dashboard|gateway-service restart|help --all|--help --all/); @@ -250,9 +272,10 @@ test("copies every exact command synchronously and announces only successful cop for (const legacy of [false, "throw"]) { test(`uses Clipboard API when legacy copy returns ${legacy}`, async () => { const copy = runCopyScript({ legacy, modern: "success" }); - await copy.buttons[4].click(); + const restartIndex = expectedCommands.findIndex(([title]) => title === "Restart Gateway"); + await copy.buttons[restartIndex].click(); assert.equal(copy.feedback.textContent, "Copied Restart Gateway command."); - assert.deepEqual(copy.calls.at(-1), ["modern", expectedCommands[4][1]]); + assert.deepEqual(copy.calls.at(-1), ["modern", expectedCommands[restartIndex][1]]); }); for (const modern of ["missing", "reject"]) { test(`offers selected manual copy when legacy=${legacy} and modern=${modern}`, async () => { @@ -453,7 +476,7 @@ for (const mode of invalidModes) { assert.match(response.body, /<dt>Gateway Isolation<\/dt>\s*<dd><span class="status status--neutral">Invalid<\/span><\/dd>/); assert.doesNotMatch( response.body, - />Active<|class="status status--ok"|status--warn|<script>alert|<button|<code|clipboard|execCommand|getSelection|createRange|aria-live|id="copy-status"|clawctl|Command reference/, + />Active<|class="status status--ok"|status--warn|<script>alert|<button|<code|clipboard|execCommand|getSelection|createRange|aria-live|id="copy-status"|clawctl|Command reference|<section|<h2/i, ); const bridge = runThemeBridge(response.body); bridge.listener({ From 99597e7f8585cbce781e687cc901f6af829ce148 Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Fri, 18 Sep 2026 10:01:21 -0700 Subject: [PATCH 75/76] Clarify launcher command purposes and session routing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 14 +++++++++----- plugins/gateway-isolation/index.js | 13 +++++++------ plugins/gateway-isolation/index.test.js | 11 ++++++----- src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 2 ++ 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c180327f..8956ea00 100644 --- a/README.md +++ b/README.md @@ -238,15 +238,19 @@ and neutral status-unavailable text (HTTP 503), never a supported off state. The live route establishes Gateway availability; disconnected-Gateway messaging belongs to the Control UI. There are no isolation controls. -The active page provides a brief, copy-only cheat sheet: +The active page provides a brief, copy-only cheat sheet. Enter these commands +in your normal Windows terminal (user session): - **ClawCtl:** `clawctl pwsh` first, then `clawctl gateway-service status`, `clawctl gateway-service stop && clawctl gateway-service start`, and - `clawctl --help`. Run ClawCtl on the host. The restart sequence requires - PowerShell 7 and starts the Gateway only after a successful stop. + `clawctl --help`. PowerShell opens inside the isolated agent, where `openclaw` + and `node` are available; ClawCtl manages the session from outside it. + The restart sequence preserves the session and its data, requires PowerShell 7, + and starts the Gateway only after a successful stop. - **OpenClaw:** `openclaw tui`, `openclaw dashboard --no-open`, and - `openclaw --help`. The packaged openclaw command runs the OpenClaw CLI - inside your agent session. + `openclaw --help`. The packaged `openclaw` command forwards to your agent + session; inside `clawctl pwsh`, it runs directly. The dashboard reference + shows the access URL for this dashboard without opening a browser. The page never executes commands or sends mutation requests. Copy controls announce success only after a clipboard operation succeeds; otherwise they diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index fba4dffb..ad3d1d45 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -9,19 +9,19 @@ const COMMAND_GROUPS = [ { id: "agent-shell", title: "Agent session PowerShell", - description: "Open the agent shell.", + description: "Open PowerShell inside the isolated agent. openclaw and node are available there; clawctl manages the session from outside it.", command: "clawctl pwsh", }, { id: "gateway-status", title: "Gateway status", - description: "Check the background Gateway.", + description: "Show whether the gateway is running.", command: "clawctl gateway-service status", }, { id: "gateway-restart", title: "Restart Gateway", - description: "Requires PowerShell 7 on the Gateway host.", + description: "Stop, then start the gateway, keeping the session and its data. Requires PowerShell 7 on the Gateway host.", command: "clawctl gateway-service stop && clawctl gateway-service start", }, { @@ -35,7 +35,7 @@ const COMMAND_GROUPS = [ { id: "openclaw", title: "OpenClaw", - description: "The packaged openclaw command runs the OpenClaw CLI inside your agent session.", + description: "The packaged openclaw command forwards to your agent session. Inside clawctl pwsh, it runs directly.", commands: [ { id: "terminal", @@ -46,7 +46,7 @@ const COMMAND_GROUPS = [ { id: "dashboard", title: "Dashboard access", - description: "Show dashboard access details.", + description: "Show the access URL for this dashboard.", command: "openclaw dashboard --no-open", }, { @@ -176,7 +176,8 @@ function renderCommandReferences() { </div> </section>`; }).join(""); - return `${groups} + return `<p class="intro">Run these commands in your normal Windows terminal (user session).</p> + ${groups} <p id="copy-status" class="copy-status" role="status" aria-live="polite" aria-atomic="true"></p>`; } diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index cb0e9dfb..f6a0983c 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -173,7 +173,8 @@ test("groups the brief cheat sheet with ClawCtl first and packaged OpenClaw seco const groups = [...html.matchAll(/<section aria-labelledby="([^"]+)">([\s\S]*?)<\/section>/g)]; assert.deepEqual(groups.map(match => match[1]), ["clawctl-title", "openclaw-title"]); assert.match(groups[0][2], /<h2 id="clawctl-title">ClawCtl<\/h2>/); - assert.match(groups[1][2], /<h2 id="openclaw-title">OpenClaw<\/h2>\s*<p class="intro">The packaged openclaw command runs the OpenClaw CLI inside your agent session\.<\/p>/); + assert.match(html, /<p class="intro">Run these commands in your normal Windows terminal \(user session\)\.<\/p>\s*<section aria-labelledby="clawctl-title">/); + assert.match(groups[1][2], /<h2 id="openclaw-title">OpenClaw<\/h2>\s*<p class="intro">The packaged openclaw command forwards to your agent session\. Inside clawctl pwsh, it runs directly\.<\/p>/); for (const [index, expected] of [expectedCommands.slice(0, 4), expectedCommands.slice(4)].entries()) { assert.deepEqual( [...groups[index][2].matchAll(/<h3>([^<]+)<\/h3>/g)].map(match => match[1]), @@ -183,12 +184,12 @@ test("groups the brief cheat sheet with ClawCtl first and packaged OpenClaw seco assert.deepEqual( [...html.matchAll(/<p class="command-description">([^<]+)<\/p>/g)].map(match => match[1]), [ - "Open the agent shell.", - "Check the background Gateway.", - "Requires PowerShell 7 on the Gateway host.", + "Open PowerShell inside the isolated agent. openclaw and node are available there; clawctl manages the session from outside it.", + "Show whether the gateway is running.", + "Stop, then start the gateway, keeping the session and its data. Requires PowerShell 7 on the Gateway host.", "List launcher commands.", "Chat in the terminal.", - "Show dashboard access details.", + "Show the access URL for this dashboard.", "List OpenClaw commands.", ], ); diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index de724ba0..e2320dd4 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -27,6 +27,8 @@ internal sealed class ClawCtlOutputOptions // doctor, gateway, uninstall, and every other OpenClaw command is owned by the // bundled CLI and reached through `openclaw`, which forwards its arguments // without parsing them. +// Keep the curated command references in plugins/gateway-isolation/index.js and +// index.test.js synchronized when exposed commands or their descriptions change. internal static class ClawCtlCommandLine { public const string SetupCommandName = "setup"; From 1b2ea924f7789b9578b59d0dea129b9ec2dc7f1a Mon Sep 17 00:00:00 2001 From: Chaz Gordish <chgord@microsoft.com> Date: Fri, 18 Sep 2026 10:55:00 -0700 Subject: [PATCH 76/76] Group launcher commands under an accessible reference heading Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 ++- plugins/gateway-isolation/index.js | 11 +++++++---- plugins/gateway-isolation/index.test.js | 19 +++++++++++++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8956ea00..085e9b5b 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,8 @@ and neutral status-unavailable text (HTTP 503), never a supported off state. The live route establishes Gateway availability; disconnected-Gateway messaging belongs to the Control UI. There are no isolation controls. -The active page provides a brief, copy-only cheat sheet. Enter these commands +The active page's **Command reference** section provides a brief, copy-only +cheat sheet grouped under **ClawCtl** and **OpenClaw**. Enter these commands in your normal Windows terminal (user session): - **ClawCtl:** `clawctl pwsh` first, then `clawctl gateway-service status`, diff --git a/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js index ad3d1d45..aeaecb4d 100644 --- a/plugins/gateway-isolation/index.js +++ b/plugins/gateway-isolation/index.js @@ -160,7 +160,7 @@ function renderCommandReferences() { const rows = group.commands.map(({ id, title, description, command }) => ` <div class="command-row"> <div> - <h3>${title}</h3> + <h4>${title}</h4> <p class="command-description">${description}</p> </div> <div class="command"> @@ -170,13 +170,14 @@ function renderCommandReferences() { </div>`).join(""); return ` <section aria-labelledby="${group.id}-title"> - <h2 id="${group.id}-title">${group.title}</h2> + <h3 id="${group.id}-title" class="command-group-title">${group.title}</h3> ${group.description ? `<p class="intro">${group.description}</p>` : ""} <div class="status-section">${rows} </div> </section>`; }).join(""); - return `<p class="intro">Run these commands in your normal Windows terminal (user session).</p> + return `<h2 class="command-reference-title">Command reference</h2> + <p class="intro">Run these commands in your normal Windows terminal (user session).</p> ${groups} <p id="copy-status" class="copy-status" role="status" aria-live="polite" aria-atomic="true"></p>`; } @@ -293,7 +294,9 @@ function renderStatusPage(enabled) { .status--ok { color: var(--ok-text); background: var(--ok-bg); } .status--neutral { color: var(--muted); } h2 { margin: 28px 0 8px; color: var(--text-strong); font-size: 17px; } - h3 { margin: 0; color: var(--text-strong); font-size: 14px; font-weight: 600; } + .command-reference-title { font-size: 20px; } + .command-group-title { margin: 20px 0 8px; color: var(--text-strong); font-size: 17px; font-weight: 600; } + .command-row h4 { margin: 0; color: var(--text-strong); font-size: 14px; font-weight: 600; } .command-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr); diff --git a/plugins/gateway-isolation/index.test.js b/plugins/gateway-isolation/index.test.js index f6a0983c..1cd7674f 100644 --- a/plugins/gateway-isolation/index.test.js +++ b/plugins/gateway-isolation/index.test.js @@ -170,14 +170,25 @@ const expectedCommands = [ test("groups the brief cheat sheet with ClawCtl first and packaged OpenClaw second", () => { const html = renderGatewayIsolationPage("enabled"); + assert.deepEqual( + [...html.matchAll(/<h([1-6])(?: [^>]*)?>([^<]+)<\/h\1>/g)].map(match => [Number(match[1]), match[2]]), + [ + [1, "Windows Launcher"], + [2, "Command reference"], + [3, "ClawCtl"], + ...expectedCommands.slice(0, 4).map(([title]) => [4, title]), + [3, "OpenClaw"], + ...expectedCommands.slice(4).map(([title]) => [4, title]), + ], + ); const groups = [...html.matchAll(/<section aria-labelledby="([^"]+)">([\s\S]*?)<\/section>/g)]; assert.deepEqual(groups.map(match => match[1]), ["clawctl-title", "openclaw-title"]); - assert.match(groups[0][2], /<h2 id="clawctl-title">ClawCtl<\/h2>/); - assert.match(html, /<p class="intro">Run these commands in your normal Windows terminal \(user session\)\.<\/p>\s*<section aria-labelledby="clawctl-title">/); - assert.match(groups[1][2], /<h2 id="openclaw-title">OpenClaw<\/h2>\s*<p class="intro">The packaged openclaw command forwards to your agent session\. Inside clawctl pwsh, it runs directly\.<\/p>/); + assert.match(groups[0][2], /<h3 id="clawctl-title" class="command-group-title">ClawCtl<\/h3>/); + assert.match(html, /<h2 class="command-reference-title">Command reference<\/h2>\s*<p class="intro">Run these commands in your normal Windows terminal \(user session\)\.<\/p>\s*<section aria-labelledby="clawctl-title">/); + assert.match(groups[1][2], /<h3 id="openclaw-title" class="command-group-title">OpenClaw<\/h3>\s*<p class="intro">The packaged openclaw command forwards to your agent session\. Inside clawctl pwsh, it runs directly\.<\/p>/); for (const [index, expected] of [expectedCommands.slice(0, 4), expectedCommands.slice(4)].entries()) { assert.deepEqual( - [...groups[index][2].matchAll(/<h3>([^<]+)<\/h3>/g)].map(match => match[1]), + [...groups[index][2].matchAll(/<h4>([^<]+)<\/h4>/g)].map(match => match[1]), expected.map(([title]) => title), ); }