Skip to content

fix(guardrails): plugin を v1 形で export し、loader を通ることを検査する - #329

Merged
terisuke merged 2 commits into
devfrom
fix/guardrail-plugin-load-detection
Sep 4, 2026
Merged

terisuke merged 2 commits into
devfrom
fix/guardrail-plugin-load-detection

Conversation

@terisuke

@terisuke terisuke commented Sep 4, 2026

Copy link
Copy Markdown

何が壊れていたか — guardrail は実 CLI で一度もロードされていなかった

#328 は gate-fires.test.ts(6 pass)をもって発火検証済みと読める書き方をした。過大だった。
Codex による実環境貫通と監督の独立再現で、実 opencode run は

failed to load plugin .../guardrail.ts  error="The \"paths[0]\" property must be of type string, got object"
failed to load plugin .../team.ts       error="Plugin export is not a function"

を出し、.opencode/guardrails/events.jsonl の chat.params block は全期間で 0 件、
state.json は last_block='' / llm_call_count=0 だった。gate() は本番で一度も呼ばれていない。

gate-fires.test.ts は createContext を直接 import して gate() を呼ぶので loader を通らず、
この状態でも緑だった。テストが緑のまま本番の強制経路が死んでいた。

根因 — export の形

loader(packages/opencode/src/plugin/index.ts applyPlugin)は
readV1Plugin(mod, spec, "server", "detect") で default が object かつ id/server/tui を持つかを見る。
持たなければ legacy 経路 getLegacyPlugins に落ち、module の全 export を plugin と見なして
server(input, options) で呼ぶ。

export default async function guardrail(...) は関数なので v1 と認識されず legacy に落ち、
named export の ensureLocalOpencodeIgnored(worktree: string) に input オブジェクトが渡って
path.join(object, ...) で落ちた。team.ts は非関数の const を named export しているので
Plugin export is not a function。

workspace:* の依存解決失敗(2026-05-20 から)も並行して出るが、致命なのは export 形である
(依存解決は WARN で続行するが、load 失敗は hooks が登録されない)。

変更(判定内容は 1 バイトも変えていない)

ファイル 変更
guardrail.ts export default async function guardrail → async function guardrailServer + export default { id: "aidd-guardrail", server: guardrailServer }
team.ts 同上(aidd-team)
plugin-loads.test.ts 新規。loader の判定を複製し、v1 認識 / legacy 経路の再現 / chat.params 到達性 / ソースとの pair を検査
gate-fires.test.ts ヘッダを「loader を迂回した単体検査」と明記(虚偽の保証を取り消し)

guardrail.ts の非コメント差分は rename 1 行 + default 1 行のみ(git diff --cached で確認)。

なぜ loader を直接 import せず判定を複製したか

