Skip to content

feat: opencode backend + per-user opt-in providers (Z.AI/Groq/OpenRouter)#141

Merged
Desperado merged 3 commits into
mainfrom
Desperado/opencode-per-user-providers
Jul 13, 2026
Merged

feat: opencode backend + per-user opt-in providers (Z.AI/Groq/OpenRouter)#141
Desperado merged 3 commits into
mainfrom
Desperado/opencode-per-user-providers

Conversation

@Desperado

Copy link
Copy Markdown
Contributor

What

Adds opencode as a fifth inference backend, driven as a subprocess CLI just like the existing cc/codex backends. It inherits opencode's broad provider support — but providers are gated per-user: nothing is enabled for everyone. Each user turns providers on individually via a new /providers command, and only their enabled providers show up in their /orch model picker.

Requested: make qmax-code work on the opencode harness, and let users selectively enable extra providers/models (Z.AI coding plan, Groq, Cerebras, OpenRouter) individually rather than for everyone.

Design decisions

# Decision
Harness opencode driven as a subprocess CLI (mirrors cc/codex). opencode supports native session resume + rich NDJSON, so it mirrors CCAgent, not Codex's self-managed history.
Enablement Local opt-in now, behind a single ProviderAllowed() entitlement seam so a QualityMax cloud check can gate it later with no UI change. Visible = entitled ∧ enabled.
Config qmax writes a managed ~/.qmax-code/opencode.json, pointed at the subprocess via OPENCODE_CONFIG — opencode merges it on top of the user's own opencode.jsonc, never clobbering it.
Keys Bring-your-own, stored in the OS keychain. They never hit disk in the managed config: custom providers reference {env:VAR}, and the real key is injected into the subprocess env at launch.
Model list Dynamic via opencode models <provider>, filtered to enabled+entitled providers.
Cerebras Stays on its first-class native backend (not routed through opencode); listed in /providers for discoverability only.

New surface

  • /providers — list opt-in providers; /providers enable <id> (prompts + validates key), /providers disable <id>.
  • /opencode — switch to the opencode backend; opencode models appear as a new section in /orch.
  • qmax-code config set backend opencode; --backend opencode.

Files

New: internal/agent/opencode_agent.go (OpenCodeAgent/CLIAgent, NDJSON parse, session resume), internal/agent/opencode_config.go (managed config + MCP + dynamic model listing), internal/api/providers.go (registry + keychain + entitlement seam), plus tests.

Edited: main.go (flag + routing + construction), internal/repl/repl.go (picker wiring, /providers, /opencode, /clear reset, help), internal/tui/tui_backend.go (opencode picker section + dynamic entries), internal/tui/input.go (slash menu), internal/setup/consent.go (opencode consent, skips external global-install), config_command.go, internal/api/config.go (EnabledProviders).

Testing

  • go build ./... && go vet ./... && go test ./... — all green.
  • Unit tests: provider registry/validation/keychain-env-fallback; managed-config generation asserts no plaintext key leak, provider block + MCP entry present; env injection.
  • Integration smoke: confirmed opencode accepts the managed config schema (mcp local server + provider block) and opencode models <provider> lists models through it; config CLI round-trip verified.

Notes / follow-ups

  • No auto-install of opencode in v1 — startup and /providers show an install hint if the binary is missing.
  • Custom provider (Z.AI) model seed is small; opencode models returns the live list where models.dev knows the provider.
  • Effort→--variant mapping is intentionally omitted in v1 (provider-specific values); effort still drives the QA system-prompt directive.

…OpenRouter)

Adds "opencode" as a fifth inference backend, driven as a subprocess CLI like
the cc/codex backends. It inherits opencode's broad provider support, but
providers are gated per-user: nothing is enabled for everyone. Each user opts
into providers individually via a new /providers command, and only their
enabled providers appear in their /orch model picker.

Backend (mirrors CCAgent — native session resume + rich NDJSON stream):
- internal/agent/opencode_agent.go: OpenCodeAgent implements CLIAgent, drives
  `opencode run --format json --model <provider>/<model> [--session <id>]`,
  parses NDJSON (text/tool/step events), resumes via --session.
- internal/agent/opencode_config.go: writes a qmax-managed opencode config
  (~/.qmax-code/opencode.json) pointed at the subprocess via OPENCODE_CONFIG so
  the user's own opencode.jsonc is never clobbered. qmax MCP server entry lets
  opencode call qmax tools natively.

Per-user providers:
- internal/api/providers.go: registry (zai-coding-plan custom, groq, openrouter),
  per-provider keychain storage, ValidateProviderKey, Config.EnabledProviders,
  and ProviderAllowed() — the single entitlement seam (returns true locally
  today; a cloud/QualityMax check plugs in later without UI changes). Visible
  set is always (entitled AND enabled).
- Keys are bring-your-own, stored in the OS keychain. They never touch disk in
  the managed config: custom providers reference {env:VAR} and the real key is
  injected into the subprocess env at launch (dynamic model list via
  `opencode models <provider>`).

Wiring: --backend flag + config validation, startup routing + construction
(main.go), /orch picker section + dynamic entries (tui_backend.go), /providers
and /opencode commands + slash menu (repl.go, input.go), consent branch
(consent.go), config show (config_command.go).

Tests: provider registry/keychain/validation, managed-config generation
(asserts no plaintext key leak, provider block + MCP present), env injection.
@sigilix

sigilix Bot commented Jul 12, 2026

Copy link
Copy Markdown

Sigilix Overview

Effort: 4/5 (large)

Quality gates

  • ✅ PR title follows convention
  • ✅ PR description is complete
  • ℹ️ PR is linked to an issue — No Closes #N / Closes SIG-N keyword found in PR body or commit messages.

Summary — latest push

Adds the opencode subprocess backend with per-user opt-in providers (Z.AI, Groq, OpenRouter), a /providers command for key management, and dynamic model listing. The managed config isolates secrets via {env:VAR} references and injects keys at runtime, while a new check warns users about plaintext keys in their own opencode config. The specialist review flagged missing tests for the REPL provider handlers and a performance concern around blocking subprocess calls during TUI model picker construction.

Important files

File Score Notes Next step
internal/repl/repl.go 5/5 Wires the /providers and /opencode commands, pre-flight validation, backend switching, and dynamic model resolution into the REPL loop. Add integration tests for handleProviders and resolveOpenCodeModelOverride to cover the enable/disable/resolve paths and error branches.
internal/agent/opencode_config.go 4/5 Generates the managed opencode config with MCP, custom provider blocks, and permission policies, and shells out to opencode models for dynamic model discovery. Extract the OpenCodeModels subprocess call into an interface so the REPL and TUI can invoke it asynchronously without blocking the event loop.
internal/agent/opencode_agent.go 4/5 Implements the OpenCodeAgent that orchestrates the opencode subprocess, manages session resume, and parses the NDJSON event stream to render output. Add unit tests for validOpenCodeSessionID and the NDJSON parseStream logic to cover session capture, tool call rendering, and edge cases like empty or malformed events.
internal/api/providers.go 4/5 Defines the provider registry, entitlement seam, keychain integration, and enable/disable logic for per-user opt-in providers. Add a concurrency test for EnableProviderID/DisableProviderID since the current Config methods lack mutex protection and could race if called from multiple goroutines.
internal/agent/opencode_config_test.go 3/5 Unit tests for config generation, key injection, and the plaintext key scanner, plus a regression test ensuring provider env vars reach the opencode models subprocess. Add a test verifying that WriteOpenCodeConfig handles a nil SessionContext gracefully without panicking on the nil map access in mcpEnv.

