diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce43e3c8c..8dfc79515 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -132,14 +132,28 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. ## 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 +Windows deploys the MSIX. The current Gateway package owns its isolated session +and Gateway service; Companion owns setup, connection and credential handoff; +upstream OpenClaw supplies onboarding over RPC. The known legacy proof package +instead uses Companion's same-user process supervisor. 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 +Isolated runtime inspection and lifecycle ownership are separate. Verification +is cached per record and is always checked against fresh listener snapshots; +probing another package cannot transfer stop ownership. Only starts issued by +Companion are stopped on detach, and failed authorization rolls back only a +start issued by that same call. Explicit wizard restart uses a separate runtime +operation and preserves a pre-existing service's leave-running policy. +Ownership inspection, including the fresh check before credential handoff, has +a five-second deadline. Package inspection failures, including WinRT deployment +errors and an explicit unknown service state, deny handoff as unavailable +inspection rather than claiming a conflicting listener. Authentication recovery +classifies these unavailable probes as network failures. + `NativeGatewayMsixInstaller.InstallAsync` invokes the signed-in user's App Installer alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\winget.exe`) through the existing `CommandRunner`, without a shell or elevation. The fixed command is @@ -156,8 +170,14 @@ environment override, direct download, certificate-trust change, or ARM64-only g 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. +installation path. `NativeGatewayPackageClient` probes `clawctl status --json` +for `integration.kind: "isolated-session"` and version `1`. An unversioned +response that already describes a session is unsupported, not a legacy +fallback. Only the known `0.0.0.0` and `0.0.0.1` proof packages retain the +same-user path. +`NativeGatewaySetupHost` invokes `clawctl setup --json` and requires a ready +isolated session before configuring it; that command does not perform +Gateway onboarding. `NativeGatewayPackageIdentity` accepts the Store manifest's exact pair: `OpenClawFoundation.OpenClawGateway` and @@ -172,7 +192,8 @@ existing Gateway remains usable when both packages are installed. Runtime record 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. +remain mandatory before launching. An existing same-user record is not silently +migrated to a newly installed isolated package; it requires new setup. After native capability/permission review, `NativeGatewaySetupPage` starts automatically. It rechecks device support, then calls @@ -196,8 +217,10 @@ 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. +On finalization, the session applies selected command IDs to the Gateway's +`gateway.nodes.commands.allow` through the upstream CLI before config/health +verification. The isolated path applies this inside the agent account, not +under Companion's Windows profile. `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 @@ -207,11 +230,11 @@ 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. +The 2026-09-18 onboarding decision removed the separate "not isolated" +warning/checkbox. General security consent and exact-identity pairing remain. +Capability eligibility alone does not establish the Gateway's runtime account: +the package-qualified versioned integration check selects the isolated +package path, while the known legacy proof remains same-user. On Welcome, `NativeGatewaySetupEligibility` consumes the actual `wxc-exec --probe` result `probes.isolationSessionAvailable` exposed by @@ -244,13 +267,41 @@ 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 +### Draft and configuration 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. +containing a Gateway ID, preferred loopback port, package family, and runtime +contract. The draft is not yet a published `GatewayRegistry` record. Companion +keeps its device identity under its own data directory, but an isolated package +keeps OpenClaw configuration, credentials and workspace under the agent account. + +For an isolated package, `clawctl companion prepare --port --json` +reads the agent's default `openclaw.json` and invokes upstream +`openclaw config patch` inside the recorded session. It preserves an existing +local port and token plus unrelated settings and rejects incompatible mode, +bind and authentication settings. Companion records the returned effective +port and token; it does not create a host-side `openclaw.json`, forward its +profile paths or install its own Gateway supervisor. Package +`gateway-service start/status/stop` owns the service lifetime. A cancelled +draft can resume without replacing the agent's credential. Finalization +checks that the returned port and token still match before publishing. + +If an unpublished same-user draft survives an upgrade to an isolated package, +Companion shows an explicit replacement choice. Discarding removes only the +Companion draft descriptor, then starts isolated setup with a new identity. +Existing configuration, credentials and workspace files remain untouched, even +if a previously published connection was removed from the registry. Like +`StoreMigrationRecoveryDiscard`, native recovery discards intent, not user data; +the Inno-specific receipts and installation checks are not shared with native setup. +It never reuses host configuration as agent configuration. A published +same-user profile remains blocked and directs the user through Connections to +remove it and create a new isolated profile. It never adopts a foreign listener +or session, and it does not modify WSL or other package paths. + +The following same-user profile and port-rotation path applies only to the +recognized legacy proof package. It creates a separate profile at +`\gateways\\native-gateway`, with its own +`openclaw.json`, generated authentication token and workspace. Retry re-reads the draft instead of keeping a stale in-memory port. If an unpublished draft's port is occupied, `NativeGatewaySetupService` selects another @@ -261,20 +312,36 @@ 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 +For that legacy path, `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 +`NativeGatewaySetupSession` owns cancellation, pairing and final publication +gates for both paths. Only legacy setup backs up and suspends the reload setting +in its host-owned profile; the isolated path leaves the agent's existing +reload setting intact. Stopping either runtime does not delete its configuration. + +### Isolated listener proof and legacy process ownership + +`IsolatedGatewayRuntime` accepts a running Gateway only after a fresh +package-qualified `clawctl gateway-service status --json` attributes its +listener process IDs, creation times, and OS sequence numbers to the +recorded isolated session and agent SID. Companion compares that +attribution to two complete IPv4/IPv6 loopback snapshots and +`SystemBasicProcessInformation` process-sequence snapshots. It does not +open the isolated agent's process handle: Windows denies that cross-account +query. Windows 11 build 26100.4770 or newer is required for that +process-sequence API; unsupported builds fail closed with update guidance. +A port alone, a stale process ID, an unrelated listener, or a +replaced listener is denied. Its +synchronous browser credential callback uses the last attributed identity +plus fresh OS snapshots; it never starts a Gateway on the UI thread. + +The process-job and ancestry checks below describe only the legacy same-user +runtime. They are not an MXC session ownership proof. The installed package's launcher owns a separate kill-on-close job for Node. Observed ownership with the development package was: @@ -324,7 +391,7 @@ process inspection stays in `NativeGatewayRuntime`. Non-native handoffs retain conflicts retain `LocalPortConflict` classification instead of becoming generic network failures. -This is same-user supervision, not an MXC sandbox or a security boundary against +The legacy path 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. @@ -335,24 +402,41 @@ cleanup applies after assignment, and no launcher code has resumed before then. `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. +1. Validate initial configuration, start the package-owned service or legacy + runtime as appropriate, 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 + The package omits listener ownership details if Windows cannot supply + process-sequence evidence. Gateway health remains independent, but Companion + refuses credential handoff until running on Windows 11 build 26100.4770 or + later. Re-running `clawctl setup` cannot add this OS capability. +3. For the legacy profile, clear inherited URL overrides, pin the port and + supply the token in the child environment, not argv. The isolated path + uses package-qualified `openclaw devices list` and + `openclaw devices approve `. These commands run in the agent + context and use its Gateway config; Companion checks that its saved port + and token still match the agent config before each command. It does not pass + the token or host profile paths to the CLI. 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. + session. Companion does 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. +For isolated setup, `WizardConsoleTail` uses the authenticated operator's +upstream `logs.tail` RPC to project only root-logger `console.log` entries +after listener verification. The initial cursor is captured before +`wizard.start`, and later polls are byte/line bounded and already redacted +by OpenClaw. A gap or failure surfaces recovery-terminal guidance rather +than silently discarding an OAuth prompt. No package log-file path or host +profile is passed to the agent; legacy profile and WSL tail modes remain +separate. ### Finalization and transfer to normal connection management @@ -366,24 +450,29 @@ 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 +Stop setup-owned Gateway -> apply capability policy (and restore legacy reload) +-> validate endpoint/auth and config -> restart with verified ownership +-> authenticated health -> stop setup-owned 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 +to `GatewayConnectionManager` and its App-composed +`NativeGatewayRuntimeRouter`. The isolated branch delegates lifecycle to +`clawctl gateway-service`; it leaves a pre-existing package service running +when Companion detaches. The legacy branch retains Companion-owned process +supervision. Reconnect verifies package/agent listener identity again rather +than adopting an unrelated port. 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 +Disconnect stops a Gateway that Companion started, but preserves its data; +an already-running package-owned service is not stopped merely because +Companion detached. Removing the saved Gateway in Connection settings removes +the registry entry, not the agent's OpenClaw configuration or a legacy +Companion-owned profile. Windows Installed apps owns MSIX uninstallation and +package-managed agent data; Companion's device identity is separate. Back up configuration, credentials, workspace and conversation state before destructive cleanup. The broader Companion/WSL uninstall flow is not a native-Gateway-only uninstaller. diff --git a/docs/GATEWAY_SETUP_RESPONSIBILITIES.md b/docs/GATEWAY_SETUP_RESPONSIBILITIES.md index f4d2d5147..3f61116ab 100644 --- a/docs/GATEWAY_SETUP_RESPONSIBILITIES.md +++ b/docs/GATEWAY_SETUP_RESPONSIBILITIES.md @@ -4,9 +4,17 @@ 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 +**Historical evidence, not the current implementation contract.** The +same-user proof and proposed future MXC integration below describe the +2026-09-16 state. Current source uses the Gateway package's versioned +`isolated-session` contract, agent-owned config and package-owned +`gateway-service` lifecycle; Companion uses the package-qualified `openclaw` +alias for device listing and exact-request approval after checking the +agent-owned config. Listener process-sequence evidence is required for +Companion credential handoff, but not for baseline Gateway health/status. See +[current native architecture](ARCHITECTURE.md#native-gateway-msix-lifecycle-and-shared-wizard-handoffs) +and [onboarding](ONBOARDING_WIZARD.md#native-gateway-msix-isolated-or-legacy). +Packaging findings below 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 @@ -338,7 +346,7 @@ 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 | +| Responsibility | WSL path in this snapshot | Same-user native proof in this snapshot | |---|---|---| | 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. | @@ -379,7 +387,7 @@ Local source references: - `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): +- [Native setup documentation](ONBOARDING_WIZARD.md#native-gateway-msix-isolated-or-legacy): the branch's non-isolated native implementation. ## Decided ownership and persistence for MXC sessions @@ -414,7 +422,7 @@ Companion must: 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) +[historical setup recommendation policy](ONBOARDING_WIZARD.md#historical-mxc-recommendation-requirements) requires a working session integration before recommending it as isolated. ## Verified MXC 0.8 contract and implementation blockers diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index 1ed467ee2..35b267b32 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -60,9 +60,19 @@ entry from the original configuration; the generated template does not reproduce that entry or its value. The bundled configuration leaves setup mode and search provider unspecified so policy can choose among the actual offered values. -### Native Gateway MSIX (not isolated) - -**Package-aware verification (2026-09-16, package 0.0.0.1 ARM64):** the original +### Native Gateway MSIX (isolated or legacy) + +Isolated Gateway console reads retry transient I/O and timeout failures twice, +including the initial cursor anchor before `wizard.start`. Polling retries use +the same cursor; initial reads never display historical instructions. A gap or +failure also shows persistent terminal +recovery outside the normal wizard-error controls, so continuing to another +question cannot hide lost OAuth guidance. Starting a new wizard resets this +warning. Explicit **Restart gateway** restarts the verified package service +even if it was already running before Companion attached; merely preparing a +wizard or disconnecting does not restart or stop that pre-existing service. + +**Historical package-aware proof (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 @@ -93,11 +103,11 @@ for launch, shutdown, verification details and the pre-assignment crash window. **Install a local native gateway** is the first Welcome choice and retains its **Recommended** badge even while disabled, with WinUI disabled brushes for the title, description, badge and icon instead of active accent colors. Only a successful native capability -check enables it. 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. +check enables it. The separate isolation warning and acknowledgment checkbox +remain removed; the general security notice and provider/onboarding consent +remain explicit. With an isolated-session Gateway package, the package provisions +and runs the agent account; the known legacy proof package retains its original +same-user runtime. The UI capability gate alone never proves isolation. 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**. @@ -117,7 +127,14 @@ 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. +there is no implicit migration. Companion additionally checks the qualified +`clawctl status --json` integration kind and version before choosing a runtime. +An unversioned package already reporting a session is unsupported and requires +an MSIX update; it never falls back to same-user execution. +If the package reports a healthy Gateway but omits listener ownership because +Windows lacks process-sequence support, Gateway lifecycle remains available +but Companion will not send credentials. Update to Windows 11 build 26100.4770 +or later before retrying; `clawctl setup` cannot add the missing OS API. The review explains that **Set up gateway** authorizes WinGet installation and accepts the package and Store source agreements. Provider sign-in remains interactive. @@ -141,77 +158,87 @@ 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 +1. Saves a credential-free setup draft and Companion device identity. For a + versioned isolated package, `clawctl setup --json` must report a ready session, + then `clawctl companion prepare --port --json` uses the agent's + default configuration and packaged Node.js. An existing agent port, token and + provider settings are preserved; incompatible configuration fails explicitly. + No host profile path is forwarded. The known legacy proof package alone + retains a separate Companion-owned profile and same-user runtime. +2. Validates the active Gateway configuration and starts via the package's + `gateway-service` lifecycle (or the legacy runtime). Before every credential + handoff, including reconnects with a saved device identity, setup verifies + the package-owned listener and its isolated-session process sequence + against fresh Windows snapshots. The staged record is **not** made active + in the registry. +3. 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 + The isolated path uses package-qualified `openclaw devices list` and + `openclaw devices approve `, which run as the agent user and read + its config. Before each command, Companion checks that the effective port + and token still match its setup record, then approves only the verified + request. No host token or profile path is forwarded. No consent or provider + answer is supplied automatically. Legacy console + output is tailed from its dedicated profile. The isolated path uses + upstream's authenticated `logs.tail` RPC after verifying the Gateway + listener, then displays only root-logger `console.log` messages. It anchors + at the current log size before `wizard.start`, polls bounded redacted + batches, and reports skipped output or RPC failure with recovery-terminal + guidance. It never tails WSL or a host profile for agent-side messages. +4. Error-free wizard completion or the validated optional-tail handoff permits + finalization. Setup stops a Gateway it started, restores the legacy 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. +5. Stops a Gateway started by this setup 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. +For the isolated package, `clawctl gateway-service` owns the Gateway process, +sign-in recovery and isolated session. Companion starts it through the package +when connecting and stops it only when this Companion runtime started it; an +already-running package service is left alone on detach. Legacy proof packages +retain Companion-owned same-user process supervision. Companion never calls +`openclaw gateway install` or registers a second service. + +Cancelling setup, returning from the wizard, or closing setup stops only a +Gateway started by the setup runtime and closes its recovery terminal. Legacy +setup restores its reload setting. Cancel is not successful setup. +**Restart gateway** controls the selected runtime; **Open terminal** opens +`clawctl pwsh` in the agent for an isolated package, or the dedicated legacy +profile's shell, 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 +already entered is retained in the agent's profile (or the legacy dedicated +profile) so it is not lost on retry. 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 +resumes the same setup until successful publication. Only the legacy path has +a credential-free reload backup. Existing WSL distributions and remote +gateways are not replaced. Isolated setup reuses the agent account's default +OpenClaw configuration instead of touching the invoking user's `.openclaw`. +The native path does not enter the WSL cleanup, Local AI installation, or WSL repair pipelines. -This integration targets the packaging contract at +The legacy proof path was based on 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 +The current source's isolated path instead requires the versioned +`clawctl status --json` integration, agent-side configuration and +`gateway-service status --json` listener attribution. MSIX registration or +the presence of `IsolationProxy.exe` alone is not sufficient. + +### Historical MXC recommendation requirements + +The requirements below were recorded on 2026-09-16 for an earlier design. +They are historical context, not the current lifecycle contract: the Gateway +package now owns its isolated agent session, while Companion handles setup +and verifies endpoint ownership. See [Welcome](#welcome) for the current UI. + +- **Lifecycle owner (superseded proposal):** Companion would have provisioned + and supervised the MXC session. The implemented package instead provisions + and records that session; Companion never creates a second one. 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, @@ -240,9 +267,9 @@ implemented recommendation behavior. 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. +The implemented path must actually run the Gateway in the package's recorded +isolated session. Do not relabel the historical ordinary-process proof as MXC, +or recommend any MSIX as isolated based only on its registration. ## Overview @@ -309,9 +336,11 @@ 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. +screen-reader announcement. The description says **Install and set up an OpenClaw +gateway on this device**; it does not claim isolation based on a capability +probe alone. An older recognized proof package still runs same-user; a +non-proof package without the versioned isolation contract fails during +setup rather than pretending to be isolated. 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; diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index c344b25ef..298e49c7a 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -143,7 +143,7 @@ It never launches `openclaw onboard` or WSL. 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) +See [Native Gateway MSIX](ONBOARDING_WIZARD.md#native-gateway-msix-isolated-or-legacy) for consent, lifecycle, retry, and acquisition boundaries. See [Gateway setup responsibilities](GATEWAY_SETUP_RESPONSIBILITIES.md) for the diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 306b6ebf9..f40ec49ed 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -2221,7 +2221,11 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen) _endpointProvenanceProbe is not null ? await _endpointProvenanceProbe(activeRecord, CancellationToken.None).ConfigureAwait(false) : null; - var unexpectedManagedLocalOwner = + bool nativeInspectionUnavailable = activeRecord?.NativePackageFamilyName is not null && + provenance is not null && NativeGatewayEndpointSecurity.IsInspectionUnavailable(provenance); + if (nativeInspectionUnavailable) + failureKind = GatewayErrorKind.Network; + var unexpectedManagedLocalOwner = !nativeInspectionUnavailable && provenance?.Kind is GatewayEndpointProvenanceKind.ConflictingOpenClawGateway or GatewayEndpointProvenanceKind.UnknownListener; diff --git a/src/OpenClaw.Connection/GatewayEndpointProvenance.cs b/src/OpenClaw.Connection/GatewayEndpointProvenance.cs index 4d18c2fe9..7f870afd1 100644 --- a/src/OpenClaw.Connection/GatewayEndpointProvenance.cs +++ b/src/OpenClaw.Connection/GatewayEndpointProvenance.cs @@ -27,6 +27,8 @@ public enum GatewayEndpointProvenanceFailureReason { None, ListenerSnapshotChanged, + ProcessIdentityUnavailable, + InspectionUnavailable, } /// diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs index 9c83d7706..de142fc58 100644 --- a/src/OpenClaw.Connection/GatewayRecord.cs +++ b/src/OpenClaw.Connection/GatewayRecord.cs @@ -39,6 +39,9 @@ public sealed record GatewayRecord /// public string? NativePackageFamilyName { get; init; } + /// The package-owned isolation contract. Null identifies an existing same-user profile. + public string? NativeRuntimeContract { get; init; } + /// Per-gateway SSH tunnel configuration. Null if no tunnel needed. public SshTunnelConfig? SshTunnel { get; init; } @@ -94,6 +97,7 @@ existing.SetupManagedDistroName is null && rebuilt.SetupManagedDistroName is nul return result with { NativePackageFamilyName = preserveNative ? existing.NativePackageFamilyName : null, + NativeRuntimeContract = preserveNative ? existing.NativeRuntimeContract : null, IsLocal = OpenClaw.Shared.LocalGatewayUrlClassifier.IsLocalGatewayUrl(rebuilt.Url), RequiresV2Signature = preserveNative && (rebuilt.RequiresV2Signature || existing.RequiresV2Signature), diff --git a/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs b/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs new file mode 100644 index 000000000..9fe128163 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs @@ -0,0 +1,432 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.NativeGateway; + +/// Uses the package's isolated-session lifecycle and checks its live listener attribution. +public sealed class IsolatedGatewayRuntime : INativeGatewayRuntime +{ + private readonly INativeGatewayPackageResolver _resolver; + private readonly NativeGatewayPackageClient _client; + private readonly Func _capture; + private readonly Func> _captureSequences; + private readonly Func _aliasExists; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly TimeSpan _inspectionTimeout; + private readonly Dictionary _proofs = new(StringComparer.Ordinal); + private readonly HashSet _ownedStarts = new(StringComparer.Ordinal); + private bool _disposed; + private sealed record VerifiedGateway(GatewayRecord Record, NativeGatewayPackage Package, IsolatedGatewayStatus Status); + + public IsolatedGatewayRuntime(INativeGatewayPackageResolver resolver) + : this(resolver, new NativeGatewayPackageClient(), WindowsTcpListenerSnapshot.Capture, + WindowsProcessSequenceSnapshot.Capture, File.Exists) + { + } + + internal IsolatedGatewayRuntime( + INativeGatewayPackageResolver resolver, + NativeGatewayPackageClient client, + Func capture, + Func> captureSequences, + Func aliasExists, + TimeSpan? inspectionTimeout = null) + { + _resolver = resolver; + _client = client; + _capture = capture; + _captureSequences = captureSequences; + _aliasExists = aliasExists; + _inspectionTimeout = inspectionTimeout ?? TimeSpan.FromSeconds(5); + } + + public async Task EnsureRunningAsync(GatewayRecord record, CancellationToken cancellationToken) + { + Uri endpoint = RequireRecord(record); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + await EnsureRunningCoreAsync(record, endpoint, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private async Task EnsureRunningCoreAsync(GatewayRecord record, Uri endpoint, CancellationToken cancellationToken) + { + NativeGatewayPackage? startedThisCall = null; + _proofs.Remove(record.Id); + try + { + NativeGatewayPackage package = await ResolveAsync(record, cancellationToken).ConfigureAwait(false); + IsolatedGatewayStatus status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); + if (status.State is "not-started" or "stopped") + { + var conflict = InspectSnapshot(endpoint.Port, null); + if (conflict.Kind != GatewayEndpointProvenanceKind.NoListener) + throw new NativeGatewayListenerException(conflict); + startedThisCall = package; + _ownedStarts.Add(package.PackageFamilyName); + await _client.StartAsync(package, cancellationToken).ConfigureAwait(false); + status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); + } + if (status.State != "running" || status.Port != endpoint.Port) + throw new NativeGatewayContractException( + "The package-managed Gateway is not running on Companion's selected port. " + + "Check clawctl gateway-service status before connecting."); + + var provenance = InspectSnapshot(endpoint.Port, status); + if (provenance.Kind != GatewayEndpointProvenanceKind.ExpectedManagedGateway) + throw new NativeGatewayListenerException(provenance); + _proofs[record.Id] = new(record, package, status); + } + catch + { + if (startedThisCall is not null) + { + try + { + await _client.StopAsync(startedThisCall, CancellationToken.None).ConfigureAwait(false); + _ownedStarts.Remove(startedThisCall.PackageFamilyName); + } + catch (Exception cleanupFailure) when (IsInspectionFailure(cleanupFailure)) + { + Trace.TraceError($"Isolated Gateway start rollback failed ({cleanupFailure.GetType().Name}); stop ownership retained."); + } + } + throw; + } + } + + public async Task InspectAsync( + GatewayRecord record, CancellationToken cancellationToken) + { + Uri endpoint = RequireRecord(record); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(_inspectionTimeout); + bool entered = false; + try + { + await _gate.WaitAsync(deadline.Token).ConfigureAwait(false); + entered = true; + ObjectDisposedException.ThrowIf(_disposed, this); + _proofs.Remove(record.Id); + NativeGatewayPackage package = await ResolveAsync(record, deadline.Token) + .WaitAsync(deadline.Token).ConfigureAwait(false); + IsolatedGatewayStatus status = await _client.StatusAsync(package, deadline.Token) + .WaitAsync(deadline.Token).ConfigureAwait(false); + deadline.Token.ThrowIfCancellationRequested(); + if (status.State == "unknown") + { + Trace.TraceWarning("The Gateway package could not establish its service state. Credentials were not sent."); + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "The Gateway package could not inspect its service. Check the package and retry. Credentials were not sent.", + FailureReason: GatewayEndpointProvenanceFailureReason.InspectionUnavailable); + } + if (status.State != "running" || status.Port != endpoint.Port) + { + return InspectSnapshot(endpoint.Port, null); + } + var provenance = InspectSnapshot(endpoint.Port, status); + deadline.Token.ThrowIfCancellationRequested(); + if (provenance.Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway) + _proofs[record.Id] = new(record, package, status); + return provenance; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + Trace.TraceWarning("Isolated Gateway ownership inspection timed out. Credentials were not sent."); + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: entered + ? "Gateway ownership inspection timed out. Retry after checking the package. Credentials were not sent." + : "Gateway lifecycle is busy. Retry after it finishes. Credentials were not sent.", + FailureReason: GatewayEndpointProvenanceFailureReason.InspectionUnavailable); + } + catch (Exception ex) when (IsInspectionFailure(ex)) + { + Trace.TraceWarning($"Isolated Gateway ownership inspection failed ({ex.GetType().Name}). Credentials were not sent."); + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "Gateway ownership inspection failed. Check the installed Gateway package and retry. Credentials were not sent.", + FailureReason: GatewayEndpointProvenanceFailureReason.InspectionUnavailable); + } + finally + { + if (entered) + _gate.Release(); + } + } + + public GatewayEndpointProvenance Inspect(GatewayRecord record) + { + Uri endpoint = RequireRecord(record); + if (!_gate.Wait(0)) + return new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "Gateway lifecycle is busy. Credentials were not sent."); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _proofs.TryGetValue(record.Id, out var proof) && + proof.Record.NativePackageFamilyName == record.NativePackageFamilyName && + GatewayRecordEditing.AreEquivalentLoopbackEndpoints(proof.Record.Url, record.Url) && + _aliasExists(proof.Package.ClawCtlAliasPath) + ? InspectSnapshot(endpoint.Port, proof.Status) + : new(GatewayEndpointProvenanceKind.UnknownListener, endpoint.Port, + Detail: "No verified Gateway package listener is recorded for this Companion."); + } + finally + { + _gate.Release(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + _proofs.Clear(); + await StopOwnedAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + public async Task RestartAsync(GatewayRecord record, CancellationToken cancellationToken) + { + Uri endpoint = RequireRecord(record); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + NativeGatewayPackage package = await ResolveAsync(record, cancellationToken).ConfigureAwait(false); + IsolatedGatewayStatus status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); + var provenance = InspectSnapshot(endpoint.Port, + status.State == "running" && status.Port == endpoint.Port ? status : null); + if (status.State == "running" && status.Port != endpoint.Port || + provenance.Kind is not (GatewayEndpointProvenanceKind.ExpectedManagedGateway or GatewayEndpointProvenanceKind.NoListener)) + throw new NativeGatewayListenerException(provenance); + + bool owned = _ownedStarts.Contains(package.PackageFamilyName); + _proofs.Remove(record.Id); + await _client.StopAsync(package, cancellationToken).ConfigureAwait(false); + status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); + if (status.State is not ("stopped" or "not-started")) + throw new NativeGatewayContractException( + "The Gateway package has not confirmed that its service stopped. Retry restart after checking its status."); + _ownedStarts.Remove(package.PackageFamilyName); + await EnsureRunningCoreAsync(record, endpoint, cancellationToken).ConfigureAwait(false); + // An explicit restart must not turn a pre-existing service into a detach-owned start. + if (owned) + _ownedStarts.Add(package.PackageFamilyName); + else + _ownedStarts.Remove(package.PackageFamilyName); + } + finally + { + _gate.Release(); + } + } + + private async Task StopOwnedAsync(CancellationToken cancellationToken) + { + List failures = []; + foreach (string family in _ownedStarts.ToArray()) + { + try + { + NativeGatewayPackage package = await _resolver.ResolveAsync(family, cancellationToken).ConfigureAwait(false); + NativeGatewayPaths.ValidatePackage(package, family); + await _client.StopAsync(package, cancellationToken).ConfigureAwait(false); + _ownedStarts.Remove(family); + } + catch (Exception ex) when (IsInspectionFailure(ex)) + { + failures.Add(ex); + } + } + if (failures.Count > 0) + throw new AggregateException("One or more Companion-owned Gateways could not be stopped.", failures); + } + + private static bool IsInspectionFailure(Exception ex) => + ex is InvalidOperationException or IOException or Win32Exception or COMException or UnauthorizedAccessException or TimeoutException; + + public async ValueTask DisposeAsync() + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) + return; + _proofs.Clear(); + await StopOwnedAsync(CancellationToken.None).ConfigureAwait(false); + _disposed = true; + } + finally + { + _gate.Release(); + } + } + + private async Task ResolveAsync( + GatewayRecord record, CancellationToken cancellationToken) + { + NativeGatewayPackage package = await _resolver.ResolveAsync( + record.NativePackageFamilyName!, cancellationToken).ConfigureAwait(false); + NativeGatewayPaths.ValidatePackage(package, record.NativePackageFamilyName!); + return package; + } + + private static Uri RequireRecord(GatewayRecord record) + { + Uri endpoint = NativeGatewayPaths.ValidateRecord(record); + if (record.NativeRuntimeContract != NativeGatewayPackageClient.IsolatedContract) + throw new NativeGatewayContractException( + "This Gateway profile does not use the isolated-session contract. Reconfigure it in Companion."); + return endpoint; + } + + private GatewayEndpointProvenance InspectSnapshot(int port, IsolatedGatewayStatus? status) + { + WindowsTcpListenerSnapshotResult first = _capture(); + if (!first.Ipv4Complete || !first.Ipv6Complete) + return Unknown("The Gateway listener snapshot is incomplete."); + WindowsTcpListenerInfo[] listeners = first.Listeners.Where(listener => listener.Port == port).ToArray(); + if (listeners.Length == 0) + return status is null + ? new(GatewayEndpointProvenanceKind.NoListener, port) + : Unknown("The package-managed Gateway is not listening on its verified port."); + if (status?.AgentUserSid is not { Length: > 0 }) + return Unknown("A listener exists, but the package did not attest to it."); + IReadOnlyDictionary firstSequences; + try + { + firstSequences = _captureSequences(); + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or InvalidDataException) + { + return Unknown(ex.Message, GatewayEndpointProvenanceFailureReason.ProcessIdentityUnavailable); + } + if (listeners.Any(listener => !IPAddress.IsLoopback(listener.Address) || + !firstSequences.TryGetValue(listener.ProcessId, out ulong sequence) || + !status.Listeners.Any(owned => owned.Port == port && + owned.ProcessId == listener.ProcessId && owned.SequenceNumber == sequence))) + return Unknown("The listener is not owned by the verified isolated Gateway session."); + WindowsTcpListenerSnapshotResult second = _capture(); + WindowsTcpListenerInfo[] current = second.Listeners.Where(listener => listener.Port == port).ToArray(); + IReadOnlyDictionary secondSequences; + try + { + secondSequences = _captureSequences(); + } + catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or InvalidDataException) + { + return Unknown(ex.Message, GatewayEndpointProvenanceFailureReason.ProcessIdentityUnavailable); + } + if (!second.Ipv4Complete || !second.Ipv6Complete || current.Length != listeners.Length || + !listeners.All(listener => current.Any(now => now.Address.Equals(listener.Address) && + now.ProcessId == listener.ProcessId)) || + current.Any(listener => !secondSequences.TryGetValue(listener.ProcessId, out ulong sequence) || + !firstSequences.TryGetValue(listener.ProcessId, out ulong initial) || + initial != sequence)) + return Unknown("The Gateway listener changed during ownership verification."); + WindowsTcpListenerInfo owner = listeners.First(); + IsolatedGatewayListener identity = status.Listeners.First(listener => + listener.Port == port && listener.ProcessId == owner.ProcessId); + return new(GatewayEndpointProvenanceKind.ExpectedManagedGateway, port, + ProcessId: owner.ProcessId, + ProcessStartTimeUtc: identity.ProcessStartTimeUtc, + Detail: "Verified listener in the package-managed isolated session."); + + GatewayEndpointProvenance Unknown( + string detail, + GatewayEndpointProvenanceFailureReason reason = GatewayEndpointProvenanceFailureReason.None) => + new(GatewayEndpointProvenanceKind.UnknownListener, port, Detail: detail, FailureReason: reason); + } + +} + +/// Retains the legacy host-owned runtime only for an explicitly detected legacy package. +public sealed class NativeGatewayRuntimeRouter( + INativeGatewayRuntime legacy, + INativeGatewayRuntime isolated, + INativeGatewayPackageResolver resolver, + NativeGatewayPackageClient client) : INativeGatewayRuntime +{ + private INativeGatewayRuntime? _active; + + public static NativeGatewayRuntimeRouter Create( + GatewayRegistry registry, INativeGatewayPackageResolver resolver, IOpenClawLogger logger) => + new(new NativeGatewayRuntime(registry, resolver, logger), + new IsolatedGatewayRuntime(resolver), resolver, new NativeGatewayPackageClient()); + + public async Task EnsureRunningAsync(GatewayRecord record, CancellationToken cancellationToken) + { + if (record.NativeRuntimeContract == NativeGatewayPackageClient.IsolatedContract) + { + _active = isolated; + await isolated.EnsureRunningAsync(record, cancellationToken).ConfigureAwait(false); + return; + } + if (record.NativeRuntimeContract is not null) + throw new InvalidOperationException("This Gateway record has an unsupported runtime contract."); + NativeGatewayPackage package = await resolver.ResolveAsync( + record.NativePackageFamilyName!, cancellationToken).ConfigureAwait(false); + if (await client.DetectAsync(package, cancellationToken).ConfigureAwait(false) != + NativeGatewayContract.Legacy) + throw new NativeGatewayContractException( + "This Companion profile was created for a same-user Gateway. " + + "In Companion, open Connections, select this old profile and choose Remove, then choose Add Gateway > Install a local native Gateway. " + + "This explicitly creates a new isolated profile instead of sending the old profile's credentials to the package."); + _active = legacy; + await legacy.EnsureRunningAsync(record, cancellationToken).ConfigureAwait(false); + } + + public Task InspectAsync( + GatewayRecord record, CancellationToken cancellationToken) => + record.NativeRuntimeContract == NativeGatewayPackageClient.IsolatedContract + ? isolated.InspectAsync(record, cancellationToken) + : record.NativeRuntimeContract is null && ReferenceEquals(_active, legacy) + ? legacy.InspectAsync(record, cancellationToken) + : Task.FromResult(Unestablished(record)); + + public GatewayEndpointProvenance Inspect(GatewayRecord record) => + record.NativeRuntimeContract == NativeGatewayPackageClient.IsolatedContract + ? isolated.Inspect(record) + : record.NativeRuntimeContract is null && ReferenceEquals(_active, legacy) + ? legacy.Inspect(record) + : Unestablished(record); + + public async Task RestartAsync(GatewayRecord record, CancellationToken cancellationToken) + { + if (record.NativeRuntimeContract == NativeGatewayPackageClient.IsolatedContract) + { + _active = isolated; + await isolated.RestartAsync(record, cancellationToken).ConfigureAwait(false); + return; + } + await EnsureRunningAsync(record, cancellationToken).ConfigureAwait(false); + await legacy.RestartAsync(record, cancellationToken).ConfigureAwait(false); + } + + public Task StopAsync(CancellationToken cancellationToken) => + Task.WhenAll(legacy.StopAsync(cancellationToken), isolated.StopAsync(cancellationToken)); + + public async ValueTask DisposeAsync() + { + try { await legacy.DisposeAsync().ConfigureAwait(false); } + finally { await isolated.DisposeAsync().ConfigureAwait(false); } + } + + private static GatewayEndpointProvenance Unestablished(GatewayRecord record) => + new(GatewayEndpointProvenanceKind.UnknownListener, NativeGatewayPaths.ValidateRecord(record).Port, + Detail: "Gateway ownership has not been established for this profile. Credentials were not sent."); +} diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs index 52aa6ab4a..b6d736fde 100644 --- a/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs @@ -5,7 +5,31 @@ public sealed record NativeGatewayPackage( string PackageFamilyName, string Version, string OpenClawAliasPath, - string ClawCtlAliasPath); + string ClawCtlAliasPath) +{ + public NativeGatewayContract Contract { get; init; } +} + +public enum NativeGatewayContract +{ + Unknown, + Legacy, + IsolatedSessionV1 +} + +/// A safe-to-display failure of the qualified Gateway integration contract. +public sealed class NativeGatewayContractException(string message) : InvalidOperationException(message); + +public sealed record IsolatedGatewayConfiguration(int Port, string Token); + +public sealed record IsolatedGatewayListener( + int Port, int ProcessId, DateTime ProcessStartTimeUtc, ulong SequenceNumber); + +public sealed record IsolatedGatewayStatus( + string State, + int? Port, + string? AgentUserSid, + IReadOnlyList Listeners); /// No matching trusted package is registered. Other validation failures are not install requests. public sealed class NativeGatewayPackageNotInstalledException : InvalidOperationException @@ -40,12 +64,18 @@ async Task ResolveAsync(string expectedFamily, Cancellatio } /// -/// Owns a non-isolated native gateway's process lifetime, not its installation or persisted state. +/// Owns native gateway runtime handoffs, 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); + /// Explicit user-requested restart, unlike Stop which only detaches owned starts. + async Task RestartAsync(GatewayRecord record, CancellationToken cancellationToken) + { + await StopAsync(cancellationToken).ConfigureAwait(false); + await EnsureRunningAsync(record, cancellationToken).ConfigureAwait(false); + } 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. diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayPackageClient.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayPackageClient.cs new file mode 100644 index 000000000..14b56e89d --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayPackageClient.cs @@ -0,0 +1,303 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace OpenClaw.Connection.NativeGateway; + +public sealed record NativeGatewayCommandResult(int ExitCode, string StandardOutput); + +/// Invokes only the installed package's qualified control alias and checks its JSON contract. +public sealed class NativeGatewayPackageClient +{ + public const string IsolatedContract = "isolated-session-v1"; + + private readonly Func, CancellationToken, + Task> _invoke; + + public NativeGatewayPackageClient() + : this(InvokePackageAsync) + { + } + + internal NativeGatewayPackageClient( + Func, CancellationToken, + Task> invoke) => _invoke = invoke; + + public async Task DetectAsync( + NativeGatewayPackage package, CancellationToken cancellationToken) + { + NativeGatewayCommandResult result = await _invoke( + package, ["status", "--json"], cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(result.StandardOutput)) + { + if (IsLegacyProofVersion(package.Version)) + return NativeGatewayContract.Legacy; + throw Unsupported(); + } + JsonDocument document; + try + { + document = Parse(result.StandardOutput); + } + catch (InvalidOperationException) when ( + IsLegacyProofVersion(package.Version)) + { + return NativeGatewayContract.Legacy; + } + using (document) + { + JsonElement root = document.RootElement; + if (!root.TryGetProperty("integration", out _)) + { + if (root.TryGetProperty("session", out _)) + throw Unsupported(); + if (IsLegacyProofVersion(package.Version)) + return NativeGatewayContract.Legacy; + throw Unsupported(); + } + RequireIntegration(root, "status"); + return NativeGatewayContract.IsolatedSessionV1; + } + } + + public async Task PrepareAsync( + NativeGatewayPackage package, int port, CancellationToken cancellationToken) + { + NativeGatewayCommandResult result = await _invoke( + package, ["companion", "prepare", "--port", port.ToString( + System.Globalization.CultureInfo.InvariantCulture), "--json"], cancellationToken) + .ConfigureAwait(false); + return ReadCompanionConfiguration(result); + } + + public async Task CheckAsync( + NativeGatewayPackage package, CancellationToken cancellationToken) + { + NativeGatewayCommandResult result = await _invoke( + package, ["companion", "prepare", "--check", "--json"], cancellationToken) + .ConfigureAwait(false); + return ReadCompanionConfiguration(result); + } + + private static IsolatedGatewayConfiguration ReadCompanionConfiguration( + NativeGatewayCommandResult result) + { + using JsonDocument document = Parse(result.StandardOutput); + JsonElement root = document.RootElement; + RequireIntegration(root, "companion prepare"); + RequireSuccess(root, result.ExitCode); + JsonElement prepared = RequiredObject(root, "companion"); + int selectedPort = RequiredInt(prepared, "port"); + string token = RequiredString(prepared, "token"); + if (selectedPort is < 1 or > 65535 || string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("The Gateway package returned an invalid Companion credential."); + return new IsolatedGatewayConfiguration(selectedPort, token); + } + + public async Task SetupAsync(NativeGatewayPackage package, CancellationToken cancellationToken) + { + NativeGatewayCommandResult result = await _invoke( + package, ["setup", "--json"], cancellationToken).ConfigureAwait(false); + using JsonDocument document = Parse(result.StandardOutput); + JsonElement root = document.RootElement; + RequireIntegration(root, "setup"); + RequireSuccess(root, result.ExitCode); + JsonElement session = RequiredObject(root, "session"); + if (RequiredString(session, "state") != "ready" || + string.IsNullOrWhiteSpace(RequiredString(session, "sandboxId"))) + throw new NativeGatewayContractException( + "The Gateway MSIX has no ready isolated session. Run clawctl setup again."); + } + + public async Task StatusAsync( + NativeGatewayPackage package, CancellationToken cancellationToken) + { + NativeGatewayCommandResult result = await _invoke( + package, ["gateway-service", "status", "--json"], cancellationToken).ConfigureAwait(false); + using JsonDocument document = Parse(result.StandardOutput); + JsonElement root = document.RootElement; + RequireIntegration(root, "gateway-service status"); + JsonElement gateway = RequiredObject(root, "gateway"); + string state = RequiredString(gateway, "state"); + if (state is not ("running" or "not-started" or "stopped" or "starting" or "unhealthy" or "unknown")) + throw new InvalidOperationException("The Gateway package reported an unsupported lifecycle state."); + if (state == "running" && result.ExitCode != 0) + throw PackageError(root); + if (state != "running") + return new IsolatedGatewayStatus(state, null, null, []); + + int port = RequiredInt(gateway, "port"); + if (!gateway.TryGetProperty("ownership", out JsonElement ownership) || + ownership.ValueKind != JsonValueKind.Object) + throw new NativeGatewayContractException( + "The Gateway is running, but Companion cannot verify isolated listener ownership, " + + "so it did not send credentials. Update Windows to a build supporting " + + "SystemBasicProcessInformation (Windows 11 26100.4770 or later), then retry. " + + "Running clawctl setup again cannot add this Windows capability."); + _ = RequiredString(ownership, "sandboxId"); + string sid = RequiredString(ownership, "agentUserSid"); + if (string.IsNullOrWhiteSpace(sid) || port is < 1 or > 65535 || + !ownership.TryGetProperty("listeners", out JsonElement entries) || + entries.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException("The Gateway package did not provide listener ownership."); + var listeners = new List(); + foreach (JsonElement entry in entries.EnumerateArray()) + { + int listenerPort = RequiredInt(entry, "port"); + int processId = RequiredInt(entry, "processId"); + DateTime created = RequiredDateTime(entry, "processStartTimeUtc"); + ulong sequence = RequiredUInt64(entry, "sequenceNumber"); + if (listenerPort is < 1 or > 65535 || processId <= 0 || created == default || + sequence == 0) + throw new InvalidOperationException("The Gateway package returned an invalid listener identity."); + listeners.Add(new IsolatedGatewayListener(listenerPort, processId, created, sequence)); + } + if (!listeners.Any(listener => listener.Port == port)) + throw new InvalidOperationException("The Gateway package did not attribute the observed port to its session."); + return new IsolatedGatewayStatus(state, port, sid, listeners); + } + + public async Task StartAsync(NativeGatewayPackage package, CancellationToken cancellationToken) => + await RunLifecycleAsync(package, "start", cancellationToken).ConfigureAwait(false); + + public async Task StopAsync(NativeGatewayPackage package, CancellationToken cancellationToken) => + await RunLifecycleAsync(package, "stop", cancellationToken).ConfigureAwait(false); + + private async Task RunLifecycleAsync( + NativeGatewayPackage package, string operation, CancellationToken cancellationToken) + { + NativeGatewayCommandResult result = await _invoke( + package, ["gateway-service", operation, "--json"], cancellationToken).ConfigureAwait(false); + using JsonDocument document = Parse(result.StandardOutput); + RequireIntegration(document.RootElement, $"gateway-service {operation}"); + RequireSuccess(document.RootElement, result.ExitCode); + } + + private static JsonDocument Parse(string text) + { + try + { + return JsonDocument.Parse(text); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "The Gateway package did not return a supported JSON contract. Update the Gateway MSIX.", ex); + } + } + + private static void RequireIntegration(JsonElement root, string command) + { + if (RequiredInt(root, "schemaVersion") != 1 || + RequiredString(root, "command") != command || + RequiredString(RequiredObject(root, "integration"), "kind") != "isolated-session" || + RequiredInt(RequiredObject(root, "integration"), "version") != 1) + throw Unsupported(); + } + + private static NativeGatewayContractException Unsupported() => + new("The installed Gateway MSIX does not expose Companion's isolated-session contract. " + + "Update the Gateway MSIX, then retry setup. Credentials were not sent."); + + private static bool IsLegacyProofVersion(string version) => + version is "0.0.0.0" or "0.0.0.1"; + + private static void RequireSuccess(JsonElement root, int exitCode) + { + if (exitCode != 0 || !root.TryGetProperty("ok", out JsonElement ok) || + ok.ValueKind != JsonValueKind.True) + throw PackageError(root); + } + + private static InvalidOperationException PackageError(JsonElement root) + { + string? message = root.TryGetProperty("error", out JsonElement error) && + error.ValueKind == JsonValueKind.Object && + error.TryGetProperty("message", out JsonElement value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + return new InvalidOperationException(message ?? + "The Gateway package could not complete the request. Run clawctl status and retry."); + } + + private static JsonElement RequiredObject(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.Object) + throw Unsupported(); + return value; + } + + private static string RequiredString(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String) + throw Unsupported(); + return value.GetString()!; + } + + private static int RequiredInt(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt32(out int result)) + throw Unsupported(); + return result; + } + + private static DateTime RequiredDateTime(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement value) || + value.ValueKind != JsonValueKind.String || + !value.TryGetDateTime(out DateTime result)) + throw Unsupported(); + return result.ToUniversalTime(); + } + + private static ulong RequiredUInt64(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetUInt64(out ulong result)) + throw Unsupported(); + return result; + } + + private static async Task InvokePackageAsync( + NativeGatewayPackage package, + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var start = new ProcessStartInfo(package.ClawCtlAliasPath) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (string argument in arguments) + start.ArgumentList.Add(argument); + using Process process = Process.Start(start) + ?? throw new InvalidOperationException("The Gateway package's clawctl alias could not be started."); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(3)); + Task output = process.StandardOutput.ReadToEndAsync(timeout.Token); + Task error = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); + string stdout = await output.ConfigureAwait(false); + _ = await error.ConfigureAwait(false); + return new NativeGatewayCommandResult(process.ExitCode, stdout); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + cancellationToken.ThrowIfCancellationRequested(); + throw new TimeoutException("The Gateway package did not respond within three minutes. Retry setup."); + } + } +} diff --git a/src/OpenClaw.Connection/NativeGateway/WindowsProcessSequenceSnapshot.cs b/src/OpenClaw.Connection/NativeGateway/WindowsProcessSequenceSnapshot.cs new file mode 100644 index 000000000..ec1c944ba --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/WindowsProcessSequenceSnapshot.cs @@ -0,0 +1,82 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; + +namespace OpenClaw.Connection.NativeGateway; + +// SystemBasicProcessInformation provides a PID-reuse-proof identity without opening +// the isolated agent's process handle, which its DACL denies to Companion. +internal static class WindowsProcessSequenceSnapshot +{ + private const int SystemBasicProcessInformation = 252; + private const int StatusInfoLengthMismatch = unchecked((int)0xC0000004); + private const int StatusInvalidInfoClass = unchecked((int)0xC0000003); + private const int FirstBufferSize = 256 * 1024; + private const int MaxBufferSize = 16 * 1024 * 1024; + private const int MinimumEntrySize = 48; + + public static IReadOnlyDictionary Capture() + { + if (!OperatingSystem.IsWindows() || IntPtr.Size != 8) + throw new NotSupportedException("Gateway listener sequence inspection requires 64-bit Windows."); + + for (int capacity = FirstBufferSize; capacity <= MaxBufferSize;) + { + IntPtr buffer = Marshal.AllocHGlobal(capacity); + try + { + int status = NtQuerySystemInformation( + SystemBasicProcessInformation, buffer, capacity, out int returned); + if (status == 0) + { + if (returned is < MinimumEntrySize or > MaxBufferSize || returned > capacity) + throw new InvalidDataException("Windows returned an invalid process sequence table size."); + byte[] bytes = new byte[returned]; + Marshal.Copy(buffer, bytes, 0, returned); + return Parse(bytes); + } + if (status == StatusInvalidInfoClass) + throw new NotSupportedException( + "Windows does not support isolated listener sequence inspection. Update Windows."); + if (status != StatusInfoLengthMismatch) + throw new InvalidOperationException( + $"Windows could not inspect process sequence numbers (NTSTATUS 0x{status:X8})."); + capacity = Math.Max(capacity * 2, returned); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + throw new InvalidDataException("The Windows process sequence table exceeds the bounded inspection size."); + } + + internal static IReadOnlyDictionary Parse(ReadOnlySpan bytes) + { + var sequences = new Dictionary(); + int offset = 0; + while (true) + { + if (offset > bytes.Length - MinimumEntrySize) + throw new InvalidDataException("The Windows process sequence table is truncated."); + + ReadOnlySpan entry = bytes[offset..]; + int next = BinaryPrimitives.ReadInt32LittleEndian(entry); + long processId = BinaryPrimitives.ReadInt64LittleEndian(entry[8..]); + ulong sequence = BinaryPrimitives.ReadUInt64LittleEndian(entry[24..]); + if (processId is > 0 and <= int.MaxValue && + (sequence == 0 || !sequences.TryAdd((int)processId, sequence))) + throw new InvalidDataException("The Windows process sequence table has an invalid identity."); + + if (next == 0) + return sequences; + if (next < MinimumEntrySize || next % 8 != 0 || next > bytes.Length - offset) + throw new InvalidDataException("The Windows process sequence table has an invalid entry offset."); + offset += next; + } + } + + [DllImport("ntdll.dll")] + private static extern int NtQuerySystemInformation( + int informationClass, IntPtr buffer, int length, out int returnedLength); +} diff --git a/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs b/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs index 969e091d9..c46969d87 100644 --- a/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs +++ b/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs @@ -31,7 +31,7 @@ internal static async Task AuthorizeAsync( } return new(false, - provenance.Kind == GatewayEndpointProvenanceKind.NoListener + provenance.Kind == GatewayEndpointProvenanceKind.NoListener || IsInspectionUnavailable(provenance) ? GatewayErrorKind.Network : GatewayErrorKind.LocalPortConflict, provenance.Detail ?? "Native Gateway endpoint ownership could not be verified. Credentials were not sent."); @@ -42,13 +42,25 @@ internal static async Task AuthorizeAsync( } catch (NativeGatewayListenerException ex) { - return new(false, GatewayErrorKind.LocalPortConflict, + return new(false, + IsInspectionUnavailable(ex.Provenance) + ? GatewayErrorKind.Network + : GatewayErrorKind.LocalPortConflict, ex.Provenance.Detail ?? ex.Message); } + catch (NativeGatewayContractException ex) + { + return new(false, GatewayErrorKind.Network, 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."); } + } + + internal static bool IsInspectionUnavailable(GatewayEndpointProvenance provenance) => + provenance.FailureReason is GatewayEndpointProvenanceFailureReason.ProcessIdentityUnavailable + or GatewayEndpointProvenanceFailureReason.InspectionUnavailable; } diff --git a/src/OpenClaw.SetupEngine.UI/NativeGatewaySetupHost.cs b/src/OpenClaw.SetupEngine.UI/NativeGatewaySetupHost.cs index dc458bcaa..115ca1a19 100644 --- a/src/OpenClaw.SetupEngine.UI/NativeGatewaySetupHost.cs +++ b/src/OpenClaw.SetupEngine.UI/NativeGatewaySetupHost.cs @@ -9,6 +9,23 @@ internal sealed class NativeGatewaySetupHost( { // Includes packaged Node/CLI startup, not just the Gateway RPC timeout. private static readonly TimeSpan PairingCommandTimeout = TimeSpan.FromMinutes(2); + private readonly NativeGatewayPackageClient _packageClient = new(); + + public Task DetectContractAsync( + NativeGatewayPackage package, CancellationToken cancellationToken) => + _packageClient.DetectAsync(package, cancellationToken); + + public Task PrepareIsolatedConfigurationAsync( + NativeGatewayPackage package, int port, CancellationToken cancellationToken) => + _packageClient.PrepareAsync(package, port, cancellationToken); + + public Task ApplyIsolatedCapabilitiesAsync( + NativeGatewayPackage package, IReadOnlyList commandIds, CancellationToken cancellationToken) => + RunAsync(package.OpenClawAliasPath, + ["config", "set", ConfigureGatewayStep.NodeCommandsAllowKey, + System.Text.Json.JsonSerializer.Serialize(commandIds), "--strict-json"], + new Dictionary(), TimeSpan.FromMinutes(2), + "Applying Windows node capabilities inside the isolated Gateway...", cancellationToken); public void ReportProgress(NativeGatewaySetupStage stage) { @@ -24,6 +41,16 @@ public void ReportProgress(NativeGatewaySetupStage stage) public IDisposable OpenRecoveryTerminal( NativeGatewayPackage package, IReadOnlyDictionary environment) { + if (package.Contract == NativeGatewayContract.IsolatedSessionV1) + { + var terminal = new ProcessStartInfo(package.ClawCtlAliasPath) + { + UseShellExecute = true + }; + terminal.ArgumentList.Add("pwsh"); + return new RecoveryTerminal(Process.Start(terminal) + ?? throw new InvalidOperationException("The isolated Gateway terminal could not be opened.")); + } var start = new ProcessStartInfo { FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), @@ -58,9 +85,16 @@ public void Dispose() public Task PreparePackageAsync( NativeGatewayPackage package, IReadOnlyDictionary environment, - CancellationToken cancellationToken) => - RunAsync(package.ClawCtlAliasPath, ["setup"], environment, + CancellationToken cancellationToken) + { + if (package.Contract == NativeGatewayContract.IsolatedSessionV1) + { + progress?.Invoke("Preparing the isolated Gateway session..."); + return _packageClient.SetupAsync(package, cancellationToken); + } + return RunAsync(package.ClawCtlAliasPath, ["setup"], environment, TimeSpan.FromMinutes(3), "Preparing the packaged Gateway runtime...", cancellationToken); + } public Task ValidateConfigurationAsync( NativeGatewayPackage package, @@ -76,17 +110,29 @@ public Task VerifyHealthAsync( RunAsync(package.OpenClawAliasPath, ["gateway", "health", "--json"], environment, TimeSpan.FromMinutes(2), "Checking native Gateway health...", cancellationToken); + public Task CheckIsolatedPairingConfigurationAsync( + NativeGatewayPackage package, CancellationToken cancellationToken) => + _packageClient.CheckAsync(package, 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); + package.Contract == NativeGatewayContract.IsolatedSessionV1 + ? RunAsync(package.OpenClawAliasPath, ["devices", "list", "--json"], + new Dictionary(), PairingCommandTimeout, + "Verifying this Companion's pairing request...", 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); + package.Contract == NativeGatewayContract.IsolatedSessionV1 + ? RunAsync(package.OpenClawAliasPath, ["devices", "approve", requestId, "--json"], + new Dictionary(), PairingCommandTimeout, + "Pairing this Companion with its setup Gateway...", 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, @@ -97,7 +143,9 @@ private async Task RunAsync( using var logger = new SetupLogger(filePath: null); var result = await new CommandRunner(logger).RunAsync( executable, arguments, timeout, environment, - workingDirectory: environment["OPENCLAW_STATE_DIR"], + workingDirectory: environment.TryGetValue("OPENCLAW_STATE_DIR", out var directory) + ? directory + : null, ct: cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (result.TimedOut || result.ExitCode != 0) diff --git a/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs index 7a14ac9f0..f9ff76fd1 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs @@ -16,6 +16,7 @@ public sealed partial class NativeGatewaySetupPage : Page private readonly NativeGatewayMsixInstaller _installer = new(); private CancellationTokenSource? _operationCts; private Task? _operation; + private NativeGatewaySetupService? _setupService; private readonly List _rows = []; private int _currentStep; internal bool IsBusy => _operation is { IsCompleted: false }; @@ -59,6 +60,48 @@ private async Task RunOperationAsync(CancellationToken cancellationToken) { await ConfigureAsync(cancellationToken); } + catch (NativeGatewaySetupService.NativeGatewayDraftRecoveryRequiredException ex) + { + var discard = await new ContentDialog + { + XamlRoot = XamlRoot, + Title = SetupLocalization.GetString("Onboarding_Native_ReplaceDraftTitle"), + Content = SetupLocalization.GetString("Onboarding_Native_ReplaceDraftContent"), + PrimaryButtonText = SetupLocalization.GetString("Onboarding_Native_ReplaceDraftPrimary"), + CloseButtonText = SetupLocalization.GetString("Onboarding_Native_ReplaceDraftClose"), + DefaultButton = ContentDialogButton.Close, + }.ShowAsync(); + if (discard == ContentDialogResult.Primary) + { + try + { + await (_setupService ?? throw new InvalidOperationException("Native Gateway setup is unavailable.")) + .DiscardIncompatibleDraftAsync(cancellationToken); + await ConfigureAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _rows[_currentStep].SetStatus(StepStatus.Idle); + StatusText.Text = SetupLocalization.GetString("Onboarding_Native_Cancelled"); + RetryButton.Visibility = Visibility.Visible; + } + catch (Exception discardFailure) when (discardFailure is InvalidOperationException or IOException or + UnauthorizedAccessException or Win32Exception or COMException or + JsonException or TimeoutException or AggregateException) + { + Trace.TraceError($"Native Gateway draft discard: {discardFailure}"); + _rows[_currentStep].SetStatus(StepStatus.Failed); + StatusText.Text = SetupLogger.Sanitize(discardFailure.Message); + RetryButton.Visibility = Visibility.Visible; + } + } + else + { + _rows[_currentStep].SetStatus(StepStatus.Failed); + StatusText.Text = ex.Message; + RetryButton.Visibility = Visibility.Visible; + } + } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { _rows[_currentStep].SetStatus(StepStatus.Idle); @@ -115,11 +158,11 @@ void ReportStage(NativeGatewaySetupStage stage) => DispatchProgress(() => if (stage is NativeGatewaySetupStage.StartingGateway or NativeGatewaySetupStage.VerifyingEndpoint) SetCurrentStep(3); }); - var service = new NativeGatewaySetupService( + var service = _setupService = new NativeGatewaySetupService( registry, _resolver, new NativeGatewaySetupHost(ReportProgress, ReportStage), - () => new NativeGatewayRuntime(registry, _resolver, appLogger)); + () => NativeGatewayRuntimeRouter.Create(registry, _resolver, appLogger)); window.NativeSetupDraft = await service.CreateDraftAsync(cancellationToken); StatusText.Text = SetupLocalization.GetString("Onboarding_Native_InProgress"); var session = await service.PrepareAsync(window.NativeSetupDraft, cancellationToken); diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml index 1db67e466..d3552f838 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml @@ -81,7 +81,7 @@ + + +