Skip to content

fix(web-search): bind continuations to the serving API key - #4489

Merged
lidge-jun merged 11 commits into
devfrom
codex/260913-carry-4387
Sep 13, 2026
Merged

fix(web-search): bind continuations to the serving API key#4489
lidge-jun merged 11 commits into
devfrom
codex/260913-carry-4387

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

  • A hosted-search continuation is not a new turn: it is the first leg plus the search the proxy just executed, and it has to reach the account that already served that leg. It went out through the ordinary dispatch override instead, so a selection change during the search could send the search-bearing body under a different key — or rebuild the request from the original turn and drop the search result entirely.
  • The bridge now captures the request binding that served the first leg, after any permitted initial reselection, and rechecks it after provider pacing before every continuation dispatch. The binding must still be an API-key selection matching the configured entry, reference, revision, resolved key, authentication mode and base URL; a disabled or removed provider fails the same check.
  • On drift the turn ends with the bridge's failed terminal and no further provider request is issued. An unchanged binding resends the built request with the executed search result appended, never re-entering the reselection and rebuild path. Initial dispatch keeps its normal reselection policy.
  • Carries fix(web-search): bind continuations to the serving API key #4387 by @luvs01 onto current dev, with a Co-authored-by trailer on the branch commit.

This is the lane R tip. Stacked on #4488#4475; retarget down the chain as each lands.

Structure documentation scope

The branch documents this across fifteen structure/ files. Four are carried:

Document Why it stays
structure/runtime.md Holds the contract itself, and already owns src/web-search/.
structure/transports/responses.md Owns the /v1/responses transport this send belongs to.
structure/data-planes/search.md Owns the search data plane.
structure/transports/streaming-health.md The recheck happens after provider pacing, which is its subject.

The other eleven received the same cross-reference sentence pasted into documents that own none of the changed source — data-planes/images.md, providers/xai-grok.md, clients/claude-desktop.md and subagents.md among them, with ops/service-and-sidecars.md a character-identical copy of the data-planes/search.md insertion. structure/AGENTS.md makes these documents a source-ownership map, so a pointer in a document that owns nothing here adds a maintenance edge without adding a fact. Dropping them keeps bun run structure:check green and leaves the contract discoverable from every document that does own a piece of this path.

Carrying the responses.md and gui-and-management-api.md files wholesale would also have reverted later dev edits, since both drifted after the branch's merge base while staying at the 600-line budget. The responses.md sentence is carried as the branch wrote it, appended to an existing line so the document stays at exactly 600.

Verification

  • bun test tests/web-search/ tests/codex-integration/reasoning-metadata.test.ts tests/codex-integration/reasoning-effort.test.ts tests/responses/responses-reasoning-effort-downgrade.test.ts tests/responses/responses-undeclared-tool-guard.test.ts tests/responses/legacy-shell-compat.test.ts tests/adapters/bridge-legacy-shell-normalization.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts — 438 pass, 0 fail on this exact head.
  • bun run typecheck — clean.
  • bun run structure:check — passed. Required for this link because of the structure/ edits.
  • bun run privacy:scan — passed.
  • origin/dev (f7d9dbad0) is merged up through the stack, so this tip carries the current integration state.
  • This head has no [skip ci], so it runs the full matrix. That tip run is the lane's suite proof.

The carried tests are what make the contract real rather than asserted: eight drift cases mutated during the search (revision, same-value key reference, pool entry id, env-resolved key behind an unchanged reference, auth mode, base URL, provider disabled, provider removed) each assert one search, one outbound request, a response.failed carrying the bridge error code, and no answer text. Separate cases cover drift during the continuation's pacing wait and drift after the first-leg send but before its headers return. One case pins the other direction: reselection before the first physical send is still allowed, and both legs then use the new key with the search result preserved.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

This is credential-selection handling, so it is the security-relevant one in lane R. The change is fail-closed in both directions: a binding that is not an API-key selection, or no binding at all, refuses the continuation rather than falling back. No key material is logged or serialized — the check compares selection identity and the resolved value in memory, and the tests assert only the Authorization header the mock destination observed.

lidge-jun and others added 7 commits September 13, 2026 15:56
…ip ci]

A routed provider that echoes the nested helper name emitted `view_image`
instead of the declared code-mode `exec`, and the undeclared-tool guard failed
the turn. `view_image` now joins the helper names the guard admits behind a bare
`exec` declaration, and the compiler turns the call into
`await tools.view_image(...)`, surfacing the returned `image_url` through
`image()` and falling back to `text()` when the host returns no image. The
`default.view_image` spelling some providers invent is stripped to the bare
helper first. Explicit `path` wins over the `file_path`, `file` and
`image_path` aliases, in that order.

Carries #4455 by jeongjin0 and the overlapping four-file subset in #4171 by
rrmlima, both answering #4412.

Folds in the review findings recorded on #4171: the compat path executes the
helper rather than emitting a text-only stub, it asserts nothing about
`view_image` being unavailable in code mode, and the composition cases the
review named are covered end to end — a namespaced `view_image` keeps its full
wire name, a flat-bridge catalog that declares `exec` beside a bare
`exec_command` is never rewritten, and malformed arguments still reach nested
validation as data.

Co-authored-by: Jeongjin Shin <80797980+jeongjin0@users.noreply.github.com>
Co-authored-by: rrmlima <137737127+rrmlima@users.noreply.github.com>
…lay a refused rung [skip ci]

A routed gateway publishes model ids but not the reasoning ladder behind them,
so the catalog advertised rungs the upstream refuses. A models.dev snapshot now
supplies the ladder when nothing is configured for that model, and a rung the
upstream actually refuses is learned, dropped from every later ladder, and
replayed once at the next lower published rung instead of failing the turn.
`requestedEffort` and `effectiveEffort` keep both values in usage under the
`reasoning-effort-downgrade` recovery kind.

Carries #4409 by yxr1995-maker onto current dev.

Conflict resolved in src/server/responses/core.ts: dev grew a
`consoleGoUploadRetryGuard` replay block at both of the insertion points this
branch targets. The two recoveries are independent and share only their trailing
`continue` tail, so both blocks are kept, console-go first, each closing its own
`if`.

Review findings folded in:

- The generic `recovery:` loop declared its downgrade guard inside the loop, so
  every `continue recovery` handed the turn a fresh downgrade budget. The guard
  now sits outside, beside the opaque-blob and console-go guards, and a
  regression test pins one downgrade for a replay that is refused again.
- `planReasoningEffortDowngrade` read the models.dev ladder before the
  configured one, so a replay could land on a rung a pinned registry ladder
  deliberately excludes. Precedence now matches `configuredReasoningEfforts()`:
  model ladder, then provider ladder, then metadata, with the same family and
  case-folded id lookup.
- `isReasoningEffortRejection` treated the bare parameter name as evidence, so a
  400 refusing another field while echoing the request back spent the turn's one
  replay and persisted a false refusal for thirty days. It now needs the
  upstream to name the parameter, or rejection language beside the effort term.
- `loadSupport()` applied the 30-day TTL only on the first disk read, so a
  long-running proxy kept clamping on month-old refusals through the memo.
- `configuredReasoningEfforts()` asked for a metadata refresh only after a
  successful lookup, which is the one path a missing or corrupt snapshot never
  reaches. The refresh is now requested before the lookup.
- The streamed test used an `openai-chat` fixture and so exercised the generic
  recovery loop while claiming to cover the passthrough one. It keeps that
  coverage and gains an `openai-responses` case for `passthroughRecovery:`.
- The decision record claimed models.dev outranks a hand-written ladder, which
  contradicted both the code and its own layer list.

`responses-reasoning-effort-downgrade.test.ts` is registered in both
`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`;
the branch had registered only `reasoning-metadata.test.ts`.

