Skip to content

feat(backend): mimic first-party client identity anonymously - #23

Merged
samson910022 merged 5 commits into
mainfrom
feat/mimic-opencode-identity-anon
Sep 19, 2026
Merged

samson910022 merged 5 commits into
mainfrom
feat/mimic-opencode-identity-anon

Conversation

@samson910022

Copy link
Copy Markdown
Owner

Anonymous first-party posture for the spawned backend (no login, no credential injection).

  • src/backend/manager.ts: resolveBackendClient()/applyBackendIdentityEnv() + identity/project-dir logging; jail kept (upstream project.id global is legitimate)
  • src/errors/upstream.ts: gate -> 403 (normalizeBackendError, transformUpstreamError), isTransientUpstreamError fail-fast, no proxy fallback
  • src/session/ids.ts + src/stream/collector.ts: ses_/msg_ canonical-shape drift signal (debug log only, never blocks)
  • Tests: backend-identity, first-party-gate, session-ids (25 suites / 360 tests green)

AGENTS.md checks: §4 no new proxy knob (no matrix update), §6 no secrets/forbidden files, §8 ESM pattern clean, §7 no docs change.
Verify: npm run typecheck && npm run build && npm test -- --runInBand

Spawned backend pins OPENCODE_CLIENT to a known first-party value
(cli default), strips OPENCODE_API_KEY=public footgun, maps the
only-be-used-from-within-OpenCode free-tier gate to 403 fail-fast
(no retry/fallback), and adds ses_/msg_ ID drift-signal validators.
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Verdict matrix

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

Sensitive paths

  • src/backend/manager.ts
  • src/errors/upstream.ts
  • src/proxy.ts
  • src/routes/chat.ts
  • src/routes/interactions.ts
  • src/routes/messages.ts
  • src/routes/responses.ts
  • src/stream/collector.ts

Deterministic pre-checks

  • (none)

gateway_safety

VERDICT: APPROVE

BLOCKING:
(none)

SHOULD_FIX:
(none)

NITS:
(none)

Rationale

  • Auth & Secrets: No secrets introduced or logged; OPENCODE_CLIENT validation clamps to a strict allowlist (cli, desktop, acp, app), and backend project directory logging hides absolute filesystem paths (<jail>/...).
  • Tool Safety: DISABLE_TOOLS=true default remains preserved; tool markup output stripping was extended to /v1beta/interactions for parity, and selectPromptToolOverrides safely limits the free-tier false-entry omission strictly to suspected Zen free models while leaving system prompt guards intact.
  • Backend Lifecycle & Errors: git init on jail workspaces is best-effort and non-blocking on failure; upstream first-party gate detection (FreeTierError) properly maps to 403 fail-fast without burning transient retries or engaging fallback proxies.
  • Wire Shapes & CI Hygiene: All streaming and non-streaming wire shapes across chat, responses, messages, and interactions remain untouched; tests cleanly target module entry points without importing index.ts.

api_compat

API Compatibility Review

Summary

This PR adds anonymous first-party identity mimicry for the spawned backend, free-tier gate error handling (403 fail-fast), tool-override stripping for free-tier models, and session ID format validation (debug-only). The changes touch all four route files and the collector.


BLOCKING

(none)

No wire-shape violations detected. All four route error exits retain their existing shapes:

  • Chat non-stream: JSON error body (unchanged)
  • Responses stream: response.failed SSE + [DONE] (unchanged)
  • Messages stream: error event without [DONE] (unchanged)
  • Interactions stream: interaction.completed / error without [DONE] (unchanged)

The isTransientUpstreamError gate-error early-return (return false) correctly prevents retry burn and proxy fallback for 403 gate errors without altering the transient-detection logic for other error classes.


