From 9c65f5944058173cc6c0b446a2a2fba94c4acadd Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 29 Sep 2026 18:55:23 -0700 Subject: [PATCH 1/5] fix: connect Companion to the isolated Gateway package Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ARCHITECTURE.md | 151 ++++++--- docs/GATEWAY_SETUP_RESPONSIBILITIES.md | 20 +- docs/ONBOARDING_WIZARD.md | 159 +++++---- docs/SETUP_ENGINE_REDESIGN.md | 2 +- .../GatewayEndpointProvenance.cs | 1 + src/OpenClaw.Connection/GatewayRecord.cs | 4 + .../NativeGateway/IsolatedGatewayRuntime.cs | 317 ++++++++++++++++++ .../NativeGateway/NativeGatewayContracts.cs | 26 +- .../NativeGatewayPackageClient.cs | 303 +++++++++++++++++ .../WindowsProcessSequenceSnapshot.cs | 82 +++++ .../NativeGatewayEndpointSecurity.cs | 9 +- .../NativeGatewaySetupHost.cs | 62 +++- .../Pages/NativeGatewaySetupPage.xaml.cs | 2 +- .../Pages/WelcomePage.xaml | 2 +- .../Pages/WizardPage.xaml.cs | 46 ++- .../WizardConsoleTail.cs | 146 +++++++- .../ApprovalRequestHelper.cs | 1 + .../NativeGatewaySetupService.cs | 69 +++- .../NativeGatewaySetupSession.cs | 74 +++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 +- .../Strings/en-us/Resources.resw | 8 +- .../Strings/fr-fr/Resources.resw | 8 +- .../Strings/nl-nl/Resources.resw | 8 +- .../Strings/pt-br/Resources.resw | 8 +- .../Strings/zh-cn/Resources.resw | 8 +- .../Strings/zh-tw/Resources.resw | 8 +- .../IsolatedGatewayRuntimeTests.cs | 223 ++++++++++++ .../NativeGatewayPackageClientTests.cs | 149 ++++++++ .../NativeGatewayRecordTests.cs | 10 +- .../WindowsProcessSequenceSnapshotTests.cs | 40 +++ .../ApprovalRequestHelperTests.cs | 2 + .../NativeGatewaySetupTests.cs | 132 ++++++++ .../NativeGatewaySetupUxContractTests.cs | 114 ------- .../WizardConsoleTailTests.cs | 182 ++++++++++ 34 files changed, 2100 insertions(+), 281 deletions(-) create mode 100644 src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs create mode 100644 src/OpenClaw.Connection/NativeGateway/NativeGatewayPackageClient.cs create mode 100644 src/OpenClaw.Connection/NativeGateway/WindowsProcessSequenceSnapshot.cs create mode 100644 tests/OpenClaw.Connection.Tests/IsolatedGatewayRuntimeTests.cs create mode 100644 tests/OpenClaw.Connection.Tests/NativeGatewayPackageClientTests.cs create mode 100644 tests/OpenClaw.Connection.Tests/WindowsProcessSequenceSnapshotTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce43e3c8c..fef0a776f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -132,9 +132,11 @@ 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. @@ -156,8 +158,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 +180,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 +205,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 +218,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 +255,29 @@ 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. + +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 +288,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 +367,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 +378,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 +426,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..ce0bdeffb 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -60,9 +60,9 @@ 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) +### Native Gateway MSIX (isolated or legacy) -**Package-aware verification (2026-09-16, package 0.0.0.1 ARM64):** the original +**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 +93,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 +117,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 +148,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 +257,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 +326,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/GatewayEndpointProvenance.cs b/src/OpenClaw.Connection/GatewayEndpointProvenance.cs index 4d18c2fe9..4e87ab20b 100644 --- a/src/OpenClaw.Connection/GatewayEndpointProvenance.cs +++ b/src/OpenClaw.Connection/GatewayEndpointProvenance.cs @@ -27,6 +27,7 @@ public enum GatewayEndpointProvenanceFailureReason { None, ListenerSnapshotChanged, + ProcessIdentityUnavailable, } /// 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..1677b40d6 --- /dev/null +++ b/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs @@ -0,0 +1,317 @@ +using System.Net; +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 GatewayRecord? _record; + private NativeGatewayPackage? _package; + private IsolatedGatewayStatus? _proof; + private bool _startedHere; + private bool _disposed; + + 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) + { + _resolver = resolver; + _client = client; + _capture = capture; + _captureSequences = captureSequences; + _aliasExists = aliasExists; + } + + public async Task EnsureRunningAsync(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); + _package = package; + IsolatedGatewayStatus status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); + if (status.State is "not-started" or "stopped") + { + _proof = null; + var conflict = InspectSnapshot(endpoint.Port, null); + if (conflict.Kind != GatewayEndpointProvenanceKind.NoListener) + throw new NativeGatewayListenerException(conflict); + _startedHere = true; + 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); + _record = record; + _proof = status; + } + catch + { + _proof = null; + if (_startedHere && _package is not null) + { + await _client.StopAsync(_package, CancellationToken.None).ConfigureAwait(false); + _startedHere = false; + } + throw; + } + finally + { + _gate.Release(); + } + } + + public async Task InspectAsync( + 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); + if (status.State != "running" || status.Port != endpoint.Port) + { + _proof = null; + return InspectSnapshot(endpoint.Port, null); + } + var provenance = InspectSnapshot(endpoint.Port, status); + _proof = provenance.Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway ? status : null; + _record = record; + _package = package; + return provenance; + } + finally + { + _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 _record?.Id == record.Id && + _record.NativePackageFamilyName == record.NativePackageFamilyName && + GatewayRecordEditing.AreEquivalentLoopbackEndpoints(_record.Url, record.Url) && + _package is not null && _aliasExists(_package.ClawCtlAliasPath) + ? InspectSnapshot(endpoint.Port, _proof) + : 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 + { + _proof = null; + if (_startedHere && _package is not null) + { + NativeGatewayPackage package = await _resolver.ResolveAsync( + _package.PackageFamilyName, cancellationToken).ConfigureAwait(false); + await _client.StopAsync(package, cancellationToken).ConfigureAwait(false); + _startedHere = false; + } + } + finally + { + _gate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) + return; + _disposed = true; + _proof = null; + if (_startedHere && _package is not null) + { + NativeGatewayPackage package = await _resolver.ResolveAsync( + _package.PackageFamilyName, CancellationToken.None).ConfigureAwait(false); + await _client.StopAsync(package, CancellationToken.None).ConfigureAwait(false); + _startedHere = false; + } + _record = null; + _package = null; + } + 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. " + + "Set up the isolated Gateway again instead of sending it this profile's credentials."); + _active = legacy; + await legacy.EnsureRunningAsync(record, cancellationToken).ConfigureAwait(false); + } + + public Task InspectAsync( + GatewayRecord record, CancellationToken cancellationToken) => + Select(record).InspectAsync(record, cancellationToken); + + public GatewayEndpointProvenance Inspect(GatewayRecord record) => Select(record).Inspect(record); + + public Task StopAsync(CancellationToken cancellationToken) => + _active?.StopAsync(cancellationToken) ?? Task.CompletedTask; + + public async ValueTask DisposeAsync() + { + await legacy.DisposeAsync().ConfigureAwait(false); + await isolated.DisposeAsync().ConfigureAwait(false); + } + + private INativeGatewayRuntime Select(GatewayRecord record) => + record.NativeRuntimeContract == NativeGatewayPackageClient.IsolatedContract + ? isolated + : record.NativeRuntimeContract is null && ReferenceEquals(_active, legacy) + ? legacy + : throw new InvalidOperationException("Gateway ownership has not been established for this profile."); +} diff --git a/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs b/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs index 52aa6ab4a..89d926264 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 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..210ba39d9 100644 --- a/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs +++ b/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs @@ -42,9 +42,16 @@ internal static async Task AuthorizeAsync( } catch (NativeGatewayListenerException ex) { - return new(false, GatewayErrorKind.LocalPortConflict, + return new(false, + ex.Provenance.FailureReason == GatewayEndpointProvenanceFailureReason.ProcessIdentityUnavailable + ? 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, 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..e3220e7b1 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs @@ -119,7 +119,7 @@ void ReportStage(NativeGatewaySetupStage stage) => DispatchProgress(() => 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 @@ + void AppendMessage(string message) { try { @@ -1374,7 +1385,32 @@ private void StartConsoleTail() catch { } - }); + } + void ReportIssue(GatewayLogTailIssue issue) => AppendMessage(SetupLocalization.GetString(issue switch + { + GatewayLogTailIssue.Skipped => "Onboarding_Wizard_GatewayConsoleGap", + GatewayLogTailIssue.Unavailable => "Onboarding_Wizard_GatewayConsoleUnavailable", + _ => throw new ArgumentOutOfRangeException(nameof(issue)) + })); + if (!isolated) + { + tail.Start(AppendMessage); + return; + } + try + { + await tail.StartGatewayAsync(AppendMessage, ReportIssue, _nativeSession!.LifetimeToken); + } + catch (OperationCanceledException) when (_nativeSession?.LifetimeToken.IsCancellationRequested == true) + { + throw; + } + catch (Exception ex) + { + Trace.TraceWarning($"Isolated Gateway console output could not start ({ex.GetType().Name})."); + StopConsoleTail(); + AppendConsoleLine(SetupLocalization.GetString("Onboarding_Wizard_GatewayConsoleUnavailable")); + } } private void StopConsoleTail() diff --git a/src/OpenClaw.SetupEngine.UI/WizardConsoleTail.cs b/src/OpenClaw.SetupEngine.UI/WizardConsoleTail.cs index 2c69cf909..b1353eb9a 100644 --- a/src/OpenClaw.SetupEngine.UI/WizardConsoleTail.cs +++ b/src/OpenClaw.SetupEngine.UI/WizardConsoleTail.cs @@ -6,9 +6,15 @@ namespace OpenClaw.SetupEngine.UI; +internal enum GatewayLogTailIssue +{ + Skipped, + Unavailable +} + /// -/// Tails the OpenClaw gateway log running inside WSL and emits a callback for -/// every line that the upstream openclaw wizard plugins wrote via +/// Tails the WSL or legacy Gateway log, or the isolated Gateway's authenticated +/// logs.tail RPC, and emits a callback for every line that wizard plugins wrote via /// console.log. Workaround for an upstream bug: plugins emit /// user-critical content (OAuth URLs, install fallback messages) to gateway /// stdout instead of as a wizard.payload WS frame, leaving the tray UI @@ -31,16 +37,29 @@ internal sealed class WizardConsoleTail : IDisposable private readonly object _stateLock = new(); private Process? _process; private readonly string? _nativeLogPath; + private readonly Func>? _gatewayLogTail; private CancellationTokenSource? _nativeTailCancellation; public WizardConsoleTail(IOpenClawLogger? logger = null, string? distroNameOverride = null, - string? nativeLogPath = null) + string? nativeLogPath = null, + Func>? gatewayLogTail = null) { _logger = logger ?? NullLogger.Instance; _distroName = distroNameOverride ?? DefaultDistroName; _nativeLogPath = nativeLogPath; + _gatewayLogTail = gatewayLogTail; } + internal static Func> CreateGatewayLogReader( + Func> send) => + (cursor, cancellationToken) => + { + object parameters = cursor is long position + ? new { cursor = position, limit = 128, maxBytes = 64 * 1024 } + : new { limit = 1, maxBytes = 1 }; + return send("logs.tail", parameters, 10_000).WaitAsync(cancellationToken); + }; + /// /// Starts tailing in the background. is invoked /// once per console.log line emitted by the upstream openclaw runtime. @@ -50,6 +69,8 @@ public WizardConsoleTail(IOpenClawLogger? logger = null, string? distroNameOverr public void Start(Action onMessage) { ArgumentNullException.ThrowIfNull(onMessage); + if (_gatewayLogTail is not null) + throw new InvalidOperationException("The isolated Gateway requires StartGatewayAsync."); Stop(); if (_nativeLogPath is not null) { @@ -126,6 +147,125 @@ public void Start(Action onMessage) } } + public async Task StartGatewayAsync( + Action onMessage, + Action onIssue, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(onMessage); + ArgumentNullException.ThrowIfNull(onIssue); + if (_gatewayLogTail is null) + throw new InvalidOperationException("No isolated Gateway log source was configured."); + Stop(); + _nativeTailCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + try + { + // Anchor before wizard.start so old OAuth prompts cannot reappear on retry. + var initial = ParseGatewayLogTail( + await _gatewayLogTail(null, _nativeTailCancellation.Token), + previousCursor: null); + _ = TailGatewayLogAsync(initial.File, initial.Size, onMessage, onIssue, + _nativeTailCancellation.Token); + } + catch + { + Stop(); + throw; + } + } + + private async Task TailGatewayLogAsync( + string file, + long cursor, + Action onMessage, + Action onIssue, + CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var batch = ParseGatewayLogTail( + await _gatewayLogTail!(cursor, cancellationToken), cursor); + cancellationToken.ThrowIfCancellationRequested(); + if (!string.Equals(file, batch.File, StringComparison.Ordinal)) + { + onIssue(GatewayLogTailIssue.Skipped); + file = batch.File; + cursor = batch.Size; + await Task.Delay(500, cancellationToken); + continue; + } + if (batch.Skipped) + onIssue(GatewayLogTailIssue.Skipped); + foreach (string message in batch.Messages) + onMessage(message); + cursor = batch.Cursor; + await Task.Delay(500, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception ex) + { + _logger.Warn($"Isolated Gateway console tail stopped ({ex.GetType().Name})."); + onIssue(GatewayLogTailIssue.Unavailable); + } + } + + internal static GatewayConsoleBatch ParseGatewayLogTail(JsonElement payload, long? previousCursor) + { + if (payload.ValueKind != JsonValueKind.Object || + !payload.TryGetProperty("file", out var fileValue) || + fileValue.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(fileValue.GetString()) || + !payload.TryGetProperty("cursor", out var cursorValue) || + cursorValue.ValueKind != JsonValueKind.Number || + !cursorValue.TryGetInt64(out long cursor) || + !payload.TryGetProperty("size", out var sizeValue) || + sizeValue.ValueKind != JsonValueKind.Number || + !sizeValue.TryGetInt64(out long size) || + cursor < 0 || size < cursor || + !payload.TryGetProperty("lines", out var lines) || + lines.ValueKind != JsonValueKind.Array || + lines.GetArrayLength() > 128) + throw new InvalidDataException("The Gateway returned an invalid bounded log tail."); + bool reset = false; + if (payload.TryGetProperty("reset", out var resetValue)) + { + if (resetValue.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new InvalidDataException("The Gateway log tail has an invalid reset flag."); + reset = resetValue.GetBoolean(); + } + if (previousCursor is long previous && cursor < previous && !reset) + throw new InvalidDataException("The Gateway log cursor moved backward without a reset."); + bool skipped = false; + if (payload.TryGetProperty("truncated", out var truncated)) + { + if (truncated.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new InvalidDataException("The Gateway log tail has an invalid truncation flag."); + skipped = truncated.GetBoolean(); + } + if (payload.TryGetProperty("skippedBytes", out var skippedBytes)) + { + if (skippedBytes.ValueKind != JsonValueKind.Number || + !skippedBytes.TryGetInt64(out long count) || count < 0) + throw new InvalidDataException("The Gateway log tail has an invalid skipped-byte count."); + skipped |= count > 0; + } + var messages = new List(); + foreach (var line in lines.EnumerateArray()) + { + if (line.ValueKind != JsonValueKind.String) + throw new InvalidDataException("The Gateway log tail contains a non-text entry."); + if (TryExtractConsoleMessage(line.GetString()) is { } message) + messages.Add(message); + } + return new GatewayConsoleBatch(fileValue.GetString()!, cursor, size, messages, skipped); + } + + internal sealed record GatewayConsoleBatch( + string File, long Cursor, long Size, IReadOnlyList Messages, bool Skipped); + public void Stop() { _nativeTailCancellation?.Cancel(); diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs index 603b48df5..04b3f8b9e 100644 --- a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -15,6 +15,7 @@ internal static partial class ApprovalRequestHelper internal static bool IsSafeRequestId(string? requestId) => !string.IsNullOrWhiteSpace(requestId) + && !string.Equals(requestId.Trim(), "latest", StringComparison.OrdinalIgnoreCase) && SafeRequestIdPattern().IsMatch(requestId.Trim()); internal static string ApprovalCommand(ApprovalRequestKind kind) diff --git a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs index fd8a8e7e5..2dc0f1a7c 100644 --- a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs +++ b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs @@ -15,6 +15,19 @@ public interface INativeGatewaySetupHost { void ReportProgress(NativeGatewaySetupStage stage) { } + Task DetectContractAsync( + NativeGatewayPackage package, CancellationToken cancellationToken); + + Task PrepareIsolatedConfigurationAsync( + NativeGatewayPackage package, int port, CancellationToken cancellationToken); + + Task ApplyIsolatedCapabilitiesAsync( + NativeGatewayPackage package, IReadOnlyList commandIds, + CancellationToken cancellationToken); + + Task CheckIsolatedPairingConfigurationAsync( + NativeGatewayPackage package, CancellationToken cancellationToken); + Task ListDevicePairingRequestsAsync( NativeGatewayPackage package, IReadOnlyDictionary environment, CancellationToken cancellationToken); @@ -47,6 +60,7 @@ public sealed record NativeGatewaySetupDraft(string GatewayId, int Port, string { // Durable intent makes a port-only config/descriptor update recoverable across a crash. public int? PreviousPort { get; init; } + public NativeGatewayContract Contract { get; init; } = NativeGatewayContract.Legacy; } /// @@ -62,6 +76,7 @@ public sealed class NativeGatewaySetupService( public async Task CreateDraftAsync(CancellationToken cancellationToken) { var package = await packageResolver.ResolveAsync(cancellationToken); + var contract = await host.DetectContractAsync(package, cancellationToken); var draftPath = GetDraftPath(registry); if (File.Exists(draftPath)) { @@ -76,11 +91,20 @@ await File.ReadAllTextAsync(draftPath, cancellationToken)) { if (saved.PackageFamilyName != package.PackageFamilyName) throw new InvalidOperationException("The installed Gateway package does not match the saved setup profile."); - return ResumeDraft(saved, draftPath); + if (saved.Contract != contract) + throw new InvalidOperationException( + "The saved native setup draft belongs to a different Gateway runtime. " + + "Finish or discard that draft before starting a new setup."); + return contract == NativeGatewayContract.IsolatedSessionV1 + ? saved + : ResumeDraft(saved, draftPath); } } var port = SelectAvailablePort(); - var draft = new NativeGatewaySetupDraft(Guid.NewGuid().ToString("N"), port, package.PackageFamilyName); + var draft = new NativeGatewaySetupDraft(Guid.NewGuid().ToString("N"), port, package.PackageFamilyName) + { + Contract = contract + }; Directory.CreateDirectory(Path.GetDirectoryName(draftPath)!); AtomicFile.WriteAllText(draftPath, JsonSerializer.Serialize(draft)); return draft; @@ -172,6 +196,47 @@ public async Task PrepareAsync( var package = await packageResolver.ResolveAsync(cancellationToken); if (!string.Equals(package.PackageFamilyName, draft.PackageFamilyName, StringComparison.Ordinal)) throw new InvalidOperationException("The installed Gateway package changed. Return to setup and check it again."); + var contract = await host.DetectContractAsync(package, cancellationToken); + if (contract != draft.Contract) + throw new InvalidOperationException( + "The installed Gateway runtime changed during setup. Retry after checking the package."); + package = package with { Contract = contract }; + + if (contract == NativeGatewayContract.IsolatedSessionV1) + { + await host.PreparePackageAsync(package, new Dictionary(), cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + IsolatedGatewayConfiguration configured = await host.PrepareIsolatedConfigurationAsync( + package, draft.Port, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var selected = draft with { Port = configured.Port }; + if (selected.Port != draft.Port) + AtomicFile.WriteAllText(GetDraftPath(registry), JsonSerializer.Serialize(selected)); + var isolatedRecord = new GatewayRecord + { + Id = selected.GatewayId, + Url = $"ws://127.0.0.1:{selected.Port}", + FriendlyName = "Native Gateway", + IsLocal = true, + RequiresV2Signature = true, + SharedGatewayToken = configured.Token, + NativePackageFamilyName = package.PackageFamilyName, + NativeRuntimeContract = NativeGatewayPackageClient.IsolatedContract + }; + var isolatedSession = new NativeGatewaySetupSession( + registry, selected, isolatedRecord, package, + new Dictionary(), host, runtimeFactory()); + try + { + await isolatedSession.PrepareAsync(cancellationToken); + return isolatedSession; + } + catch + { + await isolatedSession.DisposeAsync(); + throw; + } + } var stateDirectory = NativeGatewayPaths.GetStateDirectory(registry, draft.GatewayId); var configPath = NativeGatewayPaths.GetConfigPath(registry, draft.GatewayId); diff --git a/src/OpenClaw.SetupEngine/NativeGatewaySetupSession.cs b/src/OpenClaw.SetupEngine/NativeGatewaySetupSession.cs index b1e3f1faf..7717651c9 100644 --- a/src/OpenClaw.SetupEngine/NativeGatewaySetupSession.cs +++ b/src/OpenClaw.SetupEngine/NativeGatewaySetupSession.cs @@ -27,6 +27,7 @@ public sealed class NativeGatewaySetupSession( private bool _published; private IDisposable? _terminal; public GatewayRecord Record { get; private set; } = record; + public bool IsIsolated => draft.Contract == NativeGatewayContract.IsolatedSessionV1; public string IdentityDirectory => registry.GetIdentityDirectory(draft.GatewayId); public string ConsoleLogPath => Path.Combine( NativeGatewayPaths.GetStateDirectory(registry, draft.GatewayId), "wizard-console.log"); @@ -63,7 +64,8 @@ internal async Task PrepareAsync(CancellationToken cancellationToken) await _gate.WaitAsync(cancellationToken); try { - SuspendReload(); + if (!IsIsolated) + SuspendReload(); await host.ValidateConfigurationAsync(package, environment, cancellationToken); await AuthorizeCoreAsync(cancellationToken); } @@ -92,18 +94,20 @@ public async Task ApproveWizardPairingAsync(string? requestId, CancellationToken { ObjectDisposedException.ThrowIf(_disposed, this); await AuthorizeCoreAsync(linked.Token); + await RequireIsolatedPairingConfigurationAsync(linked.Token); RequirePairingConfiguration(); var identity = new DeviceIdentity(IdentityDirectory); identity.Initialize(); - var approvalEnvironment = new Dictionary(environment) - { - // The CLI's local bootstrap requires a profile target, not --url. - // Prevent inherited target overrides from selecting another Gateway. - ["OPENCLAW_GATEWAY_URL"] = "", - ["OPENCLAW_GATEWAY_PORT"] = draft.Port.ToString(System.Globalization.CultureInfo.InvariantCulture), - ["OPENCLAW_GATEWAY_TOKEN"] = Record.SharedGatewayToken ?? - throw new InvalidOperationException("The setup Gateway credential is unavailable for pairing."), - }; + var approvalEnvironment = IsIsolated + ? new Dictionary() + : new Dictionary(environment) + { + // Legacy CLI bootstrap needs the dedicated profile and explicit target. + ["OPENCLAW_GATEWAY_URL"] = "", + ["OPENCLAW_GATEWAY_PORT"] = draft.Port.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["OPENCLAW_GATEWAY_TOKEN"] = Record.SharedGatewayToken ?? + throw new InvalidOperationException("The setup Gateway credential is unavailable for pairing."), + }; var normalizedId = requestId!.Trim(); var listing = await host.ListDevicePairingRequestsAsync( package, approvalEnvironment, linked.Token); @@ -111,6 +115,7 @@ public async Task ApproveWizardPairingAsync(string? requestId, CancellationToken listing, normalizedId, identity.DeviceId, identity.PublicKeyBase64Url); // Recheck after the CLI read and before issuing the exact approval. await AuthorizeCoreAsync(linked.Token); + await RequireIsolatedPairingConfigurationAsync(linked.Token); RequirePairingConfiguration(); var result = await host.ApproveDevicePairingAsync( package, normalizedId, approvalEnvironment, linked.Token); @@ -124,11 +129,27 @@ public async Task ApproveWizardPairingAsync(string? requestId, CancellationToken private void RequirePairingConfiguration() { + if (IsIsolated) + return; var configured = NativeGatewaySetupService.ReadConfiguredRecord(draft, File.ReadAllText(ConfigPath)); if (configured.SharedGatewayToken != Record.SharedGatewayToken) throw new InvalidOperationException("The setup Gateway credential changed. Restart setup before pairing."); } + private async Task RequireIsolatedPairingConfigurationAsync(CancellationToken cancellationToken) + { + if (!IsIsolated) + return; + IsolatedGatewayConfiguration configuration = + await host.CheckIsolatedPairingConfigurationAsync(package, cancellationToken); + if (configuration.Port != new Uri(Record.Url).Port || + !string.Equals(configuration.Token, Record.SharedGatewayToken, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The isolated Gateway port or token changed. Restart Companion setup before pairing."); + } + } + private async Task AuthorizeCoreAsync(CancellationToken cancellationToken) { // Never let loopback or an existing device token bypass package workload inspection. @@ -152,8 +173,11 @@ public async Task RestartAsync(CancellationToken cancellationToken) _terminal = null; await runtime.StopAsync(linked.Token); // Upstream may have changed configuration before an error. Validate before restart. - Record = NativeGatewaySetupService.ReadConfiguredRecord(draft, File.ReadAllText(ConfigPath)); - SuspendReload(); + if (!IsIsolated) + { + Record = NativeGatewaySetupService.ReadConfiguredRecord(draft, File.ReadAllText(ConfigPath)); + SuspendReload(); + } await host.ValidateConfigurationAsync(package, environment, linked.Token); await AuthorizeCoreAsync(linked.Token); } @@ -186,10 +210,25 @@ public async Task CompleteAsync( // Stop before touching configuration: the hosted wizard must no longer be writing. await runtime.StopAsync(linked.Token); linked.Token.ThrowIfCancellationRequested(); - RestoreReload(); - if (capabilities is not null) - ApplyCapabilities(capabilities); - Record = NativeGatewaySetupService.ReadConfiguredRecord(draft, File.ReadAllText(ConfigPath)); + if (IsIsolated) + { + if (capabilities is not null) + await host.ApplyIsolatedCapabilitiesAsync( + package, capabilities.GetEnabledCommandIds().ToArray(), linked.Token); + IsolatedGatewayConfiguration configured = + await host.CheckIsolatedPairingConfigurationAsync(package, linked.Token); + if (configured.Port != draft.Port || configured.Token != Record.SharedGatewayToken) + throw new InvalidOperationException( + "The isolated Gateway credential or port changed during setup. " + + "Restart setup before publishing this Companion connection."); + } + else + { + RestoreReload(); + if (capabilities is not null) + ApplyCapabilities(capabilities); + Record = NativeGatewaySetupService.ReadConfiguredRecord(draft, File.ReadAllText(ConfigPath)); + } await host.ValidateConfigurationAsync(package, environment, linked.Token); await AuthorizeCoreAsync(linked.Token); await host.VerifyHealthAsync(package, environment, linked.Token); @@ -298,7 +337,8 @@ public async ValueTask DisposeAsync() _terminal?.Dispose(); _terminal = null; await runtime.DisposeAsync(); - RestoreReload(); + if (!IsIsolated) + RestoreReload(); } finally { _gate.Release(); } } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 63ce8db28..ad2ccb6ba 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -830,8 +830,9 @@ _dispatcherQueue is null // SshTunnelService implements ISshTunnelManager directly — no shim needed var managedLocalPortProvenance = _managedLocalPortProvenance = new ManagedLocalGatewayPortProvenanceService(appLogger); - var nativeGatewayRuntime = new OpenClaw.Connection.NativeGateway.NativeGatewayRuntime( - _gatewayRegistry, new OpenClaw.SetupEngine.UI.NativeGatewayPackageResolver(), appLogger); + var nativeGatewayResolver = new OpenClaw.SetupEngine.UI.NativeGatewayPackageResolver(); + var nativeGatewayRuntime = OpenClaw.Connection.NativeGateway.NativeGatewayRuntimeRouter.Create( + _gatewayRegistry, nativeGatewayResolver, appLogger); InteractiveEndpointAuthorizer = new InteractiveGatewayEndpointAuthorizer( nativeGatewayRuntime, managedLocalPortProvenance.IsStrongCredentialAllowed, appLogger); _connectionManager = new GatewayConnectionManager( diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 3b4b14fad..44322efff 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -2402,6 +2402,12 @@ Use one of these options: Gateway disconnected + + Some Gateway console messages were skipped. Open the Gateway terminal for missing sign-in guidance. + + + Gateway console updates are unavailable. Open the Gateway terminal for provider sign-in guidance. + Lost connection to gateway. Click Next to skip the wizard, or wait for reconnection. @@ -7411,7 +7417,7 @@ Your settings, gateway, files, and models stay where they are. Faster Smarter Install a local native gateway - Install a local, MXC contained OpenClaw gateway + Install and set up an OpenClaw gateway on this device Install a local WSL gateway Checking native gateway support... Native gateway support is available on this device. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index e571f4a3c..1dc05bc97 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -2355,6 +2355,12 @@ Utilisez l'une de ces options : Passerelle déconnectée + + Certains messages de la console du Gateway ont été ignorés. Ouvrez le terminal du Gateway pour retrouver les instructions de connexion manquantes. + + + Les mises à jour de la console du Gateway sont indisponibles. Ouvrez le terminal du Gateway pour obtenir les instructions de connexion au fournisseur. + Connexion à la passerelle perdue. Cliquez sur Suivant pour ignorer l’assistant, ou attendez la reconnexion. @@ -7347,7 +7353,7 @@ Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place Plus rapide Plus approfondi Installer un Gateway natif local - Installer un Gateway OpenClaw local, confiné par MXC + Installer et configurer un Gateway OpenClaw sur cet appareil Installer un Gateway WSL local Vérification de la prise en charge du Gateway natif... Le Gateway natif est pris en charge sur cet appareil. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 091cca7ab..6c7d93a3d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -2356,6 +2356,12 @@ Gebruik een van deze opties: Gatewayverbinding verbroken + + Sommige Gateway-consoleberichten zijn overgeslagen. Open de Gateway-terminal voor ontbrekende aanmeldinstructies. + + + Updates van de Gateway-console zijn niet beschikbaar. Open de Gateway-terminal voor aanmeldinstructies voor de provider. + Verbinding met gateway verloren. Klik op Volgende om de wizard over te slaan of wacht op opnieuw verbinden. @@ -7348,7 +7354,7 @@ Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. Sneller Grondiger Een lokale native Gateway installeren - Een lokale OpenClaw Gateway installeren die door MXC wordt ingesloten + Een OpenClaw Gateway op dit apparaat installeren en instellen Een lokale WSL Gateway installeren Ondersteuning voor de native Gateway controleren... De native Gateway wordt ondersteund op dit apparaat. diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index dcad36e75..af5c3dd9f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -2401,6 +2401,12 @@ Use uma destas opções: Gateway desconectado + + Algumas mensagens do console do Gateway foram ignoradas. Abra o terminal do Gateway para ver as instruções de entrada ausentes. + + + As atualizações do console do Gateway não estão disponíveis. Abra o terminal do Gateway para ver as instruções de entrada no provedor. + Conexão com o Gateway perdida. Clique em Avançar para pular o assistente, ou aguarde a reconexão. @@ -7401,7 +7407,7 @@ Suas configurações, gateway, arquivos e modelos permanecem onde estão.Mais rápido Mais detalhado Instalar um Gateway nativo local - Instalar um Gateway OpenClaw local, contido pelo MXC + Instalar e configurar um Gateway OpenClaw neste dispositivo Instalar um Gateway WSL local Verificando o suporte ao Gateway nativo... O suporte ao Gateway nativo está disponível neste dispositivo. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 8556c1066..f25f2edfa 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -2355,6 +2355,12 @@ 网关已断开连接 + + 部分 Gateway 控制台消息已跳过。请打开 Gateway 终端查看缺失的登录说明。 + + + 无法获取 Gateway 控制台更新。请打开 Gateway 终端查看提供商登录说明。 + 与网关的连接已丢失。单击“下一步”跳过向导,或等待重新连接。 @@ -7347,7 +7353,7 @@ 更快 更深入 安装本地原生 Gateway - 安装受 MXC 约束的本地 OpenClaw Gateway + 在此设备上安装并配置 OpenClaw Gateway 安装本地 WSL Gateway 正在检查原生 Gateway 支持情况... 此设备支持原生 Gateway。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 89864c368..02f86d027 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -2355,6 +2355,12 @@ 閘道已中斷連線 + + 部分 Gateway 主控台訊息已略過。請開啟 Gateway 終端機查看遺漏的登入指示。 + + + 無法取得 Gateway 主控台更新。請開啟 Gateway 終端機查看提供者登入指示。 + 與閘道的連線已中斷。按一下「下一步」略過精靈,或等待重新連線。 @@ -7347,7 +7353,7 @@ 更快 更深入 安裝本機原生 Gateway - 安裝受 MXC 約束的本機 OpenClaw Gateway + 在此裝置上安裝並設定 OpenClaw Gateway 安裝本機 WSL Gateway 正在檢查原生 Gateway 支援情況... 此裝置支援原生 Gateway。 diff --git a/tests/OpenClaw.Connection.Tests/IsolatedGatewayRuntimeTests.cs b/tests/OpenClaw.Connection.Tests/IsolatedGatewayRuntimeTests.cs new file mode 100644 index 000000000..e7483f78b --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/IsolatedGatewayRuntimeTests.cs @@ -0,0 +1,223 @@ +using System.Net; +using System.Text.Json; +using OpenClaw.Connection.NativeGateway; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Tests; + +public sealed class IsolatedGatewayRuntimeTests : IAsyncDisposable +{ + private const int Port = 19001; + private const int ProcessId = 4321; + private const string Sid = "S-1-5-21-fixture"; + private const string Family = "OpenClaw.Gateway_123456789abcd"; + private static readonly DateTime s_startTime = + new(2026, 9, 29, 12, 0, 0, DateTimeKind.Utc); + + private readonly NativeGatewayPackage _package = FixturePackage(); + private readonly GatewayRecord _record = new() + { + Id = "gw-isolated", + Url = $"ws://127.0.0.1:{Port}", + NativePackageFamilyName = Family, + NativeRuntimeContract = NativeGatewayPackageClient.IsolatedContract, + SharedGatewayToken = "fixture-token", + IsLocal = true + }; + private readonly List _calls = []; + private readonly IsolatedGatewayRuntime _runtime; + private WindowsTcpListenerInfo[] _listeners = + [new(IPAddress.Loopback, Port, ProcessId, "fixture", null)]; + private string _state = "running"; + private bool _listenOnStart; + private ulong _hostSequence = 77; + + public IsolatedGatewayRuntimeTests() + { + var client = new NativeGatewayPackageClient((_, args, _) => + { + _calls.Add(string.Join(" ", args)); + if (args.SequenceEqual(["gateway-service", "start", "--json"])) + { + _state = "running"; + if (_listenOnStart) + _listeners = [new(IPAddress.Loopback, Port, ProcessId, "fixture", null)]; + } + if (args.SequenceEqual(["gateway-service", "stop", "--json"])) + { + _state = "stopped"; + _listeners = []; + } + object gateway = _state == "running" + ? new + { + state = _state, + port = Port, + ownership = new + { + sandboxId = "iso:fixture", + agentUserSid = Sid, + listeners = new[] { new { port = Port, processId = ProcessId, + processStartTimeUtc = s_startTime, sequenceNumber = (ulong)77 } } + } + } + : new { state = _state }; + return Task.FromResult(new NativeGatewayCommandResult(0, + JsonSerializer.Serialize(new + { + ok = true, + schemaVersion = 1, + command = string.Join(" ", args.Take(2)), + integration = new { kind = "isolated-session", version = 1 }, + gateway + }))); + }); + _runtime = new IsolatedGatewayRuntime(new Resolver(_package), client, + () => new WindowsTcpListenerSnapshotResult(_listeners, true, true), + () => new Dictionary { [ProcessId] = _hostSequence, [4567] = 88 }, + _ => true); + } + + [Fact] + public async Task PackageAttestationAndFreshListenerIdentityAuthorizeCredentials() + { + var authorizer = new InteractiveGatewayEndpointAuthorizer(_runtime, + (_, _) => throw new InvalidOperationException("Native credentials must not use a WSL fallback."), + NullLogger.Instance); + var credential = new GatewayCredential("fixture-token", false, CredentialResolver.SourceSharedGatewayToken); + Assert.False(authorizer.IsCredentialAllowed(_record, credential)); + + await _runtime.EnsureRunningAsync(_record, CancellationToken.None); + Assert.True(authorizer.IsCredentialAllowed(_record, credential)); + Assert.Equal(s_startTime, _runtime.Inspect(_record).ProcessStartTimeUtc); + Assert.Contains("gateway-service status --json", _calls); + Assert.DoesNotContain("gateway-service start --json", _calls); + + _listeners = [new(IPAddress.Loopback, Port, 4567, "unrelated", null)]; + Assert.False(authorizer.IsCredentialAllowed(_record, credential)); + Assert.Equal(GatewayEndpointProvenanceKind.UnknownListener, _runtime.Inspect(_record).Kind); + } + + [Fact] + public async Task AbsentServiceStartsThroughPackageAndStopsOnlyOwnedStart() + { + _state = "not-started"; + _listeners = []; + + await Assert.ThrowsAsync(() => + _runtime.EnsureRunningAsync(_record, CancellationToken.None)); + Assert.Contains("gateway-service start --json", _calls); + Assert.Contains("gateway-service stop --json", _calls); + } + + [Fact] + public async Task FreshServiceStartsAndStopsThroughThePackage() + { + _state = "not-started"; + _listeners = []; + _listenOnStart = true; + + await _runtime.EnsureRunningAsync(_record, CancellationToken.None); + Assert.Equal(GatewayEndpointProvenanceKind.ExpectedManagedGateway, + (await _runtime.InspectAsync(_record, CancellationToken.None)).Kind); + await _runtime.StopAsync(CancellationToken.None); + + Assert.Contains("gateway-service start --json", _calls); + Assert.Contains("gateway-service stop --json", _calls); + } + + [Fact] + public async Task ExistingPackageServiceIsNotStoppedWhenCompanionDetaches() + { + await _runtime.EnsureRunningAsync(_record, CancellationToken.None); + + await _runtime.StopAsync(CancellationToken.None); + + Assert.DoesNotContain("gateway-service stop --json", _calls); + } + + [Fact] + public async Task ReusedPidCannotReuseCachedPackageOwnership() + { + await _runtime.EnsureRunningAsync(_record, CancellationToken.None); + + _hostSequence = 88; + + Assert.Equal(GatewayEndpointProvenanceKind.UnknownListener, _runtime.Inspect(_record).Kind); + } + + [Fact] + public async Task UnsupportedPackageContractProvidesActionableCredentialDenial() + { + var unsupported = new NativeGatewayPackageClient((_, _, _) => + Task.FromResult(new NativeGatewayCommandResult(0, + """{"ok":true,"schemaVersion":1,"command":"gateway-service status","integration":{"kind":"isolated-session","version":2},"gateway":{"state":"running","port":19001}}"""))); + await using var runtime = new IsolatedGatewayRuntime(new Resolver(_package), unsupported, + () => new WindowsTcpListenerSnapshotResult(_listeners, true, true), + () => new Dictionary { [ProcessId] = 77 }, _ => true); + + EndpointCredentialAuthorization authorization = await NativeGatewayEndpointSecurity.AuthorizeAsync( + runtime, _record, CancellationToken.None); + + Assert.False(authorization.Allowed); + Assert.Contains("Update the Gateway MSIX", authorization.Detail, StringComparison.Ordinal); + Assert.Equal(GatewayErrorKind.Network, authorization.FailureKind); + } + + [Fact] + public async Task MissingOsSequenceSupportReportsRecoveryInsteadOfPortConflict() + { + var client = new NativeGatewayPackageClient((_, _, _) => + Task.FromResult(new NativeGatewayCommandResult(0, + JsonSerializer.Serialize(new + { + ok = true, + schemaVersion = 1, + command = "gateway-service status", + integration = new { kind = "isolated-session", version = 1 }, + gateway = new + { + state = "running", + port = Port, + ownership = new + { + sandboxId = "iso:fixture", + agentUserSid = Sid, + listeners = new[] + { + new { port = Port, processId = ProcessId, + processStartTimeUtc = s_startTime, sequenceNumber = (ulong)77 } + } + } + } + })))); + await using var runtime = new IsolatedGatewayRuntime(new Resolver(_package), client, + () => new WindowsTcpListenerSnapshotResult(_listeners, true, true), + () => throw new NotSupportedException("Update Windows for process sequence inspection."), + _ => true); + + EndpointCredentialAuthorization authorization = + await NativeGatewayEndpointSecurity.AuthorizeAsync(runtime, _record, CancellationToken.None); + + Assert.False(authorization.Allowed); + Assert.Equal(GatewayErrorKind.Network, authorization.FailureKind); + Assert.Contains("Update Windows", authorization.Detail, StringComparison.Ordinal); + } + + public ValueTask DisposeAsync() => _runtime.DisposeAsync(); + + private static NativeGatewayPackage FixturePackage() + { + string aliases = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", "WindowsApps", Family); + return new NativeGatewayPackage(Family, "2026.9.5.5", + Path.Combine(aliases, "openclaw.exe"), Path.Combine(aliases, "clawctl.exe")); + } + + private sealed class Resolver(NativeGatewayPackage package) : INativeGatewayPackageResolver + { + public Task ResolveAsync(CancellationToken cancellationToken) => + Task.FromResult(package); + } +} diff --git a/tests/OpenClaw.Connection.Tests/NativeGatewayPackageClientTests.cs b/tests/OpenClaw.Connection.Tests/NativeGatewayPackageClientTests.cs new file mode 100644 index 000000000..fe2a693bd --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/NativeGatewayPackageClientTests.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using OpenClaw.Connection.NativeGateway; + +namespace OpenClaw.Connection.Tests; + +public sealed class NativeGatewayPackageClientTests +{ + private static readonly NativeGatewayPackage s_package = + new("OpenClaw.Gateway_123456789abcd", "2026.9.5.5", + @"C:\package\openclaw.exe", @"C:\package\clawctl.exe"); + + [Fact] + public async Task DetectAcceptsOnlyVersionedIsolatedContract() + { + var client = new NativeGatewayPackageClient((_, args, _) => + { + Assert.Equal(["status", "--json"], args); + return Task.FromResult(new NativeGatewayCommandResult(0, + """{"schemaVersion":1,"command":"status","integration":{"kind":"isolated-session","version":1}}""")); + }); + Assert.Equal(NativeGatewayContract.IsolatedSessionV1, + await client.DetectAsync(s_package, CancellationToken.None)); + + var unsupported = new NativeGatewayPackageClient((_, _, _) => + Task.FromResult(new NativeGatewayCommandResult(0, + """{"schemaVersion":1,"command":"status","session":{"state":"ready"}}"""))); + await Assert.ThrowsAsync( + () => unsupported.DetectAsync(s_package, CancellationToken.None)); + } + + [Fact] + public async Task KnownLegacyProofPackageDoesNotPretendToSupportIsolation() + { + var client = new NativeGatewayPackageClient((_, _, _) => + Task.FromResult(new NativeGatewayCommandResult(1, "unsupported --json"))); + + Assert.Equal(NativeGatewayContract.Legacy, + await client.DetectAsync(s_package with { Version = "0.0.0.1" }, CancellationToken.None)); + await Assert.ThrowsAsync( + () => client.DetectAsync(s_package, CancellationToken.None)); + await Assert.ThrowsAsync( + () => client.DetectAsync(s_package with { Version = "0.0.0.2" }, CancellationToken.None)); + } + + [Fact] + public async Task PrepareReturnsOnlyTheAgentsChosenPortAndToken() + { + var client = new NativeGatewayPackageClient((_, args, _) => + { + Assert.Equal(["companion", "prepare", "--port", "19001", "--json"], args); + return Task.FromResult(new NativeGatewayCommandResult(0, + """ + {"ok":true,"schemaVersion":1,"command":"companion prepare", + "integration":{"kind":"isolated-session","version":1}, + "companion":{"port":20123,"token":"fixture-token"}} + """)); + }); + + IsolatedGatewayConfiguration config = await client.PrepareAsync(s_package, 19001, CancellationToken.None); + + Assert.Equal(20123, config.Port); + Assert.Equal("fixture-token", config.Token); + } + + [Fact] + public async Task CheckUsesTheReadOnlyPrepareContract() + { + var client = new NativeGatewayPackageClient((_, args, _) => + { + Assert.Equal(["companion", "prepare", "--check", "--json"], args); + return Task.FromResult(new NativeGatewayCommandResult(0, + """ + {"ok":true,"schemaVersion":1,"command":"companion prepare", + "integration":{"kind":"isolated-session","version":1}, + "companion":{"port":20123,"token":"fixture-token"}} + """)); + }); + + IsolatedGatewayConfiguration config = await client.CheckAsync(s_package, CancellationToken.None); + + Assert.Equal(20123, config.Port); + Assert.Equal("fixture-token", config.Token); + } + + [Fact] + public async Task RunningWithoutOwnershipFailsClosedWithWindowsRecoveryGuidance() + { + var client = new NativeGatewayPackageClient((_, args, _) => + { + Assert.Equal(["gateway-service", "status", "--json"], args); + return Task.FromResult(new NativeGatewayCommandResult(0, + """ + {"ok":true,"schemaVersion":1,"command":"gateway-service status", + "integration":{"kind":"isolated-session","version":1}, + "gateway":{"state":"running","port":19001}} + """)); + }); + + NativeGatewayContractException error = await Assert.ThrowsAsync( + () => client.StatusAsync(s_package, CancellationToken.None)); + + Assert.Contains("did not send credentials", error.Message, StringComparison.Ordinal); + Assert.Contains("26100.4770", error.Message, StringComparison.Ordinal); + Assert.Contains("cannot add this Windows capability", error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(0, """{"ok":true,"schemaVersion":1,"command":"wrong","integration":{"kind":"isolated-session","version":1},"companion":{"port":20123,"token":"fixture-token"}}""")] + [InlineData(0, """{"ok":true,"schemaVersion":1,"command":"companion prepare","integration":{"kind":"isolated-session","version":1},"companion":{"port":"invalid","token":"fixture-token"}}""")] + [InlineData(1, """{"ok":false,"schemaVersion":1,"command":"companion prepare","integration":{"kind":"isolated-session","version":1},"error":{"message":"failed"}}""")] + public async Task CheckRejectsMismatchedMalformedAndFailedResponses(int exitCode, string response) + { + var client = new NativeGatewayPackageClient((_, _, _) => + Task.FromResult(new NativeGatewayCommandResult(exitCode, response))); + + await Assert.ThrowsAnyAsync( + () => client.CheckAsync(s_package, CancellationToken.None)); + } + + [Fact] + public async Task SetupRejectsAnIncompleteAgentSession() + { + var client = new NativeGatewayPackageClient((_, args, _) => + { + Assert.Equal(["setup", "--json"], args); + return Task.FromResult(new NativeGatewayCommandResult(0, + """{"ok":true,"schemaVersion":1,"command":"setup","integration":{"kind":"isolated-session","version":1},"session":{"state":"not-configured"}}""")); + }); + + await Assert.ThrowsAsync( + () => client.SetupAsync(s_package, CancellationToken.None)); + } + + [Theory] + [InlineData("""{"ok":true,"schemaVersion":1,"command":"gateway-service status","integration":{"kind":"isolated-session","version":1},"gateway":{"state":"running","port":19001}}""", typeof(NativeGatewayContractException))] + [InlineData("""{"ok":true,"schemaVersion":1,"command":"gateway-service status","integration":{"kind":"isolated-session","version":1},"gateway":{"state":"running","port":19001,"ownership":{"sandboxId":"iso:fixture","agentUserSid":"S-1-fixture","listeners":[]}}}""", typeof(InvalidOperationException))] + [InlineData("""{"ok":true,"schemaVersion":1,"command":"gateway-service status","integration":{"kind":"isolated-session","version":1},"gateway":{"state":"running","port":19001,"ownership":{"sandboxId":"iso:fixture","agentUserSid":"S-1-fixture","listeners":[{"port":19001,"processId":4321,"processStartTimeUtc":"2026-09-29T12:00:00Z"}]}}}""", typeof(NativeGatewayContractException))] + [InlineData("""{"ok":true,"schemaVersion":1,"command":"gateway-service status","integration":{"kind":"isolated-session","version":2},"gateway":{"state":"running","port":19001}}""", typeof(NativeGatewayContractException))] + public async Task StatusRejectsMissingAttributionOrUnsupportedVersion( + string response, Type expectedException) + { + var client = new NativeGatewayPackageClient((_, _, _) => + Task.FromResult(new NativeGatewayCommandResult(0, response))); + + Exception? error = await Record.ExceptionAsync( + () => client.StatusAsync(s_package, CancellationToken.None)); + Assert.Equal(expectedException, error?.GetType()); + } +} diff --git a/tests/OpenClaw.Connection.Tests/NativeGatewayRecordTests.cs b/tests/OpenClaw.Connection.Tests/NativeGatewayRecordTests.cs index 454ecbe09..e7b267f7c 100644 --- a/tests/OpenClaw.Connection.Tests/NativeGatewayRecordTests.cs +++ b/tests/OpenClaw.Connection.Tests/NativeGatewayRecordTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using OpenClaw.Connection.NativeGateway; namespace OpenClaw.Connection.Tests; @@ -6,7 +7,11 @@ public sealed class NativeGatewayRecordTests { private static GatewayRecord NativeRecord() => new GatewayRecordBuilder() .WithId("gw-native").WithUrl("ws://127.0.0.1:18789").Local().RequiresV2Signature().Build() - with { NativePackageFamilyName = "OpenClaw.Gateway_123456789abcd" }; + with + { + NativePackageFamilyName = "OpenClaw.Gateway_123456789abcd", + NativeRuntimeContract = NativeGatewayPackageClient.IsolatedContract + }; [Fact] public void Marker_RoundTripsWithoutWslOwnership() @@ -28,6 +33,7 @@ public void EquivalentEndpointEdit_PreservesNativeOwnership(string url) var rebuilt = new GatewayRecord { Id = existing.Id, Url = url, FriendlyName = "New label", SharedGatewayToken = "new-token" }; var result = rebuilt.PreserveAdvancedFields(existing); Assert.Equal(existing.NativePackageFamilyName, result.NativePackageFamilyName); + Assert.Equal(existing.NativeRuntimeContract, result.NativeRuntimeContract); Assert.True(result.IsLocal); Assert.True(result.RequiresV2Signature); Assert.Equal("New label", result.FriendlyName); @@ -44,6 +50,7 @@ public void Repointing_ClearsNativeOwnership(string url) var record = NativeRecord(); var result = (record with { Url = url }).PreserveAdvancedFields(record); Assert.Null(result.NativePackageFamilyName); + Assert.Null(result.NativeRuntimeContract); Assert.False(result.RequiresV2Signature); Assert.Null(result.SetupManagedDistroName); } @@ -54,6 +61,7 @@ public void AddingTunnel_ClearsNativeOwnership() var record = NativeRecord(); var result = (record with { SshTunnel = new("user", "host", 18789, 18789) }).PreserveAdvancedFields(record); Assert.Null(result.NativePackageFamilyName); + Assert.Null(result.NativeRuntimeContract); Assert.False(result.RequiresV2Signature); } diff --git a/tests/OpenClaw.Connection.Tests/WindowsProcessSequenceSnapshotTests.cs b/tests/OpenClaw.Connection.Tests/WindowsProcessSequenceSnapshotTests.cs new file mode 100644 index 000000000..60d054e41 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/WindowsProcessSequenceSnapshotTests.cs @@ -0,0 +1,40 @@ +using System.Buffers.Binary; +using OpenClaw.Connection.NativeGateway; + +namespace OpenClaw.Connection.Tests; + +public sealed class WindowsProcessSequenceSnapshotTests +{ + [Fact] + public void DecodesTwoProcessGenerationsWithoutOpeningTheirHandles() + { + byte[] table = new byte[96]; + BinaryPrimitives.WriteInt32LittleEndian(table, 48); + BinaryPrimitives.WriteInt64LittleEndian(table.AsSpan(8), 1234); + BinaryPrimitives.WriteUInt64LittleEndian(table.AsSpan(24), 17); + BinaryPrimitives.WriteInt64LittleEndian(table.AsSpan(56), 5678); + BinaryPrimitives.WriteUInt64LittleEndian(table.AsSpan(72), 0x8000_0000_0000_0001); + + IReadOnlyDictionary sequences = WindowsProcessSequenceSnapshot.Parse(table); + + Assert.Equal((ulong)17, sequences[1234]); + Assert.Equal(0x8000_0000_0000_0001, sequences[5678]); + } + + [Theory] + [InlineData(8, 5678, 18)] + [InlineData(48, 1234, 18)] + [InlineData(48, 5678, 0)] + public void InvalidOrAmbiguousGenerationFailsClosed( + int nextOffset, long secondProcessId, ulong secondSequence) + { + byte[] table = new byte[96]; + BinaryPrimitives.WriteInt32LittleEndian(table, nextOffset); + BinaryPrimitives.WriteInt64LittleEndian(table.AsSpan(8), 1234); + BinaryPrimitives.WriteUInt64LittleEndian(table.AsSpan(24), 17); + BinaryPrimitives.WriteInt64LittleEndian(table.AsSpan(56), secondProcessId); + BinaryPrimitives.WriteUInt64LittleEndian(table.AsSpan(72), secondSequence); + + Assert.Throws(() => WindowsProcessSequenceSnapshot.Parse(table)); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs index 35804ecd9..c08479376 100644 --- a/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs @@ -15,6 +15,8 @@ public void IsSafeRequestId_AcceptsExpectedIds(string requestId) [InlineData("-starts-with-dash")] [InlineData("bad;rm -rf")] [InlineData("bad id")] + [InlineData("latest")] + [InlineData("LATEST")] public void IsSafeRequestId_RejectsUnsafeIds(string requestId) { Assert.False(ApprovalRequestHelper.IsSafeRequestId(requestId)); diff --git a/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs b/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs index ab7f8643f..c9f721162 100644 --- a/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs @@ -351,6 +351,23 @@ public async Task WizardPairing_ApprovesOnlyItsOwnRequestOnTheVerifiedProfile() await Assert.ThrowsAsync(() => session.CompleteAsync(CancellationToken.None)); } + [Fact] + public async Task IsolatedWizardPairingKeepsAgentCredentialsOutOfHostOverrides() + { + using var fixture = new Fixture(); + fixture.Host.Contract = NativeGatewayContract.IsolatedSessionV1; + NativeGatewaySetupDraft draft = await fixture.Service.CreateDraftAsync(CancellationToken.None); + fixture.Host.IsolatedConfiguration = new(draft.Port, "agent-only-token"); + await using var session = await fixture.Service.PrepareAsync(draft, CancellationToken.None); + fixture.Host.PendingRequests = PairingRequests(session); + + await session.ApproveWizardPairingAsync("request-1", CancellationToken.None); + + Assert.Equal("request-1", fixture.Host.ApprovedId); + Assert.Empty(fixture.Host.PairingEnvironment!); + Assert.Empty(fixture.Registry.GetAll()); + } + [Theory] [InlineData("different-device")] [InlineData("different-key")] @@ -754,6 +771,90 @@ public void Environment_MapsCrossPackageStatePaths() Assert.DoesNotContain("TOKEN", string.Join(",", environment.Keys)); } + [Fact] + public async Task IsolatedSetupUsesAgentConfigurationAndPublishesOnlyAfterHealthyWizard() + { + using var fixture = new Fixture(); + fixture.Host.Contract = NativeGatewayContract.IsolatedSessionV1; + NativeGatewaySetupDraft draft = await fixture.Service.CreateDraftAsync(CancellationToken.None); + fixture.Host.IsolatedConfiguration = new(20123, "preserved-agent-token"); + + await using var session = await fixture.Service.PrepareAsync(draft, CancellationToken.None); + + Assert.True(session.IsIsolated); + Assert.Equal("ws://127.0.0.1:20123", session.Record.Url); + Assert.Equal("preserved-agent-token", session.Record.SharedGatewayToken); + Assert.Equal(NativeGatewayPackageClient.IsolatedContract, session.Record.NativeRuntimeContract); + Assert.False(File.Exists(NativeGatewayPaths.GetConfigPath(fixture.Registry, draft.GatewayId))); + Assert.Empty(fixture.Host.LastPreparationEnvironment!); + Assert.Empty(fixture.Registry.GetAll()); + Assert.Equal(20123, JsonSerializer.Deserialize( + File.ReadAllText(NativeGatewaySetupService.GetDraftPath(fixture.Registry)))!.Port); + + var capabilities = new CapabilitiesConfig { System = true, Screen = true }; + session.MarkWizardCompleted(); + GatewayRecord published = await session.CompleteAsync(CancellationToken.None, capabilities); + + Assert.Equal(capabilities.GetEnabledCommandIds(), fixture.Host.AppliedCommands); + Assert.Equal(published, fixture.Registry.GetById(draft.GatewayId)); + Assert.Contains("health", fixture.Events); + Assert.False(File.Exists(NativeGatewayPaths.GetConfigPath(fixture.Registry, draft.GatewayId))); + } + + [Fact] + public async Task IsolatedSetupRetriesCancelledDraftWithoutReplacingAgentCredentials() + { + using var fixture = new Fixture(); + fixture.Host.Contract = NativeGatewayContract.IsolatedSessionV1; + NativeGatewaySetupDraft draft = await fixture.Service.CreateDraftAsync(CancellationToken.None); + fixture.Host.IsolatedConfiguration = new(draft.Port, "agent-token"); + await using (var first = await fixture.Service.PrepareAsync(draft, CancellationToken.None)) { } + + NativeGatewaySetupDraft resumed = await fixture.Service.CreateDraftAsync(CancellationToken.None); + await using var retry = await fixture.Service.PrepareAsync(resumed, CancellationToken.None); + + Assert.Equal(draft, resumed); + Assert.Equal("agent-token", retry.Record.SharedGatewayToken); + Assert.Empty(fixture.Registry.GetAll()); + } + + [Fact] + public async Task IsolatedSetupRejectsChangedPortAndTokenBeforePublicationWithoutRepairingConfiguration() + { + using var fixture = new Fixture(); + fixture.Host.Contract = NativeGatewayContract.IsolatedSessionV1; + NativeGatewaySetupDraft draft = await fixture.Service.CreateDraftAsync(CancellationToken.None); + fixture.Host.IsolatedConfiguration = new(draft.Port, "first-token"); + await using var session = await fixture.Service.PrepareAsync(draft, CancellationToken.None); + session.MarkWizardCompleted(); + fixture.Events.Clear(); + fixture.Host.IsolatedConfiguration = new(draft.Port + 1, "different-token"); + + await Assert.ThrowsAsync( + () => session.CompleteAsync(CancellationToken.None)); + Assert.Empty(fixture.Registry.GetAll()); + Assert.Contains("agent-check", fixture.Events); + Assert.DoesNotContain("agent-config", fixture.Events); + Assert.Equal(new IsolatedGatewayConfiguration(draft.Port + 1, "different-token"), + fixture.Host.IsolatedConfiguration); + } + + [Fact] + public async Task CancellingAfterIsolatedPackageSetupDoesNotConfigureTheAgent() + { + using var fixture = new Fixture(); + fixture.Host.Contract = NativeGatewayContract.IsolatedSessionV1; + NativeGatewaySetupDraft draft = await fixture.Service.CreateDraftAsync(CancellationToken.None); + using var cancellation = new CancellationTokenSource(); + fixture.Host.Prepare = cancellation.Cancel; + + await Assert.ThrowsAnyAsync( + () => fixture.Service.PrepareAsync(draft, cancellation.Token)); + + Assert.DoesNotContain("agent-config", fixture.Events); + Assert.Empty(fixture.Registry.GetAll()); + } + private sealed class Fixture : IDisposable { public TempDirectory Temp { get; } = new(); @@ -788,6 +889,36 @@ public Task ResolveAsync(CancellationToken cancellationTok private sealed class Host(List events) : INativeGatewaySetupHost { + public NativeGatewayContract Contract { get; set; } = NativeGatewayContract.Legacy; + public IsolatedGatewayConfiguration IsolatedConfiguration { get; set; } = + new(18789, "fixture-agent-token"); + public IReadOnlyDictionary? LastPreparationEnvironment { get; private set; } + public IReadOnlyList? AppliedCommands { get; private set; } + public Task DetectContractAsync( + NativeGatewayPackage package, CancellationToken cancellationToken) => + Task.FromResult(Contract); + public Task PrepareIsolatedConfigurationAsync( + NativeGatewayPackage package, int port, CancellationToken cancellationToken) + { + events.Add("agent-config"); + return Task.FromResult(IsolatedConfiguration); + } + public Task ApplyIsolatedCapabilitiesAsync( + NativeGatewayPackage package, IReadOnlyList commandIds, CancellationToken cancellationToken) + { + events.Add("agent-capabilities"); + AppliedCommands = commandIds; + return Task.CompletedTask; + } + + public Task CheckIsolatedPairingConfigurationAsync( + NativeGatewayPackage package, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add("agent-check"); + return Task.FromResult(IsolatedConfiguration); + } + public string PendingRequests { get; set; } = "{\"pending\":[]}"; public Action OnList { get; set; } = () => { }; public string? ApprovedId { get; private set; } @@ -822,6 +953,7 @@ public Task PreparePackageAsync(NativeGatewayPackage package, IReadOnlyDictionar CancellationToken cancellationToken) { events.Add("prepare"); + LastPreparationEnvironment = environment; Prepare(); return Task.CompletedTask; } diff --git a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs index 231d1d3b6..144915e7f 100644 --- a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs @@ -199,120 +199,6 @@ XElement Named(string name) => document.Descendants().Single( Assert.Contains("\", \" + SetupLocalization.GetString(\"Onboarding_Native_Recommended.Text\")", source); } - [Fact] - public void Welcome_GatewayLabelsMatchEnglishResourcesAndAccessibleNames() - { - var root = TestRepositoryPaths.GetRepositoryRoot(); - var pages = Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages"); - var document = XDocument.Load(Path.Combine(pages, "WelcomePage.xaml")); - XNamespace names = "http://schemas.microsoft.com/winfx/2006/xaml"; - var resources = XDocument.Load(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw")) - .Descendants("data").ToDictionary( - element => (string)element.Attribute("name")!, element => element.Element("value")?.Value); - foreach (var (choiceName, uid, expected) in new[] - { - ("NativeChoice", "Onboarding_Native_Title", "Install a local native gateway"), - ("InstallChoice", "Onboarding_Wsl_Title", "Install a local WSL gateway"), - }) - { - var choice = document.Descendants().Single(element => (string?)element.Attribute(names + "Name") == choiceName); - var title = choice.Descendants().Single(element => (string?)element.Attribute(names + "Uid") == uid); - Assert.Equal(expected, (string?)title.Attribute("Text")); - Assert.Equal(expected, (string?)choice.Attribute("AutomationProperties.Name")); - Assert.Equal(expected, resources[uid + ".Text"]); - } - Assert.Equal("Install a local, MXC contained OpenClaw gateway", resources["Onboarding_Native_Description.Text"]); - var source = File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs")); - Assert.Contains("AutomationProperties.SetName(InstallChoice, SetupLocalization.GetString(\"Onboarding_Wsl_Title.Text\"))", source); - } - - [Fact] - public void Welcome_NativeBadgeSharesHeadingAndSuccessFollowsDescription() - { - var pages = Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.SetupEngine.UI", "Pages"); - var document = XDocument.Load(Path.Combine(pages, "WelcomePage.xaml")); - XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; - XNamespace names = "http://schemas.microsoft.com/winfx/2006/xaml"; - var heading = document.Descendants(xaml + "Grid") - .Single(element => (string?)element.Attribute(names + "Name") == "NativeHeading"); - Assert.Equal("*,Auto", (string?)heading.Attribute("ColumnDefinitions")); - Assert.Contains(heading.Elements(), element => - (string?)element.Attribute(names + "Uid") == "Onboarding_Native_Title"); - var badge = heading.Elements().Single(element => - (string?)element.Attribute(names + "Name") == "NativeRecommendedBadge"); - Assert.Equal("1", (string?)badge.Attribute("Grid.Column")); - var description = heading.ElementsAfterSelf().First(); - Assert.Equal("Onboarding_Native_Description", (string?)description.Attribute(names + "Uid")); - Assert.Equal("Install a local, MXC contained OpenClaw gateway", (string?)description.Attribute("Text")); - var success = description.ElementsAfterSelf().First(); - Assert.Equal("NativeSupportAvailablePanel", (string?)success.Attribute(names + "Name")); - Assert.Equal("Collapsed", (string?)success.Attribute("Visibility")); - var checkmark = Assert.Single(success.Elements(xaml + "FontIcon")); - Assert.Equal("\uE73E", (string?)checkmark.Attribute("Glyph")); - Assert.Equal("Raw", (string?)checkmark.Attribute("AutomationProperties.AccessibilityView")); - var text = Assert.Single(success.Elements(xaml + "TextBlock")); - Assert.Equal("Polite", (string?)text.Attribute("AutomationProperties.LiveSetting")); - var source = File.ReadAllText(Path.Combine(pages, "WelcomePage.xaml.cs")); - Assert.Contains("NativeSupportAvailablePanel.Visibility = Visibility.Collapsed", source); - Assert.Contains("NativeSupportAvailablePanel.Visibility = available ? Visibility.Visible : Visibility.Collapsed", source); - Assert.Contains("NativeSupportStatusPanel.Visibility = available ? Visibility.Collapsed : Visibility.Visible", source); - Assert.Contains("available ? NativeSupportAvailableText : NativeSupportStatus", source); - Assert.Contains("CreatePeerForElement(supportText)", source); - } - - [Fact] - public void NativeWizard_UsesSharedPageAndRpc_WithFailClosedStagedAuthorization() - { - var root = TestRepositoryPaths.GetRepositoryRoot(); - var window = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); - var wizard = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); - var host = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "NativeGatewaySetupHost.cs")); - Assert.Contains("NativeSetupSession = session;", window); - Assert.Contains("NavigateTo(typeof(WizardPage), _config)", window); - Assert.Contains("preserveStartupPreference || _nativeSetupCompleted", window); - Assert.Contains("await wizardPage.CancelAndWaitAsync()", window); - Assert.Contains("await ReleaseNativeSetupAsync()", window); - Assert.Contains("await native.PrepareWizardAsync(native.LifetimeToken)", wizard); - Assert.Contains("await native.AuthorizeAsync(cancellationToken)", wizard); - Assert.Contains("reportNativePairing: true", wizard); - Assert.Contains("NativeGatewaySetupSession.GetPairingGuidance(requestId)", wizard); - Assert.Contains("await native.ApproveWizardPairingAsync(ex.RequestId, native.LifetimeToken)", wizard); - Assert.Contains("when (attempt == 0)", wizard); - var nativeConnection = wizard[ - wizard.IndexOf("private async Task ConnectNativeClientAsync", StringComparison.Ordinal).. - wizard.IndexOf("private sealed class NativePairingRequiredException", StringComparison.Ordinal)]; - Assert.True(nativeConnection.IndexOf("for (var attempt", StringComparison.Ordinal) < - nativeConnection.IndexOf("new OpenClawGatewayClient", StringComparison.Ordinal)); - Assert.Contains("client.Dispose();", nativeConnection); - Assert.Contains("client.HandshakeAuthorizationAsync = client.ReconnectAuthorizationAsync;", nativeConnection); - Assert.True(nativeConnection.IndexOf("client.HandshakeAuthorizationAsync =", StringComparison.Ordinal) < - nativeConnection.IndexOf("await WaitForConnectAsync", StringComparison.Ordinal)); - Assert.DoesNotContain("client.DisconnectAsync()", nativeConnection); - Assert.Contains("[\"devices\", \"list\", \"--json\"]", host); - Assert.Contains("[\"devices\", \"approve\", requestId, \"--json\"]", host); - Assert.Contains("PairingCommandTimeout = TimeSpan.FromMinutes(2)", host); - Assert.Equal(2, host.Split("environment, PairingCommandTimeout,").Length - 1); - Assert.Contains("WizardPayloadHelpers.GetNativeTerminalError(payload)", wizard); - Assert.Contains("ShowError(SetupLogger.Sanitize(nativeError))", wizard); - Assert.DoesNotContain("--url", host); - Assert.DoesNotContain("--latest", host); - Assert.Contains("new { mode = \"local\", installDaemon = false }", wizard); - Assert.Contains("SendWizardRequestAsync(\"wizard.start\"", wizard); - Assert.Contains("SendWizardRequestAsync(\"wizard.next\"", wizard); - Assert.Contains("SendWizardRequestAsync(\"wizard.cancel\"", wizard); - Assert.Contains("_nativeSession?.MarkWizardCompleted()", wizard); - Assert.Contains("await native.CompleteAsync(native.LifetimeToken, _config!.Capabilities)", wizard); - Assert.Contains("await native.RestartAsync(native.LifetimeToken)", wizard); - Assert.Contains("nativeLogPath: _nativeSession?.ConsoleLogPath", wizard); - Assert.DoesNotContain("onboard", host); - Assert.DoesNotContain("wsl.exe", host); - Assert.DoesNotContain("RunInWslAsync", host); - Assert.Contains("[\"setup\"]", host); - Assert.Contains("[\"config\", \"validate\", \"--json\"]", host); - Assert.Contains("[\"gateway\", \"health\", \"--json\"]", host); - Assert.False(File.Exists(Path.Combine(root, "src", "OpenClaw.SetupEngine", "NativeGatewayTerminalCommand.cs"))); - } - [Fact] public void OptionalSetup_UsesOnePolicyAndValidatedHandoffForNativeWslAndHeadless() { diff --git a/tests/OpenClaw.Tray.Tests/WizardConsoleTailTests.cs b/tests/OpenClaw.Tray.Tests/WizardConsoleTailTests.cs index 2aeaa8f77..af25484d6 100644 --- a/tests/OpenClaw.Tray.Tests/WizardConsoleTailTests.cs +++ b/tests/OpenClaw.Tray.Tests/WizardConsoleTailTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using OpenClaw.SetupEngine.UI; namespace OpenClaw.Tray.Tests; @@ -10,6 +11,187 @@ namespace OpenClaw.Tray.Tests; /// public class WizardConsoleTailTests { + private static JsonElement Payload(long cursor, long size, params string[] lines) + => PayloadFromFile("agent-wizard.log", cursor, size, lines); + + private static JsonElement PayloadFromFile(string file, long cursor, long size, params string[] lines) + { + using JsonDocument document = JsonDocument.Parse(JsonSerializer.Serialize(new + { + file, cursor, size, lines + })); + return document.RootElement.Clone(); + } + + [Fact] + public async Task GatewayReaderSendsBoundedLogsTailRequestsWithoutANullCursor() + { + var requests = new List(); + var reader = WizardConsoleTail.CreateGatewayLogReader((method, parameters, timeout) => + { + Assert.Equal("logs.tail", method); + Assert.Equal(10_000, timeout); + requests.Add(JsonSerializer.SerializeToElement(parameters)); + return Task.FromResult(Payload(100, 100)); + }); + + await reader(null, CancellationToken.None); + await reader(100, CancellationToken.None); + + Assert.False(requests[0].TryGetProperty("cursor", out _)); + Assert.Equal(1, requests[0].GetProperty("maxBytes").GetInt32()); + Assert.Equal(100, requests[1].GetProperty("cursor").GetInt64()); + Assert.Equal(128, requests[1].GetProperty("limit").GetInt32()); + Assert.Equal(64 * 1024, requests[1].GetProperty("maxBytes").GetInt32()); + } + + [Fact] + public async Task IsolatedTailUsesAuthenticatedCursorAndOnlyDisplaysNewPluginConsoleLines() + { + string oauth = """{"_meta":{"name":"openclaw","path":{"method":"console.log"}},"message":"Open https://auth.example/authorize?client_id=fixture"}"""; + string unrelated = """{"_meta":{"name":"openclaw/auth","path":{"method":"console.log"}},"message":"not user-facing"}"""; + var requested = new List(); + var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var tail = new WizardConsoleTail(gatewayLogTail: (cursor, _) => + { + requested.Add(cursor); + return Task.FromResult(cursor is null + ? Payload(100, 100, oauth) + : Payload(300, 300, unrelated, oauth)); + }); + + await tail.StartGatewayAsync(message => delivered.TrySetResult(message), + _ => { }, CancellationToken.None); + string received = await delivered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + tail.Stop(); + + Assert.Contains("https://auth.example/authorize", received, StringComparison.Ordinal); + Assert.Equal([null, 100], requested.Take(2)); + } + + [Fact] + public void IsolatedTailMarksSkippedOutputAndRejectsMalformedCursors() + { + string oauth = """{"_meta":{"name":"openclaw","path":{"method":"console.log"}},"message":"OAuth URL available"}"""; + using JsonDocument gap = JsonDocument.Parse(JsonSerializer.Serialize(new + { + file = "agent-wizard.log", + cursor = 250, + size = 250, + reset = true, + truncated = true, + skippedBytes = 47, + lines = new[] { oauth } + })); + + var batch = WizardConsoleTail.ParseGatewayLogTail(gap.RootElement, previousCursor: 100); + + Assert.True(batch.Skipped); + Assert.Equal(250, batch.Cursor); + Assert.Equal(["OAuth URL available"], batch.Messages); + Assert.Throws(() => + WizardConsoleTail.ParseGatewayLogTail(Payload(50, 50), previousCursor: 100)); + using JsonDocument invalid = JsonDocument.Parse( + """{"file":"agent-wizard.log","cursor":"50","size":50,"lines":[]}"""); + Assert.Throws(() => + WizardConsoleTail.ParseGatewayLogTail(invalid.RootElement, previousCursor: null)); + using JsonDocument invalidGap = JsonDocument.Parse( + """{"file":"agent-wizard.log","cursor":50,"size":50,"reset":"yes","lines":[]}"""); + Assert.Throws(() => + WizardConsoleTail.ParseGatewayLogTail(invalidGap.RootElement, previousCursor: 100)); + } + + [Fact] + public async Task IsolatedTailReportsAVisibleGapBeforeLaterConsoleMessages() + { + string oauth = """{"_meta":{"name":"openclaw","path":{"method":"console.log"}},"message":"Open https://auth.example/authorize"}"""; + var messages = new List(); + var issues = new List(); + var receivedBoth = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var tail = new WizardConsoleTail(gatewayLogTail: (cursor, _) => + { + if (cursor is null) + return Task.FromResult(Payload(100, 100)); + using JsonDocument document = JsonDocument.Parse(JsonSerializer.Serialize(new + { + file = "agent-wizard.log", + cursor = 250, + size = 250, + reset = true, + truncated = true, + skippedBytes = 47, + lines = new[] { oauth } + })); + return Task.FromResult(document.RootElement.Clone()); + }); + + await tail.StartGatewayAsync( + message => + { + messages.Add(message); + if (issues.Count == 1) + receivedBoth.TrySetResult(); + }, + issue => issues.Add(issue), + CancellationToken.None); + await receivedBoth.Task.WaitAsync(TimeSpan.FromSeconds(5)); + tail.Stop(); + + Assert.Equal([GatewayLogTailIssue.Skipped], issues); + Assert.Equal(["Open https://auth.example/authorize"], messages); + } + + [Fact] + public async Task IsolatedTailRejectsAChangedGatewayLogSource() + { + string oauth = """{"_meta":{"name":"openclaw","path":{"method":"console.log"}},"message":"Unrelated OAuth URL"}"""; + var issue = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var tail = new WizardConsoleTail(gatewayLogTail: (cursor, _) => + Task.FromResult(cursor is null + ? PayloadFromFile("first-agent.log", 100, 100) + : PayloadFromFile("different-agent.log", 200, 200, oauth))); + + await tail.StartGatewayAsync( + _ => throw new InvalidOperationException("A replaced log must not be projected."), + value => issue.TrySetResult(value), + CancellationToken.None); + GatewayLogTailIssue result = await issue.Task.WaitAsync(TimeSpan.FromSeconds(5)); + tail.Stop(); + + Assert.Equal(GatewayLogTailIssue.Skipped, result); + } + + [Fact] + public async Task IsolatedTailDoesNotStartWslWhenLogRpcIsUnavailable() + { + using var tail = new WizardConsoleTail(gatewayLogTail: (_, _) => + Task.FromException(new InvalidOperationException("operator.read unavailable"))); + + await Assert.ThrowsAsync( + () => tail.StartGatewayAsync(_ => { }, _ => { }, CancellationToken.None)); + } + + [Fact] + public async Task IsolatedTailSurfacesPostStartRpcFailure() + { + var issue = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var tail = new WizardConsoleTail(gatewayLogTail: (cursor, _) => + cursor is null + ? Task.FromResult(Payload(100, 100)) + : Task.FromException(new IOException("agent log unavailable"))); + + await tail.StartGatewayAsync( + _ => throw new InvalidOperationException("A failed RPC must not invent a console message."), + value => issue.TrySetResult(value), + CancellationToken.None); + GatewayLogTailIssue result = await issue.Task.WaitAsync(TimeSpan.FromSeconds(5)); + tail.Stop(); + + Assert.Equal(GatewayLogTailIssue.Unavailable, result); + } + [Fact] public void ExtractsOAuthUrlFromUpstreamConsoleLogEntry() { From 77a7eb64d1c8635147be24bec6bb37b84f886ace Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 29 Sep 2026 19:54:49 -0700 Subject: [PATCH 2/5] fix: recover native Companion setup after Gateway package upgrade Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ARCHITECTURE.md | 8 +++ .../NativeGateway/IsolatedGatewayRuntime.cs | 3 +- .../Pages/NativeGatewaySetupPage.xaml.cs | 45 +++++++++++++++- .../NativeGatewaySetupService.cs | 30 ++++++++++- .../Strings/en-us/Resources.resw | 4 ++ .../Strings/fr-fr/Resources.resw | 4 ++ .../Strings/nl-nl/Resources.resw | 4 ++ .../Strings/pt-br/Resources.resw | 4 ++ .../Strings/zh-cn/Resources.resw | 4 ++ .../Strings/zh-tw/Resources.resw | 4 ++ .../NativeGatewaySetupTests.cs | 52 +++++++++++++++++++ 11 files changed, 158 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fef0a776f..bde394766 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -274,6 +274,14 @@ profile paths or install its own Gateway supervisor. Package 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 that +unpublished Companion draft and its local credentials, then starts isolated +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 diff --git a/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs b/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs index 1677b40d6..5bf1ddcb7 100644 --- a/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs +++ b/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs @@ -288,7 +288,8 @@ public async Task EnsureRunningAsync(GatewayRecord record, CancellationToken can NativeGatewayContract.Legacy) throw new NativeGatewayContractException( "This Companion profile was created for a same-user Gateway. " + - "Set up the isolated Gateway again instead of sending it this profile's credentials."); + "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); } diff --git a/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs index e3220e7b1..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,7 +158,7 @@ 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), diff --git a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs index 2dc0f1a7c..f82245ece 100644 --- a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs +++ b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs @@ -92,9 +92,9 @@ await File.ReadAllTextAsync(draftPath, cancellationToken)) if (saved.PackageFamilyName != package.PackageFamilyName) throw new InvalidOperationException("The installed Gateway package does not match the saved setup profile."); if (saved.Contract != contract) - throw new InvalidOperationException( + throw new NativeGatewayDraftRecoveryRequiredException( "The saved native setup draft belongs to a different Gateway runtime. " + - "Finish or discard that draft before starting a new setup."); + "Discard the unpublished draft to create a new isolated Gateway profile."); return contract == NativeGatewayContract.IsolatedSessionV1 ? saved : ResumeDraft(saved, draftPath); @@ -110,6 +110,29 @@ await File.ReadAllTextAsync(draftPath, cancellationToken)) return draft; } + /// + /// Discards an unpublished draft after the user explicitly chooses to replace an + /// incompatible runtime. Published profiles and unrelated gateway state are never removed. + /// + public async Task DiscardIncompatibleDraftAsync(CancellationToken cancellationToken) + { + var draftPath = GetDraftPath(registry); + if (!File.Exists(draftPath)) + return; + + var saved = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(draftPath, cancellationToken).ConfigureAwait(false)) + ?? throw new InvalidOperationException("The saved native setup draft is invalid."); + var stateDirectory = NativeGatewayPaths.GetStateDirectory(registry, saved.GatewayId); + registry.Load(); + if (registry.GetById(saved.GatewayId) is not null) + throw new InvalidOperationException("The saved native Gateway profile has already been published and cannot be discarded from setup."); + + if (Directory.Exists(stateDirectory)) + Directory.Delete(stateDirectory, recursive: true); + File.Delete(draftPath); + } + private NativeGatewaySetupDraft ResumeDraft(NativeGatewaySetupDraft draft, string draftPath) { if (draft.PreviousPort is not null) @@ -126,6 +149,9 @@ private NativeGatewaySetupDraft ResumeDraft(NativeGatewaySetupDraft draft, strin return FinishPortChange(pending, draftPath); } + /// Signals that an unpublished same-user draft requires explicit replacement after a package upgrade. + public sealed class NativeGatewayDraftRecoveryRequiredException(string message) : InvalidOperationException(message); + private NativeGatewaySetupDraft FinishPortChange(NativeGatewaySetupDraft draft, string draftPath) { var previousPort = draft.PreviousPort diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 44322efff..d5c789c84 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -7453,4 +7453,8 @@ Your settings, gateway, files, and models stay where they are. Preparing the native Gateway for the shared setup wizard. Provider and model questions will appear here in Companion. Setup cancelled. The WinGet request was cancelled, but Windows may still finish deployment. Any configuration already entered is retained in the dedicated native profile. No Gateway package was removed. Gateway configuration and authenticated health verified at {0}. Open Companion to start the gateway and finish device pairing in Connection settings. Your selected Windows capabilities have been saved. + Replace unfinished Gateway setup? + This removes only the unpublished Companion setup draft and its local credentials. It does not change an existing Gateway, WSL setup, or package-managed profile. + Discard and set up again + Keep draft diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 1dc05bc97..8d14e1bc6 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -7389,4 +7389,8 @@ Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place Préparation du Gateway natif pour l'assistant partagé. Les questions sur le fournisseur et le modèle s'afficheront ici dans Companion. Configuration annulée. La demande WinGet a été annulée, mais Windows peut encore terminer le déploiement. Les paramètres déjà saisis sont conservés dans le profil natif dédié. Aucun package Gateway n'a été supprimé. Configuration et état authentifié du Gateway vérifiés à {0}. Ouvrez Companion pour démarrer le Gateway et terminer l'association dans les paramètres de connexion. Les fonctionnalités Windows que vous avez sélectionnées ont été enregistrées. + Remplacer la configuration Gateway inachevée ? + Cette opération supprime uniquement le brouillon non publié de la configuration Companion et ses informations d'identification locales. Elle ne modifie pas une Gateway existante, la configuration WSL ou le profil géré par le package. + Ignorer et configurer à nouveau + Conserver le brouillon diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 6c7d93a3d..0101421c9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -7390,4 +7390,8 @@ Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. De native Gateway wordt voorbereid voor de gedeelde wizard. Vragen over de provider en het model verschijnen hier in Companion. Instelling geannuleerd. Het WinGet-verzoek is geannuleerd, maar Windows kan de implementatie nog voltooien. Ingevoerde configuratie blijft bewaard in het aparte native profiel. Er is geen Gateway-pakket verwijderd. Gateway-configuratie en geauthenticeerde gezondheid gecontroleerd op {0}. Open Companion om de Gateway te starten en het apparaat te koppelen in de verbindingsinstellingen. Uw geselecteerde Windows-functies zijn opgeslagen. + Onvoltooide Gateway-instelling vervangen? + Hiermee worden alleen het niet-gepubliceerde Companion-instellingsconcept en de lokale referenties verwijderd. Een bestaande Gateway, WSL-instelling of pakketbeheerd profiel verandert niet. + Verwijderen en opnieuw instellen + Concept behouden diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index af5c3dd9f..0625dd154 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -7443,4 +7443,8 @@ Suas configurações, gateway, arquivos e modelos permanecem onde estão.Preparando o Gateway nativo para o assistente compartilhado. As perguntas sobre provedor e modelo aparecerão aqui no Companion. Configuração cancelada. A solicitação do WinGet foi cancelada, mas o Windows ainda pode concluir a implantação. Os dados já inseridos são mantidos no perfil nativo dedicado. Nenhum pacote Gateway foi removido. Configuração e integridade autenticada verificadas em {0}. Abra o Companion para iniciar o Gateway e concluir o pareamento nas configurações de conexão. Os recursos do Windows que você selecionou foram salvos. + Substituir a configuração inacabada do Gateway? + Isso remove apenas o rascunho não publicado da configuração do Companion e suas credenciais locais. Não altera um Gateway existente, a configuração do WSL ou o perfil gerenciado pelo pacote. + Descartar e configurar novamente + Manter rascunho diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index f25f2edfa..2a4dee41a 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -7389,4 +7389,8 @@ 正在为共享设置向导准备原生 Gateway。提供商和模型问题将在 Companion 中显示。 设置已取消。WinGet 请求已取消,但 Windows 仍可能完成部署。已输入的配置保留在专用原生配置目录中。未删除任何 Gateway 包。 已在 {0} 验证 Gateway 配置和经过身份验证的健康状态。打开 Companion 启动 Gateway,并在连接设置中完成设备配对。您选择的 Windows 功能已保存。 + 替换未完成的 Gateway 设置? + 这只会删除未发布的 Companion 设置草稿及其本地凭据。不会更改现有 Gateway、WSL 设置或包管理的配置文件。 + 丢弃并重新设置 + 保留草稿 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 02f86d027..a491ad2af 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -7389,4 +7389,8 @@ 正在為共用設定精靈準備原生 Gateway。供應商和模型問題將在 Companion 中顯示。 設定已取消。WinGet 要求已取消,但 Windows 仍可能完成部署。已輸入的設定保留在專用原生設定目錄中。未移除任何 Gateway 套件。 已在 {0} 驗證 Gateway 設定和經過驗證的健康狀態。開啟 Companion 啟動 Gateway,並在連線設定中完成裝置配對。您選取的 Windows 功能已儲存。 + 取代未完成的 Gateway 設定? + 這只會移除未發佈的 Companion 設定草稿及其本機認證。它不會變更現有 Gateway、WSL 設定或套件管理的設定檔。 + 捨棄並重新設定 + 保留草稿 diff --git a/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs b/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs index c9f721162..47784f0f7 100644 --- a/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs @@ -13,6 +13,58 @@ public sealed class NativeGatewaySetupTests { private const string Family = "OpenClaw.Gateway_123456789abcd"; + [Fact] + public async Task IncompatibleUnpublishedDraft_RequiresExplicitDiscardBeforeCreatingIsolatedProfile() + { + using var fixture = new Fixture(); + var legacy = await fixture.Service.CreateDraftAsync(default); + var stateDirectory = NativeGatewayPaths.GetStateDirectory(fixture.Registry, legacy.GatewayId); + Directory.CreateDirectory(stateDirectory); + File.WriteAllText(Path.Combine(stateDirectory, "credential-marker.txt"), "legacy-credential"); + + fixture.Host.Contract = NativeGatewayContract.IsolatedSessionV1; + + await Assert.ThrowsAsync( + () => fixture.Service.CreateDraftAsync(default)); + Assert.True(File.Exists(Path.Combine(stateDirectory, "credential-marker.txt"))); + + await fixture.Service.DiscardIncompatibleDraftAsync(default); + var isolated = await fixture.Service.CreateDraftAsync(default); + + Assert.NotEqual(legacy.GatewayId, isolated.GatewayId); + Assert.Equal(NativeGatewayContract.IsolatedSessionV1, isolated.Contract); + Assert.False(Directory.Exists(stateDirectory)); + Assert.Equal(isolated, JsonSerializer.Deserialize( + File.ReadAllText(NativeGatewaySetupService.GetDraftPath(fixture.Registry)))); + Assert.Empty(fixture.Registry.GetAll()); + } + + [Fact] + public async Task PublishedNativeProfileCannotBeDiscardedByDraftRecovery() + { + using var fixture = new Fixture(); + var draft = await fixture.Service.CreateDraftAsync(default); + var stateDirectory = NativeGatewayPaths.GetStateDirectory(fixture.Registry, draft.GatewayId); + Directory.CreateDirectory(stateDirectory); + var credentialPath = Path.Combine(stateDirectory, "credential-marker.txt"); + File.WriteAllText(credentialPath, "preserved-credential"); + fixture.Registry.AddOrUpdate(new GatewayRecord + { + Id = draft.GatewayId, + Url = $"ws://127.0.0.1:{draft.Port}", + IsLocal = true, + NativePackageFamilyName = draft.PackageFamilyName + }); + fixture.Registry.Save(); + + await Assert.ThrowsAsync( + () => fixture.Service.DiscardIncompatibleDraftAsync(default)); + + Assert.True(File.Exists(credentialPath)); + Assert.True(File.Exists(NativeGatewaySetupService.GetDraftPath(fixture.Registry))); + Assert.NotNull(fixture.Registry.GetById(draft.GatewayId)); + } + [Fact] public async Task Draft_OccupiedPortRotatesWithoutReplacingProfileOrCredentials() { From 4f1ab4246bc460bd267f3a2357b81a23bdf893f7 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:24:12 -0700 Subject: [PATCH 3/5] fix: preserve Gateway data when replacing an incompatible setup draft Follow Store migration recovery's metadata-only discard policy. Retain configuration, workspace and identity after connection removal; verify old-format drafts and update recovery copy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fd22b9-0532-4fcc-96d0-79ce77725e98 --- docs/ARCHITECTURE.md | 10 ++-- .../NativeGatewaySetupService.cs | 10 ++-- .../Strings/en-us/Resources.resw | 2 +- .../Strings/fr-fr/Resources.resw | 2 +- .../Strings/nl-nl/Resources.resw | 2 +- .../Strings/pt-br/Resources.resw | 2 +- .../Strings/zh-cn/Resources.resw | 2 +- .../Strings/zh-tw/Resources.resw | 2 +- .../NativeGatewaySetupTests.cs | 47 ++++++++++++++++++- 9 files changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bde394766..cde4ed8e3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -275,9 +275,13 @@ 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 that -unpublished Companion draft and its local credentials, then starts isolated -setup; it never reuses host configuration as agent configuration. A published +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. diff --git a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs index f82245ece..1742f23a3 100644 --- a/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs +++ b/src/OpenClaw.SetupEngine/NativeGatewaySetupService.cs @@ -111,8 +111,8 @@ await File.ReadAllTextAsync(draftPath, cancellationToken)) } /// - /// Discards an unpublished draft after the user explicitly chooses to replace an - /// incompatible runtime. Published profiles and unrelated gateway state are never removed. + /// Discards only the setup descriptor after explicit runtime replacement consent. + /// A removed registry entry does not prove its profile was never published. /// public async Task DiscardIncompatibleDraftAsync(CancellationToken cancellationToken) { @@ -123,13 +123,13 @@ public async Task DiscardIncompatibleDraftAsync(CancellationToken cancellationTo var saved = JsonSerializer.Deserialize( await File.ReadAllTextAsync(draftPath, cancellationToken).ConfigureAwait(false)) ?? throw new InvalidOperationException("The saved native setup draft is invalid."); - var stateDirectory = NativeGatewayPaths.GetStateDirectory(registry, saved.GatewayId); + _ = NativeGatewayPaths.GetStateDirectory(registry, saved.GatewayId); registry.Load(); if (registry.GetById(saved.GatewayId) is not null) throw new InvalidOperationException("The saved native Gateway profile has already been published and cannot be discarded from setup."); - if (Directory.Exists(stateDirectory)) - Directory.Delete(stateDirectory, recursive: true); + cancellationToken.ThrowIfCancellationRequested(); + // As with Store migration recovery, discard intent, never the data it describes. File.Delete(draftPath); } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index d5c789c84..1a192432e 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -7454,7 +7454,7 @@ Your settings, gateway, files, and models stay where they are. Setup cancelled. The WinGet request was cancelled, but Windows may still finish deployment. Any configuration already entered is retained in the dedicated native profile. No Gateway package was removed. Gateway configuration and authenticated health verified at {0}. Open Companion to start the gateway and finish device pairing in Connection settings. Your selected Windows capabilities have been saved. Replace unfinished Gateway setup? - This removes only the unpublished Companion setup draft and its local credentials. It does not change an existing Gateway, WSL setup, or package-managed profile. + This clears only the Companion setup draft. Existing workspace files, configuration and credentials are kept. New setup uses a new profile and does not import or change the old Gateway data, WSL setup, or package-managed profile. Discard and set up again Keep draft diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 8d14e1bc6..f663c3b42 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -7390,7 +7390,7 @@ Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place Configuration annulée. La demande WinGet a été annulée, mais Windows peut encore terminer le déploiement. Les paramètres déjà saisis sont conservés dans le profil natif dédié. Aucun package Gateway n'a été supprimé. Configuration et état authentifié du Gateway vérifiés à {0}. Ouvrez Companion pour démarrer le Gateway et terminer l'association dans les paramètres de connexion. Les fonctionnalités Windows que vous avez sélectionnées ont été enregistrées. Remplacer la configuration Gateway inachevée ? - Cette opération supprime uniquement le brouillon non publié de la configuration Companion et ses informations d'identification locales. Elle ne modifie pas une Gateway existante, la configuration WSL ou le profil géré par le package. + Cette opération efface uniquement le brouillon de configuration Companion. Les fichiers de travail, la configuration et les informations d'identification existants sont conservés. La nouvelle configuration utilise un nouveau profil sans importer ni modifier les anciennes données Gateway, la configuration WSL ou le profil géré par le package. Ignorer et configurer à nouveau Conserver le brouillon diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 0101421c9..8d4ac8394 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -7391,7 +7391,7 @@ Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. Instelling geannuleerd. Het WinGet-verzoek is geannuleerd, maar Windows kan de implementatie nog voltooien. Ingevoerde configuratie blijft bewaard in het aparte native profiel. Er is geen Gateway-pakket verwijderd. Gateway-configuratie en geauthenticeerde gezondheid gecontroleerd op {0}. Open Companion om de Gateway te starten en het apparaat te koppelen in de verbindingsinstellingen. Uw geselecteerde Windows-functies zijn opgeslagen. Onvoltooide Gateway-instelling vervangen? - Hiermee worden alleen het niet-gepubliceerde Companion-instellingsconcept en de lokale referenties verwijderd. Een bestaande Gateway, WSL-instelling of pakketbeheerd profiel verandert niet. + Hiermee wordt alleen het Companion-instellingsconcept gewist. Bestaande werkruimtebestanden, configuratie en referenties blijven behouden. De nieuwe installatie gebruikt een nieuw profiel en importeert of wijzigt geen oude Gateway-gegevens, WSL-instellingen of pakketbeheerd profiel. Verwijderen en opnieuw instellen Concept behouden diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index 0625dd154..1e1580fa6 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -7444,7 +7444,7 @@ Suas configurações, gateway, arquivos e modelos permanecem onde estão.Configuração cancelada. A solicitação do WinGet foi cancelada, mas o Windows ainda pode concluir a implantação. Os dados já inseridos são mantidos no perfil nativo dedicado. Nenhum pacote Gateway foi removido. Configuração e integridade autenticada verificadas em {0}. Abra o Companion para iniciar o Gateway e concluir o pareamento nas configurações de conexão. Os recursos do Windows que você selecionou foram salvos. Substituir a configuração inacabada do Gateway? - Isso remove apenas o rascunho não publicado da configuração do Companion e suas credenciais locais. Não altera um Gateway existente, a configuração do WSL ou o perfil gerenciado pelo pacote. + Isso limpa apenas o rascunho de configuração do Companion. Os arquivos do espaço de trabalho, a configuração e as credenciais existentes são mantidos. A nova configuração usa um novo perfil e não importa nem altera os dados antigos do Gateway, a configuração do WSL ou o perfil gerenciado pelo pacote. Descartar e configurar novamente Manter rascunho diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 2a4dee41a..665a51b8d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -7390,7 +7390,7 @@ 设置已取消。WinGet 请求已取消,但 Windows 仍可能完成部署。已输入的配置保留在专用原生配置目录中。未删除任何 Gateway 包。 已在 {0} 验证 Gateway 配置和经过身份验证的健康状态。打开 Companion 启动 Gateway,并在连接设置中完成设备配对。您选择的 Windows 功能已保存。 替换未完成的 Gateway 设置? - 这只会删除未发布的 Companion 设置草稿及其本地凭据。不会更改现有 Gateway、WSL 设置或包管理的配置文件。 + 这只会清除 Companion 设置草稿。现有工作区文件、配置和凭据将被保留。新设置使用新的配置文件,不会导入或更改旧 Gateway 数据、WSL 设置或包管理的配置文件。 丢弃并重新设置 保留草稿 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index a491ad2af..991851b95 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -7390,7 +7390,7 @@ 設定已取消。WinGet 要求已取消,但 Windows 仍可能完成部署。已輸入的設定保留在專用原生設定目錄中。未移除任何 Gateway 套件。 已在 {0} 驗證 Gateway 設定和經過驗證的健康狀態。開啟 Companion 啟動 Gateway,並在連線設定中完成裝置配對。您選取的 Windows 功能已儲存。 取代未完成的 Gateway 設定? - 這只會移除未發佈的 Companion 設定草稿及其本機認證。它不會變更現有 Gateway、WSL 設定或套件管理的設定檔。 + 這只會清除 Companion 設定草稿。現有工作區檔案、設定和認證都會保留。新設定使用新的設定檔,不會匯入或變更舊 Gateway 資料、WSL 設定或套件管理的設定檔。 捨棄並重新設定 保留草稿 diff --git a/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs b/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs index 47784f0f7..b173138e0 100644 --- a/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/NativeGatewaySetupTests.cs @@ -33,12 +33,57 @@ public async Task IncompatibleUnpublishedDraft_RequiresExplicitDiscardBeforeCrea Assert.NotEqual(legacy.GatewayId, isolated.GatewayId); Assert.Equal(NativeGatewayContract.IsolatedSessionV1, isolated.Contract); - Assert.False(Directory.Exists(stateDirectory)); + Assert.Equal("legacy-credential", File.ReadAllText(Path.Combine(stateDirectory, "credential-marker.txt"))); Assert.Equal(isolated, JsonSerializer.Deserialize( File.ReadAllText(NativeGatewaySetupService.GetDraftPath(fixture.Registry)))); Assert.Empty(fixture.Registry.GetAll()); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DraftRecovery_PreservesProfileDataAfterRemovingPublishedRegistration(bool published) + { + using var fixture = new Fixture(); + var draft = await fixture.Service.CreateDraftAsync(default); + await using (var session = await fixture.Service.PrepareAsync(draft, default)) + { + if (published) + { + session.MarkWizardCompleted(); + await session.CompleteAsync(default); + } + } + var identityDirectory = fixture.Registry.GetIdentityDirectory(draft.GatewayId); + var configPath = NativeGatewayPaths.GetConfigPath(fixture.Registry, draft.GatewayId); + var workspace = JsonNode.Parse(File.ReadAllText(configPath))!["agents"]!["defaults"]!["workspace"]!.GetValue(); + Directory.CreateDirectory(workspace); + File.WriteAllText(Path.Combine(workspace, "user-work.txt"), "retained work"); + var identity = new DeviceIdentity(identityDirectory); + identity.Initialize(); + var before = Directory.GetFiles(identityDirectory, "*", SearchOption.AllDirectories) + .ToDictionary(path => path, File.ReadAllBytes); + fixture.Registry.Remove(draft.GatewayId); + fixture.Registry.Save(); + + // Descriptors written before runtime contracts were introduced omit Contract. + File.WriteAllText(NativeGatewaySetupService.GetDraftPath(fixture.Registry), + JsonSerializer.Serialize(new { draft.GatewayId, draft.Port, draft.PackageFamilyName })); + fixture.Host.Contract = NativeGatewayContract.IsolatedSessionV1; + await Assert.ThrowsAsync( + () => fixture.Service.CreateDraftAsync(default)); + + await fixture.Service.DiscardIncompatibleDraftAsync(default); + var replacement = await fixture.Service.CreateDraftAsync(default); + + Assert.NotEqual(draft.GatewayId, replacement.GatewayId); + Assert.Equal(NativeGatewayContract.IsolatedSessionV1, replacement.Contract); + Assert.Equal(before.Count, Directory.GetFiles(identityDirectory, "*", SearchOption.AllDirectories).Length); + foreach (var (path, bytes) in before) + Assert.Equal(bytes, File.ReadAllBytes(path)); + Assert.Empty(fixture.Registry.GetAll()); + } + [Fact] public async Task PublishedNativeProfileCannotBeDiscardedByDraftRecovery() { From 938294189801242d97db1195c55153cfaadafc1a Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:58:10 -0700 Subject: [PATCH 4/5] fix: keep isolated Gateway recovery and lifecycle ownership reliable Keep package start ownership separate from listener proofs, scope rollback to the current start, bound passive inspection, and restart pre-existing services only on explicit request. Retry transient wizard log failures and keep terminal recovery visible across steps. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fd22b9-0532-4fcc-96d0-79ce77725e98 --- docs/ARCHITECTURE.md | 9 + docs/ONBOARDING_WIZARD.md | 8 + .../GatewayConnectionManager.cs | 6 +- .../GatewayEndpointProvenance.cs | 1 + .../NativeGateway/IsolatedGatewayRuntime.cs | 226 +++++++++++----- .../NativeGateway/NativeGatewayContracts.cs | 8 +- .../NativeGatewayEndpointSecurity.cs | 9 +- .../Pages/WizardPage.xaml | 8 + .../Pages/WizardPage.xaml.cs | 45 +++- .../WizardConsoleTail.cs | 29 +- .../NativeGatewaySetupSession.cs | 7 +- .../GatewayConnectionManagerTests.cs | 45 +++- .../IsolatedGatewayRuntimeTests.cs | 255 ++++++++++++++++-- .../NativeGatewaySetupTests.cs | 24 ++ .../NativeGatewaySetupUxContractTests.cs | 25 ++ .../WizardConsoleTailTests.cs | 59 ++++ 16 files changed, 661 insertions(+), 103 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cde4ed8e3..d4dd1df1b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -142,6 +142,15 @@ 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. +Passive ownership probes have a five-second deadline and report unknown +ownership on package failure, without interrupting authentication recovery. + `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 diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index ce0bdeffb..eb7001df7 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -62,6 +62,14 @@ provider unspecified so policy can choose among the actual offered values. ### Native Gateway MSIX (isolated or legacy) +Isolated Gateway console polling retries transient I/O and timeout failures +twice from the same cursor. 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 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 4e87ab20b..7f870afd1 100644 --- a/src/OpenClaw.Connection/GatewayEndpointProvenance.cs +++ b/src/OpenClaw.Connection/GatewayEndpointProvenance.cs @@ -28,6 +28,7 @@ public enum GatewayEndpointProvenanceFailureReason None, ListenerSnapshotChanged, ProcessIdentityUnavailable, + InspectionUnavailable, } /// diff --git a/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs b/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs index 5bf1ddcb7..02864cd75 100644 --- a/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs +++ b/src/OpenClaw.Connection/NativeGateway/IsolatedGatewayRuntime.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.Diagnostics; using System.Net; using OpenClaw.Shared; @@ -12,11 +14,11 @@ public sealed class IsolatedGatewayRuntime : INativeGatewayRuntime private readonly Func> _captureSequences; private readonly Func _aliasExists; private readonly SemaphoreSlim _gate = new(1, 1); - private GatewayRecord? _record; - private NativeGatewayPackage? _package; - private IsolatedGatewayStatus? _proof; - private bool _startedHere; + 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, @@ -29,13 +31,15 @@ internal IsolatedGatewayRuntime( NativeGatewayPackageClient client, Func capture, Func> captureSequences, - Func aliasExists) + 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) @@ -45,16 +49,29 @@ public async Task EnsureRunningAsync(GatewayRecord record, CancellationToken can 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); - _package = package; IsolatedGatewayStatus status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); if (status.State is "not-started" or "stopped") { - _proof = null; var conflict = InspectSnapshot(endpoint.Port, null); if (conflict.Kind != GatewayEndpointProvenanceKind.NoListener) throw new NativeGatewayListenerException(conflict); - _startedHere = true; + startedThisCall = package; + _ownedStarts.Add(package.PackageFamilyName); await _client.StartAsync(package, cancellationToken).ConfigureAwait(false); status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); } @@ -66,49 +83,74 @@ public async Task EnsureRunningAsync(GatewayRecord record, CancellationToken can var provenance = InspectSnapshot(endpoint.Port, status); if (provenance.Kind != GatewayEndpointProvenanceKind.ExpectedManagedGateway) throw new NativeGatewayListenerException(provenance); - _record = record; - _proof = status; + _proofs[record.Id] = new(record, package, status); } catch { - _proof = null; - if (_startedHere && _package is not null) + if (startedThisCall is not null) { - await _client.StopAsync(_package, CancellationToken.None).ConfigureAwait(false); - _startedHere = false; + 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; } - finally - { - _gate.Release(); - } } public async Task InspectAsync( GatewayRecord record, CancellationToken cancellationToken) { Uri endpoint = RequireRecord(record); - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + 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); - NativeGatewayPackage package = await ResolveAsync(record, cancellationToken).ConfigureAwait(false); - IsolatedGatewayStatus status = await _client.StatusAsync(package, cancellationToken).ConfigureAwait(false); + _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 != "running" || status.Port != endpoint.Port) { - _proof = null; return InspectSnapshot(endpoint.Port, null); } var provenance = InspectSnapshot(endpoint.Port, status); - _proof = provenance.Kind == GatewayEndpointProvenanceKind.ExpectedManagedGateway ? status : null; - _record = record; - _package = package; + 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 { - _gate.Release(); + if (entered) + _gate.Release(); } } @@ -121,11 +163,11 @@ public GatewayEndpointProvenance Inspect(GatewayRecord record) try { ObjectDisposedException.ThrowIf(_disposed, this); - return _record?.Id == record.Id && - _record.NativePackageFamilyName == record.NativePackageFamilyName && - GatewayRecordEditing.AreEquivalentLoopbackEndpoints(_record.Url, record.Url) && - _package is not null && _aliasExists(_package.ClawCtlAliasPath) - ? InspectSnapshot(endpoint.Port, _proof) + 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."); } @@ -140,14 +182,44 @@ public async Task StopAsync(CancellationToken cancellationToken) await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - _proof = null; - if (_startedHere && _package is not null) - { - NativeGatewayPackage package = await _resolver.ResolveAsync( - _package.PackageFamilyName, cancellationToken).ConfigureAwait(false); - await _client.StopAsync(package, cancellationToken).ConfigureAwait(false); - _startedHere = false; - } + _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 { @@ -155,6 +227,30 @@ public async Task StopAsync(CancellationToken cancellationToken) } } + 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 UnauthorizedAccessException or TimeoutException; + public async ValueTask DisposeAsync() { await _gate.WaitAsync().ConfigureAwait(false); @@ -162,17 +258,9 @@ public async ValueTask DisposeAsync() { if (_disposed) return; + _proofs.Clear(); + await StopOwnedAsync(CancellationToken.None).ConfigureAwait(false); _disposed = true; - _proof = null; - if (_startedHere && _package is not null) - { - NativeGatewayPackage package = await _resolver.ResolveAsync( - _package.PackageFamilyName, CancellationToken.None).ConfigureAwait(false); - await _client.StopAsync(package, CancellationToken.None).ConfigureAwait(false); - _startedHere = false; - } - _record = null; - _package = null; } finally { @@ -296,23 +384,41 @@ public async Task EnsureRunningAsync(GatewayRecord record, CancellationToken can public Task InspectAsync( GatewayRecord record, CancellationToken cancellationToken) => - Select(record).InspectAsync(record, 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 GatewayEndpointProvenance Inspect(GatewayRecord record) => Select(record).Inspect(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) => - _active?.StopAsync(cancellationToken) ?? Task.CompletedTask; + Task.WhenAll(legacy.StopAsync(cancellationToken), isolated.StopAsync(cancellationToken)); public async ValueTask DisposeAsync() { - await legacy.DisposeAsync().ConfigureAwait(false); - await isolated.DisposeAsync().ConfigureAwait(false); + try { await legacy.DisposeAsync().ConfigureAwait(false); } + finally { await isolated.DisposeAsync().ConfigureAwait(false); } } - private INativeGatewayRuntime Select(GatewayRecord record) => - record.NativeRuntimeContract == NativeGatewayPackageClient.IsolatedContract - ? isolated - : record.NativeRuntimeContract is null && ReferenceEquals(_active, legacy) - ? legacy - : throw new InvalidOperationException("Gateway ownership has not been established for this profile."); + 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 89d926264..b6d736fde 100644 --- a/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs +++ b/src/OpenClaw.Connection/NativeGateway/NativeGatewayContracts.cs @@ -64,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/NativeGatewayEndpointSecurity.cs b/src/OpenClaw.Connection/NativeGatewayEndpointSecurity.cs index 210ba39d9..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."); @@ -43,7 +43,7 @@ internal static async Task AuthorizeAsync( catch (NativeGatewayListenerException ex) { return new(false, - ex.Provenance.FailureReason == GatewayEndpointProvenanceFailureReason.ProcessIdentityUnavailable + IsInspectionUnavailable(ex.Provenance) ? GatewayErrorKind.Network : GatewayErrorKind.LocalPortConflict, ex.Provenance.Detail ?? ex.Message); @@ -57,5 +57,10 @@ internal static async Task AuthorizeAsync( 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/Pages/WizardPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml index 92f04b107..980cd8983 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml @@ -52,6 +52,14 @@ + + +