Not folded in: the refusal cache still keys on destination, model and effort, so
two configured entries pointing at the same gateway share learned refusals.
Widening the key needs a provider identity threaded through every read path in
the catalog, and a credential-derived key would put this link inside the
security-review boundary. Recorded as open rather than half-applied.

Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>
…tting OPENCODEX_HOME [skip ci]

Two defects found by running the carried change against tests/web-search, which
the reasoning-focused test selection did not reach.

configuredReasoningEfforts() asked for a models.dev snapshot refresh before the
lookup so a missing or corrupt snapshot could recover, but it asked for every
provider. A destination the snapshot does not cover gained a background fetch on
its request path that could never help it, and in tests it consumed the mocked
fetch that the web-search bridge was counting. The refresh now sits behind
providerUsesReasoningMetadata(), which is true only for the gated destinations
ladders are stored for.

tests/codex-integration/reasoning-metadata.test.ts deleted OPENCODEX_HOME in
afterEach instead of restoring it, so every later file in the same bun process
read the real ~/.opencodex. That failed unrelated suites depending on file
order, and made the run depend on the machine's actual configuration.

Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>
A hosted-search continuation is not a new turn. It is the first leg plus the
search the proxy just executed, and it has to reach the account that already
served that leg. The continuation went out through the ordinary dispatch
override instead, so a selection change during the search could send the
search-bearing body under a different key -- or rebuild the request from the
original turn and drop the search result entirely.

The bridge now captures the request binding that served the first leg, after
any permitted initial reselection, and rechecks it after provider pacing on
every continuation dispatch. The binding must still be an API-key selection
matching the configured entry, reference, revision, resolved key, auth mode and
base URL; a disabled or removed provider fails the same check. Drift ends the
turn with the bridge's failed terminal and issues no further provider request.
Initial dispatch keeps its normal reselection policy.

Carries #4387 by luvs01 onto current dev.

The branch documents this in fifteen structure/ files. Four are kept:
structure/runtime.md carries the contract itself, and transports/responses.md,
data-planes/search.md and transports/streaming-health.md own the transport, the
search data plane and the post-pacing check. The other eleven received the same
cross-reference sentence pasted into documents that own none of the changed
source -- data-planes/images.md, providers/xai-grok.md and subagents.md among
them, with ops/service-and-sidecars.md a character-identical copy of the
data-planes/search.md insertion. structure/AGENTS.md makes these documents a
source-ownership map, so a pointer in a document that owns nothing here adds a
maintenance edge without adding a fact.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 13, 2026 07:23
@coderabbitai

coderabbitai Bot commented Sep 13, 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: b1e9f42f-3c22-43b5-ad5e-e4562e0629e3

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 13, 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-13T07:27:37.405944Z a4732cb 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

리뷰 · 우선순위 72 / 80

설명
이 PR은 @luvs01의 #4387을 현재 dev tip(d865aacf9, package 2.53.0, 방금 #4481 remote catalog pull이 들어간 상태) 쪽으로 옮긴 메인테이너 캐리다. 다만 base는 dev가 아니라 lane R 스택(codex/260913-carry-4409#4488#4475) 위다. 그래서 바로 dev에 단독 머지하는 PR이 아니라, 아래 링크가 랜딩될 때마다 retarget 해야 하는 tip이다.

고치는 문제는 한 문장으로 말할 수 있다. hosted web search 연속 요청(continuation)은 “새 턴”이 아니다. 1번 다리(first leg)에서 이미 쓴 API 키로, 방금 프록시가 실행한 검색 결과를 붙여 다시 보내야 한다. 그런데 예전에는 continuation이 일반 oauthDispatch 경로를 탔다. 그 경로는 선택이 바뀌면 요청을 다시 만들고 보낼 수 있어서, 검색 도중에 키/엔트리/revision/baseUrl이 바뀌면 (1) 검색 결과가 다른 키로 나가거나 (2) 원본 턴으로 재조립되면서 검색 결과가 통째로 사라질 수 있었다.

이번 수정은 src/server/responses/core.ts에서 1번 다리를 실제로 보낸 binding을 requestBindings에서 붙잡아 두고, continuation의 providerFetchbeforeDispatch로 다시 검사한다. binding이 API-key가 아니거나 providerApiKeySelectionIsCurrent가 거짓이면 바로 에러를 던져 더 이상 provider 요청을 보내지 않는다(fail-closed). 선택이 그대로면 검색 결과가 붙은 그 요청을 그대로 재전송한다. 첫 물리 전송 전의 정상 reselection은 그대로 둔다. 문서도 소유권 맵에 맞게 structure/runtime.md 계약 + search/responses/streaming-health 네 곳만 남기고, 소스와 무관한 11개 문서 복붙 문장은 뺐다. 테스트는 revision/같은 값 reference/entry id/env로 풀린 키/authMode/baseUrl/disabled/removed, pacing 중 drift, first-leg 헤더 전 drift, 그리고 첫 전송 전 reselection 성공까지 잠근다.

우선순위 72는 자격 증명 바인딩(보안·정확성)이고 테스트가 계약을 실제로 증명하기 때문이다. 다만 스택 tip이라 아래 PR이 안 오면 머지가 막힌다. types/config 분할과 무관하다. 원본 #4387은 이 캐리가 랜딩되면 leftover로 닫으면 된다.

src/server/responses/core.ts - continuation send에서 dispatchOverride: oauthDispatch(request)를 제거하고, 캡처한 API-key binding을 beforeDispatch로 재검증한다. 바뀌면 throw, 같으면 검색 결과 붙은 body를 그대로 보낸다.

심볼 webSearchBridgeBinding / providerApiKeySelectionIsCurrent / WEB_SEARCH_BRIDGE_ERROR_CODE - binding이 없거나 oauth/forward면 역시 fail-closed다. key-auth bridge 전제와 맞는지 머지 전에 한 번만 확인하면 된다.

tests/web-search/web-search-passthrough-bridge.test.ts - 8개 drift + pacing/headers-return + 초기 reselection 보존. Authorization 헤더와 outbound 길이로 “두 번째 provider 요청이 안 나갔는지”를 본다.

structure/runtime.md #hosted-search-continuation-binding - 제품 계약의 단일 출처. search/responses/streaming-health는 링크만. 소유권 없는 문서 복붙은 제거한 판단이 맞다.

경로 docs-site/.../providers.md - 운영자용으로 “검색 중 키 바꾸면 그 continuation은 끝, 새 턴 시작”을 적었다. 코드와 같다.

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

너의 추천
아래 스택이 초록으로 내려온 뒤 이 tip을 머지하세요. 단독으로 dev에 억지 머지하지 마세요. 랜딩 후 #4387을 landed로 닫으세요.

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

@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 13, 2026
lidge-jun and others added 4 commits September 13, 2026 17:01
Reverts the review finding that asked configuredReasoningEfforts() to request a
models.dev refresh before the metadata lookup rather than after it. The
reasoning was that a missing or corrupt snapshot is the case the lookup cannot
serve, so asking only on success never refreshes it. That is true, and it is
still the wrong place.

A missing snapshot is the default state of a fresh install and of every test
process. Asking there put a models.dev fetch on the request path of the first
routed turn to a gated destination, which is observable: the lane tip run failed
tests/responses/responses-console-go-upload-retry.test.ts and
tests/providers/opencode-go-session-header.test.ts, where the extra bodyless
request landed in the middle of a recovery replay the test was counting, and
tests/web-search saw it consume the mocked destination's next leg.

Refreshing a snapshot that does not exist yet is catalog-sync work. The refresh
stays where the branch put it, so it only ever refreshes a stale snapshot that
has already answered a lookup.

Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com>
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