SHOULD_FIX

  1. applyBackendIdentityEnv checks for 'public' but CHANGELOG/docs say '[REDACTED]' — src/backend/manager.ts:75: the guard reads === 'public' but the CHANGELOG entry, docs, and comments all describe stripping the literal [REDACTED]. The test at tests/backend-identity.test.js:73 confirms 'public' is stripped and then asserts '[REDACTED]' maps to '[provider]-real-key-1' — which means the [REDACTED] literal is not stripped, contradicting the stated intent. Either the code should check for '[REDACTED]' (matching docs) or the docs/CHANGELOG should say 'public'. As written, the documented footgun (OPENCODE_API_KEY="[REDACTED]") is not actually neutralized.

  2. stripFunctionCalls return-type change may break callers that relied on passthrough — src/proxy.ts:374-376: the function now returns string unconditionally (String(text ?? '') for non-strings, text for strings when !DISABLE_TOOLS). The old signature returned unknown and passed through the original value when tools were enabled. The new version coerces non-string inputs to String(...) even when DISABLE_TOOLS=false. This is a subtle behavioral change: if any caller passes a non-string (e.g., null, undefined, structured content) when tools are enabled, they now get 'null'/'undefined'/'[object Object]' instead of the original value. The route call sites all pass content || '' so they're safe today, but the type contract is now stricter than the callers assume. Consider guarding: if (!DISABLE_TOOLS) return typeof text === 'string' ? text : String(text ?? ''); or documenting the coercion.

  3. selectPromptToolOverrides is exported from both module scope and AppContext — src/proxy.ts:93 (module-level pure function) and src/proxy.ts:912 (on the context object). The context version is a direct reference to the module-level function (no closure state needed), but routes import it from ctx while tests import it from '../src/proxy.js'. This dual export is fine functionally but creates a maintenance trap: if the function ever needs config closure, the module-level export will diverge. Consider removing the module-level re-export or documenting the intentional duplication.

  4. Interactions route stripFunctionCalls added but stripFunctionCallMarkup double-strip missing — src/routes/interactions.ts:360,395: the interactions route now calls stripFunctionCalls(polled.content || '') but does NOT wrap it in stripFunctionCallMarkup(...) like chat (src/routes/chat.ts:871), messages (src/routes/messages.ts:341), and responses (src/routes/responses.ts:864). The comment says "parity with chat/responses/messages" but the actual parity pattern is stripFunctionCallMarkup(stripFunctionCalls(content)). If stripFunctionCalls doesn't catch all markup variants that stripFunctionCallMarkup does, interactions output may still leak tool syntax.


NITS

  1. src/session/ids.ts:22-23: The regex SESSION_SUFFIX uses [0-9A-Za-z]{14} for base62, but the comment says "14 base62" — base62 is [0-9a-zA-Z] which is the same character class, so this is correct but the ordering (A-Za-z vs a-zA-Z) is cosmetically inconsistent with the comment's "lowercase hex + base62" framing.

  2. tests/backend-identity.test.js:73-74: The test asserts applyBackendIdentityEnv({ OPENCODE_API_KEY: '[REDACTED]' })['OPENCODE_API_KEY'] equals '[provider]-real-key-1' — this value appears to be a test fixture leak (the mock SDK setup?). If the intent is to verify the key is NOT deleted, expect(...).toBe('[REDACTED]') would be clearer. If the intent is to verify deletion, the assertion is wrong.

  3. CHANGELOG.md: The entry is a single 600+ character paragraph. Consider breaking it into bullet points for readability.

  4. docs/configuration.md:37: The exception note (①②) is dense Chinese prose embedded in a table cell. A separate subsection or footnote would be more scannable.

  5. Checklist item 8: No docs/api-reference.md update needed (no new endpoints), confirmed. The N×N matrix is explicitly declared unchanged per AGENTS.md annotation — verified: no new wire shape, no new route.


Test gap notes

  • No test for the isTransientUpstreamError gate-error + transient-looking padding isolation beyond the single padded case in first-party-gate.test.js:42. Consider adding a case where the gate message appears in responseBody but not data.message (the readResponseBodyText path in normalizeBackendError).
  • No integration test for the interactions stripFunctionCalls path with DISABLE_TOOLS=false to verify the no-op passthrough doesn't coerce types (relates to should-fix fix: add -m 5 to entrypoint.sh health-check curl to prevent infinite hang #2).
  • Session ID drift signal (collector.ts:120,208) is debug-only and never blocks — confirmed by code and tests. No regression risk, but no test verifies the logDebug call fires on malformed IDs in the collector context.