Sequence diagram

sequenceDiagram
    participant User
    participant REPL
    participant Providers as /providers
    participant Config as WriteOpenCodeConfig
    participant Keychain
    participant OpenCode as opencode CLI
    User->>REPL: /providers enable groq
    REPL->>Keychain: SaveProviderKey(groq, key)
    REPL->>Config: WriteOpenCodeConfig(providers)
    Config->>Config: Write {env:VAR} refs
    User->>REPL: /opencode
    REPL->>Config: WriteOpenCodeConfig(providers, session, mode)
    REPL->>OpenCode: OpenCodeModels(bin, config, env, provider)
    OpenCode-->>REPL: [groq/model-a, groq/model-b]
    REPL->>REPL: Show model picker
    User->>REPL: Select model
    REPL->>OpenCode: NewOpenCodeAgent(bin, model, mode)
Loading

Confidence: 3/5

Synchronous subprocess calls in the TUI picker path and untested REPL handler logic introduce latency and regression risk that should be validated before merge.

  • buildOpenCodeModelEntries (repl.go ~line 1790) calls agent.OpenCodeModels synchronously for each active provider, which will freeze the TUI picker on slow networks or when the opencode binary stalls.
  • handleProviders and its sub-functions in repl.go lack any test coverage for the enable, disable, and validation branches.
  • In WriteOpenCodeConfig, if sctx is nil, accessing sctx.ProjectID on line 155 will panic; add a nil guard or document that nil is invalid.
  • The PlaintextKeyInUserOpenCodeConfig regex will match apiKey inside JSONC comments since it doesn't strip them first, potentially producing false positives.

Suggested labels: feature security

Phase: Degraded
Commit: e3990cc
Specialists: 3/4 completed
Dispatched: logic, security, performance, tests
Completed: security, performance, tests
Missing: logic
Graph context: none; 0/4 files; 0 nodes

@sigilix sigilix Bot added the enhancement New feature or request label Jul 12, 2026
@qualitymaxapp

qualitymaxapp Bot commented Jul 12, 2026

Copy link
Copy Markdown

✅ QualityMax Pipeline

Gate Result
🔍 AI Review ✅ Clean
🧪 Repo Tests ✅ 424/424 passed (go)
🤖 AI Tests ✅ 47/51 passed

Powered by QualityMax — AI-Powered Test Automation

@sigilix sigilix Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Reviewed 10 of 14 changed files across this PR so far — the remaining 4 were below the priority cutoff for this very large PR. Split into smaller PRs to cover them.

a.mu.Unlock()
}

func (a *OpenCodeAgent) SetOutputVerbose(verbose bool) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 LOGICGROUNDED Unlocked read of outputVerbose races with SetOutputVerbose

SetOutputVerbose writes outputVerbose under a.mu, but parseStream reads it without the mutex when deciding whether to emit the trailing newline, and Run reads it without the mutex when building the system prompt. This breaks the intended synchronization and is a data race under Go's memory model; a concurrent verbose toggle could stream stale state or be flagged by the race detector.

Example:

input: user toggles verbose mode while a long opencode turn is streaming
actual: race detector reports read/write race on outputVerbose
expected: all reads and writes of outputVerbose are synchronized

Current:

