Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
113e422168 fix(agent/openclaw): block tasks if openclaw < 2026.5.5 with upgrade hint
PR #2101 swapped the openclaw runtime adapter from reading --json on
stderr to stdout. That fixed openclaw 2026.5+ but inverted the breakage
for pre-2026.5 builds — those still write JSON to stderr, so the
adapter now sees an empty stdout and falls through to the same
"openclaw returned no parseable output" failure that 2026.5+ users
saw before #2101.

Add a per-task version gate inside openclawBackend.Execute that runs
`openclaw --version`, parses the dotted version, and rejects anything
below 2026.5.5 with a hardcoded upgrade hint:

    openclaw <detected> is below the minimum supported version 2026.5.5.
    Run `openclaw update` to upgrade and try again.

The check is intentionally per-task and uncached so users who upgrade
do not need to restart the daemon — the next task automatically
re-checks. ~20ms per task is negligible vs. the typical run.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 02:05:38 +08:00
2 changed files with 218 additions and 0 deletions

View File

@@ -8,10 +8,25 @@ import (
"io"
"log/slog"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// minOpenclawVersion is the lowest openclaw version that emits its
// --json result on stdout. PR #2101 swapped the adapter from reading
// stderr to stdout; older builds wrote JSON to stderr and now appear
// to silently produce no output. The check in Execute fails fast with
// a hardcoded upgrade hint so users see an actionable message instead
// of "openclaw returned no parseable output".
const minOpenclawVersion = "2026.5.5"
// openclawVersionPattern extracts a three-segment dotted version from
// arbitrary `openclaw --version` output (e.g. "openclaw 2026.5.5",
// "openclaw v2026.5.5 c37871e").
var openclawVersionPattern = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`)
// openclawBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args.
var openclawBlockedArgs = map[string]blockedArgMode{
@@ -39,6 +54,10 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
return nil, fmt.Errorf("openclaw executable not found at %q: %w", execPath, err)
}
if err := checkOpenclawVersion(ctx, execPath); err != nil {
return nil, err
}
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
@@ -188,6 +207,59 @@ func customArgsContains(args []string, flag string) bool {
return false
}
// checkOpenclawVersion runs `<execPath> --version` and returns a
// user-facing error when the installed openclaw is older than
// minOpenclawVersion. The returned error becomes the task's failure
// comment, so the message intentionally names the detected version
// and the upgrade command.
func checkOpenclawVersion(ctx context.Context, execPath string) error {
cmd := exec.CommandContext(ctx, execPath, "--version")
hideAgentWindow(cmd)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("openclaw --version failed: %w", err)
}
detected, ok := parseOpenclawVersion(string(out))
if !ok {
return fmt.Errorf("could not parse openclaw version from output: %q", strings.TrimSpace(string(out)))
}
if compareOpenclawVersion(detected, minOpenclawVersion) < 0 {
return fmt.Errorf("openclaw %s is below the minimum supported version %s. Run `openclaw update` to upgrade and try again.", detected, minOpenclawVersion)
}
return nil
}
// parseOpenclawVersion extracts the first three-segment dotted version
// from arbitrary `openclaw --version` output. Returns ok=false when no
// match is found.
func parseOpenclawVersion(raw string) (string, bool) {
m := openclawVersionPattern.FindString(raw)
if m == "" {
return "", false
}
return m, true
}
// compareOpenclawVersion compares two three-segment dotted versions
// numerically. Returns -1, 0, or +1 like bytes.Compare. Inputs must be
// well-formed (matched by openclawVersionPattern); malformed segments
// compare as zero.
func compareOpenclawVersion(a, b string) int {
aParts := strings.SplitN(a, ".", 3)
bParts := strings.SplitN(b, ".", 3)
for i := 0; i < 3; i++ {
ai, _ := strconv.Atoi(aParts[i])
bi, _ := strconv.Atoi(bParts[i])
if ai < bi {
return -1
}
if ai > bi {
return 1
}
}
return 0
}
// ── Event handlers ──
// openclawEventResult holds accumulated state from processing the event stream.

View File

@@ -1,9 +1,12 @@
package agent
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -1228,3 +1231,146 @@ func TestOpenclawProcessOutputStdoutFixture(t *testing.T) {
t.Errorf("expected a MessageText event containing %q", "hi")
}
}
// ── Version gate tests (MUL-1803) ──
func TestParseOpenclawVersion(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
ok bool
}{
{"bare", "2026.5.5", "2026.5.5", true},
{"with prefix", "openclaw 2026.5.5", "2026.5.5", true},
{"with v prefix", "openclaw v2026.5.5", "2026.5.5", true},
{"with commit suffix", "openclaw 2026.5.5 c37871e", "2026.5.5", true},
{"trailing newline", "openclaw 2026.5.5\n", "2026.5.5", true},
{"two segments rejected", "openclaw 2026.5", "", false},
{"no version at all", "openclaw build info", "", false},
{"empty", "", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := parseOpenclawVersion(c.in)
if ok != c.ok {
t.Fatalf("ok = %v, want %v (input=%q)", ok, c.ok, c.in)
}
if got != c.want {
t.Errorf("got %q, want %q (input=%q)", got, c.want, c.in)
}
})
}
}
func TestCompareOpenclawVersion(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
want int
}{
{"2026.5.5", "2026.5.5", 0},
{"2026.5.4", "2026.5.5", -1},
{"2026.5.6", "2026.5.5", 1},
{"2026.4.99", "2026.5.0", -1},
{"2027.0.0", "2026.99.99", 1},
{"0.0.0", "2026.5.5", -1},
}
for _, c := range cases {
got := compareOpenclawVersion(c.a, c.b)
if got != c.want {
t.Errorf("compareOpenclawVersion(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
// TestOpenclawExecuteRejectsOldVersion verifies that an openclaw build
// older than minOpenclawVersion is blocked at task-start with a
// user-facing error naming the detected version and the upgrade
// command. Without this gate, the task would silently fail with
// "openclaw returned no parseable output" because pre-2026.5 builds
// emit JSON on stderr (see PR #2101).
func TestOpenclawExecuteRejectsOldVersion(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakePath := filepath.Join(t.TempDir(), "openclaw")
script := "#!/bin/sh\n" +
"if [ \"$1\" = \"--version\" ]; then\n" +
" echo 'openclaw 2026.4.9 abc123'\n" +
" exit 0\n" +
"fi\n" +
"echo 'fake openclaw should not have been invoked' >&2\n" +
"exit 99\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("openclaw", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new openclaw backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err == nil {
t.Fatal("expected Execute to return a version error, got nil")
}
msg := err.Error()
for _, want := range []string{"2026.4.9", "2026.5.5", "openclaw update"} {
if !strings.Contains(msg, want) {
t.Errorf("error message missing %q: %s", want, msg)
}
}
}
// TestOpenclawExecuteAllowsCurrentVersion verifies that an openclaw
// build at or above minOpenclawVersion clears the version gate and
// proceeds to the actual run. The fake exits without producing JSON,
// so the eventual Result is a downstream failure — but the failure
// must NOT be the version-gate error.
func TestOpenclawExecuteAllowsCurrentVersion(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakePath := filepath.Join(t.TempDir(), "openclaw")
script := "#!/bin/sh\n" +
"if [ \"$1\" = \"--version\" ]; then\n" +
" echo 'openclaw 2026.5.5 c37871e'\n" +
" exit 0\n" +
"fi\n" +
"exit 0\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("openclaw", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new openclaw backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("Execute returned synchronous error past the version gate: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if strings.Contains(result.Error, "openclaw update") {
t.Errorf("version gate fired for a current version: %q", result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}