Skip to content

fix(backend): keep read visible for Zen free-tier gate - #33

Merged
samson910022 merged 4 commits into
mainfrom
fix/zen-free-tier-read-gate
Oct 20, 2026
Merged

samson910022 merged 4 commits into
mainfrom
fix/zen-free-tier-read-gate

Conversation

@samson910022

Copy link
Copy Markdown
Owner

Summary

Fixes the Zen free-tier 403 FreeTierError for the headless backend while preserving default-deny and client-side tool calling.

Root cause: Zen free tier requires read to appear in the upstream tools[]; the previous deny-all backend permission hid it.

Changes

  • Default backend read permission is now an ordered, inert gate: { "*": "deny", "/__opencode_free_tier_gate_never__\u0000": "allow" }.
    • OpenCode Permission.disabled() uses findLast by tool name, so the trailing non-* sentinel rule keeps read visible upstream.
    • Execution-time path matching still hits the leading *: deny; the NUL-terminated sentinel cannot match any real path, including after Wildcard.match backslash normalization.
  • bash remains deny; explicit allowlist read remains a real allow; external_directory jail is unchanged.
  • entrypoint.sh static fallback mirrors the generator; the isolated-home backend path uses the same builder.
  • Free-tier suspect model prompt tools maps strip every false entry, and all-false maps are omitted.
  • Client tools remain on the external__* prompt bridge and are never registered as backend built-ins.
  • Docker builds pin opencode-ai@1.18.35 through a build-only ARG, with a regression test.
  • Docs updated: configuration, troubleshooting, Docker bump checklist, AGENTS, CHANGELOG.

Validation

  • npm run typecheck ✅
  • npm run build ✅
  • npm test -- --runInBand ✅ 31/31 suites, 767/767 tests
  • bash -n entrypoint.sh ✅
  • docker compose config -q ✅
  • git diff --check ✅
  • Four independent final reviews (security, tool calling, Docker/version, docs/hygiene) all APPROVE.

Notes

  • Direct push to main is blocked by branch protection, so this PR is the required integration path.
  • The unrelated local .gitignore change is not included.
  • Non-blocking follow-ups: paid→free continuation via previous_response_id / previous_interaction_id may retain a stale session read: deny when the new tools map is omitted; opencode-go/*-free is not matched by the current predicate; workflows still pin opencode-ai@1.18.30.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T13:08:40.294763Z 2f29fc7 PR opened
🔒 Security Review ✅ Completed 2026-10-20T13:10:37.935160Z 2f29fc7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown

Verdict matrix

  • Roles: gateway_safety, api_compat, docs_config
  • FINAL_VERDICT: APPROVE

Sensitive paths

  • Dockerfile
  • entrypoint.sh

Deterministic pre-checks

  • (none)

gateway_safety

VERDICT: APPROVE

Rationale: PR delivers a minimal, fully-tested change to the default backend permission map (and its static mirror) that keeps read visible in upstream tools[] for Zen free-tier models while preserving the leading *: deny execution-time denial and the external-directory jail. All other invariants (auth gating, DISABLE_TOOLS=true, external __* bridge, no tool-output logging, no secrets, no high-risk no-touch list regressions, wire-shape preservation) remain untouched. Regression tests, Docker pin, and docs updates are present and pass per the validation section. No BLOCKING / SHOULD_FIX / NITS findings.

api_compat

Review: API Compatibility — fix(backend): keep read visible for Zen free-tier gate

Summary

This PR modifies the backend permission generator (buildBackendPermission) to emit an ordered gate object for read instead of a bare "deny", pins the Docker backend to [provider]-ai@1.18.35, updates the entrypoint.sh static fallback, adds three new test suites, and updates docs/AGENTS/CHANGELOG. The change is entirely in the backend-permission and prompt-tools layer; no route handler, converter, stream collector, retry, or wire-shape code is touched.


Checklist evaluation

# 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

  1. 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 on Object.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).

  2. tests/free-tier-gate.test.js:7 — imports isFreeTierSuspectModel and selectPromptToolOverrides from ../src/proxy.js, which is the 1149-line god file that boots Express middleware.

    src/proxy.ts is createApp/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 current proxy.ts apparently doesn't execute side effects at import time (only on startProxy() call), so this works today. But it's fragile: any future top-level side effect in proxy.ts will break this test suite silently or noisily. Consider extracting isFreeTierSuspectModel and selectPromptToolOverrides into a pure utility module (e.g., src/utils/free-tier.ts) that both proxy.ts and the test import.

    Severity: should-fix (test fragility; not blocking because it works today).


NITS

  1. 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.

  2. tests/dockerfile-pin.test.js:42-43 — The regex \btest\s+-n\s+"\$\{OPENCODE_VERSION\}"\s+&&\s+npm\s+(?:install|i)\s+(?:-g|--global)\b is 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.

  3. 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.

  4. entrypoint.sh:73 — The static fallback JSON string contains \u0000 as a literal six-character escape, not an actual NUL byte. This is correct because JSON.parse in the Node generator path will interpret \u0000 as NUL, and the shell string '...\u0000...' passes through to the JSON file verbatim where Node's JSON.parse handles it. Worth a brief comment in the shell script confirming this is intentional (the \u0000 is a JSON escape, not a shell escape).


Test gaps

  • No test verifies that selectPromptToolOverrides handles null/undefined input gracefully (only allFalse and mixed maps are tested). If a request arrives with no tools map at all, the function should return null/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.sh fallback with a corrupted/missing dist/ directory — the fallback path is tested indirectly via the backend-permission.test.js mirror 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_VERSION is properly treated and tested as a build-only argument (tests/dockerfile-pin.test.js:53-55), not leaking into runtime ENV.
  • Backend permission gate: The ordered { "*": "deny", "/__opencode_free_tier_gate_never__\u0000": "allow" } gate keeps read visible to [provider]'s Permission.disabled() findLast check for Zen free tier while maintaining execution-time default-deny and filesystem jail invariants.
  • Static fallback sync: entrypoint.sh static fallback JSON matches buildBackendPermission() output exactly and is asserted against drift by tests/backend-permission.test.js.
  • Documentation & Agent sync: AGENTS.md, CHANGELOG.md (EN-first with Chinese mirror), docs/configuration.md, docs/docker.md, and docs/troubleshooting.md are 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].

@samson910022
samson910022 merged commit 93cec69 into main Oct 20, 2026
5 checks passed
@samson910022
samson910022 deleted the fix/zen-free-tier-read-gate branch October 20, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant