diff --git a/.gitignore b/.gitignore index 97d6a0cbc..212024a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,13 @@ project.lock.json project.fragment.lock.json artifacts/ +# Local Gateway package used by the temporary setup handoff. +/OpenClawGateway-0.0.0.0-arm64.msix +/OpenClawGateway-0.0.0.1-arm64.msix +/OpenClawGateway-0.0.0.1.msixbundle +/.artifacts/native-shared-wizard/ +/.artifacts/native-shared-wizard-tests/ + # ASP.NET Scaffolding ScaffoldingReadMe.txt diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 210a045f1..6e177e4b9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,6 +72,22 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Settings page load/persist view logic | `SettingsPageViewModel` | authoritative | | Native tool identity, display arguments, payload extraction, and flattened-history projection | `NativeToolProjector` | authoritative | | Managed-local listener provenance and strong-credential authorization | `ManagedLocalGatewayPortProvenanceService` | authoritative | +| Native Gateway Microsoft Store listing handoff | `NativeGatewayMsixInstaller` | authoritative | +| Trusted Store and existing development Gateway registration identities | `NativeGatewayPackageIdentity` | authoritative | +| Current-user Gateway package registration, health and package-qualified alias discovery | `NativeGatewayPackageResolver` | authoritative | +| Missing-package acquisition, one installer handoff and bounded registration wait | `NativeGatewayPackageAcquisition` | authoritative | +| Shared Windows capability and permission selection, with runtime-specific install review | `CapabilitiesPage` | authoritative | +| Native profile draft creation and canonical state/config launch paths | `NativeGatewaySetupService` + `NativeGatewayPaths` | authoritative | +| Native onboarding capability admission and default/remembered gateway choice policy | `NativeGatewaySetupEligibility`, consuming `MxcAvailability` session probe metadata | authoritative | +| Manually installed native MSIX Gateway process/job lifetime and owned-listener verification | `NativeGatewayRuntime` | authoritative | +| Retained package-launcher identity, live same-user ancestry and lifetime attribution | `WindowsPackagedProcessAncestry`, anchored by `WindowsNativeGatewayProcessHost` | authoritative | +| Native setup staged-record runtime, reload restoration, config/health gates and publication | `NativeGatewaySetupSession` | authoritative | +| Hosted Gateway onboarding RPC and provider/auth/model rendering for WSL and native | `WizardPage` | authoritative | +| Audited optional onboarding defaults shared by native, WSL and headless setup | `WizardOnboardingPolicy` | authoritative | +| Optional-tail cancellation acknowledgement and saved-config/authenticated-health gates | `WizardOptionalSetupHandoff` | authoritative | +| Native terminal TUI onboarding and pre-wizard registry publication | `NativeGatewaySetupHost` / `NativeGatewaySetupService` | closed | +| Native Gateway credential preflight and retry authorization | `NativeGatewayEndpointSecurity` | authoritative | +| HTTP/dashboard/web-chat credential handoff routing and fresh native inspection | `InteractiveGatewayEndpointAuthorizer`, borrowing the manager-owned runtime | authoritative | | Local AI gateway-record ownership and WSL distro binding | `LocalAiGatewayDistroResolver` | authoritative | | Local AI model cache acquisition, explicit legacy migration, and active-path receipt selection | `HuggingFaceModelInstaller` + `LocalAiManifestStore` + `LocalAiInstallReconciler` | authoritative | | Exact Gateway wizard terminal-restart compatibility and bounded retry policy | `GatewayWizardRestartRecoveryPolicy` | authoritative | @@ -111,6 +127,266 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Aborted IDs and last-chat-state persistence | `ChatStatePersistence` | authoritative | | Sensitive instant-capture ordering | `SensitiveCaptureExecutor` + `SensitiveCapturePlans` | authoritative | +## Native Gateway MSIX lifecycle and shared-wizard handoffs + +The native path has three separate owners: Windows deploys the MSIX, Companion +supervises the installed Gateway, and upstream OpenClaw supplies onboarding over +RPC. Package installation, a listening port, and successful onboarding are not +interchangeable readiness signals. `App` remains the composition root; do not +move package resolution, process inspection or setup finalization back into it. + +### Package installation and discovery + +`NativeGatewayMsixInstaller.OpenAsync` asks Windows to open the fixed +[OpenClaw Gateway Microsoft Store listing](https://apps.microsoft.com/detail/9nv70lv3d6xc?hl=en-US&gl=US) +with `Launcher.LaunchUriAsync`. Microsoft Store owns architecture/package selection, +signature validation, deployment and installation consent. Opening the listing +does not mean installation succeeded. There is no local source path, environment +override, direct download, or ARM64-only installer gate. + +`NativeGatewayPackageResolver.ResolveAsync` subsequently requires exactly one +matching current-user package registration, verifies package health, and resolves +its package-qualified `openclaw.exe` and `clawctl.exe` execution aliases. Never +substitute a generic PATH/npm command, copied executable or guessed WindowsApps +installation path. `NativeGatewaySetupHost` invokes `clawctl setup` to prepare +the packaged runtime; that command does not perform Gateway onboarding. + +`NativeGatewayPackageIdentity` accepts the Store manifest's exact pair: +`OpenClawFoundation.OpenClawGateway` and +`CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC` +([packaging manifest](https://github.com/openclaw/openclaw-windows-packaging/blob/96770f14d73edfcba41964c08cd2f64f39420278/src/OpenClaw.Launcher/Package.appxmanifest)). +The original `OpenClaw.Gateway` / OpenClaw Foundation development publisher pair +remains accepted for already installed packages and saved profiles. Names and +publishers cannot be mixed. New setup with both identities installed produces an +explicit duplicate-registration error, not an implicit migration or preferred-package +fallback. The runtime resolver selects the saved profile's exact family, so an +existing Gateway remains usable when both packages are installed. Runtime records +remain pinned to their saved package family; Store +installation does not rewrite a development profile's identity. Package-family +syntax checks admit both names, while registration and exact family matching +remain mandatory before launching. MXC session provisioning is not implemented. + +After native capability/permission review, `NativeGatewaySetupPage` starts +automatically. It rechecks device support, then calls +`NativeGatewayPackageAcquisition.EnsureAsync`. Only the typed +`NativeGatewayPackageNotInstalledException` opens the Store listing, once per attempt. +Healthy registration skips installation; duplicate registration, unhealthy packages +and missing aliases fail explicitly instead of triggering reinstall loops. +The cancellable acquisition deadline is five minutes, with one-second polling. +Cancelling Companion setup stops waiting, not Windows deployment. + +The page reuses WSL's `StepRow` presentation for support, package readiness, +profile preparation and verified runtime startup. Completed rows get checkmarks. +It automatically transfers the staged session to `WizardPage`; no separate +Install, Check again or Open Gateway setup actions remain. Retry is error/cancel +recovery only. Preview never installs or starts a Gateway. + +Native and WSL use the same `CapabilitiesPage` profiles, toggles and Windows +permission checks. Native entry branches before Local AI/Tailscale probes and +uses its own review instead of advertising or invoking WSL provisioning. Cancelling +the native wizard returns to this review flow, not automatic runtime restart. +On finalization, the session applies selected command IDs to the dedicated +profile's `gateway.nodes.commands.allow` before config/health verification. +`TraySettingsConfig.MergeCapabilitiesIntoSettingsFile` then saves only node-mode +and capability flags, preserving startup, MCP and unrelated settings. Settings +write failure stays retryable before releasing the session. Completion shows the +configured Gateway and saved capability choices, not a running or paired Windows +node. The normal connection owner still performs node connection/pairing; Windows +permission and exec-approval gates are unchanged. + +### Capability recommendation and Windows Update + +The 2026-09-18 onboarding decision recommends the existing signed-in-user native +Gateway, not a newly implemented isolated-session runtime. The separate +"not isolated" warning/checkbox is removed by design; general onboarding security +consent and exact-identity pairing remain unchanged. Capability eligibility does +not change the native process ownership or account under which it runs. + +On Welcome, `NativeGatewaySetupEligibility` consumes the actual +`wxc-exec --probe` result `probes.isolationSessionAvailable` exposed by +`MxcAvailability.IsolationSessionCapability`. It must not infer session capability +from `IsolationProxy.exe`, a process-containment tier or a Windows build alone. +Windows Server/unknown SKU suppression remains in the shared probe. Missing or +invalid session metadata does not invalidate a usable process sandbox, but it +cannot authorize native onboarding. + +A positive result enables and initially selects the first **Install a local native +gateway** card with the accent highlight and **Recommended** badge. A negative +capability result offers Windows Update; reopening the page rechecks support. The pinned SDK documents +Insider build **26340.9212** as its baseline. This is update guidance, not a +hardcoded admission floor or a promise that a particular feature is enabled. +The native boolean cannot distinguish every OS API failure from missing support. +Missing executables, malformed results and probe errors instead offer retry or +Companion repair, not an assertion that Windows must be updated. + +The Welcome page always presents native Gateway first, WSL second and +**Connect to an existing gateway** third in one single-selection list. WSL is +visible and selectable during the native probe and for every probe outcome, +without an expander. There is no Welcome-page **Check again** button. +WSL/Local AI discovery starts on page load; fresh WSL readiness and +destructive-replacement confirmation still run before its capabilities page. +WSL is never selected implicitly after a failed native probe. A late probe +result preserves explicit WSL and existing-gateway selections, including choices +restored on Back navigation. Re-entering the +page rechecks support, stale results cannot mutate an unloaded page, and native +package setup rechecks capability before preparing a profile. +`ms-settings:windowsupdate` only opens Settings; Companion does not enroll the +device in an Insider channel or change Windows feature flags. + +### Dedicated profile and staged setup ownership + +`NativeGatewaySetupService` creates or resumes a credential-free draft descriptor +containing a Gateway ID, selected loopback port and package family. It creates a +separate profile at `\gateways\\native-gateway`, +with its own `openclaw.json`, generated authentication token and agent workspace. +The draft is not yet a published `GatewayRegistry` record. + +Retry re-reads the draft instead of keeping a stale in-memory port. If an +unpublished draft's port is occupied, `NativeGatewaySetupService` selects another +loopback port and updates only the port in the descriptor and configuration. +Gateway ID, identity, authentication token and provider settings are preserved. +A durable `PreviousPort` intent in the descriptor allows either interrupted +write to finish on the next attempt. Published records are never rotated by +this recovery. Runtime ownership checks still reject listeners that race startup; +no conflicting process is adopted or terminated. + +`NativeGatewayPaths` supplies explicit `OPENCLAW_STATE_DIR` and +`OPENCLAW_CONFIG_PATH` for package commands, rather than using the user's default +Gateway profile. Launch paths are mapped through `ResolveDataPath` to physical +locations because Companion and Gateway can have different MSIX filesystem +views; canonical registry paths are unchanged. External-supervisor/service-repair +flags and disabled automatic updates preserve Companion's lifecycle ownership. + +`NativeGatewaySetupSession` owns the temporary runtime, cancellation, pairing, +reload-setting backup/restoration and final publication gates. It suspends +configuration-triggered reload while the hosted wizard writes settings and +persists the previous reload mode so restoration survives a Companion restart. +Stopping the runtime does not delete the profile. + +### Why listener verification requires package-aware process ownership + +The installed package's launcher owns a separate kill-on-close job for Node. +Observed ownership with the development package was: + +```text +Companion-owned Windows lifecycle job + Packaged Gateway launcher + Package-owned Windows lifecycle job + Node process hosting the Gateway TCP listener +``` + +The original requirement that the TCP listener itself belong to Companion's job +rejected this valid arrangement: the launcher was in Companion's job, but Node +was not. A longer timeout, direct executable launch or an open loopback port +could not establish ownership. The exact Windows job-inheritance mechanism behind +that separation was not established. + +`WindowsNativeGatewayProcessHost` now creates the package launcher suspended, +assigns it to Companion's kill-on-close job, retains its process handle, then +resumes it. Assignment/resume failures terminate the created process. Atomic +job-list creation had failed while the package was already active; suspended +creation and assignment worked with the existing Gateway still running. + +`NativeGatewayRuntime` accepts the listener through direct job membership or +`WindowsPackagedProcessAncestry`: a live same-user descendant chain anchored to +that exact, still-job-owned launcher with the expected package family. Retained +process handles, parent/child creation ordering and bounded ancestry checks +prevent cached PIDs or executable names from being treated as ownership proof. +The runtime also requires complete IPv4/IPv6 inspection, loopback-only listeners, +the requested address, matching process creation times and two consistent TCP +snapshots. Unknown or replaced listeners are rejected, not adopted. + +No new cross-package process-handle protocol was added to the packaging repo. +An explicit authenticated handle-handoff contract was considered; the implemented +solution uses local retained-handle attribution with the existing package. +Companion owns the launcher lifetime, while the launcher owns Node cleanup. +`NativeGatewayEndpointSecurity` and setup authorization use this verification +before credential-bearing connections, including reconnects. + +`InteractiveGatewayEndpointAuthorizer` routes dashboard and web-chat HTTP +credential handoffs to the same runtime's fresh, double-snapshot inspection. +Its synchronous UI callback never starts a Gateway or waits for a busy lifecycle +gate; busy, stopped, disposed or replaced workloads fail closed. It does not +trust cached proof or connected status. App only composes this non-owning adapter; +process inspection stays in `NativeGatewayRuntime`. Non-native handoffs retain +`ManagedLocalGatewayPortProvenanceService` authorization. Typed native listener +conflicts retain `LocalPortConflict` classification instead of becoming generic +network failures. + +This is same-user supervision, not an MXC sandbox or a security boundary against +malicious same-user code. There is a narrow crash window between suspended +creation and job assignment that can leave a suspended launcher; kill-on-close +cleanup applies after assignment, and no launcher code has resumed before then. + +### RPC handoff to the existing wizard + +`NativeGatewaySetupPage` prepares the session and passes it to the shared +`WizardPage`. The page owns RPC/rendering; the session owns the profile/runtime. +There is no second provider UI or normal terminal-based onboarding path. + +1. Validate initial configuration, start the packaged Gateway and verify its + listener before sending credentials. +2. Connect using the per-Gateway identity and prefer its stored device token. + When pairing is required, `ApproveWizardPairingAsync` matches the handshake + request ID against that identity's device ID and public key, rechecks endpoint + ownership/configuration, and approves only the exact request via the package + CLI. Never approve the latest unrelated request or bypass onboarding consent. +3. Scope pairing commands to the dedicated profile, clear inherited URL overrides, + pin the port, and supply the token in the child environment, not argv. The CLI + budget includes slow package startup. Dispose the failed-handshake client + before one bounded retry; disconnect alone does not stop its reconnect loop. +4. Call `wizard.start` with `mode=local` and `installDaemon=false`; render upstream + steps and submit answers through `wizard.next`. `wizard.cancel` ends the + session. Native setup must not install a competing Gateway service. + +`WizardOnboardingPolicy` supplies the same audited optional skip/keep answers to +native, WSL and headless onboarding. Consent, provider/auth/model choices, +permissions, unknown prompts and errors are not silently answered. + +### Finalization and transfer to normal connection management + +At the Optional apps checkpoint, `WizardOptionalSetupHandoff` explicitly cancels +the remaining optional wizard tail, requires cancellation acknowledgement, and +checks `config.get` validity and authenticated `health`. The native session records +optional setup as deferred, not upstream wizard completion. Arbitrary errors or +user cancellation cannot take this success path. + +After real wizard completion or the validated optional handoff, +`NativeGatewaySetupSession.CompleteAsync` performs: + +```text +Stop setup-owned Gateway -> restore reload -> validate endpoint/auth and config +-> restart with verified ownership -> authenticated health -> stop setup runtime +-> save and activate GatewayRegistry record -> release setup ownership +``` + +Only then does the existing setup-completion restart path hand normal operation +to `GatewayConnectionManager` and its App-composed `NativeGatewayRuntime`. +Disconnect/switch/shutdown delegate stopping to that runtime; reconnect can reuse +a healthy owned runtime or restart a crashed one. Setup and normal connection +management must not become simultaneous process owners. Failed/cancelled setup +must not publish an unverified Gateway record. + +### Removal and remaining boundaries + +Disconnect stops the owned runtime but preserves its files. Removing the saved +Gateway in Connection settings removes the registry entry, not the native profile. +Windows Installed apps owns MSIX uninstallation, including package-managed data; +the separate Companion-managed profile is not automatically purged. Back up its +configuration, credentials, workspace and conversation state before destructive +cleanup. The broader Companion/WSL uninstall flow is not a native-Gateway-only +uninstaller. + +Gateway health does not prove AI-provider authentication, required model-runtime +availability or a usable default chat. Those first-chat readiness gaps remain +follow-up work, as do Store delivery and MXC isolation. See +[Gateway setup responsibilities](GATEWAY_SETUP_RESPONSIBILITIES.md) for the +investigation, production-backed proof and local-only recovery workarounds, and +[onboarding wizard](ONBOARDING_WIZARD.md) for the user flow. Regression coverage +lives in `NativeGatewayRuntimeTests`, `NativeGatewayWindowsProcessHostTests`, +`NativeGatewaySetupTests`, `WizardOptionalSetupHandoffTests` and +`NativeGatewaySetupUxContractTests`. + ## When you touch file X, extract toward Y | If you are editing… | Do not grow it. Extract toward… | @@ -249,6 +525,7 @@ leading and trailing pipe. Columns, in order: | tray-menu-presentation | authoritative | TrayMenuStateBuilder and src/OpenClaw.Tray.WinUI/App.xaml.cs | tray row and flyout presence, ordering, text, formatting, icon identity, action, checked and enabled state, accelerator, accessibility names, and connection-toggle projection | TrayMenuPresenter + ConnectionTogglePresenter | App captures immutable input and owns semantic callbacks, persistence, and reconnect policy; TrayController applies live projection; TrayMenuRenderer builds WinUI controls; TrayMenuWindow owns popup mechanics | equal immutable snapshots project equal complete menus; connected and disconnected compositions, all nine permission toggles, and transient connection states preserve behavior | TrayMenuPresenterTests.Connected_ProjectsExactTopLevelAndNestedOrder | behavioral | - | | tray-menu-state-builder-closed | closed | TrayMenuStateBuilder and src/OpenClaw.Tray.WinUI/App.xaml.cs | snapshot interpretation, semantic menu construction, and duplicated connection-toggle decisions | TrayMenuPresenter + ConnectionTogglePresenter | mechanical rendering, immutable snapshot capture, action dispatch, persistence callbacks, TrayController weak control references, and TrayMenuWindow native popup behavior | presentation owners stay WinUI/App/concrete-settings free and the renderer and controller do not interpret runtime snapshots | TrayMenuPresentationContractTests.PresentationFiles_AreWinUiAppAndConcreteSettingsFree | source-shape | when the tray menu no longer uses WinUI rendering | | node-connection-coordinator | authoritative | src/OpenClaw.Connection/GatewayConnectionManager.cs | node generation, cancellation, start guard, connect ordering, classified token recovery, connector events, and node telemetry | NodeConnectionCoordinator | manager public node façade; node-only operator/lifecycle/tunnel preparation; typed lifecycle/state/security ports; one event-forwarding subscription set | a superseded lifecycle or node generation cannot write node snapshot state | NodeConnectionCoordinatorTests.SupersededGeneration_DoesNotWriteSnapshot | behavioral | - | +| native-gateway-runtime | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs and src/OpenClaw.Connection/GatewayConnectionManager.cs | native Gateway package resolution, process launch, job ownership, and listener verification | NativeGatewayRuntime and NativeGatewayEndpointSecurity | App composes one runtime with its registry and installed-package resolver; manager delegates start/retry authorization, explicit disconnect and switch stop, and shutdown disposal | native credentials require runtime-owned endpoint proof for both roles; no WSL or remote exemption; reconnect preserves healthy native runtime and starts a crashed owned runtime | GatewayConnectionManagerTests.NativeGateway_ReconnectRestartsCrashWithoutStoppingHealthyRuntime | behavioral | - | | gateway-manager-node-owner-closed | closed | src/OpenClaw.Connection/GatewayConnectionManager.cs | private node generation/CTS/start workflow/recovery/telemetry implementation | NodeConnectionCoordinator | public node façade; node-only operator/lifecycle/tunnel preparation; typed lifecycle/state/security ports; one event-forwarding subscription set | the manager has no node generation, node CTS, combined node-attempt predicate, node connect core, or node telemetry names | ConnectionDomainOwnerClosureTests.GatewayConnectionManager_DoesNotReintroduceNodeGenerationOrTelemetryOwnership | source-shape | when GatewayConnectionManager no longer composes NodeConnectionCoordinator directly | | bootstrap-token-lifecycle | authoritative | src/OpenClaw.Connection/GatewayConnectionManager.cs | bootstrap selection and durable clear timing, device-token persistence handoff, post-bootstrap reconnect, and operator token recovery | BootstrapTokenLifecycle | manager public setup/shared-token façade and save-failure rollback; operator event forwarding; typed lifecycle lease, endpoint-security, reconnect, and v2 persistence ports | bootstrap clears only after canonical operator and node role tokens are both durably readable | BootstrapTokenLifecycleTests.ClearsBootstrap_OnlyWhenBothRoleTokensDurable | behavioral | - | | gateway-manager-bootstrap-owner-closed | closed | src/OpenClaw.Connection/GatewayConnectionManager.cs | bootstrap timing flags, durable-token clear helper, post-bootstrap scheduling, and operator mismatch recovery | BootstrapTokenLifecycle | public setup/shared-token façade and save-failure rollback; one-shot shared-token validation; operator event forwarding; typed lifecycle/reconnect/v2 ports | stale token events cannot restore timing flags, clear a newer record, or schedule an untyped reconnect callback | ConnectionDomainOwnerClosureTests.GatewayConnectionManager_DoesNotReintroduceBootstrapTimingOwnership | source-shape | when GatewayConnectionManager no longer composes BootstrapTokenLifecycle directly | diff --git a/docs/GATEWAY_SETUP_RESPONSIBILITIES.md b/docs/GATEWAY_SETUP_RESPONSIBILITIES.md new file mode 100644 index 000000000..9f8221d4a --- /dev/null +++ b/docs/GATEWAY_SETUP_RESPONSIBILITIES.md @@ -0,0 +1,633 @@ +# Gateway setup responsibilities + +Research snapshot: **2026-09-16**. This records the Gateway packaging assessment +and its comparison with WSL provisioning, so future integration work does not +duplicate existing owners. + +The native Companion integration described here is work in this branch, not a +claim that it has shipped. It runs ordinary Windows user processes, not MXC +isolated sessions. Packaging findings refer to +[`openclaw-windows-packaging` at `3a0491c`](https://github.com/openclaw/openclaw-windows-packaging/tree/3a0491cf8790a3336d118486b66aeaa04dba7ba7). +The supplied `0.0.0.0` ARM64 proof package is older and must not be assumed to +provide current source's bundled-Node preparation behavior. This assessment used +source inspection and a read-only package-manifest inspection, not a live +isolated-session test. + +## What Gateway packaging already provides + +| Responsibility | Provided by Gateway packaging? | +|---|---| +| Prepare Node.js | **Yes, in the inspected source.** `clawctl setup` extracts or repairs the bundled architecture-specific Node runtime in package LocalState and verifies the OpenClaw entry point. | +| Configure models/providers and Gateway | **Through bundled OpenClaw.** `openclaw onboard` invokes upstream onboarding. `clawctl setup` itself does not perform onboarding. | +| Launch Gateway | **Yes.** `openclaw` forwards arguments to the packaged CLI, including Gateway commands. | +| Clean up launched descendants | **Yes.** The launcher owns a kill-on-close Windows job and waits for its Node child. | +| Persistent supervision/restart | **No.** The launcher sets external-supervisor flags and expects another owner to manage the overall lifecycle. | +| Provision isolated Windows agent identity/session | **No.** | +| Run onboarding and Gateway inside that session | **No.** | +| Stop/deprovision an MXC session | **No.** | + +The packaging README explicitly reserves isolated agent sessions for future work. +MSIX integrity and read-only package files do not make the running Gateway an +isolated session. + +Packaging-owned surfaces to reuse: + +- [`Program.RunSetupAsync`](https://github.com/openclaw/openclaw-windows-packaging/blob/3a0491cf8790a3336d118486b66aeaa04dba7ba7/src/OpenClaw.Launcher/Program.cs): + prepares the runtime and checks packaged application readiness. +- [`ClawCtlCommandLine`](https://github.com/openclaw/openclaw-windows-packaging/blob/3a0491cf8790a3336d118486b66aeaa04dba7ba7/src/OpenClaw.Launcher/ClawCtlCommandLine.cs): + package readiness and launcher-version commands, not a second onboarding engine. +- [`GatewayLauncher`](https://github.com/openclaw/openclaw-windows-packaging/blob/3a0491cf8790a3336d118486b66aeaa04dba7ba7/src/OpenClaw.Launcher/GatewayLauncher.cs): + ordinary process launch, upstream argument forwarding, external-supervisor + environment and child lifetime. + +Release `v0.0.0.1` was available at the time of inspection with x64 MSIX, ARM64 +MSIX, and MSIX bundle assets. The future Store DLO is expected to target a bundle, +subject to confirmation when available. Windows selects its matching architecture; +the temporary ARM64 development input is not a product-wide restriction. + +## Comparison with current WSL provisioning + +### Implementation plan: package-aware native setup verification + +Scope: make fresh-profile native setup reach the existing WinUI wizard using the +installed package's launcher and Node lifecycle. Preserve the manually paired +proof Gateway and all existing WSL behavior. Do not add MXC provisioning, change +machine deployment policy, or implement another onboarding UI. + +1. Record the baseline and isolate proof. Use a new profile and free loopback + port for each attempt. Never read provider credentials from, restart, or + replace the user's working proof Gateway on port 18791. +2. Prove a supported Windows ownership mechanism before changing authorization. + First test whether documented desktop-app process creation policy can + preserve job inheritance with the package's existing Node supervisor. Prefer + retaining the current strong job-membership invariant over inventing a + weaker parent-PID rule. If it does not work, evaluate retained-handle + attribution, with an explicit proof gate. Stop and identify the missing + package capability if no trustworthy mechanism can be established. +3. Exercise real process lifetime and negative cases: unrelated listener, + mismatched creation time, listener replacement, cancellation, launcher + shutdown and child shutdown. A listening port or successful connection alone + does not authorize credential handoff. +4. Integrate the proven mechanism in `WindowsNativeGatewayProcessHost` and + `NativeGatewayRuntime`. Keep the package-qualified alias and the package's + Node supervision. Share the verification result through setup, health checks, + app connections and reconnects; do not relabel native records as remote to + use a credential exemption. +5. Make native setup progress reflect package preparation, config validation, + Gateway startup, endpoint verification and connection/pairing. Preserve + bounded waits, actionable errors and cancellation. Keep profile state for + retries; publish the Gateway only after wizard completion and final checks. +6. Prove `wizard.start` and cancellation on a fresh profile without answering + security/provider prompts. Collect current-build visible shared-wizard + evidence, or report the exact remaining blocker. Full interactive onboarding + and provider consent remain user-owned. +7. Add focused regression tests and run the full build, Shared, Tray, + SetupEngine and Connection suites plus strict MXC E2E regression validation. + Run structured review and report any review-tool blocker without bypassing + its protections. +8. Update this document, onboarding documentation and the architecture ledger + with the actual mechanism, preserved invariants, exact proof and remaining + limitations. No commit or PR publication is included in this request. + +### Original native shared-wizard validation blocker + +The 2026-09-16 disposable-profile proof with installed +`OpenClaw.Gateway_0.0.0.1_arm64__kaa03rpbbqef6` ran `clawctl setup` and +`openclaw config validate --json` successfully. Native startup then rejected the +loopback listener before any credential handoff: + +- Listener PID/start time matched the live process, and its handle was readable. +- `IsProcessInJob(listener, companionJob)` returned `true`, with membership + `false` and Win32 error `0`. +- The setup owner cleaned up; the proof port had no remaining listener, and + no gateway record was published. + +The original `WindowsNativeGatewayProcessHost.StartAsync` assigned the **alias launch** to a +kill-on-close job via `PROC_THREAD_ATTRIBUTE_JOB_LIST`. +`JobProcess.Owns` requires the actual listener to belong to that job. The +packaging source's `GatewayLauncher.RunAsync` creates its own +`WindowsKillOnCloseJob` for Node; this is not a documented handoff of listener +ownership to Companion. The observed membership result is the blocker, not +proof that the Gateway is isolated or that arbitrary loopback is safe. + +#### Launcher ownership and shutdown comparison + +A follow-up on 2026-09-16 used the installed release +[`v0.0.0.1`](https://github.com/openclaw/openclaw-windows-packaging/tree/3e5b1fff078e3ed46e9d2598835870a4e456717a), +fresh dedicated profiles and free loopback ports, without sending credentials or +wizard answers. It exercised Companion's actual process host and queried both +the job's process list and the listener's process identity: + +| Launch path | Launcher in Companion job | Listener in Companion job | After stopping Companion job | +| --- | --- | --- | --- | +| Registered `openclaw.exe` execution alias | Yes, PID 45876 | No, Node PID 51588; parent PID 45876 | Port 61716 closed; launcher and Node exited | +| Physical installed `openclaw.exe` (diagnostic comparison only) | Yes, PID 19968 | No, Node PID 18904; parent PID 19968 | Port 62312 closed; launcher and Node exited | + +The alias is not losing the launcher: Companion owns that launcher in both +cases. The package's `GatewayLauncher.RunAsync` keeps a +`WindowsKillOnCloseJob` alive while waiting for Node, so terminating the launcher +also terminates its workload. This confirms shutdown for both tested launches, +but does not make the listener a member of Companion's job or authorize sending +credentials based on parent PID alone. The exact Windows job-inheritance +mechanism behind the separation has not been established. + +The integration gap at that point was a supported way for Companion to verify the package-owned +workload. Prefer an explicit, authenticated supervision handoff that retains the +package as Node lifecycle owner, rather than duplicating runtime resolution, +walking private launcher handles, trusting a reported PID, or bypassing package +activation. Current release and inspected `main` launcher source expose no +external job/process-handle handoff. An explicit handle-handoff contract would require coordinated package and +Companion changes. The implementation below instead uses local, retained-handle +process attribution with the existing package. Neither direct executable launch +nor a longer startup timeout alone fixes the original membership check. + +Local proof outputs: `TestResults\launch-contract-proof\alias-result.log` and +`TestResults\launch-contract-proof\direct-result.log`. Both proof process trees +were confirmed exited. The existing interactive setup process was left untouched. + +The installed upstream `app\dist\wizard-Df2L6HWQ.js` does expose +`wizard.start/next/cancel` and accepts `mode` and `installDaemon`; there is no +need for a second wizard or undocumented flags. A supported package activation +and process-ownership handoff is required before proving that RPC from +Companion. Direct executable launch or adopting a PID is not an approved +production fallback. Shared-page rendering and provider authorization +remain unverified; no onboarding/security answer was sent by the proof. + +#### Package-aware implementation results + +The implementation uses the existing package without adding a cross-repository +protocol or another Node supervisor: + +- `WindowsNativeGatewayProcessHost` creates the launcher suspended, assigns its + kill-on-close job, retains a process handle while the original creation handle + is still held, then resumes the launcher. Failures terminate the created + process rather than continuing without job ownership. +- `PROC_THREAD_ATTRIBUTE_JOB_LIST` creation failed with access denied while the + user's package was already active. Both the original implementation and the + desktop-app-policy experiment failed under those conditions. The policy + experiment therefore does not establish a policy-specific failure. Suspended + creation followed by job assignment succeeded alongside the existing Gateway. +- `WindowsPackagedProcessAncestry` accepts a live same-user descendant only when + anchored to that exact, still-job-owned launcher and its expected package + family. It retains all traversed handles, rejects cycles and excessive depth, + and checks parent/child creation ordering. It does not trust a cached parent + PID, executable name or unrelated listener. The runtime still checks listener + creation time, loopback-only binding and two consistent TCP snapshots. +- Setup, health checks and reconnect authorization continue to use the same + runtime. Native records do not use the remote credential exemption. +- Startup/verification progress is explicit and dispatched to the WinUI thread. + A fresh setup identity is paired automatically: `NativeGatewaySetupSession` + checks the handshake request ID against the pending request's device ID and + public key, then approves only that exact request through the package CLI. + It verifies package listener ownership and local-profile configuration before + listing and approving. The failed client is disposed to cancel its reconnect + loop; `WizardPage` creates a new client for one bounded retry. Unrelated devices + and onboarding consent are never approved. The profile-specific terminal is + retained for recovery only. + +The installed CLI rejects `--url` when credentials are supplied only through the +environment. Pairing therefore uses `devices list --json` and +`devices approve --json` against the verified dedicated local profile. +Setup clears inherited `OPENCLAW_GATEWAY_URL` and pins `OPENCLAW_GATEWAY_PORT`; +`OPENCLAW_GATEWAY_TOKEN` is supplied only in the child environment. No token is +placed on argv, and no private Gateway pairing-storage format is assumed. + +| Proof | Result | +| --- | --- | +| Package-aware listener attribution | Passed with installed ARM64 0.0.0.1 | +| Unrelated listener and mismatched creation time | Rejected | +| Replacement listener after shutdown | Rejected | +| Supervisor exits without running disposal | Launcher PID 67480 and Node PID 55832 exited when Windows closed the job handle | +| Existing paired proof Gateway | Port 18791 remained on PID 17788 | +| Fresh-profile ownership, automatic pairing, authenticated connection and wizard RPC | Production setup automatically approved the exact disposable identity request after device ID/public-key matching; `wizard.start` returned `type=note`, `title=OpenClaw setup`. No manual terminal approval. | +| Wizard cancellation | Runtime stopped; no Gateway registry publication | +| Full repository build | Passed | +| Shared tests | 3,983 passed, 33 skipped | +| Tray tests | 2,995 passed, including terminal-error display regression cases | +| SetupEngine tests | 1,238 passed, 1 skipped, including 14 automatic-pairing regression cases | +| Connection tests | 870 passed, 1 skipped | +| Strict WSL-to-Windows-node MXC E2E | 17 passed | +| Full upstream optional tail | Not repaired: developer captured `PreparedModelCatalogConfigReplacedError` after Optional apps. Shortened setup now ends explicitly before this tail; see below. | +| `python .agents\skills\autoreview\scripts\autoreview --mode local` | Blocked by the helper's secret-like-content guard on existing untracked setup/test files; no clean review claimed | + +Evidence is local under `TestResults\launch-contract-proof\`, +`TestResults\native-auto-pair-proof.log` and the +`TestResults\native-auto-pair-*-tests.log` validation logs. The fresh-profile +proof invokes the production setup host/session, verifies authenticated +`hello-ok`, opens and cancels the first wizard step, and checks no registry +publication. It does not submit security, provider or model answers. +One Shared in-flight MCP disposal test failed on the first suite run, then passed +both its focused rerun and the full required-validation rerun. + +The subsequent interactive run also exceeded the original 30-second pairing CLI +budget. A same-profile read-only probe later succeeded in 4.6 seconds, and the +developer's retry paired successfully. Pairing commands now use the same +two-minute total process budget as configuration/health checks. This includes +packaged runtime startup, not just RPC execution. Current diagnostic-fix +validation logs use the `TestResults\native-terminal-` prefix. An attempted +recovery of the old masked error found an overwritten response buffer; the +temporary process dump was removed. + +### Shortened onboarding implementation and proof + +The same `WizardOnboardingPolicy` now drives native and WSL `WizardPage` plus +the headless WSL runner. It acknowledges the audited informational notes and +selects offered skip/keep values for optional configuration. Security and telemetry +consent, agent name, provider/auth/model choices, permissions and errors remain +explicit. Unknown prompts remain visible rather than receiving guessed answers. + +`WizardOptionalSetupHandoff` owns the Optional apps checkpoint: do not acknowledge +that note, explicitly cancel the remaining upstream wizard, require cancellation +confirmation, then require valid saved config and authenticated Gateway health. +Native setup records a separate deferred-optional state, not `wizardCompleted`, +and still performs reload restoration, CLI config validation, owned listener +verification and health before registry publication. WSL retains its existing +service ownership, reload restoration and Windows-node context lifecycle. +User cancellation and terminal errors cannot take this handoff. + +Historical disposable production-host/session proofs from the original native +integration, before the subsequent onboarding UI updates: + +| Proof | Result | +| --- | --- | +| Fresh ARM64 MSIX 0.0.0.1 profile | Automatic exact-identity pairing, authenticated wizard RPC, all requested optional steps bypassed, validated optional-tail cancellation, final config/owned-health checks and disposable registry publication passed | +| Existing configuration | Repeated the complete proof with enabled web search (`maxResults: 3`) and existing disabled Telegram/pairing configuration; both subtrees remained exactly equal | +| Safety/provider answers | Test-only authorization accepted safety and skipped provider credentials/integrations; real UI consent remains explicit | +| Historical full build | Passed | +| Historical Shared / Tray suites | 3,983 passed / 33 skipped; 2,996 passed | +| Historical SetupEngine / Connection suites | 1,285 passed / 1 skipped; 870 passed / 1 skipped | +| Historical strict WSL-to-Windows-node MXC E2E | 17 passed, including real Gateway `system.run` execution and denied writes to tray data | +| Native and WSL UI/headless wiring | Shared-policy and handoff source contracts passed, plus behavioral policy, failed-gate, cancellation and native publication tests | +| Visual UI and live shortened WSL wizard | Not yet verified by these RPC proofs; native production lifecycle was exercised without clicking the WinUI page | +| Structured autoreview | Still blocked by the existing untracked-file secret-like-content guard; no clean review claimed | + +Two earlier 2026-09-18 strict MXC attempts were blocked by WSL distro import +failure and Gateway restart failure during WSL wizard setup. After source-path +sanitization and native handshake/finalization fixes, the current-source Release +strict MXC run passed all 17 tests with no skips. The historical table above is +not the evidence for that rerun. The updated native UI has visibly advanced +through package/profile preparation into the shared wizard, but full interactive +native completion and normal-owner handoff remain unverified. + +Logs: `TestResults\short-wizard-live-proof.log`, +`TestResults\short-wizard-preservation-proof.log`, +`TestResults\short-wizard-build.log`, and `TestResults\short-wizard-*-tests.log`. +Strict Gateway-to-node proof is in `TestResults\short-wizard-mxc.log`. +The first Shared run hit the existing in-flight MCP disposal race; the focused +rerun and subsequent full build/all-suite rerun passed. The first preservation +fixture selected unavailable Brave integration and correctly failed config +validation; the successful fixture uses valid provider-independent search config. + +### Local chat smoke and remaining setup gaps + +The developer's native Gateway was verified as a Windows process listening on +loopback, with its own configuration, agent workspace and `SOUL.md`. The browser +Control UI and Companion connected to the same Gateway. After explicit GitHub +Copilot device authorization, the profile selected +`github-copilot/claude-sonnet-5`; a real `chat.send` turn completed successfully +and `chat.history` contained the requested `READY` reply. + +These local recovery steps are **not automated by the source changes**: + +- The selected OpenAI model initially needed the separate + `@openclaw/codex@2026.8.2` runtime. Installation required explicit capability + consent. Plugin registration and authenticated Gateway health then passed. +- The installed Codex executable existed, but its 285-character development + profile path caused Node's ordinary Windows spawn to return `ENOENT`. The + namespaced Windows path launched successfully and completed an app-server + `initialize` handshake. The local profile used the documented + `plugins.entries.codex.config.appServer.command` override, without editing + installed package files. This is a machine-local workaround, not a general + launcher fix; an install-location change requires revisiting the override. +- A subsequent OpenAI turn reached the provider but lacked authentication. + GitHub Copilot was then selected and authorized for this profile. Gateway + pairing tokens are not AI-provider credentials. + +First-run chat selection still needs a source fix. `ChatSnapshotProjector` can +restore a remembered background session, and `ChatPresentationState` can persist +the first listed session when the main chat is unavailable. This left the +automation "Memory Dreaming Promotion" selected instead of a normal conversation. +Creating a regular conversation avoids that placement error, but setup should +select or initialize a normal default conversation itself. + +Follow-up work must validate the selected model's required runtime/authentication +and a successful first conversation, not just Gateway health. The working local +profile, provider credentials, installed plugins, launch override, MSIX payloads +and raw proof artifacts are intentionally excluded from the commit. + +**Limits:** This is same-user supervision, not a security boundary against +malicious code holding that user's process-creation/injection rights. There is +also a narrow pre-assignment crash window: forced Companion termination between +successful suspended creation and job assignment can leave a suspended launcher. +No launcher code has been resumed in that window. Kill-on-close cleanup is +proven after assignment, not before it. No claim of atomic job-list creation is +made. The observed host could not tighten the proof identity's ACL through the +existing best-effort helper; its warning is retained in the proof log. No real +provider credentials were placed in the disposable profile. + +Rubber-duck review found a background-thread progress callback that could reject +otherwise healthy reconnects. The callback now marshals through the captured +WinUI dispatcher and ignores updates after its page unloads; source-contract +coverage guards that wiring. The full build and four suites above were rerun +after the fix. Structured review command +`python .agents\skills\autoreview\scripts\autoreview --mode local` remains blocked +by the review helper's secret-like-content guard on existing untracked setup and +test sources. No clean autoreview result or guard bypass is claimed. + +Companion owns the WSL environment's lifecycle end to end. It delegates Gateway +installation, onboarding and service commands to upstream OpenClaw, while WSL +and systemd provide execution and service management. + +| Responsibility | Current WSL path | Native Gateway MSIX path in this branch | +|---|---|---| +| Check host prerequisites | **Companion:** OS, WSL readiness, virtualization and port checks. | **Companion:** actual MXC session-capability probe for recommendation, then package/alias checks. This does not provision an isolated session. | +| Provision isolated environment | **Companion:** creates the app-owned WSL distro. | **Not implemented:** MSIX installation does not create an isolated session. | +| Create workload identity | **Companion:** creates a Linux user inside the distro. | **Not implemented:** runs as the signed-in Windows user. | +| Configure environment restrictions | **Companion:** writes `wsl.conf`, configures automount/interop and validates lockdown. | **Not implemented for MXC sessions.** | +| Install Node and Gateway payload | **Companion orchestrates the upstream installer** inside WSL. | **Packaging owns payload delivery; `clawctl setup` prepares bundled Node** in current packaging source. | +| Acquire missing Gateway package | Not applicable. | **Companion:** automatically opens the [Gateway Microsoft Store listing](https://apps.microsoft.com/detail/9nv70lv3d6xc?hl=en-US&gl=US) once, then waits up to five minutes for validated current-user registration. Store owns package selection/consent/signature/deployment; cancellation only stops Companion's wait. Exact Store and existing development identities are accepted, but duplicate registrations fail and saved profiles stay pinned to their family. | +| Select Windows capabilities and permissions | **Shared `CapabilitiesPage`:** profiles, toggles and Windows permission checks. | **The same page**, with native review instead of WSL/Local AI/Tailscale provisioning. Selected Gateway commands are applied before validation; only node/capability flags are merged into Companion settings. | +| Prepare Gateway configuration | **Companion:** configures endpoint/auth and optional integrations. | **Companion:** prepares a dedicated profile; packaged CLI consumes it. | +| Provider/model onboarding | **Upstream OpenClaw wizard**, rendered by the shared WinUI `WizardPage`. | **The same `WizardPage` and upstream `wizard.start/next/cancel`**, over a verified native runtime endpoint. No separate TUI or provider UI. | +| Install persistent Gateway service | **Companion calls `openclaw gateway install --force`; upstream installs the systemd user service.** | **No service installation:** launcher declares external supervision. | +| Start/stop/restart Gateway | **Companion requests actions; upstream CLI/systemd execute them.** | **Companion runtime manages launcher lifetime; package manages its Node descendants.** | +| Detect failure and recover | **systemd service management plus Companion's managed-local repair monitor.** | **Companion reconnect can restart its owned runtime; packaging has no persistent restart supervisor.** | +| Keep hosting environment available | **Companion's WSL keepalive service.** | No separate environment in the current non-isolated path. | +| Connect, pair and verify | **Companion:** bootstrap/operator/node pairing and end-to-end checks. | **Companion:** health verification and connection/pairing handoff. | +| Remove hosting environment | **Companion:** ownership-guarded distro rollback/removal. | Windows removes the package; **MXC session deprovisioning does not exist yet.** | + +The distinction is **orchestrates versus implements**. Companion does not +reimplement OpenClaw's onboarding or systemd service installer. It invokes those +inside the environment it provisioned. + +Local source references: + +- `src\OpenClaw.SetupEngine\NativeGatewaySetupSession.cs`: staged native runtime, + reload restoration, config/health gates and registry publication. Native + cancellation does not finalize setup; original startup preference is preserved. +- `src\OpenClaw.SetupEngine\WizardOnboardingPolicy.cs` and + `WizardOptionalSetupHandoff.cs`: shared deferred-feature defaults and the + explicit cancellation/config/health checkpoint, not an upstream completion claim. +- `src\OpenClaw.SetupEngine.UI\Pages\WizardPage.xaml.cs`: shared hosted RPC and + provider/auth/model rendering. Native branches delegate lifecycle to the native + session and do not call WSL recovery or Windows-node WSL context injection. +- `src\OpenClaw.SetupEngine\SetupPipeline.cs`: overall WSL provisioning sequence. +- `src\OpenClaw.SetupEngine\ConfigureWslInstanceStep.cs`: Linux user, + directories and WSL configuration. +- `src\OpenClaw.SetupEngine\InstallCliStep.cs` and + `InstallGatewayServiceStep.cs`: upstream installation commands. +- `src\OpenClaw.SetupEngine\RunGatewayWizardStep.cs`: Gateway onboarding delegation. +- [Connection architecture](CONNECTION_ARCHITECTURE.md): ongoing managed-local + WSL supervision and repair. +- [Native setup documentation](ONBOARDING_WIZARD.md#native-gateway-msix-not-isolated): + the branch's non-isolated native implementation. + +## Decided ownership and persistence for MXC sessions + +**2026-09-18 UI decision:** native setup now recommends the existing signed-in-user +Gateway after a successful session-capability probe, with WSL under collapsed +alternatives. The separate isolation warning/acknowledgment is removed by design. +`NativeGatewaySetupEligibility` uses the actual `probes.isolationSessionAvailable` +field, replacing the `IsolationProxy.exe` heuristic for this capability signal. +Negative results offer Windows-update guidance; failed or incomplete checks offer +retry/repair. This does not implement the future isolated-session lifecycle below. +See [the current architecture](ARCHITECTURE.md#capability-recommendation-and-windows-update). + +The product decision on **2026-09-16** is that **Companion owns the full MXC +Gateway lifecycle**, matching its WSL orchestration role. Packaging remains the +runtime-preparation and CLI launcher owner, and upstream OpenClaw remains the +onboarding owner. This is a required boundary, not an implemented session path. + +Companion must: + +- Provision the isolated Windows agent identity/session through MXC, then run + package preparation, upstream onboarding, and Gateway inside that identity. +- Preserve the same agent identity and configuration across Companion restarts. + A restart must neither re-provision nor re-onboard. +- Supervise Gateway with a bounded crash-restart budget. Explicit stop must + cancel recovery so a pending retry cannot resurrect the workload. +- Stop the session on Companion exit without deleting the account/profile. +- Deprovision only on explicit removal. Retain the saved identity and recovery + information when removal fails or its outcome is unknown. +- Keep listener ownership verification before sending Gateway credentials. + A loopback response or a live `wxc-exec` process is not ownership evidence. + +Do not independently implement account creation or session teardown in both +repositories. OS eligibility alone does not make the Gateway session-isolated. The +[planned setup recommendation policy](ONBOARDING_WIZARD.md#planned-mxc-native-gateway-recommendation-policy) +requires a working session integration before recommending it as isolated. + +## Verified MXC 0.8 contract and implementation blockers + +The installed `node_modules\@microsoft\mxc-sdk\package.json` reports **0.8.0**. +The matching upstream release is +[`7dac1a9`](https://github.com/microsoft/mxc/tree/7dac1a952f0c9ad13f0a4cb089c4e0e8b3e0013a), +not the newer `0.9.0-alpha` contract on current main. + +### Persistence is supported by the state-aware contract + +The pinned [state-aware runtime specification](https://github.com/microsoft/mxc/blob/7dac1a952f0c9ad13f0a4cb089c4e0e8b3e0013a/docs/isolation-session/state-aware-rust.md) +explicitly states that the session/account outlive the executor process. +`stop` ends the session but preserves the agent user; `deprovision` removes it. +The implementation creates a fresh manager for each phase in +[`state_aware.rs`](https://github.com/microsoft/mxc/blob/7dac1a952f0c9ad13f0a4cb089c4e0e8b3e0013a/src/backends/isolation_session/common/src/state_aware.rs). +This supports the chosen persistence semantics at the API level. The initial +read-only review did not create an account or session. The authorized live +follow-up below verifies identity lifecycle, but remains blocked on activation. + +The shipped `dist\state-aware-helper.js` defaults IsolationSession requests to +**`0.6.0-alpha`**. Its provision config requires +`network: { defaultPolicy: 'allow', allowLocalNetwork: true }`, not the newer +directional network schema. `appId` identifies the calling application +(`PFN:` for a packaged caller); it is not a documented +installation request for a different package. Persist the returned opaque +`sandboxId`; do not reconstruct it from a username or SID. + +Important recovery limits: + +- Provision is not idempotent: every call creates a new identity. Never repeat + it automatically after losing the response. +- Repeated start/stop have OS-dependent errors reported as `backend_error`. + There is no state-query/enumeration phase in the shipped MXC lifecycle API. + Do not interpret every failed start as "already running", or every failed stop + as "already stopped". +- The state-aware account/session can outlive the supervisor. Putting only + `wxc-exec` in the existing kill-on-close job does not establish session + cleanup on an abrupt Companion crash. + +### Capability evidence is not readiness evidence + +The read-only host-architecture `wxc-exec.exe --probe` invocation on 2026-09-16 +returned exit code 0 with `probes.isolationSessionAvailable: true`, +`tier: "base-container"`, and `needsDaclAugmentation: false`. No feature flags +were changed. The session field, not the process tier, is the relevant signal. + +The pinned SDK's `dist\platform.js` consumes that boolean. The current +`MxcAvailability` C# implementation instead combines AppContainer support with +`IsolationProxy.exe` file presence; that heuristic must not gate the new path. +Moreover, upstream +[`availability.rs`](https://github.com/microsoft/mxc/blob/7dac1a952f0c9ad13f0a4cb089c4e0e8b3e0013a/src/backends/isolation_session/common/src/availability.rs) +maps both a failed native API probe and a zero feature level to `false`. +Process-launch/JSON failures can be distinguished locally, but that boolean +alone cannot distinguish all OS API failures from unsupported Windows. A richer +probe result is needed before promising that distinction or recommending an OS +update for every negative result. + +### Blocking package and ownership contract + +The existing `NativeGatewayPackageResolver` resolves **current-user** +registration and aliases under that user's LocalAppData. Those paths are not +proof of registration or activation under a newly provisioned agent identity. +The Gateway manifest declares both aliases against one `openclaw.exe`; +[`HostEntrypointResolver`](https://github.com/openclaw/openclaw-windows-packaging/blob/3a0491cf8790a3336d118486b66aeaa04dba7ba7/src/OpenClaw.Launcher/HostEntrypoint.cs) +selects `clawctl` from the invoked name. Running the physical `openclaw.exe` +with `setup` is therefore not equivalent to invoking `clawctl setup`. + +Neither inspected project's documented contract establishes how this separate +Gateway package is registered/activated for an MXC agent user. MXC's +`RunProcessWithOptionsAsync` wrapper launches `cmd.exe /c` in that session; it +does not expose a package deployment operation. Do not invent cross-user alias +paths, copy launcher binaries, or grant access to the signed-in user's runtime +and credentials as a workaround. + +The provided `0.0.0.0` ARM64 MSIX was inspected read-only: its manifest declares +the aliases, but it contains no bundled Node executable/archive. It is not +evidence that the latest packaging preparation contract works in an agent +profile. No package was installed, upgraded, or downloaded during this review. + +Listener provenance is another required proof: the current native runtime uses +membership in its own job. MXC launches the workload through an OS service in +another identity/session, so that same test cannot simply be retained or +replaced by a successful health request. + +**Implementation is blocked, not proven impossible.** The initial review called +for approval of a bounded, disposable integration proof on an eligible test host, +using an approved bundled-runtime Gateway artifact: register/activate the package +for the agent identity, run `clawctl setup`, verify profile persistence across +stop/start and supervisor restart, and establish listener attribution without +real provider credentials. Explicit approval must cover account/session creation, +package deployment, and removal of that disposable identity. That approval was +subsequently granted and the bounded proof was executed below. Companion session +ownership and the preservation policy do not need to be reconsidered. + +### Authorized live proof: 2026-09-16 + +**Outcome: do not implement the isolated Gateway path yet.** The read-only MXC +probe and disposable identity lifecycle work, but registration of the actual +Gateway package for that identity is rejected by Windows policy. This is no +longer a user-permission blocker, and it is not a missing bundled runtime. +Existing native Gateway source remains the previous nonisolated implementation; +none was replaced or promoted to an isolated implementation in this proof. + +Evidence is retained locally under `TestResults\mxc-disposable-proof\` +(ignored, not committed). The exact lifecycle requests and responses are JSON +files alongside the bounded runner `lifecycle.mjs`. Each invocation uses a fresh +Node process and the shipped SDK helper from 0.8.0, emitting the +`0.6.0-alpha` IsolationSession envelope. Provision ran exactly once, with a +write-once attempt record before the call and the returned opaque ID saved in +`provision-result.json` before start. + +#### Artifact verification and preservation + +- Release: [`v0.0.0.1`](https://github.com/openclaw/openclaw-windows-packaging/releases/tag/v0.0.0.1), + packaging commit `3e5b1fff078e3ed46e9d2598835870a4e456717a`. +- Artifact: `OpenClawGateway-0.0.0.1-arm64.msix`. +- SHA-256: `b5d7241a2a1b4870eb67dffadfedd04b71a29bfde92e6366827b3dd67444ae17`, + matching GitHub's release asset digest. +- `Get-AuthenticodeSignature`: `Valid`, `Signature verified`. + Signer: `CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US`. + Signer thumbprint: `D2767C9CA44650FD3D257D881CFF40240C97EF94`. + The signature includes a Microsoft timestamp; no certificate was trusted or + installed by this proof. +- ZIP inspection confirms `runtime/node-v24.20.0-win-arm64.zip` (33,621,271 bytes). +- Contrary to the earlier local-artifact observation, the current-user package + was already `OpenClaw.Gateway_0.0.0.1_arm64__kaa03rpbbqef6` when this live proof + began. No current-user install/update/uninstall was necessary or performed. + The installed launcher SHA-256 matched the verified release's launcher: + `c38bb6a4a6ed64306631e430df8276112cbd70b2497fc9f7d2000e76302aafad`. + The same package full name and install location remained after cleanup. + +#### Live results and limits + +| Check | Actual result | +| --- | --- | +| `bin\arm64\wxc-exec.exe --probe` | Exit 0, `isolationSessionAvailable=true`; no feature or policy changes | +| Provision | Exit 0, account `D5-Y4`, SID `S-1-5-21-3948813628-2141399506-1640693369-1002`; shared workspace returned by MXC | +| Start and independent exec caller | Exit 0, agent SID matches provision; workload runs in session 2 rather than controller session 1 | +| Package/alias discovery inside agent | No `OpenClaw.Gateway` registration; neither `clawctl.exe` nor `openclaw.exe` resolved with `Get-Command` | +| Exact package registration inside agent | Failed with `0x80073D23`, special-profile deployment policy; details below | +| `clawctl setup`, bundled Node extraction, Gateway activation | Not run: no registered alias, so the required activation prerequisite failed | +| Caller restart plus stop/start | Separate controller processes successfully addressed the saved ID; after stop/start the same account, SID and profile path ran in session 3 | +| Profile content persistence | Not conclusively proven: the diagnostic marker helper creates a missing marker, so equal marker text alone cannot establish survival. Repeat with a read-only verification phase once activation is unblocked | +| Credential-free TCP listener | `127.0.0.1:57227`, host TCP owner PID 35876 and host process session 2 matched the agent report; loopback HTTP responded | +| Host-side SID attribution | **Not established**: `Win32_Process.GetOwnerSid` returned 2 (access denied), and host `ExecutablePath` was unavailable. Do not substitute the workload's self-reported SID or a successful HTTP response for trusted ownership | +| Stop while listener active | Stop exit 0; PID 35876 disappeared, no listener remained on 57227. Attached workload exited `3221225786` (`0xC000013A`) | +| Explicit cleanup | Stop exit 0, deprovision exit 0; post-cleanup exact-SID queries found zero accounts and zero profiles, and the returned shared directory no longer existed | + +The exact disposable opaque ID, retained for audit rather than reuse, is: + +```text +iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiRDUtWTQiLCJhcHBJZCI6Ik9wZW5DbGF3LkNvbXBhbmlvbi5EaXNwb3NhYmxlUHJvb2YuYzQ5NjZlYjMifQ +``` + +Only that returned ID was passed to stop/deprovision. No other account, profile, +package, running user workload, provider setup, or user settings were removed or +modified by the proof. The listener had its own 90-second deadline in addition +to the MXC exec timeout. No provider credentials or user environment secrets were +passed to the workload. + +#### Exact activation blocker and upstream action + +The documented [Add-AppxPackage](https://learn.microsoft.com/en-us/powershell/module/appx/add-appxpackage?view=windowsserver2025-ps) +`RegisterByPackageFullNameSet` was used from inside the agent identity, targeting +the existing signed package by its actual full name: + +```powershell +Add-AppxPackage -Register -MainPackage 'OpenClaw.Gateway_0.0.0.1_arm64__kaa03rpbbqef6' +``` + +It returned: + +```text +Deployment failed with HRESULT: 0x80073D23 +The deployment operation was blocked because Special profile deployment is not allowed. +The package deployment operation is blocked by the "Allow deployment operations in special profiles" policy. +ActivityId: 6e5a4865-454d-0004-3203-54704d45dd01 +``` + +`Get-AppPackageLog -ActivityID 6e5a4865-454d-0004-3203-54704d45dd01` +confirms `RegisterByPackageFullName`, the exact test SID, and failure in +`BlockDeploymentIfNeeded` (events 441/605/401/404). This is registration of a +staged signed package, not a fabricated alias path or launcher copy. +The [special-profile deployment policy](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-appxpackagemanager#allowdeploymentinspecialprofiles) +is machine policy; this proof did not change it. + +The pinned MXC +[`IsoSessionOperations` bindings](https://github.com/microsoft/mxc/blob/7dac1a952f0c9ad13f0a4cb089c4e0e8b3e0013a/src/backends/isolation_session/bindings/src/bindings.rs#L1577-L1755) +expose add/remove user, start/stop session, process execution and feature queries. +They expose no package-registration or package-activation operation. +`IsAppScopedRegistrationSupported` and `AddUserAsync2` concern agent-user +association with the calling app; this evidence does not establish registration +of the separate Gateway MSIX. No other documented activation path was established. + +Required upstream action before implementation: + +1. MXC/Windows and packaging owners must supply a supported registration and + alias-activation contract for a separate signed Gateway MSIX in a local agent + special profile, without machine-wide policy/ACL relaxation. If policy + configuration is inherently required, document that prerequisite and its + security implications explicitly; this proof's authorization does not cover + changing it. +2. Supply or prove a trusted, non-elevating workload-ownership query usable by + Companion across the agent session (stable process identity, session/SID and + creation/lifetime checks). The existing same-job provenance test cannot be + carried over, and the tested CIM owner query is denied on this host. +3. Then repeat current-head proof of actual `clawctl` activation and setup, + read-only profile-content verification after stop/start and controller + restart, bounded supervision, and explicit-only removal. + +No product code or UI was changed in this proof follow-up. Required repository +build/test suites were not rerun for this documentation-only product change; +the live command results above are integration evidence, not substitutes for +the required build, Shared, Tray, SetupEngine and Connection suites when the +implementation proceeds. No commit or push was performed. diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index 41fde9491..701189dfc 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -1,6 +1,216 @@ # Onboarding Wizard -The onboarding wizard installs a new app-owned local WSL gateway on Windows and then runs OpenClaw onboard. +The onboarding wizard can install an app-owned WSL gateway, acquire a native +Gateway MSIX through Microsoft Store and configure it, or connect to an existing gateway. + +### Shortened local onboarding (native and WSL) + +The shared `WizardOnboardingPolicy` removes optional setup cards from both WinUI +paths and the headless WSL runner. Security consent, telemetry opt-in, agent name, +AI provider/authentication/model selection, permissions and actionable errors +remain explicit. + +- Existing config detected, QuickStart, Model check, How channels work, Web + search, Skills status and Gateway notes are acknowledged without rendering. +- Setup mode chooses the offered keep-existing-model mode, otherwise QuickStart. + Config handling keeps current values. Channel and search selectors choose the + offered skip value. Skill configuration/dependency installation is deferred. +- Gateway service prompts are not part of onboarding. `wizard.start` requests + `installDaemon: false`; Companion's existing WSL service installation and native + runtime ownership remain unchanged. Older WSL gateways can use the existing + parameter-compatibility fallback. +- Before acknowledging Optional apps, setup explicitly cancels the optional tail. + It requires a confirmed `cancelled` result, valid saved configuration from + `config.get`, and authenticated `health` success. This is a deliberate handoff, + not a claim that the upstream wizard completed. Arbitrary errors never qualify. + +Native setup then restores reload and performs its own final config, listener +ownership and health checks before publishing the staged record. WSL setup +continues its existing Windows-node context step; the headless runner restores +reload through its existing completion/cleanup wrapper. A user's Cancel remains +an abort, not this validated handoff. + +Defaults match audited English prompt kinds, labels and raw option values, never +random step IDs or option positions. Unknown/localized prompts and missing skip +options stay visible (or require a headless answer) rather than receiving guessed +answers. Configured channels/search are deferred, not intentionally disabled. + +### Native Gateway MSIX (not isolated) + +**Package-aware verification (2026-09-16, package 0.0.0.1 ARM64):** the original +listener-job mismatch is resolved. Companion creates the launcher suspended, +assigns its lifecycle job, retains its process handle and resumes it. A listener +may belong to that job or be a verified live, same-user descendant of the +package-identified launcher. Every ancestor handle is retained during inspection, +creation times must be ordered, and TCP ownership is checked again before +credential handoff. This is local process supervision, not MXC isolation or a +defense against malicious code with the same user's process-access rights. + +The disposable-profile proof reached authenticated `hello-ok`, received the +initial `wizard.start` note and cancelled without publishing a Gateway record. +It used production setup code to automatically approve only its own test device +through the package CLI, not security or provider prompts. For a fresh Companion +identity, setup verifies that the handshake request matches its device ID and +public key, approves that exact request in the dedicated profile, then reconnects +once. Listener ownership and local-profile configuration are checked before both +CLI calls. The token stays in the process environment, not command-line arguments. +There is no `--latest` approval or remote-record exemption. The profile-specific +terminal remains a recovery option, not a required onboarding step. +Pairing CLI calls have a two-minute total budget for packaged runtime startup and +the request. A failed terminal wizard response displays the Gateway's error detail +before its status. The installed Gateway's full optional tail previously failed +with `PreparedModelCatalogConfigReplacedError` after Optional apps. Shortened +setup deliberately ends before that tail and uses the validated handoff above. +It does not suppress that exception or repair the upstream full-wizard finalizer. +See the [implementation results and limitations](GATEWAY_SETUP_RESPONSIBILITIES.md#package-aware-implementation-results) +for launch, shutdown, verification details and the pre-assignment crash window. + +**Install a local native gateway** is the first Welcome choice and is recommended after +the native capability check succeeds. By the 2026-09-18 product decision, this +continues to run the existing Gateway MSIX with the signed-in Windows user's +access. The separate isolation warning and acknowledgment checkbox are removed; +the general security notice and provider/onboarding consent remain explicit. +This UI gate does not provision an MXC session or change the runtime identity. +Native and WSL use the **same WinUI `WizardPage`**, not separate provider/model +wizards. WSL is always shown as the second Welcome choice after native, +followed by **Connect to an existing gateway**. + +Companion checks current-user registration for the Store package +`OpenClawFoundation.OpenClawGateway` and publisher +`CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC`, package health, and the package-qualified +`clawctl.exe` and `openclaw.exe` aliases. It does not resolve an npm installation +from `PATH`. Native setup uses the same Windows capabilities and permission +selection as WSL, followed by a native-specific review without WSL, Local AI or +Tailscale provisioning. After confirming the review, progress runs automatically. +A missing package opens the +[OpenClaw Gateway Microsoft Store listing](https://apps.microsoft.com/detail/9nv70lv3d6xc?hl=en-US&gl=US); +unhealthy registration or unavailable aliases show an explicit repair error. +No local MSIX path or environment-variable configuration is required. +The original `OpenClaw.Gateway` / OpenClaw Foundation development publisher pair +is still accepted for existing installations. If both identities are installed, +new setup reports duplicate registrations instead of guessing which to use. Existing +profiles resolve their original package family even when both packages are installed; +there is no implicit migration. + +Microsoft Store owns architecture/package selection, signature validation, user +consent, and deployment. Opening the listing is not reported as a successful install: +Companion waits for actual package registration and verified aliases, then +automatically prepares the profile and opens the shared Gateway wizard. +The wait is cancellable and limited to five minutes. Cancelling in Companion does +not cancel Windows installation. Errors/timeouts offer **Retry setup**; the normal +path has no separate install, availability-check or wizard-launch buttons. +An already installed healthy package skips this handoff. +Companion never downloads a package or changes certificate trust. + +Progress reuses the WSL spinner/checkmark rows. Completion lists the configured +native Gateway and saved Windows capability choices, with a reminder that node +pairing, Windows permissions and command approvals still apply. It does not +claim the stopped setup runtime or a not-yet-paired Windows node is running. + +The native path: + +1. Creates a dedicated configuration and workspace under + `gateways\\native-gateway` in the Companion data directory. + `OPENCLAW_STATE_DIR` and `OPENCLAW_CONFIG_PATH` keep this separate from the + user's default `.openclaw` profile. A different profile is not a security + sandbox. +2. Runs the installed package's `clawctl setup` with captured progress/errors in + Companion, without opening a TUI. The current package may extract its bundled Node runtime. + Older proof packages require a separately installed compatible Node runtime + (the supplied `0.0.0.0` proof rejects Node 22.19.0). Companion does not install + missing prerequisites. +3. Validates the dedicated configuration, suspends config reload, and starts the + Gateway through the native runtime owner. Before every credential handoff, + including reconnects with a saved device identity, setup verifies package-owned + listener provenance. The staged record is **not** made active in the registry. +4. Automatically pairs the setup's own Companion identity if required, then + opens the shared `WizardPage` using `wizard.start` with `mode: "local"` and + `installDaemon: false`. The same `wizard.next` transport and cards render the + upstream security acknowledgement, provider, authentication, and model steps. + No consent or provider answer is supplied automatically. Native console output + is tailed from the dedicated profile, never through WSL. +5. Error-free wizard completion or the validated optional-tail handoff permits + finalization. Setup stops its + runtime, restores the original reload setting, checks the selected + local/loopback/token configuration, runs `config validate --json`, restarts + with owned-listener proof and runs authenticated `gateway health --json`. + A failed gate remains retryable and does not publish the staged record. +6. Stops the setup-owned runtime before reloading and updating the registry. + **Open Companion to connect** restarts Companion into the existing + connection flow with the paired operator identity. This does not approve the + separate Windows node role. Current Windows node permissions are preserved. + +Companion owns the native gateway process lifetime after this handoff. It starts +the selected native gateway when connecting, can start it again after an exit, +and stops its owned process when disconnecting, switching away, or shutting down. +The MSIX package itself owns updates. Companion does not install an OS service +or call `openclaw gateway install`. + +Cancelling setup, returning from the wizard, or closing setup stops only its own +recovery terminal/runtime and restores reload. Cancel is not successful setup. +**Restart gateway** controls the native owner; **Open terminal** opens a shell +scoped to the dedicated profile and package aliases, not a second onboarding TUI. +Its lifetime ends with setup or a restart. Configuration +already entered is retained in the dedicated native profile so it is not lost +on retry, including after returning to Welcome or reopening Companion. A +credential-free draft descriptor under `gateways\native-setup-draft.json` +resumes the same profile until successful publication. +The credential-free reload backup survives an interrupted process so a retry +does not mistake the temporary `off` mode for the user's preference. +Existing WSL distributions, remote gateways, and the default native +OpenClaw profile are not replaced. The native path does not enter the WSL +cleanup, Local AI installation, or WSL repair pipelines. + +This integration targets the packaging contract at +[`9a8cd4a`](https://github.com/openclaw/openclaw-windows-packaging/tree/9a8cd4af139513c21d290a01a8a1f2be19b602bc). +Its README explicitly reserves isolated agent sessions for future work. A future +MXC option needs a real session provisioning, eligibility, and lifecycle contract; +MSIX registration or the presence of `IsolationProxy.exe` is not sufficient. + +### Planned MXC native Gateway recommendation policy + +The lifecycle/session-provisioning requirements below were recorded on 2026-09-16 +and remain future work. The 2026-09-18 UI decision implements capability-first +recommendation and Windows-update guidance for the existing signed-in-user native +Gateway without claiming session isolation. See [Welcome](#welcome) for the +implemented recommendation behavior. + +- **Lifecycle owner:** Companion provisions and supervises the MXC session, + delegates preparation to packaging and onboarding to upstream OpenClaw, and + runs both inside the isolated agent identity. Preserve that identity and its + configuration across Companion restarts; stop on exit and deprovision only + on explicit removal. Do not re-provision or re-onboard on restart. See the + [verified MXC 0.8 contract and blockers](GATEWAY_SETUP_RESPONSIBILITIES.md#verified-mxc-08-contract-and-implementation-blockers). +- **Distribution:** Gateway packages are available as x64 MSIX, ARM64 MSIX, + and an MSIX bundle. The future Store DLO is expected to point to the bundle, + subject to confirmation when the link is available. Let Windows select the + matching architecture from the bundle. The configured ARM64 development file + is not a product-wide architecture restriction. +- **Primary eligibility check:** Windows version and enabled OS session + capabilities determine MXC native Gateway eligibility, not GPU or Local AI + eligibility. Evaluate this before recommending a local gateway path. + The shipped MXC 0.8 `wxc-exec --probe` exposes + `probes.isolationSessionAvailable`; the read-only local probe returned `true`. + A `false` result conflates native API errors with lack of support, so a richer + diagnostic contract is still needed. Read the OS build/revision separately if the probe + does not expose them. A process-containment tier alone is not session support: + require the session-specific capability result, not merely a high build + number or the presence of `IsolationProxy.exe`. +- **Recommendation order:** Recommend the MXC native Gateway when the OS + supports sessions and the Gateway session integration is available. If the + OS is unsupported, first recommend updating Windows to a supported version, + with a capability recheck after updating. Present WSL as the secondary + fallback, not the initial recommendation. Do not automatically change the + Windows update channel or enable preview features. +- **Actionable failures:** Distinguish an unsupported OS from a failed probe, + disabled/unavailable session features, and a missing Gateway package/runtime. + A probe error offers retry and diagnostics rather than asserting that an OS + update is required. Meeting a version floor does not guarantee that a + feature-gated OS API is enabled. + +The final MXC path must actually provision and run the Gateway inside an +isolated session. Do not relabel the current ordinary-process MSIX path as MXC, +or recommend it as isolated based only on a successful eligibility check. ## Overview @@ -9,7 +219,7 @@ On first launch, the wizard appears only when there is no usable saved gateway c The setup flow walks users through: 1. **Security notice** - Device-trust warning before setup choices -2. **Welcome / Advanced** - Install app-owned WSL gateway or connect existing gateway from Settings +2. **Welcome / Advanced** - Capability-gated native Gateway recommendation, optional WSL fallback, or connect existing gateway from Settings 3. **Capabilities** - Recommended profile, inline Windows permission status, and install review 4. **Local setup progress** - Fresh app-owned `OpenClawGateway` WSL installation 5. **Gateway installed** - Explicit handoff from infrastructure setup to OpenClaw onboard @@ -21,7 +231,42 @@ The setup flow no longer configures remote/manual gateways inline. The Welcome p ## Screen Details ### Welcome -Displays the OpenClaw icon, app title, and a brief description. Choosing local gateway setup runs the read-only WSL readiness gate before the Capabilities page or its Local AI decision UI can open. WSL2 environment failures, including disabled hardware virtualization, are shown as WSL readiness failures and block both Local AI and non-Local-AI local gateway setup. The readiness dialog can retry with a fresh inspection after the user resolves the reported problem. If an app-owned local WSL gateway already exists, the primary CTA reads **Install new WSL Gateway** and confirmation warns that the current OpenClaw WSL gateway and distro will be deleted. If only an external gateway exists, the CTA remains **Set up locally** and confirmation explains that the external connection remains available in Connections. +The page checks `wxc-exec --probe` asynchronously before recommending the first +**Install a local native gateway** card. It requires the reported +`probes.isolationSessionAvailable` boolean, not a process sandbox tier, build +comparison or `IsolationProxy.exe` file. On success the native card is enabled, +selected with the accent highlight and marked **Recommended**. An explicit +WSL or existing-gateway selection is not overridden by a late probe result. +Back navigation preserves those explicit choices even when native is supported. +The badge sits to the right of the title. Successful capability status appears +inside the card below its description, with a decorative green checkmark and a +screen-reader announcement. The requested description is **Install a local, +MXC contained OpenClaw gateway**; this copy change does not implement MXC +session containment, which remains outstanding for the current signed-in-user runtime. +If a resumed native setup profile's port has been taken by another process, +Retry selects a new port without replacing the profile, credentials or identity. +Unexpected launch/cleanup failures show an explicit failure and Retry action; +Companion never takes over or stops the conflicting process. +The WSL title is **Install a local WSL gateway**. Checking, unavailable and error +messages remain outside the disabled card so retry/update actions stay usable. + +When capability is unavailable, **Open Windows Update** opens +`ms-settings:windowsupdate`. Guidance names Insider build **26340.9212**, the +baseline documented by the pinned MXC SDK, or a newer supported build. Reopening +the page reruns the check; the Welcome page has no **Check again** button. +Feature rollout varies; a negative native API result is not proof +that the build alone is the cause. Probe failures or missing/invalid metadata +offer retry/Companion repair rather than misleading update advice. Windows Server +remains unsupported without invoking the native probe. No update-channel or +feature-policy changes are automatic. + +The single-selection list always shows native first, WSL second and +**Connect to an existing gateway** third. WSL is visible and selectable while +native capability is being checked, when it succeeds, and when it fails or is +unavailable. There is no **Other gateway options** expander. Page load starts the +existing WSL/Local AI discovery; choosing WSL retains the fresh readiness gate +and destructive-replacement confirmation before Capabilities. Native package +setup independently rechecks capability before configuration. The gateway-choice scroll viewport owns the 560-DIP maximum width and stretches its list content. Keep the width constraint on the viewport, not on the nested diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 11b055d0c..c22dbb62d 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -10,6 +10,64 @@ The Setup Engine is a **config-driven system** for provisioning an OpenClaw WSL The bundled `default-config.json` ships with the tray executable and provides secure defaults (loopback bind, WSL isolation, systemd enabled). Defaults can be overridden via config file or environment variables. +The separate **native Gateway MSIX** Welcome path does not use +`SetupStepFactory.BuildDefaultSteps()`. `NativeGatewaySetupService` owns its +dedicated-profile and package preparation. `NativeGatewaySetupSession` owns +staged-record runtime authorization, reload suspension/restoration, retry/cancel, +authenticated health/config gates, and final registry publication. +`WizardPage` is the single hosted WinUI wizard for both WSL and native: +`wizard.start/next/cancel` transport, upstream prompts, and provider/model cards +are not duplicated. Native uses the upstream `installDaemon: false` contract. +`NativeGatewayPackageResolver` +checks Windows package registration and package-qualified aliases. +`NativeGatewayMsixInstaller` opens the +[OpenClaw Gateway Microsoft Store listing](https://apps.microsoft.com/detail/9nv70lv3d6xc?hl=en-US&gl=US) +when a package is not installed. Microsoft Store owns architecture selection, +installation consent and deployment; no local MSIX path is required. +`NativeGatewayPackageIdentity` pins the exact Store name/publisher pair and retains +the original development identity for existing installations. Multiple matching +registrations fail explicitly for new setup. Existing runtime profiles resolve only +their saved family, allowing both packages to coexist without an implicit migration. +`NativeGatewayPackageAcquisition` automatically opens it once only for missing +registration, then waits up to five minutes for verified package readiness. +Cancellation stops the wait, not Windows deployment. Repair errors and timeouts +stay visible, with explicit retry rather than repeated installer launches. +Native setup shares the capability profiles and Windows permissions page with +WSL but skips WSL/Local AI/Tailscale installation review and probes. The native +progress page uses shared spinner/checkmark rows and automatically enters the +Gateway wizard after preparing its runtime. Finalization applies the selected +Gateway command allowlist before config/health gates, then persists only the +Companion node/capability settings. Completion does not claim node pairing. +`NativeGatewaySetupHost` runs captured `clawctl setup`, config validation, and +health commands, plus an explicitly requested profile-scoped recovery terminal. +It never launches `openclaw onboard` or WSL. +`NativeGatewayRuntime` in the Connection project owns the gateway process. +This path is non-isolated and UI-only. Companion never downloads an MSIX itself +or bypasses Microsoft Store installation. +Existing headless setup arguments continue to select the WSL pipeline. +See [Native Gateway MSIX](ONBOARDING_WIZARD.md#native-gateway-msix-not-isolated) +for consent, lifecycle, retry, and acquisition boundaries. + +See [Gateway setup responsibilities](GATEWAY_SETUP_RESPONSIBILITIES.md) for the +Gateway packaging responsibility matrix, its comparison with WSL provisioning, +and the decided Companion-owned MXC lifecycle. The required isolated path +preserves identity/configuration across restarts, stops on exit, and deprovisions +only on explicit removal. Its package activation and listener-provenance contracts +remain blocked pending integration proof; the non-isolated runtime is not a +substitute. + +The [Welcome recommendation policy](ONBOARDING_WIZARD.md#welcome) now checks +`wxc-exec --probe` session capability before recommending the existing native +Gateway. `NativeGatewaySetupEligibility` owns admission and selection policy. +Unavailable capability offers Windows Update with the pinned SDK's Insider +baseline (26340.9212); failed probes offer retry/repair instead. WSL is always +visible as the second option after native, with existing-gateway connection third. +Explicit WSL and existing-gateway choices survive late native probe results. Welcome has no +manual recheck button; reopening the page checks again. The separate isolation warning/checkbox is removed by the +2026-09-18 product decision; general security consent remains. This is not +session provisioning. Gateway distribution includes x64, ARM64 and MSIX bundle +artifacts, but the temporary development installer remains ARM64-only. + > **Status note (2026-07-06):** Current default setup includes `WindowsNodeBootstrapContextStep`, which injects Windows-node context into the WSL workspace `AGENTS.md` after onboarding. --- @@ -337,7 +395,8 @@ The WinUI app is a **thin shell** - no business logic, just rendering pipeline s **WelcomePage** - OpenClaw icon + "OpenClaw Setup" title bar -- Install app-owned WSL gateway (recommended) or connect to existing gateway +- Capability-checked native Gateway first and recommended; Windows Update/retry guidance when unavailable +- Collapsed WSL alternative or visible connection to an existing gateway - Replacement prompt when an app-owned WSL gateway already exists **CapabilitiesPage** diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index c53823e54..306b6ebf9 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using OpenClaw.Shared; using OpenClaw.Shared.Telemetry; +using OpenClaw.Connection.NativeGateway; namespace OpenClaw.Connection; @@ -57,6 +58,8 @@ public sealed class GatewayConnectionManager : private readonly IGatewayClientFactory _clientFactory; private readonly GatewayRegistry _registry; private readonly IOpenClawLogger _logger; + private readonly INativeGatewayRuntime? _nativeGatewayRuntime; + private GatewayRecord? _nativeGatewayRecord; private readonly IDeviceIdentityStore? _identityStore; private readonly INodeConnector? _nodeConnector; private readonly ISshTunnelManager? _tunnelManager; @@ -130,12 +133,14 @@ public GatewayConnectionManager( Func? validationTunnelFactory = null, TimeSpan? credentialHandoffTimeout = null, TimeSpan? manualSshRestartTimeout = null, - TimeSpan? manualSshRestartCleanupTimeout = null) + TimeSpan? manualSshRestartCleanupTimeout = null, + INativeGatewayRuntime? nativeGatewayRuntime = null) { _credentialResolver = credentialResolver ?? throw new ArgumentNullException(nameof(credentialResolver)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); _registry = registry ?? throw new ArgumentNullException(nameof(registry)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _nativeGatewayRuntime = nativeGatewayRuntime; _identityStore = identityStore; _nodeConnector = nodeConnector; _tunnelManager = tunnelManager; @@ -325,6 +330,7 @@ private async Task ConnectCoreAsync( // Dispose old client await DisposeActiveClientAsync(); + await PrepareNativeGatewayTargetAsync(record); StartOperatorTelemetryAttempt(operation, gen); // Update snapshot with gateway info @@ -828,6 +834,7 @@ await RecordOperatorCredentialHandoffFailureAsync( await DisposeActiveClientAsync(); } + await PrepareNativeGatewayTargetAsync(record); _activeIdentityPath = perGatewayIdentityDir; _activeGatewayRecordId = record.Id; _activeSshTunnel = record.SshTunnel; @@ -888,6 +895,8 @@ await RecordOperatorCredentialHandoffFailureAsync( } if (!nodeEndpointAuthorization.Allowed) { + if (record.NativePackageFamilyName is not null) + _stateMachine.SetNodeErrorKind(nodeEndpointAuthorization.FailureKind); _diagnostics.Record("setup", "Blocked node credential before managed-local endpoint ownership was proven", nodeEndpointAuthorization.Detail); _stateMachine.SetNodeCredentialResolution(nodeCredentialResolution); _stateMachine.BlockNodeStart(nodeEndpointAuthorization.Detail, preserveCredentialResolution: true); @@ -1047,7 +1056,7 @@ public async Task DisconnectAsync() await _transitionSemaphore.WaitAsync(); try { - await DisconnectCoreAsync(); + await DisconnectCoreAsync(stopNativeGateway: true); } finally { @@ -1064,7 +1073,7 @@ public async Task DisconnectByUserAsync() var gatewayId = _registry.ActiveGatewayId; if (gatewayId is not null) SetGatewayConnectionIntent(gatewayId, shouldBeConnected: false); - await DisconnectCoreAsync(); + await DisconnectCoreAsync(stopNativeGateway: true); } finally { @@ -1073,7 +1082,7 @@ public async Task DisconnectByUserAsync() } /// Core disconnect logic. Caller must hold . - private async Task DisconnectCoreAsync() + private async Task DisconnectCoreAsync(bool stopNativeGateway = false) { CancelOperatorTelemetryAttempt("canceled", ConnectionErrorCategory.Cancelled); Interlocked.Increment(ref _generation); @@ -1086,6 +1095,12 @@ private async Task DisconnectCoreAsync() var prev = _stateMachine.Current.OverallState; await DisposeActiveClientAsync(); + if (stopNativeGateway || + (_nativeGatewayRecord is not null && + !string.Equals(_nativeGatewayRecord.Id, _registry.ActiveGatewayId, StringComparison.Ordinal))) + { + await StopNativeGatewayAsync(); + } SyncNodeIntentFromSettings(); _stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested); _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); @@ -2197,9 +2212,13 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen) var activeRecord = _activeGatewayRecordId is null ? null : _registry.GetById(_activeGatewayRecordId); - var provenance = activeRecord is not null && - GatewayRecordEditing.ResolveManagedDistroName(activeRecord) is not null && - _endpointProvenanceProbe is not null + var provenance = activeRecord?.NativePackageFamilyName is not null + ? _nativeGatewayRuntime is not null + ? await _nativeGatewayRuntime.InspectAsync(activeRecord, CancellationToken.None).ConfigureAwait(false) + : new GatewayEndpointProvenance(GatewayEndpointProvenanceKind.UnknownListener, 0) + : activeRecord is not null && + GatewayRecordEditing.ResolveManagedDistroName(activeRecord) is not null && + _endpointProvenanceProbe is not null ? await _endpointProvenanceProbe(activeRecord, CancellationToken.None).ConfigureAwait(false) : null; var unexpectedManagedLocalOwner = @@ -2348,6 +2367,12 @@ private async Task IsRecoverySafeEndpointAsync( GatewayRecord record, CancellationToken cancellationToken) { + if (record.NativePackageFamilyName is not null) + { + return _nativeGatewayRuntime is not null && + (await _nativeGatewayRuntime.InspectAsync(record, cancellationToken).ConfigureAwait(false)).Kind == + GatewayEndpointProvenanceKind.ExpectedManagedGateway; + } if (GatewayRecordEditing.IsLoopbackEndpoint(record.Url)) { if (record.IsLocal || GatewayRecordEditing.ResolveManagedDistroName(record) is not null) @@ -2382,6 +2407,11 @@ private async Task AuthorizeCredentialForEndpoi CancellationToken cancellationToken, bool requireSshTunnelOwnership = false) { + if (record.NativePackageFamilyName is not null) + { + return await NativeGatewayEndpointSecurity.AuthorizeAsync( + _nativeGatewayRuntime, record, cancellationToken).ConfigureAwait(false); + } if (record.SshTunnel is not null) { if (!requireSshTunnelOwnership) @@ -2565,6 +2595,10 @@ private static bool IsSameCredentialHandoffRecord( expected.BootstrapToken, StringComparison.Ordinal) && current.IsLocal == expected.IsLocal && + string.Equals( + current.NativePackageFamilyName, + expected.NativePackageFamilyName, + StringComparison.Ordinal) && (current.RequiresV2Signature || !expected.RequiresV2Signature) && string.Equals( current.SetupManagedDistroName, @@ -3576,6 +3610,28 @@ private static void RecordTelemetryStateTransition( ]); } + private async Task PrepareNativeGatewayTargetAsync(GatewayRecord record) + { + if (_nativeGatewayRecord is not null && + (!string.Equals(_nativeGatewayRecord.Id, record.Id, StringComparison.Ordinal) || + !string.Equals(_nativeGatewayRecord.Url, record.Url, StringComparison.Ordinal) || + !string.Equals(_nativeGatewayRecord.NativePackageFamilyName, record.NativePackageFamilyName, StringComparison.Ordinal) || + _nativeGatewayRecord.IsLocal != record.IsLocal || + _nativeGatewayRecord.SshTunnel != record.SshTunnel)) + { + await StopNativeGatewayAsync(); + } + if (record.NativePackageFamilyName is not null) + _nativeGatewayRecord = record; + } + + private async Task StopNativeGatewayAsync() + { + if (_nativeGatewayRuntime is not null && _nativeGatewayRecord is not null) + await _nativeGatewayRuntime.StopAsync(CancellationToken.None).ConfigureAwait(false); + _nativeGatewayRecord = null; + } + private async Task DisposeActiveClientAsync() { await _nodeConnectionCoordinator.RetireAsync().ConfigureAwait(false); @@ -3689,6 +3745,11 @@ private async Task DisposeCoreAsync() } finally { + if (_nativeGatewayRuntime is not null) + { + try { await _nativeGatewayRuntime.DisposeAsync().ConfigureAwait(false); } + catch (Exception ex) { _logger.Warn($"[ConnMgr] Native gateway dispose failed: {ex.Message}"); } + } if (semaphoreEntered) { try { _transitionSemaphore.Release(); } diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs index 4c08ac3b4..9c83d7706 100644 --- a/src/OpenClaw.Connection/GatewayRecord.cs +++ b/src/OpenClaw.Connection/GatewayRecord.cs @@ -33,6 +33,12 @@ public sealed record GatewayRecord /// WSL distro name for gateway records provisioned by SetupEngine. public string? SetupManagedDistroName { get; init; } + /// + /// Installed MSIX package family owned by the native gateway runtime. Native gateways + /// are non-isolated and must never also carry a setup-managed WSL distro marker. + /// + public string? NativePackageFamilyName { get; init; } + /// Per-gateway SSH tunnel configuration. Null if no tunnel needed. public SshTunnelConfig? SshTunnel { get; init; } @@ -70,6 +76,8 @@ public static class GatewayRecordEditing /// the standard localhost aliases localhost, 127.0.0.1, and ::1, with scheme, /// port, path, and query unchanged. If the user repoints the URL or adds a tunnel, the record becomes /// manual and all managed-ownership metadata is removed. + /// Native MSIX ownership is likewise preserved only for an equivalent loopback endpoint + /// without SSH or WSL ownership. A native marker never grants legacy WSL ownership. /// public static GatewayRecord PreserveAdvancedFields(this GatewayRecord rebuilt, GatewayRecord? existing) { @@ -78,6 +86,20 @@ public static GatewayRecord PreserveAdvancedFields(this GatewayRecord rebuilt, G var result = rebuilt with { BrowserControlPort = rebuilt.BrowserControlPort ?? existing.BrowserControlPort }; + if (existing.NativePackageFamilyName is not null) + { + var preserveNative = existing.SshTunnel is null && rebuilt.SshTunnel is null && + existing.SetupManagedDistroName is null && rebuilt.SetupManagedDistroName is null && + AreEquivalentLoopbackEndpoints(rebuilt.Url, existing.Url); + return result with + { + NativePackageFamilyName = preserveNative ? existing.NativePackageFamilyName : null, + IsLocal = OpenClaw.Shared.LocalGatewayUrlClassifier.IsLocalGatewayUrl(rebuilt.Url), + RequiresV2Signature = preserveNative && + (rebuilt.RequiresV2Signature || existing.RequiresV2Signature), + }; + } + var stillSameManagedEndpoint = AreEquivalentManagedEndpoints(rebuilt.Url, existing.Url); var existingManagedDistroName = ResolveManagedDistroName(existing); var managedDistroName = @@ -168,6 +190,10 @@ public static bool IsLoopbackEndpoint(string? url) => public static string? ResolveManagedDistroName(GatewayRecord record) { + // A native gateway's display name must never confer legacy WSL ownership. + if (record.NativePackageFamilyName is not null) + return null; + if (!string.IsNullOrWhiteSpace(record.SetupManagedDistroName)) return record.SetupManagedDistroName; diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs index 7f588f421..d69fc8f74 100644 --- a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs +++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs @@ -60,6 +60,11 @@ public static bool TryResolve( var active = registry?.GetActive(); if (active != null && !string.IsNullOrWhiteSpace(active.Url)) { + if (active.NativePackageFamilyName is not null && authorizeCredential is null) + { + credential = null; + return false; + } // For HTTP surfaces (chat), prefer SharedGatewayToken over DeviceToken. // DeviceToken is for WebSocket auth (auth.deviceToken); SharedGatewayToken // is for HTTP ?token= auth which the chat/dashboard endpoints expect. @@ -102,7 +107,8 @@ public static bool TryResolve( return true; } - if (!string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase)) + if (active.NativePackageFamilyName is not null || + !string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase)) { credential = null; return false; diff --git a/src/OpenClaw.Connection/InteractiveGatewayEndpointAuthorizer.cs b/src/OpenClaw.Connection/InteractiveGatewayEndpointAuthorizer.cs new file mode 100644 index 000000000..e6e54a76d --- /dev/null +++ b/src/OpenClaw.Connection/InteractiveGatewayEndpointAuthorizer.cs @@ -0,0 +1,31 @@ +using OpenClaw.Connection.NativeGateway; +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// Authorizes HTTP credential handoff without taking ownership of the shared native runtime. +public sealed class InteractiveGatewayEndpointAuthorizer( + INativeGatewayRuntime nativeRuntime, + Func authorizeNonNative, + IOpenClawLogger logger) +{ + public bool IsCredentialAllowed(GatewayRecord record, GatewayCredential credential) + { + if (record.NativePackageFamilyName is null) + return authorizeNonNative(record, credential); + + try + { + // Never substitute connection state or a cached result for inspection at this handoff. + var provenance = nativeRuntime.Inspect(record); + if (provenance.Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway) + return true; + logger.Warn($"Native Gateway HTTP credential handoff denied: {provenance.Kind}."); + } + catch (Exception ex) + { + logger.Warn($"Native Gateway HTTP ownership inspection failed ({ex.GetType().Name}). Credentials were not sent."); + } + return false; + } +} diff --git a/src/OpenClaw.Connection/ManagedLocalGatewayPortProvenanceService.cs b/src/OpenClaw.Connection/ManagedLocalGatewayPortProvenanceService.cs index b586b4ad5..5222c04bb 100644 --- a/src/OpenClaw.Connection/ManagedLocalGatewayPortProvenanceService.cs +++ b/src/OpenClaw.Connection/ManagedLocalGatewayPortProvenanceService.cs @@ -285,6 +285,11 @@ public Task InspectAsync( public GatewayEndpointProvenance Inspect(GatewayRecord record) { + if (record.NativePackageFamilyName is not null) + { + return new(GatewayEndpointProvenanceKind.UnknownListener, 0, + Detail: "Native Gateway ownership must be verified by its Companion runtime, not WSL."); + } var result = InspectCore(record); _lastProvenance[new ProvenanceCacheKey(record.Id, record.Url)] = result; return result; @@ -292,6 +297,8 @@ public GatewayEndpointProvenance Inspect(GatewayRecord record) public bool IsStrongCredentialAllowed(GatewayRecord record, GatewayCredential credential) { + if (record.NativePackageFamilyName is not null) + return false; var isStrong = credential.IsBootstrapToken || string.Equals(credential.Source, CredentialResolver.SourceSharedGatewayToken, StringComparison.Ordinal) || diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs new file mode 100644 index 000000000..52aa6ab4a --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs @@ -0,0 +1,53 @@ +namespace OpenClaw.Connection.NativeGateway; + +/// A current-user installed package and its package-qualified execution aliases. +public sealed record NativeGatewayPackage( + string PackageFamilyName, + string Version, + string OpenClawAliasPath, + string ClawCtlAliasPath); + +/// No matching trusted package is registered. Other validation failures are not install requests. +public sealed class NativeGatewayPackageNotInstalledException : InvalidOperationException +{ + public NativeGatewayPackageNotInstalledException() + : base("A supported OpenClaw Gateway MSIX is not registered for this Windows user. " + + "Complete installation from Microsoft Store, then retry native setup.") + { + } +} + +/// Resolves only an already installed, trusted OpenClaw Gateway package. Never installs it. +public interface INativeGatewayPackageResolver +{ + /// No matching package is registered. + Task ResolveAsync(CancellationToken cancellationToken); + + /// Resolves a saved profile's exact family without selecting a different installed Gateway. + async Task ResolveAsync(string expectedFamily, CancellationToken cancellationToken) + { + NativeGatewayPaths.ValidateFamilyName(expectedFamily); + var package = await ResolveAsync(cancellationToken); + NativeGatewayPaths.ValidatePackage(package, expectedFamily); + return package; + } + + /// + /// Maps Companion's logical data path to the physical path visible to another package + /// identity. Used only at the launch boundary; registry/state ownership remains logical. + /// + string ResolveDataPath(string path) => path; +} + +/// +/// Owns a non-isolated native gateway's process lifetime, not its installation or persisted state. +/// Ensure is an explicit start/retry request; Stop and Dispose never schedule subsequent restarts. +/// +public interface INativeGatewayRuntime : IAsyncDisposable +{ + Task EnsureRunningAsync(GatewayRecord record, CancellationToken cancellationToken); + Task StopAsync(CancellationToken cancellationToken); + Task InspectAsync(GatewayRecord record, CancellationToken cancellationToken); + /// Fresh ownership inspection without starting or waiting for a lifecycle operation. Busy runtimes deny handoff. + GatewayEndpointProvenance Inspect(GatewayRecord record); +} diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayListenerException.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayListenerException.cs new file mode 100644 index 000000000..803fee0b2 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayListenerException.cs @@ -0,0 +1,16 @@ +namespace OpenClaw.Connection.NativeGateway; + +/// +/// Separates an occupied or unverifiable endpoint from package/configuration/start failures. +/// Preserves the failed inspection even when cleanup removes the owned listener afterward. +/// +public sealed class NativeGatewayListenerException : InvalidOperationException +{ + public NativeGatewayListenerException(GatewayEndpointProvenance provenance) + : base("The native gateway port is occupied or its listener cannot be verified.") + { + Provenance = provenance ?? throw new ArgumentNullException(nameof(provenance)); + } + + public GatewayEndpointProvenance Provenance { get; } +} diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayPackageIdentity.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayPackageIdentity.cs new file mode 100644 index 000000000..77ad7b000 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayPackageIdentity.cs @@ -0,0 +1,17 @@ +namespace OpenClaw.Connection.NativeGateway; + +/// Exact registration identities accepted before resolving package-qualified aliases. +public static class NativeGatewayPackageIdentity +{ + public const string StoreName = "OpenClawFoundation.OpenClawGateway"; + public const string StorePublisher = "CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC"; + + // Retain already installed development packages and their saved profiles, not local acquisition. + public const string DevelopmentName = "OpenClaw.Gateway"; + public const string DevelopmentPublisher = + "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US"; + + public static bool IsTrusted(string name, string publisher) => + (name == StoreName && publisher == StorePublisher) || + (name == DevelopmentName && publisher == DevelopmentPublisher); +} diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayPaths.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayPaths.cs new file mode 100644 index 000000000..ac9a58dcd --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayPaths.cs @@ -0,0 +1,108 @@ +using System.Text.RegularExpressions; + +namespace OpenClaw.Connection.NativeGateway; + +/// One durable state/config location for native setup and runtime. Stop never removes it. +public static class NativeGatewayPaths +{ + public static string GetStateDirectory(GatewayRegistry registry, string gatewayId) + { + ArgumentNullException.ThrowIfNull(registry); + ValidateGatewayId(gatewayId); + return Path.Combine(registry.GetIdentityDirectory(gatewayId), "native-gateway"); + } + + public static string GetConfigPath(GatewayRegistry registry, string gatewayId) => + Path.Combine(GetStateDirectory(registry, gatewayId), "openclaw.json"); + + public static IReadOnlyDictionary GetEnvironment(GatewayRegistry registry, string gatewayId) => + GetEnvironment(registry, gatewayId, static path => path); + + /// + /// Builds a launch-only environment using physical cross-package paths. The canonical + /// registry/state paths returned by GetStateDirectory and GetConfigPath are not changed. + /// + public static IReadOnlyDictionary GetEnvironment( + GatewayRegistry registry, string gatewayId, Func resolveDataPath) + { + ArgumentNullException.ThrowIfNull(resolveDataPath); + var stateDirectory = resolveDataPath(GetStateDirectory(registry, gatewayId)); + var configPath = resolveDataPath(GetConfigPath(registry, gatewayId)); + if (string.IsNullOrWhiteSpace(stateDirectory) || string.IsNullOrWhiteSpace(configPath) || + !Path.IsPathFullyQualified(stateDirectory) || !Path.IsPathFullyQualified(configPath)) + { + throw new InvalidOperationException("Native gateway launch data paths must be absolute."); + } + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["OPENCLAW_STATE_DIR"] = stateDirectory, + ["OPENCLAW_CONFIG_PATH"] = configPath, + ["OPENCLAW_SUPERVISOR_MODE"] = "external", + ["OPENCLAW_SERVICE_REPAIR_POLICY"] = "external", + ["OPENCLAW_NO_AUTO_UPDATE"] = "1", + }; + } + + internal static void ValidateGatewayId(string gatewayId) + { + // Existing registry IDs include both GUIDs and gw- identifiers. No filesystem aliases, + // separators, alternate streams, trailing dots/spaces, or DOS device names are allowed. + if (string.IsNullOrEmpty(gatewayId) || + !Regex.IsMatch(gatewayId, @"\A[A-Za-z0-9][A-Za-z0-9_-]{0,127}\z", + RegexOptions.CultureInvariant) || + Regex.IsMatch(gatewayId, @"\A(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + throw new ArgumentException("The native gateway ID must be a path-safe identifier.", nameof(gatewayId)); + } + } + + internal static Uri ValidateRecord(GatewayRecord record) + { + ArgumentNullException.ThrowIfNull(record); + ValidateGatewayId(record.Id); + ValidateFamilyName(record.NativePackageFamilyName); + if (record.SshTunnel is not null || record.SetupManagedDistroName is not null || + !Uri.TryCreate(record.Url, UriKind.Absolute, out var uri) || + uri.Scheme != "ws" || !uri.IsLoopback || uri.Port is < 1 or > 65535 || + uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0 || + uri.AbsolutePath != "/") + { + throw new ArgumentException("A native gateway requires a plain loopback ws endpoint without a tunnel or WSL distro."); + } + return uri; + } + + internal static void ValidateFamilyName(string? familyName) + { + if (familyName is null || !Regex.IsMatch(familyName, + @"\A(?:OpenClaw\.Gateway|OpenClawFoundation\.OpenClawGateway)_[a-z0-9]{13}\z", RegexOptions.CultureInvariant)) + { + throw new ArgumentException("An installed OpenClaw Gateway package family is required."); + } + } + + internal static void ValidatePackage(NativeGatewayPackage package, string expectedFamily) + { + ArgumentNullException.ThrowIfNull(package); + ValidateFamilyName(package.PackageFamilyName); + if (!string.Equals(package.PackageFamilyName, expectedFamily, StringComparison.Ordinal) || + !Version.TryParse(package.Version, out _)) + { + throw new InvalidOperationException("The installed native gateway package does not match the saved package identity."); + } + ValidateAlias(package.OpenClawAliasPath, expectedFamily, "openclaw.exe"); + ValidateAlias(package.ClawCtlAliasPath, expectedFamily, "clawctl.exe"); + } + + private static void ValidateAlias(string path, string family, string alias) + { + var expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", "WindowsApps", family, alias); + if (!Path.IsPathFullyQualified(path) || + !string.Equals(path, expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The native gateway executable must be its trusted package-qualified alias."); + } + } +} diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayRuntime.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayRuntime.cs new file mode 100644 index 000000000..c7cdc1f01 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayRuntime.cs @@ -0,0 +1,275 @@ +using OpenClaw.Shared; +using System.Diagnostics; +using System.Net; + +namespace OpenClaw.Connection.NativeGateway; + +internal sealed record NativeGatewayStartSpec( + string ExecutablePath, + string WorkingDirectory, + int Port, + IReadOnlyDictionary Environment, + string? PackageFamilyName = null); + +internal interface INativeGatewayProcess : IAsyncDisposable +{ + bool HasExited { get; } + bool Owns(WindowsTcpListenerInfo listener); +} + +internal interface INativeGatewayProcessHost +{ + Task StartAsync(NativeGatewayStartSpec spec, CancellationToken cancellationToken); +} + +/// +/// Explicit, serialized supervision for an installed MSIX gateway. Listener ownership, not a +/// successful TCP connection, authorizes reuse. No timers, adoption, package acquisition, or +/// state/config mutation occur here. +/// +public sealed class NativeGatewayRuntime : INativeGatewayRuntime +{ + private readonly GatewayRegistry _registry; + private readonly INativeGatewayPackageResolver _packageResolver; + private readonly IOpenClawLogger _logger; + private readonly INativeGatewayProcessHost _host; + private readonly Func _capture; + private readonly TimeSpan _startupTimeout; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly CancellationTokenSource _shutdown = new(); + private readonly object _startLock = new(); + private CancellationTokenSource? _starting; + private long _stopGeneration; + private INativeGatewayProcess? _process; + private GatewayRecord? _record; + private int _disposed; + + public NativeGatewayRuntime( + GatewayRegistry registry, + INativeGatewayPackageResolver packageResolver, + IOpenClawLogger logger) + : this(registry, packageResolver, logger, new WindowsNativeGatewayProcessHost(), + WindowsTcpListenerSnapshot.Capture, TimeSpan.FromMinutes(2)) + { + } + + internal NativeGatewayRuntime( + GatewayRegistry registry, + INativeGatewayPackageResolver packageResolver, + IOpenClawLogger logger, + INativeGatewayProcessHost host, + Func capture, + TimeSpan startupTimeout) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _packageResolver = packageResolver ?? throw new ArgumentNullException(nameof(packageResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _host = host; + _capture = capture; + _startupTimeout = startupTimeout; + } + + public async Task EnsureRunningAsync(GatewayRecord record, CancellationToken cancellationToken) + { + var generation = Interlocked.Read(ref _stopGeneration); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdown.Token); + await _gate.WaitAsync(linked.Token).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + lock (_startLock) + { + if (generation != _stopGeneration) + throw new OperationCanceledException("Native gateway start was superseded by a stop request."); + _starting = linked; + } + var endpoint = NativeGatewayPaths.ValidateRecord(record); + if (!IsCurrent(record)) + await StopOwnedAsync().ConfigureAwait(false); + + // Resolve on every request, even idempotent reuse. Package removal or replacement + // must not silently keep authorizing a listener under stale package metadata. + var package = await _packageResolver.ResolveAsync(record.NativePackageFamilyName!, linked.Token).ConfigureAwait(false); + NativeGatewayPaths.ValidatePackage(package, record.NativePackageFamilyName!); + linked.Token.ThrowIfCancellationRequested(); + if (_process is { HasExited: false } && + InspectCore(record, endpoint).Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway) + { + return; + } + + await StopOwnedAsync().ConfigureAwait(false); + var beforeStart = InspectCore(record, endpoint); + if (beforeStart.Kind != GatewayEndpointProvenanceKind.NoListener) + throw new NativeGatewayListenerException(beforeStart); + var directory = NativeGatewayPaths.GetStateDirectory(_registry, record.Id); + if (!Directory.Exists(directory) || !File.Exists(NativeGatewayPaths.GetConfigPath(_registry, record.Id))) + throw new InvalidOperationException("Native gateway setup must create its state and configuration before starting."); + + var environment = NativeGatewayPaths.GetEnvironment(_registry, record.Id, _packageResolver.ResolveDataPath); + _process = await _host.StartAsync(new NativeGatewayStartSpec( + package.OpenClawAliasPath, environment["OPENCLAW_STATE_DIR"], endpoint.Port, + environment, package.PackageFamilyName), linked.Token).ConfigureAwait(false); + _record = record; + var started = Stopwatch.StartNew(); + while (true) + { + linked.Token.ThrowIfCancellationRequested(); + if (_process.HasExited) + throw new InvalidOperationException("The native gateway exited before its owned listener became ready."); + var provenance = InspectCore(record, endpoint); + if (provenance.Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway) + { + _logger.Info("Native gateway started with verified process ownership."); + return; + } + if (provenance.Kind != GatewayEndpointProvenanceKind.NoListener) + throw new NativeGatewayListenerException(provenance); + if (started.Elapsed >= _startupTimeout) + throw new TimeoutException("The native gateway did not open its owned loopback listener in time."); + await Task.Delay(TimeSpan.FromMilliseconds(100), linked.Token).ConfigureAwait(false); + } + } + catch + { + // Cleanup is intentionally not cancellable: cancellation must not orphan a child. + await StopOwnedAsync().ConfigureAwait(false); + throw; + } + finally + { + lock (_startLock) + _starting = null; + _gate.Release(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_startLock) + { + Interlocked.Increment(ref _stopGeneration); + _starting?.Cancel(); + } + // Once accepted, stopping is non-cancellable so cancellation while waiting for the + // serialized operation cannot leave a previously running child behind. + await _gate.WaitAsync().ConfigureAwait(false); + try + { + await StopOwnedAsync().ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + public GatewayEndpointProvenance Inspect(GatewayRecord record) + { + var endpoint = NativeGatewayPaths.ValidateRecord(record); + if (!_gate.Wait(0)) + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "Native Gateway startup or shutdown is in progress. Retry after it finishes."); + try + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + return InspectCore(record, endpoint); + } + finally + { + _gate.Release(); + } + } + + public async Task InspectAsync(GatewayRecord record, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + return InspectCore(record, NativeGatewayPaths.ValidateRecord(record)); + } + finally + { + _gate.Release(); + } + } + + private bool IsCurrent(GatewayRecord record) => + _record is not null && string.Equals(_record.Id, record.Id, StringComparison.Ordinal) && + string.Equals(_record.NativePackageFamilyName, record.NativePackageFamilyName, StringComparison.Ordinal) && + GatewayRecordEditing.AreEquivalentLoopbackEndpoints(_record.Url, record.Url); + + private GatewayEndpointProvenance InspectCore(GatewayRecord record, Uri endpoint) + { + var first = _capture(); + if (!first.Ipv4Complete || !first.Ipv6Complete) + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "The native listener snapshot is incomplete."); + var listeners = first.Listeners.Where(l => l.Port == endpoint.Port).ToArray(); + if (listeners.Length == 0) + return new(GatewayEndpointProvenanceKind.NoListener, endpoint.Port); + if (!IsCurrent(record) || _process is null || _process.HasExited) + { + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "There is no active native gateway lifecycle job for this record."); + } + if (listeners.Any(l => !IPAddress.IsLoopback(l.Address))) + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "The native gateway port has a non-loopback listener."); + if (listeners.Any(l => !_process.Owns(l))) + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "The listener is not a verified workload of the owned Gateway package launcher."); + if (!listeners.Any(l => MatchesEndpoint(l.Address, endpoint))) + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "The native gateway is not listening on the requested loopback address."); + + // Snapshot again after membership/lifetime checks to reject observed PID recycling or + // listener replacement. No cached PIDs or executable names confer trust. + var second = _capture(); + var current = second.Listeners.Where(l => l.Port == endpoint.Port).ToArray(); + if (!second.Ipv4Complete || !second.Ipv6Complete || listeners.Length != current.Length || + !listeners.All(l => current.Any(c => c.Address.Equals(l.Address) && + c.ProcessId == l.ProcessId && c.ProcessStartTimeUtc == l.ProcessStartTimeUtc)) || + _process.HasExited || current.Any(l => !_process.Owns(l))) + { + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "The native listener changed during ownership verification.", + FailureReason: GatewayEndpointProvenanceFailureReason.ListenerSnapshotChanged); + } + var owner = listeners.First(l => MatchesEndpoint(l.Address, endpoint)); + return new(GatewayEndpointProvenanceKind.ExpectedManagedGateway, endpoint.Port, + ProcessId: owner.ProcessId, ProcessStartTimeUtc: owner.ProcessStartTimeUtc, + Detail: "Verified native gateway package workload."); + } + + private static bool MatchesEndpoint(IPAddress address, Uri endpoint) => + string.Equals(endpoint.DnsSafeHost.TrimEnd('.'), "localhost", StringComparison.OrdinalIgnoreCase) + ? IPAddress.IsLoopback(address) + : IPAddress.TryParse(endpoint.DnsSafeHost, out var expected) && expected.Equals(address); + + private async Task StopOwnedAsync() + { + var process = _process; + _process = null; + _record = null; + if (process is not null) + await process.DisposeAsync().ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + Interlocked.Exchange(ref _disposed, 1); + await _shutdown.CancelAsync().ConfigureAwait(false); + await _gate.WaitAsync().ConfigureAwait(false); + try + { + await StopOwnedAsync().ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/OpenClaw.Connection/NativeGateway/WindowsNativeGatewayProcessHost.cs b/src/OpenClaw.Connection/NativeGateway/WindowsNativeGatewayProcessHost.cs new file mode 100644 index 000000000..3e8c24cc6 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/WindowsNativeGatewayProcessHost.cs @@ -0,0 +1,261 @@ +using Microsoft.Win32.SafeHandles; +using System.Collections; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace OpenClaw.Connection.NativeGateway; + +/// +/// Creates the package launcher suspended, assigns its kill-on-close job, then resumes it. +/// Explicit JOB_LIST creation conflicts with an already-active package's Windows job. +/// No launcher code executes before assignment; assignment/resume failures terminate it. +/// Output is not captured or exported. The package remains non-isolated. +/// +internal sealed class WindowsNativeGatewayProcessHost : INativeGatewayProcessHost +{ + public Task StartAsync(NativeGatewayStartSpec spec, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Native gateway supervision requires Windows."); + + var job = CreateJobObjectW(IntPtr.Zero, null); + if (job.IsInvalid) + { + job.Dispose(); + throw Failure("Could not create the native gateway lifecycle job."); + } + IntPtr environment = IntPtr.Zero; + Process? launcher = null; + try + { + var limits = new ExtendedLimits + { + BasicLimitInformation = new BasicLimits { LimitFlags = 0x2000 }, // KILL_ON_JOB_CLOSE + }; + if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf())) + throw Failure("Could not configure the native gateway lifecycle job."); + + environment = Marshal.StringToHGlobalUni(BuildEnvironmentBlock(spec.Environment)); + var startup = new StartupInfo + { + Size = Marshal.SizeOf(), + }; + var command = new StringBuilder($"\"{spec.ExecutablePath}\" gateway run --port {spec.Port} --bind loopback"); + cancellationToken.ThrowIfCancellationRequested(); + if (!CreateProcessW(spec.ExecutablePath, command, IntPtr.Zero, IntPtr.Zero, false, + 0x00000004 | 0x00000400 | 0x08000000, // SUSPENDED, UNICODE_ENVIRONMENT, NO_WINDOW + environment, spec.WorkingDirectory, ref startup, out var created)) + { + throw Failure("Could not start the installed native gateway package alias."); + } + using var process = new SafeProcessHandle(created.Process, ownsHandle: true); + using var thread = new SafeNativeHandle(created.Thread); + try + { + if (!AssignProcessToJobObject(job, process)) + throw Failure("Could not assign the suspended Gateway launcher to its lifecycle job."); + // Acquire and retain the managed handle while the original creation handle + // is held and the process is suspended. A later PID lookup is not an anchor. + launcher = Process.GetProcessById(checked((int)created.ProcessId)); + _ = launcher.SafeHandle; + if (ResumeThread(thread) == uint.MaxValue) + throw Failure("Could not resume the owned Gateway launcher."); + } + catch (Exception launchFailure) + { + if (!TerminateProcess(process, 1)) + throw new AggregateException(launchFailure, + Failure("Could not terminate the suspended Gateway launcher after a launch failure.")); + throw; + } + var owned = new JobProcess(job, launcher, spec.PackageFamilyName); + job = null!; + launcher = null; + if (cancellationToken.IsCancellationRequested) + { + owned.DisposeAsync().AsTask().GetAwaiter().GetResult(); + cancellationToken.ThrowIfCancellationRequested(); + } + return Task.FromResult(owned); + } + finally + { + if (environment != IntPtr.Zero) + Marshal.FreeHGlobal(environment); + launcher?.Dispose(); + job?.Dispose(); + } + } + + internal static string BuildEnvironmentBlock(IReadOnlyDictionary overrides) + { + var environment = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + environment[(string)entry.Key] = (string)entry.Value!; + foreach (var entry in overrides) + environment[entry.Key] = entry.Value; + return string.Join('\0', environment.Select(entry => $"{entry.Key}={entry.Value}")) + "\0\0"; + } + + private sealed class JobProcess(SafeNativeHandle job, Process launcher, string? family) : INativeGatewayProcess + { + private int _disposed; + + public bool HasExited => _disposed != 0 || ActiveProcesses() == 0; + + private uint ActiveProcesses() + { + if (!QueryInformationJobObject(job, 1, out BasicAccounting accounting, + (uint)Marshal.SizeOf(), IntPtr.Zero)) + throw Failure("Could not inspect the native gateway lifecycle job."); + return accounting.ActiveProcesses; + } + + public bool Owns(WindowsTcpListenerInfo listener) + { + if (_disposed != 0 || listener.ProcessId <= 0 || listener.ProcessStartTimeUtc is null) + return false; + try + { + using var process = Process.GetProcessById(listener.ProcessId); + // Retain this process handle throughout the lifetime/membership comparison. + // Looking up a PID by itself cannot prove that the TCP snapshot still applies. + var handle = process.SafeHandle; + return !process.HasExited && + process.StartTime.ToUniversalTime() == listener.ProcessStartTimeUtc.Value && + IsProcessInJob(handle, job, out var member) && + (member || (family is not null && !launcher.HasExited && + IsProcessInJob(launcher.SafeHandle, job, out var launcherMember) && launcherMember && + WindowsPackagedProcessAncestry.OwnsDescendant(launcher, process, family))) && + !process.HasExited; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or Win32Exception) + { + return false; + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + try + { + if (!TerminateJobObject(job, 1)) + throw Failure("Could not stop the native gateway lifecycle job."); + var elapsed = Stopwatch.StartNew(); + while (ActiveProcesses() != 0) + { + if (elapsed.Elapsed > TimeSpan.FromSeconds(5)) + throw new TimeoutException("The native gateway lifecycle job did not stop in time."); + await Task.Delay(20).ConfigureAwait(false); + } + } + finally + { + job.Dispose(); + launcher.Dispose(); + } + } + } + + private static Win32Exception Failure(string message) => new(Marshal.GetLastWin32Error(), message); + + private sealed class SafeNativeHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeNativeHandle() : base(true) { } + public SafeNativeHandle(IntPtr value) : base(true) => SetHandle(value); + protected override bool ReleaseHandle() => CloseHandle(handle); + } + + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimits + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public nuint MinimumWorkingSetSize; + public nuint MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public nuint Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount; + public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ExtendedLimits + { + public BasicLimits BasicLimitInformation; + public IoCounters IoInfo; + public nuint ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BasicAccounting + { + public long TotalUserTime, TotalKernelTime, ThisPeriodTotalUserTime, ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount, TotalProcesses, ActiveProcesses, TotalTerminatedProcesses; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfo + { + public int Size; + public IntPtr Reserved, Desktop, Title; + public uint X, Y, XSize, YSize, XCountChars, YCountChars, FillAttribute, Flags; + public ushort ShowWindow, ReservedSize; + public IntPtr ReservedBytes, StandardInput, StandardOutput, StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr Process, Thread; + public uint ProcessId, ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeNativeHandle CreateJobObjectW(IntPtr securityAttributes, string? name); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject(SafeNativeHandle job, int informationClass, + ref ExtendedLimits information, uint length); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool QueryInformationJobObject(SafeNativeHandle job, int informationClass, + out BasicAccounting information, uint length, IntPtr returnLength); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsProcessInJob(SafeProcessHandle process, SafeNativeHandle job, + [MarshalAs(UnmanagedType.Bool)] out bool result); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateJobObject(SafeNativeHandle job, uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeNativeHandle job, SafeProcessHandle process); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateProcess(SafeProcessHandle process, uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(SafeNativeHandle thread); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, + out ProcessInformation processInformation); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/src/OpenClaw.Connection/NativeGateway/WindowsPackagedProcessAncestry.cs b/src/OpenClaw.Connection/NativeGateway/WindowsPackagedProcessAncestry.cs new file mode 100644 index 000000000..e656c2e66 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/WindowsPackagedProcessAncestry.cs @@ -0,0 +1,132 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; + +namespace OpenClaw.Connection.NativeGateway; + +/// +/// Attributes a workload to an already-owned package launcher without requiring its +/// descendants to inherit the caller's Windows job. This is same-user supervision, +/// not an isolation boundary against code running with that user's process-access rights. +/// +internal static class WindowsPackagedProcessAncestry +{ + internal static bool OwnsDescendant(Process launcher, Process listener, string expectedFamily) + { + try + { + return !launcher.HasExited && + string.Equals(GetFamily(launcher.SafeHandle), expectedFamily, StringComparison.Ordinal) && + IsLiveDescendant(launcher, listener); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or Win32Exception) + { + // A failed inspection is a negative ownership result, never an adoption. + return false; + } + } + + internal static bool IsLiveDescendant(Process launcher, Process listener) + { + var ancestors = new List(); + try + { + var launcherHandle = launcher.SafeHandle; + if (launcher.HasExited || launcher.Id == listener.Id) + return false; + var owner = GetUser(launcherHandle); + var child = listener; + var seen = new HashSet { child.Id }; + for (var depth = 0; depth < 16; depth++) + { + var childHandle = child.SafeHandle; + if (child.HasExited || GetUser(childHandle) != owner) + return false; + var parentId = GetParentId(childHandle); + if (parentId == launcher.Id) + { + return launcher.StartTime.ToUniversalTime() <= child.StartTime.ToUniversalTime() && + !launcher.HasExited && !listener.HasExited && ancestors.All(p => !p.HasExited); + } + if (parentId <= 0 || !seen.Add(parentId)) + return false; + + // Keep every handle until the complete chain has been checked. A reused + // parent PID creates a newer process and fails the lifetime ordering check. + var parent = Process.GetProcessById(parentId); + ancestors.Add(parent); + _ = parent.SafeHandle; + if (parent.HasExited || parent.StartTime.ToUniversalTime() > child.StartTime.ToUniversalTime()) + return false; + child = parent; + } + return false; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or Win32Exception or OverflowException) + { + return false; + } + finally + { + foreach (var ancestor in ancestors) + ancestor.Dispose(); + } + } + + private static string GetUser(SafeProcessHandle process) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Package workload verification requires Windows."); + if (!OpenProcessToken(process, 8 /* TOKEN_QUERY */, out var token)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + using (token) + using (var identity = new WindowsIdentity(token.DangerousGetHandle())) + return identity.User?.Value ?? throw new InvalidOperationException("The process has no user SID."); + } + + private static string? GetFamily(SafeProcessHandle process) + { + uint length = 0; + var result = GetPackageFamilyName(process, ref length, null); + if (result == 15700) // APPMODEL_ERROR_NO_PACKAGE + return null; + if (result != 122 || length == 0) // ERROR_INSUFFICIENT_BUFFER + throw new Win32Exception(result); + var buffer = new char[length]; + result = GetPackageFamilyName(process, ref length, buffer); + if (result != 0) + throw new Win32Exception(result); + return new string(buffer, 0, checked((int)length - 1)); + } + + private static int GetParentId(SafeProcessHandle process) + { + var status = NtQueryInformationProcess(process, 0, out var basic, + (uint)Marshal.SizeOf(), out var returned); + if (status != 0 || returned != Marshal.SizeOf()) + throw new InvalidOperationException("The process parent could not be verified."); + return checked((int)basic.ParentProcessId); + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessBasicInformation + { + public int ExitStatus; + public IntPtr PebAddress; + public nuint AffinityMask; + public int BasePriority; + public nuint ProcessId; + public nuint ParentProcessId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetPackageFamilyName(SafeProcessHandle process, ref uint length, [Out] char[]? name); + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenProcessToken(SafeProcessHandle process, uint access, out SafeAccessTokenHandle token); + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess(SafeProcessHandle process, int informationClass, + out ProcessBasicInformation information, uint size, out uint returned); +} diff --git a/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs b/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs new file mode 100644 index 000000000..969e091d9 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs @@ -0,0 +1,54 @@ +using OpenClaw.Connection.NativeGateway; +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// Native records never use the WSL or remote endpoint credential exemptions. +internal static class NativeGatewayEndpointSecurity +{ + internal static async Task AuthorizeAsync( + INativeGatewayRuntime? runtime, + GatewayRecord record, + CancellationToken cancellationToken) + { + if (runtime is null) + { + return new(false, GatewayErrorKind.Network, + "The native Gateway runtime is unavailable. Install the approved MSIX manually and restart Companion. Credentials were not sent."); + } + + try + { + await runtime.EnsureRunningAsync(record, cancellationToken).ConfigureAwait(false); + var provenance = await runtime.InspectAsync(record, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (provenance.Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway) + { + // Runtime inspection attributes the workload to its owned package launcher on every handoff. + // A crash replacement remains trusted without pinning the previous process ID. + return EndpointCredentialAuthorization.AllowWithProof( + new EndpointOwnershipProof("native-managed", null, null, null, record.NativePackageFamilyName)); + } + + return new(false, + provenance.Kind == GatewayEndpointProvenanceKind.NoListener + ? GatewayErrorKind.Network + : GatewayErrorKind.LocalPortConflict, + provenance.Detail ?? "Native Gateway endpoint ownership could not be verified. Credentials were not sent."); + } + catch (OperationCanceledException) + { + throw; + } + catch (NativeGatewayListenerException ex) + { + return new(false, GatewayErrorKind.LocalPortConflict, + ex.Provenance.Detail ?? ex.Message); + } + catch (Exception) + { + return new(false, GatewayErrorKind.Network, + "The native Gateway could not start or verify its owned endpoint. Check the manually installed MSIX and retry. Credentials were not sent."); + } + } +} diff --git a/src/OpenClaw.SetupEngine.UI/NativeGatewayEligibilityText.cs b/src/OpenClaw.SetupEngine.UI/NativeGatewayEligibilityText.cs new file mode 100644 index 000000000..46d6169f6 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/NativeGatewayEligibilityText.cs @@ -0,0 +1,14 @@ +namespace OpenClaw.SetupEngine.UI; + +internal static class NativeGatewayEligibilityText +{ + internal static string Get(NativeGatewayEligibility eligibility) => eligibility switch + { + NativeGatewayEligibility.Available => SetupLocalization.GetString("Onboarding_Native_SupportAvailable"), + NativeGatewayEligibility.CapabilityUnavailable => SetupLocalization.Format( + "Onboarding_Native_SupportUnavailable", NativeGatewaySetupEligibility.InsiderBuild), + NativeGatewayEligibility.UnsupportedPlatform => SetupLocalization.GetString("Onboarding_Native_UnsupportedPlatform"), + NativeGatewayEligibility.CheckFailed => SetupLocalization.GetString("Onboarding_Native_SupportCheckFailed"), + _ => throw new ArgumentOutOfRangeException(nameof(eligibility)), + }; +} diff --git a/src/OpenClaw.SetupEngine.UI/NativeGatewayPackageResolver.cs b/src/OpenClaw.SetupEngine.UI/NativeGatewayPackageResolver.cs new file mode 100644 index 000000000..e81ae5c92 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/NativeGatewayPackageResolver.cs @@ -0,0 +1,56 @@ +using OpenClaw.Connection.NativeGateway; +using Windows.Management.Deployment; + +namespace OpenClaw.SetupEngine.UI; + +/// Uses current-user package registration, never an npm/PATH alias. +public sealed class NativeGatewayPackageResolver : INativeGatewayPackageResolver +{ + public Task ResolveAsync(CancellationToken cancellationToken) => + ResolveCoreAsync(null, cancellationToken); + + public Task ResolveAsync(string expectedFamily, CancellationToken cancellationToken) => + ResolveCoreAsync(expectedFamily, cancellationToken); + + private static Task ResolveCoreAsync( + string? expectedFamily, CancellationToken cancellationToken) => + Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + var packages = new PackageManager().FindPackagesForUser(string.Empty) + .Where(package => NativeGatewayPackageIdentity.IsTrusted(package.Id.Name, package.Id.Publisher) && + (expectedFamily is null || package.Id.FamilyName == expectedFamily) && + !package.IsFramework && !package.IsResourcePackage) + .ToArray(); + if (packages.Length == 0) + throw new NativeGatewayPackageNotInstalledException(); + if (packages.Length > 1) + throw new InvalidOperationException( + "Multiple supported OpenClaw Gateway MSIX packages are registered for this Windows user. " + + "Resolve the duplicate registrations, then retry native setup."); + + var package = packages[0]; + if (!package.Status.VerifyIsOK()) + throw new InvalidOperationException("The installed Gateway package needs repair. Repair it separately, then retry native setup."); + + var family = package.Id.FamilyName; + var aliases = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", "WindowsApps", family); + var openClaw = Path.Combine(aliases, "openclaw.exe"); + var clawCtl = Path.Combine(aliases, "clawctl.exe"); + if (!File.Exists(openClaw) || !File.Exists(clawCtl)) + throw new InvalidOperationException( + "The Gateway package's app execution aliases are unavailable. " + + "Enable its openclaw and clawctl aliases in Windows Settings, then retry native setup."); + + var version = package.Id.Version; + return new NativeGatewayPackage( + family, + $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}", + openClaw, + clawCtl); + }, cancellationToken); + + public string ResolveDataPath(string path) => LogFileLauncher.ResolveRealPath(path); +} diff --git a/src/OpenClaw.SetupEngine.UI/NativeGatewaySetupHost.cs b/src/OpenClaw.SetupEngine.UI/NativeGatewaySetupHost.cs new file mode 100644 index 000000000..dc458bcaa --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/NativeGatewaySetupHost.cs @@ -0,0 +1,113 @@ +using OpenClaw.Connection.NativeGateway; +using System.Diagnostics; + +namespace OpenClaw.SetupEngine.UI; + +internal sealed class NativeGatewaySetupHost( + Action? progress = null, + Action? stageProgress = null) : INativeGatewaySetupHost +{ + // Includes packaged Node/CLI startup, not just the Gateway RPC timeout. + private static readonly TimeSpan PairingCommandTimeout = TimeSpan.FromMinutes(2); + + public void ReportProgress(NativeGatewaySetupStage stage) + { + stageProgress?.Invoke(stage); + progress?.Invoke(stage switch + { + NativeGatewaySetupStage.StartingGateway => "Starting the packaged Gateway and verifying its listener...", + NativeGatewaySetupStage.VerifyingEndpoint => "Verifying the Gateway endpoint before connecting...", + _ => throw new ArgumentOutOfRangeException(nameof(stage)), + }); + } + + public IDisposable OpenRecoveryTerminal( + NativeGatewayPackage package, IReadOnlyDictionary environment) + { + var start = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"), + WorkingDirectory = environment["OPENCLAW_STATE_DIR"], + UseShellExecute = false, + }; + start.ArgumentList.Add("-NoLogo"); + start.ArgumentList.Add("-NoProfile"); + start.ArgumentList.Add("-NoExit"); + foreach (var (key, value) in environment) + start.Environment[key] = value; + start.Environment["PATH"] = Path.GetDirectoryName(package.OpenClawAliasPath) + + Path.PathSeparator + start.Environment["PATH"]; + return new RecoveryTerminal(Process.Start(start) + ?? throw new InvalidOperationException("The native profile terminal could not be opened.")); + } + + private sealed class RecoveryTerminal(Process process) : IDisposable + { + public void Dispose() + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + finally { process.Dispose(); } + } + } + + public Task PreparePackageAsync( + NativeGatewayPackage package, + IReadOnlyDictionary environment, + CancellationToken cancellationToken) => + RunAsync(package.ClawCtlAliasPath, ["setup"], environment, + TimeSpan.FromMinutes(3), "Preparing the packaged Gateway runtime...", cancellationToken); + + public Task ValidateConfigurationAsync( + NativeGatewayPackage package, + IReadOnlyDictionary environment, + CancellationToken cancellationToken) => + RunAsync(package.OpenClawAliasPath, ["config", "validate", "--json"], environment, + TimeSpan.FromMinutes(2), "Validating native Gateway configuration...", cancellationToken); + + public Task VerifyHealthAsync( + NativeGatewayPackage package, + IReadOnlyDictionary environment, + CancellationToken cancellationToken) => + RunAsync(package.OpenClawAliasPath, ["gateway", "health", "--json"], environment, + TimeSpan.FromMinutes(2), "Checking native Gateway health...", cancellationToken); + + public Task ListDevicePairingRequestsAsync( + NativeGatewayPackage package, + IReadOnlyDictionary environment, CancellationToken cancellationToken) => + RunAsync(package.OpenClawAliasPath, ["devices", "list", "--json"], + environment, PairingCommandTimeout, "Verifying this Companion's pairing request...", cancellationToken); + + public Task ApproveDevicePairingAsync( + NativeGatewayPackage package, string requestId, + IReadOnlyDictionary environment, CancellationToken cancellationToken) => + RunAsync(package.OpenClawAliasPath, ["devices", "approve", requestId, "--json"], + environment, PairingCommandTimeout, "Pairing this Companion with its setup Gateway...", cancellationToken); + + private async Task RunAsync( + string executable, string[] arguments, + IReadOnlyDictionary environment, + TimeSpan timeout, string message, CancellationToken cancellationToken) + { + progress?.Invoke(message); + using var logger = new SetupLogger(filePath: null); + var result = await new CommandRunner(logger).RunAsync( + executable, arguments, timeout, environment, + workingDirectory: environment["OPENCLAW_STATE_DIR"], + ct: cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (result.TimedOut || result.ExitCode != 0) + { + var detail = SetupLogger.Sanitize(result.Stderr + Environment.NewLine + result.Stdout).Trim(); + throw new InvalidOperationException( + $"{message} {(result.TimedOut ? "Timed out." : $"Exit code {result.ExitCode}.")} {detail}"); + } + if (arguments is ["setup"] && !string.IsNullOrWhiteSpace(result.Stdout)) + progress?.Invoke(SetupLogger.Sanitize(result.Stdout).Trim()); + return result.Stdout; + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml index a07fbfaf5..5bb4c2cd4 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml @@ -11,7 +11,7 @@ - @@ -73,7 +73,7 @@ - @@ -104,6 +104,16 @@ + + + + + @@ -271,6 +281,7 @@ + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs index 26855297d..7ea1c8c24 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs @@ -26,6 +26,7 @@ public sealed partial class CapabilitiesPage : Page private bool _suppressLocalAiSelection; private bool _suppressLocalAiConsent; private bool _skipPermissions; + private bool _nativeGateway; private bool _skipWizardWithoutLocalAi; private bool _localAiSelectionEligible; private bool _localAiNetworkingConsentRequired; @@ -78,6 +79,7 @@ protected override void OnNavigatedTo(NavigationEventArgs e) { var args = e.Parameter as CapabilitiesPageArgs; _config = args?.Config ?? e.Parameter as SetupConfig ?? new SetupConfig(); + _nativeGateway = args?.NativeGateway == true; // The tray always registers device.info/status with Node Mode. Keep the // setup declaration and gateway allowlist aligned with that runtime contract. _config.Capabilities.Device = true; @@ -103,6 +105,15 @@ protected override void OnNavigatedTo(NavigationEventArgs e) _setupWindow = SetupWindow.Active; if (_setupWindow is not null) _setupWindow.Activated += SetupWindow_Activated; + if (_nativeGateway) + { + GatewayTitle.Text = SetupLocalization.GetString("Onboarding_Native_Title.Text"); + LocalAiProfileNote.Visibility = Visibility.Collapsed; + WslReviewContent.Visibility = Visibility.Collapsed; + NativeReviewContent.Visibility = Visibility.Visible; + GoToStep(1); + return; + } TailscaleToggle.IsOn = _config.Tailscale.Enabled; TailscaleTrustAuthToggle.IsOn = _config.Tailscale.TrustTailscaleAuth; TailscaleAuthModeSelector.SelectedIndex = _config.Tailscale.AuthMode == TailscaleAuthMode.AuthKey ? 1 : 0; @@ -173,9 +184,13 @@ private void GoToStep(int step) { 1 => "What should your agent be able to do?", 2 => "Windows permissions", - _ => "What setup will install on this PC", + _ => _nativeGateway + ? SetupLocalization.GetString("Onboarding_Native_ReviewTitle") + : "What setup will install on this PC", }; - PrimaryButton.Content = step == 3 ? "Install & set up" : "Next"; + PrimaryButton.Content = step == 3 + ? _nativeGateway ? SetupLocalization.GetString("Onboarding_Native_Start.Content") : "Install & set up" + : "Next"; // Back is always available — from step 1 it returns to the Welcome screen. BackButton.Visibility = Visibility.Visible; UpdatePrimaryButtonState(); @@ -214,7 +229,10 @@ private async Task PrimaryClickAsync() break; default: WriteCapabilities(); - SetupWindow.Active?.NavigateToProgress(); + if (_nativeGateway) + SetupWindow.Active?.NavigateToNativeGatewaySetup(); + else + SetupWindow.Active?.NavigateToProgress(); break; } } @@ -253,6 +271,11 @@ private void WriteCapabilities() } } config.Settings.ApplyCapabilities(caps); + if (_nativeGateway) + { + config.Settings.EnableNodeMode = true; + return; + } config.Tailscale.Enabled = TailscaleToggle.IsOn == true; config.Tailscale.TrustTailscaleAuth = TailscaleTrustAuthToggle.IsOn == true; config.Tailscale.AuthMode = TailscaleAuthModeSelector.SelectedIndex == 1 @@ -271,6 +294,8 @@ private void WriteCapabilities() private void ApplySetupReviewSummary(SetupConfig config) { + if (_nativeGateway) + return; var summary = SetupReviewSummaryBuilder.Build( config, SetupWindow.Active?.DataDir, @@ -800,7 +825,7 @@ private void UpdatePrimaryButtonState() // has a way out: turning Local AI off satisfies the LocalAiToggle.IsOn != true branch // below immediately, without needing Continue to advance on incomplete information. PrimaryButton.IsEnabled = - _step != 3 || + _nativeGateway || _step != 3 || (!_localAiRecoveryOnly && LocalAiToggle.IsOn != true) || (LocalAiToggle.IsOn == true && _localAiSelectionEligible && @@ -968,6 +993,12 @@ private string ProfileSummary() return $"{n} of {Capabilities.Length} capabilities"; } + internal static string DescribeCapabilities(CapabilitiesConfig capabilities) => + string.Join(", ", Capabilities + .Where(capability => typeof(CapabilitiesConfig).GetProperty(capability.Key)?.GetValue(capabilities) is true) + .Select(capability => capability.Name) + .Append("Device info and status")); + private string PermissionSummary() { var visible = 1; // Notifications always shown diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPageArgs.cs b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPageArgs.cs index 83e3ad4e7..46193a512 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPageArgs.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPageArgs.cs @@ -3,4 +3,5 @@ namespace OpenClaw.SetupEngine.UI.Pages; internal sealed record CapabilitiesPageArgs( SetupConfig Config, bool StartAtLocalAiReview, - bool PinLocalAiModel); + bool PinLocalAiModel, + bool NativeGateway = false); diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml index d378de9d7..0fafc8fa2 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml @@ -73,7 +73,7 @@ - + @@ -101,7 +101,7 @@ VerticalAlignment="Center" /> - + @@ -116,11 +116,16 @@ - + + + + + + + + + + + + + + + + +