Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
af75bc3fd1 feat(daemon): add minimum Codex CLI version check (>= 0.100.0)
The `codex app-server --listen stdio://` flag was introduced in v0.100.0.
Older versions lack this flag and fail silently. Add codex to the
MinVersions map so the daemon skips outdated codex CLIs with a clear
warning, matching the existing Claude version check.

Refs #490
2026-04-10 16:07:38 +08:00
Jiang Bohan
f98030a17d feat(daemon): add minimum Claude Code version check during runtime registration
The daemon now validates the detected agent CLI version against a
minimum requirement before registering a runtime. Claude Code requires
>= 2.0.0 (when --output-format stream-json and --permission-mode
bypassPermissions were introduced). Older versions are skipped with a
warning log, preventing silent failures.

Closes #569
2026-04-10 14:52:48 +08:00
3 changed files with 150 additions and 0 deletions

View File

@@ -235,6 +235,10 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
d.logger.Warn("skip registering runtime", "name", name, "error", err)
continue
}
if err := agent.CheckMinVersion(name, version); err != nil {
d.logger.Warn("skip registering runtime: version too old", "name", name, "version", version, "error", err)
continue
}
displayName := strings.ToUpper(name[:1]) + name[1:]
if d.cfg.DeviceName != "" {
displayName = fmt.Sprintf("%s (%s)", displayName, d.cfg.DeviceName)

View File

@@ -0,0 +1,67 @@
package agent
import (
"fmt"
"regexp"
"strconv"
)
// MinVersions defines the minimum required CLI version for each agent type.
// Versions below these will be rejected during daemon registration.
var MinVersions = map[string]string{
"claude": "2.0.0",
"codex": "0.100.0", // app-server --listen stdio:// added in 0.100.0
}
// semver holds a parsed semantic version (major.minor.patch).
type semver struct {
Major, Minor, Patch int
}
// versionRe matches version strings like "2.1.100", "v2.0.0", or
// "2.1.100 (Claude Code)" — it extracts the first three numeric components.
var versionRe = regexp.MustCompile(`v?(\d+)\.(\d+)\.(\d+)`)
// parseSemver extracts a semver from a version string.
func parseSemver(raw string) (semver, error) {
m := versionRe.FindStringSubmatch(raw)
if m == nil {
return semver{}, fmt.Errorf("cannot parse version %q", raw)
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
patch, _ := strconv.Atoi(m[3])
return semver{Major: major, Minor: minor, Patch: patch}, nil
}
// lessThan returns true if v < other.
func (v semver) lessThan(other semver) bool {
if v.Major != other.Major {
return v.Major < other.Major
}
if v.Minor != other.Minor {
return v.Minor < other.Minor
}
return v.Patch < other.Patch
}
// CheckMinVersion validates that detectedVersion meets the minimum for agentType.
// Returns nil if the version is acceptable or no minimum is defined.
func CheckMinVersion(agentType, detectedVersion string) error {
minRaw, ok := MinVersions[agentType]
if !ok {
return nil
}
min, err := parseSemver(minRaw)
if err != nil {
return fmt.Errorf("invalid minimum version %q for %s: %w", minRaw, agentType, err)
}
detected, err := parseSemver(detectedVersion)
if err != nil {
return fmt.Errorf("cannot parse detected %s version %q: %w", agentType, detectedVersion, err)
}
if detected.lessThan(min) {
return fmt.Errorf("%s version %s is below minimum required %s — please upgrade", agentType, detectedVersion, minRaw)
}
return nil
}

View File

@@ -0,0 +1,79 @@
package agent
import (
"testing"
)
func TestParseSemver(t *testing.T) {
tests := []struct {
input string
want semver
wantErr bool
}{
{"2.0.0", semver{2, 0, 0}, false},
{"v2.1.100", semver{2, 1, 100}, false},
{"2.1.100 (Claude Code)", semver{2, 1, 100}, false},
{"codex-cli 0.118.0", semver{0, 118, 0}, false},
{"1.0.20", semver{1, 0, 20}, false},
{"invalid", semver{}, true},
{"", semver{}, true},
}
for _, tt := range tests {
got, err := parseSemver(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseSemver(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
continue
}
if got != tt.want {
t.Errorf("parseSemver(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestSemverLessThan(t *testing.T) {
tests := []struct {
a, b semver
want bool
}{
{semver{1, 0, 0}, semver{2, 0, 0}, true},
{semver{2, 0, 0}, semver{1, 0, 0}, false},
{semver{2, 0, 0}, semver{2, 1, 0}, true},
{semver{2, 1, 0}, semver{2, 0, 0}, false},
{semver{2, 1, 12}, semver{2, 1, 13}, true},
{semver{2, 1, 13}, semver{2, 1, 12}, false},
{semver{2, 0, 0}, semver{2, 0, 0}, false},
}
for _, tt := range tests {
got := tt.a.lessThan(tt.b)
if got != tt.want {
t.Errorf("%v.lessThan(%v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
}
}
func TestCheckMinVersion(t *testing.T) {
tests := []struct {
agentType string
version string
wantErr bool
}{
{"claude", "2.0.0", false},
{"claude", "2.1.100", false},
{"claude", "2.1.100 (Claude Code)", false},
{"claude", "v2.0.0", false},
{"claude", "1.0.128", true},
{"claude", "1.9.99", true},
{"claude", "invalid", true},
{"codex", "codex-cli 0.118.0", false},
{"codex", "codex-cli 0.100.0", false},
{"codex", "codex-cli 0.99.0", true},
{"codex", "codex-cli 0.50.0", true},
{"unknown", "1.0.0", false},
}
for _, tt := range tests {
err := CheckMinVersion(tt.agentType, tt.version)
if (err != nil) != tt.wantErr {
t.Errorf("CheckMinVersion(%q, %q) error = %v, wantErr %v", tt.agentType, tt.version, err, tt.wantErr)
}
}
}