Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down Expand Up @@ -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).
Expand All @@ -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`.

Expand All @@ -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.

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand Down
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Loading
Loading