From 20b80596ade5117a4622802cc48b0672e91c11fc Mon Sep 17 00:00:00 2001 From: Ranjesh Jaganathan Date: Tue, 26 May 2026 19:40:17 -0700 Subject: [PATCH 1/5] Pin gateway setup to LKG and automate drift updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 39 +++++ .github/workflows/gateway-lkg-update.yml | 135 ++++++++++++++++++ DEVELOPMENT.md | 7 + .../SetupWindow.xaml.cs | 1 + src/OpenClaw.SetupEngine/GatewayLkgVersion.cs | 16 +++ src/OpenClaw.SetupEngine/Program.cs | 1 + src/OpenClaw.SetupEngine/SetupSteps.cs | 29 +++- src/OpenClaw.SetupEngine/default-config.json | 2 +- .../Setup/E2ESetupFixture.cs | 5 + .../GatewayLkgVersionTests.cs | 23 +++ .../SetupStepsTests.cs | 16 +++ 11 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/gateway-lkg-update.yml create mode 100644 src/OpenClaw.SetupEngine/GatewayLkgVersion.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60d819a9b..ada40024c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,45 @@ jobs: with: fetch-depth: 0 + - name: Detect gateway LKG drift from npm latest + id: gateway_lkg_drift + continue-on-error: true + shell: pwsh + run: | + $sourcePath = "src/OpenClaw.SetupEngine/GatewayLkgVersion.cs" + $source = Get-Content -LiteralPath $sourcePath -Raw + $match = [regex]::Match($source, 'LkgVersion\s*=\s*"([^"]+)"') + if (-not $match.Success) { + throw "Unable to parse LKG version from $sourcePath" + } + + $pinned = $match.Groups[1].Value + $latest = (Invoke-RestMethod -Uri "https://registry.npmjs.org/openclaw/latest" -TimeoutSec 30).version + if ([string]::IsNullOrWhiteSpace($latest)) { + throw "Unable to resolve npm latest version for openclaw." + } + + "pinned=$pinned" >> $env:GITHUB_OUTPUT + "latest=$latest" >> $env:GITHUB_OUTPUT + + if ($pinned -ne $latest) { + "drifted=true" >> $env:GITHUB_OUTPUT + Write-Host "::warning::Gateway LKG drift detected: pinned $pinned, npm latest $latest. Run gateway-lkg-update workflow to refresh the standing draft PR." + Write-Error "Gateway LKG drift detected (pinned $pinned, npm latest $latest)." + "### :warning: Gateway LKG drift detected" >> $env:GITHUB_STEP_SUMMARY + "" >> $env:GITHUB_STEP_SUMMARY + "- Pinned LKG: $pinned" >> $env:GITHUB_STEP_SUMMARY + "- npm latest: $latest" >> $env:GITHUB_STEP_SUMMARY + "- Action: run `gateway-lkg-update` to refresh the standing draft PR." >> $env:GITHUB_STEP_SUMMARY + exit 1 + } else { + "drifted=false" >> $env:GITHUB_OUTPUT + Write-Host "Gateway LKG is current ($pinned)." + "### Gateway LKG is current" >> $env:GITHUB_STEP_SUMMARY + "" >> $env:GITHUB_STEP_SUMMARY + "- Pinned LKG: $pinned" >> $env:GITHUB_STEP_SUMMARY + } + - name: Setup .NET 10 uses: actions/setup-dotnet@v5 with: diff --git a/.github/workflows/gateway-lkg-update.yml b/.github/workflows/gateway-lkg-update.yml new file mode 100644 index 000000000..e1f958d7f --- /dev/null +++ b/.github/workflows/gateway-lkg-update.yml @@ -0,0 +1,135 @@ +name: Gateway LKG Update + +on: + schedule: + - cron: '23 7 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: gateway-lkg-update + cancel-in-progress: false + +jobs: + update-lkg: + runs-on: ubuntu-latest + env: + BRANCH_NAME: automation/gateway-lkg-update + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve pinned and latest gateway versions + id: versions + shell: bash + run: | + pinned="$(python3 - <<'PY' + import pathlib, re + source = pathlib.Path("src/OpenClaw.SetupEngine/GatewayLkgVersion.cs").read_text(encoding="utf-8") + match = re.search(r'LkgVersion\s*=\s*"([^"]+)"', source) + if not match: + raise SystemExit("Could not parse LkgVersion constant from GatewayLkgVersion.cs") + print(match.group(1)) + PY + )" + latest="$(python3 - <<'PY' + import json, urllib.request + with urllib.request.urlopen('https://registry.npmjs.org/openclaw/latest', timeout=30) as r: + payload = json.load(r) + print(payload['version']) + PY + )" + + { + echo "pinned=${pinned}" + echo "latest=${latest}" + } >> "$GITHUB_OUTPUT" + + if [[ "$pinned" != "$latest" ]]; then + echo "drifted=true" >> "$GITHUB_OUTPUT" + else + echo "drifted=false" >> "$GITHUB_OUTPUT" + fi + + - name: Stop when pinned version is current + if: ${{ steps.versions.outputs.drifted != 'true' }} + shell: bash + run: echo "Gateway LKG already matches npm latest (${{ steps.versions.outputs.pinned }})." + + - name: Prepare update branch + if: ${{ steps.versions.outputs.drifted == 'true' }} + shell: bash + run: | + git fetch origin "${DEFAULT_BRANCH}" + git checkout -B "${BRANCH_NAME}" "origin/${DEFAULT_BRANCH}" + + - name: Write updated LKG version + if: ${{ steps.versions.outputs.drifted == 'true' }} + shell: bash + env: + LATEST_VERSION: ${{ steps.versions.outputs.latest }} + run: | + python3 - <<'PY' + import os + import pathlib, re + latest = os.environ["LATEST_VERSION"] + path = pathlib.Path("src/OpenClaw.SetupEngine/GatewayLkgVersion.cs") + source = path.read_text(encoding="utf-8") + pattern = r'(LkgVersion\s*=\s*")([^"]+)(")' + updated, count = re.subn(pattern, lambda m: f"{m.group(1)}{latest}{m.group(3)}", source, count=1) + if count != 1: + raise SystemExit("Failed to rewrite LkgVersion constant") + path.write_text(updated, encoding="utf-8") + PY + + - name: Commit and push branch + if: ${{ steps.versions.outputs.drifted == 'true' }} + shell: bash + run: | + if git diff --quiet -- src/OpenClaw.SetupEngine/GatewayLkgVersion.cs; then + echo "No LKG change detected after update write." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/OpenClaw.SetupEngine/GatewayLkgVersion.cs + git commit -m "chore(setup): bump gateway LKG to ${{ steps.versions.outputs.latest }}" + git push --force-with-lease origin "${BRANCH_NAME}" + + - name: Create or update standing draft PR + if: ${{ steps.versions.outputs.drifted == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + title="chore(setup): bump gateway LKG to ${{ steps.versions.outputs.latest }}" + body=$( + cat < diff --git a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs new file mode 100644 index 000000000..cc23df514 --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs @@ -0,0 +1,16 @@ +namespace OpenClaw.SetupEngine; + +public static class GatewayLkgVersion +{ + public const string LkgVersion = "2026.5.22"; + + public static string ResolveLkgVersion() => LkgVersion; + + public static void ApplyToConfig(SetupConfig config) + { + if (!string.IsNullOrWhiteSpace(config.Gateway.Version)) + return; + + config.Gateway.Version = LkgVersion; + } +} diff --git a/src/OpenClaw.SetupEngine/Program.cs b/src/OpenClaw.SetupEngine/Program.cs index 6323defec..e0432608c 100644 --- a/src/OpenClaw.SetupEngine/Program.cs +++ b/src/OpenClaw.SetupEngine/Program.cs @@ -45,6 +45,7 @@ public static async Task Main(string[] args) // Apply CLI overrides config = SetupConfig.FromEnvironment(config); + GatewayLkgVersion.ApplyToConfig(config); if (headless) config.Headless = true; if (rollback) config.RollbackOnFailure = true; if (noRollback) config.RollbackOnFailure = false; diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 4f851d16b..54e2f8ac6 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -727,9 +727,16 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return StepResult.Fail($"Installer URL must be HTTPS: {installUrl}"); } - // Shell-quote the URL and enforce TLS - var escapedUrl = installUrl.Replace("'", "'\\''"); - var installScript = $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash"; + string installScript; + try + { + installScript = BuildInstallCommand(installUrl, ctx.Config.Gateway.Version); + } + catch (ArgumentException ex) + { + return StepResult.Fail(ex.Message); + } + var result = await ctx.Commands.RunInWslAsync(distro, installScript, TimeSpan.FromMinutes(5), ct: ct); if (result.ExitCode != 0) @@ -763,6 +770,20 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return StepResult.Fail("CLI installed but not found in any known location"); } + internal static string BuildInstallCommand(string installUrl, string? requestedVersion) + { + var escapedUrl = ShellEscape(installUrl); + if (string.IsNullOrWhiteSpace(requestedVersion)) + return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash"; + + var trimmedVersion = requestedVersion.Trim(); + if (trimmedVersion.Contains('\n') || trimmedVersion.Contains('\r')) + throw new ArgumentException("Gateway version cannot contain newlines."); + + var escapedVersion = ShellEscape(trimmedVersion); + return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash -s -- --version '{escapedVersion}'"; + } + private static async Task EnsureCliOnDefaultPathAsync( SetupContext ctx, string distro, @@ -810,6 +831,8 @@ echo OPENCLAW_PATH_READY return StepResult.Ok(); } + private static string ShellEscape(string value) => value.Replace("'", "'\\''"); + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) { var user = ctx.Config.Wsl.User; diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index 4ce9ebea5..c3df047aa 100644 --- a/src/OpenClaw.SetupEngine/default-config.json +++ b/src/OpenClaw.SetupEngine/default-config.json @@ -73,7 +73,7 @@ "Bind": "loopback", // Override CLI install script URL (null = https://openclaw.ai/install-cli.sh) "InstallUrl": null, - // Pin gateway version (null = latest) + // Pin gateway version (null = use GatewayLkgVersion.cs embedded LKG) "Version": null, // Seconds to wait for gateway health endpoint before failing "HealthTimeoutSeconds": 90, diff --git a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs index 0cedc23cf..2cecfa794 100644 --- a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs +++ b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs @@ -212,6 +212,7 @@ public async Task DisposeAsync() private void WriteConfig() { + var lkgVersion = GatewayLkgVersion.ResolveLkgVersion(); var config = new { DistroName = _distroName, @@ -246,6 +247,10 @@ private void WriteConfig() NodeTtsEnabled = true, NodeSttEnabled = true, }, + Gateway = new + { + Version = lkgVersion + } }; var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs new file mode 100644 index 000000000..938b54858 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs @@ -0,0 +1,23 @@ +namespace OpenClaw.SetupEngine.Tests; + +[Collection(EnvironmentVariableCollection.Name)] +public sealed class GatewayLkgVersionTests +{ + [Fact] + public void ResolveLkgVersion_ReturnsEmbeddedLkg() + { + var version = GatewayLkgVersion.ResolveLkgVersion(); + + Assert.Equal(GatewayLkgVersion.LkgVersion, version); + } + + [Fact] + public void ApplyToConfig_SetsGatewayVersionWhenUnset() + { + var config = new SetupConfig(); + config.Gateway.Version = null; + GatewayLkgVersion.ApplyToConfig(config); + + Assert.Equal(GatewayLkgVersion.LkgVersion, config.Gateway.Version); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index fea5d2716..3dd0a25af 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -217,6 +217,22 @@ public async Task InstallCli_RejectsHttpUrl() Assert.Contains("HTTPS", result.Message); } + [Fact] + public void InstallCli_BuildInstallCommand_UsesDefaultWhenVersionMissing() + { + var command = InstallCliStep.BuildInstallCommand("https://openclaw.ai/install-cli.sh", null); + + Assert.Equal("curl -fsSL --proto '=https' --tlsv1.2 'https://openclaw.ai/install-cli.sh' | bash", command); + } + + [Fact] + public void InstallCli_BuildInstallCommand_AppendsVersionWhenConfigured() + { + var command = InstallCliStep.BuildInstallCommand("https://openclaw.ai/install-cli.sh", "2026.5.22"); + + Assert.Equal("curl -fsSL --proto '=https' --tlsv1.2 'https://openclaw.ai/install-cli.sh' | bash -s -- --version '2026.5.22'", command); + } + private static int GetFreeTcpPort() { var listener = new TcpListener(IPAddress.Loopback, 0); From 2824fdac8c65f8c9c28174114b68480b9aa0b477 Mon Sep 17 00:00:00 2001 From: Ranjesh Jaganathan Date: Tue, 26 May 2026 19:50:40 -0700 Subject: [PATCH 2/5] Harden LKG pinning and updater validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ .github/workflows/gateway-lkg-update.yml | 12 +++++++++--- DEVELOPMENT.md | 1 + src/OpenClaw.SetupEngine/GatewayLkgVersion.cs | 7 +++++++ src/OpenClaw.SetupEngine/SetupSteps.cs | 2 +- .../GatewayLkgVersionTests.cs | 11 +++++++++++ 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ada40024c..f7aed7036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: if ([string]::IsNullOrWhiteSpace($latest)) { throw "Unable to resolve npm latest version for openclaw." } + if ($latest -notmatch '^[0-9]+\.[0-9]+\.[0-9]+([.\-+][A-Za-z0-9.\-]+)*$') { + throw "Resolved npm latest version has unexpected format: $latest" + } "pinned=$pinned" >> $env:GITHUB_OUTPUT "latest=$latest" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/gateway-lkg-update.yml b/.github/workflows/gateway-lkg-update.yml index e1f958d7f..6cd19e9a0 100644 --- a/.github/workflows/gateway-lkg-update.yml +++ b/.github/workflows/gateway-lkg-update.yml @@ -34,14 +34,20 @@ jobs: match = re.search(r'LkgVersion\s*=\s*"([^"]+)"', source) if not match: raise SystemExit("Could not parse LkgVersion constant from GatewayLkgVersion.cs") - print(match.group(1)) + pinned = match.group(1) + if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+([.\-+][A-Za-z0-9.\-]+)*', pinned): + raise SystemExit(f"Pinned LkgVersion has unexpected format: {pinned}") + print(pinned) PY )" latest="$(python3 - <<'PY' - import json, urllib.request + import json, re, urllib.request with urllib.request.urlopen('https://registry.npmjs.org/openclaw/latest', timeout=30) as r: payload = json.load(r) - print(payload['version']) + latest = payload['version'] + if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+([.\-+][A-Za-z0-9.\-]+)*', latest): + raise SystemExit(f"Resolved npm latest version has unexpected format: {latest}") + print(latest) PY )" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9078281f8..9fc6cfff4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -585,6 +585,7 @@ The repository uses GitHub Actions for continuous integration and release automa - The pinned gateway setup version lives in `src/OpenClaw.SetupEngine/GatewayLkgVersion.cs` (`GatewayLkgVersion.LkgVersion`). - Setup/E2E consume this as the default source of truth when `Gateway.Version` is not explicitly set. +- When `Gateway.InstallUrl` points to a custom installer script, SetupEngine does not auto-inject the LKG; set `Gateway.Version` explicitly if your script supports `--version`. - The `e2etests` job in `.github/workflows/ci.yml` compares pinned LKG vs npm `openclaw@latest` and emits a **warning** on drift (non-blocking). - `.github/workflows/gateway-lkg-update.yml` creates or updates one standing draft PR on branch `automation/gateway-lkg-update` to bump `GatewayLkgVersion.LkgVersion` when upstream latest advances. diff --git a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs index cc23df514..3a40b90bd 100644 --- a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs +++ b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs @@ -2,6 +2,7 @@ namespace OpenClaw.SetupEngine; public static class GatewayLkgVersion { + public const string DefaultInstallUrl = "https://openclaw.ai/install-cli.sh"; public const string LkgVersion = "2026.5.22"; public static string ResolveLkgVersion() => LkgVersion; @@ -11,6 +12,12 @@ public static void ApplyToConfig(SetupConfig config) if (!string.IsNullOrWhiteSpace(config.Gateway.Version)) return; + if (!string.IsNullOrWhiteSpace(config.Gateway.InstallUrl) && + !string.Equals(config.Gateway.InstallUrl, DefaultInstallUrl, StringComparison.OrdinalIgnoreCase)) + { + return; + } + config.Gateway.Version = LkgVersion; } } diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 54e2f8ac6..8bce66ef3 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -718,7 +718,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati var user = ctx.Config.Wsl.User; // Download and run install script (URL configurable) - var installUrl = ctx.Config.Gateway.InstallUrl ?? "https://openclaw.ai/install-cli.sh"; + var installUrl = ctx.Config.Gateway.InstallUrl ?? GatewayLkgVersion.DefaultInstallUrl; // Validate URL is HTTPS to prevent downgrade attacks if (!Uri.TryCreate(installUrl, UriKind.Absolute, out var parsedUrl) || diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs index 938b54858..dc410cc1b 100644 --- a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs @@ -20,4 +20,15 @@ public void ApplyToConfig_SetsGatewayVersionWhenUnset() Assert.Equal(GatewayLkgVersion.LkgVersion, config.Gateway.Version); } + + [Fact] + public void ApplyToConfig_DoesNotSetGatewayVersionForCustomInstallUrl() + { + var config = new SetupConfig(); + config.Gateway.Version = null; + config.Gateway.InstallUrl = "https://contoso.example/install-cli.sh"; + GatewayLkgVersion.ApplyToConfig(config); + + Assert.Null(config.Gateway.Version); + } } From 97a23d257e20e64bc15929615d4128172bf63e22 Mon Sep 17 00:00:00 2001 From: Ranjesh Jaganathan Date: Wed, 27 May 2026 05:56:42 -0700 Subject: [PATCH 3/5] Address PR review feedback and add quoting proof Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DEVELOPMENT.md | 2 +- tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9fc6cfff4..bfa8f92f4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -586,7 +586,7 @@ The repository uses GitHub Actions for continuous integration and release automa - The pinned gateway setup version lives in `src/OpenClaw.SetupEngine/GatewayLkgVersion.cs` (`GatewayLkgVersion.LkgVersion`). - Setup/E2E consume this as the default source of truth when `Gateway.Version` is not explicitly set. - When `Gateway.InstallUrl` points to a custom installer script, SetupEngine does not auto-inject the LKG; set `Gateway.Version` explicitly if your script supports `--version`. -- The `e2etests` job in `.github/workflows/ci.yml` compares pinned LKG vs npm `openclaw@latest` and emits a **warning** on drift (non-blocking). +- The `test` job in `.github/workflows/ci.yml` compares pinned LKG vs npm `openclaw@latest` and emits a **warning** on drift (non-blocking). - `.github/workflows/gateway-lkg-update.yml` creates or updates one standing draft PR on branch `automation/gateway-lkg-update` to bump `GatewayLkgVersion.LkgVersion` when upstream latest advances. ### Build Matrix diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 3dd0a25af..fc535e5b6 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -233,6 +233,14 @@ public void InstallCli_BuildInstallCommand_AppendsVersionWhenConfigured() Assert.Equal("curl -fsSL --proto '=https' --tlsv1.2 'https://openclaw.ai/install-cli.sh' | bash -s -- --version '2026.5.22'", command); } + [Fact] + public void InstallCli_BuildInstallCommand_EscapesSingleQuotesInUrlAndVersion() + { + var command = InstallCliStep.BuildInstallCommand("https://openclaw.ai/install-cli's.sh", "2026.5.22'a"); + + Assert.Equal("curl -fsSL --proto '=https' --tlsv1.2 'https://openclaw.ai/install-cli'\\''s.sh' | bash -s -- --version '2026.5.22'\\''a'", command); + } + private static int GetFreeTcpPort() { var listener = new TcpListener(IPAddress.Loopback, 0); From acdafe2503267ab37245d97dde97c3fed672a184 Mon Sep 17 00:00:00 2001 From: Ranjesh Jaganathan Date: Thu, 28 May 2026 06:16:21 -0700 Subject: [PATCH 4/5] Update gateway LKG to 2026.5.26 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.SetupEngine/GatewayLkgVersion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs index 3a40b90bd..b22fedd20 100644 --- a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs +++ b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs @@ -3,7 +3,7 @@ namespace OpenClaw.SetupEngine; public static class GatewayLkgVersion { public const string DefaultInstallUrl = "https://openclaw.ai/install-cli.sh"; - public const string LkgVersion = "2026.5.22"; + public const string LkgVersion = "2026.5.26"; public static string ResolveLkgVersion() => LkgVersion; From d31572700607aa2bd418866b214c4dcb2e71519a Mon Sep 17 00:00:00 2001 From: Ranjesh Jaganathan Date: Thu, 28 May 2026 07:12:53 -0700 Subject: [PATCH 5/5] Update gateway LKG to 2026.5.27 Drain pending WSL device approvals before pair-node so the WSL CLI's own scope-upgrade request no longer blocks approval of the Windows node request. Keep the final verify drain for any remaining device or node approvals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.SetupEngine/GatewayLkgVersion.cs | 2 +- src/OpenClaw.SetupEngine/SetupSteps.cs | 26 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs index b22fedd20..30f938ffa 100644 --- a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs +++ b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs @@ -3,7 +3,7 @@ namespace OpenClaw.SetupEngine; public static class GatewayLkgVersion { public const string DefaultInstallUrl = "https://openclaw.ai/install-cli.sh"; - public const string LkgVersion = "2026.5.26"; + public const string LkgVersion = "2026.5.27"; public static string ResolveLkgVersion() => LkgVersion; diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 8bce66ef3..03f380598 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -1653,6 +1653,10 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return StepResult.Fail($"Gateway not reachable before node pairing: {ex.Message}"); } + var drainResult = await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, ct); + if (!drainResult.IsSuccess) + return drainResult; + var wsLogger = new SetupOpenClawLogger(ctx.Logger); WindowsNodeClient? client = null; @@ -2004,12 +2008,12 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return StepResult.Ok("Gateway running; operator finalized; settings written for tray."); } - private static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct) + internal static async Task DrainPendingDeviceApprovalsAsync(SetupContext ctx, CancellationToken ct) { var distro = ctx.DistroName!; var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; if (string.IsNullOrWhiteSpace(token)) - return StepResult.Fail("No gateway token available to drain pending approvals"); + return StepResult.Fail("No gateway token available to drain pending device approvals"); var pathPrefix = ctx.WslPathPrefix; var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; @@ -2063,6 +2067,24 @@ private static async Task DrainPendingApprovalsAsync(SetupContext ct return StepResult.Fail($"Could not select pending device approval for drain (exit {preview.ExitCode}): {parsed.Error ?? preview.Stderr.Trim()}"); } + return StepResult.Ok("Pending device approvals drained"); + } + + private static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct) + { + var deviceDrainResult = await DrainPendingDeviceApprovalsAsync(ctx, ct); + if (!deviceDrainResult.IsSuccess) + return deviceDrainResult; + + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + if (string.IsNullOrWhiteSpace(token)) + return StepResult.Fail("No gateway token available to drain pending approvals"); + + var pathPrefix = ctx.WslPathPrefix; + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + const int maxDrainIterations = 10; + for (var i = 0; i < maxDrainIterations; i++) { var nodeList = await ctx.Commands.RunInWslAsync(