diff --git a/AGENTS.md b/AGENTS.md index a66fce0..a5d626e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +33,9 @@ src/proxy.ts # createApp/startProxy (1149 lines; god file, see §9) src/config/proxy-config.ts # buildProxyConfig + shared pure normalizers (bool/int/port/url/list/enum) src/routes/ # chat.ts (1213) / responses.ts (1798) / messages.ts (979) / interactions.ts (533) / system.ts src/tool-runtime/ # contracts / registry / router / parser (1934) / validator / policy -src/backend/manager.ts # backend lifecycle + request lock/queue (601 lines) -src/backend/active-model.ts # ensureActiveModel change-only PUT per client + reset on respawn, pure (45 lines) -src/backend/backend-permission.ts # buildBackendPermission deny-all generator, single source for entrypoint.sh + manager.ts isolated path, pure (97 lines) +src/backend/manager.ts # backend lifecycle + request lock/queue (608 lines) +src/backend/active-model.ts # ensureActiveModel change-only PUT per client + reset on respawn, pure (56 lines) +src/backend/backend-permission.ts # buildBackendPermission deny-all generator + inert ordered `read` free-tier gate (NUL sentinel), single source for entrypoint.sh + manager.ts isolated path, pure (125 lines) src/stream/collector.ts # prompt/poll/collect SSE pipeline src/retry/policy.ts # pure retry helpers (resolveMaxRetries, backoff+jitter) src/errors/upstream.ts # normalizeBackendError, transformUpstreamError, isTransient* @@ -75,7 +75,7 @@ config.json.example / .env.example # examples only, never real secrets | Auth multi-key (A) | `OPENCODE_API_KEYS` canonical, `API_KEYS` legacy alias (merge; empty never blocks) | `API_KEYS` | `(none, merges with API_KEY)` | | Fallback proxies | `OPENCODE_UPSTREAM_PROXIES` / `OPENCODE_UPSTREAM_PROXY_STRATEGY` / `OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS` / `OPENCODE_UPSTREAM_PROXY_NO_PROXY` (legacy raw env `UPSTREAM_*` merged in **both** entries) | `UPSTREAM_*` short forms | `(none)` / `failover-rr` / `300000` / `localhost,127.0.0.1,::1` | -- `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:76-86`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper. Note: `true` still resolves `true` — the free-tier strip (`selectPromptToolOverrides`, `src/proxy.ts:75-91`) is a post-resolution prompt-body filter for `isFreeTierSuspectModel` only (guard text + output markup stripping stay active). +- `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:76-86`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper. Note: `true` still resolves `true` — the free-tier strip (`selectPromptToolOverrides`, `src/proxy.ts:75-91`) is a post-resolution prompt-body filter for `isFreeTierSuspectModel` only (guard text + output markup stripping stay active). The default backend `read` permission is an inert ordered gate (see §5), so free-tier requests still carry `read`; explicit allowlist `read` remains a real allow. - Out-of-matrix backend identity (do NOT add to the six-way matrix): `OPENCODE_CLIENT` is read directly in `src/backend/manager.ts:41-45` (allowlist `cli/desktop/acp/app`, default `cli`, foreign values overwritten), never enters `ProxyConfig`/`buildProxyConfig`; documented in `docs/configuration.md` backend-identity subsection + `CHANGELOG.md`. Same for the `OPENCODE_API_KEY="public"` strip and best-effort jail `git init`. - `RETRY`: strict-merged by `resolveRetryCountSetting` (options > `OPENCODE_PROXY_RETRY_MAX_RETRIES` > legacy `RETRY_MAX_RETRIES` > file > `3`; partial numerics like `3abc`/`2.9` are invalid and fall through — never `parseInt`-truncated), then clamped by `resolveMaxRetries` (`src/retry/policy.ts:51-58`, clamp 0–5, total attempts `1+n`, `2s×2ⁿ⁻¹` + 25% jitter, `retry-after` clamped to 30s). Covered by `tests/env-alias.test.js` + `tests/retry-policy.test.js`. - **Single merge contract:** every key goes through the pure resolvers in `src/config/proxy-config.ts` (`resolveStringSetting` / `resolveBoolSetting` / `resolveIntSetting` / `resolvePortSetting` / `resolveDurationSetting` / `resolveRetryCountSetting` / `resolveListSetting` / `resolveEnumSetting` / `resolveUrlSetting`, plus parsers `parseStrictInteger` / `parseStrictPort` / `parseHttpUrl` / `parseToolNameList`). `index.ts` and `buildProxyConfig` share them, so never re-implement a local `parseBool`/`parsePort`/list parser in either entry — add a resolver there instead. Recognized bool tokens are exactly `1/true/yes/y/on` and `0/false/no/n/off` (case-insensitive, trimmed); everything else is *unset* and falls through (no `Boolean()` coercion). @@ -87,6 +87,7 @@ config.json.example / .env.example # examples only, never real secrets - `DISABLE_TOOLS=true` default. External client `tools` go through the proxy bridge and are namespaced (`external__*`, never registered as OpenCode built-ins). - Internal allowlist applies **only when the request carries no `tools`**; empty/unmatched allowlist falls back to all-builtins-disabled (safe). Request-level `opencode.internal_allowed_tools` can only narrow: `effective = server ∩ request`. - `OPENCODE_INTERNAL_WEB_FETCH_ENABLED=true` is a legacy shortcut for allowlist `web_fetch` when no explicit allowlist is configured. +- Backend `read` permission default is an **inert ordered gate**, not plain `deny`/`allow`: `{ '*': 'deny', [FREE_TIER_READ_GATE_PATH]: 'allow' }` with `FREE_TIER_READ_GATE_PATH = '/__opencode_free_tier_gate_never__\u0000'` (`src/backend/backend-permission.ts`). OpenCode `Permission.disabled()` selects the last rule by **tool name** (`findLast`) then checks whether that rule's pattern is exactly `*`; the trailing non-`*` NUL sentinel allow keeps `read` in the upstream `tools[]` (Zen free tier otherwise returns 403 `FreeTierError`). Execution-time path matching still denies every real path via the leading `*: deny`, and the terminal NUL means no real path can match the sentinel even after `Wildcard.match` backslash normalization. This is an upstream gate token, not execution permission — explicit allowlist `read` still emits a real `'allow'`, and `bash` stays `deny` by default. Do not "simplify" it to `deny` (re-breaks free tier) or `allow` (real read execution). - Never log tool outputs; `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` logs mode/hits/downgrade reasons only. - Prod posture: `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=true`, `OPENCODE_METRICS_ENABLED=false`. @@ -109,6 +110,8 @@ config.json.example / .env.example # examples only, never real secrets - `tsconfig.json` includes only `index.ts` + `src/**/*` (tests excluded): `tsc` will NOT catch test-side API misuse. `tests/*.test.js` are JS run through `@swc/jest` (`jest.config.cjs`) importing `../src/*.js` paths mapped to `.ts`. Keep `import { jest } from '@jest/globals'` + `jest.unstable_mockModule` + top-level `await import(...)` patterns as-is (ESM order-sensitive). - `tests/env-alias.test.js` mirrors the `index.ts` merge (it cannot import `index.ts`, which boots the server): the `DISABLE_TOOLS` chain plus the port/url/bool/policy-list/retry/upstream-strategy layer arrays. Update the mirrors when a layer array changes; the shared resolvers themselves are imported from `src/config/proxy-config.ts` and tested directly. +- `tests/free-tier-gate.test.js` locks the cross-module free-tier contract: `selectPromptToolOverrides` drops every `false` entry (all-false maps omitted) for suspect models, and `buildBackendPermission` keeps `read` visible via the ordered NUL-sentinel gate while the leading `*: deny` keeps real paths denied. This is a data-level contract only — source/live verification is still required when bumping the backend. Update both halves together — either regression re-breaks Zen free tier. +- `tests/dockerfile-pin.test.js` locks `ARG OPENCODE_VERSION=1.18.35` (build-only, not a runtime `ENV`). Bumping the pin requires updating the Dockerfile `ARG OPENCODE_VERSION`, `PINNED_VERSION`, and current version references, re-running `tests/free-tier-gate.test.js`, `tests/backend-permission.test.js`, and `tests/dockerfile-pin.test.js`, checking the target version's source for `Permission.disabled()` findLast semantics (last rule by tool name, pattern exactly `*`), plus a live Zen check that `read` stays in upstream `tools[]`; see `docs/docker.md`. - `tests/test-integration.sh` and `tests/test-streaming-real.sh` need a real backend/credentials and are not CI gates; do not "fix" them into the default `npm test` path. - `custom-bin/opencode` is a local-only binary; `Dockerfile` intentionally has no `custom-bin` logic (stock `npm i -g opencode-ai`). Do not reintroduce arch-specific binary coupling without an explicit request. @@ -117,7 +120,7 @@ config.json.example / .env.example # examples only, never real secrets - `src/proxy.ts:createApp` is a god closure (~800 lines) assembling `AppContext` (50+ fields, `src/types/context.ts`); the four routes (`chat`/`responses`/`messages`/`interactions`, ~3200 lines combined) duplicate the preflight → stream/non-stream → retry → usage/cleanup template. Prefer **pure moves + thin wrappers** over behavior changes. - Shared unknown-guards (`asRecord`, `toErrorMessage`) live in `src/utils/guards.ts` — import from there, do not add new copies. Exception: `src/routes/system.ts` and `src/stream/collector.ts` keep their own narrower `toErrorMessage` variant (see `guards.ts` header); do not "unify" it. - Pure layers to preserve: `src/retry/policy.ts`, `src/converters/anthropic.ts`, `src/errors/upstream.ts` (no Express/SDK deps). Route error exits must keep their wire shapes: chat non-stream JSON, responses-stream `response.failed` SSE + `[DONE]`, messages-stream `error` event without `[DONE]`, interactions-stream `interaction.completed` / `error` event without `[DONE]`. -- High-risk no-touch list without explicit approval + regression tests: timeout/retry constants, `transformUpstreamError` mappings, `isTransientUpstreamError` matchers, parser ambiguity policy, prompt guard text/order, model alias rules, policy evaluation order, backend spawn/jail/HOME isolation, collector `finish==='tool'`/idle-exemption logic, bool-fallthrough semantics. +- High-risk no-touch list without explicit approval + regression tests: timeout/retry constants, `transformUpstreamError` mappings, `isTransientUpstreamError` matchers, parser ambiguity policy, prompt guard text/order, model alias rules, policy evaluation order, backend permission gate ordering/sentinel, backend spawn/jail/HOME isolation, collector `finish==='tool'`/idle-exemption logic, bool-fallthrough semantics. ## 10. Commit convention & PR checklist diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a91e8c..2d6e221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Responses 示例模型**:`README`、`docs/api-reference.md`、`docs/getting-started.md` 的 `/v1/responses` 示例统一为 `opencode/muse-spark-1.3-contributor-free`(解析逻辑无需改动,裸名与带前缀均兼容)。 - **SDK 对齐**:`@opencode-ai/sdk ^1.1.51` → `^1.18.29`(与本地 server `1.18.29` 对齐;v1 调用形状不变,零改码;139 tests 全绿;`cross-spawn@7.0.6` 为 SDK 新增 prod 依赖,既有包去 dev 标记,无新版本;`tool.ids` 仍走上游 experimental 路径,后续跟进)。 - **Unified env/file/default config merge (Phase 3A)**: `index.ts` and `buildProxyConfig` now share the strict resolvers in `src/config/proxy-config.ts` (`resolve*Setting`, `parseStrictInteger/Port`, `parseHttpUrl`, `parseToolNameList`); lookup stays env canonical > env legacy alias > file short key > hardcoded default with invalid values falling through. Library `resolveDisableTools` default is now `true` (was `false`), matching prod/docs; library consumers relying on the old tools-enabled default must now set `DISABLE_TOOLS=false` explicitly. Bool tokens are `1/true/yes/y/on` vs `0/false/no/n/off` everywhere. Docker/Compose keep empty defaults so code owns values; `entrypoint.sh` validates `OPENCODE_PROXY_PORT`/`PORT` and `OPENCODE_SERVER_PORT` against `1..65535` and unsets invalid proxy ports so `config.json` still applies. +- **Docker backend pin (`opencode-ai@1.18.35`)**: the runtime image now installs `opencode-ai@${OPENCODE_VERSION}` from `ARG OPENCODE_VERSION=1.18.35` (build-only ARG, never a runtime `ENV`), keeping the Zen free-tier `read` gate effective because it depends on the pinned backend's `Permission.disabled()` findLast semantics. Bump checklist (update the Dockerfile `ARG OPENCODE_VERSION`, `PINNED_VERSION`, and current version references, rerun the three gate tests, check the target source's `Permission.disabled()` findLast/pattern-`*` semantics, live Zen `tools[]` check) lives in `docs/docker.md`; no runtime config-matrix change. 镜像后端固定 `opencode-ai@1.18.35`(仅构建 ARG,不作为运行时 `ENV`);升级时按 `docs/docker.md` 清单同步更新 Dockerfile `ARG OPENCODE_VERSION`、`PINNED_VERSION` 及当前版本引用、重跑三个闸门测试、检查目标版本源码的 findLast 语义并做真实 Zen `tools[]` 验证,无运行时配置矩阵变化。 ### Added @@ -32,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Zen free-tier `read` gate (`FreeTierError` 403)**: the default backend `read` permission is now an inert ordered gate (`{ '*': 'deny', [FREE_TIER_READ_GATE_PATH]: 'allow' }`, NUL-terminated sentinel) instead of plain `deny`, so OpenCode `Permission.disabled()`'s findLast-by-tool-name check keeps `read` in the upstream `tools[]` (the free tier requires it) while execution-time path matching still denies every real path; the NUL sentinel closes the `Wildcard.match` backslash-normalization bypass, `bash` remains `deny`, and explicit allowlist `read` still emits a real `allow`. The data-level contract is locked by `tests/free-tier-gate.test.js` + `tests/backend-permission.test.js`; source/live verification is still required when bumping the backend pin because unit tests cannot execute OpenCode's installed backend. 默认后端 `read` 权限改为有序惰性闸门(NUL 哨兵 allow 让 `read` 保留在上游 `tools[]`),真实路径仍全部拒绝,`bash` 默认仍为 deny,显式 allowlist `read` 仍是真 allow;该数据层契约由 `tests/free-tier-gate.test.js` 与 `tests/backend-permission.test.js` 锁定,升级后端固定版本时仍需源码与真实 Zen 验证(单测无法执行镜像内安装的后端)。 - **Backend tool-hang + per-request dispose fixes**: the proxy no longer `PUT config {activeModel}` on every request — native `ConfigHttpApi.update` disposes the whole backend project instance unconditionally (no changed-check), aborting in-flight sessions into the `Aborted` retry-loop storm; `ensureActiveModel` (`src/backend/active-model.ts`) PUTs only on actual model change per backend client (failures never cached, existing catch semantics preserved). Separately, the headless backend can no longer `ask` (nobody approves, so prompts hung until the 180s timeout) nor silently execute unlisted tools (the free-tier strip omits the prompt tools map, so the backend fell back to agent defaults = all on): backend `opencode.json` now carries a generated deny-all `permission` map (`src/backend/backend-permission.ts`, single source for `entrypoint.sh` via `dist` with static fallback and `src/backend/manager.ts` isolated path) allowing only explicitly configured internal tools, with `external_directory` always jailed to `/home/node/project/**`. 后端不再每请求 dispose(模型变更才 PUT activeModel);headless 后端不再 `ask` 卡死、不再静默执行未授权工具(deny-all,仅放行明确配置的 internal 工具)。Locked by `tests/active-model.test.js` + `tests/backend-permission.test.js` + `tests/tool-protocol-parity.test.js`. - **Anonymous first-party backend posture + Zen free-tier gate handling**: spawned backend pins `OPENCODE_CLIENT` to a known first-party value (`cli` default; `desktop`/`acp`/`app` pass through), strips the `OPENCODE_API_KEY="public"` footgun (anonymous uses empty), and git-inits the jail workspace best-effort (5s timeout, warn-and-continue) as defense-in-depth for session-dir sensitivity. Prompt `tools` maps have `false` entries stripped (true-only sent; all-false omitted) only for likely-free `opencode` models (`-free` suffix, `big-pickle`, `union-alpha`) to dodge the `FreeTierError` 403 (any `false` gates); stripped tools fall back to agent defaults server-side while paid/other providers keep the exact hard-disable map. The gate (`...used from within OpenCode` / `FreeTierError`) now surfaces as 403 `permission_denied` fail-fast (no retry, no proxy fallback) with false-positive guards for plain quota text. Jail stdout no longer prints absolute paths; interactions route gains text-level tool-markup stripping parity with chat/responses/messages. New `tests/backend-identity.test.js` / `tests/first-party-gate.test.js` / `tests/prompt-tool-overrides.test.js` / `tests/session-ids.test.js`. No new proxy knob (no config matrix change). - **Translator parity fixes (N×N matrix review)**: chat array `content` parts no longer silently dropped (extract `text`/`content`); `responses` `incomplete` with `content_filter` reason round-trips instead of collapsing to `length` (non-stream + stream); `is_error` now round-trips via the `ERROR: ` text prefix on chat/messages/responses tool legs (strict `true`/`"true"` check; success text literally starting with `ERROR: ` round-trips as `is_error` by design); named `custom` tool declarations are kept so `custom_tool_call` invocations always have a matching declaration; ID generation centralized on `makeId(prefix)` (`toolu_` fallback separator canonicalized, prefix-only checks unaffected). N×N 翻译矩阵对齐修复。New `tests/translator-phase-fixes.test.js` (13 cases). diff --git a/Dockerfile b/Dockerfile index 8df375d..bdad248 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && gosu --version \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g opencode-ai +# Pin the bundled backend: the Zen free-tier read gate relies on the +# Permission.disabled() findLast semantics verified against this version. +# Bumping requires both proxy-side regression tests and a live check that the +# target version's Permission.disabled()/upstream tools[] behavior still holds. +ARG OPENCODE_VERSION=1.18.35 +RUN test -n "${OPENCODE_VERSION}" && npm install -g "opencode-ai@${OPENCODE_VERSION}" RUN mkdir -p /home/node/.local/share/opencode \ && mkdir -p /home/node/.config/opencode \ diff --git a/docs/configuration.md b/docs/configuration.md index fddcad9..3ff2d32 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -241,7 +241,10 @@ OpenCode2API 现在支持把外部客户端传入的 OpenAI-compatible `tools` - 默认 deny-all;只放行 `OPENCODE_INTERNAL_ALLOWED_TOOLS` 明确列出的工具(`web_fetch` 等别名归一;未知名忽略,不会写坏 schema)。 - 未显式配置 allowlist 时 `OPENCODE_INTERNAL_WEB_FETCH_ENABLED=true` 沿用旧兼容:放行 `webfetch`。 - `external_directory` 永远 jail 在 `/home/node/project/**`,之外一律 deny(快速失败,不会 hang;模型转而走外部桥接,由客户端执行并经用户确认)。 -- 空 allowlist = 后端零执行(与“全部内置禁用”安全模式一致)。 +- `read` 是默认规则中的惰性闸门例外:只要 allowlist 未显式放行 `read`(包括配置了其他 allowlist 时),它写成一个**有序惰性闸门对象** `{ "*": "deny", "/__opencode_free_tier_gate_never__\u0000": "allow" }`,而不是裸 `deny` 或 `allow`。OpenCode 的 `Permission.disabled()`(供 `resolveTools()` 使用)先按**工具名**对规则做 `findLast`,再检查选中规则的 pattern 是否为 `*`;最后一条非 `*` 的 NUL 哨兵 allow 让按工具名的检查不隐藏 `read`,因此 Zen 免费层要求的上游 `tools[]` 中仍包含 `read`(否则返回 403 `FreeTierError`)。执行期的路径匹配是另一套逻辑:真实路径只会命中开头的 `*: deny`,所以仍然全部拒绝。哨兵以 NUL 结尾,真实文件路径不可能包含 NUL,因此即使 OpenCode 的 `Wildcard.match` 把反斜杠归一成斜杠,也不会有真实路径命中哨兵。该哨兵只是上游免费层闸门 token,**不是执行权限**;显式在 allowlist 中写 `read` 才会恢复真正的 `allow`。 +- `bash` 默认仍是 `deny`;不要为了“通过闸门”把 `read` 或 `bash` 设为 `allow`(前者打开真实后端读取,后者打开命令执行)。 +- 空 allowlist 且未启用 `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` 兼容开关时 = 后端零真实执行;启用该开关时仅额外放行 `webfetch`。`read` 仅为免费层闸门保持可见,**并未放行**(真实路径仍被拒绝)。 +- 客户端自带 `tools` 时仍走外部桥接(`external__*`),这些工具从不会注册成后端内置工具,因此上述 `read` 闸门不影响客户端侧的工具执行。 ### 请求级 allowlist 覆盖 (Request-Level Override) diff --git a/docs/docker.md b/docs/docker.md index 18b10ae..5765ce2 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -78,6 +78,46 @@ OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=true docker build -t my-opencode2api . ``` +### 固定 OpenCode 后端版本 + +镜像默认安装 `opencode-ai@1.18.35`(由 Dockerfile 的 `ARG OPENCODE_VERSION` 控制)。这个版本是有意固定的:Zen 免费层的 `read` 闸门依赖 OpenCode `Permission.disabled()` 的 findLast 语义——它先按工具名选出最后一条规则,再检查该规则的 pattern 是否为 `*`。不指定版本的 npm `latest`(例如 `npm install -g opencode-ai`)可能悄悄改变该行为,让免费层 403 修复重新失效。需要 Zen 免费层修复时,不要使用不指定版本安装的镜像。 + +构建时覆盖版本: + +```bash +docker build --build-arg OPENCODE_VERSION=1.18.35 -t my-opencode2api . +``` + +`docker compose build` 默认使用 Dockerfile 里的版本;要覆盖需在 `docker-compose.yml`(或 override 文件)的 `build.args` 中传 `OPENCODE_VERSION`: + +```yaml +services: + opencode2api: + build: + args: + OPENCODE_VERSION: "1.18.35" +``` + +也可以一次性覆盖(无需改 compose 文件): + +```bash +docker compose build --build-arg OPENCODE_VERSION=1.18.35 opencode2api +``` + +修改 `build.args` 不会让 `docker compose up -d` 自动重建镜像;需要先构建再强制重建容器: + +```bash +docker compose build opencode2api +docker compose up -d --force-recreate +``` + +升级固定版本时,代理侧单测只能保护代理侧契约,无法执行镜像里安装的后端。合并前必须完成: + +1. 同步更新 `Dockerfile` 的 `ARG OPENCODE_VERSION`、`tests/dockerfile-pin.test.js` 的 `PINNED_VERSION`、本页所有版本字符串(含默认说明),以及 `docs/troubleshooting.md` / `AGENTS.md` / `CHANGELOG.md` 中当前 pin 的版本引用; +2. 重跑 `tests/free-tier-gate.test.js`、`tests/backend-permission.test.js` 与 `tests/dockerfile-pin.test.js`; +3. 检查目标版本源码,确认 `Permission.disabled()` 仍使用 `findLast`,且选择逻辑仍是“按工具名取最后一条规则,再判断该规则 pattern 是否为 `*`”; +4. 用真实后端与 Zen 请求验证 `read` 仍出现在上游 `tools[]` 中,然后再合并。 + ### 运行单个容器 ```bash diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7410ac1..5291488 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -118,7 +118,26 @@ curl -H "Authorization: Bearer YOUR_API_KEY" ... --- -### 9️⃣ 跨协议转换结果异常 +### 9️⃣ Zen 免费层 403(FreeTierError) + +| 项目 | 说明 | +|:-----|:-----| +| **症状** | 请求返回 HTTP `403`,客户端可见的 `type`/`code` 为 `permission_denied`;上游响应/日志中含 `FreeTierError`。上游消息为 `OpenCode's free tier can only be used from within OpenCode. Download it at https://opencode.ai and keep building.` | +| **原因** | 代理侧已知两类原因:(a) 上游请求 `tools[]` 缺少 `read`(headless deny-all 或旧版/未固定版本的后端会隐藏它);(b) 免费层模型的 prompt `tools` **map** 中出现任何 `false` 条目(代理会为疑似免费模型剥离这些条目,但自定义/旧路径可能漏掉)。若两者均已排除,403 也可能是上游 Zen 的账号资格/网络/出口 IP 拒绝,代理无法修复。 | + +排查/修复清单: + +1. 升级并重建到当前镜像(后端固定 `opencode-ai@1.18.35`)或当前源码;使用 compose 时执行 `docker compose build`,再 `docker compose up -d --force-recreate`(只改 `build.args` 不会让 `up -d` 自动重建)。 +2. 保持 `DISABLE_TOOLS=true`(默认)。不要仅为通过闸门把 `read` 加入 `OPENCODE_INTERNAL_ALLOWED_TOOLS`——那会授予真实的后端读取执行权限。 +3. 客户端工具按正常请求 `tools` 发送;代理会把它们桥接为 `external__*`,不会注册成后端内置工具。 +4. 默认容器每次启动都会重新生成 `/home/node/.config/opencode/opencode.json`(生成的 `permission` 就在其中);实际可能覆盖它的是项目级 `/home/node/project/config.json`。若该文件设置了冲突的 `permission.read` deny(字符串或对象形式),删除该 deny;**不要**把它改成 `"allow"`。重建后确认生成的 `read` 闸门仍在。 +5. 若使用自定义或未固定版本的后端,确认其 `Permission.disabled()` 仍使用 `findLast`,且 `read` 仍出现在上游 `tools[]` 中;版本升级检查见 [docs/docker.md](./docker.md) 的「固定 OpenCode 后端版本」。 + +> 该闸门只保证 `read` 出现在上游 `tools[]` 中,不保证 Zen 免费额度可用;额度限制仍可能以其他错误返回。 + +--- + +### 🔟 跨协议转换结果异常 | 项目 | 说明 | |:-----|:-----| diff --git a/entrypoint.sh b/entrypoint.sh index 8dcc60e..0d91670 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -68,8 +68,10 @@ import('./dist/src/backend/backend-permission.js').then((m) => { }).catch((e) => { console.error(String(e && e.message || e)); process.exit(1); });" 2>/dev/null) || BACKEND_PERM_JSON="" fi if [ -z "$BACKEND_PERM_JSON" ]; then - echo "[Config] Warning: backend permission generator unavailable; using static deny-all fallback" >&2 - BACKEND_PERM_JSON='{"read":"deny","edit":"deny","glob":"deny","grep":"deny","list":"deny","bash":"deny","task":"deny","todowrite":"deny","question":"deny","webfetch":"deny","websearch":"deny","lsp":"deny","doom_loop":"deny","skill":"deny","external_directory":{"/home/node/project/**":"allow","*":"deny"}}' + # Safety mirror of buildBackendPermission() with no allowlist; guarded + # against drift by tests/backend-permission.test.js. + echo "[Config] Warning: backend permission generator unavailable; using static deny-all-execution fallback" >&2 + BACKEND_PERM_JSON='{"read":{"*":"deny","/__opencode_free_tier_gate_never__\u0000":"allow"},"edit":"deny","glob":"deny","grep":"deny","list":"deny","bash":"deny","task":"deny","todowrite":"deny","question":"deny","webfetch":"deny","websearch":"deny","lsp":"deny","doom_loop":"deny","skill":"deny","external_directory":{"/home/node/project/**":"allow","*":"deny"}}' fi PLUGIN_JSON_LINE="" if [[ "${OPENCODE_PROXY_PROMPT_MODE:-standard}" == "plugin-inject" ]]; then diff --git a/src/backend/backend-permission.ts b/src/backend/backend-permission.ts index 7e6a1e7..3a33734 100644 --- a/src/backend/backend-permission.ts +++ b/src/backend/backend-permission.ts @@ -13,10 +13,36 @@ // jailed to the backend project dir so allowed file tools cannot escape it. // Unknown allowlist names are ignored (never emitted) to stay schema-valid // per https://opencode.ai/config.json (PermissionConfig keys). +// +// Free-tier gate: OpenCode's Permission.disabled() (used by resolveTools()) +// runs findLast over the permission rules, matching the TOOL NAME only, then +// checks whether that selected rule's pattern is `*` with action `deny`. A +// final `*: deny` therefore hides `read`; hiding it drops `read` from the +// upstream tools[] and Zen free tier answers 403 FreeTierError. `read` thus +// emits an ordered rule object ({ "*": deny, [FREE_TIER_READ_GATE_PATH]: +// allow }) whose selected (last) rule is the non-wildcard sentinel allow, so +// the tool-name check does not disable `read`. Execution-time path matching is +// separate: it evaluates the actual path, which matches only the leading +// `*: deny`, so every real path is denied. The sentinel is NUL-terminated: no +// real file path can contain NUL, so no real read path can match it, even +// after OpenCode's Wildcard.match normalizes backslashes to slashes (which +// would otherwise let a workspace file named +// `\__opencode_free_tier_gate_never__` collide with a bare absolute sentinel). +// The sentinel is an upstream free-tier gate token, NOT a real permission +// grant; explicit `read` opt-in still emits "allow". Do not "simplify" it +// back to "deny". /** Backend project dir inside the container (session working scope). */ export const BACKEND_PROJECT_DIR = '/home/node/project'; +/** NUL-terminated sentinel rule path for the free-tier `read` gate (see + * header). No real file path can contain NUL, so no actual read path can + * match it, including after OpenCode's Wildcard.match backslash-to-slash + * normalization. It only keeps `read` visible to OpenCode's findLast-based + * permission filter so the upstream tools[] carries `read`. This is not a + * permission grant. */ +export const FREE_TIER_READ_GATE_PATH = '/__opencode_free_tier_gate_never__\u0000'; + /** Schema-valid opencode permission keys (PermissionConfig). Flat-action keys * (todowrite/question/webfetch/websearch/doom_loop) take a bare * "allow"/"deny" string, never an object. */ @@ -82,6 +108,15 @@ export function buildBackendPermission( for (const key of BACKEND_PERMISSION_TOOL_KEYS) { if (key === 'external_directory') { permission[key] = { [`${scope}/**`]: 'allow', '*': 'deny' }; + } else if (key === 'read' && !allowed.has(key)) { + // Permission.disabled() runs findLast against the tool name only, then + // checks the selected rule's pattern: a final `*: deny` hides `read`. + // The NUL-terminated sentinel allow must stay last so the selected rule + // is not `*`. Execution-time path matching is separate and still denies + // every real path via the leading `*: deny` (no real path contains NUL, + // so none can match the sentinel). Do not replace this with 'deny' + // (re-breaks Zen free tier) or 'allow' (real read execution). + permission[key] = { '*': 'deny', [FREE_TIER_READ_GATE_PATH]: 'allow' }; } else { permission[key] = allowed.has(key) ? 'allow' : 'deny'; } diff --git a/tests/backend-permission.test.js b/tests/backend-permission.test.js index c3efafb..297324f 100644 --- a/tests/backend-permission.test.js +++ b/tests/backend-permission.test.js @@ -1,6 +1,11 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { BACKEND_PROJECT_DIR, BACKEND_PERMISSION_TOOL_KEYS, + FREE_TIER_READ_GATE_PATH, buildBackendPermission, } from '../src/backend/backend-permission.js'; @@ -10,6 +15,10 @@ import { // omits the tools map -> backend falls back to agent defaults = all on). // Default posture is deny-all; only explicitly allowlisted internal tools // are allowed. external_directory is always jailed to the backend project. +// `read` is special: when not explicitly allowed it emits an ordered gate +// object whose NUL-terminated sentinel allow keeps it visible upstream (Zen +// free tier needs read in tools[]) while every real path still hits the +// leading `*` deny. const EXECUTION_KEYS = [ 'read', @@ -28,22 +37,60 @@ const EXECUTION_KEYS = [ 'skill', ]; +const READ_GATE = { '*': 'deny', [FREE_TIER_READ_GATE_PATH]: 'allow' }; + +// Ordered rules matter: OpenCode's findLast-based filter must see the +// NUL-terminated non-wildcard sentinel allow last, or `read` is hidden from +// the upstream tools[] again. Every real path must still match only the `*` +// deny. +function expectReadGate(value) { + expect(value).toEqual(READ_GATE); + const entries = Object.entries(value); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual(['*', 'deny']); + expect(entries[entries.length - 1]).toEqual([FREE_TIER_READ_GATE_PATH, 'allow']); + expect(entries[entries.length - 1][0]).not.toBe('*'); +} + describe('backend permission contract', () => { test('project dir constant matches the container backend project', () => { expect(BACKEND_PROJECT_DIR).toBe('/home/node/project'); }); + test('read gate sentinel is NUL-terminated, non-wildcard, and outside the project jail', () => { + expect(FREE_TIER_READ_GATE_PATH.endsWith('\u0000')).toBe(true); + expect(FREE_TIER_READ_GATE_PATH).not.toMatch(/[*?]/); + expect(FREE_TIER_READ_GATE_PATH.startsWith(BACKEND_PROJECT_DIR)).toBe(false); + }); + + test('backslash normalization cannot reach the NUL-terminated sentinel', () => { + const backslashCandidate = '\\__opencode_free_tier_gate_never__'; + expect(backslashCandidate.replaceAll('\\', '/')).not.toBe(FREE_TIER_READ_GATE_PATH); + }); + test('tool keys are exactly the schema-valid opencode permission keys', () => { expect([...BACKEND_PERMISSION_TOOL_KEYS].sort()).toEqual( [...EXECUTION_KEYS, 'external_directory'].sort(), ); }); - test('default (no args) denies everything and jails the filesystem', () => { + test('entrypoint.sh static fallback mirrors buildBackendPermission() with NUL sentinel order', () => { + const entrypointPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'entrypoint.sh'); + const script = readFileSync(entrypointPath, 'utf8'); + const match = script.match(/^\s*BACKEND_PERM_JSON='([^']*)'\s*$/m); + expect(match).not.toBeNull(); + const fallback = JSON.parse(match[1]); + expect(fallback).toEqual(buildBackendPermission()); + expectReadGate(fallback['read']); + }); + + test('default denies every execution tool and jails the filesystem', () => { const perm = buildBackendPermission(); for (const key of EXECUTION_KEYS) { + if (key === 'read') continue; expect(perm[key]).toBe('deny'); } + expectReadGate(perm['read']); expect(perm['external_directory']).toEqual({ [`${BACKEND_PROJECT_DIR}/**`]: 'allow', '*': 'deny', @@ -51,12 +98,19 @@ describe('backend permission contract', () => { expect(Object.values(perm)).not.toContain('ask'); }); + test('default read value is an ordered free-tier gate, not deny or allow', () => { + const gate = buildBackendPermission()['read']; + expectReadGate(gate); + expect(gate).not.toBe('deny'); + expect(gate).not.toBe('allow'); + }); + test('explicit allowlist allows only the listed tools', () => { const perm = buildBackendPermission(['websearch']); expect(perm['websearch']).toBe('allow'); expect(perm['webfetch']).toBe('deny'); expect(perm['bash']).toBe('deny'); - expect(perm['read']).toBe('deny'); + expectReadGate(perm['read']); }); test('explicit opt-in to execution tools is preserved', () => { @@ -66,6 +120,16 @@ describe('backend permission contract', () => { expect(perm['edit']).toBe('deny'); }); + test('explicit read allowlist keeps a real allow (no gate object)', () => { + const perm = buildBackendPermission(['read']); + expect(perm['read']).toBe('allow'); + expect(perm['read']).not.toEqual(READ_GATE); + }); + + test('case and whitespace normalization still yields an explicit read allow', () => { + expect(buildBackendPermission([' READ '])['read']).toBe('allow'); + }); + test('alias and case variants match the schema key (web_fetch -> webfetch)', () => { expect(buildBackendPermission(['web_fetch'])['webfetch']).toBe('allow'); expect(buildBackendPermission(['WebSearch'])['websearch']).toBe('allow'); @@ -88,17 +152,28 @@ describe('backend permission contract', () => { const perm = buildBackendPermission(['nonexistent_xyz', '', ' ', 123, null]); expect(perm['nonexistent_xyz']).toBeUndefined(); for (const key of EXECUTION_KEYS) { + if (key === 'read') continue; expect(perm[key]).toBe('deny'); } + expectReadGate(perm['read']); expect(Object.keys(perm).sort()).toEqual([...BACKEND_PERMISSION_TOOL_KEYS].sort()); }); - test('garbage inputs fall back to deny-all', () => { + test('garbage inputs fall back to deny-all execution', () => { for (const bad of [null, undefined, 'bash', 42, { bash: true }]) { const perm = buildBackendPermission(bad); for (const key of EXECUTION_KEYS) { + if (key === 'read') continue; expect(perm[key]).toBe('deny'); } + expectReadGate(perm['read']); + } + }); + + test('empty and garbage allowlists still emit the read gate', () => { + const inputs = [[], null, undefined, 'bash', 42, { bash: true }, ['nonexistent_xyz', '', ' ', 123, null]]; + for (const input of inputs) { + expectReadGate(buildBackendPermission(input)['read']); } }); @@ -126,17 +201,44 @@ describe('backend permission contract', () => { '/tmp/jail-xyz/empty-workspace/**': 'allow', '*': 'deny', }); - for (const key of ['read', 'bash', 'websearch']) { - expect(perm[key]).toBe('deny'); - } + expectReadGate(perm['read']); + expect(perm['bash']).toBe('deny'); + expect(perm['websearch']).toBe('deny'); + }); + + test('custom projectDir only rescopes external_directory, keeping the read gate', () => { + const perm = buildBackendPermission([], false, '/tmp/jail-gate-check'); + expectReadGate(perm['read']); + expect(perm['external_directory']).toEqual({ + '/tmp/jail-gate-check/**': 'allow', + '*': 'deny', + }); }); test('garbage projectDir falls back to the container default', () => { for (const bad of [null, undefined, '', ' ', 42]) { - expect(buildBackendPermission([], false, bad)['external_directory']).toEqual({ + const perm = buildBackendPermission([], false, bad); + expect(perm['external_directory']).toEqual({ [`${BACKEND_PROJECT_DIR}/**`]: 'allow', '*': 'deny', }); + expectReadGate(perm['read']); + } + }); + + test('permission values never contain ask', () => { + const variants = [ + buildBackendPermission(), + buildBackendPermission(['read', 'bash', 'websearch']), + buildBackendPermission([], true, '/tmp/jail-ask-check'), + ]; + for (const perm of variants) { + expect(Object.values(perm)).not.toContain('ask'); + for (const value of Object.values(perm)) { + if (value && typeof value === 'object') { + expect(Object.values(value)).not.toContain('ask'); + } + } } }); }); diff --git a/tests/dockerfile-pin.test.js b/tests/dockerfile-pin.test.js new file mode 100644 index 0000000..523efec --- /dev/null +++ b/tests/dockerfile-pin.test.js @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Contract: lock the runtime image's OpenCode backend pin and the proxy-side +// expectations around it. These tests cannot execute the installed backend, so +// a version bump still requires an upstream/real-backend check: the target +// version's Permission.disabled() must keep the findLast semantics (select the +// last rule by tool name, then check that rule's pattern is `*`), and `read` +// must stay in the upstream tools[] for a real Zen request. +// +// Bumping the pin requires updating PINNED_VERSION and re-running +// tests/free-tier-gate.test.js, tests/backend-permission.test.js, and this file. + +const PINNED_VERSION = '1.18.35'; + +const dockerfilePath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'Dockerfile'); +const dockerfile = readFileSync(dockerfilePath, 'utf8'); + +describe('Dockerfile OpenCode backend pin', () => { + test(`declares ARG OPENCODE_VERSION=${PINNED_VERSION}`, () => { + const match = dockerfile.match(/^\s*ARG\s+OPENCODE_VERSION=(.+?)\s*$/m); + expect(match).not.toBeNull(); + expect(match[1]).toBe(PINNED_VERSION); + }); + + test('declares a build-arg-pinned install', () => { + const installLines = dockerfile + .split(/\r?\n/) + .filter( + (line) => + /\bnpm\s+(?:install|i)\s+(?:-g|--global)\b/.test(line) && + line.includes('opencode-ai'), + ); + expect(installLines).toHaveLength(1); + const [installLine] = installLines; + expect(installLine).toContain('opencode-ai@${OPENCODE_VERSION}'); + expect(installLine).toMatch( + /\btest\s+-n\s+"\$\{OPENCODE_VERSION\}"\s+&&\s+npm\s+(?:install|i)\s+(?:-g|--global)\b/, + ); + expect(dockerfile).not.toMatch(/opencode-ai@(?!\$\{OPENCODE_VERSION\})[\w.-]+/); + + const runtime = dockerfile.slice(dockerfile.lastIndexOf('\nFROM ')); + const argIdx = runtime.search(/^\s*ARG\s+OPENCODE_VERSION=/m); + const runIdx = runtime.search( + /^\s*RUN\s+test\s+-n\s+"\$\{OPENCODE_VERSION\}"\s+&&\s+npm\s+install\b/m, + ); + expect(argIdx).toBeGreaterThan(-1); + expect(runIdx).toBeGreaterThan(argIdx); + expect(dockerfile.match(/^\s*ARG\s+OPENCODE_VERSION=/gm)).toHaveLength(1); + }); + + test('does not persist the pin as a runtime ENV', () => { + expect(dockerfile).not.toMatch(/^\s*ENV\s+OPENCODE_VERSION=/m); + }); +}); diff --git a/tests/free-tier-gate.test.js b/tests/free-tier-gate.test.js new file mode 100644 index 0000000..19b9169 --- /dev/null +++ b/tests/free-tier-gate.test.js @@ -0,0 +1,103 @@ +import { + BACKEND_PROJECT_DIR, + FREE_TIER_READ_GATE_PATH, + buildBackendPermission, +} from '../src/backend/backend-permission.js'; +import { isFreeTierSuspectModel, selectPromptToolOverrides } from '../src/proxy.js'; + +// Cross-module contract for the Zen free-tier `read` gate. Two independent +// mechanisms must agree: +// 1. selectPromptToolOverrides() drops every `false` entry for suspect +// models and returns null when nothing stays true, so the prompt body +// carries no `tools` map (any `false` entry -> 403 FreeTierError). +// 2. buildBackendPermission() leaves `read` visible via an ordered +// { '*': deny, [NUL sentinel]: allow } gate whose selected (last) rule +// is not `*`, while execution-time paths still hit the leading deny. +// If either half softens (`read: false` leaking into a free-tier prompt body, +// or the gate regressing to plain "deny"), Zen free tier breaks again. + +const FREE_TIER_MODELS = [ + ['opencode', 'big-pickle'], + ['opencode', 'union-alpha'], + ['opencode', 'kimi-k2.5-free'], +]; + +const PAID_OR_OTHER_MODELS = [ + ['opencode', 'kimi-k2.5'], + ['opencode', 'gpt-5'], + ['anthropic', 'claude-sonnet-4-5'], +]; + +function expectDefaultReadGate(permission) { + const gate = permission['read']; + const entries = Object.entries(gate); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual(['*', 'deny']); + expect(entries[entries.length - 1]).toEqual([FREE_TIER_READ_GATE_PATH, 'allow']); + expect(entries[entries.length - 1][0]).not.toBe('*'); +} + +describe('free-tier read gate cross-module contract', () => { + test('all-false overrides vanish for free-tier models (no tools map)', () => { + const allFalse = { bash: false, read: false }; + for (const [providerID, modelID] of FREE_TIER_MODELS) { + expect(isFreeTierSuspectModel(providerID, modelID)).toBe(true); + expect(selectPromptToolOverrides(allFalse, providerID, modelID)).toBeNull(); + } + }); + + test('mixed overrides keep only true entries for free-tier models', () => { + const mixed = { bash: false, read: true }; + for (const [providerID, modelID] of FREE_TIER_MODELS) { + const selected = selectPromptToolOverrides(mixed, providerID, modelID); + expect(selected).toEqual({ read: true }); + expect(selected).not.toHaveProperty('read', false); + expect(Object.values(selected).every((value) => value === true)).toBe(true); + } + }); + + test('paid/other models preserve the override map verbatim, including read:false', () => { + const allFalse = { bash: false, read: false }; + const mixed = { bash: false, read: true }; + for (const [providerID, modelID] of PAID_OR_OTHER_MODELS) { + expect(isFreeTierSuspectModel(providerID, modelID)).toBe(false); + expect(selectPromptToolOverrides(allFalse, providerID, modelID)).toBe(allFalse); + expect(selectPromptToolOverrides(mixed, providerID, modelID)).toBe(mixed); + } + }); + + test('default backend permission read gate keeps read visible without granting it', () => { + const permission = buildBackendPermission(); + expectDefaultReadGate(permission); + expect(permission['read']).not.toBe('deny'); + expect(permission['read']).not.toBe('allow'); + expect(permission['read']['*']).toBe('deny'); + }); + + test('explicit read opt-in still yields a real allow (not the gate object)', () => { + const permission = buildBackendPermission(['read']); + expect(permission['read']).toBe('allow'); + expectDefaultReadGate(buildBackendPermission()); + expect(permission['external_directory']).toEqual({ + [`${BACKEND_PROJECT_DIR}/**`]: 'allow', + '*': 'deny', + }); + }); + + test('NUL sentinel cannot be matched by representative real read paths', () => { + expect(FREE_TIER_READ_GATE_PATH.endsWith('\u0000')).toBe(true); + const candidates = [ + '/home/node/project/README.md', + '/tmp/anything', + '/__opencode_free_tier_gate_never__', + '\\__opencode_free_tier_gate_never__', + ]; + for (const candidate of candidates) { + expect(candidate.endsWith('\u0000')).toBe(false); + expect(candidate).not.toBe(FREE_TIER_READ_GATE_PATH); + // OpenCode's Wildcard.match normalizes backslashes to slashes; + // even then the sentinel's terminal NUL keeps it unmatched. + expect(candidate.replaceAll('\\', '/')).not.toBe(FREE_TIER_READ_GATE_PATH); + } + }); +});