Repository navigation
Conversation
|
Note for reviewers: on #2420 the decision is to take the default from the target's model-specific configuration, not from a gateway setting. I will rework this PR once the question in #2420 (comment) (how the gateway obtains the target limit) is settled, so please do not merge it in its current form. |
hidb4ai
left a comment
There was a problem hiding this comment.
Reviewed 84641150e5706d6b6556041fe38ebb3bb0b491fc: one Chat output-limit compatibility issue below.
| if request.output_limits.max_output_tokens.is_none() { | ||
| request.output_limits.max_output_tokens = default; |
There was a problem hiding this comment.
[P2] Use a target-compatible Chat token-limit field for defaults
With PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENS=32000, a Chat request such as {"model":"o3","messages":[{"role":"user","content":"hi"}]} leaves token_limit_field as None, so the renderer emits max_tokens:32000. I confirmed those outgoing bytes with the actual gateway process and a recording upstream (Core/upstream were scripted, not live OpenAI). OpenAI's Chat contract excludes max_tokens for o-series models; they need max_completion_tokens, which the existing max_completion_tokens_field_name_preserved test already covers for explicit client limits. The new default therefore cannot unblock enforcing o-series Chat requests and makes otherwise valid omitted-limit requests incompatible when enabled. Select a supported wire field when supplying the default, preserve explicit client limits, and cover the omitted-limit o-series case.
There was a problem hiding this comment.
Thanks, agreed. When the gateway supplies the limit, Chat will use max_completion_tokens, which current OpenAI Chat models accept, including o-series, where max_tokens is rejected. Explicit client fields stay as they are, and the omitted-limit o-series case will be covered. This goes into the rework that takes the default from the target configuration, as decided on #2420.
There was a problem hiding this comment.
Rechecked the PR after your reply: the head is still 84641150e5706d6b6556041fe38ebb3bb0b491fc, so the emitted max_tokens issue remains in the code. The proposed max_completion_tokens change addresses this finding; please include the omitted-limit o-series regression case in the target-configuration rework. Approval remains pending the implementation and validation.
8464115 to
82cde71
Compare
frf12
left a comment
There was a problem hiding this comment.
Reviewed 82cde710d9244d8c16d349fa23e3bce88eef8b38. The target-derived output limit and Chat field fix look good. Three remaining receipt-handling cases are described inline. @jasper-yl could you share your proposed handling for these cases?
| .receipts | ||
| .retain(|(_, expires)| *expires > now_ms); | ||
| if conversation.receipts.len() >= MAX_RECEIPTS { | ||
| conversation.receipts.pop_front(); |
There was a problem hiding this comment.
[P2] Active conversations lose required receipts after 32 responses
When each response adds a distinct reasoning ciphertext and subsequent requests retain the history, the 33rd response evicts the first receipt. Request 34 still carries that ciphertext and is withheld with encrypted-origin-unverified, even though no receipt has expired. A child fails on request 33: its own 32 receipts fill for_request's limit, so truncate(32) drops the parent's still-cached task receipt while the initial encrypted agent_message remains in its history. I reproduced both Core decisions at a fixed time using this FIFO/selection logic and the actual Core issuer/verifier; this was not a Rust gateway end-to-end run. Normal continuation therefore stops within the advertised one-hour validity window.
There was a problem hiding this comment.
Rechecked 12e5a1446: sequential consolidation works, but concurrent checks can still evict an unexpired proof. I ran 33 same-conversation requests, within the default concurrency limit of 64, each snapshotting the same receipt for R. The first request completed and released X; the other 32 delayed send verdicts then stored consolidations proving only R. Their replaced IDs had already been removed, so these receipts accumulated and pop_front() evicted X's release receipt. The next R+X request returned HTTP 409 / encrypted-origin-unverified; the actual Core verifier allowed the same request with its original, still-unexpired R/X receipts. The sequence completed in 7.65 seconds, with every Core request below its 10-second timeout. This used the real gateway binary and actual Core checker/BindingCodec through an HTTP bridge, with a scripted provider, rather than a full Server-router or live-provider run. Please preserve proof added after a concurrent snapshot when applying later consolidations, including at the receipt-count bound.
There was a problem hiding this comment.
Thanks, reproduced: 33 consolidations from one snapshot evicted the release receipt added after it. Fixed in dfbce7e05.
The gateway now remembers, per conversation, the receipt IDs a consolidation has already replaced (the last 128). A consolidation whose replaced IDs were all replaced by an earlier one is dropped. It came from the same snapshot, so its proofs are already in the consolidation that won. At most one renewal is lost, and the next request renews again. Receipts added after the snapshot, including a release that finishes between the checks, therefore stay within the bound.
Regression test: concurrent_consolidations_of_one_snapshot_keep_later_receipts in receipt_store.rs runs your sequence (33 snapshots, the first consolidates, a release adds x, the other 32 consolidate). It ends with ["x", "merged0"]; without the fix it ends with 32 redundant consolidations and no x. Gateway suite: 142 passed, clippy clean.
There was a problem hiding this comment.
Thanks for the update. Rechecked dfbce7e05: the identical-snapshot regression is addressed, but two other concurrent cases still lose proof:
- Different renewals from the same snapshot. At minute 0 a receipt proves A and B. At minute 59 two concurrent checks present that receipt; one replays A, the other B. Both are allowed, but their consolidated receipts have different renewed values. If A stores first, this stale check discards B's consolidation. At minute 61 a continuation replaying B is withheld with
encrypted-origin-unverified, although B was successfully checked only two minutes earlier. The discarded B receipt admits it. - No own receipts in the snapshot. A new child can present a parent's receipt while
presented.ownis empty. With 33 concurrent checks taking that snapshot, the first can consolidate and release X, but the other 32 consolidations never enter the stale branch becausereplacedis empty. They fill the 32 slots and evict X's still-unexpired receipt, reproducing the earlier loss.
I reproduced the first case with the actual Core issuer/verifier at controlled times, and exercised both store sequences with the actual Rust ReceiptStore in a standalone test. These were component probes with simulated payloads, not an end-to-end gateway/provider run. Please preserve distinct renewals and handle concurrent empty-own snapshots without discarding receipts added after the snapshot.
There was a problem hiding this comment.
Confirmed the different-renewal case above on dfbce7e05 through the actual gateway executable and Core checker/BindingCodec behind an HTTP bridge: both concurrent requests at T+59m returned HTTP 200; replaying B at T+61m returned HTTP 409 / encrypted-origin-unverified; presenting the discarded B renewal directly to Core still allowed it. This extends the component reproduction to the gateway HTTP/store path. Core time was controlled through its existing now parameter and provider responses were scripted; this was not a live-provider run.
The original 33-request same-snapshot case now returns HTTP 200, and the target-profile endpoint-redefinition regression is also fixed. The distinct-renewal failure remains blocking.
| return None, None | ||
| receipt = _issue_receipt( | ||
| request, | ||
| (*encrypted, *released_argument_strings(parsed)), |
There was a problem hiding this comment.
[P2] Ordinary tool arguments can suppress the entire receipt
released_argument_strings includes every string argument, including ordinary unencrypted values. With a declared string-array tool, a response containing 64 distinct ordinary argument strings plus one encrypted reasoning item produces 65 digests. _issue_receipt then returns None, although before_release returns allow. The next request replaying that same reasoning is withheld with encrypted-origin-unverified. I reproduced this through the public /v1/context/check route with a simulated provider response. A normal tool payload can therefore discard the provenance of the single encrypted item and prevent the conversation from continuing.
| for part in content: | ||
| payload = part.get("encrypted_content") if isinstance(part, dict) else None | ||
| if isinstance(part, dict) and part.get("type") == "encrypted_content" and isinstance(payload, str): | ||
| found.append(payload) |
There was a problem hiding this comment.
[P2] Admitting an encrypted part also hides its extra opaque fields
This branch removes the entire content part from the unsupported-content inspection. With a valid task receipt, an agent_message part containing type: encrypted_content, the receipted encrypted_content, and an extra image_url passes the public check with allow and coverage.complete=true. An extra nested content containing an input_file passes too. The equivalent standalone image is withheld with content_unsupported. Thus the exception also bypasses inspection of unsupported fields beyond the receipted ciphertext. These are Core HTTP-route reproductions with simulated inputs, not evidence that a real provider accepts the extended shape; the separate forbidden-plaintext check still blocks its configured values.
|
@frf12 Thanks, all three are real. Proposed handling: 1. Receipts evicted from an active conversation (
2. Ordinary argument strings void the receipt ( 3. An admitted part hides its other fields (
I'll add regression tests for your three reproductions through the public check route: the 34th request and a child's 33rd request, 64 ordinary argument strings plus one reasoning item, and the extended part with If you agree, I'll implement this on #2429. |
|
@jasper-yl Thanks for laying this out. Consolidating at A few suggestions for combining the approaches:
One detail to keep in mind when replacing stored receipts: consolidating a child request should leave the parent's receipts available to the parent, and a check finishing later should preserve any newer receipts added since its snapshot. This may be straightforward within the existing store. Does this fit with your approach? Please combine whichever parts are useful and let us know if you see a simpler way or a tradeoff we have missed. |
|
@frf12 Thanks — all four points are in, pushed as
Your three reproductions are regression tests in |
frf12
left a comment
There was a problem hiding this comment.
Reviewed 6e9457216e0e1d1b0f716f87029043800c536f5e. Thanks for the update. I found one remaining case in the new consolidation flow, described inline. @jasper-yl could you take a look and share how you would handle it?
| declared = declared_encrypted_fields(parsed) | ||
| if declared is None: | ||
| return _RECEIPT_CAPACITY, None, () | ||
| digests = {value_digest(value) for value in encrypted} |
There was a problem hiding this comment.
[P2] Parent consolidation drops a pending child's task proof
A parent response containing reasoning ciphertext R and an encrypted spawn_agent.message T receives a release receipt covering both. If the parent continues before the child's first check, T is still in its function_call.arguments history, but this consolidation collects only the admitted reasoning/agent-message values, so the new receipt covers R alone. ReceiptStore::store then removes the presented original receipt (receipt_store.rs:104). The child's first request carrying T is consequently withheld with encrypted-origin-unverified, despite the original proof being unexpired. This requires only one parent continuation, not the receipt-count limit. I reproduced the sequence through the public /v1/context/check route with simulated provider payloads: the child is withheld with the consolidated receipt and allowed with the original release receipt. The Rust replacement path was inspected, not run end to end. A valid parent/child interleaving should retain the proof needed for the pending handoff.
There was a problem hiding this comment.
@frf12 Good catch, and thanks for reproducing it through the public route. Fixed in 0e530365b (plus the qualification record 0e3501529):
Approach. The send-time receipt now re-covers the values the request itself replays. before_send scans the request's replayed function_call items with the same declared-encrypted-field matching the release path uses, and adds those values' digests to the consolidated receipt — but only the digests the presented receipts already prove. So:
- a parent that keeps talking extends its consolidated receipt to the task ciphertext still sitting in its
function_call.argumentshistory, and the child's first request stays admissible after the replacement; - consolidation still mints no new proof: a replayed value without a presented, unexpired receipt stays unreceipted, exactly as before;
- the union still goes through the explicit capacity check, so a history too large to re-cover withholds with
encrypted-receipt-capacity-exceededinstead of silently dropping the handover's proof.
Verification. The regression test reproduces your sequence: parent release receipts R+T, the parent continues (presenting only that receipt, with the call in its history), and the child's first request presenting only the consolidated receipt is allowed — it was withheld with encrypted-origin-unverified before the fix. Your reading of ReceiptStore::store matches ours; the replacement semantics there are unchanged, Core just no longer strands the value. make check passes and the desktop contract is re-qualified against the new commit.
|
|
||
|
|
||
| @pytest.mark.anyio | ||
| async def test_only_declared_encrypted_arguments_are_receipted(): |
There was a problem hiding this comment.
[P2] 「只回执声明为加密的参数」缺少能检出回归的测试
encrypted_items.py 的 declared_encrypted_fields 只记录 "encrypted": true 的参数值,这是修复「普通参数值进入回执并取得准入证明」的关键;但本文件现有用例无法检出该过滤被移除。
变异证据(D:\test\github\review\oceanbase-powercontext-pr-2429\probes\,一次性副本,exact head 0e350152,未改动被审仓库):把
declared_encrypted_fields 中 isinstance(schema, dict) and schema.get("encrypted") is True 改成 isinstance(schema, dict)(即所有参数都算"声明加密"),本文件 18 个用例全部通过。
同一变异下 probes/probe_declared_filter.py 的公开路由行为发生变化:请求带 TOOLS(spawn_agent.message 声明加密、task_name 未声明),release 响应携带 SPAWN(task_name="helper"、message="gAAAA-task"),随后把 "helper" 当作 agent_message 的 encrypted_content 重放——未变异 head 得到 withhold ('encrypted-origin-unverified'),变异后得到 allow ()。
现有用例为什么覆盖不到:
- 本函数(
test_only_declared_encrypted_arguments_are_receipted)的请求只有[_user(...)],没有TOOLS,declared=()与"完全没有声明"等价,不检验声明过滤; test_ordinary_tool_arguments_do_not_displace_encrypted_values的普通值放在tags数组里,已被_argument_values的isinstance(argument, str)提前过滤。
影响:随后任何把"声明过滤"放宽的改动(例如为兼容某宿主改成"所有字符串参数都回执")都不会被 CI 拦住,普通参数值可以重新取得准入证明,而这正是上一轮评审要求修掉的缺陷形状。
建议:补一条用例——请求带 TOOLS,release 携带 SPAWN,再把 task_name 的值重放为 encrypted_content,断言 encrypted-origin-unverified;当前实现应通过该用例,上述变异应使其失败。
There was a problem hiding this comment.
@AlexStocks 谢谢,变异测试很有说服力。已按你的建议补上,见 2ba69243f:
- 新增
test_an_undeclared_argument_of_a_declared_tool_proves_nothing。父请求带上 Codex 的additional_tools声明(spawn_agent.message声明为加密,task_name未声明),release 返回SPAWN。随后:- 把
"helper"(task_name的值)作为子代理agent_message的encrypted_content重放,断言encrypted-origin-unverified; - 把真正的任务密文作为对照重放,断言放行。
- 把
- 我按你的变异做了验证:把
schema.get("encrypted") is True改成只判断isinstance(schema, dict),这条用例失败,其余用例不受影响;恢复后全部通过。
tests (3.14, unit) failed only on the runner: the handoff statement-count test passes on 3.14 locally, single and full suite, and the same stack is green as oceanbase#2429. No file changes. Co-Authored-By: Claude Code <noreply@anthropic.com>
tests (3.14, unit) failed only on the runner: the handoff statement-count test passes on 3.14 locally, single and full suite, and the same stack is green as oceanbase#2429. No file changes. Co-Authored-By: Claude Code <noreply@anthropic.com>
hidb4ai
left a comment
There was a problem hiding this comment.
Reviewed the current head. Two receipt-origin and continuation issues remain; details are inline.
| } | ||
| match format { | ||
| Format::Responses => { | ||
| header_id(headers, CODEX_PARENT_THREAD_HEADER).map(|id| format!("codex-thread:{id}")) |
There was a problem hiding this comment.
[P2] Verify the parent-to-child handoff before forwarding its receipts
A Responses client can set thread-id to an unrelated conversation and x-codex-parent-thread-id to any known conversation in the same Scope. This code forwards that parent's receipts, and Core accepts every matching digest, including the parent's reasoning ciphertext, without checking that this child was spawned by that parent or that the ciphertext was handed over as its task. With the actual Core issuer/verifier and simulated payloads, an unrelated child's replay of the parent's reasoning is withheld without the claimed parent and allowed with it. This violates the verified parent-to-child restriction in #2420; the same principal/Scope/target checks do not establish that relationship. Please authenticate the handoff and restrict inherited proof to the values it authorized. This reproduction exercised Core directly; the gateway header-to-receipt path was inspected, not run end to end.
There was a problem hiding this comment.
@hidb4ai Thanks, confirmed. A named parent's receipts were accepted for any matching digest. Fixed in 2ba69243f by restricting what a claimed parent can lend:
- Handover values. Receipts now mark handover values separately. These are the function-call argument values the checked request's tool definitions declare
"encrypted": true, which for Codex isspawn_agent.message,send_message.messageandfollowup_task.message. - What a parent's receipt can prove. A receipt issued to the request's
parent_conversation_idproves only those handover values, and only in anagent_messageencrypted_contentpart, which is where a subagent receives its task. It never proves the parent's reasoning, at the reasoning position or disguised as anagent_message. Your reproduction (an unrelated child replaying the parent's reasoning with the parent named) is now withheld withencrypted-origin-unverifiedeither way. - Regression test:
test_a_claimed_parent_lends_only_its_handover_valuescovers three cases: the parent's reasoning replayed as reasoning, the same value as anagent_message, and the task value replayed as reasoning.
What this does not establish. Core still cannot prove that the conversation naming a parent is the subagent that parent actually spawned. The child's thread ID is assigned by the client and reaches Core only in the parent's later spawn_agent output, which can arrive after the child's first request. So a conversation of the same principal, Scope and target that names the parent can receive that parent's declared task values, and nothing else.
Binding the handover to the child's identity would need the parent's tool result to precede the child's first request, which Codex does not guarantee. If you consider the remaining case blocking, I'd suggest binding each handover value to a single child conversation on first use. I can add that.
Verified end to end as well: an unmodified Codex 0.145 against the real OpenAI API, through the gateway and Server built from this commit in enforcing mode, completed the parent-plus-subagent run with no withheld check. The child's task was admitted as the parent's declared handover value.
There was a problem hiding this comment.
Thanks for tightening the allowed values. I rechecked 74ebb734398660415fd2e3d505103b1b56635b4c: the parent reasoning replay is now withheld, but the relationship requirement remains open. With one parent receipt for task T, the actual Core verifier admits T as an agent_message for two different child conversation IDs that both claim that parent; no spawned-child identity or handoff is checked. This assumes the caller already knows T and the parent ID and uses the same principal, Scope, and target; it does not demonstrate disclosure of T. The reproduction uses simulated payloads and calls Core directly, while the gateway's header/receipt path was inspected. Since #2420 explicitly requires a verified parent-to-child handoff, please preserve that restriction or get an explicit maintainer decision to relax it. A first-use binding limits replay but by itself does not prove that the first claimant is the intended child.
There was a problem hiding this comment.
@hidb4ai You're right that this does not verify which child the parent spawned, and first-use binding would not either. @frf12, this needs your decision, since #2420 asks for "a verified parent-to-child handoff".
What Core can verify today. A subagent can use only values its parent's release declared for handover, and only as its agent_message task. Those values must be for the same principal, Scope, target and account, and unexpired. It cannot use the parent's reasoning.
What it cannot. Whether the conversation naming that parent is the child the parent actually spawned.
- The child's identity is its client-supplied conversation ID (Codex
thread-id, orX-PowerContext-Conversation-IDper RFC 2199). Every receipt in this design is scoped by that same client-supplied identity. - Codex assigns the child's ID itself. It reaches Core only in the parent's later
spawn_agentoutput, which can arrive after the child's first request.
So a stronger check would need an identity source the client does not assert. As far as I can see, neither the gateway nor Core has one.
Options:
- Accept the restriction as implemented (handover values only, same principal, Scope, target and account) and document that the parent relationship is asserted by the client, like the conversation identity itself.
- Disable
agent_messageadmission until there is an authenticated child identity, and leave subagent tasks withheld. Codex subagents would then not work through an enforcing Server.
I'd suggest option 1, since it matches the trust level of the conversation identity the rest of the design already relies on. I'll implement whichever you choose.
There was a problem hiding this comment.
Thanks for laying out the tradeoff. Rechecked 12e5a1446: two different claimed child conversation IDs can still use the same parent's handover value under the same principal, Scope and target. This does not show disclosure of the value, but the relationship is still client-asserted rather than verified, as you described. I agree this needs the maintainer's decision on whether to relax #2420's requirement; until then I am keeping the existing change request open. Separately, I replayed the reasoning-to-handover counterexample on this head and confirmed it is now withheld.
There was a problem hiding this comment.
Thanks for the explicit scope decision in #2429 (comment). Under that accepted combined-deployment trust model, I no longer consider the host/client-asserted parent relationship itself a blocking defect.
At the unchanged head dfbce7e05, the English and Chinese operating docs still describe subagent handover without explicitly stating that Core does not authenticate the parent-to-child relationship. Please document that boundary as requested. The separate concurrent-renewal failure remains open: #2429 (comment).
| }; | ||
| conversation | ||
| .receipts | ||
| .retain(|r| r.expires_ms > now_ms && !replaced.contains(&r.id)); |
There was a problem hiding this comment.
[P2] Preserve unexpired proof needed by divergent requests
Consolidation deletes every presented receipt, but the replacement covers only ciphertexts and declared arguments in this particular request. Suppose an unexpired receipt covers reasoning values R and X, and a retry or concurrent branch submits an earlier history containing only R. Its send check returns a receipt for R, and this replacement removes the only proof of X. A subsequent valid request on the other branch that still replays R and X is then withheld with encrypted-origin-unverified, even though X was released and its original receipt has not expired. I reproduced the Core decisions with simulated responses and applied the replacement logic shown here; I did not run the Rust gateway end to end. Please retain proof needed by other in-flight or retried histories when consolidating.
There was a problem hiding this comment.
@hidb4ai Agreed, consolidation must not delete proof another history still needs. Fixed in 2ba69243f, on the Core side, so the gateway's replacement stays simple:
- Receipts carry proofs with their own expiry. A receipt groups its digests by expiry, rounded down to the minute so a carried proof never outlives its original receipt.
- Consolidation carries every unexpired proof. The
before_sendreceipt now carries every unexpired proof of the conversation's own presented receipts, each with its original expiry. It renews for one hour only the values this request carries, plus the proven handover values its replayed function calls carry. The parent's receipts are never folded into a child's. - Effect in your scenario. The receipt that replaces the presented ones proves X as well as R, so the branch replaying R and X still verifies. X still expires when its original proof would have, because an unused value is not renewed.
- Regression tests:
test_a_divergent_history_keeps_the_proof_another_branch_needscovers your sequence: R and X released, a retry replaying only R consolidates, and the branch replaying both is allowed with only the consolidated receipt.test_a_carried_proof_keeps_its_original_expirychecks that X is withheld after its original hour while the renewed R is still admitted.
- Capacity. The total still goes through the explicit 1,024-value check.
1aa509b to
74ebb73
Compare
|
Two updates on #2429. 1. Corrections to my summary of
2. Branch history. I removed two empty "retrigger CI" commits from the branch and force-pushed. The |
hidb4ai
left a comment
There was a problem hiding this comment.
Re-reviewed the updated receipt logic. The divergent-history case is fixed, but the parent-to-child verification remains open in the existing thread and the new consolidation path permits a further proof upgrade below.
| proofs[digest] = _Proof(renewed, digest in own and own[digest].handoff) | ||
| for value in replayed_argument_values(parsed, declared): | ||
| digest = value_digest(value) | ||
| if digest in own or digest in inherited: |
There was a problem hiding this comment.
[P2] Do not upgrade ordinary origin proof to handover proof
digest in own also matches reasoning values that were never released as encrypted tool arguments. A caller can put a previously receipted reasoning ciphertext R into a replayed spawn_agent.message in its request history; this branch then issues a consolidated receipt marking R as a handover value even though the provider never released that function call. A child naming this parent can subsequently submit R as an encrypted agent_message and gets allow, whereas the original receipt correctly withholds it. I reproduced both decisions using the actual Core checks and simulated request/response payloads; I did not run a gateway or provider end to end. Preserve the handover classification from authenticated release evidence when consolidating rather than deriving it from caller-supplied replay history.
There was a problem hiding this comment.
@hidb4ai Confirmed and fixed in 12e5a1446.
Consolidation now keeps a value as a handover value only if the presented receipts already proved it as one: from a release whose checked request declared that argument encrypted, or from the parent's handover values. A value that appears in a replayed function call of the caller's history is no longer upgraded. It keeps only the proof it already had.
Regression test: test_replayed_history_cannot_turn_a_reasoning_proof_into_a_handover_proof. It follows your sequence: reasoning R is receipted, the caller replays R as a spawn_agent.message the provider never released, and a child naming the parent submits R as an agent_message. The child is withheld with encrypted-origin-unverified. The test fails without the change.
The branch is also rebased onto current master. The Desktop record is now sqlite-stats-18d062ce-v1: twelve operations, qualified as a bundled product record.
An enforcing Server requires every generation request to carry an output limit, and Codex 0.145 never sends max_output_tokens, so every unmodified Codex request was withheld with output_limit_required. PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENS adds a limit to Chat and Responses requests that set none, before the check; a client's own limit is kept. The value is validated at startup. Refs oceanbase#2420. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on in Core Core's capabilities report each target's configured max_output_tokens in context_protection.output_limits. The gateway reads its own target's value at startup and fills it into Chat and Responses requests that set no limit, before the check; Chat uses max_completion_tokens, which o-series models accept. This replaces the gateway-side PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENS setting, so the limit is configured only on the Server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For an openai-responses target that sets admit_encrypted_items, before_release admits encrypted reasoning in the response and, when the request names a conversation, returns a receipt sealed with the existing binding codec: the digests of those values and of the response's function-call arguments, bound to the principal, Scope, target and conversation for one hour. before_send admits an encrypted reasoning item or agent_message part only when a presented receipt for its conversation, or the parent conversation that handed a subagent its task, contains the value's digest; otherwise it withholds with encrypted-origin-unverified. Other encrypted positions stay opaque. The gateway keeps receipts per conversation and presents them with the conversation's later requests and with its subagents' requests. Refs oceanbase#2420. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… declared and exact values Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipts A parent that keeps talking after spawning a child replaces the receipts it presents at before_send. The consolidated receipt now also re-covers the encrypted argument values its replayed function calls carry when the presented receipts prove them, so the pending subagent handover keeps its proof. Values without a presented proof stay unreceipted and the capacity limit still withholds explicitly. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ut_limits 85017ff added the Rust field without running export_ipc, so desktop-validation ipc:check reported drift. Regenerated with cargo run --example export_ipc. Co-Authored-By: Claude Code <noreply@anthropic.com>
…oof to handover values A consolidated receipt now carries every unexpired proof of the receipts it replaces, with its expiry, so a retried or divergent history keeps the proof another branch still needs; only the values a request uses are renewed. Receipts mark the declared encrypted arguments as handover values, and a subagent may use its parent's receipts only for those values as its agent_message task, never for the parent's reasoning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aster Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Records sqlite-stats-18d062ce-v1 for Server source 18d062c: the wheel built from that commit (704 runtime files identical to the source) passed all four real_server.py modes as a bundled product qualification, with the Rust 1.95.0 native probe and the product coverage test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lidating receipts A replayed function call in the caller's history could mark an ordinary receipted value, such as reasoning ciphertext, as a handover value in the consolidated receipt, so a child naming the parent could then submit it. Consolidation now keeps a value as a handover value only when a receipt already proved it as one, or the parent's receipts did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
74ebb73 to
12e5a14
Compare
hidb4ai
left a comment
There was a problem hiding this comment.
Re-reviewed 12e5a1446de5e8aa59a5835c55ad1079f481071b. The original Chat output-limit field issue is fixed. One additional receipt target-binding issue is below; a concurrent proof-loss reproduction is being added to the existing receipt-count thread. The existing parent-to-child verification discussion remains open.
| if ( | ||
| claims.get("identity") != binding_identity | ||
| or claims.get("scope_id") != scope_id | ||
| or claims.get("target_profile") != request.target_profile | ||
| ): |
There was a problem hiding this comment.
[P2] Bind encrypted receipts to the resolved upstream target
Matching only target_profile lets an unexpired receipt survive redefining that profile. Through /v1/context/check, I issued a reasoning receipt, closed the Server app lifespan, and recreated the app with the same SQLite database and persisted signing key but a different endpoint for the same profile. Replaying the old ciphertext and receipt was still allowed and renewed, while the old ordinary send binding correctly returned context-refresh-required. The receipt therefore no longer proves that the current upstream target produced the value, as #2420 requires. Bind receipt issuance and verification to the resolved target identity or its configuration generation, and reject proofs when that identity changes. This exercised actual Server lifespans, authentication, SQLite and ASGI HTTP routing with simulated provider payloads; it was not an external-provider call or a spawned-process restart.
There was a problem hiding this comment.
Fixed at head dfbce7e0. Receipts are now bound to the resolved target, not only to the profile name: _target_identity(profile) (src/powercontext/builtin/runtime/context_check.py:181) hashes [endpoint, protocol], issuance seals it as target (:611), and _presented_proofs drops any proof whose claims["target"] differs (:656). Replaying a receipt after the same profile is redefined at another endpoint now returns encrypted-origin-unverified instead of renewing.
The regression test encodes exactly the sequence reported here: test_a_receipt_proves_nothing_after_its_target_profile_is_redefined (tests/builtin/runtime/test_context_check_encrypted.py:247) keeps the profile unchanged (allow), then redefines targets["test"].endpoint with the same codec (withhold).
Counter-check, so this is a reproducer and not a restatement of the implementation: that test fails on a head that lacks the binding (#2435 ce0e256f, otherwise the same file) with Right contains one more item: 'encrypted-origin-unverified', and passes here. tests/builtin/runtime/test_context_check_encrypted.py 24 passed at dfbce7e0.
Boundary for the record: the identity covers endpoint and protocol by design, so rotating credentials or headers on the same endpoint does not void receipts; a different credential on the same endpoint cannot decrypt the released value anyway. Closing this thread — no further action requested.
…defined Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@jasper-yl plz fix the file confliction. |
|
@jasper-yl I agree with option 1 under our combined Gateway/Core deployment assumption: the trusted client or host may assert the parent conversation, while Core verifies the existing boundaries — same principal, Scope, target and account; an unexpired receipt; and only values declared for handover, admitted as the child Please document the boundary clearly: Core verifies the released value and its allowed handover use, but the parent-to-child relationship itself remains host/client asserted. That is the trust model we are accepting for the combined deployment. |
knqiufan
left a comment
There was a problem hiding this comment.
The existing divergent-request receipt-loss finding remains open. The following additional recovery failure was reproduced with the actual Gateway and a scripted Core.
| output_limit, | ||
| } | ||
| } | ||
| Ok(Err(e)) => { |
There was a problem hiding this comment.
[P2] Recover the target limit when Core becomes available
If the capabilities query fails at startup, output_limit is fixed to None for the lifetime of the process. When Core subsequently recovers, requests that omit an output limit still have none; a real Core policy then withholds them as output_limit_required. A startup with Core available injects the configured limit. Refresh or otherwise recover the target limit after transient startup failure.
There was a problem hiding this comment.
Independently confirmed on dfbce7e05 with the actual Gateway process and the real Core check_context/BindingCodec behind an HTTP bridge. After the first capabilities request returned 503, I verified capabilities had recovered and advertised a limit of 64. Two subsequent requests omitting the limit both returned HTTP 409 / output_limit_required, while an explicit limit of 32 succeeded. The healthy-startup control injected 64 and succeeded. Gateway made no further capabilities requests. Capabilities/provider responses were scripted; this was not a full Server-router or live-provider test.
knqiufan
left a comment
There was a problem hiding this comment.
Re-reviewed dfbce7e05. The inline finding covers a mixed-version regression that affects ordinary clients and checks even without encrypted receipt admission.
Two existing findings still apply: concurrent receipt renewal can lose valid proof and the Gateway does not recover the default output limit after a failed startup capabilities query.
Open question: the receipt now binds the endpoint and protocol. Please confirm how the agreed upstream account boundary should apply when the provider account changes at the same endpoint. This needs clarification; I did not test an actual account switch.
| "send_seconds": int(SEND_VALIDITY.total_seconds()), | ||
| "correlate_seconds": int(RESPONSE_CORRELATION.total_seconds()), | ||
| }, | ||
| "output_limits": { |
There was a problem hiding this comment.
[P2] Preserve SDK and Gateway compatibility during upgrades
The Server always emits context_protection.output_limits, even when encrypted receipt admission is off. In an isolated Server run, /v1/capabilities returned HTTP 200, but the pre-change Python SDK response model rejected output_limits with extra_forbidden. Removing only that field made the same response validate. The reverse upgrade direction also breaks: the new Gateway sends conversation_id on ordinary conversation checks; the pre-change Core request model accepts the same check without that field and rejects it with the field. Please keep the existing wire contract usable during a mixed-version rollout, for example by negotiating the new fields, or explicitly define and test the required upgrade boundary.
There was a problem hiding this comment.
I reproduced both model-level rejections, but the SDK/CLI upgrade boundary is already documented in the installation guide: SDK/CLI installations must use a matching release or Git ref with the Server, and capabilities does not negotiate an older response shape. That paragraph also exists at base 4b50c158. The old SDK rejection alone therefore does not establish a failure within the documented support boundary.
I did not find a promise of mixed-version Gateway/Core support; explicitly documenting their required version pairing would clarify that rollout boundary. My validation used the current Server with authentication/SQLite through ASGI HTTP and the base generated models, not an installed old SDK or a live old Server/Gateway deployment. The separate concurrent-renewal and startup-recovery findings remain open.
|
I've proposed closing this PR together with #2420: since #2457, the Gateway works with a non-enforcing Core, and an unmodified Codex completes there, subagents included (#2420 (comment)). I'll hold the remaining review fixes until that is decided. |
Which issue or RFC does this PR close?
Closes #2420: both parts, the output-limit default and encrypted-content admission, as decided there for the combined deployment. Retry handling is #2428 and is not included.
Rationale for this change
An unmodified Codex 0.145 could not complete a turn through an enforcing Server, for two reasons:
max_output_tokens, so every request was withheld withoutput_limit_required.store: falsewithinclude: ["reasoning.encrypted_content"], so every response carries encrypted reasoning that the next requests replay. A subagent receives its task as an encryptedagent_messagepart, which the parent's model returned insidespawn_agentarguments. Core withheld all of these ascontent_unsupported.What changes are included in this PR?
Output limit, from Core's own target configuration
context_protection.output_limits: each target's configuredmax_output_tokens.PC_GATEWAY_UPSTREAM_PROFILE) from capabilities at startup. It fills that value into Chat and Responses requests that set no limit, before injection and the check. On Chat it usesmax_completion_tokens, which o-series models accept. A client's own limit and field are kept, and the final request is checked as before.PC_GATEWAY_DEFAULT_MAX_OUTPUT_TOKENSis gone.Encrypted items, admitted only with receipts (existing binding codec, no persisted state)
Opt-in.
UpstreamProfile.admit_encrypted_items(reasoning,agent_message), foropenai-responsestargets only. Empty by default, so nothing changes without it.Inspection. For such a target, encrypted values at exactly these positions are set aside before the opaque-content check:
encrypted_contentof a root-levelreasoningitem (its other fields stay inspected);agent_messagecontent part whose only fields aretype: "encrypted_content"andencrypted_content.A part with any other field, and every other encrypted value or opaque type, still withholds. Only the context check enables this; Core's own protected inference transport is unchanged.
Receipts. Sealed with the binding codec under their own purpose, bound to the principal, Scope, target and conversation. Values are 16-byte digests grouped by expiry (rounded down to the minute), and a value is marked as a handover value when it is a declared encrypted argument. One receipt holds at most 1,024 values (about 30 KB sealed). A check whose values do not fit withholds with
encrypted-receipt-capacity-exceeded, so no later request silently loses its proof.before_release. When the request names a conversation, Core returns a receipt valid for one hour."encrypted": true. Codex declaresspawn_agent.messagethis way, in itsadditional_toolsnamespace. The send binding records the declared fields for the release.before_send.conversation_id.parent_conversation_idproves only handover values, and only in anagent_messagepart. A subagent can receive its task, never the parent's reasoning.encrypted-origin-unverified. Without a conversation nothing is receipted or inherited.Consolidation. An allowed
before_sendwith a conversation returns one receipt that replaces the conversation's receipts the request presented.Gateway.
ReceiptStorekeeps receipts per conversation.x-codex-parent-thread-idon the Responses route, the Claude Code parent agent (or the main session for a top-level subagent), and the OpenCode parent session.Admission proves where a value came from, not what it contains: Core has not inspected the plaintext. The docs say so.
Contract and docs
openapi/powercontext.yaml:conversation_id,parent_conversation_idandencrypted_receiptsonbefore_send;conversation_idonbefore_release;receiptin the result, atbefore_sendandbefore_release;output_limitsin capabilities.pnpm --dir desktop generate) and the IPC bindings (export_ipc) are regenerated.sqlite-stats-18d062ce-v1incompatibility.jsonanddesktop/VALIDATION.md(details below).inference-protection.mdanddeploy-server.mdin English and Chinese, andgateway/README.md.Are there any user-facing changes?
max_output_tokensinstead of being withheld.encrypted-receipt-capacity-exceeded.null.How was this change tested?
tests/builtin/runtime/test_context_check_encrypted.py: 23 tests with Codex request and response shapes (including Codex'sadditional_toolsdeclaration):content_unsupported;nullvalidate.agent_message;encrypted: truefilter);image_urlor nestedcontentis inspected whole;output_limits.make checkandmake contract-testpass.release_receipts_are_presented_for_the_conversation_and_its_subagents;ReceiptStorebounds and expiry, consolidation that keeps receipts added after the snapshot and the parent's receipts, and an active conversation's bounded receipt count;max_completion_tokensfor an o-series request with no limit, with a client's ownmax_tokenskept.gpt-5.6-sol), through a Server and a gateway built from this branch, enforcing mode, with no shim. The target hasmax_output_tokens: 32000andadmit_encrypted_items: ["reasoning", "agent_message"]. The parent was asked to start one subagent that replies "pong".null, so every check failed with 422. It is fixed in this PR and covered by a test.2ba69243f(consolidated receipts, declared handover values, exact admission), it completed the same way, with no withheld check. A recording pass-through between the gateway and OpenAI showed:max_output_tokens: 32000, filled from Core;agent_messagepart, admitted with the parent's receipt as a declared handover value (spawn_agent.messagein Codex'scollaborationnamespace).uv build --wheelfrom18d062ce0(rebased onto master). Its 704 runtime files are identical to the commit (manifest digesta106efca…).desktop/tests/real_server.py --product-qualification sqlite-stats-18d062ce-v1passed anonymous loopback, static Bearer, HTTPS with an explicit CA and base path, and enforced-provider access, with the Rust 1.95.0server_probe.bundled_product_qualification_covers_every_desktop_operationpasses.desktop/VALIDATION.md#server-18d062ce-statistics-product-qualification.AI usage statement
Built with Claude Code (Claude Opus 5.5), which wrote the change, the tests, the docs and the qualification record, and ran the checks above.
🤖 Generated with Claude Code