Author SHA1 Message Date
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
John Smith 8de5c45dd5 0.1.1 2026-10-10 18:27:25 -04:00
John Smith 1447e5080f fix(release): bin path without ./ prefix so npm keeps the c4o binary
npm strips bin entries in ./ form ('bin[c4o] was invalid and removed').
Verified with npm publish --dry-run: zero warnings.
2026-10-10 18:27:02 -04:00
John Smith ee1944f0d4 chore(release): scope package as @heretek-ai/claude-4-opencode for org publishing 2026-10-10 18:23:14 -04:00
John Smith 99bb5930bf chore(release): 0.1.0, MIT license, OIDC trusted-publishing workflow
- version 0.0.1 -> 0.1.0, license MIT
- release.yml on v* tags with id-token:write, pinned actions
2026-10-10 18:20:28 -04:00
BillyOutlast 82c73a224b Merge pull request #1 from Heretek-AI/plugin-compat-layer
feat: run Claude Code plugins and marketplaces on opencode v2
2026-10-10 17:16:34 -04:00
19 changed files with 639 additions and 14 deletions

No files matched your search

+1
View File
@@ -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: Release
# Publishes to npm via OIDC trusted publishing (no long-lived tokens).
# First publish is done locally; afterwards, pushing a `v*` tag releases.
# npmjs.com → package Settings → Trusted Publisher must list:
# org/user: Heretek-AI, repo: claude-4-opencode, workflow: release.yml
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
id-token: write # Required for npm OIDC
contents: read
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
package-manager-cache: false # never use caching in release builds
- run: bun install --frozen-lockfile
- run: bun run format:check
- run: bun run typecheck
- run: bun run test
- run: bun run build
# Provenance is automatic with trusted publishing; no --provenance flag needed.
# No NODE_AUTH_TOKEN: authentication comes from the OIDC id-token.
- run: npm publish --access public
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Heretek-AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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 `cwd`/`timeout`/`disabled`/`codemode`/`protocol`/`oauth` are defined by opencode but omitted from this adapter's translation. `cwd`/`timeout` are retained on the def and warned about; the rest are warned and ignored (drift D).
- 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` allowlists mentioning MCP/plugin tools grant nothing (fail-closed deny-all); MCP actions are never emitted as opencode permissions (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 @@
# DRAFT — upstream issue for sst/opencode (WS7, not yet filed)
Filing requires a maintainer with GitHub access to `sst/opencode`.
Do not auto-file from agent loops. Copy the body below into a new
`sst/opencode` issue and link it back here.
---
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.
+64
View File
@@ -0,0 +1,64 @@
# RFC — WS4: MCP permission opt-in (design only, not implemented)
Status: proposed. No code changed. 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. No implementation
until this RFC is approved.
+57
View File
@@ -0,0 +1,57 @@
# RFC — WS5: structured `cwd` handling (design only, not implemented)
Status: proposed. No code changed. 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
until visibility ships. No implementation until this RFC is approved.
+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.
+4 -3
View File
@@ -1,14 +1,15 @@
{
"name": "claude-4-opencode",
"version": "0.0.1",
"name": "@heretek-ai/claude-4-opencode",
"version": "0.1.2",
"description": "Run Claude Code plugins and marketplaces on opencode v2",
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/Heretek-AI/claude-4-opencode.git"
},
"bin": {
"c4o": "./dist/cli.js"
"c4o": "dist/cli.js"
},
"exports": {
".": "./dist/server.js",
+13 -4
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>()
@@ -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()))
}
}
+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()
}
},
}
+77 -2
View File
@@ -634,15 +634,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 +716,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(
+17 -2
View File
@@ -84,8 +84,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
+9
View File
@@ -40,6 +40,10 @@ 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` only covers built-in tools. MCP/plugin tool names (`mcp__*`) map to
* nothing and grant nothing (fail-closed). Mapping them to `shell`/`read` would over-privilege
* read-only agents, so an allowlist mentioning MCP tools stays deny-all until an opt-in exists.
*/
export function toAgent(agent: AgentDef, context: VarContext): OpencodeAgent {
return {
@@ -88,6 +92,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)],
+5
View File
@@ -7,6 +7,7 @@
* 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__*`) are also absent by design (fail-closed, see `toAgent`).
*/
const ACTIONS: Record<string, string[]> = {
Read: ["read"],
@@ -69,6 +70,10 @@ 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.
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" } },
+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" } })
+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")
})