Skip to content

fix(core): isolate invalid plugin tools during reload #35963

Description

@kitlangton

Summary

A plugin can register an opaque Tool created by a different loaded copy of @opencode-ai/plugin. Registration and plugin activation appear to succeed, but Core later throws TypeError: Invalid Tool value while materializing the Location's tool catalog.

The broken tool does not need to be called. Its presence prevents every ordinary tool-enabled model step in that Location, affecting existing and newly created sessions.

Hot reload makes the impact broader:

  1. Any plugin/config change rebuilds the entire ordered plugin set for the Location.
  2. Core closes the previous plugin scopes before proving the replacements are valid.
  3. Tool registrations are removed and added immediately rather than committed atomically.
  4. A failed replacement cannot restore the previous working plugin version.
  5. An invalid opaque tool is not detected during registration, so the broken plugin remains listed as active.

Environment

  • Channel: next
  • Initially observed on 0.0.0-next-15144
  • Reproduced after updating to 0.0.0-next-15145
  • macOS arm64
  • Observed origin/v2 revision: 212c9f99ee2d082082a00f7074df950974747a2a

Actual incident timeline

Times are UTC and rounded to the nearest relevant event.

21:15:36  An active session edits a project plugin to import Tool from a source checkout.
21:15:36  The watched configuration directory emits config.updated.
21:15:37  PluginSupervisor imports the changed plugin using its new mtime.
21:15:40  The active session begins its next model step.
21:15:42  An advertised edit call fails: Stale tool call: edit.
21:15:42  The continuation fails: Invalid Tool value.

21:21-23  Five later prompts in the same session fail before starting a model step.
21:23-24  Three newly created sessions in the same Location also fail immediately.

21:25:45  The project plugin is changed back to canonical package imports.
21:25:47  The watcher imports it with a new mtime.
21:25-26  Four more prompts still fail with Invalid Tool value.

21:26-35  Managed-service restarts do not recover while the project plugin remains present.
21:35:12  The project-local plugin is removed.
21:35:13  Reload omits that plugin and removes its registration.
21:36:10  The next prompt starts a real model step.
Afterward  No further Invalid Tool value failures are observed.

Four sessions in the same Location produced 22 durable Invalid Tool value failures. Other Locations were not implicated.

The restart activity also exposed a separate managed-service election problem tracked in #35964. That restart race was not the cause of the invalid tool: failures persisted on the stable service until the project plugin was removed.

What caused Invalid Tool value

packages/plugin/src/v2/effect/tool.ts represents a tool as an opaque frozen object. Its actual runtime is held in a module-local WeakMap:

const runtimes = new WeakMap<AnyTool, Runtime>()

Core can inspect a tool only when that exact object was constructed by the same loaded module instance:

function runtimeOf(tool: AnyTool) {
  const runtime = runtimes.get(tool)
  if (!runtime) throw new TypeError("Invalid Tool value")
  return runtime
}

In the incident, the project plugin constructed its tool through the source checkout while the compiled service used its bundled plugin runtime. The two module instances had separate WeakMaps:

Plugin/source runtime WeakMap: contains the custom tool
Compiled Core runtime WeakMap: does not contain the custom tool

Returning to canonical imports did not recover because package resolution from inside the source checkout still produced a module instance distinct from the one bundled into the running service. Restarting reconstructed the same incompatible registration.

Why one broken tool breaks every session

ToolRegistry.register currently stores opaque tool values without validating runtime ownership:

Plugin activation
  -> ctx.tool.transform
  -> ToolRegistry.register
  -> raw tool object stored successfully
  -> plugin reported as active

Before each non-final model step, the runner materializes every permission-eligible registered tool:

SessionRunner.attemptStep
  -> ToolRegistry.materialize
  -> Tool.permission / Tool.definition
  -> runtimeOf(invalidTool)
  -> throws Invalid Tool value
  -> LLM request never starts

No invocation of the broken custom tool is required. One invalid registration poisons the entire tool catalog shared by all sessions in that Location.

The durable sequence for subsequent prompts is therefore:

session.prompt.admitted
session.execution.started
session.prompt.promoted
session.execution.failed  unknown: Invalid Tool value

There is no session.step.started, because failure occurs before llm.stream().

Current reload semantics

Today a plugin generation is the complete ordered set of plugin IDs and optional versions for one Location.

T0  Active generation: [A v1, B v1, C v1]
T1  Any watched plugin/config change occurs
T2  Supervisor rescans and imports the complete candidate set
T3  If all IDs/versions are unchanged and active, activation is skipped
T4  Otherwise Core clears the active map
T5  Core closes C, B, then A
T6  Core loads candidate A, B, and C sequentially
T7  Successfully loaded candidates become active

There is no rollback:

  • An import failure is silently omitted from the candidate set.
  • A plugin effect failure is logged and skipped after its previous scope is already gone.
  • An opaque invalid tool is accepted, so the plugin is considered successfully active and fails sessions later.
  • Unchanged plugins are unnecessarily closed and recreated when any generation entry changes.

State.batch delays materialization for state-backed plugin domains, but scoped tool registration mutates the live registry immediately. Session tool materialization does not share the plugin activation lock. Sessions can therefore observe old registrations, no registrations, a partial replacement set, or the completed set.

Stale in-flight tool call