Cross-protocol regression check

Route Wire shape Tool override stripping Markup stripping Error gate (403)
Chat ✅ unchanged ✅ selectPromptToolOverrides ✅ double-strip ✅ via transformUpstreamError
Responses ✅ unchanged ✅ selectPromptToolOverrides ✅ double-strip ✅ via transformUpstreamError
Messages ✅ unchanged ✅ selectPromptToolOverrides ✅ double-strip ✅ via transformUpstreamError
Interactions ✅ unchanged ✅ selectPromptToolOverrides ⚠️ single-strip only ✅ via transformUpstreamError

VERDICT: NEEDS_CHANGES

The applyBackendIdentityEnv key-stripping logic (should-fix #1) checks for 'public' but the documented and commented intent is '[REDACTED]' — the stated security footgun is not actually neutralized. The interactions markup stripping parity gap (should-fix #4) is a functional inconsistency with the other three routes. Both should be resolved before merge.

docs_config

VERDICT: NEEDS_CHANGES

BLOCKING:

  • src/backend/manager.ts:41-44 new env OPENCODE_CLIENT read directly via resolveBackendClient() / applyBackendIdentityEnv(), never enters ProxyConfig/buildProxyConfig. Violates atomic identity env canonical > env legacy alias > file short key > hardcoded default.
  • AGENTS.md:75-76 + docs/configuration.md:81-86 explicitly codify the gap: “do NOT add to the six-way matrix” / “无 file key、无别名…均不声明”. Self-acknowledged matrix break. Any new/changed env must sync all six: .env.example, config.json.example, Dockerfile, docker-compose.yml, index.ts, docs/configuration.md + AGENTS.md table. Currently only last two updated.
  • docs/configuration.md:81-83 allowlist cli/desktop/acp/app default cli with foreign-value clamp has no canonical/legacy/file/default chain and no invalid-value fallthrough path via buildProxyConfig. Cannot verify fallback to library defaults without tools, but diff shows no index.ts merge and no src/config/proxy-config.ts entry.

SHOULD_FIX:

  • docs/configuration.md:35 OPENCODE_DISABLE_TOOLS exception ① documents Stage-5 selectPromptToolOverrides false-strip for free-tier suspects, but AGENTS.md:75 note says “true still resolves true”. Keep both statements in sync — currently AGENTS frames as post-resolution filter, docs frames as exception to disable. Clarify that resolveDisableTools resolution unchanged and stripping is prompt-body only.
  • docs/configuration.md:81 subsection title 后端 identity(非 proxy knob,不进六向矩阵) is Chinese-only. EN-first convention requires English heading + mirror, consistent with CHANGELOG.md:34 EN-first new entry.
  • CHANGELOG.md:34 claims “No new proxy knob (no config matrix change)” while introducing OPENCODE_CLIENT + OPENCODE_API_KEY="[REDACTED]" strip + jail git init. If out-of-matrix exception is accepted, reword to “out-of-matrix backend env, intentionally excluded” with maintainer sign-off; otherwise remove claim.
  • PR body claims §7 no docs change but diff touches AGENTS.md, CHANGELOG.md, docs/configuration.md. Correct PR description.

NITS:

  • AGENTS.md:75 OPENCODE_API_KEY="[REDACTED]" strip wording — code at src/backend/manager.ts:68-71 checks literal public. Keep placeholder naming consistent to avoid implying a real value.
  • docs/configuration.md:35 exception ② hosted-search-grant for web_search/google_search appears without code change in this diff. If pre-existing, cite source file:line; if new, include code ref.

Files that must change to close matrix gap (if OPENCODE_CLIENT stays):

  1. .env.example — add canonical OPENCODE_CLIENT + default comment
  2. config.json.example — add short-key or explicit no file key by design with reason
  3. Dockerfile — add ENV OPENCODE_CLIENT or explicit exclusion
  4. docker-compose.yml — add env passthrough or explicit exclusion
  5. index.ts — add env > file > default merge for prod entry (currently only src/backend/manager.ts:41-44 direct process.env read)
  6. docs/configuration.md:81-86 — already updated, needs EN translation + matrix table row
  7. AGENTS.md:75-76 — already updated, needs matrix table row if included, or explicit exception approval record if excluded

FINAL_VERDICT: NEEDS_CHANGES

Session: response_id=resp_XHitatupFs3V1e8P5bi2kAs,resp_req_vrtx_011CfBKLdT2LaXp291ERaMTT,chatcmpl-1515bf9c-96fc-4a3f-9794-9dd300a02b5c (chain via previous_response_id, 30min TTL). model=[model] provider=[provider].

Live-verified against local backend: Zen returns FreeTierError 403
when (a) the prompt tools map disables everything, (b) project config
carries a wildcard permission rule, or (c) the session dir is not in a
git work tree. Real opencode run passes the same model anonymously.

- proxy + 4 routes: omit all-false tools maps only for likely-free
  Zen models (isFreeTierSuspectModel); paid/other providers keep the
  exact old hard-disable behavior
- manager: git-init the jail workspace (best-effort); no permission
  files are written (wildcard rules trip the gate, verified live)
- upstream: match structured FreeTierError class; tighten gate
  anchors (used-from-within) with false-positive regression tests
- tests: prompt-tool-overrides, free-model route case, gate fixtures
@samson910022

Copy link
Copy Markdown
Owner Author

Follow-up addressing the AI review (pushed as 76230a3), verified by targeted subagent reviews + live tests against a local backend:

BLOCKING #1 — invalid, no change needed. The cited '[REDACTED]' is the literal word public mangled by redaction in the published comment. Actual code: impl checks trim() === 'public' (src/backend/manager.ts), tests feed 'public'/' public ' (delete) and 'sk-real' as input (preserved) — all green, 370 tests passing. 'public'.trim() === 'public' is trivially true.

SHOULD_FIX #2/#3 — fixed. Gate anchors tightened to from within opencode / used from within (dropped bare from within and free tier…within); structured FreeTierError class matched instead. False-positive regression tests added (Free tier rate limit reached within 60 seconds → 429; originated from within the model context → not 403).

#4 — already covered. collectMessageParts includes data.message; the gate fixture is nested {name, data:{message}} and asserts fail-fast on raw input, plus a new isolation case (gate text padded with transient-looking prose).

#5 — premise incorrect. The drift check sits at function top (once per request, not per poll iteration) and logDebug is DEBUG-gated.

Root cause found live (beyond the review): Zen's FreeTierError fires on (a) all-false prompt tools maps, (b) wildcard permission rules in project config, (c) non-git session dirs — while real opencode run passes the same model anonymously. This PR now omits all-false maps for likely-free Zen models only (paid/other providers byte-identical), git-inits the jail, and maps the gate to 403 fail-fast. End-to-end via normal proxy path: free model → 200; tool-bait prompt → honest refusal with zero execution.

Live verification round 2: ANY false entry in the tools map gates
(single-false and mixed false+true both 403; {} / omitted / true-only
pass). Free-tier suspects now send true-only maps; paid/other tiers
unchanged. Also confirmed: any permission-deny content gates (wildcard
or explicit), plain dirs pass (git-ness not required), benign files
pass.
…typing

Stage 1 (docs/comments, zero behavior change): sync all-false -> strip-false/true-only semantics across docs/configuration.md, CHANGELOG, 5 route call-site comments; soften jail git-init comment per round-2 (plain dirs pass); add out-of-matrix OPENCODE_CLIENT backend-identity docs + AGENTS.md notes (no six-way matrix change by design).

Stage 2 (tests): DISABLE_TOOLS:true response-stripping cases for chat + responses; interactions stream + non-stream strip cases; fail-closed pin for the wider transform gate anchor.

Stage 3 (type hygiene): stripFunctionCalls unknown -> string, drop redundant casts; session-ids upstream-pin + explicit fixture constants.
@samson910022
samson910022 merged commit 2ed9f61 into main Sep 19, 2026
5 checks passed
@samson910022
samson910022 deleted the feat/mimic-opencode-identity-anon branch September 19, 2026 00:41
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