diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c1c7682 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,268 @@ +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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e98d94f..f8904f4 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)) ` @@ -53,23 +57,32 @@ 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. The resolved + Node.js version flows through `source.json` and `payload-metadata.json`; + Windows payload builds use the same version. `Build-MSIX.ps1` downloads its + matching official archive, rejects Node.js from the application payload, + builds the application inventory, publishes the NativeAOT host, validates + package contents, and emits MSIX metadata including the runtime hash. - Unsigned artifacts are the normal PR/push output. Test signing uses a temporary runner-local certificate. Official signing is gated to `main` and the immutable upstream commit in `release-policy.json`; signing inputs are @@ -77,6 +90,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 +114,31 @@ 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. +- 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. - 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 0000000..08e9180 --- /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 15d1530..ff4e36e 100644 --- a/.github/workflows/gateway-msix.yml +++ b/.github/workflows/gateway-msix.yml @@ -10,7 +10,7 @@ on: openclaw_ref: description: openclaw/openclaw tag, branch, or commit to package required: true - default: 0965053fe6b9341776df147a6934b7485c60b5ca + default: f65ecca89667b8a55d9f88d76c487f0a0ab11da8 type: string signing_mode: description: Package signing mode @@ -26,9 +26,7 @@ permissions: contents: read env: - NODE_VERSION: 24.16.0 - PNPM_VERSION: 11.15.1 - OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || '0965053fe6b9341776df147a6934b7485c60b5ca' }} + OPENCLAW_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.openclaw_ref || 'f65ecca89667b8a55d9f88d76c487f0a0ab11da8' }} PACKAGING_ROOT: . jobs: @@ -48,66 +46,135 @@ 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 Node.js packaging inputs + shell: pwsh + run: .\scripts\Test-NodeRuntimeInputs.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 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 runs-on: ubuntu-latest outputs: source_sha: ${{ steps.source.outputs.sha }} package_version: ${{ steps.source.outputs.version }} + node_version: ${{ steps.source.outputs.node_version }} steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + # The OpenClaw checkout below must own GITHUB_WORKSPACE because its + # composite setup action resolves workspace files from that root. Keep + # the packaging-owned validator outside the workspace before replacing + # this checkout with the OpenClaw source tree. + - name: Stage build identity validator + shell: pwsh + run: > + Copy-Item + ./scripts/Test-OpenClawBuildIdentity.ps1 + $env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1 + - name: Check out OpenClaw source uses: actions/checkout@v7 with: repository: openclaw/openclaw ref: ${{ env.OPENCLAW_REF }} - path: openclaw-source persist-credentials: false fetch-depth: 1 - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Set up upstream Node.js and pnpm + uses: ./.github/actions/setup-node-env with: - node-version: ${{ env.NODE_VERSION }} - - - name: Enable pnpm - run: | - corepack enable - corepack prepare "pnpm@${PNPM_VERSION}" --activate + install-bun: "false" + install-deps: "false" - name: Install dependencies - working-directory: openclaw-source run: pnpm install --frozen-lockfile - name: Build OpenClaw - working-directory: openclaw-source env: OPENCLAW_CONTROL_UI_RELEASE_BUILD: "1" - run: | - pnpm build - pnpm ui:build + run: pnpm build + + - name: Validate OpenClaw build identities + shell: pwsh + run: > + & "$env:RUNNER_TEMP/Test-OpenClawBuildIdentity.ps1" + -OpenClawDirectory $env:GITHUB_WORKSPACE - name: Pack npm package id: source - working-directory: openclaw-source run: | set -euo pipefail artifact_dir="${RUNNER_TEMP}/openclaw-package" @@ -121,15 +188,18 @@ jobs: source_sha="$(git rev-parse HEAD)" package_version="$(node -p "require('./package.json').version")" + node_version="$(node -p 'process.versions.node')" echo "sha=${source_sha}" >> "${GITHUB_OUTPUT}" echo "version=${package_version}" >> "${GITHUB_OUTPUT}" + echo "node_version=${node_version}" >> "${GITHUB_OUTPUT}" cat > "${artifact_dir}/source.json" <> $env:GITHUB_OUTPUT + "release_tag=$releaseTag" >> $env:GITHUB_OUTPUT + "release_version=$($releaseTag.Substring(1))" >> $env:GITHUB_OUTPUT + - name: Enforce official signing policy shell: pwsh env: @@ -346,6 +507,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 +516,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 +537,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 +558,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 +608,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 +641,78 @@ jobs: path: artifacts\arm64\ if-no-files-found: error retention-days: 7 + + - name: Upload signed multi-architecture MSIX bundle + uses: actions/upload-artifact@v7 + with: + name: openclaw-gateway-msix-bundle + path: artifacts\bundle\OpenClawGateway.msixbundle + if-no-files-found: error + retention-days: 7 + + publish-release: + name: Publish signed Gateway MSIX release + if: ${{ needs.sign-msix.result == 'success' }} + needs: + - authorize-signing + - sign-msix + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download signed x64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-x64 + path: signed/x64 + + - name: Download signed ARM64 package + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-arm64 + path: signed/arm64 + + - name: Download signed multi-architecture bundle + uses: actions/download-artifact@v8 + with: + name: openclaw-gateway-msix-bundle + path: signed/bundle + + - name: Stage versioned release assets + shell: bash + env: + RELEASE_VERSION: ${{ needs.authorize-signing.outputs.release_version }} + run: | + set -euo pipefail + mkdir release-assets + cp signed/x64/OpenClawGateway-x64.msix \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}-x64.msix" + cp signed/arm64/OpenClawGateway-arm64.msix \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}-arm64.msix" + cp signed/bundle/OpenClawGateway.msixbundle \ + "release-assets/OpenClawGateway-${RELEASE_VERSION}.msixbundle" + + - name: Create permanent GitHub release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.authorize-signing.outputs.release_tag }} + target_commitish: ${{ github.sha }} + name: OpenClaw Gateway MSIX ${{ needs.authorize-signing.outputs.release_tag }} + generate_release_notes: true + prerelease: false + make_latest: true + overwrite_files: false + fail_on_unmatched_files: true + files: | + release-assets/*.msix + release-assets/*.msixbundle + body: | + Packages OpenClaw Gateway `${{ needs.authorize-signing.outputs.release_tag }}` + from [`openclaw/openclaw@${{ inputs.openclaw_ref }}`](https://github.com/openclaw/openclaw/commit/${{ inputs.openclaw_ref }}). + + ### Downloads + - **Recommended:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}.msixbundle` + - **x64:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}-x64.msix` + - **ARM64:** `OpenClawGateway-${{ needs.authorize-signing.outputs.release_version }}-arm64.msix` + + The packages are signed by OpenClaw Foundation through Azure Artifact Signing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..26f26f6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,232 @@ +# 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-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. +- 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 ec3ccb0..079e291 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 05461f4..4ea58b0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/OpenClaw.Gateway.MSIX.slnx b/OpenClaw.Gateway.MSIX.slnx index e22ddf6..d0f21df 100644 --- a/OpenClaw.Gateway.MSIX.slnx +++ b/OpenClaw.Gateway.MSIX.slnx @@ -3,6 +3,7 @@ + diff --git a/README.md b/README.md index f5d4df6..17e6eb3 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,9 @@ This repository builds a Windows MSIX package containing: - one .NET 10 NativeAOT launcher exposed through the `openclaw` and `clawctl` app execution aliases; - a pinned, verified build of - [`openclaw/openclaw`](https://github.com/openclaw/openclaw). - -Node.js is a device prerequisite and is never downloaded or included in the -MSIX. + [`openclaw/openclaw`](https://github.com/openclaw/openclaw); +- the official Node.js 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) @@ -29,9 +28,12 @@ own package-management commands. Every argument, including an empty argument list, is forwarded unchanged to `node openclaw.mjs`, and the launcher returns the exact child exit code. -Before launching, the host discovers `node.exe` on `PATH` and verifies its -version and executable architecture. It never downloads, installs, or services -Node.js. +Before launching, the host resolves the bundled Node.js executable previously +prepared by `clawctl setup` and 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 +43,76 @@ copy, repair, or otherwise change package files at runtime. Every OpenClaw child process runs with `OPENCLAW_SUPERVISOR_MODE=external`, `OPENCLAW_SERVICE_REPAIR_POLICY=external`, and -`OPENCLAW_NO_AUTO_UPDATE=1`. These declare external lifecycle ownership, -prevent doctor-owned service repair, and disable configured background -auto-updates. The pinned OpenClaw `v2026.8.2` release honors external supervisor -mode by refusing native service mutation and OpenClaw self-update with guidance -to use the external supervisor's workflow. This behavior belongs to upstream -OpenClaw; the launcher does not reserve, reject, or rewrite upstream command -arguments. +`OPENCLAW_NO_AUTO_UPDATE=1`. It also reports the selected Windows Gateway +session mode through the process-stable +`CLAWCTL_GATEWAY_ISOLATION=enabled|disabled` environment variable. The current +interactive-session launch path reports `disabled`; the future isolated-session +launch path will select `enabled` when that session switch is implemented. +These values declare external lifecycle ownership, prevent doctor-owned service +repair, disable configured background auto-updates, and expose diagnostic +isolation status without claiming independent attestation. The 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` exposes package readiness and launcher version information: | Command | Behavior | |---|---| -| `clawctl setup` | Verify compatible Node.js is on `PATH` and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl setup` | Extract the bundled Node.js runtime when needed and confirm packaged `app\openclaw.mjs` exists. | +| `clawctl --version` | Print the packaged launcher version. | + +Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing +state. `clawctl setup --help` prints help for that command alone. Help, usage, +and completion come from +[System.CommandLine](https://learn.microsoft.com/en-us/dotnet/standard/commandline/). +Invalid management input is rejected with exit code `1` and a parse diagnostic +on standard error; no readiness check runs. + +Help and version requests take precedence over the rest of the command line. +`clawctl --version bogus` prints the launcher version and exits `0` rather than +reporting `bogus`, because the version request is satisfied before the +remaining arguments are validated. The version printed is always the packaged +launcher's assembly version, including when the launcher is hosted by another +process. + +Response-file expansion is disabled. A leading `@` has no meaning to `clawctl` +and is reported as an unrecognized argument rather than read from disk. + +These parser conveniences belong to `clawctl` only. `openclaw` forwards every +argument to the OpenClaw CLI verbatim, so a leading `@` or a directive-shaped +token reaches that CLI uninterpreted. -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. +`setup` extracts the architecture-specific runtime archive from the immutable +MSIX into the package'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. 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 +126,18 @@ both: Changing only the workflow-dispatch default does not change automatic builds. For a one-time override, run **Build OpenClaw Gateway MSIX** manually and provide a tag, branch, or preferably a full 40-character commit SHA in -`openclaw_ref`. +`openclaw_ref`. Payload composition validates that the selected OpenClaw +runtime can discover and load the packaging-owned Windows Launcher plugin +with its required read-only route shape; incompatible older refs fail instead +of producing a package without status UI. + +The source build uses that revision's `.github/actions/setup-node-env` action +to select Node.js and pnpm. Its resolved Node.js version is recorded in +`source.json`, reused for both Windows payload builds, and carried in +`payload-metadata.json`. Package composition downloads that exact version; +the launcher derives its runtime version and LocalState path from the bundled +archive name. There is no separate packaging-side Node.js version pin or +runtime-support policy. The payload artifact records the requested ref and resolved upstream commit in `payload-metadata.json`. That build-only file is not embedded in the MSIX. @@ -108,12 +145,15 @@ The payload artifact records the requested ref and resolved upstream commit in OpenClaw commit, while embedded `payload-files.json` records every packaged application file's path, length, and SHA-256. -`release-policy.json` records the immutable OpenClaw commit approved for -official signing. Updating that policy requires a reviewed repository change. -Official signing runs only from `main` and verifies the workflow input, both +`release-policy.json` records the immutable OpenClaw commit and payload version +approved for official signing, plus the independent MSIX package version and +release tag. Updating that +policy requires a reviewed repository change. Official signing runs only from +`main` and verifies the workflow input, policy-approved package version, both architecture metadata files, both MSIX hashes, the embedded manifests, and -every file against the embedded application inventory before requesting Azure -credentials. +every file against the embedded application inventory. It also byte-compares +the bundle's embedded packages with those authorized standalone packages before +requesting Azure credentials. ## Build and test @@ -125,12 +165,31 @@ dotnet test .\OpenClaw.Gateway.MSIX.slnx ` ``` `scripts\Build-Payload.ps1` npm-installs an OpenClaw package into an expanded, -architecture-specific application tree. `scripts\Build-MSIX.ps1` copies that -tree into package content, rejects any Node.js executable or runtime archive, -creates a per-file inventory, and then creates an unsigned NativeAOT MSIX. +architecture-specific application tree and provisions the packaging-owned, +enabled-by-default Windows Launcher plugin into OpenClaw's bundled plugin +directory. Its internal package, path, and plugin ID remain +`gateway-isolation`. The plugin adds a read-only **Windows Launcher** tab to the +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. + +Full selected-theme cohesion requires the generic plugin-frame theme forwarding +merged by +[`openclaw/openclaw#145409`](https://github.com/openclaw/openclaw/pull/145409). +The minimum selected OpenClaw revision is its merged commit +`f65ecca89667b8a55d9f88d76c487f0a0ab11da8`. The page consumes validated +`openclaw:widget-theme` messages from its parent frame and follows built-in and +custom light/dark themes without reloading. Direct opens and older compatible +hosts still use the browser or operating system light/dark preference with a +safe built-in palette. + +`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. Normal pull-request and push workflows publish unsigned packages for validation. Manual runs support three signing modes: @@ -141,7 +200,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 +209,91 @@ Test-signing private keys are generated only on the temporary GitHub runner and are deleted before artifacts are uploaded. No signing secret or private key is stored in the repository. +Official releases use the independent four-part numeric `packageVersion` and +`releaseTag` from `release-policy.json`. The initial signing proof uses package +version `0.0.0.0` and tag `v0.0.0.0`; a later policy change can establish the +long-term Gateway-to-MSIX version mapping. The workflow creates the tag in this +repository and a GitHub Release with generated release notes. Each release +contains a signed, multi-architecture +`OpenClawGateway-.msixbundle` as the recommended download, plus signed +`OpenClawGateway--x64.msix` and +`OpenClawGateway--arm64.msix` packages for architecture-specific +deployment. The duplicate GitHub Actions artifacts remain short-lived transport +and diagnostic copies. + +For the all-zero proof only, MakeAppx assigns the outer bundle identity its +date/time-based version because it does not preserve `0.0.0.0` as a bundle +version. The two embedded architecture packages retain identity version +`0.0.0.0`; signing authorization verifies those versions and byte-compares both +embedded packages with the approved standalone inputs. + +An `.msixbundle` is a single installable container for the x64 and ARM64 MSIX +packages; Windows selects the package appropriate for the device. An +`.appinstaller` file is separate update-channel metadata rather than an +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/validation/themed-windows-launcher/README.md b/docs/validation/themed-windows-launcher/README.md new file mode 100644 index 0000000..91db6ba --- /dev/null +++ b/docs/validation/themed-windows-launcher/README.md @@ -0,0 +1,23 @@ +# Themed Windows Launcher validation + +This proof was generated from packaging implementation commit +`5ef558a28f182fd002478db89b8844c8ac4747fc` against the merged generic theme +forwarding in `openclaw/openclaw` commit +`f65ecca89667b8a55d9f88d76c487f0a0ab11da8`. + +The four screenshots cover the default Claw dark and light themes plus +distinctive imported custom dark and light themes. Each run uses the same +authenticated Gateway process and iframe. The result metadata verifies: + +- `Windows Launcher` in the Control UI sidebar, tab, and page heading. +- `Gateway Isolation` as the launcher status row. +- The CLI command and Copy control remain present. +- Exact semantic color, font, and radius forwarding. +- One authenticated iframe request with no navigation or reload during live + theme changes. +- `sandbox="allow-scripts"` without `allow-same-origin`. +- Read-only HTTP behavior and fail-closed launcher mode handling. + +`runtime-results.json` contains sanitized environment details, source commits, +artifact hashes, route checks, frame checks, and semantic token results. This +is expanded-layout browser proof, not installed MSIX proof. diff --git a/docs/validation/themed-windows-launcher/claw-dark.png b/docs/validation/themed-windows-launcher/claw-dark.png new file mode 100644 index 0000000..b62b10a Binary files /dev/null and b/docs/validation/themed-windows-launcher/claw-dark.png differ diff --git a/docs/validation/themed-windows-launcher/claw-light.png b/docs/validation/themed-windows-launcher/claw-light.png new file mode 100644 index 0000000..26f82cd Binary files /dev/null and b/docs/validation/themed-windows-launcher/claw-light.png differ diff --git a/docs/validation/themed-windows-launcher/custom-dark.png b/docs/validation/themed-windows-launcher/custom-dark.png new file mode 100644 index 0000000..d028518 Binary files /dev/null and b/docs/validation/themed-windows-launcher/custom-dark.png differ diff --git a/docs/validation/themed-windows-launcher/custom-light.png b/docs/validation/themed-windows-launcher/custom-light.png new file mode 100644 index 0000000..00d0f38 Binary files /dev/null and b/docs/validation/themed-windows-launcher/custom-light.png differ diff --git a/docs/validation/themed-windows-launcher/runtime-results.json b/docs/validation/themed-windows-launcher/runtime-results.json new file mode 100644 index 0000000..05e07d9 --- /dev/null +++ b/docs/validation/themed-windows-launcher/runtime-results.json @@ -0,0 +1,201 @@ +{ + "schemaVersion": 1, + "packagingCommit": "5ef558a28f182fd002478db89b8844c8ac4747fc", + "corePullRequest": "openclaw/openclaw#145409", + "coreCommit": "f65ecca89667b8a55d9f88d76c487f0a0ab11da8", + "build": { + "version": "2026.9.4", + "buildId": "2026.9.4-release-f65ecca89667-2026-09-14T22-27-01.258Z", + "controlUiBuildSource": "bundled" + }, + "environment": { + "os": "Windows x64", + "node": "v24.18.0", + "browser": "153.0.4234.32", + "layout": "expanded application" + }, + "sha256": { + "launcher": "2d59e8937074d252af3b0c3aa89814e59b132ef6a0e77ad4a38fb90c14b958be", + "node": "9a4eb5f1c29c6a2e93852ead46b999e284a6a5ca8bab4d4e241d587d025a52de", + "runtimeEntry": "538e8ee2b65a0b24bb8a5ed3421bfe66621b1e0b5f726a167758c004f566fb36", + "plugin": "1f3a0125bfa25216b203cdf760347286091da896246e74645606bab7092a8760" + }, + "launcher": { + "inheritedIsolationInput": "enabled", + "reportedIsolationMode": "disabled", + "provesLauncherOverridesInheritedInput": true + }, + "route": { + "path": "/plugins/gateway-isolation/status", + "httpResults": [ + { + "method": "GET", + "access": "anonymous", + "status": 401 + }, + { + "method": "GET", + "access": "wrong-token", + "status": 401 + }, + { + "method": "GET", + "access": "authenticated", + "status": 200 + }, + { + "method": "HEAD", + "access": "anonymous", + "status": 401 + }, + { + "method": "HEAD", + "access": "wrong-token", + "status": 401 + }, + { + "method": "HEAD", + "access": "authenticated", + "status": 200 + }, + { + "method": "POST", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PUT", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "PATCH", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + }, + { + "method": "DELETE", + "access": "authenticated", + "status": 200, + "effect": "same read-only response" + } + ], + "authenticated": true, + "readOnly": true, + "responseHardening": true + }, + "frame": { + "sandbox": "allow-scripts", + "allowSameOrigin": false, + "visibleRequests": 1, + "postMountNavigations": 0, + "sameFrameAcrossLiveThemeSwitches": true + }, + "themes": [ + { + "id": "claw-dark", + "family": "Claw", + "importedCustomTheme": false, + "screenshot": "claw-dark.png", + "hostTheme": "dark", + "mode": "dark", + "tokens": { + "--bg": "#0e1015", + "--card": "#161920", + "--button-bg": "#191c24", + "--text": "#bcbcc0", + "--text-strong": "#f4f4f5", + "--muted": "#8b8b94", + "--border": "#1e2028", + "--border-strong": "#2e3040", + "--focus": "#ff5c5c", + "--ok-text": "#22c55e", + "--warn-text": "#f59e0b", + "--radius": "10px", + "--radius-full": "9999px", + "--font-body": "\"Instrument Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "--font-mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace" + } + }, + { + "id": "claw-light", + "family": "Claw", + "importedCustomTheme": false, + "screenshot": "claw-light.png", + "hostTheme": "light", + "mode": "light", + "tokens": { + "--bg": "#faf9f7", + "--card": "#fff", + "--button-bg": "#fff", + "--text": "#403c35", + "--text-strong": "#211e1a", + "--muted": "#6e6960", + "--border": "#e8e4dc", + "--border-strong": "#d6d0c5", + "--focus": "#bd4531", + "--ok-text": "#166534", + "--warn-text": "#92400e", + "--radius": "10px", + "--radius-full": "9999px", + "--font-body": "\"Instrument Sans\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "--font-mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace" + } + }, + { + "id": "custom-dark", + "family": "Windows Launcher Proof", + "importedCustomTheme": true, + "screenshot": "custom-dark.png", + "hostTheme": "custom", + "mode": "dark", + "tokens": { + "--bg": "#151026", + "--card": "#281d45", + "--button-bg": "#3f2a63", + "--text": "#f8efff", + "--text-strong": "#ffffff", + "--muted": "#c5aad8", + "--border": "#7b5aa6", + "--border-strong": "#ad86d8", + "--focus": "#ff4fd8", + "--ok-text": "#22c55e", + "--warn-text": "#f59e0b", + "--radius": "10px", + "--radius-full": "9999px", + "--font-body": "Georgia, serif", + "--font-mono": "Courier New, monospace" + } + }, + { + "id": "custom-light", + "family": "Windows Launcher Proof", + "importedCustomTheme": true, + "screenshot": "custom-light.png", + "hostTheme": "custom-light", + "mode": "light", + "tokens": { + "--bg": "#fff4d6", + "--card": "#ffe3a3", + "--button-bg": "#ffd166", + "--text": "#342400", + "--text-strong": "#1f1300", + "--muted": "#765b23", + "--border": "#b7791f", + "--border-strong": "#7c4f00", + "--focus": "#7b2cbf", + "--ok-text": "#166534", + "--warn-text": "#92400e", + "--radius": "10px", + "--radius-full": "9999px", + "--font-body": "Georgia, serif", + "--font-mono": "Courier New, monospace" + } + } + ], + "cliControlPresent": true, + "passed": true +} diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100644 index 0000000..c2656fe --- /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/plugins/gateway-isolation/index.js b/plugins/gateway-isolation/index.js new file mode 100644 index 0000000..6f457d0 --- /dev/null +++ b/plugins/gateway-isolation/index.js @@ -0,0 +1,375 @@ +const ISOLATION_ENVIRONMENT_VARIABLE = "CLAWCTL_GATEWAY_ISOLATION"; +const STATUS_PATH = "/plugins/gateway-isolation/status"; +const THEME_MESSAGE_TYPE = "openclaw:widget-theme"; +const THEME_BRIDGE_SCRIPT = ``; + +export function readGatewayIsolationMode(env) { + const value = env[ISOLATION_ENVIRONMENT_VARIABLE]; + return value === "enabled" || value === "disabled" ? value : null; +} + +export function renderGatewayIsolationPage(mode) { + if (mode !== "enabled" && mode !== "disabled") { + throw new TypeError("Gateway isolation mode must be enabled or disabled."); + } + + const enabled = mode === "enabled"; + const status = enabled ? "Enabled" : "Disabled"; + const command = `clawctl gateway-isolation ${enabled ? "disable" : "enable"}`; + const tone = enabled ? "ok" : "warn"; + + return ` + + + + + Windows Launcher + + + +
+

