[WRONG BRANCH] release: promote native macOS tray candidate to main 2.61.0 - #5510
Conversation
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>
…thing to report (#5412)
…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
|
Important Review skippedToo 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 configurationConfiguration used: Repository: lidge-jun/opencodex/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: ⛔ Files ignored due to path filters (41)
📒 Files selected for processing (782)
You can disable this status message by setting the 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. Comment |
|
✅ Deterministic PR hygiene checks passed. |
⏳ DRAFT
What to do
Its title has been prefixed with |
| ? provider.apiKey | ||
| : resolveProviderApiKey(provider.apiKey); | ||
| if (typeof resolved !== "string" || resolved.length === 0) return undefined; | ||
| return createHash("sha256").update(resolved).digest("hex"); |
| || 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"); |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
리뷰 · 우선순위 74 / 80이 PR은 macOS에서 트레이를 눌렀을 때 나오는 사용량 화면을 웹뷰 대신 네이티브 패널로 바꾸는 큰 묶음을, main에 올려 버전 2.61.0으로 내보내려고 만든 승격(promotion) PR입니다. 데스크톱(Tauri) 셸, Swift 네이티브 패널/위젯, 릴리스 워크플로, GUI·문서·프록시 쪽 수정이 한 번에 들어 있고 바뀐 파일이 800개가 넘습니다. 본문에도 “이 헤드에 대한 최종 승격/릴리스 검사는 아직”이라고 적혀 있고, 로컬 테스트는 돌리지 말라고 해서 안 돌렸으며 라인 - 메인테이너의 판단이 필요한 지점 이 PR을 “릴리스 예외로 main에 직접 넣는 승격”으로 받아들일지, 아니면 게이트대로 닫거나 너의 추천 지금은 머지하지 마세요. 진짜 main 승격이 맞다면 게이트/ 이 댓글은 grok-bot이 작성했습니다 |
Summary
2dec4be4fd77c6b193e25bcc9923e8bea87369eeto main for 2.61.0. The owner explicitly requested creating main and preview promotion PRs now.Verification
3d64bd3040b2da7953962da3c05be14f31991e56passed PR CI, all-platform CI and service lifecycle before admin integration through feat(desktop): native macOS usage panel and release regression fixes #5490.Checklist
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.