feat: improve QR setup pairing
Preserve setup bootstrap tokens separately from gateway tokens, support QR image and clipboard setup imports, and improve pairing notification copy flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c1296be7fd
commit
00670860ed
@ -238,25 +238,41 @@ When a node connects with `auth: { token: "...", bootstrapToken: "..." }`, the g
|
||||
- Preferred over shared-secret even if both succeed (QR flow relies on this)
|
||||
3. **Device token** — `auth.token` as device-token fallback (for already-paired devices)
|
||||
|
||||
### 4.4 What Our Setup Wizard Does (and the Gap)
|
||||
### 4.4 Setup Wizard Entry Points
|
||||
|
||||
Currently, our Setup Wizard:
|
||||
1. Decodes the setup code from `openclaw qr`
|
||||
2. Extracts `url` and `bootstrapToken`
|
||||
3. Stores `bootstrapToken` as the settings `Token` field
|
||||
4. Sends it as `auth.token` in the connect handshake
|
||||
The setup code and QR code are the same bootstrap concept in different packaging:
|
||||
|
||||
**The problem**: We send it as `auth.token`, not `auth.bootstrapToken`. The gateway's auth resolution:
|
||||
- Tries `auth.token` as shared-secret → **fails** (it's not the gateway token)
|
||||
- Never sees `auth.bootstrapToken` → never tries bootstrap-token auth
|
||||
- Falls back to device-token → **fails** (no prior pairing)
|
||||
```text
|
||||
QR image
|
||||
-> decodes to setup code text
|
||||
-> decodes to JSON payload
|
||||
-> contains gateway URL + bootstrapToken + expiry
|
||||
```
|
||||
|
||||
**The fix**: Send the bootstrap token as `auth.bootstrapToken` in the connect payload, separate from `auth.token`. This lets the gateway correctly classify it as a bootstrap-token handshake, which enables:
|
||||
Advanced users can drop into setup at any level:
|
||||
|
||||
| Entry point | User has | Wizard behavior |
|
||||
|---|---|---|
|
||||
| QR image | A saved/screenshot/email attachment containing the QR | Import or paste the image, decode QR text, then decode the setup payload |
|
||||
| Setup code | The pasteable text from `openclaw qr` | Paste the text directly, then decode the setup payload |
|
||||
| Manual URL + token | Gateway URL/IP and a long-lived gateway token | Skip bootstrap; connect with `auth.token` and use manual approval if required |
|
||||
|
||||
The QR/setup-code path is preferred for first-time node onboarding because it avoids telling users to copy permanent gateway secrets and enables auto-approval.
|
||||
|
||||
### 4.5 What Our Setup Wizard Does
|
||||
|
||||
The Windows Setup Wizard:
|
||||
1. Accepts a QR image, clipboard QR image, pasteable setup code, or manual gateway URL/token.
|
||||
2. For QR/setup-code input, decodes `{ url, bootstrapToken, expiresAtMs }`.
|
||||
3. Stores `bootstrapToken` separately from the normal gateway `Token` setting.
|
||||
4. Sends it as `auth.bootstrapToken` in the node connect handshake.
|
||||
|
||||
This lets the gateway correctly classify QR setup as a bootstrap-token handshake, which enables:
|
||||
- Silent auto-approval (no manual `devices approve` needed)
|
||||
- Bootstrap token revocation after pairing
|
||||
- Bounded operator token handoff (if configured)
|
||||
|
||||
### 4.5 Post-Pairing: Device Tokens
|
||||
### 4.6 Post-Pairing: Device Tokens
|
||||
|
||||
After a successful bootstrap-token pairing:
|
||||
1. Gateway issues a `deviceToken` in `hello-ok.auth.deviceToken`
|
||||
@ -264,13 +280,13 @@ After a successful bootstrap-token pairing:
|
||||
3. Future connections use `auth.token = <deviceToken>` (device-token auth path)
|
||||
4. The bootstrap token is revoked and no longer valid
|
||||
|
||||
**We're not doing step 2-3 yet.** Our node uses the same settings token forever. It works because the settings token matches the gateway's shared secret (if the user entered it manually), but it means QR-based pairing doesn't complete the handoff properly.
|
||||
Windows stores `hello-ok.auth.deviceToken` in its device identity file and prefers that saved device token on future node connections. The bootstrap token is only used when there is no saved device token yet.
|
||||
|
||||
### 4.6 Ideal Bootstrap Flow (What We Should Implement)
|
||||
### 4.7 Bootstrap Flow
|
||||
|
||||
```
|
||||
1. User runs `openclaw qr` on gateway host
|
||||
2. User pastes setup code into Windows Setup Wizard
|
||||
2. User imports/scans QR image or pastes setup code into Windows Setup Wizard
|
||||
3. Wizard decodes → { url, bootstrapToken, expiresAtMs }
|
||||
4. Node connects with: auth: { bootstrapToken: "<token>" }
|
||||
5. Gateway auto-approves pairing (bootstrap-token auth method)
|
||||
@ -280,7 +296,7 @@ After a successful bootstrap-token pairing:
|
||||
9. No manual `devices approve` needed!
|
||||
```
|
||||
|
||||
This would make pairing truly seamless — scan QR, auto-paired, done.
|
||||
Manual URL/token setup remains useful for advanced troubleshooting and environments where QR/bootstrap is unavailable. In that path, the tray may show a pairing notification with an `openclaw devices approve <device-id>` command that must be run on the gateway host.
|
||||
|
||||
---
|
||||
|
||||
@ -319,10 +335,11 @@ Until the gateway expands Windows safe defaults, the practical local solution is
|
||||
- [x] Remove `screen.list` from declared commands
|
||||
- [ ] Remove debug logging from `WindowsNodeClient.cs` (done)
|
||||
|
||||
### 5.2 Setup Wizard Improvements (Next Sprint)
|
||||
### 5.2 Setup Wizard Improvements
|
||||
|
||||
- [ ] Send `bootstrapToken` in correct field: `auth.bootstrapToken` not `auth.token`
|
||||
- [ ] Handle `hello-ok.auth.deviceToken` — save it for future connections
|
||||
- [x] Send `bootstrapToken` in correct field: `auth.bootstrapToken` not `auth.token`
|
||||
- [x] Handle `hello-ok.auth.deviceToken` — save it for future connections
|
||||
- [x] Accept QR images and clipboard setup content as alternate ways to enter the same bootstrap payload
|
||||
- [ ] Show "auto-paired!" vs "waiting for approval" based on auth method
|
||||
- [ ] Handle bootstrap token expiry gracefully (re-generate if expired)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestPatch"
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ public class SettingsData
|
||||
{
|
||||
public string? GatewayUrl { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public string? BootstrapToken { get; set; }
|
||||
public bool UseSshTunnel { get; set; } = false;
|
||||
public string? SshTunnelUser { get; set; }
|
||||
public string? SshTunnelHost { get; set; }
|
||||
|
||||
@ -29,6 +29,8 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
private bool _isPaired;
|
||||
// Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken.
|
||||
private bool _pairingApprovedAwaitingReconnect;
|
||||
private readonly string _gatewayToken;
|
||||
private readonly string? _bootstrapToken;
|
||||
|
||||
// Cached serialization/validation — reused on every message instead of allocating per-call
|
||||
private static readonly JsonSerializerOptions s_ignoreNullOptions = new()
|
||||
@ -64,9 +66,12 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
protected override int ReceiveBufferSize => 65536;
|
||||
protected override string ClientRole => "node";
|
||||
|
||||
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null)
|
||||
: base(gatewayUrl, token, logger)
|
||||
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null)
|
||||
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken), logger)
|
||||
{
|
||||
_gatewayToken = NormalizeOptionalCredential(token);
|
||||
_bootstrapToken = NormalizeOptionalCredential(bootstrapToken);
|
||||
|
||||
// Initialize device identity
|
||||
_deviceIdentity = new DeviceIdentity(dataPath, _logger);
|
||||
_deviceIdentity.Initialize();
|
||||
@ -80,6 +85,28 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
DisplayName = $"Windows Node ({Environment.MachineName})"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeOptionalCredential(string? credential)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential;
|
||||
}
|
||||
|
||||
private static string ResolveRequiredCredential(string? token, string? bootstrapToken)
|
||||
{
|
||||
var gatewayToken = NormalizeOptionalCredential(token);
|
||||
if (!string.IsNullOrEmpty(gatewayToken))
|
||||
{
|
||||
return gatewayToken;
|
||||
}
|
||||
|
||||
var bootstrap = NormalizeOptionalCredential(bootstrapToken);
|
||||
if (!string.IsNullOrEmpty(bootstrap))
|
||||
{
|
||||
return bootstrap;
|
||||
}
|
||||
|
||||
throw new ArgumentException("Token or bootstrap token is required.", nameof(token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a capability handler
|
||||
@ -445,30 +472,35 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
private const string ClientId = "node-host"; // Must be "node-host" for nodes
|
||||
|
||||
private async Task SendNodeConnectAsync(string? nonce, long ts)
|
||||
{
|
||||
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
|
||||
var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken);
|
||||
|
||||
_logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})");
|
||||
|
||||
await SendRawAsync(BuildNodeConnectMessage(nonce, ts));
|
||||
_logger.Info($"Sent node registration with device ID: {_deviceIdentity.DeviceId[..16]}..., paired: {isPaired}");
|
||||
}
|
||||
|
||||
private string BuildNodeConnectMessage(string? nonce, long ts)
|
||||
{
|
||||
// Sign the full payload with Ed25519 - this is how device pairing works
|
||||
string? signature = null;
|
||||
var signedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Use device token if we have one (already paired), otherwise use operator token
|
||||
// IMPORTANT: This token must be included in the signed payload!
|
||||
var authToken = _deviceIdentity.DeviceToken ?? _token;
|
||||
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
|
||||
var (auth, tokenForSignature) = BuildConnectAuth();
|
||||
|
||||
if (!string.IsNullOrEmpty(nonce))
|
||||
{
|
||||
try
|
||||
{
|
||||
signature = _deviceIdentity.SignPayload(nonce, signedAt, ClientId, authToken);
|
||||
signature = _deviceIdentity.SignPayload(nonce, signedAt, ClientId, tokenForSignature);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to sign payload: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired})");
|
||||
|
||||
|
||||
// Always include device identity - this is required for pairing
|
||||
var msg = new
|
||||
{
|
||||
@ -492,7 +524,7 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
caps = _registration.Capabilities,
|
||||
commands = _registration.Commands,
|
||||
permissions = _registration.Permissions,
|
||||
auth = new { token = authToken },
|
||||
auth,
|
||||
locale = "en-US",
|
||||
userAgent = $"openclaw-windows-node/{_registration.Version}",
|
||||
device = new
|
||||
@ -505,9 +537,23 @@ public class WindowsNodeClient : WebSocketClientBase
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await SendRawAsync(JsonSerializer.Serialize(msg));
|
||||
_logger.Info($"Sent node registration with device ID: {_deviceIdentity.DeviceId[..16]}..., paired: {isPaired}");
|
||||
|
||||
return JsonSerializer.Serialize(msg, s_ignoreNullOptions);
|
||||
}
|
||||
|
||||
private (Dictionary<string, string> Auth, string TokenForSignature) BuildConnectAuth()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
|
||||
{
|
||||
return (new Dictionary<string, string> { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_bootstrapToken))
|
||||
{
|
||||
return (new Dictionary<string, string> { ["bootstrapToken"] = _bootstrapToken }, string.Empty);
|
||||
}
|
||||
|
||||
return (new Dictionary<string, string> { ["token"] = _gatewayToken }, _gatewayToken);
|
||||
}
|
||||
|
||||
private void HandleResponse(JsonElement root)
|
||||
|
||||
@ -274,7 +274,7 @@ public partial class App : Application
|
||||
_sshTunnelService.TunnelExited += OnSshTunnelExited;
|
||||
|
||||
// First-run check
|
||||
if (string.IsNullOrWhiteSpace(_settings.Token))
|
||||
if (RequiresSetup(_settings))
|
||||
{
|
||||
await ShowSetupWizardAsync();
|
||||
}
|
||||
@ -1152,9 +1152,10 @@ public partial class App : Application
|
||||
{
|
||||
if (_settings == null || !_settings.EnableNodeMode) return;
|
||||
if (_dispatcherQueue == null) return;
|
||||
if (string.IsNullOrWhiteSpace(_settings.Token))
|
||||
if (string.IsNullOrWhiteSpace(_settings.Token) &&
|
||||
string.IsNullOrWhiteSpace(_settings.BootstrapToken))
|
||||
{
|
||||
Logger.Warn("Node mode enabled but no token configured — skipping node service. Run Setup Guide to configure.");
|
||||
Logger.Warn("Node mode enabled but no token or bootstrap token configured — skipping node service. Run Setup Guide to configure.");
|
||||
return;
|
||||
}
|
||||
if (!EnsureSshTunnelConfigured()) return;
|
||||
@ -1169,13 +1170,23 @@ public partial class App : Application
|
||||
_nodeService.PairingStatusChanged += OnPairingStatusChanged;
|
||||
|
||||
// Connect to gateway as a node (separate connection from operator)
|
||||
_ = _nodeService.ConnectAsync(_settings.GetEffectiveGatewayUrl(), _settings.Token);
|
||||
_ = _nodeService.ConnectAsync(_settings.GetEffectiveGatewayUrl(), _settings.Token, _settings.BootstrapToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Failed to initialize node service: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiresSetup(SettingsManager settings)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(settings.Token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(settings.EnableNodeMode && !string.IsNullOrWhiteSpace(settings.BootstrapToken));
|
||||
}
|
||||
|
||||
private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
|
||||
{
|
||||
@ -1211,10 +1222,15 @@ public partial class App : Application
|
||||
if (args.Status == OpenClaw.Shared.PairingStatus.Pending)
|
||||
{
|
||||
AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
|
||||
var approvalCommand = $"openclaw devices approve {args.DeviceId}";
|
||||
// Show toast with approval instructions
|
||||
ShowToast(new ToastContentBuilder()
|
||||
.AddText(LocalizationHelper.GetString("Toast_PairingPending"))
|
||||
.AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16))));
|
||||
.AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16)))
|
||||
.AddButton(new ToastButton()
|
||||
.SetContent(LocalizationHelper.GetString("Toast_CopyPairingCommand"))
|
||||
.AddArgument("action", "copy_pairing_command")
|
||||
.AddArgument("command", approvalCommand)));
|
||||
}
|
||||
else if (args.Status == OpenClaw.Shared.PairingStatus.Paired)
|
||||
{
|
||||
@ -2150,11 +2166,24 @@ public partial class App : Application
|
||||
case "open_activity":
|
||||
ShowActivityStream();
|
||||
break;
|
||||
case "copy_pairing_command" when arguments.TryGetValue("command", out var command):
|
||||
CopyTextToClipboard(command);
|
||||
ShowToast(new ToastContentBuilder()
|
||||
.AddText(LocalizationHelper.GetString("Toast_PairingCommandCopied"))
|
||||
.AddText(command));
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyTextToClipboard(string text)
|
||||
{
|
||||
var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage();
|
||||
dataPackage.SetText(text);
|
||||
global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exit
|
||||
|
||||
@ -39,9 +39,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260101001" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="WinUIEx" Version="2.9.0" />
|
||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
||||
<PackageReference Include="Updatum" Version="1.3.4" />
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -59,7 +59,7 @@ public class NodeService : IDisposable
|
||||
/// <summary>
|
||||
/// Initialize and connect the node
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(string gatewayUrl, string token)
|
||||
public async Task ConnectAsync(string gatewayUrl, string token, string? bootstrapToken = null)
|
||||
{
|
||||
if (_nodeClient != null)
|
||||
{
|
||||
@ -69,7 +69,7 @@ public class NodeService : IDisposable
|
||||
_logger.Info($"Starting Windows Node connection to {GatewayUrlHelper.SanitizeForDisplay(gatewayUrl)}");
|
||||
_token = token;
|
||||
|
||||
_nodeClient = new WindowsNodeClient(gatewayUrl, token, _dataPath, _logger);
|
||||
_nodeClient = new WindowsNodeClient(gatewayUrl, token, _dataPath, _logger, bootstrapToken);
|
||||
_nodeClient.StatusChanged += OnNodeStatusChanged;
|
||||
_nodeClient.PairingStatusChanged += OnPairingStatusChanged;
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ public class SettingsManager
|
||||
// Connection
|
||||
public string GatewayUrl { get; set; } = "ws://localhost:18789";
|
||||
public string Token { get; set; } = "";
|
||||
public string BootstrapToken { get; set; } = "";
|
||||
public bool UseSshTunnel { get; set; } = false;
|
||||
public string SshTunnelUser { get; set; } = "";
|
||||
public string SshTunnelHost { get; set; } = "";
|
||||
@ -70,6 +71,7 @@ public class SettingsManager
|
||||
{
|
||||
GatewayUrl = loaded.GatewayUrl ?? GatewayUrl;
|
||||
Token = loaded.Token ?? Token;
|
||||
BootstrapToken = loaded.BootstrapToken ?? BootstrapToken;
|
||||
UseSshTunnel = loaded.UseSshTunnel;
|
||||
SshTunnelUser = loaded.SshTunnelUser ?? SshTunnelUser;
|
||||
SshTunnelHost = loaded.SshTunnelHost ?? SshTunnelHost;
|
||||
@ -113,6 +115,7 @@ public class SettingsManager
|
||||
{
|
||||
GatewayUrl = GatewayUrl,
|
||||
Token = Token,
|
||||
BootstrapToken = string.IsNullOrWhiteSpace(BootstrapToken) ? null : BootstrapToken,
|
||||
UseSshTunnel = UseSshTunnel,
|
||||
SshTunnelUser = SshTunnelUser,
|
||||
SshTunnelHost = SshTunnelHost,
|
||||
|
||||
@ -569,6 +569,12 @@ Use one of these options:
|
||||
<data name="Toast_PairingPendingDetail" xml:space="preserve">
|
||||
<value>Run on gateway: openclaw devices approve {0}...</value>
|
||||
</data>
|
||||
<data name="Toast_CopyPairingCommand" xml:space="preserve">
|
||||
<value>Copy approval command</value>
|
||||
</data>
|
||||
<data name="Toast_PairingCommandCopied" xml:space="preserve">
|
||||
<value>Pairing command copied</value>
|
||||
</data>
|
||||
<data name="Toast_NodePaired" xml:space="preserve">
|
||||
<value>✅ Node Paired!</value>
|
||||
</data>
|
||||
@ -644,6 +650,21 @@ Use one of these options:
|
||||
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
|
||||
<value>Paste the setup code from your gateway dashboard</value>
|
||||
</data>
|
||||
<data name="Setup_PasteSetupButton" xml:space="preserve">
|
||||
<value>Paste setup code / QR</value>
|
||||
</data>
|
||||
<data name="Setup_ImportQrButton" xml:space="preserve">
|
||||
<value>Import QR image...</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecoded" xml:space="preserve">
|
||||
<value>✅ QR image decoded — press Test Connection</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecodeFailed" xml:space="preserve">
|
||||
<value>❌ Could not find a valid setup QR code in that image.</value>
|
||||
</data>
|
||||
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
|
||||
<value>❌ Clipboard does not contain setup code text or a QR image.</value>
|
||||
</data>
|
||||
<data name="Setup_ManualEntryToggle" xml:space="preserve">
|
||||
<value>Or enter URL and token manually ▾</value>
|
||||
</data>
|
||||
|
||||
@ -569,6 +569,12 @@ Utilisez l'une de ces options :
|
||||
<data name="Toast_PairingPendingDetail" xml:space="preserve">
|
||||
<value>Lancer sur la passerelle : openclaw devices approve {0}...</value>
|
||||
</data>
|
||||
<data name="Toast_CopyPairingCommand" xml:space="preserve">
|
||||
<value>Copier la commande d'approbation</value>
|
||||
</data>
|
||||
<data name="Toast_PairingCommandCopied" xml:space="preserve">
|
||||
<value>Commande de jumelage copiée</value>
|
||||
</data>
|
||||
<data name="Toast_NodePaired" xml:space="preserve">
|
||||
<value>✅ Noeud appairé!</value>
|
||||
</data>
|
||||
@ -644,6 +650,21 @@ Utilisez l'une de ces options :
|
||||
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
|
||||
<value>Collez le code de configuration depuis le tableau de bord de votre passerelle</value>
|
||||
</data>
|
||||
<data name="Setup_PasteSetupButton" xml:space="preserve">
|
||||
<value>Coller le code / QR</value>
|
||||
</data>
|
||||
<data name="Setup_ImportQrButton" xml:space="preserve">
|
||||
<value>Importer une image QR...</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecoded" xml:space="preserve">
|
||||
<value>✅ Image QR décodée — cliquez sur Tester la connexion</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecodeFailed" xml:space="preserve">
|
||||
<value>❌ Aucun code QR de configuration valide trouvé dans cette image.</value>
|
||||
</data>
|
||||
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
|
||||
<value>❌ Le presse-papiers ne contient pas de code de configuration texte ni d'image QR.</value>
|
||||
</data>
|
||||
<data name="Setup_ManualEntryToggle" xml:space="preserve">
|
||||
<value>Ou entrez l'URL et le jeton manuellement ▾</value>
|
||||
</data>
|
||||
|
||||
@ -569,6 +569,12 @@ Gebruik een van deze opties:
|
||||
<data name="Toast_PairingPendingDetail" xml:space="preserve">
|
||||
<value>Voer uit op gateway: openclaw devices approve {0}...</value>
|
||||
</data>
|
||||
<data name="Toast_CopyPairingCommand" xml:space="preserve">
|
||||
<value>Goedkeuringsopdracht kopiëren</value>
|
||||
</data>
|
||||
<data name="Toast_PairingCommandCopied" xml:space="preserve">
|
||||
<value>Koppelingsopdracht gekopieerd</value>
|
||||
</data>
|
||||
<data name="Toast_NodePaired" xml:space="preserve">
|
||||
<value>✅ Node gekoppeld!</value>
|
||||
</data>
|
||||
@ -644,6 +650,21 @@ Gebruik een van deze opties:
|
||||
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
|
||||
<value>Plak de installatiecode van uw gateway-dashboard</value>
|
||||
</data>
|
||||
<data name="Setup_PasteSetupButton" xml:space="preserve">
|
||||
<value>Setupcode / QR plakken</value>
|
||||
</data>
|
||||
<data name="Setup_ImportQrButton" xml:space="preserve">
|
||||
<value>QR-afbeelding importeren...</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecoded" xml:space="preserve">
|
||||
<value>✅ QR-afbeelding gedecodeerd — klik op Verbinding testen</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecodeFailed" xml:space="preserve">
|
||||
<value>❌ Geen geldige setup-QR-code gevonden in die afbeelding.</value>
|
||||
</data>
|
||||
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
|
||||
<value>❌ Het klembord bevat geen setupcodetekst of QR-afbeelding.</value>
|
||||
</data>
|
||||
<data name="Setup_ManualEntryToggle" xml:space="preserve">
|
||||
<value>Of voer URL en token handmatig in ▾</value>
|
||||
</data>
|
||||
|
||||
@ -569,6 +569,12 @@
|
||||
<data name="Toast_PairingPendingDetail" xml:space="preserve">
|
||||
<value>在网关上运行: openclaw devices approve {0}...</value>
|
||||
</data>
|
||||
<data name="Toast_CopyPairingCommand" xml:space="preserve">
|
||||
<value>复制批准命令</value>
|
||||
</data>
|
||||
<data name="Toast_PairingCommandCopied" xml:space="preserve">
|
||||
<value>配对命令已复制</value>
|
||||
</data>
|
||||
<data name="Toast_NodePaired" xml:space="preserve">
|
||||
<value>✅ 节点已配对!</value>
|
||||
</data>
|
||||
@ -644,6 +650,21 @@
|
||||
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
|
||||
<value>粘贴来自网关仪表板的设置码</value>
|
||||
</data>
|
||||
<data name="Setup_PasteSetupButton" xml:space="preserve">
|
||||
<value>粘贴设置码 / QR</value>
|
||||
</data>
|
||||
<data name="Setup_ImportQrButton" xml:space="preserve">
|
||||
<value>导入 QR 图片...</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecoded" xml:space="preserve">
|
||||
<value>✅ QR 图片已解码 — 请点击"测试连接"</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecodeFailed" xml:space="preserve">
|
||||
<value>❌ 无法在该图片中找到有效的设置 QR 码。</value>
|
||||
</data>
|
||||
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
|
||||
<value>❌ 剪贴板不包含设置码文本或 QR 图片。</value>
|
||||
</data>
|
||||
<data name="Setup_ManualEntryToggle" xml:space="preserve">
|
||||
<value>或手动输入 URL 和令牌 ▾</value>
|
||||
</data>
|
||||
|
||||
@ -569,6 +569,12 @@
|
||||
<data name="Toast_PairingPendingDetail" xml:space="preserve">
|
||||
<value>在閘道器上執行: openclaw devices approve {0}...</value>
|
||||
</data>
|
||||
<data name="Toast_CopyPairingCommand" xml:space="preserve">
|
||||
<value>複製核准命令</value>
|
||||
</data>
|
||||
<data name="Toast_PairingCommandCopied" xml:space="preserve">
|
||||
<value>配對命令已複製</value>
|
||||
</data>
|
||||
<data name="Toast_NodePaired" xml:space="preserve">
|
||||
<value>✅ 節點已配對!</value>
|
||||
</data>
|
||||
@ -644,6 +650,21 @@
|
||||
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
|
||||
<value>貼上來自閘道器儀表板的設定碼</value>
|
||||
</data>
|
||||
<data name="Setup_PasteSetupButton" xml:space="preserve">
|
||||
<value>貼上設定碼 / QR</value>
|
||||
</data>
|
||||
<data name="Setup_ImportQrButton" xml:space="preserve">
|
||||
<value>匯入 QR 圖片...</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecoded" xml:space="preserve">
|
||||
<value>✅ QR 圖片已解碼 — 請按「測試連線」</value>
|
||||
</data>
|
||||
<data name="Setup_QrDecodeFailed" xml:space="preserve">
|
||||
<value>❌ 無法在該圖片中找到有效的設定 QR 碼。</value>
|
||||
</data>
|
||||
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
|
||||
<value>❌ 剪貼簿不包含設定碼文字或 QR 圖片。</value>
|
||||
</data>
|
||||
<data name="Setup_ManualEntryToggle" xml:space="preserve">
|
||||
<value>或手動輸入 URL 和權杖 ▾</value>
|
||||
</data>
|
||||
|
||||
@ -91,6 +91,10 @@ public sealed partial class SettingsWindow : WindowEx
|
||||
_manualGatewayUrl = _settings.GatewayUrl;
|
||||
}
|
||||
_settings.Token = TokenTextBox.Text.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(_settings.Token))
|
||||
{
|
||||
_settings.BootstrapToken = "";
|
||||
}
|
||||
_settings.AutoStart = AutoStartToggle.IsOn;
|
||||
_settings.GlobalHotkeyEnabled = GlobalHotkeyToggle.IsOn;
|
||||
_settings.ShowNotifications = NotificationsToggle.IsOn;
|
||||
|
||||
@ -7,8 +7,20 @@ using OpenClaw.Shared;
|
||||
using OpenClawTray.Helpers;
|
||||
using OpenClawTray.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
using Windows.Storage.Pickers;
|
||||
using WinUIEx;
|
||||
using ZXing;
|
||||
using ZXing.Common;
|
||||
using DrawingBitmap = System.Drawing.Bitmap;
|
||||
using DrawingGraphics = System.Drawing.Graphics;
|
||||
using DrawingImageLockMode = System.Drawing.Imaging.ImageLockMode;
|
||||
using DrawingPixelFormat = System.Drawing.Imaging.PixelFormat;
|
||||
|
||||
namespace OpenClawTray.Windows;
|
||||
|
||||
@ -25,6 +37,7 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
// Draft settings (not saved until Finish)
|
||||
private string _draftGatewayUrl = "ws://";
|
||||
private string _draftToken = "";
|
||||
private string _draftBootstrapToken = "";
|
||||
private bool _draftEnableNodeMode = false;
|
||||
|
||||
// UI elements
|
||||
@ -59,10 +72,11 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
_existingSettings = settings;
|
||||
_draftGatewayUrl = settings.GatewayUrl;
|
||||
_draftToken = settings.Token;
|
||||
_draftBootstrapToken = settings.BootstrapToken;
|
||||
_draftEnableNodeMode = settings.EnableNodeMode;
|
||||
|
||||
Title = LocalizationHelper.GetString("Setup_Title");
|
||||
this.SetWindowSize(720, 700);
|
||||
this.SetWindowSize(720, 900);
|
||||
this.CenterOnScreen();
|
||||
this.SetIcon("Assets\\openclaw.ico");
|
||||
SystemBackdrop = new MicaBackdrop();
|
||||
@ -134,6 +148,22 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
_setupCodeBox.TextChanged += OnSetupCodeChanged;
|
||||
_stepPanels[0].Children.Add(_setupCodeBox);
|
||||
|
||||
var setupCodeActions = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8
|
||||
};
|
||||
var pasteSetupButton = new Button { Content = LocalizationHelper.GetString("Setup_PasteSetupButton") };
|
||||
AutomationProperties.SetAutomationId(pasteSetupButton, "PasteSetupButton");
|
||||
pasteSetupButton.Click += OnPasteSetupFromClipboard;
|
||||
setupCodeActions.Children.Add(pasteSetupButton);
|
||||
|
||||
var importQrButton = new Button { Content = LocalizationHelper.GetString("Setup_ImportQrButton") };
|
||||
AutomationProperties.SetAutomationId(importQrButton, "ImportQrButton");
|
||||
importQrButton.Click += OnImportQrImage;
|
||||
setupCodeActions.Children.Add(importQrButton);
|
||||
_stepPanels[0].Children.Add(setupCodeActions);
|
||||
|
||||
// Manual entry toggle
|
||||
var manualToggle = new HyperlinkButton { Content = LocalizationHelper.GetString("Setup_ManualEntryToggle") };
|
||||
AutomationProperties.SetAutomationId(manualToggle, "ManualEntryToggle");
|
||||
@ -208,10 +238,7 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
AutomationProperties.SetAutomationId(_nodeModeToggle, "NodeModeToggle");
|
||||
_nodeModeToggle.Toggled += (s, e) =>
|
||||
{
|
||||
var showPairing = _nodeModeToggle.IsOn;
|
||||
_deviceIdText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
|
||||
_copyDeviceIdButton.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
|
||||
_pairingStatusText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdateNodeModePairingVisibility(_nodeModeToggle.IsOn);
|
||||
};
|
||||
_stepPanels[1].Children.Add(_nodeModeToggle);
|
||||
|
||||
@ -382,17 +409,30 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
{
|
||||
_connectionTested = false;
|
||||
var code = _setupCodeBox.Text.Trim();
|
||||
if (string.IsNullOrEmpty(code)) return;
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
_draftBootstrapToken = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryApplySetupCode(code, LocalizationHelper.GetString("Setup_CodeDecoded")))
|
||||
{
|
||||
// Not a valid setup code; that's fine, user might be typing manually.
|
||||
_draftBootstrapToken = "";
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryApplySetupCode(string code, string successMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try base64url decode
|
||||
var b64 = code.Replace('-', '+').Replace('_', '/');
|
||||
var b64 = code.Trim().Replace('-', '+').Replace('_', '/');
|
||||
var pad = b64.Length % 4;
|
||||
if (pad > 0) b64 += new string('=', 4 - pad);
|
||||
|
||||
var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
||||
var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
|
||||
if (doc.RootElement.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
@ -401,19 +441,165 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("bootstrapToken", out var tokenProp))
|
||||
{
|
||||
_draftToken = tokenProp.GetString() ?? "";
|
||||
_tokenBox.Password = _draftToken;
|
||||
_draftBootstrapToken = tokenProp.GetString() ?? "";
|
||||
_draftEnableNodeMode = !string.IsNullOrWhiteSpace(_draftBootstrapToken);
|
||||
_nodeModeToggle.IsOn = _draftEnableNodeMode;
|
||||
UpdateNodeModePairingVisibility(_draftEnableNodeMode);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_draftGatewayUrl) ||
|
||||
string.IsNullOrWhiteSpace(_draftBootstrapToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show manual fields so user can see what was decoded
|
||||
_manualEntryPanel.Visibility = Visibility.Visible;
|
||||
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_CodeDecoded");
|
||||
_testStatusLabel.Text = successMessage;
|
||||
_connectionTested = GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl);
|
||||
Logger.Info($"[Setup] Setup code decoded: gateway={GatewayUrlHelper.SanitizeForDisplay(_draftGatewayUrl)}");
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (System.FormatException)
|
||||
{
|
||||
// Not a valid setup code — that's fine, user might be typing manually
|
||||
return false;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPasteSetupFromClipboard(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = Clipboard.GetContent();
|
||||
if (content.Contains(StandardDataFormats.Text))
|
||||
{
|
||||
var text = await content.GetTextAsync();
|
||||
ApplyDecodedSetupCode(text, LocalizationHelper.GetString("Setup_CodeDecoded"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.Contains(StandardDataFormats.Bitmap))
|
||||
{
|
||||
var bitmapReference = await content.GetBitmapAsync();
|
||||
using var randomAccessStream = await bitmapReference.OpenReadAsync();
|
||||
using var stream = randomAccessStream.AsStreamForRead();
|
||||
var setupCode = DecodeQrSetupCode(stream);
|
||||
ApplyDecodedSetupCode(setupCode, LocalizationHelper.GetString("Setup_QrDecoded"));
|
||||
return;
|
||||
}
|
||||
|
||||
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_ClipboardUnsupported");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Logger.Warn($"[Setup] Clipboard setup import failed: {ex.Message}");
|
||||
_testStatusLabel.Text = ex is InvalidOperationException
|
||||
? ex.Message
|
||||
: LocalizationHelper.GetString("Setup_ClipboardUnsupported");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnImportQrImage(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var picker = new FileOpenPicker();
|
||||
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
|
||||
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
|
||||
picker.FileTypeFilter.Add(".png");
|
||||
picker.FileTypeFilter.Add(".jpg");
|
||||
picker.FileTypeFilter.Add(".jpeg");
|
||||
picker.FileTypeFilter.Add(".bmp");
|
||||
picker.FileTypeFilter.Add(".gif");
|
||||
|
||||
var file = await picker.PickSingleFileAsync();
|
||||
if (file == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var randomAccessStream = await file.OpenReadAsync();
|
||||
using var stream = randomAccessStream.AsStreamForRead();
|
||||
var setupCode = DecodeQrSetupCode(stream);
|
||||
ApplyDecodedSetupCode(setupCode, LocalizationHelper.GetString("Setup_QrDecoded"));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Logger.Warn($"[Setup] QR image import failed: {ex.Message}");
|
||||
_testStatusLabel.Text = ex is InvalidOperationException
|
||||
? ex.Message
|
||||
: LocalizationHelper.GetString("Setup_QrDecodeFailed");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDecodedSetupCode(string setupCode, string successMessage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(setupCode))
|
||||
{
|
||||
throw new InvalidOperationException(LocalizationHelper.GetString("Setup_QrDecodeFailed"));
|
||||
}
|
||||
|
||||
_setupCodeBox.Text = setupCode.Trim();
|
||||
if (!TryApplySetupCode(setupCode, successMessage))
|
||||
{
|
||||
throw new InvalidOperationException(LocalizationHelper.GetString("Setup_QrDecodeFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string DecodeQrSetupCode(Stream stream)
|
||||
{
|
||||
using var source = new DrawingBitmap(stream);
|
||||
using var bitmap = new DrawingBitmap(source.Width, source.Height, DrawingPixelFormat.Format32bppArgb);
|
||||
using (var graphics = DrawingGraphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.DrawImage(source, 0, 0, source.Width, source.Height);
|
||||
}
|
||||
|
||||
var bounds = new System.Drawing.Rectangle(0, 0, bitmap.Width, bitmap.Height);
|
||||
var data = bitmap.LockBits(bounds, DrawingImageLockMode.ReadOnly, DrawingPixelFormat.Format32bppArgb);
|
||||
try
|
||||
{
|
||||
var rowBytes = bitmap.Width * 4;
|
||||
var pixels = new byte[rowBytes * bitmap.Height];
|
||||
for (var y = 0; y < bitmap.Height; y++)
|
||||
{
|
||||
Marshal.Copy(IntPtr.Add(data.Scan0, y * data.Stride), pixels, y * rowBytes, rowBytes);
|
||||
}
|
||||
|
||||
var reader = new BarcodeReaderGeneric
|
||||
{
|
||||
AutoRotate = true,
|
||||
Options = new DecodingOptions
|
||||
{
|
||||
PossibleFormats = new List<BarcodeFormat> { BarcodeFormat.QR_CODE },
|
||||
TryHarder = true,
|
||||
TryInverted = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = reader.Decode(pixels, bitmap.Width, bitmap.Height, RGBLuminanceSource.BitmapFormat.BGRA32);
|
||||
if (string.IsNullOrWhiteSpace(result?.Text))
|
||||
{
|
||||
throw new InvalidOperationException(LocalizationHelper.GetString("Setup_QrDecodeFailed"));
|
||||
}
|
||||
|
||||
return result.Text;
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNodeModePairingVisibility(bool showPairing)
|
||||
{
|
||||
_deviceIdText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
|
||||
_copyDeviceIdButton.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
|
||||
_pairingStatusText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private async void OnTestConnection(object sender, RoutedEventArgs e)
|
||||
@ -427,12 +613,21 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_draftToken))
|
||||
if (string.IsNullOrWhiteSpace(_draftToken) &&
|
||||
string.IsNullOrWhiteSpace(_draftBootstrapToken))
|
||||
{
|
||||
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_TokenRequired");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_draftToken) &&
|
||||
!string.IsNullOrWhiteSpace(_draftBootstrapToken))
|
||||
{
|
||||
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_CodeDecoded");
|
||||
_connectionTested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_Testing");
|
||||
_testButton.IsEnabled = false;
|
||||
_connectionTested = false;
|
||||
@ -526,6 +721,10 @@ public sealed class SetupWizardWindow : WindowEx
|
||||
_existingSettings.GatewayUrl = _draftGatewayUrl;
|
||||
_existingSettings.Token = _draftToken;
|
||||
_existingSettings.EnableNodeMode = _draftEnableNodeMode;
|
||||
_existingSettings.BootstrapToken =
|
||||
_draftEnableNodeMode && string.IsNullOrWhiteSpace(_draftToken)
|
||||
? _draftBootstrapToken
|
||||
: "";
|
||||
_existingSettings.Save();
|
||||
|
||||
Completed = true;
|
||||
|
||||
@ -720,6 +720,93 @@ public class WindowsNodeClientTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNodeConnectMessage_UsesBootstrapToken_WhenNoStoredDeviceToken()
|
||||
{
|
||||
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dataPath);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new WindowsNodeClient(
|
||||
"ws://localhost:18789",
|
||||
"",
|
||||
dataPath,
|
||||
bootstrapToken: "bootstrap-token-123");
|
||||
|
||||
var json = InvokeBuildNodeConnectMessage(client);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var auth = doc.RootElement.GetProperty("params").GetProperty("auth");
|
||||
|
||||
Assert.Equal("bootstrap-token-123", auth.GetProperty("bootstrapToken").GetString());
|
||||
Assert.False(auth.TryGetProperty("token", out _));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dataPath))
|
||||
Directory.Delete(dataPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNodeConnectMessage_UsesStoredDeviceToken_OverBootstrapToken()
|
||||
{
|
||||
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dataPath);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new WindowsNodeClient(
|
||||
"ws://localhost:18789",
|
||||
"",
|
||||
dataPath,
|
||||
bootstrapToken: "bootstrap-token-123");
|
||||
|
||||
var identityField = typeof(WindowsNodeClient).GetField(
|
||||
"_deviceIdentity",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var identity = identityField!.GetValue(client)!;
|
||||
var storeMethod = identity.GetType().GetMethod("StoreDeviceToken");
|
||||
storeMethod!.Invoke(identity, ["stored-device-token"]);
|
||||
|
||||
var json = InvokeBuildNodeConnectMessage(client);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var auth = doc.RootElement.GetProperty("params").GetProperty("auth");
|
||||
|
||||
Assert.Equal("stored-device-token", auth.GetProperty("token").GetString());
|
||||
Assert.False(auth.TryGetProperty("bootstrapToken", out _));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dataPath))
|
||||
Directory.Delete(dataPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNodeConnectMessage_UsesGatewayToken_WhenNoBootstrapOrDeviceToken()
|
||||
{
|
||||
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dataPath);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new WindowsNodeClient("ws://localhost:18789", "gateway-token", dataPath);
|
||||
|
||||
var json = InvokeBuildNodeConnectMessage(client);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var auth = doc.RootElement.GetProperty("params").GetProperty("auth");
|
||||
|
||||
Assert.Equal("gateway-token", auth.GetProperty("token").GetString());
|
||||
Assert.False(auth.TryGetProperty("bootstrapToken", out _));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dataPath))
|
||||
Directory.Delete(dataPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterCapability_AddsToCapabilitiesListAndRegistration()
|
||||
{
|
||||
@ -1022,6 +1109,16 @@ public class WindowsNodeClientTests
|
||||
await task!;
|
||||
}
|
||||
|
||||
private static string InvokeBuildNodeConnectMessage(WindowsNodeClient client)
|
||||
{
|
||||
var method = typeof(WindowsNodeClient).GetMethod(
|
||||
"BuildNodeConnectMessage",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(method);
|
||||
|
||||
return (string)method!.Invoke(client, ["nonce-123", 0L])!;
|
||||
}
|
||||
|
||||
// ─── Command dispatch map tests ────────────────────────────────────────────
|
||||
|
||||
private sealed class MockCapability : INodeCapability
|
||||
|
||||
@ -12,6 +12,7 @@ public class SettingsRoundTripTests
|
||||
{
|
||||
GatewayUrl = "ws://localhost:18789",
|
||||
Token = "secret-token",
|
||||
BootstrapToken = "bootstrap-token",
|
||||
UseSshTunnel = true,
|
||||
SshTunnelUser = "user1",
|
||||
SshTunnelHost = "remote-host",
|
||||
@ -46,6 +47,7 @@ public class SettingsRoundTripTests
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(original.GatewayUrl, restored.GatewayUrl);
|
||||
Assert.Equal(original.Token, restored.Token);
|
||||
Assert.Equal(original.BootstrapToken, restored.BootstrapToken);
|
||||
Assert.Equal(original.UseSshTunnel, restored.UseSshTunnel);
|
||||
Assert.Equal(original.SshTunnelUser, restored.SshTunnelUser);
|
||||
Assert.Equal(original.SshTunnelHost, restored.SshTunnelHost);
|
||||
@ -97,6 +99,7 @@ public class SettingsRoundTripTests
|
||||
Assert.NotNull(settings);
|
||||
Assert.Null(settings.GatewayUrl);
|
||||
Assert.Null(settings.Token);
|
||||
Assert.Null(settings.BootstrapToken);
|
||||
Assert.False(settings.UseSshTunnel);
|
||||
Assert.Null(settings.SshTunnelUser);
|
||||
Assert.Null(settings.SshTunnelHost);
|
||||
@ -149,6 +152,7 @@ public class SettingsRoundTripTests
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal("ws://localhost:18789", settings.GatewayUrl);
|
||||
Assert.Equal("abc", settings.Token);
|
||||
Assert.Null(settings.BootstrapToken);
|
||||
Assert.False(settings.UseSshTunnel);
|
||||
Assert.Null(settings.SshTunnelUser);
|
||||
Assert.Null(settings.SshTunnelHost);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user