From 6cde74b2e53c08c559cf38cb831fa3f3ed3f6ccb Mon Sep 17 00:00:00 2001 From: Obaid Ahmed Date: Fri, 22 May 2026 10:09:27 -0700 Subject: [PATCH] Fix #29: robust openclaw_version parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous parser used `Str::after($output, 'openclaw ')` which only matched the lowercase-prefix format and silently dropped the value when the binary's output changed format between releases. Servers provisioned on 5.6+ ended up with empty `servers.openclaw_version` columns, breaking version-conditional code paths (ChatGPTAuthService::ensureMinimumOpenClawVersion). Extracted the parsing into a static `parseOpenClawVersion` helper that handles every observed format with a single regex: - "openclaw v2026.3.8" (≤5.5, lowercase + v prefix) - "openclaw 2026.5.6 (c97b9f7)" (5.6+, lowercase + build hash) - "OpenClaw 2026.5.19" (capital O, no prefix) - "v2026.5.19" / "2026.5.19" (bare) - "openclaw 2026.5.20-beta.1" (prerelease) When the parser returns null we now log the raw output so the next unknown format is debuggable instead of silently disappearing. The ipv4_address and provider_server_id concerns from the original ticket were a false alarm — both columns were populated correctly by `ProvisionDigitalOceanServerJob`; the reporter was querying with wrong column names (server.ip, server.cloud_resource_id) in tinker. This commit only addresses the openclaw_version capture. Tests: 9 new unit tests covering every observed output format plus the failure path. Full suite: 172 passed, 4 skipped. Fixes #29 --- app/Jobs/SetupOpenClawOnServerJob.php | 29 ++++++++++- .../Jobs/SetupOpenClawOnServerJobTest.php | 49 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Jobs/SetupOpenClawOnServerJobTest.php diff --git a/app/Jobs/SetupOpenClawOnServerJob.php b/app/Jobs/SetupOpenClawOnServerJob.php index 91f6e06..72586e9 100644 --- a/app/Jobs/SetupOpenClawOnServerJob.php +++ b/app/Jobs/SetupOpenClawOnServerJob.php @@ -171,17 +171,42 @@ private function captureOpenClawVersion(SshService $sshService): void { try { $output = trim($sshService->exec('openclaw --version')); - // Output is like "openclaw v2026.3.8" — extract the version part - $version = Str::after($output, 'openclaw '); + $version = self::parseOpenClawVersion($output); if ($version) { $this->server->update(['openclaw_version' => $version]); Log::info("OpenClaw {$version} installed on server {$this->server->id}"); + } else { + Log::warning("Could not parse OpenClaw version from output on server {$this->server->id}: {$output}"); } } catch (\RuntimeException $e) { Log::warning("Could not determine OpenClaw version on server {$this->server->id}: {$e->getMessage()}"); } } + /** + * Extract the version string from `openclaw --version` output. + * + * Handles every observed format across OpenClaw releases: + * - "openclaw v2026.3.8" (≤ 5.5, lowercase + "v") + * - "openclaw 2026.5.19 (c97b9f7)" (5.6+, lowercase, no v, build hash) + * - "OpenClaw 2026.5.19" (capital O, no prefix) + * - "v2026.5.19" / "2026.5.19" (bare) + * + * Returns the bare version string (e.g. "2026.5.19") with any leading + * "v" stripped and trailing build hash discarded. Returns null when the + * output doesn't contain a recognisable semver-ish token. + * + * Fixes issue #29. + */ + public static function parseOpenClawVersion(string $output): ?string + { + if (preg_match('/(\d{4}\.\d+\.\d+(?:[-.][a-z0-9.]+)?)/i', $output, $matches)) { + return ltrim($matches[1], 'v'); + } + + return null; + } + private function logProgress(string $step): void { $this->server->events()->create([ diff --git a/tests/Unit/Jobs/SetupOpenClawOnServerJobTest.php b/tests/Unit/Jobs/SetupOpenClawOnServerJobTest.php new file mode 100644 index 0000000..0a92da5 --- /dev/null +++ b/tests/Unit/Jobs/SetupOpenClawOnServerJobTest.php @@ -0,0 +1,49 @@ +toBe('2026.3.8'); +}); + +test('parses lowercase no-prefix format with build hash (5.6+)', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion('openclaw 2026.5.6 (c97b9f7)')) + ->toBe('2026.5.6'); +}); + +test('parses 5.19 format from the actual openclaw binary', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion('openclaw 2026.5.19 (abc1234)')) + ->toBe('2026.5.19'); +}); + +test('parses capital O variant (OpenClaw 2026.5.19)', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion('OpenClaw 2026.5.19')) + ->toBe('2026.5.19'); +}); + +test('parses bare version string with leading v', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion('v2026.5.19')) + ->toBe('2026.5.19'); +}); + +test('parses bare version string without leading v', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion('2026.5.19')) + ->toBe('2026.5.19'); +}); + +test('parses prerelease version (2026.5.20-beta.1)', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion('openclaw 2026.5.20-beta.1')) + ->toBe('2026.5.20-beta.1'); +}); + +test('returns null for unparseable output', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion(''))->toBeNull() + ->and(SetupOpenClawOnServerJob::parseOpenClawVersion('command not found'))->toBeNull() + ->and(SetupOpenClawOnServerJob::parseOpenClawVersion('openclaw'))->toBeNull(); +}); + +test('extracts version when wrapped in noise', function () { + expect(SetupOpenClawOnServerJob::parseOpenClawVersion("Warning: foo\nopenclaw 2026.5.19 (abc)")) + ->toBe('2026.5.19'); +});