Visitar URL original
fix: correct V1/V2 MCP shape attribution, evidence-based V1 floor (v0… · bytesnail/opencode-secrets-env@37d915f · GitHub
Skip to content

Commit 37d915f

Browse files
committed
fix: correct V1/V2 MCP shape attribution, evidence-based V1 floor (v0.4.2)
- MCP config shape attribution was inverted: V1's canonical schema is flat mcp.<name>, V2's is nested mcp.servers.<name> (each host still accepts the other's form). Fixed across README (en/zh), rawconfig.ts, unit tests and e2e; the e2e now writes each host's actual canonical shape. - Raw-config scan precedence now lets a nested mcp.servers entry win over a flat one of the same name, matching V2 hosts (the only consumers of the scan; V1 has no MCP transform API). - engines.opencode floor moved from the arbitrary >=1.18.29 to >=1.14.34 — the oldest V1 release passing the real-host e2e harness (bisected: 1.14.33 fails, 1.14.34 passes; the mcp list in-process bootstrap refactor anomalyco/opencode#25521 first shipped there). The harness learned the legacy bin/opencode wrapper fallback so pre-.exe hosts can be probed. - Corrected the plugin/plugins key mechanism: plural "plugins" is V2's native form (written by "opencode plugin add"); on V1 it feeds the embedded next-gen loader whose context lacks location/mcp, so this plugin cannot run through it — the singular key is required there.
1 parent 6530426 commit 37d915f

8 files changed

Lines changed: 101 additions & 59 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,18 @@ OpenCode V2 (`@opencode/cli` 2.x):
2323
opencode plugin add opencode-secrets-env
2424
```
2525

26-
OpenCode V1 (`opencode-ai` 1.x, >= 1.18.29):
26+
OpenCode V1 (`opencode-ai` 1.x, >= 1.14.34):
2727

2828
```sh
2929
opencode plugin opencode-secrets-env
3030
```
3131

3232
Or add it manually to `~/.config/opencode/opencode.jsonc` (global) or a
3333
project's `opencode.jsonc`. The `plugin` key (singular) with string or
34-
`[name, options]` tuple entries works on both V1 and V2 hosts — the legacy
35-
`plugins` key with `{ "package", "options" }` objects is only understood by
36-
V2:
34+
`[name, options]` tuple entries is the one form both V1 and V2 hosts load.
35+
The plural `plugins` key — V2's native form, also what `opencode plugin add`
36+
writes — takes string or `{ "package", "options" }` object entries and does
37+
not work for this plugin on V1:
3738

3839
```jsonc
3940
{
@@ -83,9 +84,10 @@ Reference the variables from your OpenCode configuration:
8384
}
8485
```
8586

86-
(MCP servers nest under `mcp.servers.<name>` on V1 hosts, as shown. V2's
87-
canonical schema flattens this to `mcp.<name>` — V2 still accepts the nested
88-
legacy form, and this plugin scans both shapes.)
87+
(Nesting under `mcp.servers.<name>`, as shown, is V2's canonical schema; V2
88+
still accepts the flat legacy `mcp.<name>` form. V1 hosts canonically define
89+
servers directly at `mcp.<name>` and likewise accept the nested form shown.
90+
This plugin scans both shapes.)
8991

9092
Restart OpenCode so the plugin (re)injects the variables — on V2 that is the
9193
background service:
@@ -196,7 +198,7 @@ Only counts and key *names* are ever logged — never secret values.
196198
## Notes & security
197199

198200
- **Ordering**: put `opencode-secrets-env` before other plugins in the
199-
`plugins` array. Plugins run their `setup()` in order, and only later
201+
`plugin` array. Plugins run their `setup()` in order, and only later
200202
plugins will see the injected variables.
201203
- **Reloading**: with `watch` on (default), edits to `secrets.env` apply
202204
automatically within a second. With `watch: false`, restart OpenCode after
@@ -209,7 +211,7 @@ Only counts and key *names* are ever logged — never secret values.
209211
- **Local MCP servers inherit the full environment** of the OpenCode process,
210212
including every injected secret — that is the point of this plugin, but only
211213
run MCP servers you trust.
212-
- **OpenCode V1**: the package also exports a V1 (`>= 1.18.29`) entry point.
214+
- **OpenCode V1**: the package also exports a V1 (`>= 1.14.34`) entry point.
213215
V1 hosts have no MCP transform API, so env substitution into MCP configs and
214216
MCP reconnection are skipped; secret injection, `options`, hot reload and
215217
clean withdrawal on shutdown all work. V2 is the primary target.
@@ -228,8 +230,8 @@ npm run test:e2e:v2 # only against @opencode/cli (V2 host)
228230
The package is published as TypeScript source (OpenCode loads plugins
229231
directly), so there is no build step. The plugin entry point is `index.ts` at
230232
the package root; `env.ts` holds the pure loading logic and `rawconfig.ts`
231-
the raw-config scanning (both the V1 `mcp.servers.<name>` and the V2 flat
232-
`mcp.<name>` shapes). Everything is unit tested with `node --test`,
233+
the raw-config scanning (both the flat V1 `mcp.<name>` and the nested V2
234+
`mcp.servers.<name>` shapes). Everything is unit tested with `node --test`,
233235
including an end-to-end pass through the V1 entry point and a V2 pass
234236
through `setup()` with a fake MCP domain covering env-ref substitution and
235237
precise reconnection.
@@ -242,6 +244,14 @@ plugin load, secrets injection, a stub MCP server spawned with the values,
242244
hot reload and (on V2) the reconnect cycle. Host versions are pinned at the
243245
top of the file; bump them deliberately.
244246

247+
The pins track the latest stable hosts. The `engines.opencode` floor in
248+
`package.json` is the oldest V1 release passing the harness — 1.14.34, where
249+
the `mcp list` in-process bootstrap refactor (sst/opencode#25521) first
250+
shipped; older V1 releases inject and hot-reload fine but do not load
251+
plugins on the `mcp list` path, so MCP processes spawned there miss the
252+
injected variables. Re-probe with
253+
`OPENCODE_E2E_V1_SPEC=opencode-ai@<version> node test/e2e/run.mjs --host v1`.
254+
245255
To load a local checkout while developing, reference the directory:
246256

247257
```jsonc

‎README.zh-CN.md‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@ OpenCode V2(`@opencode/cli` 2.x):
1616
opencode plugin add opencode-secrets-env
1717
```
1818

19-
OpenCode V1(`opencode-ai` 1.x,>= 1.18.29):
19+
OpenCode V1(`opencode-ai` 1.x,>= 1.14.34):
2020

2121
```sh
2222
opencode plugin opencode-secrets-env
2323
```
2424

2525
或在 `~/.config/opencode/opencode.jsonc` 中配置。`plugin` 键(单数)配合
26-
字符串或 `[名字, 选项]` 元组形式在 V1/V2 上都能工作;旧式 `plugins` 键
27-
(复数)加 `{ "package", "options" }` 对象只有 V2 能识别。建议把本插件放
28-
在数组**最前面**,这样后面的插件在 `setup()` 时就能读到变量:
26+
字符串或 `[名字, 选项]` 元组形式是 V1/V2 都能加载的写法;复数
27+
`plugins` 键(V2 的原生形式,也是 `opencode plugin add` 写入的形式)接受
28+
字符串或 `{ "package", "options" }` 对象,但 V1 宿主无法通过它运行本
29+
插件。建议把本插件放在数组**最前面**,这样后面的插件在 `setup()` 时就能
30+
读到变量:
2931

3032
```jsonc
3133
{
@@ -43,9 +45,9 @@ chmod 600 ~/.config/opencode/secrets.env
4345
opencode service restart # V2;V1 请重启 TUI / server 进程
4446
```
4547

46-
在 MCP 配置中引用(V1 宿主按下图嵌套在 `mcp.servers.<名字>` 下;V2 的
47-
新 schema 扁平化为 `mcp.<名字>`,同时仍兼容嵌套旧写法,本插件两种形状
48-
都会扫描):
48+
在 MCP 配置中引用(如下图嵌套在 `mcp.servers.<名字>` 下是 V2 的规范
49+
写法,V2 仍兼容扁平的旧式 `mcp.<名字>`;V1 宿主的规范写法是扁平的
50+
`mcp.<名字>`,也同样兼容下图的嵌套形式。本插件两种形状都会扫描):
4951

5052
```jsonc
5153
{
@@ -127,11 +129,17 @@ MCP 服务器是长生命周期子进程,环境变量在启动时确定,因
127129
- 开发调试:`npm test` 跑单元测试;`npm run test:e2e` 会用 `npm pack`
128130
打包当前代码,在隔离环境中真实安装并启动两代宿主(v1 = `opencode-ai`,
129131
v2 = `@opencode/cli`),验证注入、热更新与 MCP 重连全链路。
132+
- `package.json` 的 `engines.opencode` 下限是通过该 e2e 的最旧 V1 版本
133+
(1.14.34,即 `mcp list` 进程内引导重构 sst/opencode#25521 首次发布的
134+
版本);更早的 V1 版本注入与热更新正常,但 `mcp list` 路径不会加载
135+
插件,经该路径启动的 MCP 进程拿不到注入的变量。可用
136+
`OPENCODE_E2E_V1_SPEC=opencode-ai@<版本> node test/e2e/run.mjs --host v1`
137+
重新探测。
130138
- 请保持文件权限为 `600`;权限过宽时插件会发出警告。
131139
- 插件**不会**自动读取项目目录里的 env 文件,防止恶意仓库投毒。
132140
- 本地 MCP 服务器会继承 OpenCode 进程的完整环境变量(包含注入的全部
133141
密钥),请只运行可信的 MCP 服务器。
134-
- **OpenCode V1**:包同时提供 V1(`>= 1.18.29`)入口。V1 宿主没有 MCP
142+
- **OpenCode V1**:包同时提供 V1(`>= 1.14.34`)入口。V1 宿主没有 MCP
135143
transform API,因此 MCP 配置内的 `{env:...}` 重替换与 MCP 重连不可用;
136144
密钥注入、插件选项、热更新与关闭时的清理均正常。V2 是主要目标。
137145

‎index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export default {
460460
},
461461
}),
462462

463-
// OpenCode V1 (>= 1.18.29) entry point. The host calls server(input,
463+
// OpenCode V1 (>= 1.14.34) entry point. The host calls server(input,
464464
// options) and later invokes the returned `dispose` hook on shutdown. V1
465465
// has no MCP transform API, so env substitution and MCP reconnection are
466466
// skipped; injection, hot reload and every other option still apply.

‎package-lock.json‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/package.json",
33
"name": "opencode-secrets-env",
4-
"version": "0.4.1",
4+
"version": "0.4.2",
55
"description": "OpenCode plugin that injects secrets from ~/.config/opencode/secrets.env into process.env at startup, so MCP servers and other plugins can reference them with {env:...}",
66
"type": "module",
77
"exports": {
@@ -41,7 +41,7 @@
4141
"homepage": "https://github.com/bytesnail/opencode-secrets-env#readme",
4242
"engines": {
4343
"node": ">=22.18",
44-
"opencode": ">=1.18.29"
44+
"opencode": ">=1.14.34"
4545
},
4646
"dependencies": {
4747
"@opencode/plugin": "^2.0.0",

‎rawconfig.ts‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -185,33 +185,38 @@ export function setAtPath(target: unknown, path: readonly (string | number)[], v
185185
/**
186186
* Extract MCP server references from one parsed config document.
187187
*
188-
* Two shapes exist in the wild: OpenCode V1 nests server definitions under
189-
* `mcp.servers.<name>`, while the V2 schema puts them directly at
190-
* `mcp.<name>` (V2 still migrates the nested legacy form, so both must be
191-
* scanned). Within one file a flat definition wins over a legacy one with
192-
* the same name; the `servers` key itself is reserved as the legacy
193-
* container and is never a server name.
188+
* Two shapes exist in the wild: OpenCode V1's schema defines servers
189+
* directly at `mcp.<name>` (and accepts the nested `mcp.servers.<name>`
190+
* envelope for V2-style configs), while V2's canonical schema nests them
191+
* under `mcp.servers.<name>` (and still migrates flat legacy entries), so
192+
* both shapes must be scanned. Within one file a nested entry wins over a
193+
* flat one with the same name — matching the V2 hosts, the only place
194+
* these references are consumed (V1 has no MCP transform API). The
195+
* `servers` key itself is reserved as the V2 container and is never a
196+
* server name.
194197
*/
195198
function refsFromConfig(config: unknown, into: ServerReferences): void {
196199
if (config === null || typeof config !== "object") return
197200
const mcp = (config as Record<string, unknown>).mcp
198201
if (mcp === null || typeof mcp !== "object") return
199202
const container = mcp as Record<string, unknown>
200203

204+
// Nested (V2 canonical) entries first: on V2 a nested entry overrides a
205+
// flat legacy one with the same name, so it must claim the name here too.
206+
const nested = container.servers
207+
if (nested !== null && typeof nested === "object") {
208+
for (const [name, definition] of Object.entries(nested as Record<string, unknown>)) {
209+
collectServerRefs(name, definition, into)
210+
}
211+
}
201212
for (const [name, definition] of Object.entries(container)) {
202213
if (name === "servers") continue
203214
collectServerRefs(name, definition, into)
204215
}
205-
const legacy = container.servers
206-
if (legacy !== null && typeof legacy === "object") {
207-
for (const [name, definition] of Object.entries(legacy as Record<string, unknown>)) {
208-
collectServerRefs(name, definition, into)
209-
}
210-
}
211216
}
212217

213218
function collectServerRefs(name: string, definition: unknown, into: ServerReferences): void {
214-
if (into.has(name)) return // higher-precedence file or flat entry already claimed it
219+
if (into.has(name)) return // higher-precedence file or nested entry already claimed it
215220
const refs: EnvReference[] = []
216221
collectRefs(definition, [], refs)
217222
if (refs.length > 0) into.set(name, refs)

‎test/e2e/run.mjs‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
//
1010
// Host versions are pinned for reproducibility; bump them deliberately.
1111
// OPENCODE_E2E_V1_SPEC / OPENCODE_E2E_V2_SPEC override (e.g. to probe a newer
12-
// release before adopting it).
12+
// release before adopting it, or an older one before lowering a floor).
13+
// The pins track the latest stable hosts; package.json's engines.opencode
14+
// floor is the oldest V1 release passing this harness (see the README's
15+
// Development section).
1316

1417
import { execFile, spawn } from "node:child_process"
1518
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
@@ -115,10 +118,14 @@ const hostRoot = join(workdir, "host")
115118
// must stay enabled or the bin is a stub that only prints an error.
116119
await npm(["install", "--prefix", hostRoot, "--loglevel=error", HOST_SPECS[host]])
117120
const hostPkg = host === "v1" ? "opencode-ai" : "@opencode/cli"
118-
// bin/opencode.exe is the REAL binary on every platform after postinstall
121+
const hostBinDir = join(hostRoot, "node_modules", ...hostPkg.split("/"), "bin")
122+
// Current hosts link the REAL binary to bin/opencode.exe on every platform
119123
// (the .bin/opencode shim is a .cmd on Windows; the exe path avoids it).
120-
const bin = join(hostRoot, "node_modules", ...hostPkg.split("/"), "bin", "opencode.exe")
121-
check("host binary exists", existsSync(bin), bin)
124+
// Older V1 hosts (<= ~1.15) instead ship a node wrapper at bin/opencode that
125+
// spawns the platform binary; it works on POSIX (on Windows a wrapper script
126+
// cannot be execFile'd, so probing pre-.exe hosts is POSIX-only).
127+
const bin = ["opencode.exe", "opencode"].map((name) => join(hostBinDir, name)).find((candidate) => existsSync(candidate))
128+
check("host binary exists", bin !== undefined, hostBinDir)
122129

123130
// ---------------------------------------------------------------------------
124131
// Isolated environment + real configuration
@@ -157,21 +164,28 @@ function writeSecrets(value) {
157164
writeSecrets(VALUE_ONE)
158165

159166
// Plugin entry: "plugin" with [name, options] tuples is the one form that
160-
// both host generations understand (V1 rejects {package, options} objects
161-
// and silently ignores the plural "plugins" key).
167+
// both host generations load the same way. V1's config schema decodes the
168+
// singular "plugin" key only, as string | [name, options] — {package,
169+
// options} objects are rejected there. V2 additionally accepts the plural
170+
// "plugins" key with {package, options} objects (its `plugin add` command
171+
// writes that form). On V1 the plural key feeds the embedded next-gen
172+
// loader, whose plugin context has no location/mcp — this plugin cannot
173+
// run through it, so the singular key is required on V1.
162174
writeFileSync(
163175
join(xdgConfig, "opencode", "opencode.jsonc"),
164176
JSON.stringify({ autoupdate: false, plugin: [[pluginDir, { debug: true }]] }, null, 2),
165177
)
166178

167-
// MCP server config in each host's canonical shape: V1 nests under
168-
// mcp.servers.<name>, V2 flattens to mcp.<name>.
179+
// MCP server config in each host's canonical shape: V1 defines servers
180+
// directly at mcp.<name>, V2 nests them under mcp.servers.<name>. Each host
181+
// still accepts the other's form (the unit tests cover the cross-shape
182+
// scanning); the plugin scans both.
169183
const stubServer = {
170184
type: "local",
171185
command: [process.execPath, stubPath],
172186
environment: { STUB_SUBSTITUTED: `{env:${SECRET_KEY}}` },
173187
}
174-
const mcpConfig = host === "v1" ? { mcp: { servers: { stub: stubServer } } } : { mcp: { stub: stubServer } }
188+
const mcpConfig = host === "v1" ? { mcp: { stub: stubServer } } : { mcp: { servers: { stub: stubServer } } }
175189
writeFileSync(join(project, "opencode.jsonc"), JSON.stringify(mcpConfig, null, 2))
176190

177191
const port = 20000 + Math.floor(Math.random() * 25000)

‎test/rawconfig.test.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,11 @@ test("scanServerEnvRefs reads servers defined in the legacy global config.json",
188188
})
189189
})
190190

191-
// OpenCode V2's canonical schema puts server definitions directly at
192-
// mcp.<name> instead of nesting them under mcp.servers.<name>.
191+
// OpenCode V1's canonical schema defines servers directly at mcp.<name>;
192+
// V2 nests them under mcp.servers.<name> and migrates the flat form as
193+
// legacy. Both shapes must scan.
193194

194-
test("scanServerEnvRefs reads the flat V2 mcp shape", () => {
195+
test("scanServerEnvRefs reads the flat V1 mcp shape", () => {
195196
withTempDir((dir) => {
196197
writeFileSync(
197198
join(dir, "opencode.jsonc"),
@@ -202,26 +203,30 @@ test("scanServerEnvRefs reads the flat V2 mcp shape", () => {
202203
})
203204
})
204205

205-
test("scanServerEnvRefs finds both shapes in one file and lets the flat entry win", () => {
206+
// Within one file V2 (the only host generation consuming these references)
207+
// lets the nested mcp.servers entry override a flat legacy one of the same
208+
// name — the scanner must match that precedence.
209+
210+
test("scanServerEnvRefs finds both shapes in one file and lets the nested entry win", () => {
206211
withTempDir((dir) => {
207212
writeFileSync(
208213
join(dir, "opencode.jsonc"),
209214
`{
210215
"mcp": {
211-
"shared": { "type": "local", "command": ["run"], "environment": { "K": "{env:FLAT_WINS}" } },
212-
"legacy-only": { "type": "local", "command": ["run"], "environment": { "K": "{env:WRONG}" } },
216+
"shared": { "type": "local", "command": ["run"], "environment": { "K": "{env:FLAT_LOSES}" } },
217+
"flat-only": { "type": "local", "command": ["run"], "environment": { "K": "{env:WRONG}" } },
213218
"servers": {
214-
"shared": { "type": "local", "command": ["run"], "environment": { "K": "{env:LEGACY_LOSES}" } },
219+
"shared": { "type": "local", "command": ["run"], "environment": { "K": "{env:NESTED_WINS}" } },
215220
"nested-only": { "type": "local", "command": ["run"], "environment": { "K": "{env:NESTED_KEY}" } }
216221
}
217222
}
218223
}`,
219224
)
220225
const refs = scanServerEnvRefs(dir, { xdgConfigHome: join(dir, "xdg"), home: join(dir, "home") })
221-
assert.deepEqual([...refs.keys()].sort(), ["legacy-only", "nested-only", "shared"])
222-
assert.deepEqual(refs.get("shared"), [{ path: ["environment", "K"], template: "{env:FLAT_WINS}" }])
226+
assert.deepEqual([...refs.keys()].sort(), ["flat-only", "nested-only", "shared"])
227+
assert.deepEqual(refs.get("shared"), [{ path: ["environment", "K"], template: "{env:NESTED_WINS}" }])
223228
assert.deepEqual(refs.get("nested-only"), [{ path: ["environment", "K"], template: "{env:NESTED_KEY}" }])
224-
assert.deepEqual(refs.get("legacy-only"), [{ path: ["environment", "K"], template: "{env:WRONG}" }])
229+
assert.deepEqual(refs.get("flat-only"), [{ path: ["environment", "K"], template: "{env:WRONG}" }])
225230
})
226231
})
227232

@@ -241,11 +246,11 @@ test("scanServerEnvRefs applies file precedence across shapes", () => {
241246
withTempDir((dir) => {
242247
const project = join(dir, "project")
243248
mkdirSync(project, { recursive: true })
244-
// Project file defines "s" with the legacy nested shape.
249+
// Project file defines "s" with the nested (V2) shape.
245250
writeFileSync(join(project, "opencode.jsonc"), `{ "mcp": { "servers": { "s": { "type": "local", "command": ["a"], "environment": { "K": "{env:PROJECT}" } } } } }`)
246251
const xdg = join(dir, "xdg")
247252
mkdirSync(join(xdg, "opencode"), { recursive: true })
248-
// Global file defines the same server with the flat shape — must lose.
253+
// Global file defines the same server with the flat (V1) shape — must lose.
249254
writeFileSync(join(xdg, "opencode", "opencode.jsonc"), `{ "mcp": { "s": { "type": "local", "command": ["a"], "environment": { "K": "{env:GLOBAL}" } } } }`)
250255

251256
const refs = scanServerEnvRefs(project, { xdgConfigHome: xdg, home: join(dir, "home") })

0 commit comments

Comments
 (0)