Skip to content

fix(openai-chat): bound flattened tool wire names for strict gateways (#4679) - #4774

Merged
lidge-jun merged 5 commits into
codex/pw4-custom-baseurl-joinfrom
codex/pw5-bounded-tool-wire-names
Sep 16, 2026
Merged

lidge-jun merged 5 commits into
codex/pw4-custom-baseurl-joinfrom
codex/pw5-bounded-tool-wire-names

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

Carries #4715 by @HulianBuligon for #4679, with the fix moved to a different layer — see "Why the placement changed" below.

Command Code's gateway rejects a request outright with 400 "name must be at most 64 characters, got 66". Codex Desktop built-in app tools flatten to <namespace>__<name> past that bound, a user cannot exclude them, and Responses-Lite catalogs bundle every declared tool into additional_tools, so the surface cannot be shrunk from configuration.

The bound belongs to the adapter, not to the shared name helper. Three adapters already solve this for themselves:

Adapter Its own mechanism
Kiro normalizes to its charset with a deterministic 8-hex suffix
Google compiles and restores names in its wire compiler
Meta Muse aliases over-limit / charset-invalid names on api.meta.ai
openai-chat (translated) nothing — this is #4679

A request-scoped registry now owns one collision domain per translated Chat Completions request, following Kiro's shape. A namespaced name whose flattened spelling exceeds 64 characters becomes a charset-safe alias derived purely from the native identity, so it is stable across processes, catalog order and catalog membership. Declarations, replayed assistant tool calls and tool_choice all pass through the same registry, and both the streaming and buffered parsers restore the echoed alias before tool_call_start, so the existing bridge map still hands the client its native {namespace, name}.

The registry is seeded from the union of the current catalog and the structured tool calls still present in replay history. A historical call can keep its namespace without being redeclared, so seeding from the catalog alone would let exactly the reported over-limit name reach the gateway again on a later turn.

Nothing else changes: names at or under 64 characters and all bare names are byte-identical on the wire, and Kiro, Google and Muse still receive the raw flattened name and run their own normalization.

64 is the Chat Completions function-name limit and a strict-gateway compatibility concern, not OpenAI Responses parity — upstream Codex raised its own MCP ceiling to 128 bytes in openai/codex#39594 because native Responses accepts 128. Applying 64 on this wire is correct for this wire alone.

Why the placement changed

#4715 placed the bound in the shared namespacedToolName() helper, and that was provisionally accepted. Hosted CI then showed twice that the shared point intercepts adapters which already had an answer:

  1. tests/adapters/google/google-wire-compiler.test.ts — Google received a pre-aliased name and its own restore path broke, returning the synthetic ocx_… spelling to the client.
  2. After narrowing that to namespaced names only, tests/providers/kiro/kiro-adapter.test.ts"long namespaced tool names are normalized to Kiro's <=64-char charset" expected /_[0-9a-f]{8}$/ and received ocx_ent_applications_9ecUBk…, because the shared helper pre-empted Kiro's normalizer.

Breaking two different adapters in two rounds is the signal that the bound sat at the wrong layer. src/types/tools.ts is now byte-identical to the merge base.

The problem statement and issue analysis are the original author's; only the placement changed, and the Co-authored-by trailer is retained.

Closes #4679

Verification

Static source review plus an adversarial static audit, and hosted CI. No local suite, typecheck, or build was run — the repository owner prohibits local suite execution in this lane after a past local run deleted real ~/.opencodex data. The verification claims in #4715's description are that author's, not re-asserted here.

An adversarial audit of the first draft of this rewrite returned FAIL and found two defects that were fixed before pushing:

  • Replay-only history bypassed aliasing. The registry was seeded from the catalog alone, so a historical call absent from the current catalog still serialized its full over-limit name — the original bug, surviving. Fixed by the union seeding above, with a regression that builds the request with no current declaration.
  • The alias was not identity-only. A salt selected against the in-use set meant catalog membership could change an identity's spelling, which breaks prompt-cache stability and makes a replayed alias unresolvable against a later catalog. The salt and counter are gone; the alias is a pure digest of the identity.

A follow-up review also removed two throws on the request path: distinct identities really can flatten to the same name (namespace a__b + name c versus namespace a + name b__c), and turning that into a hard error would have been a regression beyond this issue's scope. Ambiguous flattened spellings are now simply left out of the replay rewrite map rather than guessed, and map construction iterates in sorted identity order so no outcome depends on catalog order.

Regression coverage in tests/responses/bounded-tool-names.test.ts:

  • bounds and restores the exact reported identity across request, replay, tool_choice, and response
  • bounds and restores a replay-only historical call absent from the current catalog
  • leaves names at or under 64 characters and ordinary bare names byte-identical
  • derives deterministic distinct aliases independent of catalog order
  • keeps shared naming untouched so Kiro and Google retain adapter-owned normalization — this one fails if anyone reintroduces a bound in the shared helper
  • aliases colliding identities but leaves their ambiguous replay spelling unchanged

structure/providers/chat-compat.md records the adapter-ownership split and the compatibility-limit framing with the upstream reference.

Hosted CI: this is a non-tip layer of a stacked lane and carries [skip ci] under the maintainer-approved DEV-STACK-08 tip-only policy. The lane's CI gate runs on the tip branch, which contains this commit.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. (structure/providers/chat-compat.md; no user-facing configuration changes, and ordinary catalogs are unaffected)
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. (no auth surface; aliases derive only from declared tool identities and carry no credential or account material, and reserving the alias shape prevents a declared name from impersonating another tool's wire spelling)

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 02:34
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 976a46e0-ab85-4783-bc04-1f4af8a7736b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-16T02:39:14.389722Z cbcd0e2 PR opened
ℹ️ About Codex in GitHub

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

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

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

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 77 / 80

이 PR(#4774)은 이슈 #4679를 고칩니다. Codex 클라이언트가 MCP/앱 도구를 많이 선언하면, 프록시는 채팅 게이트웨이용으로 이름을 <namespace>__<name> 형태로 납작하게 만듭니다. 그 결과가 64자를 넘으면 Command Code 같은 엄격한 게이트웨이가 추론 전에 요청 전체를 거절합니다. 실제 오류는 400 name must be at most 64 characters, got 66 이고, 범인은 Codex Desktop 기본 앱 도구 mcp__codex_apps__codex_document_control___execute_document_command(66자) 같은 이름입니다. 사용자는 이 도구를 설정에서 빼기 어렵고, Responses-Lite(use_responses_lite: true)는 선언된 도구를 전부 additional_tools에 넣어서 표면을 줄일 수도 없습니다.

지금 비교 기준인 dev HEAD는 b3035fe292168bc598b5d67e77203e2b65404578 (패키지 2.57.0, 방금 #4720 Codex account/catalog lane L2 랜딩)입니다. 현재 체크아웃의 src/types/tools.ts namespacedToolName은 네임스페이스가 있으면 그냥 namespace__name을 이어 붙이기만 합니다. 길이/바이트 상한이 없어서 #4679가 그대로 재현됩니다. 같은 파일의 dottedToolNametoolChoiceAliases도 그 납작 이름에 의존합니다. 복원 쪽은 이미 src/server/responses/collaboration.tsbuildToolBridgeMapsnamespacedToolName으로 toolNsMap/declaredToolNames를 키우므로, 와이어 이름 생성 함수만 바뀌면 복원 경로도 같이 따라갑니다.

이 PR이 하는 일은 그 생성 함수에 64 UTF-8 바이트 게이트웨이 호환 정책을 넣는 것입니다. 한도를 넘는(또는 예약 철자를 이미 쓰는) 이름은 ocx_<16자 꼬리>_<sha256-base64url 43자> 예약 철자로 바뀝니다. 다이제스트 입력은 JSON.stringify([namespace ?? null, name])뿐이라 프로세스·카탈로그 순서·재시작과 무관하게 같은 정체성은 같은 별칭을 받습니다. 전역 레지스트리·카운터·8192 상한·축출이 없습니다. 그게 #4715 원본과의 핵심 차이입니다. 원본의 claimWireName 레지스트리는 상한에 닿으면 축출할 수 있고, 축출은 대화 중간에 도구 이름을 바꿔 더 위험한 버그입니다. 이 캐리본은 공유 상태를 없애서 그 위험을 제거했습니다.

64바이트는 OpenAI Responses 스키마 상한(128)이 아니라 Chat Completions/엄격 게이트웨이 쪽의 더 짧은 한도입니다. 소스 주석과 structure/providers/chat-compat.md가 업스트림 openai/codex#39594(MCP 한도 64→128)와 별칭 철자 차이(업스트림 12-hex SHA-1 vs 여기 SHA-256 base64url)를 명시해 두어, 나중에 Responses maxLength: 128을 보고 한도를 올려 #4679를 다시 열지 못하게 합니다. Muse 패스(src/responses/muse-tool-name-alias.ts)는 클라이언트에서 온 이름을 다루고, 이 레이어는 프록시가 납작하게 만든 이름을 다룹니다. 예약 철자는 ASCII·정확히 64바이트라 isPassThroughMuseToolName에 그대로 통과해 이중 별칭이 나지 않습니다. 회귀 테스트 tests/responses/bounded-tool-names.test.ts 8개가 바이트 한도·결정성·충돌·섀도잉·dotted 붕괴·tool_choice·브리지 왕복·undeclared 가드를 잠급니다. types.ts/config.ts 분할 캠페인에 무효화될 경로가 아닙니다(src/types/tools.ts 후분할 위치).

스택 위치가 중요합니다. base는 dev가 아니라 codex/pw4-custom-baseurl-join(#4773)이고, 레인은 #4752(pw1) → #4769(pw2) → #4770(pw3) → #4773(pw4) → 이 PR(pw5 tip cbcd0e20a, 커밋 [skip ci]) → 자식 #4776(pw6 CodeBuddy scaffold)입니다. mergeable은 true, mergeable_state는 unstable이며 hygiene/label/resolve-pr가 아직 pending입니다. 본문의 DEV-STACK-08 tip-only CI 설명대로 non-tip 레이어라 [skip ci]이고, 레인 게이트는 tip(#4776 쪽)에서 돌아야 합니다. 원본 기여자 PR #4715는 아직 OPEN·base dev입니다. 이 캐리가 랜딩하면 landed-via-maintainer로 닫아야 남은 열린 PR 수가 부풀지 않습니다.

라인 namespacedToolName / boundedToolName (src/types/tools.ts) - 64 UTF-8 바이트를 넘는 납작 이름만 예약 철자로 바꿉니다. 다이제스트는 네이티브 정체성만 보고, 레지스트리/축출이 없습니다.
라인 needsBoundedToolName + BOUNDED_TOOL_NAME_PATTERN - 이미 ocx_… 예약 형태인 정규 이름도 별칭으로 보내 섀도잉을 막습니다. 테스트가 이 경로를 고정합니다.
라인 dottedToolName / toolChoiceAliases - 바운드된 별칭에서는 dotted 철자를 따로 내지 않고 tool_choice 별칭도 하나로 접습니다. 원래 긴 이름은 resolver가 여전히 고릅니다.
경로 buildToolBridgeMaps (collaboration.ts) - 생산 코드 변경 없이 namespacedToolName 키를 그대로 쓰므로 왕복/리플레이가 맞습니다. 테스트가 bridge maps + undeclared 가드까지 증명합니다.
경로 Muse (muse-tool-name-alias.ts) - 클라이언트 유입 이름용 레이어와 역할이 다릅니다. 예약 철자는 charset-safe·64라 Muse pass-through와 잘 맞습니다.
테스트 bounded-tool-names.test.ts + layout/fixture 등록 - ASCII/비ASCII 바이트 한도, 결정성, 충돌, 섀도잉, dotted/tool_choice/왕복/undeclared 8케이스가 실재 재현 이름(mcp__codex_apps__…)을 씁니다.
커밋 [skip ci] + 스택 - non-tip 레이어라 tip(#4776) CI에 의존합니다. base #4773 이전 부모(#4752#4769#4770)도 먼저 안정적으로 랜딩해야 합니다.
경로 #4715 - 같은 #4679를 레지스트리 방식으로 풀던 원본이 아직 OPEN입니다. 이 PR 랜딩 후 landed-via로 닫지 않으면 열린 PR 수가 부풀어 보입니다.

메인테이너의 판단이 필요한 지점

너의 추천
KEEP. #4679를 정확히 고치고 #4715의 축출 위험을 제거한 캐리라 머지 가치가 높다. 부모 스택(#4752#4769#4770#4773)이 먼저 안착한 뒤 tip CI(#4776 레인)가 그린인지 확인하고 머지하세요. 머지와 함께 #4679를 close하고, 원본 #4715는 Landed via #4774 + landed-via-maintainer로 닫으세요. 한도를 128로 올리지 마세요. types/config 분할 close-don't-rebase 대상 아닙니다.

이 댓글은 grok-bot이 작성했습니다

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cbcd0e20a2

ℹ️ About Codex in GitHub

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

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

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/types/tools.ts Outdated
Comment on lines +80 to +81
const flat = namespace ? `${namespace}__${name}` : name;
return needsBoundedToolName(flat) ? boundedToolName(namespace, name, flat) : flat;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore aliases for bounded bare tool names

When a client declares a bare tool name longer than 64 UTF-8 bytes—or one matching the reserved alias pattern—this branch replaces it even though namespace is undefined. However, buildToolBridgeMaps only adds reverse mappings inside its if (t.namespace) branch (src/server/responses/collaboration.ts:163-165), so an upstream echo of the new ocx_* name is emitted to the client unchanged in both buffered and streaming responses. The client then cannot match the call to its declared tool; register changed bare names in the reverse map as well, or avoid transforming them here.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 16, 2026
@lidge-jun
lidge-jun force-pushed the codex/pw4-custom-baseurl-join branch from 9984bca to a9eb794 Compare September 16, 2026 03:11
@lidge-jun
lidge-jun force-pushed the codex/pw5-bounded-tool-wire-names branch 2 times, most recently from e3d5e7f to cf8969f Compare September 16, 2026 04:00
@lidge-jun lidge-jun changed the title fix(responses): bound flattened tool wire names for strict gateways (#4679) fix(openai-chat): bound flattened tool wire names for strict gateways (#4679) Sep 16, 2026
@lidge-jun
lidge-jun force-pushed the codex/pw4-custom-baseurl-join branch from a9eb794 to d75657e Compare September 16, 2026 04:32
@lidge-jun
lidge-jun force-pushed the codex/pw5-bounded-tool-wire-names branch from cf8969f to a85315a Compare September 16, 2026 04:32
@lidge-jun
lidge-jun force-pushed the codex/pw4-custom-baseurl-join branch from d75657e to 3d74681 Compare September 16, 2026 04:46
@lidge-jun
lidge-jun force-pushed the codex/pw5-bounded-tool-wire-names branch from a85315a to 441dc16 Compare September 16, 2026 04:46
lidge-jun and others added 2 commits September 16, 2026 14:01
The CodeBuddy route launches the vendor CLI with --tools "" and
--strict-mcp-config, so the routed model has no native tool channel and writes
its call as prose. The shared coding-agent projection forwards text_delta
unrepaired, so that markup reached the client as an ordinary assistant answer.

Qoder's guard does not match it. The leaked tags are wrapped in FULLWIDTH
VERTICAL LINE (U+FF5C), which none of the shipped UNREPAIRABLE_MARKERS cover, so
this needed a signature of its own rather than a port.

Refusal requires the observed two-line grammar: a calls control line at column
zero, outside a Markdown fence, immediately followed by an invoke line naming a
functions.* tool. A lone tag, a quoted or inline-code literal, a fenced example,
a blockquote, indented source, or prose discussing the markup all carry extra
syntax before the tag and are forwarded untouched. Matching the marker alone
would refuse a legitimate answer that merely explains this protocol, which is
why the detector is narrower than the marker spelling.

A detected leak preserves the answer text already proven safe, emits one
non-retryable vendor_scaffold_detected error, and suppresses the vendor's later
success terminal so the client never sees a completed turn. Markers split across
streamed deltas are caught by holding only a bounded suffix that could still
complete a control sequence or a fence; unrelated pending text is released at
the next mismatch or terminal. The reasoning channel is guarded independently.

Leaked prose is never promoted into a real tool call. The text channel carries
no authenticated call envelope and no validated arguments, so converting it
would manufacture execution authority out of model output.

Kept CodeBuddy-owned rather than lifted into the shared coding-agent path, the
same containment #4234 chose for Qoder: the contract observed here is this
vendor's, and #4190's lane packet asked for a report rather than symmetry.

Co-authored-by: Ingwannu <ingwannu@users.noreply.github.com>
…#4679) [skip ci]

Command Code's gateway rejects a request outright with 400 name must be at most
64 characters, got 66. Codex Desktop built-in app tools flatten to
<namespace>__<name> past that bound, a user cannot exclude them, and
Responses-Lite catalogs bundle every declared tool, so the surface cannot be
shrunk from configuration.

The bound belongs to the adapter, not to the shared name helper. Three adapters
already solve this for themselves: Kiro normalizes to its own charset with a
deterministic 8-hex suffix, Google compiles and restores names in its wire
compiler, and Meta Muse aliases names on api.meta.ai. The translated
openai-chat path is the only one with no answer, and it is the path Command Code
uses.

A request-scoped registry now owns one collision domain per translated Chat
Completions request, following Kiro's shape. A namespaced name whose flattened
spelling exceeds 64 characters becomes a charset-safe alias derived purely from
the native identity, so it is stable across processes, catalog order and catalog
membership. Declarations, replayed assistant tool calls and tool_choice all pass
through the same registry, and both the streaming and buffered parsers restore
the echoed alias before tool_call_start, so the existing bridge map still hands
the client its native {namespace, name}.

The registry is seeded from the union of the current catalog and the structured
tool calls still present in replay history, because a historical call can keep
its namespace without being redeclared; seeding from the catalog alone would let
exactly the reported over-limit name reach the gateway again on a later turn.

Nothing else changes. Names at or under 64 characters and bare names are
byte-identical on the wire, and Kiro, Google and Muse still receive the raw
flattened name and run their own normalization.

64 is the Chat Completions function-name limit and a strict-gateway
compatibility concern, not OpenAI Responses parity: upstream Codex raised its own
MCP ceiling to 128 bytes in openai/codex#39594 because native Responses accepts
128. Applying it on this wire is correct for that wire alone.

Carried from #4715. That PR placed the bound in the shared namespacedToolName
helper and was provisionally accepted there. Hosted CI then showed twice that the
shared point intercepts adapters which already had an answer: it broke Google's
wire-compiler restore, and after that was narrowed it broke Kiro's normalizer.
The problem statement and issue analysis are the original author's; only the
placement changed.

Co-authored-by: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com>
@lidge-jun
lidge-jun force-pushed the codex/pw4-custom-baseurl-join branch from 3d74681 to 217fe01 Compare September 16, 2026 05:02
@lidge-jun
lidge-jun force-pushed the codex/pw5-bounded-tool-wire-names branch from 441dc16 to 99c977d Compare September 16, 2026 05:02
@lidge-jun

Copy link
Copy Markdown
Owner Author

Cascading downward. Bounded tool wire names now apply only in the openai-chat translation path. Kiro, the Google wire compiler and the Meta Muse alias each already own their names, and hooking the shared flattening point intercepted them — CI demonstrated that twice, once through Google and once through Kiro. The 64-character bound is a gateway-compatibility policy, not an upstream Responses requirement: upstream raised that limit to 128 in openai/codex#39594, and the code, description and structure docs all say so now.

Evidence at the verified tip 49f815d (tree cea66da56f10b8d1aee2290fa185d321442eab98), from run 35061163092:

  • test 1-4/4 and macos 1-2/2 all completed with conclusion success, confirmed through the check-runs API rather than the check rollup, so the heavy jobs actually executed and were not path-filtered. gates, changes, storage policy, api usage, docker smoke, keyring and npm-global on three platforms, the three service-lifecycle jobs, and the aggregate ci check all succeeded.
  • The ci failure at this commit belongs to run 35061161660, which this push superseded; run 35061163092 is the live one and it concluded success.
  • The lane absorbed dev at cf6e939 from the bottom layer upward, so each pull request keeps its own layer diff (4 / 9 / 6 / 7 / 7 / 5 files) and no dev commit appears in any layer's diff.
  • The single conflict was structure/transports/responses.md, where both sides appended a new section to the same empty base. It was resolved by keeping both: the section count goes from 14 on dev to 15 here, and every dev section name is still present. That loss is the kind CI cannot detect, so the names were compared directly rather than trusting the count.
  • The file-size ratchet reports no offenders after the absorption; openai-chat.ts sits exactly at its 822 cap.
  • git merge-tree --write-tree origin/dev <tip> reports a clean merge, and origin/dev is itself an ancestor of this tip.
  • Ancestry verified so each layer closes as MERGED: pw1 through pw5 are all ancestors of this tip.

Maintainer integration decision under MAINTAINERS.md / AGENTS.md: a maintainer with maintain or admin access may integrate into dev without a second maintainer approval, recording the decision and exact-head CI evidence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant