feat(runtime): implement OpenCodeRuntime - #7103
Conversation
E2E tests are runningAuthorization passed for this commit. See the E2E Tests workflow for results. |
PR Summary by QodoImplement OpenCode as an opt-in agent runtime
AI Description
Diagram
High-Level Assessment
Files changed (38)
|
1520507 to
06db3c2
Compare
Code Review by Qodo
1. Readers mistake deferred work as ready
|
| defer f.Close() | ||
| reader = io.TeeReader(stdout, f) | ||
| } |
There was a problem hiding this comment.
1. Run artifacts can expose credentials 📘 Rule violation ⛨ Security
Run tees the raw OpenCode event stream directly into OutputPath, and ExtractTranscripts downloads the second raw copy without applying credential-literal or secret-pattern redaction. When model or tool output contains a token, key, password, or credential-bearing command output, both persisted artifacts receive it unchanged.
Agent Prompt
## Issue description
OpenCode output and transcript artifacts are persisted without the repository's required credential-redaction passes.
## Issue Context
Apply sensitive runner-environment literal replacement and `security.SecretRedactor` before external content reaches host files or extracted transcripts. Ensure resulting files use restrictive permissions and add tests containing representative opaque and recognizable credentials.
## Fix Focus Areas
- internal/runtime/opencode_run.go[275-283]
- internal/runtime/opencode_transcript.go[54-67]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| > **Experimental — read-only agents only.** OpenCode is enabled for read-only agents (`triage`, | ||
| > `prioritize`). Write-capable agents (`code`, `fix`) are gated on the security-hook adapter, tracked | ||
| > in [unbound-force#515](https://github.com/unbound-force/unbound-force/issues/515): OpenCode has no | ||
| > native PreToolUse/PostToolUse hooks, so until the runner-owned, sha256-gated plugin adapter lands, |
There was a problem hiding this comment.
2. Readers mistake deferred work as ready 📜 Skill insight ≡ Correctness
docs/runtimes/opencode.md presents the unimplemented security-hook adapter under an Experimental blockquote rather than the required > **Planned:** callout. The same deferred adapter governs write-path denial and sandbox hooks, so readers encounter future behavior in several capability descriptions without the standardized status marker.
Agent Prompt
## Issue description
Documentation mentions the unimplemented OpenCode hook adapter without the required planned-feature callout format.
## Issue Context
Convert each mention of deferred adapter, write-denial, hook, or transcript work into a `> **Planned:**` blockquote containing the relevant linked issue, while keeping current capabilities distinct from future ones.
## Fix Focus Areas
- docs/runtimes/opencode.md[14-18]
- docs/runtimes/opencode.md[41-44]
- docs/runtimes/opencode.md[96-98]
- docs/contributing/runtime-implementation.md[157-175]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| cmd.Flags().BoolVar(&publicApps, "public", false, "create public (unlisted) GitHub Apps installable by other orgs") | ||
| cmd.Flags().StringVar(&appSet, "app-set", appsetup.DefaultAppSet, "app set name prefix for GitHub Apps (e.g., myorg creates myorg-fullsend, myorg-coder)") | ||
| cmd.Flags().StringVar(&runtimeName, "runtime", "claude", "agent runtime for fullsend run (claude, pi or dummy; dummy is for behaviour test orgs only)") | ||
| cmd.Flags().StringVar(&runtimeName, "runtime", "claude", "agent runtime for fullsend run (claude, pi, codex, opencode, dummy or dummy-playback; dummy runtimes are for behaviour tests only)") |
There was a problem hiding this comment.
3. Critical command changes go untested 📘 Rule violation ▣ Testability
The PR modifies internal/cli but its testing declaration explicitly says make e2e-test was not run. Because these command paths trigger the live installation-flow requirement, the latest changes have no successful end-to-end result before merge.
Agent Prompt
## Issue description
Changes under `internal/cli` require a successful `make e2e-test` result, but the PR states that this suite was not run.
## Issue Context
Run the required suite in an environment with the live GitHub pool credentials, record its successful result for the latest commit, and address any failures before merge.
## Fix Focus Areas
- internal/cli/admin.go[630-630]
- internal/cli/runtime_binaries_test.go[49-51]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if id, ok := openCodeModelAliases[model]; ok { | ||
| model = id | ||
| } | ||
| return provider + "/" + model |
There was a problem hiding this comment.
4. Repository model choices are ignored 🐞 Bug ≡ Correctness
translateOpenCodeModel consults only the built-in alias map and never uses RunParams.ModelAliases. When a repository remaps an alias such as sonnet, OpenCode instead runs the built-in generation while the plan can display the repository's configured target.
Agent Prompt
## Issue description
OpenCode ignores `.fullsend/config.yaml` model alias overrides even though the runner passes them in `RunParams.ModelAliases`. Merge repository aliases over the built-in OpenCode alias table before constructing the provider/model value.
## Issue Context
Alias resolution must occur before provider-prefix handling because an alias target may already be a complete provider/model reference. Keep command selection, telemetry, and displayed model resolution consistent.
## Fix Focus Areas
- internal/runtime/opencode_run.go[35-60]
- internal/runtime/opencode_run.go[267-300]
- internal/cli/run.go[1043-1064]
- internal/cli/run.go[2216-2233]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // translateOpenCodeModel never returns empty (it falls back to the default | ||
| // alias), so --model is always supplied; opencode's own resolution is a | ||
| // backstop, not the primary path. --model takes provider/model | ||
| // (`opencode run --help`). | ||
| invocation = append(invocation, "--model "+shellQuote(openCodeValidatedArg(modelSpec))) |
There was a problem hiding this comment.
5. Agent model pins are ignored 🐞 Bug ≡ Correctness
buildOpenCodeRunCommand always supplies --model, and translateOpenCodeModel converts an empty runner model into the default opus. When only the agent definition has a model: value, that generated frontmatter is overridden and OpenCode runs the default model instead.
Agent Prompt
## Issue description
OpenCode unconditionally passes a default `--model`, overriding the model copied from the agent definition into OpenCode frontmatter. Preserve the shared model precedence by using the agent-definition model when the runner did not resolve one, or omit `--model` so OpenCode can use its agent configuration.
## Issue Context
The repository already defines `EffectiveModel(runModel, agentModel)` as the shared fallback chain. Ensure the same effective value drives the command, initialization event, and metrics.
## Fix Focus Areas
- internal/runtime/opencode_bootstrap.go[147-176]
- internal/runtime/opencode_run.go[41-60]
- internal/runtime/opencode_run.go[154-165]
- internal/runtime/model.go[8-24]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| pipeline := "{ " + strings.Join(invocation, " ") + " ; echo $? > " + shellQuote(rcFile) + " ; }" + | ||
| " | tee " + shellQuote(sandboxTranscript) + | ||
| " ; exit \"$(cat " + shellQuote(rcFile) + " 2>/dev/null || echo 1)\"" |
There was a problem hiding this comment.
10. Missing transcripts appear successful 🐞 Bug ☼ Reliability
The generated pipeline records and returns only OpenCode's exit status after piping stdout through tee, discarding a failed tee status. When the sandbox cannot create or write the transcript file, the host can still parse stdout and report a successful run, while extraction merely logs that no transcript was found.
Agent Prompt
## Issue description
A failed sandbox-side `tee` is ignored, allowing successful runs without the transcript artifact that this runtime promises to extract.
## Issue Context
The command must preserve OpenCode's exit code across the pipeline, but it also needs to detect a non-zero `tee` result before returning success.
## Fix Focus Areas
- internal/runtime/opencode_run.go[185-203]
- internal/runtime/opencode_transcript.go[41-67]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| "OPENCODE_CONFIG_CONTENT", // Vertex provider + permission denials (merges last) | ||
| "GOOGLE_APPLICATION_CREDENTIALS", // WIF credential file |
There was a problem hiding this comment.
11. First-party models stay unconfigured 🔗 Cross-repo conflict ≡ Correctness
OpenCodeRuntime.EnvExports references OPENCODE_CONFIG_CONTENT, but neither the runtime bootstrap nor the agents repository constructs the required anthropic-vertex provider and permission policy. The first-party harnesses provide only the existing Vertex environment and credentials, so selecting OpenCode leaves its default model reference without the configuration needed for inference.
Agent Prompt
## Issue description
Generate and export the OpenCode provider and permission configuration instead of assuming first-party harnesses already supply `OPENCODE_CONFIG_CONTENT`.
## Issue Context
The agents repository currently supplies Vertex credentials and Claude-oriented variables only. Prefer constructing runner-owned OpenCode configuration in this repository; otherwise coordinate explicit configuration across every agents harness.
## Fix Focus Areas
- internal/runtime/opencode.go[53-74]
- internal/runtime/opencode_bootstrap.go[96-139]
- harness/triage.yaml[23-31]
- env/gcp-vertex.env[1-5]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| stdout, stderr, exitCode, err := sandbox.Exec(sandboxName, "opencode --version", 30*time.Second) | ||
| if err != nil { | ||
| return fmt.Errorf("opencode preflight: %w", err) |
There was a problem hiding this comment.
12. First-party images lack opencode 🔗 Cross-repo conflict ☼ Reliability
openCodePreflightVersion now requires opencode --version to succeed, but the sandbox image source installs the other runtimes without installing OpenCode. The agents repository pins that image family by digest in its triage and prioritize harnesses, so either advertised first-party OpenCode pilot fails during bootstrap before an iteration begins.
Agent Prompt
## Issue description
Install a reviewed, pinned OpenCode CLI version in the sandbox images before making the runtime selectable.
## Issue Context
After publishing updated images, coordinate updates to the image digests used by the first-party triage and prioritize harnesses.
## Fix Focus Areas
- images/sandbox/Containerfile[49-104]
- internal/runtime/opencode_bootstrap.go[243-256]
- harness/triage.yaml[11-18]
- harness/prioritize.yaml[11-18]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| - "**/opencode" | ||
| - "**/opencode.exe" |
There was a problem hiding this comment.
13. Vertex blocks first-party inference 🔗 Cross-repo conflict ≡ Correctness
The scaffold Vertex profile adds OpenCode binary patterns, but the agents repository's profile actually selected by first-party harnesses still permits only Claude, pi, and Node. Triage and prioritize therefore reach the sandbox with a profile that denies OpenCode's Vertex requests even after an image and provider configuration are supplied.
Agent Prompt
## Issue description
Add the OpenCode executable patterns to the Vertex profile enforced by the agents repository.
## Issue Context
The scaffold copy and fleet copy are release-coupled. Update and validate both before advertising OpenCode for first-party agents.
## Fix Focus Areas
- internal/scaffold/fullsend-repo/profiles/fullsend-vertex-ai.yaml[21-27]
- profiles/fullsend-vertex-ai.yaml[28-32]
- harness/triage.yaml[14-18]
- harness/prioritize.yaml[14-18]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // are for behaviour tests only. | ||
| func ValidRuntimes() []string { | ||
| return []string{"claude", "pi", "codex", "dummy", "dummy-playback"} | ||
| return []string{"claude", "pi", "codex", "opencode", "dummy", "dummy-playback"} |
There was a problem hiding this comment.
14. Write agents run without safeguards 🔗 Cross-repo conflict ⛨ Security
Adding opencode to ValidRuntimes makes it selectable through repository, per-agent, and command-line overrides, while ResolveForAgent checks neither the agent role nor ReadonlyRepo, even though OpenCode installs no sandbox hook adapter and enables Write, Edit, and Bash. When selected for the first-party code or fix agents, or any unrestricted custom agent in a normally writable harness, execution reaches write-capable tools without the intended read-only rollout gate.
Agent Prompt
## Issue description
Reject OpenCode for write-capable or unrestricted agents until its sandbox hook adapter is installed and integrity-checked. Ensure repository configuration, per-agent configuration, and command-line runtime overrides all enforce the documented read-only limitation before sandbox execution.
## Issue Context
Configuration validation alone cannot identify whether an agent is safe for the OpenCode pilot. Runtime resolution currently accepts OpenCode for any named agent, `ReadonlyRepo` is an independent harness setting that defaults to false, and the OpenCode bootstrap enables write, edit, and bash tools while intentionally installing no hook adapter. Enforce eligibility at a central boundary where the resolved agent and harness capabilities are available, including the intended triage/prioritize or explicitly read-only cases, and add coverage for the first-party `code` and `fix` agents as well as unrestricted custom agents across every runtime-selection path.
## Fix Focus Areas
- internal/config/config.go[317-323]
- internal/runtime/registry.go[69-96]
- internal/cli/run.go[1011-1035]
- internal/cli/run.go[1972-1990]
- internal/runtime/opencode_bootstrap.go[60-66]
- internal/runtime/opencode_bootstrap.go[179-228]
- agents/code.md[2-17]
- agents/fix.md[2-17]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
waynesun09
left a comment
There was a problem hiding this comment.
Automated review sweep — 4 findings below (2 HIGH, 2 MEDIUM), verified against upstream OpenCode source and cross-checked against existing bot comments on this PR for duplicates.
| // the harness (env.sandbox / host_files); they are listed here so the run | ||
| // command can assert they are present and so docs/runtimes.md's config-key | ||
| // table stays in sync. | ||
| func (r OpenCodeRuntime) EnvExports() []string { |
There was a problem hiding this comment.
[HIGH] Runner never sets OPENCODE_DISABLE_PROJECT_CONFIG; a target repo's own .opencode/opencode.json can re-grant write permissions, and the PR's own doc comment misdescribes this.
Verified against anomalyco/opencode@dev (packages/opencode/src/config/paths.ts directories()): unless OPENCODE_DISABLE_PROJECT_CONFIG is set, OpenCode walks up from the target repo's checkout directory looking for a .opencode/ dir and loads its opencode.json/opencode.jsonc. This PR's diff never sets that flag anywhere (grepped the whole tree). Verified in config.ts (lines ~430-490): every directory's opencode.json (including a repo-authored one, if the walk isn't disabled) is merged into the result via mergeConfigConcatArrays -> remeda mergeDeep (a per-key deep merge, not a replace) BEFORE OPENCODE_CONFIG_CONTENT is merged in last. Because a deep merge only overrides keys the later source (OPENCODE_CONFIG_CONTENT) actually specifies, any write-permission key the runner-owned policy doesn't explicitly touch that a hostile target repo's own .opencode/opencode.json sets to "allow" is not guaranteed to be overridden. This also directly contradicts this PR's own doc comment at opencode.go:18-21, which states OpenCode's "config search path is driven by an explicit OPENCODE_CONFIG_DIR pointer to the runner-owned dir rather than a working-directory scan, so the agent-writable workspace is never consulted" — the upstream source shows the opposite is true by default.
Suggestion: Set OPENCODE_DISABLE_PROJECT_CONFIG=true in EnvExports as defense in depth, and correct the opencode.go:18-21 doc comment's claim that the workspace is never consulted. Separately, whatever builds OPENCODE_CONFIG_CONTENT (in fullsend-ai/agents) must explicitly set every write-capable permission key to "deny" rather than relying on omission.
| "MultiEdit": "edit", | ||
| "Grep": "grep", | ||
| "Glob": "glob", | ||
| "LS": "list", |
There was a problem hiding this comment.
[HIGH] Claude's LS tool is mapped to "list", a tool ID that does not exist anywhere in OpenCode.
openCodeToolForClaude maps "LS": "list" (this line), and the same wrong ID is asserted as fact in opencode.go:59 ("the tools a read-only agent needs (read, grep, glob, list, and read-only bash)"). I enumerated the actual OpenCode tool registry from anomalyco/opencode@dev (packages/opencode/src/tool/registry.ts, cross-checked via gh api file fetch): the full builtin tool-id set is invalid, shell ("bash"), read, glob, grep, edit, write, task, webfetch, todo, search, skill, patch, question, lsp, plan (plus a conditional execute) — there is no "list" tool anywhere in the codebase. Because openCodeToolForClaude["LS"] resolves successfully (to the bogus "list"), the intended warn-and-drop path (openCodeToolsRecord's if !ok branch, opencode_bootstrap.go:196-198) never fires, so the failure is silent. Codified into the merged test suite: TestOpenCodeToolsRecord (opencode_test.go:83-84) asserts []string{"bash", "edit", "glob", "list", "read", "task"} as the expected output for an LS-containing input.
Suggestion: Remove the "LS": "list" entry (or map it to a real equivalent, e.g. glob) and correct the opencode.go:59 comment and TestOpenCodeToolsRecord accordingly. Consider validating openCodeToolForClaude's values against a generated OpenCode tool-ID list in a test.
| Description string `json:"description,omitempty"` | ||
| Mode string `json:"mode"` | ||
| Model string `json:"model,omitempty"` | ||
| Tools map[string]bool `json:"tools,omitempty"` |
There was a problem hiding this comment.
[MEDIUM] Bootstrap emits OpenCode's deprecated agent tools: field instead of the current permission: field.
openCodeAgentFrontmatter (opencode_bootstrap.go:141-152) and openCodeAgentMarkdown (opencode_bootstrap.go:154-177) emit the per-agent tools: {toolID: bool} field. Verified against OpenCode's current agent schema (packages/core/src/v1/config/agent.ts): tools is annotated @deprecated Use 'permission' field instead, and the schema's own normalize() transform (same file) converts tools entries into permission entries at load time. The field still works today via that compatibility shim, but new integration code is standardizing on permission: and upstream has already flagged tools: for removal.
Suggestion: Emit the permission: field directly (allow/deny per tool, mirroring what upstream's normalize() does) instead of the deprecated tools: field, to avoid depending on a shim upstream may remove.
|
|
||
| // ClearIterationArtifacts removes the previous iteration's outputs and the | ||
| // debug log so transcripts and output files are per-iteration. | ||
| func (r OpenCodeRuntime) ClearIterationArtifacts(sandboxName string) error { |
There was a problem hiding this comment.
[MEDIUM] OpenCodeRuntime.ClearIterationArtifacts skips the stray-process sweep every other runtime performs.
Every other runtime's ClearIterationArtifacts (ClaudeRuntime, CodexRuntime, PiRuntime, DummyRuntime, DummyPlaybackRuntime — confirmed by grep across the whole internal/runtime package) calls clearStrayProcesses(...) before wiping iteration files. OpenCodeRuntime.ClearIterationArtifacts (opencode_run.go:354-359) only runs rm -rf on the output dir and debug log; it never calls clearStrayProcesses. A timed-out or leftover opencode/child bash process from the previous iteration is not swept, unlike every other runtime's contract.
Suggestion: Call clearStrayProcesses(sandbox.Exec, sandboxName, os.Stderr, "the previous iteration") in OpenCodeRuntime.ClearIterationArtifacts before removing the iteration files, matching the other runtimes.
Bootstrap/Run/ExtractTranscripts/ConfigDir for the OpenCode agent runtime,
mirroring the pi runtime pattern. Flip config.ValidRuntimes() to include
"opencode" so org/per-repo/per-agent config can select it.
What lands in this PR
- sandbox.SandboxOpenCodeConfig (/sandbox/opencode-config) — runner-owned
config dir, off the agent-writable workspace. Path convention pinned by
unbound-force#515 (hook adapter also lives here under plugins/).
- opencode.go: ConfigDir → SandboxOpenCodeConfig; EnvExports exports
OPENCODE_CONFIG_DIR (runner-owned dir pointer), OPENCODE_CONFIG_CONTENT
(Vertex provider + permission-deny config, merges last in opencode's config
stack so it wins over any repo config), and GOOGLE_APPLICATION_CREDENTIALS;
implements DebugLogNamer.
- opencode_bootstrap.go: Bootstrap() creates agent/, skills/, plugins/ under
ConfigDir; translates the Claude-style agent .md to OpenCode's
{agent,agents}/**/*.md layout with JSON frontmatter (mode: primary, tools
as {toolID: bool}); uploads harness skills; preflights opencode --version.
No ClaudeHooksBootstrap — hook adapter is unbound-force#515; plugin path
reserved at ConfigDir/plugins/fullsend-hooks.ts with sha256 fail-closed
guard shape (exit 97, before .env) for fullsend-ai#515 to populate.
- opencode_run.go: buildOpenCodeRunCommand renders "opencode run --format json
--model <provider/model> --variant <effort> --agent <name> <prompt>
</dev/null"; honors RunParams.Prompt (feedback_mode); shell-escapes all
interpolated values (sh -c boundary); emits InitEvent from RunParams.Model
(wire format carries no model metadata); captures ResultEvent metrics;
overrides exit 0 when the stream reports an error (same as pi).
- opencode_transcript.go: ExtractTranscripts downloads the tee'd output.jsonl
(interim approach; full redesign → unbound-force#513); ParseTranscriptFile
replays ndjson through parseOpenCodeStream for the exit-0 override;
ExtractDebugLog downloads opencode-debug.log.
- config.ValidRuntimes(): adds "opencode"; all call sites that hardcoded
"opencode" as a rejected stub updated to accept it or use "nonexistent"
for the invalid-name coverage.
- docs/contributing/runtime-implementation.md: security matrix OpenCode
column filled in (hooks: not wired / fullsend-ai#515; transcripts: interim tee;
host-side scans: runtime-agnostic). docs/runtimes.md: config-key table
OpenCode column added; runtime table entry updated from "Stub" to
"Implemented".
- 44 new tests; patch coverage on new opencode production files ≈ 82%.
What does NOT land (owned by unbound-force#515)
- Hook plugin adapter (tool.execute.before/after) — path reserved.
- SandboxHooksBootstrap type-assert in Bootstrap.
- ContextBridger: omitted — opencode reads AGENTS.md natively.
Phase 0 (opencode headless in sandbox) confirmed via unbound-force#509.
ConfigDir path + sha256 convention pinned per unbound-force#515 comment.
Signed-off-by: Yvonne Devlin <ydevlin@redhat.com>
Signed-off-by: Yvonne Devlin <ydevlin@redhat.com>
06db3c2 to
236d183
Compare
| | Unattended | No approval prompts, stdin closed; a non-config-allowed tool request is auto-rejected | | ||
| | Artifacts | `output.jsonl`, `transcripts/<agent>-output.jsonl`, `metrics.json` with `runtime: opencode`, plus `opencode-debug.log` with `--debug` | | ||
| | Extra knobs | `FULLSEND_OPENCODE_PROVIDER` (prefix for bare ids) | | ||
| | Not supported | Fallback chains, `plugins:` (Claude marketplace layout), sandbox tool hooks (until #515) | |
There was a problem hiding this comment.
I think we started to introduce a plugins key that depends on Runtime implementation, so even it is called plugins it could be applied to OpenCode. The code would place the plugins on the correct place for them to work. Would this solve the "not supported"? I don't recall if it was already merged or not.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Site previewPreview: https://e13e2f7d-site.fullsend-ai.workers.dev Commit: |
waynesun09
left a comment
There was a problem hiding this comment.
Automated review sweep — 7 findings (2 HIGH, 5 MEDIUM), each verified against this PR's head, the sibling runtime implementations, and upstream OpenCode source. Cross-checked against the existing threads on this PR; the two that sit near an existing thread carry an explicit note on how they differ.
Six findings are inline below. One has no line inside a diff hunk, so it is recorded here:
[MEDIUM] Two consumers this PR's own runtime-implementation checklist marks required were not updated
docs/contributing/runtime-implementation.md (checklist lines 83 and 90 — outside this PR's diff hunks, hence this body comment)
The "Consumer-completeness touchpoints" checklist that this PR itself edits lists at line 83 [ ] docs/architecture.md — update the runtime selection diagram and at line 90 [ ] docs/guides/infrastructure/layered-config-reference.md — update.
Neither file appears in this PR's changed-file list, and both still exclude opencode on the head branch:
docs/architecture.md:198renders the config node asruntime: claude | pi | codex | dummy | dummy-playbackdocs/guides/infrastructure/layered-config-reference.md:137readsValid values: claude, pi, codex, dummy, ...
The PR did update the other consumers on the list (choosing-a-runtime.md, cli/run.md, runtimes.md), so this reads as two specific misses rather than a wholesale omission. A user following layered-config-reference.md will conclude that runtime: opencode is invalid.
Suggestion: Add opencode to the docs/architecture.md:198 runtime selection diagram (and the surrounding prose at line 232) and to the valid-values list at layered-config-reference.md:137, then tick both checklist boxes.
| // An agent that listed only unsupported/Skill tools gets an explicit | ||
| // empty record rather than nil, so OpenCode does not silently fall | ||
| // back to its full default tool set. | ||
| return map[string]bool{} |
There was a problem hiding this comment.
[HIGH] openCodeToolsRecord can only widen permissions; omitempty drops the deliberate empty record, and Bash(a,b,c) collapses to a bare bash: true
The translated tool record is this PR's only code-level tool restriction, and as written it cannot restrict anything. Three separate mechanisms:
- The deliberate empty record is silently dropped. Lines 202-206 return
map[string]bool{}with the comment that a Skill-only agent "gets an explicit empty record rather than nil, so OpenCode does not silently fall back to its full default tool set" — but the field is declared at line 151 as amap[string]boolcarrying the JSON tagtools,omitempty, and Go'somitemptyomits an empty map. Compiling and marshalling the exact struct shape confirms it:json.Marshal(fm{Mode: "primary", Tools: map[string]bool{}})emits{"mode":"primary"}with notoolskey at all, so OpenCode applies its full default set (write,edit,bash,webfetch) — precisely what the comment promises to prevent. - The record only ever emits
true. Line 205 isrec[ot] = trueand there is no deny path anywhere in the function, so a tool that is absent from the record keeps OpenCode's default rather than being denied. - The Bash sub-command allowlist is parsed and then discarded, with no warning.
parseClaudeToolSpecspopulatesdef.BashAllowlist(pi_agent.go:88, documented atpi_agent.go:25-27), butgrep -rn BashAllowlist internal/runtime/returns zero hits in anyopencode_*.gofile. So an agent declaringtools: Bash(gh,curl,jq)collapses to an unrestrictedbash: true. Codex at least surfaces the gap (codex_bootstrap.go:196-200: "Agent Bash allowlist (%s) is recorded but not enforced on codex"); OpenCode is silent about it.
Suggestion: Emit OpenCode's permission: map instead of tools:, with an explicit deny for every tool not in the Claude allowlist, and translate Bash(a,b,c) into a bash pattern map ({"gh *": "allow", ..., "*": "deny"}). Drop omitempty from the Tools/Permission field so a deliberately empty record still serializes. Add tests asserting (a) a Skill-only agent serializes a restrictive record and (b) Bash(gh,jq) produces a deny-by-default pattern map.
| return sandbox.SandboxWorkspace + "/" + openCodeOutputSubdir + "/" + openCodeOutputFile | ||
| } | ||
|
|
||
| // buildOpenCodeRunCommand renders the in-sandbox command line for one |
There was a problem hiding this comment.
[HIGH] The prelude sources the agent-writable .env but never re-pins OPENCODE_CONFIG_DIR / OPENCODE_CONFIG_CONTENT, unlike pi and codex
buildOpenCodeRunCommand's prelude (lines 96-99) is && . <envFile> followed only by && export FULLSEND_RUNTIME=opencode. Nothing re-applies r.EnvExports() afterwards.
Both sibling runtimes do, with comments naming this exact threat:
pi_run.go:394-398— ".env is agent-writable; re-pin the runner-owned locations and the offline switches after it so a rewritten .env cannot move pi's config dir out from under the guards below"codex_run.go:313— ".env is agent-writable; re-pin the runner-owned config location after it so a rewritten .env cannot move codex's home out from under the guards"
OpenCode is the runtime where this matters most: per this PR's own design, the entire provider definition and permission policy travel in OPENCODE_CONFIG_CONTENT, and the config search is pinned by OPENCODE_CONFIG_DIR — both delivered through .env. An agent that has bash in iteration N can rewrite the workspace .env so that iteration N+1 points OPENCODE_CONFIG_DIR at a workspace-writable directory, or replaces the permission policy outright.
Suggestion: After . .env in the prelude, append && + strings.Join(r.EnvExports(), " && "), mirroring pi_run.go:398 and codex_run.go:313, and add a buildOpenCodeRunCommand test asserting the config-dir export appears after the .env source.
| // effort, e.g., high, max, minimal)"). Verified against opencode CLI. | ||
| invocation = append(invocation, "--variant "+shellQuote(openCodeValidatedArg(params.Effort))) | ||
| } | ||
| invocation = append(invocation, "--agent "+shellQuote(openCodeValidatedArg(agentName))) |
There was a problem hiding this comment.
[MEDIUM] An --agent miss silently falls back to OpenCode's default agent with the full default tool set, and the runner never detects it
(Distinct from the --model override thread just above — this one is about --agent resolution, not model selection.)
Verified against upstream packages/opencode/src/cli/cmd/run.ts: pickAgent (line 661) delegates to localAgent/attachAgent, and on every miss those print a warning via UI.println and return undefined — agent "${name}" not found. Falling back to default agent (lines 606 and 644) and agent "${name}" is a subagent, not a primary agent. Falling back to default agent (lines 614 and 653). Returning undefined means opencode proceeds under its default primary agent with the default tool set, so the entire translated permission record is bypassed and the run still reports success.
The runner has no detection for this: Run only parses the --format json stream and the exit code. Triggers that are live in this PR: the config dir failing to load, a frontmatter parse failure on the translated agent file, and a name divergence between the two independent sources — Bootstrap writes the file at openCodeAgentPath(input.AgentName()) (opencode_bootstrap.go:38-39, unsanitized), while Run passes --agent openCodeValidatedArg(params.AgentBaseName) (this line), which strips every character outside [A-Za-z0-9-_./@ ]. For the current first-party agent names (triage, prioritize) the strip is a no-op, so the divergence trigger is latent rather than active; the fallback behaviour itself is upstream-confirmed.
Suggestion: Set default_agent to the translated agent name in the runner-owned config so a fallback still lands on the intended agent, and treat opencode's "Falling back to default agent" output as a hard iteration failure. Add a Bootstrap assertion that the name written to agent/<name>.md is byte-identical to the value Run passes to --agent.
| // sandbox image's /bin/sh (dash) without relying on the non-POSIX | ||
| // `pipefail`. The prelude (guard, .env) runs before the pipeline so its | ||
| // own exits — notably the guard's 97 — are not swallowed by the subshell. | ||
| pipeline := "{ " + strings.Join(invocation, " ") + " ; echo $? > " + shellQuote(rcFile) + " ; }" + |
There was a problem hiding this comment.
[MEDIUM] A stale .opencode-run-rc plus an unconditional ; exit "$(cat rc)" masks prelude failures with the previous iteration's exit code
(Distinct from the neighbouring thread about tee's discarded exit status — this is about the rc file surviving between iterations and the final exit running even when the prelude short-circuits.)
Lines 199-201 build { opencode ... ; echo $? > rc ; } | tee <transcript> ; exit "$(cat rc 2>/dev/null || echo 1)", and line 164 joins that to the prelude with &&. Because ; binds looser than &&, the final exit runs unconditionally even when the prelude short-circuits. Reproduced under /bin/sh with a pre-existing rc containing 0:
cd . && false && { :; echo $? > .opencode-run-rc; } | tee out ; exit "$(cat .opencode-run-rc || echo 1)"
# exits 0The rc file survives between iterations: it is written to sandbox.SandboxWorkspace + "/" + openCodeRunRCFile (line 121, const at line 86), while ClearIterationArtifacts (lines 354-357) removes only WorkspaceDir()/output/* and the debug log. So on iteration >= 2, a failed cd, mkdir, or . .env re-raises the previous iteration's status.
The impact is diagnostic masking rather than a false success: the stream is empty, so parseOpenCodeStream's EOF synthesis sets isError = sawError || numTurns == 0 (opencode_progress.go:242) and Run's exitCode == 0 && lastResult.IsError branch converts it to 1 — but the operator then sees "opencode exited 0 but the stream reports an error" instead of the actual prelude failure.
Suggestion: Place the rc file under the output/ subdirectory that ClearIterationArtifacts already wipes (or rm -f it in the prelude), and make the final exit conditional on the prelude having succeeded so a prelude failure propagates its own status.
| // keeps opencode's stderr out of the tee'd stdout transcript. | ||
| invocation = append(invocation, "</dev/null") | ||
| if params.Debug != "" { | ||
| invocation = append(invocation, "2>>"+shellQuote(sandbox.SandboxWorkspace+"/"+openCodeDebugLogFile)) |
There was a problem hiding this comment.
[MEDIUM] The debug log captures only direct stderr writes; OpenCode's structured logs need --print-logs and never reach it
With params.Debug set, this line appends only 2>><debug log>. Verified in upstream packages/core/src/observability/logging.ts:
export function loggers() {
return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()]
}The stderr logger is attached only when OPENCODE_PRINT_LOGS is set, which happens solely through the global --print-logs flag (packages/opencode/src/index.ts:53-67, whose middleware also maps --log-level onto OPENCODE_LOG_LEVEL). Neither the flag nor the env var is set anywhere in this PR. So the debug log receives only what opencode writes directly to stderr — uncaught crashes and a few UI warnings — while every structured log line goes to OpenCode's own log file inside the sandbox and is never extracted. docs/runtimes/opencode.md tells operators that sandbox-side failures land in opencode-debug.log, which overstates what will actually be there.
Suggestion: When Debug is set, add the global flags before the run subcommand (opencode --print-logs --log-level DEBUG run ...) and keep the stderr redirect; extend the buildOpenCodeRunCommand debug test to assert their presence and placement.
| // (unbound-force#510). | ||
| cfg.Defaults.Runtime = "opencode" | ||
| require.Error(t, cfg.Validate()) | ||
| require.NoError(t, cfg.Validate(), "opencode is user-selectable (unbound-force#510)") |
There was a problem hiding this comment.
[MEDIUM] This removes the ADR 0044 org-mode deprecation guard and adds new positive org-mode coverage
AGENTS.md:28 states: "Per-org installation mode is deprecated (ADR 0044) and is being removed. This applies to human contributors and agents alike: do not add or extend org-mode-specific content in docs or code, and when reviewing a PR that touches org-mode content, flag it as referencing deprecated functionality."
This hunk does both things that rule forbids. In TestOrgConfigValidateRuntime it removes the standing guard comment — "No codex case here: org mode is deprecated (ADR 0044), so codex's selectability is asserted on the per-repo and agents: paths instead" — and replaces the negative assertion with a new positive one (cfg.Defaults.Runtime = "opencode" / require.NoError(t, cfg.Validate(), ...)), extending OrgConfig coverage to a newly added runtime. docs/runtimes/opencode.md:3 correspondingly advertises the runtime as "opt-in per org".
The equivalent per-repo assertion at line 695 (TestPerRepoConfigValidate_Runtime) already covers selectability, so the org-mode case adds no coverage the repo's own convention permits.
Suggestion: Restore the "org mode is deprecated (ADR 0044)" comment in TestOrgConfigValidateRuntime and keep opencode selectability asserted only on the per-repo and agents: paths, matching how codex was handled. Reword docs/runtimes/opencode.md:3 to "opt-in per repo".
Summary
Adds OpenCode as an opt-in Fullsend agent runtime while keeping Claude Code as the default.
OpenCode is currently restricted to read-only agents until the sandbox security-hook adapter tracked by unbound-force/unbound-force#515 is implemented.
Related Issue
Related to #1260.
Downstream implementation tracker: unbound-force/unbound-force#510
Changes Made
--variant.main.Security
OpenCode does not yet install sandbox tool hooks. Write-capable agents remain gated until the hook adapter lands.
Host-side scanning and sandbox egress controls continue to apply.
Testing
make lint-allinternal/runtime,internal/cli,internal/config,internal/repos, andinternal/scaffoldFull local Go testing encountered pre-existing environment-sensitive failures tracked in #7078 and #7079. The arm64 vendor-test failures do not affect amd64 CI.
make e2e-testwas not run because it requires live GitHub pool credentials.Backward Compatibility
This is additive and opt-in. Existing configurations continue to use Claude Code unless
runtime: opencodeis selected.