shared.ts / index.ts は @/util/* と @opencode-ai/core/npm に依存し、guardrails の bun test から
解決できない。複製だけだと「テストは緑だが loader は変わっていた」を通すので、
ソースの決定的な 2 条件を文字列で照合する pair 検査を付けた。ずれたら落ちる。

実測

反証(修正前の code に新テストをかける)

dev (修正前) に plugin-loads.test.ts をコピーして実行:
  (fail) F1 guardrail: default は object で server() を持つ
  (fail) F1 team: default は object で server() を持つ
  (fail) F3 到達性: guardrail.server(input) が返す hooks に chat.params がある
  (fail) F3 統制: v1 なので loader は named export を呼ばない
  5 pass / 4 fail
修正後:
  9 pass / 0 fail

落ちた 4 本は v1 形に依存する assertion だけで、legacy 再現(F2)と pair は両方で通る = 変異は局所的。

実 CLI(修正後)

$ opencode run --print-logs -m "zai/glm-4.7-preview" "echo hi"
  failed to load plugin の件数: 0   (修正前は 2)

スイート / lint / typecheck

bun test profile/plugins   45 pass 0 fail   (36 → 45)
local:check-offline        rc=0
lint                       5131 warnings / 1 error(#328 と同一。本 PR は警告を増やしていない)
typecheck                  30/30

解けていないもの(ふりをしない)

chat.params が実セッションで発火することは、まだ観測できていない。

実在する preview モデル(deepseek/deepseek-v4-flash-vision-exp、whitelist 内かつ preview 正規表現に一致)で
実 CLI を起動すると、init ログの直後でストールし session.created に到達しない(90s / 300s とも rc=124、
events.jsonl 増分 0)。plugin ロードとは別の起動時問題(models.dev の fetch 失敗が先行して出ている)。
gate は session.created の後で動くので、この経路では生死を判定できない。

本 PR が保証するのは **「plugin が loader を通り、chat.params を含む hooks が返される」**まで。
「実セッションで gate が throw する」は次の観測課題として残す。

監督の誤りの記録

  • #328 本文の「@opencode-ai/plugin は version bump のみ・API 変更なし」は package.json の版数差分だけを
    見て挙動を断定した表面形一致だった
  • gate-fires.test.ts の当初ヘッダ「The guardrail must actually BLOCK」は発火検証と読めたが、
    loader を迂回しており本番経路を何も保証していなかった

H5-guard: yes
H5-NEGATIVE: dev(修正前)に plugin-loads.test.ts をコピーして実行 → 5 pass / 4 fail(F1 ×2・F3 ×2 が落ち、legacy 再現 F2 と PAIR は通る = 変異は局所的)。修正後 9 pass / 0 fail。加えて実 CLI で failed to load plugin が 2 → 0。
H5-LEDGER: 本 PR は export の形だけを変え、guardrail の発火経路(chat.params → ctx.mark → .opencode/guardrails/events.jsonl / state.json)は不変。plugin がロードされなかった期間は台帳に chat.params が 0 件だったこと自体が「配線はあるが到達していなかった」証跡である。
H5-RETIRE: plugin-loads.test.ts の PAIR 節が参照する readV1Plugin / getLegacyPlugins の文字列が upstream で書き換えられたら PAIR が落ちるので、その時点で複製を追随させるか loader を直接 import できる形へ移す。実セッションでの chat.params 発火が 1 度観測されたら、本 PR の「未観測」節を閉じる。
H5-SUBTRACTION: N/A
H5-PAIR: packages/opencode/src/plugin/shared.ts の readV1Plugin detect 条件 ↔ plugin-loads.test.ts の複製; mutation=dev の関数 default を持つ module に対して v1 判定が undefined を返し legacy 経路で throw する(F2 で再現)。
H5-E2E: bun run local:check-offline && opencode run --print-logs -m "zai/glm-4.7-preview" "echo hi"
H5-E2E-OUT: 45 pass, 0 fail / rc=0 / 実 CLI の failed to load plugin 0 件(修正前 2 件)。実セッションでの gate throw は未観測(init 後ストール、上記)。

Evidence: bun test profile/plugins/plugin-loads.test.ts(修正前 worktree 5/4、修正後 9/0)/ opencode run --print-logs ...(load error 2→0)/ bun run local:check-offline / bun run lint / bun turbo typecheck | 45 pass、lint 5131/1、typecheck 30/30、実 gate は init 後ストールで未観測 | 2026-09-04 JST

🤖 Generated with Claude Code

#328 は gate-fires.test.ts をもって発火検証済みと読める書き方をしたが、
実 opencode run では guardrail.ts / team.ts とも loader で落ちており、
chat.params の block は全期間で 0 件だった。gate() は本番で一度も呼ばれていない。

根因は export の形。default が関数だと readV1Plugin (detect) に v1 と認識されず
legacy 経路に落ち、module の全 named export を server(input, options) で呼ぶ。
ensureLocalOpencodeIgnored(worktree: string) に input オブジェクトが渡って
path.join で落ち、team.ts は非関数の const export で "not a function"。

変更:
  guardrail.ts / team.ts   default を { id, server } の v1 形へ(判定内容は不変。
                           guardrail.ts の非コメント差分は rename 1 行 + default 1 行)
  plugin-loads.test.ts     新規。loader の判定を複製し v1 認識 / legacy 再現 /
                           chat.params 到達 / ソースとの pair を検査
  gate-fires.test.ts       ヘッダを「loader を迂回した単体検査」と明記

実測: 修正前 (dev) に新テストをかけると 5 pass / 4 fail、修正後 9 pass / 0 fail。
実 CLI で failed to load plugin が 2 -> 0。harness 45 pass、lint 5131/1 (不変)、typecheck 30/30。

未解決: 実在 preview モデルでの実セッションは init 後にストールし session.created に
到達しないため、chat.params の実発火はまだ観測できていない (別の起動時問題)。

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Et1J3J3Zi3V6zEzAod8QPD
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

PR #329 の 93f28f4 で guardrail.ts / team.ts の default export を
v1 形 `{ id, server }` に変えたが、packages/opencode/test/plugin/ の
6 ファイルが default を関数として呼んでいたため typecheck が TS2349 で落ちた。
修正前の grep が packages/guardrails しか見ておらず、import 元を取り逃した。

- guardrail.ts / team.ts: `export { guardrailServer as guardrail }` /
  `export { teamServer as team }` を追加(named export で旧 API を維持)
- 6 テストの import を `import { guardrail }` / `import { team }` 形へ

実測:
  bun turbo typecheck --force   30/30(cache 0)
  bun run lint                  5131 warnings / 1 error(変化なし)
  bun test profile/plugins      45 pass 0 fail
  bun test test/plugin/{6 files} 96 pass 0 fail

Agent-Lane: claude-code
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Et1J3J3Zi3V6zEzAod8QPD
@terisuke
terisuke merged commit 5d65dad into dev Sep 4, 2026
8 checks passed
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