The first failure included Stale tool call: edit. Tool materialization snapshots each advertised registration's identity. Settlement rejects the provider's call if reload has replaced that identity:

Session snapshots edit from generation A
Plugin reload replaces registrations with generation B
Provider returns the edit call advertised from A
Registry compares A's identity with active B
Registry returns Stale tool call: edit

This ordering is strongly supported by the behavior and existing registry semantics, but the exact overlap is not provable from persisted events because activation completion and registration identities are not logged.

The later Invalid Tool value failures are not timing-dependent. Once the invalid tool is active, materialization fails deterministically.

Minimal reproduction: invalid registration

The module-identity failure can be reproduced without filesystem/package setup by cloning a valid opaque tool. The clone has the same apparent shape but is absent from the module's private WeakMap:

const valid = Tool.make({
  description: "Example",
  input: Schema.Struct({}),
  output: Schema.Struct({ ok: Schema.Boolean }),
  execute: () => Effect.succeed({ ok: true }),
})

const invalid = structuredClone(valid)

yield* registry.register({ invalid }) // currently succeeds
yield* registry.materialize({ model }) // throws Invalid Tool value

Expected timeline:

T0  A healthy tool is registered
T1  Registration receives an invalid opaque tool
T2  Registration fails with Tool.RegistrationError naming the tool
T3  Registry remains unchanged
T4  Healthy tools still materialize

Minimal reproduction: adding a broken plugin

T0  Healthy plugin A v1 is active
T1  New plugin B v1 is discovered
T2  B attempts to register an invalid opaque tool
T3  B is rejected
T4  A remains active without being closed or reloaded
T5  Sessions can still materialize A's tools

Current behavior instead closes and reloads A, accepts B, and then breaks materialization for the Location.

Minimal reproduction: broken replacement

T0  Plugin A v1 is active with a valid tool
T1  The file changes and candidate A v2 is loaded
T2  A v2 attempts to register an invalid opaque tool
T3  A v2 is rejected
T4  A v1 remains active and its scope remains open
T5  Sessions continue using A v1's tool

Current behavior closes A v1 first, accepts the invalid A v2 registration, and has no previous version to restore.

Desired semantics

Plugin replacement should be isolated per plugin:

Successful update

T0  A v1 is active
T1  A v2 is staged and validated
T2  A v2 becomes active atomically
T3  A v1 is closed

Failed update

T0  A v1 is active
T1  A v2 is staged and validation fails
T2  A v2 is discarded
T3  A v1 remains active

Failed addition

T0  A and B are active
T1  New plugin C fails validation
T2  C is absent
T3  A and B remain untouched

Removal

T0  A and B are active
T1  A is removed from configuration
T2  A is closed
T3  B remains untouched

Regression tests prepared locally

A local branch based on origin/v2 currently adds three failing tests:

  1. ToolRegistry rejects invalid tool values before mutating registrations.
  2. Adding an invalid plugin does not close or reload an unchanged healthy plugin.
  3. An invalid replacement leaves the previous plugin scope and valid tool active.

Focused verification currently produces 26 passing existing tests and exactly these three expected failures. Typecheck and file-scoped oxlint pass.

Implementation considerations

Small, immediate safety fix

Validate every opaque tool at ToolRegistry.register before mutating the registry. Return a typed RegistrationError containing the registered name. This prevents one invalid object from poisoning later session materialization.

Broader isolation fix

Change plugin activation from whole-generation teardown to differential, per-plugin replacement. Unchanged plugin scopes should be retained.

Atomicity complication

Simply loading a candidate while the previous scope remains open is insufficient. Plugin effects currently write directly into live state, tools, hooks, and other scoped contribution points. A candidate can therefore become partially visible before its effect finishes.

True last-valid-plugin behavior needs either:

  • staged contribution registries that commit only after the candidate effect succeeds, or
  • an activation transaction shared by every plugin contribution domain and by session materialization.

Tool validation is localized and straightforward. Atomic per-plugin replacement across all plugin contribution types is the larger architectural change.

Relevant code

  • packages/plugin/src/v2/effect/tool.ts: opaque tool runtime and runtimeOf
  • packages/core/src/tool/registry.ts: registration, materialization, and stale identity checks
  • packages/core/src/plugin/host.ts: plugin tool registration boundary
  • packages/core/src/plugin.ts: whole-generation activation and scope lifecycle
  • packages/core/src/plugin/supervisor.ts: filesystem/config-driven scan, import, and activation
  • packages/core/src/session/runner/llm.ts: tool materialization before the LLM request
  • packages/core/src/state.ts: batching used by state-backed plugin contributions

Acceptance criteria

  • Invalid opaque tools fail during registration with plugin/tool context.
  • A bad new plugin cannot break built-ins, healthy plugins, or sessions in the Location.
  • A failed plugin update preserves that plugin's previous valid version.
  • Unchanged plugins are not closed or reloaded because another plugin changed.
  • Candidate contributions are not visible to sessions before successful activation.
  • A plugin reload cannot write Invalid Tool value as an unscoped session execution failure.

Metadata

Metadata

Assignees

No one assigned

    Labels

    2.0bugSomething isn't workingcoreAnything pertaining to core functionality of the application (opencode server stuff)gang-grillDesign topics queued for later group grilling

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions