mirror of
https://github.com/Heretek-AI/claude-4-opencode.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3a3910caa | ||
|
|
618837f001 | ||
|
|
cad3181e5d | ||
|
|
f85756e861 | ||
|
|
1b175f91f9 | ||
|
|
2bb334b301 | ||
|
|
f3c70ededc | ||
|
|
dcfc02ff00 | ||
|
|
c8c819bf7a | ||
|
|
5059bef8cd | ||
|
|
2f297b4ed5 | ||
|
|
5933744629 | ||
|
|
d5faa3523f | ||
|
|
1674412efd | ||
|
|
b666ec333a | ||
|
|
72b45c6638 |
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? "")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) : {}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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" })
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
Reference in new issue
Block a user