From 4e6c57b4ccf8539fcdbbd2088e7b539c3de35c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 26 Jul 2026 19:41:38 +0100 Subject: [PATCH] fix(agents): relax OpenCode install detection (#1967) --- .../agent/agents/ACP_BRIDGE_VERSIONS.md | 9 +- .../internal/agent/agents/opencode_acp.go | 48 +--------- .../agent/agents/opencode_acp_test.go | 92 ++----------------- 3 files changed, 12 insertions(+), 137 deletions(-) diff --git a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md index 43a0b85fbc..b81c618678 100644 --- a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md +++ b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md @@ -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 diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index ab4f57f3f5..2b72a9e6b6 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -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() } diff --git a/apps/backend/internal/agent/agents/opencode_acp_test.go b/apps/backend/internal/agent/agents/opencode_acp_test.go index 9713b8022f..d923c290e5 100644 --- a/apps/backend/internal/agent/agents/opencode_acp_test.go +++ b/apps/backend/internal/agent/agents/opencode_acp_test.go @@ -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") } }