Skip to content

fix(server): project /config response to JSON-safe at the HTTP boundary#26553

Closed
kitlangton wants to merge 3 commits intodevfrom
kit/plugin-mutation-reproducers
Closed

fix(server): project /config response to JSON-safe at the HTTP boundary#26553
kitlangton wants to merge 3 commits intodevfrom
kit/plugin-mutation-reproducers

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

@kitlangton kitlangton commented May 9, 2026

Summary

Companion to #26550 (Provider.toPublicInfo) at the /config HTTP boundary. Internal opencode state — including the live config object — may legitimately carry function/symbol/bigint/undefined values from plugin config: hooks. The product invariant is that the public HTTP API is JSON-safe data matching its typed schema, not that the internal in-memory cfg is.

This PR adds toJsonSafe and applies it in ConfigHttpApi.get only. Plugin mutation contract for config: is preserved (cfg passes through unchanged); the projection happens at the wire boundary, mirroring how Provider.toPublicInfo already projects providers in /provider and /config/providers.

Why this shape (and not in-place scrub or clone-before-hook)

  • In-place scrub of cfg: would mutate live shared state. Recursing risks throwing on frozen sub-objects; non-recursing misses nested garbage. Either way it ignores the actual invariant.
  • Clone before passing to plugin: breaks the documented mutation contract — fixtures and external plugins (agent-plugin.ts, etc.) register agents/providers by mutating cfg. Cloning silently discards those edits.
  • Project at the HTTP boundary: matches fix(server): keep provider lists JSON-safe #26550's precedent, doesn't touch internal state, doesn't break plugin contract. Internal consumers that want raw cfg still get raw cfg; HTTP clients get the typed-schema-shaped response.

Reproducers

packages/opencode/test/plugin/mutation-repro.test.ts:

  • plugin.config: plugin attaches a function to cfg, fixture asserts GET /config returns a JSON-safe body without the function. Passes after this fix.
  • ⏭️ tool.definition / experimental.chat.messages.transform / experimental.chat.system.transform: skipped. Mutation IS the documented contract for these hooks. A blanket clone-and-return would break legitimate mutating plugins; a blanket in-place scrub corrupts shared Effect Schema instances in tool.parameters. Per-hook design needed — separate follow-up.

Also drops an orphan returns structured validation errors test from httpapi-sync.test.ts that no longer matches the route shape after #26550.

Test

  • `bun run typecheck` — clean
  • `bun run test test/plugin/mutation-repro.test.ts` — 1 pass, 3 skip, 0 fail
  • `bun run test test/provider/provider.test.ts test/agent/plugin-agent-regression.test.ts` — 78/78 (no regression in plugin config-hook fixtures)

Followups (not in this PR)

  • Per-hook design for the 3 trigger findings.
  • Coordinate with `opencode-gemini-auth` maintainer: fix(server): keep provider lists JSON-safe #26550's clone semantics silently disabled their cost-zeroing in `auth.loader`. Recommend moving cost-norm into the `provider.models` hook, which still writes through.

@kitlangton kitlangton changed the title test(plugin): reproducers for plugin-hook mutation bugs fix(plugin): clone config before passing to plugin config hook May 9, 2026
@kitlangton kitlangton changed the title fix(plugin): clone config before passing to plugin config hook fix(server): project /config response to JSON-safe at the HTTP boundary May 9, 2026
Mirrors #26550 (Provider.toPublicInfo): internal runtime objects may
legitimately carry function/symbol/bigint/undefined values, but the
HTTP API contract is JSON-safe data matching the typed schema.

Plugin `config:` hooks are documented to mutate cfg in place
(registering agents/providers); that contract is preserved. The
projection happens in ConfigHttpApi.get only, so internal consumers
keep raw cfg and HTTP clients see a clean response.

Active reproducer asserts the actual product invariant — GET /config
returns a JSON-safe body even when a plugin attached a function to
cfg. Three trigger-output reproducers stay skipped: per-hook design
needed because the documented mutation contract makes a blanket
clone unsafe and `tool.parameters` is a live Effect Schema with
function-valued own keys that recursive in-place scrub would corrupt.
@kitlangton kitlangton force-pushed the kit/plugin-mutation-reproducers branch from 691a3cd to 6833070 Compare May 9, 2026 19:08
kitlangton added 2 commits May 9, 2026 15:11
Reverts the redundant-cleanup hypothesis. The reproducer was upgraded
to exercise the actual gemini-auth bug shape — function and bigint
values under provider.options (a Schema.Any-typed declared field, not
an excess property) — and that fails /config encode without the
projection. Effect's onExcessProperty handles excess keys for free,
but Schema.Any pass-through plus encode validation doesn't, so the
toJsonSafe clone at the HTTP boundary is load-bearing for this case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant