Skip to content

Caller-owned thread_spawn preview still reads physical main auth.json #4850

Closed

Description

@lidge-jun

Client or integration

Codex App

Area

Authentication and account pool

Summary

A thread_spawn request that authenticates with the caller's own forwardable Codex bearer still opens the operator's physical native-main auth.json during request preview.

The native-main read fence already prevents quota priming, entitlement discovery, denial-cache credential validation, reconciliation, and final-auth selection from reading physical main for this credential domain. Pool eligibility remains outside that fence:

  1. prepareResponsesRequest calls previewCodexAccountForRequest.
  2. Priority preemption reaches getEligiblePoolAccounts.
  3. Main eligibility calls isMainAccountCredentialUsable, which opens and parses auth.json.
  4. applySubagentModelFallback invokes the preview callback and repeats the same read.

Final authentication does not select or send the physical main credential: it uses synthetic liveness and either selects a stored pool account or preserves the caller-owned credential. The preview can nevertheless score physical-main liveness, cached quota, and plan state and use that result when deciding whether to rewrite the subagent model.

Expected: a request authenticated with its own credential performs no physical-main credential reads or scoring.

Actual: request preview reads auth.json twice through pool eligibility.

Severity is bounded. There is no token disclosure, refresh, upstream send, or quota consumption through the observed path, and the credential-substitution hypothesis was checked and falsified. The observable consequence is that operator-main state can influence subagent model selection for a request that owns its own credential.

ADR-0086 records "read the physical main token for identity" as an explicitly rejected alternative, and #4813 states that caller-owned preview "could read the physical main token and score main differently from final authentication" — so this path being outside the fence appears to be an omission rather than a decision.

Reproduction

  1. Configure canonical OpenAI forwarding in Pool mode with:

    • a physical native-main auth.json;
    • one usable stored pool account;
    • a subagent fallback chain.
  2. Send a /v1/responses thread_spawn request carrying a forwardable caller Codex bearer.

  3. Instrument fs.readFileSync calls whose path ends in auth.json.

  4. Observe these two stacks before final dispatch:

    • prepareResponsesRequest -> previewCodexAccountForRequest -> pickPriorityPreemption -> getEligiblePoolAccounts -> isCodexAccountUsable -> isMainAccountCredentialUsable
    • applySubagentModelFallback -> poolAccountPreview -> previewCodexAccountForRequest -> ... -> isMainAccountCredentialUsable
  5. Observe that final authentication still dispatches with the stored pool account or caller-owned bearer rather than the physical main credential.

The behavioural test at tests/responses/responses-preview-main-read-fence.test.ts records these reads; its denial-cache assertions deliberately filter them out because closing pool-liveness was outside that test-only change.

Version

d6ebcc8

Operating system

Observed in hosted GitHub Actions while converting the read-fence source contract to behavioural assertions. The call path is platform-independent.

Provider and model

openai / gpt-5.6-sol

Logs or error output

prepareResponsesRequest
  previewCodexAccountForRequest
  pickPriorityPreemption
  getEligiblePoolAccounts
  isCodexAccountUsable
  isMainAccountCredentialUsable
  readFileSync(.../auth.json)

applySubagentModelFallback
  poolAccountPreview
  previewCodexAccountForRequest
  pickPriorityPreemption
  getEligiblePoolAccounts
  isCodexAccountUsable
  isMainAccountCredentialUsable
  readFileSync(.../auth.json)

Screenshots and supporting files

The behavioural evidence is in tests/responses/responses-preview-main-read-fence.test.ts.

A narrow fix exists: use the existing CodexAccountUsabilityOptions.isMainAccountTokenLive seam in both request-preview option objects, returning read-free synthetic liveness for caller-owned requests and preserving the documented healthy main-pin behaviour from #3166. Scoping it to previewRequestScopedMainCredential === true leaves ordinary request selection unchanged.

Redacted configuration

{
  "defaultProvider": "openai",
  "activeCodexAccountId": "pool-a",
  "autoSwitchThreshold": 0,
  "subagentModelFallback": ["xai/example-fallback"],
  "providers": {
    "openai": {
      "adapter": "openai-responses",
      "baseUrl": "https://chatgpt.com/backend-api/codex",
      "authMode": "forward",
      "codexAccountMode": "pool"
    }
  },
  "codexAccounts": [
    { "id": "__main__", "isMain": true },
    { "id": "pool-a", "isMain": false }
  ]
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. lidge-jun commented on Sep 16, 2026

    @lidge-jun
    OwnerAuthor

    리뷰 · 우선순위 73 / 80

    이 이슈는 caller가 자기 Codex bearer로 인증하는 thread_spawn 요청의 preview가, 최종 인증은 건드리지 않는 물리 native-main auth.json을 아직 읽는다는 보고입니다. 지금 dev(cfdf5c379) 기준으로 보면 최종 쪽 울타리는 이미 있습니다. src/codex/auth-context.ts는 requestScopedMainCredential일 때 isMainAccountTokenLive를 합성 값으로 넣어 물리 토큰을 읽지 않습니다.

    그런데 preview 준비는 다릅니다. src/server/responses/request-prepare.ts에서 previewRequestScopedMainCredential과 nativeMainReadsForbidden을 계산하고, quota priming·entitlement·denied-cache 쪽은 그 울타리를 지킵니다. 하지만 previewSelectionOptions 객체에는 nativeMainSelectionOnly와 uploaded-file retention만 들어가고 isMainAccountTokenLive는 없습니다. 그 옵션이 previewCodexAccountForRequest → getEligiblePoolAccounts → 계정 사용 가능 판정으로 흘러가면, src/codex/account-usability.ts가 기본값으로 isMainAccountCredentialUsable()을 호출하고, 그 함수는 readMainAuthJsonCredential()으로 물리 auth.json을 엽니다.

    이슈가 적은 스택 두 줄은 그 경로와 맞습니다. 하나는 prepareResponsesRequest의 첫 preview이고, 다른 하나는 applySubagentModelFallback이 preview 콜백을 다시 부를 때입니다. 최종 인증은 synthetic liveness와 pool/caller 자격으로 물리 main을 고르지 않는다고 적혀 있고, 토큰 유출·refresh·upstream 전송·쿼ota 소비는 없다고 한정합니다. 남는 관측 결과는 operator-main의 살아 있음/쿼ota/플랜 상태가 subagent 모델 fallback 점수에 섞일 수 있다는 점입니다. ADR-0086이 물리 main 토큰을 identity로 읽는 대안을 거부했고, #4813도 caller-owned preview가 물리 main을 읽어 최종과 다르게 점수 매길 수 있다고 적어 두었으니, 울타리 누락으로 보는 설명이 설득력 있습니다.

    제안된 수정 방향도 현재 코드와 잘 맞습니다. 이미 있는 CodexAccountUsabilityOptions.isMainAccountTokenLive seam을 preview 옵션 두 곳(직접 selection options와 fallback이 넘기는 preview 옵션)에, previewRequestScopedMainCredential === true일 때만 읽기 없는 합성을 넣으면 됩니다. 일반 요청 selection은 그대로 두고, tests/responses/responses-preview-main-read-fence.test.ts가 기록해 둔 auth.json 읽기를 denial-cache 필터 밖에서도 막히게 단언을 키우면 회귀가 고정됩니다. types.ts/config.ts 대형 분리와는 무관합니다.

    path request-prepare previewSelectionOptions - isMainAccountTokenLive가 빠져 caller-owned preview가 pool eligibility에서 물리 main을 읽는다.
    path account-usability main 분기 - seam이 없으면 isMainAccountCredentialUsable() → auth.json 동기 읽기가 기본 경로다.
    path applySubagentModelFallback / preview 콜백 - 같은 옵션 객체를 다시 써서 읽기가 두 번 반복된다.
    path responses-preview-main-read-fence.test.ts - 읽기를 관측하지만 denial-cache 단언이 그 경로를 의도적으로 걸러 회귀를 못 잡는다.
    symbol preserveRequestOwnedMainPin / #3166 - 합성 liveness를 넣을 때 healthy main-pin 보존 동작을 깨지 않게 스코프를 previewRequestScoped에만 묶어야 한다.

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

    • preview에서 main을 '후보로만' 남겨야 하는지, caller-owned에서는 eligibility 점수에서 main을 아예 제외하는 편이 맞는지
    • 합성 isMainAccountTokenLive 값을 auth-context의 preserveRequestOwnedMainPin과 동일 함수로 맞출지, preview 전용 상수 true로 둘지
    • 수정 PR을 이 이슈에 바로 붙일지, read-fence 행동 단언 강화와 한 묶음으로 갈지
    • 심각도를 bug 유지로 둘지(토큰 유출 없음) documentation/ADR 정렬 작업으로 낮출지

    너의 추천
    이슈 서술과 현재 dev 코드가 일치한다. 좁은 수정 PR을 열어 previewSelectionOptions(및 fallback preview가 spread하는 동일 객체)에 previewRequestScopedMainCredential일 때만 isMainAccountTokenLive 합성을 넣고, responses-preview-main-read-fence.test.ts에서 물리 auth.json 읽기 0건을 단언하는 쪽이 가장 작다. #3166 main-pin 동작을 깨지 않게 스코프를 그 플래그에만 묶으면 된다.

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions