fix(agents): relax OpenCode install detection (#1967)

This commit is contained in:
Carlos Florêncio
2026-07-26 19:41:38 +01:00
committed by GitHub
parent 68250fe3b3
commit 4e6c57b4cc
3 changed files with 12 additions and 137 deletions
@@ -16,11 +16,10 @@ part of `AgentCommand`, which existing lifecycle diagnostics log as
OpenCode intentionally uses the direct `opencode acp` command for discovery,
normal sessions, container commands, and one-shot inference. This keeps startup
offline-compatible and ensures discovery validates the executable actually
launched. Its installer remains pinned to `opencode-ai@1.18.4`, and discovery
runs that executable with `--version` before reporting it as supported. Missing,
malformed, failing, or mismatched version output reports the agent as unavailable
with the pinned install command as remediation. The ACP initialize response
separately records the runtime-reported agent name and version.
launched. Its installer remains pinned to `opencode-ai@1.18.4`, but discovery
accepts any `opencode` executable found on `PATH`; the ACP probe surfaces auth
or protocol-compatibility failures. The ACP initialize response separately
records the runtime-reported agent name and version.
To update a bridge, change one version constant at a time, confirm the exact
version exists in the configured npm registry, capture only sanitized ACP wire
@@ -3,10 +3,6 @@ package agents
import (
"context"
_ "embed"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/mcpconfig"
@@ -24,11 +20,6 @@ const (
opencodeACPPackage = "opencode-ai"
opencodeACPVersion = "1.18.4"
opencodeACPPackageSpec = opencodeACPPackage + "@" + opencodeACPVersion
opencodeVersionTimeout = 5 * time.Second
)
var opencodeVersionPattern = regexp.MustCompile(
`(?:^|[^0-9A-Za-z])[vV]?([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?:$|[^0-9A-Za-z.+-])`,
)
var (
@@ -83,25 +74,12 @@ func (a *OpenCodeACP) Logo(v LogoVariant) []byte {
}
func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
// Check for the opencode CLI on PATH. Auth state is surfaced later by
// the ACP probe, not by scanning ~/.opencode.
// Check for the opencode CLI on PATH. Any installed version is eligible
// for the ACP probe; auth and protocol compatibility are surfaced there.
result, err := Detect(ctx, WithCommand("opencode"))
if err != nil || !result.Available {
return result, err
}
version, err := probeOpenCodeVersion(ctx, result.MatchedPath)
if err != nil {
return unsupportedOpenCodeResult(result.MatchedPath), fmt.Errorf(
"cannot verify OpenCode %s at %q: %w; reinstall with `%s`",
opencodeACPVersion, result.MatchedPath, err, a.InstallScript(),
)
}
if version != opencodeACPVersion {
return unsupportedOpenCodeResult(result.MatchedPath), fmt.Errorf(
"unsupported OpenCode version %s at %q; Kandev requires %s; reinstall with `%s`",
version, result.MatchedPath, opencodeACPVersion, a.InstallScript(),
)
}
result.SupportsMCP = true
result.Capabilities = DiscoveryCapabilities{
SupportsSessionResume: true,
@@ -109,28 +87,6 @@ func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error)
return result, nil
}
func probeOpenCodeVersion(ctx context.Context, path string) (string, error) {
probeCtx, cancel := context.WithTimeout(ctx, opencodeVersionTimeout)
defer cancel()
output, err := exec.CommandContext(probeCtx, path, "--version").CombinedOutput()
if err != nil {
if probeCtx.Err() != nil {
return "", probeCtx.Err()
}
return "", fmt.Errorf("run --version: %w", err)
}
match := opencodeVersionPattern.FindStringSubmatch(strings.TrimSpace(string(output)))
if len(match) != 2 {
return "", fmt.Errorf("parse --version output %q", strings.TrimSpace(string(output)))
}
return match[1], nil
}
func unsupportedOpenCodeResult(path string) *DiscoveryResult {
return &DiscoveryResult{MatchedPath: path}
}
func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command {
return Cmd("opencode", "acp").Build()
}
@@ -2,11 +2,9 @@ package agents
import (
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
@@ -29,7 +27,7 @@ func TestOpenCodeACPUsesInstalledBinaryAndPinnedInstaller(t *testing.T) {
}
func TestOpenCodeACPDiscoveryMatchesRuntimeExecutable(t *testing.T) {
binaryPath := writeOpenCodeTestBinary(t, "printf '1.18.4\\n'")
binaryPath := writeOpenCodeTestBinary(t, "exit 7")
a := NewOpenCodeACP()
result, err := a.IsInstalled(context.Background())
@@ -47,93 +45,15 @@ func TestOpenCodeACPDiscoveryMatchesRuntimeExecutable(t *testing.T) {
}
}
func TestOpenCodeACPDiscoveryAcceptsSupportedVersionOutput(t *testing.T) {
tests := []struct {
name string
output string
}{
{name: "exact", output: "1.18.4"},
{name: "prefixed and whitespace", output: " OpenCode version v1.18.4 "},
{name: "slash prefix", output: "opencode/1.18.4 linux-x64"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writeOpenCodeTestBinary(t, "printf '%s\\n' \"$OPEN_CODE_VERSION_OUTPUT\"")
t.Setenv("OPEN_CODE_VERSION_OUTPUT", tt.output)
result, err := NewOpenCodeACP().IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled() error = %v", err)
}
if !result.Available {
t.Fatal("IsInstalled() Available = false, want true")
}
})
}
}
func TestOpenCodeACPDiscoveryRejectsUnsupportedVersion(t *testing.T) {
binaryPath := writeOpenCodeTestBinary(t, "printf '1.16.2\\n'")
result, err := NewOpenCodeACP().IsInstalled(context.Background())
if err == nil {
t.Fatal("IsInstalled() error = nil, want version mismatch")
}
if result.Available {
t.Fatal("IsInstalled() Available = true, want false")
}
if result.MatchedPath != binaryPath {
t.Fatalf("MatchedPath = %q, want %q", result.MatchedPath, binaryPath)
}
for _, want := range []string{"1.16.2", "requires 1.18.4", "npm install -g opencode-ai@1.18.4"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not contain %q", err, want)
}
}
}
func TestOpenCodeACPDiscoveryRejectsMalformedVersion(t *testing.T) {
writeOpenCodeTestBinary(t, "printf 'OpenCode development build\\n'")
result, err := NewOpenCodeACP().IsInstalled(context.Background())
if err == nil {
t.Fatal("IsInstalled() error = nil, want parse failure")
}
if result.Available {
t.Fatal("IsInstalled() Available = true, want false")
}
if !strings.Contains(err.Error(), "npm install -g opencode-ai@1.18.4") {
t.Fatalf("error %q does not contain pinned remediation", err)
}
}
func TestOpenCodeACPDiscoveryHandlesVersionCommandFailure(t *testing.T) {
func TestOpenCodeACPDiscoveryDoesNotRunVersionCommand(t *testing.T) {
writeOpenCodeTestBinary(t, "exit 7")
result, err := NewOpenCodeACP().IsInstalled(context.Background())
if err == nil {
t.Fatal("IsInstalled() error = nil, want command failure")
if err != nil {
t.Fatalf("IsInstalled() error = %v", err)
}
if result.Available {
t.Fatal("IsInstalled() Available = true, want false")
}
if !strings.Contains(err.Error(), "npm install -g opencode-ai@1.18.4") {
t.Fatalf("error %q does not contain pinned remediation", err)
}
}
func TestOpenCodeACPDiscoveryHonorsCancellation(t *testing.T) {
writeOpenCodeTestBinary(t, "/bin/sleep 10")
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := NewOpenCodeACP().IsInstalled(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("IsInstalled() error = %v, want context.Canceled", err)
}
if result.Available {
t.Fatal("IsInstalled() Available = true, want false")
if !result.Available {
t.Fatal("IsInstalled() Available = false, want true")
}
}