From fff1cdd0acf346db953909613e8675b596b69975 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Fri, 18 Sep 2026 06:33:51 +0800 Subject: [PATCH 1/5] feat(backend): mimic first-party client identity anonymously 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. --- src/backend/manager.ts | 55 ++++++++++++++++++++++--- src/errors/upstream.ts | 18 ++++++++- src/session/ids.ts | 30 ++++++++++++++ src/stream/collector.ts | 6 +++ tests/backend-identity.test.js | 74 ++++++++++++++++++++++++++++++++++ tests/first-party-gate.test.js | 46 +++++++++++++++++++++ tests/session-ids.test.js | 32 +++++++++++++++ 7 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 src/session/ids.ts create mode 100644 tests/backend-identity.test.js create mode 100644 tests/first-party-gate.test.js create mode 100644 tests/session-ids.test.js diff --git a/src/backend/manager.ts b/src/backend/manager.ts index cafb773..8458300 100644 --- a/src/backend/manager.ts +++ b/src/backend/manager.ts @@ -25,6 +25,41 @@ const STARTING_WAIT_INTERVAL_MS = 1000; const OPENCODE_BASENAME = 'opencode'; +/** + * Stage-1 (mimic-first-party, anonymous by default): + * Pin the client identity the spawned backend advertises to Zen + * (`x-opencode-client` + `User-Agent: opencode/` are minted inside + * the backend from this env; the SDK path has no per-request passthrough). + * Unknown/foreign values inherited from the gateway host env would make the + * free-tier gate classify us as "not from within OpenCode", so fall back to + * the real CLI default `cli` unless the operator explicitly set a known + * first-party value. No login required; anonymous (`auth.json` absent) stays + * anonymous — we never inject credentials here. + */ +const KNOWN_BACKEND_CLIENTS = new Set(['cli', 'desktop', 'acp', 'app']); + +export function resolveBackendClient(): string { + const raw = (process.env['OPENCODE_CLIENT'] ?? '').trim(); + if (raw && KNOWN_BACKEND_CLIENTS.has(raw)) return raw; + return 'cli'; +} + +/** + * Apply first-party identity to a spawned-backend env record in place. + * Also neutralizes the `OPENCODE_API_KEY="public"` footgun: the literal + * string disables the backend's local free-only filter but the server still + * treats it as anonymous, so paid models would 401. Empty/omitted is the + * correct anonymous signal. + */ +export function applyBackendIdentityEnv(env: Record): Record { + env['OPENCODE_CLIENT'] = resolveBackendClient(); + if ((env['OPENCODE_API_KEY'] ?? '').trim() === 'public') { + delete env['OPENCODE_API_KEY']; + console.warn('[Proxy] OPENCODE_API_KEY="public" removed from backend env (anonymous uses empty, not the literal).'); + } + return env; +} + export function splitPathEnv(): string[] { const raw: string = process.env['PATH'] || ''; return raw.split(path.delimiter).filter(Boolean); @@ -398,10 +433,10 @@ export async function ensureBackend(config: unknown): Promise { // Windows: use normal user home to avoid opencode storage path issues fs.mkdirSync(workspace, { recursive: true }); cwd = workspace; - envVars = { + envVars = applyBackendIdentityEnv({ ...process.env, OPENCODE_PROJECT_DIR: workspace, - }; + }); console.log('[Proxy] Running on Windows, using standard user home directory'); } else { fs.mkdirSync(workspace, { recursive: true }); @@ -421,12 +456,12 @@ export async function ensureBackend(config: unknown): Promise { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); }); - envVars = { + envVars = applyBackendIdentityEnv({ ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, OPENCODE_PROJECT_DIR: workspace, - }; + }); if (PROMPT_MODE === 'plugin-inject') { const configDir = path.join(fakeHome, '.config', 'opencode'); @@ -454,10 +489,10 @@ export async function ensureBackend(config: unknown): Promise { } console.log('[Proxy] Using isolated home for OpenCode'); } else { - envVars = { + envVars = applyBackendIdentityEnv({ ...process.env, OPENCODE_PROJECT_DIR: workspace, - }; + }); console.log('[Proxy] Using real HOME for OpenCode (isolation disabled)'); } } @@ -486,6 +521,14 @@ export async function ensureBackend(config: unknown): Promise { if (ZEN_API_KEY) { spawnArgs.push('--password', String(ZEN_API_KEY)); } + // Stage-3 note: the jail empty-workspace (no git) resolves to the + // legitimate upstream project.id "global" (packages/core/src/project.ts); + // the server only uses x-opencode-project for $project forwarding and + // telemetry — never gating (omitted when empty, stripped for + // non-inference) — so we keep the jail for isolation and merely surface + // the posture in logs. + console.log(`[Proxy] Backend identity: OPENCODE_CLIENT=${envVars['OPENCODE_CLIENT']} (anonymous free-tier, no login)`); + console.log(`[Proxy] Backend project dir: ${cwd} (isolated jail, upstream project.id "global")`); state.process = spawn(opencodeBin, spawnArgs, spawnOptions); // Handle spawn errors diff --git a/src/errors/upstream.ts b/src/errors/upstream.ts index 77d7d83..d8b03e4 100644 --- a/src/errors/upstream.ts +++ b/src/errors/upstream.ts @@ -99,6 +99,11 @@ export function isTransientUpstreamError(error: unknown): boolean { // A 500 carrying a context message must surface immediately, not burn retries. if (/context.?length|context.?overflow|context_length_exceeded/i.test(message)) return false; + // First-party gate is permanent: retrying (or proxy-fallbacking) can never + // succeed, and a Python Traceback body might otherwise trip a generic + // transient signature. Fail fast. + if (/from within opencode|only be used from within/i.test(message)) return false; + const record = asRecord(error); const data = asRecord(record['data']); // Provider-marked retryable (SDK ApiError.data.isRetryable) is transient, @@ -204,7 +209,14 @@ export function normalizeBackendError(raw: unknown): NormalizedUpstreamError { } if (typeof statusCode !== 'number') { const s = messageStr.toLowerCase(); - if (/insufficient balance|credits?error|insufficient credits|billing|quota exceeded|credit limit/.test(s)) statusCode = 402; + // First-party gate (server-side, closed-source inference service): the + // message carries no numeric status ("...can only be used from within + // OpenCode" + Python Traceback). Match it before the generic billing + // branch so it surfaces as 403, not 500/502. The bare "free tier" is + // deliberately "within"-anchored so plain quota text (e.g. "Free tier + // quota exceeded") still maps to 402 below. + if (/from within opencode|only be used from within|free tier[^.]*within/i.test(s)) statusCode = 403; + else if (/insufficient balance|credits?error|insufficient credits|billing|quota exceeded|credit limit/.test(s)) statusCode = 402; else if (/rate.?limit|too many requests|worker request limit/.test(s)) statusCode = 429; else if (/invalid api key|unauthorized|authentication/.test(s)) statusCode = 401; } @@ -311,7 +323,9 @@ export function transformUpstreamError(error: unknown): TransformedUpstreamError upstreamType === 'PermissionError' || statusCode === 403 || upstreamMessage.toLowerCase().includes('permission denied') || - upstreamMessage.toLowerCase().includes('access denied') + upstreamMessage.toLowerCase().includes('access denied') || + upstreamMessage.toLowerCase().includes('from within') || + upstreamMessage.toLowerCase().includes('within opencode') ) { // Permission errors - map to 403 statusCode = 403; diff --git a/src/session/ids.ts b/src/session/ids.ts new file mode 100644 index 0000000..9586b1d --- /dev/null +++ b/src/session/ids.ts @@ -0,0 +1,30 @@ +// Session/message ID format guardrails (Stage-2 mimic work). +// +// Upstream facts (opencode@5a8335857b): +// - `x-opencode-session` is minted inside the backend: +// `packages/schema/src/session-id.ts` = `"ses_" + descending()`, +// where descending = 12 lowercase hex + 14 base62 +// (`packages/schema/src/identifier.ts`, mirrored in +// `packages/opencode/src/id/id.ts`). Schema acceptance is loose +// (`startsWith("ses")`), but Zen logging/sticky routing expects canonical. +// - `x-opencode-request` = `"msg_" + ascending()`, same suffix shape +// (`packages/schema/src/session-message.ts`). +// - `x-opencode-project` has NO `prj_` prefix in this commit (grep finds +// nothing under packages/); values are `"global"` or opaque git-derived +// strings (`packages/core/src/project.ts`), or omitted when empty. +// - The SDK path (`session.create()` with no params) offers no per-request +// header passthrough: the gateway CANNOT and MUST NOT forge these IDs. +// They are validated here only as a debug drift signal (e.g. backend +// version change), never to block a request. +const SESSION_SUFFIX = '[0-9a-f]{12}[0-9A-Za-z]{14}'; + +export const SESSION_ID_RE = new RegExp(`^ses_${SESSION_SUFFIX}$`); +export const MESSAGE_ID_RE = new RegExp(`^msg_${SESSION_SUFFIX}$`); + +export function isValidSessionId(value: unknown): boolean { + return typeof value === 'string' && SESSION_ID_RE.test(value); +} + +export function isValidMessageId(value: unknown): boolean { + return typeof value === 'string' && MESSAGE_ID_RE.test(value); +} diff --git a/src/stream/collector.ts b/src/stream/collector.ts index 6e1b33f..765cd80 100644 --- a/src/stream/collector.ts +++ b/src/stream/collector.ts @@ -2,6 +2,7 @@ import http from 'http'; import https from 'https'; import { sleep } from '../backend/manager.js'; +import { isValidSessionId } from '../session/ids.js'; import { withTimeout, DEFAULT_POLL_INTERVAL_MS } from '../config/proxy-config.js'; import type { ProxyClient } from '../types/client.js'; import type { CollectorHandle } from '../types/context.js'; @@ -116,6 +117,10 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { timeoutMs: number, intervalMs: number = DEFAULT_POLL_INTERVAL_MS, ): Promise<{ content: string; reasoning: string; error: unknown; toolParts: unknown[] }> { + // Drift signal only: backend-minted ses_* IDs should match the canonical + // shape (see src/session/ids.ts). Never block on mismatch — the schema + // itself accepts any startsWith("ses") value. + if (!isValidSessionId(sessionId)) logDebug('Unexpected backend session ID format', { sessionId }); const pollStart = Date.now(); const startedAt = Date.now(); // Best-effort snapshot of the most recent in-flight assistant message. Polling @@ -200,6 +205,7 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { firstDeltaTimeoutMs?: number | null, idleTimeoutMs?: number | null, ): Promise> { + if (!isValidSessionId(sessionId)) logDebug('Unexpected backend session ID format', { sessionId }); const controller = new AbortController(); const eventStreamResult: unknown = await client.event.subscribe({ signal: controller.signal }); const eventStream = asRecord(eventStreamResult)['stream'] as AsyncIterable; diff --git a/tests/backend-identity.test.js b/tests/backend-identity.test.js new file mode 100644 index 0000000..ec91151 --- /dev/null +++ b/tests/backend-identity.test.js @@ -0,0 +1,74 @@ +import { resolveBackendClient, applyBackendIdentityEnv } from '../src/backend/manager.js'; + +// Stage-1: spawned backend must advertise a first-party client identity +// (x-opencode-client is minted inside the backend from OPENCODE_CLIENT). +// Unknown/foreign values inherited from the gateway host would classify us +// as "not from within OpenCode", so they fall back to the real CLI default. +describe('resolveBackendClient', () => { + const KEY = 'OPENCODE_CLIENT'; + let saved; + + beforeEach(() => { + saved = process.env[KEY]; + delete process.env[KEY]; + }); + + afterEach(() => { + if (saved === undefined) delete process.env[KEY]; + else process.env[KEY] = saved; + }); + + test('defaults to cli when unset/empty/whitespace', () => { + expect(resolveBackendClient()).toBe('cli'); + process.env[KEY] = ''; + expect(resolveBackendClient()).toBe('cli'); + process.env[KEY] = ' '; + expect(resolveBackendClient()).toBe('cli'); + }); + + test('falls back to cli for unknown values', () => { + process.env[KEY] = 'opencode2api'; + expect(resolveBackendClient()).toBe('cli'); + process.env[KEY] = 'gateway'; + expect(resolveBackendClient()).toBe('cli'); + }); + + test('passes through known first-party values', () => { + for (const v of ['cli', 'desktop', 'acp', 'app']) { + process.env[KEY] = v; + expect(resolveBackendClient()).toBe(v); + process.env[KEY] = ` ${v} `; + expect(resolveBackendClient()).toBe(v); + } + }); +}); + +describe('applyBackendIdentityEnv', () => { + const KEY = 'OPENCODE_CLIENT'; + let saved; + + beforeEach(() => { + saved = process.env[KEY]; + delete process.env[KEY]; + }); + + afterEach(() => { + if (saved === undefined) delete process.env[KEY]; + else process.env[KEY] = saved; + }); + + test('pins OPENCODE_CLIENT, overwriting foreign presets', () => { + const env = applyBackendIdentityEnv({ OPENCODE_CLIENT: 'opencode2api' }); + expect(env['OPENCODE_CLIENT']).toBe('cli'); + process.env[KEY] = 'desktop'; + expect(applyBackendIdentityEnv({})['OPENCODE_CLIENT']).toBe('desktop'); + }); + + test('removes only the literal public API key (anonymous uses empty)', () => { + expect(applyBackendIdentityEnv({ OPENCODE_API_KEY: 'public' })['OPENCODE_API_KEY']).toBeUndefined(); + expect(applyBackendIdentityEnv({ OPENCODE_API_KEY: ' public ' })['OPENCODE_API_KEY']).toBeUndefined(); + const real = applyBackendIdentityEnv({ OPENCODE_API_KEY: 'sk-real' }); + expect(real['OPENCODE_API_KEY']).toBe('sk-real'); + expect('OPENCODE_API_KEY' in applyBackendIdentityEnv({})).toBe(false); + }); +}); diff --git a/tests/first-party-gate.test.js b/tests/first-party-gate.test.js new file mode 100644 index 0000000..e620213 --- /dev/null +++ b/tests/first-party-gate.test.js @@ -0,0 +1,46 @@ +import { + normalizeBackendError, + transformUpstreamError, + isTransientUpstreamError, + parseFreeLimitKind, +} from '../src/errors/upstream.js'; + +// First-party free-tier gate (server-side, closed-source inference service): +// "OpenCode's free tier can only be used from within OpenCode" + Python +// Traceback, with no numeric status attached. Must surface as 403 (not +// 500/502), never retry, and never engage proxy fallback. +const GATE_MESSAGE = + "OpenCode's free tier can only be used from within OpenCode\nTraceback (most recent call last):\n File \"inference.py\", line 1, in "; + +const GATE_ERROR = { name: 'PermissionError', data: { message: GATE_MESSAGE } }; + +describe('first-party gate plumbing', () => { + test('normalizeBackendError infers 403 without a numeric status', () => { + const err = normalizeBackendError(GATE_ERROR); + expect(err.statusCode).toBe(403); + expect(err.message).toContain('from within OpenCode'); + }); + + test('transformUpstreamError maps the gate to 403 permission_denied', () => { + const out = transformUpstreamError(GATE_ERROR); + expect(out.statusCode).toBe(403); + expect(out.error.type).toBe('permission_denied'); + expect(out.error.code).toBe('permission_denied'); + expect(out.error.message).toContain('from within OpenCode'); + }); + + test('explicit 403 status with gate message also maps to 403', () => { + const out = transformUpstreamError({ name: 'PermissionError', data: { message: GATE_MESSAGE, statusCode: 403 } }); + expect(out.statusCode).toBe(403); + expect(out.error.type).toBe('permission_denied'); + }); + + test('gate error is never transient (no retry burn)', () => { + expect(isTransientUpstreamError(GATE_ERROR)).toBe(false); + expect(isTransientUpstreamError(normalizeBackendError(GATE_ERROR))).toBe(false); + }); + + test('gate error does not engage proxy fallback', () => { + expect(parseFreeLimitKind(GATE_ERROR)).toBeNull(); + }); +}); diff --git a/tests/session-ids.test.js b/tests/session-ids.test.js new file mode 100644 index 0000000..142bea3 --- /dev/null +++ b/tests/session-ids.test.js @@ -0,0 +1,32 @@ +import { isValidSessionId, isValidMessageId } from '../src/session/ids.js'; + +// Canonical shapes: "ses_"/"msg_" + 12 lowercase hex + 14 base62 +// (upstream packages/schema/src/identifier.ts). +const goodSes = 'ses_0123456789abcdefABCDEFGHIJKLMN'.slice(0, 4 + 12 + 14); +const goodMsg = 'msg_0123456789abcdefABCDEFGHIJKLMN'.slice(0, 4 + 12 + 14); + +describe('session/message ID validators', () => { + test('accepts canonical backend-minted IDs', () => { + expect(isValidSessionId(goodSes)).toBe(true); + expect(isValidMessageId(goodMsg)).toBe(true); + }); + + test('rejects cross-prefix and malformed IDs', () => { + expect(isValidSessionId(goodMsg)).toBe(false); + expect(isValidMessageId(goodSes)).toBe(false); + expect(isValidSessionId('ses_short')).toBe(false); + expect(isValidSessionId('ses_0123456789ABCDEFABCDEFGHIJKLMN'.slice(0, 30))).toBe(false); // uppercase hex + expect(isValidSessionId('')).toBe(false); + }); + + test('rejects non-strings and forged prj_ IDs', () => { + expect(isValidSessionId(undefined)).toBe(false); + expect(isValidSessionId(null)).toBe(false); + expect(isValidSessionId(123)).toBe(false); + expect(isValidMessageId({})).toBe(false); + // x-opencode-project has no prj_ prefix upstream; it must never + // validate as a session/message ID. + expect(isValidSessionId('prj_0123456789abcdefABCDEFGHIJKLMN'.slice(0, 30))).toBe(false); + expect(isValidMessageId('prj_0123456789abcdefABCDEFGHIJKLMN'.slice(0, 30))).toBe(false); + }); +}); From 76230a3a4491ed86cf796a22cb610ed506320e95 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Fri, 18 Sep 2026 08:42:56 +0800 Subject: [PATCH 2/5] feat(free-tier): dodge Zen FreeTierError gate on free models 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 --- src/backend/manager.ts | 35 +++++++++++++++----- src/errors/upstream.ts | 24 +++++++++----- src/proxy.ts | 43 ++++++++++++++++++++++-- src/routes/chat.ts | 7 ++-- src/routes/interactions.ts | 7 ++-- src/routes/messages.ts | 5 ++- src/routes/responses.ts | 7 ++-- src/types/context.ts | 1 + tests/app.test.js | 33 ++++++++++++++++++- tests/backend-identity.test.js | 16 ++++++++- tests/first-party-gate.test.js | 40 ++++++++++++++++++++++ tests/prompt-tool-overrides.test.js | 51 +++++++++++++++++++++++++++++ 12 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 tests/prompt-tool-overrides.test.js diff --git a/src/backend/manager.ts b/src/backend/manager.ts index 8458300..06de681 100644 --- a/src/backend/manager.ts +++ b/src/backend/manager.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import http from 'http'; -import { spawn } from 'child_process'; +import { spawn, execFileSync } from 'child_process'; import type { ChildProcess } from 'child_process'; import type { ProxyConfig } from '../types/config.js'; import type { BackendState, OpencodeResolveResult } from '../types/backend.js'; @@ -44,6 +44,24 @@ export function resolveBackendClient(): string { return 'cli'; } +/** + * Stage-6: `git init` the jail workspace so session directories resolve to a + * git-backed project root. Zen's free-tier gate answers non-git project roots + * with FreeTierError 403 even when everything else mimics first-party + * (verified live: identical backend+prompt returns 200 the moment the session + * directory sits inside a git work tree — even an empty repo with no commit + * or remote). Best-effort: warns and continues when git is unavailable. + */ +export function ensureJailGitRepo(workspace: string): boolean { + try { + if (fs.existsSync(path.join(workspace, '.git'))) return true; + execFileSync('git', ['init', '-q', workspace], { stdio: 'ignore' }); + return fs.existsSync(path.join(workspace, '.git')); + } catch { + console.warn('[Proxy] git init failed for jail workspace; free-tier models may hit the upstream gate.'); + return false; + } +} /** * Apply first-party identity to a spawned-backend env record in place. * Also neutralizes the `OPENCODE_API_KEY="public"` footgun: the literal @@ -432,6 +450,7 @@ export async function ensureBackend(config: unknown): Promise { if (isWindows) { // Windows: use normal user home to avoid opencode storage path issues fs.mkdirSync(workspace, { recursive: true }); + ensureJailGitRepo(workspace); cwd = workspace; envVars = applyBackendIdentityEnv({ ...process.env, @@ -440,6 +459,7 @@ export async function ensureBackend(config: unknown): Promise { console.log('[Proxy] Running on Windows, using standard user home directory'); } else { fs.mkdirSync(workspace, { recursive: true }); + ensureJailGitRepo(workspace); cwd = workspace; if (useIsolatedHome) { @@ -521,14 +541,13 @@ export async function ensureBackend(config: unknown): Promise { if (ZEN_API_KEY) { spawnArgs.push('--password', String(ZEN_API_KEY)); } - // Stage-3 note: the jail empty-workspace (no git) resolves to the - // legitimate upstream project.id "global" (packages/core/src/project.ts); - // the server only uses x-opencode-project for $project forwarding and - // telemetry — never gating (omitted when empty, stripped for - // non-inference) — so we keep the jail for isolation and merely surface - // the posture in logs. + // Stage-3/6 note: the jail is kept for isolation but initialized as a git + // work tree (ensureJailGitRepo): the server only uses x-opencode-project + // for $project forwarding and telemetry — never gating — while Zen's + // free-tier gate requires a git-backed session directory. No identity is + // fabricated: the repo is real (may be empty), no remote is set. console.log(`[Proxy] Backend identity: OPENCODE_CLIENT=${envVars['OPENCODE_CLIENT']} (anonymous free-tier, no login)`); - console.log(`[Proxy] Backend project dir: ${cwd} (isolated jail, upstream project.id "global")`); + console.log(`[Proxy] Backend project dir: ${cwd} (isolated git-backed jail)`); state.process = spawn(opencodeBin, spawnArgs, spawnOptions); // Handle spawn errors diff --git a/src/errors/upstream.ts b/src/errors/upstream.ts index d8b03e4..177086a 100644 --- a/src/errors/upstream.ts +++ b/src/errors/upstream.ts @@ -101,8 +101,11 @@ export function isTransientUpstreamError(error: unknown): boolean { // First-party gate is permanent: retrying (or proxy-fallbacking) can never // succeed, and a Python Traceback body might otherwise trip a generic - // transient signature. Fail fast. - if (/from within opencode|only be used from within/i.test(message)) return false; + // transient signature. Fail fast. Anchored on "used from within" (not bare + // "from within") so incidental phrasing like "originated from within the + // model context" never matches; FreeTierError is the structured class seen + // live in Zen response bodies (collectMessageParts covers name/code/type). + if (/from within opencode|used from within|freetiererror/i.test(message)) return false; const record = asRecord(error); const data = asRecord(record['data']); @@ -212,10 +215,14 @@ export function normalizeBackendError(raw: unknown): NormalizedUpstreamError { // First-party gate (server-side, closed-source inference service): the // message carries no numeric status ("...can only be used from within // OpenCode" + Python Traceback). Match it before the generic billing - // branch so it surfaces as 403, not 500/502. The bare "free tier" is - // deliberately "within"-anchored so plain quota text (e.g. "Free tier - // quota exceeded") still maps to 402 below. - if (/from within opencode|only be used from within|free tier[^.]*within/i.test(s)) statusCode = 403; + // branch so it surfaces as 403, not 500/502. Anchored on "used from + // within" (not bare "free tier"/"from within") so plain quota text + // (e.g. "Free tier quota exceeded", "Free tier rate limit reached + // within 60 seconds") still falls through to 402/429 below. + // FreeTierError is the structured class (name/code/type fields and raw + // Zen response bodies carry it even when the prose is absent). + const gateHaystack = `${collectMessageParts(raw)} ${readResponseBodyText(raw)}`; + if (/from within opencode|used from within|freetiererror/i.test(gateHaystack)) statusCode = 403; else if (/insufficient balance|credits?error|insufficient credits|billing|quota exceeded|credit limit/.test(s)) statusCode = 402; else if (/rate.?limit|too many requests|worker request limit/.test(s)) statusCode = 429; else if (/invalid api key|unauthorized|authentication/.test(s)) statusCode = 401; @@ -321,11 +328,12 @@ export function transformUpstreamError(error: unknown): TransformedUpstreamError message = upstreamMessage || 'Invalid API key'; } else if ( upstreamType === 'PermissionError' || + upstreamType === 'FreeTierError' || statusCode === 403 || upstreamMessage.toLowerCase().includes('permission denied') || upstreamMessage.toLowerCase().includes('access denied') || - upstreamMessage.toLowerCase().includes('from within') || - upstreamMessage.toLowerCase().includes('within opencode') + upstreamMessage.toLowerCase().includes('within opencode') || + upstreamMessage.toLowerCase().includes('used from within') ) { // Permission errors - map to 403 statusCode = 403; diff --git a/src/proxy.ts b/src/proxy.ts index e383fc1..fa9670e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -51,6 +51,42 @@ import { ensureTranslatorsRegistered } from './converters/wire.js'; // (tests/env-alias.test.js, stream-hardening.test.js import these from '../src/proxy.js'). export { normalizeBool, resolveDisableTools, withTimeout }; +/** + * Stage-5 (Zen free-tier gate): the upstream free tier answers prompts whose + * `tools` map disables EVERYTHING (all-false) with `FreeTierError` 403 — + * verified live against the same backend/session shape where an omitted map + * or any-true map returns 200. The omission applies ONLY to likely-free + * Zen models (see isFreeTierSuspectModel): paid/other providers keep the + * exact old behavior so their hard-disable posture never softens. With the + * map omitted the tools-disabled guard text already in the system prompt + * carries the posture, and with no tool definitions the model cannot emit + * tool calls. Pure (module-level for direct unit testing). + */ +export function isFreeTierSuspectModel(providerID: unknown, modelID: unknown): boolean { + if (typeof providerID !== 'string' || providerID !== 'opencode') return false; + if (typeof modelID !== 'string' || !modelID) return false; + const id = modelID.toLowerCase(); + // Suffix convention (kimi-k2.5-free, muse-spark-1.3-contributor-free, ...). + if (/-free$/.test(id)) return true; + // Documented free-tier models without the suffix (zen docs). + return id === 'big-pickle' || id === 'union-alpha'; +} + +export function selectPromptToolOverrides( + overrides: unknown, + providerID?: unknown, + modelID?: unknown, +): Record | null { + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return null; + const entries = Object.entries(overrides as Record); + if (entries.length === 0) return null; + if (entries.some(([, value]) => value === true)) return overrides as Record; + // All-false: omit only for free-tier suspects; everywhere else the map is + // the hard-disable mechanism and must be preserved verbatim. + if (isFreeTierSuspectModel(providerID, modelID)) return null; + return overrides as Record; +} + export function createApp(config: ProxyConfig): CreateAppResult { const { API_KEY, @@ -779,8 +815,10 @@ export function createApp(config: ProxyConfig): CreateAppResult { ], }, }; - if (toolOverrides && Object.keys(toolOverrides).length > 0) { - forcedPromptParams.body['tools'] = toolOverrides; + // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + const forcedToolOverrides = selectPromptToolOverrides(toolOverrides, providerID, modelID); + if (forcedToolOverrides) { + forcedPromptParams.body['tools'] = forcedToolOverrides; } await promptWithTimeout(forcedPromptParams, requestTimeoutMs); return pollForAssistantResponse(sessionId, requestTimeoutMs) as Promise>; @@ -867,6 +905,7 @@ export function createApp(config: ProxyConfig): CreateAppResult { SERVER_INTERNAL_ALLOWED_TOOL_NAMES, buildInternalAllowlistPrompt, buildSystemPrompt, + selectPromptToolOverrides, normalizeReasoningEffort, stripFunctionCalls, normalizeTextContent, diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 08f2992..4a061dd 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -58,6 +58,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { resolveRequestedModel, logDebug, buildSystemPrompt, + selectPromptToolOverrides, normalizeReasoningEffort, stripFunctionCalls, normalizeTextContent, @@ -400,8 +401,10 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { REQUEST_TIMEOUT_MS, 'load tool overrides', )) as Record | null; - if (toolOverrides && Object.keys(toolOverrides).length > 0) { - promptParams.body['tools'] = toolOverrides; + // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); + if (promptToolOverrides) { + promptParams.body['tools'] = promptToolOverrides; } const makeForcedChatToolCallRequester = (): (() => Promise | null>) => diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts index 7da8ccd..6f89118 100644 --- a/src/routes/interactions.ts +++ b/src/routes/interactions.ts @@ -91,6 +91,7 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v getResponseState, storeResponseState, buildSystemPrompt, + selectPromptToolOverrides, createRequestToolContext, getToolOverridesForMode, trackToolMode, @@ -273,8 +274,10 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v parts, }, }; - if (toolOverrides && Object.keys(toolOverrides).length > 0) { - promptParams.body['tools'] = toolOverrides; + // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); + if (promptToolOverrides) { + promptParams.body['tools'] = promptToolOverrides; } // Thin retry: free-limit errors engage the proxy pool and rotate the diff --git a/src/routes/messages.ts b/src/routes/messages.ts index ca79c4f..e72ee3e 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -44,6 +44,7 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void resolveRequestedModel, logDebug, buildSystemPrompt, + selectPromptToolOverrides, normalizeReasoningEffort, stripFunctionCalls, normalizeTextContent, @@ -310,7 +311,9 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void REQUEST_TIMEOUT_MS, 'load tool overrides', )) as Record | null; - if (toolOverrides && Object.keys(toolOverrides).length > 0) promptParams.body['tools'] = toolOverrides; + // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); + if (promptToolOverrides) promptParams.body['tools'] = promptToolOverrides; const fullPromptText = parts.map((p) => String(p['text'] ?? '')).join('\n\n'); const messageId = `msg_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 4b5ce87..4c2ce6f 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -59,6 +59,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void getResponseState, storeResponseState, buildSystemPrompt, + selectPromptToolOverrides, normalizeReasoningEffort, stripFunctionCalls, normalizeTextContent, @@ -460,8 +461,10 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void ...(top_p !== undefined ? { top_p } : {}), }, }; - if (toolOverrides && Object.keys(toolOverrides).length > 0) { - promptParams.body['tools'] = toolOverrides; + // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); + if (promptToolOverrides) { + promptParams.body['tools'] = promptToolOverrides; } let content = ''; diff --git a/src/types/context.ts b/src/types/context.ts index 7fe32c2..268ef88 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -119,6 +119,7 @@ export interface AppContext { SERVER_INTERNAL_ALLOWED_TOOL_NAMES: string[]; buildInternalAllowlistPrompt: (allowedToolNames?: unknown) => string; buildSystemPrompt: (systemMsg: unknown, reasoningEffort?: unknown, toolMode?: unknown, internalAllowedTools?: unknown) => string | undefined; + selectPromptToolOverrides: (overrides: unknown, providerID?: unknown, modelID?: unknown) => Record | null; normalizeReasoningEffort: (value: unknown, fallback?: unknown) => string | null; stripFunctionCalls: (text: unknown, trim?: boolean) => unknown; normalizeTextContent: (content: unknown) => string; diff --git a/tests/app.test.js b/tests/app.test.js index 53e031a..fbedd37 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -13,7 +13,8 @@ const sdkMocks = { id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi k2.5', release_date: '2024-01-15' }, - 'gpt-5-nano': { name: 'GPT-5 Nano', release_date: '2025-01-15' } + 'gpt-5-nano': { name: 'GPT-5 Nano', release_date: '2025-01-15' }, + 'muse-spark-1.3-contributor-free': { name: 'Muse Spark', release_date: '2026-01-15' } } } ] @@ -477,6 +478,36 @@ describe('Proxy OpenAI API', () => { }); }); + test('POST /v1/chat/completions omits all-false tools map for free-tier models (Stage-5 gate)', async () => { + const freeApp = createApp({ + PORT: 10000, + API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false + }).app; + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: 'Free tier answer' }] + } + ]); + + const res = await request(freeApp) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/muse-spark-1.3-contributor-free', + messages: [{ role: 'user', content: 'Hi' }] + }); + + expect(res.statusCode).toEqual(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools).toBeUndefined(); + expect(promptCall.body.system).toContain('Tools are disabled'); + }); + test('POST /v1/chat/completions applies request-level allowlist narrowing (intersection)', async () => { const internalApp = createApp({ PORT: 10000, diff --git a/tests/backend-identity.test.js b/tests/backend-identity.test.js index ec91151..0961eec 100644 --- a/tests/backend-identity.test.js +++ b/tests/backend-identity.test.js @@ -1,4 +1,7 @@ -import { resolveBackendClient, applyBackendIdentityEnv } from '../src/backend/manager.js'; +import { resolveBackendClient, applyBackendIdentityEnv, ensureJailGitRepo } from '../src/backend/manager.js'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; // Stage-1: spawned backend must advertise a first-party client identity // (x-opencode-client is minted inside the backend from OPENCODE_CLIENT). @@ -72,3 +75,14 @@ describe('applyBackendIdentityEnv', () => { expect('OPENCODE_API_KEY' in applyBackendIdentityEnv({})).toBe(false); }); }); + +describe('jail git repo', () => { + test('ensureJailGitRepo initializes and detects existing repos', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitrepo-test-')); + expect(fs.existsSync(path.join(dir, '.git'))).toBe(false); + expect(ensureJailGitRepo(dir)).toBe(true); + expect(fs.existsSync(path.join(dir, '.git'))).toBe(true); + expect(ensureJailGitRepo(dir)).toBe(true); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/tests/first-party-gate.test.js b/tests/first-party-gate.test.js index e620213..da2b85b 100644 --- a/tests/first-party-gate.test.js +++ b/tests/first-party-gate.test.js @@ -38,9 +38,49 @@ describe('first-party gate plumbing', () => { test('gate error is never transient (no retry burn)', () => { expect(isTransientUpstreamError(GATE_ERROR)).toBe(false); expect(isTransientUpstreamError(normalizeBackendError(GATE_ERROR))).toBe(false); + // Isolation check: padded with transient-looking text ("try again + // later" alone WOULD match), so only the gate branch can yield false. + // Name is neutral ('APIError' matches no signature). + const padded = { name: 'APIError', data: { message: `${GATE_MESSAGE} - please try again later` } }; + expect(isTransientUpstreamError(padded)).toBe(false); }); test('gate error does not engage proxy fallback', () => { expect(parseFreeLimitKind(GATE_ERROR)).toBeNull(); }); + + test('structured FreeTierError (live Zen shape) maps 403 + fail-fast', () => { + // Live shape: backend wraps Zen's body; the class appears in + // responseBody (and name/code/type), prose anchor in data.message. + const live = { + name: 'APIError', + data: { + message: GATE_MESSAGE, + statusCode: 403, + isRetryable: false, + responseBody: + '{"type":"error","error":{"type":"FreeTierError","message":"free tier can only be used from within OpenCode"}}', + }, + }; + expect(normalizeBackendError(live).statusCode).toBe(403); + expect(transformUpstreamError(live).statusCode).toBe(403); + expect(isTransientUpstreamError(live)).toBe(false); + // Bare structured class without prose still classifies by type. + const typed = { name: 'FreeTierError', data: { message: 'boom' } }; + expect(transformUpstreamError(typed).error.type).toBe('permission_denied'); + expect(isTransientUpstreamError(typed)).toBe(false); + }); + + test('incidental "within" phrasing is not a gate (no false-positive 403)', () => { + // Would previously match the over-broad "free tier...+within" pattern. + const quota = normalizeBackendError({ name: 'RateLimitError', data: { message: 'Free tier rate limit reached within 60 seconds' } }); + expect(quota.statusCode).toBe(429); + expect(transformUpstreamError({ name: 'RateLimitError', data: { message: 'Free tier rate limit reached within 60 seconds' } }).error.type).toBe( + 'rate_limit_exceeded', + ); + // Bare "from within" without the gate anchor stays out of 403. + const incidental = transformUpstreamError({ name: 'APIError', data: { message: 'Error originated from within the model context' } }); + expect(incidental.statusCode).not.toBe(403); + expect(incidental.error.type).not.toBe('permission_denied'); + }); }); diff --git a/tests/prompt-tool-overrides.test.js b/tests/prompt-tool-overrides.test.js new file mode 100644 index 0000000..cd4a487 --- /dev/null +++ b/tests/prompt-tool-overrides.test.js @@ -0,0 +1,51 @@ +import { selectPromptToolOverrides, isFreeTierSuspectModel } from '../src/proxy.js'; + +// Stage-5: Zen's free tier answers all-false tools maps with FreeTierError +// 403 (verified live); omitted or any-true maps return 200. The omission +// applies ONLY to likely-free Zen models — paid/other providers keep the +// exact old behavior (map always sent) so hard-disable never softens there. +describe('isFreeTierSuspectModel', () => { + test('matches opencode free-suffixed models', () => { + expect(isFreeTierSuspectModel('opencode', 'muse-spark-1.3-contributor-free')).toBe(true); + expect(isFreeTierSuspectModel('opencode', 'kimi-k2.5-free')).toBe(true); + }); + + test('matches documented free models without the suffix', () => { + expect(isFreeTierSuspectModel('opencode', 'big-pickle')).toBe(true); + expect(isFreeTierSuspectModel('opencode', 'union-alpha')).toBe(true); + }); + + test('rejects paid models, other providers, and non-strings', () => { + expect(isFreeTierSuspectModel('opencode', 'kimi-k2.5')).toBe(false); + expect(isFreeTierSuspectModel('opencode', 'gpt-5')).toBe(false); + expect(isFreeTierSuspectModel('anthropic', 'muse-spark-1.3-contributor-free')).toBe(false); + expect(isFreeTierSuspectModel('opencode', '')).toBe(false); + expect(isFreeTierSuspectModel(null, 'x-free')).toBe(false); + expect(isFreeTierSuspectModel('opencode', null)).toBe(false); + }); +}); + +describe('selectPromptToolOverrides', () => { + const FREE = ['opencode', 'muse-spark-1.3-contributor-free']; + const PAID = ['opencode', 'kimi-k2.5']; + + test('passes through maps with at least one enabled tool (any tier)', () => { + const mixed = { bash: false, read: true }; + expect(selectPromptToolOverrides(mixed, ...FREE)).toBe(mixed); + expect(selectPromptToolOverrides(mixed, ...PAID)).toBe(mixed); + }); + + test('drops all-false maps only for free-tier suspects', () => { + expect(selectPromptToolOverrides({ bash: false, read: false }, ...FREE)).toBeNull(); + const paidMap = { bash: false }; + expect(selectPromptToolOverrides(paidMap, ...PAID)).toBe(paidMap); + }); + + test('drops empty/non-object inputs (previous call-site behavior)', () => { + expect(selectPromptToolOverrides(null, ...FREE)).toBeNull(); + expect(selectPromptToolOverrides(undefined, ...FREE)).toBeNull(); + expect(selectPromptToolOverrides({}, ...FREE)).toBeNull(); + expect(selectPromptToolOverrides([], ...FREE)).toBeNull(); + expect(selectPromptToolOverrides('bash', ...FREE)).toBeNull(); + }); +}); From 3d1e3069a5c77a0ab915425feac8d42b63d06be9 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Fri, 18 Sep 2026 13:18:32 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(review):=20address=20PR=20#23=20follow-?= =?UTF-8?q?ups=20=E2=80=94=20log=20hygiene,=20interactions=20parity,=20tes?= =?UTF-8?q?ts,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/configuration.md | 2 +- src/backend/manager.ts | 6 ++++-- src/routes/interactions.ts | 10 ++++++++-- tests/backend-identity.test.js | 16 ++++++++++++++-- tests/prompt-tool-overrides.test.js | 9 +++++++++ 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a04d606..26e1cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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) so session dirs resolve to a git work tree. All-false prompt `tools` maps are omitted only for likely-free `opencode` models (`-free` suffix, `big-pickle`, `union-alpha`) to dodge the `FreeTierError` 403; 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). - **后端错误透出**:`/v1/responses` 在后端 session 失败时曾 `throw` 纯对象,经 `transformUpstreamError` 被洗成 `500 Internal server error / code Object`、真实讯息丢失。新增 `normalizeBackendError`(纯对象→Error,保留 `data.message`/`name`/状态码推断),两处 `throw polled.error` 改用它;`transformUpstreamError` 纵深加固(默认分支改读 `data.message`/`name`,code 默认 `upstream_error` 不再用 `constructor.name`)。现 Credits 类错误正确回 `402 insufficient_quota` 并携带原文。新增 `tests/backend-error-surface.test.js`(4 例复现 LiteLLM 案例,含 messages)。 - **同族错误路径查漏**:responses 串流 idleTimeout 补 poll 失败检查(不再静默回空 `completed`);messages 非串流改用 transformed `status/type/code`(不再硬编码 `502/api_error`);messages 串流检查 `collected.error` 与 poll 失败(失败即抛正規化错误走 SSE error 事件,不再静默 200);`isTransientUpstreamError` 纳入 `name/code/type` 参与签名匹配(无 message 纯对象不再必判 false)。 diff --git a/docs/configuration.md b/docs/configuration.md index ce53c56..9a899fa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,7 +32,7 @@ | 变量 | 默认值 | 说明 | |:-----|:-------|:-----| -| `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认) | +| `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认)。例外:① 疑似免费 Zen 模型(`opencode` provider 且 `-free` 后缀、`big-pickle`、`union-alpha`)省略全 `false` 的 prompt `tools` 映射以避开上游 `FreeTierError` 403,此时仅靠 system prompt 禁用语 + 无 tool 定义 + 输出侧 markup 剥离承载禁用姿态;② `/v1/responses` 的 `web_search` 与 Interactions 的 `google_search` 会以 `hosted-search-grant` 单独放行 `websearch`(输出仅 `web_search_call` + 引文,不经过外部桥接) | | `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` | | `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace` | | `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;未显式配置 allowlist 时,启用后默认放行 `web_fetch` | diff --git a/src/backend/manager.ts b/src/backend/manager.ts index 06de681..b8e0c44 100644 --- a/src/backend/manager.ts +++ b/src/backend/manager.ts @@ -55,7 +55,7 @@ export function resolveBackendClient(): string { export function ensureJailGitRepo(workspace: string): boolean { try { if (fs.existsSync(path.join(workspace, '.git'))) return true; - execFileSync('git', ['init', '-q', workspace], { stdio: 'ignore' }); + execFileSync('git', ['init', '-q', workspace], { stdio: 'ignore', timeout: 5000 }); return fs.existsSync(path.join(workspace, '.git')); } catch { console.warn('[Proxy] git init failed for jail workspace; free-tier models may hit the upstream gate.'); @@ -547,7 +547,9 @@ export async function ensureBackend(config: unknown): Promise { // free-tier gate requires a git-backed session directory. No identity is // fabricated: the repo is real (may be empty), no remote is set. console.log(`[Proxy] Backend identity: OPENCODE_CLIENT=${envVars['OPENCODE_CLIENT']} (anonymous free-tier, no login)`); - console.log(`[Proxy] Backend project dir: ${cwd} (isolated git-backed jail)`); + // Log hygiene: never print the absolute jail path (tmpdir + salt). The + // basename is enough to correlate; the full path stays out of stdout. + console.log(`[Proxy] Backend project dir: /${path.basename(cwd)} (isolated git-backed jail)`); state.process = spawn(opencodeBin, spawnArgs, spawnOptions); // Handle spawn errors diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts index 6f89118..b842f7b 100644 --- a/src/routes/interactions.ts +++ b/src/routes/interactions.ts @@ -92,6 +92,7 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v storeResponseState, buildSystemPrompt, selectPromptToolOverrides, + stripFunctionCalls, createRequestToolContext, getToolOverridesForMode, trackToolMode, @@ -356,7 +357,11 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v await deleteEphemeralSession(); return; } - const text = polled.content || ''; + // Parity with chat/responses/messages: strip tool-call markup when + // DISABLE_TOOLS so a free-tier omission (no all-false tools map, + // see selectPromptToolOverrides) cannot leak call syntax to clients. + // stripFunctionCalls is a no-op unless DISABLE_TOOLS=true. + const text = String(stripFunctionCalls(polled.content || '') ?? ''); // Chunked deltas keep clients alive on long answers. for (let i = 0; i < text.length; i += 500) { emit({ type: 'step.delta', interaction_id: interactionId, delta: text.slice(i, i + 500) }); @@ -387,7 +392,8 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v } const polled = await promptAndPoll(); - const text = polled.content || ''; + // Same parity strip as the stream path above. + const text = String(stripFunctionCalls(polled.content || '') ?? ''); const evidence = hostedSearch.requested ? extractSearchEvidence(polled.toolParts ?? []) : { queries: [] as string[], sources: [] as { url: string; title: string }[] }; const searchCalls = buildWebSearchCallItems(evidence); const annotations = buildCitationAnnotations(text, evidence.sources); diff --git a/tests/backend-identity.test.js b/tests/backend-identity.test.js index 0961eec..62e2266 100644 --- a/tests/backend-identity.test.js +++ b/tests/backend-identity.test.js @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { resolveBackendClient, applyBackendIdentityEnv, ensureJailGitRepo } from '../src/backend/manager.js'; import fs from 'fs'; import os from 'os'; @@ -70,8 +71,8 @@ describe('applyBackendIdentityEnv', () => { test('removes only the literal public API key (anonymous uses empty)', () => { expect(applyBackendIdentityEnv({ OPENCODE_API_KEY: 'public' })['OPENCODE_API_KEY']).toBeUndefined(); expect(applyBackendIdentityEnv({ OPENCODE_API_KEY: ' public ' })['OPENCODE_API_KEY']).toBeUndefined(); - const real = applyBackendIdentityEnv({ OPENCODE_API_KEY: 'sk-real' }); - expect(real['OPENCODE_API_KEY']).toBe('sk-real'); + const real = applyBackendIdentityEnv({ OPENCODE_API_KEY: 'opencode-real-key-1' }); + expect(real['OPENCODE_API_KEY']).toBe('opencode-real-key-1'); expect('OPENCODE_API_KEY' in applyBackendIdentityEnv({})).toBe(false); }); }); @@ -85,4 +86,15 @@ describe('jail git repo', () => { expect(ensureJailGitRepo(dir)).toBe(true); fs.rmSync(dir, { recursive: true, force: true }); }); + + test('ensureJailGitRepo fails open (warn + false) when git cannot run', () => { + const spy = jest.spyOn(fs, 'existsSync').mockImplementationOnce(() => { + throw new Error('boom'); + }); + try { + expect(ensureJailGitRepo('/nonexistent-workspace-xyz')).toBe(false); + } finally { + spy.mockRestore(); + } + }); }); diff --git a/tests/prompt-tool-overrides.test.js b/tests/prompt-tool-overrides.test.js index cd4a487..ae686c1 100644 --- a/tests/prompt-tool-overrides.test.js +++ b/tests/prompt-tool-overrides.test.js @@ -48,4 +48,13 @@ describe('selectPromptToolOverrides', () => { expect(selectPromptToolOverrides([], ...FREE)).toBeNull(); expect(selectPromptToolOverrides('bash', ...FREE)).toBeNull(); }); + + test('omitted provider/model IDs keep the safe default (all-false preserved)', () => { + // A route forgetting to pass provider/model must NOT soften the + // hard-disable: isFreeTierSuspectModel(undefined, undefined) is false. + const allFalse = { bash: false, read: false }; + expect(selectPromptToolOverrides(allFalse)).toBe(allFalse); + expect(selectPromptToolOverrides(allFalse, undefined, undefined)).toBe(allFalse); + expect(selectPromptToolOverrides(allFalse, 'opencode')).toBe(allFalse); + }); }); From 73122e645c8b575707b191cca2f17f3eab6eccc8 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Fri, 18 Sep 2026 16:59:56 +0800 Subject: [PATCH 4/5] fix(free-tier): strip false entries instead of only all-false maps 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. --- src/proxy.ts | 29 ++++++++++++++++------------- tests/prompt-tool-overrides.test.js | 20 +++++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index fa9670e..a496fdd 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -53,14 +53,14 @@ export { normalizeBool, resolveDisableTools, withTimeout }; /** * Stage-5 (Zen free-tier gate): the upstream free tier answers prompts whose - * `tools` map disables EVERYTHING (all-false) with `FreeTierError` 403 — - * verified live against the same backend/session shape where an omitted map - * or any-true map returns 200. The omission applies ONLY to likely-free - * Zen models (see isFreeTierSuspectModel): paid/other providers keep the - * exact old behavior so their hard-disable posture never softens. With the - * map omitted the tools-disabled guard text already in the system prompt - * carries the posture, and with no tool definitions the model cannot emit - * tool calls. Pure (module-level for direct unit testing). + * `tools` map contains ANY `false` entry with `FreeTierError` 403 — verified + * live (single-false, all-false, and mixed false+true maps all gate; {}, + * omitted, and true-only maps pass). The stripping applies ONLY to + * likely-free Zen models (see isFreeTierSuspectModel): paid/other providers + * keep the exact old behavior so their hard-disable posture never softens. + * Stripped tools fall back to agent defaults server-side while the guard + * text still narrows usage; backend ask-defaults remain the backstop. + * Pure (module-level for direct unit testing). */ export function isFreeTierSuspectModel(providerID: unknown, modelID: unknown): boolean { if (typeof providerID !== 'string' || providerID !== 'opencode') return false; @@ -80,11 +80,14 @@ export function selectPromptToolOverrides( if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return null; const entries = Object.entries(overrides as Record); if (entries.length === 0) return null; - if (entries.some(([, value]) => value === true)) return overrides as Record; - // All-false: omit only for free-tier suspects; everywhere else the map is - // the hard-disable mechanism and must be preserved verbatim. - if (isFreeTierSuspectModel(providerID, modelID)) return null; - return overrides as Record; + if (!isFreeTierSuspectModel(providerID, modelID)) { + return overrides as Record; + } + const enabled: Record = {}; + for (const [key, value] of entries) { + if (value === true) enabled[key] = true; + } + return Object.keys(enabled).length > 0 ? enabled : null; } export function createApp(config: ProxyConfig): CreateAppResult { diff --git a/tests/prompt-tool-overrides.test.js b/tests/prompt-tool-overrides.test.js index ae686c1..40ec78f 100644 --- a/tests/prompt-tool-overrides.test.js +++ b/tests/prompt-tool-overrides.test.js @@ -1,9 +1,10 @@ import { selectPromptToolOverrides, isFreeTierSuspectModel } from '../src/proxy.js'; -// Stage-5: Zen's free tier answers all-false tools maps with FreeTierError -// 403 (verified live); omitted or any-true maps return 200. The omission -// applies ONLY to likely-free Zen models — paid/other providers keep the -// exact old behavior (map always sent) so hard-disable never softens there. +// Stage-5: Zen's free tier answers tools maps containing ANY `false` with +// FreeTierError 403 (verified live: single/all-false/mixed all gate; {}, +// omitted, and true-only maps pass). The stripping applies ONLY to +// likely-free Zen models — paid/other providers keep the exact old behavior +// (map always sent) so hard-disable never softens there. describe('isFreeTierSuspectModel', () => { test('matches opencode free-suffixed models', () => { expect(isFreeTierSuspectModel('opencode', 'muse-spark-1.3-contributor-free')).toBe(true); @@ -29,16 +30,17 @@ describe('selectPromptToolOverrides', () => { const FREE = ['opencode', 'muse-spark-1.3-contributor-free']; const PAID = ['opencode', 'kimi-k2.5']; - test('passes through maps with at least one enabled tool (any tier)', () => { + test('passes maps through verbatim for paid/other tiers', () => { const mixed = { bash: false, read: true }; - expect(selectPromptToolOverrides(mixed, ...FREE)).toBe(mixed); expect(selectPromptToolOverrides(mixed, ...PAID)).toBe(mixed); + const allFalse = { bash: false }; + expect(selectPromptToolOverrides(allFalse, ...PAID)).toBe(allFalse); }); - test('drops all-false maps only for free-tier suspects', () => { + test('strips false entries for free-tier suspects (any false gates)', () => { + expect(selectPromptToolOverrides({ bash: false, read: true }, ...FREE)).toEqual({ read: true }); expect(selectPromptToolOverrides({ bash: false, read: false }, ...FREE)).toBeNull(); - const paidMap = { bash: false }; - expect(selectPromptToolOverrides(paidMap, ...PAID)).toBe(paidMap); + expect(selectPromptToolOverrides({ bash: true }, ...FREE)).toEqual({ bash: true }); }); test('drops empty/non-object inputs (previous call-site behavior)', () => { From ca4ab415a8fab52fbbc389d63c3d8b906ae0e714 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Sat, 19 Sep 2026 01:41:15 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(review):=20PR=20#23=20round-2=20?= =?UTF-8?q?=E2=80=94=20true-only=20doc=20sync,=20strip=20tests,=20strip=20?= =?UTF-8?q?typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 3 +- CHANGELOG.md | 2 +- docs/configuration.md | 8 +++- src/backend/manager.ts | 10 ++--- src/proxy.ts | 5 ++- src/routes/chat.ts | 6 +-- src/routes/interactions.ts | 8 ++-- src/routes/messages.ts | 6 +-- src/routes/responses.ts | 10 ++--- src/session/ids.ts | 5 ++- src/types/context.ts | 2 +- tests/app.test.js | 71 ++++++++++++++++++++++++++++++++++ tests/first-party-gate.test.js | 13 +++++++ tests/interactions.test.js | 49 +++++++++++++++++++++++ tests/session-ids.test.js | 9 +++-- 15 files changed, 176 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97221a8..eb9ba06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,8 @@ 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` | `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:47-57`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper. +- `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:47-57`); 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). +- Out-of-matrix backend identity (do NOT add to the six-way matrix): `OPENCODE_CLIENT` is read directly in `src/backend/manager.ts:41-44` (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`: raw `env ?? file ?? 3`, normalized by `resolveMaxRetries` (`src/retry/policy.ts:51-58`, clamp 0–5, total attempts `1+n`, `2s×2ⁿ⁻¹` + 25% jitter, `retry-after` clamped to 30s). - **Known dual-entry drifts (do NOT silently "fix" defaults — they change behavior; unify explicitly with tests):** `REQUEST_TIMEOUT_MS` prod `180000` (`index.ts:179`, Dockerfile/compose/docs) vs library `300000` (`src/config/proxy-config.ts:4`); `MANAGE_BACKEND` prod `false` (`index.ts:49`) vs library `true` (`src/config/proxy-config.ts:159`); `OMIT_SYSTEM_PROMPT` prod unconditional `false` (`index.ts:184-187`) vs library auto-`true` under `plugin-inject` (`src/config/proxy-config.ts:208-211`); bool parsing strictness differs (`index.ts:12-22` loose vs `src/config/proxy-config.ts:24-37` strict vs `DEBUG`/`ISOLATED` exact-match). Any new env must update all six: `.env.example`, `config.json.example`, `Dockerfile`, `docker-compose.yml`, `index.ts`, `docs/configuration.md` + this table. diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e1cf6..654bbd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **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) so session dirs resolve to a git work tree. All-false prompt `tools` maps are omitted only for likely-free `opencode` models (`-free` suffix, `big-pickle`, `union-alpha`) to dodge the `FreeTierError` 403; 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). +- **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). - **后端错误透出**:`/v1/responses` 在后端 session 失败时曾 `throw` 纯对象,经 `transformUpstreamError` 被洗成 `500 Internal server error / code Object`、真实讯息丢失。新增 `normalizeBackendError`(纯对象→Error,保留 `data.message`/`name`/状态码推断),两处 `throw polled.error` 改用它;`transformUpstreamError` 纵深加固(默认分支改读 `data.message`/`name`,code 默认 `upstream_error` 不再用 `constructor.name`)。现 Credits 类错误正确回 `402 insufficient_quota` 并携带原文。新增 `tests/backend-error-surface.test.js`(4 例复现 LiteLLM 案例,含 messages)。 - **同族错误路径查漏**:responses 串流 idleTimeout 补 poll 失败检查(不再静默回空 `completed`);messages 非串流改用 transformed `status/type/code`(不再硬编码 `502/api_error`);messages 串流检查 `collected.error` 与 poll 失败(失败即抛正規化错误走 SSE error 事件,不再静默 200);`isTransientUpstreamError` 纳入 `name/code/type` 参与签名匹配(无 message 纯对象不再必判 false)。 diff --git a/docs/configuration.md b/docs/configuration.md index 9a899fa..d2be906 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,7 +32,7 @@ | 变量 | 默认值 | 说明 | |:-----|:-------|:-----| -| `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认)。例外:① 疑似免费 Zen 模型(`opencode` provider 且 `-free` 后缀、`big-pickle`、`union-alpha`)省略全 `false` 的 prompt `tools` 映射以避开上游 `FreeTierError` 403,此时仅靠 system prompt 禁用语 + 无 tool 定义 + 输出侧 markup 剥离承载禁用姿态;② `/v1/responses` 的 `web_search` 与 Interactions 的 `google_search` 会以 `hosted-search-grant` 单独放行 `websearch`(输出仅 `web_search_call` + 引文,不经过外部桥接) | +| `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认)。例外:① 疑似免费 Zen 模型(`opencode` provider 且 `-free` 后缀、`big-pickle`、`union-alpha`)剔除 prompt `tools` 映射中的 `false` 项(仅保留 `true` 项;全 `false` 则省略整个映射)以避开上游 `FreeTierError` 403(任意 `false` 均触发,`{}`/省略/`true`-only 通过),被剔除的工具回落服务端 agent 默认,此时仅靠 system prompt 禁用语 + 输出侧 markup 剥离承载禁用姿态;② `/v1/responses` 的 `web_search` 与 Interactions 的 `google_search` 会以 `hosted-search-grant` 单独放行 `websearch`(输出仅 `web_search_call` + 引文,不经过外部桥接) | | `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` | | `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace` | | `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;未显式配置 allowlist 时,启用后默认放行 `web_fetch` | @@ -78,6 +78,12 @@ | `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 | | `OPENCODE_ZEN_API_KEY` | - | Zen API Key 透传 | +### 后端 identity(非 proxy knob,不进六向矩阵) + +- `OPENCODE_CLIENT`:被拉起后端向 Zen 声明的第一方身份。allowlist 为 `cli`/`desktop`/`acp`/`app`,默认 `cli`;未设置/空白/未知值一律回落 `cli`(allowlist clamp,非 bool fallthrough)。 +- 从网关宿主环境继承的外来值会被覆写(如 `opencode2api` 会被重写为 `cli`),已知值透传。无 file key、无别名,不进 `ProxyConfig`/`buildProxyConfig`,故 `.env.example`/`config.json.example`/`Dockerfile`/`docker-compose.yml`/`index.ts` 均不声明。 +- 附带:`OPENCODE_API_KEY="public"` 字面值会从后端环境删除(匿名应传空而非该字面);jail 工作目录会 best-effort `git init`(5s 超时,失败 warn 继续)。 + --- ## 📄 config.json 示例 diff --git a/src/backend/manager.ts b/src/backend/manager.ts index b8e0c44..cc99305 100644 --- a/src/backend/manager.ts +++ b/src/backend/manager.ts @@ -46,11 +46,11 @@ export function resolveBackendClient(): string { /** * Stage-6: `git init` the jail workspace so session directories resolve to a - * git-backed project root. Zen's free-tier gate answers non-git project roots - * with FreeTierError 403 even when everything else mimics first-party - * (verified live: identical backend+prompt returns 200 the moment the session - * directory sits inside a git work tree — even an empty repo with no commit - * or remote). Best-effort: warns and continues when git is unavailable. + * git-backed project root. Kept as defense-in-depth for backend versions + * whose free-tier gate is sensitive to non-git project roots (live round-2 + * verification showed plain dirs also pass on the current backend, so this + * is compatibility insurance, not a requirement). Best-effort: warns and + * continues when git is unavailable. */ export function ensureJailGitRepo(workspace: string): boolean { try { diff --git a/src/proxy.ts b/src/proxy.ts index a496fdd..95c7d5f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -371,7 +371,8 @@ export function createApp(config: ProxyConfig): CreateAppResult { return effortMap[value.toLowerCase()] ?? (fallback as string | null); }; - const stripFunctionCalls = (text: unknown, trim: boolean = true): unknown => { + const stripFunctionCalls = (text: unknown, trim: boolean = true): string => { + if (typeof text !== 'string') return String(text ?? ''); if (!DISABLE_TOOLS || !text) return text; return stripFunctionCallMarkup(String(text), trim); }; @@ -818,7 +819,7 @@ export function createApp(config: ProxyConfig): CreateAppResult { ], }, }; - // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + // Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted). const forcedToolOverrides = selectPromptToolOverrides(toolOverrides, providerID, modelID); if (forcedToolOverrides) { forcedPromptParams.body['tools'] = forcedToolOverrides; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 4a061dd..5054ba0 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -401,7 +401,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { REQUEST_TIMEOUT_MS, 'load tool overrides', )) as Record | null; - // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + // Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted). const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); if (promptToolOverrides) { promptParams.body['tools'] = promptToolOverrides; @@ -868,8 +868,8 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } } const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); const promptTokens = Math.ceil((fullPromptText || '').length / 4); const completionTokensCalc = Math.ceil((content || '').length / 4); diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts index b842f7b..d8b176b 100644 --- a/src/routes/interactions.ts +++ b/src/routes/interactions.ts @@ -275,7 +275,7 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v parts, }, }; - // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + // Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted). const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); if (promptToolOverrides) { promptParams.body['tools'] = promptToolOverrides; @@ -358,10 +358,10 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v return; } // Parity with chat/responses/messages: strip tool-call markup when - // DISABLE_TOOLS so a free-tier omission (no all-false tools map, + // DISABLE_TOOLS so a free-tier strip (no false entries in the tools map, // see selectPromptToolOverrides) cannot leak call syntax to clients. // stripFunctionCalls is a no-op unless DISABLE_TOOLS=true. - const text = String(stripFunctionCalls(polled.content || '') ?? ''); + const text = stripFunctionCalls(polled.content || ''); // Chunked deltas keep clients alive on long answers. for (let i = 0; i < text.length; i += 500) { emit({ type: 'step.delta', interaction_id: interactionId, delta: text.slice(i, i + 500) }); @@ -393,7 +393,7 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v const polled = await promptAndPoll(); // Same parity strip as the stream path above. - const text = String(stripFunctionCalls(polled.content || '') ?? ''); + const text = stripFunctionCalls(polled.content || ''); const evidence = hostedSearch.requested ? extractSearchEvidence(polled.toolParts ?? []) : { queries: [] as string[], sources: [] as { url: string; title: string }[] }; const searchCalls = buildWebSearchCallItems(evidence); const annotations = buildCitationAnnotations(text, evidence.sources); diff --git a/src/routes/messages.ts b/src/routes/messages.ts index e72ee3e..85dddad 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -311,7 +311,7 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void REQUEST_TIMEOUT_MS, 'load tool overrides', )) as Record | null; - // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + // Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted). const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); if (promptToolOverrides) promptParams.body['tools'] = promptToolOverrides; @@ -338,8 +338,8 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void reasoning: unknown, validatedToolCalls: unknown, ): Record => { - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); const publicCalls = toPublicToolCalls(validatedToolCalls); const anthropicTools = publicCalls.map((tc) => { const tcr = tc as unknown as Record; diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 4c2ce6f..8e6df24 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -461,7 +461,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void ...(top_p !== undefined ? { top_p } : {}), }, }; - // Stage-5: drop all-disabled maps for free-tier suspects (Zen gate). + // Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted). const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); if (promptToolOverrides) { promptParams.body['tools'] = promptToolOverrides; @@ -861,8 +861,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } } const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); if (streamedToolCalls.length === 0) { validatedStreamedToolCalls.forEach((toolCall) => { const record = toolCall as unknown as Record; @@ -1063,8 +1063,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } } const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); // P4: server-side grounding evidence → web_search_call items + citations. const searchEvidence = hostedSearch.requested diff --git a/src/session/ids.ts b/src/session/ids.ts index 9586b1d..8a568fb 100644 --- a/src/session/ids.ts +++ b/src/session/ids.ts @@ -1,6 +1,6 @@ // Session/message ID format guardrails (Stage-2 mimic work). // -// Upstream facts (opencode@5a8335857b): +// Upstream facts as of opencode@5a8335857b (re-verify on backend bump): // - `x-opencode-session` is minted inside the backend: // `packages/schema/src/session-id.ts` = `"ses_" + descending()`, // where descending = 12 lowercase hex + 14 base62 @@ -15,7 +15,8 @@ // - The SDK path (`session.create()` with no params) offers no per-request // header passthrough: the gateway CANNOT and MUST NOT forge these IDs. // They are validated here only as a debug drift signal (e.g. backend -// version change), never to block a request. +// version change), never to block a request. The regexes below are +// intentionally strict: a backend format change only emits a debug log. const SESSION_SUFFIX = '[0-9a-f]{12}[0-9A-Za-z]{14}'; export const SESSION_ID_RE = new RegExp(`^ses_${SESSION_SUFFIX}$`); diff --git a/src/types/context.ts b/src/types/context.ts index 268ef88..6b598e3 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -121,7 +121,7 @@ export interface AppContext { buildSystemPrompt: (systemMsg: unknown, reasoningEffort?: unknown, toolMode?: unknown, internalAllowedTools?: unknown) => string | undefined; selectPromptToolOverrides: (overrides: unknown, providerID?: unknown, modelID?: unknown) => Record | null; normalizeReasoningEffort: (value: unknown, fallback?: unknown) => string | null; - stripFunctionCalls: (text: unknown, trim?: boolean) => unknown; + stripFunctionCalls: (text: unknown, trim?: boolean) => string; normalizeTextContent: (content: unknown) => string; normalizeToolArguments: (args: unknown) => string; normalizeToolResultContent: (content: unknown) => string; diff --git a/tests/app.test.js b/tests/app.test.js index fbedd37..a79d869 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -508,6 +508,42 @@ describe('Proxy OpenAI API', () => { expect(promptCall.body.system).toContain('Tools are disabled'); }); + test('POST /v1/chat/completions strips tool-call markup from output when DISABLE_TOOLS=true', async () => { + const lockedApp = createApp({ + PORT: 10000, + API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false + }).app; + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { + type: 'text', + text: 'Here is the summary. {"name":"bash","arguments":{"command":"ls"}}' + } + ] + } + ]); + + const res = await request(lockedApp) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + messages: [{ role: 'user', content: 'List files' }] + }); + + expect(res.statusCode).toEqual(200); + const content = res.body.choices[0].message.content; + expect(content).toContain('Here is the summary.'); + expect(content).not.toContain(''); + expect(content).not.toContain('function_calls'); + }); + test('POST /v1/chat/completions applies request-level allowlist narrowing (intersection)', async () => { const internalApp = createApp({ PORT: 10000, @@ -2085,6 +2121,41 @@ describe('Proxy OpenAI API', () => { expect(res.body.output).toEqual([]); }); + test('POST /v1/responses strips tool-call markup from output when DISABLE_TOOLS=true', async () => { + const lockedApp = createApp({ + PORT: 10000, + API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false + }).app; + + sdkMocks.sessionPrompt.mockResolvedValueOnce({ + data: { + parts: [ + { + type: 'text', + text: 'Search summary here. {"name":"webfetch","arguments":{"url":"https://example.com"}}' + } + ] + } + }); + + const res = await request(lockedApp) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Summarize https://example.com' + }); + + expect(res.statusCode).toEqual(200); + const flat = JSON.stringify(res.body.output); + expect(flat).toContain('Search summary here.'); + expect(flat).not.toContain(''); + }); + /** * The Responses API declares function tools flat: { type:'function', name, parameters }. * buildExternalToolRegistry only read tool.function.name, so every tool from a Responses diff --git a/tests/first-party-gate.test.js b/tests/first-party-gate.test.js index da2b85b..7a8008f 100644 --- a/tests/first-party-gate.test.js +++ b/tests/first-party-gate.test.js @@ -83,4 +83,17 @@ describe('first-party gate plumbing', () => { expect(incidental.statusCode).not.toBe(403); expect(incidental.error.type).not.toBe('permission_denied'); }); + + test('transform gate anchor stays wider than normalize (fail-closed pin)', () => { + // Deliberate width difference (§9 no-touch): normalizeBackendError and + // isTransientUpstreamError anchor on /from within opencode|used from + // within|freetiererror/i, while transformUpstreamError also matches bare + // 'within opencode' so a numeric-status error carrying loose gate + // phrasing still surfaces as 403 instead of leaking as 5xx. Pinned here: + // do not narrow transform without widening the other two in the same change. + const loose = { name: 'APIError', data: { message: 'quota error within opencode gateway', statusCode: 500 } }; + const out = transformUpstreamError(loose); + expect(out.statusCode).toBe(403); + expect(out.error.type).toBe('permission_denied'); + }); }); diff --git a/tests/interactions.test.js b/tests/interactions.test.js index c635fe5..0edfc3b 100644 --- a/tests/interactions.test.js +++ b/tests/interactions.test.js @@ -190,6 +190,55 @@ describe('POST /v1beta/interactions', () => { expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); }); + test('non-stream strips tool-call markup from output_text and steps when DISABLE_TOOLS=true', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { + type: 'text', + text: 'Summary of findings. {"name":"bash","arguments":{"command":"ls"}}', + }, + ], + }, + ]); + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'hi', + }); + expect(res.statusCode).toBe(200); + expect(res.body.output_text).toContain('Summary of findings.'); + expect(res.body.output_text).not.toContain(''); + const out = res.body.steps.find((s) => s.type === 'model_output'); + expect(out).toBeDefined(); + expect(out.text).not.toContain(''); + }); + + test('stream strips tool-call markup from deltas and completed output when DISABLE_TOOLS=true', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { + type: 'text', + text: 'Summary of findings. {"name":"bash","arguments":{"command":"ls"}}', + }, + ], + }, + ]); + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'hi', + stream: true, + }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('interaction.completed'); + expect(res.text).toContain('Summary of findings.'); + expect(res.text).not.toContain(''); + }); + test('free-limit engages fallback and retries via proxy within the same request', async () => { sdkMocks.sessionPrompt.mockRejectedValueOnce({ name: 'APIError', diff --git a/tests/session-ids.test.js b/tests/session-ids.test.js index 142bea3..59fe802 100644 --- a/tests/session-ids.test.js +++ b/tests/session-ids.test.js @@ -1,9 +1,12 @@ import { isValidSessionId, isValidMessageId } from '../src/session/ids.js'; // Canonical shapes: "ses_"/"msg_" + 12 lowercase hex + 14 base62 -// (upstream packages/schema/src/identifier.ts). -const goodSes = 'ses_0123456789abcdefABCDEFGHIJKLMN'.slice(0, 4 + 12 + 14); -const goodMsg = 'msg_0123456789abcdefABCDEFGHIJKLMN'.slice(0, 4 + 12 + 14); +// (upstream packages/schema/src/identifier.ts). Built explicitly so a +// regex length change fails loudly instead of silently slicing wrong. +const HEX12 = '0123456789ab'; // 12 lowercase hex +const BASE62_14 = 'cdefABCDEFGHIJ'; // 14 base62 +const goodSes = `ses_${HEX12}${BASE62_14}`; +const goodMsg = `msg_${HEX12}${BASE62_14}`; describe('session/message ID validators', () => { test('accepts canonical backend-minted IDs', () => {