From e64fa5d83e96af81a1a65c0397abe0116f58d047 Mon Sep 17 00:00:00 2001 From: liu Date: Sat, 17 Oct 2026 08:24:39 -0700 Subject: [PATCH] fix(protocol): recognize Claude Desktop local-agent requests (#511) Cherry-pick from fork a120f38. Adds CHANGELOG unreleased entry. Supersedes closed fork PR #511 and bad upstream #512. Co-authored-by: ted --- CHANGELOG.md | 1 + docs/PROTOCOL.md | 2 +- src/lib/identity/claude-code-only.mjs | 2 +- src/lib/identity/crs-persona.mjs | 23 ++++++--- test/unit/claude-code-only.test.mjs | 74 +++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b01c0a9d2..ec7b03651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - GPT 槽在 TTL(10 分钟)内有可用 credits(`has_credits || unlimited`)时仅豁免窗口 cap;显式 park 仍优先;上游 429 清除 credits 并 park。(#508) - 修复 KVM 交互终端:交互/非 detach 不再包 `timeout 8`(仅保留 SSH ConnectTimeout);`-e` 按次独立转义,保留值内换行(含 `KIN_PANEL_RC`)。(#509) +- Claude Desktop local-agent(UA 含 local-agent 与 agent-sdk、平台 desktop_app、合法 anthropic-client-version)不再仅凭 cc_entrypoint=local-agent 判成 OMP,避免 claude_code_only 误 403;OMP 专属与 body-only relay 仍保守。(#511) ## 1.3.187 — 2026-10-17 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 648c9b6fe..424ba03d9 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -15,7 +15,7 @@ | UA | `^claude-cli/\d+\.\d+\.\d+`(sub2api 同款前缀;vscode / cowork / desktop 都算) | | `metadata.user_id` | 合法官方 user id | | tools | 不能是 OpenAI `{type:function}` / `tool_choice.type=function`。`/v1/messages` 上这种 tools 会先转 Anthropic,但仍判非官方 | -| system | 含 `x-anthropic-billing-header:`、官方 `cc_entrypoint`(`cli` / `sdk-cli` / `vscode` / `cowork` / `desktop`,不含 `local-agent`)、或官方身份行(含 `You are Claude Code` / `Claude Agent SDK` 变体) | +| system | 含 `x-anthropic-billing-header:`、官方 `cc_entrypoint`(`cli` / `sdk-cli` / `vscode` / `cowork` / `desktop`)、或官方身份行(含 `You are Claude Code` / `Claude Agent SDK` 变体)。`local-agent` 仅在 UA 含对应入口和 Agent SDK、平台为 `desktop_app` 且客户端版本有效时继续原有官方校验;OMP 专属特征仍优先排除 | 四闸不全 → 非官方,走人设。虚拟机测试入站会先铺 4 块官方 system + 官方 UA,因此分类为 `claude_code_official`。 diff --git a/src/lib/identity/claude-code-only.mjs b/src/lib/identity/claude-code-only.mjs index 30e25b405..4cca4b942 100644 --- a/src/lib/identity/claude-code-only.mjs +++ b/src/lib/identity/claude-code-only.mjs @@ -98,7 +98,7 @@ function isCliQuotaProbe(headers, body) { * Relayed Claude Code (UA rewritten) is admitted only while `detect_proxied_official_cc` is on. */ export function isClaudeCodeFamilyRequest(headers = {}, body = {}, { routing = {}, countTokens = false } = {}) { - if (looksLikeOhMyPiSystem(body?.system) || inboundHasOpenAIToolShape(body)) return false + if (looksLikeOhMyPiSystem(body?.system, headers) || inboundHasOpenAIToolShape(body)) return false if (hasClaudeCodeClientHeaders(headers)) { if (countTokens || isCliQuotaProbe(headers, body)) return true if ( diff --git a/src/lib/identity/crs-persona.mjs b/src/lib/identity/crs-persona.mjs index 4c01ac944..8381ed890 100644 --- a/src/lib/identity/crs-persona.mjs +++ b/src/lib/identity/crs-persona.mjs @@ -1590,13 +1590,20 @@ export function isValidOfficialUserId(raw) { /** * oh-my-pi / omp inbound: their own ROLE + PROJECT blocks, or the OAuth * stealth prefix (`cc_entrypoint=local-agent` + Agent SDK one-liner). - * That prefix reuses the official identity string, so billing/identity - * alone must not classify this as Claude Code. + * Claude Desktop shares that entrypoint, so its desktop headers let it continue + * through the existing official checks; OMP-specific system markers still reject it. */ -export function looksLikeOhMyPiSystem(system) { +export function looksLikeOhMyPiSystem(system, headers = {}) { const text = systemToText(system) if (!text) return false - if (/cc_entrypoint\s*=\s*local-agent/i.test(text)) return true + const ua = String(headers['user-agent'] || headers['User-Agent'] || '').trim() + const desktop = + isOfficialClaudeUa(ua) && + /[ (,]local-agent(?=[,)])/.test(ua) && + /[ (,]agent-sdk\/\d+\.\d+\.\d+(?=[,)])/.test(ua) && + headers['anthropic-client-platform'] === 'desktop_app' && + /^\d+\.\d+\.\d+$/.test(String(headers['anthropic-client-version'] || '').trim()) + if (/cc_entrypoint\s*=\s*local-agent/i.test(text) && !desktop) return true if (//i.test(text) && /Oh My Pi/i.test(text)) return true if (//i.test(text) && //i.test(text)) return true if (/^ROLE\s*\n\s*=+/m.test(text) && /^PROJECT\s*\n\s*=+/m.test(text)) return true @@ -1614,8 +1621,8 @@ export function thirdPartyClientPrompt(system) { } /** Official CLI system: billing header, official entrypoint, identity, or child-hop prompt. */ -export function looksLikeOfficialClaudeSystem(system) { - if (looksLikeOhMyPiSystem(system)) return false +export function looksLikeOfficialClaudeSystem(system, headers = {}) { + if (looksLikeOhMyPiSystem(system, headers)) return false const text = systemToText(system) if (!text) return false if (/x-anthropic-billing-header:/i.test(text)) return true @@ -1642,10 +1649,10 @@ export function inboundHasOpenAIToolShape(body = {}) { export function isOfficialClaudeCodeTraffic(headers = {}, body = {}) { const ua = String(headers['user-agent'] || headers['User-Agent'] || '').trim() if (!isOfficialClaudeUa(ua) || !CLAUDE_CLI_UA_RE.test(ua)) return false - if (looksLikeOhMyPiSystem(body?.system)) return false + if (looksLikeOhMyPiSystem(body?.system, headers)) return false if (!isValidOfficialUserId(body?.metadata?.user_id)) return false if (inboundHasOpenAIToolShape(body)) return false - return looksLikeOfficialClaudeSystem(body?.system) + return looksLikeOfficialClaudeSystem(body?.system, headers) } /** diff --git a/test/unit/claude-code-only.test.mjs b/test/unit/claude-code-only.test.mjs index 0e9930a3e..5f7acabe3 100644 --- a/test/unit/claude-code-only.test.mjs +++ b/test/unit/claude-code-only.test.mjs @@ -4,6 +4,11 @@ import { isClaudeCodeFamilyRequest } from '../../src/lib/identity/claude-code-on import { createHandleProtocol } from '../../src/lib/protocol/handle-protocol.mjs' import { handleUserCountTokens } from '../../src/lib/protocol/user-count-tokens.mjs' import { validatePersonaRoutingPatch } from '../../src/lib/admin/panel-api.mjs' +import { + applyCrsUnofficialPersona, + isOfficialClaudeCodeTraffic, + isProxiedOfficialClaudeCode, +} from '../../src/lib/identity/crs-persona.mjs' const CLI_UA = 'claude-cli/2.1.293 (external, cli)' const SESSION = '11111111-2222-4333-8444-555555555555' @@ -40,6 +45,68 @@ const ON = { compatibility: { claude_code_only: true } } const NO_RELAY = { routing: { compatibility: { detect_proxied_official_cc: false } } } +const DESKTOP_HEADERS = { + ...CLI_HEADERS, + 'user-agent': 'claude-cli/2.1.299 (external, local-agent, agent-sdk/0.3.299)', + 'anthropic-client-platform': 'desktop_app', + 'anthropic-client-version': '2.42304.0', + 'x-stainless-package-version': '0.128.0', +} +const DESKTOP_BODY = { + ...OFFICIAL_BODY, + system: [ + { type: 'text', text: 'x-anthropic-billing-header: cc_version=2.1.299.fa6; cc_entrypoint=local-agent;' }, + { type: 'text', text: "You are Claude Code, built on Anthropic's Claude Agent SDK." }, + ], +} + +test('local-agent with desktop markers passes the existing Claude Code checks', () => { + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, DESKTOP_BODY, NO_RELAY), true) + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, DESKTOP_BODY), true) + assert.equal(isOfficialClaudeCodeTraffic(DESKTOP_HEADERS, DESKTOP_BODY), true) + assert.equal(applyCrsUnofficialPersona(DESKTOP_BODY, { headers: DESKTOP_HEADERS, mode: 'rewrite' }), DESKTOP_BODY) + assert.equal(isProxiedOfficialClaudeCode(DESKTOP_BODY, DESKTOP_HEADERS), false) +}) + +test('local-agent still rejects incomplete headers, inconsistent sessions/builds, and OMP', () => { + for (const key of Object.keys(DESKTOP_HEADERS)) { + const { [key]: dropped, ...headers } = DESKTOP_HEADERS + assert.equal(isClaudeCodeFamilyRequest(headers, DESKTOP_BODY), false, key) + } + for (const headers of [ + { ...DESKTOP_HEADERS, 'anthropic-client-platform': 'web' }, + { ...DESKTOP_HEADERS, 'anthropic-client-version': 'unknown' }, + { ...DESKTOP_HEADERS, 'user-agent': 'claude-cli/2.1.299 (external, cli)' }, + { ...DESKTOP_HEADERS, 'user-agent': 'claude-cli/2.1.299 (external, local-agent)' }, + { ...DESKTOP_HEADERS, 'user-agent': 'Go-http-client/1.1' }, + { ...DESKTOP_HEADERS, 'x-claude-code-session-id': '99999999-2222-4333-8444-555555555555' }, + { ...DESKTOP_HEADERS, 'user-agent': 'claude-cli/2.1.280 (external, local-agent, agent-sdk/0.3.280)' }, + ]) { + assert.equal(isClaudeCodeFamilyRequest(headers, DESKTOP_BODY), false) + } + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, { ...DESKTOP_BODY, metadata: undefined }), false) + for (const text of [ + 'You are operating in the Oh My Pi coding harness.', + 'rules\ntest', + 'ROLE\n====\nAssistant\nPROJECT\n====\nWorkspace', + 'You are operating in the pi coding harness.', + 'You are operating in the omp coding harness.', + ]) { + const body = { ...DESKTOP_BODY, system: [...DESKTOP_BODY.system, { type: 'text', text }] } + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, body), false, text) + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, body, { countTokens: true }), false, text) + assert.equal(isOfficialClaudeCodeTraffic(DESKTOP_HEADERS, body), false, text) + } + const tools = [{ type: 'function', function: { name: 'read_file' } }] + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, { ...DESKTOP_BODY, tools }), false) +}) + +test('local-agent desktop count_tokens follows the existing header-only admission contract', () => { + const body = { model: DESKTOP_BODY.model, system: DESKTOP_BODY.system, messages: DESKTOP_BODY.messages } + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, body, { countTokens: true }), true) + assert.equal(isClaudeCodeFamilyRequest(DESKTOP_HEADERS, body), false) +}) + test('Claude Code family recognition needs the full client fingerprint, not the UA', () => { assert.equal(isClaudeCodeFamilyRequest(CLI_HEADERS, OFFICIAL_BODY, NO_RELAY), true) // UA plus a copied official body is not enough without the client header set. @@ -221,6 +288,13 @@ test('switch on: official Claude Code and its own quota probe reach the schedule assert.deepEqual(await runProtocol({ routing: ON, body: essay, headers: CLI_HEADERS }), BLOCKED) }) +test('switch on: local-agent desktop reaches the scheduler without relay detection', async () => { + const routing = { compatibility: { claude_code_only: true, detect_proxied_official_cc: false } } + assert.equal((await runProtocol({ routing, body: DESKTOP_BODY, headers: DESKTOP_HEADERS })).reached, true) + const body = { ...DESKTOP_BODY, system: [...DESKTOP_BODY.system, { type: 'text', text: 'Oh My Pi coding harness' }] } + assert.deepEqual(await runProtocol({ routing, body, headers: DESKTOP_HEADERS }), BLOCKED) +}) + test('switch on: master-key pinned panel diagnostics are not inbound clients', async () => { const result = await runProtocol({ routing: ON,