@khalilgharbaoui/opencode-claude-code-plugin 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.js +97 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -182,6 +182,7 @@ The account model IDs are internally suffixed, for example `claude-sonnet-4-6@wo
|
|
|
182
182
|
| `skipPermissions` | boolean | `true` | Pass `--dangerously-skip-permissions` to `claude`. Ignored when `proxyTools` is set — the proxy handles permissions through opencode instead. |
|
|
183
183
|
| `permissionMode` | `acceptEdits` \| `auto` \| `bypassPermissions` \| `default` \| `dontAsk` \| `plan` | – | Forwarded to `claude --permission-mode`. |
|
|
184
184
|
| `proxyTools` | string[] | `["Bash", "Edit", "Write", "WebFetch", "Task"]` | Claude built-in tools to route through opencode's executor + permission UI. Opt-in extras: `"Question"`, `"Compress"`. See [Selective tool proxy](#selective-tool-proxy). |
|
|
185
|
+
| `extraDisallowedTools` | string[] | – | Extra Claude built-ins to switch off with `--disallowedTools`, on top of what `proxyTools` implies. Claude's names, e.g. `["NotebookEdit"]`. See [Closing a tool with no proxy](#closing-a-tool-with-no-proxy). |
|
|
185
186
|
| `proxyToolTimeoutMs` | `Record<string, number>` | – | Per-tool proxy call deadline in ms, keyed by proxy tool name (`bash`, `task`, …). Defaults: 10 min flat, `task` → 60 min. For `bash`, the call's own `input.timeout` is honoured on top (`max(resolved, input.timeout)`). See [Selective tool proxy](#selective-tool-proxy). |
|
|
186
187
|
| `planModeQuestion` | boolean | `false` | Route `ExitPlanMode` approval through opencode's native `question` tool instead of a text "(yes/no)" prompt. Off because opencode's question form is currently broken upstream. See [Plan mode](#plan-mode). |
|
|
187
188
|
| `controlRequestBehavior` | `allow` \| `deny` | `allow` | Default response when `skipPermissions: false` and Claude sends a `can_use_tool` control request. |
|
|
@@ -298,6 +299,28 @@ recovery step for harnesses that defer MCP tool schemas. Both apply per Claude
|
|
|
298
299
|
process at spawn, and provider options are read once at opencode startup, so
|
|
299
300
|
`proxyTools` changes need a full opencode restart.
|
|
300
301
|
|
|
302
|
+
### Proxy endpoint security
|
|
303
|
+
|
|
304
|
+
The proxy is a small HTTP MCP server on an ephemeral loopback port, and calling it runs Bash, Edit and Write through opencode's executor. Since 0.13.2 it requires a 256-bit bearer token, generated per server and handed to Claude in the `headers` block of the `0600` MCP config file the plugin writes. Requests are also rejected unless the `Host` header matches the bound `127.0.0.1:<port>` authority, no `Origin` header is present, and the content type is `application/json`.
|
|
305
|
+
|
|
306
|
+
**Upgrade if you are on 0.13.1 or earlier.** Before this, any local process could post to that port and execute commands as you, and a web page you visited could do the same blind, without reading the response. Reported by @willmcginnis in [#28](https://github.com/khalilgharbaoui/opencode-claude-code-plugin/pull/28).
|
|
307
|
+
|
|
308
|
+
Nothing to configure. If proxied tools ever stop working after a Claude Code upgrade, check the plugin log for `proxy-mcp rejected a request`, which names which guard failed.
|
|
309
|
+
|
|
310
|
+
### Closing a tool with no proxy
|
|
311
|
+
|
|
312
|
+
`proxyTools` only reaches built-ins the plugin can replace. A built-in with no opencode equivalent, `NotebookEdit` today and whatever Claude Code ships next, stays enabled and unmediated no matter what you put in that list. `extraDisallowedTools` names them directly:
|
|
313
|
+
|
|
314
|
+
```json
|
|
315
|
+
"options": {
|
|
316
|
+
"extraDisallowedTools": ["NotebookEdit"]
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
These go straight to `claude --disallowedTools`, so use Claude's tool names rather than opencode's. There is no replacement: the capability goes away rather than being routed through opencode, which is the point, but the model then has to work without it.
|
|
321
|
+
|
|
322
|
+
Unknown entries in `proxyTools` are logged as a warning at spawn rather than passing silently, so a typo shows up as "ignoring unknown proxyTools entries" in the plugin log instead of quietly leaving the matching built-in unmediated.
|
|
323
|
+
|
|
301
324
|
### Context compression
|
|
302
325
|
|
|
303
326
|
`"Compress"` is off by default. Add it when you run a harness that expects the model to manage its own context (opencode-dcp injects exactly those instructions), and the plugin exposes `mcp__opencode_proxy__compress`:
|
package/dist/index.d.ts
CHANGED
|
@@ -31,8 +31,8 @@ type OpenCodeModel = {
|
|
|
31
31
|
video: boolean;
|
|
32
32
|
pdf: boolean;
|
|
33
33
|
};
|
|
34
|
-
interleaved: boolean | {
|
|
35
|
-
field: "reasoning_content" | "
|
|
34
|
+
interleaved: boolean | string | {
|
|
35
|
+
field: "reasoning" | "reasoning_content" | "reasoning_text" | string;
|
|
36
36
|
};
|
|
37
37
|
};
|
|
38
38
|
cost: {
|
|
@@ -161,6 +161,7 @@ interface ClaudeCodeConfig {
|
|
|
161
161
|
controlRequestToolBehaviors?: Record<string, ControlRequestBehavior>;
|
|
162
162
|
controlRequestDenyMessage?: string;
|
|
163
163
|
proxyTools?: string[];
|
|
164
|
+
extraDisallowedTools?: string[];
|
|
164
165
|
proxyToolTimeoutMs?: Record<string, number>;
|
|
165
166
|
/**
|
|
166
167
|
* Route `ExitPlanMode` through opencode's native `question` tool so plan
|
|
@@ -283,6 +284,20 @@ interface ClaudeCodeProviderSettings {
|
|
|
283
284
|
* entry, in which case the deny/markdown fallback applies.
|
|
284
285
|
*/
|
|
285
286
|
proxyTools?: string[];
|
|
287
|
+
/**
|
|
288
|
+
* Extra Claude Code built-ins to switch off with `--disallowedTools`,
|
|
289
|
+
* on top of the ones implied by `proxyTools`.
|
|
290
|
+
*
|
|
291
|
+
* `proxyTools` can only disable built-ins the plugin knows how to
|
|
292
|
+
* replace, so a built-in with no proxy equivalent (`NotebookEdit`, and
|
|
293
|
+
* anything Claude Code adds after this release) has no off switch
|
|
294
|
+
* otherwise. Names are Claude's, not opencode's: `["NotebookEdit"]`.
|
|
295
|
+
*
|
|
296
|
+
* Disabling a tool with no replacement removes the capability rather
|
|
297
|
+
* than routing it through opencode — that is the point, but it does mean
|
|
298
|
+
* the model has to work without it.
|
|
299
|
+
*/
|
|
300
|
+
extraDisallowedTools?: string[];
|
|
286
301
|
/**
|
|
287
302
|
* Per-tool proxy call timeouts in milliseconds, keyed by the proxy tool
|
|
288
303
|
* name (`bash`, `edit`, `write`, `webfetch`, `task`, `question` —
|
package/dist/index.js
CHANGED
|
@@ -294,6 +294,9 @@ var CLAUDE_INTERNAL_TOOLS = /* @__PURE__ */ new Set([
|
|
|
294
294
|
"TaskGet",
|
|
295
295
|
"TaskStop"
|
|
296
296
|
]);
|
|
297
|
+
function singleQuoteForShell(value) {
|
|
298
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
299
|
+
}
|
|
297
300
|
function emitTodoWrite(todos) {
|
|
298
301
|
return {
|
|
299
302
|
name: "todowrite",
|
|
@@ -348,7 +351,7 @@ function mapTool(name, input, opts) {
|
|
|
348
351
|
return {
|
|
349
352
|
name: "bash",
|
|
350
353
|
input: {
|
|
351
|
-
command: `
|
|
354
|
+
command: `printf '%s\\n' ${singleQuoteForShell(`TASK OUTPUT: ${String(output)}`)}`,
|
|
352
355
|
description: "Displaying task output"
|
|
353
356
|
},
|
|
354
357
|
executed: false
|
|
@@ -2538,10 +2541,50 @@ var DEFAULT_PROXY_TOOLS = [
|
|
|
2538
2541
|
async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides, interceptors) {
|
|
2539
2542
|
const calls = new EventEmitter3();
|
|
2540
2543
|
const pending = /* @__PURE__ */ new Map();
|
|
2544
|
+
const authToken = crypto2.randomBytes(32).toString("hex");
|
|
2545
|
+
const expectedAuth = Buffer.from(`Bearer ${authToken}`);
|
|
2546
|
+
let boundAuthority = "";
|
|
2547
|
+
function authOk(req) {
|
|
2548
|
+
const got = req.headers.authorization;
|
|
2549
|
+
if (typeof got !== "string") return false;
|
|
2550
|
+
const candidate = Buffer.from(got);
|
|
2551
|
+
if (candidate.length !== expectedAuth.length) return false;
|
|
2552
|
+
return crypto2.timingSafeEqual(candidate, expectedAuth);
|
|
2553
|
+
}
|
|
2554
|
+
function reject(req, res, statusCode, reason) {
|
|
2555
|
+
log.notice("proxy-mcp rejected a request", {
|
|
2556
|
+
statusCode,
|
|
2557
|
+
reason,
|
|
2558
|
+
method: req.method,
|
|
2559
|
+
hasAuthorization: typeof req.headers.authorization === "string"
|
|
2560
|
+
});
|
|
2561
|
+
res.statusCode = statusCode;
|
|
2562
|
+
res.setHeader("Connection", "close");
|
|
2563
|
+
res.on("finish", () => {
|
|
2564
|
+
req.socket?.destroy();
|
|
2565
|
+
});
|
|
2566
|
+
res.end();
|
|
2567
|
+
}
|
|
2541
2568
|
const server2 = createServer(async (req, res) => {
|
|
2542
2569
|
if (req.method !== "POST" || !req.url?.startsWith("/mcp")) {
|
|
2543
|
-
res
|
|
2544
|
-
|
|
2570
|
+
reject(req, res, 404, "not a POST to /mcp");
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (req.headers.host !== boundAuthority) {
|
|
2574
|
+
reject(req, res, 403, "host header is not the bound authority");
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
if (req.headers.origin !== void 0) {
|
|
2578
|
+
reject(req, res, 403, "origin header present");
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
const contentType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
2582
|
+
if (contentType !== "application/json") {
|
|
2583
|
+
reject(req, res, 415, "content-type is not application/json");
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
if (!authOk(req)) {
|
|
2587
|
+
reject(req, res, 401, "missing or invalid bearer token");
|
|
2545
2588
|
return;
|
|
2546
2589
|
}
|
|
2547
2590
|
let requestId = null;
|
|
@@ -2633,13 +2676,13 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2633
2676
|
});
|
|
2634
2677
|
let timer = null;
|
|
2635
2678
|
const result = await new Promise(
|
|
2636
|
-
(resolve4,
|
|
2679
|
+
(resolve4, reject2) => {
|
|
2637
2680
|
const entry = {
|
|
2638
2681
|
id: callId,
|
|
2639
2682
|
toolName,
|
|
2640
2683
|
input,
|
|
2641
2684
|
resolve: resolve4,
|
|
2642
|
-
reject
|
|
2685
|
+
reject: reject2
|
|
2643
2686
|
};
|
|
2644
2687
|
pending.set(callId, entry);
|
|
2645
2688
|
const deadlineMs = resolveProxyCallTimeoutMs(
|
|
@@ -2655,7 +2698,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2655
2698
|
toolName,
|
|
2656
2699
|
deadlineMs
|
|
2657
2700
|
});
|
|
2658
|
-
|
|
2701
|
+
reject2(buildProxyTimeoutError(toolName, deadlineMs));
|
|
2659
2702
|
}, deadlineMs);
|
|
2660
2703
|
calls.emit("call", entry);
|
|
2661
2704
|
}
|
|
@@ -2714,10 +2757,10 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2714
2757
|
}
|
|
2715
2758
|
}
|
|
2716
2759
|
});
|
|
2717
|
-
await new Promise((resolve4,
|
|
2718
|
-
server2.once("error",
|
|
2760
|
+
await new Promise((resolve4, reject2) => {
|
|
2761
|
+
server2.once("error", reject2);
|
|
2719
2762
|
server2.listen(0, "127.0.0.1", () => {
|
|
2720
|
-
server2.off("error",
|
|
2763
|
+
server2.off("error", reject2);
|
|
2721
2764
|
resolve4();
|
|
2722
2765
|
});
|
|
2723
2766
|
});
|
|
@@ -2726,7 +2769,8 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2726
2769
|
server2.close();
|
|
2727
2770
|
throw new Error("Failed to bind proxy MCP server");
|
|
2728
2771
|
}
|
|
2729
|
-
|
|
2772
|
+
boundAuthority = `127.0.0.1:${addr.port}`;
|
|
2773
|
+
const url = `http://${boundAuthority}/mcp`;
|
|
2730
2774
|
log.info("proxy-mcp server started", {
|
|
2731
2775
|
url,
|
|
2732
2776
|
tools: tools.map((t) => t.name)
|
|
@@ -2736,6 +2780,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2736
2780
|
url,
|
|
2737
2781
|
serverName: SERVER_NAME,
|
|
2738
2782
|
tools,
|
|
2783
|
+
authToken,
|
|
2739
2784
|
calls,
|
|
2740
2785
|
configPath() {
|
|
2741
2786
|
if (configFilePath) return configFilePath;
|
|
@@ -2745,6 +2790,10 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2745
2790
|
[SERVER_NAME]: {
|
|
2746
2791
|
type: "http",
|
|
2747
2792
|
url,
|
|
2793
|
+
// Claude CLI replays these headers on every request to this
|
|
2794
|
+
// server, which is what lets the handler above reject anyone
|
|
2795
|
+
// who did not read this 0600 file.
|
|
2796
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
2748
2797
|
timeout: resolveProxyClientCeilingMs(timeoutOverrides)
|
|
2749
2798
|
}
|
|
2750
2799
|
}
|
|
@@ -2810,6 +2859,20 @@ function disallowedToolFlags(tools) {
|
|
|
2810
2859
|
}
|
|
2811
2860
|
return out;
|
|
2812
2861
|
}
|
|
2862
|
+
function resolveDisallowedTools(options) {
|
|
2863
|
+
const out = [];
|
|
2864
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2865
|
+
const push = (name) => {
|
|
2866
|
+
const trimmed = name.trim();
|
|
2867
|
+
if (!trimmed || seen.has(trimmed)) return;
|
|
2868
|
+
seen.add(trimmed);
|
|
2869
|
+
out.push(trimmed);
|
|
2870
|
+
};
|
|
2871
|
+
for (const name of disallowedToolFlags(options.proxyTools ?? [])) push(name);
|
|
2872
|
+
for (const name of options.extraDisallowedTools ?? []) push(String(name));
|
|
2873
|
+
if (options.disableWebSearch) push("WebSearch");
|
|
2874
|
+
return out;
|
|
2875
|
+
}
|
|
2813
2876
|
function readBody(req) {
|
|
2814
2877
|
return new Promise((resolve4, reject) => {
|
|
2815
2878
|
const chunks = [];
|
|
@@ -3353,9 +3416,22 @@ var ClaudeCodeLanguageModel = class {
|
|
|
3353
3416
|
DEFAULT_PROXY_TOOLS.map((t) => [t.name.toLowerCase(), t])
|
|
3354
3417
|
);
|
|
3355
3418
|
const picked = [];
|
|
3419
|
+
const unknown = [];
|
|
3356
3420
|
for (const n of names) {
|
|
3357
3421
|
const def = defsByName.get(String(n).toLowerCase());
|
|
3358
3422
|
if (def) picked.push(def);
|
|
3423
|
+
else unknown.push(String(n));
|
|
3424
|
+
}
|
|
3425
|
+
if (unknown.length > 0) {
|
|
3426
|
+
const known = [...defsByName.keys()].join(", ");
|
|
3427
|
+
if (picked.length === 0) {
|
|
3428
|
+
log.warn(
|
|
3429
|
+
"no proxyTools entry was recognised; nothing will be proxied this turn",
|
|
3430
|
+
{ unknown, known }
|
|
3431
|
+
);
|
|
3432
|
+
} else {
|
|
3433
|
+
log.warn("ignoring unknown proxyTools entries", { unknown, known });
|
|
3434
|
+
}
|
|
3359
3435
|
}
|
|
3360
3436
|
return picked.length > 0 ? picked : null;
|
|
3361
3437
|
}
|
|
@@ -4453,10 +4529,11 @@ ${plan}
|
|
|
4453
4529
|
proxyServer = await self.ensureProxyServer(combinedProxyTools, sk);
|
|
4454
4530
|
}
|
|
4455
4531
|
const questionProxyActive = enrichedProxy?.some((t) => t.name === "question") ?? false;
|
|
4456
|
-
const
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4532
|
+
const allDisallowed = resolveDisallowedTools({
|
|
4533
|
+
proxyTools: enrichedProxy,
|
|
4534
|
+
extraDisallowedTools: self.config.extraDisallowedTools,
|
|
4535
|
+
disableWebSearch: self.config.webSearch === "disabled"
|
|
4536
|
+
});
|
|
4460
4537
|
const mcp = self.effectiveMcpConfig(
|
|
4461
4538
|
cwd,
|
|
4462
4539
|
proxyServer?.configPath(),
|
|
@@ -5707,11 +5784,11 @@ function defineModel(opts) {
|
|
|
5707
5784
|
variants: opts.reasoning ? reasoningVariants : void 0
|
|
5708
5785
|
};
|
|
5709
5786
|
}
|
|
5710
|
-
var haikuCost = { input:
|
|
5711
|
-
var sonnetCost = { input:
|
|
5712
|
-
var sonnet5Cost = { input:
|
|
5713
|
-
var opusCost = { input:
|
|
5714
|
-
var fableCost = { input:
|
|
5787
|
+
var haikuCost = { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 };
|
|
5788
|
+
var sonnetCost = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
|
|
5789
|
+
var sonnet5Cost = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
5790
|
+
var opusCost = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 };
|
|
5791
|
+
var fableCost = { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 };
|
|
5715
5792
|
function toConfigModel(model) {
|
|
5716
5793
|
const inputMods = [];
|
|
5717
5794
|
const outputMods = [];
|
|
@@ -6310,6 +6387,7 @@ function createClaudeCode(settings = {}) {
|
|
|
6310
6387
|
controlRequestToolBehaviors: settings.controlRequestToolBehaviors,
|
|
6311
6388
|
controlRequestDenyMessage: settings.controlRequestDenyMessage,
|
|
6312
6389
|
proxyTools,
|
|
6390
|
+
extraDisallowedTools: settings.extraDisallowedTools,
|
|
6313
6391
|
proxyToolTimeoutMs: settings.proxyToolTimeoutMs,
|
|
6314
6392
|
planModeQuestion: settings.planModeQuestion ?? false,
|
|
6315
6393
|
webSearch: settings.webSearch,
|