Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ project.lock.json
project.fragment.lock.json
artifacts/

# Local Gateway package used by the temporary setup handoff.
/OpenClawGateway-0.0.0.0-arm64.msix
/OpenClawGateway-0.0.0.1-arm64.msix
/OpenClawGateway-0.0.0.1.msixbundle
/.artifacts/native-shared-wizard/
/.artifacts/native-shared-wizard-tests/

# ASP.NET Scaffolding
ScaffoldingReadMe.txt

Expand Down
245 changes: 245 additions & 0 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

633 changes: 633 additions & 0 deletions docs/GATEWAY_SETUP_RESPONSIBILITIES.md

Large diffs are not rendered by default.

244 changes: 241 additions & 3 deletions docs/ONBOARDING_WIZARD.md

Large diffs are not rendered by default.

55 changes: 54 additions & 1 deletion docs/SETUP_ENGINE_REDESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,58 @@ The Setup Engine is a **config-driven system** for provisioning an OpenClaw WSL

The bundled `default-config.json` ships with the tray executable and provides secure defaults (loopback bind, WSL isolation, systemd enabled). Defaults can be overridden via config file or environment variables.

The separate **native Gateway MSIX** Welcome path does not use
`SetupStepFactory.BuildDefaultSteps()`. `NativeGatewaySetupService` owns its
dedicated-profile and package preparation. `NativeGatewaySetupSession` owns
staged-record runtime authorization, reload suspension/restoration, retry/cancel,
authenticated health/config gates, and final registry publication.
`WizardPage` is the single hosted WinUI wizard for both WSL and native:
`wizard.start/next/cancel` transport, upstream prompts, and provider/model cards
are not duplicated. Native uses the upstream `installDaemon: false` contract.
`NativeGatewayPackageResolver`
checks Windows package registration and package-qualified aliases.
`NativeGatewayMsixInstaller` validates the local development MSIX configured by
`OPENCLAW_GATEWAY_MSIX_PATH` and
hands it to Windows App Installer when a package is not installed.
`NativeGatewayPackageAcquisition` automatically opens it once only for missing
registration, then waits up to five minutes for verified package readiness.
Cancellation stops the wait, not Windows deployment. Repair errors and timeouts
stay visible, with explicit retry rather than repeated installer launches.
Native setup shares the capability profiles and Windows permissions page with
WSL but skips WSL/Local AI/Tailscale installation review and probes. The native
progress page uses shared spinner/checkmark rows and automatically enters the
Gateway wizard after preparing its runtime. Finalization applies the selected
Gateway command allowlist before config/health gates, then persists only the
Companion node/capability settings. Completion does not claim node pairing.
`NativeGatewaySetupHost` runs captured `clawctl setup`, config validation, and
health commands, plus an explicitly requested profile-scoped recovery terminal.
It never launches `openclaw onboard` or WSL.
`NativeGatewayRuntime` in the Connection project owns the gateway process.
This path is non-isolated, UI-only, and never downloads an MSIX or bypasses the
Windows installer. The temporary local source is ARM64-only.
Existing headless setup arguments continue to select the WSL pipeline.
See [Native Gateway MSIX](ONBOARDING_WIZARD.md#native-gateway-msix-not-isolated)
for consent, lifecycle, retry, and acquisition boundaries.

See [Gateway setup responsibilities](GATEWAY_SETUP_RESPONSIBILITIES.md) for the
Gateway packaging responsibility matrix, its comparison with WSL provisioning,
and the decided Companion-owned MXC lifecycle. The required isolated path
preserves identity/configuration across restarts, stops on exit, and deprovisions
only on explicit removal. Its package activation and listener-provenance contracts
remain blocked pending integration proof; the non-isolated runtime is not a
substitute.

The [Welcome recommendation policy](ONBOARDING_WIZARD.md#welcome) now checks
`wxc-exec --probe` session capability before recommending the existing native
Gateway. `NativeGatewaySetupEligibility` owns admission and selection policy.
Unavailable capability offers Windows Update with the pinned SDK's Insider
baseline (26340.9212); failed probes offer retry/repair instead. WSL is an explicit,
collapsed alternative shown only when native is unavailable. Welcome has no
manual recheck button; reopening the page checks again. The separate isolation warning/checkbox is removed by the
2026-09-18 product decision; general security consent remains. This is not
session provisioning. Gateway distribution includes x64, ARM64 and MSIX bundle
artifacts, but the temporary development installer remains ARM64-only.

> **Status note (2026-07-06):** Current default setup includes `WindowsNodeBootstrapContextStep`, which injects Windows-node context into the WSL workspace `AGENTS.md` after onboarding.

---
Expand Down Expand Up @@ -337,7 +389,8 @@ The WinUI app is a **thin shell** - no business logic, just rendering pipeline s

**WelcomePage**
- OpenClaw icon + "OpenClaw Setup" title bar
- Install app-owned WSL gateway (recommended) or connect to existing gateway
- Capability-checked native Gateway first and recommended; Windows Update/retry guidance when unavailable
- Collapsed WSL alternative or visible connection to an existing gateway
- Replacement prompt when an app-owned WSL gateway already exists

**CapabilitiesPage**
Expand Down
75 changes: 68 additions & 7 deletions src/OpenClaw.Connection/GatewayConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Net.Sockets;
using OpenClaw.Shared;
using OpenClaw.Shared.Telemetry;
using OpenClaw.Connection.NativeGateway;

namespace OpenClaw.Connection;

Expand Down Expand Up @@ -57,6 +58,8 @@ public sealed class GatewayConnectionManager :
private readonly IGatewayClientFactory _clientFactory;
private readonly GatewayRegistry _registry;
private readonly IOpenClawLogger _logger;
private readonly INativeGatewayRuntime? _nativeGatewayRuntime;
private GatewayRecord? _nativeGatewayRecord;
private readonly IDeviceIdentityStore? _identityStore;
private readonly INodeConnector? _nodeConnector;
private readonly ISshTunnelManager? _tunnelManager;
Expand Down Expand Up @@ -130,12 +133,14 @@ public GatewayConnectionManager(
Func<ISshTunnelManager>? validationTunnelFactory = null,
TimeSpan? credentialHandoffTimeout = null,
TimeSpan? manualSshRestartTimeout = null,
TimeSpan? manualSshRestartCleanupTimeout = null)
TimeSpan? manualSshRestartCleanupTimeout = null,
INativeGatewayRuntime? nativeGatewayRuntime = null)
{
_credentialResolver = credentialResolver ?? throw new ArgumentNullException(nameof(credentialResolver));
_clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory));
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_nativeGatewayRuntime = nativeGatewayRuntime;
_identityStore = identityStore;
_nodeConnector = nodeConnector;
_tunnelManager = tunnelManager;
Expand Down Expand Up @@ -325,6 +330,7 @@ private async Task ConnectCoreAsync(

// Dispose old client
await DisposeActiveClientAsync();
await PrepareNativeGatewayTargetAsync(record);
StartOperatorTelemetryAttempt(operation, gen);

// Update snapshot with gateway info
Expand Down Expand Up @@ -828,6 +834,7 @@ await RecordOperatorCredentialHandoffFailureAsync(
await DisposeActiveClientAsync();
}

await PrepareNativeGatewayTargetAsync(record);
_activeIdentityPath = perGatewayIdentityDir;
_activeGatewayRecordId = record.Id;
_activeSshTunnel = record.SshTunnel;
Expand Down Expand Up @@ -888,6 +895,8 @@ await RecordOperatorCredentialHandoffFailureAsync(
}
if (!nodeEndpointAuthorization.Allowed)
{
if (record.NativePackageFamilyName is not null)
_stateMachine.SetNodeErrorKind(nodeEndpointAuthorization.FailureKind);
_diagnostics.Record("setup", "Blocked node credential before managed-local endpoint ownership was proven", nodeEndpointAuthorization.Detail);
_stateMachine.SetNodeCredentialResolution(nodeCredentialResolution);
_stateMachine.BlockNodeStart(nodeEndpointAuthorization.Detail, preserveCredentialResolution: true);
Expand Down Expand Up @@ -1047,7 +1056,7 @@ public async Task DisconnectAsync()
await _transitionSemaphore.WaitAsync();
try
{
await DisconnectCoreAsync();
await DisconnectCoreAsync(stopNativeGateway: true);
}
finally
{
Expand All @@ -1064,7 +1073,7 @@ public async Task DisconnectByUserAsync()
var gatewayId = _registry.ActiveGatewayId;
if (gatewayId is not null)
SetGatewayConnectionIntent(gatewayId, shouldBeConnected: false);
await DisconnectCoreAsync();
await DisconnectCoreAsync(stopNativeGateway: true);
}
finally
{
Expand All @@ -1073,7 +1082,7 @@ public async Task DisconnectByUserAsync()
}

/// <summary>Core disconnect logic. Caller must hold <see cref="_transitionSemaphore"/>.</summary>
private async Task DisconnectCoreAsync()
private async Task DisconnectCoreAsync(bool stopNativeGateway = false)
{
CancelOperatorTelemetryAttempt("canceled", ConnectionErrorCategory.Cancelled);
Interlocked.Increment(ref _generation);
Expand All @@ -1086,6 +1095,12 @@ private async Task DisconnectCoreAsync()

var prev = _stateMachine.Current.OverallState;
await DisposeActiveClientAsync();
if (stopNativeGateway ||
(_nativeGatewayRecord is not null &&
!string.Equals(_nativeGatewayRecord.Id, _registry.ActiveGatewayId, StringComparison.Ordinal)))
{
await StopNativeGatewayAsync();
}
SyncNodeIntentFromSettings();
_stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested);
_diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState);
Expand Down Expand Up @@ -2197,9 +2212,13 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen)
var activeRecord = _activeGatewayRecordId is null
? null
: _registry.GetById(_activeGatewayRecordId);
var provenance = activeRecord is not null &&
GatewayRecordEditing.ResolveManagedDistroName(activeRecord) is not null &&
_endpointProvenanceProbe is not null
var provenance = activeRecord?.NativePackageFamilyName is not null
? _nativeGatewayRuntime is not null
? await _nativeGatewayRuntime.InspectAsync(activeRecord, CancellationToken.None).ConfigureAwait(false)
: new GatewayEndpointProvenance(GatewayEndpointProvenanceKind.UnknownListener, 0)
: activeRecord is not null &&
GatewayRecordEditing.ResolveManagedDistroName(activeRecord) is not null &&
_endpointProvenanceProbe is not null
? await _endpointProvenanceProbe(activeRecord, CancellationToken.None).ConfigureAwait(false)
: null;
var unexpectedManagedLocalOwner =
Expand Down Expand Up @@ -2348,6 +2367,12 @@ private async Task<bool> IsRecoverySafeEndpointAsync(
GatewayRecord record,
CancellationToken cancellationToken)
{
if (record.NativePackageFamilyName is not null)
{
return _nativeGatewayRuntime is not null &&
(await _nativeGatewayRuntime.InspectAsync(record, cancellationToken).ConfigureAwait(false)).Kind ==
GatewayEndpointProvenanceKind.ExpectedManagedGateway;
}
if (GatewayRecordEditing.IsLoopbackEndpoint(record.Url))
{
if (record.IsLocal || GatewayRecordEditing.ResolveManagedDistroName(record) is not null)
Expand Down Expand Up @@ -2382,6 +2407,11 @@ private async Task<EndpointCredentialAuthorization> AuthorizeCredentialForEndpoi
CancellationToken cancellationToken,
bool requireSshTunnelOwnership = false)
{
if (record.NativePackageFamilyName is not null)
{
return await NativeGatewayEndpointSecurity.AuthorizeAsync(
_nativeGatewayRuntime, record, cancellationToken).ConfigureAwait(false);
}
if (record.SshTunnel is not null)
{
if (!requireSshTunnelOwnership)
Expand Down Expand Up @@ -2565,6 +2595,10 @@ private static bool IsSameCredentialHandoffRecord(
expected.BootstrapToken,
StringComparison.Ordinal) &&
current.IsLocal == expected.IsLocal &&
string.Equals(
current.NativePackageFamilyName,
expected.NativePackageFamilyName,
StringComparison.Ordinal) &&
(current.RequiresV2Signature || !expected.RequiresV2Signature) &&
string.Equals(
current.SetupManagedDistroName,
Expand Down Expand Up @@ -3576,6 +3610,28 @@ private static void RecordTelemetryStateTransition<TState>(
]);
}

private async Task PrepareNativeGatewayTargetAsync(GatewayRecord record)
{
if (_nativeGatewayRecord is not null &&
(!string.Equals(_nativeGatewayRecord.Id, record.Id, StringComparison.Ordinal) ||
!string.Equals(_nativeGatewayRecord.Url, record.Url, StringComparison.Ordinal) ||
!string.Equals(_nativeGatewayRecord.NativePackageFamilyName, record.NativePackageFamilyName, StringComparison.Ordinal) ||
_nativeGatewayRecord.IsLocal != record.IsLocal ||
_nativeGatewayRecord.SshTunnel != record.SshTunnel))
{
await StopNativeGatewayAsync();
}
if (record.NativePackageFamilyName is not null)
_nativeGatewayRecord = record;
}

private async Task StopNativeGatewayAsync()
{
if (_nativeGatewayRuntime is not null && _nativeGatewayRecord is not null)
await _nativeGatewayRuntime.StopAsync(CancellationToken.None).ConfigureAwait(false);
_nativeGatewayRecord = null;
}

private async Task DisposeActiveClientAsync()
{
await _nodeConnectionCoordinator.RetireAsync().ConfigureAwait(false);
Expand Down Expand Up @@ -3689,6 +3745,11 @@ private async Task DisposeCoreAsync()
}
finally
{
if (_nativeGatewayRuntime is not null)
{
try { await _nativeGatewayRuntime.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.Warn($"[ConnMgr] Native gateway dispose failed: {ex.Message}"); }
}
if (semaphoreEntered)
{
try { _transitionSemaphore.Release(); }
Expand Down
26 changes: 26 additions & 0 deletions src/OpenClaw.Connection/GatewayRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public sealed record GatewayRecord
/// <summary>WSL distro name for gateway records provisioned by SetupEngine.</summary>
public string? SetupManagedDistroName { get; init; }

/// <summary>
/// Installed MSIX package family owned by the native gateway runtime. Native gateways
/// are non-isolated and must never also carry a setup-managed WSL distro marker.
/// </summary>
public string? NativePackageFamilyName { get; init; }

/// <summary>Per-gateway SSH tunnel configuration. Null if no tunnel needed.</summary>
public SshTunnelConfig? SshTunnel { get; init; }

Expand Down Expand Up @@ -70,6 +76,8 @@ public static class GatewayRecordEditing
/// the standard localhost aliases <c>localhost</c>, <c>127.0.0.1</c>, and <c>::1</c>, with scheme,
/// port, path, and query unchanged. If the user repoints the URL or adds a tunnel, the record becomes
/// manual and all managed-ownership metadata is removed.
/// Native MSIX ownership is likewise preserved only for an equivalent loopback endpoint
/// without SSH or WSL ownership. A native marker never grants legacy WSL ownership.
/// </summary>
public static GatewayRecord PreserveAdvancedFields(this GatewayRecord rebuilt, GatewayRecord? existing)
{
Expand All @@ -78,6 +86,20 @@ public static GatewayRecord PreserveAdvancedFields(this GatewayRecord rebuilt, G

var result = rebuilt with { BrowserControlPort = rebuilt.BrowserControlPort ?? existing.BrowserControlPort };

if (existing.NativePackageFamilyName is not null)
{
var preserveNative = existing.SshTunnel is null && rebuilt.SshTunnel is null &&
existing.SetupManagedDistroName is null && rebuilt.SetupManagedDistroName is null &&
AreEquivalentLoopbackEndpoints(rebuilt.Url, existing.Url);
return result with
{
NativePackageFamilyName = preserveNative ? existing.NativePackageFamilyName : null,
IsLocal = OpenClaw.Shared.LocalGatewayUrlClassifier.IsLocalGatewayUrl(rebuilt.Url),
RequiresV2Signature = preserveNative &&
(rebuilt.RequiresV2Signature || existing.RequiresV2Signature),
};
}

var stillSameManagedEndpoint = AreEquivalentManagedEndpoints(rebuilt.Url, existing.Url);
var existingManagedDistroName = ResolveManagedDistroName(existing);
var managedDistroName =
Expand Down Expand Up @@ -168,6 +190,10 @@ public static bool IsLoopbackEndpoint(string? url) =>

public static string? ResolveManagedDistroName(GatewayRecord record)
{
// A native gateway's display name must never confer legacy WSL ownership.
if (record.NativePackageFamilyName is not null)
return null;

if (!string.IsNullOrWhiteSpace(record.SetupManagedDistroName))
return record.SetupManagedDistroName;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public static bool TryResolve(
var active = registry?.GetActive();
if (active != null && !string.IsNullOrWhiteSpace(active.Url))
{
if (active.NativePackageFamilyName is not null && authorizeCredential is null)
{
credential = null;
return false;
}
// For HTTP surfaces (chat), prefer SharedGatewayToken over DeviceToken.
// DeviceToken is for WebSocket auth (auth.deviceToken); SharedGatewayToken
// is for HTTP ?token= auth which the chat/dashboard endpoints expect.
Expand Down Expand Up @@ -102,7 +107,8 @@ public static bool TryResolve(
return true;
}

if (!string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase))
if (active.NativePackageFamilyName is not null ||
!string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase))
{
credential = null;
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,13 +285,20 @@ public Task<GatewayEndpointProvenance> InspectAsync(

public GatewayEndpointProvenance Inspect(GatewayRecord record)
{
if (record.NativePackageFamilyName is not null)
{
return new(GatewayEndpointProvenanceKind.UnknownListener, 0,
Detail: "Native Gateway ownership must be verified by its Companion runtime, not WSL.");
}
var result = InspectCore(record);
_lastProvenance[new ProvenanceCacheKey(record.Id, record.Url)] = result;
return result;
}

public bool IsStrongCredentialAllowed(GatewayRecord record, GatewayCredential credential)
{
if (record.NativePackageFamilyName is not null)
return false;
var isStrong =
credential.IsBootstrapToken ||
string.Equals(credential.Source, CredentialResolver.SourceSharedGatewayToken, StringComparison.Ordinal) ||
Expand Down
Loading
Loading