func (a *OpenCodeAgent) SetOutputVerbose(verbose bool) {

Proposed:

if !a.outputVerboseLocked() {
Suggested change
func (a *OpenCodeAgent) SetOutputVerbose(verbose bool) {
if !a.outputVerboseLocked() {
More Info
  • Threat model: A concurrent call to SetOutputVerbose (e.g., from a TUI/config toggle) while a turn is running reads a value that is not happens-before ordered with the write, producing undefined behavior in Go.
  • Specific code citations: SetOutputVerbose locks a.mu around the write; parseStream accesses a.outputVerbose directly at the tool-icon newline branch; Run passes a.outputVerbose to outputStyleDirective.
  • Existing protections: Other mutable fields (sessionID, lastToolName) are correctly accessed under a.mu; only outputVerbose is read outside the lock.
  • Proposed mitigation: Add a locked getter for outputVerbose and use it in both Run and parseStream, or wrap each read in a.mu.Lock()/Unlock().
  • Alternative mitigations considered: Remove the mutex from SetOutputVerbose if the field is never mutated concurrently, but that contradicts the existing lock and leaves the method racy if callers do use it concurrently.
  • Severity calibration: Score 3: the blast radius is bounded to output formatting, but it is a genuine data race on mutable agent state.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_agent.go
Line: 288

Comment:
**Unlocked read of `outputVerbose` races with `SetOutputVerbose`**

`SetOutputVerbose` writes `outputVerbose` under `a.mu`, but `parseStream` reads it without the mutex when deciding whether to emit the trailing newline, and `Run` reads it without the mutex when building the system prompt. This breaks the intended synchronization and is a data race under Go's memory model; a concurrent verbose toggle could stream stale state or be flagged by the race detector.

Example:
input: user toggles verbose mode while a long opencode turn is streaming
actual: race detector reports read/write race on outputVerbose
expected: all reads and writes of outputVerbose are synchronized

Threat model:
A concurrent call to `SetOutputVerbose` (e.g., from a TUI/config toggle) while a turn is running reads a value that is not happens-before ordered with the write, producing undefined behavior in Go.

Specific code citations:
`SetOutputVerbose` locks `a.mu` around the write; `parseStream` accesses `a.outputVerbose` directly at the tool-icon newline branch; `Run` passes `a.outputVerbose` to `outputStyleDirective`.

Existing protections:
Other mutable fields (`sessionID`, `lastToolName`) are correctly accessed under `a.mu`; only `outputVerbose` is read outside the lock.

Proposed mitigation:
Add a locked getter for `outputVerbose` and use it in both `Run` and `parseStream`, or wrap each read in `a.mu.Lock()/Unlock()`.

Alternative mitigations considered:
Remove the mutex from `SetOutputVerbose` if the field is never mutated concurrently, but that contradicts the existing lock and leaves the method racy if callers do use it concurrently.

Severity calibration:
Score 3: the blast radius is bounded to output formatting, but it is a genuine data race on mutable agent state.

Suggested fix shape:
func (a *OpenCodeAgent) outputVerboseLocked() bool {
  a.mu.Lock()
  defer a.mu.Unlock()
  return a.outputVerbose
}

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/repl/repl.go Outdated
if len(active) == 0 {
return nil
}
path, err := agent.WriteOpenCodeConfig(cfg, sctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PERFGROUNDED Sequential opencode models subprocess calls block the model picker

buildOpenCodeModelEntries loops over each active provider and calls agent.OpenCodeModels, which shells out to opencode models <provider> with a 15-second timeout per call. With N enabled providers the picker opening takes the sum of N subprocess latencies — up to N * 15 s worst case — when these independent calls could run concurrently, reducing wait to the max single-call latency. With 2–3 providers and typical CLI startup of 1–3 s each, the picker opens in 3–9 s instead of 1–3 s.

More Info
  • Threat model: Any user who enables 2+ opencode providers and opens /orch experiences a multiplied delay before the picker renders.
  • Specific code citations: internal/repl/repl.go buildOpenCodeModelEntries — the for _, p := range active loop calling agent.OpenCodeModels(bin, path, p.ID) sequentially; internal/agent/opencode_config.go OpenCodeModelsexec.CommandContext(ctx, bin, 'models', providerID) with 15*time.Second timeout.
  • Existing protections: The 15 s per-call timeout prevents infinite hangs but does not reduce the additive latency across providers.
  • Proposed mitigation: Run the OpenCodeModels calls concurrently (e.g., errgroup.Group with SetLimit(len(active))) and collect results into entries with a mutex. The rest of buildOpenCodeModelEntries (config write, label extraction) stays sequential.
  • Alternative mitigations considered: Cache model lists with a short TTL so repeated /orch opens skip the subprocess; still needs concurrent cold-fill.
  • Severity calibration: Score 3 because the delay is user-facing and multiplicative with provider count, but the current provider catalogue is small (3 built-in) and the action is user-initiated — not a production-down or throughput issue.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/repl/repl.go
Line: 1784

Comment:
**Sequential `opencode models` subprocess calls block the model picker**

`buildOpenCodeModelEntries` loops over each active provider and calls `agent.OpenCodeModels`, which shells out to `opencode models <provider>` with a 15-second timeout per call. With N enabled providers the picker opening takes the sum of N subprocess latencies — up to N * 15 s worst case — when these independent calls could run concurrently, reducing wait to the max single-call latency. With 2–3 providers and typical CLI startup of 1–3 s each, the picker opens in 3–9 s instead of 1–3 s.

Threat model:
Any user who enables 2+ opencode providers and opens /orch experiences a multiplied delay before the picker renders.

Specific code citations:
internal/repl/repl.go `buildOpenCodeModelEntries` — the `for _, p := range active` loop calling `agent.OpenCodeModels(bin, path, p.ID)` sequentially; internal/agent/opencode_config.go `OpenCodeModels` — `exec.CommandContext(ctx, bin, 'models', providerID)` with `15*time.Second` timeout.

Existing protections:
The 15 s per-call timeout prevents infinite hangs but does not reduce the additive latency across providers.

Proposed mitigation:
Run the `OpenCodeModels` calls concurrently (e.g., `errgroup.Group` with `SetLimit(len(active))`) and collect results into `entries` with a mutex. The rest of `buildOpenCodeModelEntries` (config write, label extraction) stays sequential.

Alternative mitigations considered:
Cache model lists with a short TTL so repeated /orch opens skip the subprocess; still needs concurrent cold-fill.

Severity calibration:
Score 3 because the delay is user-facing and multiplicative with provider count, but the current provider catalogue is small (3 built-in) and the action is user-initiated — not a production-down or throughput issue.

How can I resolve this? If you propose a fix, please make it concise.

continue
}
seenTool[ev.Part.ID] = true
displayName := stripMCPPrefix(ev.Part.Tool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 LOGICGROUNDED parseStream silently drops oversized NDJSON lines

The scanner is capped at 1 MiB per line, but parseStream never checks scanner.Err() after the loop. If opencode emits a single NDJSON event larger than that, Scan() stops and the remainder of the stream is discarded without surfacing an error.

Example:

input: opencode emits a 2 MiB tool-result event
actual: scanner stops, remaining response is lost, no error returned
expected: the oversized-line error is propagated or logged

Current:

			displayName := stripMCPPrefix(ev.Part.Tool)

Proposed:

term.FinishMarkdown(finalResult)
return finalResult
Suggested change
displayName := stripMCPPrefix(ev.Part.Tool)
term.FinishMarkdown(finalResult)
return finalResult
More Info
  • Threat model: An oversized tool-result event from opencode causes silent truncation of the assistant's response.
  • Specific code citations: parseStream creates bufio.Scanner with Buffer(make([]byte, 1<<20), 1<<20) but lacks a scanner.Err() check after the for scanner.Scan() loop.
  • Existing protections: None — the error is silently swallowed.
  • Proposed mitigation: Check scanner.Err() after the loop and return an error wrapping it.
  • Alternative mitigations considered: Increase the buffer cap, but that only raises the threshold; the error check is still required.
  • Severity calibration: Score 2: requires an unusually large single event to trigger, but when it does the failure is silent data loss.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_agent.go
Line: 260

Comment:
**`parseStream` silently drops oversized NDJSON lines**

The scanner is capped at 1 MiB per line, but `parseStream` never checks `scanner.Err()` after the loop. If opencode emits a single NDJSON event larger than that, `Scan()` stops and the remainder of the stream is discarded without surfacing an error.

Example:
input: opencode emits a 2 MiB tool-result event
actual: scanner stops, remaining response is lost, no error returned
expected: the oversized-line error is propagated or logged

Threat model:
An oversized tool-result event from opencode causes silent truncation of the assistant's response.

Specific code citations:
`parseStream` creates `bufio.Scanner` with `Buffer(make([]byte, 1<<20), 1<<20)` but lacks a `scanner.Err()` check after the `for scanner.Scan()` loop.

Existing protections:
None — the error is silently swallowed.

Proposed mitigation:
Check `scanner.Err()` after the loop and return an error wrapping it.

Alternative mitigations considered:
Increase the buffer cap, but that only raises the threshold; the error check is still required.

Severity calibration:
Score 2: requires an unusually large single event to trigger, but when it does the failure is silent data loss.

Suggested fix shape:
if err := scanner.Err(); err != nil {
  return finalResult, fmt.Errorf("opencode stream: %w", err)
}

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/agent/opencode_config.go Outdated
// "provider/model" strings. It shells out to `opencode models <providerID>`
// with OPENCODE_CONFIG pointed at the managed config so custom providers
// (whose models come from our seeded block) resolve too. Returns nil on error.
func OpenCodeModels(bin, configPath, providerID string) []string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 LOGICGROUNDED OpenCodeModels ignores scanner errors

Like parseStream, OpenCodeModels never checks scanner.Err() after scanning opencode models output. An oversized or malformed line would silently truncate the model list returned to the picker.

Example:

input: `opencode models groq` emits a line > 1 MiB
actual: scanning stops and the function returns the partial list
expected: scanner error is checked and surfaced

Current:

func OpenCodeModels(bin, configPath, providerID string) []string {

Proposed:

return models
Suggested change
func OpenCodeModels(bin, configPath, providerID string) []string {
return models
More Info
  • Threat model: A provider with many models or a malformed line causes the picker to show an incomplete model list without warning.
  • Specific code citations: OpenCodeModels creates a scanner with Buffer(make([]byte, 1<<16), 1<<20) but lacks a scanner.Err() check after the for scanner.Scan() loop.
  • Existing protections: None — the error is silently swallowed.
  • Proposed mitigation: Check scanner.Err() after the loop and return nil on error (consistent with the function's existing error behavior).
  • Alternative mitigations considered: Return the partial list with an error, but the function signature returns []string only.
  • Severity calibration: Score 2: silent truncation of model list is a correctness bug, but the 15 s subprocess timeout and typical model list sizes make oversized lines unlikely.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_config.go
Line: 142

Comment:
**`OpenCodeModels` ignores scanner errors**

Like `parseStream`, `OpenCodeModels` never checks `scanner.Err()` after scanning `opencode models` output. An oversized or malformed line would silently truncate the model list returned to the picker.

Example:
input: `opencode models groq` emits a line > 1 MiB
actual: scanning stops and the function returns the partial list
expected: scanner error is checked and surfaced

Threat model:
A provider with many models or a malformed line causes the picker to show an incomplete model list without warning.

Specific code citations:
`OpenCodeModels` creates a scanner with `Buffer(make([]byte, 1<<16), 1<<20)` but lacks a `scanner.Err()` check after the `for scanner.Scan()` loop.

Existing protections:
None — the error is silently swallowed.

Proposed mitigation:
Check `scanner.Err()` after the loop and return `nil` on error (consistent with the function's existing error behavior).

Alternative mitigations considered:
Return the partial list with an error, but the function signature returns `[]string` only.

Severity calibration:
Score 2: silent truncation of model list is a correctness bug, but the 15 s subprocess timeout and typical model list sizes make oversized lines unlikely.

Suggested fix shape:
if err := scanner.Err(); err != nil {
  return nil
}
return models

How can I resolve this? If you propose a fix, please make it concise.

Comment thread main.go
}
cliAgent = ca
case "opencode":
oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig, ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 LOGICGROUNDED Validated opencode binary path is discarded and re-looked up

In main.go, the opencode path validates agent.FindOpenCode() into openCodeBin and exits if it is empty, but then discards that validated path and calls agent.FindOpenCode() again when constructing NewOpenCodeAgent. This breaks the invariant that the agent receives the same binary path that was just confirmed to exist. Pass the already-validated openCodeBin to the constructor instead.

Why this wasn't caught: Existing tests verify the picker signature change, not the main.go agent construction path.

Current:

		oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig, ctx)

Proposed:

		oc := agent.NewOpenCodeAgent(openCodeBin, appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig, ctx)
Suggested change
oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig, ctx)
oc := agent.NewOpenCodeAgent(openCodeBin, appConfig.ModelOverride, appConfig.Effort, appConfig.OrchPermissionMode, appConfig.OutputVerbose, appConfig, ctx)
More Info
  • Threat model: If the binary disappears between the two lookups (unlikely but possible), the agent receives an empty string and fails later with a less clear error.
  • Specific code citations: main.go lines 300-324 validate openCodeBin := agent.FindOpenCode() and exit on empty; line 435 calls agent.FindOpenCode() again for NewOpenCodeAgent.
  • Existing protections: The first lookup guards the backend activation; the second is redundant.
  • Proposed mitigation: Pass the validated openCodeBin variable to NewOpenCodeAgent.
  • Alternative mitigations considered: None needed — this is a straightforward variable reuse.
  • Severity calibration: Score 2: low likelihood of divergence, but breaks a clear validation invariant and duplicates work.
Prompt To Fix With AI
This is a comment left during a code review.
Path: main.go
Line: 435

Comment:
**Validated opencode binary path is discarded and re-looked up**

In main.go, the opencode path validates `agent.FindOpenCode()` into `openCodeBin` and exits if it is empty, but then discards that validated path and calls `agent.FindOpenCode()` again when constructing `NewOpenCodeAgent`. This breaks the invariant that the agent receives the same binary path that was just confirmed to exist. Pass the already-validated `openCodeBin` to the constructor instead.

Threat model:
If the binary disappears between the two lookups (unlikely but possible), the agent receives an empty string and fails later with a less clear error.

Specific code citations:
main.go lines 300-324 validate `openCodeBin := agent.FindOpenCode()` and exit on empty; line 435 calls `agent.FindOpenCode()` again for `NewOpenCodeAgent`.

Existing protections:
The first lookup guards the backend activation; the second is redundant.

Proposed mitigation:
Pass the validated `openCodeBin` variable to `NewOpenCodeAgent`.

Alternative mitigations considered:
None needed — this is a straightforward variable reuse.

Severity calibration:
Score 2: low likelihood of divergence, but breaks a clear validation invariant and duplicates work.

Why this wasn't caught:
Existing tests verify the picker signature change, not the main.go agent construction path.

How can I resolve this? If you propose a fix, please make it concise.

…sion/teardown

Addresses review of the opencode backend:

- BLOCKER: OpenCodeModels invoked `opencode models <provider>` without the
  provider key env, so known providers (groq/openrouter) returned "Provider not
  found" and never reached the picker. Thread OpenCodeProviderEnv through to the
  listing call. Regression test with a stub binary asserts the key is passed.

- /opencode could launch opencode with a model from a different backend (e.g.
  gpt-5.6 left in ModelOverride by Codex). Add resolveOpenCodeModelOverride:
  keep the override only if it's a real provider/model for an enabled provider,
  else fall back to the first available model.

- Standard permission mode was a no-op for opencode (only --auto for
  unattended). Write an explicit permission policy in standard mode (deny edits
  + destructive shell) and always launch with --auto so non-denied tools run in
  the non-interactive `opencode run`. This reproduces CC-standard's intent
  (reads/tests allowed, no file mutation / destructive shell). Test asserts the
  policy is present in standard and absent in unattended.

- A failed /opencode switch tore down the active agent first. Move all
  validation (CLI present, providers enabled, model resolvable, consent) into a
  pre-flight before teardown, so a failed switch leaves the current backend
  intact.

@sigilix sigilix Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Reviewed 13 of 14 changed files across this PR so far — the remaining 1 was below the priority cutoff for this very large PR. Split into smaller PRs to cover them.

switch {
case ev.Type == "text" || ev.Part.Type == "text":
id := ev.Part.ID
text := ev.Part.Text

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 TESTINGGROUNDED opencode NDJSON parser drops text payloads that arrive on tool events

parseStream scans NDJSON and discriminates ev.Type == "text" || ev.Part.Type == "text" as one case, and ev.Part.Type == "tool" || ev.Type == "tool" as the other. A tool event that carries both a Part.Tool and a non-empty Part.Text (for example, a tool result event where opencode emits type:"tool" plus a text payload containing the tool output) is consumed only by the tool branch, and the text payload is silently dropped — the new text never enters textByPart and never appears in the final result. This silently loses tool-result content. The fix is to check ev.Part.Text regardless of Type; the most natural forward-proof shape is a switch on ev.Part.Type that falls through to a shared if text := ev.Part.Text; text != "" append.

Example:

input: NDJSON line `{"type":"tool","part":{"id":"p1","type":"tool","tool":"edit","text":"Applied edit..."}}`
actual: tool icon shown; "Applied edit..." never rendered and not contributed to finalResult
expected: text is streamed/accumulated and included in finalResult alongside any streamed text from prior text-only parts

Suggested fix:

switch ev.Part.Type {
case "text":
  // handle text-by-part accumulator and delta streaming as today
case "tool":
  // handle tool-icon display
}
// shared: always process text regardless of Part.Type
if text := ev.Part.Text; text != "" {
  // accumulate and stream delta
}
More Info
  • Threat model: A user asking opencode to run a tool and incorporate its output in the conversation receives an answer that is missing the tool result. The impact is correctness — assistant reasoning that depends on the tool output degrades or is lost. This is not a security or auth boundary.
  • Specific code citations: parseStream in internal/agent/opencode_agent.go lines 217–260: the top-level switch branches are exclusive — the text branch only runs when ev.Type == "text" || ev.Part.Type == "text", and the tool branch only runs when ev.Part.Type == "tool" || ev.Type == "tool". No common path processes ev.Part.Text when ev.Part.Type == "tool" && ev.Part.Text != "".
  • Existing protections: None — the parser has no fallback that captures text on tool-typed events.
  • Proposed mitigation: Refactor the event loop to always process ev.Part.Text when non-empty, independent of ev.Part.Type. A switch on ev.Part.Type for type-specific handling (tool icon, etc.) followed by a shared text-accumulation block is the cleanest fix.
  • Alternative mitigations considered: Keep the current branch structure but duplicate the text-accumulation logic in the tool branch — more error-prone and harder to maintain.
  • Severity calibration: Score 4 because this is a reachable correctness bug on a core conversation path (tool use) with no test coverage; it silently corrupts assistant output when opencode emits tool-result text.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_agent.go
Line: 239

Comment:
**opencode NDJSON parser drops text payloads that arrive on tool events**

`parseStream` scans NDJSON and discriminates `ev.Type == "text" || ev.Part.Type == "text"` as one case, and `ev.Part.Type == "tool" || ev.Type == "tool"` as the other. A tool event that carries both a `Part.Tool` and a non-empty `Part.Text` (for example, a tool result event where opencode emits `type:"tool"` plus a text payload containing the tool output) is consumed only by the tool branch, and the text payload is silently dropped — the new text never enters `textByPart` and never appears in the final result. This silently loses tool-result content. The fix is to check `ev.Part.Text` regardless of `Type`; the most natural forward-proof shape is a `switch` on `ev.Part.Type` that falls through to a shared `if text := ev.Part.Text; text != ""` append.

Example:
input: NDJSON line `{"type":"tool","part":{"id":"p1","type":"tool","tool":"edit","text":"Applied edit..."}}`
actual: tool icon shown; "Applied edit..." never rendered and not contributed to finalResult
expected: text is streamed/accumulated and included in finalResult alongside any streamed text from prior text-only parts

Threat model:
A user asking opencode to run a tool and incorporate its output in the conversation receives an answer that is missing the tool result. The impact is correctness — assistant reasoning that depends on the tool output degrades or is lost. This is not a security or auth boundary.

Specific code citations:
`parseStream` in `internal/agent/opencode_agent.go` lines 217–260: the top-level `switch` branches are exclusive — the `text` branch only runs when `ev.Type == "text" || ev.Part.Type == "text"`, and the `tool` branch only runs when `ev.Part.Type == "tool" || ev.Type == "tool"`. No common path processes `ev.Part.Text` when `ev.Part.Type == "tool" && ev.Part.Text != ""`.

Existing protections:
None — the parser has no fallback that captures text on tool-typed events.

Proposed mitigation:
Refactor the event loop to always process `ev.Part.Text` when non-empty, independent of `ev.Part.Type`. A `switch` on `ev.Part.Type` for type-specific handling (tool icon, etc.) followed by a shared text-accumulation block is the cleanest fix.

Alternative mitigations considered:
Keep the current branch structure but duplicate the text-accumulation logic in the tool branch — more error-prone and harder to maintain.

Severity calibration:
Score 4 because this is a reachable correctness bug on a core conversation path (tool use) with no test coverage; it silently corrupts assistant output when opencode emits tool-result text.

Suggested fix shape:
switch ev.Part.Type {
case "text":
  // handle text-by-part accumulator and delta streaming as today
case "tool":
  // handle tool-icon display
}
// shared: always process text regardless of Part.Type
if text := ev.Part.Text; text != "" {
  // accumulate and stream delta
}

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/repl/repl.go
Comment on lines +1793 to +1816
if bin == "" {
return nil
}
active := cfg.ActiveProviders()
if len(active) == 0 {
return nil
}
path, err := agent.WriteOpenCodeConfig(cfg, sctx, cfg.OrchPermissionMode)
if err != nil {
return nil
}
// Provider keys must be present in the env or `opencode models <provider>`
// reports "Provider not found" for known providers (groq/openrouter).
env := agent.OpenCodeProviderEnv(cfg)
var entries []tui.OpenCodeModelEntry
for _, p := range active {
for _, full := range agent.OpenCodeModels(bin, path, env, p.ID) {
label := full
if i := strings.Index(full, "/"); i >= 0 {
label = full[i+1:]
}
entries = append(entries, tui.OpenCodeModelEntry{
ProviderID: p.ID,
ProviderName: p.DisplayName,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 TESTINGGROUNDED No test or guard for the synchronous multi-provider model-list resolution during /orch

resolveOpenCodeModelOverride calls buildOpenCodeModelEntries, which shells out to opencode models <provider> for every active provider serially and synchronously, with the full REPL input loop blocked. Each call has a 15-second timeout (opencode_config.go:185), and with multiple enabled providers the total blocking time can exceed 30 seconds. This is not a performance bug (the underlying work is necessary); it is a missing test for the usability contract: no test asserts that the model list is resolved quickly (e.g. N providers within 3×15s) or that it signals progress so the user is not staring at a frozen terminal wondering if qmax hung. A small integration test — or at minimum a documented cap such as cancelling the context when the REPL's own cancellation signal fires — would guard the contract.

Example:

input: user enables 4 providers, opens /orch
expected: model list appears within a few seconds or a progress indicator shows
actual: terminal blocks for up to 60 seconds with no feedback; if one provider is unreachable, the 15-second timeout stalls each subsequent call before any models are rendered

Suggested fix:

Insert a context cancellation tied to a terminal-level interrupt signal, and/or test that the model resolution function returns within an upper-bound time budget when all providers are reachable.
More Info
  • Threat model: Human user — this is a UX failure, not a system boundary. Indefinite blocking in a TUI makes qmax appear hung.
  • Specific code citations: buildOpenCodeModelEntries in internal/repl/repl.go lines 1786–1806: the for _, p := range active loop calling agent.OpenCodeModels sequentially. OpenCodeModels in internal/agent/opencode_config.go lines 186–207: spawns exec.CommandContext with 15 s timeout per provider.
  • Existing protections: Each OpenCodeModels call has a 15 s context.WithTimeout, preventing an infinite hang per provider, but the timeouts are additive across the sequential loop. No test exercises a multi-provider scenario, and no bounded-time contract is asserted.
  • Proposed mitigation: Add a test that exercises multi-provider model resolution and asserts an upper-bound wall-clock time (e.g. 3 providers resolve within 10 s). Optionally, tie the subprocess context to a REPL-level cancellation signal so Ctrl-C or terminal interrupt aborts the model fetch.
  • Alternative mitigations considered: Parallelize the subprocess calls (see performance finding) which reduces wall-clock time but does not replace the need for a contract test.
  • Severity calibration: Score 4 because the blocking path is on the primary interactive command (/orch), affects every user with >1 enabled provider, and has zero test coverage for the multi-producer case.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/repl/repl.go
Line: 1793-1816

Comment:
**No test or guard for the synchronous multi-provider model-list resolution during /orch**

`resolveOpenCodeModelOverride` calls `buildOpenCodeModelEntries`, which shells out to `opencode models <provider>` for every active provider *serially* and *synchronously*, with the full REPL input loop blocked. Each call has a 15-second timeout (`opencode_config.go:185`), and with multiple enabled providers the total blocking time can exceed 30 seconds. This is not a performance bug (the underlying work is necessary); it is a missing test for the usability contract: no test asserts that the model list is resolved quickly (e.g. N providers within 3×15s) or that it signals progress so the user is not staring at a frozen terminal wondering if qmax hung. A small integration test — or at minimum a documented cap such as cancelling the context when the REPL's own cancellation signal fires — would guard the contract.

Example:
input: user enables 4 providers, opens /orch
expected: model list appears within a few seconds or a progress indicator shows
actual: terminal blocks for up to 60 seconds with no feedback; if one provider is unreachable, the 15-second timeout stalls each subsequent call before any models are rendered

Threat model:
Human user — this is a UX failure, not a system boundary. Indefinite blocking in a TUI makes qmax appear hung.

Specific code citations:
`buildOpenCodeModelEntries` in `internal/repl/repl.go` lines 1786–1806: the `for _, p := range active` loop calling `agent.OpenCodeModels` sequentially. `OpenCodeModels` in `internal/agent/opencode_config.go` lines 186–207: spawns `exec.CommandContext` with 15 s timeout per provider.

Existing protections:
Each `OpenCodeModels` call has a 15 s `context.WithTimeout`, preventing an infinite hang per provider, but the timeouts are additive across the sequential loop. No test exercises a multi-provider scenario, and no bounded-time contract is asserted.

Proposed mitigation:
Add a test that exercises multi-provider model resolution and asserts an upper-bound wall-clock time (e.g. 3 providers resolve within 10 s). Optionally, tie the subprocess context to a REPL-level cancellation signal so Ctrl-C or terminal interrupt aborts the model fetch.

Alternative mitigations considered:
Parallelize the subprocess calls (see performance finding) which reduces wall-clock time but does not replace the need for a contract test.

Severity calibration:
Score 4 because the blocking path is on the primary interactive command (`/orch`), affects every user with >1 enabled provider, and has zero test coverage for the multi-producer case.

Suggested fix shape:
Insert a context cancellation tied to a terminal-level interrupt signal, and/or test that the model resolution function returns within an upper-bound time budget when all providers are reachable.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/repl/repl.go
Comment on lines +1786 to +1800
// buildOpenCodeModelEntries resolves the picker rows for the opencode backend:
// one per model exposed by each provider the user enabled AND is entitled to.
// It queries `opencode models <provider>` at call time so the list is live
// (models.dev-backed for Groq/OpenRouter, seeded config for custom providers).
// Returns nil when opencode isn't installed or no providers are active.
func buildOpenCodeModelEntries(cfg *api.Config, sctx *api.SessionContext) []tui.OpenCodeModelEntry {
bin := agent.FindOpenCode()
if bin == "" {
return nil
}
active := cfg.ActiveProviders()
if len(active) == 0 {
return nil
}
path, err := agent.WriteOpenCodeConfig(cfg, sctx, cfg.OrchPermissionMode)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PERFGROUNDED Sequential opencode models subprocess calls block the interactive picker

buildOpenCodeModelEntries loops over active providers and calls agent.OpenCodeModels for each one sequentially. Each call spawns an opencode models <provider> subprocess with a 15-second timeout. With N enabled providers, the caller blocks for up to N×15 s before the picker renders. The /orch command already prints 'Querying opencode models for your enabled providers…' and then freezes the TUI while these subprocesses run. Under normal conditions (1–3 s per call, 3–4 providers) this adds 3–12 s of latency; if opencode is slow or a provider is unreachable, it can approach the full 60 s timeout budget. The fix is to fan out the OpenCodeModels calls concurrently (e.g. errgroup with a semaphore) and collect results, cutting wall-clock time to roughly one round-trip instead of N.

Suggested fix:

var eg errgroup.Group
results := make([][]string, len(active))
for i, p := range active {
  i, p := i, p
  eg.Go(func() error {
    results[i] = agent.OpenCodeModels(bin, path, env, p.ID)
    return nil
  })
}
eg.Wait()
for _, ms := range results {
  for _, full := range ms {
    // ... build entries
  }
}
More Info
  • Threat model: Interactive CLI user types /orch or /opencode and experiences multi-second to multi-tens-of-seconds freeze before the picker appears.
  • Specific code citations: buildOpenCodeModelEntries in internal/repl/repl.go lines 1786–1806: the for _, p := range active loop calling agent.OpenCodeModels(bin, path, env, p.ID) sequentially. OpenCodeModels in internal/agent/opencode_config.go lines 186–207: spawns exec.CommandContext with 15 s timeout per provider.
  • Existing protections: Each OpenCodeModels call has a 15 s context.WithTimeout, preventing an infinite hang per provider, but the timeouts are additive across the sequential loop.
  • Proposed mitigation: Run the OpenCodeModels calls concurrently using an errgroup.Group (or goroutines + a sync mechanism), then collect and flatten results. Preserve the per-call 15 s timeout but run them in parallel so wall-clock time is max(one call) rather than sum(all calls).
  • Alternative mitigations considered: Cache model lists with a short TTL (e.g. 5 min) so repeated /orch invocations skip the subprocess calls — but this adds cache-invalidation complexity and doesn't help the first call. Parallel execution is simpler and addresses the root cause.
  • Severity calibration: Score 3 because this is a latency regression under plausible but not typical load (3+ providers, slow opencode). Under normal conditions with 1–2 providers it adds only 1–6 s, which is noticeable but not blocking. It would become score 4+ if the provider count grew or if this were a server request path rather than an interactive CLI.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/repl/repl.go
Line: 1786-1800

Comment:
**Sequential `opencode models` subprocess calls block the interactive picker**

`buildOpenCodeModelEntries` loops over `active` providers and calls `agent.OpenCodeModels` for each one sequentially. Each call spawns an `opencode models <provider>` subprocess with a 15-second timeout. With N enabled providers, the caller blocks for up to N×15 s before the picker renders. The `/orch` command already prints 'Querying opencode models for your enabled providers…' and then freezes the TUI while these subprocesses run. Under normal conditions (1–3 s per call, 3–4 providers) this adds 3–12 s of latency; if opencode is slow or a provider is unreachable, it can approach the full 60 s timeout budget. The fix is to fan out the `OpenCodeModels` calls concurrently (e.g. `errgroup` with a semaphore) and collect results, cutting wall-clock time to roughly one round-trip instead of N.

Threat model:
Interactive CLI user types `/orch` or `/opencode` and experiences multi-second to multi-tens-of-seconds freeze before the picker appears.

Specific code citations:
`buildOpenCodeModelEntries` in `internal/repl/repl.go` lines 1786–1806: the `for _, p := range active` loop calling `agent.OpenCodeModels(bin, path, env, p.ID)` sequentially. `OpenCodeModels` in `internal/agent/opencode_config.go` lines 186–207: spawns `exec.CommandContext` with 15 s timeout per provider.

Existing protections:
Each `OpenCodeModels` call has a 15 s `context.WithTimeout`, preventing an infinite hang per provider, but the timeouts are additive across the sequential loop.

Proposed mitigation:
Run the `OpenCodeModels` calls concurrently using an `errgroup.Group` (or goroutines + a sync mechanism), then collect and flatten results. Preserve the per-call 15 s timeout but run them in parallel so wall-clock time is max(one call) rather than sum(all calls).

Alternative mitigations considered:
Cache model lists with a short TTL (e.g. 5 min) so repeated `/orch` invocations skip the subprocess calls — but this adds cache-invalidation complexity and doesn't help the first call. Parallel execution is simpler and addresses the root cause.

Severity calibration:
Score 3 because this is a latency regression under plausible but not typical load (3+ providers, slow opencode). Under normal conditions with 1–2 providers it adds only 1–6 s, which is noticeable but not blocking. It would become score 4+ if the provider count grew or if this were a server request path rather than an interactive CLI.

Suggested fix shape:
var eg errgroup.Group
results := make([][]string, len(active))
for i, p := range active {
  i, p := i, p
  eg.Go(func() error {
    results[i] = agent.OpenCodeModels(bin, path, env, p.ID)
    return nil
  })
}
eg.Wait()
for _, ms := range results {
  for _, full := range ms {
    // ... build entries
  }
}

How can I resolve this? If you propose a fix, please make it concise.

…enabling a provider

When a provider is enabled via /providers, qmax now manages its key in the OS
keychain (injected at launch). Detect any plaintext apiKey still sitting in the
user's own ~/.config/opencode config ({env:}/{file:} refs are safe) and warn
them to rotate/remove it. Detector respects XDG_CONFIG_HOME (opencode's path,
not os.UserConfigDir). Tested against no-config, {env:} reference, and a literal key.
Comment thread internal/repl/repl.go
Comment on lines +1791 to +1806
func buildOpenCodeModelEntries(cfg *api.Config, sctx *api.SessionContext) []tui.OpenCodeModelEntry {
bin := agent.FindOpenCode()
if bin == "" {
return nil
}
active := cfg.ActiveProviders()
if len(active) == 0 {
return nil
}
path, err := agent.WriteOpenCodeConfig(cfg, sctx, cfg.OrchPermissionMode)
if err != nil {
return nil
}
// Provider keys must be present in the env or `opencode models <provider>`
// reports "Provider not found" for known providers (groq/openrouter).
env := agent.OpenCodeProviderEnv(cfg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PERFGROUNDED Sequential subprocess fan-out in buildOpenCodeModelEntries delays model picker

buildOpenCodeModelEntries calls agent.OpenCodeModels sequentially for each active provider, and each call spawns an opencode models <provider> subprocess with a 15-second timeout. With N providers, worst-case latency before the /orch picker appears is N × 15s. The calls are independent — they read different provider model lists — and could run concurrently. This also affects the /opencode quick-switch path via resolveOpenCodeModelOverride, which calls the same function.

More Info
  • Threat model: A user with multiple enabled providers waits for all opencode models subprocess calls to complete sequentially before the model picker renders. If any provider endpoint is slow or hung, the delay stacks across all subsequent providers.
  • Specific code citations: for _, p := range active loop in buildOpenCodeModelEntries (repl.go ~L1806) calls agent.OpenCodeModels(bin, path, env, p.ID) per provider. OpenCodeModels (opencode_config.go) runs exec.CommandContext with a 15s timeout per call.
  • Existing protections: The 15s context.WithTimeout in OpenCodeModels caps each individual call, but the sequential loop means timeouts stack multiplicatively rather than being bounded by a single deadline.
  • Proposed mitigation: Run the OpenCodeModels calls concurrently using a goroutine per provider with a sync.WaitGroup and sync.Mutex-protected results slice, bounded by a single shared context.WithTimeout matching the 15s cap.
  • Alternative mitigations considered: Cache model lists with a short TTL so repeated picker opens skip subprocess calls entirely. This eliminates subprocess overhead on repeated /orch invocations but adds cache-invalidation complexity.
  • Severity calibration: Score 3 because typical provider counts are small (1–3) and subprocess calls are usually fast (<1s), but a slow or hung provider endpoint can cause multi-second delays that stack sequentially. Not production-down, but a meaningful latency regression under plausible conditions.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/repl/repl.go
Line: 1791-1806

Comment:
**Sequential subprocess fan-out in `buildOpenCodeModelEntries` delays model picker**

`buildOpenCodeModelEntries` calls `agent.OpenCodeModels` sequentially for each active provider, and each call spawns an `opencode models <provider>` subprocess with a 15-second timeout. With N providers, worst-case latency before the `/orch` picker appears is N × 15s. The calls are independent — they read different provider model lists — and could run concurrently. This also affects the `/opencode` quick-switch path via `resolveOpenCodeModelOverride`, which calls the same function.

Threat model:
A user with multiple enabled providers waits for all `opencode models` subprocess calls to complete sequentially before the model picker renders. If any provider endpoint is slow or hung, the delay stacks across all subsequent providers.

Specific code citations:
`for _, p := range active` loop in `buildOpenCodeModelEntries` (repl.go ~L1806) calls `agent.OpenCodeModels(bin, path, env, p.ID)` per provider. `OpenCodeModels` (opencode_config.go) runs `exec.CommandContext` with a 15s timeout per call.

Existing protections:
The 15s `context.WithTimeout` in `OpenCodeModels` caps each individual call, but the sequential loop means timeouts stack multiplicatively rather than being bounded by a single deadline.

Proposed mitigation:
Run the `OpenCodeModels` calls concurrently using a goroutine per provider with a `sync.WaitGroup` and `sync.Mutex`-protected results slice, bounded by a single shared `context.WithTimeout` matching the 15s cap.

Alternative mitigations considered:
Cache model lists with a short TTL so repeated picker opens skip subprocess calls entirely. This eliminates subprocess overhead on repeated `/orch` invocations but adds cache-invalidation complexity.

Severity calibration:
Score 3 because typical provider counts are small (1–3) and subprocess calls are usually fast (<1s), but a slow or hung provider endpoint can cause multi-second delays that stack sequentially. Not production-down, but a meaningful latency regression under plausible conditions.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/repl/repl.go
// It queries `opencode models <provider>` at call time so the list is live
// (models.dev-backed for Groq/OpenRouter, seeded config for custom providers).
// Returns nil when opencode isn't installed or no providers are active.
func buildOpenCodeModelEntries(cfg *api.Config, sctx *api.SessionContext) []tui.OpenCodeModelEntry {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 TESTINGGROUNDED New /providers command and opencode backend switching in REPL ship without tests

The REPL gains a new /providers command (list, enable, disable) and opencode backend switching logic (/opencode, /orch picker integration, pre-flight validation) — all non-trivial runtime behavior with multiple branches (missing CLI, no providers, stale model override, consent gate, key validation, config regeneration). None of this is covered by a test. The [TESTING EVIDENCE] block shows siblingMapping:internal/repl/repl.go->NONE — no sibling test file exists in this PR. The handleProviders, enableProvider, disableProvider, buildOpenCodeModelEntries, and resolveOpenCodeModelOverride functions all contain reachable error paths and state transitions that can regress silently.

More Info
  • Threat model: A regression in the provider enable/disable flow (e.g., a key saved but the provider not marked enabled, or the managed config not regenerated) would silently break the opencode backend for users. A regression in the pre-flight validation (stale model override from another backend passed to opencode) would cause opencode to fail with an unrecognized model. Without tests, these regressions are invisible until a user hits them.
  • Specific code citations: handleProviders (line ~1783), enableProvider (line ~1895), disableProvider (line ~1950), buildOpenCodeModelEntries (line ~1793), resolveOpenCodeModelOverride (line ~1833), and the /opencode pre-flight block (line ~630).
  • Existing protections: The config-generation layer (WriteOpenCodeConfig) and provider registry (internal/api/providers.go) have unit tests, but the REPL orchestration that wires them together — the command parsing, key prompting, config save, and config regeneration — has no test coverage.
  • Proposed mitigation: Add a table-driven test for handleProviders (or extract the enable/disable logic into a testable function) covering: enabling a provider with a valid key, enabling with an invalid key, enabling an unknown provider, enabling an unentitled provider, disabling a provider, and the plaintext-key warning path. Add a test for resolveOpenCodeModelOverride covering: stale override from another backend, valid override, and no models available.
  • Alternative mitigations considered: An integration test that exercises the full /providers enable groq flow end-to-end would be ideal but requires a keychain mock. Unit-testing the extracted logic is the smallest practical step.
  • Severity calibration: Score 4: the new REPL commands are user-facing, reachable, and contain multiple branches and error paths. A regression here directly breaks the opencode backend for users. The fix is adding tests, not changing production behavior.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/repl/repl.go
Line: 1791

Comment:
**New `/providers` command and opencode backend switching in REPL ship without tests**

The REPL gains a new `/providers` command (list, enable, disable) and opencode backend switching logic (`/opencode`, `/orch` picker integration, pre-flight validation) — all non-trivial runtime behavior with multiple branches (missing CLI, no providers, stale model override, consent gate, key validation, config regeneration). None of this is covered by a test. The `[TESTING EVIDENCE]` block shows `siblingMapping:internal/repl/repl.go->NONE` — no sibling test file exists in this PR. The `handleProviders`, `enableProvider`, `disableProvider`, `buildOpenCodeModelEntries`, and `resolveOpenCodeModelOverride` functions all contain reachable error paths and state transitions that can regress silently.

Threat model:
A regression in the provider enable/disable flow (e.g., a key saved but the provider not marked enabled, or the managed config not regenerated) would silently break the opencode backend for users. A regression in the pre-flight validation (stale model override from another backend passed to opencode) would cause opencode to fail with an unrecognized model. Without tests, these regressions are invisible until a user hits them.

Specific code citations:
`handleProviders` (line ~1783), `enableProvider` (line ~1895), `disableProvider` (line ~1950), `buildOpenCodeModelEntries` (line ~1793), `resolveOpenCodeModelOverride` (line ~1833), and the `/opencode` pre-flight block (line ~630).

Existing protections:
The config-generation layer (`WriteOpenCodeConfig`) and provider registry (`internal/api/providers.go`) have unit tests, but the REPL orchestration that wires them together — the command parsing, key prompting, config save, and config regeneration — has no test coverage.

Proposed mitigation:
Add a table-driven test for `handleProviders` (or extract the enable/disable logic into a testable function) covering: enabling a provider with a valid key, enabling with an invalid key, enabling an unknown provider, enabling an unentitled provider, disabling a provider, and the plaintext-key warning path. Add a test for `resolveOpenCodeModelOverride` covering: stale override from another backend, valid override, and no models available.

Alternative mitigations considered:
An integration test that exercises the full `/providers enable groq` flow end-to-end would be ideal but requires a keychain mock. Unit-testing the extracted logic is the smallest practical step.

Severity calibration:
Score 4: the new REPL commands are user-facing, reachable, and contain multiple branches and error paths. A regression here directly breaks the opencode backend for users. The fix is adding tests, not changing production behavior.

How can I resolve this? If you propose a fix, please make it concise.

}
models = append(models, line)
}
return models

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 TESTINGGROUNDED OpenCodeModels subprocess call has no test for the error path (timeout, non-zero exit, empty output)

OpenCodeModels shells out to opencode models <provider> with a 15-second timeout. The existing test (TestOpenCodeModelsPassesProviderEnv) only covers the happy path where the stub binary prints a model line. The error paths — subprocess timeout, non-zero exit (e.g., provider not found, config parse error), and empty output — are untested. A regression in error handling (e.g., swallowing a real failure and returning nil silently) would make the model picker appear empty with no diagnostic.

More Info
  • Threat model: If opencode models fails (timeout, config error, provider not found), OpenCodeModels returns nil. The caller (buildOpenCodeModelEntries) treats nil as 'no models' and the picker shows an empty opencode section. The user sees no models and no error — a silent failure.
  • Specific code citations: OpenCodeModels (line ~230-263): cmd.Run() error path returns nil; timeout via context.WithTimeout returns nil; empty output returns an empty slice.
  • Existing protections: TestOpenCodeModelsPassesProviderEnv tests the happy path with a stub binary. No test covers the timeout, non-zero exit, or empty-output paths.
  • Proposed mitigation: Add test cases for: (1) stub binary that exits non-zero → expect nil; (2) stub binary that sleeps longer than the test timeout → expect nil; (3) stub binary that prints no matching lines → expect empty slice.
  • Alternative mitigations considered: Could also test at the buildOpenCodeModelEntries level, but unit-testing OpenCodeModels directly is simpler and more targeted.
  • Severity calibration: Score 3: the error paths are reachable (network issues, config errors, provider key problems) and the silent-failure behavior is a real UX gap, but the blast radius is limited to the model picker appearing empty rather than a crash or data loss.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_config.go
Line: 262

Comment:
**`OpenCodeModels` subprocess call has no test for the error path (timeout, non-zero exit, empty output)**

`OpenCodeModels` shells out to `opencode models <provider>` with a 15-second timeout. The existing test (`TestOpenCodeModelsPassesProviderEnv`) only covers the happy path where the stub binary prints a model line. The error paths — subprocess timeout, non-zero exit (e.g., provider not found, config parse error), and empty output — are untested. A regression in error handling (e.g., swallowing a real failure and returning nil silently) would make the model picker appear empty with no diagnostic.

Threat model:
If `opencode models` fails (timeout, config error, provider not found), `OpenCodeModels` returns nil. The caller (`buildOpenCodeModelEntries`) treats nil as 'no models' and the picker shows an empty opencode section. The user sees no models and no error — a silent failure.

Specific code citations:
`OpenCodeModels` (line ~230-263): `cmd.Run()` error path returns nil; timeout via `context.WithTimeout` returns nil; empty output returns an empty slice.

Existing protections:
`TestOpenCodeModelsPassesProviderEnv` tests the happy path with a stub binary. No test covers the timeout, non-zero exit, or empty-output paths.

Proposed mitigation:
Add test cases for: (1) stub binary that exits non-zero → expect nil; (2) stub binary that sleeps longer than the test timeout → expect nil; (3) stub binary that prints no matching lines → expect empty slice.

Alternative mitigations considered:
Could also test at the `buildOpenCodeModelEntries` level, but unit-testing `OpenCodeModels` directly is simpler and more targeted.

Severity calibration:
Score 3: the error paths are reachable (network issues, config errors, provider key problems) and the silent-failure behavior is a real UX gap, but the blast radius is limited to the model picker appearing empty rather than a crash or data loss.

How can I resolve this? If you propose a fix, please make it concise.

@Desperado Desperado merged commit 9e575f4 into main Jul 13, 2026
7 checks passed
@Desperado Desperado deleted the Desperado/opencode-per-user-providers branch July 13, 2026 13:20
Desperado added a commit that referenced this pull request Jul 13, 2026
)

* fix(skills): support opencode backend so /orch activation stops failing

The opencode backend (1.20.8, #141) routes skill install through the
generic InstallSkillsReport(result.Backend) call in the /orch picker, but
the skills materializer only knew about "cc" and "codex". Activating
opencode crashed with: skills: unknown backend "opencode".

Add opencode as a first-class skills backend:
- BackendOpenCode writes to ~/.config/opencode/skills/<name>/SKILL.md
  (opencode's global discovery dir), rendering name+description only —
  opencode ignores allowed-tools and has no openai.yaml sibling.
- main.go: sync skills on every opencode activation (idempotent), matching
  the cc/codex startup sync. opencode has no global-install prompt, so this
  is unconditional.
- InstallSkillsBoth -> InstallSkillsAll (now three backends); /skills
  status gains an "oc" column.
- Tests: opencode materialization + a guard that all catalog names satisfy
  opencode's stricter name regex (no underscores).

* fix(orch): sync opencode skills on /orch activation regardless of GlobalInstall

The /orch picker gated InstallSkillsReport behind consent.GlobalInstall,
which opencode never sets (its consent flow skips the global-install
prompt). So opencode-only users activating via /orch got no skills, even
though WriteOpenCodeConfig already ran unconditionally in the same flow
and main.go syncs opencode skills on every startup.

Decouple the skills install from the GlobalInstall gate for opencode only:
cc/codex keep their explicit opt-in; opencode matches its managed-config
model (and main.go). RunOrch stays gated — IsOrchInstalled("opencode")
already returns true, so it never runs for opencode anyway.

* test(skills): cover unknown-backend path + opencode name-regex negatives; surface startup error

Address review (sigilix):
- TestSkillsDirRejectsUnknownBackend: assert invalid backends error and
  that Materialize propagates it (guards the default branch).
- TestOpenCodeNameRegexRejectsInvalid: underscores, leading/trailing/double
  hyphens, uppercase, empty all rejected — confirms the regex itself.
- main.go: log the opencode InstallSkills error to stderr instead of
  discarding it, matching the adjacent WriteOpenCodeConfig warning.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant