Skip to content
Closed
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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
141 changes: 141 additions & 0 deletions .github/workflows/gateway-lkg-update.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
## Gateway LKG update

- Previous pinned LKG: \`${{ steps.versions.outputs.pinned }}\`
- Candidate latest: \`${{ steps.versions.outputs.latest }}\`
- Updated file: \`src/OpenClaw.SetupEngine/GatewayLkgVersion.cs\`

This is the standing automation PR used to review and validate gateway LKG bumps before merge.
EOF
)

pr_number="$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty')"
if [[ -n "$pr_number" ]]; then
gh pr edit "$pr_number" --title "$title" --body "$body"
echo "Updated existing PR #$pr_number"
else
gh pr create \
--draft \
--base "${DEFAULT_BRANCH}" \
--head "${BRANCH_NAME}" \
--title "$title" \
--body "$body"
fi
8 changes: 8 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,14 @@ The repository uses GitHub Actions for continuous integration and release automa
- Pull requests to `main` or `master`
- Git tags matching `v*` (e.g., `v1.2.3`) for releases

### Gateway LKG version automation

- 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 `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

The CI builds multiple configurations:
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public SetupWindow()

_config = SetupConfig.LoadFromFile(configPath);
_config = SetupConfig.FromEnvironment(_config);
GatewayLkgVersion.ApplyToConfig(_config);
_config.ApplyUiDefaults(rollbackOnFailure: !HasFlag(args, "--no-rollback-on-failure"));

Closed += (_, _) =>
Expand Down
23 changes: 23 additions & 0 deletions src/OpenClaw.SetupEngine/GatewayLkgVersion.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions src/OpenClaw.SetupEngine/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public static async Task<int> 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;
Expand Down
57 changes: 51 additions & 6 deletions src/OpenClaw.SetupEngine/SetupSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ public override async Task<StepResult> 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) ||
Expand All @@ -727,9 +727,16 @@ public override async Task<StepResult> 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)
Expand Down Expand Up @@ -763,6 +770,20 @@ public override async Task<StepResult> 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<StepResult> EnsureCliOnDefaultPathAsync(
SetupContext ctx,
string distro,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1630,6 +1653,10 @@ public override async Task<StepResult> 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;

Expand Down Expand Up @@ -1981,12 +2008,12 @@ public override async Task<StepResult> ExecuteAsync(SetupContext ctx, Cancellati
return StepResult.Ok("Gateway running; operator finalized; settings written for tray.");
}

private static async Task<StepResult> DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct)
internal static async Task<StepResult> 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<string, string> { ["OPENCLAW_GATEWAY_TOKEN"] = token };
Expand Down Expand Up @@ -2040,6 +2067,24 @@ private static async Task<StepResult> 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<StepResult> 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<string, string> { ["OPENCLAW_GATEWAY_TOKEN"] = token };
const int maxDrainIterations = 10;

for (var i = 0; i < maxDrainIterations; i++)
{
var nodeList = await ctx.Commands.RunInWslAsync(
Expand Down
2 changes: 1 addition & 1 deletion src/OpenClaw.SetupEngine/default-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ public async Task DisposeAsync()

private void WriteConfig()
{
var lkgVersion = GatewayLkgVersion.ResolveLkgVersion();
var config = new
{
DistroName = _distroName,
Expand Down Expand Up @@ -246,6 +247,10 @@ private void WriteConfig()
NodeTtsEnabled = true,
NodeSttEnabled = true,
},
Gateway = new
{
Version = lkgVersion
}
};

var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
Expand Down
Loading