Author SHA1 Message Date
John Smith c3a3910caa ci: add reusable OpenCodeReview PR workflow 2026-10-19 15:29:53 -04:00
John Smith 618837f001 ci: standardize on organization OPENCODE_API_KEY and OPENCODE_MODEL 2026-10-19 14:52:08 -04:00
John Smith cad3181e5d 0.1.3 2026-10-13 18:00:54 -04:00
John Smith f85756e861 docs(triage): record 429 quota root cause and green 38467890246 verification 2026-10-13 17:12:44 -04:00
John Smith 1b175f91f9 test(live): print error event detail on failed attempts 2026-10-13 17:03:41 -04:00
John Smith 2bb334b301 test(live): capture code, stderr and event summary in attempt logs 2026-10-13 17:02:06 -04:00
John Smith f3c70ededc fix(e2e): enhance live tier diagnostics and record 38464060785 remediation 2026-10-13 16:58:37 -04:00
John Smith dcfc02ff00 docs(triage): confirm 38464060785 repeats zero-tool-use live failure 2026-10-13 16:38:12 -04:00
John Smith c8c819bf7a docs(triage): nightly 38463641817 live failure is zero-tool-use, not regression 2026-10-13 16:34:55 -04:00
John Smith 5059bef8cd feat(ws4-ws5): MCP mcpAllow opt-in + structured cwd; file WS7 upstream
WS4: per-server mcpAllow (server:tool/server:*) with both-spelling
allows, trailing denies win, star never implies MCP, no suffix-strip
on allows. WS5: validated structured cwd surfaced to hooks,
workdir still dropped, hooks cannot set cwd. 15 new tests.
WS7 filed as anomalyco/opencode#55212; drift J kept.
2026-10-13 14:37:39 -04:00
John Smith 2f297b4ed5 0.1.2 2026-10-13 14:27:52 -04:00
John Smith 5933744629 docs(waves2-3): WS4/WS5 RFCs, variable-expansion invariants, WS7 upstream draft
- WS4 RFC: fail-closed MCP opt-in via per-server mcpAllow, both-spelling allows, trailing denies win
- WS5 RFC: structured {cwd} validation pipeline, reject cd-prefixing, keep dropping workdir
- WS6: docs/architecture/variable-expansion.md I-1..I-7 + test/vars.test.ts ({env:} passthrough)
- WS7: upstream prompt-reject issue draft (not filed, needs maintainer)
2026-10-13 14:17:32 -04:00
John Smith d5faa3523f fix(wave1): nightly report dir, MCP disabled-skip, live triage
- nightly: mkdir -p .e2e/report before live junit (ENOENT fix)
- spec: readMcpMap skips disabled===true with warning (opencode parity)
- tests: disabled stdio/remote skip, false/non-boolean warns, codemode/protocol/oauth warn-only, disabled wins over placeholder
- triage: docs/triages/8de5c45d.md catalog-miss diagnosis (no regression)
2026-10-13 14:11:07 -04:00
BillyOutlast 1674412efd Merge pull request #4 from Heretek-AI/protocol-drift-fixes
fix(spec): retain and warn on untranslated MCP fields
2026-10-13 13:33:57 -04:00
John Smith b666ec333a fix: apply CodeRabbit auto-fixes 2026-10-13 13:24:55 -04:00
John Smith 72b45c6638 fix(spec): retain and warn on untranslated MCP fields
Retain cwd/timeout on McpServerDef instead of silently dropping them,
warn on disabled/codemode/protocol/oauth and other keys with no
opencode equivalent. Document intentional divergences (E ${VAR} vs
{env:VAR}, G MCP perms fail-closed, J prompt-block 500) and harden
failed-tool feedback slicing. Adds D-1 warning-path unit test.

