diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1f3addc8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,285 @@ +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: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: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: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: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 + +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:error + +#### 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: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: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:error +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +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 +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:error +csharp_prefer_static_anonymous_function = true:suggestion +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:error + +csharp_using_directive_placement = outside_namespace:error + +#### 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 = 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 = 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 = 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 = 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 = 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 = 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 = error + +#### Analyzer severities #### +# +# 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 = error + +# Style rules cleared by the formatting layer. +dotnet_diagnostic.IDE0305.severity = error +dotnet_diagnostic.IDE0330.severity = error + +# Interop and security. +dotnet_diagnostic.CA5392.severity = error +dotnet_diagnostic.CA1838.severity = error + +# Public contracts and globalization. +dotnet_diagnostic.CA1062.severity = error +dotnet_diagnostic.CA1307.severity = error +dotnet_diagnostic.CA1308.severity = error + +# Asynchronous code. +dotnet_diagnostic.CA2007.severity = error +dotnet_diagnostic.CA1849.severity = error + +# Lifetime and design. +dotnet_diagnostic.CA2000.severity = error +dotnet_diagnostic.CA1031.severity = error +dotnet_diagnostic.CA1515.severity = error +dotnet_diagnostic.CA1859.severity = error + +[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 + +# 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/.github/copilot-instructions.md b/.github/copilot-instructions.md index e98d94f0..872ed3cf 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)) ` @@ -46,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 @@ -53,23 +81,38 @@ 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` 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. +- `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 - 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 `Build-MSIX.ps1` to reject bundled Node.js, build the application - inventory, publish the NativeAOT host, validate package contents, and emit - MSIX metadata. +- 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. 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, + 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 @@ -77,6 +120,23 @@ 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. `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. - 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 @@ -84,21 +144,39 @@ 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. - 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. +- 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. + 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/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..08e9180f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,76 @@ + + +## What Problem This Solves + + + +## User Impact + + + +## Why This Change Was Made + + + +## Evidence + + diff --git a/.github/workflows/gateway-msix.yml b/.github/workflows/gateway-msix.yml index 15d1530d..045ffc3d 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: 0965053fe6b9341776df147a6934b7485c60b5ca + default: 44e9347d3342cd8b5e27fec78df40b8dff0dca34 type: string signing_mode: description: Package signing mode @@ -22,16 +22,73 @@ 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 + pull-requests: read env: - NODE_VERSION: 24.16.0 - PNPM_VERSION: 11.15.1 - OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '0965053fe6b9341776df147a6934b7485c60b5ca' }} + OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '44e9347d3342cd8b5e27fec78df40b8dff0dca34' }} PACKAGING_ROOT: . jobs: + changes: + name: Classify pull request changes + runs-on: ubuntu-latest + outputs: + packaging: ${{ steps.filter.outputs.packaging }} + versioning: ${{ steps.filter.outputs.versioning }} + 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 + 'versioning=false' >> $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 + + $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 @@ -48,66 +105,213 @@ 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 + - name: Static analysis + shell: pwsh + run: > + .\scripts\Test-DotNetQuality.ps1 + -Configuration Release + -SkipRestore + - name: Test run: > dotnet test .\OpenClaw.Gateway.MSIX.slnx --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: > .\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 + + - name: Test MXC packaging inputs + shell: pwsh + run: .\scripts\Test-Get-MxcRuntime.Tests.ps1 + + - name: Test signing workflow configuration + shell: pwsh + run: > + .\scripts\Test-WorkflowSigningConfiguration.ps1 + + - name: Test OpenClaw build identity validation + shell: pwsh + run: > + .\scripts\Test-OpenClawBuildIdentity.Tests.ps1 + - name: Test workflow package version shell: pwsh run: > .\scripts\Test-WorkflowPackageVersion.Tests.ps1 + - name: Test packaging relevance + shell: pwsh + run: > + .\scripts\Test-PackagingRelevance.Tests.ps1 + + - name: Test OpenClaw cache keys + shell: pwsh + 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: > + .\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: > + .\scripts\Test-Build-MSIXBundle.Tests.ps1 + + - name: Test Git hooks + shell: pwsh + 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 + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.packaging == 'true' }} + needs: changes runs-on: ubuntu-latest outputs: - source_sha: ${{ steps.source.outputs.sha }} - package_version: ${{ steps.source.outputs.version }} + source_sha: ${{ steps.resolve.outputs.sha }} + 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 + 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 + 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 + + - 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 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-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 - ref: ${{ env.OPENCLAW_REF }} - path: openclaw-source + ref: ${{ steps.resolve.outputs.sha }} persist-credentials: false fetch-depth: 1 - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Set up upstream Node.js and pnpm + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} + 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 + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} run: pnpm install --frozen-lockfile - name: Build OpenClaw - working-directory: openclaw-source + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} env: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" - run: | - pnpm build - pnpm ui:build + run: pnpm build - name: Pack npm package - id: source - working-directory: openclaw-source + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} run: | set -euo pipefail artifact_dir="${RUNNER_TEMP}/openclaw-package" @@ -121,18 +325,43 @@ jobs: source_sha="$(git rev-parse HEAD)" package_version="$(node -p "require('./package.json').version")" - echo "sha=${source_sha}" >> "${GITHUB_OUTPUT}" - echo "version=${package_version}" >> "${GITHUB_OUTPUT}" + node_version="$(node -p 'process.versions.node')" + 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 + "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') }} + 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: @@ -141,16 +370,21 @@ jobs: if-no-files-found: error retention-days: 7 - build-payload: - name: Build ${{ matrix.architecture }} gateway payload - needs: build-package - runs-on: windows-latest + build-msix: + name: Build unsigned ${{ matrix.architecture }} Gateway MSIX + needs: + - changes + - test-host + - build-package + 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 @@ -160,7 +394,8 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: ${{ env.NODE_VERSION }} + node-version: ${{ needs.build-package.outputs.node_version }} + architecture: ${{ matrix.architecture }} - name: Download intermediate package uses: actions/download-artifact@v8 @@ -168,13 +403,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 @@ -182,25 +449,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 @@ -209,12 +458,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: | @@ -224,13 +467,35 @@ 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 + 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' -or + $env:VERSIONING_CHANGE -eq 'true' + ) { + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) + $versionParameters.ReleaseVersion = $identity.PackageVersion + } $packageVersion = .\scripts\Get-WorkflowPackageVersion.ps1 ` - -RunNumber '${{ github.run_number }}' ` - -RunAttempt '${{ github.run_attempt }}' + @versionParameters .\scripts\Build-MSIX.ps1 ` -PayloadDirectory '${{ runner.temp }}\openclaw-payload' ` @@ -298,6 +563,148 @@ jobs: if-no-files-found: error retention-days: 7 + build-msix-bundle: + name: Build unsigned multi-architecture Gateway MSIX bundle + needs: + - changes + - 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' }} + VERSIONING_CHANGE: ${{ needs.changes.outputs.versioning }} + run: | + $versionParameters = @{ + RunNumber = '${{ github.run_number }}' + RunAttempt = '${{ github.run_attempt }}' + } + if ( + $env:SIGNING_MODE -eq 'official' -or + $env:VERSIONING_CHANGE -eq 'true' + ) { + $policy = Get-Content -LiteralPath .\release-policy.json -Raw | + ConvertFrom-Json + $identity = .\scripts\Get-MSIXReleaseIdentity.ps1 ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) + $versionParameters.ReleaseVersion = $identity.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 + + 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' }} @@ -314,8 +721,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 @@ -337,6 +750,30 @@ 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 + $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 $identity.PackageVersion + + "package_version=$packageVersion" >> $env:GITHUB_OUTPUT + "release_tag=$($identity.ReleaseTag)" >> $env:GITHUB_OUTPUT + "release_version=$($identity.ReleaseVersion)" >> $env:GITHUB_OUTPUT + - name: Enforce official signing policy shell: pwsh env: @@ -346,6 +783,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 @@ -354,6 +792,7 @@ jobs: if: ${{ needs.authorize-signing.result == 'success' }} needs: - build-msix + - build-msix-bundle - authorize-signing runs-on: windows-latest environment: release-signing @@ -374,14 +813,20 @@ 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: - 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 + - name: Sign standalone MSIX packages uses: azure/artifact-signing-action@v2 with: endpoint: https://eus.codesigning.azure.net/ @@ -389,11 +834,23 @@ 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 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: | @@ -427,6 +884,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: @@ -442,3 +917,106 @@ 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. + + ci-gate: + name: Gateway MSIX CI + if: ${{ always() }} + needs: + - changes + - test-host + - build-package + - build-msix + - test-sign-msix + - build-msix-bundle + - test-msix-upgrades + - 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/.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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..e28271a3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,249 @@ +# 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 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: + +```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-NodeRuntimeInputs.Tests.ps1 +.\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 +``` + +The Node.js input suite requires Node.js and npm. It builds a dependency-free +local fixture; it does not download or build OpenClaw. + +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 +``` + +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 +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 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 +`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. + +## 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. + 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. The + System.CommandLine tree covers `clawctl` only; the `openclaw` entrypoint must + keep forwarding its argument vector without parsing it. +- 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 + 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. +- 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. + +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. + +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..079e2915 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,4 +4,50 @@ $(MSBuildProjectDirectory)\obj\packaging\ + + + true + latest + All + true + true + + + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 + + + true + $(NoWarn);CS1591 + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 05461f42..ce90b18a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,8 +9,10 @@ + + - + \ No newline at end of file diff --git a/OpenClaw.Gateway.MSIX.slnx b/OpenClaw.Gateway.MSIX.slnx index e22ddf63..bdeeeca4 100644 --- a/OpenClaw.Gateway.MSIX.slnx +++ b/OpenClaw.Gateway.MSIX.slnx @@ -1,8 +1,11 @@ + + + diff --git a/README.md b/README.md index f5d4df6d..085e9b5b 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,12 @@ 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). - -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 archive matching the upstream build's runtime version + and the package architecture. The package is independent from the [OpenClaw Windows Node and Companion](https://github.com/openclaw/openclaw-windows-node) @@ -16,11 +15,23 @@ 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 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` @@ -29,9 +40,17 @@ 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. +`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. +The runtime directory is prepended +to the child's `PATH` so Node.js, npm, and npx subprocesses use the bundled +tools without changing the user's environment. The expanded OpenClaw application is installed read-only inside the MSIX. After resolving Node.js, the launcher confirms that packaged @@ -41,52 +60,97 @@ 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 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 +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. ### `clawctl` -`clawctl` owns the package readiness check: +`clawctl` owns setup and the isolated-session operations: | Command | Behavior | |---|---| -| `clawctl setup` | Verify compatible Node.js is on `PATH` and confirm packaged `app\openclaw.mjs` exists. | +| `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, 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. | +| `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 +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. + +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 +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. -Bare `clawctl` 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`. -`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. +`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. 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, 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 -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`: +Prepare the bundled runtime once, then use `openclaw`: ```powershell -winget install --id OpenJS.NodeJS.LTS --exact --source winget 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 @@ -100,7 +164,29 @@ 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 discovers the packaging-owned Windows Launcher plugin in its +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 +`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. + +Non-official workflows cache the packed OpenClaw tarball by its resolved +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. @@ -108,12 +194,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 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 -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 @@ -124,13 +213,139 @@ 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. `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. It validates the Gateway and Control UI +build identities on the installed tree, including reused staged installs, then +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, 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 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'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`, + `clawctl gateway-service stop && clawctl gateway-service start`, and + `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 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 +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 +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. + +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 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 +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. `-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: @@ -141,7 +356,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. @@ -149,37 +365,141 @@ 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 +`-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. 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 +`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. + +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 +`.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 +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 | |---|---| | 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-v-win-` | | 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. ## 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 -`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. +`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`. 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/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/docs/mxc-compatibility-evidence.md b/docs/mxc-compatibility-evidence.md new file mode 100644 index 00000000..398d83e8 --- /dev/null +++ b/docs/mxc-compatibility-evidence.md @@ -0,0 +1,144 @@ +# 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` 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. + +## 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. 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 +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. 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 + 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. diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100644 index 00000000..c2656fe0 --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,23 @@ +#!/bin/sh +# openclaw-managed-hook: pre-push +# +# Installed by scripts/Install-GitHooks.ps1 and removed by the same script with +# -Remove. The marker comment on the second line is how the installer +# recognises a hook it owns, so do not delete it. +# +# This hook runs the same command as continuous integration. It is a latency +# shortcut, not a policy boundary: it is local to one clone, it is opt in, and +# `git push --no-verify` skips it. Required CI checks remain authoritative. + +set -e + +if ! command -v pwsh >/dev/null 2>&1; then + echo "pre-push: PowerShell 7 (pwsh) was not found on PATH." >&2 + echo "pre-push: Install it, or push with --no-verify and rely on CI." >&2 + exit 1 +fi + +repository_root=$(git rev-parse --show-toplevel) + +echo "pre-push: running scripts/Test-DotNetQuality.ps1" +exec pwsh -NoProfile -File "$repository_root/scripts/Test-DotNetQuality.ps1" 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/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js new file mode 100644 index 00000000..aeaecb4d --- /dev/null +++ b/plugins/gateway-isolation/index.js @@ -0,0 +1,416 @@ +const ISOLATION_ENVIRONMENT_VARIABLE = "CLAWCTL_GATEWAY_ISOLATION"; +const STATUS_PATH = "/plugins/gateway-isolation/status"; +const THEME_MESSAGE_TYPE = "openclaw:widget-theme"; +const COMMAND_GROUPS = [ + { + id: "clawctl", + title: "ClawCtl", + commands: [ + { + id: "agent-shell", + title: "Agent session PowerShell", + 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: "Show whether the gateway is running.", + command: "clawctl gateway-service status", + }, + { + id: "gateway-restart", + title: "Restart Gateway", + 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", + }, + { + id: "launcher-help", + title: "Launcher help", + description: "List launcher commands.", + command: "clawctl --help", + }, + ], + }, + { + id: "openclaw", + title: "OpenClaw", + description: "The packaged openclaw command forwards to your agent session. Inside clawctl pwsh, it runs directly.", + commands: [ + { + id: "terminal", + title: "Gateway chat TUI", + description: "Chat in the terminal.", + command: "openclaw tui", + }, + { + id: "dashboard", + title: "Dashboard access", + description: "Show the access URL for this dashboard.", + command: "openclaw dashboard --no-open", + }, + { + id: "openclaw-help", + title: "OpenClaw help", + description: "List OpenClaw commands.", + command: "openclaw --help", + }, + ], + }, +]; +const THEME_BRIDGE_SCRIPT = ``; +const COPY_COMMAND_SCRIPT = ``; + +function renderCommandReferences() { + const groups = COMMAND_GROUPS.map((group) => { + const rows = group.commands.map(({ id, title, description, command }) => ` +
+
+

${title}

+

${description}

+
+
+ ${command.replaceAll("&", "&")} + +
+
`).join(""); + return ` +
+

${group.title}

+ ${group.description ? `

${group.description}

` : ""} +
${rows} +
+
`; + }).join(""); + return `

Command reference

+

Run these commands in your normal Windows terminal (user session).

+ ${groups} +

`; +} + +export function readGatewayIsolationMode(env) { + const value = env[ISOLATION_ENVIRONMENT_VARIABLE]; + return value === "enabled" ? value : null; +} + +export function renderGatewayIsolationPage(mode) { + if (mode !== "enabled") { + throw new TypeError("Gateway isolation mode must be enabled."); + } + + 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 ` + + + + + Windows Launcher + + + +
+

Windows Launcher

+

${description}

