Skip to content

v3.20.0

Choose a tag to compare

@mrbro-bot mrbro-bot released this 20 Sep 01:07
· 107 commits to main since this release
7fbecd0

What's new

Features

A repository can now ship the model routing it depends on. Until this release, a repository that declared its own OpenCode provider had no way to distribute the routing that consumes it. Project config could select a named profile but never define one, so the routing had to live in every teammate's global config — unshareable by cloning, and dead weight in every other repository. This release adds allow_project_profiles: when a user turns it on, a repository's project config may define its own profiles map (#1011, 7fbecd0).

The guarantee is that a project-defined bundle supplies routing only where user-owned config is silent. It can never override, and never partially clobber, a value you set. That holds structurally rather than by policy: the overlay chain is normally user base → active profile → project → custom, but when the active bundle came from project config it is placed first instead, so user base merges over it. Nothing checks "is this allowed" — the ordering is the guarantee. Merging is field-additive one level into the opencode and pi blocks, reusing the same mergeProfileOverlayValue a normal profile already uses, with only the argument order differing, so next wins each field it sets and the other side fills the gaps.

Whole-entry replacement was the alternative, and it fails concretely: setting opencode.variant on an agent whose project bundle sets opencode.model erases the model, leaving a qualifier with no model — which assertRoutingInvariants rejects, breaking the whole config load because you customised one field. There is a test for exactly that case. Because the guarantee comes from two separated code sites rather than a single check, a test pins each one; the ordering test was verified to fail for the right reason, reporting the project's losing value where the user's should be. ARCHITECTURE.md states plainly that the guarantee is a consequence of the implementation as written and is not separately enforced.

The opt-in is itself protected. allow_project_profiles joins workflow_guard in a new PROJECT_ALWAYS_PROTECTED_FIELDS set stripped from a project source unconditionally, and its effective value resolves from custom and user config only — a repository cannot grant itself the permission. A project config that sets the opt-in and ships a profiles map in the same file gets both stripped, which is covered by a test. profiles moved out of the always-protected set into a conditional strip, the only behavioural change when the opt-in is off; with it off, everything is byte-identical to before, and the existing test locking project-profile stripping passes unmodified. Name collisions cannot shadow either: bundle lookup is custom, then user, then project last, so a user-defined name always wins, and SYSTEMATIC_PROFILE still overrides which profile a repository selects for itself. SECURITY_OVERLAY_FIELDS is untouched and ProfileOverlaySchema remains routing-only, so a bundle is structurally incapable of carrying permission or skills — which is what keeps this a narrow surface.

Two supporting changes ship alongside. Config sources previously loaded user → project → custom, but the opt-in can be set in OPENCODE_CONFIG_DIR config, which outranks user, so a custom-set opt-in was unknown when the project source was parsed and stripped. User and custom now load ahead of project, with the alias machinery — where project and custom can resolve to the same file — re-verified against its existing tests unchanged. Separately, because advisory semantics make "the bundle applied nothing" a normal outcome, a bundle fully absorbed by user config would otherwise be indistinguishable from no bundle at all. config show now reports Defined in: naming the file that defined the active bundle, and each routing field carries an origin (user, custom, user-profile, custom-profile, project-profile) alongside the existing shape provenance, so full absorption reads as every field user-origin while a project profile is active. Origin is tracked per merge step rather than derived from the overlay entry's sourcePath, which is last-writer-wins and would misattribute a field whose value survived from an earlier step. Both surface in --json as activeProfileSourcePath and per-field source.*.origin.

Compare

v3.19.0...v3.20.0