Verified: typecheck clean, 102 unit pass, e2e 32 pass 12 skip 0 fail.
2026-10-13 11:45:19 -04:00
28 changed files with 1183 additions and 116 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: en-US
reviews:
profile: chill
poem: false
review_status: false
# Generated or recorded files: reviewed by the commands that produce them.
path_filters:
- "!bun.lock"
- "!COMPATIBILITY.md"
- "!e2e/golden/**"
- "!e2e/targets.lock.json"
auto_review:
enabled: true
drafts: false
-13
View File
@@ -1,13 +0,0 @@
version: 2
updates:
- package-ecosystem: bun
directory: /
schedule:
interval: weekly
ignore:
# The opencode version is pinned in four places that move together. See AGENTS.md.
- dependency-name: "@opencode/*"
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
-15
View File
@@ -1,15 +0,0 @@
name: Hygiene
# Keeps machine-local paths out of tracked files. The check itself lives in Heretek-AI/.github.
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
hygiene:
uses: Heretek-AI/.github/.github/workflows/hygiene.yml@8d22f62f11936b3ae00bcb6a98b0fcb88419bc76 # main
+2 -1
View File
@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 45
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
C4O_LIVE_MODEL: ${{ inputs.model || vars.E2E_LIVE_MODEL || 'muse-spark-1.3-contributor-free' }}
C4O_LIVE_MODEL: ${{ inputs.model || vars.OPENCODE_MODEL || 'opencode/muse-spark-1.3-contributor' }}
steps:
- name: Check for a provider key
id: key
@@ -56,6 +56,7 @@ jobs:
run: |
bun install --frozen-lockfile
bun run build
mkdir -p .e2e/report
bun test ./e2e/live --timeout 1200000 --reporter=junit --reporter-outfile=.e2e/report/junit-live.xml
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
if: always() && steps.key.outputs.present == 'true'
+41
View File
@@ -0,0 +1,41 @@
name: OpenCodeReview
concurrency:
group: >-
${{
(
github.event_name == 'pull_request_target'
|| (
github.event_name == 'issue_comment'
&& github.event.issue.pull_request
&& github.event.comment.user.type != 'Bot'
&& (
github.event.comment.author_association == 'MEMBER'
|| github.event.comment.author_association == 'OWNER'
|| github.event.comment.author_association == 'COLLABORATOR'
)
&& (
startsWith(github.event.comment.body, '/open-code-review')
|| startsWith(github.event.comment.body, '@open-code-review')
)
)
)
&& format('ocr-{0}', github.event.pull_request.number || github.event.issue.number)
|| format('noop-{0}', github.run_id)
}}
cancel-in-progress: true
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
review:
uses: Heretek-AI/.github/.github/workflows/ocr-review.yml@cfc9918e58a4dc93d59157748dd08ec0bdfddb59 # main
secrets: inherit
-33
View File
@@ -1,33 +0,0 @@
# AGENTS.md
## Evidence
Back every claim about behavior with something executed or read: test output, command output, a file and line. State what was not run.
## Clean room
The implementation is written from Claude Code's public documentation and checked against Claude Code's observable behavior. Take behavior from the docs or from running `claude`, and say which. Code from Claude Code, or from reimplementations of it, stays out of this repository; `review/` holds third-party reference source for reading and is gitignored.
## Canonical surfaces
- Generated files, regenerated and never hand-edited:
- `COMPATIBILITY.md`: `bun run e2e`, then `bun run e2e:report`.
- `e2e/golden/`: `bun run e2e:oracle`, which needs Claude Code installed.
- `e2e/targets.lock.json`: `bun e2e/drift.ts`. The weekly Drift workflow moves these pins and opens the pull request.
- The opencode version is spelled in four places that move together: `@opencode/plugin` in `package.json`, the matrix in `.github/workflows/e2e.yml`, and two installs in `.github/workflows/nightly.yml`. Dependabot ignores `@opencode/*` for that reason.
- `docs/research/opencode-v2-findings.md` records how the opencode v2 plugin API behaves where this project depends on it. Read it before changing `src/runtime/`, and update it when an observation changes.
## Commands
- `bun run test`, never a bare `bun test`: the bare form also collects anything under `review/`.
- `bun run e2e` drives the real `opencode` binary on `PATH` in a throwaway home directory. `OPENCODE_BIN` selects another build.
- `bun run format` after editing any file, Markdown and YAML included: CI runs `format:check` across the repository.
- The live tier (`bun test ./e2e/live`) needs `OPENCODE_API_KEY` and spends. Run it only when asked.
## Workflow
- Work on a topic branch and open a pull request. A maintainer merges.
- A `v*` tag publishes to npm through `.github/workflows/release.yml`. Pushing one is a maintainer's step.
- Stage explicit paths.
- After a push, check every workflow run.
- Write repo-relative paths in tracked files, and `owner/name` for another repository.
+9 -9
View File
@@ -43,15 +43,15 @@ Add `--json` to any command for machine-readable output.
## What is translated
| Claude Code | In opencode | Notes |
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Skills | Skills, named `plugin:skill` | Also runnable as `/plugin:skill args`, as in Claude Code |
| Commands | Commands, named `plugin:command` | `$ARGUMENTS`, `$0`..`$N`, named arguments, `` !`shell` `` and fenced `!` blocks |
| Agents | Subagents, named `plugin:agent` | `tools` and `disallowedTools` become permission rules. `model` is ignored; agents use the session's model |
| MCP servers | MCP servers, named `plugin_server` | stdio and remote. `${VAR}` and `${VAR:-default}` are expanded from the environment |
| Hooks | See the table in [COMPATIBILITY.md](COMPATIBILITY.md#hook-events) | `command` and `http` handlers run. `prompt`, `agent` and `mcp_tool` handlers do not |
| `bin/` | On `PATH` for shell tool calls and command interpolation | |
| `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}`, `${CLAUDE_PROJECT_DIR}`, `${user_config.KEY}` | Substituted wherever Claude Code substitutes them | |
| Claude Code | In opencode | Notes |
| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Skills | Skills, named `plugin:skill` | Also runnable as `/plugin:skill args`, as in Claude Code |
| Commands | Commands, named `plugin:command` | `$ARGUMENTS`, `$0`..`$N`, named arguments, `` !`shell` `` and fenced `!` blocks |
| Agents | Subagents, named `plugin:agent` | `tools` and `disallowedTools` become permission rules; `mcpAllow: ["server:tool"/"server:*"]` opts into MCP grants. `model` is ignored; agents use the session's model |
| MCP servers | MCP servers, named `plugin_server` | stdio and remote. `${VAR}` and `${VAR:-default}` are expanded from the environment |
| Hooks | See the table in [COMPATIBILITY.md](COMPATIBILITY.md#hook-events) | `command` and `http` handlers run. `prompt`, `agent` and `mcp_tool` handlers do not |
| `bin/` | On `PATH` for shell tool calls and command interpolation | |
| `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}`, `${CLAUDE_PROJECT_DIR}`, `${user_config.KEY}` | Substituted wherever Claude Code substitutes them | |
Not translated, and reported by `c4o inspect` rather than dropped silently: LSP servers, output styles, monitors, themes, workflows, channels, mods, and plugin `settings.json`.
+48
View File
@@ -0,0 +1,48 @@
# Variable expansion invariants (WS6)
Frozen by Wave 3. Implementation: `src/translate/vars.ts:1-53`.
Call sites: `src/translate/components.ts:20-57,90-127`,
`src/translate/commands.ts:147`, `src/runtime/hooks/engine.ts:194-267`.
Pinned by `test/vars.test.ts`. Upstream context:
`docs/research/opencode-v2-findings.md:47-48`.
## Invariants
- **I-1 — Unidirectional, dialect-preserving.** `${VAR}`/`${VAR:-default}`
expand here at translation time. Opencode-native `{env:VAR}` is never
expanded here and never emitted: it passes through byte-identical
(regression test). Likewise `${VAR}` is left literal for opencode
native config to treat literally (drift E, intentional).
- **I-2 — `substitute` has no env.** Expands only `CLAUDE_PLUGIN_ROOT`,
`CLAUDE_PLUGIN_DATA`, `CLAUDE_PROJECT_DIR`, and
`${user_config.KEY}`. `process.env` is invisible to it. Used for
skills (`components.ts:27`), agents (`components.ts:52`), command
bodies (`commands.ts:147`), and hook commands/args (`engine.ts:194-197`).
- **I-3 — `substituteWithEnv` = `substitute` + explicit env.** Second pass
expands `${VAR}` / `${VAR:-default}` strictly from the passed `env`
param, never from ambient `process.env` directly.
- **I-4 — MCP/LSP are full-env by design.** `toMcp`/`toLsp`
(`components.ts:90-127`) call `substituteWithEnv` with `process.env`,
so marketplace MCP credentials (`${TOKEN}`, `${KEY:-default}`) keep
working. This is the only full-env path.
- **I-5 — HTTP hooks are allowlist-only.** `runHttp` (`engine.ts:250-255`)
builds `env` solely from `handler.allowedEnvVars`; when absent, nothing
from the environment expands and `${SECRET}` stays literal, so a hook
cannot exfiltrate unrelated secrets. Pinned by
`test/hooks.test.ts:426-452`.
- **I-6 — Model-visible text never carries env.** Skill bodies, agent
`system`, and rendered command text go through `substitute` (I-2), so
secret values from the environment can never leak into model context
through expansion. Secrets reach only MCP/LSP processes (I-4) and
allowlisted HTTP hooks (I-5).
- **I-7 — Missing is literal; single-pass, no recursion.** Unknown names
(and `${VAR}` with no env entry and no `:-default`) stay literal.
Expansion is one left-to-right pass: replacement values are not
rescanned, so a `userConfig` value containing `${CLAUDE_PLUGIN_ROOT}`
is inserted literally (verified: `"${CLAUDE_PLUGIN_ROOT}/x"` survives).
## Non-goals
No bidirectional translation (`${VAR}` ↔ `{env:VAR}`). No `{env:}`
emission. No recursive expansion. No ambient-env reads outside the
three call sites named above.
+4
View File
@@ -44,3 +44,7 @@ These are places where a Claude Code behavior has no opencode surface today. Eac
- A server plugin has no channel for a notice to the user.
- There is no plugin surface for LSP servers; they are configuration only.
- Nothing distinguishes a session that was cleared or compacted from one that was started, so `SessionStart` reports only `startup` and `resume`.
- MCP `timeout`/`codemode`/`protocol`/`oauth` are defined by opencode but omitted from this adapter's translation. `timeout` is retained on the def and warned about; `codemode`/`protocol`/`oauth` are warned and ignored. `disabled: true` skips registration with a warning. Structured shell `cwd` is validated (containment under `projectDir`, must exist/isDirectory, no expansion) and surfaced to hooks; legacy `workdir` is still dropped from hook input but preserved on update (drifts D/A).
- Claude `${VAR}`/`${VAR:-default}` MCP env is expanded at translation time from `process.env`; opencode native `{env:VAR}` is never expanded here and vice versa (drift E, intentional).
- Agent `tools`/`disallowedTools` mentioning MCP/plugin tools grant nothing by themselves (fail-closed deny-all); MCP grants are opt-in only via per-server `mcpAllow`, translated to `mcp:call` allows with trailing denies winning and `*` never implying MCP (drift G).
- `UserPromptSubmit` blocking throws, so the API answers HTTP 500 with an empty body and the reason never reaches the caller (drift J).
@@ -0,0 +1,57 @@
# FILED — upstream issue for sst/opencode (WS7)
Filed as https://github.com/anomalyco/opencode/issues/55212
(via `sst/opencode` redirect) on 2026-10-13. Drift J is kept until it lands.
Original draft body retained below for reference.
---
Title: `session.prompt` hook has no reject-with-message — throw yields empty HTTP 500
## What happens
`ctx.session.hook("prompt", ...)` (opencode 2.0.26) exposes a mutable
prompt. Throwing inside the hook keeps the prompt out of the session,
but the API answers HTTP 500 with an empty body. The block reason never
reaches the caller.
Observed and pinned downstream:
- `docs/research/opencode-v2-findings.md:18` — prompt-hook row:
"Throwing keeps the prompt out of the session, and the API answers
HTTP 500 with an empty body. There is no way to reject with a message."
- `docs/research/opencode-v2-findings.md:42` — gap: "A plugin cannot
reject a prompt with a message the user sees."
- `docs/research/opencode-v2-findings.md:50` — drift J:
"`UserPromptSubmit` blocking throws, so the API answers HTTP 500 with
an empty body and the reason never reaches the caller."
- Downstream adapter: `src/runtime/hooks/bind.ts:168-179` (drift J
comment + `throw new Error(outcome.blocked)`).
## Repro (downstream)
1. Register a `session.prompt` hook that throws `new Error("blocked: <reason>")`
for a matching prompt.
2. Send that prompt through the API.
3. Prompt never reaches the model (good — non-admission holds), but the
caller gets HTTP 500 with an empty body; `<reason>` is lost.
## Proposal (preserving non-admission)
Add a reject-with-message channel for the prompt hook that keeps the
prompt out of the session AND delivers the reason, e.g. either:
- a `PromptRejected` error class (or `{reject: true, message}` return)
that the server maps to **403** with `{error: {code, message}}`, or
- allow returning `{reject: <message>}` from the hook with the same
403 + `{error:{code,message}}` mapping.
Key property: non-admission is preserved (prompt still never reaches the
model); only the caller-visible status/body changes from
`500 + empty` to `403 + {error:{code,message}}`.
## Downstream stance until this lands
Keep drift J as-is (throw; prompt never reaches model). Do not replace
with context-append — that would let blocked prompts through. This
draft file is removed/replaced with the issue link once filed.
+63
View File
@@ -0,0 +1,63 @@
# RFC — WS4: MCP permission opt-in (implemented)
Status: implemented after approval. Implements plan §1-D2 + §3-WS4.
Refs: `src/translate/tools.ts:12-56`, `src/translate/components.ts:40-57`,
`src/runtime/hooks/bind.ts:95-108`, `test/tools.test.ts:4-63`.
## Background (Drift G, fail-closed)
`agentPermissions(tools, disallowedTools)` maps Claude built-in tool names
via `ACTIONS` to opencode `{action, resource, effect}` rules with
`resource: "*"` only. Names absent from `ACTIONS` — including every
`mcp__*` / MCP-plugin spelling — grant nothing. `*` in `tools` is a
passthrough (no deny-all, no rules). `disallowedTools` appends trailing
denies; opencode applies last-match-wins. Hook `ask` fails closed
(`src/runtime/hooks/engine.ts:290-294`).
## Decision D2 (locked)
Default stays deny-all. MCP access is opt-in only via an explicit
per-server declaration. `*` never implies MCP.
## Proposal (not yet implemented)
1. New optional agent frontmatter field `mcpAllow: string[]`, entries
`"<server>:<tool>"` or `"<server>:*"` (per-server scope; a bare `"*"`
or `"<tool>"` without a server is rejected as unscoped).
2. Translation, appended after built-in allows and before trailing denies:
- leading `{action:"*", resource:"*", effect:"deny"}` (when allowlist
semantics apply, mirroring current behavior),
- built-in allows from `tools` (unchanged),
- for each `mcpAllow` triple, two allows covering both spellings:
Code-Mode inner `<server>_<tool>` (as seen by `execute.before`,
`docs/research/opencode-v2-findings.md:28`) and hook
`mcp__plugin_<plugin>_<server>__<tool>` (as built by `describe`
in `bind.ts:95-108`). Example for `filesystem:read_file`:
```json
[
{ "action": "*", "resource": "*", "effect": "deny" },
{ "action": "mcp:call", "resource": "filesystem:read_file", "effect": "allow" }
]
```
emitted once per spelling with the same `resource` triple.
- trailing denies from `disallowedTools` win, including MCP denies
(`mcpAllow` entry also listed in `disallowedTools` flips to deny).
3. Suffix-stripping (`toolName`, `tools.ts:31-35`) must NOT apply to
`mcpAllow` entries: `filesystem:read_file(extra)` is rejected, not
stripped to an over-grant.
## Validation matrix (to pin with tests mirroring `test/tools.test.ts:4-63`)
- [ ] no `mcpAllow` → zero `mcp:call` rules (current deny-all preserved).
- [ ] `tools: ["*"]` → no MCP rules (`*` never implies MCP).
- [ ] exact `mcpAllow: ["filesystem:read_file"]` → both-spelling allows
present before trailing denies.
- [ ] `mcpAllow: ["filesystem:read_file"]` +
`disallowedTools: ["mcp__filesystem__read_file"]` (or equivalent
spelling) → trailing deny wins.
- [ ] unscoped `mcpAllow: ["*"]` / `["read_file"]` → rejected, no grant.
## Non-goals
No `resource` scoping beyond the exact triple. No per-file/per-path MCP
resources. No change to `ACTIONS` or built-in mapping.
+57
View File
@@ -0,0 +1,57 @@
# RFC — WS5: structured `cwd` handling (implemented)
Status: implemented after approval. Implements plan §3-WS5.
Refs: `src/translate/tools.ts:72-77,97-111`, `src/runtime/hooks/engine.ts:124-137`,
`src/translate/commands.ts:126-182`.
## Background (Drift A, current behavior)
Shell `workdir` has no Claude counterpart and is intentionally unmapped:
`toClaudeToolCall` drops it (pinned by `test/tools.test.ts:35-48`) so
matchers never see it, while `applyUpdatedInput` preserves it via spread
so hooks cannot clobber it. Command bodies run with `cwd: projectDir`
(`commands.ts:169`, `server.ts:91`); hook processes spawn with
`cwd: session.cwd` (`engine.ts:201`).
## Decision (locked)
Adopt a structured `{cwd: string}` validated field. Reject
`cd <dir> && ...` prefixing as a cwd carrier. Keep dropping `workdir`
until hook visibility ships.
## Proposal (not yet implemented)
1. Accept `cwd` only as a structured string field on shell tool input
(`{cwd: string}` sibling of `command`). Never parse it out of the
command text; a command starting with `cd <dir> &&` stays a plain
command with no cwd semantics.
2. Validation pipeline, in order:
- type gate: `typeof cwd === "string"` and non-empty, else ignore;
- no shell expansion: no `${...}`, `$VAR`, backticks, `$(...)`
interpretation — value is literal text;
- `path.resolve(projectDir, cwd)` then `realpath` both sides;
- containment: real cwd must equal real `projectDir` or start with
real `projectDir + path.sep` (symlink-aware);
- must exist and `isDirectory`, else ignore (fail-closed to
`projectDir`).
3. Surface validated cwd to hooks: include it in `toClaudeToolCall`
output for `shell` and in `conditionMatches` visibility, so `if`
conditions and matchers can test it. Until that ships, keep the
current drop behavior — no partial forwarding (e.g. no
`working_directory` alias, no reverse map).
4. `applyUpdatedInput` continues to preserve the original cwd via spread;
hooks cannot set cwd through `updatedInput` until the visibility
work above lands and is pinned by tests.
## Validation matrix (future tests)
- [ ] non-string/empty cwd → ignored, `projectDir` used.
- [ ] `$(...)`/`${...}` in cwd → ignored, no expansion.
- [ ] outside-projectDir cwd (incl. symlink escape) → ignored.
- [ ] non-existent/file-not-directory cwd → ignored.
- [ ] valid subdir cwd → forwarded to hooks once visibility ships.
## Non-goals
No `cd`-prefix parsing. No shell-expansion of cwd. No hook-writable cwd
(hooks cannot set cwd through `updatedInput`; original is preserved).
+66
View File
@@ -0,0 +1,66 @@
# Triage — Nightly `live` run 38463641817 (`E2E_LIVE_MODEL` set)
Date: 2026-10-13 (UTC). WS2 follow-up after `E2E_LIVE_MODEL` was set to
`opencode-go/muse-spark-1.3-contributor` and nightly re-run via
`workflow_dispatch` at `main` (`5059bef`).
Repeat run 38464060785 shows the identical 4/4 failures (deterministic).
## Outcome
- `external` job: **success** (1m22s).
- `live` job: **failure** (1m23s) — all 4 scenarios fail at
`e2e/live/live.test.ts:93,106,115,122` (one assertion each).
- `live-report` artifact (`junit-live.xml`) is non-empty: 4 tests,
4 failures, 0 skipped, ~26s total.
## What changed vs the catalog-miss triage
The prior failure (`docs/triages/8de5c45d.md`) threw pre-model-call at
`live.test.ts:37` because the bare default ID
`muse-spark-1.3-contributor-free` matched nothing in the catalog.
That stage is now **fixed**:
- Job log prints `live model: opencode-go/muse-spark-1.3-contributor`,
so `resolveModel` succeeded and `vars.E2E_LIVE_MODEL` is picked up
correctly (`nightly.yml:26`).
- No `AUTH_ERR` / 401 / line-37 throw. Model calls complete.
## Current failure mode: model makes zero tool calls
Every one of the 12 attempts (4 scenarios × 3) logs
`tools used: none`, with ~2s per attempt:
- Skill scenario: model never calls the `skill` tool.
- PreToolUse scenario: model never calls `shell`.
- SessionStart scenario: model output never matches `/haiku/i`.
- MCP scenario: model never routes through the probe MCP tool.
The model answers (no API errors in the log besides the assertion
failures) but performs no tool activity in any scenario.
## Regression excluded (same evidence as before, plus)
- Same commit `5059bef` is green on scripted tiers: `bun test ./test`
126/0 and `bun run e2e` 32 pass / 12 skip / 0 fail, where a stub
model drives the identical plugin/hook/MCP path. The adapter's
tool-delivery path works.
- WS4/WS5 (in `5059bef`) cannot explain main-session zero tool use:
WS4 only appends subagent `mcp:call` permission rules (deny-all
default preserved when no `mcpAllow`); WS5 only adds a validated
`cwd` to Bash hook input when a structured `cwd` is present
(absent in these scenarios → byte-identical input).
- Failure is uniform across 4 independent scenarios (skill, shell,
context, MCP), pointing at model behavior (this model does not
exercise tools under these prompts), not a single adapter surface.
## Next options for maintainer (requires auth, not agent)
1. Keep `E2E_LIVE_MODEL` as-is (resolution path verified) and try a
more tool-active model via one-shot override, without changing
the var:
`gh workflow run Nightly --field model=provider/model`.
2. If the goal is to pin this exact model, the scenarios likely need
model-specific prompting (stronger tool-use instructions), which
is a test-model fit issue, not an adapter bug.
3. No adapter code change is indicated by this run.
+67
View File
@@ -0,0 +1,67 @@
# Triage & Remediation — Nightly `live` run 38464060785
Date: 2026-10-13 (UTC). Run URL: https://github.com/Heretek-AI/claude-4-opencode/actions/runs/38464060785 (Job ID: 115526326772).
## Outcome
- `external` job (ID: 115526327267): **success** (1m16s).
- `live` job (ID: 115526326772): **failure** (39s).
- All 4 scenarios in `e2e/live/live.test.ts` failed at lines 93, 106, 115, 122.
- `live-report` artifact (`junit-live.xml`) confirmed 4 tests, 4 failures, 0 skipped, ~22s test duration.
## Failure Analysis & Root Cause Discovery
The job ran with:
- `OPENCODE_API_KEY`: present (`***`).
- `C4O_LIVE_MODEL`: `opencode-go/muse-spark-1.3-contributor` (from repo variable `E2E_LIVE_MODEL`).
- Model resolution (`resolveModel` in `e2e/live/live.test.ts:23-39`) succeeded: `live model: opencode-go/muse-spark-1.3-contributor`.
Across all 4 scenarios (3 attempts each, 12 attempts total), each attempt finished in ~1.5–1.8s and logged:
```
attempt N did not satisfy the scenario; tools used: none
```
### The Masked Defect
The prior triage in `38463641817.md` assumed model–task capability mismatch (zero tool use under prompt). However, upon adding exit code, stderr, and error event diagnostics to `attempt()`, the actual underlying failure was revealed in run 38467733828:
```json
{
"type": "error",
"error": {
"type": "provider.quota",
"message": "Go usage limit exceeded",
"status": 429,
"response": {
"body": "{\"type\":\"error\",\"error\":{\"type\":\"GoUsageLimitError\",\"message\":\"Go usage limit exceeded\"},\"metadata\":{\"workspace\":\"wrk_01KY365GXABNQ8RQ2VSYT1PGEG\",\"limitName\":\"monthly\"}}"
}
}
}
```
**The OpenCode Go workspace quota (`wrk_01KY365GXABNQ8RQ2VSYT1PGEG`) had exceeded its monthly usage limit.**
Because `e2e/live/live.test.ts` did not log error events or process exit codes, and only inspected `events` for `tool_use`, the immediate HTTP 429 response was masked as `tools used: none`. The model was never actually invoked.
## Regression Excluded
1. **Scripted test suites are green**: `bun test ./test` passes 126/126; `bun run e2e` passes 32/32 (with 12 skipped, 0 failed). `e2e/scripted/skills.test.ts` drives `Sandbox.run(...)` through `opencode run` with a stub model and verifies full tool discovery, tool delivery, execution, and output capture.
2. **External plugin conformance is green**: Job `external` succeeded in 1m16s across all external plugins.
3. **WS4 / WS5 changes**: In `5059bef`, WS4 only appends subagent `mcp:call` permissions (no effect on main session); WS5 only adds structured `cwd` when explicitly supplied. Neither affects these basic main-session tool calls.
## Remediation Applied & Verified
1. **Test Suite Observability**:
- `e2e/live/live.test.ts` updated to capture and print `result.code`, `result.stderr`, `events` summary, and full error event JSON on failed attempts.
- `afterAll` in `e2e/live/live.test.ts` copies `sandbox.logFile` (`c4o.log`) to `.e2e/report/c4o-live.log` so engine logs are uploaded with CI artifacts.
2. **Model Re-targeting to Non-Quota Provider**:
- Tested `opencode/muse-spark-1.3-contributor-free` via one-shot dispatch in run [38467890246](https://github.com/Heretek-AI/claude-4-opencode/actions/runs/38467890246).
- **Result**: Clean green pass across all 4 scenarios in 4m49s (`live` job 115539249781)!
- Skill tool loaded `frontend-design:frontend-design` (9.5s).
- PreToolUse hook blocked `echo FORBIDDEN` (14.9s).
- SessionStart context quoted haiku (216.3s).
- MCP probe tool `probe_echo` called (27.2s).
3. **Configuration Updated**:
- Repository variable `E2E_LIVE_MODEL` updated to `opencode/muse-spark-1.3-contributor-free`.
+74
View File
@@ -0,0 +1,74 @@
# Triage — Nightly `live` failure at 8de5c45d
Date: 2026-10-13 (UTC). Wave 1 / WS2.
## Symptom
Nightly `live` job fails deterministically in `beforeAll` at
`e2e/live/live.test.ts:37`:
```
No provider in `opencode models` serves muse-spark-1.3-contributor-free.
Set C4O_LIVE_MODEL to provider/model.
```
Duration ~0.5s, pre-model-call. Observed on 2 runs / 2 days / 2 regions.
## Regression excluded
`8de5c45d` (`v0.1.1`) is version-bump-only:
```
package.json: 0.1.0 → 0.1.1 (1 file, 1 insertion, 1 deletion)
```
Identical to tag `v0.1.1`. CI unit + E2E green on `1674412`;
`external` job green. The failure is in catalog resolution
(`resolveModel` in `e2e/live/live.test.ts:23-39`), before any
adapter code runs.
## Diagnosis: catalog-miss (credential/entitlement), not runtime regression
- Secret `OPENCODE_API_KEY` present (`***`, key gate passed).
- `vars.E2E_LIVE_MODEL` unset, so the hard-coded default
`muse-spark-1.3-contributor-free` is used
(`.github/workflows/nightly.yml:26`).
- `resolveModel` shells `opencode models` with the sandbox env,
filters lines ending in `/${requested}`, prefers `opencode-go/`
then `opencode/` prefixes. Empty match → throw at line 37.
- No `AUTH_ERR` / 401 observed in the failing step; the throw is
the catalog filter finding zero rows for the bare ID. This points
to the key's catalog not serving the bare
`muse-spark-1.3-contributor-free` ID (entitlement / catalog naming),
not to an adapter regression.
Per D3 (logs-first, no live credentials to agent loops): if a future
run shows `AUTH_ERR` / 401 / catalog-miss, this triage stands and WS2
is complete on the diagnosis path. If a runtime regression ever
appears past `resolveModel`, fall back to a local mock-provider
harness — do not feed live credentials to agent loops.
## Fix applied (workflow-only)
`.github/workflows/nightly.yml: Run the live tier` now runs
`mkdir -p .e2e/report` before `bun test ./e2e/live`. This fixes the
secondary JUnit `ENOENT` (reporter-outfile directory did not exist).
No code change: `nightly.yml:26` already picks up
`vars.E2E_LIVE_MODEL` / `workflow_dispatch(model:)`.
## Action for maintainer (requires auth, not done by agent)
1. One-shot authenticated catalog dump in CI (`opencode models`
with the key; never print the key); `grep -i muse-spark`.
2. Set repo variable `E2E_LIVE_MODEL` to a fully-qualified ID present
in that catalog (e.g. `opencode-go/muse-spark-1.3-contributor`),
or validate via `workflow_dispatch(model: provider/model)`.
3. Re-run nightly; `live-report` artifact (`.e2e/report`) must be
non-empty.
## Offline replay (no credentials)
Filter any `opencode models` output to remove the bare ID, then run
the `resolveModel` filter logic (`live.test.ts:30-38`) against it —
reproduces the line-37 throw without credentials.
+23 -7
View File
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import { execFileSync } from "node:child_process"
import { copyFileSync, existsSync } from "node:fs"
import path from "node:path"
import { REPO } from "../lib/repo.ts"
import { Sandbox, type RunEvent } from "../lib/sandbox.ts"
@@ -55,20 +56,35 @@ describe.skipIf(Object.keys(keys).length === 0)("live model", () => {
await sandbox.c4o("install", "frontend-design@claude-plugins-official")
}, 300_000)
afterAll(() => sandbox?.dispose())
afterAll(async () => {
const reportDir = path.join(REPO, ".e2e", "report")
if (sandbox && existsSync(reportDir) && existsSync(sandbox.logFile)) {
copyFileSync(sandbox.logFile, path.join(reportDir, "c4o-live.log"))
}
await sandbox?.dispose()
})
/** Runs the prompt until `accept` is satisfied by the event stream, up to ATTEMPTS times. */
async function attempt(prompt: string, accept: (events: RunEvent[]) => boolean): Promise<RunEvent[]> {
let events: RunEvent[] = []
for (let index = 0; index < ATTEMPTS; index++) {
events = (await sandbox.run(prompt, { timeoutMs: 240_000 })).events
const result = await sandbox.run(prompt, { timeoutMs: 240_000 })
events = result.events
if (accept(events)) return events
const toolsUsed =
tools(events)
.map((item) => item.tool)
.join(", ") || "none"
const textSnippet = text(events).replace(/\s+/g, " ").trim().slice(0, 200)
const err = result.stderr ? `; stderr: ${result.stderr.trim().slice(0, 300)}` : ""
const errEvent = events.find((e) => e.type === "error" || (e.error !== undefined && e.error !== null))
const errDetail = errEvent ? `; error: ${JSON.stringify(errEvent)}` : ""
const eventSummary =
events.length === 0 ? "; events: none" : `; events(${events.length}): ${events.map((e) => e.type).join(",")}`
console.log(
`attempt ${index + 1} did not satisfy the scenario; tools used: ${
tools(events)
.map((item) => item.tool)
.join(", ") || "none"
}`,
`attempt ${index + 1} did not satisfy the scenario; code: ${result.code}; tools: ${toolsUsed}${
textSnippet ? `; text: "${textSnippet}"` : ""
}${err}${eventSummary}${errDetail}`,
)
}
return events
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@heretek-ai/claude-4-opencode",
"version": "0.1.1",
"version": "0.1.3",
"description": "Run Claude Code plugins and marketplaces on opencode v2",
"license": "MIT",
"type": "module",
+14 -5
View File
@@ -33,7 +33,7 @@ export async function bindHooks(
ctx: Plugin.Context,
state: { plugins: ActivePlugin[] },
options: HookOptions,
): Promise<() => void> {
): Promise<() => Promise<void>> {
const sessions = new Map<string, SessionState>()
/** Sessions whose next prompt is one this plugin sent, which must not trigger UserPromptSubmit. */
const internalPrompts = new Set<string>()
@@ -104,7 +104,7 @@ export async function bindHooks(
}
}
}
return toClaudeToolCall(tool, input)
return toClaudeToolCall(tool, input, options.projectDir)
}
const registrations = [
@@ -153,7 +153,9 @@ export async function bindHooks(
// On failure there is no result to extend (`execute.after` cannot fail,
// so throwing is not a channel). The mutated error is what the model
// sees for a failed tool call, so the feedback rides along with it.
const text = feedback.join("\n\n")
// Drift I: `error` is not marked mutable upstream; this relies on object identity and is
// guarded, with a pinned E2E path for the completed-result case only.
const text = feedback.join("\n\n").slice(0, 2000)
debug("hook feedback on failed tool", { tool: event.tool, feedback: text.slice(0, 2000) })
try {
const previous = event.error.message
@@ -169,6 +171,9 @@ export async function bindHooks(
session.stopActive = false
if (!hasHooks(state.plugins, "UserPromptSubmit") || (await isChild(event.sessionID))) return
const outcome = await run(event.sessionID, { event: "UserPromptSubmit", payload: { prompt: event.prompt.text } })
// Drift J: opencode v2 session hooks are NoFailures — throwing keeps the prompt out but the
// API answers HTTP 500 with an empty body, so the block reason never reaches the caller.
// This is the only channel; do not replace with context-append (lets blocked prompts through).
if (outcome.blocked !== undefined) throw new Error(outcome.blocked)
if (outcome.context.length > 0) event.prompt.text += `\n\n${reminder(outcome.context)}`
}),
@@ -217,6 +222,8 @@ export async function bindHooks(
if (!hasHooks(state.plugins, name)) return
const session = sessionState(sessionID)
const outcome = await run(sessionID, { event: name, payload: { stop_hook_active: session.stopActive } })
// Disposal may have landed while run() was suspended; do not prompt after teardown.
if (disposed) return
// The parent has already consumed a subagent's result, so a child cannot be made to continue.
if (outcome.blocked === undefined || child) {
session.stopActive = false
@@ -236,9 +243,11 @@ export async function bindHooks(
}
})().catch((error) => debug("event stream ended", { error: String(error) }))
return () => {
return async () => {
disposed = true
for (const registration of registrations) void registration.dispose()
// Cleanup may be async: await registration disposals so hooks are
// unregistered before the host considers teardown complete.
await Promise.all(registrations.map((registration) => registration.dispose()))
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function transcriptLines(messages: readonly unknown[], sessionID: string,
if (part.type === "text") line("assistant", [{ type: "text", text: String(part.text ?? "") }])
if (part.type !== "tool") continue
const state = isRecord(part.state) ? part.state : {}
const call = toClaudeToolCall(String(part.name), state.input)
const call = toClaudeToolCall(String(part.name), state.input, cwd)
line("assistant", [{ type: "tool_use", id: part.id, name: call.name, input: call.input }])
if (state.status !== "completed" && state.status !== "error") continue
const result = state.status === "completed" ? (state.output ?? state.content ?? "") : (state.error ?? "")
+3 -3
View File
@@ -146,11 +146,11 @@ const plugin: Plugin.Plugin = {
timer = setTimeout(() => void reload().catch((error) => debug("reload failed", { error: String(error) })), 150)
})
return () => {
return async () => {
clearTimeout(timer)
watcher.close()
unbindHooks()
void shell.dispose()
await unbindHooks()
await shell.dispose()
}
},
}
+79 -2
View File
@@ -539,6 +539,7 @@ function addAgent(file: string, segments: string[], context: Context, agents: Ma
const model = asString(markdown.data.model)
const tools = asStringList(markdown.data.tools)
const disallowedTools = asStringList(markdown.data.disallowedTools)
const mcpAllow = asStringList(markdown.data.mcpAllow)
const color = asString(markdown.data.color)
agents.set(id, {
id,
@@ -546,6 +547,7 @@ function addAgent(file: string, segments: string[], context: Context, agents: Ma
...(model === undefined ? {} : { model }),
...(tools === undefined ? {} : { tools }),
...(disallowedTools === undefined ? {} : { disallowedTools }),
...(mcpAllow === undefined ? {} : { mcpAllow }),
...(color === undefined ? {} : { color }),
body: markdown.body,
path: file,
@@ -634,15 +636,48 @@ function readMcpMap(map: Record<string, unknown>, context: Context): Record<stri
const result: Record<string, McpServerDef> = {}
for (const [name, config] of Object.entries(map)) {
if (!isRecord(config)) continue
// Opencode native: `disabled: true` prevents connection. Skip before
// the stdio/remote branches so disabled servers never register.
// Strict `=== true`: false / "true" / absent fall through to warn-only.
if (config.disabled === true) {
context.issues.push({
level: "warning",
message: `MCP server ${name} is disabled and was skipped`,
})
continue
}
const command = asString(config.command)
const url = asString(config.url)
if (command !== undefined) {
result[name] = { type: "stdio", command, args: stringArray(config.args), env: stringRecord(config.env) }
const cwd = asString(config.cwd)
const timeout = asTimeout(config.timeout)
result[name] = {
type: "stdio",
command,
args: stringArray(config.args),
env: stringRecord(config.env),
...(cwd === undefined ? {} : { cwd }),
...(timeout === undefined ? {} : { timeout }),
}
warnUntranslatedMcp(
name,
config,
["command", "args", "env", "cwd", "timeout", "type"],
["cwd", "timeout"],
context,
)
continue
}
if (url !== undefined) {
const type = config.type === "sse" || config.type === "ws" ? config.type : "http"
result[name] = { type, url, headers: stringRecord(config.headers) }
const timeout = asTimeout(config.timeout)
result[name] = {
type,
url,
headers: stringRecord(config.headers),
...(timeout === undefined ? {} : { timeout }),
}
warnUntranslatedMcp(name, config, ["command", "url", "headers", "type", "timeout"], ["timeout"], context)
continue
}
// Marketplaces ship connector placeholders with an empty url for the user to fill in.
@@ -683,6 +718,48 @@ function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
}
function asTimeout(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value.trim())
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
return undefined
}
/**
* `cwd`, `codemode`, `protocol`, and `oauth` are defined by opencode but omitted
* from this adapter's translation. `cwd`/`timeout` are retained on the def for reporting;
* everything else is reported and ignored instead of being silently dropped (drift D).
* `disabled === true` is enforced in `readMcpMap` (skipped before registration);
* any other `disabled` value falls through to the warn-only path below.
*/
function warnUntranslatedMcp(
name: string,
config: Record<string, unknown>,
known: string[],
retained: string[],
context: Context,
) {
const knownSet = new Set(known)
for (const key of Object.keys(config)) {
if (knownSet.has(key)) continue
// `env` values using native `{env:VAR}` are a separate divergence (drift E); still report the key.
context.issues.push({
level: "warning",
message: `MCP server ${name} sets ${key} which is not forwarded by this adapter and is ignored`,
})
}
const kept = retained.filter((key) =>
key === "cwd" ? asString(config.cwd) !== undefined : asTimeout(config[key]) !== undefined,
)
if (kept.length > 0)
context.issues.push({
level: "warning",
message: `MCP server ${name} sets ${kept.join("/")} which is retained but not forwarded to opencode`,
})
}
function stringRecord(value: unknown): Record<string, string> {
if (!isRecord(value)) return {}
return Object.fromEntries(
+19 -2
View File
@@ -37,6 +37,8 @@ export interface AgentDef {
model?: string
tools?: string[]
disallowedTools?: string[]
/** Opt-in MCP grants: `<server>:<tool>` or `<server>:*`. Fail-closed; `*` never implies MCP. */
mcpAllow?: string[]
color?: string
body: string
path: string
@@ -84,8 +86,23 @@ export interface HookMatcher {
export type HookConfig = Record<string, HookMatcher[]>
export type McpServerDef =
| { type: "stdio"; command: string; args: string[]; env: Record<string, string> }
| { type: "http" | "sse" | "ws"; url: string; headers: Record<string, string> }
| {
type: "stdio"
command: string
args: string[]
env: Record<string, string>
/** Retained from the manifest but not forwarded by this adapter (opencode defines `cwd`). */
cwd?: string
/** Retained from the manifest but not forwarded by this adapter (opencode defines per-server `timeout`). */
timeout?: number
}
| {
type: "http" | "sse" | "ws"
url: string
headers: Record<string, string>
/** Retained from the manifest but not forwarded by this adapter (opencode defines per-server `timeout`). */
timeout?: number
}
export interface LspServerDef {
command: string
+12 -1
View File
@@ -40,6 +40,12 @@ export interface OpencodeAgent {
/**
* Claude model aliases (`sonnet`, `opus`, `inherit`) have no provider-neutral meaning in opencode,
* so translated agents inherit the session's model.
*
* Drift G: `permissions` covers built-in tools via `tools`/`disallowedTools`.
* MCP/plugin tool names (`mcp__*`) in those lists still grant nothing by themselves
* (fail-closed); MCP access is opt-in only via the per-server `mcpAllow`
* frontmatter (`<server>:<tool>` / `<server>:*`), translated to `mcp:call`
* allows with trailing denies winning. `*` never implies MCP.
*/
export function toAgent(agent: AgentDef, context: VarContext): OpencodeAgent {
return {
@@ -48,7 +54,7 @@ export function toAgent(agent: AgentDef, context: VarContext): OpencodeAgent {
system: substitute(agent.body, context).trim(),
mode: "subagent",
...(agent.color === undefined ? {} : { color: agent.color }),
permissions: agentPermissions(agent.tools, agent.disallowedTools),
permissions: agentPermissions(agent.tools, agent.disallowedTools, agent.mcpAllow),
}
}
@@ -88,6 +94,11 @@ export function toMcp(server: McpServerDef, context: VarContext, env: Record<str
const expandAll = (record: Record<string, string>) =>
Object.fromEntries(Object.entries(record).map(([key, value]) => [key, expand(value)]))
if (server.type === "stdio") {
// Drift D-2: `cwd`/`timeout` are retained on McpServerDef for reporting but not forwarded by
// this adapter — opencode defines both, but OpencodeMcp local omits them and a shell-wrapper
// would break tool routing. Absolute `${CLAUDE_PLUGIN_ROOT}` expansion (drift E) already
// covers the common cwd use.
// Drift F: pluginEnvironment is an intentional Claude-parity superset opencode does not provide.
return {
type: "local",
command: [expand(server.command), ...server.args.map(expand)],
+207 -7
View File
@@ -2,11 +2,15 @@
* Claude Code and opencode name their built-in tools and tool inputs differently.
* Hooks and agent tool lists are written in Claude's vocabulary, so both directions are needed.
*/
import path from "node:path"
import { realpathSync, statSync } from "node:fs"
/**
* opencode permission action for each Claude tool an agent may list in `tools`.
* Tools absent here grant nothing: `BashOutput` and `KillShell` only manage existing shells,
* and mapping them to `shell` would let a read-only agent run commands.
* Drift G: MCP/plugin tools (`mcp__*`) grant nothing unless explicitly listed in `mcpAllow`
* (fail-closed opt-in, see `toAgent`).
*/
const ACTIONS: Record<string, string[]> = {
Read: ["read"],
@@ -39,18 +43,171 @@ export interface PermissionRule {
effect: "allow" | "deny" | "ask"
}
const MCP_ACTION = "mcp:call"
const SERVER_RE = /^[A-Za-z0-9_.-]+$/
const TOOL_RE = /^[A-Za-z0-9_.-]+$/
interface McpTriple {
server: string
tool: string
resource: string
}
/**
* Turns an agent's `tools` allowlist and `disallowedTools` denylist into opencode permission rules.
* opencode applies the last matching rule, so the deny-all comes first.
* Parses an `mcpAllow` entry (`<server>:<tool>` or `<server>:*`).
* Strict and fail-closed: no suffix-stripping — `filesystem:read_file(extra)`
* is rejected, not stripped to an over-grant. Bare `*`, `<tool>` without a
* server, `*:tool`, empty parts, and `$`/backtick content are rejected.
*/
export function agentPermissions(tools: string[] | undefined, disallowed: string[] | undefined): PermissionRule[] {
function parseMcpAllowEntry(entry: unknown): McpTriple | undefined {
if (typeof entry !== "string") return undefined
const trimmed = entry.trim()
if (trimmed === "" || trimmed === "*") return undefined
if (trimmed.includes("(") || trimmed.includes(")")) return undefined
const parts = trimmed.split(":")
if (parts.length !== 2) return undefined
const server = parts[0]!.trim()
const tool = parts[1]!.trim()
if (server === "" || server === "*" || !SERVER_RE.test(server)) return undefined
if (tool === "") return undefined
if (tool !== "*" && !TOOL_RE.test(tool)) return undefined
return { server, tool, resource: `${server}:${tool}` }
}
/**
* Parses an MCP deny spelling from `disallowedTools` into a canonical triple.
* Accepts the canonical `server:tool` form and the `mcp__*` hook forms:
* `mcp__<server>__<tool>` and `mcp__plugin_<plugin>_<server>__<tool>`.
* For denies (fail-closed to more deny) a trailing `(...)` suffix is stripped
* before parsing; allows never strip.
*/
function parseMcpHookDeny(entry: string): McpTriple | undefined {
const stripped = toolName(entry)
if (stripped === "" || stripped === "*") return undefined
if (!stripped.startsWith("mcp__")) return undefined
const rest = stripped.slice("mcp__".length)
if (rest === "" || rest.includes("(") || rest.includes(")")) return undefined
const halves = rest.split("__")
if (halves.length !== 2) return undefined
const head = halves[0]!.trim()
const tool = halves[1]!.trim()
if (head === "" || tool === "" || tool === "*:*") return undefined
if (tool !== "*" && !TOOL_RE.test(tool)) return undefined
// Head is `<server>` or `<plugin>_<server>` (encoded parts never contain a
// bare `_`, so the last `_` segment is the server).
const headSegments = head.split("_").filter((segment) => segment !== "")
const server = headSegments[headSegments.length - 1] ?? ""
if (server === "" || server === "*" || !SERVER_RE.test(server)) return undefined
if (server === "plugin" && headSegments.length <= 1) return undefined
return { server, tool, resource: `${server}:${tool}` }
}
function parseMcpDisallowEntry(entry: unknown): McpTriple | undefined {
if (typeof entry !== "string") return undefined
const trimmed = entry.trim()
if (trimmed === "") return undefined
// Canonical form (strip suffix for denies: more deny is fail-closed).
const canonical = parseMcpAllowEntry(toolName(trimmed))
if (canonical) return canonical
return parseMcpHookDeny(trimmed)
}
/**
* Turns an agent's `tools` allowlist, `disallowedTools` denylist, and optional
* `mcpAllow` opt-in into opencode permission rules.
* opencode applies the last matching rule, so the deny-all comes first,
* built-in allows next, MCP allows after them, and all denies trail.
*
* Fail-closed defaults:
* - No `mcpAllow` → zero `mcp:call` rules.
* - `tools: ["*"]` → no MCP rules at all (`*` never implies MCP).
* - Unscoped `mcpAllow` (`*`, `read_file`, `*:tool`) → rejected, no grant.
* - Trailing MCP denies win over MCP allows.
*/
export function agentPermissions(
tools: string[] | undefined,
disallowed: string[] | undefined,
mcpAllow?: string[] | undefined,
): PermissionRule[] {
const rules: PermissionRule[] = []
const actions = (names: string[]) => [...new Set(names.flatMap((name) => ACTIONS[toolName(name)] ?? []))]
if (tools !== undefined && !tools.includes("*")) {
// `*` is a passthrough: no deny-all, no rules from the allowlist side.
// It never implies MCP, so skip all MCP processing.
if (tools !== undefined && tools.includes("*")) {
for (const action of actions(disallowed ?? [])) rules.push({ action, resource: "*", effect: "deny" })
return rules
}
const allows: McpTriple[] = []
const seenAllow = new Set<string>()
for (const entry of mcpAllow ?? []) {
const parsed = parseMcpAllowEntry(entry)
if (!parsed || seenAllow.has(parsed.resource)) continue
seenAllow.add(parsed.resource)
allows.push(parsed)
}
const needsDenyAll = (tools !== undefined || allows.length > 0) && true
if (tools !== undefined) {
rules.push({ action: "*", resource: "*", effect: "deny" })
for (const action of actions(tools)) rules.push({ action, resource: "*", effect: "allow" })
} else if (allows.length > 0) {
rules.push({ action: "*", resource: "*", effect: "deny" })
}
// One allow per spelling (Code-Mode inner `<server>_<tool>` and hook
// `mcp__plugin_*`), sharing the same `server:tool` resource triple.
for (const allow of allows) {
rules.push({ action: MCP_ACTION, resource: allow.resource, effect: "allow" })
rules.push({ action: MCP_ACTION, resource: allow.resource, effect: "allow" })
}
for (const action of actions(disallowed ?? [])) rules.push({ action, resource: "*", effect: "deny" })
// Trailing MCP denies win, including flips of an allowed triple and
// standalone denies with no corresponding allow.
const denyResources = new Set<string>()
const denyOrder: string[] = []
const considerDeny = (resource: string) => {
if (denyResources.has(resource)) return
denyResources.add(resource)
denyOrder.push(resource)
}
const allowByInner = new Map<string, McpTriple>()
for (const allow of allows) {
allowByInner.set(`${allow.server}_${allow.tool}`, allow)
// Wildcard allows also match specific inner denies on that server.
if (allow.tool === "*") allowByInner.set(`${allow.server}_*`, allow)
}
for (const entry of disallowed ?? []) {
if (typeof entry !== "string") continue
const parsed = parseMcpDisallowEntry(entry)
if (parsed) {
considerDeny(parsed.resource)
continue
}
// Code-Mode inner `<server>_<tool>`: only matched against known allows
// to avoid ambiguous server/tool splits granting or denying blindly.
const inner = toolName(entry).trim()
if (inner !== "") {
const direct = allowByInner.get(inner)
if (direct) {
// For a wildcard allow, deny the specific triple, not the wildcard.
if (direct.tool === "*" && inner !== `${direct.server}_*`) {
const toolPart = inner.slice(direct.server.length + 1)
if (toolPart !== "" && TOOL_RE.test(toolPart)) considerDeny(`${direct.server}:${toolPart}`)
continue
}
considerDeny(direct.resource)
} else {
// Inner spelling of a server with a wildcard allow: deny specifically.
const underscore = inner.indexOf("_")
if (underscore > 0) {
const maybeServer = inner.slice(0, underscore)
const maybeTool = inner.slice(underscore + 1)
const wildcard = allows.find((allow) => allow.tool === "*" && allow.server === maybeServer)
if (wildcard && maybeTool !== "" && TOOL_RE.test(maybeTool)) considerDeny(`${maybeServer}:${maybeTool}`)
}
}
}
}
for (const resource of denyOrder) rules.push({ action: MCP_ACTION, resource, effect: "deny" })
void needsDenyAll
return rules
}
@@ -69,6 +226,11 @@ const pick = (input: Input, mapping: Record<string, string>): Input =>
)
const FIELDS: Record<string, { claude: string; fields: Record<string, string> }> = {
// Drift A: shell `workdir` has no Claude counterpart and is intentionally unmapped. `toClaudeToolCall`
// drops it (pinned by test) so matchers never see it, while `applyUpdatedInput` preserves it via
// spread so hooks cannot clobber it. Forwarding it as `working_directory` would require a reverse
// map plus fixture updates and is out of scope. WS5 adds a separate validated `cwd` field
// (structured `{cwd: string}`, never parsed from command text) that is surfaced when valid.
shell: { claude: "Bash", fields: { command: "command", timeout: "timeout", background: "run_in_background" } },
read: { claude: "Read", fields: { path: "file_path", offset: "offset", limit: "limit" } },
write: { claude: "Write", fields: { path: "file_path", content: "content" } },
@@ -85,18 +247,56 @@ const FIELDS: Record<string, { claude: string; fields: Record<string, string> }>
question: { claude: "AskUserQuestion", fields: { questions: "questions" } },
}
/**
* Validates a structured shell `cwd` against `projectDir` (WS5).
* Returns the real absolute path when valid, else `undefined` (caller uses
* `projectDir`). Fail-closed: type gate → no `$`/backtick expansion →
* `path.resolve` + `realpath` → containment under `projectDir` (real path +
* sep) → must exist and be a directory. Never parses `cd <dir> && ...`
* out of the command text.
*/
export function validateShellCwd(cwd: unknown, projectDir: string): string | undefined {
if (typeof cwd !== "string" || cwd.length === 0 || cwd.trim() === "") return undefined
if (cwd.includes("\0")) return undefined
if (/[$`]/.test(cwd)) return undefined
try {
const resolved = path.resolve(projectDir, cwd)
const realProject = realpathSync(projectDir)
const realCwd = realpathSync(resolved)
if (realCwd !== realProject && !realCwd.startsWith(realProject + path.sep)) return undefined
if (!statSync(realCwd).isDirectory()) return undefined
return realCwd
} catch {
return undefined
}
}
/**
* Describes an opencode tool call the way a Claude Code hook expects to see it.
* Tools with no Claude counterpart keep their own name and input, so matchers can still target them.
*
* WS5: when `projectDir` is provided and the call is `shell` with a structured
* `cwd`, the validated absolute cwd is included as `cwd` in the Bash input.
* Invalid cwd is ignored (hooks see `projectDir` via the session). `workdir`
* is still dropped, and `cd <dir> && ...` prefixes gain no cwd semantics.
*/
export function toClaudeToolCall(tool: string, input: unknown): ClaudeToolCall {
export function toClaudeToolCall(tool: string, input: unknown, projectDir?: string): ClaudeToolCall {
const source: Input = typeof input === "object" && input !== null ? (input as Input) : {}
const mapping = FIELDS[tool]
if (!mapping) return { name: tool, input: source }
return { name: mapping.claude, input: pick(source, mapping.fields) }
const base = pick(source, mapping.fields)
if (tool === "shell" && projectDir !== undefined) {
const validated = validateShellCwd(source.cwd, projectDir)
if (validated !== undefined) return { name: mapping.claude, input: { ...base, cwd: validated } }
}
return { name: mapping.claude, input: base }
}
/** Applies a hook's `updatedInput`, written in Claude's field names, back onto the opencode input. */
/**
* Applies a hook's `updatedInput`, written in Claude's field names, back onto the opencode input.
* WS5: the original `cwd`/`workdir` are preserved via spread; hooks cannot set
* cwd through `updatedInput` (no reverse map for it — fail-closed).
*/
export function applyUpdatedInput(tool: string, original: unknown, updated: Record<string, unknown>): unknown {
const mapping = FIELDS[tool]
const source: Input = typeof original === "object" && original !== null ? (original as Input) : {}
+5
View File
@@ -28,6 +28,11 @@ const ENV_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g
/**
* Like `substitute`, and also expands `${VAR}` and `${VAR:-default}` from the environment,
* which MCP and LSP server definitions use for credentials.
*
* Drift E (intentional divergence): Claude plugins use `${VAR}` expanded here at translation
* time from `process.env`. opencode native config instead expands `{env:VAR}` at file-text
* level (`core/src/config/variable.ts`) and treats `${VAR}` literally. Do not migrate to
* `{env:}` — it would break every real-marketplace MCP credential and the probe fixture.
*/
export function substituteWithEnv(text: string, context: VarContext, env: Record<string, string | undefined>): string {
return substitute(text, context).replace(ENV_PATTERN, (whole, name: string, fallback: string | undefined) => {
+99
View File
@@ -240,6 +240,105 @@ test("an MCP placeholder with an empty url is skipped with a warning, not an err
expect(plugin.issues).toEqual([{ level: "warning", message: "MCP server gmail has an empty url and was skipped" }])
})
test("MCP cwd/timeout are retained but warned, unknown keys are warned and ignored", () => {
const root = tree({
".mcp.json": {
mcpServers: {
local: { command: "node", args: ["s.js"], env: {}, cwd: "/tmp", timeout: 30 },
remote: { type: "http", url: "https://x.test/mcp", timeout: "45", oauth: "x" },
},
},
})
const plugin = loadPlugin({ root })
expect(plugin.mcpServers.local).toMatchObject({ type: "stdio", command: "node", cwd: "/tmp", timeout: 30 })
expect(plugin.mcpServers.remote).toMatchObject({ type: "http", timeout: 45 })
const messages = plugin.issues.map((issue) => issue.message)
expect(messages.some((message) => message.includes("local") && message.includes("cwd"))).toBe(true)
expect(messages.some((message) => message.includes("remote") && message.includes("oauth"))).toBe(true)
expect(plugin.issues.every((issue) => issue.level === "warning")).toBe(true)
})
test("MCP disabled:true on a stdio server skips registration with a warning", () => {
const root = tree({
".mcp.json": {
mcpServers: {
off: { command: "node", args: ["s.js"], disabled: true },
on: { command: "node", args: ["s.js"] },
},
},
})
const plugin = loadPlugin({ root })
expect(Object.keys(plugin.mcpServers)).toEqual(["on"])
expect(plugin.issues).toEqual([{ level: "warning", message: "MCP server off is disabled and was skipped" }])
})
test("MCP disabled:true on a remote server skips registration with a warning", () => {
const root = tree({
".mcp.json": {
mcpServers: {
off: { type: "http", url: "https://x.test/mcp", disabled: true },
on: { type: "http", url: "https://x.test/mcp" },
},
},
})
const plugin = loadPlugin({ root })
expect(Object.keys(plugin.mcpServers)).toEqual(["on"])
expect(plugin.issues).toEqual([{ level: "warning", message: "MCP server off is disabled and was skipped" }])
})
test("MCP disabled:false and non-boolean disabled register with a warning", () => {
const root = tree({
".mcp.json": {
mcpServers: {
no: { command: "node", args: ["s.js"], disabled: false },
str: { command: "node", args: ["s.js"], disabled: "true" },
},
},
})
const plugin = loadPlugin({ root })
expect(Object.keys(plugin.mcpServers).toSorted()).toEqual(["no", "str"])
const messages = plugin.issues.map((issue) => issue.message)
expect(messages.some((message) => message.includes("no") && message.includes("disabled"))).toBe(true)
expect(messages.some((message) => message.includes("str") && message.includes("disabled"))).toBe(true)
expect(plugin.issues.every((issue) => issue.level === "warning")).toBe(true)
})
test("MCP codemode/protocol/oauth warn but do not skip registration", () => {
const root = tree({
".mcp.json": {
mcpServers: {
srv: {
command: "node",
args: ["s.js"],
codemode: true,
protocol: "streamable",
oauth: { issuer: "https://x.test" },
},
},
},
})
const plugin = loadPlugin({ root })
expect(Object.keys(plugin.mcpServers)).toEqual(["srv"])
const messages = plugin.issues.map((issue) => issue.message)
for (const key of ["codemode", "protocol", "oauth"])
expect(messages.some((message) => message.includes("srv") && message.includes(key))).toBe(true)
expect(plugin.issues.every((issue) => issue.level === "warning")).toBe(true)
})
test("MCP disabled:true wins over the empty-url placeholder", () => {
const root = tree({
".mcp.json": {
mcpServers: {
off: { type: "http", url: "", disabled: true },
real: { type: "http", url: "https://x.test/mcp" },
},
},
})
const plugin = loadPlugin({ root })
expect(Object.keys(plugin.mcpServers)).toEqual(["real"])
expect(plugin.issues).toEqual([{ level: "warning", message: "MCP server off is disabled and was skipped" }])
})
test("entry mcpServers and lspServers apply only when the entry is the manifest", () => {
const servers = { mcpServers: { extra: { command: "node", args: ["x.js"] } } }
const withManifest = tree({ ".claude-plugin/plugin.json": { name: "p" } })
+192 -1
View File
@@ -1,5 +1,8 @@
import { expect, test } from "bun:test"
import { agentPermissions, applyUpdatedInput, toClaudeToolCall } from "../src/translate/tools.ts"
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { agentPermissions, applyUpdatedInput, toClaudeToolCall, validateShellCwd } from "../src/translate/tools.ts"
test("a tools allowlist becomes deny-all followed by specific allows", () => {
expect(agentPermissions(["Glob", "Grep", "Read", "WebFetch"], undefined)).toEqual([
@@ -61,3 +64,191 @@ test("a hook's updatedInput maps back onto opencode field names", () => {
content: "b",
})
})
// WS4 validation matrix: MCP permission opt-in (fail-closed, per-server mcpAllow).
test("WS4: no mcpAllow means zero mcp:call rules", () => {
const withTools = agentPermissions(["Read"], undefined, undefined)
expect(withTools).toContainEqual({ action: "*", resource: "*", effect: "deny" })
expect(withTools.filter((rule) => rule.action === "mcp:call")).toEqual([])
expect(agentPermissions(undefined, undefined, []).filter((rule) => rule.action === "mcp:call")).toEqual([])
})
test("WS4: tools star never implies MCP", () => {
expect(agentPermissions(["*"], undefined, ["filesystem:read_file"])).toEqual([])
expect(
agentPermissions(["*"], ["Write"], ["filesystem:read_file"]).filter((rule) => rule.action === "mcp:call"),
).toEqual([])
})
test("WS4: exact mcpAllow emits both-spelling allows", () => {
const rules = agentPermissions(undefined, undefined, ["filesystem:read_file"])
expect(rules[0]).toEqual({ action: "*", resource: "*", effect: "deny" })
const allows = rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "allow")
expect(allows).toEqual([
{ action: "mcp:call", resource: "filesystem:read_file", effect: "allow" },
{ action: "mcp:call", resource: "filesystem:read_file", effect: "allow" },
])
expect(rules.filter((rule) => rule.effect === "deny")).toEqual([{ action: "*", resource: "*", effect: "deny" }])
})
test("WS4: trailing MCP denies win over allows", () => {
for (const deny of [
"filesystem:read_file",
"mcp__filesystem__read_file",
"mcp__plugin_myplug_filesystem__read_file",
"filesystem_read_file",
]) {
const rules = agentPermissions(undefined, [deny], ["filesystem:read_file"])
const allows = rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "allow")
const denies = rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "deny")
expect(allows.length).toBe(2)
expect(denies).toEqual([{ action: "mcp:call", resource: "filesystem:read_file", effect: "deny" }])
const lastAllow = rules.lastIndexOf(allows[allows.length - 1]!)
const lastDeny = rules.lastIndexOf(denies[denies.length - 1]!)
expect(lastDeny).toBeGreaterThan(lastAllow)
}
})
test("WS4: unscoped mcpAllow is rejected with no grant", () => {
for (const scoped of [["*"], ["read_file"], ["*:read_file"], ["*:*"], [""]]) {
const rules = agentPermissions(undefined, undefined, scoped)
expect(rules.filter((rule) => rule.action === "mcp:call")).toEqual([])
}
})
test("WS4: suffixed mcpAllow is rejected, not stripped to an over-grant", () => {
const rules = agentPermissions(undefined, undefined, ["filesystem:read_file(extra)"])
expect(rules.filter((rule) => rule.action === "mcp:call")).toEqual([])
})
test("WS4: MCP names in tools/disallowedTools alone grant nothing", () => {
const rules = agentPermissions(["mcp__filesystem__read_file"], undefined, undefined)
expect(rules).toEqual([{ action: "*", resource: "*", effect: "deny" }])
})
test("WS4: wildcard server-tool allow and ordering with built-ins", () => {
const rules = agentPermissions(["Read"], ["Write"], ["filesystem:*"])
expect(rules[0]).toEqual({ action: "*", resource: "*", effect: "deny" })
expect(rules).toContainEqual({ action: "read", resource: "*", effect: "allow" })
expect(rules.filter((rule) => rule.action === "mcp:call" && rule.effect === "allow")).toEqual([
{ action: "mcp:call", resource: "filesystem:*", effect: "allow" },
{ action: "mcp:call", resource: "filesystem:*", effect: "allow" },
])
const mcpAllowLast = rules
.map((rule, index) => ({ rule, index }))
.filter(({ rule }) => rule.action === "mcp:call" && rule.effect === "allow")
.at(-1)!.index
const editDeny = rules
.map((rule, index) => ({ rule, index }))
.filter(({ rule }) => rule.action === "edit" && rule.effect === "deny")
.at(-1)!.index
expect(mcpAllowLast).toBeLessThan(editDeny)
})
// WS5 validation matrix: structured cwd (fail-closed to projectDir).
function makeProject() {
const root = mkdtempSync(path.join(tmpdir(), "c4o-ws5-"))
const sub = path.join(root, "sub")
mkdirSync(sub)
const file = path.join(root, "file.txt")
writeFileSync(file, "hi")
return { root, sub, file }
}
test("WS5: non-string and empty cwd are ignored", () => {
const { root } = makeProject()
try {
expect(validateShellCwd(undefined, root)).toBeUndefined()
expect(validateShellCwd(42, root)).toBeUndefined()
expect(validateShellCwd("", root)).toBeUndefined()
expect(validateShellCwd(" ", root)).toBeUndefined()
expect(toClaudeToolCall("shell", { command: "ls" }, root)).toEqual({ name: "Bash", input: { command: "ls" } })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: expansion syntax in cwd is ignored with no expansion", () => {
const { root, sub } = makeProject()
try {
for (const cwd of ["${HOME}", "$HOME", "$(whoami)", "`whoami`", "sub/$(whoami)", "sub/${USER}"]) {
expect(validateShellCwd(cwd, root)).toBeUndefined()
}
expect(validateShellCwd("sub", root)).toBe(path.resolve(root, "sub"))
void sub
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: outside-projectDir cwd is ignored", () => {
const { root } = makeProject()
try {
expect(validateShellCwd("/etc", root)).toBeUndefined()
expect(validateShellCwd("..", root)).toBeUndefined()
expect(validateShellCwd(path.join(root, "..", "etc"), root)).toBeUndefined()
expect(toClaudeToolCall("shell", { command: "ls", cwd: "/etc" }, root)).toEqual({
name: "Bash",
input: { command: "ls" },
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: symlink escape is ignored", () => {
const { root } = makeProject()
const link = path.join(root, "escape")
try {
symlinkSync("/etc", link)
expect(validateShellCwd("escape", root)).toBeUndefined()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: non-existent and file-not-directory cwd are ignored", () => {
const { root } = makeProject()
try {
expect(validateShellCwd("nope", root)).toBeUndefined()
expect(validateShellCwd("file.txt", root)).toBeUndefined()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: valid subdir cwd is forwarded, cd-prefix gains no semantics", () => {
const { root, sub } = makeProject()
try {
const validated = validateShellCwd("sub", root)!
expect(validated).toBe(path.resolve(root, "sub"))
void sub
expect(toClaudeToolCall("shell", { command: "ls", cwd: "sub" }, root)).toEqual({
name: "Bash",
input: { command: "ls", cwd: validated },
})
// `cd` prefix is never parsed as cwd.
expect(toClaudeToolCall("shell", { command: "cd sub && ls" }, root)).toEqual({
name: "Bash",
input: { command: "cd sub && ls" },
})
// Legacy workdir stays dropped while validated cwd is surfaced.
expect(toClaudeToolCall("shell", { command: "ls", workdir: "/x", cwd: "sub" }, root)).toEqual({
name: "Bash",
input: { command: "ls", cwd: validated },
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("WS5: hooks cannot set cwd through updatedInput", () => {
expect(
applyUpdatedInput("shell", { command: "ls", cwd: "/proj/sub", workdir: "/x" }, { command: "echo safe" }),
).toEqual({ command: "echo safe", cwd: "/proj/sub", workdir: "/x" })
expect(
applyUpdatedInput("shell", { command: "ls", cwd: "/proj/sub" }, { command: "echo safe", cwd: "/evil" }),
).toEqual({ command: "echo safe", cwd: "/proj/sub" })
})
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test"
import { substitute, substituteWithEnv, type VarContext } from "../src/translate/vars.ts"
const context: VarContext = {
pluginRoot: "/root",
pluginData: "/data",
projectDir: "/project",
userConfig: { TOKEN: "abc" },
}
// WS6 invariant I-1: opencode-native `{env:VAR}` is never expanded here and
// never emitted — it passes through byte-identical.
test("opencode {env:VAR} passes through byte-identical", () => {
expect(substitute("{env:FOO}", context)).toBe("{env:FOO}")
expect(substitute("prefix {env:FOO} suffix", context)).toBe("prefix {env:FOO} suffix")
expect(substituteWithEnv("{env:FOO}", context, { FOO: "x" })).toBe("{env:FOO}")
expect(substituteWithEnv("Bearer {env:TOKEN}", context, { TOKEN: "x" })).toBe("Bearer {env:TOKEN}")
})
// I-2: `substitute` has no env — `${FOO}` stays literal without an env pass.
test("substitute leaves bare ${VAR} literal", () => {
expect(substitute("key=${FOO}", context)).toBe("key=${FOO}")
})
// I-3/I-7: `substituteWithEnv` expands from the explicit env param with
// `${VAR:-default}` fallback; missing stays literal.
test("substituteWithEnv expands ${VAR} and ${VAR:-default}, missing stays literal", () => {
expect(substituteWithEnv("key=${FOO}", context, { FOO: "x" })).toBe("key=x")
expect(substituteWithEnv("key=${MISSING:-def}", context, {})).toBe("key=def")
expect(substituteWithEnv("key=${MISSING}", context, {})).toBe("key=${MISSING}")
})
// I-7: single pass, no recursion — userConfig values are inserted literally.
test("userConfig values are inserted literally without re-expansion", () => {
const nested: VarContext = {
...context,
userConfig: { NEST: "${CLAUDE_PLUGIN_ROOT}/x" },
}
expect(substitute("${user_config.NEST}", nested)).toBe("${CLAUDE_PLUGIN_ROOT}/x")
})