+
+
+
Gateway Isolation
+
${enabled ? "Active" : "Invalid"}
+
+
+ ${enabled ? renderCommandReferences() : ""} +
+ ${THEME_BRIDGE_SCRIPT} + ${enabled ? COPY_COMMAND_SCRIPT : ""} + +`; +} + +function renderGatewayIsolationUnavailablePage() { + return renderStatusPage(false); +} + +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: "Windows Launcher", + description: "Reports the Windows launch mode selected for the running Gateway.", + register(api) { + api.session.controls.registerControlUiDescriptor({ + surface: "tab", + id: "gateway-isolation", + label: "Windows Launcher", + 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, + renderGatewayIsolationUnavailablePage(), + ); + 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..1cd7674f --- /dev/null +++ b/plugins/gateway-isolation/index.test.js @@ -0,0 +1,511 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { + createGatewayIsolationPlugin, + readGatewayIsolationMode, + 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 = []; + 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; +} + +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.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, + ); +} + +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"); + for (const value of invalidModes) { + assert.equal(readGatewayIsolationMode({ CLAWCTL_GATEWAY_ISOLATION: value }), null); + } +}); + +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 one informational Gateway Isolation row with a single active badge", () => { + const html = renderGatewayIsolationPage("enabled"); + assertInformationalPage(html); + 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 = [ + ["Agent session PowerShell", "clawctl pwsh"], + ["Gateway status", "clawctl gateway-service status"], + ["Restart Gateway", "clawctl gateway-service stop && clawctl gateway-service start"], + ["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"); + 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], /<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(/<h4>([^<]+)<\/h4>/g)].map(match => match[1]), + expected.map(([title]) => title), + ); + } + assert.deepEqual( + [...html.matchAll(/<p class="command-description">([^<]+)<\/p>/g)].map(match => match[1]), + [ + "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 the access URL for this dashboard.", + "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)]; + 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, /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/); +}); + +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" }); + 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[restartIndex][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 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 Agent session PowerShell command."); + assert.deepEqual(copy.calls, [["modern", "clawctl pwsh"]]); +}); + +test("applies recognized host theme tokens from the parent frame", () => { + const bridge = runThemeBridge(renderGatewayIsolationPage("enabled")); + bridge.listener({ + source: bridge.parent, + data: { + type: "openclaw:widget-theme", + mode: "dark", + tokens: { + surface: "#101010", + card: "#202020", + elevated: "#303030", + text: "#fefefe", + muted: "#aaaaaa", + border: "#404040", + "border-strong": "#505050", + accent: "#55aaff", + ok: "#44cc77", + warn: "#e0a020", + radius: "14px", + "radius-full": "9999px", + "font-body": "Georgia, serif", + "font-mono": "Consolas, monospace", + "text-strong": "#ffffff", + }, + }, + }); + + assert.equal(bridge.root.dataset.themeMode, "dark"); + 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("--text"), "#fefefe"); + assert.equal(bridge.properties.get("--text-strong"), "#ffffff"); + 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.match(bridge.properties.get("--ok-bg"), /#44cc77 18%, #202020/); + 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); + } + + 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("enabled")); + for (const event of [ + { + source: {}, + data: { type: "openclaw:widget-theme", mode: "light", tokens: { surface: "#fff" } }, + }, + { + source: bridge.parent, + data: { type: "other", mode: "light", tokens: { surface: "#fff" } }, + }, + { + source: bridge.parent, + data: { type: "openclaw:widget-theme", mode: "sepia", tokens: { surface: "#fff" } }, + }, + { + source: bridge.parent, + data: { type: "openclaw:widget-theme", mode: "light", tokens: null }, + }, + { + source: bridge.parent, + data: { type: "openclaw:widget-theme", mode: "light", tokens: [] }, + }, + ]) { + bridge.listener(event); + } + 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", () => { + const { descriptors, routes } = registerPlugin("enabled"); + assert.deepEqual(descriptors[0], { + surface: "tab", + id: "gateway-isolation", + label: "Windows Launcher", + 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); + assertHeaders(response); + assertInformationalPage(response.body); + assert.match(response.body, />Active</); +}); + +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; + const plugin = createGatewayIsolationPlugin({ + get CLAWCTL_GATEWAY_ISOLATION() { + reads++; + return value; + }, + }); + assert.equal(reads, 1); + value = initial === "enabled" ? "disabled" : "enabled"; + const routes = []; + plugin.register({ + session: { controls: { registerControlUiDescriptor() {} } }, + registerHttpRoute(route) { + routes.push(route); + }, + }); + + const first = invokeRoute(routes[0]); + 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); + } + assert.equal(reads, 1); + }); +} + +for (const mode of invalidModes) { + const label = JSON.stringify(mode) ?? "missing"; + 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); + 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>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|<section|<h2/i, + ); + const bridge = runThemeBridge(response.body); + bridge.listener({ + source: bridge.parent, + data: { + type: "openclaw:widget-theme", + mode: "dark", + tokens: { surface: "#101010", text: "#fefefe", muted: "#aaaaaa" }, + }, + }); + assert.equal(bridge.root.style.colorScheme, "dark"); + assert.equal(bridge.properties.get("--bg"), "#101010"); + assert.equal(bridge.properties.get("--text"), "#fefefe"); + assert.equal(bridge.properties.get("--muted"), "#aaaaaa"); + assertHeaders(response); + }); + test(`renderer rejects ${label}`, () => { + assert.throws(() => renderGatewayIsolationPage(mode), TypeError); + }); +} diff --git a/plugins/gateway-isolation/openclaw.plugin.json b/plugins/gateway-isolation/openclaw.plugin.json new file mode 100644 index 00000000..f9bdf15d --- /dev/null +++ b/plugins/gateway-isolation/openclaw.plugin.json @@ -0,0 +1,14 @@ +{ + "id": "gateway-isolation", + "name": "Windows Launcher", + "description": "Reports the Windows launch mode selected for the running Gateway.", + "enabledByDefault": false, + "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/release-policy.json b/release-policy.json index daef22a3..0fe821f9 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,6 +1,9 @@ { "repository": "https://github.com/openclaw/openclaw", - "releaseTag": "v2026.8.2", - "approvedCommit": "0965053fe6b9341776df147a6934b7485c60b5ca", + "gatewayTag": "v2026.9.4", + "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/Build-LocalMSIX.ps1 b/scripts/Build-LocalMSIX.ps1 index 7dc0a76c..2e8c89e8 100644 --- a/scripts/Build-LocalMSIX.ps1 +++ b/scripts/Build-LocalMSIX.ps1 @@ -7,6 +7,8 @@ param( [long]$PayloadRunId, + [string]$NodeArchivePath, + [string]$PackageVersion, [string]$OutputDirectory @@ -55,6 +57,10 @@ if (Test-Path -LiteralPath $OutputDirectory) { } New-Item -Path $workDirectory -ItemType Directory -Force | Out-Null +if ($NodeArchivePath) { + $NodeArchivePath = (Resolve-Path -LiteralPath $NodeArchivePath).Path +} + if ($PayloadDirectory) { $resolvedPayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path } @@ -121,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 @@ -135,6 +150,7 @@ try { Write-Host "Building unsigned MSIX version $PackageVersion." & .\scripts\Build-MSIX.ps1 ` -PayloadDirectory $resolvedPayloadDirectory ` + -NodeArchivePath $NodeArchivePath ` -Architecture $Architecture ` -PackageVersion $PackageVersion ` -SourceCommit $sourceCommit ` diff --git a/scripts/Build-MSIX.ps1 b/scripts/Build-MSIX.ps1 index d690f864..82c823a7 100644 --- a/scripts/Build-MSIX.ps1 +++ b/scripts/Build-MSIX.ps1 @@ -3,6 +3,8 @@ param( [Parameter(Mandatory)] [string]$PayloadDirectory, + [string]$NodeArchivePath, + [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string]$Architecture, @@ -129,6 +131,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 @@ -149,7 +181,6 @@ function Add-VswhereToPath { } Test-PackageVersion -Add-VswhereToPath $PayloadDirectory = (Resolve-Path -LiteralPath $PayloadDirectory).Path $payloadApplication = Join-Path $PayloadDirectory 'app' @@ -166,6 +197,9 @@ 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 + [string]::IsNullOrWhiteSpace([string]$payloadInfo.packageVersion) -or $payloadInfo.requestedRef -isnot [string] -or [string]::IsNullOrWhiteSpace($payloadInfo.requestedRef) -or $payloadInfo.resolvedCommit -notmatch '^[0-9a-fA-F]{40}$' @@ -173,6 +207,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)) { @@ -184,6 +230,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 +247,22 @@ if ( -Recurse } +New-Item -Path $runtimeTargetDirectory -ItemType Directory -Force | Out-Null +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() + $payloadSymbols = @( # MSBuild's own AppxPackagePayload step strips .pdb files when it later # packages content\openclaw\app; remove them here too so the inventory @@ -246,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 } @@ -263,6 +363,52 @@ New-Item ` Out-Null 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 ` @@ -277,6 +423,7 @@ try { -p:PublishAot=true ` -p:SelfContained=true ` -p:IncludePackagingContent=true ` + "-p:NodeRuntimeArchiveFileName=$expectedNodeArchiveName" ` -p:GenerateAppxPackageOnBuild=true ` "-p:AssemblyVersion=$PackageVersion" ` "-p:FileVersion=$PackageVersion" ` @@ -328,6 +475,28 @@ try { ).Hash.ToLowerInvariant() } ) + $expectedPackageFiles.Add( + "runtime/$expectedNodeArchiveName", + [pscustomobject]@{ + Hash = $nodeArchiveHash + } + ) + foreach ($mxcFile in $mxcRuntimeFiles) { + $expectedPackageFiles.Add( + "mxc/$Architecture/$($mxcFile.path)", + [pscustomobject]@{ + Hash = $mxcFile.sha256 + } + ) + } + 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 ) @@ -391,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')) { @@ -417,17 +587,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 @( @@ -461,8 +634,15 @@ try { payloadRepository = $payloadInfo.repository payloadRequestedRef = $payloadInfo.requestedRef payloadResolvedCommit = $payloadInfo.resolvedCommit.ToLowerInvariant() + payloadPackageVersion = [string]$payloadInfo.packageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $nodeVersion + nodeRuntimeArchive = $expectedNodeArchiveName + nodeRuntimeSha256 = $nodeArchiveHash + mxcRuntimeVersion = [string]$mxcProvenance.version + mxcRuntimeFiles = $mxcRuntimeFiles + sessionHostFiles = $sessionHostFiles architecture = $Architecture archive = $msixName sha256 = $msixHash diff --git a/scripts/Build-MSIXBundle.ps1 b/scripts/Build-MSIXBundle.ps1 new file mode 100644 index 00000000..96d06f76 --- /dev/null +++ b/scripts/Build-MSIXBundle.ps1 @@ -0,0 +1,133 @@ +[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 + $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 ` + @bundleVersionArguments ` + /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/Build-Payload.ps1 b/scripts/Build-Payload.ps1 index a823b9da..f19b7dff 100644 --- a/scripts/Build-Payload.ps1 +++ b/scripts/Build-Payload.ps1 @@ -8,10 +8,13 @@ param( [string]$Architecture, [Parameter(Mandatory)] - [string]$OutputDirectory + [string]$OutputDirectory, + + [switch]$ReuseStagedInstall ) $ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent $package = @(Get-ChildItem -Path $PackageDirectory -Filter '*.tgz' -File) if ($package.Count -ne 1) { @@ -23,35 +26,106 @@ if (-not (Test-Path $sourceMetadataPath -PathType Leaf)) { throw "Missing source metadata: $sourceMetadataPath" } -$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 +$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)'." + ) +} +$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.' +} -$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." +$packageHash = ( + Get-FileHash -LiteralPath $package[0].FullName -Algorithm SHA256 +).Hash.ToLowerInvariant() +$stagingDirectory = Join-Path $env:RUNNER_TEMP "openclaw-stage-$Architecture" +$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')) { @@ -61,6 +135,214 @@ foreach ($requiredPath in @('package.json', 'openclaw.mjs', 'dist')) { } } +& (Join-Path $PSScriptRoot 'Test-OpenClawBuildIdentity.ps1') ` + -OpenClawDirectory $installedPackage + +$applicationDirectory = Join-Path $OutputDirectory 'app' +if (Test-Path -LiteralPath $applicationDirectory) { + Remove-Item -LiteralPath $applicationDirectory -Recurse -Force +} +Copy-Item ` + -LiteralPath $installedPackage ` + -Destination $applicationDirectory ` + -Recurse +$installedPackage = $applicationDirectory + +$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 $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 must be packaged but disabled by default.' +} + +$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 +$previousConfigPath = $env:OPENCLAW_CONFIG_PATH +$previousIsolationMode = $env:CLAWCTL_GATEWAY_ISOLATION +$previousCacheHome = $env:XDG_CACHE_HOME +$validationStateDirectory = Join-Path ` + $stagingDirectory ` + 'gateway-isolation-validation' +$validationConfigPath = Join-Path ` + $validationStateDirectory ` + 'openclaw.json' +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 = ( + & 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.' + ) + } + + & 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 + $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 ( + $explicitlyEnabledEntryNames.Count -ne 1 -or + $explicitlyEnabledEntryNames[0] -ne 'gateway-isolation' + ) { + throw ( + 'The isolated validation configuration must explicitly enable ' + + 'only the Gateway isolation plugin.' + ) + } + + $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." + ) + } + + & node .\openclaw.mjs --version + if ($LASTEXITCODE -ne 0) { + throw "OpenClaw payload smoke test failed with 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 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 + $env:XDG_CACHE_HOME = $previousCacheHome + if (Test-Path -LiteralPath $validationStateDirectory) { + Remove-Item ` + -LiteralPath $validationStateDirectory ` + -Recurse ` + -Force + } +} + $bundledNodeFiles = @( Get-ChildItem -LiteralPath $installedPackage -File -Recurse | Where-Object { @@ -83,38 +365,15 @@ 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 - } -} - -$applicationDirectory = Join-Path $OutputDirectory 'app' -if (Test-Path -LiteralPath $applicationDirectory) { - Remove-Item -LiteralPath $applicationDirectory -Recurse -Force -} -Copy-Item ` - -LiteralPath $installedPackage ` - -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/Deploy-LocalPackage.ps1 b/scripts/Deploy-LocalPackage.ps1 new file mode 100644 index 00000000..08031a79 --- /dev/null +++ b/scripts/Deploy-LocalPackage.ps1 @@ -0,0 +1,108 @@ +<# +.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 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 +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/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(?<year>\d{4})\.(?<month>\d{1,2})\.(?<patch>\d{1,5})(?:-(?<correction>\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/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\<architecture>. + +.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/Get-OpenClawCacheKey.ps1 b/scripts/Get-OpenClawCacheKey.ps1 new file mode 100644 index 00000000..52df1053 --- /dev/null +++ b/scripts/Get-OpenClawCacheKey.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('package', 'payload')] + [string]$Layer, + + [Parameter(Mandatory)] + [string]$Commit, + + [ValidateSet('x64', 'arm64')] + [string]$Architecture, + + [string]$NodeVersion, + + [string]$PackageSha256, + + [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." +} +$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" +} + +$scriptHash = ( + Get-FileHash -LiteralPath $PayloadScriptPath -Algorithm SHA256 +).Hash.ToLowerInvariant() + +@( + "openclaw-payload-v$FormatVersion" + $Architecture + $normalizedCommit + "node-$NodeVersion" + $normalizedPackageSha256 + $scriptHash +) -join '-' 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/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(), + '^(?<major>\d+)\.(?<minor>\d+)\.(?<build>\d+)\.(?<revision>\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/Install-GitHooks.ps1 b/scripts/Install-GitHooks.ps1 new file mode 100644 index 00000000..e441c2fc --- /dev/null +++ b/scripts/Install-GitHooks.ps1 @@ -0,0 +1,143 @@ +<# +.SYNOPSIS + Installs or removes this repository's optional pre-push hook. + +.DESCRIPTION + The hook runs scripts\Test-DotNetQuality.ps1 before a push so the same + checks continuous integration runs are reported locally first. It is + deliberately opt in. + + Installation writes hooks\pre-push into the hooks directory Git actually + consults for this working tree, which Git reports itself. A clone keeps one + hooks directory, so installing or removing from a linked worktree affects + every worktree of that clone. Other clones are unaffected. + + Nothing outside this clone changes: global Git configuration and + core.hooksPath are never modified. If core.hooksPath is already set, the + script stops rather than writing a hook that Git would ignore. + + A managed hook is identified by the marker comment carried in + hooks\pre-push. Installing over a managed hook refreshes it, installing + over an unmanaged hook fails, and removal only deletes a hook that carries + the marker. Both operations are idempotent. + + The hook is a convenience. It is local, it is bypassable with + `git push --no-verify`, and required CI checks remain authoritative. + +.PARAMETER Remove + Remove the managed hook instead of installing it. + +.PARAMETER RepositoryRoot + The working tree to operate on. Defaults to this repository. This exists so + the hook tests can run against an isolated temporary repository. + +.EXAMPLE + .\scripts\Install-GitHooks.ps1 + +.EXAMPLE + .\scripts\Install-GitHooks.ps1 -Remove +#> +[CmdletBinding()] +param( + [switch]$Remove, + + [string]$RepositoryRoot = (Split-Path $PSScriptRoot -Parent) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$managedHookMarker = 'openclaw-managed-hook' +$hookName = 'pre-push' + +if (-not (Test-Path -LiteralPath $RepositoryRoot -PathType Container)) { + throw "Repository root '$RepositoryRoot' does not exist." +} + +$sourceHook = Join-Path $RepositoryRoot "hooks\$hookName" +if (-not (Test-Path -LiteralPath $sourceHook -PathType Leaf)) { + throw "Could not find the tracked hook at '$sourceHook'." +} + +& git -C $RepositoryRoot rev-parse --is-inside-work-tree 2>$null | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "'$RepositoryRoot' is not a Git working tree." +} + +$configuredHooksPath = & git -C $RepositoryRoot config --get core.hooksPath 2>$null +if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($configuredHooksPath)) { + throw ( + "core.hooksPath is set to '$configuredHooksPath', so Git would ignore " + + 'a hook installed in the default hooks directory. This script does ' + + 'not change Git configuration. Clear core.hooksPath first, or install ' + + 'the hook into that directory yourself.' + ) +} + +# Ask Git for the hooks directory rather than composing one. A linked +# worktree's own Git directory is not where Git looks for hooks; the clone's +# common hooks directory is, and only Git knows which that is. +$reportedHooksDirectory = + & git -C $RepositoryRoot rev-parse --path-format=absolute --git-path hooks 2>$null +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($reportedHooksDirectory)) { + throw "Git did not report a hooks directory for '$RepositoryRoot'." +} + +# Git reports forward slashes even on Windows. +$hooksDirectory = [System.IO.Path]::GetFullPath($reportedHooksDirectory) +$targetHook = Join-Path $hooksDirectory $hookName + +function Test-ManagedHook { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $content = [System.IO.File]::ReadAllText($Path) + return $content.Contains($managedHookMarker, [System.StringComparison]::Ordinal) +} + +if ($Remove) { + if (-not (Test-Path -LiteralPath $targetHook -PathType Leaf)) { + Write-Host "No $hookName hook is installed; nothing to remove." + return + } + + if (-not (Test-ManagedHook -Path $targetHook)) { + throw ( + "The existing $hookName hook was not installed by this repository " + + "and will not be removed. Inspect '$targetHook' and delete it " + + 'yourself if you no longer want it.' + ) + } + + Remove-Item -LiteralPath $targetHook -Force + Write-Host "Removed the managed $hookName hook from '$targetHook'." + Write-Host 'Every worktree of this clone stops running it.' + return +} + +if ( + (Test-Path -LiteralPath $targetHook -PathType Leaf) -and + -not (Test-ManagedHook -Path $targetHook) +) { + throw ( + "A $hookName hook already exists at '$targetHook' and was not " + + 'installed by this repository. It will not be overwritten. Move or ' + + 'delete it first if you want the managed hook.' + ) +} + +if (-not (Test-Path -LiteralPath $hooksDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $hooksDirectory -Force | Out-Null +} + +# Written rather than copied so the hook always lands with LF endings, which +# the shell that runs Git hooks requires. +$hookContent = [System.IO.File]::ReadAllText($sourceHook) -replace "`r`n", "`n" +[System.IO.File]::WriteAllText($targetHook, $hookContent) + +Write-Host "Installed the $hookName hook at '$targetHook'." +Write-Host 'It runs scripts\Test-DotNetQuality.ps1 before every push.' +Write-Host 'Every worktree of this clone shares that hook.' +Write-Host 'Bypass a single push with `git push --no-verify`.' diff --git a/scripts/LocalPackage.psm1 b/scripts/LocalPackage.psm1 new file mode 100644 index 00000000..b2eb4c47 --- /dev/null +++ b/scripts/LocalPackage.psm1 @@ -0,0 +1,964 @@ +Set-StrictMode -Version Latest +$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) + + 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]$SessionHostExecutable, + [string]$MxcRuntimeDirectory, + [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 + } + } + + $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 +} + +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, + [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", + "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 + } + } + 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 + } + 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" ` + --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 + PackageFamilyName = $package.PackageFamilyName + 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 = { + param($packageFamilyName) + Invoke-LocalPackageControlCommand -PackageFamilyName $packageFamilyName -Arguments 'setup' + 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 + } + $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 | + ForEach-Object { "$($_.Name):$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" } + ) + $fingerprint = Get-LocalPackageFingerprint (@( + $Architecture + $payload.Directory + [IO.Path]::GetFileName($runtimeArchive) + $hostInfo.Length.ToString() + $hostHash + $sessionHostInfo.Length.ToString() + $sessionHostHash + (Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash + ) + $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 + $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)) ` + -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) + 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 -SessionHostExecutable $sessionHostExecutable ` + -MxcRuntimeDirectory $mxcRuntimeDirectory -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 $registered.PackageFamilyName + } | 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/Sign-TestMSIX.ps1 b/scripts/Sign-TestMSIX.ps1 index 6e0d1162..5f3ac672 100644 --- a/scripts/Sign-TestMSIX.ps1 +++ b/scripts/Sign-TestMSIX.ps1 @@ -29,6 +29,26 @@ 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 + } + } +) +$bundleDirectory = Join-Path $resolvedArtifactsDirectory 'bundle' +$hasBundleDirectory = Test-Path -LiteralPath $bundleDirectory -PathType Container +if ($architectureDirectories.Count -eq 0 -and -not $hasBundleDirectory) { + throw ( + "No signable package directories were found under " + + "'$resolvedArtifactsDirectory'. Expected at least one of: " + + ($architectures -join ', ') + ', bundle.' + ) +} + $signtool = Get-ChildItem ` -LiteralPath "${env:ProgramFiles(x86)}\Windows Kits\10\bin" ` -Filter 'signtool.exe' ` @@ -75,7 +95,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 @@ -158,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-Build-MSIXBundle.Tests.ps1 b/scripts/Test-Build-MSIXBundle.Tests.ps1 new file mode 100644 index 00000000..70c390ee --- /dev/null +++ b/scripts/Test-Build-MSIXBundle.Tests.ps1 @@ -0,0 +1,125 @@ +[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" + } + + $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 ` + -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-Deploy-LocalPackage.Tests.ps1 b/scripts/Test-Deploy-LocalPackage.Tests.ps1 new file mode 100644 index 00000000..fd99515c --- /dev/null +++ b/scripts/Test-Deploy-LocalPackage.Tests.ps1 @@ -0,0 +1,442 @@ +[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 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' + 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'), @' +<?xml version="1.0" encoding="utf-8"?> +<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"> + <Identity Name="OpenClaw.Gateway" Publisher="CN=Fixture" Version="0.0.0.0" /> +</Package> +'@) + + $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 + MxcStages = 0 + Publishes = 0 + Registrations = 0 + Setups = 0 + SetupPackageFamilyNames = @() + 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 } + 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 + $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)") + } + 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" + PackageFamilyName = 'OpenClaw.Gateway_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 = { + param($packageFamilyName) + $state.Setups++ + $state.SetupPackageFamilyNames += $packageFamilyName + 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.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 + 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' + PackageFamilyName = 'OpenClaw.Gateway_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' + PackageFamilyName = 'OpenClaw.Gateway_pkg' + 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' + PackageFamilyName = 'OpenClaw.Gateway_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" + PackageFamilyName = 'OpenClaw.Gateway_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.' + + Test-ProductionMxcStageAdapterIgnoresStaleNativeExitState + + Write-Host 'Local package deployment scenarios passed.' +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/Test-DotNetQuality.ps1 b/scripts/Test-DotNetQuality.ps1 new file mode 100644 index 00000000..00475d37 --- /dev/null +++ b/scripts/Test-DotNetQuality.ps1 @@ -0,0 +1,99 @@ +<# +.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, 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, and + TreatWarningsAsErrors in Directory.Build.props closes the gate, so any + warning this repository has not already classified fails the build. + +.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.' + +# 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/scripts/Test-GatewayIsolationPlugin.Tests.ps1 b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 new file mode 100644 index 00000000..4d077700 --- /dev/null +++ b/scripts/Test-GatewayIsolationPlugin.Tests.ps1 @@ -0,0 +1,286 @@ +[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' +$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( + [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 $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 explicit enablement.' + } + + $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\control-ui\assets' + ), ( + Join-Path $packageSource 'dist\extensions\fixture' + ), $packageDirectory ` + -ItemType Directory ` + -Force | + Out-Null + Set-Content ` + -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"); +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 + ? 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 + }, + anthropic: { + config: { + sessionCatalog: { + enabled: false + } + } + }, + codex: { + config: { + sessionCatalog: { + enabled: false + } + } + } + } + } + }), + ); + 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"); +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", + origin: "bundled", + enabled, + activated: enabled && runtime, + status: enabled && runtime ? "loaded" : "disabled", + imported: enabled && runtime, + httpRoutes: enabled && runtime ? 1 : 0 + }, + httpRouteCount: enabled && runtime ? 1 : 0, + gatewayMethods: [], + tools: [], + services: [], + diagnostics: [] +})); +'@ ` + -Encoding utf8 + Set-Content ` + -LiteralPath (Join-Path $packageSource 'dist\index.js') ` + -Value 'export {};' ` + -Encoding utf8 + Set-Content ` + -LiteralPath (Join-Path $packageSource 'dist\build-info.json') ` + -Value '{"buildId":"fixture-build"}' ` + -Encoding utf8 + Set-Content ` + -LiteralPath (Join-Path $packageSource 'dist\control-ui\sw.js') ` + -Value 'const EMBEDDED_CACHE_VERSION = "fixture-build";' ` + -Encoding utf8 + Set-Content ` + -LiteralPath ( + Join-Path $packageSource 'dist\control-ui\assets\app.js' + ) ` + -Value 'const buildId = "fixture-build";' ` + -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' + nodeVersion = (& node -p 'process.versions.node' | Out-String).Trim() + } | + ConvertTo-Json | + Set-Content ` + -LiteralPath (Join-Path $packageDirectory 'source.json') ` + -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 $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 ` + $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.' + } + $stagedPlugin = Join-Path ` + $testRoot ` + "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.' + } + + $packagedManifest = Get-Content ` + -LiteralPath (Join-Path $packagedPlugin 'openclaw.plugin.json') ` + -Raw | + ConvertFrom-Json + if ( + $packagedManifest.id -ne 'gateway-isolation' -or + $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.' + } + + Write-Host 'Gateway isolation plugin tests passed.' +} +finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force + } +} 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-GitHooks.Tests.ps1 b/scripts/Test-GitHooks.Tests.ps1 new file mode 100644 index 00000000..1d27af03 --- /dev/null +++ b/scripts/Test-GitHooks.Tests.ps1 @@ -0,0 +1,384 @@ +<# +.SYNOPSIS + Tests the optional pre-push hook and its installer. + +.DESCRIPTION + Every case runs against a throwaway Git repository under the temporary + directory. Nothing here touches this clone's .git directory, the user's + global Git configuration, or the real quality script. + + The end-to-end cases replace scripts\Test-DotNetQuality.ps1 in the + throwaway repository with a stub, then perform a real `git push` to a local + bare remote. That proves Git actually invokes the hook and that the hook's + exit code decides whether the push proceeds, without running a full build. + + Every case runs twice: once in an ordinary clone and once against a linked + worktree of that clone, because Git keeps one hooks directory per clone but + runs the pushing worktree's quality script. +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$installScript = Join-Path $PSScriptRoot 'Install-GitHooks.ps1' +$trackedHook = Join-Path $repositoryRoot 'hooks\pre-push' + +function Invoke-Git { + param( + [Parameter(Mandatory, ValueFromRemainingArguments)] + [string[]]$Arguments + ) + + $output = & git @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git $($Arguments -join ' ') failed: $output" + } + + return $output +} + +function New-QualityStub { + param( + [Parameter(Mandatory)] + [int]$ExitCode, + + [Parameter(Mandatory)] + [string]$Label + ) + + return "Write-Host 'stub quality check ($Label)'`nexit $ExitCode`n" +} + +function New-TestRepository { + <# + Creates a work tree wired to a local bare remote, seeded with the + tracked hook and a stub quality script that exits with $StubExitCode. + + With -Layout Worktree the repository also gains a linked worktree on + its own branch, and that linked worktree becomes the target the case + operates on. The primary work tree then carries the opposite stub exit + code, so a push from the linked worktree can only behave as the case + expects if Git ran the pushing worktree's quality script. + #> + param( + [int]$StubExitCode = 0, + + [ValidateSet('Clone', 'Worktree')] + [string]$Layout = 'Clone' + ) + + $root = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('n')) + $work = Join-Path $root 'work' + $remote = Join-Path $root 'remote.git' + New-Item -ItemType Directory -Path $work -Force | Out-Null + + Invoke-Git init --bare --initial-branch=main $remote | Out-Null + Invoke-Git init --initial-branch=main $work | Out-Null + Invoke-Git -C $work config user.email 'hook-tests@example.invalid' | Out-Null + Invoke-Git -C $work config user.name 'Hook Tests' | Out-Null + Invoke-Git -C $work remote add origin $remote | Out-Null + + New-Item -ItemType Directory -Path (Join-Path $work 'hooks') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $work 'scripts') -Force | Out-Null + + $hookContent = [System.IO.File]::ReadAllText($trackedHook) -replace "`r`n", "`n" + [System.IO.File]::WriteAllText((Join-Path $work 'hooks\pre-push'), $hookContent) + + $primaryExitCode = $StubExitCode + if ($Layout -eq 'Worktree') { + $primaryExitCode = if ($StubExitCode -eq 0) { 1 } else { 0 } + } + + [System.IO.File]::WriteAllText( + (Join-Path $work 'scripts\Test-DotNetQuality.ps1'), + (New-QualityStub -ExitCode $primaryExitCode -Label 'primary')) + + [System.IO.File]::WriteAllText((Join-Path $work 'seed.txt'), "seed`n") + Invoke-Git -C $work add --all | Out-Null + Invoke-Git -C $work commit -m 'seed' | Out-Null + + $target = $work + $branch = 'main' + if ($Layout -eq 'Worktree') { + $linked = Join-Path $root 'linked' + Invoke-Git -C $work worktree add -b feature $linked | Out-Null + [System.IO.File]::WriteAllText( + (Join-Path $linked 'scripts\Test-DotNetQuality.ps1'), + (New-QualityStub -ExitCode $StubExitCode -Label 'linked')) + Invoke-Git -C $linked add --all | Out-Null + Invoke-Git -C $linked commit -m 'linked stub' | Out-Null + + $target = $linked + $branch = 'feature' + } + + return [pscustomobject]@{ + Root = $root + Work = $work + Remote = $remote + Layout = $Layout + Target = $target + Branch = $branch + + # The clone's common hooks directory, which Git consults for every + # worktree. Stated independently of the installer on purpose. + HookPath = Join-Path $work '.git\hooks\pre-push' + } +} + +function Assert-Fails { + param( + [Parameter(Mandatory)] + [scriptblock]$Action, + + [Parameter(Mandatory)] + [string]$MessagePattern, + + [Parameter(Mandatory)] + [string]$Case + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw ( + "$Case`: expected a failure matching '$MessagePattern'; " + + "received: $($_.Exception.Message)" + ) + } + return + } + + throw "$Case`: expected a failure matching '$MessagePattern', but it succeeded." +} + +function Assert-True { + param( + [Parameter(Mandatory)] + [bool]$Condition, + + [Parameter(Mandatory)] + [string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Invoke-Case { + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [scriptblock]$Body, + + [int]$StubExitCode = 0, + + [ValidateSet('Clone', 'Worktree')] + [string[]]$Layouts = @('Clone', 'Worktree') + ) + + foreach ($layout in $Layouts) { + $repository = New-TestRepository -StubExitCode $StubExitCode -Layout $layout + try { + & $Body $repository + Write-Host "PASS $Name [$layout]" + } + finally { + Remove-Item -LiteralPath $repository.Root -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Invoke-Case 'installs the managed hook' { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + + Assert-True ` + -Condition (Test-Path -LiteralPath $repository.HookPath -PathType Leaf) ` + -Message 'Install did not create .git\hooks\pre-push.' + + $installed = [System.IO.File]::ReadAllText($repository.HookPath) + Assert-True ` + -Condition $installed.Contains('openclaw-managed-hook') ` + -Message 'The installed hook is missing its managed marker.' + Assert-True ` + -Condition (-not $installed.Contains("`r`n")) ` + -Message 'The installed hook must use LF line endings.' +} + +Invoke-Case 'installing twice is idempotent' { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + & $installScript -RepositoryRoot $repository.Target | Out-Null + + Assert-True ` + -Condition (Test-Path -LiteralPath $repository.HookPath -PathType Leaf) ` + -Message 'The second install removed the hook.' +} + +Invoke-Case 'a passing check allows the push' { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + Invoke-Git -C $repository.Target push origin $($repository.Branch) | Out-Null + + $remoteHead = Invoke-Git --git-dir=$($repository.Remote) rev-parse $($repository.Branch) + $localHead = Invoke-Git -C $repository.Target rev-parse $($repository.Branch) + Assert-True ` + -Condition ($remoteHead -eq $localHead) ` + -Message 'The push did not reach the remote.' +} + +Invoke-Case 'a failing check blocks the push' -StubExitCode 1 { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + + Assert-Fails ` + -Case 'failing check' ` + -MessagePattern 'failed' ` + -Action { Invoke-Git -C $repository.Target push origin $($repository.Branch) } + + & git --git-dir=$($repository.Remote) rev-parse --verify $($repository.Branch) 2>&1 | Out-Null + Assert-True ` + -Condition ($LASTEXITCODE -ne 0) ` + -Message 'A blocked push still updated the remote.' +} + +Invoke-Case 'a failing check is bypassable with --no-verify' -StubExitCode 1 { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + Invoke-Git -C $repository.Target push --no-verify origin $($repository.Branch) | Out-Null + + $remoteHead = Invoke-Git --git-dir=$($repository.Remote) rev-parse $($repository.Branch) + Assert-True ` + -Condition (-not [string]::IsNullOrWhiteSpace($remoteHead)) ` + -Message '--no-verify did not bypass the failing hook.' +} + +Invoke-Case 'removes the managed hook' { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + & $installScript -RepositoryRoot $repository.Target -Remove | Out-Null + + Assert-True ` + -Condition (-not (Test-Path -LiteralPath $repository.HookPath)) ` + -Message 'Remove did not delete the managed hook.' +} + +Invoke-Case 'removing twice is idempotent' { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + & $installScript -RepositoryRoot $repository.Target -Remove | Out-Null + & $installScript -RepositoryRoot $repository.Target -Remove | Out-Null +} + +Invoke-Case 'refuses to overwrite an unmanaged hook' { + param($repository) + + $hooksDirectory = Split-Path $repository.HookPath -Parent + New-Item -ItemType Directory -Path $hooksDirectory -Force | Out-Null + [System.IO.File]::WriteAllText($repository.HookPath, "#!/bin/sh`necho mine`n") + + Assert-Fails ` + -Case 'unmanaged install' ` + -MessagePattern 'not be overwritten' ` + -Action { & $installScript -RepositoryRoot $repository.Target } + + $preserved = [System.IO.File]::ReadAllText($repository.HookPath) + Assert-True ` + -Condition $preserved.Contains('echo mine') ` + -Message 'The unmanaged hook was modified.' +} + +Invoke-Case 'refuses to remove an unmanaged hook' { + param($repository) + + $hooksDirectory = Split-Path $repository.HookPath -Parent + New-Item -ItemType Directory -Path $hooksDirectory -Force | Out-Null + [System.IO.File]::WriteAllText($repository.HookPath, "#!/bin/sh`necho mine`n") + + Assert-Fails ` + -Case 'unmanaged remove' ` + -MessagePattern 'will not be removed' ` + -Action { & $installScript -RepositoryRoot $repository.Target -Remove } + + Assert-True ` + -Condition (Test-Path -LiteralPath $repository.HookPath -PathType Leaf) ` + -Message 'The unmanaged hook was deleted.' +} + +Invoke-Case 'stops when core.hooksPath is set' { + param($repository) + + Invoke-Git -C $repository.Target config core.hooksPath 'custom-hooks' | Out-Null + + Assert-Fails ` + -Case 'core.hooksPath' ` + -MessagePattern 'core\.hooksPath' ` + -Action { & $installScript -RepositoryRoot $repository.Target } +} + +Invoke-Case 'installs into the clone-wide hooks directory' -Layouts 'Worktree' { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + + Assert-True ` + -Condition (Test-Path -LiteralPath $repository.HookPath -PathType Leaf) ` + -Message 'Installing from a linked worktree did not reach the shared hooks directory.' + + $privateHooks = Get-ChildItem ` + -Path (Join-Path $repository.Work '.git\worktrees') ` + -Recurse -Filter 'pre-push' -File -ErrorAction SilentlyContinue + Assert-True ` + -Condition ($null -eq $privateHooks) ` + -Message 'The hook was written into a private worktree directory Git does not consult.' + + # The primary work tree sees the same hook, so installing again is a no-op + # rather than a second installation. + & $installScript -RepositoryRoot $repository.Work | Out-Null + + Assert-True ` + -Condition (Test-Path -LiteralPath $repository.HookPath -PathType Leaf) ` + -Message 'Reinstalling from another worktree removed the shared hook.' +} + +Invoke-Case 'removal from one worktree applies to the whole clone' -StubExitCode 1 -Layouts 'Worktree' { + param($repository) + + & $installScript -RepositoryRoot $repository.Target | Out-Null + + Assert-Fails ` + -Case 'shared hook before removal' ` + -MessagePattern 'failed' ` + -Action { Invoke-Git -C $repository.Target push origin $($repository.Branch) } + + & $installScript -RepositoryRoot $repository.Work -Remove | Out-Null + + Assert-True ` + -Condition (-not (Test-Path -LiteralPath $repository.HookPath)) ` + -Message 'Removing from the primary work tree left the shared hook in place.' + + Invoke-Git -C $repository.Target push origin $($repository.Branch) | Out-Null + + $remoteHead = Invoke-Git --git-dir=$($repository.Remote) rev-parse $($repository.Branch) + Assert-True ` + -Condition (-not [string]::IsNullOrWhiteSpace($remoteHead)) ` + -Message 'The linked worktree still ran a hook after it was removed.' +} + +Write-Host 'All Git hook tests passed.' 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-NativeAotCli.Tests.ps1 b/scripts/Test-NativeAotCli.Tests.ps1 new file mode 100644 index 00000000..e7a8d218 --- /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 real bundled 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/scripts/Test-NodeRuntimeInputs.Tests.ps1 b/scripts/Test-NodeRuntimeInputs.Tests.ps1 new file mode 100644 index 00000000..86b2ad07 --- /dev/null +++ b/scripts/Test-NodeRuntimeInputs.Tests.ps1 @@ -0,0 +1,317 @@ +[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 +$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) + + 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\control-ui\assets", ` + "$source\dist\extensions\fixture", ` + $package ` + -Force | + Out-Null + '{"name":"openclaw","version":"0.0.0","type":"module"}' | + Set-Content -LiteralPath "$source\package.json" + @' +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, + activated: enabled && runtime, + status: enabled && runtime ? "loaded" : "disabled", + imported: enabled && runtime, + httpRoutes: enabled && runtime ? 1 : 0 + }, + httpRouteCount: enabled && 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" + '{"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.' + } + $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" + + $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 $fixtureArchitecture -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.' + } + + $reusedPayload = Join-Path $testRoot 'payload-reused' + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package ` + -Architecture $fixtureArchitecture ` + -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 $fixtureArchitecture ` + -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 $fixtureArchitecture ` + -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-$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 $fixtureArchitecture ` + -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 $fixtureArchitecture ` + -OutputDirectory (Join-Path $testRoot 'invalid-provenance-reuse') ` + -ReuseStagedInstall + } + [IO.File]::WriteAllText( + $stagingMetadataPath, + $stagingMetadata, + [Text.UTF8Encoding]::new($false) + ) + + 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-$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 $fixtureArchitecture ` + -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 $fixtureArchitecture ` + -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 { + & "$PSScriptRoot\Build-Payload.ps1" ` + -PackageDirectory $package -Architecture $fixtureArchitecture -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-OpenClawBuildIdentity.Tests.ps1 b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 new file mode 100644 index 00000000..ede7de61 --- /dev/null +++ b/scripts/Test-OpenClawBuildIdentity.Tests.ps1 @@ -0,0 +1,117 @@ +[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 { + # 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' ` + -OmitControlUiBuildId + Assert-Fails -MessagePattern 'Control UI build identity is missing' -Action { + & $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' ` + -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..db56d5f5 --- /dev/null +++ b/scripts/Test-OpenClawBuildIdentity.ps1 @@ -0,0 +1,79 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$OpenClawDirectory +) + +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' +$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'." +} + +# 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, + '(?m)^\s*const\s+EMBEDDED_CACHE_VERSION\s*=\s*(?<value>"[^"\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'." + ) +} + +# 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 { + (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-OpenClawCacheKey.Tests.ps1 b/scripts/Test-OpenClawCacheKey.Tests.ps1 new file mode 100644 index 00000000..a4da52e5 --- /dev/null +++ b/scripts/Test-OpenClawCacheKey.Tests.ps1 @@ -0,0 +1,105 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$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'))" +) + +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 ` + -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' + + $payloadV2 = & $scriptPath ` + -Layer payload ` + -Commit $commitA ` + -Architecture x64 ` + -NodeVersion 24.20.0 ` + -PackageSha256 $packageHashA ` + -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-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-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-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-Sign-TestMSIX.Tests.ps1 b/scripts/Test-Sign-TestMSIX.Tests.ps1 new file mode 100644 index 00000000..e305019c --- /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 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.' + } + + $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 +} diff --git a/scripts/Test-SigningInputs.Tests.ps1 b/scripts/Test-SigningInputs.Tests.ps1 index e31b3e01..b5dcc76e 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 +$releaseIdentity = & ( + Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1' +) ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) +$approvedPackageVersion = $releaseIdentity.PackageVersion +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $packagingCommit = '1111111111111111111111111111111111111111' $testRoot = Join-Path $env:TEMP ( "openclaw-signing-policy-$([guid]::NewGuid().ToString('N'))" @@ -24,8 +31,12 @@ function New-TestArtifact { [string]$PayloadCommit = $approvedCommit, + [string]$PayloadPackageVersion = $approvedPayloadVersion, + [bool]$SourceTreeDirty = $false, + [string]$NodeRuntimeVersion = '24.16.0', + [bool]$IncludeBundledNode = $false, [bool]$IncludeApplicationBundledNode = $false, @@ -110,7 +121,7 @@ function New-TestArtifact { <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"> <Identity Name="OpenClaw.Gateway" Publisher="$($policy.publisher)" - Version="0.1.1.0" + Version="$approvedPackageVersion" ProcessorArchitecture="$Architecture" /> </Package> "@ | Set-Content ` @@ -124,6 +135,98 @@ 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 + } + $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() + + $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 + ) + + $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 @@ -141,13 +244,20 @@ function New-TestArtifact { payloadRepository = $policy.repository payloadRequestedRef = $PayloadCommit payloadResolvedCommit = $PayloadCommit + payloadPackageVersion = $PayloadPackageVersion payloadLayout = 'immutable-package' payloadFileCount = $payloadFiles.Count + nodeRuntimeVersion = $nodeRuntimeVersion + nodeRuntimeArchive = $nodeRuntimeArchive + nodeRuntimeSha256 = $nodeRuntimeHash + mxcRuntimeVersion = $mxcRuntimeVersion + mxcRuntimeFiles = $mxcRuntimeFiles + sessionHostFiles = $sessionHostFiles architecture = $Architecture archive = $msixName sha256 = $msixHash signed = $false - packageVersion = '0.1.1.0' + packageVersion = $approvedPackageVersion publisher = $policy.publisher } | ConvertTo-Json | @@ -161,16 +271,88 @@ 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'), + + [string]$BundleVersion = $approvedPackageVersion + ) + + $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') + + @" +<?xml version="1.0" encoding="utf-8"?> +<Bundle xmlns="http://schemas.microsoft.com/appx/2013/bundle"> + <Identity Name="OpenClaw.Gateway" + Publisher="$($policy.publisher)" + Version="$BundleVersion" /> + <Packages> + <Package Type="application" + Version="$approvedPackageVersion" + Architecture="x64" + FileName="OpenClawGateway-x64.msix" /> + <Package Type="application" + Version="$approvedPackageVersion" + Architecture="arm64" + FileName="OpenClawGateway-arm64.msix" /> + </Packages> +</Bundle> +"@ | 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)] @@ -239,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 { @@ -254,6 +438,67 @@ 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 + 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 + 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) + 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' ` -Action { @@ -262,6 +507,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 @@ -275,6 +534,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.3' + 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 @@ -296,7 +568,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 +581,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 +594,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 } @@ -452,6 +724,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..62c9b4b1 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, @@ -108,6 +111,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 @@ -117,12 +153,23 @@ $policy = Get-Content -LiteralPath $resolvedPolicyPath -Raw | if ( $policy.repository -ne 'https://github.com/openclaw/openclaw' -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.' } +$releaseIdentity = & ( + Join-Path $PSScriptRoot 'Get-MSIXReleaseIdentity.ps1' +) ` + -GatewayTag ([string]$policy.gatewayTag) ` + -MSIXRevision ([int]$policy.msixRevision) +$approvedPackageVersion = $releaseIdentity.PackageVersion +$approvedPayloadVersion = [string]$policy.payloadPackageVersion $approvedCommit = ([string]$policy.approvedCommit).ToLowerInvariant() $normalizedRequestedRef = $RequestedRef.Trim().ToLowerInvariant() if ( @@ -137,6 +184,9 @@ if ( $expectedPackagingCommit = $PackagingCommit.ToLowerInvariant() $expectedPackageVersion = $null +$expectedNodeRuntimeVersion = $null +$expectedMxcRuntimeVersion = $null +$expectedPackages = @{} foreach ($architecture in @('x64', 'arm64')) { $directory = Join-Path $resolvedArtifactsDirectory $architecture $metadataPath = Join-Path $directory 'msix-metadata.json' @@ -163,13 +213,22 @@ 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 + $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 + [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 $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." @@ -182,26 +241,214 @@ 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.' + } + + 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() 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 { # 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' + ) -and + $_ -ine $expectedNodeArchivePath + } + ) + 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 + ) + ) + + $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 { - [IO.Path]::GetFileName($_) -ieq 'node.exe' -or - [IO.Path]::GetFileName($_) -match '^node-v\d' + $_.StartsWith( + "mxc/$architecture/", + [StringComparison]::OrdinalIgnoreCase + ) } ) - if ($bundledNodeEntries.Count -ne 0) { - throw "The $architecture MSIX bundles Node.js." + 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." + } + + $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 ` @@ -321,7 +568,105 @@ 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']" + ) + $bundleVersion = if ($null -eq $bundleIdentity) { + '' + } + else { + [string]$bundleIdentity.Version + } + $bundleVersionMatch = [regex]::Match( + $bundleVersion, + '^(?<major>\d+)\.(?<minor>\d+)\.(?<build>\d+)\.(?<revision>\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 + -not $bundleVersionIsValid -or + $bundleVersion -ne $approvedPackageVersion + ) { + 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." + "and Gateway MSIX version $expectedPackageVersion " + + "(bundle version $bundleVersion)." ) 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 new file mode 100644 index 00000000..4fc5f111 --- /dev/null +++ b/scripts/Test-WorkflowSigningConfiguration.ps1 @@ -0,0 +1,177 @@ +[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 = @( + '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')" + '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' + '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' + 'client-id: ${{ vars.AZURE_CLIENT_ID }}' + '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' + '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' + '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' + '.\scripts\Get-MSIXReleaseIdentity.ps1' + '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' + '.\scripts\Test-OpenClawWorkflowInputs.Tests.ps1' +) + +foreach ($fragment in $requiredFragments) { + if (-not $workflow.Contains($fragment, [StringComparison]::Ordinal)) { + throw "Signing workflow is missing required configuration: $fragment" + } +} + +$buildMsixJobMatch = [regex]::Match( + $workflow, + '(?ms)^ build-msix:\s*(?<job>.*?)(?=^ [a-z][a-z0-9-]+:)' +) +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)) { + 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})' +) +$automaticFallbackMatch = [regex]::Match( + $workflow, + "OPENCLAW_REF:.*?\|\|\s*'(?<sha>[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 +$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 + $expectedDefault + ) | Select-Object -Unique +) +if ($pinnedRevisions.Count -ne 1) { + throw ( + 'The workflow defaults must match the declared development pin, or ' + + 'the official approval when no development pin is declared. ' + + "Found: $($pinnedRevisions -join ', ')." + ) +} + +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.' 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" + } + ] +} 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/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<string, string?> readEnvironmentVariable, + Func<bool> 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<string, string?> 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/ClawCtlCommand.cs b/src/OpenClaw.Launcher/ClawCtlCommand.cs deleted file mode 100644 index 2c1b141c..00000000 --- a/src/OpenClaw.Launcher/ClawCtlCommand.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace OpenClaw.Launcher; - -public enum ClawCtlCommand -{ - Help, - Version, - Setup -} - -public sealed record ClawCtlCommandParseResult( - ClawCtlCommand Command, - string? Error = null); - -public static class ClawCtlCommandParser -{ - public static ClawCtlCommandParseResult Parse(IReadOnlyList<string> 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<string> 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..e2320dd4 --- /dev/null +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -0,0 +1,223 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; + +namespace OpenClaw.Launcher; + +internal sealed record ClawCtlHandlers +{ + public required Func<SetupOptions, CancellationToken, Task<int>> Setup { get; init; } + public required Func<CancellationToken, Task<int>> Status { get; init; } + public required Func<string?, CancellationToken, Task<int>> CollectLogs { get; init; } + public required Func<bool, CancellationToken, Task<int>> Teardown { get; init; } + public required Func<CancellationToken, Task<int>> PowerShell { get; init; } + public required Func<CancellationToken, Task<int>> GatewayStart { get; init; } + public required Func<CancellationToken, Task<int>> GatewayStatus { get; init; } + public required Func<CancellationToken, Task<int>> GatewayStop { get; init; } +} + +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 +// 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"; + 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 + // 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 + }; + + // Setup guidance is available without preparing the runtime. + public static string RootDescription => + "Get packaged OpenClaw and its bundled Node.js runtime ready, and manage its isolated session." + + Environment.NewLine + + Environment.NewLine + + "Run `clawctl setup` before using OpenClaw for the first time." + + Environment.NewLine + + Environment.NewLine + + "Run `openclaw <arguments>` to invoke the OpenClaw CLI."; + + public static string SetupDescription => + "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. + public static RootCommand Create( + ClawCtlHandlers handlers, + ClawCtlOutputOptions? outputOptions = null) + { + ArgumentNullException.ThrowIfNull(handlers); + outputOptions ??= new ClawCtlOutputOptions(); + Option<bool> noColor = new("--no-color") + { + Description = "Disable colored output.", + Recursive = true + }; + Command setup = new(SetupCommandName, SetupDescription); + Option<bool> fresh = new("--fresh") + { + Description = "Remove this installation's owned session and local state before setting it up again." + }; + Option<bool> 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) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.Setup( + new SetupOptions( + parsed.GetValue(fresh), + parsed.GetValue(force)), + cancellationToken); + }); + Command status = new( + StatusCommandName, + "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<string?> outputPath = new("--output") + { + Description = "Path for the diagnostics ZIP file." + }; + Command collectLogs = new( + CollectLogsCommandName, + "Gather redacted diagnostics into one ZIP file for troubleshooting."); + collectLogs.Options.Add(outputPath); + collectLogs.SetAction((parsed, cancellationToken) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.CollectLogs(parsed.GetValue(outputPath), cancellationToken); + }); + Option<bool> 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) => + { + outputOptions.NoColor = parsed.GetValue(noColor); + return handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken); + }); + Command powerShell = new( + "pwsh", + "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 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); + + RootCommand root = new(RootDescription) + { + setup, + status, + collectLogs, + teardown, + 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. + 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 49d44166..4d3b3cac 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -1,43 +1,744 @@ +using System.Globalization; +using Spectre.Console; +using Spectre.Console.Rendering; + namespace OpenClaw.Launcher; -public static class ClawCtlConsole +internal static class ClawCtlConsole { - public static void WriteHelp(TextWriter output) + 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 { - output.WriteLine("clawctl - OpenClaw package preparation"); - output.WriteLine(); - WriteUsage(output); - output.WriteLine(); - output.WriteLine("Commands:"); - output.WriteLine(" setup Prepare the packaged OpenClaw environment."); - output.WriteLine(); - WriteNodePrerequisite(output); - output.WriteLine(); - output.WriteLine("Run `openclaw <arguments>` to invoke the OpenClaw CLI."); + Neutral, + Success, + Warning, + Failure } - public static void WriteUsage(TextWriter output) => - output.WriteLine("Usage: clawctl setup"); + internal static void WriteResult( + TextWriter output, + IClawCtlResult result, + bool useColor = false) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(result); - public static void WriteNodePrerequisite(TextWriter output) + 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) { - output.WriteLine( - $"Prerequisite: install Node.js {NodeRuntimeResolver.SupportedVersions}."); - output.WriteLine($" {NodeRuntimeResolver.InstallCommand}"); + 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 void WriteNodeRuntimeSummary( + internal static async Task<T> NarrateAsync<T>( TextWriter output, - NodeRuntime runtime) => - output.WriteLine( - $"Using Node.js {runtime.Version} from {runtime.ExecutablePath}"); + bool useColor, + bool narrate, + ClawCtlProgress initial, + Func<IProgress<ClawCtlProgress>, Task<T>> operation) + { + 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<T> NarrateWithStatusAsync<T>( + IAnsiConsole console, + ClawCtlProgress initial, + Func<IProgress<ClawCtlProgress>, Task<T>> 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<ClawCtlProgress> report) + : IProgress<ClawCtlProgress> + { + public void Report(ClawCtlProgress value) => report(value); + } + + private sealed class NullProgress : IProgress<ClawCtlProgress> + { + 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<int> 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 "<mark> <word><trailing>". 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(" "); + } - public static void WriteReadinessSummary( + 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, - string applicationDirectory) + 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) { - output.WriteLine(); - output.WriteLine("OpenClaw package is ready."); - output.WriteLine($"Read-only application files: {applicationDirectory}"); + 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<Item> _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<IRenderable>(); + 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 new file mode 100644 index 00000000..738486f1 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/DiagnosticsBundle.cs @@ -0,0 +1,429 @@ +using System.IO.Compression; +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary>What a diagnostics run produced.</summary> +internal sealed record DiagnosticsBundleResult( + string? BundlePath, + bool SessionReached, + IReadOnlyList<string> Notes); + +internal sealed partial class GatewayRuntime +{ + /// <summary> + /// File names that must never reach a bundle, matched with a trailing + /// wildcard. + /// </summary> + /// <remarks> + /// These are the agent and shared credential stores. Provider tokens live + /// in them and in their <c>-wal</c> 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. + /// </remarks> + internal static readonly string[] DeniedBundleNames = + [ + "openclaw-agent.sqlite*", + "openclaw.sqlite*", + "auth-profiles*", + ]; + + /// <summary>Directory in the shared workspace used to stage guest files.</summary> + internal const string StagingDirectoryName = "openclaw-diagnostics"; + + /// <summary> + /// Opens an interactive shell inside the recorded session. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public async Task<int> 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); + } + + /// <summary> + /// Bundles host-side, and where reachable agent-side, diagnostics. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public async Task<DiagnosticsBundleResult> CollectLogsAsync( + string? requestedPath, + CancellationToken cancellationToken) + { + string bundlePath = ResolveBundlePath( + requestedPath, + _paths, + Environment.CurrentDirectory, + _clock); + + List<string> 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/pre-reset.log", _paths.PreResetReportPath); + 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<string> 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); + } + } + + /// <summary> + /// The agent-side artifacts worth collecting, named relative to the agent + /// profile so the bundle layout never carries the account name. + /// </summary> + private static IReadOnlyList<SessionCollectSource> 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<string> 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<string> EnumerateStagedFiles( + string workspace, + string staged, + List<string> notes) + { + List<string> files = []; + try + { + TrustedPath.EnsureNoReparsePoints(workspace, staged); + Stack<string> 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<string> 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; + +/// <summary> +/// Removes credential-shaped values from text before it enters a bundle. +/// </summary> +/// <remarks> +/// <para> +/// A bundle exists to be handed to someone else, which is the entire reason +/// this is needed. Credential <em>stores</em> are excluded outright rather than +/// redacted; this covers configuration files that legitimately belong in a +/// bundle but may carry an inline key. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +internal static partial class DiagnosticsRedactor +{ + public const string Placeholder = "<redacted>"; + + public static string Redact(string text) + { + ArgumentNullException.ThrowIfNull(text); + return SecretMember().Replace(text, $"$1\"{Placeholder}\""); + } + + /// <summary> + /// Matches a JSON member whose name contains a credential word, capturing + /// everything up to its value so only the value is replaced. + /// </summary> + [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 new file mode 100644 index 00000000..1dd34df1 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayConfigurationStore.cs @@ -0,0 +1,290 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary> +/// How the gateway should be launched. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal sealed record GatewayLaunchConfiguration +{ + /// <summary> + /// OpenClaw's own default gateway port, reported so a user knows where to + /// look when nothing was configured. + /// </summary> + /// <remarks> + /// 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 + /// <c>gateway.port</c> setting, and a constant in this package would + /// silently diverge the moment upstream changed its default. + /// </remarks> + public const int UpstreamDefaultPort = 18789; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// <summary> + /// The port to launch on, or null to let OpenClaw resolve its own. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("port")] + public int? Port { get; init; } + + /// <summary> + /// 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. + /// </summary> + [JsonPropertyName("workingDirectory")] + public string? WorkingDirectory { get; init; } + +} + +/// <summary>Why a stored launch configuration could not be used.</summary> +internal enum GatewayConfigurationFault +{ + /// <summary>Nothing has been configured; defaults apply.</summary> + Missing, + + /// <summary>The file exists but is not readable as a configuration.</summary> + Unreadable, + + /// <summary>Written by an incompatible newer version.</summary> + UnsupportedSchema, + + /// <summary>A stored value is outside what Windows or OpenClaw accepts.</summary> + Invalid, +} + +internal sealed record GatewayConfigurationResult( + GatewayLaunchConfiguration? Configuration, + GatewayConfigurationFault? Fault, + string? Detail); + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(GatewayLaunchConfiguration))] +internal sealed partial class GatewayConfigurationJsonContext : JsonSerializerContext; + +/// <summary> +/// Reads and writes the gateway's launch configuration. +/// </summary> +internal sealed class GatewayConfigurationStore +{ + public const int CurrentSchemaVersion = 1; + + /// <summary> + /// Overrides the configured port for one invocation, for diagnosing a port + /// conflict without changing what the next sign-in will use. + /// </summary> + public const string PortVariable = "OPENCLAW_GATEWAY_PORT"; + + private readonly string _filePath; + + public GatewayConfigurationStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = filePath; + } + + /// <summary> + /// Returns the configuration to launch with, applying a one-invocation + /// environment override if present. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public GatewayLaunchConfiguration Resolve( + string defaultWorkingDirectory, + Func<string, string?>? 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)."); + } + + return new GatewayConfigurationResult(configuration, null, null); + } + + public void Write(GatewayLaunchConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + 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<string, string?> 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; +} + +/// <summary> +/// The gateway's launch configuration could not be used. +/// </summary> +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/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; + +/// <summary>Writes gateway command results without trusting guest-produced log text.</summary> +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/GatewayController.cs b/src/OpenClaw.Launcher/Gateway/GatewayController.cs new file mode 100644 index 00000000..0d2063fc --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayController.cs @@ -0,0 +1,538 @@ +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary>What the gateway is actually doing, as opposed to what was recorded.</summary> +internal enum GatewayState +{ + /// <summary>No gateway has been started by this installation.</summary> + NotStarted, + + /// <summary>A live, identity-matched gateway is serving.</summary> + Running, + + /// <summary> + /// 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. + /// </summary> + Stopped, + + /// <summary> + /// The recorded process is alive but is not serving, so it is neither + /// usable nor safe to ignore. + /// </summary> + Unhealthy, + + /// <summary> + /// 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. + /// </summary> + 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); + +/// <summary> +/// Owns this installation's background gateway. +/// </summary> +/// <remarks> +/// Nothing here infers liveness from the record. The record says what was +/// started; only an inspection inside the session says what is running. +/// </remarks> +internal sealed class GatewayController +{ + private readonly SessionCoordinator _sessions; + private readonly ISessionGatewayClient _client; + private readonly GatewayStateStore _store; + private readonly Func<CancellationToken, Task<GatewayStartRequest>> _startRequestFactory; + private readonly Func<SessionRecord> _requireSetup; + private readonly ISessionLock _lifecycleLock; + private readonly Action<string> _log; + private readonly TimeProvider _clock; + + public GatewayController( + SessionCoordinator sessions, + ISessionGatewayClient client, + GatewayStateStore store, + Func<CancellationToken, Task<GatewayStartRequest>> startRequestFactory, + Action<string> log, + Func<SessionRecord> 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; + } + + /// <summary> + /// Reports the gateway without starting one, provisioning a session, or + /// writing anything. + /// </summary> + public async Task<GatewayStatusReport> 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)); + } + + /// <summary> + /// Starts the gateway only in a previously configured session. + /// </summary> + public async Task<GatewayStartResult> StartAsync( + string helperPath, + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + SessionRecord configured = _requireSetup(); + GatewayStateResult existing = _store.Read(); + 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."); + } + 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) + .ConfigureAwait(false); + + if (existing.Record is not null) + { + SessionInspectResult inspection = await InspectAsync( + session, + existing.Record, + helperPath, + cancellationToken).ConfigureAwait(false); + + if (inspection.IsOwnedAndHealthy) + { + GatewayRecord confirmedRecord = existing.Record; + _log("The gateway is already running."); + return new GatewayStartResult( + GatewayState.Running, + confirmedRecord, + 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."); + } + + 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) + { + 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) + }); + } + + /// <summary> + /// Names the port a user should actually connect to. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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"; + } + + /// <summary> + /// Stops the gateway without removing the session or the user's data. + /// </summary> + public async Task<GatewayStopResult> StopAsync( + string helperPath, + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + return await StopUnderLockAsync(helperPath, cancellationToken).ConfigureAwait(false); + } + + internal async Task<GatewayStopResult> StopUnderLockAsync( + string helperPath, + 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); + } + 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 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) + { + if (clearRecord) + { + _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); + } + if (clearRecord) + { + _store.Clear(); + } + return new GatewayStopResult(Stopped: true, "The gateway is stopped."); + } + + private async Task<SessionInspectResult> 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."; + + /// <summary> + /// Explains an unhealthy gateway in terms of what was observed. + /// </summary> + /// <remarks> + /// A pinned port that nothing in our process tree listens on is its own + /// diagnosis: OpenClaw's configuration moved the gateway somewhere else. + /// </remarks> + 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/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; + +/// <summary> +/// Generates the launcher files both persistence lanes invoke. +/// </summary> +internal static class GatewayLauncherScript +{ + /// <summary> + /// Identifies a file this installation generated, so a file that happens to + /// share the name is reported rather than silently overwritten. + /// </summary> + 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); + } + + /// <summary> + /// The Startup-folder fallback calls the same launcher rather than + /// repeating its contents, so the two lanes cannot drift apart. + /// </summary> + 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 new file mode 100644 index 00000000..01867369 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceContracts.cs @@ -0,0 +1,98 @@ +namespace OpenClaw.Launcher.Gateway; + +/// <summary>Which mechanism is actually restarting the gateway at logon.</summary> +internal enum GatewayPersistenceLane +{ + /// <summary>Nothing is configured to restart the gateway.</summary> + None, + + /// <summary>The registered logon task, which is the intended lane.</summary> + TaskScheduler, + + /// <summary> + /// The Startup folder. Permitted only when the task could not be + /// registered, and always reported, never a silent substitute. + /// </summary> + StartupFolderFallback, +} + +internal enum GatewayPersistenceState +{ + /// <summary>Logon recovery is not configured.</summary> + NotInstalled, + + /// <summary>Logon recovery is configured and matches what this build registers.</summary> + Ready, + + /// <summary> + /// Something is configured but differs from, or cannot be reconciled with, + /// what this build registers. The user is told which command repairs it. + /// </summary> + ActionRequired, + + /// <summary> + /// The configured state could not be read. Reported as unknown rather than + /// guessed in either direction. + /// </summary> + 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); + +/// <summary> +/// Everything the persistence manager needs about this user and installation. +/// </summary> +/// <param name="UserSid"> +/// 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. +/// </param> +/// <param name="PackageFamilyName"> +/// Scopes the task name to this installation, so the public and internal +/// packages can never overwrite or delete each other's registration. +/// </param> +/// <param name="LauncherPath">The generated script both lanes invoke.</param> +/// <param name="StartupFolderPath"> +/// Where the explicitly reported Startup-folder fallback is written. +/// </param> +/// <param name="WorkingDirectory"> +/// The directory the gateway is given, rather than the system directory a +/// logon task would otherwise inherit. +/// </param> +/// <param name="CommandProcessorPath"> +/// The absolute path to the inbox command processor the task runs. +/// </param> +/// <param name="DeleteFile"> +/// Optional file-delete seam used to make persistence failures observable in tests. +/// </param> +/// <param name="LogonTriggerUserSid"> +/// The interactive user whose logon starts recovery. Defaults to <paramref name="UserSid"/>. +/// </param> +internal sealed record GatewayPersistenceOptions( + string UserSid, + string PackageFamilyName, + string LauncherPath, + string StartupFolderPath, + string WorkingDirectory, + string CommandProcessorPath, + Action<string>? DeleteFile = null, + string? LogonTriggerUserSid = null); diff --git a/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs new file mode 100644 index 00000000..ce2788f8 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayPersistenceManager.cs @@ -0,0 +1,502 @@ +using System.Text; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary> +/// Registers, inspects, and removes this installation's logon recovery. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// This type never elevates and never repairs behind the user's back. An +/// explicit <c>install</c> rewrites what it owns; <c>status</c> only reports, +/// and names the command that repairs what it found. +/// </para> +/// </remarks> +internal sealed class GatewayPersistenceManager +{ + /// <summary>The command a reported problem tells the user to run.</summary> + public const string RepairCommand = "clawctl gateway-service install"; + + private readonly IGatewayTaskScheduler _scheduler; + private readonly GatewayPersistenceOptions _options; + private readonly GatewayTaskIdentity _identity; + private readonly Action<string> _log; + private readonly Func<string, string?> _resolveUserSid; + + public GatewayPersistenceManager( + IGatewayTaskScheduler scheduler, + GatewayPersistenceOptions options, + Action<string>? log = null, + Func<string, string?>? 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; + + /// <summary> + /// 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. + /// </summary> + 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<GatewayPersistenceStatus> 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<GatewayPersistenceInstallResult> 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<GatewayPersistenceRemovalResult> UninstallAsync( + CancellationToken cancellationToken) + { + GatewayTaskOperation deletion = await _scheduler.DeleteAsync( + _identity.Name, + cancellationToken).ConfigureAwait(false); + + 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, + fallback.Changed || launcher.Changed || activationScript.Changed, + "Logon recovery could not be fully removed.", + detail); + } + + return new GatewayPersistenceRemovalResult( + Succeeded: true, + fallback.Changed || launcher.Changed || activationScript.Changed, + "Logon recovery is removed."); + } + + private GatewayTaskSnapshot DesiredSnapshot() => + GatewayTaskDefinition.CreateSnapshot( + _options.UserSid, + _options.CommandProcessorPath, + LauncherPath, + _options.LogonTriggerUserSid); + + private string? DescribeDrift(GatewayTaskSnapshot actual) + { + GatewayTaskSnapshot desired = DesiredSnapshot(); + List<string> 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 GatewayGeneratedFileRemoval RemoveFallback() => + RemoveGeneratedFile(FallbackPath); + + private GatewayGeneratedFileRemoval RemoveLauncher() => + RemoveGeneratedFile(LauncherPath); + + private GatewayGeneratedFileRemoval RemoveActivationScript() => + RemoveGeneratedFile(ActivationScriptPath); + + private GatewayGeneratedFileRemoval 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 new GatewayGeneratedFileRemoval(false, null); + } + + (_options.DeleteFile ?? File.Delete)(path); + return new GatewayGeneratedFileRemoval(true, null); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + return new GatewayGeneratedFileRemoval( + false, + $"Could not remove generated recovery file '{path}': {exception.Message}"); + } + } + + 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 sealed record GatewayGeneratedFileRemoval(bool Changed, string? Detail); + + 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); + +} diff --git a/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs new file mode 100644 index 00000000..0f58094f --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs @@ -0,0 +1,244 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary>Assembles gateway management from the running installation.</summary> +internal sealed partial class GatewayRuntime +{ + private static readonly Guid StartupFolderId = + new("B97D20BB-F46A-4C97-BA10-5E3608430854"); + + 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; } + + 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); + + public static GatewayPersistenceManager CreateRecoveryManager(Action<string> 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."); + 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(), + new GatewayPersistenceOptions( + userSid, + packageFamilyName, + paths.GatewayLauncherPath, + GetStartupFolderPath(), + paths.StateRoot, + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + LogonTriggerUserSid: logonUserSid), + 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<string> log) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(log); + + 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); + return Create(options, paths, session, log); + } + + internal static GatewayRuntime Create( + HostOptions options, + HostPaths paths, + SessionRuntime session, + Action<string> 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<string> 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<string> log) + { + var configuration = new GatewayConfigurationStore(paths.GatewayConfigurationPath); + Task<GatewayStartRequest> 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); + 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(archivePath), + applicationDirectory, + launch.Port) + { + WorkingDirectory = launch.WorkingDirectory + }); + } + + return new GatewayController( + session.Coordinator, + new SessionGatewayClient( + session.Backend, + log, + isCurrentRecord: IsCurrentSessionRecord), + session.GatewayState, + CreateRequestAsync, + log, + session.RequireSetup, + session.LifecycleLock); + + 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<string, string?> 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..726a2d7a --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayStateStore.cs @@ -0,0 +1,262 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenClaw.Launcher.Session; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary> +/// The durable record of the gateway this installation started. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +internal sealed record GatewayRecord +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// <summary>The session the gateway was started in.</summary> + [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; } + + /// <summary> + /// The port this launch pinned, or null when OpenClaw chose it. + /// </summary> + /// <remarks> + /// Recorded rather than inferred, so a later status can tell a pinned port + /// apart from one that was observed. <see cref="ObservedPorts"/> carries + /// what the gateway actually listens on. + /// </remarks> + [JsonPropertyName("port")] + public int? Port { get; init; } + + /// <summary>The ports the running gateway was last observed listening on.</summary> + [JsonPropertyName("observedPorts")] + public IReadOnlyList<int>? ObservedPorts { get; init; } + + /// <summary> + /// 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. + /// </summary> + [JsonPropertyName("statusPath")] + public string? StatusPath { get; init; } + + [JsonPropertyName("logPath")] + public string? LogPath { get; init; } + + [JsonPropertyName("startedUtc")] + public DateTimeOffset StartedUtc { get; init; } + + /// <summary> + /// Set when the user explicitly turned logon recovery off, so a later + /// manual start does not quietly turn it back on. + /// </summary> + [JsonPropertyName("autostartDisabled")] + public bool AutostartDisabled { get; init; } +} + +/// <summary>Why a stored gateway record could not be used.</summary> +internal enum GatewayStateFault +{ + /// <summary>No gateway has been recorded.</summary> + Missing, + + /// <summary>The file exists but is not readable as a record.</summary> + Unreadable, + + /// <summary>The record was written by an incompatible newer version.</summary> + UnsupportedSchema, + + /// <summary>The record is missing values it cannot be used without.</summary> + 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; + +/// <summary> +/// Reads and writes the gateway record. +/// </summary> +internal sealed class GatewayStateStore +{ + public const int CurrentSchemaVersion = 1; + + private readonly string _filePath; + + public GatewayStateStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = filePath; + } + + /// <summary> + /// Reads the record, distinguishing "no gateway" from "unusable record". + /// </summary> + /// <remarks> + /// A damaged record never degrades to <see cref="GatewayStateFault.Missing"/>. + /// Starting a replacement over a record we merely failed to read would + /// abandon a running gateway that still holds its port. + /// </remarks> + 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); + } + + /// <summary>Removes the record. Deleting nothing is success.</summary> + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } + + /// <summary> + /// Removes a record only when it names a session MXC explicitly reported + /// stale and setup has replaced. + /// </summary> + /// <returns>True when the matching stale record was removed.</returns> + 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/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; + +/// <summary> +/// What a task probe found. Deliberately three-valued. +/// </summary> +internal enum GatewayTaskPresence +{ + /// <summary>Task Scheduler reported no such task.</summary> + Missing, + + /// <summary>The task exists and its definition was read.</summary> + Present, + + /// <summary> + /// 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. + /// </summary> + 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); +} + +/// <summary> +/// The Task Scheduler operations gateway persistence needs. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal interface IGatewayTaskScheduler +{ + Task<GatewayTaskProbe> QueryAsync( + string taskName, + CancellationToken cancellationToken); + + Task<GatewayTaskOperation> RegisterAsync( + string taskName, + string taskXml, + CancellationToken cancellationToken); + + Task<GatewayTaskOperation> DeleteAsync( + string taskName, + CancellationToken cancellationToken); + + Task<GatewayTaskOperation> 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..6fedfb32 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/GatewayTaskDefinition.cs @@ -0,0 +1,244 @@ +using System.Xml; +using System.Xml.Linq; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary> +/// The settings of a registered logon task that this installation cares about. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +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); + +/// <summary> +/// Builds the logon task this installation registers. +/// </summary> +internal static class GatewayTaskDefinition +{ + private const string TaskNamespace = + "http://schemas.microsoft.com/windows/2004/02/mit/task"; + + /// <summary> + /// No execution time limit. The gateway is long-running, and Task + /// Scheduler's default would terminate it after 72 hours. + /// </summary> + public const string NoExecutionTimeLimit = "PT0S"; + + /// <summary> + /// 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. + /// </summary> + public const string IgnoreNewInstances = "IgnoreNew"; + + public static GatewayTaskSnapshot CreateSnapshot( + string userSid, + string commandProcessorPath, + string launcherPath, + string? logonTriggerUserSid = null) => + 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: logonTriggerUserSid ?? 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(); + } + + /// <summary> + /// 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. + /// </summary> + 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<XElement> logonTriggers = root + .Element(ns + "Triggers")? + .Elements(ns + "LogonTrigger") + .ToList() ?? []; + List<XElement> actions = root + .Element(ns + "Actions")? + .Elements() + .ToList() ?? []; + List<XElement> 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; + +/// <summary> +/// Identifies this installation's logon task. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +internal sealed record GatewayTaskIdentity +{ + public const string DisplayName = "OpenClaw Gateway"; + + private GatewayTaskIdentity(string name, string packageFamilyName, string userSid) + { + Name = name; + PackageFamilyName = packageFamilyName; + UserSid = userSid; + } + + /// <summary>The Task Scheduler name, without a folder path.</summary> + 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); + } + + /// <summary> + /// True when <paramref name="taskName"/> 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. + /// </summary> + 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; + +/// <summary> +/// Drives Task Scheduler through the inbox <c>schtasks.exe</c>. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// Nothing in this type elevates. Registration must run as the signed-in user: +/// a task registered elevated is owned by <c>BUILTIN\Administrators</c>, and +/// the user can then neither overwrite nor delete their own gateway task. +/// </para> +/// </remarks> +internal sealed class SchTasksGatewayScheduler : IGatewayTaskScheduler +{ + private readonly string _executablePath; + private readonly Func<string[], CancellationToken, Task<SchTasksOutcome>>? _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<string[], CancellationToken, Task<SchTasksOutcome>> run) + { + ArgumentNullException.ThrowIfNull(run); + _executablePath = "schtasks.exe"; + _run = run; + } + + public async Task<GatewayTaskProbe> 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<GatewayTaskOperation> 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<GatewayTaskOperation> 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<GatewayTaskOperation> 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"); + } + + /// <summary> + /// Separates a missing task from a refused one without reading localized + /// diagnostics. + /// </summary> + /// <remarks> + /// <para> + /// <c>schtasks.exe</c> 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. + /// </para> + /// <para> + /// 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. + /// </para> + /// </remarks> + private async Task<GatewayTaskProbe> 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; + } + + /// <summary> + /// Reports whether this account can see that the task is gone. + /// </summary> + /// <remarks> + /// Answers false when the listing itself fails, because an unanswerable + /// question is not evidence of absence. + /// </remarks> + private async Task<bool> 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); + } + + /// <summary> + /// Reports whether the CSV listing names this task. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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<SchTasksOutcome> 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<string> standardOutput = process.StandardOutput.ReadToEndAsync( + cancellationToken); + Task<string> 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/Gateway/SessionGatewayClient.cs b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs new file mode 100644 index 00000000..94b83776 --- /dev/null +++ b/src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs @@ -0,0 +1,337 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Session; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Gateway; + +/// <summary>What to start inside the session.</summary> +/// <remarks> +/// <see cref="Port"/> is null unless the user pinned one, in which case +/// OpenClaw resolves the port from its own configuration. +/// </remarks> +internal sealed record GatewayStartRequest( + string HelperPath, + string NodePath, + string ApplicationDirectory, + int? Port) +{ + /// <summary> + /// The guest-visible directory to run in. The shared workspace is used when + /// the user configured nothing. + /// </summary> + public string? WorkingDirectory { get; init; } +} + +/// <summary>The identity of a gateway that was started.</summary> +internal sealed record GatewayStartOutcome( + int ProcessId, + DateTimeOffset ProcessStartTimeUtc, + string StatusPath, + string LogPath) +{ + public string? HelperPath { get; init; } +} + +/// <summary> +/// Starts, inspects, and stops the gateway inside the owned session. +/// </summary> +internal interface ISessionGatewayClient +{ + Task<GatewayStartOutcome> StartAsync( + SessionRecord session, + GatewayStartRequest request, + CancellationToken cancellationToken); + + Task<SessionInspectResult> InspectAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken); + + Task<SessionInspectResult> StopAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken); +} + +/// <summary> +/// Starts and inspects the gateway inside the owned session. +/// </summary> +/// <remarks> +/// 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 <c>cmd.exe</c>. +/// </remarks> +internal sealed class SessionGatewayClient : ISessionGatewayClient +{ + private readonly IMxcSessionClient _backend; + private readonly Action<string> _log; + private readonly Func<IReadOnlyDictionary<string, string>> _buildEnvironment; + private readonly Func<SessionRecord, bool> _isCurrentRecord; + + public SessionGatewayClient(IMxcSessionClient backend, Action<string> log, + Func<IReadOnlyDictionary<string, string>>? buildEnvironment = null, + Func<SessionRecord, bool>? isCurrentRecord = null) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(log); + _backend = backend; + _log = log; + _buildEnvironment = buildEnvironment ?? + OpenClawRuntimeEnvironment.Build; + _isCurrentRecord = isCurrentRecord ?? (_ => true); + } + + public async Task<GatewayStartOutcome> 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<string> 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 ?? workspace, + 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); + } + } + + /// <summary> + /// Ends the recorded gateway inside the session. + /// </summary> + /// <remarks> + /// The guest verifies identity before terminating anything, so a recorded + /// identifier that now belongs to an unrelated process stops nothing. + /// </remarks> + public Task<SessionInspectResult> StopAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) => + SendAsync(session, gateway, helperPath, "--stop", cancellationToken); + + /// <summary> + /// Re-establishes, inside the session, whether the recorded gateway is + /// still ours and still serving. + /// </summary> + public Task<SessionInspectResult> InspectAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) => + SendAsync(session, gateway, helperPath, "--inspect", cancellationToken); + + private async Task<SessionInspectResult> 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, + LaunchPending = gateway.LaunchPending, + 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<SessionLaunchResult> 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/GatewayLauncher.cs b/src/OpenClaw.Launcher/GatewayLauncher.cs deleted file mode 100644 index 9f4f0fd1..00000000 --- a/src/OpenClaw.Launcher/GatewayLauncher.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Diagnostics; - -namespace OpenClaw.Launcher; - -public static class GatewayLauncher -{ - public static async Task<int> RunAsync( - string nodePath, - string payloadDirectory, - IReadOnlyList<string> openClawArguments, - CancellationToken cancellationToken, - Action<string>? log = null) - { - ProcessStartInfo startInfo = CreateStartInfo( - nodePath, - payloadDirectory, - openClawArguments); - 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); - 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); - } - - throw; - } - } - - public static ProcessStartInfo CreateStartInfo( - string nodePath, - string applicationDirectory, - IReadOnlyList<string> openClawArguments, - string? workingDirectory = null) - { - 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"; - startInfo.ArgumentList.Add(entryPoint); - - foreach (string argument in openClawArguments) - { - startInfo.ArgumentList.Add(argument); - } - - return startInfo; - } -} 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 ce9de5d2..79b68c09 100644 --- a/src/OpenClaw.Launcher/HostDiagnosticLog.cs +++ b/src/OpenClaw.Launcher/HostDiagnosticLog.cs @@ -1,15 +1,12 @@ using System.Globalization; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; namespace OpenClaw.Launcher; -public sealed class HostDiagnosticLog : IDisposable +internal 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; @@ -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( @@ -83,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, @@ -119,40 +119,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/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/HostOptions.cs b/src/OpenClaw.Launcher/HostOptions.cs index 146e152e..5c14526f 100644 --- a/src/OpenClaw.Launcher/HostOptions.cs +++ b/src/OpenClaw.Launcher/HostOptions.cs @@ -1,7 +1,8 @@ namespace OpenClaw.Launcher; -public sealed record HostOptions( +internal sealed record HostOptions( string? PackagedApplicationDirectory, + string? PackagedNodeArchivePath, IReadOnlyList<string> OpenClawArguments) { public static HostOptions Parse(IReadOnlyList<string> arguments) => @@ -23,9 +24,13 @@ internal static HostOptions Parse( "openclaw.mjs")) ? packagedApplicationDirectory : null; + string? packagedNodeArchivePath = NodeRuntimeInstaller.FindArchivePath( + Path.Combine(baseDirectory, "runtime"), + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); return new HostOptions( directApplicationDirectory, - arguments.ToArray()); + packagedNodeArchivePath, + [.. arguments]); } } diff --git a/src/OpenClaw.Launcher/HostPaths.cs b/src/OpenClaw.Launcher/HostPaths.cs new file mode 100644 index 00000000..57a98614 --- /dev/null +++ b/src/OpenClaw.Launcher/HostPaths.cs @@ -0,0 +1,195 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher; + +/// <summary> +/// The running process's MSIX package identity. +/// </summary> +internal static class PackageIdentity +{ + private const int ErrorInsufficientBuffer = 122; + private const int AppModelErrorNoPackage = 15700; + private const int AppModelErrorNoApplication = 15703; + + /// <summary> + /// Prefix required by MXC when a packaged caller provisions a sandbox. + /// </summary> + public const string ApplicationIdPrefix = "PFN:"; + + /// <summary> + /// The package family name, or null when running unpackaged. + /// </summary> + /// <remarks> + /// Unpackaged is a normal development configuration, not an error, so it + /// is reported as null rather than thrown. + /// </remarks> + public static string? TryGetPackageFamilyName() + { + if (!OperatingSystem.IsWindows()) + { + 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 = readIdentity(ref length, null); + if (result is AppModelErrorNoPackage or AppModelErrorNoApplication) + { + return null; + } + + if (result != ErrorInsufficientBuffer || length == 0) + { + throw new InvalidOperationException( + $"Unable to determine package identity (error {result})."); + } + + var value = new char[length]; + result = readIdentity(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)); + } + + /// <summary> + /// Builds the MXC application id for a package family name. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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); + + [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); +} + +/// <summary> +/// Where this installation keeps its own writable state. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +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 PreResetReportPath => + Path.Combine(StateRoot, "Logs", "pre-reset.log"); + + public string SessionStatePath => Path.Combine(StateRoot, "session.json"); + + /// <summary> + /// The marker written only after explicit setup completes all of its + /// session, launch-configuration, and sign-in-recovery steps. + /// </summary> + public string SetupStatePath => Path.Combine(StateRoot, "setup.json"); + + /// <summary> + /// The script the logon task runs. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public string GatewayLauncherPath => + Path.Combine(StateRoot, "gateway-launcher.cmd"); + + /// <summary> + /// Where the recorded gateway process and its persistence choices live. + /// </summary> + public string GatewayStatePath => Path.Combine(StateRoot, "gateway.json"); + + /// <summary> + /// The gateway's launch configuration, kept separate from its recorded + /// process so that stopping the gateway never discards the user's port. + /// </summary> + 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); + } + + /// <summary> + /// Builds paths rooted at an arbitrary directory, for tests and diagnosis. + /// </summary> + 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 new file mode 100644 index 00000000..9b621899 --- /dev/null +++ b/src/OpenClaw.Launcher/HostStartup.cs @@ -0,0 +1,35 @@ +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<HostDiagnosticLog> CreateDiagnostics { get; init; } + + public required string BaseDirectory { get; init; } + + public required TextWriter Output { get; init; } + + public required TextWriter Error { get; init; } + + public Session.IInstallationLifecycle? InstallationLifecycle { get; init; } + + public Func<string, string?>? ReadEnvironmentVariable { get; init; } + + public static HostStartup CreateProduction() => new() + { + Entrypoint = HostEntrypointResolver.Resolve(), + CreateDiagnostics = HostDiagnosticLog.Create, + BaseDirectory = AppContext.BaseDirectory, + Output = Console.Out, + Error = Console.Error, + InstallationLifecycle = Session.InstallationLifecycle.Production + }; +} 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<string> Arguments); + +internal sealed record MxcExecutorOutcome( + int ExitCode, + string StandardOutput, + string StandardError); + +/// <summary> +/// Runs the MXC executor. Exists so lifecycle behavior can be tested without a +/// real sandbox, a capable host, or executing a downloaded binary. +/// </summary> +internal interface IMxcExecutorInvoker +{ + Task<MxcExecutorOutcome> InvokeAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken); +} + +/// <summary> +/// Runs the MXC executor with the host's console streams attached. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal interface IMxcAttachedExecutorInvoker +{ + Task<int> InvokeAttachedAsync( + MxcExecutorInvocation invocation, + CancellationToken cancellationToken); +} + +/// <summary> +/// Temporary <see cref="IMxcSessionClient"/> transport over the executor +/// published in <c>@microsoft/mxc-sdk</c>. +/// </summary> +/// <remarks> +/// This exists only until the official Microsoft.Mxc.Sdk .NET package ships. +/// Every preview wire detail is confined to this adapter and +/// <see cref="MxcWireProtocol"/>; the published SDK adapter must satisfy the +/// same <see cref="IMxcSessionClient"/> behavior. +/// </remarks> +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<MxcProvisionResult> ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.AppId)) + { + throw new MxcException( + MxcErrorCode.PolicyValidation, + "A packaged caller must supply PFN:<packageFamilyName>."); + } + + 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<MxcExecutionResult> 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); + } + + /// <summary> + /// Runs a command with the host's console streams attached. + /// </summary> + /// <remarks> + /// 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". + /// </remarks> + public Task<int> 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)); + + /// <summary> + /// 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. + /// </summary> + public async Task<MxcBackendProbe> 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<MxcExecutorOutcome> 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<MxcExecutorOutcome> 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<string> standardOutput = process.StandardOutput.ReadToEndAsync( + cancellationToken); + Task<string> 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<int> 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; + +/// <summary> +/// Error classifications this package acts on. Unrecognized backend codes map +/// to <see cref="Unknown"/> and keep their original text in +/// <see cref="MxcException.BackendCode"/> rather than being reshaped into a +/// code that implies a recovery path the backend did not report. +/// </summary> +internal enum MxcErrorCode +{ + Unknown, + + /// <summary>The persisted identity is not a well-formed sandbox id.</summary> + MalformedId, + + /// <summary>The sandbox named by a well-formed id no longer exists.</summary> + StaleId, + + /// <summary>The backend rejected the requested policy.</summary> + PolicyValidation, + + /// <summary> + /// The backend could not parse or accept the request this package built. + /// This indicates a defect here, not a recoverable runtime condition. + /// </summary> + MalformedRequest, + + /// <summary>No state-aware backend is registered for the id's prefix.</summary> + UnsupportedContainment, + + /// <summary> + /// The backend attempted the operation and failed, e.g. exec against a + /// session that is provisioned but not started. + /// </summary> + BackendError, + + /// <summary>The MXC runtime is missing, unusable, or unsupported here.</summary> + RuntimeUnavailable, + + /// <summary>The runtime produced output this package cannot interpret.</summary> + 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; } + + /// <summary> + /// The verbatim backend error code, preserved even when + /// <see cref="Code"/> is <see cref="MxcErrorCode.Unknown"/>, so diagnostics + /// report what the runtime actually said. + /// </summary> + 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; + +/// <summary> +/// Windows build identity, including the update build revision that the +/// documented IsolationSession minimum specifies. +/// </summary> +internal sealed record MxcHostBuild(int Build, int UpdateBuildRevision) +{ + public override string ToString() => $"{Build}.{UpdateBuildRevision}"; +} + +internal enum MxcHostSupport +{ + /// <summary>The host build could not be determined.</summary> + Unknown, + + Supported, + + Unsupported +} + +/// <summary> +/// How <see cref="MxcReadinessReport.HostSupport"/> 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. +/// </summary> +internal enum MxcSupportEvidence +{ + /// <summary>Nothing could be established.</summary> + None, + + /// <summary>Inferred from the Windows build against the documented minimum.</summary> + HostBuild, + + /// <summary>Measured by the runtime's own host capability detector.</summary> + 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; +} + +/// <summary> +/// 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. +/// </summary> +internal static class MxcReadiness +{ + /// <summary> + /// 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. + /// </summary> + public static readonly MxcHostBuild MinimumHostBuild = new(26340, 9212); + + /// <summary> + /// 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. + /// </summary> + public static MxcReadinessReport Unavailable(string reason) => + new( + null, + null, + reason, + MxcHostSupport.Unknown, + null, + MxcSupportEvidence.None); + + public static Task<MxcReadinessReport> 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<MxcReadinessReport> ProbeAsync( + string baseDirectory, + Func<string, string?> readEnvironmentVariable, + Func<MxcHostBuild?> readHostBuild, + Func<MxcRuntimeLocation, CancellationToken, Task<MxcBackendProbe>> 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"; + + /// <summary> + /// 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. + /// </summary> + 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; + +/// <summary> +/// 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. +/// </summary> +internal sealed record MxcRuntimeProvenance( + string Package, + string Version, + string Architecture); + +internal sealed record MxcRuntimeLocation( + string Directory, + string ExecutorPath, + string PackageLifecyclePath, + MxcRuntimeProvenance? Provenance); + +/// <summary> +/// Finds the MXC runtime staged beside the launcher. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal static class MxcRuntimeLocator +{ + /// <summary> + /// Directory name under the application base that holds the staged runtime. + /// </summary> + 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"; + + /// <summary> + /// Development and compatibility-experiment override naming a directory + /// that already contains a verified runtime layout. + /// </summary> + public const string RuntimeDirectoryVariable = "OPENCLAW_MXC_RUNTIME_DIR"; + + public static MxcRuntimeLocation Locate() => + Locate(AppContext.BaseDirectory, Environment.GetEnvironmentVariable); + + internal static MxcRuntimeLocation Locate( + string baseDirectory, + Func<string, string?> 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)); + } + + /// <summary> + /// 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. + /// </summary> + 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..50a1fd33 --- /dev/null +++ b/src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs @@ -0,0 +1,238 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace OpenClaw.Launcher.Mxc; + +internal sealed record MxcBackendProbe( + bool IsolationSessionAvailable, + string? Tier, + IReadOnlyList<string> Warnings); + +/// <summary> +/// 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. +/// </summary> +internal enum MxcContainment +{ + IsolationSession +} + +/// <summary> +/// 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. +/// </summary> +internal readonly record struct MxcSandboxId +{ + private MxcSandboxId(string value, string backendPrefix) + { + Value = value; + BackendPrefix = backendPrefix; + } + + public string Value { get; } + + /// <summary> + /// Leading segment of <see cref="Value"/> that selects the backend, such as + /// <c>iso</c> for IsolationSession. + /// </summary> + public string BackendPrefix { get; } + + public const string IsolationSessionPrefix = "iso"; + + public bool IsIsolationSession => + string.Equals(BackendPrefix, IsolationSessionPrefix, StringComparison.Ordinal); + + /// <summary> + /// Parses a persisted or backend-returned identity. A missing or unknown + /// prefix is a malformed identity, not an unknown backend to guess at. + /// </summary> + 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]); + } + + /// <summary> + /// Reports the application this identity was issued to, when the backend + /// encodes one. + /// </summary> + /// <remarks> + /// <para> + /// 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. + /// </para> + /// <para> + /// 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. + /// </para> + /// </remarks> + 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; +} + +/// <summary> +/// 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. +/// </summary> +internal sealed record MxcProvisionMetadata( + string AgentUserName, + string AgentUserSid, + string EphemeralWorkspacePath); + +/// <summary> +/// Request to create a sandbox. <paramref name="AppId"/> must be +/// <c>PFN:<packageFamilyName></c> for a packaged caller; it is fixed for +/// the sandbox lifetime and is rejected on every later phase. +/// </summary> +internal sealed record MxcProvisionRequest(string AppId); + +internal sealed record MxcProvisionResult( + MxcSandboxId SandboxId, + MxcProvisionMetadata? Metadata, + string? CorrelationVector); + +/// <summary> +/// A command to run inside a started sandbox. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal sealed record MxcExecutionRequest(string CommandLine); + +internal sealed record MxcExecutionResult( + int ExitCode, + string StandardOutput, + string StandardError); + +/// <summary> +/// The MXC lifecycle operations this package requires, shaped after the +/// upcoming <c>Microsoft.Mxc.Sdk</c> state-aware API so the published SDK can +/// replace the transport without changing session or gateway behavior. +/// </summary> +internal interface IMxcSessionClient +{ + Task<MxcProvisionResult> ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken); + + Task StartAsync( + MxcSandboxId sandboxId, + string? correlationVector, + CancellationToken cancellationToken); + + Task<MxcExecutionResult> ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken); + + /// <summary> + /// Runs a command with the caller's console attached, returning its exit + /// code. Nothing is captured. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + Task<int> 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; } +} + +/// <summary> +/// 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. +/// </summary> +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; } + + /// <summary> + /// Backend operation that failed. Observed on IsolationSession exec + /// failures, e.g. "IsoSessionOps.RunProcessWithOptionsAsync". + /// </summary> + [JsonPropertyName("operation")] + public string? Operation { get; set; } + + /// <summary>Underlying Windows status, e.g. "0x80070520".</summary> + [JsonPropertyName("nativeCode")] + public string? NativeCode { get; set; } + + /// <summary> + /// Backend-authored guidance. Surfaced verbatim because it names the + /// actual recovery step more precisely than this package can infer. + /// </summary> + [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; + +/// <summary> +/// 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. +/// </summary> +internal static class MxcWireProtocol +{ + /// <summary> + /// 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. + /// </summary> + 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 + } + } + } + }; + + /// <summary> + /// 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. + /// </summary> + 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."); + } + + /// <summary> + /// Parses the single response envelope produced by a non-execution phase. + /// An <c>{error}</c> envelope is raised as an <see cref="MxcException"/> + /// carrying the backend's own code. + /// </summary> + 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; + } + + /// <summary> + /// Discriminates an MXC dispatch failure from ordinary command output. + /// </summary> + /// <remarks> + /// 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 <c>{error:{code}}</c> envelope qualifies. + /// </remarks> + 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); + } + } + + /// <summary> + /// Reads the executor's <c>--probe</c> output. This is plain JSON, not a + /// lifecycle result/error envelope, so it is parsed separately. + /// </summary> + 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/NodeRuntimeInstaller.cs b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs new file mode 100644 index 00000000..92451d99 --- /dev/null +++ b/src/OpenClaw.Launcher/NodeRuntimeInstaller.cs @@ -0,0 +1,246 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenClaw.Launcher; + +internal static partial class NodeRuntimeInstaller +{ + public static string? FindArchivePath( + string runtimeDirectory, + Architecture architecture) + { + if (!Directory.Exists(runtimeDirectory)) + { + return null; + } + + 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 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", + Path.GetFileNameWithoutExtension(archivePath)); + + public static NodeRuntime EnsureInstalled( + string archivePath, + Action<string> log) + { + Architecture architecture = RuntimeInformation.ProcessArchitecture; + Version version = GetArchiveVersion(archivePath, architecture); + return EnsureInstalled( + archivePath, + GetInstallDirectory(archivePath), + path => NodeRuntimeResolver.ResolvePath( + path, + version), + log); + } + + internal static NodeRuntime EnsureInstalled( + string archivePath, + string installDirectory, + Func<string, NodeRuntime> resolveNode, + Action<string> log) + { + string executablePath = Path.Combine(installDirectory, "node.exe"); + if (!File.Exists(archivePath)) + { + throw new FileNotFoundException( + "The packaged Node.js runtime archive was not found.", + archivePath); + } + + // 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 + { + try + { + ownsMutex = mutex.WaitOne(); + } + catch (AbandonedMutexException) + { + ownsMutex = true; + } + + string stagingDirectory = $"{installDirectory}.extract"; + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + + if (File.Exists(executablePath)) + { + try + { + return resolveNode(executablePath); + } + catch (InvalidOperationException exception) + { + log($"Reinstalling invalid bundled Node.js: {exception.Message}"); + } + } + + try + { + ExtractRuntime( + archivePath, + 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 + { + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, recursive: true); + } + } + } + finally + { + if (ownsMutex) + { + mutex.ReleaseMutex(); + } + } + } + + 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) + { + string archiveRoot = Path.GetFileNameWithoutExtension(archivePath); + 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); + } + } + + [GeneratedRegex( + @"^node-v(?<version>\d+\.\d+\.\d+)-win-(?<architecture>x64|arm64)\.zip$", + RegexOptions.CultureInvariant)] + private static partial Regex ArchiveNameRegex(); + + 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 dd93b0cb..6f1e9992 100644 --- a/src/OpenClaw.Launcher/NodeRuntimeResolver.cs +++ b/src/OpenClaw.Launcher/NodeRuntimeResolver.cs @@ -13,94 +13,71 @@ 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 = - [ - 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<NodeRuntime> ResolveAsync(CancellationToken cancellationToken) => - ResolveAsync( - FindPathCandidates(), - QueryVersionAsync, - ReadArchitecture, - RuntimeInformation.ProcessArchitecture, - cancellationToken); - - internal static async Task<NodeRuntime> ResolveAsync( - IReadOnlyList<string> candidates, - Func<string, CancellationToken, Task<string>> queryVersion, - Func<string, Architecture> readArchitecture, - Architecture requiredArchitecture, - CancellationToken cancellationToken) + 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, + Version expectedVersion) { - if (candidates.Count == 0) + if (!File.Exists(executablePath)) { 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<string>(); - foreach (string candidate in candidates) + return Resolve( + executablePath, + expectedVersion, + ReadVersion, + ReadArchitecture, + RuntimeInformation.ProcessArchitecture); + } + + internal static NodeRuntime Resolve( + string executablePath, + Version expectedVersion, + Func<string, string> readVersion, + Func<string, Architecture> readArchitecture, + Architecture requiredArchitecture) + { + try { - try + Version version = ParseVersion(readVersion(executablePath)); + if (version != expectedVersion) { - string output = await queryVersion(candidate, cancellationToken); - 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()); @@ -109,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; @@ -117,97 +94,19 @@ 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."; + "Run `clawctl setup` to prepare the Node.js runtime bundled " + + "with the installed OpenClaw package."; - private static IReadOnlyList<string> FindPathCandidates() + private static string ReadVersion(string executablePath) { - string? pathValue = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrWhiteSpace(pathValue)) + // 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)) { - return []; - } - - var candidates = new List<string>(); - var seen = new HashSet<string>(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; - } - - private static async Task<string> QueryVersionAsync( - string executablePath, - CancellationToken cancellationToken) - { - 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 - { - Task<string> standardOutput = process.StandardOutput.ReadToEndAsync( - timeout.Token); - Task<string> standardError = process.StandardError.ReadToEndAsync( - timeout.Token); - await process.WaitForExitAsync(timeout.Token); - string output = await standardOutput; - string error = await standardError; - 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); - } - 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) @@ -229,8 +128,4 @@ private static Architecture ReadArchitecture(string executablePath) @"^v?(?<version>\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 c5aa467e..5073c2d1 100644 --- a/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj +++ b/src/OpenClaw.Launcher/OpenClaw.Launcher.csproj @@ -32,6 +32,11 @@ <WindowsAppSdkSelfContained Condition="'$(IncludePackagingContent)' == 'true'">true</WindowsAppSdkSelfContained> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Spectre.Console" /> + <PackageReference Include="System.CommandLine" /> + </ItemGroup> + <ItemGroup Condition="'$(IncludePackagingContent)' == 'true'"> <PackageReference Include="Microsoft.WindowsAppSDK.Base" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> @@ -46,12 +51,17 @@ PrivateAssets="all" /> </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\OpenClaw.SessionProtocol\OpenClaw.SessionProtocol.csproj" /> + </ItemGroup> + <ItemGroup> <AppxManifest Include="Package.appxmanifest"> <SubType>Designer</SubType> </AppxManifest> <Content Include="Images\**\*.png" /> <InternalsVisibleTo Include="OpenClaw.Launcher.Tests" /> + <InternalsVisibleTo Include="OpenClaw.Launcher.AotSmoke" /> </ItemGroup> <Target Name="GenerateVersionedAppxManifest" @@ -74,6 +84,13 @@ </ItemGroup> </Target> + <PropertyGroup Condition="'$(IncludePackagingContent)' == 'true'"> + <MxcRuntimeArchitecture Condition="'$(RuntimeIdentifier)' == 'win-x64'">x64</MxcRuntimeArchitecture> + <MxcRuntimeArchitecture Condition="'$(RuntimeIdentifier)' == 'win-arm64'">arm64</MxcRuntimeArchitecture> + <MxcRuntimeContentDirectory>$(MSBuildThisFileDirectory)..\..\content\mxc\$(MxcRuntimeArchitecture)\</MxcRuntimeContentDirectory> + <SessionHostContentDirectory>$(MSBuildThisFileDirectory)..\..\content\session-host\$(MxcRuntimeArchitecture)\</SessionHostContentDirectory> + </PropertyGroup> + <ItemGroup Condition="'$(IncludePackagingContent)' == 'true'"> <Content Include="$(MSBuildThisFileDirectory)..\..\content\openclaw\app\**\*" Link="app\%(RecursiveDir)%(Filename)%(Extension)" @@ -81,6 +98,17 @@ <Content Include="$(MSBuildThisFileDirectory)..\..\content\openclaw\payload-files.json" Link="payload\payload-files.json" CopyToOutputDirectory="PreserveNewest" /> + <Content Include="$(MSBuildThisFileDirectory)..\..\content\openclaw\runtime\$(NodeRuntimeArchiveFileName)" + Condition="'$(NodeRuntimeArchiveFileName)' != ''" + Link="runtime\%(Filename)%(Extension)" + CopyToOutputDirectory="PreserveNewest" /> + <Content Include="$(MxcRuntimeContentDirectory)**\*" + Link="mxc\$(MxcRuntimeArchitecture)\%(RecursiveDir)%(Filename)%(Extension)" + CopyToOutputDirectory="PreserveNewest" /> + <Content Include="$(SessionHostContentDirectory)**\*" + Exclude="$(SessionHostContentDirectory)**\*.pdb;$(SessionHostContentDirectory)**\*.xml" + Link="session-host\$(MxcRuntimeArchitecture)\%(RecursiveDir)%(Filename)%(Extension)" + CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> <Target Name="ValidatePackagingContent" @@ -90,6 +118,16 @@ Text="Missing expanded OpenClaw application content." /> <Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\content\openclaw\payload-files.json')" Text="Missing content\openclaw\payload-files.json." /> + <Error Condition="'$(NodeRuntimeArchiveFileName)' == '' Or !Exists('$(MSBuildThisFileDirectory)..\..\content\openclaw\runtime\$(NodeRuntimeArchiveFileName)')" + Text="Missing architecture-specific bundled Node.js archive." /> + <Error Condition="'$(MxcRuntimeArchitecture)' == ''" + Text="Packaging requires RuntimeIdentifier win-x64 or win-arm64 to select the MXC runtime." /> + <Error Condition="!Exists('$(MxcRuntimeContentDirectory)wxc-exec.exe')" + Text="Missing the staged MXC runtime. Run scripts\Get-MxcRuntime.ps1 -Architecture $(MxcRuntimeArchitecture)." /> + <Error Condition="!Exists('$(MxcRuntimeContentDirectory)mxc-runtime.json')" + Text="Missing MXC runtime provenance. Run scripts\Get-MxcRuntime.ps1 -Architecture $(MxcRuntimeArchitecture)." /> + <Error Condition="!Exists('$(SessionHostContentDirectory)openclaw-session-host.exe')" + Text="Missing the published isolated-session guest helper. scripts\Build-MSIX.ps1 stages it for $(MxcRuntimeArchitecture)." /> </Target> <Target Name="FinalizeNativeAotPackagePayload" diff --git a/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs new file mode 100644 index 00000000..e4171a66 --- /dev/null +++ b/src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs @@ -0,0 +1,153 @@ +namespace OpenClaw.Launcher; + +/// <summary> +/// The environment every OpenClaw process this package starts must receive. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +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"; + + /// <summary> + /// Every OpenClaw process this package starts runs inside the isolated + /// session, so the reported isolation state is constant. + /// </summary> + public const string GatewayIsolationValue = "enabled"; + + 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"; + + /// <summary> + /// The variables to apply, as an ordinary dictionary. + /// </summary> + public static IReadOnlyDictionary<string, string> Build() => + new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + [SupervisorModeVariable] = ExternalValue, + [ServiceRepairPolicyVariable] = ExternalValue, + [NoAutoUpdateVariable] = NoAutoUpdateValue, + [GatewayIsolationVariable] = GatewayIsolationValue, + }; + + public static IReadOnlyDictionary<string, string> Build( + bool isInteractive, + Func<string, string?> readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(readEnvironmentVariable); + + Dictionary<string, string> 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; + } + + /// <summary> + /// Applies the variables to a process environment. + /// </summary> + public static void ApplyTo(IDictionary<string, string?> environment) + { + ArgumentNullException.ThrowIfNull(environment); + + foreach ((string name, string value) in Build()) + { + environment[name] = value; + } + } + + public static void ApplyTo( + IDictionary<string, string?> environment, + bool isInteractive, + Func<string, string?> readEnvironmentVariable) + { + ArgumentNullException.ThrowIfNull(environment); + + foreach ((string name, string value) in Build(isInteractive, readEnvironmentVariable)) + { + environment[name] = value; + } + } + + private static void AddIfPresent( + Dictionary<string, string> environment, + string name, + string? value) + { + if (value is not null) + { + environment[name] = value; + } + } + + private static void AddDefault( + Dictionary<string, string> environment, + string name, + string? currentValue, + string value) + { + if (currentValue is null) + { + environment[name] = 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"> <uap5:AppExecutionAlias> <uap5:ExecutionAlias Alias="openclaw.exe" /> + </uap5:AppExecutionAlias> + </uap5:Extension> + </Extensions> + </Application> + <Application + Id="Control" + Executable="openclaw.exe" + EntryPoint="Windows.FullTrustApplication"> + <uap:VisualElements + DisplayName="OpenClaw Gateway" + Description="OpenClaw Gateway management CLI" + AppListEntry="none" + BackgroundColor="transparent" + Square150x150Logo="Images\Square150x150Logo.png" + Square44x44Logo="Images\Square44x44Logo.png" /> + <Extensions> + <uap5:Extension + Category="windows.appExecutionAlias" + Executable="openclaw.exe" + EntryPoint="Windows.FullTrustApplication"> + <uap5:AppExecutionAlias> <uap5:ExecutionAlias Alias="clawctl.exe" /> </uap5:AppExecutionAlias> </uap5:Extension> diff --git a/src/OpenClaw.Launcher/Program.cs b/src/OpenClaw.Launcher/Program.cs index a3402c4d..2f7d2975 100644 --- a/src/OpenClaw.Launcher/Program.cs +++ b/src/OpenClaw.Launcher/Program.cs @@ -1,24 +1,43 @@ -using System.Reflection; +using System.CommandLine; +using System.Diagnostics.CodeAnalysis; +using OpenClaw.SessionProtocol; namespace OpenClaw.Launcher; internal static class Program { - public static async Task<int> Main(string[] args) + public static async Task<int> 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 = + "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.")] + internal static async Task<int> 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; bool diagnosticWarningWritten = false; bool consoleWarningWritten = false; + IDisposable? consoleRestore = null; + TextWriter output = startup.Output; + TextWriter error = startup.Error; void WriteConsoleError(string message) { try { - Console.Error.WriteLine(message); + error.WriteLine(message); } catch (Exception exception) when ( exception is IOException or ObjectDisposedException) @@ -34,7 +53,7 @@ void WriteConsoleError(string message) try { - diagnostics = HostDiagnosticLog.Create(); + diagnostics = startup.CreateDiagnostics(); } catch (Exception exception) when ( exception is IOException or @@ -71,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 { @@ -85,121 +119,738 @@ PlatformNotSupportedException or try { WriteDiagnostic($"Host started through the {commandName} entrypoint."); - HostOptions options = HostOptions.Parse(args); - return entrypoint == HostEntrypoint.Control + 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, - WriteConsoleError, - Console.Out) - : await RunAgentAsync(options, WriteDiagnostic); + output, + error, + startup.InstallationLifecycle).ConfigureAwait(false) + : await RunAgentAsync( + options, + WriteDiagnostic, + startup.InstallationLifecycle is null + ? null + : startup.InstallationLifecycle.CreateRuntime, + readEnvironmentVariable: startup.ReadEnvironmentVariable) + .ConfigureAwait(false); } 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(); } } + // 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<int> RunAgentAsync( HostOptions options, Action<string> log, - Func<CancellationToken, Task<NodeRuntime>>? resolveNode = null) => - await RunAgentAsync( - options, - log, - resolveNode ?? NodeRuntimeResolver.ResolveAsync, - GatewayLauncher.RunAsync); - - // 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<int> RunAgentAsync( - HostOptions options, - Action<string> log, - Func<CancellationToken, Task<NodeRuntime>> resolveNode, - LaunchOpenClawAsync launchOpenClaw) + Func<Action<string>, Session.SessionRuntime>? createSessionRuntime = null, + Func<CancellationToken, Task<Mxc.MxcReadinessReport>>? probeReadiness = null, + Func<string?>? getPackageFamilyName = null, + Func<string, string?>? readEnvironmentVariable = null, + Func<bool>? isInteractive = null) { - NodeRuntime nodeRuntime = await resolveNode(CancellationToken.None); - log( - $"Using Node.js {nodeRuntime.Version} from " + - $"{nodeRuntime.ExecutablePath}."); string applicationDirectory = GetPackagedApplicationDirectory(options); log("Using the OpenClaw application directly from the package."); - return await launchOpenClaw( - nodeRuntime.ExecutablePath, - applicationDirectory, - options.OpenClawArguments, - CancellationToken.None, - log); + + 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 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<int> RunControlAsync( HostOptions options, IReadOnlyList<string> args, Action<string> log, - Action<string> writeError, TextWriter output, - Func<CancellationToken, Task<NodeRuntime>>? resolveNode = null) + TextWriter error, + Session.IInstallationLifecycle? installationLifecycle = null, + Func<string, string?>? readEnvironmentVariable = null) { - ClawCtlCommandParseResult parsed = ClawCtlCommandParser.Parse(args); - if (parsed.Error is not null) - { - writeError($"clawctl: {parsed.Error}"); - ClawCtlConsole.WriteUsage(Console.Error); - return 2; - } - - switch (parsed.Command) - { - case ClawCtlCommand.Help: - ClawCtlConsole.WriteHelp(output); - return 0; - case ClawCtlCommand.Version: - output.WriteLine( - Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? - "unknown"); - return 0; - case ClawCtlCommand.Setup: - { - NodeRuntime nodeRuntime = await ( - resolveNode ?? NodeRuntimeResolver.ResolveAsync)( - CancellationToken.None); - ClawCtlConsole.WriteNodeRuntimeSummary(output, nodeRuntime); - string applicationDirectory = - GetPackagedApplicationDirectory(options); - log("Confirmed the packaged OpenClaw application is present."); - ClawCtlConsole.WriteReadinessSummary( - output, - applicationDirectory); - return 0; + 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 = 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.SessionRuntime runtime = GetSessionRuntime(); + Session.SessionStatus status = await runtime + .Coordinator.ProbeRecordedStatusAsync(cancellationToken) + .ConfigureAwait(false); + 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); + Session.SetupStateResult setup = + runtime.SetupState.Read(runtime.ApplicationId); + return WriteResult(new StatusCommandResult( + status, + gateway, + recovery, + setup.Record?.AgentNodeVersion)); + }, + CollectLogs = async (requestedPath, cancellationToken) => + { + HostPaths paths = HostPaths.Create(); + Gateway.DiagnosticsBundleResult result = + await Gateway.GatewayRuntime.Create( + options, + paths, + GetSessionRuntime(), + log) + .CollectLogsAsync(requestedPath, cancellationToken) + .ConfigureAwait(false); + return WriteResult(new CollectLogsCommandResult(result)); + }, + 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(); + Session.TeardownResult result = await lifecycle.TeardownAsync( + options, runtime, log, lockAlreadyHeld: false, cancellationToken) + .ConfigureAwait(false); + return WriteResult(new TeardownCommandResult(result)); + }, + PowerShell = cancellationToken => RunPowerShellAsync( + options, + GetSessionRuntime(), + cancellationToken), + GatewayStart = async cancellationToken => + { + Session.SessionRuntime runtime = GetSessionRuntime(); + Gateway.GatewayStartResult result = await Gateway.GatewayRuntime + .Create(options, runtime.Paths, runtime, log) + .Controller + .StartAsync(runtime.HelperPath, cancellationToken) + .ConfigureAwait(false); + 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 => + { + Gateway.GatewayRuntime runtime = Gateway.GatewayRuntime.Create(options, log); + Gateway.GatewayStatusReport result = await runtime.Controller + .GetStatusAsync(GetSessionRuntime().HelperPath, cancellationToken) + .ConfigureAwait(false); + 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, runtime.Paths, runtime, log) + .Controller + .StopAsync(runtime.HelperPath, cancellationToken) + .ConfigureAwait(false); + 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() + { + 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 the session host. 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<SetupCommandResult> RunSetupAsync( + SetupOptions setupOptions, + HostOptions options, + Func<Session.SessionRuntime> getSessionRuntime, + Session.IInstallationLifecycle lifecycle, + Action<string> log, + IProgress<ClawCtlProgress> 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); + + 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); + 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); + } + 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) + { + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + Error: teardownResult.Detail ?? teardownResult.Message, + Fresh: true); + } + + 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) + { + 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; + } + + RestoreFreshDiagnosticReport(report, log); + 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); + } + + return await RunSetupCoreAsync( + runtime, + options, + lifecycle, + applicationDirectory, + log, + lockAlreadyHeld: true, + warning, + localStateCleared, + fresh: true, + progress, + cancellationToken) + .ConfigureAwait(false); } - default: - throw new InvalidOperationException("Unknown clawctl command."); + + return await RunSetupCoreAsync( + 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}"); + 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}"); + 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}"); + return new SetupCommandResult( + 1, + applicationDirectory, + null, + null, + false, + warning, + Error: exception.Message, + Fresh: setupOptions.Fresh, + LocalStateCleared: localStateCleared); + } + catch (OperationCanceledException) + { + string retryCommand = setupOptions.Fresh + ? "clawctl setup --fresh" + : "clawctl setup"; + log($"Setup was cancelled before it completed. Retry `{retryCommand}`."); + 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 delegate Task<int> LaunchOpenClawAsync( - string nodePath, + internal static async Task<SetupCommandResult> RunSetupCoreAsync( + Session.SessionRuntime runtime, + HostOptions options, + Session.IInstallationLifecycle lifecycle, string applicationDirectory, - IReadOnlyList<string> openClawArguments, - CancellationToken cancellationToken, - Action<string>? log); + Action<string> log, + bool lockAlreadyHeld, + FreshSetupWarning? warning, + bool localStateCleared, + bool fresh, + IProgress<ClawCtlProgress> progress, + CancellationToken cancellationToken) + { + progress.Report(new ClawCtlProgress("Preparing the isolated session.")); + 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<string> 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); + progress.Report(new ClawCtlProgress( + "Installing Node.js in the isolated session.")); + SessionRuntimeInstallResult agentRuntime = await runtime.Executor.InstallRuntimeAsync( + record, helperPath, GetPackagedNodeArchivePath(options), cancellationToken).ConfigureAwait(false); + progress.Report(new ClawCtlProgress("Enabling gateway startup at sign-in.")); + Gateway.GatewayPersistenceInstallResult recovery = await lifecycle + .InstallRecoveryAsync(log, cancellationToken).ConfigureAwait(false); + + if (recovery.State != Gateway.GatewayPersistenceState.Ready) + { + 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); + return new SetupCommandResult( + 0, + applicationDirectory, + $"{agentRuntime.Version}", + recovery, + true, + warning, + SandboxId: record.SandboxId, + Fresh: fresh, + RuntimeLocation: SetupRuntimeLocation.IsolatedSession, + LocalStateCleared: localStateCleared); + } + + private static FreshDiagnosticReport WriteFreshDiagnosticReport( + Session.SessionRuntime runtime, + Action<string> log) + { + Session.SessionStatus session = runtime.Coordinator.GetRecordedStatus(); + Gateway.GatewayStateResult gateway = runtime.GatewayState.Read(); + 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); + var lines = File.Exists(path) + ? new List<string>(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}"); + 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"}"); + } + lines.Add("--- end pre-reset snapshot ---"); + + File.WriteAllLines(path, lines); + log($"Captured redacted pre-reset diagnostic report at {path}."); + return new FreshDiagnosticReport(path, lines); + } + + private static void RestoreFreshDiagnosticReport( + FreshDiagnosticReport report, + Action<string> 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<string> Lines); + + private static async Task<int> RunPowerShellAsync( + HostOptions options, + Session.SessionRuntime runtime, + 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); + + 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), + Session.AgentToolShim.BuildEnvironment(agentNodePath, applicationDirectory)) + }, + $"Opening {shell.DisplayName} in the isolated session.", + shell.DisplayName, + cancellationToken).ConfigureAwait(false); + } private static string GetPackagedApplicationDirectory(HostOptions options) { @@ -219,4 +870,20 @@ 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"); + 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/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; + +/// <summary>The shell opened inside the agent session.</summary> +internal sealed record AgentShell(string ExecutablePath, string DisplayName); + +/// <summary>Chooses the machine-wide PowerShell available to the agent.</summary> +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<string, bool> 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<string> 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; + +/// <summary>Where the agent's <c>openclaw</c> command lives.</summary> +internal sealed record AgentTools(string DirectoryPath, string ShimPath); + +/// <summary>Builds environment values consumed by the guest-side command shim.</summary> +internal static class AgentToolShim +{ + internal const string NodeVariable = "OPENCLAW_SHIM_NODE"; + internal const string EntryPointVariable = "OPENCLAW_SHIM_ENTRY"; + + public static IReadOnlyDictionary<string, string> BuildEnvironment( + string nodePath, + string applicationDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(nodePath); + ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory); + + return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + [NodeVariable] = nodePath, + [EntryPointVariable] = Path.Combine(applicationDirectory, "openclaw.mjs"), + }; + } +} diff --git a/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs b/src/OpenClaw.Launcher/Session/InstallationLifecycle.cs new file mode 100644 index 00000000..dbdabdee --- /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; + +/// <summary> +/// Composes the package-owned dependencies used while installing or resetting +/// the isolated OpenClaw session. +/// </summary> +internal interface IInstallationLifecycle +{ + SessionRuntime CreateRuntime(Action<string> log); + + Task EnsureSessionSupportedAsync( + CancellationToken cancellationToken); + + PackageRuntimeMetadata ValidatePackageRuntime(HostOptions options, SessionRuntime runtime); + + ISessionLockHandle AcquireLifecycleLock(SessionRuntime runtime); + + Task<TeardownResult> TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action<string> log, + bool lockAlreadyHeld, + CancellationToken cancellationToken); + + IInstallationStateCleaner CreateStateCleaner(SessionRuntime runtime); + + Task<GatewayPersistenceInstallResult> InstallRecoveryAsync( + Action<string> log, + CancellationToken cancellationToken); + + Task<GatewayPersistenceStatus> GetRecoveryStatusAsync( + Action<string> log, + CancellationToken cancellationToken); +} + +internal sealed class InstallationLifecycle : IInstallationLifecycle +{ + public static InstallationLifecycle Production { get; } = new(); + + public SessionRuntime CreateRuntime(Action<string> log) => SessionRuntime.Create(log); + + public async Task EnsureSessionSupportedAsync( + CancellationToken cancellationToken) + { + MxcReadinessReport readiness = await MxcReadiness.ProbeAsync(cancellationToken) + .ConfigureAwait(false); + SessionSupportPolicy.EnsureSupported( + HostPaths.Create().PackageFamilyName, + readiness); + } + + 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<TeardownResult> TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action<string> log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + RunTeardownAsync(options, runtime, log, lockAlreadyHeld, cancellationToken); + + private static Task<TeardownResult> RunTeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action<string> 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<GatewayPersistenceInstallResult> InstallRecoveryAsync( + Action<string> log, + CancellationToken cancellationToken) => + GatewayRuntime.CreateRecoveryManager(log).InstallAsync(cancellationToken); + + public Task<GatewayPersistenceStatus> GetRecoveryStatusAsync( + Action<string> log, + CancellationToken cancellationToken) => + GatewayRuntime.CreateRecoveryManager(log).GetStatusAsync(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; + +/// <summary>Deletes only the contents of the two package-owned state roots.</summary> +internal sealed class InstallationStateCleaner : IInstallationStateCleaner +{ + private readonly string[] _roots; + private readonly Dictionary<string, TrustedPath.FileIdentity?> _rootIdentities; + private readonly IInstallationFileSystem _fileSystem; + private readonly Action<string>? _beforeTraversal; + private readonly Action<string>? _beforeDeleteEntry; + + public InstallationStateCleaner(HostPaths paths, string productLocalStateRoot) + : this([paths.StateRoot, productLocalStateRoot], paths.PackageFamilyName) + { + } + + internal InstallationStateCleaner( + IReadOnlyList<string> trustedRoots, + string? packageFamilyName = "test", + IInstallationFileSystem? fileSystem = null, + Action<string>? beforeTraversal = null, + Action<string>? 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<string> 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<string> 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/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; + +/// <summary> +/// Defers backend construction until a lifecycle call actually needs it. +/// </summary> +/// <remarks> +/// <c>clawctl status</c> 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. +/// </remarks> +internal sealed class LazyMxcSessionClient(Func<IMxcSessionClient> create) + : IMxcSessionClient +{ + private readonly Lazy<IMxcSessionClient> _inner = new(create); + + public Task<MxcProvisionResult> 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<MxcExecutionResult> ExecuteAsync( + MxcSandboxId sandboxId, + MxcExecutionRequest request, + string? correlationVector, + CancellationToken cancellationToken) => + _inner.Value.ExecuteAsync( + sandboxId, + request, + correlationVector, + cancellationToken); + + public Task<int> 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..102fb3d0 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionCoordinator.cs @@ -0,0 +1,514 @@ +using System.Diagnostics.CodeAnalysis; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// <summary> +/// A session lifecycle operation could not proceed. +/// </summary> +internal class SessionException : Exception +{ + public SessionException() + { + } + + public SessionException(string message) + : base(message) + { + } + + public SessionException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// <summary> +/// The current installation or machine cannot host an isolated session. +/// </summary> +internal sealed class SessionCapabilityUnavailableException : SessionException +{ + public SessionCapabilityUnavailableException() + { + } + + public SessionCapabilityUnavailableException(string message) + : base(message) + { + } + + public SessionCapabilityUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// <summary> +/// The recorded session exists but cannot be used, and replacing it silently +/// would risk abandoning a live backend session. +/// </summary> +[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; } +} + +/// <summary> +/// Another process held the lifecycle lock for longer than the caller allowed. +/// </summary> +[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.") + { + } +} + +/// <summary> +/// What status established about the owned session. +/// </summary> +internal enum SessionAvailability +{ + /// <summary>No session has been recorded; a first one may be created.</summary> + None, + + /// <summary>A usable record exists but its backend state is not known.</summary> + Recorded, + + /// <summary>A record exists but cannot be used. Recovery is required.</summary> + Unusable, + + /// <summary>The backend accepted the recorded provision and started it.</summary> + Running, + + /// <summary>The backend explicitly reported that the recorded provision is missing.</summary> + Stale, + + /// <summary>The MXC runtime or backend could not be reached.</summary> + BackendUnavailable, + + /// <summary>The MXC backend returned an error other than a missing provision.</summary> + BackendError, +} + +/// <summary> +/// 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. +/// </summary> +internal sealed record SessionStatus( + SessionAvailability Availability, + SessionRecord? Record, + SessionStateFault? Fault, + string? Detail); + +/// <summary> +/// The outcome of removing the owned session. +/// </summary> +/// <param name="Removed">False when there was nothing to remove.</param> +/// <param name="StopFailure"> +/// Set when stop failed but deprovision was still attempted, so the caller can +/// report the degraded path instead of claiming a clean teardown. +/// </param> +internal sealed record SessionRemovalResult(bool Removed, string? StopFailure); + +/// <summary> +/// The result of ensuring that an owned session is started. +/// </summary> +/// <param name="Record">The started session record.</param> +/// <param name="SupersededRecord"> +/// The previously owned record MXC explicitly reported as stale, when setup +/// had to replace it. +/// </param> +internal sealed record SessionStartResult( + SessionRecord Record, + SessionRecord? SupersededRecord); + +/// <summary> +/// Owns this installation's isolated session across processes. +/// </summary> +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<string> _log; + private readonly TimeProvider _clock; + private readonly TimeSpan _lockTimeout; + + public SessionCoordinator( + IMxcSessionClient backend, + SessionStateStore store, + ISessionLock lifecycleLock, + string applicationId, + Action<string> 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; + } + + /// <summary> + /// Reports the recorded session without provisioning, starting, or writing. + /// </summary> + 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); + } + + /// <summary> + /// Establishes whether the recorded provision can be started by MXC without + /// creating or replacing a session. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public async Task<SessionStatus> 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); + } + } + + /// <summary> + /// Returns the owned, started session, creating it only on first use. + /// </summary> + public async Task<SessionRecord> EnsureStartedAsync( + CancellationToken cancellationToken) => + (await EnsureStartedWithResultAsync(cancellationToken).ConfigureAwait(false)).Record; + + /// <summary> + /// Returns the owned, started session and identifies an explicitly stale + /// record when setup replaced it. + /// </summary> + internal async Task<SessionStartResult> EnsureStartedWithResultAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + + SessionStateResult state = _store.Read(_applicationId); + if (state.Record is not null) + { + _log("Reusing the recorded OpenClaw session."); + 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) + { + // 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."); + SessionRecord provisioned = await ProvisionAndStartAsync([], cancellationToken) + .ConfigureAwait(false); + return new SessionStartResult(provisioned, null); + } + + private async Task<SessionRecord> ProvisionAndStartAsync( + IReadOnlyList<string> supersededSandboxIds, + CancellationToken cancellationToken) + { + 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(), + SupersededSandboxIds = [.. supersededSandboxIds], + }; + 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; + } + + private static IReadOnlyList<string> GetSupersededSandboxIds(SessionRecord record) => + [.. (record.SupersededSandboxIds ?? []) + .Append(record.SupersededSandboxId) + .Append(record.SandboxId) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Select(static id => id!) + .Distinct(StringComparer.Ordinal)]; + + /// <summary> + /// Starts the recorded session without provisioning a replacement. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public async Task<SessionRecord> 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; + } + + /// <summary> + /// Stops the session, keeping the provision and the guest profile. + /// </summary> + /// <returns>False when no session is recorded.</returns> + public async Task<bool> 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; + } + + /// <summary> + /// Stops and deprovisions the session, then forgets it. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public async Task<SessionRemovalResult> RemoveAsync( + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = AcquireLock(); + return await RemoveUnderLockAsync(cancellationToken).ConfigureAwait(false); + } + + internal async Task<SessionRemovalResult> RemoveUnderLockAsync( + CancellationToken cancellationToken) + { + 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); + } + + /// <summary> + /// Discards the local record without contacting the backend. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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..e6658d5a --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionExecutor.cs @@ -0,0 +1,576 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Session; + +/// <summary> +/// What to run inside the session. +/// </summary> +internal sealed record SessionExecutionRequest( + string HelperPath, + string NodePath, + string ApplicationDirectory, + IReadOnlyList<string> Arguments, + string WorkingDirectory) +{ + /// <summary> + /// Environment determined by the host entrypoint for this invocation. + /// </summary> + public IReadOnlyDictionary<string, string>? AdditionalEnvironment { get; init; } +} + +/// <summary> +/// An arbitrary foreground command to run inside the session. +/// </summary> +/// <remarks> +/// This is the general form of <see cref="SessionExecutionRequest"/>: 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. +/// </remarks> +internal sealed record SessionCommandRequest( + string HelperPath, + string Executable, + IReadOnlyList<string> Arguments, + string WorkingDirectory) +{ + /// <summary>Guest directory that must lead the child process PATH.</summary> + public string? PathPrefix { get; init; } + + /// <summary> + /// Values merged over the shared runtime environment for this command only. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public IReadOnlyDictionary<string, string>? AdditionalEnvironment { get; init; } +} + +/// <summary> +/// Runs OpenClaw inside the owned session. +/// </summary> +/// <remarks> +/// The backend's execution API takes a command line, not an argument vector, +/// and the pinned runtime flattens it through <c>cmd.exe</c>. 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. +/// </remarks> +internal sealed class SessionExecutor +{ + private const int ControlCExitCode = unchecked((int)0xc000013a); + private const int PortableInterruptedExitCode = 130; + + private readonly IMxcSessionClient _backend; + private readonly Action<string> _log; + private readonly Func<IReadOnlyDictionary<string, string>> _buildEnvironment; + private readonly Func<string> _createRequestId; + private readonly Func<SessionRecord, bool> _isCurrentRecord; + + public SessionExecutor(IMxcSessionClient backend, Action<string> log, + Func<IReadOnlyDictionary<string, string>>? buildEnvironment = null, + Func<string>? createRequestId = null, + Func<SessionRecord, bool>? 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); + } + + internal SessionWorkspaceOperation CreateWorkspaceOperation(SessionRecord record) => + new(record, _isCurrentRecord); + + public async Task<int> ExecuteAsync( + SessionRecord record, + SessionExecutionRequest request, + CancellationToken cancellationToken) + { + 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<string, string> environment = _buildEnvironment(); + + return await ExecuteCommandAsync( + record, + new SessionCommandRequest( + request.HelperPath, + request.NodePath, + BuildNodeArguments(request), + request.WorkingDirectory) + { + PathPrefix = nodeDirectory, + AdditionalEnvironment = MergeEnvironment( + environment, + request.AdditionalEnvironment) + }, + "Running OpenClaw in the isolated session.", + "OpenClaw", + cancellationToken).ConfigureAwait(false); + } + + /// <summary> + /// Runs one arbitrary foreground command inside the session and returns its + /// exit code. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public async Task<int> 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), + PathPrefix = request.PathPrefix, + }; + + 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); + } + } + + /// <summary> + /// Asks the guest to stage diagnostic files into the shared workspace. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public async Task<SessionCollectResult> CollectAsync( + SessionRecord record, + string helperPath, + string destinationDirectory, + IReadOnlyList<SessionCollectSource> sources, + IReadOnlyList<string> 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."); + + MxcExecutionResult execution = await _backend.ExecuteAsync( + 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 " + + DescribeMissingResult(execution)); + } + + 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); + } + } + + /// <summary> + /// Installs the packaged Node.js runtime into the agent's profile. + /// </summary> + /// <remarks> + /// Setup invokes this explicitly rather than leaving the first gateway + /// launch to discover a missing agent runtime. + /// </remarks> + public async Task<SessionRuntimeInstallResult> 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<string, string> MergeEnvironment( + IReadOnlyDictionary<string, string> baseEnvironment, + IReadOnlyDictionary<string, string>? additional) + { + ArgumentNullException.ThrowIfNull(baseEnvironment); + + if (additional is null || additional.Count == 0) + { + return baseEnvironment; + } + + var merged = new Dictionary<string, string>( + baseEnvironment, StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair<string, string> entry in additional) + { + merged[entry.Key] = entry.Value; + } + + return merged; + } + + /// <summary>Installs the agent command shim under the guest identity.</summary> + public async Task<SessionToolInstallResult> 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<string> BuildNodeArguments( + SessionExecutionRequest request) + { + string entryPoint = Path.Combine(request.ApplicationDirectory, "openclaw.mjs"); + var arguments = new List<string>(request.Arguments.Count + 1) { entryPoint }; + arguments.AddRange(request.Arguments); + return arguments; + } + + /// <summary> + /// Builds the only command line the backend ever sees. + /// </summary> + /// <remarks> + /// <para> + /// The pinned runtime dispatches this string through <c>cmd.exe /c</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 <c>"exe" --request "path"</c> 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. + /// </para> + /// <para> + /// Both values are paths this package controls, but they are still verified + /// rather than trusted, because <c>cmd.exe</c> also expands <c>%VAR%</c> + /// and lets an embedded quote truncate the rest of the line. + /// </para> + /// </remarks> + 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); + } + } + } + + /// <summary> + /// Establishes what actually happened from the helper's control result. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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) + { + if (executorExitCode == ControlCExitCode) + { + return PortableInterruptedExitCode; + } + + 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; + +/// <summary> +/// Stages the packaged guest helper into the session's shared workspace. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +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; + +/// <summary> +/// A held lifecycle lock. Disposing releases it. +/// </summary> +internal interface ISessionLockHandle : IDisposable +{ +} + +/// <summary> +/// Serializes session lifecycle transitions across processes. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal interface ISessionLock +{ + /// <summary> + /// Acquires the lock, or returns null if <paramref name="timeout"/> elapses. + /// </summary> + ISessionLockHandle? TryAcquire(TimeSpan timeout); +} + +/// <summary> +/// Named-mutex lifecycle lock, scoped to one installation's LocalState path. +/// </summary> +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<char> 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<bool> _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/SessionRuntime.cs b/src/OpenClaw.Launcher/Session/SessionRuntime.cs new file mode 100644 index 00000000..6d86d41a --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionRuntime.cs @@ -0,0 +1,265 @@ +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Gateway; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Session; + +/// <summary> +/// Assembles the session stack from the running installation. +/// </summary> +/// <remarks> +/// Composition lives here rather than in <c>Program</c> 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. +/// </remarks> +internal sealed class SessionRuntime +{ + /// <summary> + /// Directory under the application base holding the guest helper, staged + /// per architecture exactly like the MXC runtime. + /// </summary> + 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, + HostPaths paths, + SetupStateStore setupState, + GatewayStateStore gatewayState, + string lifecycleLockScope) + { + Coordinator = coordinator; + Executor = executor; + Backend = backend; + HelperPath = helperPath; + ApplicationId = applicationId; + Paths = paths; + SetupState = setupState; + GatewayState = gatewayState; + LifecycleLock = new NamedSessionLock(lifecycleLockScope); + } + + public SessionCoordinator Coordinator { get; } + + public SessionExecutor Executor { get; } + + /// <summary> + /// The one backend instance this process uses, so the gateway and ordinary + /// invocations cannot end up talking to differently configured clients. + /// </summary> + public IMxcSessionClient Backend { get; } + + public string HelperPath { get; } + + public string ApplicationId { get; } + + public HostPaths Paths { get; } + + public SetupStateStore SetupState { get; } + + public GatewayStateStore GatewayState { get; } + + public ISessionLock LifecycleLock { get; } + + public ISessionLockHandle AcquireLifecycleLock() => + LifecycleLock.TryAcquire(SessionCoordinator.DefaultLockTimeout) + ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); + + public async Task<SessionRecord> 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<string> log) => + Create( + HostPaths.Create(), + MxcRuntimeLocator.Locate, + AppContext.BaseDirectory, + log); + + internal static SessionRuntime Create( + HostPaths paths, + Func<MxcRuntimeLocation> locateRuntime, + string baseDirectory, + Action<string> 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, + paths, + new SetupStateStore(paths.SetupStatePath), + new GatewayStateStore(paths.GatewayStatePath), + 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); + } + } + + /// <summary> + /// Requires both the explicit setup marker and the owned session record. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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; + } + + /// <summary> + /// Returns the Node.js executable extracted by the agent for the currently + /// packaged runtime. + /// </summary> + 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; + } + + /// <summary>Records a completed guest runtime installation.</summary> + 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..7a964609 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/SessionStateStore.cs @@ -0,0 +1,315 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Session; + +/// <summary> +/// The durable record of the session this installation owns. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +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; } + + [JsonPropertyName("supersededSandboxId")] + public string? SupersededSandboxId { get; init; } + + /// <summary> + /// Every prior owned session whose gateway record still may need + /// reconciliation. The singular property remains for records written by + /// earlier package versions. + /// </summary> + [JsonPropertyName("supersededSandboxIds")] + public IReadOnlyList<string>? SupersededSandboxIds { get; init; } +} + +/// <summary> +/// Why a stored session record could not be used. +/// </summary> +internal enum SessionStateFault +{ + /// <summary>No record has been written yet.</summary> + Missing, + + /// <summary>The file exists but is not readable as a record.</summary> + Unreadable, + + /// <summary>The record was written by an incompatible newer version.</summary> + UnsupportedSchema, + + /// <summary>The record is missing values it cannot be used without.</summary> + Incomplete, + + /// <summary>The record belongs to a different package identity.</summary> + ForeignIdentity, +} + +/// <summary> +/// The outcome of reading the stored session record. +/// </summary> +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 +{ +} + +/// <summary> +/// Reads and atomically writes the owned-session record. +/// </summary> +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; + + /// <summary> + /// Reads the record, distinguishing "no session" from "unusable session". + /// </summary> + /// <remarks> + /// A damaged record never degrades to <see cref="SessionStateFault.Missing"/>. + /// Provisioning a replacement over a record we cannot read would abandon a + /// live backend session and the user's guest profile with it. + /// </remarks> + 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); + } + + /// <summary> + /// Replaces the record atomically. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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); + } + + /// <summary> + /// Removes the record. Deleting nothing is success. + /// </summary> + public void Clear() + { + try + { + File.Delete(_filePath); + } + catch (DirectoryNotFoundException) + { + } + } +} 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; + +/// <summary> +/// Decides whether this machine can host the isolated agent session that every +/// OpenClaw invocation now requires. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +internal static class SessionSupportPolicy +{ + /// <summary> + /// What the user can do about a machine that cannot host a session. + /// </summary> + 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"; + + /// <summary> + /// Throws when this machine cannot host an isolated session. + /// </summary> + /// <exception cref="SessionException"> + /// The machine cannot host a session. The message carries both the reason + /// and <see cref="Remediation"/>. + /// </exception> + 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/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; + +/// <summary> +/// Owns one host/guest exchange in the shared session workspace. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal sealed class SessionWorkspaceOperation : IDisposable +{ + private readonly SessionRecord _record; + private readonly Func<SessionRecord, bool> _isCurrent; + private readonly TrustedPath.ValidatedDirectory _workspace; + + public SessionWorkspaceOperation( + SessionRecord record, + Func<SessionRecord, bool> 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<string> 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<SetupPhase>))] +internal enum SetupPhase +{ + Ready, + Preparing, + TearingDown +} + +/// <summary> +/// The durable marker that this installation completed explicit setup. +/// </summary> +/// <remarks> +/// 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 <c>openclaw</c>. +/// </remarks> +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; } +} + +/// <summary>Why the explicit setup marker could not be used.</summary> +internal enum SetupStateFault +{ + /// <summary>Setup has not completed for this installation.</summary> + Missing, + + /// <summary>The marker exists but cannot be read as a record.</summary> + Unreadable, + + /// <summary>The marker was written by an incompatible newer version.</summary> + UnsupportedSchema, + + /// <summary>The marker is missing values it cannot be used without.</summary> + Incomplete, + + /// <summary>The marker belongs to a different package identity.</summary> + 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; + +/// <summary> +/// Reads and atomically writes the explicit setup marker. +/// </summary> +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/TeardownOrchestrator.cs b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs new file mode 100644 index 00000000..e5793b01 --- /dev/null +++ b/src/OpenClaw.Launcher/Session/TeardownOrchestrator.cs @@ -0,0 +1,88 @@ +using OpenClaw.Launcher.Gateway; + +namespace OpenClaw.Launcher.Session; + +/// <summary>Runs the owned teardown in one lifecycle-lock acquisition.</summary> +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<TeardownResult> RunAsync( + string helperPath, + bool force, + CancellationToken cancellationToken) + { + using ISessionLockHandle handle = _lock.TryAcquire(SessionCoordinator.DefaultLockTimeout) + ?? throw new SessionBusyException(SessionCoordinator.DefaultLockTimeout); + return await RunUnderLockAsync(helperPath, force, cancellationToken).ConfigureAwait(false); + } + + /// <summary>Runs teardown while the caller owns the installation lifecycle lock.</summary> + public async Task<TeardownResult> RunUnderLockAsync( + string helperPath, + bool force, + CancellationToken cancellationToken) + { + 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, + session.Removed); + } +} + +internal sealed record TeardownResult( + bool Succeeded, + string Message, + string? Detail = null, + bool SessionRemoved = false); 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; + +/// <summary>Rejects path redirection below a caller-established root.</summary> +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<ValidatedDirectory> _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<byte> 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<string, FileAttributes> 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}"); + } + } + } + /// <summary> + /// Opens a file only when the object opened by the host is still below the + /// trusted root and is not a reparse point. + /// </summary> + [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<UnicodeString>()); + try + { + Marshal.StructureToPtr(unicodeName, unicodeNamePointer, fDeleteOld: false); + var attributes = new ObjectAttributes + { + Length = Marshal.SizeOf<ObjectAttributes>(), + 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<FileDispositionInformation>()); + } + + 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.Launcher/WindowsHostConsole.cs b/src/OpenClaw.Launcher/WindowsHostConsole.cs new file mode 100644 index 00000000..bc03790e --- /dev/null +++ b/src/OpenClaw.Launcher/WindowsHostConsole.cs @@ -0,0 +1,394 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace OpenClaw.Launcher; + +internal interface IHostConsole +{ + IDisposable Capture(Action<string> 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 => IsInteractiveOutput(Console.Out); + + internal bool IsInteractiveOutput(TextWriter output) + { + ArgumentNullException.ThrowIfNull(output); + + uint standardHandle = ReferenceEquals(output, Console.Out) + ? StdOutput + : ReferenceEquals(output, Console.Error) + ? StdError + : 0; + if (standardHandle == 0) + { + return false; + } + + nint handle = _native.GetStdHandle(standardHandle); + return handle != 0 && handle != InvalidHandle && + _native.GetConsoleMode(handle, 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)); + } + + internal bool TryEnableVirtualTerminalProcessing( + TextWriter output, + Action<string> 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<string> log) + { + ArgumentNullException.ThrowIfNull(log); + return new ConsoleCapture(_native, _encoding, log); + } + + private static void TrySetCodePage(Func<uint, bool> setter, uint codePage) + { + try + { + setter(codePage); + } + catch (Exception exception) when (exception is IOException or + InvalidOperationException or System.ComponentModel.Win32Exception) + { + } + } + + private static void TrySetEncoding(Action<Encoding> 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<string> _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<string> 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 VirtualTerminalScope : IDisposable + { + private readonly IConsoleNativeApi _native; + private readonly nint _handle; + private readonly uint _mode; + private readonly Action<string> _log; + private int _disposed; + + public VirtualTerminalScope( + IConsoleNativeApi native, + nint handle, + uint mode, + Action<string> 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); + 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/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs index 6d6d9a74..6f50c3ae 100644 --- a/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs +++ b/src/OpenClaw.Launcher/WindowsKillOnCloseJob.cs @@ -1,10 +1,15 @@ +using System.Buffers; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; using Microsoft.Win32.SafeHandles; +#if OPENCLAW_SESSION_HOST +namespace OpenClaw.SessionHost; +#else namespace OpenClaw.Launcher; +#endif internal sealed class WindowsKillOnCloseJob : IDisposable { @@ -17,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<char> QuoteCharacters = SearchValues.Create(" \t\r\n\v\""); private WindowsKillOnCloseJob(SafeJobHandle handle) { @@ -72,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) @@ -91,25 +104,44 @@ 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); + + 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<StartupInfo>(), - Flags = StartfUseStdHandles, - StandardInput = GetStdHandle(StandardInputHandle), - StandardOutput = GetStdHandle(StandardOutputHandle), - StandardError = GetStdHandle(StandardErrorHandle) + Startup = new StartupInfo + { + Size = handles.Attributes != 0 ? Marshal.SizeOf<ExtendedStartupInfo>() : Marshal.SizeOf<StartupInfo>(), + Flags = StartfUseStdHandles, + StandardInput = inputCopy?.DangerousGetHandle() ?? 0, + StandardOutput = outputCopy?.DangerousGetHandle() ?? 0, + StandardError = errorCopy?.DangerousGetHandle() ?? 0 + }, + Attributes = handles.Attributes }; try { if (!CreateProcessW( startInfo.FileName, - new StringBuilder(commandLine), + 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 @@ -124,7 +156,8 @@ public Process StartProcess(ProcessStartInfo startInfo) try { - if (!AssignProcessToJobObject( + if (assignToJob && + !AssignProcessToJobObject( _handle, processInformation.Process)) { @@ -163,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(); @@ -178,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) @@ -256,14 +394,14 @@ 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, 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/AssemblyInfo.cs b/src/OpenClaw.SessionHost/AssemblyInfo.cs new file mode 100644 index 00000000..c448bf45 --- /dev/null +++ b/src/OpenClaw.SessionHost/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Runtime.InteropServices; + +// Every P/Invoke in this assembly targets kernel32.dll or iphlpapi.dll, both of +// which always live 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. That matters more here +// than in the launcher, because this helper runs inside the isolated session +// alongside whatever the agent has written to its own workspace. +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] 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; + +/// <summary> +/// Maps a listening TCP port to the process that owns it. +/// </summary> +/// <remarks> +/// <c>IPGlobalProperties.GetActiveTcpListeners</c> 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. +/// </remarks> +internal static class TcpListenerOwnership +{ + private const int AfInet = 2; + private const int AfInet6 = 23; + private const int TcpTableOwnerPidListener = 3; + private const uint InsufficientBuffer = 122; + + /// <summary> + /// Returns the identifiers of processes listening on <paramref name="port"/>. + /// </summary> + public static IReadOnlyList<int> GetListenerProcessIds(int port) + { + List<int> owners = []; + foreach ((int rowPort, int owner) in GetListeners()) + { + if (rowPort == port && !owners.Contains(owner)) + { + owners.Add(owner); + } + } + + return owners; + } + + /// <summary> + /// Every listening port and the process that owns it. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + 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<TcpRowOwnerPid>() + : Marshal.SizeOf<Tcp6RowOwnerPid>(); + IntPtr row = buffer + 4; + + for (int index = 0; index < count; index++) + { + (int rowPort, int owner) = addressFamily == AfInet + ? Read(Marshal.PtrToStructure<TcpRowOwnerPid>(row)) + : Read(Marshal.PtrToStructure<Tcp6RowOwnerPid>(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); + + /// <summary> + /// The table stores the port in network byte order inside a 32-bit field, + /// so the two low bytes have to be swapped. + /// </summary> + 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; + } +} + +/// <summary> +/// Answers whether a process is a descendant of another. +/// </summary> +internal static class ProcessAncestry +{ + private const int Th32CsSnapProcess = 2; + + /// <summary> + /// True when <paramref name="candidate"/> is <paramref name="ancestor"/> or + /// is descended from it. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public static bool IsSelfOrDescendant(int candidate, int ancestor) + { + if (candidate == ancestor) + { + return true; + } + + Dictionary<int, int> parents = ReadParents(); + int current = candidate; + var seen = new HashSet<int>(); + + // 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<int, int> ReadParents() + { + Dictionary<int, int> 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<ProcessEntry32>(); + + 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; + } +} + +/// <summary> +/// Observes a recorded gateway from inside the session. +/// </summary> +internal static class GuestProcessObserver +{ + /// <summary> + /// Returns the process's creation time, or null when no such process + /// exists or it cannot be examined. + /// </summary> + 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; + } + } + + /// <summary> + /// True when the recorded supervisor, or a descendant of it, is listening + /// on the port. + /// </summary> + 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; + } + + /// <summary> + /// The ports the recorded supervisor, or a descendant of it, listens on. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public static IReadOnlyList<int> ListeningPortsOwnedBy(int supervisorProcessId) + { + List<int> 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/OpenClaw.SessionHost.csproj b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj new file mode 100644 index 00000000..50c0a6d6 --- /dev/null +++ b/src/OpenClaw.SessionHost/OpenClaw.SessionHost.csproj @@ -0,0 +1,33 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0-windows10.0.19041.0</TargetFramework> + <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <NuGetAuditMode>all</NuGetAuditMode> + <AssemblyName>openclaw-session-host</AssemblyName> + <RootNamespace>OpenClaw.SessionHost</RootNamespace> + <Platforms>x64;arm64</Platforms> + <RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> + <TargetLatestRuntimePatch>false</TargetLatestRuntimePatch> + <PublishAot Condition="'$(RuntimeIdentifier)' != ''">true</PublishAot> + <UseAppHost Condition="'$(RuntimeIdentifier)' != ''">true</UseAppHost> + + <!-- + This helper runs inside the isolated session, never as a packaged app, + so it takes no part in MSIX tooling. + --> + <WindowsPackageType>None</WindowsPackageType> + <EnableMSIXTooling>false</EnableMSIXTooling> + <DefineConstants>$(DefineConstants);OPENCLAW_SESSION_HOST</DefineConstants> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\OpenClaw.SessionProtocol\OpenClaw.SessionProtocol.csproj" /> + <InternalsVisibleTo Include="OpenClaw.Launcher.Tests" /> + <Compile Include="..\OpenClaw.Launcher\WindowsKillOnCloseJob.cs" Link="WindowsKillOnCloseJob.cs" /> + </ItemGroup> + +</Project> diff --git a/src/OpenClaw.SessionHost/Program.cs b/src/OpenClaw.SessionHost/Program.cs new file mode 100644 index 00000000..930c8b26 --- /dev/null +++ b/src/OpenClaw.SessionHost/Program.cs @@ -0,0 +1,166 @@ +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// <summary> +/// The guest-side helper that runs inside an isolated session. +/// </summary> +/// <remarks> +/// It exists because the backend's execution API takes a command-line string +/// that is flattened through <c>cmd.exe</c>. The MXC command therefore launches +/// only this controlled helper, which reads the real argument vector as data +/// and replays it byte for byte. +/// </remarks> +internal static class Program +{ + public static int Main(string[] args) => + Run(args, new SessionProcessLauncher(), Console.Error, File.ReadAllText, WriteResult); + + internal static int Run( + IReadOnlyList<string> args, + ISessionProcessLauncher launcher, + TextWriter errorOutput, + Func<string, string> readFile, + Action<string, SessionLaunchResult> writeResult) + { + 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|--supervise|--inspect|--stop|--collect|--install-runtime|--install-tools <path>"); + return SessionLaunchProtocol.HelperFailureExitCode; + } + + 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); + } + + if (args[0] == "--collect") + { + return SessionCollector.Run(requestPath, readFile, File.WriteAllText); + } + + string resultPath = SessionLaunchProtocol.ResultPathFor(requestPath); + string? requestId = null; + + try + { + SessionLaunchRequest request = + SessionLaunchProtocol.ReadRequest(readFile(requestPath)); + requestId = request.RequestId; + 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, + 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 bool TryGetMode( + IReadOnlyList<string> 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<string, SessionLaunchResult> 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/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; + +/// <summary> +/// The <c>--collect</c> mode: stages named diagnostic files into the shared +/// workspace so the host can read them. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +internal static class SessionCollector +{ + private static readonly Guid ProfileFolderId = + new("5E6C858F-0E22-4760-9AFE-EA3317B67173"); + + public static int Run( + string requestPath, + Func<string, string> readFile, + Action<string, string> writeFile, + string? profileRoot = null, + Func<string, string, SearchOption, IEnumerable<string>>? 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<SessionCollectEntry> 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<SessionCollectEntry> entries, + Func<string, string, SearchOption, IEnumerable<string>> 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<SessionCollectEntry> entries, + Func<string, string, SearchOption, IEnumerable<string>> enumerateFiles, + Action? beforeDestinationOpen) + { + IEnumerable<string> 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" + }); + } + } + + /// <summary> + /// Resolves a source against this account's profile. + /// </summary> + /// <remarks> + /// 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 <c>Path.Combine</c> + /// on a rooted segment silently discards the root it was given. + /// </remarks> + 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<SessionCollectEntry> 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<SafeFileHandle> _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<UnicodeString>()); + try + { + Marshal.StructureToPtr(unicodeName, unicodeNamePointer, fDeleteOld: false); + var attributes = new ObjectAttributes + { + Length = Marshal.SizeOf<ObjectAttributes>(), + 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<string, string> 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.SessionHost/SessionInspector.cs b/src/OpenClaw.SessionHost/SessionInspector.cs new file mode 100644 index 00000000..47f567cf --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionInspector.cs @@ -0,0 +1,175 @@ +using System.ComponentModel; +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// <summary> +/// The <c>--inspect</c> mode: re-establishes whether a recorded gateway is +/// still ours and still serving. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal static class SessionInspector +{ + public static int Run( + string requestPath, + Func<string, string> readFile, + Action<string, string> 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<string, string> 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); + _ = 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<int> 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<string, string> 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<string, string> 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<string, string> 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 new file mode 100644 index 00000000..fdc0dae8 --- /dev/null +++ b/src/OpenClaw.SessionHost/SessionProcessLauncher.cs @@ -0,0 +1,228 @@ +using System.Diagnostics; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.SessionHost; + +/// <summary> +/// Starts a process from a launch request, preserving the argument vector +/// exactly. +/// </summary> +internal interface ISessionProcessLauncher +{ + /// <summary>Runs the request to completion and returns its exit code.</summary> + int Run(SessionLaunchRequest request); + + /// <summary> + /// Starts the request without waiting and returns the identity of the + /// process that supervises it. + /// </summary> + SessionDetachedProcess Start(SessionLaunchRequest request, string helperPath); +} + +/// <summary> +/// The supervising process left behind by a detached launch. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal sealed record SessionDetachedProcess(int ProcessId, DateTimeOffset StartTimeUtc); + +/// <summary> +/// Launches the requested executable shell-free, so no quoting, metacharacter, +/// or <c>%VAR%</c> interpretation can alter the arguments. +/// </summary> +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<string, string>()) + { + startInfo.Environment[name] = value; + } + PrependPath(startInfo, Path.GetDirectoryName(request.Executable)); + + PrependPath(startInfo, request.PathPrefix); + + 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; + } + + /// <summary> + /// Puts a directory at the front of the child's <c>PATH</c>. + /// </summary> + /// <remarks> + /// <para> + /// 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 <c>PATH</c> contains, so it names the directory + /// and the resolution happens here. + /// </para> + /// <para> + /// Prepending is the point: the packaged runtime has to win over any + /// machine-wide Node.js for tools that resolve <c>node</c> or <c>npm</c> by + /// name rather than by the path this package hands them. + /// </para> + /// </remarks> + 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}"; + } + + /// <summary> + /// Starts a detached launch by re-running this helper as a supervisor. + /// </summary> + /// <remarks> + /// <para> + /// 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. + /// </para> + /// <para> + /// 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. + /// </para> + /// </remarks> + 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/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; + +/// <summary> +/// The <c>--install-runtime</c> mode: installs the packaged Node.js runtime +/// into this account's own profile. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// Running extraction here means every file is created by the account that +/// will run it. +/// </para> +/// </remarks> +internal static class SessionRuntimeInstaller +{ + private static readonly Guid LocalApplicationDataFolderId = + new("F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); + + public static int Run( + string requestPath, + Func<string, string> readFile, + Action<string, string> writeFile, + Func<string>? getLocalApplicationData = null, + Func<string, bool>? tryPrependUserPath = null, + Func<string, string?>? 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; + } + } + + /// <summary> + /// Puts the runtime directory at the front of this account's persistent + /// user <c>PATH</c>. + /// </summary> + /// <remarks> + /// <para> + /// Prepended, not appended: a machine-wide Node.js installation is common, + /// and the packaged runtime is the one this installation supports. + /// </para> + /// <para> + /// 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. + /// </para> + /// <para> + /// Only this account's own hive is touched, never the machine's. + /// </para> + /// </remarks> + 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; + } + + /// <summary> + /// Builds the new value: the directory first, every unrelated entry after, + /// and any previous runtime directory dropped. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + internal static string BuildPath(string current, string directory) + { + string? previousRoot = Path.GetDirectoryName(directory); + List<string> 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<string, string> 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.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; + +/// <summary> +/// The <c>--supervise</c> mode: owns one detached application and its log. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal static class SessionSupervisor +{ + public static string RequestPathFor(string statusPath) => + statusPath + ".request.json"; + + public static int Run( + string requestPath, + Func<string, string> readFile, + Action<string, string> 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<string, string> 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<string, string>()) + { + 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<string, string> 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; + +/// <summary> +/// The <c>--stop</c> mode: ends a gateway this installation started. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +internal static class SessionTerminator +{ + public static int Run( + string requestPath, + Func<string, string> readFile, + Action<string, string> 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<string, string> 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<string, string> 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.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; + +/// <summary>Installs the agent command shim from inside the isolated account.</summary> +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<string, string> readFile, + Action<string, string> 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/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 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net10.0-windows10.0.19041.0</TargetFramework> + <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <NuGetAuditMode>all</NuGetAuditMode> + <RootNamespace>OpenClaw.SessionProtocol</RootNamespace> + <Platforms>x64;arm64</Platforms> + <RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> + + <!-- + Both consumers are published with NativeAOT, so this contract must stay + trim- and AOT-safe. Serialization is source generated for that reason. + --> + <IsAotCompatible>true</IsAotCompatible> + </PropertyGroup> + + <ItemGroup> + <InternalsVisibleTo Include="OpenClaw.Launcher.Tests" /> + </ItemGroup> + +</Project> 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; + +/// <summary> +/// Asks the guest to stage diagnostic files into the shared workspace. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +public sealed record SessionCollectRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// <summary> + /// Where the guest writes the staged copies. The host owns this directory + /// and removes it once the bundle is written. + /// </summary> + [JsonPropertyName("destinationDirectory")] + public string? DestinationDirectory { get; init; } + + [JsonPropertyName("sources")] + public IReadOnlyList<SessionCollectSource>? Sources { get; init; } + + /// <summary> + /// File names the guest must never copy, matched case-insensitively against + /// the file name with <c>*</c> as a trailing wildcard. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("deniedNames")] + public IReadOnlyList<string>? DeniedNames { get; init; } +} + +/// <summary>One file or directory to stage, and the name to stage it under.</summary> +public sealed record SessionCollectSource +{ + /// <summary> + /// Where to read from, relative to the <em>agent's</em> user profile. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("relativePath")] + public string? RelativePath { get; init; } + + /// <summary> + /// The relative name inside the staging directory, which keeps guest paths + /// (and the agent account name in them) out of the bundle layout. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; init; } + + /// <summary> + /// Which files in a directory source to take. Defaults to all of them. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("pattern")] + public string? Pattern { get; init; } + + /// <summary>Whether a directory source is copied recursively.</summary> + [JsonPropertyName("recursive")] + public bool Recursive { get; init; } +} + +/// <summary>What the guest actually staged.</summary> +/// <remarks> +/// 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. +/// </remarks> +public sealed record SessionCollectResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + [JsonPropertyName("entries")] + public IReadOnlyList<SessionCollectEntry>? Entries { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +/// <summary>The outcome for one staged file.</summary> +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; } + + /// <summary>Why this entry was not copied, when it was not.</summary> + [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; + } + + /// <summary> + /// Whether a file name is denied, with <c>*</c> supported only as a + /// trailing wildcard. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public static bool IsDenied(string fileName, IReadOnlyList<string>? 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<T>( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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/SessionInspectProtocol.cs b/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs new file mode 100644 index 00000000..61b06b41 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionInspectProtocol.cs @@ -0,0 +1,273 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// <summary> +/// Asks the guest whether a recorded gateway is still the process we started. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +public sealed record SessionInspectRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// <summary>The recorded supervising process.</summary> + [JsonPropertyName("processId")] + public int ProcessId { get; init; } + + /// <summary> + /// 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. + /// </summary> + [JsonPropertyName("launchPending")] + public bool LaunchPending { get; init; } + + /// <summary> + /// The recorded creation time. A live process whose creation time differs + /// is an unrelated process that inherited a reused identifier, never ours. + /// </summary> + [JsonPropertyName("processStartTimeUtc")] + public DateTimeOffset ProcessStartTimeUtc { get; init; } + + [JsonPropertyName("helperPath")] + public string? HelperPath { get; init; } + + /// <summary> + /// Where the supervising helper records this launch generation. Its path is + /// unguessable, so only the generation we started can have written it. + /// </summary> + [JsonPropertyName("statusPath")] + public string? StatusPath { get; init; } + + /// <summary> + /// The port the gateway was configured to listen on, when one was + /// configured at all. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("port")] + public int? Port { get; init; } +} + +/// <summary> +/// What the guest observed. Every field is an observation, never an inference. +/// </summary> +public sealed record SessionInspectResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// <summary>A process with the recorded identifier exists.</summary> + [JsonPropertyName("processFound")] + [JsonRequired] + public bool ProcessFound { get; init; } + + /// <summary> + /// That process's creation time matches the recorded one, so it is the + /// process we started rather than one that inherited a reused identifier. + /// </summary> + [JsonPropertyName("startTimeMatches")] + [JsonRequired] + public bool StartTimeMatches { get; init; } + + /// <summary>What the supervising helper last recorded, if anything.</summary> + [JsonPropertyName("supervisorState")] + public string? SupervisorState { get; init; } + + /// <summary>What the supervising helper last recorded about its state.</summary> + [JsonPropertyName("supervisorDetail")] + public string? SupervisorDetail { get; init; } + + /// <summary>Something is listening on the recorded port.</summary> + [JsonPropertyName("portListening")] + [JsonRequired] + public bool PortListening { get; init; } + + /// <summary> + /// The ports the recorded supervisor or its descendants are listening on. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("listeningPorts")] + public IReadOnlyList<int>? ListeningPorts { get; init; } + + /// <summary> + /// 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. + /// </summary> + [JsonPropertyName("listenerOwned")] + [JsonRequired] + public bool ListenerOwned { get; init; } + + /// <summary>Set when the inspection itself could not be completed.</summary> + [JsonPropertyName("error")] + public string? Error { get; init; } + + /// <summary> + /// 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. + /// </summary> + [JsonIgnore] + public bool IsOwnedAndHealthy => + Error is null && + ProcessFound && + StartTimeMatches && + PortListening && + ListenerOwned; +} + +/// <summary> +/// What the supervising helper writes about its own launch generation. +/// </summary> +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.LaunchPending) + { + return request; + } + + 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<T>( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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 new file mode 100644 index 00000000..c79b5f4f --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionLaunchProtocol.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// <summary> +/// A single OpenClaw invocation, expressed as data for the guest helper. +/// </summary> +/// <remarks> +/// The MXC execution API accepts one command-line string, which the pinned +/// backend flattens through <c>cmd.exe</c>. That silently expands +/// <c>%VAR%</c> 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 +/// <c>OpenClaw.SessionHost</c>. +/// </remarks> +public sealed record SessionLaunchRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + /// <summary> + /// Identifies this invocation so the helper's control result cannot be + /// confused with a concurrent invocation's. + /// </summary> + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// <summary> + /// Whether the helper waits for the application or leaves it running. + /// </summary> + [JsonPropertyName("mode")] + [JsonConverter(typeof(JsonStringEnumConverter<SessionLaunchMode>))] + public SessionLaunchMode Mode { get; init; } = SessionLaunchMode.Attached; + + [JsonPropertyName("executable")] + public string? Executable { get; init; } + + [JsonPropertyName("arguments")] + public IReadOnlyList<string>? Arguments { get; init; } + + /// <summary> + /// Required. Execution does not inherit the caller's working directory; + /// without this the guest command would silently run in the system + /// directory. + /// </summary> + [JsonPropertyName("workingDirectory")] + public string? WorkingDirectory { get; init; } + + [JsonPropertyName("environment")] + public IReadOnlyDictionary<string, string>? Environment { get; init; } + + /// <summary> + /// A directory to put at the front of the launched process's <c>PATH</c>. + /// </summary> + /// <remarks> + /// Named rather than resolved by the host: <see cref="Environment"/> is + /// merged onto the guest account's own environment, and the host cannot + /// know what that account's <c>PATH</c> already contains. This is how the + /// packaged Node.js runtime wins over a machine-wide installation for tools + /// that resolve <c>node</c> by name. + /// </remarks> + [JsonPropertyName("pathPrefix")] + public string? PathPrefix { get; init; } + + /// <summary> + /// 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. + /// </summary> + [JsonPropertyName("logPath")] + public string? LogPath { get; init; } + + /// <summary> + /// Detached only. Where the supervising helper records that this launch + /// generation is running or has exited. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("statusPath")] + public string? StatusPath { get; init; } +} + +/// <summary> +/// Whether a launch is awaited or left running. +/// </summary> +public enum SessionLaunchMode +{ + /// <summary> + /// 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. + /// </summary> + Attached, + + /// <summary> + /// 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. + /// </summary> + Detached, +} + +/// <summary> +/// The guest helper's own outcome, kept separate from the launched +/// application's stdout, stderr, and exit code. +/// </summary> +/// <remarks> +/// Without this separation a helper that failed to start Node at all would be +/// indistinguishable from OpenClaw itself exiting with the same code. +/// </remarks> +public sealed record SessionLaunchResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// <summary>Whether the helper actually started the application.</summary> + [JsonPropertyName("launched")] + public bool Launched { get; init; } + + /// <summary>The application's exit code; null when it never started.</summary> + [JsonPropertyName("exitCode")] + public int? ExitCode { get; init; } + + /// <summary> + /// Detached only. The supervising process's identifier. + /// </summary> + [JsonPropertyName("processId")] + public int? ProcessId { get; init; } + + /// <summary> + /// Detached only. The supervising process's creation time. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + [JsonPropertyName("processStartTimeUtc")] + public DateTimeOffset? ProcessStartTimeUtc { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +/// <summary> +/// Raised when a launch request or result cannot be honored. The message is +/// written to the control result rather than to application output. +/// </summary> +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 +{ + /// <summary> + /// 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. + /// </summary> + public const int CurrentSchemaVersion = 1; + + /// <summary> + /// 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. + /// </summary> + 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); + + /// <summary> + /// Parses and fully validates a request. Validation happens here so the + /// helper and its tests agree on what a usable request is. + /// </summary> + 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/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs new file mode 100644 index 00000000..4c84fa83 --- /dev/null +++ b/src/OpenClaw.SessionProtocol/SessionRuntimeProtocol.cs @@ -0,0 +1,235 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SessionProtocol; + +/// <summary> +/// Asks the guest to install the packaged Node.js runtime into its own profile. +/// </summary> +/// <remarks> +/// <para> +/// 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. +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +public sealed record SessionRuntimeInstallRequest +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// <summary> + /// The packaged Node.js archive to install, as a fully qualified path. + /// </summary> + [JsonPropertyName("archivePath")] + public string? ArchivePath { get; init; } + + /// <summary> + /// Whether to prepend the installed directory to the agent's persistent + /// user <c>PATH</c>. + /// </summary> + /// <remarks> + /// Separate from the install itself so a caller that only wants the files + /// does not silently change the account's environment. + /// </remarks> + [JsonPropertyName("updateUserPath")] + public bool UpdateUserPath { get; init; } = true; +} + +/// <summary>Where the guest put the runtime, and what it is.</summary> +public sealed record SessionRuntimeInstallResult +{ + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = SessionLaunchProtocol.CurrentSchemaVersion; + + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// <summary>The agent-side <c>node.exe</c> the launcher must use.</summary> + [JsonPropertyName("executablePath")] + public string? ExecutablePath { get; init; } + + /// <summary>The version the installed runtime reported about itself.</summary> + [JsonPropertyName("version")] + public string? Version { get; init; } + + /// <summary> + /// The archive the install came from, so a later launch can tell that the + /// package has since shipped a different one. + /// </summary> + [JsonPropertyName("archiveName")] + public string? ArchiveName { get; init; } + + /// <summary>Whether the agent's persistent user path was changed.</summary> + [JsonPropertyName("userPathUpdated")] + public bool UserPathUpdated { get; init; } + + /// <summary>Set when the install could not be completed.</summary> + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +/// <summary>Asks the guest to install its OpenClaw command shim.</summary> +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; } +} + +/// <summary>Reports guest-side command shim installation.</summary> +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 +{ + 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 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( + 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 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( + 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<T>( + string json, + System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> 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/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 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0-windows10.0.19041.0</TargetFramework> + <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <NuGetAuditMode>all</NuGetAuditMode> + <AssemblyName>OpenClaw.Launcher.AotSmoke</AssemblyName> + <RootNamespace>OpenClaw.Launcher.AotSmoke</RootNamespace> + <Platforms>x64;arm64</Platforms> + <RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> + <TargetLatestRuntimePatch>false</TargetLatestRuntimePatch> + <PublishAot Condition="'$(RuntimeIdentifier)' != ''">true</PublishAot> + <UseAppHost Condition="'$(RuntimeIdentifier)' != ''">true</UseAppHost> + <IsPackable>false</IsPackable> + + <!-- + Deliberately different from the launcher's version. The version scenario + asserts that the version option reports the launcher assembly, so an + accidental return to the library's entry-assembly default is visible here. + --> + <Version>9.9.9.9</Version> + <AssemblyVersion>9.9.9.9</AssemblyVersion> + <FileVersion>9.9.9.9</FileVersion> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\OpenClaw.Launcher\OpenClaw.Launcher.csproj" /> + </ItemGroup> + +</Project> diff --git a/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs new file mode 100644 index 00000000..4d329521 --- /dev/null +++ b/tests/OpenClaw.Launcher.AotSmoke/SmokeProgram.cs @@ -0,0 +1,530 @@ +using System.Diagnostics.CodeAnalysis; +using OpenClaw.Launcher.Gateway; +using OpenClaw.Launcher.Session; +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<int> Main() + { + (string Name, Func<Task> 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), + ("setup --force requires --fresh", SetupForceRequiresFreshAsync), + ("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) + ]; + + 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<Task> 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.AssertNoInstallationWorkStarted(); + 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.AssertNoInstallationWorkStarted(); + } + + 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.AssertNoInstallationWorkStarted(); + } + + // 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.AssertNoInstallationWorkStarted(); + } + + 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.AssertNoInstallationWorkStarted(); + } + + // 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.AssertNoInstallationWorkStarted(); + } + + 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.AssertNoInstallationWorkStarted(); + } + + private static async Task SetupReportsReadinessAsync() + { + using Fixture fixture = await Fixture + .CreateWithApplicationAsync(allowInstallationWork: true) + .ConfigureAwait(false); + + int exitCode = await fixture.RunAsync(["setup"]).ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + string error = FlattenRendered(fixture.Error.ToString()); + AssertContains( + error, + "not running from its installed package", + fixture); + AssertContains(error, "newer version of Windows", fixture); + AssertContains(error, "clawctl collect-logs", fixture); + + // The attempted requirement check is visible, but nothing is reported + // as ready when the support check refuses. + Assert( + 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( + File.Exists(fixture.EntryPoint), + "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 directs users to the diagnostics command instead of exposing an + // implementation-specific log path. + 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(), "clawctl collect-logs", 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, 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 = Fixture.CreateWithoutApplication(HostEntrypoint.Agent); + + int exitCode = await fixture.RunAsync(arguments).ConfigureAwait(false); + + AssertExitCode(1, exitCode, fixture); + Assert( + 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() => + 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"; + + 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 + { + private readonly HostEntrypoint _entrypoint; + private readonly WorkTrackingLifecycle? _lifecycle; + + 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"); + } + + 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 static Fixture CreateWithoutApplication( + HostEntrypoint entrypoint = HostEntrypoint.Control) => + 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<Fixture> CreateWithApplicationAsync( + HostEntrypoint entrypoint = HostEntrypoint.Control, + bool allowInstallationWork = false) + { + Fixture fixture = new(CreateRoot(), entrypoint, allowInstallationWork); + Directory.CreateDirectory(fixture.ApplicationDirectory); + await File.WriteAllTextAsync(fixture.EntryPoint, "console.log('fixture');") + .ConfigureAwait(false); + return fixture; + } + + public async Task<int> RunAsync(string[] args) + { + HostStartup startup = new() + { + Entrypoint = _entrypoint, + CreateDiagnostics = () => HostDiagnosticLog.Create(LogPath), + BaseDirectory = Root, + Output = Output, + Error = Error, + InstallationLifecycle = + _lifecycle ?? (IInstallationLifecycle)InstallationLifecycle.Production + }; + + return await LauncherProgram.RunAsync(args, startup).ConfigureAwait(false); + } + + public void AssertNoInstallationWorkStarted() => + Assert( + _lifecycle is not null && !_lifecycle.WorkStarted, + "Installation work ran for an invocation that must not start it."); + + 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); + } + } + + // 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<string> 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<TeardownResult> TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action<string> log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => throw Started(); + + public IInstallationStateCleaner CreateStateCleaner( + SessionRuntime sessionRuntime) => throw Started(); + + public Task<GatewayPersistenceInstallResult> InstallRecoveryAsync( + Action<string> log, + CancellationToken cancellationToken) => throw Started(); + + public Task<GatewayPersistenceStatus> GetRecoveryStatusAsync( + Action<string> log, + CancellationToken cancellationToken) => throw Started(); + } + + 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/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 new file mode 100644 index 00000000..aab387be --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -0,0 +1,285 @@ +using System.CommandLine; +using System.Reflection; + +namespace OpenClaw.Launcher.Tests; + +public sealed class ClawCtlCommandLineTests +{ + 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, null, []), + args, + _ => { }, + output, + error, + new FailIfWorkStartsLifecycle()).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 HelpDescribesBundledRuntimePreparation() + { + (_, 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("ready", help, StringComparison.Ordinal); + Assert.Contains("clawctl setup", help, StringComparison.Ordinal); + Assert.Contains("openclaw <arguments>", help, StringComparison.Ordinal); + } + + // clawctl owns package readiness and the managed gateway only. Upstream + // command names must continue flowing through `openclaw` unchanged. + [Fact] + public void OnlyTheOwnedCommandsAreExposed() + { + 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) + }); + + Assert.Equal( + [ + ClawCtlCommandLine.SetupCommandName, + ClawCtlCommandLine.StatusCommandName, + ClawCtlCommandLine.CollectLogsCommandName, + "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), + CollectLogs = (_, _) => 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); + } + + [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() + { + (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); + 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] + 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. + [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")] + [InlineData("setup", "--force")] + 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 667509cc..00000000 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -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) - { - ClawCtlCommandParseResult result = ClawCtlCommandParser.Parse(args); - - Assert.Null(result.Error); - Assert.Equal(expected, 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 d6851c03..1c802229 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlConsoleTests.cs @@ -1,51 +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 WriteHelpListsOnlyThePublicCommands() + public void WriteSetupWithoutIsolationReportsHostRuntime() { - var output = new StringWriter(); + using var output = new StringWriter(); - ClawCtlConsole.WriteHelp(output); + ClawCtlConsole.WriteResult( + output, + new SetupCommandResult( + 0, + @"C:\package\app", + "24.20.0", + null, + SessionReady: false, + RuntimeLocation: SetupRuntimeLocation.Host)); - 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); + string summary = output.ToString(); Assert.Contains( - NodeRuntimeResolver.SupportedVersions, - help, + "Node.js 24.20.0 installed in the host", + summary, StringComparison.Ordinal); - Assert.Contains( - NodeRuntimeResolver.InstallCommand, - help, + 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("nothing to stop", summary, StringComparison.Ordinal); + Assert.DoesNotContain( + "clawctl gateway-service start", + summary, StringComparison.Ordinal); - Assert.DoesNotContain("update-package", help, StringComparison.Ordinal); - Assert.DoesNotContain("gateway-service", help, StringComparison.Ordinal); } [Fact] - public void WriteReadinessSummaryDescribesPackagedApplication() + public void SuccessfulTeardownPreservesStopFailureDetail() { - var output = new StringWriter(); - string applicationDirectory = Path.Combine(_testDirectory, "app"); + using var output = new StringWriter(); - ClawCtlConsole.WriteReadinessSummary( + ClawCtlConsole.WriteResult( output, - applicationDirectory); + 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("package is ready", summary, StringComparison.Ordinal); - Assert.Contains(applicationDirectory, summary, StringComparison.Ordinal); + Assert.Contains("Session:", summary, StringComparison.Ordinal); + Assert.Contains("removed", summary, StringComparison.Ordinal); + Assert.Contains( + "stopping the session failed before deprovisioning succeeded", + summary, + StringComparison.OrdinalIgnoreCase); } - public void Dispose() + [Fact] + public async Task RedirectedNarrationWritesTheInitialAndReportedStages() { - Directory.Delete(_testDirectory, recursive: true); - GC.SuppressFinalize(this); + 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<ArgumentException>(() => + 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/ClawCtlParserDefaultsTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs new file mode 100644 index 00000000..0ff7212a --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlParserDefaultsTests.cs @@ -0,0 +1,109 @@ +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 async Task<(int ExitCode, string Output)> RunAsync( + params string[] args) + { + using var output = new StringWriter(); + + int exitCode = await Program.RunControlAsync( + new HostOptions(null, null, []), + args, + _ => { }, + output, + TextWriter.Null, + new FailIfWorkStartsLifecycle()).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(new ClawCtlHandlers + { + 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), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }); + + IEnumerable<string> 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/FailIfWorkStartsLifecycle.cs b/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs new file mode 100644 index 00000000..d5314f02 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/FailIfWorkStartsLifecycle.cs @@ -0,0 +1,42 @@ +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<string> 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<TeardownResult> TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action<string> log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => throw Fail(); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + throw Fail(); + + public Task<GatewayPersistenceInstallResult> InstallRecoveryAsync( + Action<string> log, + CancellationToken cancellationToken) => throw Fail(); + + public Task<GatewayPersistenceStatus> GetRecoveryStatusAsync( + Action<string> log, + CancellationToken cancellationToken) => throw Fail(); +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs new file mode 100644 index 00000000..7c831101 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/DiagnosticsBundleTests.cs @@ -0,0 +1,189 @@ +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 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() + { + (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<IOException>( + () => 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/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; + +/// <summary> +/// 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. +/// </summary> +internal sealed class FakeGatewayTaskScheduler : IGatewayTaskScheduler +{ + public List<string> 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<GatewayTaskProbe> QueryAsync( + string taskName, + CancellationToken cancellationToken) + { + Calls.Add($"query:{taskName}"); + return Task.FromResult(Probe); + } + + public Task<GatewayTaskOperation> RegisterAsync( + string taskName, + string taskXml, + CancellationToken cancellationToken) + { + Calls.Add($"register:{taskName}"); + RegisteredXml = taskXml; + return Task.FromResult(RegisterResult); + } + + public Task<GatewayTaskOperation> DeleteAsync( + string taskName, + CancellationToken cancellationToken) + { + Calls.Add($"delete:{taskName}"); + return Task.FromResult(DeleteResult); + } + + public Task<GatewayTaskOperation> RunAsync( + string taskName, + CancellationToken cancellationToken) + { + Calls.Add($"run:{taskName}"); + return Task.FromResult(RunResult); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs new file mode 100644 index 00000000..bd0cf72d --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayConfigurationStoreTests.cs @@ -0,0 +1,138 @@ +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 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<GatewayConfigurationException>( + () => Store.Resolve( + _root, + name => name == GatewayConfigurationStore.PortVariable ? value : null)); + } + + [Fact] + public void AnUnreadableConfigurationIsAnErrorRatherThanASilentDefault() + { + File.WriteAllText(Path_, "{ not json"); + + Assert.Throws<GatewayConfigurationException>( + () => 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<GatewayConfigurationException>( + () => 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 + }); + + GatewayLaunchConfiguration? read = Store.Read().Configuration; + + Assert.NotNull(read); + Assert.Equal(9100, read.Port); + Assert.Equal(GatewayConfigurationStore.CurrentSchemaVersion, read.SchemaVersion); + } +} 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/Gateway/GatewayControllerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs new file mode 100644 index 00000000..213f2043 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayControllerTests.cs @@ -0,0 +1,651 @@ +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; + +/// <summary> +/// 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. +/// </summary> +internal sealed class FakeSessionGatewayClient : ISessionGatewayClient +{ + public List<string> 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<GatewayStartOutcome> StartAsync( + SessionRecord session, + GatewayStartRequest request, + CancellationToken cancellationToken) + { + Calls.Add("start"); + StartRequest = request; + return StartFailure is null + ? Task.FromResult(StartOutcome) + : Task.FromException<GatewayStartOutcome>(StartFailure); + } + + public Task<SessionInspectResult> InspectAsync( + SessionRecord session, + GatewayRecord gateway, + string helperPath, + CancellationToken cancellationToken) + { + Calls.Add($"inspect:{gateway.ProcessId}"); + return Task.FromResult(Inspection); + } + + public Task<SessionInspectResult> 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"), + 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"), + 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 + }); + + 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() + { + 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 AnAmbiguousPendingGatewayIsNotReplacedOrStopped() + { + RecordPendingGateway(processId: 1234); + + SessionException exception = await Assert.ThrowsAsync<SessionException>( + () => 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() + { + _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<SessionException>( + () => 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/Gateway/GatewayPersistenceManagerTests.cs b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs new file mode 100644 index 00000000..bbecf7b2 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayPersistenceManagerTests.cs @@ -0,0 +1,664 @@ +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<string, string?>? resolveUserSid = null, + Action<string>? deleteFile = 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", + DeleteFile: deleteFile), + 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<ArgumentException>(() => + 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); + } + + [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() + { + 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("<Exec><Command>C:\\Windows\\System32\\cmd.exe</Command><Arguments>/d /c \"\"x\"\"</Arguments></Exec><Exec><Command>C:\\Windows\\System32\\cmd.exe</Command></Exec>")] + [InlineData("<ComHandler><ClassId>{00000000-0000-0000-0000-000000000000}</ClassId></ComHandler><Exec><Command>C:\\Windows\\System32\\cmd.exe</Command><Arguments>/d /c \"\"x\"\"</Arguments></Exec>")] + [InlineData("<ComHandler><ClassId>{00000000-0000-0000-0000-000000000000}</ClassId></ComHandler>")] + 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 = "<Actions Context=\"Author\">"; + const string end = "</Actions>"; + 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..03e45f7e --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Gateway/GatewayTaskDefinitionTests.cs @@ -0,0 +1,145 @@ +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<ArgumentException>( + () => 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 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() + { + 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( + "<Task><not-closed>", + out GatewayTaskSnapshot? parsed, + out string? detail)); + Assert.Null(parsed); + Assert.NotNull(detail); + } + + [Fact] + public void AbsentSettingsParseToTaskSchedulersOwnDefaults() + { + const string xml = """ + <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> + <Triggers /> + <Principals /> + <Settings /> + <Actions /> + </Task> + """; + + 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; + +/// <summary> +/// Covers the classification that separates a missing scheduled task from one +/// this account may not read. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +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<string>? 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<OperationCanceledException>( + () => 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<string> invocations = []; + SchTasksGatewayScheduler scheduler = CreateScheduler( + new SchTasksGatewayScheduler.SchTasksOutcome(0, "<Task />", 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/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/GatewayLauncherTests.cs b/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs deleted file mode 100644 index 34bd1dbf..00000000 --- a/tests/OpenClaw.Launcher.Tests/GatewayLauncherTests.cs +++ /dev/null @@ -1,101 +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( - [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 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( - [Path.Combine(_payloadDirectory, "openclaw.mjs"), .. arguments], - startInfo.ArgumentList); - } - - [Theory] - [InlineData("update", "--yes")] - [InlineData("--update")] - [InlineData("gateway", "call", "update.run")] - [InlineData("gateway", "install")] - [InlineData("setup", "--install-daemon")] - [InlineData("onboard", "--mode", "local")] - 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/HostDiagnosticLogTests.cs b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs index 982e5462..cd7291c4 100644 --- a/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs +++ b/tests/OpenClaw.Launcher.Tests/HostDiagnosticLogTests.cs @@ -44,7 +44,21 @@ 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)); + } + + [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() 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/HostOptionsTests.cs b/tests/OpenClaw.Launcher.Tests/HostOptionsTests.cs index 41f8893d..a165df85 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,43 @@ public void ParseResolvesPackagedApplicationWhenEntryPointExists() Assert.Equal( applicationDirectory, options.PackagedApplicationDirectory); + Assert.Null(options.PackagedNodeArchivePath); Assert.Equal(["gateway", "run"], options.OpenClawArguments); } + [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, + $"node-v{version}-win-{architecture}.zip"); + File.WriteAllText(archivePath, "fixture"); + + HostOptions options = HostOptions.Parse([], _testDirectory); + + 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<InvalidDataException>(() => + NodeRuntimeInstaller.FindArchivePath( + _testDirectory, + System.Runtime.InteropServices.Architecture.X64)); + } + public void Dispose() { Directory.Delete(_testDirectory, recursive: true); diff --git a/tests/OpenClaw.Launcher.Tests/Mxc/MxcCliSessionClientTests.cs b/tests/OpenClaw.Launcher.Tests/Mxc/MxcCliSessionClientTests.cs new file mode 100644 index 00000000..e35efe82 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Mxc/MxcCliSessionClientTests.cs @@ -0,0 +1,268 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Mxc; + +public sealed class MxcCliSessionClientTests +{ + private static readonly MxcRuntimeLocation Runtime = new( + @"C:\package\mxc\x64", + @"C:\package\mxc\x64\wxc-exec.exe", + @"C:\package\mxc\x64\plm.exe", + new MxcRuntimeProvenance("@microsoft/mxc-sdk", "0.8.0", "x64")); + + private static readonly MxcSandboxId SandboxId = MxcSandboxId.Parse("iso:abc"); + + [Fact] + public async Task ProvisionInvokesTheStagedExecutorWithAnEncodedEnvelope() + { + var invoker = new RecordingInvoker( + """{"result":{"sandboxId":"iso:abc","correlationVector":"cv-1"}}"""); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcProvisionResult result = await client.ProvisionAsync( + new MxcProvisionRequest("PFN:Contoso.App_8wekyb3d8bbwe"), + CancellationToken.None); + + Assert.Equal("iso:abc", result.SandboxId.Value); + Assert.Equal(Runtime.ExecutorPath, invoker.Invocation!.ExecutorPath); + Assert.Equal("--config-base64", invoker.Invocation.Arguments[0]); + + // The state-aware lifecycle surface is gated behind this flag; without + // it the executor rejects the request before reading the envelope. + Assert.Contains("--experimental", invoker.Invocation.Arguments); + + MxcRequestEnvelope sent = + MxcWireProtocol.DecodeConfig(invoker.Invocation.Arguments[1]); + Assert.Equal(MxcWireProtocol.ProvisionPhase, sent.Phase); + Assert.Equal( + "PFN:Contoso.App_8wekyb3d8bbwe", + sent.Experimental?.IsolationSession?.Provision?.AppId); + } + + [Fact] + public async Task ProvisionWithoutAnApplicationIdentityIsRejectedLocally() + { + var invoker = new RecordingInvoker("{}"); + var client = new MxcCliSessionClient(Runtime, invoker); + + MxcException exception = await Assert.ThrowsAsync<MxcException>( + () => 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<MxcException>( + () => 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<MxcException>( + () => 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<MxcException>( + () => 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<MxcException>( + () => 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<MxcException>( + () => 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<MxcExecutorOutcome> 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; + + /// <summary> + /// Stands in for a host whose backend detector cannot run, which is the + /// case whenever the runtime itself is missing. + /// </summary> + private static Task<MxcBackendProbe> BackendUnreachable( + MxcRuntimeLocation location, + CancellationToken cancellationToken) => + Task.FromException<MxcBackendProbe>( + new MxcException( + MxcErrorCode.RuntimeUnavailable, + "probe unavailable")); + + private static Func<MxcRuntimeLocation, CancellationToken, Task<MxcBackendProbe>> + 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<string, string?> 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<MxcHostSupport>(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<MxcException>( + () => 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<MxcException>( + () => 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<MxcException>(() => 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<MxcException>( + () => 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<MxcException>( + () => MxcWireProtocol.ParseNonExecutionResponse( + $"{{\"error\":{{\"code\":\"{backendCode}\",\"message\":\"denied\"}}}}")); + + Assert.Equal(Enum.Parse<MxcErrorCode>(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<MxcException>( + () => 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<MxcException>( + () => 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<MxcException>( + () => MxcWireProtocol.ParseNonExecutionResponse( + $"{{\"error\":{{\"code\":\"{backendCode}\",\"message\":\"m\"}}}}")); + + Assert.Equal(Enum.Parse<MxcErrorCode>(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<MxcException>( + () => 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<MxcException>( + () => MxcWireProtocol.ReadProbeResponse("""{"tier":"base-container"}""")); + + Assert.Equal(MxcErrorCode.ProtocolViolation, exception.Code); + } + + [Fact] + public void AMalformedProbeResponseIsRejected() + { + MxcException exception = Assert.Throws<MxcException>( + () => 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<MxcExecutorOutcome> 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<OperationCanceledException>(() => invocation); + Assert.Throws<ArgumentException>(() => 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<OperationCanceledException>( + () => 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<string> Events { get; } = []; + + public bool IsInteractive => true; + + public IDisposable Capture(Action<string> log) + { + Events.Add("capture"); + return new Restore(Events); + } + + public void InitializeUtf8() => Events.Add("utf8"); + + private sealed class Restore(List<string> events) : IDisposable + { + public void Dispose() => events.Add("restore"); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs new file mode 100644 index 00000000..a397a11b --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeInstallerTests.cs @@ -0,0 +1,260 @@ +using System.IO.Compression; +using System.Runtime.InteropServices; + +namespace OpenClaw.Launcher.Tests; + +public sealed class NodeRuntimeInstallerTests : IDisposable +{ + private readonly string _testDirectory = TestDirectory.Create(); + + [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) + { + string archivePath = CreateRuntimeArchive(version, architecture); + string installDirectory = Path.Combine(_testDirectory, "installed"); + + NodeRuntime first = EnsureInstalled( + archivePath, + installDirectory); + DateTime firstWriteTime = File.GetLastWriteTimeUtc(first.ExecutablePath); + NodeRuntime second = EnsureInstalled( + archivePath, + installDirectory); + + 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); + } + + [Theory] + [InlineData("unexpected/node.exe")] + [InlineData("{root}/../outside.txt")] + [InlineData("{root}/nested/../../outside.txt")] + public void EnsureInstalledRejectsUnsafeArchiveEntries(string entryName) + { + ArgumentNullException.ThrowIfNull(entryName); + string archivePath = CreateRuntimeArchive("26.1.0", Architecture.X64); + using (ZipArchive archive = ZipFile.Open( + archivePath, + ZipArchiveMode.Update)) + { + 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<InvalidDataException>(() => + EnsureInstalled(archivePath, installDirectory)); + Assert.False(Directory.Exists(installDirectory)); + Assert.False(Directory.Exists($"{installDirectory}.extract")); + } + + [Fact] + public void EnsureInstalledReplacesIncompleteRuntimeDirectory() + { + 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"); + + NodeRuntime runtime = EnsureInstalled(archivePath, installDirectory); + + 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<string>(); + + 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<InvalidOperationException>(() => + 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<NodeRuntime> first = Task.Run(Install); + Task<NodeRuntime> 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<InvalidDataException>(() => + NodeRuntimeInstaller.GetArchiveVersion(name, Architecture.X64)); + } + + public void Dispose() + { + Directory.Delete(_testDirectory, recursive: true); + GC.SuppressFinalize(this); + } + + private static NodeRuntime EnsureInstalled( + string archivePath, + string installDirectory, + Action<string>? 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, + $"node-v{version}-win-{architectureName}.zip"); + 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(nodeContent); + return archivePath; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs index e3d55556..8ccbc2b0 100644 --- a/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs +++ b/tests/OpenClaw.Launcher.Tests/NodeRuntimeResolverTests.cs @@ -4,137 +4,116 @@ 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 ResolveReportsInstallCommandWhenRuntimeIsMissing() + 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("not found on PATH", exception.Message); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message); + 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); - Assert.Contains(NodeRuntimeResolver.SupportedVersions, exception.Message); + 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); + 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); + 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)); - Assert.Contains("architecture Arm64 does not match X64", exception.Message); + Assert.Contains("architecture Arm64 does not match X64", exception.Message, StringComparison.Ordinal); } [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<string>( - new InvalidOperationException("query failed")), + () => Resolve( + _ => throw new IOException("version read failed"), Architecture.X64, Architecture.X64)); - Assert.Contains("query failed", exception.Message); - Assert.Contains(NodeRuntimeResolver.InstallCommand, exception.Message); - } - - [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))); + Assert.Contains("version read failed", exception.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", exception.Message, StringComparison.Ordinal); } - private static Task<NodeRuntime> ResolveAsync( - IReadOnlyList<string> candidates, - Func<string, Task<string>> queryVersion, + private static NodeRuntime Resolve( + Func<string, string> 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); } 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 @@ <ItemGroup> <ProjectReference Include="..\..\src\OpenClaw.Launcher\OpenClaw.Launcher.csproj" /> + <ProjectReference Include="..\..\src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj" /> + <ProjectReference Include="..\..\src\OpenClaw.SessionProtocol\OpenClaw.SessionProtocol.csproj" /> </ItemGroup> <ItemGroup> 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<string, string> result = Build(true, new Dictionary<string, string?>()); + + Assert.Equal("3", result["FORCE_COLOR"]); + Assert.Equal("1", result["WT_SESSION"]); + Assert.False(result.ContainsKey("NO_COLOR")); + } + + [Fact] + public void BuildPreservesExplicitTerminalValuesAndNoColor() + { + IReadOnlyDictionary<string, string> result = Build( + true, + new Dictionary<string, string?> + { + ["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<string, string> result = Build(false, new Dictionary<string, string?>()); + + Assert.DoesNotContain("FORCE_COLOR", result.Keys); + Assert.DoesNotContain("WT_SESSION", result.Keys); + } + + [Fact] + public void BuildPerfAddsDefaultsButExplicitValuesWin() + { + IReadOnlyDictionary<string, string> result = Build( + false, + new Dictionary<string, string?> + { + ["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<string, string> result = Build( + false, + new Dictionary<string, string?> { ["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<string, int> reads = new(StringComparer.OrdinalIgnoreCase); + + IReadOnlyDictionary<string, string> 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<string, string> result = Build( + false, + new Dictionary<string, string?> + { + ["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<string, string?> 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<string, string> Build( + bool isInteractive, + Dictionary<string, string?> values) => + OpenClawRuntimeEnvironment.Build( + isInteractive, + name => values.TryGetValue(name, out string? value) ? value : null); +} diff --git a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs index bef5652c..d59fa78f 100644 --- a/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs +++ b/tests/OpenClaw.Launcher.Tests/PackageManifestTests.cs @@ -5,26 +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<string>() - .OrderBy(value => value, StringComparer.Ordinal) - .ToArray(); - Assert.Equal(["clawctl.exe", "openclaw.exe"], aliases); - } + .OrderBy(value => value, StringComparer.Ordinal)]; } diff --git a/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs new file mode 100644 index 00000000..02718556 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ProgramStartupTests.cs @@ -0,0 +1,186 @@ +using System.Text; + +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 void ProductionStartupProvidesTheLifecycleFactoryForFallbackValidation() + { + Assert.NotNull(HostStartup.CreateProduction().InstallationLifecycle); + } + + [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 OperationalFailureRecommendsCollectingLogsAndExitsNonZero() + { + 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("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", + 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<ObjectDisposedException>(() => diagnostics.Write("after return")); + } + + // 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. 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 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"]; + using var output = new StringWriter(); + using var error = new StringWriter(); + + HostStartup startup = new() + { + Entrypoint = HostEntrypoint.Agent, + CreateDiagnostics = () => HostDiagnosticLog.Create(logPath), + BaseDirectory = _testDirectory, + Output = output, + Error = error + }; + + int exitCode = await Program.RunAsync(arguments, startup); + + 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); + } + + private HostStartup CreateStartup( + string logPath, + TextWriter output, + TextWriter error) => new() + { + Entrypoint = HostEntrypoint.Control, + CreateDiagnostics = () => HostDiagnosticLog.Create(logPath), + BaseDirectory = _testDirectory, + Output = output, + Error = error + }; + + 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 a32bb2ad..15a962cb 100644 --- a/tests/OpenClaw.Launcher.Tests/ProgramTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ProgramTests.cs @@ -1,131 +1,1199 @@ +using System.IO.Compression; +using OpenClaw.Launcher.Mxc; +using OpenClaw.Launcher.Gateway; +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; [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, 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( + new HostOptions( + applicationDirectory, + setupOptions.PackagedNodeArchivePath, + arguments), + _ => { }, + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => runtime.Paths.PackageFamilyName, + readEnvironmentVariable: _ => null); + + Assert.Equal(arguments, forwarded); + 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"], _ => { }, - _ => - { - 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); - }); + 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() + { + string applicationDirectory = await CreateApplicationAsync().ConfigureAwait(true); + bool runtimeCreated = false; + + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => 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.True(nodeResolutionAttempted); - Assert.True(launchAttempted); - Assert.Equal(23, exitCode); + Assert.False(runtimeCreated); + AssertRecommendsNewerWindows(failure.Message); } [Fact] - public async Task SetupChecksNodeAndPackagedApplicationWithoutMutation() + public async Task SetupFailsLoudlyWhenTheMachineCannotHostASession() { 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 nodeRuntime = new NodeRuntime( - Path.Combine(_testDirectory, "node.exe"), - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture); - var output = new StringWriter(); + var options = new HostOptions(applicationDirectory, null, []); + using var output = new StringWriter(); - int exitCode = await Program.RunControlAsync( + // The test host is unpackaged, so production readiness reports a + // machine that cannot own a session. + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => Program.RunControlAsync( + options, + ["setup"], + _ => { }, + output, + TextWriter.Null)); + + AssertRecommendsNewerWindows(failure.Message); + Assert.True(File.Exists(entryPoint)); + Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(entryPoint)); + + // 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() + { + 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); + + string expectedAgentNode = runtime.SetupState + .Read(runtime.ApplicationId).Record!.AgentNodePath!; + _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<SessionException>(() => Program.RunAgentAsync( + options, + _ => { }, + _ => runtime, + _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)), + () => "OpenClaw.Gateway_test", + _ => null)); + } + + [Fact] + public async Task UnsupportedMachineFailsBeforeTouchingSavedSessionState() + { + 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)!); + await File.WriteAllTextAsync( + runtime.Paths.SessionStatePath, + "{ not json").ConfigureAwait(true); + + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => 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 UnpackagedHostFailsWithoutCreatingASessionRuntime() + { + string applicationDirectory = Path.Combine(_testDirectory, "unpackaged-app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "fixture").ConfigureAwait(true); + bool runtimeCreated = false; + + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => 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.Contains("installed package", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AgentRefusesForeignSessionRecord() + { + SessionRuntime runtime = await SetUpSessionAsync(); + + 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<SessionException>( + () => RunAgentOnSupportedHostAsync(runtime)); + + Assert.Contains("Some.Other.App", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AgentRefusesSetupSessionMismatch() + { + SessionRuntime runtime = await SetUpSessionAsync(); + SetupRecord setup = runtime.SetupState.Read(runtime.ApplicationId).Record!; + runtime.SetupState.Write(setup with { SandboxId = "iso:different" }); + + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => RunAgentOnSupportedHostAsync(runtime)); + + Assert.Contains("different session", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AgentRefusesUnreadableSessionRecord() + { + SessionRuntime runtime = await SetUpSessionAsync(); + await File.WriteAllTextAsync(runtime.Paths.SessionStatePath, "{ not json"); + + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => RunAgentOnSupportedHostAsync(runtime)); + + Assert.Contains("not valid JSON", failure.Message, StringComparison.Ordinal); + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AgentSurfacesAnUnavailableSessionRuntimeInsteadOfRunningOnTheHost() + { + SessionRuntime runtime = await SetUpSessionAsync(); + _lastSessionBackend!.StartFailure = new MxcException( + MxcErrorCode.RuntimeUnavailable, + "The MXC runtime is not available."); + + SessionCapabilityUnavailableException failure = + await Assert.ThrowsAsync<SessionCapabilityUnavailableException>( + () => RunAgentOnSupportedHostAsync(runtime)); + + Assert.Contains("not available", failure.Message, StringComparison.Ordinal); + } + + [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, + installationLifecycle: new FailingFreshLifecycle(runtime)); + + Assert.Equal(1, exitCode); + Assert.Contains("--force", error.ToString(), StringComparison.Ordinal); + } + + [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(); + + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup"], _ => { }, output, - _ => Task.FromResult(nodeRuntime)); + TextWriter.Null, + installationLifecycle: lifecycle, + readEnvironmentVariable: name => + name == "OPENCLAW_SESSION" ? "0" : null); Assert.Equal(0, exitCode); - Assert.True(File.Exists(entryPoint)); - Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(entryPoint)); + 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] + 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("Session:", output.ToString(), StringComparison.Ordinal); + Assert.Contains( + "recovery removal failed", + output.ToString(), + StringComparison.OrdinalIgnoreCase); + } + + [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( + ((FakeMxcSessionClient)runtime.Backend).Calls, + call => call.StartsWith("execute-attached:", 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() + { + 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<string> 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( - nodeRuntime.ExecutablePath, + "not proven clean", output.ToString(), StringComparison.Ordinal); Assert.Contains( - applicationDirectory, + 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); + + // 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( + "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 SetupResolvesNodeBeforeReportingMissingApplication() + public async Task FreshResetReportPreservesReadableResidualIdentityOutsideClearedState() { - bool nodeResolutionAttempted = false; + 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(); - await Assert.ThrowsAsync<FileNotFoundException>( - () => Program.RunControlAsync( - new HostOptions(null, []), + int exitCode = await Program.RunControlAsync( + CreateSetupOptions(applicationDirectory), + ["setup", "--fresh", "--force"], + _ => { }, + output, + TextWriter.Null, + installationLifecycle: lifecycle); + + Assert.True(exitCode == 0, output.ToString()); + string reportPath = runtime.Paths.PreResetReportPath; + try + { + string report = await File.ReadAllTextAsync(reportPath); + Assert.Contains($"sessionSandboxId={oldSandboxId}", report, StringComparison.Ordinal); + } + finally + { + File.Delete(reportPath); + } + } + + [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() + { + 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); + 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] + 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 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() + { + 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<int> 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.OrdinalIgnoreCase); + } + + [Fact] + public async Task SetupReportsMissingApplicationBeforeProbingSupport() + { + bool supportProbed = false; + SessionRuntime runtime = CreateSessionRuntime(); + var lifecycle = new ProbeCountingLifecycle(runtime, () => supportProbed = true); + + await Assert.ThrowsAsync<FileNotFoundException>( + () => Program.RunControlAsync( + new HostOptions(null, null, []), + ["setup"], + _ => { }, + TextWriter.Null, + TextWriter.Null, + installationLifecycle: lifecycle)); + + Assert.False(supportProbed); + } + + [Fact] + public async Task AgentReportsMissingApplicationBeforeProbingSupport() + { + bool supportProbed = false; + + await Assert.ThrowsAsync<FileNotFoundException>( + () => Program.RunAgentAsync( + new HostOptions(null, null, []), + _ => { }, + probeReadiness: token => + { + supportProbed = true; + return SupportedHost(token); + })); + + Assert.False(supportProbed); + } + + [Fact] + public async Task AgentFailsLoudlyOnAnUnsupportedWindowsBuild() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool sessionRuntimeCreated = false; + + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => 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 AgentIgnoresTheRemovedSessionOptOutVariable() + { + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool sessionRuntimeCreated = false; + + // A stale OPENCLAW_SESSION=0 must not reopen the removed host path. + await Assert.ThrowsAsync<SessionException>(() => Program.RunAgentAsync( + new HostOptions(applicationDirectory, null, []), _ => { }, - new StringWriter(), _ => { - nodeResolutionAttempted = true; - return Task.FromResult( - new NodeRuntime( - "node.exe", - new Version(24, 15, 0), - System.Runtime.InteropServices.RuntimeInformation - .ProcessArchitecture)); - })); + sessionRuntimeCreated = true; + return CreateSessionRuntime(); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Unsupported, + null, + MxcSupportEvidence.HostBuild)), + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: name => + name == "OPENCLAW_SESSION" ? "0" : null)); - Assert.True(nodeResolutionAttempted); + Assert.False(sessionRuntimeCreated); } [Fact] - public async Task AgentResolvesNodeBeforeReportingMissingApplication() + public async Task UndeterminedSupportStillProvisionsTheSession() { - bool nodeResolutionAttempted = false; + string applicationDirectory = Path.Combine(_testDirectory, "app"); + Directory.CreateDirectory(applicationDirectory); + await File.WriteAllTextAsync(Path.Combine(applicationDirectory, "openclaw.mjs"), string.Empty); + bool sessionRuntimeCreated = false; - await Assert.ThrowsAsync<FileNotFoundException>( - () => Program.RunAgentAsync( - new HostOptions(null, []), + await Assert.ThrowsAsync<SessionException>(() => 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)); - })); + sessionRuntimeCreated = true; + throw new SessionException("the backend decided"); + }, + probeReadiness: _ => Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Unknown, + null, + MxcSupportEvidence.None, + null, + "the host detector could not run")), + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null)); + + 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); - Assert.True(nodeResolutionAttempted); + SessionException failure = await Assert.ThrowsAsync<SessionException>( + () => 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() @@ -133,4 +1201,416 @@ public void Dispose() Directory.Delete(_testDirectory, recursive: true); 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<MxcReadinessReport> SupportedHost(CancellationToken _) => + Task.FromResult(new MxcReadinessReport( + "runtime", + null, + null, + MxcHostSupport.Supported, + null, + MxcSupportEvidence.HostBuild)); + + private static Task<GatewayPersistenceInstallResult> RecoveryConfigured( + Action<string> _, + 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<string> 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"); + string baseDirectory = Path.Combine(_testDirectory, "base"); + Directory.CreateDirectory(baseDirectory); + string workspace = Path.Combine(_testDirectory, "workspace"); + 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 = _ => + { + WriteRuntimeInstallResult(backend, 0); + return Task.FromResult(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + _lastSessionBackend = backend; + return SessionRuntime.Create( + HostPaths.ForRoot(stateRoot, "OpenClaw.Gateway_test"), + () => throw new InvalidOperationException("The test backend must be supplied."), + baseDirectory, + _ => { }, + backend); + } + + private async Task<SessionRuntime> 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, + 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<string> log) => runtime; + + public Task EnsureSessionSupportedAsync( + CancellationToken cancellationToken) => Task.CompletedTask; + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, SessionRuntime sessionRuntime) => + Inner.ValidatePackageRuntime(options, sessionRuntime); + + public ISessionLockHandle AcquireLifecycleLock(SessionRuntime sessionRuntime) => + Inner.AcquireLifecycleLock(sessionRuntime); + + public Task<TeardownResult> TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action<string> log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + Inner.TeardownAsync(options, sessionRuntime, log, lockAlreadyHeld, cancellationToken); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + Inner.CreateStateCleaner(sessionRuntime); + + public Task<GatewayPersistenceInstallResult> InstallRecoveryAsync( + Action<string> log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceInstallResult( + GatewayPersistenceState.Ready, + 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<GatewayPersistenceStatus> GetRecoveryStatusAsync( + Action<string> log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceStatus( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "Logon recovery is configured.")); + } + + private static string SandboxIdFor(string applicationId) => + $"iso:{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( + $"{{\"appId\":\"{applicationId}\"}}")) + .TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + + private static void AssertRecommendsNewerWindows(string message) + { + Assert.Contains("newer version of Windows", message, StringComparison.Ordinal); + Assert.Contains("Windows Update", message, StringComparison.Ordinal); + Assert.Contains("#requirements", message, StringComparison.Ordinal); + } + + private Task<int> RunAgentOnSupportedHostAsync(SessionRuntime runtime) => + Program.RunAgentAsync( + CreateAgentOptions(), + _ => { }, + _ => runtime, + probeReadiness: SupportedHost, + getPackageFamilyName: () => "OpenClaw.Gateway_test", + readEnvironmentVariable: _ => null); + + 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"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllText( + Path.Combine(applicationDirectory, "openclaw.mjs"), + "console.log('fixture');"); + return new HostOptions(applicationDirectory, null, ["--version"]); + } + + private async Task<string> 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<string> Calls { get; } = []; + + public RecordingCleaner Cleaner { get; } = new(); + + public IInstallationStateCleaner? StateCleaner { get; init; } + + public Exception? CleanerException { get; init; } + + public Exception? TeardownException { get; init; } + + public TeardownResult? TeardownResult { get; init; } + + public bool TeardownSucceeds { get; init; } + + public GatewayPersistenceInstallResult RecoveryResult { get; init; } = + new( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready", + false); + + public TaskCompletionSource ProvisionStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource AllowProvision { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int TeardownCount { get; private set; } + + public SessionRuntime CreateRuntime(Action<string> log) => _runtime; + + public Task EnsureSessionSupportedAsync( + CancellationToken cancellationToken) => Task.CompletedTask; + + public PackageRuntimeMetadata ValidatePackageRuntime( + HostOptions options, + SessionRuntime runtime) + { + Calls.Add("validate"); + return new PackageRuntimeMetadata("node.zip", new Version(24, 0), runtime.HelperPath); + } + + public Task<TeardownResult> TeardownAsync( + HostOptions options, + SessionRuntime runtime, + Action<string> 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"); + if (StateCleaner is not null) + { + return StateCleaner; + } + + Cleaner.Exception = CleanerException; + return Cleaner; + } + + public Task<GatewayPersistenceInstallResult> InstallRecoveryAsync( + Action<string> log, + CancellationToken cancellationToken) => + Task.FromResult(RecoveryResult); + + public Task<GatewayPersistenceStatus> GetRecoveryStatusAsync( + Action<string> log, + CancellationToken cancellationToken) => + Task.FromResult(new GatewayPersistenceStatus( + GatewayPersistenceState.Ready, + GatewayPersistenceLane.TaskScheduler, + "ready")); + } + + private sealed class ProbeCountingLifecycle(SessionRuntime runtime, Action onProbe) + : IInstallationLifecycle + { + public SessionRuntime CreateRuntime(Action<string> 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<TeardownResult> TeardownAsync( + HostOptions options, + SessionRuntime sessionRuntime, + Action<string> log, + bool lockAlreadyHeld, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public IInstallationStateCleaner CreateStateCleaner(SessionRuntime sessionRuntime) => + throw new NotSupportedException(); + + public Task<GatewayPersistenceInstallResult> InstallRecoveryAsync( + Action<string> log, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task<GatewayPersistenceStatus> GetRecoveryStatusAsync( + Action<string> log, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + 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/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<string> 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/FakeMxcSessionClient.cs b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs new file mode 100644 index 00000000..e080c9d9 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/FakeMxcSessionClient.cs @@ -0,0 +1,127 @@ +using OpenClaw.Launcher.Mxc; + +namespace OpenClaw.Launcher.Tests.Session; + +/// <summary> +/// Records every lifecycle call so tests can assert ordering and ownership. +/// </summary> +internal sealed class FakeMxcSessionClient : IMxcSessionClient +{ + private int _provisionCount; + + public List<string> Calls { get; } = []; + + public List<string> 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 Func<MxcSandboxId, Exception?>? StartFailureForSandbox { 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<string> ExecutedCommandLines { get; } = []; + + public Func<MxcExecutionRequest, Task<MxcExecutionResult>>? ExecuteBehavior { get; set; } + + public Task<MxcProvisionResult> ProvisionAsync( + MxcProvisionRequest request, + CancellationToken cancellationToken) + { + Calls.Add("provision"); + ProvisionedAppIds.Add(request.AppId); + if (ProvisionFailure is not null) + { + return Task.FromException<MxcProvisionResult>(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}"); + Exception? failure = StartFailureForSandbox?.Invoke(sandboxId) ?? StartFailure; + return failure is not null + ? Task.FromException(failure) + : Task.CompletedTask; + } + + public Task<MxcExecutionResult> 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<MxcExecutionResult>(ExecuteFailure) + : ExecuteBehavior is not null ? ExecuteBehavior(request) : Task.FromResult(ExecutionResult); + } + + public int AttachedExitCode { get; set; } + + public List<string> AttachedCommandLines { get; } = []; + + public Func<CancellationToken, Task<int>>? AttachedBehavior { get; set; } + + public Task<int> 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<int>(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/GuestProcessObserverTests.cs b/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs new file mode 100644 index 00000000..e548beb7 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/GuestProcessObserverTests.cs @@ -0,0 +1,394 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; + +namespace OpenClaw.Launcher.Tests.Session; + +/// <summary> +/// 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. +/// </summary> +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 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() + { + 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/HostPathsTests.cs b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs new file mode 100644 index 00000000..3823f555 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/HostPathsTests.cs @@ -0,0 +1,78 @@ + +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<InvalidOperationException>( + () => 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()); + } + + [Fact] + public void ApplicationUserModelIdIsAbsentWithoutAnApplicationIdentity() + { + Assert.Null(PackageIdentity.TryGetApplicationUserModelId()); + } +} 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<SessionException>(() => 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<SessionException>(() => 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<SessionException>(() => 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/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; + +/// <summary> +/// Drives the real guest collector against real files, because its whole job is +/// copying files the host cannot otherwise reach. +/// </summary> +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<string> 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<SessionLaunchException>(() => + 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<SessionLaunchException>(() => + 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<string> ThrowAfterYield(string first) + { + yield return first; + throw new UnauthorizedAccessException("fixture access denied"); + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs new file mode 100644 index 00000000..b2a85a61 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionCoordinatorTests.cs @@ -0,0 +1,527 @@ +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<string> _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 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() + { + 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<MxcException>( + () => 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<MxcException>( + () => 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 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<MxcException>( + () => 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<MxcException>( + () => 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() + { + // 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<SessionStateException>( + () => 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<SessionStateException>( + () => 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<SessionBusyException>( + () => 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<MxcException>( + () => 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<MxcException>( + () => 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<SessionStateException>( + () => 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<SessionStateException>( + () => 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..92d34612 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionExecutorTests.cs @@ -0,0 +1,717 @@ +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<string> _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(Func<string>? createRequestId = null) => + new(_backend, _log.Add, createRequestId: createRequestId); + + [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:\Users\agent\AppData\Local\OpenClawGatewayMSIX\agent-node\node-v24.20.0-win-x64\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"]); + } + + /// <summary> + /// Stands in for the guest helper: reads the delivered request and writes + /// the control result the real helper would. + /// </summary> + private void RespondAsHelper( + Func<SessionLaunchRequest, SessionLaunchResult> 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, + }); + + private void RespondAsCollector( + Func<SessionCollectRequest, SessionCollectResult> respond) + { + _backend.ExecuteBehavior = _ => + { + 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(new MxcExecutionResult(0, string.Empty, string.Empty)); + }; + } + + private void RespondAsRuntimeInstaller( + Func<SessionRuntimeInstallRequest, SessionRuntimeInstallResult> 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)); + }; + } + + [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: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<SessionException>( + () => 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)); + } + + [Fact] + public async Task CollectionFailureResultIsReported() + { + RespondAsCollector(request => new SessionCollectResult + { + RequestId = request.RequestId, + Error = "access denied", + }); + + SessionException exception = await Assert.ThrowsAsync<SessionException>( + () => 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() + { + RespondLaunched(42); + + int exitCode = await Create().ExecuteAsync( + Record(), + Request("--version"), + CancellationToken.None); + + 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() + { + 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() + { + SessionLaunchRequest? delivered = null; + RespondAsHelper(request => + { + delivered = request; + return new SessionLaunchResult + { + RequestId = request.RequestId, + Launched = true, + ExitCode = 0, + }; + }); + + IReadOnlyDictionary<string, string> environment = + SessionExecutor.MergeEnvironment( + OpenClawRuntimeEnvironment.Build( + isInteractive: true, + _ => null), + 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() + { + // 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<int>(new MxcException(MxcErrorCode.BackendError, "no")); + + await Assert.ThrowsAsync<MxcException>( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Empty(Directory.GetFiles(Workspace)); + } + + [Fact] + public async Task ConcurrentInvocationsUseDistinctRequestFiles() + { + var seen = new List<string>(); + _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<SessionException>( + () => 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<SessionException>( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + 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() + { + _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<SessionException>( + () => 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<SessionException>( + () => Create().ExecuteAsync(Record(), Request(), CancellationToken.None)); + + Assert.Contains("different request", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SessionWithoutAWorkspaceIsReported() + { + SessionException exception = await Assert.ThrowsAsync<SessionException>( + () => 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<SessionException>( + () => 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<SessionException>( + () => SessionExecutor.BuildGuestCommandLine(helperPath, @"C:\ws\r.json")); + + Assert.Contains("quote or percent sign", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnsafeRequestPathIsRefused() + { + Assert.Throws<SessionException>( + () => 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<OperationCanceledException>( + () => Create().ExecuteAsync(Record(), Request(), cancellation.Token)); + + Assert.Empty(Directory.GetFiles(Workspace)); + } +} + +public sealed class OpenClawRuntimeEnvironmentTests +{ + [Fact] + public void ExternalSupervisionIsDeclared() + { + IReadOnlyDictionary<string, string> 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 ApplyToOverwritesAnInheritedValue() + { + var environment = new Dictionary<string, string?>(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; + +/// <summary> +/// Runs the command line this package hands the backend through the command +/// processor the backend actually dispatches with. +/// </summary> +/// <remarks> +/// <para> +/// The defect this covers reached a real installation: every path was quoted, +/// the string looked correct, and the launch still failed with +/// <c>'C:\Program' is not recognized</c>. 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. +/// </para> +/// <para> +/// The packaged helper lives under <c>C:\Program Files\WindowsApps\...</c>, so +/// a space in the executable path is the normal case rather than an edge one. +/// </para> +/// </remarks> +public sealed class SessionGuestCommandLineTests : IDisposable +{ + private readonly string _root = TestDirectory.Create(); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + /// <summary> + /// Writes a script that reports the argument vector it actually received. + /// </summary> + 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; + } + + /// <summary> + /// Dispatches exactly as the pinned runtime does: the command line is + /// appended to <c>cmd.exe /c</c> verbatim, with no further quoting. + /// </summary> + 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<SessionException>( + () => SessionExecutor.BuildGuestCommandLine( + Path.Combine(_root, fragment, "probe.exe"), + Path.Combine(_root, "launch.json"))); + + Assert.Throws<SessionException>( + () => 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<SessionException>( + () => SessionHelperStager.RequireStaged( + source, + Path.Combine(_root, "workspace"))); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void MissingPackagedHelperIsReportedBeforeStaging() + { + SessionException failure = Assert.Throws<SessionException>( + () => 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<IOException>( + () => 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/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; + +/// <summary> +/// Exercises the real <c>openclaw-session-host.exe</c> against real request and +/// result files, rather than only its internals. +/// </summary> +/// <remarks> +/// 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". +/// </remarks> +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; + } + } + + /// <summary> + /// A directory whose name contains a space and literal percent signs, so a + /// path that was expanded or split would not resolve. + /// </summary> + 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<string> standardOutput = helper.StandardOutput.ReadToEndAsync(); + Task<string> 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 new file mode 100644 index 00000000..3428b179 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionHostModeTests.cs @@ -0,0 +1,60 @@ +using OpenClaw.SessionHost; +using OpenClaw.SessionProtocol; +using SessionHostProgram = OpenClaw.SessionHost.Program; + +namespace OpenClaw.Launcher.Tests.Session; + +public sealed class SessionHostModeTests +{ + [Fact] + public void UndefinedModeIsRejectedBeforeLaunching() + { + const SessionLaunchMode mode = (SessionLaunchMode)42; + 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.Equal(0, launcher.StartCount); + 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 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<string>? 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/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<string, string> + { + ["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<SessionLaunchException>( + () => 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<SessionLaunchException>( + () => 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<SessionLaunchException>( + () => SessionLaunchProtocol.ReadRequest( + SessionLaunchProtocol.SerializeRequest(request))); + } + + [Fact] + public void MalformedJsonIsRejected() + { + Assert.Throws<SessionLaunchException>( + () => 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<SessionLaunchException>( + () => 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")); + } +} 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<ArgumentException>(() => new NamedSessionLock(" ")); + } + + [Fact] + public void NegativeTimeoutIsRejected() + { + NamedSessionLock sessionLock = CreateLock("negative"); + + Assert.Throws<ArgumentOutOfRangeException>( + () => sessionLock.TryAcquire(TimeSpan.FromSeconds(-1))); + } +} 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; + +/// <summary> +/// Drives the real guest installer against real files. +/// </summary> +/// <remarks> +/// 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. +/// </remarks> +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; + +/// <summary> +/// Covers the agent's own <c>PATH</c>, which is what makes the packaged Node.js +/// runtime win over a machine-wide installation. +/// </summary> +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; + +/// <summary> +/// The contract for installing the agent's own Node.js runtime. +/// </summary> +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<SessionLaunchException>( + () => SessionRuntimeProtocol.ReadRequest( + SessionRuntimeProtocol.SerializeRequest( + Valid() with { ArchivePath = archivePath }))); + } + + [Fact] + public void ARequestWithoutAnIdIsRejected() + { + Assert.Throws<SessionLaunchException>( + () => 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<SessionLaunchException>( + () => 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..a141a899 --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/Session/SessionRuntimeTests.cs @@ -0,0 +1,148 @@ +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<SessionCapabilityUnavailableException>( + () => 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<SessionException>( + host.RequireSetup); + + Assert.Contains("clawctl setup", failure.Message, StringComparison.Ordinal); + 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<SessionException>(() => + 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() + { + 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<SessionRecord>(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<Exception>( + () => 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<string, JsonElement> 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/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; + +/// <summary> +/// 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. +/// </summary> +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<string, string> + { + ["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<SessionLaunchException>( + () => 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<SessionLaunchException>( + () => 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); + } +} 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<SessionException>( + () => SessionSupportPolicy.EnsureSupported( + PackageFamilyName, + Ready(backendAvailable: false))); + + Assert.Contains("not available", exception.Message, StringComparison.Ordinal); + AssertRecommendsNewerWindows(exception); + } + + [Fact] + public void UnsupportedBuildWithoutAProbeFails() + { + SessionException exception = Assert.Throws<SessionException>( + () => 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<SessionException>( + () => 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<SessionException>( + () => 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<SessionException>( + () => 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); + } +} 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/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); + } +} 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); + } +} diff --git a/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs b/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs new file mode 100644 index 00000000..97d781be --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/WindowsHostConsoleTests.cs @@ -0,0 +1,241 @@ +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, + ErrorIsConsole = 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); + } + + [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; } + public uint OutputMode { get; set; } + public uint InputCodePage { get; set; } + public uint OutputCodePage { get; set; } + public bool OutputIsConsole { get; set; } = true; + public bool ErrorIsConsole { get; set; } = true; + public List<string> Writes { get; } = []; + public List<(nint Handle, uint Mode)> SetModes { get; } = []; + public List<uint> 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 handle == 2 ? OutputIsConsole : ErrorIsConsole; + } + + 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; + } + } +} diff --git a/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs b/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs index a9531fb6..eff88d41 100644 --- a/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs +++ b/tests/OpenClaw.Launcher.Tests/WindowsKillOnCloseJobTests.cs @@ -60,4 +60,62 @@ 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); + string actualContent = + await File.ReadAllTextAsync(outputPath, timeout.Token); + Assert.Equal(expectedContent, actualContent); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } }