Repository navigation
fix(backend): keep read visible for Zen free-tier gate - #33
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Verdict matrix
Sensitive paths
Deterministic pre-checks
gateway_safetyVERDICT: APPROVE Rationale: PR delivers a minimal, fully-tested change to the default backend permission map (and its static mirror) that keeps api_compatReview: API Compatibility —
|
| # | Item | Status | Notes |
|---|---|---|---|
| 1 | Route error wire shapes preserved | ✅ | No changes to src/routes/, src/stream/, or src/errors/. |
| 2 | Converters preserve thinking/signature/encrypted blocks | ✅ | No converter changes. |
| 3 | previous_response_id / previous_interaction_id chaining |
✅ | Untouched. PR body notes a non-blocking follow-up about stale session read: deny on paid→free continuation — acknowledged, not a regression from this diff. |
| 4 | Reasoning mapping thresholds | ✅ | Untouched. |
| 5 | Model alias rules | ✅ | Untouched. |
| 6 | Collector finish==='tool' / idle-exemption / flushHeaders |
✅ | Untouched. |
| 7 | resolveMaxRetries clamp / backoff / jitter / retry-after |
✅ | Untouched. |
| 8 | New endpoints documented | N/A | No new endpoints. |
BLOCKING
None.
SHOULD_FIX
-
src/backend/backend-permission.ts:120-126— Object key insertion order is relied upon for correctness, but the invariant is only tested at the JS level, not documented as a language-spec guarantee in the code comments.The gate
{ '*': 'deny', [FREE_TIER_READ_GATE_PATH]: 'allow' }depends onObject.entries()returning keys in insertion order. This is guaranteed by the ES2015+ spec for string keys (integer-index keys first in ascending order, then string keys in insertion order). Since'*'and the sentinel are both non-integer string keys, insertion order is preserved. The tests (expectReadGate) correctly verify entry order. However, the code comment says "ordered rule object" without citing why ordering is safe. A one-line note (// ES2015+ guarantees string-key insertion order for Object.entries) would prevent a future contributor from "optimizing" the object construction in a way that breaks order.Severity: should-fix (documentation gap on a correctness-critical invariant).
-
tests/free-tier-gate.test.js:7— importsisFreeTierSuspectModelandselectPromptToolOverridesfrom../src/proxy.js, which is the 1149-line god file that boots Express middleware.src/proxy.tsiscreateApp/startProxy— importing it from tests risks side effects (Express app creation, port binding) depending on module-level code. The test file imports only named exports and the currentproxy.tsapparently doesn't execute side effects at import time (only onstartProxy()call), so this works today. But it's fragile: any future top-level side effect inproxy.tswill break this test suite silently or noisily. Consider extractingisFreeTierSuspectModelandselectPromptToolOverridesinto a pure utility module (e.g.,src/utils/free-tier.ts) that bothproxy.tsand the test import.Severity: should-fix (test fragility; not blocking because it works today).
NITS
-
AGENTS.md:90— The new §5 bullet is 8 lines of dense prose. Consider splitting the mechanism description (findLast semantics, NUL sentinel, execution-time path matching) into sub-bullets for scanability. The current wall-of-text form is accurate but hard to parse quickly. -
tests/dockerfile-pin.test.js:42-43— The regex\btest\s+-n\s+"\$\{OPENCODE_VERSION\}"\s+&&\s+npm\s+(?:install|i)\s+(?:-g|--global)\bis tightly coupled to the exact shell syntax. A minor Dockerfile reformatting (e.g., adding--before the package name, or splitting across\continuation lines) would break the test without any semantic change. This is acceptable for a pin-lock test but worth noting. -
docs/troubleshooting.md— Section numbering jumped from 9️⃣ to 🔟. The emoji numbering style change (digit-keycap to boxed-ten) is inconsistent with the preceding sections (1️⃣–8️⃣ all use keycap style). Minor visual nit. -
entrypoint.sh:73— The static fallback JSON string contains\u0000as a literal six-character escape, not an actual NUL byte. This is correct becauseJSON.parsein the Node generator path will interpret\u0000as NUL, and the shell string'...\u0000...'passes through to the JSON file verbatim where Node'sJSON.parsehandles it. Worth a brief comment in the shell script confirming this is intentional (the\u0000is a JSON escape, not a shell escape).
Test gaps
- No test verifies that
selectPromptToolOverrideshandlesnull/undefinedinput gracefully (onlyallFalseandmixedmaps are tested). If a request arrives with no tools map at all, the function should returnnull/passthrough — this path isn't covered in the new test file. - The cross-module contract test (
free-tier-gate.test.js) cannot verify the actual [provider]Permission.disabled()behavior — this is explicitly acknowledged in the PR body and AGENTS.md, which is the correct approach. The test locks the proxy-side data contract only. - No negative test for the
entrypoint.shfallback with a corrupted/missingdist/directory — the fallback path is tested indirectly via thebackend-permission.test.jsmirror check, but the shell-level fallback trigger (|| BACKEND_PERM_JSON="") isn't exercised. This is pre-existing and not introduced by this PR.
Cross-protocol regression risk
Low. This PR touches no route handlers, no converters, no stream logic, no retry logic, and no wire-shape code. The changes are confined to:
- Backend permission generation (pure function, well-tested)
- Prompt tool override stripping (existing function, new cross-module test)
- Dockerfile build-arg pinning (build-time only)
- Documentation
The four protocol wire shapes (chat JSON, responses SSE, messages SSE, interactions SSE) are unaffected.
VERDICT: APPROVE
The core change is sound: the ordered gate object exploits a well-defined ES2015+ property-key ordering guarantee to keep read visible upstream without granting execution permission. The NUL-terminated sentinel is a clean defense against path-matching collisions. Test coverage is thorough for the proxy-side contract, and the PR correctly acknowledges that upstream backend verification remains a manual step on version bumps. The two should-fix items (ES spec citation in code comment, proxy.js import fragility) are non-blocking improvements.
docs_config
VERDICT: APPROVE
Summary
The PR correctly preserves the OpenCode2API config matrix and documentation contracts:
- Matrix integrity: No new runtime environment variables or proxy config keys were introduced;
ARG OPENCODE_VERSIONis properly treated and tested as a build-only argument (tests/dockerfile-pin.test.js:53-55), not leaking into runtimeENV. - Backend permission gate: The ordered
{ "*": "deny", "/__opencode_free_tier_gate_never__\u0000": "allow" }gate keepsreadvisible to [provider]'sPermission.disabled()findLast check for Zen free tier while maintaining execution-time default-deny and filesystem jail invariants. - Static fallback sync:
entrypoint.shstatic fallback JSON matchesbuildBackendPermission()output exactly and is asserted against drift bytests/backend-permission.test.js. - Documentation & Agent sync:
AGENTS.md,CHANGELOG.md(EN-first with Chinese mirror),docs/configuration.md,docs/docker.md, anddocs/troubleshooting.mdare completely synchronized.
Findings
BLOCKING
(none)
SHOULD_FIX
(none)
NITS
(none)
Files to Change to Close Matrix Gap
(none — matrix is in sync)
FINAL_VERDICT: APPROVE
Session: response_id=48cf0fcc-d625-9b6e-9daa-5f6be17d9521,resp_req_vrtx_011CgDYE348nM4koBDZZYhgM,resp_VmfXaszzDZPV9tMPkfDcuQg (chain via previous_response_id, 30min TTL). model=[model] provider=[provider].
Summary
Fixes the Zen free-tier
403 FreeTierErrorfor the headless backend while preserving default-deny and client-side tool calling.Root cause: Zen free tier requires
readto appear in the upstreamtools[]; the previous deny-all backend permission hid it.Changes
readpermission is now an ordered, inert gate:{ "*": "deny", "/__opencode_free_tier_gate_never__\u0000": "allow" }.Permission.disabled()usesfindLastby tool name, so the trailing non-*sentinel rule keepsreadvisible upstream.*: deny; the NUL-terminated sentinel cannot match any real path, including afterWildcard.matchbackslash normalization.bashremainsdeny; explicit allowlistreadremains a realallow;external_directoryjail is unchanged.entrypoint.shstatic fallback mirrors the generator; the isolated-home backend path uses the same builder.toolsmaps strip everyfalseentry, and all-false maps are omitted.external__*prompt bridge and are never registered as backend built-ins.opencode-ai@1.18.35through a build-only ARG, with a regression test.Validation
npm run typecheck✅npm run build✅npm test -- --runInBand✅ 31/31 suites, 767/767 testsbash -n entrypoint.sh✅docker compose config -q✅git diff --check✅Notes
mainis blocked by branch protection, so this PR is the required integration path..gitignorechange is not included.previous_response_id/previous_interaction_idmay retain a stale sessionread: denywhen the new tools map is omitted;opencode-go/*-freeis not matched by the current predicate; workflows still pinopencode-ai@1.18.30.