Skip to content

[WRONG BRANCH] release: promote native macOS tray candidate to main 2.61.0 - #5510

Merged
lidge-jun merged 97 commits into
mainfrom
codex/native-tray-release-main-2.61.0
Sep 22, 2026
Merged

lidge-jun merged 97 commits into
mainfrom
codex/native-tray-release-main-2.61.0

Conversation

@lidge-jun

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

Copy link
Copy Markdown
Owner

Summary

  • Prepare the maintainer promotion of the native macOS usage panel and regression repairs from dev commit 2dec4be4fd77c6b193e25bcc9923e8bea87369ee to main for 2.61.0. The owner explicitly requested creating main and preview promotion PRs now.
  • This freezes the product candidate before the dev version pre-move. Stable and preview derive independently from the same candidate; this branch does not include the future 2.62.0 development version.
  • This is the owner-requested stable promotion. Gateway partial-state bookkeeping (fix(claude): retain committed gateway state after cleanup failure #5519) and bounded ordinary macOS shard execution (ci: bound ordinary macOS shard process lifetimes #5520) are included. Fresh promotion/release checks remain pending. Prepare preview publication first, then stable, under the canonical release workflow.

Verification

  • Source candidate 3d64bd3040b2da7953962da3c05be14f31991e56 passed PR CI, all-platform CI and service lifecycle before admin integration through feat(desktop): native macOS usage panel and release regression fixes #5490.
  • Promotion-head checks and release artifact validation are pending; source-candidate results do not claim promotion-head success.
  • Local tests, typechecks, builds and QA probes: NOT RUN by explicit user instruction. Branch push used --no-verify. Sol source/security review is recorded in the native-tray unit and does not represent independent maintainer approval.
  • Native screenshot below uses synthetic data captured before the final data-parity adjustments; it is not final installed-artifact proof.

Native macOS usage panel

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.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Owner-controlled promotion: the owner explicitly requested both channel deployments. This PR uses repository admin promotion authority, not an independent approving review, and changes no rulesets. Publication remains serialized after preview and gated by canonical release.yml at the final main SHA.

luvs01 and others added 30 commits September 20, 2026 12:20
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…link refusal (#5264)

* fix(transport): decide DNS pinning by whether the proxy applies to the request

Carries the proxy-applies decision onto current dev and tightens the
cases exact-head review found uncovered.

The DNS-pinned provider transport used to leave pinning whenever ANY
proxy variable was present (outboundProxyConfigured). Presence is not
application: with only a scheme-mismatched variable set, an https:
request still downgraded to the unpinned fetch even though Bun fetch
would never use that proxy for it, and a local DNS failure silently
degraded to the same unpinned fetch. The benchmark/fake-IP admission,
the transport downgrade, the DNS-failure degradation and the
private-network NO_PROXY demand now key on one snapshot: whether a
proxy actually applies to this request (a usable, scheme-matched
proxy variable that NO_PROXY does not exempt).

effectiveProxyFor models that decision. Two corrections to the
carried model: a non-SOCKS ALL_PROXY counts for plain http: targets
on every CI platform, not just POSIX — the provider-outbound e2e
drives that exact request through the proxy on Linux, macOS and
Windows — and a present-but-unusable scheme-matched variable fails
closed instead of falling through to ALL_PROXY, because no usable
proxy is guaranteed either way and keeping the pinned transport is
the safe direction.

The Mihomo IPv6 fake-IP gate deliberately does not move to the new
snapshot. Its documented condition is stricter — a scheme-matched
variable or a SOCKS5 ALL_PROXY, with a non-SOCKS ALL_PROXY never
counting — and admission pins the fetch to that value explicitly, so
it now reads schemeMatchedProxyFor. That keeps every documented and
tested #3462 behaviour byte-identical, including a SOCKS URL written
into a scheme variable remaining a valid explicit binding.

Regressions pin a scheme-mismatched variable keeping the pinned
transport with benchmark answers rejected, a NO_PROXY match keeping
it, a mismatched variable not demanding NO_PROXY for private
providers, a DNS failure with only a mismatched variable surfacing
instead of degrading, and the degradation surviving for the proxy
that genuinely applies.

Every caller of providerOutboundGet/Post — provider discovery, the
model-catalog gather, quota probes, ollama show and the management
model-refresh routes — shares this single decision function. The
main inference dispatch and OAuth token exchange do not use the
DNS-pinned transport today and are unchanged.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(integrations): reject symlinked managed write targets

Carries the managed-target symlink refusal onto current dev, without
the src/config.ts re-export — that file sits exactly at its file-size
ratchet cap, and the only consumer imports the leaf directly.

A managed client configuration lives in a directory another process
can write, and the writer used to resolve a symlink at the final path
component both when inspecting the target and when committing the
atomic replacement. A symlink swapped in between read and write could
redirect the write — and the ownership journal's confidence — onto a
file the integration does not manage.

Three layers now refuse that. loadTarget probes the named directory
entry without following it whenever the IO exposes a no-follow probe,
so apply, refresh, disable and restore all classify a symlinked
target as unsafe before any write is planned; the Aside profile guard
forwards that probe so the per-profile path keeps the same boundary.
fileIO.writeText rejects a non-regular entry up front. And the atomic
commit replaces the named directory entry itself: a new
atomicWriteFileNoFollow resolves only the parent (an OS alias above
the configured root stays legitimate) and re-validates the target
inside the write immediately before the rename, so a link exchanged
after validation is refused rather than followed. A swap past the
last check can only replace the named entry, never redirect through
it.

Regressions pin an omo catalog symlink refused at rest with its
target byte-identical, a symlink swapped in during apply's snapshot
window refused with no ownership recorded, a Cline pair member
exchanged for a symlink at the write boundary unable to redirect the
replacement, and disable and restore each refusing a symlinked
target while leaving the linked file alone. Refresh shares the apply
observation and write path, so it inherits the same refusals.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* docs(devlog): record lane B transport and write-safety progress

* docs(devlog): link lane B progress to its pull request

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
…ey model policy (#5265)

* fix(devin): send the configured output budget instead of the encoder default

Codex never sends max_output_tokens, and the devin adapter only forwarded a
caller-supplied value, so every devin turn was capped at the cloud-direct
encoder's 8192 fallback however the provider was configured. A turn that
legitimately needed more ended as an upstream incomplete/max_output_tokens the
client then retried into the same deterministic wall.

Resolve the cap the way the other adapters do, highest authority first: an
explicit caller value forwarded unchanged, then the configured per-model cap,
then the provider default, then the encoder fallback. The lookup is the same
UID-aware hint resolution the input ceiling already uses, extracted so both
sides read a per-model number identically.

The output cap and the history ceiling stay separate. CompletionConfiguration
#2 is the output cap and #3 is the context window, so the resolver reads
neither contextWindow nor modelContextWindows -- collapsing them would ask
Cognition to generate a whole context window of output.

Also stop OAuth startup reconciliation from deleting an operator's output
budget. No OAuth preset declares defaultMaxOutputTokens or
modelMaxOutputTokens, so the delete-when-preset-undefined branch was the only
branch either field ever took and a hand-edited value was wiped before the
next startup finished. A preset that does declare one still refreshes the row.

Closes #5190

* feat(coding-agent): derive the projected-history ceiling from the model context window

The coding-agent CLIs replay the whole conversation each turn as one projected
user message over stream-json stdin, and the projection had a flat
200k-character history ceiling: roughly 50k tokens of English or code, a
fraction of what even a 128k-token model holds and far below the 1M-token
families. Long sessions lost their earliest context at a bound unrelated to
the model.

Derive the ceiling from the declared model context window on the routed
provider row -- modelContextWindows by model, then the provider-wide
contextWindow -- at three characters per token, floored at the legacy 200k cap
so small windows and missing metadata behave exactly as before, and capped by
a 4M hard ceiling so runaway metadata cannot unbound stdin. The resolution
lives once in the shared runCodingAgentTurn driver, so both CodeBuddy and
Qoder turns get it and the family adapters are unchanged.

This ceiling is a runaway-memory bound on replayed history, measured in
characters. It is deliberately not the output budget: caller-side compaction
remains the token authority, and nothing here decides how long a reply may
run.

Co-authored-by: mdwsk88 <924038395@qq.com>

* fix(adapters): bound the event queue by retained payload, not by event count alone

The adapter event queue capped how many events it buffered but never how much
those events held. Coalescing merges adjacent deltas up to 64 KiB an item, so
the old 1024-event cap admitted about 64 MiB of retained text before it said
anything, and a single oversized event was unbounded on its own.

Charge what the queue actually retains, against two separate budgets. The
aggregate budget bounds everything held at one moment; the per-event budget
bounds one event and applies however empty the queue is. They describe
different failures -- a consumer that is not keeping up versus an event that
is malformed -- so they report different terminal messages and an operator can
tell which happened.

The accounting is exact on every path. Each queued item records what it was
charged, so a merge pays only for the text it appends, a dequeue gives back
precisely what it took, a refused event is priced before anything is retained
and never charged, and the terminal record that explains a refusal is admitted
past the budget it reports but still charged and released. Draining therefore
returns the counter to zero after a normal turn, after an overflow abort and
after a consumer walks away mid-stream; retainedCodeUnits() exposes that so a
regression can assert it rather than infer it from an abort that happened to
fire.

A long healthy stream is still not capped by its total length: every dequeue
releases its charge, so only an undrained backlog accumulates. The aggregate
default is sized for the other legitimate case -- a synchronous producer that
fills the queue before its consumer is scheduled, as the image loop does with
over a million one-character deltas -- which is roughly 1.2 MB of retained
text and must not abort.

Retention is measured by walking own enumerable properties rather than by
naming each variant's string fields, because a hand-written per-variant table
is exhaustive over the AdapterEvent union and would silently stop counting a
member added on another branch. The walk carries depth and node ceilings so
one push stays cheap against the open provider-shaped payloads two members
carry.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* feat(server): scope an admission key to the models and providers it may reach

A hub serving several clients with their own data-plane keys had no way to stop
a mail or cron key spending a coding key's Grok or Claude quota. Hub-level
model selection cannot express it: it hides a model from everyone or from
no one.

An admission key may now declare allowedProviders and allowedModels. Absent or
empty means unrestricted, which is every existing key, so nothing changes until
an operator sets one.

Two rules decide what this is. A scope names destinations, not selectors: it is
evaluated against the resolved provider and model a turn will actually bill,
never against the string the client sent. Alias resolution, a policy or combo
selection, a subagent fallback and a compaction override all rewrite that
string, so a scope checked at the front door would authorize one destination
and reach another. And a scope is never management authority -- it narrows
which models an inference key may call and grants nothing else.

Enforcement sits where each route becomes concrete. On the Responses path that
is the single point every produced route passes through, which covers the
direct name, an alias, a policy or combo child, a shadow-intercept target,
both subagent-fallback re-routes and, through translation, the Chat and
Messages surfaces. The native Chat lane and the compaction route send without
re-entering that path, so each applies the same predicate itself. A refusal is
403 with a stable model_not_allowed_for_key type naming the caller's own
selector; the resolved destination stays in the server log, because a key that
may not reach a provider has no business learning that its alias points there.

/v1/models filters by the same predicate, so what a key can see and what it
can call cannot diverge. That filter is a convenience and not the boundary:
hiding a row only stops a client that reads the catalog first.

A malformed scope drops the key rather than degrading to undefined, unlike
every other field on the record. Degrading a damaged permission field reads as
"allowed everything", which is the one direction it must never fail.

Management exposes the lists on GET /api/keys and accepts them on PATCH, where
rename and scope are independent edits, and ocx access key get/set reads and
writes them without printing or rotating the secret.

Closes #5049

* docs(devlog): record lane E of the phase 2 consolidation batch

* fix(server,adapters): close the virtual-model scope gap and harden queue retention

Three findings from an adversarial review of this branch.

The scope check ran before applyOpenAiVirtualModel, which rewrites route.modelId
to the wire id that is actually billed. A key allowing only the public selector
was therefore authorized on one model and sent on another. The settled route is
now re-checked after normalization, so the id that is billed is the id that was
authorized.

The account-qualified branch of /v1/alpha/search resolves a model through the
router and bills the account it names, so it applies the same rule. The
endpoints that spend quota without routing a model -- images, audio, realtime,
and the non-account-qualified search branch -- are recorded in the lane document
as uncovered rather than left to read as covered.

The queue's per-event budget comment claimed it bound any single event. It bounds
a RETAINED one: an event handed straight to a waiting consumer is never held, so
refusing it would abort a turn over memory this queue does not own. The comment
now says what the code does. retainedEventCodeUnits also guards a non-object,
so a malformed adapter emission becomes a terminal event rather than a TypeError
thrown out of push with the queue half-updated.

The scope regression reached the config schema through config/schema/leaf-validators
directly, which enters that module cycle from the wrong end and threw a TDZ
ReferenceError on CI. It now loads a hand-written config.json through src/config
the way a startup does, which also proves the stronger property: a damaged
permission field drops that key alone and its valid neighbour survives.

---------

Co-authored-by: mdwsk88 <924038395@qq.com>
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
…-in (#5267)

* fix(codex): name the undo command in injected Codex routing

A Windows user whose proxy had stopped was locked out of Codex sign-in
(#5261). The root openai_base_url opencodex writes keeps pointing Codex's
built-in openai provider at 127.0.0.1:10100 after the proxy is gone, and
the injection survives reboot, so the lockout persists.

The only surface such a user can still read is config.toml, and it named
no way out: the marker said "Auto-injected by opencodex" and nothing
else. The recovery they found was to hand-delete the routing lines and
the catalog file, which is worse than "ocx restore" -- a model_catalog_json
target that no longer exists makes Codex fail on a missing file.

Routing markers now read "# Auto-injected by opencodex (undo: ocx
restore)". Every ownership predicate matches OCX_SECTION_MARKER as a
substring rather than by equality, so both the new line and markers
written by earlier builds are still recognized, stripped and restored.
An in-place rewrite refreshes the marker, so an existing install gains
the hint on its next start instead of keeping a bare marker.

Scope is routing only. Prompt layers keep the bare marker, because
"ocx restore" is not what undoes them.

* fix(cli): point a dead-proxy status report at the offline Codex restore

When the proxy is down, "ocx status" says Codex requests will fail and
then offers only ways to bring the proxy back: restart it, install the
service, repair the service. For the user in #5261 that was the wrong
half of the choice. Their injected routing points Codex's own built-in
openai provider at a dead loopback port, so they were stopped at Codex
sign-in, and every suggestion on screen asked them to fix opencodex
first.

Add the other half. When the proxy is down and the routing is one
opencodex owns, the report now says that sign-in fails too, and names
"ocx restore", which needs no proxy, no management API and no network.

The sentences live in a pure function beside unusedProxyWarningLines so
they are testable without spawning the CLI. Routing opencodex does not
own is excluded: "ocx restore" would not remove somebody else's local
gateway, so advertising it there would be a false promise.

* test(codex): pin the Codex sign-in lockout behind a stopped proxy

Covers the state #5261 was actually reported in: routing on disk, proxy
gone, nothing listening on the loopback port Codex is pointed at. The
tests never start a proxy or bind a port, because recovery has to work
without one, and a test that needed a live proxy would be exercising the
wrong state.

What it holds:

- Routing written before the recovery hint existed is still recognized,
  and recovery from the old and new marker forms is byte-identical, so
  an install that upgrades mid-incident restores the same way.
- Removal clears the dead base URL, the realtime sideband override and
  the catalog pointer together, while leaving the user's own keys. The
  catalog pointer matters as much as the routing: left behind, it names
  a file only opencodex maintains and Codex fails on a missing target.
- An install that predates the hint gains it in place on the next
  injection, without adding an ownership line or breaking idempotency.
- A user-owned root override is still untouched and gains no hint.
- The dead-proxy advice appears only for routing opencodex owns.
- Both recovery surfaces name a command the CLI registry actually has.
  That one is derived from the marker rather than restated, so renaming
  the command in one place fails here instead of shipping a config file
  that points at nothing.

* docs: add a troubleshooting page for a Codex lockout behind a stopped proxy

There was no page for the state in #5261, and it is the one a user in it
can actually reach: Codex is unusable, so the docs site and the config
file are what is left.

The page names the mechanism, both ways out, and the manual edit for
someone without the CLI. It warns specifically against deleting the
catalog pointer on its own, which is the repair people reach for and
which produces the same symptom from a second cause.

Account-pool failures are covered separately on the same page rather
than folded into the lockout. They happened in the same session in the
report, but the pool needs a live management API and a fixed loopback
callback port, so they are a different problem with a different fix.

* docs(devlog): record lane H, the Codex sign-in lockout response

Names the mechanism, the four independent source reads that agreed on
it, and the three gaps this lane deliberately leaves open. Also records
that the account-pool failures which opened the report are a second
cause with a different fix, so a later reader does not merge them.

* fix(codex): correct the marker refresh fallout and the manual removal steps

Three existing cases asserted that injecting over routing we already own
returns the file byte for byte apart from the URL. Refreshing the
ownership marker breaks that literal expectation, and hosted CI failed on
exactly those three. The contract they protect still holds -- injection
is idempotent and no unrelated value moves -- so they now expect our own
marker to refresh and assert everything else unchanged, including the
malformed tail that must be returned verbatim.

The troubleshooting page told a stuck user to delete every
"# Auto-injected by opencodex" comment and the line below it. That same
comment sits above other managed keys, such as an injected
developer_instructions, so following it would have cost configuration
that has nothing to do with sign-in. It now names the three keys to
remove and says to go by the key rather than the comment.

The lockout test claimed more migration than it exercised: the fixture
carries two markers and only the routing writer had run. It now asserts
the exact marker list at each step, which pins the real behaviour --
each writer refreshes only the marker it owns -- and covers the realtime
override as well. Dropped one assertion that restated how the constant
is defined rather than testing behaviour.

* test(codex): derive marker assertions from the constants they describe

Swept every marker occurrence under tests/ and classified each one as
routing output, prompt-layer output, or an input fixture. The three
cases hosted CI failed on are already fixed; this closes the class that
produced them rather than the three instances.

Assertions on what the injector WRITES above a routing key now come from
OCX_ROUTING_MARKER_LINE, and the two that checked a substring now assert
the whole line. A substring check passes even when the wrong ownership
line is written above a routing key, which is exactly the defect that
would have to be caught here.

The four prompt-layer files kept a private literal copy of the bare
marker. They now derive it from OCX_SECTION_MARKER and say why: prompt
layers keep the short marker because "ocx restore" is not their undo, so
the two scopes cannot drift apart silently.

Input fixtures are deliberately left as literals. A hand-written config
or one from an older build is what those tests exist to exercise, and
rewriting them to the current constant would delete the backward
compatibility coverage instead of strengthening it.
…5212, #5213) (#5271)

* fix(chat): carry allowed_tools and a caller's parallel_tool_calls to the wire

Two ways a Chat Completions caller restricts tool use reached the parser and
were then dropped on the way out, both under a normal HTTP 200.

A tool_choice of type allowed_tools is a record, is not type "function", and
carries no "function" member, so it fell past every branch of the Chat inbound
translator and body.tool_choice was never assigned. The upstream received the
full catalogue and no choice at all. Chat nests the subset under allowed_tools
and names each entry under a member keyed by its own type, while the Responses
shape mapToolChoice reads carries mode and tools on the choice itself with a
flat name, so neither level lined up. Flatten both. An entry nobody can name is
refused rather than skipped, because dropping one widens the very subset the
field was sent to narrow.

parallel_tool_calls had three provider states and two branches, in two places.
When a provider expresses no preference, which is the default for every provider
that never configured the knob, neither branch ran and an explicit request-level
false was lost — on the translated path and, from its own copy of the same
branch, on the native Chat passthrough. That state now forwards the caller's
false. An explicit true still omits the key, matching the configured opt-out, so
strict OpenAI-compatible hosts never see a knob they did not have to accept
before. The NVIDIA and pinParallelToolCallsFalse pins are unchanged.

The decision moved into openai-chat/parallel-tool-calls.ts, which both builders
now read, so the three states cannot drift between them again. openai-chat.ts is
811 lines against its 822-line cap.

Regressions assert on the serialized outbound request body for both builders,
since a successful tool call and a 200 response look identical with or without
either constraint.

Closes #5211

* fix(adapters): preserve tool declaration strict and allowed_callers

Three fields a caller sets on a tool declaration were parsed, carried
internally, and then dropped by the outbound adapter, so the request was
dispatched as though the constraint were in force and answered normally.

Messages to Messages rebuilt every tool from name, description and input_schema
alone. Anthropic is the target that defines strict, the Messages inbound already
kept the source intent deliberately, and the OpenAI Chat adapter already
forwarded it, so Anthropic was the one destination losing it. It now emits an
explicit strict: true. An unstated strict stays absent: the inbound records it
as false, so a false on the wire cannot be told apart from silence and must not
become an opt-out nobody asked for.

allowed_callers had no carrier at all. The identifier existed once in the tree,
raising a caller_mode diagnostic that only becomes a refusal when the operator
has set claudeCode.compatibility. The field now rides OcxTool.allowedCallers
from the Messages inbound through the Responses schema — where an undeclared key
is stripped, which is why it never reached buildTools — to the Anthropic wire.
The OpenAI Chat and Gemini builders have no counterpart for it, so they refuse
with a 400 rather than rebuild the declaration without the fence, in the shape
ollama-native and kiro already use for a tool_choice they cannot enforce. The
unrestricted ["direct"] default is not treated as a restriction.

Gemini expresses schema-enforced calling as functionCallingConfig.mode
VALIDATED. The mode was plumbed to the wire compiler but only reachable by
matching a model name, so a strict declaration arrived as an ordinary AUTO turn.
It now replaces the absent-choice default. NONE, ANY and a forced-name choice
are stronger constraints the caller asked for and are never overwritten.

Native passthrough is unaffected on every route.

Closes #5210

* fix(openai-chat): keep developer messages in their conversation position

A developer message kept its slot only when the provider base URL host was
exactly api.openai.com. On every other OpenAI-compatible Chat endpoint its text
was appended to the system prompt and the message itself was skipped, so an
instruction written to apply from the second turn onward arrived ahead of the
first one and the caller got an ordinary completion either way.

The two halves of a Claude Code route were working against each other because
of it: #4161 established that folding in-conversation instructions into the
prompt preamble is harmful and made the Claude inbound mint chronological
developer items specifically to preserve timeline order, and this adapter then
folded them again on every host but one.

One destination already had the chronological behaviour, keyed to a model id
and a registry entry, because hoisting a newly appended reminder rewrites the
reusable prompt prefix. That is a property of prompt-prefix caching rather than
of that destination, so it is now what every destination gets, and the
model/registry test is gone. A reminder that arrives while a tool call is open
is still deferred past the result, which is what keeps tool-call adjacency
intact; it lands in its own slot immediately after, never at the front.

This commit changes placement only. The wire role is still developer on
api.openai.com and system elsewhere, and is addressed separately.

Co-authored-by: Yum-wu <118118663+Yum-wu@users.noreply.github.com>

* fix(openai-chat): forward the developer role instead of inferring it from the host

A developer message reached the upstream as developer only when the provider
base URL host was exactly api.openai.com. Everywhere else it was rewritten to
system, so every OpenAI-compatible gateway was assumed not to support a standard
Chat Completions role until proven otherwise — including gateways that proxy
OpenAI itself — and the instruction silently lost the precedence the caller
chose.

The role is now forwarded as sent. A destination that genuinely rejects it sets
foldDeveloperRoleToSystem, which converts the role where the message already is
and never moves it, so the placement contract from the previous commit holds on
both paths. That makes the conversion a recorded decision about one destination
rather than an inference from its hostname, which is what the hostname test
could never express.

The flag is registered in the provider config schema and in the exhaustive
provider field policy, which is keyed on keyof OcxProviderConfig and fails
typecheck until a new key is classified.

Closes #5213

* fix(inbound): carry inline document bytes through to the wires that hold them

Both inbound parsers reduced an attached document to its name before any
adapter ran, so no adapter could forward one even to a target that has a
representation for it. The Messages inbound replaced a base64 document block
with a "[document: title]" marker, and the Chat file part matched no branch of
the content loop at all. The request succeeded either way, so the caller could
not tell "the model read the document" from "the model was told a document
existed".

OcxContentPart gains a document member carrying the media type and the base64
payload. The Anthropic wire emits it as the document block the caller sent, the
OpenAI Chat wire as the file part that is its direct counterpart, and Gemini as
the inline_data part it already uses for images and video.

Widening the union is the hazard here, so the part also carries the marker every
text-only consumer already falls back to. That keeps a wire with no document
representation emitting exactly what it emitted before instead of undefined or a
mislabelled [video]. Six consumers needed more than the fallback and are fixed
explicitly: ollama-native and the Cursor tool-result decoder would have read a
nonexistent imageUrl, and Kiro, Devin, Cursor and coding-agent text serializers
would have produced an empty turn. Token admission counts the encoded payload
rather than the marker.

The untranslated-media refusal is narrowed to match, and only where a converter
actually builds the part: user content on the Chat projection, user and
developer messages on the Responses one. A file in a tool output, a system
message or an assistant message is still refused, because those converters
flatten their content to a string and exempting them would restore the silent
drop the scanner exists to prevent. The scanner and the decoder share one
predicate, so a request cannot be exempted in one and reduced to a marker in the
other; a ";notbase64," parameter is not a payload. A reference with no bytes —
a file_id, a remote source — is unchanged in every position.

Tool-result documents keep the #939 marker: the Responses tool-output
vocabulary has no file block and every adapter's tool-result path flattens to
text, so carrying bytes there needs a separate change.

Closes #5212

* fix(adapters): refuse unrepresentable declarations by default, not per adapter

Adversarial review of the whole branch found the same shape of hole in two of
its fixes: a constraint the normalized request now carries still reached wires
that rebuild the declaration or the message without it, and answered normally.

tools[].allowed_callers was refused by the OpenAI Chat and Gemini builders
because those are the two the report named. Cursor, Devin, Kiro, Command Code,
Ollama and the coding-agent wires rebuild tools from name, description and
schema, so a caller-restricted tool reached those upstreams unrestricted. Inline
document bytes had the same problem from the other direction: admission exempted
every user-content document without knowing the destination, and a wire with no
carrier replaced the bytes with the marker and continued.

Both are now default-deny allowlists in adapters/declaration-carrier.ts,
enforced at the single guard in adapters/input-media-guard.ts that every
registered adapter passes through. allowed_callers reaches the anthropic wire;
document bytes reach anthropic, openai-chat and google. Adding an AdapterWire
member makes the omission visible in those lists rather than at a customer's
upstream, which a per-adapter opt-in could never do. The Responses passthrough
stays exempt from the whole guard because it forwards the original body.

The refusal no longer names the tool, which is caller-controlled and put client
metadata into an error body. An allowed_tools entry whose selector kind is
neither function, custom, nor a hosted type is refused rather than flattened to
a bare name. Token admission counts a document's payload arithmetically instead
of rebuilding a request-sized data URL to measure it.

* test(document): derive the attachment marker from its source constant

A restated literal is the union-defect class AGENTS.md records: the next change to the marker breaks a test for the wording rather than for the contract. Every assertion about it now reads inlineDocumentMarker, and the data URL spelling comes from inlineDocumentDataUrl.

* fix(adapters): type the document scan against OcxMessage and keep a developer document's role

Two defects from a final adversarial pass over the branch.

The document scan took content shaped as OcxContentPart[], but context.messages is
OcxMessage[] and an assistant turn carries OcxAssistantContentPart[], which is not
assignable to the user-content union. It now takes OcxMessage and reads the
discriminant structurally, which is all it ever needed.

A developer message carrying a document reached the structured-content branch and
was emitted as role user, undoing the role preservation the same adapter had just
established. A developer message with images keeps the user-compatible shape it
has always had on this wire; a document has no such precedent and keeps its role.

* docs(devlog): record lane A meaning preservation

What each of the four contracts restores, the review findings that changed the shape of the fix, the union-defect check run before push, and the one gap left open.

* docs(structure): repoint the instruction-ordering links at the renamed heading

Renaming the section from the OpenCode Go exception to the universal contract left five documents linking a heading anchor that no longer exists, which is what the SSOT gate is for. The link text now describes the contract rather than the destination it used to be scoped to.

* test(anthropic): await the registered adapter build

createRegisteredAdapter wraps openai-chat in withClinePassDeepSeekV4ToolReplayCompatibility, whose buildRequest is async, so reading .body off the returned promise parsed undefined. The refusal cases in the same file already tolerated both shapes.

---------

Co-authored-by: Yum-wu <118118663+Yum-wu@users.noreply.github.com>
* refactor(usage): state the attempt recovery vocabulary once

The recovery kinds were written twice: as a union and as the read-back whitelist
that normalizedAttempt filters against. The two are not interchangeable. A member
added only to the union compiles, is written to disk, and is then dropped on the
next read, so the row loses the one field that says why the attempt recovered.

Declare each vocabulary as a frozen roster and derive both the type and the Set
from it, so the declaration cannot drift from itself.

* feat(lib): one stage, cause and resend vocabulary for a failed request

Roadmap items 7 and 14 want the same substrate: item 7 divides a failure into
pre-header, headers-only, protocol prelude, semantic output, side effect and
terminal and decides resend permission per stage; item 14 wants one cause
dictionary spanning logical request, attempt, physical send and terminal.
Defined separately they typecheck on each branch and contradict each other in
the merge, which is the class that blocked 2.60.0, so they are one module.

The resend decision is derived from three small per-member facts -- what the
caller observed at a stage, what a cause proves about whether the origin ran the
turn, and what a resend would have to change -- rather than written out as a
stage-by-cause matrix. A matrix of that size is a restatement: it has to be
re-derived by hand whenever a member is added, and the cell nobody revisited is
how two correct branches merge into a wrong table.

The module adds no record store. Durable shapes stay in src/usage/log.ts and
projections read them structurally. It stays a leaf: both imports are types,
erased at runtime, so nothing here reaches a request path that lacked it.

The tests run over the full stage-by-cause cross product, so none of them can be
satisfied by a request that returned 200 and none can go stale when a member is
added.

* feat(metrics): project recovery counters through the shared cause dictionary

recoveryClass() ended in `default: return "other"`, so a recovery kind added
later compiled cleanly and then disappeared into a bucket an operator cannot act
on. Key the projection on the shared cause instead and make it total, so a
missing member is a typecheck failure.

This also separates four refusals that used to be indistinguishable in the
counter. Waiting out a rate limit, changing account on quota exhaustion,
changing the prompt on a policy refusal and dropping stale ciphertext are four
different operator responses; `quota`, `policy` and `ciphertext` are new label
values so the metric can tell them apart. An opaque blob rejection moves from
`payload` to `ciphertext`, which is the one existing series whose meaning
changes: the payload was never the problem, the stale encrypted state was.

Label cardinality is unchanged in kind. Every value still comes from a frozen
roster, so no user, model, account or request identifier can reach a series.

* feat(responses): say the Codex WebSocket failure in the shared vocabulary

The WebSocket transport was the one surface whose failures could not be compared
with anything else, which is the reported symptom in #4191: an unanswered
socket, a socket carrying only control frames and a socket that died mid-reply
all reached the user as the same sentence.

This is a projection, not a second classifier. classifyCodexWsFailure stays the
only place that reads the counters; this restates its answer as the stage and
cause the durable log, the metrics projection and the HTTP path already use.

It does not relax the transport's own rule. The no-replay-after-send contract in
codex-ws-exchange.ts holds regardless of what the projection returns; the shared
table independently agrees that everything past before-send is refused.

* fix(responses): recover from a relayed ciphertext rejection

An OpenAI-compatible gateway does not forward the upstream error envelope; it
puts the real payload inside its own message string. The single-shot sanitized
rebuild keys on that envelope, so behind such a gateway it never matched and a
turn carrying a stale reasoning blob failed outright instead of being resent
without it.

Recognise exactly one identity through the wrapper: an embedded
invalid_request_error carrying invalid_encrypted_content. The generic classifier
is deliberately NOT re-run against the embedded payload. Doing so would also
admit the code-less unverifiable-ciphertext wording, the #4469 caller mismatch
and the two xAI decoder strings, each of which was accepted on evidence about
how one specific upstream words its own rejection -- and a gateway in between is
not that evidence.

The embedded object is found by counting braces outside string literals, because
the payload legitimately contains braces and escaped quotes and the gateway
appends prose after the closing brace. The scan is bounded so an
upstream-controlled string cannot decide how much work the classifier does.

Nothing else moves: the rebuild stays single-shot, still requires the send to
have carried a blob, still requires a 4xx on the Responses adapter, and is still
recorded as opaque-blob-rejection, which the shared table classifies as a
ciphertext refusal repaired rather than repeated.

The regression cases are mostly negative, because recognising the wrapper is the
easy half and admitting only the coded identity through it is the half a broad
implementation gets wrong.

Co-authored-by: cmdy <zhang_lin66@foxmail.com>

* docs: bind the resend rule and record the lane C dispositions

INV-RESEND-01 states the rule the substrate exists to hold: once the caller has
observed output or an externally visible effect no cause automatically permits a
resend, and an unknown upstream execution state is not made replayable by having
budget left. It is bound to the cross-product test, so deleting that file fails
structure:check rather than quietly unbinding the rule.

The management-api reference now lists the closed recovery label set, including
that a rejected opaque reasoning blob counts as ciphertext rather than payload.

The lane document records what was carried, what was deferred and why, including
one defect found while mapping the substrate and deliberately not half-landed:
the GUI declares its own recovery-kind roster with nine of the durable thirteen
members, so four kinds render without a label. Fixing it needs strings across ten
locale catalogs and a screenshot this branch cannot produce.

---------

Co-authored-by: cmdy <zhang_lin66@foxmail.com>
…on and add privacy-bounded cache diagnostics (#5268)

* fix(codex): preserve cache affinity across model detours

Carries #5209.

A gated-model detour under pool.cacheAffinity + the quota strategy evicted a
cache-warm shared binding on a threshold crossing (a hint), before the account
was actually exhausted. The three shared-state/affinity preservation
predicates now use the 100%-exhaustion boundary via
hasCodexSharedStateQuotaHeadroom, matching live-binding quota re-evaluation.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(reasoning): scope learned reasoning-effort refusals to credential identity

Carries #5145.

A learned upstream refusal was persisted under a destination-wide key
(provider, model, effort), so every credential reaching the same destination
inherited it. Each learned fact is now bound to a one-way SHA-256 digest of
the active credential; the support row key becomes a JSON array; the snapshot
advances to version 2 and legacy destination-wide rows are ignored on load.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(cursor): isolate live roster and Max Mode evidence by account

Carries #5229.

Cursor pooled accounts shared module-level singletons for the Claude
wire-spelling map and the Max-Mode evidence set, so a discovery recorded
under one credential could rewrite the wire id or arm ultra for a request
resolved under a different account. Both maps are now keyed by a non-secret
sha256 scope over the upstream destination and credential, and a
provider-scoped evidence entry is dropped when its model cache clears.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(codex): fence entitlement credential refreshes behind admission

Carries #5214.

Background and data-plane entitlement resolves (catalog sync, convergence,
serve-options /models, CLI startup discovery, ensureCodexEntitlementFreshness)
could refresh or rewrite the native auth.json while native-main lifecycle,
recovery, or profile-switch drains intend the physical native identity to
stay untouched, and a refused claim also took down Pool discovery. Adds
model-entitlement-admission.ts plus withNativeMainCredentialAdmission in
native-main-admission.ts, applied at the five sites; the test file lands in
the codex-integration domain registered in the layout map.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* feat(usage): show cache metrics by model

Carries #4793.

The Usage page's Models table now shows input tokens, output tokens, cache
hits, cache writes, and cache hit rate for each model; providers without
cache telemetry render an em dash. Includes translations for all supported
GUI locales, dashboard documentation, and a rendered GUI regression test.

Co-authored-by: xdober <10195626+xdober@users.noreply.github.com>

* test(codex): move cache-affinity detour cases to a sibling under the file-size cap

codex-routing.test.ts sits exactly at its file-size cap; the carried #5209
cases would have grown it 83 lines over. The three detour cases move to
codex-routing-cache-affinity-detour.test.ts byte for byte with their own
minimal harness, registered in both layout.json and the expected fixture.

* fix(codex): bind Cursor and Devin live rosters to the observing credential

The live Cursor and Devin model rosters are entitlement-specific, but their
provider roster cache was scoped by provider name alone: a credential switch
could read the previous account's fresh or stale plan roster, and a failed
discovery's cooldown suppressed the next credential's first fetch while
offering it the previous account's stale list. Bind the cache entry to an
irreversible credential fingerprint (the Qoder precedent), make the stale
fallback credential-scoped, and let a credential with no roster of its own
fetch through another credential's cooldown. Quota and rate-limit health stay
account-scoped by design: they describe the subscription, not the token
generation, and the 401/403 quarantine is already generation-fenced.

* fix(codex): fence cancelled entitlement refreshes behind caller cancellation

A data-plane /v1/models request now passes its own signal into admitted
entitlement resolution, and the native-main token refresh re-checks that
signal after the upstream grant resolves and before the auth.json commit: a
refresh that resolves after its caller went away no longer rewrites the
physical credential on behalf of a request that no longer exists. The reauth
twin already fenced its commit the same way; the roster-cache publication
stays fenced by credential identity and mutation epoch, which is the
correct boundary for a shared flight.

* feat(usage): opt-in privacy-bounded cache diagnostic (#5178)

Under OPENCODEX_CACHE_DEBUG=1 the proxy writes one record per finalized
request to <config-dir>/cache-debug.jsonl (0600, 200-to-100 rolling), letting
an operator compare two requests and tell a client prefix change, an account
change, and a proxy transformation change apart as the cause of a cache-read
drop. Records hold only presence booleans, counts, closed enums, the raw
upstream cache counter before defaulting, and process-local HMAC equality
tags (independent process-random key, never persisted) for the prompt-cache
key, allowlisted session headers, the account log label, and ordered
instruction/tool/message blocks capped at 128 per section with only the
first divergent section/index. No prompt text, tool names, raw identifiers,
or header values are recorded, and no tag survives a process restart, so a
fingerprint can never become a public or durable correlation key. The
request path reaches the module through a process-local registration hook so
responses/core.ts gains no runtime import, and an all-zero usage frame with
a measured cache counter now survives extraction instead of collapsing to
"unreported", which is what keeps a measured zero distinct from an absent
counter downstream. Off by default.

* docs(devlog): record lane D account/cache-generation progress

* fix(usage,tests): close review findings on the diagnostic and the moved admission test

Pre-CI adversarial review found two blocking defects: the carried
entitlement-admission test kept its tests-root import paths after the domain
move (every case failed at load), and the diagnostic's block splitter
aliased an array-valued instructions field, so observation would have
mutated the live request body the adapter was about to serialize. Both are
fixed, the second with a mutation regression test. The all-zero usage
extraction change is reverted: it reclassified spend settlement for
placeholder frames, and the measured-zero versus absent distinction already
rides the provenance enum for every frame that reports tokens.

* fix(catalog,codex): derive the reasoning-rung type and scope discovery cooldown to its credential

Exact-head CI on this branch failed gates, both typecheck-dependent shards and
one Cursor case. Three causes, fixed here.

catalog/effort.ts and catalog/build-entries.ts cast a partially populated
ladder to Array<{ effort?: string }> and push a canonical CODEX_REASONING_LEVELS
rung into it, which also carries description. That was always a type error, but
reasoning-effort.ts -> providers/reasoning-metadata.ts -> providers/key-store.ts
-> the ../config barrel formed an import cycle in which the rung type degraded
and the excess-property check never ran. Carried #5145 breaks that cycle by
design, so the latent error surfaced here first. reasoning-effort.ts now exports
CodexReasoningLevel and the three casts derive Array<Partial<CodexReasoningLevel>>
from it rather than restating a narrower shape. The translator-budget contract
test, which spawns tsc over the project, was downstream of these errors.

The Cursor cooldown case was a real regression from this lane. Scoping only the
roster reads to the credential left the failure cooldown provider-wide, so the
branch had to require a credential-scoped stale entry before honouring it, and a
discovery that fails before caching anything has no stale entry -- reopening the
timeout storm #54 closed. The scope now sits where the observation belongs: a
discovery failure records the credential that observed it and suppresses only
that credential. A failure recorded without an identity stays
credential-agnostic and suppresses everyone, so plain-endpoint providers and the
existing Qoder branch are unchanged.

cache-diagnostic.ts narrowed draft.promptCacheKey through optional chaining and
then read it again unguarded; the inbound key is bound once.

* fix(gui-tests): derive the usage header and locale symbol checks from their sources

The carried #4793 columns broke three GUI assertions that restate what the
page and the catalogs already own.

usage-custom-range listed the models-table headers as English literals and
omitted the API list-price column that ships today, so the case failed on any
tree where both exist. The expectation now maps the ordered column keys the
page renders through the en catalog, which is where that copy lives.

The French accidental-English guard and the zh-TW stale-placeholder guard both
flagged usage.unavailable, whose value is an em dash. A value with no letters
once its placeholders are removed has nothing to translate and is identical in
every locale by construction, so both checks now derive that from the value
instead of taking one more allowlist entry. Real words still fail: the existing
entries that carry letters, such as uptime.hour, remain allowlisted and
required.

* docs(devlog): record the lane D CI dispositions

* test(ci): quarantine the 50 MiB sideband relay case into its own lane

sideband GET /v1/live/{callId} relays a 50 MiB WebSocket frame end to end
against a hard 15s deadline while sharing a process with the rest of its
--shard=N/2 half, so its result measures the whole process rather than the
relay. On dev it lands in shard 1 and its echo leg alone spends 7.4s of that
budget. Three test files added elsewhere in this branch made Bun repartition
the halves, the case moved to shard 2, and the echo leg went past 15s twice
with the peer never receiving the frame -- with nothing on the sideband path
changed.

SERIAL_FULL_SUITE_FILES is the mechanism this repository already has for that
category; its own guard describes it as quarantining load-sensitive files into
one-worker lanes. The deadline, the assertion and the macOS leg are unchanged;
the case simply stops sharing a process, which also keeps it from breaking the
next branch that adds a test file anywhere in the tree.

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
Co-authored-by: xdober <10195626+xdober@users.noreply.github.com>
* fix(codex): stop the Codex shim hiding a failed autostart, and never block Codex

The shim ran "ocx ensure" with both streams discarded and its exit status
ignored, then launched Codex regardless. A proxy that failed to come up
was therefore completely silent, and Codex started against injected
routing pointing at a port nothing was listening on -- the #5261 shape,
with nothing on screen naming opencodex.

It now checks the exit status and prints one line on stderr when the
start failed, naming "ocx doctor" and "ocx restore". Ensure's own streams
stay discarded: it prints progress and warnings on exit-zero runs too,
and a wrapper that leaked those would put noise in front of every
ordinary launch, which is how a diagnostic gets ignored. The exit status
is the signal, and the one line is the whole message.

PowerShell had the opposite defect in the same place. An "ensure" that
threw escaped the try/finally, which has no catch, so Codex never
launched at all -- the autostart helper creating the exact lockout it
exists to prevent. That path now catches, reports, and hands over to the
real launcher, with Codex's exit status still authoritative.

The Unix revision marker moves to 3 so installed Unix shims are detected
as obsolete and regenerated. Windows shims have no revision marker and
are excluded from obsolete-shim refresh, so existing Windows wrappers
keep the old text until reinstalled; that gap is recorded in the lane
document rather than papered over here.

The new behaviour is covered by running the generated script against a
stand-in ensure, in a sibling file because codex-shim.test.ts is close
to its line cap and the cap only moves down. The test constants it kept
as private literal copies now come from the source module.

* fix(cli): report a Codex catalog pointer whose file is gone

A model_catalog_json naming a file that no longer exists does not degrade
Codex, it stops Codex loading its configuration at all. That presents as
the same blank wall as the dead routing in #5261 while having a different
cause and a different fix, and it is the state the reporter's machine was
left in after the catalog file was deleted by hand.

Injection already repairs this: the chooser refuses a missing owned path
and the caller strips the stale line. My earlier note that the pointer
survived injection was wrong, and the end-to-end coverage for it already
exists. What was missing is that the repair only reaches someone who runs
opencodex again, and the whole difficulty of this state is that Codex is
the thing that stopped working, so nothing prompts them to.

"ocx status" now says it out loud, names the file, and offers both
outcomes: regenerate the catalog with "ocx start", or take opencodex out
of Codex with "ocx restore". Which one they want is their choice.

A catalog the user named is left alone whether or not it exists, exactly
as during injection. Claiming it would put opencodex's recovery advice in
front of a problem that is not opencodex's to explain. An unreadable or
absent config reports nothing rather than inventing a finding.

* fix(cli): say at setup time that Codex routing outlives the proxy

Applying the Codex integration writes routing that survives a restart,
and then setup ends on "Setup complete". It does not install a background
service -- that is a separate command -- so "routing written, nothing
listening" is an ordinary state after the next reboot rather than a
corruption. Nobody says so, which is half of why #5261 read as a Codex
fault rather than an opencodex one.

Setup now ends by reusing the existing restart-health model: when the
install is restart-unsafe it prints what status and doctor already say
about it, and names "ocx restore" as the way out that does not need the
proxy back. It runs after the autostart choice, because that choice is
what decides whether the warning applies, and a diagnostic that cannot
be computed does not fail a completed setup.

Deliberately not a Windows boot trigger. The scheduled task runs as the
interactive user, so before logon there is no session for it to run in;
adding a BootTrigger would read like a fix and change nothing. Making it
genuinely pre-logon means a different principal and a different service
backend, which is a larger change than this lane, and is recorded as
such rather than half-done here.

* fix(oauth): report a browser launch that never happened

The URL launcher swallowed its own failure and returned nothing, so the
Codex login route answered identically whether a browser opened, failed
to open, or was deliberately skipped. The CLI then printed the URL and
started polling, and a user whose machine could not launch a browser sat
watching something that looked like it was working. That is the
account-pool half of #5261: not an error, a silence.

openUrl now resolves a result instead of returning void. It still never
rejects and never throws, because a browser that will not open is an
inconvenience rather than a login failure -- the URL remains a valid
thing to open by hand and the flow stays live. Callers that genuinely do
not care say so with void.

The Codex login response carries browserLaunch, and the CLI prints a
recovery line only when the launch failed. It names the fixed callback
port, because that is the part a user cannot work out alone: ChatGPT
supplies the redirect URI, so the flow cannot move to a free port, and
--device is the way around it.

Existing tests mocked openUrl as returning void, which the awaiting
caller would have read as a failed launch; all eight mock sites now
resolve a result. The new test does not exercise the started case: the
launcher command is fixed per platform, so proving it would mean opening
a real browser on whatever machine runs the suite.

* docs(devlog): record lane H2, the remaining lockout dead ends

Explains why the four paths belong in one lane: none locks anyone out
alone, and the incident is what happens when every signal is missing at
once. Records two corrections rather than burying them -- lane H's claim
that the catalog pointer survives injection was wrong, and a Windows
BootTrigger is a fake fix given the task's interactive principal -- plus
the three residuals this lane deliberately leaves open.

* test(ci): record the new shim test's cold-spawn disposition

Hosted CI failed on the guard that every test file bounding a spawned
child with the internal deadline must declare whether it warms that
child's module graph. The new shim test spawns children and did not.

Recorded as unwarmed, for the same reason as the file it sits beside:
its children are throwaway shell scripts standing in for ensure and for
the real Codex launcher, so the cold cost is shell and process startup
rather than a repository module graph, and an import scan has nothing to
warm. The generated shim never loads a repository module in the child --
the point of the file is what the shell does with an exit status.

* fix: close the gaps an adversarial review found in this lane

Blocker: the PowerShell shim test's Codex-failure fixture also exited 19
from ensure, which the wrapper now correctly reports as a failed
autostart, so that phase no longer isolated a Codex failure and both
PowerShell variants would have failed on Windows. Its ensure now
succeeds, which is what the phase always meant.

The setup warning said "nothing here will restart the proxy" for every
restart-unsafe install. A healthy launcher shim is also restart-unsafe,
because it covers CLI launches only, but it does restart the proxy for
those -- and the summary line printed directly beneath said so. The
warning now states the dependency without the false absolute, and a test
pins that the shim case does not claim otherwise.

The URL launcher resolved "started" on the spawn event, which only
proves a process began. A launcher with no handler spawns happily and
exits nonzero a moment later without opening anything, so a login could
still report a launch that never happened. It now watches briefly for an
immediate nonzero exit, which is how those failures arrive.

"ocx gui" awaits the launch and says so when it did not happen. It still
exits 0: the proxy is serving and the URL it printed is reachable, only
the launch failed.

The catalog finding is tagged (local) on its header like every other
local-state line, so a connected client cannot read a finding about its
own Codex home as something the hub reported.

Catalog ownership stays decided by basename, now stated as a choice
rather than left to look like an oversight: it is the same test injection
applies, and a detector drawing the line elsewhere would report a state
injection would then treat differently.

Also removes the temporary directory the launcher test created, and
corrects that file's header to describe what it actually covers.
…ery limits (#5216, #5215) (#5294)

* fix(gui): resolve combos by what they are, and describe failover as it runs

Two user-visible strings on the compaction-routing surface described behaviour
the code does not have.

The panel decided whether a selection was a combo by testing for a "combo/"
prefix. A combo reached through an alias carries no prefix, so it was described
as an ordinary provider and none of its targets were named -- the answer existed
and the operator could not see it. The panel now asks what the selection
resolves to: the combo list is keyed by the public model id the server already
computes, which is the alias when one is set and "combo/<id>" otherwise, so both
spellings answer the same way. It reads that list through parseComboList, the
same reader the combo workspace uses, so the selector rule is not spelled out a
second time here.

The combo warning told the operator that a covered compaction goes to every
target, including failover targets. It does not. core-combo.ts dispatches one
target per loop iteration, returns as soon as one responds, and advances only
after a retryable failure. An operator reading the old text would budget fan-out
cost and fan-out latency for something that never happens. The warning now says
the targets are attempted in order and the first that answers is used, which is
both what happens and what someone debugging a slow compaction needs.

Wording changed in all ten locales. The regression covers the aliased combo the
prefix test could not see, and asserts the ordering sentence rather than the
fan-out claim.

Closes #5216

* docs: check the provider discovery limits against the registry

The provider guides restate a byte ceiling and a row ceiling for thirteen
fixed-host presets, in eight pages, and nothing compared any copy to the
registry. #5198 fixed a preset count that had drifted across sixteen files for
months for exactly that reason; these limits are the same shape one layer down.

Every number is now read from that preset's modelDiscovery and asserted against
every shipped guide, so lowering a ceiling fails in all eight locales at once
instead of leaving seven translations describing the old one. A grouped section
must first agree in the registry before one sentence may speak for two presets,
which is what makes the Nscale/Vultr and Command Code sentences legitimate
rather than convenient.

Sections are located by brand name and the presence of a KiB or MiB token, not
by a translated sentence. A restated anchor phrase is the same hand-copied value
the guard exists to remove, and the brand names are Latin in all eight published
locales. The byte ceiling is compared as an exact token set rather than a
substring, so a stale number left beside the current one fails.

The structure record claimed the guides carried identical limits. That claim was
false when it was written: the Korean guide had no Featherless section, so it
documented twelve of the thirteen limited presets. The section is added and the
prose is replaced by a description of what is actually asserted.

Closes #5215

* fix(gui,test): close three defects an adversarial pass found in this lane

Combo target lookup read a plain object by the selected model id. A combo id is
free-form, so an alias of "constructor" or "toString" resolved to an inherited
Object member and the renderer tried to join a function. Read it with
Object.hasOwn.

Recognizing a combo only through the fetched list lost the canonical prefix as
a signal of its own. When /api/combos has not answered yet or failed, a
"combo/x" selection was described as an ordinary provider named "combo" -- worse
than the alias gap this lane set out to fix, because that path is reachable
whenever the management API is briefly unavailable. The prefix is kept as an
independent signal and the target names fall back to the existing
"its configured target providers" wording.

The documentation guard compared the row ceiling as a substring of the whole
paragraph, so the byte ceiling's own digits could satisfy it: a Hyperbolic
paragraph saying "256 KiB and 128 raw rows" would have passed an expected 256
rows. Row numbers are now read from the prose with the unit tokens removed. All
104 locale/section combinations still pass, verified by transcribing the test's
own logic over the eight guides.

* docs(devlog): record lane G onboarding, update and screen improvements

Why the recovery path cannot live in the dashboard, what each of the six targets needed, the differential between the two workspace pull requests with the three findings that decide their sequencing, and the one src/ defect this lane identified and left stated rather than half-fixed.

* fix(gui): key the combo lookup by Map, not by a caller-configured object key

A combo's public model id is free-form and operator-configured, and
readComboProviders wrote it straight into an object literal. That is a
prototype-pollution sink on the write side, and the read side returned an
inherited member for an alias of "constructor" or "toString" -- the previous
commit guarded the read with Object.hasOwn and left the write as it was.

A Map removes both. There is no prototype to shadow, the guard disappears, and
the failed-fetch fallback returns an empty Map rather than an empty object, so
the two branches keep the same type.

* fix(gui): build the combo target list in one pass

React Doctor's js-flatmap-filter fired on the map().filter(Boolean) this lane
introduced at CompactionRoutingPanel.tsx:51 -- one new warning in one file, and
the job's blocking threshold is warning. flatMap does the same work in a single
pass. The related js-combine-iterations rule is switched off in
gui/doctor.config.json, but this is a different rule and is enabled, so this is
a real new finding rather than an accepted one.
* refactor(usage): one terminal classification for a finished request

Three surfaces answered "how did this request end" three different ways. The
durable row carries terminalStatus and closeReason, the Prometheus exporter had
its own private classifyResult, and the dashboard read the numeric HTTP status
and nothing else.

That is not cosmetic. A turn cut short by max_output_tokens is durably
status 200 with terminalStatus "incomplete", which the exporter reports as
incomplete and the dashboard rendered as a green 200: the metric and the
operator disagreed about whether the user got an answer.

Move the classifier into src/usage/request-outcome.ts and have the exporter
import it, including its result label set, so the four strings are stated once.
Semantic terminal facts are read before the numeric status, which is the whole
point; the status is consulted only when no terminal event was recorded.

The module also names the send totals a surface should show, because reporting
sends without the unresolved remainder is how a duplicate-send incident stays
invisible. It is a leaf: its only import is a type.

* fix(gui): make the logs page agree with the ledger and the exporter

Carries the rehydration half of #2366 — the half that brings the durable
terminal facts out to where an operator reads them. Its separate attribution
vocabulary is deliberately left behind, because the landed stage and cause model
already owns that question and two vocabularies for one thing is the class of
defect this batch exists to remove.

The page classified every request by its numeric HTTP status alone and showed no
send count at all, so it disagreed with both other surfaces about the same
request. A turn cut short by max_output_tokens is durably incomplete and is
reported incomplete by the exporter; the page rendered a green 200. The data was
never missing — /api/logs spreads the whole durable entry — the page simply did
not declare terminalStatus, closeReason or spend.

It now declares them and calls the shared classifier rather than reimplementing
the precedence, so agreement is structural instead of a rule someone maintains.
It also shows the upstream send count, and names the unresolved remainder when
there is one, because a send total without it is how a duplicate-send incident
stays invisible.

The recovery-kind union is now the durable roster instead of a copy. The copy had
drifted to nine of thirteen members, so key-401, oauth-account-429,
opaque-blob-rejection and reasoning-effort-downgrade each reached the operator as
"Unknown recovery reason" — four real causes rendered as an absence of one. The
satisfies clause makes the next added kind a typecheck failure here rather than a
silent fallback, and the four missing labels are added across all ten catalogs.

Co-authored-by: chilung <b0423031@gmail.com>

* test(usage): hold the three surfaces to one answer

The exporter is driven over the full cross product of status, terminal status
and close reason and its emitted result label is compared against the shared
classifier, so the two cannot drift apart without a case objecting. The cases
that actually broke are asserted by name as well: an incomplete 200 is not a
success, and a cancelled 200 is aborted.

A source oracle holds the dashboard to the same contract. It has to call the
shared classifier rather than read the status, it has to show the send total and
the unresolved remainder, and its recovery-label map has to cover every member of
the durable roster. That last one is a source oracle rather than a type check
because the page is compiled by a separate project, which is how the copy drifted
to nine of thirteen members unnoticed in the first place.

Every label key the page names is required to exist in all ten catalogs, so a new
recovery kind cannot ship with an English label and nine blanks.

One case asserts the exporter's whole label set is still protocol, result,
recovery and le after thirty-two requests carrying recoveries, which is the
bounded-cardinality promise stated as an assertion rather than a convention.

* docs(devlog): record lane C2 and refresh the deferred dispositions

Each item that did not land carries the reason that is true against current dev,
not the one written a day ago. #3748's blocker is now narrower and more useful
than "parallel store": the recorder does not yet record why a request finally
failed, so there is nothing closed to group by. #3983's emission path turns out
not to be ephemeral, because stderr is redirected to the service log under both
launchd and systemd. #5063 has a concurrent-append data-loss window that the
rename cannot see.

Retention and masking are stated in one table rather than reimplemented, with the
policy that projections inherit both instead of getting their own.

---------

Co-authored-by: chilung <b0423031@gmail.com>
…ata planes (#5290)

Per-key model and provider scope (#5265) is evaluated on the resolved route, at the capture point
the Responses path funnels every destination through. Four authenticated endpoints spend provider
quota without resolving a model through the router, so the predicate never reached them: the
standalone Images relay, file transcription and the dictation socket, voice call-create and the
realtime sockets, and both unrouted branches of /v1/alpha/search.

Each one now applies the landed predicate where its destination becomes concrete, never to the
string the client sent. Images checks the provider it settles on and the model the caller named,
and checks the xAI bridge and the Antigravity fallback against the model each of those picks for
itself. Audio and voice resolve through one upstream decision, so one check on each of its return
paths covers transcription, dictation, external call-create and the sideband join; a refused
forward request releases its probe lease. The native voice relay reads the model from the
call-create session or the socket query. The search relay checks the account an unqualified model
resolved to, and checks the sidecar fallback against the backend and model the operator configured.

A destination nobody named -- a body with no model, or a join onto a call this process never
recorded -- refuses a key that carries a model list, since no entry in that list can describe it.
The external voice path records the model a call settled on in its binding so a rejoin is judged
against it. A provider-only scope is judged on the provider alone, and a key with no scope behaves
exactly as before on every surface.

No new policy system: the denial helper composes the existing scope resolution, predicate and 403
response for handlers that return a Response instead of throwing into a route resolver. Nothing
here reads or moves a credential, and no logging was added; a refusal carries only the selector the
caller already sent.

Four endpoint-level regression suites cover the refusals, that no upstream call is attempted, that
an allowed scope still reaches its destination, and that an unscoped key stays unrestricted.

Refs #5049. The issue stays open: this covers the four endpoints named in the #5265 review and
nothing beyond them.
… provider (#5289)

* feat(proxy): decide a provider's outbound egress per request

The global `proxy` is one value for every upstream, so it cannot express
the split #2894 describes: one gateway must exit through a regional proxy
while another stays direct on the local network. `src/lib/provider-egress.ts`
is the single authority that answers that question for one request, in the
same shape #5087 established for the global decision -- the question is never
"is a proxy configured" but "does a proxy apply to THIS request".

Four states, resolved against the destination:

  absent            inherit the global decision, byte-identical to today
  "direct" / null   never use the global proxy for this provider
  http(s) URL       this provider's own HTTP(S) proxy
  socks5(h) URL     this provider's own SOCKS5 proxy

`providers.<name>.noProxy` is applied to whichever route resolved, so it
carves an exemption out of the provider's own proxy AND out of an inherited
global one. That second case is how a provider exempts a single host without
owning a proxy of its own.

Two deliberate divergences from the issue's sketch. An empty string is
rejected rather than read as a third spelling of DIRECT: a dashboard field
the operator merely cleared must not silently switch a provider from
inheriting the global proxy to refusing it. And a malformed value throws
instead of degrading, because falling back to the global proxy would send a
credential out a route nobody chose while falling back to direct would leave
a restricted network with no exit -- both read as success at the call site.

Direct egress is expressed to the runtime as `proxy: false`, which overrides
HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY alike. `undefined`, `null`
and `""` all mean "no option given" and fall back to the environment, so none
of them can express it. `configuredOutboundFetch` had to learn the same
distinction: reading `false` as "no string supplied" fell through to
ALL_PROXY and sent a request pinned to direct egress through the global
SOCKS proxy instead, which would have succeeded by the wrong exit.

Configuration and request time share one definition through
`providerEgressConfigError`, so a value the loader or the dashboard accepts
is one the transport can carry. `proxy` is classified credential-bearing
alongside `apiKey`: a proxy URL routinely embeds `user:password@`, so it
never reaches the dashboard DTO, and nothing derived from it is logged --
not a hash, not a prefix, because a short digest over a known host is a
guessable stand-in for the secret and a durable correlation key.

Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com>

* feat(proxy): apply the provider route to inference, discovery and quota

Three transport owners now consume the decision instead of re-deriving it.

Inference (`providerFetch`). The route is resolved per request rather than
once per wrapper, because `noProxy` is evaluated against the destination and
two sends through the same executor can legitimately take different exits.
The resolved value reaches the dispatch init, so it survives `dispatchOverride`
and the fresh-connection policy.

Discovery and quota (`providerOutboundRequest`). This is the chokepoint every
`providerOutboundGet`/`Post` caller shares -- provider discovery, the
model-catalog gather, the management provider test and the Ollama show probe.
A provider route replaces the global decision outright rather than combining
with it: an explicit proxy applies even where global NO_PROXY exempts the
host, because the operator named that proxy for that provider and
`providers.<name>.noProxy` is the exemption belonging to that choice. A
provider pinned to `direct` keeps the DNS-pinned transport, which reaches the
peer through node:http and therefore needs nothing from the runtime's proxy
handling -- the one path where direct egress is available by construction.

An explicit proxy is pinned onto the request unconditionally, including
through the DNS-failure degradation. Letting fetch re-infer the route there
would move the request to a different exit at the exact moment local DNS
stopped working, which is when the proxy matters most.

Quota (`vendor-probes-key.ts`). Seventeen probes were bare global fetches,
so a provider pinned to its own proxy still sent its quota probe by the
process-wide route -- reporting a healthy account while inference failed, or
sending the key out an exit the operator did not choose. Each probe already
receives its provider config, so the route was available; only the transport
was wrong.

Where the route cannot be carried it is refused rather than dropped. A
caller-supplied `provider.fetch` executor owns its own routing, so an
explicit route throws instead of running the executor by a contradicting
route. The WebSocket upstream selects its proxy from the process environment
when it dials, so an explicit route serves those turns over HTTP/SSE and says
so once per provider; a transport change nobody asked for is the same class of
silent substitution this batch exists to remove.

The regressions assert which transport carried each request and which proxy
value it was pinned to. Asserting a 200 would pass with the route dropped
entirely, which is the defect, not the fix.

Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com>

* docs(proxy): document per-provider egress and its uncovered surface

The provider guide gains the two fields and a worked example matching the
issue's real case. The transport inventory records, per request path, whether
a provider route is honoured -- and where it is not, which is the part that
matters: OAuth token exchange and refresh, the OAuth-backed quota probes and
the API-key validation probes all reach fixed vendor endpoints from modules
that hold no provider config, so a provider pinned to its own proxy still
refreshes credentials by the process-wide route. Cursor's HTTP/2 transport,
the coding-agent subprocess providers and the Lab pinned sender are recorded
for the same reason.

The lane document records the egress work, the disposition for the CodeBuddy
and native-wire bundle, and the union-defect sweep.

* fix(proxy): resolve the provider route at the physical send

Adversarial review of the branch found three defects in the first pass.

The route was resolved when the fetch wrapper was built, but a
`dispatchOverride` can rebuild a queued request against a different upstream
host before it leaves -- account reselection moves the regional host for
Copilot, and Anthropic pool rotation rebuilds the request entirely. The
original `dispatchInit` was reused with its now-stale proxy value, so a
host-scoped `noProxy` decision could be inverted and a bearer could leave by
a route the operator excluded. The decision now sits in
`sendWithConnectionPolicy`, against the destination actually being sent to
and around whichever executor was just selected. That is the same boundary
and the same reason as #4992, which that function's own comment already
records for the connection policy.

Refusing every `provider.fetch` as transport-owning was too broad. The xAI
route installs a wrapper on every request that only adds a generated request
id and forwards the init, so an explicit route would have thrown for xAI --
one of the two providers #2894 names. Executors that forward their init are
now marked transparent and carry the route; the mark is opt-in, so an executor
arriving from configuration stays opaque and is still refused. The executor
`providerFetch` returns is marked too, because Cursor hands it back as
`provider.fetch`.

xAI's default executor also fell back to the bare global fetch, which ignores
a socks5 value. A per-provider SOCKS5 route would have sent the request
unproxied while the configuration named a proxy. It now routes through
`configuredOutboundFetch` like every other default.

Two smaller ones: the WebSocket downgrade notice logged a configuration-controlled
provider name unredacted, which this repository treats as potentially
token-shaped everywhere else, and its notice set had no bound.

* fix(proxy): bind the route on native Chat sends and refuse before dispatch

Two more defects from review of the previous commit.

Native Chat builds its own physical send and calls the connection policy with
`activeProvider.fetch ?? execute`. A provider transport wins over the executor
that carries the egress binding, so that send omitted the route entirely --
and since the xAI route now always installs a transport, xAI native Chat would
have followed global routing while its configuration named a proxy, and an
opaque executor would have been invoked instead of refused. The binding now
travels with that send, resolved against the provider the send actually uses,
which matters because reselection can replace it mid-dispatch.

Moving the refusal to the physical send also moved it after
`options.beforeDispatch`, which commits attempt accounting and consumes
admission state. A refusal firing after it would charge an attempt for a send
that never happens, and a throwing hook would mask the egress error with an
unrelated one. The wrapper now fails fast before the hook; the authoritative
decision still happens at the send, against the destination that send uses.

* fix(proxy): decide the route once at the outermost physical boundary

A third review round found that the executor `providerFetch` hands to a
`dispatchOverride` was not marked transparent. Every override selects
`provider.fetch ?? execute`, so for an ordinary provider with no custom
transport that executor IS the selected one -- and an explicit route would
have been refused on every overridden path, after the attempt had already
been recorded by `commitKeyAttemptSend` or `noteProviderAttemptSend`. Only
xAI escaped it, because its own wrapper carries the mark. None of the
existing regressions covered the production-shaped nested send, so two now do.

Marking it alone would have been wrong in the other direction: these calls
nest, and the inner pass would have recomputed the route from the closure's
provider after the override had already decided with the reselected one. The
outermost boundary now decides and marks the init; the inner pass honours the
mark. An override that simply calls the executor still gets a decision rather
than losing the route.

The pre-dispatch fast fail is narrowed to match. With no override, the input
and executor at that point are the final ones, so the full decision is made
before `beforeDispatch`. With an override, only the configured value is
validated, because refusing against a destination the override is about to
replace would reject a request whose real route is fine.

A refusal caused by `noProxy` now names `noProxy` rather than telling the
operator to remove a `proxy` override they never wrote. The provider guide
gained the coverage limits it was missing -- it described the three states
without saying which transports cannot carry them.

* fix(proxy): give the provider egress config fields a declared output type

The two zod field schemas used `z.unknown().superRefine(...)` so the shared
resolver could produce the message, but never narrowed the result. That makes
the parsed provider record carry `proxy: unknown` and `noProxy: unknown`,
which is not assignable to `OcxProviderConfig` -- four errors in
`config-schema.ts`, and a typecheck-based adapter contract test that asserts
zero errors reported one. Both CI failures had this single cause.

They now transform to their declared types, matching the superRefine-plus-transform
idiom the neighbouring field schemas already use. Validation is unchanged and
still delegates to `providerEgressConfigError`, so configuration and request
time keep one definition of a usable value.

The fetch-helpers import boundary test pins the exact runtime-import list for
that file; it gains the two modules this lane added.

* test(proxy): keep credentialed proxy fixtures off the email pattern

The privacy scan reads a URL userinfo pair as an address: `user:pw@host.tld`
looks exactly like `pw@host.tld`. Three fixtures that deliberately carry a
credential to prove it never reaches a log or an error tripped it.

They move to a `.test` host, which the scanner already allows for fixtures and
which the repository uses elsewhere for the same reason. The assertions are
unchanged: the credential must still not appear in the sanitized label, the
described route, or the validation error.

* docs(devlog): record the lane F outcome and the defects each gate caught

Names the exact-head CI evidence, the three route-seam defects adversarial
review caught before CI ran, and the two CI caught after review had cleared
them.

* docs(devlog): describe the credential-fixture defect without reproducing it

The lane document explained why the privacy scan rejected the credentialed
proxy fixtures by quoting the shape that triggered it, which tripped the same
scan on the document. It now describes the shape instead of writing one.

---------

Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com>
…onitor integrated into Usage (#5196)

* docs(devlog): plan macOS menu bar companion (Phase 0 roadmap)

Nine numbered docs covering the roadmap for a maintainer-owned macOS menu
bar app in app/, consolidating the two competing community PRs (#387
Swift/SwiftUI, #421 Tauri/React).

- 000_plan: constraints, dependency-ordered phase map, accept criteria
- 001_pr_survey: head-to-head of both PRs; stack decision is Swift/AppKit
  runtime with HTTP management-API transport, plus the salvage list
- 002_api_surface: live payload inventory, the seconds-vs-milliseconds
  quota timestamp trap, and the default-provider 400 trap
- 003_design_read: Design Read and dial lock (V2/M1/D7), inheriting the
  existing gui/src/styles.css tokens
- 010-050: diff-level decade docs, one per implementation phase

* docs(devlog): fold 13 audit blockers into the macOS app roadmap

Adversarial Phase-0 review returned FAIL. Corrections, all verified against
live source and the running proxy:

- /api/stop calls stopServiceIfInstalled() before responding, so nothing
  restarts the proxy. The app now ships Stop proxy, never Restart, and
  never spawns a process.
- /api/usage supports only 7d/30d/all; 24h silently degrades to 30d. The
  range is now a closed enum and the UI labels the range the response
  returned, not the one it requested.
- defaultProvider lives on /api/config, not /api/settings. Added the model,
  the client method, and the test.
- The bundle script now defines every path before use and copies Info.plist
  before plutil; it could not have run as previously written.
- release.yml grants contents:write and id-token:write at workflow level, so
  the new jobs declare explicit least-privilege permissions. Added a separate
  attach-macos job so packaging can never block the npm publish, and pinned
  both new actions to full SHAs.
- Re-surveyed PR #421 at head 049ef2ac: the committed src-tauri/target tree
  was already removed by the contributor. The closing comment must credit
  that fix rather than repeat a stale defect.
- /api/logs exists for per-request activity; documented as a deliberate v1
  exclusion instead of an implicit gap.
- Phase 1 no longer claims verification via a Phase 4 script.
- Added StartupHealth service fields, security-review acceptance evidence to
  Phase 4, and removed developer-absolute paths from tracked docs.

* docs(devlog): fold round-2 audit blockers (design lock, bundle ownership, plist)

Round-2 adversarial review returned FAIL on 9 findings, most of them caused
by round-1 edits that corrected prose without correcting the specs those
documents actually lock.

- 003 was never touched in round 1, so the design lock still mandated a 24h
  sparkline and a Restart button that 002/030 prove impossible. Wireframe now
  shows LAST 7 DAYS and Stop proxy.
- release.yml's input is named dry-run, not dry_run. inputs.dry_run would
  resolve to null and the attach-macos guard would silently pass during a
  dry run — the exact failure that guard exists to prevent.
- Bundle ownership was relocated, not resolved: Phase 2 claimed a launchable
  .app while the builder stayed in Phase 4. Phases 1-3 now verify through
  swift test/build/run; Phase 4 owns the bundle end to end.
- app/Info.plist was missing CFBundleExecutable, CFBundlePackageType, and
  CFBundleIconFile, so the specified bundle would not have launched.
- Re-read #421 at head 049ef2ac: menubar/src/api.ts:12-13 returns the token
  into renderer memory, so the PR's isolation claim does not hold. Removed
  that credit from both the survey and the planned closing comment, and
  removed the remaining stale rejection sentences.
- Scoped the absolute-path criterion to files this unit touches; unrelated
  historical devlogs already contain such paths.
- loading is now explicitly exempt from the next-action rule, and per-section
  empty states are defined with their own copy.
- The range-fallback test injects a stubbed response, since the closed
  UsageRange enum makes the curl path unreachable from production code.

* docs(devlog): clear round-3 residual nits

Round-3 audit returned GO-WITH-FIXES (blockers=0). Cleared all three:

- 010 still called the first bundle a Phase-2 deliverable, contradicting
  000/020/040. Phase 4 owns the bundle end to end.
- 001's salvage list still credited #421 with renderer-side token isolation,
  contradicting its own verified analysis, and claimed all four surfaces were
  adopted when per-request activity was deliberately excluded.
- 040 and 050 still scoped the absolute-path rule to every tracked file, which
  pre-existing historical devlogs already violate. Both now match 000's
  unit-scoped wording.

* feat(app): add macOS menu bar core — discovery, client, formatting

Phase 1 of the macOS companion (010_phase1_core.md). Zero third-party
dependencies; AppKit and Foundation only.

- Discovery resolves the proxy from OPENCODEX_HOME/runtime-port.json with a
  10100 fallback. The host is pinned to loopback and never read from the
  record, so a file write cannot redirect the app at another host.
- ProxyModels mirror the live payloads. QuotaReport.normalized() absorbs two
  real traps: the window key differs per provider (weekly/monthly/custom),
  and weeklyResetAt arrives in seconds from openai but milliseconds from
  anthropic within the same array, so timestamps are disambiguated by
  magnitude.
- UsageRange is a closed enum because the server silently degrades an
  unrecognized range to 30d; UsageReport.rangeLabel is derived from the
  response so the UI can never label 30 days of data as something else.
- ProxyClient is an actor. ProxyError carries human sentences only, never a
  response body, since bodies can echo configuration.
- Format renders an em dash for unknown and a real zero for zero; the live
  proxy reports 3.6e10 tokens, so everything is abbreviated.

Testing is an executable target rather than a .testTarget: Command Line
Tools resolves neither XCTest (module not found) nor the swift-testing
runtime (Testing.framework fails to dlopen). Requiring full Xcode to run
these tests would exclude most contributors. 31 cases pass via
`swift run --package-path app MenuBarCoreTests`.

Verified live against the running proxy: endpoint discovery, health
(at-risk, service-managed), defaultProvider=openai from /api/config, 7-day
usage (44.5K requests, 7.34B tokens, $6.15K), and four provider quotas with
correctly resolved reset windows.

* fix(app): fold code-review blockers into the menu bar core

Adversarial review of c7fbf57c returned FAIL on 10 findings. All verified
against the live proxy or Apple docs before folding.

- Info.plist: add NSAllowsLocalNetworking. macOS 14 stopped allowing IP
  loads under ATS, so the packaged bundle could not reach 127.0.0.1 at all
  while swift run stayed green — the app's primary function, broken only in
  the artifact users would actually download.
- ProxyClient: wire the lazy Keychain retry that the plan required and the
  code never implemented. A CredentialStore protocol is injected, the key
  loads once, and exactly one retry follows a 401 so a stale key cannot spin.
- Keychain: set kSecUseDataProtectionKeychain on every query, without which
  kSecAttrAccessible is ignored on macOS; tighten to ThisDeviceOnly; update
  before add so a failed add cannot destroy a working key.
- ProxyModels: live kimi reports fiveHourPercent alongside weeklyPercent, and
  cursor and google-antigravity each carry two customWindows. Added the
  five-hour fields and normalizedWindows() returning every window;
  normalized() keeps an explicit longest-horizon precedence.
- isEmptyOrUnknown preserves three states so an omitted request count cannot
  render as "No requests".
- Cancellation now propagates instead of reading as a stopped proxy, and
  unrelated transport failures get their own .transport case.
- ProxyEndpoint is failable; baseURL is built once instead of force-unwrapped.
- Format promotes at rollover: 999_999 renders 1.00M, not 1000K.
- TransportSuite adds 14 cases over status mapping, the 401 retry path,
  cancellation, request shape, and body redaction. 51 pass, 0 fail.
- Harness no longer counts a case as passed when it recorded a failure.

Live re-verification across all six providers: Kimi 5h+week, Cursor's three
windows, and correct primary-window selection for each.

* fix(app): per-request 401 retry and pressure-based quota selection

Round-2 code review found two real defects, both semantic rather than
syntactic, and both proven with gated probes.

- Concurrent initial 401s produced a false authorization failure. The actor
  suspends across each request, so two calls can both receive 401; the first
  loaded the key and retried while the second saw the global
  didAttemptCredentialLoad flag and threw .unauthorized even though a usable
  key now existed. Retry eligibility is now decided per request against the
  key that request actually sent, so a caller that started before the load
  still retries with it, and a caller that already used the current key does
  not loop. Actor isolation prevented data races here but not reentrancy.

- The compact quota row preferred the longest horizon, which could hide the
  window actually blocking the user: 99% of a five-hour limit alongside 10%
  monthly rendered as a green 10%. Selection is now highest reported usage,
  with ties breaking toward the longer horizon since that one does not
  recover on its own. Live proof: Cursor's row moved from month=10% to
  API usage=42%, which the previous logic concealed.

Regressions: a gated concurrent-401 case asserting two successes, exactly one
credential load, and four total requests; plus pressure-selection cases for
higher-short-window, tie-break, and unmeasured-window inputs. 51 -> 55 cases,
all passing. Live re-verified across all six providers.

* feat(app): add menu bar status item and popover UI

Phase 2 (020_phase2_ui.md). AppKit rather than SwiftUI: this is a fixed-width
column of rows, which stack views do without fighting NSPopover sizing.

- ProxySnapshot is the single source the views render from, so no view
  invents its own loading flag. Five states, and every one carries a word
  beside its dot so meaning is never colour-only.
- PollingCoordinator implements the 002 contract: 5s liveness always, heavy
  aggregation only while the popover is open, 30s backoff after three
  consecutive failures. Cancellation is not treated as a failure.
- Theme derives from gui/src/styles.css but prefers AppKit semantic colours
  where they exist, since those also track increased-contrast and vibrancy.
  Numerics use monospaced digits so polling does not make digits jitter.
- The menu bar glyph is a vector template image and carries state through
  fill and a notch, not colour: a coloured dot in the menu bar is the tell of
  an app that ignores the platform.
- Quota rows show which window each percentage belongs to. Without it, 42%
  of Cursor's API-usage window and 42% of a month look identical.
- A nil percent draws no bar at all, because a zero-width bar reads as
  "0% used" — a different fact from unknown.

Visual verification drove three fixes that code review would not have caught:
the sparkline rendered as wide slabs that read as a progress bar rather than
a chart; the trend was centred and floated away from the columns it belongs
to; and hidden sections left a large empty void because the view kept its
initial 260pt instead of sizing to content. Screenshots of running, stopped,
unauthorized, degraded, and empty were inspected in the real window server.

UI moved into a MenuBarUI library so the visual-QA probe can build the same
surface — an executable target cannot be imported. 55 -> 64 test cases.

* fix(app): fold UI review blockers — states, keyboard, polling, glyph

Adversarial review rendered every state and returned FAIL on 9 findings.

- Stop proxy now confirms first. It interrupts in-flight requests and stops
  launchd, so firing it on a single click was wrong.
- Escape did not work at all: an accessory app never takes key focus, so
  keyDown never arrived. Now activates on open, sets a first responder, and
  installs a scoped key monitor that is removed on close.
- Loading, unauthorized, and degraded were specified but not built. Loading
  shows skeleton rows with disabled chrome; unauthorized has a real Add key
  button; degraded keeps its last-known data with an explicit age plus Retry,
  because stale-but-labelled beats a blank panel.
- Polling split into on-open reads (providers, config) and interval-gated
  aggregation (usage, quotas). Previously every open forced aggregation while
  background ticks fetched on-open data — exactly backwards.
- Refreshes can no longer overlap or outlive a close: one in-flight cycle, a
  generation counter that discards superseded results, and freshness advanced
  only when the aggregation actually completed.
- The at-risk notch never rendered. Stroking with .clear under a .clear
  composite silently did nothing, so a protected and an at-risk proxy showed
  an identical glyph — the state signal was invisible. Carved with even-odd
  winding and verified against a rendered glyph sheet.
- recommendedCommand was decoded but never displayed; the live proxy has been
  advising ocx service install this whole time. Now shown as selectable text,
  alongside a provider summary line.
- Popover height is capped at 480pt with a scrolling body, and scrollers
  appear only on real overflow.
- PollingSuite replaces a test that asserted four constants: gating, cadence,
  backoff, recovery, degraded retention, and observer delivery. 64 -> 73.

UIProbe captures via CGWindowListCreateImage so nothing under app/ constructs
a Process, per the 030 security rule.

* fix(app): make Escape work, top-anchor overflow, split data freshness

Round-2 UI review found 6 defects, all reproduced before folding.

- Escape genuinely did not work. Activating before presentation leaves an
  accessory app's popover without key focus, so no key event ever arrived.
  Activation now happens on the next main-loop turn after show(relativeTo:).
  Verified by synthesizing keycode 53 into the app's own event queue: popover
  shown true before, false after.
- Overflowing content opened scrolled to the bottom, hiding the status line
  and metrics that the urgency order exists to surface. NSScrollView is
  bottom-origin by default; a flipped clip view fixes it.
- Close-then-immediate-reopen dropped the reopen's refresh: the old cycle
  exited on its generation guard while the new one had already been rejected
  by the in-flight lock. Refreshes now queue and drain on every exit path.
- Closing mid-sequence still paid for later requests, and a partial
  aggregation failure re-fetched its healthy sibling every 5 seconds because
  the rate limit keyed on success. Now every request re-checks the cycle, and
  aggregation is limited by attempt.
- Retry opened a browser. Add key and Retry now have separate callbacks.
- Degraded quoted an age derived from the last health probe, so it could
  claim to be showing data it never loaded. healthUpdated and usageUpdated
  are now separate, showsData requires actually-loaded sections, and the
  guidance quotes the data age.

The overflow menu ships Refresh, Open dashboard, and Quit rather than the
sketched Preferences: there is no preferences surface, and a menu item that
opens nothing is worse than its absence. Spec amended to match.

* fix(app): replace NSPopover with a key-capable panel so Escape works

Three rounds of Escape fixes failed because the premise was wrong, not the
implementation. Probing the real delegate from an accessory process:

  popover window in NSApp.windows : absent
  canBecomeKey                    : false
  after NSApp.activate            : appActive=true, isKey=false
  after NSRunningApplication      : appActive=true, isKey=false
  after raising the window level  : appActive=true, isKey=false

macOS does not route key events to a window that cannot become key, so no
activation strategy could ever have delivered Escape. PopoverPanel measures
shown=1 canBecomeKey=1 isKey=1, and Escape closes it.

The panel keeps the parts of the popover contract that matter: transient
dismissal on outside click, dismissal on losing key focus, and
nonactivatingPanel so opening does not steal focus from the user's editor.

Also fixed:
- The success path's generation guard returned without draining a queued
  reopen, so close-then-reopen still dropped its refresh. Every exit path now
  clears the lock and drains.
- On-open reads ran on every 5s liveness tick, turning two rarely-changing
  endpoints into pollers. Now gated on an actual open or manual refresh.
- An already-invalid cycle could consume the aggregation window and make a
  legitimate reopen skip usage and quotas for 60 seconds.
- Removed lastHeavyRefresh and healthUpdated, written but never read.

Four new polling tests: tick-while-open, closed-popover, partial aggregation
failure, and degraded-without-data. 73 -> 77.

* fix(app): give the panel a real surface and keep it alive behind the alert

Round-4 review found two defects introduced by the NSPopover -> NSPanel
amendment. Both are things NSPopover had been providing for free.

- The borderless panel had no background at all. isOpaque=false with a clear
  backgroundColor composited the whole dashboard onto whatever application
  was underneath: labels collided with the app behind it, and contrast
  depended on that app's colours. Content is now wrapped in an
  NSVisualEffectView with .popover material, rounded and clipped.
- Presenting the Stop confirmation made the alert key, which tripped
  resignKey() and dismissed the panel behind it. A user who chose Cancel was
  returned to nothing. isPresentingModal now suspends resign-key dismissal;
  Cancel restores key focus and Confirm dismisses deliberately.

UIProbe missed the first defect because it rendered the controller inside an
ordinary NSWindow, which supplies its own background. It now presents through
the real PopoverPanel over a loud backdrop, so a missing surface cannot hide.
That is twice in this phase that the harness rather than the code was
concealing a defect.

Also: dismiss() is idempotent against a late monitor callback,
debugTogglePanel() is #if DEBUG only, and applicationWillTerminate dismisses
the panel.

* fix(app): let the alert own Escape, and fix measured contrast failures

Round-5 review found two defects, both verified by measurement.

- Escape during the Stop confirmation dismissed the panel and consumed the
  event, leaving the alert stranded with no keyboard way to cancel. The
  monitor now returns the event unchanged while isPresentingModal, so NSAlert
  handles Escape as Cancel.
- Theme.faint used tertiaryLabelColor, which measured 2.01:1 in light and
  2.39:1 in dark against the popover material — far under the 4.5:1 required
  for text. AppKit's tertiary tier is meant for disabled affordances, but it
  was carrying the range heading, metric captions, and quota window labels:
  information the user has to read. Replaced with calibrated tokens plus a
  separate graphMark token held to the 3:1 non-text threshold.

Re-measured from the rendered PNG: 7.27:1 light, 4.98:1 dark. Contrast is now
measured rather than assumed from token names, and UIProbe can force an
appearance without touching system settings.

* fix(app): recalibrate all four text tiers against the rendered material

My round-5 contrast correction was itself wrong. The sampling took the
darkest pixel in a band, which is primary text, not faint — so 7.27:1 and
4.98:1 described a token that was never in question while the actual faint
tier sat at 2.87:1 in dark and the sparkline marks at 1.85:1.

Corrected method: count pixels matching each exact token value in the
rendered PNG, so one tier cannot be measured by accidentally sampling
another. Measured against light (220,219,218) and dark (103,102,102):

  text       12.59 / 5.72   (>= 4.5)
  muted       7.86 / 5.11   (>= 4.5)
  faint       5.48 / 4.89   (>= 4.5)
  graphMark   3.58 / 3.79   (>= 3.0, non-text)

All four pass and text > muted > faint holds in both appearances. The light
inversion the reviewer found — faint outranking muted — is gone.

The dark material is the binding constraint: pure white measures only 5.81:1
against it, so three text tiers have to fit inside a 1.3-point band. That is
why the dark values cluster, and why AppKit's semantic tiers cannot be used
here without silently reintroducing the failure.

* docs(app): note the material pixel variation and the semantic-colour exception

Round-7 review passed. Two documentation nits from it:

- The dark popover material is not perfectly flat: the dominant pixel is
  (102,101,101) while adjacent pixels read (103,102,102). The contrast table
  uses the lighter value (the stricter test) and the 5.81:1 ceiling comes
  from the darker one. Both are now named.
- Theme's header claimed AppKit semantic colours always win, which is true
  for surfaces but is now a deliberate exception for the text tiers.

* feat(app): wire proxy control and provider toggles

Phase 3 (030_phase3_actions.md). The client write methods and the
confirmation sheet already landed in Phase 2 — a Stop button could not ship
without them — so this phase adds what was actually missing: outcome
reporting, the provider toggle UI, and result feedback.

- ActionCoordinator reports what happened rather than what was requested.
  /api/stop answers before it drains and stops launchd on the way, so a 200
  means accepted, not stopped: the coordinator polls until the port stops
  answering and reports requiresManualStart with the command for that
  install. A proxy still answering after 10s is a failure, not a success.
- Provider toggles are optimistic with revert on rejection. The default
  provider's switch is inert and explains why, since the proxy answers 400
  for that case and firing a request that cannot succeed is worse than not
  offering it.
- A result banner reports every write outcome and clears itself, guarded by a
  token so an older timer cannot clear a newer result.
- No failure path quotes a response body; bodies can echo configuration.

The stop timeout test needed an injectable clock, not just a no-op sleeper:
the loop is bounded by a deadline, so skipping the sleep without advancing
time meant it never expired and the test reported success. Recorded in 030
along with the stub's drain-to-refused fallback, which can make an
under-queued test pass for the wrong reason.

Live-verified against the running proxy: anthropic disabled and re-enabled
with the proxy confirming each state, and the default-provider guard refusing
before any request. Proxy state restored afterwards, 10 of 10 enabled. 77 ->
87 tests.

* fix(app): distinguish liveness states and serialize provider writes

Review of ef1c59c5 returned FAIL on 6 findings, all verified against the
proxy source.

- isReachable() treated every non-401 error as "gone", so a 500 or a decode
  failure while polling after /api/stop reported the stop as confirmed while
  an HTTP server was still listening. Replaced with three-state liveness:
  reachable (any HTTP answer proves the port is occupied), refused (the only
  proof the proxy is gone), indeterminate (a timeout proves nothing).
- /api/stop returns success:false when restoreNativeCodex() fails
  (management-api.ts:145-147). The proxy still exits, but native Codex is
  left pointing at a closing port. The body was discarded, so the app said
  "Proxy stopped". Now decodes only the boolean — never the server's message
  — and reports stoppedWithRestoreFailure telling the user to run ocx restore.
- Two rapid toggles could reach the server out of order and leave it opposite
  to the user's last click, since both actors are reentrant across awaits.
  One in-flight write per provider, and the row stays inert until its
  authoritative refresh lands. Pending state survives rebuildRows so a poll
  cannot resurrect the pre-toggle switch.
- A default provider that was already disabled could never be re-enabled: the
  switch was inert whenever isDefault, but the proxy guard fires only when
  disabled is true AND the name matches the default — enabling is valid.
- The "exact body" test encoded its own dictionary rather than reading the
  request, so it would have passed with no body at all. StubProtocol now
  drains httpBodyStream and the test asserts on the decoded actual body.
- Acceptance criterion 1 demanded a live stop while the notes said stop was
  deliberately not run live. Amended with reasoning: stopping the developer's
  proxy is out of bounds, and the branches that matter cannot be produced on
  demand from a healthy proxy.

Also corrected 002 (the success flag was undocumented) and 050's stale
"scroll-free column". 87 -> 93 tests.

* fix(app): only a refused connection proves the proxy stopped

Round-2 review found the three-state liveness contract was still two states
in practice, plus three follow-on defects.

- perform() mapped .timedOut, .networkConnectionLost, .cannotFindHost, and
  .notConnectedToInternet to ProxyError.unreachable, which liveness() then
  read as .refused. So a timeout during the stop poll could still confirm a
  stop while the proxy was running — the exact defect round 1 was meant to
  fix. Added ProxyError.inconclusive; only .cannotConnectToHost becomes
  .refused now. Liveness probes also take a 1.5s timeout so a single probe
  cannot overrun the 10s stop deadline it is supposed to respect.
- rebuildRows() initialised each switch from the server snapshot, so a poll
  landing mid-write snapped the switch back to its pre-toggle value even
  though the row was marked busy. pending now stores the intended state and
  applies it before marking the row busy.
- The post-write refresh coalesced: refresh() queues and returns immediately
  when another cycle holds the lock, so the switch became interactive again
  against pre-write data. Added refreshAndWait().
- 030 still demanded a live stop in its verification line and carried three
  pre-review snippets (void stop(), boolean isReachable() loop, unconditional
  default guard) that would have reintroduced the reviewed defects.

Added liveness classification tests for every URLError code that matters, an
HTTP-answer table (200/401/403/500 all prove the port is occupied), an
undecodable-200 case, and a stop-with-timeout case asserting the inconclusive
message rather than a false success. 93 -> 97 tests.

Also corrected the 002 stop snippet, which showed only the success:true
branch while the prose below it described both.

* fix(app): single-attempt liveness and a real refresh completion signal

Round-3 review returned GO-WITH-FIXES on two Medium blockers.

- liveness() went through the generic send(), so a 401 with a stored key
  triggered the credential retry: a second full timeout spent re-asking a
  question the 401 had already answered, and a failed retry downgraded a
  known-reachable result to indeterminate. It now calls perform() directly —
  one attempt, no retry.
- The stop loop always asked for a 1.5s probe regardless of time remaining,
  so the final probe could overrun the 10s deadline. Each probe is capped to
  min(1.5, remaining) and the loop breaks when nothing is left.
- refreshAndWait() spun on shared booleans with a 5s bound. A legitimately
  slow cycle (providers + config sequentially, plus a due aggregation) can
  exceed that, at which point it returned and the switch became interactive
  against pre-write data — the exact window the method was added to close.
  It now waits on a continuation released when no cycle is running or queued.

Also corrected two stale ProxyError doc comments (timeout is no longer
unreachable, DNS is no longer transport) and the ActionOutcome snippet in 030,
which predated stoppedWithRestoreFailure.

New tests: a 401 with a stored key resolves in one request; the probe honours
a caller-supplied timeout; every stop probe stays within the cap;
refreshAndWait returns only after a cycle published, and survives a failing
cycle without hanging. 97 -> 102.

* test(app): actually exercise the refresh continuation path

Round-4 review found that neither refreshAndWait test entered the code they
were written to protect. Both ran with refreshInFlight == false, so they took
the direct path and never touched completionWaiters, waitForCompletion, or
signalCompletionIfIdle. They would have stayed green if the continuation
never resumed, resumed early, or was deleted.

StubProtocol gained a request gate so a cycle can be held suspended. Two new
tests start a refresh, block it in the stub, call refreshAndWait
concurrently, assert it has NOT returned, then release and assert it does.
One covers a succeeding queued cycle, one a failing cycle.

Sabotage-verified, because a passing test proves nothing about a path it
never takes: removing the resume line made the suite hang until the 120s
timeout rather than pass. Restored, it completes in about 2 seconds.

102 -> 104 tests.

* test(app): make the failing-cycle test actually consume a failure

Round-5 review found the "queued cycle fails" test was re-testing the success
path. With the popover closed a cycle consumes exactly one health response,
and the queue led with three 200s, so the connection-refused responses were
never reached. It would have stayed green if the error exit stopped signaling
waiters.

Two contract details drive the corrected setup: drainPendingRefresh only runs
while the popover is open, and an open cycle consumes health + providers +
config + usage + quotas. So the popover is opened first, then a single gated
200 lets cycle 1 reach the gate, and everything after is a refusal. A new
snapshot.state == .unreachable assertion proves the failure was consumed —
and that assertion is what caught the original defect.

Hardened the gate harness alongside it: setGate/currentGate now go through
the stub's existing lock rather than racing on a bare static, a gateEntered
semaphore lets a test wait for the request to actually arrive instead of
inferring it from a 200ms sleep, and defer releases the gate so a mid-test
failure cannot wedge the suite.

Sabotage results, both recorded in 030 because the second one matters:
removing waiter.resume() entirely hangs the suite, so the gate tests do
depend on the continuation. Removing only the ProxyError signal does not fail
it — a signal trace showed the waiter is protected by several exit paths, so
single-site sabotage is not a valid probe here.

104 tests.

* test(app): deterministic waiter registration and a UI test target

Round-6 review found two ways the suite could pass without proving anything.

- The continuation tests synchronised on a fixed sleep. gateEntered proved
  cycle 1 reached the gate, but nothing proved the waiter had registered
  before the gate was released; under starvation the waiter could start
  afterwards, take the ordinary non-coalesced path, and still satisfy every
  assertion. PollingCoordinator now exposes waiterCount, and the tests poll
  it until registration is observed, then assert it returns to zero.
- No test drove MenuBarUI at all. The Phase 3 behaviours that had actually
  been defects in earlier rounds — optimistic rollback, pending state
  surviving a stale poll, and the direction-sensitive default guard — had no
  regression cover, because MenuBarCoreTests depends only on MenuBarCore.
  Added a MenuBarUITests target with read-only inspection hooks.

Sabotage-verified: reintroducing both original defects failed exactly the two
matching cases and left the other five green. Making the default guard
direction-insensitive failed the disabled-default recovery test; dropping the
intended value in rebuildRows failed the stale-poll test.

Also removed an unnecessary nonisolated(unsafe) on a let constant.

104 core + 7 UI tests.

* chore(app): narrow test hooks to package visibility

Round-7 review passed. Carry-forward items folded now rather than deferred:

- waiterCount and the ProviderListView test hooks are `package` rather than
  `public`. Neither module ships as a library product, so this was never an
  external API risk, but package visibility says what these are: test-only
  access within the package.
- 040's test:macos script runs both suites, and its acceptance criteria now
  state that "build clean" means exit 0 rather than warning-free, since the
  remaining warnings are Command Line Tools search paths from the toolchain.
- 030's stop example carries the remaining-time clamp that shipped.

* feat(release): build and package the macOS companion

Phase 4 (040_phase4_release.md). The app now has a distribution path, which
is what the whole question was about: a menu bar app a user has to compile is
not a shipped app.

- scripts/build-macos-app.sh assembles OpenCodex.app by hand — no Xcode
  project to keep in sync. It stages into a temp directory and moves at the
  end, so an interrupted build cannot leave a half-written bundle that
  launches and misbehaves. Version comes from package.json, so the app can
  never claim a version the release did not ship. UNIVERSAL=1 under Command
  Line Tools refuses with an explanation instead of a linker error.
- scripts/package-macos-release.sh asserts rather than hopes: codesign
  --verify --deep --strict, lipo arch check, ditto archiving (plain zip
  corrupts the signature), an archive-contents assertion, and a SHA-256
  sidecar.
- release.yml gains package-macos and attach-macos. Workflow-level
  permissions drop to {} and each job declares its own, so a new job cannot
  silently inherit a write token or an OIDC credential. package-macos has no
  needs relationship with publish in either direction: a Swift failure must
  never be able to block an npm release.
- ci.yml runs the macOS test and build on macOS runners only, after
  privacy:scan so a credential leak fails before a multi-minute Swift build.
  The path filter gained app/** — without it an app-only change ran no CI.

Verified locally end to end: the bundle builds, passes codesign, launches
with no ATS errors, packages to an 813 KB zip whose checksum verifies, and
survives unpack-and-launch — the path a user actually takes, and the one that
would expose a corrupted signature.

One debugging note recorded in 040: the archive assertion originally used
`unzip -Z1 | grep -Fqx`, which fails under pipefail because grep -q exits on
match and unzip dies on SIGPIPE. It rejected correctly-packaged archives.

* fix(release): env-pass the release input, fix preview versions, guard output

Security review returned FAIL on three findings. The first was caught by the
repository's own regression suite, which is the best possible outcome.

- release.yml interpolated inputs.version directly into run: shell source.
  tests/ci-workflows.test.ts:76-81 rejects exactly this pattern repo-wide as
  script-injection hardening, and the suite was failing. The version now
  reaches the shell through env as RELEASE_VERSION.
- CFBundleVersion accepted prerelease suffixes. Apple restricts that field to
  period-separated integers, so every preview build would have shipped
  invalid metadata. The script now uses the numeric core for CFBundleVersion
  while CFBundleShortVersionString keeps the full human-facing string, and
  MACOS_BUILD_NUMBER (github.run_number in CI) appends a monotonic build
  component. Verified: 2.7.36-preview.1 produces 2.7.36, and 2.7.36.42 with a
  build number.
- The output containment check compared $app_bundle against $output_root,
  both derived from the same variable, so it always passed. OUTPUT_DIR could
  point at /Applications and have an existing bundle recursively removed. The
  destination must now sit under the repository or a temp directory.
  Verified: /Applications is refused, /tmp is allowed.

On Gatekeeper: the reviewer is right that the asset is ad-hoc signed and
spctl rejects it. Developer ID signing plus notarization needs a paid Apple
Developer account and this project has no certificate (verified: zero
Developer ID identities, no Apple secrets in any workflow). Rather than
pretend otherwise, build-macos-app.sh gained an optional MACOS_SIGN_IDENTITY
that switches to hardened-runtime signing, package-macos-release.sh reports
the spctl verdict and fails only when a real identity was claimed and still
rejected, and release.yml wires the secret so adding a certificate becomes
configuration rather than code. 040 documents what ships today and why the
Phase 5 Gatekeeper section is mandatory.

Also corrected the SIGPIPE note in 040: the reviewer reproduced the old
pipeline exiting 0, so it is a race rather than a certainty — which is a
better argument for fixing it, not a weaker one.

* fix(release): honour Apple's actual version limits and drop the phantom secret

Security re-review found my first version fix was still wrong, in a way I
had not read carefully enough.

- CFBundleShortVersionString must be exactly three integers, so a preview
  release was still writing "2.7.36-preview.1" into a field that does not
  accept it. It now gets the numeric core.
- CFBundleVersion accepts ONE TO THREE integers and ignores a fourth. So
  "2.7.36.<run>" provided no additional identity at all — repeated builds of
  the same version compared as identical despite the run number. When CI
  supplies a run number it now becomes the CFBundleVersion outright: a single
  monotonically increasing integer is both valid and genuinely distinguishing.
  Verified: 2.7.36-preview.1 gives short 2.7.36 / build 2.7.36, and with a run
  number, build 1234.
- The output containment check resolved logical paths, so a repository-local
  symlink pointing outside would pass the prefix test and then be deleted for
  real. Paths are now resolved with pwd -P, and a symlinked destination is
  refused outright. Verified: a symlink to a home directory is refused, while
  ordinary paths still build.
- Removed MACOS_SIGN_IDENTITY from release.yml. The reviewer is right that an
  identity name alone cannot sign on a hosted runner — nothing imports the
  certificate and private key, so codesign fails with "no identity found".
  Advertising the secret implied a capability that does not exist. The build
  script keeps the hook for local signing and says so; real CI signing needs a
  protected P12 import, a temporary keychain, notarytool credentials, and
  stapling as one security-reviewed change.

Also updated 040's executable snippets, which still showed the pre-review
version handling and the direct inputs.version interpolation while later
sections described the fixes — a source-of-truth document contradicting
itself is worse than one that is merely incomplete.

* docs(release): sync the Phase 4 plan with what actually shipped

Closure blocker from the security review: 040 is the security-review
artifact, and it still demonstrated the defects the last two rounds fixed.
Copying its workflow example would have reintroduced the repository's
prohibited injection pattern.

Synchronised every stale snippet:
- the tautological output guard is now the physical-path containment check
- pwd gained -P where the implementation has it
- the ad-hoc signing note no longer claims CI may re-sign, which the workflow
  deliberately does not support
- the package job example carries MACOS_BUILD_NUMBER
- the attach step passes RELEASE_VERSION through env instead of interpolating
  inputs.version into run: source
- acceptance criterion 3a describes both Apple limits correctly rather than
  the invalid 2.7.36.<run> form

Also folded the Low finding: the script created the output directory before
validating containment, so a refused path still left a directory behind.
Validation now resolves the physical path by walking up to the nearest
existing ancestor, and mkdir runs only after the check passes. Verified: a
refused path creates nothing, symlinks outside the allowed roots are still
refused, and ordinary builds are unaffected.

* fix(release): normalise .. before the containment check, and cover it

The containment fix was itself bypassable, which the reviewer demonstrated
and I reproduced: resolve_physical walked up to the nearest existing ancestor
and re-appended the missing tail verbatim, so

  <repo>/.ocx-nope/../../outside-probe

resolved to itself, satisfied the prefix check, and mkdir -p then followed
the .. components out of the repository. The build landed outside the
permitted roots, where the destructive replace runs.

The resolver now normalises the collected tail component by component,
dropping "." and popping a level for "..". Verified: the same traversal is
now refused, naming the RESOLVED path, and creates no directory.

Added tests/macos-build-script.test.ts, which runs the real script: outside
paths refused with nothing created, unresolved .. traversal refused,
repository paths allowed, temp allowed.

Writing that test surfaced its own trap worth recording: building the
traversal with path.join() silently normalises the .. away, so the script
never receives the bypass and the test passes against broken code. It is
built by string concatenation instead. Sabotage-verified — reverting the
normaliser fails exactly the traversal case and leaves the other three green.

Also synced 040's snippet, which still showed the plain pwd -P form.

* fix(release): normalise before resolving, and stop the test deleting fixed paths

Second bypass in the same boundary, found by review and reproduced here.

- resolve_physical resolved physically BEFORE normalising, so `..` could reveal
  a symlink that was then never followed: <repo>/.missing/../outward-link
  passed containment while pointing elsewhere. The order is now inverted —
  normalise lexically, then resolve the surviving path component by component
  so a symlink anywhere along it is followed.
- Iteration is over a quoted array. `for part in $tail` word-split, so a
  literal glob such as `rel*` expanded against the filesystem.
- Found while fixing it: `unset 'stack[-1]'` is a bad subscript in bash 3.2,
  which is what macOS ships. It failed silently, so `..` was never applied at
  all and the previous fix only appeared to work. Computes the index instead.

Verified against every construction the reviewer named: a symlink reached
through `..`, a direct outward symlink, a plain `..` traversal, and a literal
glob. Each is refused naming the RESOLVED path, and none creates a directory.

The regression test was itself unsafe: it recursively deleted fixed paths
outside the repository, including <repo-parent>/ocx-escaped-probe, which would
have destroyed unrelated data if anything already lived there. A test for a
safety boundary must not itself be destructive. Every fixture now lives in a
mkdtemp sandbox or carries a pid-and-timestamp suffix, and the suite only
removes what it created. Grew from 4 to 7 cases, adding both symlink forms and
the glob.

Sabotage-verified: restoring the bash 3.2 unset fails exactly the traversal and
symlink cases and leaves the other five green.

* fix(release): refuse symlinks that do not resolve to a directory

Third bypass in this boundary, found by review and reproduced first.

A symlink with a RELATIVE dangling target was joined onto the resolved prefix
without normalising, so `link -> ../../outside` became `<repo>/../../outside`,
satisfied the `<repo>/*` prefix check, and escaped during mkdir -p. Confirmed
by building straight out of the repository before the fix.

Rather than recursively resolve dangling targets with cycle detection, the
script now refuses any symlink that does not resolve to an existing directory.
OUTPUT_DIR has no legitimate reason to pass through one, and a refusal is
easier to reason about than a clever resolver that has now been wrong three
times.

Two test-quality fixes from the same review:

- The outside-path test derived its destination from process.env.HOME. Other
  suites replace HOME with a temp directory, and temp is a permitted root, so
  the script built there and the assertion failed during a full-suite run. It
  now uses a sibling of the repository, which no suite mutates. The full suite
  is green again: 4076 pass / 0 fail.
- The glob test ran the child with cwd at the repository root while the glob
  sat under dist/, so the old unquoted loop had nothing to expand and the test
  would have passed against the broken implementation. It now runs in a
  sandbox that contains a matching entry and asserts the literal-star path was
  used rather than the decoy.

Added a relative-escaping-symlink regression. Sabotage-verified: disabling the
new symlink guard fails exactly the three symlink cases.

040's containment snippet now shows the real implementation, with all four
bypasses recorded as the reason it looks the way it does, and criterion 3b
describes the eight cases plus the two harness traps.

* docs(release): make the Phase 4 containment snippet honest and complete

Review passed the implementation and left one blocker: 040 is the
security-review artifact, and its containment snippet still could not be
trusted.

- It called resolve_physical without defining it, so it was not executable.
  Now explicitly marked ABBREVIATED with the script named as authoritative.
- It omitted the allowed_tmp branch. That is not cosmetic: macOS puts TMPDIR
  under /var/folders, so the documented version would have rejected the
  packaging script's own temporary build root while claiming to describe it.
- Criterion 3b claimed coverage it did not describe. It now enumerates the
  eight cases and all three harness traps — the HOME mutation, the path.join
  normalisation, and the glob cwd — each of which made a test pass against
  broken code at some point.

Also removed the `normalised` variable, which was computed and never read
after the resolver was restructured.

* docs(release): correct two counting errors in the Phase 4 criteria

Review passed. Editorial only: 'Two harness details' introduced three bullets,
and the eight-case list implied every case was a refusal when two are
acceptance cases.

* docs(macos): document the companion and the Gatekeeper first launch

Phase 5 documentation. The guide ships in all five locales and is linked from
the sidebar; docs-site builds 131 pages with all five present.

The Gatekeeper section is the reason this guide is mandatory rather than
nice-to-have. Users WILL see "cannot be opened because the developer cannot be
verified", and the honest explanation is that Developer ID signing plus
notarization needs a paid Apple Developer account the project does not have.
So the guide says that plainly, gives the right-click-Open path and the xattr
alternative, and points at building from source for anyone who wants neither.

The rest documents what the app actually does rather than what a menu bar app
usually does: the monochrome icon states and why colour is not used up there,
the quota row showing the window under most pressure rather than the longest
horizon, why the button says Stop proxy and not Restart, and the polling
cadence — since a companion that hammers your own proxy every five seconds is
a battery complaint waiting to happen.

Also registered app/ in AGENTS.md and structure/00_overview.md. A new
top-level directory that neither file mentions is invisible to the next agent,
and the overview now states the boundary explicitly: the app is a client of
the management API, so a change that needs a new endpoint is a change to the
proxy first.

* docs(macos): correct the API-key claim the app does not implement

Review found the guide describing a flow that does not exist, in all five
locales, and the same overclaim repeated in the #421 closing comment.

Nothing calls Keychain.write. The app can READ a key under
com.opencodex.menubar and retries once with it, but there is no entry UI, and
"Add key…" only opens the dashboard — whose token lives in session storage and
is unreachable from a native app. So a user with a non-loopback proxy stays on
"Needs API key" no matter what the guide promised.

All five locales now say that plainly: the key can be read from the Keychain,
there is no way to enter one yet, a loopback proxy needs none, and native
entry is planned. The uninstall section no longer claims a Keychain entry
exists unless the user created it. Keychain.swift documents the same thing at
the source.

Also folded:
- Bun was missing from the build prerequisites, so a machine with only Command
  Line Tools would hit "command not found" after satisfying the stated
  requirements. Added, with `bash scripts/build-macos-app.sh` as the
  no-Bun path.
- README now names app/ as the source directory, which criterion 2 asked for
  literally and the previous wording only implied.
- "the address it is listening on" was imprecise: the panel shows the loopback
  endpoint the app is using, which is not necessarily the proxy's configured
  bind. Reworded in all five locales.
- Added the System Settings → Privacy & Security → Open Anyway fallback, since
  current macOS does not always offer an Open button in the first dialog.

A corrective note on #421 follows separately — the credit there also needs
fixing, and closing a PR with an inaccurate credit is worse than not crediting
at all.

* docs(macos): stop pointing users at a Keychain item they cannot create

Review found the documented identifier does not match the code: the app
queries service com.opencodex.menubar.apikey with account "default", while the
guide named com.opencodex.menubar. All five locales repeated it, so the
workaround I had just added would have left users exactly where they started.

Rather than publish the exact identifier, the guides now say there is no
supported way to provision the key by hand. That is the honest answer: the
entry is a data-protection Keychain item, which Keychain Access does not
create, so naming the service would send people down a path that does not
work either. A loopback proxy — the default — needs no key, and native entry
is planned.

The uninstall sections no longer describe removing a Keychain item, since the
app stores nothing there today.

Also took the reviewer's suggestion on 050: criterion 1 said "Guide
published", which implied a deployment this phase does not perform. It now
says the source is added and the docs build verified, with publication
following merge and Pages.

* docs(app): align the Keychain comment with what the guides now say

The source comment still told a maintainer that users create the Keychain
item themselves — the exact workaround the guides just stopped publishing,
because every query sets kSecUseDataProtectionKeychain and Keychain Access
does not create data-protection items.

Left as-is it would have reintroduced the invalid advice the next time
someone read the source instead of the guide.

* ci: declare macos-app in the aggregate gate after the dev rebase

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: add usage timeline companion settings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui): companion section in Usage with live timeline preview

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): timeline cache isolation, other-fold aggregation, ocx companion set/reset

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(app): settings-driven menu bar title, today metrics, timeline chart, widget snapshot export

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(widget): WidgetKit extension with small/medium/large families, packaged into OpenCodex.app

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: structure/docs/ci parity for the macOS companion

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(widget): NSExtensionMain entry point, family-specific layouts, popover legend/captions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cli): include companion in help banner

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ci): cover companion parity and GUI doctor findings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): defer companion loading and translate French labels

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): surface corrupt companion settings and correct the widget copy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): reset fieldset chrome on the companion controls

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(companion): default the menu bar headline to tokens

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(companion): integer token abbreviation (K/M/B, no decimals)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui): companion install card driven by app presence

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui): scrollable model list with switches for the companion chart

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(app): Liquid Glass surfaces on macOS 26

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): round integer token abbreviations and panel presence age

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(companion): update integer token examples

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): live presence refresh, tokens headline in the small widget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): stable model ordering, tokens-first today row

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): address macOS widget review findings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(devlog): drop the duplicate _plan copy of the closed macOS unit

The branch opened this unit under devlog/_plan/ before dev published the
same unit as closed at devlog/_fin/260725_macos_menubar_app/ in 8ae52e4291.
The rebase replayed the _plan addition on top of that publication, so the
tree carried both copies: nine files, 2,605 lines, six byte-identical to
their _fin counterparts.

The three that differ are worse than redundant. 003_design_read.md,
010_phase1_core.md and 020_phase2_ui.md keep the decimal token text
(12.4M, 36.5B) that this same pull request corrects to integers in the
_fin copies, so the duplicate contradicted the corrected record two
directories over.

AGENTS.md defines _plan as units still open and _fin as closed work, and
nothing in CI reads devlog/ - the file-size scanner excludes it - so no
gate would have caught this. The _fin copies, including the
051_feature_summary.md this PR adds, remain the record.

* ci(release): gate package-macos on dispatch validation and align the artifact pin

Two defects in the release jobs this pull request adds, both found in
review of the workflow surface.

package-macos had no needs:, so a dispatch that validate-dispatch would
reject still spun up a macOS runner and packaged an asset. Every other
job in the file gates on that validation; this one now does too. The
blast radius was bounded - contents: read, no secrets, and the script's
own version guard - but running at all on a rejected dispatch is not the
design.

The upload step pinned actions/upload-artifact at v5.0.0 while ci.yml
already pins v7.0.1, leaving the repository with two pins for one
action and pairing a v5 upload against the v8 download in attach-macos.
Both now use the SHA ci.yml already trusts, which is also the pairing
actions/download-artifact v8 expects.

---------

Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Consolidates the desktop stack that was carried as a GitHub native stack on top
of the macOS menu bar companion. #5196 landed as 38a5ab9 by squash, which
detached every child in the chain from its base, so the remaining work is
applied here as one branch against the current dev instead of replayed through
bases that no longer exist.

Carries the standalone binary, the Tauri v2 cross-platform tray and webview
shell, the GUI desktop shell integration, the WidgetKit appex bundle, and the
signed desktop packaging for DMG, MSI, AppImage and deb.

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Add Claude intercept pair: local CA, CONNECT proxy, TLS listener, server wiring

Claude Code honours HTTPS_PROXY/NODE_EXTRA_CA_CERTS from its settings env, so a
loopback CONNECT proxy plus a locally-signed TLS listener for api.anthropic.com
lets the router see Claude Code's Messages traffic without any ANTHROPIC_BASE_URL
rewrite and without touching the Desktop app's own (first-party) configuration.

- src/claude/intercept/local-ca.ts: ECDSA P-256 CA + leaf issuance with a
  hand-rolled DER encoder (node:crypto only); CA persisted under
  <OPENCODEX_HOME>/claude-intercept with a 0600 key, never installed in an OS store.
- src/claude/intercept/connect-proxy.ts: CONNECT-only loopback proxy; splices
  api.anthropic.com:443 onto the TLS listener, relays other targets blind,
  refuses plain HTTP, loopback targets and oversized heads.
- src/claude/intercept/listener.ts: TLS listener; POST /v1/messages and
  /v1/messages/count_tokens are rewritten onto a loopback origin and dispatched
  to the route table under the new claude-intercept ingress (loopback policy);
  every other path is relayed verbatim to the configured Anthropic upstream.
- src/claude/intercept/settings.ts: ownership-aware apply/inspect/remove of the
  two env keys in Claude Code settings.json (anchored on the CA path).
- src/claude/intercept/runtime.ts + server wiring: enabled by default on a hub,
  proxy port = public port + 100 unless claudeCode.intercept.port is set; bind
  failure degrades to a warning; stop joins both sockets.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Validate claudeCode.intercept in the config schema

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-intercept): keep ephemeral-port servers proxy-free and lift wiring out of index.ts

startServer(0) has no stable port to derive the CONNECT proxy from, so the
intercept pair now stays off unless claudeCode.intercept.port is explicit;
this also keeps in-process test fixtures at their expected listener count.
The wiring moves into src/server/index/claude-intercept-lifecycle.ts and the
inbound-body-limit warning into startup-warnings.ts so src/server/index.ts
stays under its file-size cap.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-intercept): refuse mapped/unspecified loopback CONNECT targets, share the intercepted-path predicate

isLoopbackTarget now checks 127/8, ::1, 0.0.0.0 and :: through a BlockList (which also
matches IPv4-mapped IPv6), *.localhost, and numeric resolver shorthands like 127.1.
serve-options.ts reuses isClaudeInterceptedPath from listener.ts so the two route lists
cannot drift apart.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(claude-desktop): first-party mode as default, gateway profile as explicit opt-in

Add claudeCode.desktopMode (first-party | gateway). First-party keeps Claude
Desktop on claude.ai and writes only HTTPS_PROXY/NODE_EXTRA_CA_CERTS into
Claude Code's settings.json so the Code tab, subagents and the claude CLI go
through the local intercept pair. Gateway keeps the existing 3P profile writer.

- resolveClaudeDesktopMode: explicit > applied gateway fingerprint > first-party,
  so existing gateway installs do not flip on update while new installs get 1P
- resolveClaudeDesktopApplyMode: implied 1P falls back to gateway where the
  intercept pair cannot run (client role / intercept disabled)
- CLI: ocx claude desktop apply [--first-party|--gateway]; legacy shape flags
  imply --gateway; connected clients default to gateway
- API: /api/claude-desktop/apply accepts first-party|gateway (+ legacy shapes),
  status reports mode + firstParty block; native toggle applies resolved mode
  and disable removes both gateway profile and 1P env
- ocx ensure: refresh stale 1P env when ON, remove it when OFF
- modes are mutually exclusive; foreign proxy/CA env is never overwritten

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-desktop): first-party apply replaces an active gateway profile

Switching gateway -> first-party from the GUI/CLI/API previously refused with
gateway_profile_active and required turning the integration off first, while the
docs and the GUI switch note promise a direct replacement in both directions.
The first-party branch now pivots the owned gateway profile back to standard
(removeDesktop3pStandardPivot with replaceWhileEnabled, since the durable switch
stays ON) before writing the env, and fails without writing when the pivot cannot
complete.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-desktop): native toggle enable follows the apply contract for first-party

- The native ON toggle pivots an owned gateway profile to standard (replaceWhileEnabled)
  before writing the first-party env, and persists the desktopMode marker, matching
  POST /api/claude-desktop/apply.
- Gateway apply now reports a failed mode-marker save as saved:false plus a warning
  instead of dropping the result.
- ensure/update warns when an explicit first-party marker contradicts a gateway profile
  still on disk rather than returning silently.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-desktop): drop the gateway apply marker on first-party and record gateway mode from the native toggle

All three mode-marker writers (CLI apply, /api/claude-desktop/apply, native toggle) now share
recordClaudeDesktopMode. Switching to first-party clears desktopProfile.appliedFingerprint/appliedAt
so a lost explicit marker can no longer resolve back to gateway while first-party env is on disk;
the profile assignments stay for a later gateway apply. The native toggle's gateway enable branch
saves desktopMode="gateway" like the apply route does.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(claude-desktop): prove the native toggle writes the gateway marker from an unmarked config

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui,docs): Claude Desktop connection-mode selector and first-party docs

Dashboard Desktop tab gains a Connection mode picker (first-party default,
gateway opt-in) that sends the chosen mode with /api/claude-desktop/apply and
shows the intercept proxy state in first-party mode. Strings added to every
locale.

Docs describe both modes, the settings.json env the first-party apply writes,
intercepted routes, the local CA trust boundary, update behavior for existing
gateway installs, and Claude Code CLI compatibility/limitations. Translated
guides get a summary section pointing at the canonical English text.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: avoid literal home path in first-party settings example

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(gui): cover Claude Desktop connection-mode picker

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(gui): move Claude Desktop mode-picker CSS into its own stylesheet

gui/src/styles.css sits at its file-size cap; the picker rules move byte-for-byte
into gui/src/styles/claude-desktop-mode-picker.css, imported from main.tsx.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): keep the Desktop mode picker unresolved until /status answers

The picker no longer pre-checks the first-party default while the status request is
in flight, so a gateway install does not see the wrong radio and badge flash.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): unlock the Desktop mode picker after a confirmed /status failure

A failed /status with no cached status left the picker disabled forever. It now unlocks on
the first-party default once the error is shown, while the current-mode badge and switch
note stay hidden because the real mode is still unknown.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: re-run after macos 2/2 runner hang in launcher --version test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(claude-desktop): state that Save alone does not switch the connection mode

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
* test(tray): prove hung-probe cleanup behaviorally with a controlled fake CLI child

Follow-up to #5184: replace the placement-blind gate substring assert
and the comment-text assert with a win32-only lifecycle regression test.
A PowerShell driver loads the real probe functions from
windows-tray.ps1 via the AST, stages a real hung child through
Start-StartupHealthProbe, backdates past the 30s timeout, and invokes
the real Update-TrayState ticks:

- Offline: tray stays offline yet terminates the hung child.
- Online (fake /healthz): ticks kill the hung child and launch no
  replacement before the refresh interval (pid file proves 1 launch).

Ablation: deleting the timeout-branch Kill() flips childTerminated to
false, so the test goes red exactly when the behavior regresses.

* test(tray): name the driver safety-net catch for hygiene

* test(tray): harden the probe lifecycle driver and keep merge-time placement cover

- Driver wraps the post-launch lifecycle in try/finally so a throw before
  the fake CLI writes its pid file cannot leak the 120s sleeper; the
  in-hand pid is stopped in finally, verdict evaluation stays before it.
- Driver asserts the child is still alive after the settle wait, so an
  already-exited child cannot vacuous-pass through the exited-probe path.
- Restore a lightweight platform-independent placement assert (timeout
  maintenance must precede the online-only UI branch, line-anchored to
  dodge the inline proxyPid conditional), because the win32-only
  behavioral test does not run on the PR-gated legs.

* test(tray): anchor the placement check to the timeout branch itself
* fix(gui): keep Apple SD Gothic Neo behind San Francisco

* test(gui): guard system font fallback precedence

* fix(gui): preserve product font priority before system fallbacks

* test(gui): cover all named system UI font fallbacks

---------

Co-authored-by: stleamist <2215080+stleamist@users.noreply.github.com>
…s that it accepts it (#5334)

#5213 removed a hostname test that decided the wire role, which was right: a
gateway proxying OpenAI accepts `developer` and the hostname cannot say so. The
replacement default was wrong in the other direction. Forwarding to every
destination assumed each one accepts a role until an operator marks it, so a
gateway that rejects it answered `400 role 'developer' is not allowed` and the
turn never started.

Nothing in this repository could see that. Every test asserted the new default
and passed; what broke was outside the tree. The key is now tri-state and the
unset state is the safe one: absent folds to `system`, `true` records an
upstream that rejects the role, `false` records one that accepts it and the
role is forwarded. Placement is untouched in all three cases, which is the
contract #5213 established and this change preserves.

The regression fixes the gap directly: an undeclared destination must fold, and
the role must still never be read from the hostname. The ordering suites declare
their destinations rather than asserting the default, because they are about
where a reminder sits, not which role carries it.

Co-authored-by: codex <codex@users.noreply.github.com>
…5335)

Co-authored-by: Flowershangfromthebranches <flowershangfromthebranches@users.noreply.github.com>
…veloper role (#5341)

#5334 folds the wire role unless a destination records acceptance. This vector is the one place that asserts the forwarded role, and it lives outside tests/, so the change missed it and dev went red with roles:value_mismatch.

Co-authored-by: codex <codex@users.noreply.github.com>
… the developer role (#5344)

Co-authored-by: codex <codex@users.noreply.github.com>
…developer role (#5346)

Co-authored-by: codex <codex@users.noreply.github.com>
lidge-jun and others added 16 commits September 21, 2026 15:32
…le publish path (#5405)

* feat(release): add the pre-publication asset verifier

The release pipeline verified checksums and generated the updater manifest
inside attach-release, a job that runs after publication and is skipped on
dry-run. The guarantee that gives is "packaging finished before publish";
the guarantee a release needs is "everything about to be published was
verified valid before publish".

desktop/scripts/verify-release-assets.ts is the verification authority.
It derives the expected platform file set from the release workflow's own
packaging matrices and the producer tables (build-standalone targets,
collect-release-assets bundle names), verifies every recorded checksum
against the bytes on disk with the bare-name rule the flat verification
directory requires, verifies every updater signature cryptographically
against the minisign public key pinned in tauri.conf.json (pure Ed25519
"Ed" mode, the form the Tauri bundler emits; the prehashed "ED" mode
fails loudly rather than mis-verifying), generates the updater manifest
and parses it back against the files it names, and writes a
machine-readable receipt that a later stage can require.

bundlesByTarget and platformFiles are exported from their owning scripts,
and the standalone target set, archive naming, and executable naming move
into scripts/standalone-targets.ts, which the builder and the verifier
share — a target added to one side without the other fails verification,
not the release. Unit tests in
release-desktop-scripts.test.ts cover the derivation against the real
workflow, checksum acceptance and the three refusal modes, signature
verification with real Ed25519 fixtures (tampered payload, foreign key
id, unsupported algorithm), and the full flow including the receipt.
They were reviewed statically and are first executed by hosted CI.

* feat(release): verify everything before publication and add a resumable publish path

The pipeline now has a verify-release job between packaging and
publication. It downloads the packaged artifacts, runs the verifier over
them — expected platform set, checksums, updater signatures, manifest
generation and parse-back — and publishes the verified bundle plus the
verification receipt. publish waits for verify-release instead of
verifying nothing, and attach-release downloads the verified bundle and
refuses to upload unless the receipt names this run's version and
commit. Checksum verification and latest.json generation moved out of
attach-release into verify-release, so they now run on dry-run too: a
dry run proves the same chain a real release relies on.

npm and GitHub are not published atomically, so a run that acknowledged
npm publication and failed afterwards needs a path that completes the
GitHub side without republishing. The new resume-after-npm-publish
dispatch input is that path: the preflight requires the version to
already exist on npm and refuses to combine with dry-run, the publish
step skips npm publish while still emitting the publication receipt the
downstream steps gate on, and release creation is idempotent so a
release left behind by the failed run is reused for attachment. A
successful publish records these recovery instructions in the job
summary at the moment they matter.

The workflow-contract tests assert the new ordering graph, the absence
of verification steps in attach-release, the receipt gate's ordering
before the upload, and the recovery branches; the publish-needs
assertion in ci-workflows.test.ts follows the new graph. Release
automation changed, so this carries the explicit security review the
repository requires: no permissions blocks change, no secrets are added
or re-scoped, and verification (commit 1) is reviewable separately from
publication ordering and the recovery input (this commit).
…lacement (#5406)

* fix(service): make ownership state crash-safe and consent-bound

Use one default-home authority with an active-home compatibility mirror, token/PID/process-instance locks, fsynced atomic replacement, mirror-first deletion, and authoritative recovery after partial commits.

Bind ownership grants to the exact approved owner/install/generation/revision and to re-observed managing-CLI compatibility. OpenCodex 2.60.x, unknown managers, and registrations without protocol 1 remain guests.

Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; the included regressions are for hosted CI.

* fix(update): fence replacement and restart with ownership leases

Split package replacement, runtime stop, and service restoration authority. Unknown and desktop ownership now block package replacement; Node and Bun read the same authoritative state observations.

Hold a shared mutation lease from final subject and liveness validation through replacement, and through dashboard restart. Direct bind takes the same lease, while repair children join by an exact live token.

Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier.

* docs(structure): define authoritative ownership and takeover compatibility

Record the authority/mirror commit protocol, consent subject precondition, managing-CLI compatibility floor, independent update authorities, and shared replacement/start lease.

Local structure checks were NOT RUN by instruction; hosted CI is the verifier.

* fix(service): recover incomplete locks without poisoning delegates

Reclaim empty or partial state locks only after the stale grace and dead-PID proof. Canonical delegated mutation tokens are consumed from child environments, cached only while the exact parent lease remains live, and discarded before fresh acquisition.

Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier.

* fix(service): make lease cleanup intent explicit

Keep token-specific stale recovery as the owner of uncertain descriptor, owner-file, directory, and release cleanup paths so deterministic hygiene accepts the deliberate best-effort boundaries.

Local checks were NOT RUN by instruction.

* fix(service): align typed evidence with shared record selector

Cast the service-owned evidence union at the shared plain-ESM selector boundary; both carry the same validated record shape, while TypeScript correctly rejects the missing index signature without the explicit boundary cast.

Local checks were NOT RUN by instruction; this fixes the exact hosted typecheck diagnostic.

* test: isolate corrupt authority and follow shared state paths

Reset the corrupt-authority fixture before exercising valid-authority mirror recovery, and point the updater source oracle at the shared active/default path resolver instead of an inlined filename literal.

Local tests were NOT RUN by instruction; this fixes the exact hosted shard failures.

* test(update): follow the reconciled install-state facade

Point the Node launcher, Bun state reader, and source oracle at the install-state-contract surface landed on dev, while keeping one state-record authority implementation underneath it.

Local checks were NOT RUN by instruction; this fixes the exact hosted shard diagnostic.
…5410)

On Linux, bun run build:local asked for appimage,deb in ONE tauri
invocation. When AppImage bundling failed (linuxdeploy missing a host
dependency), the invocation died and the deb was never attempted — a
contributor following the README got zero artifacts and an error that
named a tool they never invoked (observed on a real GNOME desktop,
devlog plan 260921 / 120_install_verification.md).

Each format now builds in its own invocation, every format is
attempted, and the summary reports each outcome beside the artifacts
that did build; the exit code is non-zero when any requested format
failed. A failing format is retried once with --verbose: at the
bundler's default log level the error is a bare "failed to run
linuxdeploy" with the tool's stderr discarded, and the verbose pass is
the branch where those diagnostics reach the terminal.

The release workflow builds its artifacts on its own runner image and
is untouched.
Co-authored-by: Ingwannu <ingwannu@users.noreply.github.com>
Co-authored-by: Ingwannu <ingwannu@users.noreply.github.com>
…ater table (#5425)

dev went red at the union of #5405 and #5391: lane F made the deb a second
Linux updater target, so the verifier's derived expected set gained
OpenCodex-<version>-linux-amd64.deb.sig, while the test's hand-written
oracle still described the earlier world where only the AppImage was
signed. Each branch was green alone; the merge was not.

The fix is derivation, not list-keeping. The signed set and the manifest
platform list in the fixture now come straight from platformFiles — the
table that decides which bundles carry the updater key — and the produced
payload list comes from the shared standalone target module and the bundle
table. A future updater target changes both sides of the assertion by
itself. The derivation test keeps its concrete payload anchors (a renamed
or dropped bundle should still fail for a human to review) and asserts the
rule instead of the roster: a bundle's signature is expected exactly when
the updater table names it.

Only the two test oracles changed; the verification ordering (checksums,
signatures and the manifest all precede publication) is untouched.
* test(service): assert ownership parser behavior

* fix(service): fence runtime start and stop ownership

* fix(update): hold runtime authority through replacement

* docs(runtime): record ownership mutation boundaries

* test(cli): follow transactional start prewarm

* test(cli): anchor fenced start refusal

* test(update): assert lock boundary behavior
…e does not fork execPath (#5418)

* fix(cli): probe endpoint liveness in-process so the standalone resolve does not fork execPath

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cli): prove absence on every loopback host and see refusals inside AggregateError

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: retrigger cross-platform run (macos 1/2 shard hit the 20-minute runner timeout)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cli): keep a mixed aggregate out of the absence proof

---------

Co-authored-by: jun <bitkyc08@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
#5445)

* fix(desktop): name the bootstrap script in the policy the webview is actually served

* fix(desktop): ship the startup surface as one page the policy can name
* feat(desktop): restore detailed usage in a compact tray popup

* fix(desktop): point the popup at the endpoint module this tree has

* fix(desktop): let the popup navigation gate read its own constants

* docs(devlog): record the tray usage popup as built and run on macOS

* fix(gui): clear the React Doctor findings in the tray popup page

The pull_request check runs react-doctor at blocking: warning with no comment,
review comment, or commit status, so nine findings failed the PR while being
readable only inside the run's job summary. Reproduced with the repository's own
pinned scan and fixed at the root rather than suppressed:

- import useI18n from i18n/shared instead of through the barrel
- hoist the hidden-provider and configured-model lookups into Sets in both the
  chart filter and filterUsage, keeping models === null as "all models"
- move the pure incomplete() helper to module scope
- key quota rows by an identity quotaWindows() now assigns

That identity also closes a bug the index key was hiding: a provider may report
two custom windows under one label, and the row key has to stay unique anyway or
React reconciles two different windows onto the same row.

The effect's post-await writes keep the single active() guard, which checks both
disposal and the AbortSignal. The rule cannot see the property through a helper,
so the finding carries a scoped suppression that states the evidence.

* feat(desktop): give the tray popup the native translucent surface

The popup sat next to the WidgetKit widget as a flat #202022 rectangle. macOS now
gets the active HUD window material at a 12-point radius and Windows gets Acrylic,
both through Tauri's own effects builder, so no new dependency is involved.

Linux stays opaque: blur there belongs to the compositor and the effects path does
not support it. That asymmetry is exactly where a transparent stylesheet would
paint a hole instead of a panel, so one cfg constant, VIBRANT_SURFACE, decides both
the native builder and the data-tray-vibrancy hook the page reads. The two cannot
disagree because neither restates the other.

Transparent windows on macOS require the macos-private-api feature and
app.macOSPrivateApi; that forecloses Mac App Store submission, which this
Developer ID DMG channel does not use.

Also restores rustfmt formatting in popup.rs. The desktop shell job failed on the
format step, which gates clippy and the Rust tests, so neither had run since the
popup landed on the branch.

* feat(gui): restyle the tray popup to the widget's vocabulary

The popup and the menu bar widget showed the same numbers in two different visual
languages. The page now uses the material behind a thin scrim, rounded tabular
numerals with the leading figure carrying each section, secondary-tone labels, and
separators only where the subject changes. The scrim is what keeps contrast stable
across wallpapers; without it a light desktop washes the labels out.

Every rule keeps an opaque default and layers the glass on top, so the stylesheet
is correct on a platform that never sets the hook. The appended duplicate rules
that had accumulated at the end of the file are folded into the blocks they were
overriding.

gui-tray-vibrancy-surface.test.ts reads both popup.rs and tray.css and fails if the
dataset flag and the attribute selector drift apart. Nothing in either toolchain
connects them, and a silent rename would either lose the glass or punch a hole.

* fix(gui): keep the quota row identity out of the hardcoded-string gate

The GUI lint rule reads a template literal's leading text as UI copy, so both
"custom:" and the key-shaped "quota.custom." failed the gates job. A custom window
is identified by its own label now, with no literal at all; the de-duplication pass
is what keeps that unique, including against the three fixed keys.

---------

Co-authored-by: Jyun1998 <yjunwoo14@gmail.com>
Co-authored-by: JayYun98 <JayYun98@users.noreply.github.com>
* fix(desktop): let the tray's left click reach the usage popup

Attaching a menu to a tray icon makes the left click open that menu, and the
builder never said otherwise. So on macOS and Windows the click never reached
on_tray_icon_event in any visible way: the icon showed the menu, and the menu item
that opens the popup is Linux-only. The popup #5452 added had no way to open at all
on the two platforms where the left click is the whole interaction.

Found by clicking it. The change reads correctly either way, which is why static
review kept missing it — the handler is there, the event fires, and the wrong
surface appears on top.

Linux keeps the default. Its StatusNotifier hosts deliver no usable click event, so
the menu is the entire interaction there and releasing it would remove the only way
in.

The pairing is now a test. Nothing in the type system connects .menu() to
show_menu_on_left_click(), and the failure is quiet, so tray.rs reads its own
production half at compile time and fails if a menu is attached without releasing
the click. It slices the source at the test attribute because the assertions quote
the call names they look for, and scanning the whole file would find the test's own
literals and keep passing after the real calls were gone.

* fix(desktop): give every platform a menu path to the usage popup

Releasing the left click is not enough on macOS, and the reason is upstream:
tray-icon assigns the menu to the NSStatusItem itself, so AppKit pops that menu on
mouse-down before the crate's own click handler runs. show_menu_on_left_click(false)
sets an ivar that handler reads, and the handler never gets the chance. Verified by
reading tray-icon 0.24.2's macOS implementation after a locally built bundle kept
showing the menu with the flag set.

So the icon click cannot be the only way in. The Show Usage item was Linux-only
because Linux hosts differ in whether a click reaches the application at all; that
same reasoning applies to macOS for a different cause, and it leaves Windows as the
only platform the icon alone would have served. The item is unconditional now.

The click release stays: it is correct on Windows, where it does what it says.

The guard covers the item too. Platform-gating it once already left two platforms
with no way to the popup, so the test now fails if any line that mentions the item
sits under a cfg attribute.

* fix(desktop): anchor the menu-opened popup on the tray icon

The menu handler passed a zero anchor, which popup::geometry clamps into the
top-left corner of the work area. That was tolerable while the item was a Linux
fallback; now that the menu is the ordinary way in on macOS, a window in the far
corner reads as misplaced rather than as a menu.

It reads the tray icon's rect and anchors on its centre. A host that cannot report
a rect still gets the clamped corner, which is the best answer available there.

* docs(devlog): record the glass popup opening from the tray menu

Captured from a locally built bundle on macOS, against a runtime started with an
isolated configuration home. The dashboard behind the panel shows through the
material at the left edge, and the window is anchored under the icon rather than
clamped into a corner.

* fix(desktop): satisfy clippy and stop the guard matching its own prose

Two things the format step had been hiding. It gates clippy and the Rust tests, so
neither had run on the popup since it landed.

clippy rejects the nested `if` inside the `Focused(false)` arm; it is a match guard
now, with the same behaviour.

The left-click guard matched the bare call name, and the comments above the menu
explain why that flag is inert on macOS — so the assertion found its own prose
earlier in the file than the builder and concluded the order was wrong. It matches
the call site now. The ordering comparison is gone: it asserted nothing the presence
of the call site does not already say.

cargo test --lib tray:: and popup:: pass locally.
* fix(desktop): make the startup surface unable to wait forever

On Linux the bootstrap window drew its six phase rows and then never changed
again: the headline stayed on the markup's default and every row stayed
pending, pixel-identical at 26s and 46s. The page's own handshake deadline is
5s and it never fired, so all three invokes answered — `apply` had been handed
a falsy progress and returned at its first line. Three things made that
reachable.

`startup_snapshot` could answer `None`. The page returns early on a falsy
progress, so the one case it cannot render, a shell with no startup state,
arrived as silence instead of as a diagnostic. It now always answers with a
state, and a missing startup state is reported as a failure the user can read
and copy.

`run` looked up `AppState` before publishing anything, so the sequence could
return having said nothing at all. The first report now happens before any
lookup that can fail.

Nothing enforced the 30-second ceiling. Several returns inside `run` report no
terminal state, and a step that outlives the deadline reports none either, so
the surface kept whatever it was last told for as long as the process lived. A
guard bound to the run now reports a terminal state when the run does not. It
is idempotent and generation-scoped, so it cannot overwrite a result the run
reported and one left over from an earlier run cannot fail the retry that
replaced it, and it waits a short grace past the ceiling so the run's own
failure — which names the endpoint, the home and how the child ended — is the
diagnostic on screen rather than the guard's thinner one.

`Phase::NotStarted` is new and deliberately absent from the checklist the page
draws. The state used to be seeded with `registering`, so a shell that never
began rendered exactly like one that had just begun, on the surface whose whole
job is to tell those apart.

* test(clients): anchor the run oracles on the name, not the signature

`desktop-cli-contracts` sliced the sequence at the literal
`async fn run(app: &AppHandle)`. Giving `run` its start instant changed that
string, `indexOf` answered -1, and `slice(-1)` returns the last character
rather than failing — so every index the case computes became -1 and the four
ordering assertions compared -1 against -1. It did not go quietly only because
the first one is `toBeGreaterThan(-1)`; the rest would have passed on an empty
string.

Both run oracles now anchor on the function name, which is what they are about,
and assert the anchor was found before slicing. A parameter added to the
sequence is not a change to the order these cases pin.
#5467)

* docs(devlog): land the app runtime ownership record and close the unit

The planning record for this batch lived only on an unmerged branch while every
lane it describes was already on dev. The decisions, the contradiction rounds, the
lane split and the re-audit are the reasons the code looks the way it does, so they
belong in the tree beside it. They carry no host detail; 040 says so explicitly,
and a sweep for addresses, mesh names, accounts and absolute user paths finds
nothing beyond loopback in technical prose.

150 records the outcome and the two things worth carrying forward: the single
constant that binds the native translucent surface to the page's CSS hook, and the
defect that no amount of reading would have found — tray-icon assigns the menu to
the NSStatusItem, so AppKit pops it before the crate's click handler runs, and the
popup had no way to open on macOS or Windows.

* docs(devlog): record that the privacy scan skips devlog-only commits

The scan lives in the gates job, which is gated on the ci paths filter, and that
allowlist has no devlog entry. So the one change class where reading devlog
matters is the one where the scan never runs, and the aggregate check goes green
anyway. This pull request is an instance: sixteen devlog files, proven by a hand
sweep rather than by the gate.
fix(google): preserve permission enum in normalized errors
…5490)

* docs(plan): define native tray and release verification cycles

* feat(desktop): add native SwiftUI tray presentation and glass surface

* feat(desktop): host macOS tray usage in a native Liquid Glass panel

* fix: close release regressions and align companion usage across native surfaces

* fix: preserve working integrations and resolve hosted platform regressions

* fix: report incomplete mode bookkeeping after a partial desktop switch

* fix: preserve rejected settings updates and finish platform shutdown repairs

* test: bind failure fixtures to actual IO and timeout boundaries

* test: isolate structure checks and align scaled watchdog ceilings

* test: drain native startup producers before ACL reap cleanup

* test: bound crash phases and settle cancelled stream fixtures

* test: bind stale status wording to its own probe result

* ci: bound unsharded macOS coverage with sequential fresh batches

* test: make Anthropic refusal activation independent of port reuse
@coderabbitai

coderabbitai Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 782 files, which is 482 over the limit of 300.

To get a review, reduce the PR to 300 files or fewer by splitting it into smaller PRs or changing its base branch.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration

Configuration used: Repository: lidge-jun/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b6824f8c-31d0-421a-b59f-05b19240e172

📥 Commits

Reviewing files that changed from the base of the PR and between 7c625fc and 2dec4be.

⛔ Files ignored due to path filters (41)
  • .github/pr-assets/260921-lane-d-consent-surface.png is excluded by !**/*.png
  • .github/pr-assets/opencodex-cache-usage.png is excluded by !**/*.png
  • assets/pr-screenshots/app-icon-finder-menubar.png is excluded by !**/*.png
  • assets/pr-screenshots/favicon-browser-tab.png is excluded by !**/*.png
  • assets/pr-screenshots/favicon-light-tab.png is excluded by !**/*.png
  • desktop/src-tauri/Cargo.lock is excluded by !**/*.lock
  • desktop/src-tauri/icons/128x128.png is excluded by !**/*.png
  • desktop/src-tauri/icons/128x128@2x.png is excluded by !**/*.png
  • desktop/src-tauri/icons/32x32.png is excluded by !**/*.png
  • desktop/src-tauri/icons/64x64.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square107x107Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square142x142Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square150x150Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square284x284Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square30x30Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square310x310Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square44x44Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square71x71Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/Square89x89Logo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/StoreLogo.png is excluded by !**/*.png
  • desktop/src-tauri/icons/icon.ico is excluded by !**/*.ico
  • desktop/src-tauri/icons/icon.png is excluded by !**/*.png
  • desktop/src-tauri/icons/icon.svg is excluded by !**/*.svg
  • desktop/src-tauri/icons/tray/icon.png is excluded by !**/*.png
  • desktop/src-tauri/icons/tray/icon.svg is excluded by !**/*.svg
  • devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png is excluded by !**/*.png
  • devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png is excluded by !**/*.png
  • devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png is excluded by !**/*.png
  • devlog/_plan/260921_app_runtime_ownership/tray-usage-popup-glass.png is excluded by !**/*.png
  • devlog/_plan/260921_app_runtime_ownership/tray-usage-popup.png is excluded by !**/*.png
  • devlog/_plan/260922_native_tray_release/evidence/native-glass-bottom.png is excluded by !**/*.png
  • devlog/_plan/260922_native_tray_release/evidence/native-glass-top.png is excluded by !**/*.png
  • devlog/_plan/260922_native_tray_release/evidence/native-panel-bottom.png is excluded by !**/*.png
  • devlog/_plan/260922_native_tray_release/evidence/native-panel-dark.png is excluded by !**/*.png
  • devlog/_plan/260922_native_tray_release/evidence/native-panel-light.png is excluded by !**/*.png
  • devlog/_plan/260922_native_tray_release/evidence/web-tray-opaque-bottom.png is excluded by !**/*.png
  • devlog/_plan/260922_native_tray_release/evidence/web-tray-vibrant-bottom.png is excluded by !**/*.png
  • docs-site/public/favicon.ico is excluded by !**/*.ico
  • docs-site/public/favicon.png is excluded by !**/*.png
  • gui/public/favicon.png is excluded by !**/*.png
  • gui/public/provider-icons/stepfun-color.svg is excluded by !**/*.svg
📒 Files selected for processing (782)
  • .github/workflows/ci.yml
  • .github/workflows/desktop-installed-gate.yml
  • .github/workflows/release.yml
  • .github/workflows/service-lifecycle.yml
  • .gitignore
  • AGENTS.md
  • AGENTS_INSTALL.md
  • README.md
  • app/.gitignore
  • app/Package.swift
  • app/Sources/MenuBarCore/ActionCoordinator.swift
  • app/Sources/MenuBarCore/CompanionSettings.swift
  • app/Sources/MenuBarCore/CompanionUsage.swift
  • app/Sources/MenuBarCore/Discovery.swift
  • app/Sources/MenuBarCore/Formatting.swift
  • app/Sources/MenuBarCore/Keychain.swift
  • app/Sources/MenuBarCore/MenuBarTitle.swift
  • app/Sources/MenuBarCore/PollingCoordinator.swift
  • app/Sources/MenuBarCore/ProxyClient.swift
  • app/Sources/MenuBarCore/ProxyModels.swift
  • app/Sources/MenuBarCore/ProxySnapshot.swift
  • app/Sources/MenuBarCore/UsageTimeline.swift
  • app/Sources/MenuBarCore/WidgetSnapshot.swift
  • app/Sources/MenuBarCoreTests/ActionSuite.swift
  • app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift
  • app/Sources/MenuBarCoreTests/DiscoverySuite.swift
  • app/Sources/MenuBarCoreTests/FormattingSuite.swift
  • app/Sources/MenuBarCoreTests/Harness.swift
  • app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift
  • app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift
  • app/Sources/MenuBarCoreTests/PollingSuite.swift
  • app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift
  • app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift
  • app/Sources/MenuBarCoreTests/TransportSuite.swift
  • app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift
  • app/Sources/MenuBarCoreTests/main.swift
  • app/Sources/NativeTray/Models.swift
  • app/Sources/NativeTray/Panel.swift
  • app/Sources/NativeTray/Popover.swift
  • app/Sources/NativeTray/Surface.swift
  • app/Sources/NativeTray/UsageSections.swift
  • app/Sources/NativeTray/UsageView.swift
  • app/Sources/NativeTrayTests/main.swift
  • app/Sources/OpenCodexWidget/Provider.swift
  • app/Sources/OpenCodexWidget/SnapshotReader.swift
  • app/Sources/OpenCodexWidget/Views.swift
  • app/Widget-Info.plist
  • app/Widget.entitlements
  • bin/ocx.mjs
  • desktop/README.md
  • desktop/package.json
  • desktop/scripts/build-local.ts
  • desktop/scripts/build-widget.sh
  • desktop/scripts/collect-release-assets.ts
  • desktop/scripts/generate-icons.ts
  • desktop/scripts/installed-gate-platforms.ts
  • desktop/scripts/installed-gate.ts
  • desktop/scripts/prepare-sidecar.ts
  • desktop/scripts/updater-manifest.ts
  • desktop/scripts/verify-macos-runtime.sh
  • desktop/scripts/verify-release-assets.ts
  • desktop/src-tauri/Cargo.toml
  • desktop/src-tauri/Entitlements.plist
  • desktop/src-tauri/build.rs
  • desktop/src-tauri/capabilities/default.json
  • desktop/src-tauri/icons/icon.icns
  • desktop/src-tauri/src/auth.rs
  • desktop/src-tauri/src/companion_query.rs
  • desktop/src-tauri/src/companion_usage.rs
  • desktop/src-tauri/src/endpoint.rs
  • desktop/src-tauri/src/exit.rs
  • desktop/src-tauri/src/first_run.rs
  • desktop/src-tauri/src/formatting.rs
  • desktop/src-tauri/src/identity.rs
  • desktop/src-tauri/src/lib.rs
  • desktop/src-tauri/src/logging.rs
  • desktop/src-tauri/src/main.rs
  • desktop/src-tauri/src/menu.rs
  • desktop/src-tauri/src/native_tray.rs
  • desktop/src-tauri/src/native_tray_accounts.rs
  • desktop/src-tauri/src/native_tray_data.rs
  • desktop/src-tauri/src/native_tray_snapshot.rs
  • desktop/src-tauri/src/ownership.rs
  • desktop/src-tauri/src/popup.rs
  • desktop/src-tauri/src/proxy.rs
  • desktop/src-tauri/src/resolve.rs
  • desktop/src-tauri/src/runtime_stop.rs
  • desktop/src-tauri/src/sidecar.rs
  • desktop/src-tauri/src/startup.rs
  • desktop/src-tauri/src/tray.rs
  • desktop/src-tauri/src/tray_availability.rs
  • desktop/src-tauri/src/updater.rs
  • desktop/src-tauri/src/widget.rs
  • desktop/src-tauri/src/window.rs
  • desktop/src-tauri/tauri.conf.json
  • desktop/ui/index.html
  • devlog/_fin/260725_macos_menubar_app/003_design_read.md
  • devlog/_fin/260725_macos_menubar_app/010_phase1_core.md
  • devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md
  • devlog/_fin/260725_macos_menubar_app/051_feature_summary.md
  • devlog/_fin/260921_brand_icon_and_menu_bar_mark/000_plan.md
  • devlog/_fin/260921_brand_icon_and_menu_bar_mark/010_favicons.md
  • devlog/_fin/260921_brand_icon_and_menu_bar_mark/020_closure.md
  • devlog/_fin/260921_cross_path_contract/000_plan.md
  • devlog/_fin/260921_cross_path_contract/010_lane_boundaries.md
  • devlog/_fin/260921_cross_path_contract/090_outcome.md
  • devlog/_fin/260921_cross_path_contract/100_verification.md
  • devlog/_fin/260921_remaining_seams/000_plan.md
  • devlog/_fin/260921_remaining_seams/090_outcome.md
  • devlog/_plan/260920_desktop_app_stabilization/000_local_build.md
  • devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md
  • devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md
  • devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md
  • devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md
  • devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md
  • devlog/_plan/260920_desktop_app_stabilization/050_landing.md
  • devlog/_plan/260920_meaning_preservation_batch/000_plan.md
  • devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md
  • devlog/_plan/260920_meaning_preservation_batch/010_phase2.md
  • devlog/_plan/260920_meaning_preservation_batch/020_incident_5261.md
  • devlog/_plan/260920_meaning_preservation_batch/020_lane_c.md
  • devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md
  • devlog/_plan/260920_meaning_preservation_batch/020_lane_h_codex_signin_lockout.md
  • devlog/_plan/260920_meaning_preservation_batch/030_lane_b_transport_write_safety.md
  • devlog/_plan/260920_meaning_preservation_batch/030_lane_c2.md
  • devlog/_plan/260920_meaning_preservation_batch/030_lane_h2_lockout_dead_ends.md
  • devlog/_plan/260920_meaning_preservation_batch/050_lane_d_account_cache_generation.md
  • devlog/_plan/260920_meaning_preservation_batch/050_lane_e.md
  • devlog/_plan/260920_meaning_preservation_batch/060_lane_f.md
  • devlog/_plan/260920_meaning_preservation_batch/060_lane_i.md
  • devlog/_plan/260920_regression_release/010_execution.md
  • devlog/_plan/260920_round2_followups/000_plan.md
  • devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md
  • devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md
  • devlog/_plan/260920_round2_followups/030_lane_r3.md
  • devlog/_plan/260920_round2_followups/040_r4_retry_rework.md
  • devlog/_plan/260920_round2_followups/050_lane_r5.md
  • devlog/_plan/260920_round2_followups/050_r4_remainders.md
  • devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md
  • devlog/_plan/260920_round2_followups/070_widget_signing.md
  • devlog/_plan/260920_round2_followups/090_closeout.md
  • devlog/_plan/260921_app_runtime_ownership/000_charter.md
  • devlog/_plan/260921_app_runtime_ownership/010_coexistence_findings.md
  • devlog/_plan/260921_app_runtime_ownership/020_windows_linux_findings.md
  • devlog/_plan/260921_app_runtime_ownership/030_contradictions.md
  • devlog/_plan/260921_app_runtime_ownership/040_verification_hosts.md
  • devlog/_plan/260921_app_runtime_ownership/050_webview_dialogs.md
  • devlog/_plan/260921_app_runtime_ownership/060_contradictions_round2.md
  • devlog/_plan/260921_app_runtime_ownership/070_decisions.md
  • devlog/_plan/260921_app_runtime_ownership/080_decisions_round2.md
  • devlog/_plan/260921_app_runtime_ownership/090_lanes.md
  • devlog/_plan/260921_app_runtime_ownership/100_resolutions.md
  • devlog/_plan/260921_app_runtime_ownership/110_reaudit.md
  • devlog/_plan/260921_app_runtime_ownership/120_gate_runbook.md
  • devlog/_plan/260921_app_runtime_ownership/120_install_verification.md
  • devlog/_plan/260921_app_runtime_ownership/130_linux_surface_findings.md
  • devlog/_plan/260921_app_runtime_ownership/140_tray_popup_polish.md
  • devlog/_plan/260921_app_runtime_ownership/150_closeout.md
  • devlog/_plan/260922_native_tray_release/000_plan.md
  • devlog/_plan/260922_native_tray_release/001_evidence.md
  • devlog/_plan/260922_native_tray_release/002_plan_review.md
  • devlog/_plan/260922_native_tray_release/010_native_presentation.md
  • devlog/_plan/260922_native_tray_release/011_presentation_verification.md
  • devlog/_plan/260922_native_tray_release/020_shell_integration.md
  • devlog/_plan/260922_native_tray_release/021_integration_verification.md
  • devlog/_plan/260922_native_tray_release/030_regression.md
  • devlog/_plan/260922_native_tray_release/031_verification.md
  • devlog/_plan/260922_native_tray_release/040_release.md
  • docs-site/astro.config.mjs
  • docs-site/src/content/docs/fr/getting-started/quickstart.md
  • docs-site/src/content/docs/fr/guides/claude-code.md
  • docs-site/src/content/docs/fr/guides/codex-integration.md
  • docs-site/src/content/docs/fr/guides/integrations.md
  • docs-site/src/content/docs/fr/guides/providers.md
  • docs-site/src/content/docs/fr/reference/cli/lifecycle.md
  • docs-site/src/content/docs/fr/reference/configuration/providers.md
  • docs-site/src/content/docs/getting-started/installation.md
  • docs-site/src/content/docs/getting-started/quickstart.md
  • docs-site/src/content/docs/guides/claude-code.md
  • docs-site/src/content/docs/guides/codex-integration.md
  • docs-site/src/content/docs/guides/desktop-app.md
  • docs-site/src/content/docs/guides/integrations.md
  • docs-site/src/content/docs/guides/macos-menu-bar.md
  • docs-site/src/content/docs/guides/pi.md
  • docs-site/src/content/docs/guides/providers.md
  • docs-site/src/content/docs/guides/web-dashboard.md
  • docs-site/src/content/docs/ja/getting-started/installation.md
  • docs-site/src/content/docs/ja/getting-started/quickstart.md
  • docs-site/src/content/docs/ja/guides/claude-code.md
  • docs-site/src/content/docs/ja/guides/codex-integration.md
  • docs-site/src/content/docs/ja/guides/macos-menu-bar.md
  • docs-site/src/content/docs/ja/guides/providers.md
  • docs-site/src/content/docs/ja/reference/cli/lifecycle.md
  • docs-site/src/content/docs/ja/reference/configuration/providers.md
  • docs-site/src/content/docs/ko/getting-started/installation.md
  • docs-site/src/content/docs/ko/getting-started/quickstart.md
  • docs-site/src/content/docs/ko/guides/claude-code.md
  • docs-site/src/content/docs/ko/guides/codex-integration.md
  • docs-site/src/content/docs/ko/guides/macos-menu-bar.md
  • docs-site/src/content/docs/ko/guides/providers.md
  • docs-site/src/content/docs/ko/reference/cli/lifecycle.md
  • docs-site/src/content/docs/ko/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/cli/lifecycle.md
  • docs-site/src/content/docs/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/configuration/server.md
  • docs-site/src/content/docs/reference/management-api.md
  • docs-site/src/content/docs/ru/getting-started/installation.md
  • docs-site/src/content/docs/ru/getting-started/quickstart.md
  • docs-site/src/content/docs/ru/guides/claude-code.md
  • docs-site/src/content/docs/ru/guides/codex-integration.md
  • docs-site/src/content/docs/ru/guides/macos-menu-bar.md
  • docs-site/src/content/docs/ru/guides/providers.md
  • docs-site/src/content/docs/ru/reference/cli/lifecycle.md
  • docs-site/src/content/docs/ru/reference/configuration/providers.md
  • docs-site/src/content/docs/tr/getting-started/quickstart.md
  • docs-site/src/content/docs/tr/guides/claude-code.md
  • docs-site/src/content/docs/tr/guides/codex-integration.md
  • docs-site/src/content/docs/tr/guides/integrations.md
  • docs-site/src/content/docs/tr/guides/providers.md
  • docs-site/src/content/docs/tr/reference/cli/lifecycle.md
  • docs-site/src/content/docs/tr/reference/configuration/providers.md
  • docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md
  • docs-site/src/content/docs/zh-cn/getting-started/installation.md
  • docs-site/src/content/docs/zh-cn/getting-started/quickstart.md
  • docs-site/src/content/docs/zh-cn/guides/claude-code.md
  • docs-site/src/content/docs/zh-cn/guides/codex-integration.md
  • docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md
  • docs-site/src/content/docs/zh-cn/guides/providers.md
  • docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-tw/getting-started/quickstart.md
  • docs-site/src/content/docs/zh-tw/guides/claude-code.md
  • docs-site/src/content/docs/zh-tw/guides/codex-integration.md
  • docs-site/src/content/docs/zh-tw/guides/integrations.md
  • docs-site/src/content/docs/zh-tw/guides/providers.md
  • docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md
  • docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
  • docs/design-system/foundations.md
  • gui/src/App.tsx
  • gui/src/action-dialogs.ts
  • gui/src/codex-restart.ts
  • gui/src/components/CodexAccountPool.tsx
  • gui/src/components/CompactionRoutingPanel.tsx
  • gui/src/components/MemoryObservabilityCard.tsx
  • gui/src/components/codex-account-pool-main-card.tsx
  • gui/src/components/codex-account-pool-types.ts
  • gui/src/components/provider-workspace/ProviderModels.tsx
  • gui/src/components/provider-workspace/ProviderSettings.tsx
  • gui/src/credential-alias.ts
  • gui/src/hooks/useCodexAccountPool.ts
  • gui/src/hooks/useProviderAccountPools.ts
  • gui/src/i18n/de.ts
  • gui/src/i18n/en.ts
  • gui/src/i18n/fr.ts
  • gui/src/i18n/ja.ts
  • gui/src/i18n/ko.ts
  • gui/src/i18n/ru.ts
  • gui/src/i18n/tr.ts
  • gui/src/i18n/vi.ts
  • gui/src/i18n/zh-TW.ts
  • gui/src/i18n/zh.ts
  • gui/src/lib/desktop-shell.ts
  • gui/src/main.tsx
  • gui/src/pages/ClaudeDesktop.tsx
  • gui/src/pages/Logs.tsx
  • gui/src/pages/Models.tsx
  • gui/src/pages/RemoteWorkspace.tsx
  • gui/src/pages/RoutingProfiles.tsx
  • gui/src/pages/Tray.tsx
  • gui/src/pages/Usage.tsx
  • gui/src/pages/integrations/ConsequenceDialog.tsx
  • gui/src/pages/integrations/FileIntegrationPage.tsx
  • gui/src/pages/integrations/IntegrationPlanDetails.tsx
  • gui/src/pages/integrations/integration-api.ts
  • gui/src/pages/models-alias-editing.ts
  • gui/src/pages/startup-sections.tsx
  • gui/src/pages/tray-data.ts
  • gui/src/pages/tray.css
  • gui/src/pages/usage-companion-chart.tsx
  • gui/src/pages/usage-companion-panel.tsx
  • gui/src/pages/usage-companion-utils.ts
  • gui/src/provider-icons.ts
  • gui/src/stop-proxy.ts
  • gui/src/styles-usage-workspace.css
  • gui/src/styles.css
  • gui/src/styles/claude-desktop-mode-picker.css
  • gui/src/styles/provider-workspace-settings.css
  • gui/src/use-codex-restart.ts
  • gui/tests/action-dialogs.test.ts
  • gui/tests/app-stop.test.ts
  • gui/tests/claude-desktop-mode-picker.test.tsx
  • gui/tests/codex-account-pool-controller.test.ts
  • gui/tests/codex-account-pool-stale-refresh.test.tsx
  • gui/tests/codex-account-pool-toast-tone.test.tsx
  • gui/tests/codex-restart.test.ts
  • gui/tests/codex-stale-banner-dom.test.tsx
  • gui/tests/compaction-routing-panel.test.tsx
  • gui/tests/desktop-shell.test.ts
  • gui/tests/fr-localization.test.ts
  • gui/tests/helpers/action-dialog.ts
  • gui/tests/locale-parity.test.ts
  • gui/tests/memory-observability-card.test.tsx
  • gui/tests/provider-model-management.test.tsx
  • gui/tests/provider-settings-account-mode.test.tsx
  • gui/tests/tray-data.test.ts
  • gui/tests/ui-font-fallback.test.ts
  • gui/tests/usage-companion-utils.test.ts
  • gui/tests/usage-custom-range.test.tsx
  • gui/tests/usage-layout.test.ts
  • package.json
  • readme/README.fr.md
  • readme/README.ja.md
  • readme/README.ko.md
  • readme/README.ru.md
  • readme/README.tr.md
  • readme/README.zh-CN.md
  • readme/README.zh-TW.md
  • readme/i18n-manifest.json
  • scripts/brand-favicons.ts
  • scripts/build-standalone.ts
  • scripts/ci/run-bun-test-batches.sh
  • scripts/lib/icon-render.ts
  • scripts/privacy-scan.ts
  • scripts/standalone-targets.ts
  • scripts/test-layout/layout.json
  • scripts/test.ts
  • scripts/verify-release-resume.ts
  • skills/ocx/references/01_management_surface.md
  • src/adapters/anthropic.ts
  • src/adapters/coding-agent/protocol.ts
  • src/adapters/coding-agent/turn.ts
  • src/adapters/command-code.ts
  • src/adapters/cursor.ts
  • src/adapters/cursor/catalog.ts
  • src/adapters/cursor/protobuf-request.ts
  • src/adapters/cursor/request-builder.ts
  • src/adapters/declaration-carrier.ts
  • src/adapters/devin.ts
  • src/adapters/google-antigravity-wire.ts
  • src/adapters/google-errors.ts
  • src/adapters/google.ts
  • src/adapters/image.ts
  • src/adapters/input-media-guard.ts
  • src/adapters/kiro-tool-fallback.ts
  • src/adapters/kiro/usage.ts
  • src/adapters/ollama-native.ts
  • src/adapters/openai-chat.ts
  • src/adapters/openai-chat/developer-role.ts
  • src/adapters/openai-chat/messages.ts
  • src/adapters/openai-chat/parallel-tool-calls.ts
  • src/adapters/openai-chat/passthrough.ts
  • src/adapters/openai-chat/reasoning-wire.ts
  • src/adapters/openai-responses/passthrough.ts
  • src/adapters/registry.ts
  • src/adapters/run-turn-queue.ts
  • src/adapters/xai-web-search.ts
  • src/bridge/errors.ts
  • src/bridge/response-json.ts
  • src/bridge/sse.ts
  • src/chat/inbound.ts
  • src/claude/desktop-3p.ts
  • src/claude/desktop-first-party.ts
  • src/claude/desktop-gateway-state.ts
  • src/claude/inbound-content-options.ts
  • src/claude/inbound.ts
  • src/claude/intercept/connect-proxy.ts
  • src/claude/intercept/listener.ts
  • src/claude/intercept/local-ca.ts
  • src/claude/intercept/runtime.ts
  • src/claude/intercept/settings.ts
  • src/cli/access.ts
  • src/cli/account-auth.ts
  • src/cli/capabilities.ts
  • src/cli/claude-desktop.ts
  • src/cli/codex-shim-autorestore.ts
  • src/cli/companion.ts
  • src/cli/dispatch.ts
  • src/cli/ensure-desired-integrations.ts
  • src/cli/help.ts
  • src/cli/index.ts
  • src/cli/init.ts
  • src/cli/integrations.ts
  • src/cli/registry.ts
  • src/cli/resolve.ts
  • src/cli/root.ts
  • src/cli/start-ownership-publication.ts
  • src/cli/status-probes.ts
  • src/cli/status.ts
  • src/cli/stop-report.ts
  • src/cli/uninstall-plan.ts
  • src/client/machine-listener.ts
  • src/clients/aside-profiles.ts
  • src/clients/config-export.ts
  • src/clients/config-export/zcode-store.ts
  • src/codex/app-server-processes.ts
  • src/codex/auth-api/login-flow.ts
  • src/codex/autostart-health.ts
  • src/codex/catalog/build-entries.ts
  • src/codex/catalog/effort.ts
  • src/codex/catalog/provider-models.ts
  • src/codex/catalog/retained-sync.ts
  • src/codex/convergence.ts
  • src/codex/history-provider.ts
  • src/codex/inject.ts
  • src/codex/inject/config-toml.ts
  • src/codex/inject/paginated-openai-compat.ts
  • src/codex/injected-marker.ts
  • src/codex/main-account.ts
  • src/codex/model-cache.ts
  • src/codex/model-entitlement-admission.ts
  • src/codex/model-entitlements.ts
  • src/codex/native-main-admission.ts
  • src/codex/routing.ts
  • src/codex/routing/health-store.ts
  • src/codex/routing/selection.ts
  • src/codex/shim-templates.ts
  • src/companion/settings.ts
  • src/config.ts
  • src/config/atomic-write.ts
  • src/config/load-degrade.ts
  • src/config/process-state.ts
  • src/config/schema/config-schema.ts
  • src/config/schema/leaf-validators.ts
  • src/integrations/config-io.ts
  • src/integrations/merge.ts
  • src/integrations/mutation-plan.ts
  • src/integrations/registry.ts
  • src/integrations/state.ts
  • src/integrations/target.ts
  • src/integrations/writer.ts
  • src/lab/conformance/fixture-provider.ts
  • src/lib/browser-launch-notice.ts
  • src/lib/bun-runtime.ts
  • src/lib/debug.ts
  • src/lib/open-url.ts
  • src/lib/package-tree-integrity.ts
  • src/lib/package-version.ts
  • src/lib/provider-egress.ts
  • src/lib/provider-outbound.ts
  • src/lib/proxy-env.ts
  • src/lib/request-execution-budget.ts
  • src/lib/request-failure-attribution.ts
  • src/lib/request-failure-model.ts
  • src/lib/request-resend-gate.ts
  • src/lib/standalone.ts
  • src/lib/upstream-retry.ts
  • src/lib/winsw.ts
  • src/oauth/index.ts
  • src/oauth/login-cli.ts
  • src/providers/api-key-resolve.ts
  • src/providers/api-key-selection.ts
  • src/providers/key-failover.ts
  • src/providers/key-store.ts
  • src/providers/model-rename-fields.ts
  • src/providers/model-rename-migration.ts
  • src/providers/quota/vendor-probes-key.ts
  • src/providers/reasoning-metadata.ts
  • src/providers/registry/entries-core.ts
  • src/providers/registry/entries-extended.ts
  • src/providers/registry/model-seeds.ts
  • src/providers/xai-transport.ts
  • src/reasoning-effort.ts
  • src/responses/function-call-compat.ts
  • src/responses/inline-document.ts
  • src/responses/input-media.ts
  • src/responses/muse-tool-name-alias.ts
  • src/responses/parser-content.ts
  • src/responses/parser-tools.ts
  • src/responses/parser.ts
  • src/responses/schema.ts
  • src/router.ts
  • src/server/admission-model-scope.ts
  • src/server/audio-live.ts
  • src/server/audio-upstream.ts
  • src/server/auth-cors.ts
  • src/server/chat-completions.ts
  • src/server/chat-native.ts
  • src/server/claude-messages.ts
  • src/server/grok-responses-snapshot-repair.ts
  • src/server/gui-freshness.ts
  • src/server/gui-static.ts
  • src/server/images.ts
  • src/server/index.ts
  • src/server/index/claude-intercept-lifecycle.ts
  • src/server/index/serve-options.ts
  • src/server/index/spend-ledger-lifecycle.ts
  • src/server/index/startup-warnings.ts
  • src/server/lifecycle.ts
  • src/server/live-call-bindings.ts
  • src/server/live.ts
  • src/server/management-api.ts
  • src/server/management/agent-settings-routes.ts
  • src/server/management/companion-routes.ts
  • src/server/management/logs-usage-routes.ts
  • src/server/management/native-integration-routes.ts
  • src/server/management/oauth-account-routes.ts
  • src/server/management/route-registry.ts
  • src/server/management/shared.ts
  • src/server/management/usage-timeline-routes.ts
  • src/server/proxy-liveness.ts
  • src/server/relay.ts
  • src/server/request-log-failure-attribution.ts
  • src/server/request-log.ts
  • src/server/request-metrics.ts
  • src/server/responses-request-tool-scope.ts
  • src/server/responses-undeclared-tool-guard.ts
  • src/server/responses/codex-ws-wire.ts
  • src/server/responses/combo-stream-preflight.ts
  • src/server/responses/compact.ts
  • src/server/responses/core-opaque-recovery.ts
  • src/server/responses/fetch-helpers.ts
  • src/server/responses/input-admission.ts
  • src/server/responses/passthrough-delivery.ts
  • src/server/responses/passthrough-dispatch.ts
  • src/server/responses/passthrough-error.ts
  • src/server/responses/request-prepare.ts
  • src/server/responses/request-send-budget.ts
  • src/server/responses/request-transport.ts
  • src/server/responses/reset-replay.ts
  • src/server/search.ts
  • src/server/usage-ledger-retention.ts
  • src/service.ts
  • src/service/cli.ts
  • src/service/health.ts
  • src/service/install-state-contract.d.mts
  • src/service/install-state-contract.mjs
  • src/service/launchd.ts
  • src/service/orchestration.ts
  • src/service/ownership-compatibility.ts
  • src/service/ownership-mutation-lease.d.mts
  • src/service/ownership-mutation-lease.mjs
  • src/service/repair.ts
  • src/service/state-lock.ts
  • src/service/state-record.d.mts
  • src/service/state-record.mjs
  • src/service/state.ts
  • src/service/windows-taskxml.ts
  • src/tray/windows-tray.ps1
  • src/types.ts
  • src/types/config.ts
  • src/types/provider.ts
  • src/types/request.ts
  • src/types/tools.ts
  • src/update/index.ts
  • src/update/job.ts
  • src/update/ownership-transaction.ts
  • src/update/restart-ownership.ts
  • src/update/runtime-ownership.d.mts
  • src/update/runtime-ownership.mjs
  • src/usage/attempt-delivery.ts
  • src/usage/cache-diagnostic.ts
  • src/usage/failure-fingerprint.ts
  • src/usage/failure-projection-cache.ts
  • src/usage/failure-projection.ts
  • src/usage/ledger-retention.ts
  • src/usage/log.ts
  • src/usage/request-outcome.ts
  • src/usage/retention-contract.ts
  • src/usage/summary.ts
  • src/usage/telemetry-contract.ts
  • src/usage/timeline.ts
  • src/web-search/alpha-search.ts
  • structure/INDEX.md
  • structure/adapters/registry.md
  • structure/catalog.md
  • structure/clients/claude-desktop.md
  • structure/clients/integrations.md
  • structure/codex-home.md
  • structure/companion.md
  • structure/config.md
  • structure/data-planes/images.md
  • structure/data-planes/inbound-compat.md
  • structure/data-planes/search.md
  • structure/decisions/ADR-0097-post-write-app-server-freshness.md
  • structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md
  • structure/desktop-shell.md
  • structure/gui-and-management-api.md
  • structure/manifest.json
  • structure/ops/docs-and-release.md
  • structure/ops/service-and-sidecars.md
  • structure/overview.md
  • structure/providers-and-adapters.md
  • structure/providers/chat-compat.md
  • structure/providers/cursor.md
  • structure/providers/openai-tiers.md
  • structure/runtime.md
  • structure/transports/inventory.md
  • structure/transports/responses.md
  • tests/adapters/adapter-input-media-guard.test.ts
  • tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts
  • tests/adapters/bridge-lifecycle.test.ts
  • tests/adapters/google/google-errors.test.ts
  • tests/adapters/google/google-strict-tool-validated-mode.test.ts
  • tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts
  • tests/adapters/openai/openai-chat-developer-position.test.ts
  • tests/adapters/openai/openai-chat-reasoning-wire-policy.test.ts
  • tests/adapters/openai/openai-chat-system-order.test.ts
  • tests/adapters/run-turn-queue.test.ts
  • tests/ci-workflows/brand-favicons.test.ts
  • tests/ci-workflows/build-desktop-icon-set.test.ts
  • tests/ci-workflows/ci-bun-crash-classifier.test.ts
  • tests/ci-workflows/ci-crash-disposition.test.ts
  • tests/ci-workflows/ci-structure-gate.test.ts
  • tests/ci-workflows/ci-workflows.test.ts
  • tests/ci-workflows/cold-spawn-warmup.test.ts
  • tests/ci-workflows/docs-developer-role-policy.test.ts
  • tests/ci-workflows/docs-provider-discovery-limits.test.ts
  • tests/ci-workflows/installed-gate-drivers.test.ts
  • tests/ci-workflows/macos-serial-lanes.test.ts
  • tests/ci-workflows/privacy-scan-asset-names.test.ts
  • tests/ci-workflows/release-desktop-scripts.test.ts
  • tests/ci-workflows/release-pipeline-contract.test.ts
  • tests/ci-workflows/release-resume-identity.test.ts
  • tests/ci-workflows/test-runner.test.ts
  • tests/claude-integration/claude-desktop-config-path.test.ts
  • tests/claude-integration/claude-desktop-first-party.test.ts
  • tests/claude-integration/claude-desktop-mode-explanation.test.ts
  • tests/claude-integration/claude-inbound.test.ts
  • tests/claude-integration/claude-intercept-local-ca.test.ts
  • tests/claude-integration/claude-intercept-proxy.test.ts
  • tests/claude-integration/claude-intercept-settings.test.ts
  • tests/claude-integration/claude-management-api.test.ts
  • tests/claude-integration/claude-messages-endpoint.test.ts
  • tests/claude-integration/claude-models-discovery.test.ts
  • tests/claude-integration/claude-source-envelope.test.ts
  • tests/cli/cli-catalog-prewarm.test.ts
  • tests/cli/cli-companion.test.ts
  • tests/cli/cli-dispatch.test.ts
  • tests/cli/cli-effort.test.ts
  • tests/cli/cli-headless-parity.test.ts
  • tests/cli/cli-ready.test.ts
  • tests/cli/cli-resolve-subprocess.test.ts
  • tests/cli/cli-resolve.test.ts
  • tests/cli/cli-restore-back.test.ts
  • tests/cli/cli-status-json.test.ts
  • tests/cli/cli-stop-json.test.ts
  • tests/cli/start-ownership-publication.test.ts
  • tests/cli/uninstall.test.ts
  • tests/clients/cline-writer.test.ts
  • tests/clients/desktop-cli-contracts.test.ts
  • tests/clients/desktop-exit-ownership.test.ts
  • tests/clients/desktop-install-identity.test.ts
  • tests/clients/desktop-proxy-direct-transport.test.ts
  • tests/clients/desktop-runtime-identity.test.ts
  • tests/clients/desktop-start-at-login-default.test.ts
  • tests/clients/desktop-startup-surface.test.ts
  • tests/clients/desktop-tray-availability.test.ts
  • tests/clients/desktop-widget-entry.test.ts
  • tests/clients/integrations-current-store.test.ts
  • tests/clients/integrations-merge.test.ts
  • tests/clients/integrations-superseded-store.test.ts
  • tests/clients/integrations-writer.test.ts
  • tests/clients/mutation-plan.test.ts
  • tests/clients/remote-workspace-command-runner.test.ts
  • tests/codex-integration/codex-app-server-processes.test.ts
  • tests/codex-integration/codex-auth-api.test.ts
  • tests/codex-integration/codex-inject-integration.test.ts
  • tests/codex-integration/codex-inject.test.ts
  • tests/codex-integration/codex-main-account-refresh.test.ts
  • tests/codex-integration/codex-model-entitlement-admission.test.ts
  • tests/codex-integration/codex-prompt-adopt.test.ts
  • tests/codex-integration/codex-prompt-layers-read.test.ts
  • tests/codex-integration/codex-prompt-layers-write.test.ts
  • tests/codex-integration/codex-prompt-layers.test.ts
  • tests/codex-integration/codex-prompt-route.test.ts
  • tests/codex-integration/codex-routing-cache-affinity-detour.test.ts
  • tests/codex-integration/codex-routing.test.ts
  • tests/codex-integration/codex-service-manager-probe-hardening.test.ts
  • tests/codex-integration/codex-shim-autorestore.test.ts
  • tests/codex-integration/codex-shim-destroyed-probe.test.ts
  • tests/codex-integration/codex-shim-ensure-failure.test.ts
  • tests/codex-integration/codex-shim.test.ts
  • tests/codex-integration/codex-signin-lockout.test.ts
  • tests/codex-integration/history-paginated-openai-compat.test.ts
  • tests/codex-integration/history-paginated-transition-destinations.test.ts
  • tests/codex-integration/native-claude-desktop-toggle.test.ts
  • tests/codex-integration/native-profile-crash-boundaries.test.ts
  • tests/codex-integration/parallel-tool-calls-optin.test.ts
  • tests/codex-integration/reasoning-metadata.test.ts
  • tests/fixtures/provider-outbound-e2e.ts
  • tests/fixtures/test-layout-expected.json
  • tests/gui/gui-desktop-sidecar-script.test.ts
  • tests/gui/gui-static.test.ts
  • tests/gui/gui-tray-vibrancy-surface.test.ts
  • tests/gui/integrations-invariants.test.ts
  • tests/gui/platform-dialog-guard.test.ts
  • tests/gui/standalone-build-script.test.ts
  • tests/helpers/abortable-sse-upstream.ts
  • tests/helpers/platform-dialog-scan.ts
  • tests/helpers/responses-core-source.ts
  • tests/helpers/status-stale-observation.ts
  • tests/helpers/timeout-gated-error-body.ts
  • tests/helpers/update-bun-ownership-child.ts
  • tests/helpers/windows-tray-probe-lifecycle-driver.ps1
  • tests/lab/core-lab-boundary.test.ts
  • tests/lib/ambiguous-resend-composition.test.ts
  • tests/lib/ambiguous-resend-gate.test.ts
  • tests/lib/execution-budget-permits.test.ts
  • tests/lib/failure-attribution.test.ts
  • tests/lib/failure-stage-model.test.ts
  • tests/lib/provider-egress.test.ts
  • tests/lib/standalone.test.ts
  • tests/lib/upstream-retry.test.ts
  • tests/oauth/oauth-login-cli-browser-launch.test.ts
  • tests/oauth/oauth-login-open-browser.test.ts
  • tests/oauth/oauth-open-browser-choice.test.ts
  • tests/oauth/oauth-open-url-result.test.ts
  • tests/oauth/oauth-provider-reconcile.test.ts
  • tests/providers/api-key-catalog-authority.test.ts
  • tests/providers/codebuddy-adapter.test.ts
  • tests/providers/codebuddy-protocol.test.ts
  • tests/providers/cursor/cursor-catalog.test.ts
  • tests/providers/cursor/cursor-roster-account-scope.test.ts
  • tests/providers/devin-live-models.test.ts
  • tests/providers/devin-output-budget.test.ts
  • tests/providers/model-rename-migration.test.ts
  • tests/providers/provider-egress-outbound.test.ts
  • tests/providers/provider-outbound.test.ts
  • tests/providers/provider-registry-parity.test.ts
  • tests/providers/stepfun-provider.test.ts
  • tests/providers/xai/grok-lifecycle.test.ts
  • tests/providers/xai/xai-empty-catalog-tool-choice.test.ts
  • tests/providers/xai/xai-web-search-compat.test.ts
  • tests/responses/chat-inbound-developer-position.test.ts
  • tests/responses/chat-inline-document-bytes.test.ts
  • tests/responses/chat-media-translation.test.ts
  • tests/responses/chat-native-developer-role.test.ts
  • tests/responses/chat-tool-choice-allowed-tools.test.ts
  • tests/responses/opaque-blob-wrapped-rejection.test.ts
  • tests/responses/provider-egress-fetch.test.ts
  • tests/responses/responses-fetch-helpers-boundary.test.ts
  • tests/responses/responses-function-tool-repair.test.ts
  • tests/responses/responses-muse-tool-name-alias.test.ts
  • tests/responses/responses-passthrough-transient-policy.test.ts
  • tests/responses/responses-reset-replay.test.ts
  • tests/responses/responses-sparse-terminal-tool-scope.test.ts
  • tests/responses/ws-failure-stage.test.ts
  • tests/routing/cl01-openai-chat-review-regressions.test.ts
  • tests/routing/combo-stream-preflight.test.ts
  • tests/server/api-key-model-scope.test.ts
  • tests/server/api-key-scope-alpha-search.test.ts
  • tests/server/api-key-scope-audio.test.ts
  • tests/server/api-key-scope-images.test.ts
  • tests/server/api-key-scope-live.test.ts
  • tests/server/api-keys-routes.test.ts
  • tests/server/claude-intercept-integration.test.ts
  • tests/server/companion-settings.test.ts
  • tests/server/loopback-listener-integration.test.ts
  • tests/server/management-metrics-export.test.ts
  • tests/server/management-provider-reset-replay.test.ts
  • tests/server/memory-watchdog.test.ts
  • tests/server/provider-egress-management-validation.test.ts
  • tests/server/proxy-liveness.test.ts
  • tests/server/replay-refusal-parity.test.ts
  • tests/server/server-auth.test.ts
  • tests/server/server-gui-bundle-freshness.test.ts
  • tests/server/server-management-auth.test.ts
  • tests/server/server-request-body-size.test.ts
  • tests/server/spend-ledger-lifecycle.test.ts
  • tests/service/autostart-health.test.ts
  • tests/service/launchd-repair.test.ts
  • tests/service/process-state.test.ts
  • tests/service/service-ownership-compatibility.test.ts
  • tests/service/service-ownership-handover.test.ts
  • tests/service/service-ownership-state.test.ts
  • tests/service/service-start-environment.test.ts
  • tests/service/shutdown-launcher.test.ts
  • tests/service/standalone-service.test.ts
  • tests/update/update-bun-ownership-lease.test.ts
  • tests/update/update-desktop-owner.test.ts
  • tests/update/update-stop-classification.test.ts
  • tests/update/update-stop-first.test.ts
  • tests/usage/cache-diagnostic.test.ts
  • tests/usage/request-outcome-agreement.test.ts
  • tests/usage/usage-attempt-delivery.test.ts
  • tests/usage/usage-failure-fingerprint.test.ts
  • tests/usage/usage-failure-projection.test.ts
  • tests/usage/usage-ledger-retention.test.ts
  • tests/usage/usage-timeline.test.ts
  • tests/vision/vision-routed.test.ts
  • tests/windows/windows-secret-acl.test.ts
  • tests/windows/windows-tray.test.ts

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


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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot changed the title release: promote native macOS tray candidate to main 2.61.0 [WRONG BRANCH] release: promote native macOS tray candidate to main 2.61.0 Sep 22, 2026
@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (main); retarget to dev.

What to do

  • Retarget this PR to dev — all contributions go to dev.

Its title has been prefixed with [WRONG BRANCH].
Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required enforce-target check will keep failing until every issue above is resolved.

? provider.apiKey
: resolveProviderApiKey(provider.apiKey);
if (typeof resolved !== "string" || resolved.length === 0) return undefined;
return createHash("sha256").update(resolved).digest("hex");
Comment thread src/router.ts
|| account.credential.expires <= Date.now()) return undefined;
key = account.credential.access;
}
return key ? createHash("sha256").update(key).digest("hex") : undefined;

/** Non-secret key binding live roster evidence to one upstream destination and credential. */
export function cursorLiveRosterScope(baseUrl: string | undefined, credential: string): string {
const destination = (baseUrl?.trim().replace(/\/+$/, "") || "https://api2.cursor.sh");
// The roster is entitlement-specific, so the cache entry is bound to an irreversible
// credential fingerprint: a credential switch must not observe the previous account's
// plan roster, its stale fallback, or its failure cooldown suppression.
const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
// Devin's usable-model list is entitlement-specific. Bind cache reads/writes to an
// irreversible credential fingerprint so a credential switch cannot observe another
// account's roster or stale fallback (the Qoder precedent above).
const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
@lidge-jun
lidge-jun marked this pull request as ready for review September 22, 2026 10:10
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 22, 2026 10:10
@lidge-jun
lidge-jun merged commit e45692f into main Sep 22, 2026
38 of 63 checks passed
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@lidge-jun
lidge-jun deleted the codex/native-tray-release-main-2.61.0 branch September 22, 2026 10:10
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 74 / 80

이 PR은 macOS에서 트레이를 눌렀을 때 나오는 사용량 화면을 웹뷰 대신 네이티브 패널로 바꾸는 큰 묶음을, main에 올려 버전 2.61.0으로 내보내려고 만든 승격(promotion) PR입니다. 데스크톱(Tauri) 셸, Swift 네이티브 패널/위젯, 릴리스 워크플로, GUI·문서·프록시 쪽 수정이 한 번에 들어 있고 바뀐 파일이 800개가 넘습니다. 본문에도 “이 헤드에 대한 최종 승격/릴리스 검사는 아직”이라고 적혀 있고, 로컬 테스트는 돌리지 말라고 해서 안 돌렸으며 --no-verify로 푸시했다고 합니다. 예전에 통과한 건 소스 후보(#5490 쪽) CI이지, 지금 이 커밋(2dec4be…)의 승격 검증이 아닙니다.

라인 - enforce-target 실패 + 제목 [WRONG BRANCH]: base가 main이라 게이트가 “전부 dev로 보내라”고 막고 있습니다. 그런데 PR 본문은 main 승격을 의도합니다. 정책과 의도가 서로 부딪칩니다.
라인 - package.json 버전 2.61.0 + .github/workflows/release.yml 대폭 변경: 배포 파이프까지 건드리는데, 이 헤드의 승격 CI/아티팩트 검증이 본문 기준으로 아직 끝나지 않았습니다.
라인 - CI에 desktop shell, macos widget + bundle 등 실패가 보입니다. exact-head가 초록이 되기 전에 main에 넣는 건 위험합니다.
라인 - 823파일 / +7만 라인: 사람 리뷰로 전부 다 볼 수 없는 크기입니다. “후보에서 돌렸던 CI”를 이 헤드의 통과로 바꿔 읽으면 안 됩니다.
라인 - 본문은 preview를 먼저 낸 뒤 stable이라고 하는데, 지금 열린 preview 승격 PR은 보이지 않습니다. 순서 약속과 실제 상태가 맞지 않습니다.

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

이 PR을 “릴리스 예외로 main에 직접 넣는 승격”으로 받아들일지, 아니면 게이트대로 닫거나 dev로 돌릴지입니다. 또한 exact-head CI가 전부 통과하기 전에 머지해도 되는지도 메인테이너가 정해야 합니다. src/types.ts / src/config.ts 쪽은 작은 re-export 수준이라, 예전에 말하던 types/config 중복 PR을 닫으라는 케이스는 아닙니다.

너의 추천

지금은 머지하지 마세요. 진짜 main 승격이 맞다면 게이트/[WRONG BRANCH]를 메인테이너가 명시적으로 예외 처리한 뒤에, 이 헤드 SHA 기준으로 CI·릴리스 검사를 다 통과시킨 다음으로 미루세요. 예외가 아니면 이 PR은 닫고, 저장소 정식 승격 흐름(보통은 preview 먼저, 그다음 stable)으로 다시 여세요. 소스 후보에서 통과한 기록만 보고 이 헤드를 통과했다고 보지 마세요.

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants