diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60d819a9b..f7aed7036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,48 @@ 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." + } + 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 + + 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..6cd19e9a0 --- /dev/null +++ b/.github/workflows/gateway-lkg-update.yml @@ -0,0 +1,141 @@ +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") + 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, re, urllib.request + with urllib.request.urlopen('https://registry.npmjs.org/openclaw/latest', timeout=30) as r: + payload = json.load(r) + 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 + )" + + { + 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..30f938ffa --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs @@ -0,0 +1,23 @@ +namespace OpenClaw.SetupEngine; + +public static class GatewayLkgVersion +{ + public const string DefaultInstallUrl = "https://openclaw.ai/install-cli.sh"; + public const string LkgVersion = "2026.5.27"; + + public static string ResolveLkgVersion() => LkgVersion; + + 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/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..03f380598 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) || @@ -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; @@ -1630,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; @@ -1981,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 }; @@ -2040,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( 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..dc410cc1b --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs @@ -0,0 +1,34 @@ +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); + } + + [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); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index fea5d2716..fc535e5b6 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -217,6 +217,30 @@ 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); + } + + [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);