Windows Launcher

+

Diagnostic launch mode reported by the Windows launcher.

+
+
+
Gateway Isolation
+
+ ${status} +
+
+
+
+
Change with CLI
+
Run from the signed-in user session on the Gateway host.
+
+
+ ${command} + +
+
+
+
+
+ ${THEME_BRIDGE_SCRIPT} + + +`; +} + +function renderGatewayIsolationUnavailablePage() { + return ` + + + + + Windows Launcher unavailable + + + +

Windows Launcher unavailable

+

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

+ ${THEME_BRIDGE_SCRIPT} + +`; +} + +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 0000000..0475aac --- /dev/null +++ b/plugins/gateway-isolation/index.test.js @@ -0,0 +1,289 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { + createGatewayIsolationPlugin, + readGatewayIsolationMode, + renderGatewayIsolationPage, +} from "./index.js"; + +function registerPlugin(mode) { + const descriptors = []; + const routes = []; + const plugin = createGatewayIsolationPlugin({ + CLAWCTL_GATEWAY_ISOLATION: mode, + }); + plugin.register({ + session: { + controls: { + registerControlUiDescriptor(descriptor) { + descriptors.push(descriptor); + }, + }, + }, + registerHttpRoute(route) { + routes.push(route); + }, + }); + assert.equal(descriptors.length, 1); + assert.equal(routes.length, 1); + return { descriptors, routes }; +} + +function invokeRoute(route) { + const result = { + body: "", + headers: {}, + statusCode: 0, + }; + const handled = route.handler( + {}, + { + writeHead(statusCode, headers) { + result.statusCode = statusCode; + result.headers = headers; + }, + end(body) { + result.body = body; + }, + }, + ); + assert.equal(handled, true); + return result; +} + +function runThemeBridge(html) { + const scripts = [...html.matchAll(/