mirror of
https://github.com/Heretek-AI/claude-4-opencode.git
synced 2026-11-07 17:16:29 -10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f297b4ed5 | ||
|
|
5933744629 | ||
|
|
d5faa3523f | ||
|
|
1674412efd | ||
|
|
b666ec333a | ||
|
|
72b45c6638 | ||
|
|
8de5c45dd5 | ||
|
|
1447e5080f | ||
|
|
ee1944f0d4 | ||
|
|
99bb5930bf | ||
|
|
82c73a224b |
No files matched your search
@@ -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: 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
|
||||
@@ -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.
|
||||
@@ -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 `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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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" } })
|
||||
|
||||
@@ -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