diff --git a/CREDITS.md b/CREDITS.md index 5de4f2f696..5737ed185e 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -243,6 +243,42 @@ unnecessary. If you find a landing that belongs on this page, open an issue. Being missed is the defect this file documents, not a claim you have to argue for. +### 2026-09-13: independent work that overtook an open proposal + +The gate fires on what a pull request *says*. It cannot fire on a landing that +never mentions the proposal it overtakes, which is how this one happened. + +[#4077](https://github.com/lidge-jun/opencodex/pull/4077) by +[@laerad777](https://github.com/laerad777) proposed opening the xAI Grok OAuth +lane to `service_tier: "priority"` and correcting the Fast-tier catalog copy. +The registry half landed independently through #4431 at `7ca00ffe7`, derived +from its own live probe, with no reference to #4077 and no trailer. The landed +scope is narrower on evidence — `grok-4.20-multi-agent-0309` stays excluded +because the gateway answers `service_tier: "default"` when sent `priority` — +so this is genuinely independent work rather than a silent carry. + +The copy correction was still unlanded, and it was the part #4077 identified +first. It landed through #4474 with a `Co-authored-by` trailer naming the +author. The registry half is recorded here as an acknowledgement rather than as +carried code, because that is what the evidence supports. + +The generalizable point: "independent" and "first" are different claims, and +only the second one is visible from the open queue. + +### 2026-09-13: the gate also fires on prose about carrying + +The matcher reads the description, so a pull request that merely *describes* a +carry train trips `missing_coauthor_credit` even when it has no source author. +[#4499](https://github.com/lidge-jun/opencodex/pull/4499) was an ordinary +implementation with no source branch; the phrases "contributor-carry train" and +"Head commit carries `[skip ci]`" were enough to fail the gate. Rewording +cleared it. + +That is a false positive rather than a defect worth loosening the matcher for. +A gate that occasionally asks an author to justify wording is cheaper than one +that misses a real uncredited carry, which is the failure this whole page +documents. Write around it. + ### A gap the gate does not close The gate checks that a trailer is **present**. It cannot check that the trailer diff --git a/devlog/_fin/260913_devin_image_passthrough/000_plan.md b/devlog/_fin/260913_devin_image_passthrough/000_plan.md new file mode 100644 index 0000000000..abad7e4ccc --- /dev/null +++ b/devlog/_fin/260913_devin_image_passthrough/000_plan.md @@ -0,0 +1,114 @@ +# 000 — Devin 이미지 패스스루 + +- 단위: `260913_devin_image_passthrough` +- 세션: `01a0985e-ce1a-7d12-81b9-c2e93a2bce67` (HOTL, cxc-loop) +- 기준: `origin/dev` + +## 증상 + +사용자가 Codex composer에 이미지를 붙여넣고 devin/swe-2에 보냈더니 턴이 0초에 죽었다. +이미지가 전달되지 않아 tesseract OCR로 우회하려던 상황이었다. + +## 원인 — 세 층이 겹쳐서 + +와이어 계층(`src/adapters/devin/cloud-direct/chat.ts`)은 이미 멀티모달이다. +`ContentPart`에 `{type:"image", mimeType, base64Data, caption}`이 있고 +`encodeImageData`(:189-196)가 이를 `ChatMessagePrompt` 필드 #10 `ImageData` +`{#1 base64_data, #2 mime_type, #3 caption}`로 인코딩한다. extension.js 대조 검증 완료. + +그런데 매핑 계층(`src/adapters/devin.ts`)이 이미지를 버린다. + +| 함수 | 줄 | 하는 일 | +|---|---|---| +| `textFromParts` | :205-209 | `type:"text"`만 뽑아 문자열로 반환 — 이미지는 빈 문자열 기여 | +| `mapOneMessage` (user) | :293-294 | 텍스트만 남기고 `if (!text) return undefined` — **이미지만 있는 메시지가 통째로 사라짐** | +| `toolResultText` | :211-214 | 같은 방식으로 툴 결과의 이미지도 버림 | + +사용자의 스크린샷에서 data: URI가 텍스트 첨부로 보인 것은 UI 표시이고, 실제로는 +`OcxImageContent`(`types/request.ts:189-195`)의 `imageUrl`이 data: URL로 들어온다. +매핑이 그것을 인식하지 못하고 텍스트 추출에서 빈 문자열을 얻어 메시지를 드롭한다. + +## 수정 — `src/adapters/devin.ts` + +### 1) NEW: `mapOcxContentToWire` 헬퍼 + +```ts +import type { ContentPart } from "./devin/cloud-direct/chat"; + +/** + * Convert inbound content parts to the wire shape the encoder accepts. + * + * The wire layer is already multimodal (ChatMessagePrompt field #10 ImageData), + * but every image was discarded here: textFromParts returned text-only strings, + * and a message whose only content was an image was dropped entirely. A data: + * URL carries everything field #10 needs; a remote https URL cannot be inlined + * without a fetch, so it stays as an explicit text reference rather than + * pretending the model can see a picture it cannot. Video has no Devin field. + */ +function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): string | ContentPart[] { + if (typeof content === "string" || !Array.isArray(content)) return content ?? ""; + const out: ContentPart[] = []; + for (const part of content) { + if (part.type === "text" && part.text) out.push({ type: "text", text: part.text }); + else if (part.type === "image") { + const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/); + if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! }); + else out.push({ type: "text", text: "[image url: " + part.imageUrl + "]" }); + } + } + return out; +} +``` + +### 2) MODIFY: `mapOneMessage` user/developer 분기 + +```ts +// before + const text = textFromParts(message.content).trim(); + if (!text) return undefined; + return { role: ..., content: text }; + +// after + const content = mapOcxContentToWire(message.content); + // 텍스트 없이 이미지만 있는 메시지도 유효하다 — 드롭하면 안 된다. + if (typeof content === "string" ? !content.trim() : content.length === 0) return undefined; + return { role: ..., content }; +``` + +### 3) MODIFY: 툴 결과 + +```ts +// before + content: toolResultText(message), + +// after — 오류 접두사는 유지하되, 이미지가 있으면 ContentPart[]로 넘긴다 + const wireContent = mapOcxContentToWire(message.content); + content: message.isError + ? (typeof wireContent === "string" ? "ERROR: " + wireContent + : [{ type: "text", text: "ERROR:" }, ...wireContent]) + : wireContent, +``` + +## NEW: tests/providers/devin-image-passthrough.test.ts + +| 케이스 | 기대 | +|---|---| +| data: URL 이미지 파트가 ContentPart image로 변환 | `{type:"image", mimeType:"image/png", base64Data:"iVBOR..."}` | +| 이미지만 있는 user 메시지가 드롭되지 않음 | items에 존재 | +| 텍스트 + 이미지 혼합 | 순서 보존 | +| https URL 이미지 | 텍스트 참조로 남음 | +| 툴 결과의 이미지 | ContentPart[]로 전달 | +| 툴 결과 오류 + 이미지 | ERROR 접두사 유지 | +| 와이어 인코딩 | buildGetChatMessageRequestForTests가 field #10을 냄 | + +## 레이아웃 등록 + +- `scripts/test-layout/layout.json` explicit → providers +- `tests/fixtures/test-layout-expected.json` + + +## 결과 (2026-09-13) + +- PR [#4513](https://github.com/lidge-jun/opencodex/pull/4513) squash merge: `c5d7f6a6efc22ab2fc17b377e0d6aae79c77b6a8` +- exact-head CI (`6106478389`): test 1-4/4, macos 1-2/2, keyring/docker/npm-global/hygiene/gates 전부 green (windows shard는 runner 선택으로 skip) +- 로컬 포커스 테스트: devin 도메인 9개 파일 154 pass / 0 fail (디버깅용; 제품 스위트·typecheck·build는 NOT RUN, 호스티드 CI가 머지 증거) diff --git a/devlog/_plan/260913_contributor_carry_train/000_plan.md b/devlog/_plan/260913_contributor_carry_train/000_plan.md new file mode 100644 index 0000000000..d3b03d7fe3 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/000_plan.md @@ -0,0 +1,137 @@ +# Contributor carry train — 60+ scored work into dev + +## Objective + +Land the highest-value contributor work that is still open on this repository. +Seventeen open pull requests score 60 or higher by maintainer review once the +maintainer's own #4462 is set aside; 16 of them are dispatched here and #3389 is +deferred for a recorded reason. Eight issues score 60 or higher with no pull +request owning them. The goal also closes everything those landings actually +resolve. `origin/dev` was `2df82f412` when this roadmap was written. + +This unit follows the 36-PR lane-stack merge in +`devlog/_plan/260913_lane_stack_merge/`, which landed the maintainer-authored +backlog. That batch is the precedent for the mechanism here; what changes is the +authorship. Every branch in this train belongs to someone else, so attribution is +a correctness requirement rather than a courtesy. + +## Selection + +Candidates come from the maintainer's own `## 리뷰 · 우선순위 NN / 80` comments, +harvested live from every open issue and pull request through the GraphQL API on +2026-09-13. All 73 open pull requests and all 58 open issues carry a score, so +the 60 cut is a real threshold rather than a sample. + +The inventory and its verification live in `001_candidate_inventory.md`. + +## Attribution is a gate, not a footnote + +`AGENTS.md` requires a `Co-authored-by` trailer naming the original author on any +landing that reimplements, supersedes, carries, or rebases their pull request. +`CREDITS.md` records 27 landings that failed this and explains why prose credit is +not equivalent: GitHub reads the trailer, and nothing reads a sentence in a commit +body. + +Two rules follow for every lane in this unit: + +- The trailer address is taken from the author's GitHub account, in the numeric + `users.noreply.github.com` form, not from the commit metadata on their branch. +- The trailer is verified in the **actual landing commit** after the squash, not + in the pull request description. A custom squash message silently drops text + that was only in the body. + +## CI economy + +Unchanged from the previous batch and re-verified there. Each lane is a cumulative +stack: the bottom branch merges `origin/dev`, each branch above merges its parent's +resulting commit, so merging bottom-up produces shrinking diffs. Every non-tip head +commit carries `[skip ci]`, which suppresses `ci.yml`, `react-doctor`, +`service-lifecycle` and `issue-quality-tests`. Only `enforce-pr-target`, +`pr-hygiene` and `pr-labeler` still run, because `pull_request_target` ignores the +skip marker. + +Only the lane tip runs the full matrix, and that tip run is the merge gate for the +whole lane. Squash messages never contain `[skip ci]`, because the dev-branch run +each merge triggers is the regression gate. + +This is an owner-authorized deviation from the MAINTAINERS.md requirement that +every pull request carry its own successful required check. Each merge comment +states it explicitly: the owner authorization, the tip pull request and run id +that covers the branch, and the fact that this branch's own `ci` check never ran. + +## Common principles + +Pushes use `git push --no-verify`, fast-forward only. No `--force` anywhere. +Nothing is pushed to `dev`, `main` or `preview`. + +Local full-suite runs are forbidden. Allowed local checks are `bun run typecheck`, +`bun run structure:check`, `bun run privacy:scan`, and `bun test` limited to the +files a pull request touches. Hosted CI on the lane tip is the only suite proof +this goal accepts. + +Each lane thread works in its own managed worktree and may fan out unlimited +`xai/grok-4.6` subagents inside that worktree. Subagents share their parent's +checkout, so a lane's subagents never run branch-level git operations concurrently. + +Lane threads never merge, never mark a pull request ready, and never close +anything. They push and report. The main session performs every merge. + +Conflicts are resolved by reading both sides and judging which matches current +behavior. A genuinely ambiguous conflict stops that link and is reported with both +sides and the reasoning, never guessed past. + +## Lane map + +| Lane | Doc | Contents, bottom to top | Owner model | +| --- | --- | --- | --- | +| R responses/core | 010 | 4455, 4086, 4409, 4387 | anthropic/claude-opus-5 | +| C chat + adapters | 010 | 4438, 4389, 4457 | anthropic/claude-opus-5 | +| L cli + hub | 010 | 4382, 4413, 4170 | xai/grok-4.6 | +| B bridge + images + auth | 010 | 4381, 4388, 4460 | xai/grok-4.6 | +| S security-review hold | 010 | 4447 | xai/grok-4.6 | +| X small carries | 010 | 4077 copy fix, 4171 dedupe | xai/grok-4.6 | +| I1 Windows issues | 010 | 4425, 4442 | kimi/k3[1m] | +| I2 config + account issues | 010 | 4430, 4435 | xai/grok-4.6 | +| H context history | 030 | 3663 | anthropic/claude-opus-5 | +| I3 routing compatibility | 030 | 3775, 4436, 4429 | xai/grok-4.6 | +| I4 encrypted history regression | 030 | 4454 | anthropic/claude-opus-5 | + +Wave 1 prepares R, C, L, B, S, X, I1 and I2 in parallel. Wave 2 is I3, I4 and H. + +Wave 2 is not parallel throughout. I3 prepares alongside, but I4 and H must be +serialized in that order: both land in the routed Responses request path, so H +rebases onto I4 rather than preparing beside it. Preparing them as peers would +put two `src/server/responses/core.ts` writers in one wave, which is the exact +thing lane R exists to prevent. + +Lane S is prepared in wave 1 but is deliberately not merged in wp3. `#4447` +touches CORS and the management provider routes, which is inside the +MAINTAINERS.md security-review boundary, and a tip merge would have landed it on +a CI signal that was never meant to certify it. Splitting it out of lane B keeps +lane B's tip at `#4460` and keeps the hold visible at merge time rather than +three documents away. + +## Work phases + +wp1 is this roadmap. wp2 prepares wave 1, wp3 merges it, wp4 prepares wave 2 +against landed `dev`, wp5 merges wave 2 and confirms the dev regression run, and +wp6 closes what landed and records the outcome. + +## Risks + +Five of the carried pull requests touch `src/server/responses/core.ts`: #4455, +#4086, #4409 and #4387 in lane R, plus #3663 in lane H. Issue #4454 lands in the +same path without being a pull request at all. #3389 is a sixth `core.ts` +toucher and is deferred for an unrelated reason recorded in +`001_candidate_inventory.md`. Lane R serializes its four; H and I4 serialize +after R. A lane merged out of order defeats the shrinking-diff property. + +GitHub had not computed `mergeable` for most of these branches when the roadmap +was written, so lane assignment rests on file overlap rather than a proven +conflict-free merge. Each lane thread discovers its real conflicts at prepare time +and reports them. + +All four issue lanes — I1, I2, I3 and I4 — have no branch to carry at all. Those +are ordinary implementations by the lane thread, and they carry no +`Co-authored-by` trailer because there is no source branch; the reporter is +credited in the description instead. diff --git a/devlog/_plan/260913_contributor_carry_train/001_candidate_inventory.md b/devlog/_plan/260913_contributor_carry_train/001_candidate_inventory.md new file mode 100644 index 0000000000..ce5c04a0f0 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/001_candidate_inventory.md @@ -0,0 +1,119 @@ +# Candidate inventory and its verification + +## How the scores were collected + +The maintainer leaves a `## 리뷰 · 우선순위 NN / 80` line in a comment on triaged +issues and pull requests. This unit harvested that line from every open item on +2026-09-13 through the GraphQL API. + +The first attempt was wrong in a way worth recording. `gh api graphql --paginate` +only advances a query whose cursor variable is named `$endCursor`; a query using +`$cursor` re-requests page one forever. That run produced a 230 MB file of 472 +identical pages and, worse, a plausible-looking result: deduplicating by number +hid the loop and returned a correct-but-partial page-one answer. Five candidates +at 61 to 68 were missing from it. + +The corrected collection covers 73 open pull requests and 58 open issues. Every +one carries a score, so nothing in this inventory is an unscored guess. + +Seventeen open pull requests score 60 or higher once the maintainer's own #4462 +is set aside. Sixteen are dispatched here; #3389 is deferred for a reason +recorded below. + +## Pull request candidates, 60 and above + +| Score | PR | Author | Domain | Lane | +| --- | --- | --- | --- | --- | +| 74 | #4457 | jeongjin0 | devin adapter tool identity | C | +| 73 | #4381 | luvs01 | bridge truncated terminal | B | +| 71 | #4387 | luvs01 | web-search continuation key binding | R | +| 70 | #4455 | jeongjin0 | code-mode view_image | R | +| 69 | #4388 | luvs01 | images loop buffering bound | B | +| 68 | #3389 | Yum-wu | zero-output retry | deferred | +| 68 | #4382 | luvs01 | hub status credentials | L | +| 67 | #4438 | Yongzhaooo | OCG DeepSeek timeline instructions | C | +| 66 | #4389 | olddonkey | stream memory allocations, 44 files | C | +| 66 | #4413 | rrmlima | authenticated remote catalog pull | L | +| 66 | #4460 | AgenticLab-SH | model availability vs auth failure | B | +| 64 | #4077 | laerad777 | xAI Fast catalog copy, residue only | X | +| 63 | #4086 | Eleven-is-cool | routed continuation after replay miss | R | +| 61 | #4409 | yxr1995-maker | routed effort ladders from models.dev | R | +| 61 | #3663 | y2ambition-ai | context history relay, 19 files | H | +| 61 | #4170 | yeongjunyoo | stop refusal cause | L | +| 61 | #4447 | Veritas-7 | field-masked provider writes | S | + +`#4462` also scores 74 but is maintainer-authored and belongs to the sub-agent +surface work, not this train. + +`#3389` is deferred by prior judgment, not by score. The 2026-09-04 priority-65 +closeout recorded `NEEDS_DESIGN`: its zero-output premise was disproved by +experiment. Reviving it needs a design decision first, so it is not dispatched +here. + +## Issue candidates with no owning pull request + +| Score | Issue | Reporter | Lane | +| --- | --- | --- | --- | +| 76 | #4425 | wanjinxingoo-bot | I1 | +| 76 | #4430 | samwang0041-star | I2 | +| 73 | #4454 | 321sssrt-bit | I4 | +| 71 | #3775 | leonclab | I3 | +| 70 | #4429 | mdwsk88 | I3 | +| 67 | #4442 | SeanChengN | I1 | +| 66 | #4436 | jaychou0642-create | I3 | +| 64 | #4435 | ren-min-wan-sui | I2 | + +The remaining 60+ issues are excluded for a stated reason rather than by +oversight. #4456, #4412 and #4439 already have an owning pull request in this +train (#4457, #4455, #4438). #4191, #4311, #3661, #3781, #4312 and #3506 are +partially landed: the merged work references them with `Refs` and each landing +states in its own description that the issue stays open. #3375, #3376 and #3377 +are maintainer feature issues under separate execution. + +## Supersede closures already executed + +Before this train was planned, nine contributor pull requests were closed because +the previous batch had already absorbed them. Each carrier's merge commit was +verified as an ancestor of `origin/dev` at `2df82f412` before the close, and each +close carries a comment naming the carrier, the merge commit, and the fact that +the carry preserved the author's trailer. + +| Closed | Author | Carrier | Merge commit | +| --- | --- | --- | --- | +| #4319 | ke-1t | #4346 | `b551b524f` | +| #4310 | yxr1995-maker | #4354 | `b474013` | +| #3652 | itismyfield | #4355 | `c6372ca` | +| #4229 | yansigit | #4363 | `37bc1a0` | +| #4216 | ildunari | #4367 | `c240534` | +| #4119 | DamnUi | #4366 | `90667e5` | +| #4317 | cortes-ventures | #4402 | `8acd73b` | +| #4080 | terrytan95 | #4369 | `c27eeec` | +| #3458 | Ingwannu | #4362, #4372 | `19601ea`, `7874900` | + +## The half-superseded case + +`#4077` was not closed, and the reason generalizes. Its registry half landed +independently through `#4431` (`7ca00ffe7`), which classified the xAI OAuth lane +from its own live probe and deliberately excluded `grok-4.20-multi-agent-0309` +because the gateway answers `service_tier: "default"` when sent `priority`. That +branch flips the same model to `true`, so the landed scope is narrower on +evidence. + +Its third point is still unlanded and now more visible than when it was filed: +`src/providers/registry.ts` still reads +`fastTierDescription: "Priority processing, 2x token price"`, and `#4431` opened +the OAuth subscription rows where no per-token price exists. Lane X carries that +correction with the author's trailer. + +`#4431` landed without referencing `#4077` and without a trailer for its author. +That is the `missing_coauthor_credit` pattern `CREDITS.md` exists to stop +repeating, and it is why lane X exists as its own slice rather than as a footnote +in another lane. + +## Duplicate to collapse + +`#4171` (rrmlima, `CHANGES_REQUESTED`) and `#4455` (jeongjin0) both answer issue +`#4412` and both touch `src/responses/code-mode-helper-compat.ts` and +`src/types/tools.ts`. `#4455` is the 8-file superset. Lane R carries `#4455` with +trailers for both authors; lane X closes `#4171` after that landing is verified on +`dev`. diff --git a/devlog/_plan/260913_contributor_carry_train/010_wave1.md b/devlog/_plan/260913_contributor_carry_train/010_wave1.md new file mode 100644 index 0000000000..c016253407 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/010_wave1.md @@ -0,0 +1,205 @@ +# wp2 — Wave 1 lane preparation + +Eight worktree threads prepare in parallel. Every head in this train lives on a +contributor fork, so no lane pushes to a source branch. Each lane creates its own +`codex/260913-carry-*` branches in `lidge-jun/opencodex` and opens new pull +requests that carry the work with attribution. + +## The carry shape, identical for every link + +1. Branch from `origin/dev` for the bottom link, or from the previous link's + pushed head for every link above it. +2. Fetch the source head directly: `git fetch https://github.com//opencodex `. + The fork is not a configured remote in a fresh worktree. +3. Apply the source content. Cherry-pick when it is clean, reimplement when the + branch is stale against current `dev`. Either way the result is judged against + current behavior, not against the branch's original base. +4. Fold in the review findings already on the source pull request. A + `CHANGES_REQUESTED` review is not an approval and its findings are not optional. +5. Add `Co-authored-by: ` to the commit + that will survive the squash, taking the address from the author's GitHub + account rather than from their commit metadata. +6. Non-tip head commit subjects carry `[skip ci]`. The tip does not. +7. Push with `git push --no-verify`, fast-forward only. +8. Open the pull request against `dev` for the bottom link and against the parent + link's branch for every link above, filling every section of + `.github/PULL_REQUEST_TEMPLATE.md`. + +A lane reports: each new pull request number, the tip head SHA, the tip CI run id +and its conclusion, and every conflict it resolved with the reasoning. + +## Lane R — responses/core, serialized + +Owner `anthropic/claude-opus-5`. Order is fixed because all four touch +`src/server/responses/core.ts`. + +| # | Source | Author | Fork branch | Head | +| --- | --- | --- | --- | --- | +| 1 | #4455 | jeongjin0 | `codex/code-mode-view-image-helper` | `61b9aefc3` | +| 2 | #4086 | Eleven-is-cool | `fix/routed-continuation-replay` | `0c39bc608` | +| 3 | #4409 | yxr1995-maker | `feat/reasoning-metadata` | `7de03fb06` | +| 4 | #4387 | luvs01 | `agent/web-search-key-binding-20260912` | `2a1b3c307` | + +`#4455` carries two trailers. It answers issue #4412 together with `#4171` +(rrmlima), whose four files are a strict subset of its eight, so both authors are +named. Lane X closes `#4171` after this lands. + +`#4409` adds `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json` entries. `tests/test-layout.test.ts` +and `tests/test-layout-tooling.test.ts` enforce both; a new test file missing +from either is a hard failure, not a lint warning. + +`#4387` touches fifteen `structure/` documents. `bun run structure:check` is a +required local check for this link. + +## Lane C — chat and adapters + +Owner `anthropic/claude-opus-5`. `#4438` and `#4389` share +`src/adapters/openai-chat.ts`, so the order is fixed. + +| # | Source | Author | Fork branch | Head | +| --- | --- | --- | --- | --- | +| 1 | #4438 | Yongzhaooo | `codex/ocg-deepseek-system-order` | `9ebbcad26` | +| 2 | #4389 | olddonkey | `codex/memory-stream-optimizations` | `176cbbd2e` | +| 3 | #4457 | jeongjin0 | `codex/devin-restore-tool-names` | `edc6db3ff` | + +`#4389` is the cost of this lane: 44 files, +1491/-268, touching four adapters, +the chat outbound path, admission, the translator budget and request +decompression. It is a performance change, so the audit question is whether any +allocation removal changes observable behavior on a truncated or folded stream. + +`#4438` closes issue #4439 and `#4457` closes issue #4456 once landed; both +closures belong to wp6, not to the lane. + +## Lane L — cli and hub + +Owner `xai/grok-4.6`. Three links with no shared file at all. They are chained +only to collapse three CI runs into one; no ordering dependency exists between +them. + +| # | Source | Author | Fork branch | Head | +| --- | --- | --- | --- | --- | +| 1 | #4382 | luvs01 | `agent/status-hub-binding-20260912` | `094f12713` | +| 2 | #4413 | rrmlima | `feat/remote-catalog-pull` | `2aa821dff` | +| 3 | #4170 | yeongjunyoo | `fix/4169-stop-refusal-cause` | `4d72ef010` | + +`#4413` adds a CLI command, so `bun run skill:surface` must be regenerated and +`bun run skill:surface:check` must pass — `tests/ci-workflows/skill-ocx.test.ts` +fails on a committed map that drifts from `src/cli/capabilities.ts`, and it also +fails on a documented command the registry does not have. + +`#4413` duplicates its author's own issue #3729. That issue closes with this +landing. + +## Lane B — bridge, images, auth + +Owner `xai/grok-4.6`. + +| # | Source | Author | Fork branch | Head | +| --- | --- | --- | --- | --- | +| 1 | #4381 | luvs01 | `agent/content-filter-terminal-20260912` | `aad1d75bc` | +| 2 | #4388 | luvs01 | `agent/media-loop-bounds-20260912` | `9518b5281` | +| 3 | #4460 | AgenticLab-SH | `codex/260913-model-capacity-errors-dev` | `eefa07442` | + +`#4460` ships a `devlog/_fin/` document on its branch. A `_fin` unit records work +already visible in public git history, so the carry either moves it to `_plan` or +drops it; it does not land a `_fin` record for work that has not landed. + +The lane tip is `#4460`. + +## Lane S — security-review hold + +Owner `xai/grok-4.6`. One link: `#4447` (Veritas-7, fork branch +`fix/openai-patch-operator-overlays`, head `ed9655286`), letting field-masked +writes reach canonical OpenAI past stored overlays. + +It is its own lane rather than the top of lane B for a reason that is easy to get +wrong. It touches `src/server/auth-cors.ts` and +`src/server/management/provider-routes.ts`, which is inside the MAINTAINERS.md +security-review boundary. If it sat on top of lane B, merging that lane's tip in +wp3 would land the CORS and management change as a side effect of a lane merge, +on a CI signal that was never meant to certify it. + +`#4447` targets `main`, which is why its own title says `[WRONG BRANCH]`. The +carry lands on `dev` like everything else; the source pull request is left for +wp6 disposition rather than retargeted underneath its author. + +This lane prepares in wave 1 and does not merge in wp3. It merges in wp5 only +after maintainer security review, or it is recorded as deferred with that reason. + +## Lane X — small carries and dedupe + +Owner `xai/grok-4.6`. One branch, two obligations. + +The carry is the residue of `#4077` (laerad777, fork branch +`fix/xai-oauth-service-tier`, head `a071c67a1`, currently `CONFLICTING`): change +`fastTierDescription` on the xai registry entry away from +`"Priority processing, 2x token price"`, because `#4431` opened OAuth subscription +rows that have no per-token price. The registry classification in that branch is +already landed and is **not** re-applied; in particular +`grok-4.20-multi-agent-0309` keeps `forwardCallerServiceTier: false`, since a live +probe recorded the gateway downgrading it to `default`. + +The pins that read this string must move with it. The lane finds them rather than +assuming: `rg fastTierDescription` across `src/`, `tests/` and `docs-site/`. + +The dedupe is `#4171`, which wp6 closes after lane R's `#4455` carry is verified +on `dev`. Lane X does not close it. + +## Lane I1 — Windows issues, no source branch + +Owner `kimi/k3[1m]`. Two implementations, no carry, so no `Co-authored-by`; the +reporters are credited in the descriptions and the issues are linked. + +`#4425` (76): a non-elevated `ocx service install` fails because the generated +`LogonTrigger` omits `UserId`, and the resulting `Access is denied` is reported as +a permission problem, which is a misdiagnosis. The fix emits `UserId` and the +diagnostic stops blaming elevation. + +`#4442` (67): the Windows history manifest integrity check treats `C:\...` and +`\\?\C:\...` as different paths, so the check fails after sessions are archived. +The fix normalizes the extended-length prefix before comparison. + +Both are Windows-only paths that this lane cannot execute locally, and the obvious +evidence is not available by default: `platform-windows` in +`.github/workflows/ci.yml` is gated on `github.event_name == 'workflow_dispatch'`, +so a pull-request tip run never executes it. The gate for this lane is therefore +an explicit `workflow_dispatch` run on the exact tip SHA, plus focused tests that +encode the path and trigger shapes directly. If the dispatch run is not obtainable, +the lane says so instead of implying a Windows leg ran. + +## Lane I2 — configuration and account issues, no source branch + +Owner `xai/grok-4.6`. + +`#4430` (76): 2.52.0 treats `claudeCode.desktopProfile.appliedFingerprint=null` as +fatal and replaces the entire configuration with defaults. Losing a user's whole +config on a nullable field is the severe part; the fix accepts `null` and, more +generally, stops a single invalid field from triggering a whole-config reset. + +`#4435` (64): a second Kiro account disappears shortly after a successful login. +The lane reproduces against the account store before proposing a fix, because a +disappearing second account is equally consistent with a write collision, a +single-slot overwrite, and a projection that keys on a non-unique identifier. + +## Dispatch packet + +Every thread receives the same frame, with its lane table substituted: + +- Worktree environment, this repository, base `dev`. +- The common principles from `000_plan.md`, quoted rather than referenced. +- Unlimited `xai/grok-4.6` subagents may be fanned out inside the lane's own + worktree, in parallel. They share that worktree's checkout, so they need + non-overlapping write scopes and must never run branch-level git operations + concurrently. +- Do not merge, do not mark ready, do not close anything, do not push to `dev`, + `main` or `preview`. +- Report the pull request numbers, the tip head SHA, the tip run id and + conclusion, and every conflict resolution with its reasoning. + +## Exit criteria + +Every wave-1 lane has its chain pushed, its pull requests open with complete +descriptions, and a hosted run on its exact tip head. A lane whose tip run is red +for a reason belonging to the lane fixes it before wp3; a lane whose tip is red +because `dev` drifted re-merges `origin/dev` at its tip and re-runs. diff --git a/devlog/_plan/260913_contributor_carry_train/011_wave1_outcome.md b/devlog/_plan/260913_contributor_carry_train/011_wave1_outcome.md new file mode 100644 index 0000000000..a073b351ff --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/011_wave1_outcome.md @@ -0,0 +1,116 @@ +# wp2/wp3 — Wave 1 outcome + +All eight wave-1 lanes are on dev. Lane S landed last, after the security review +it was held for changed the diff. + +## What landed + +| Lane | Tip | Merge commit | Carried | +| --- | --- | --- | --- | +| L | #4481 | d865aacf93 | #4382 luvs01, #4413 rrmlima | +| B | #4480 | 2af30c2d0e | #4381 luvs01, #4388 luvs01, #4460 AgenticLab-SH | +| X | #4474 | 2296e485d6 | #4077 laerad777, residue only | +| I1 | #4486 | a3ca64f605 | issues #4425, #4442 | +| I2 | #4482 | 990cd8cce5 | issues #4430, #4435 | +| C | #4487 | 55bb9f3fef | #4438 Yongzhaooo, #4389 olddonkey, #4457 jeongjin0 | +| R | #4489 | 3f76ce415d | #4455 jeongjin0, #4409 yxr1995-maker, #4387 luvs01 | +| S | #4477 | 981b53e7d0 | #4447 Veritas-7, plus the review fix c39098ba3d | + +Every merge was gated the same way: a Cross-platform CI run concluded success on +the exact tip head SHA, the merge commit was verified with +git merge-base --is-ancestor against origin/dev afterwards, and the +Co-authored-by trailers were read out of the landed commits rather than the pull +request bodies. + +## The security hold earned its keep + +#4477 was green on df7cbd5b7b for hours before it merged. It carries #4447, which +touches src/server/auth-cors.ts and src/server/management/provider-routes.ts, so +MAINTAINERS.md requires explicit security review and a green tip is not that +review. Splitting it out of lane B is what made the hold enforceable: as lane B's +tip it would have landed as a side effect of a lane merge. + +The review changed the outcome, which is the argument for the hold existing at +all. The threat model established that overlay tolerance is openai-only, that the +destination and auth keys stay byte-pinned, and that PATCH is an explicit +per-field allowlist so a request cannot introduce a novel key. It also established +that the canonical OpenAI seed defines only four keys — adapter, authMode, baseUrl, +codexAccountMode — so "ignore keys the seed never defines" reaches nearly every +config key, which is a much wider door than the description implied. + +That width is where the finding was. The author had already denied +allowPrivateNetwork, correctly: it is patchable, it disables destination DNS +classification, and overlay tolerance would have persisted it on the ChatGPT +forward row. headers sits in exactly the same class and was not covered. Canonical +OpenAI has no registry staticHeaders, the PATCH field mask writes headers with a +shallow merge, and the forward adapter applies provider.headers to the upstream +request before the incoming forward headers — so a persisted value wins whenever +the caller omits that header. A dashboard-session +PATCH {"headers":{"chatgpt-account-id":"..."}} would have ridden every subsequent +ChatGPT request that did not carry the header itself. POST still refused it; PATCH, +the editor and reload did not. + +c39098ba3d denies headers on canonical openai the same way and adds the missing +PATCH regression. The test was driven red before it was accepted: removing the +guard fails exactly that case and nothing else in the file. + +Two process notes worth keeping. The finding came from an independent reviewer +rather than the main pass, which had stopped at the field-policy map and concluded +headers were redacted — true for editor admission, false for the PATCH mask, which +has its own allowlist. And the tip's first run failed in select-windows-runner with +no failing step; re-running the failed jobs on the same commit turned it green, so +the exact-head evidence survived rather than needing a new head. + +Recorded follow-up: the overlay tolerance is a denylist and denylists rot. +PROVIDER_CONFIG_FIELD_POLICY forces a new provider field to be classified but does +not force an overlay decision, so a future editor field touching a trust boundary +becomes silently reachable on the canonical row. codexToolMode is the current +example. The durable fix is an explicit overlay allowlist plus a guard test. + +## The credit defect this wave surfaced + +Lane I1 implemented #4442 as an ordinary fix, because the candidate harvest found +no pull request owning that issue. There was one: draft #4465 by maoxin1234, +opened 2026-09-13T05:43Z, after the harvest and before the lane. It proposed the +same normalization in the same two files. + +The landed implementation is a superset — it adds the backup-id compatibility +fallback, the native-residue path and the structure doc — but it supersedes a +contributor proposal that came first, which AGENTS.md treats as requiring a +trailer. The I1 merge commit therefore names maoxin1234, and #4465 was closed with +that stated rather than closed as a duplicate. + +The generalizable failure is the harvest, not the lane. A scored inventory is a +snapshot, and an issue lane must re-check for an owning pull request at dispatch +time rather than trusting the snapshot it was planned from. Wave 2 lanes I3 and I4 +do that check before implementing. + +## What #4086 turned out to be + +Lane R's fourth planned link was already on dev as d6723f7f3, with its own +Co-authored-by trailer for Eleven-is-cool. The lane attempted the carry before +concluding that, and the modify/delete conflict on +structure/04_transports-and-sidecars.md — a file the #4276 SSOT restructure had +deleted — is what prompted the check. The landed version is a superset of the +branch. + +That is the second time in this train that a planned carry was already satisfied +on dev; #4170 in lane L was the first. Both were found by attempting the work +rather than by reading the plan, which is the argument for lanes re-verifying +their own inputs. + +## Disposition executed + +Closed with evidence comments naming the landing pull request and merge commit: +source pull requests #4382, #4413, #4170, #4381, #4388, #4460, #4077, #4438, +#4389, #4457, #4455, #4409, #4387, #4171, #4086, #4465, plus the carry links that +GitHub did not auto-close (#4479, #4473, #4485). Issues #4425, #4442, #4430, +#4435, #4439, #4456, #4412 and #3729 were closed the same way. + +## Honest limits + +Non-tip pull requests merged without their own ci check, under the recorded owner +authorization for tip-only CI. Every dev run triggered by these merges ended +cancelled by the concurrency group as the next merge superseded it, which is the +workflow behaving as configured; the batch regression gate is a completed dev run +on a commit containing everything, and that belongs to wp5 rather than here. diff --git a/devlog/_plan/260913_contributor_carry_train/020_wave1_merge.md b/devlog/_plan/260913_contributor_carry_train/020_wave1_merge.md new file mode 100644 index 0000000000..7b0ea43350 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/020_wave1_merge.md @@ -0,0 +1,71 @@ +# wp3 — Wave 1 integration + +The main session performs every merge. No lane thread merges anything. + +## Order + +Lanes merge one at a time, smallest blast radius first, so that each later lane +re-merges a dev that already contains the earlier ones: + +X, I1, I2, L, B, C, R. Lane S does not merge here. + +X is first because it is a single-file registry correction. R is last because its +four links all rewrite src/server/responses/core.ts, which is the file every other +lane is most likely to have touched indirectly through +structure/transports/responses.md. + +## The wave-1 hold + +Lane S (#4447) prepares in wave 1 and is deliberately excluded from this merge +order. It touches CORS and the management provider routes, which is inside the +MAINTAINERS.md security-review boundary, so it merges in wp5 after review or is +recorded as deferred. Lane B's tip is #4460; if a merge attempt on lane B would +carry #4447, the lane was assembled wrong and is fixed before merging rather than +merged and reverted. + +## Per-lane merge procedure + +1. git fetch origin dev and re-read the head. dev moves during this batch. +2. Read the lane tip current head SHA with gh pr view --json headRefOid. +3. Confirm the hosted run that concluded success ran on that exact SHA. A green + run on an earlier head is not evidence for the head being merged. +4. If the tip re-merged origin/dev after its green run, verify the resolution by + reading it and re-run bun run typecheck, bun run structure:check and + bun run privacy:scan before merging. This happened twice in the previous batch + and both times the re-merge was mechanical; that is a finding to reconfirm, + not to assume. +5. Merge the tip. A cumulative lane merges as one merge commit on the tip, which + lands every link beneath it. A lane whose links must appear separately in + history squash-merges bottom-up instead. +6. Verify the landing rather than trusting the merge report: + git merge-base --is-ancestor origin/dev. +7. Verify attribution in the landed commit: git log -1 --format=%B + must show the Co-authored-by trailers for every carried author. A trailer that + lived only in the pull request body is gone after a custom squash message, and + that is exactly the failure CREDITS.md documents. + +## Merge comment + +Every merge comment names three things explicitly, so the deviation stays a +recorded owner decision rather than an inferred one: + +- the owner authorization for tip-only CI in this batch, +- the tip pull request and run id that covers this branch, +- the fact that this branch own ci check never ran. + +## The screenshot gate + +enforce-target fails with missing UI screenshot on any pull request whose title or +description merely mentions gui. No wave-1 source pull request touches gui/, so +the gate is expected to be quiet here — but it reads the text, not the diff, so a +description that mentions the dashboard trips it anyway. Where a real rendered +state exists, capture it. A control that only renders against a real expired +credential is covered by its tests, and that limitation is stated in the +description rather than faked with a fixture. + +## Exit criteria + +Every wave-1 lane except S reports MERGED with its own merge commit, every merge +commit is a verified ancestor of origin/dev, and every carried author trailer is +present in the landed commit. Lane S exits wp3 prepared, green and unmerged; that +is its success state here, not a failure to land. diff --git a/devlog/_plan/260913_contributor_carry_train/030_wave2.md b/devlog/_plan/260913_contributor_carry_train/030_wave2.md new file mode 100644 index 0000000000..7b3625a1b2 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/030_wave2.md @@ -0,0 +1,126 @@ +# wp4 — Wave 2 lane preparation + +Three lanes were held out of wave 1 because each collides with wave-1 work on a +file wave 1 rewrites. They prepare against landed dev, not against the heads they +would have started from. + +They are not all parallel. I3 prepares on its own. I4 and H both land in the +routed Responses request path, so they run in that order and H rebases onto I4. +Preparing them as peers would put two writers on src/server/responses/core.ts in +one wave, which is precisely what lane R exists to prevent. + +## Lane H — context history relay: NOOP, already on dev + +Owner anthropic/claude-opus-5. Source #3663 (y2ambition-ai, fork branch +feat/codex-context-history, head 8e0b53b0f), 19 files, +1249/-15. + +Outcome: there was nothing to carry. The lane verified against landed dev before +writing anything and found the content already there as a33b51eb, landed through +#4360 on 2026-09-12 (merge 2526715fb2), carried from exactly this branch's head +with Co-authored-by trailers for both y2ambition-ai and nbbb26 intact. Five +hardening commits followed it; all 19 files are present on dev and every one is +larger there, so re-applying the branch would have been a downgrade. + +The lane also disproved the conflict this document predicted, and the reason is +stronger than "different regions". Lane I4's strip is gated on +adapter === "openai-responses" and not isCanonicalOpenAiForwardProvider, while +the context-history ownership recording is gated on isCanonicalOpenAiForwardProvider +and contextRelayActivated(). The predicates are complements on the same +destination question, so no request takes both paths, and they sit on opposite +sides of dispatch: one repairs the outbound body before the send, the other +records the account after upstream accepted it. + +This is the third planned carry in this train that turned out to be already +satisfied on dev, after #4170 in lane L and #4086 in lane R. All three were found +by attempting the work rather than by reading the plan. #3663 was closed with a +comment naming a33b51eb and #4360. + +Because lane H needed nothing, lane I5 branches from dev directly rather than +from lane H's head. + +The original deferral reasoning is kept below, because it is why the lane was +scheduled here at all. + +It is deferred because it touches src/server/responses/core.ts, +src/server/index.ts and src/server/live.ts at once. Lane R rewrites the first and +lane B touches the third, so preparing it in parallel would mean resolving the +same conflicts twice. + +src/server/index.ts carries a hard constraint that a large carry can break +silently. It is the composition root, and the window between the Bun.serve call +and the labActivationRequired check must stay synchronous: a scan in that file +fails on any await added to that window and on startServer being declared async. +The reason is not style — the synchronous subagent-fallback chain has nowhere to +await, so an await there reroutes subagents to a different model than the +operator configured. + +The lane also adds test-layout entries and must keep +tests/lab/core-lab-boundary.test.ts green: src/server/responses/core.ts may not +reach src/lab/ even transitively. + +## Lane I3 — routing compatibility, no source branch + +Owner xai/grok-4.6. Three issues in the model and route capability area, deferred +because #3775 overlaps lane R #4409 effort-ladder work. + +#3775 (71): Codex 0.153.4 rejects minimal and none on custom models mapped to +gpt-6-astra. #4349 landed effort ceilings enforced independently of model pins, so +this lane starts by determining what that already fixed; the honest outcome may be +a narrower fix than the issue describes. + +#4436 (66): deepseek-flash accepts native image input on both Chat and Responses, +but the registry still routes it through the vision sidecar. This one is a CARRY, +not an implementation. The dispatch-time re-check found #4467 by +jaychou0642-create, opened 2026-09-13T06:06Z — after the candidate harvest and +before this lane — by the same person who filed the issue. It is ready rather than +draft, targets dev, and touches 13 files (+154/-15) centred on +src/providers/registry.ts, with locale docs and parity tests. + +Carry it with a Co-authored-by trailer naming +jaychou0642-create <283093853+jaychou0642-create@users.noreply.github.com>. The +review question is whether it declares the capability through the per-model +contract #4374 and #4376 landed, rather than adding a new special case. + +That this was caught at all is the wave-1 lesson applied: the scored inventory is +a snapshot, and lane I1 landed #4442 as an ordinary fix while contributor draft +#4465 already proposed it. Every issue lane re-checks for an owning pull request +at dispatch time now. + +#4429 (70): a key-auth Responses gateway (Kimi K3) echoes hosted web_search as a +client function_call, and webSearchBridge only has an Ollama executor. This is the +largest of the three and may split into its own work-phase if the executor +abstraction turns out to be Ollama-shaped rather than merely Ollama-only. + +## Lane I4 — encrypted history regression + +Owner anthropic/claude-opus-5. #4454 (73) is an issue, not a pull request: there +is no branch to carry and no Co-authored-by trailer. Reported by 321sssrt-bit, +mixed encrypted agent_message history bypasses the routed Responses fail-closed +path. + +Deferred behind lane R because it lands in the same request path, and it is a +regression in a fail-closed boundary, which makes it the highest-risk single item +in the train. A bypass of fail-closed is a security-boundary defect: it reports +for maintainer review per MAINTAINERS.md rather than being treated as ordinary +once CI is green. Lane H starts from this lane's pushed head, not from dev. + +#4364 bounded multipart encrypted task recovery and #4351 restored opt-in +plaintext V2 messages; both explicitly state they do not close #3661. This lane +establishes whether #4454 is a distinct defect or the same envelope problem seen +from the other side before writing a fix. + +## Exit criteria + +Each wave-2 lane has its chain pushed against landed dev, its pull requests open, +and a hosted run concluded on its exact tip head. I4 reaches that state before H +starts. + +## Conditional closures + +Two of these lanes may find that the reported defect is already fixed or is a +restatement of another open issue. #3775 may be covered by the effort ceilings +#4349 landed, and #4454 may be the same envelope problem as #3661 seen from the +other side. In either case the honest outcome is a comment on the issue naming +what was found, not a closure claimed against a fix that was not written here. +050_disposition.md lists the expected closures; a lane finding no defect +overrides that list. diff --git a/devlog/_plan/260913_contributor_carry_train/040_wave2_merge_regression.md b/devlog/_plan/260913_contributor_carry_train/040_wave2_merge_regression.md new file mode 100644 index 0000000000..fbbcfc1597 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/040_wave2_merge_regression.md @@ -0,0 +1,46 @@ +# wp5 — Wave 2 integration and the dev regression gate + +## Order + +S, I3, I4, H. Lane S is first because it has been waiting since wave 1 and is a +single link. H is last because it is the largest diff and the one most exposed to +everything that landed before it, and because it was prepared on top of I4. + +The merge procedure is identical to 020_wave1_merge.md, including the exact-head +CI check, the ancestry verification, the trailer check in the landed commit, and +the three-part merge comment. + +## Security-review holds + +Two lanes do not merge on green CI alone. Lane S (#4447) touches CORS and the +management provider routes; lane I4 (#4454) repairs a fail-closed bypass. Both are +inside the MAINTAINERS.md security-review boundary. If review is not available +within this goal, they are recorded as deferred with the reason rather than merged +on a CI signal that was never meant to certify them. + +Deferring lane I4 has a consequence worth stating up front: lane H was prepared on +top of it. If I4 is held, H is rebased onto dev without it before merging, or H is +held too. H is not merged with an unmerged parent silently folded in. + +## The regression gate + +A merge report is not a regression proof. The gate is a dev workflow run that +concluded success on a commit that contains the whole batch. + +Two distinctions carried over from the previous batch, both learned the hard way: + +- A run that ends cancelled is not a pass. Merges inside one concurrency group + supersede each other, which is the workflow behaving as configured; the evidence + is the completed run on a descendant commit. +- The commit the run executed on must be an ancestor-verified descendant of the + last merge. git merge-base --is-ancestor is the check. + +If the final run is red, the failure is triaged before any completion claim. A +failure that belongs to a landed lane is fixed as a follow-up pull request in this +same goal, not recorded as an acceptable residue. + +## Exit criteria + +Every lane is merged or explicitly deferred with a stated reason, and one dev run +has concluded success on a batch-containing commit, recorded by run id and head +SHA. diff --git a/devlog/_plan/260913_contributor_carry_train/050_disposition.md b/devlog/_plan/260913_contributor_carry_train/050_disposition.md new file mode 100644 index 0000000000..e447d56506 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/050_disposition.md @@ -0,0 +1,73 @@ +# wp6 — Disposition and outcome + +## What closes, and on what evidence + +A merged pull request does not prove its linked issue is resolved. The rule from +the previous batch holds: Refs #N is not Closes #N, and a description stating the +issue stays open outranks any topical similarity. + +GitHub auto-closes linked issues only on merge into the default branch. These +merge into dev, so every closure here is manual. + +### Source pull requests carried by this train + +Each source is closed only after its carry is verified on dev by +git merge-base --is-ancestor, with a comment naming the carry, its merge commit, +and the trailer that preserved the author credit. + +| Source | Author | Carried by lane | +| --- | --- | --- | +| #4455, #4086, #4409, #4387 | jeongjin0, Eleven-is-cool, yxr1995-maker, luvs01 | R | +| #4438, #4389, #4457 | Yongzhaooo, olddonkey, jeongjin0 | C | +| #4382, #4413, #4170 | luvs01, rrmlima, yeongjunyoo | L | +| #4381, #4388, #4460 | luvs01, luvs01, AgenticLab-SH | B | +| #4447 | Veritas-7 | S | +| #4077 | laerad777 | X | +| #3663 | y2ambition-ai | H — no carry needed; already on dev as a33b51eb via #4360 | + +#4171 (rrmlima) closes against lane R #4455 carry as a duplicate, with both +authors named in that landing. + +### Issues expected to close, and three that are conditional + +#4412 with lane R, #4439 and #4456 with lane C, #3729 with lane L, #4425 and +#4442 with lane I1, #4430 and #4435 with lane I2, and #4429 with lane I3. + +#3775 and #4436 in lane I3, and #4454 in lane I4, are conditional rather than +expected, for the reasons below. + +Each closure is re-verified against landed dev before it is executed, because the +map above is written while the lanes are still in flight. + +Three of those closures are conditional rather than expected. #3775 may already be +covered by the effort ceilings #4349 landed, #4436 may reduce to a capability +declaration on the contract #4374 and #4376 established, and #4454 may be the same +envelope defect as #3661 seen from the other side. If a lane finds no defect to +fix, the issue gets a comment naming what was found and stays open. A closure is +never claimed against a fix that was not written. + +### Issues that stay open + +#4191, #4311, #3661, #3781, #4312 and #3506 are partially landed. They receive a +comment naming what landed and what remains, and they stay open. The previous +batch checked 24 closure candidates and every one came back KEEP for exactly this +reason: related work had shipped, but the actual ask had not been met. + +## Credit repair + +#4431 landed the xAI OAuth Fast classification without a trailer for the author of +#4077, who had proposed the registry change first. The landing was independently +derived from a live probe, so this is not a silent carry, but CREDITS.md is the +place that distinction gets recorded rather than left to memory. wp6 adds the +entry. + +## Outcome document + +060_outcome.md records, for a reader who was not in the loop: what landed and how, +the run ids that prove it, what the tips caught that per-link CI would have caught +earlier, what did not land and why, and the honest limits of the proof — +specifically that non-tip pull requests merged without their own ci check under a +recorded owner authorization. + +LOOP-PESSIMIST-01 applies to that document: it also records which hypothesis died +and what evidence would show the tip-only CI economy is the wrong trade. diff --git a/devlog/_plan/260913_contributor_carry_train/060_outcome.md b/devlog/_plan/260913_contributor_carry_train/060_outcome.md new file mode 100644 index 0000000000..3cd0ca6548 --- /dev/null +++ b/devlog/_plan/260913_contributor_carry_train/060_outcome.md @@ -0,0 +1,123 @@ +# Outcome — the contributor carry train + +Twelve lanes were dispatched to land the open contributor work scored 60 or +higher. Eleven landed, one needed nothing, and one is recorded separately below. + +## What landed + +| Wave | Lane | Tip | Merge commit | Carried | +| --- | --- | --- | --- | --- | +| 1 | L | #4481 | d865aacf93 | #4382 luvs01, #4413 rrmlima | +| 1 | B | #4480 | 2af30c2d0e | #4381 luvs01, #4388 luvs01, #4460 AgenticLab-SH | +| 1 | X | #4474 | 2296e485d6 | #4077 laerad777, copy residue only | +| 1 | I1 | #4486 | a3ca64f605 | issues #4425, #4442 | +| 1 | I2 | #4482 | 990cd8cce5 | issues #4430, #4435 | +| 1 | C | #4487 | 55bb9f3fef | #4438 Yongzhaooo, #4389 olddonkey, #4457 jeongjin0 | +| 1 | R | #4489 | 3f76ce415d | #4455 jeongjin0, #4409 yxr1995-maker, #4387 luvs01 | +| 1 | S | #4477 | 981b53e7d0 | #4447 Veritas-7 | +| 2 | I3 | #4500 | 94063d0798 | #4467 jaychou0642-create, issue #3775 | +| 2 | I4 | #4498 | 8e6c99608c | issue #4454 | +| 2 | H | — | — | nothing to carry; #3663 was already on dev | + +Every merge used the same gate: a Cross-platform CI run concluded success on the +exact tip head SHA, the merge commit was verified with +`git merge-base --is-ancestor` against `origin/dev` afterwards, and the +`Co-authored-by` trailers were read out of the landed commits rather than the +pull request bodies. + +## Dispositions + +Closed with evidence naming the landing and merge commit: source pull requests +#4382, #4413, #4170, #4381, #4388, #4460, #4077, #4438, #4389, #4457, #4455, +#4409, #4387, #4171, #4086, #4465, #4467, #3663, #4447, plus the carry links +GitHub did not auto-close (#4479, #4473, #4485). Issues #4425, #4442, #4430, +#4435, #4439, #4456, #4412, #3729, #4436, #3775 and #4454 were closed the same +way. + +Nine further contributor pull requests were closed before the train started, +because the previous 36-PR batch had already absorbed them: #4319, #4310, #3652, +#4229, #4216, #4119, #4317, #4080 and #3458. + +Six issues stay open with a status comment naming what landed and what remains: +#4191, #4311, #3661, #3781, #4312 and #3506. Each one has merged work that +references it and no merged work that closes it, which is the distinction the +previous batch learned to make. + +## What the reviews caught + +Every audit round in this train found something real, which is the argument for +running them rather than trusting a green tip. + +The roadmap itself failed its first audit on five blockers, including two lanes +prepared as peers that both write `src/server/responses/core.ts`, and a lane +claiming a Windows CI leg that only runs on `workflow_dispatch`. + +The dispatch packets failed their first audit on five more. The severe one was +an unqualified "never merge" in the common frame, which would have stopped every +lane from running the `git merge origin/dev` the roadmap requires at a drifted +tip. Another would have had lane X cherry-pick a commit that re-breaks an +evidence-based model exclusion. + +Lane S took two security rounds. The first found that the canonical OpenAI seed +defines only four keys, so "ignore keys the seed never defines" reached `headers` +— which the PATCH mask writes and the forward adapter applies to the upstream +ChatGPT request ahead of incoming headers. A dashboard-session PATCH would have +ridden every later request. + +Lane I4 took three. Rounds one and two each found the same defect wearing a +different payload: combo children bypassed the repair on their own cloned body, +and the matcher required a well-formed Fernet token so near-miss ciphertext fell +straight back into the original path. Round two then found the fix had traded +fail-open for data loss — `looksLikeBackendCiphertext` is length ≥ 64 over a +character class that a SHA-256 digest matches exactly. The landed version keeps +the two slot kinds asymmetric: an `encrypted_content` slot is stripped whatever +it holds, free text is matched strictly. + +## The lesson that repeated + +Three planned carries turned out to be already satisfied on dev: #4170 in lane L, +#4086 in lane R, and the whole of lane H. All three were found by attempting the +work, not by reading the plan. + +The inverse happened twice. #4465 proposed the #4442 fix before lane I1 existed +and was not in the scored inventory, because the inventory is a snapshot taken +before it was opened; the landing carries a trailer for its author. #4467 was +caught the same way, but at dispatch time rather than after the fact, because the +first incident turned into a standing check. + +Both directions are the same defect in the plan, not in the lanes: a snapshot of +the open queue is stale the moment it is taken, and only the lane touching the +code can tell. + +## Honest limits of the proof + +Non-tip pull requests merged without their own `ci` check, under the recorded +owner authorization for tip-only CI. What makes that defensible is that each lane +is cumulative, so the content of every link is a strict subset of what its tip's +green run executed; the evidence exists, attached to the tip. + +No local full test suite was run at any point. Every suite claim traces to a +hosted run id. + +Most `dev` runs triggered mid-batch ended `cancelled` as the next merge +superseded them inside the concurrency group. That is the workflow behaving as +configured, and the regression evidence is the completed runs on batch-containing +commits rather than those cancelled ones. + +## LOOP-PESSIMIST-01: what did not improve + +The hypothesis that died is that a scored inventory plus a written roadmap is +enough to dispatch from. It was wrong twice in each direction, and the correction +was not a better inventory — it was giving every lane the obligation to +re-verify its own inputs before writing code. + +What did not improve is lane-thread observability. `list_threads` is capped at +50 and the newest lane tasks fell outside it repeatedly, so reaching a lane +required reading session files off disk to recover its thread id. Coordination +worked anyway, but it worked around the tool rather than through it. + +What would show the tip-only CI economy is the wrong trade: a defect landing on +`dev` that a per-link run would have caught and the cumulative tip run did not. +No instance appeared in this batch or the previous one. That is not proof it +cannot happen — a lane whose links conflict semantically rather than textually +could still produce one — and it is the specific thing to watch for. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/000_plan.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/000_plan.md new file mode 100644 index 0000000000..2011ce6f46 --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/000_plan.md @@ -0,0 +1,199 @@ +# Cross-platform Codex desktop-app restart, folded into `--restart-codex` + +Status: OPEN. Opened 2026-09-13. Class C4 (public CLI contract change, process +termination on three operating systems, management-API contract change). + +## 1. Objective + +`ocx sync --restart-codex` must fully quit and relaunch the Codex desktop app on +macOS, Linux and Windows, not merely send SIGTERM to `codex app-server` children. +The Windows-only `--restart-desktop-app` capability becomes one cross-platform +shared surface that every caller reads, and `ocx system codex-restart` restarts the +desktop app through that same surface instead of being app-server-only. + +The maintainer report that opened this unit: `ocx sync --restart-codex` stopped +having any observable effect. The measured cause is in §3. + +## 2. Constraints + +- **No local product suite.** `bun run test`, `bun run typecheck`, `bun run build:gui` + and installs are NOT RUN for this unit. Proof is hosted CI at the exact final head. + Focused local reads are for debugging only and are never quoted as a gate. +- Everything fails **closed**. A failed discovery, a failed enumeration, an + unreadable process identity or an unreadable ancestry chain must never be read as + "nothing to do" and must never authorise a kill. This is the existing doctrine in + `src/codex/desktop-app-restart.ts` and `src/codex/app-server-processes.ts`; the + cross-platform rewrite inherits it unchanged. +- Only the **current user's** processes are ever signalled, on every platform. +- Executables are resolved from trusted absolute system locations, never from PATH. +- The relaunch never creates a **second** instance. If anything survived + termination, nothing is relaunched and the operator is told. +- Out of scope: the proxy's own restart (`system-restart-contract.ts`), Claude + Desktop, Cursor, any provider or routing behaviour, and the GUI's visual design. + +## 3. Measured cause of "`--restart-codex` does nothing" + +Measured live on 2026-09-13; full evidence in `001_platform_topology.md`. + +The macOS Codex desktop app runs its app-server as a bundle-internal child: + +``` +72687 1 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT +73511 72687 /Applications/ChatGPT.app/Contents/Resources/codex -c features.code_mode_host=true app-server ... +76297 73511 /Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host +``` + +`isCodexAppServerCommandLine` **does** match pid 73511, so `--restart-codex` is not +failing to find a target. It signals the app's own child, the app immediately +respawns it, and the renderer keeps the model list it built at app start. The +result an operator sees is "the command ran and nothing changed" — the same +symptom #2292 recorded on Windows, now on macOS too, because the picker's cache +lives in the shell rather than in the app-server. + +So the fix is not a better matcher. The only thing that reliably refreshes the +picker is restarting the shell that owns it, which is exactly what +`--restart-desktop-app` already does on Windows and what no platform other than +Windows can currently do at all. + +## 4. The consent question, decided + +`--restart-desktop-app` was deliberately kept separate from `--restart-codex` +(see the module header of `src/codex/desktop-app-restart.ts`): quitting the desktop +app ends live conversations, which is a larger consent than restarting a background +helper. That reasoning was sound and is now **superseded by an explicit maintainer +decision**: `--restart-codex` must mean "the Codex app is fully stopped and started +again". The narrow behaviour does not disappear — it moves to an explicit +`--restart-app-server-only` flag — so no caller loses the ability to ask for it. + +`--restart-desktop-app` keeps working as a deprecated alias so existing scripts and +the published documentation do not break in the same release that changes the +meaning of the other flag. + +## 5. Work-phase map (dependency-ordered) + +| # | Work-phase | Doc | Depends on | +|---|---|---|---| +| wp1 | Docs-first roadmap (this unit) | `000`, `001` | — | +| wp2 | Cross-platform shared restart surface | `010` | wp1 | +| wp5 | Detached self-handoff restart | `020` | wp2 | +| wp3 | CLI + management contract merge, docs and generated surfaces | `030` | wp2, wp5 | +| wp4 | Live three-host verification, hosted CI, PR, merge | `040` | wp3 | + +`002` records the A-phase audit findings and their dispositions. It is part of wp1's +output: the roadmap was audited by three independent reviewers before any +implementation, two returned FAIL, and six blockers were folded back into `010`, +`020` and `030` before wp2 started. + +Execution order is wp1 -> wp2 -> wp5 -> wp3 -> wp4. The goalplan ids are not +chronological because wp5 was appended after the first four were registered +(LOOP-UNIT-CHAIN-01); the dependency column above is authoritative. + +## 6. Why wp5 exists + +The self-ancestry guard refuses to restart the desktop app when the command is +running **inside** it. On this maintainer's machine that is the normal case: the +shell that runs `ocx` is a descendant of `ChatGPT.app` through the app-server +(`10456 -> 73511 -> 72687 -> launchd`). Without wp5 the merged flag would refuse in +exactly the situation that produced the original complaint, and the feature would +still "not work". + +wp5 replaces the refusal with a handoff: a fully detached helper outlives the +caller, waits for it to exit, re-enumerates, and then performs the restart from +outside the tree. The guard itself is kept — it is what decides that a handoff is +needed rather than a direct kill. + +## 7. Acceptance + +- `ocx sync --restart-codex` fully quits and relaunches the Codex desktop app on + macOS, Linux and Windows, proven by a root-process identity change on a real host + of each platform. +- `ocx system codex-restart --yes` does the same through the same module. +- One module owns discovery, stop and relaunch; no caller carries a per-platform + branch. +- `--restart-desktop-app` still works and says it is deprecated. +- `--restart-app-server-only` reproduces the old `--restart-codex` behaviour. +- `ocx catalog pull --restart-codex` means the same thing as `ocx sync --restart-codex`, + so the flag has one meaning across the CLI (`002` §B4). +- The remote `POST /api/machine/sync` `restartCodex` field does **not** gain desktop + scope (`002` §B5). +- Hosted CI green at the exact final head; PR merged into `dev`. + +## 8. Terminal outcomes + +- **DONE** — every item in §7 has fresh evidence recorded in `040`. +- **BLOCKED** — a host required for platform proof is unreachable and no equivalent + host of that platform exists. Record which platform lacks proof; do not claim it. +- **UNSAFE** — any design that could terminate a process outside the discovered, + current-user, package-owned tree. Stop and redesign. +- **NEEDS_HUMAN** — CI red at the final head for a reason outside this unit's scope. + +## 9. Resume state + +Kept current so a later cycle, or a reader after a context loss, resumes from this +file rather than from a transcript. + +| Work-phase | State | Artifact | +|---|---|---| +| wp1 roadmap | **done** | `000`, `001`, `002`, `010`, `020`, `030`, `040` on `codex/260913-cross-platform-desktop-restart` | +| wp2 shared surface | **done** | `010`; commits de44e6a6..49e36f1d | +| wp5 self-handoff | **built, cycle not yet closed** | `020`; commits d1efbebd, 75722903 | +| wp3 contract merge | **done** | `030`; commits 8ebbdc5c..7ac03181 | +| wp4 verification and delivery | not started | `040` | + +**What wp1 concluded.** The inert `--restart-codex` is not a matcher defect — the +matcher finds the app-server correctly, and the app respawns it while the picker +keeps the roster the shell built at launch. Only restarting the shell fixes it, which +is why the Windows-only capability has to become cross-platform rather than the +matcher being widened. Three audit rounds moved the design from "quit the app" to +"quit the app, safely, from a process that will survive doing it", which is where the +handoff and the singleton lock came from. + +**What wp2 concluded.** `010` was built as written. Two independent code audits found +three fail-open defects that the plan had specified correctly and the code had not +implemented: Linux ancestry returned a non-empty chain for an unreadable hop, so a +probe failure would have quit the shell hosting the caller's own session; macOS +classified every dead parent as unreadable, which would have made the wp5 helper +refuse forever; and the lock was not exclusive at all, because `wx` on a uniquely +named staging file always succeeds. All folded and re-audited to PASS. + +The lesson for the remaining phases: a plan section saying "fails closed" is not +evidence that the code does. Each of the three defects reads as correct until the +error shape is checked against what the runtime actually throws. + +**FSM note for whoever resumes.** The cycle currently open is bound to **wp3**, not +wp5: both became ready when wp2 closed and the orchestrator activated wp3. The wp5 +code is built, audited and committed regardless; the open cycle should now deliver +wp3 and close, and wp5's record lives in the goalplan task ledger. + +**Direction for wp5.** Build `020` as written. The lock it depends on already exists +and is verified, including the transfer that lets the helper inherit ownership, so +wp5 adds `handoff.ts`, the hidden `internal` command, and the `startHandoff` seam the +ladder already accepts. `handoff_started` is in the union and confirmed unreachable +until that seam is supplied. + +**What wp3 concluded.** The merged contract shipped across `sync`, `sync-cache`, +`catalog pull` and `ocx system codex-restart`, with docs in English and seven +locales. Three audit rounds were needed. The recurring failure was not the design +but the seams between the pieces: `excludePids` was passed to an option that did not +exist, `desktopAppRestarted` was computed and dropped, `restartIncomplete` was +assigned where it should only ever be set, and three source-oracle tests still +pinned the contract the change reverses. + +**Two residuals accepted, both reviewed and recorded rather than hidden:** + +- **Windows `excludePids` is a no-op for app-servers.** The CIM probe enumerates + `ChatGPT.exe` while Windows app-servers run as `codex.exe` / `codex-code-mode-host`, + so they still receive SIGTERM before the app quits. The restart is correct; the cost + is one extra interrupted turn on the platform that already had this feature. Closing + it means widening the query that decides what may be killed, which needs its own + verification. Documented at the decision point in `src/cli/restart-scope.ts`. +- **`desktopAppRestarted` appears on an unchanged-catalog pull.** Presence means the + restart was requested and `true` means it relaunched; a failed relaunch is + `ok: false` with `code: "restart_incomplete"`, so no caller can confuse them. + +**Direction for wp4.** Execute `040` as written. The local macOS host cannot prove its +own direct restart and is reserved for the handoff proof, after the merge. + +**Standing constraint.** No local product suite, build, typecheck or install at any +point in this unit (§2). Every completion claim rests on live host evidence plus +hosted CI at the exact final head. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md new file mode 100644 index 0000000000..07da0beeb6 --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md @@ -0,0 +1,172 @@ +# Measured desktop-app topology on three platforms + +Research document for `000_plan.md`. Measurements taken 2026-09-13 on live hosts. +No diffs here by LEXICO-SPLIT-01; the implementation designs are in `010`, `020`, `030`. + +Every value below was read from a running installation. Nothing is inferred from +documentation or from the existing Windows implementation. + +## 1. macOS + +Two hosts were measured: the maintainer's laptop (`local`) and `macmini-cf`. + +| | local | macmini-cf | +|---|---|---| +| Bundle | `/Applications/ChatGPT.app` | `/Applications/ChatGPT.app` | +| `CFBundleIdentifier` | `com.openai.codex` | `com.openai.codex` | +| `CFBundleName` | `ChatGPT` | `ChatGPT` | +| `CFBundleShortVersionString` | 26.908.40834 | 26.901.51231 | +| Root process | pid 15901, ppid 1 | pid 25712, ppid 1 | +| Root argv[0] | `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT` | same | +| bun | `~/.bun/bin/bun` 1.4.0 | `~/.bun/bin/bun` 1.3.14 | +| opencodex checkout | this worktree | `~/Developer/opencodex` | + +The bundle **name** is `ChatGPT` but the bundle **identifier** is `com.openai.codex`. +Discovery must key on the identifier: the display name is shared with a different +OpenAI product and is the wrong thing to match. + +`mdfind "kMDItemCFBundleIdentifier == 'com.openai.codex'"` resolves to the bundle on +both hosts, and `osascript -e 'id of app "ChatGPT"'` returns `com.openai.codex`. + +### 1.1 Process shape + +``` +15901 1 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT +16733 15901 /Applications/ChatGPT.app/Contents/Resources/codex ... app-server ... +28300 16733 /Applications/ChatGPT.app/Contents/Resources/codex-code-mode-host +16722 15901 .../bare-modifier-monitor --key DoubleShift +15910/15911/15913/16916 15901 GPU, network, storage, audio services + renderers +``` + +Two findings that the implementation must respect: + +**Crashpad handlers are launchd children, not app children.** pids 72689 and 72691 +were still alive under ppid 1 from an app instance that had already exited. A "root +process" rule of "parent is not in the tree" would classify a stale crashpad handler +as a restart target. Membership must therefore be decided by executable path inside +the bundle **and** liveness of the shell, and a surviving crashpad handler must not +block the relaunch. + +**The app-server is inside the bundle.** `Contents/Resources/codex ... app-server` is +matched by `isCodexAppServerCommandLine`, so `--restart-codex` already signals it and +the app simply respawns it. This is the measured reason the flag appears to do +nothing (`000_plan.md` §3). + +### 1.2 Quit and relaunch primitives + +- `osascript -e 'quit app id "com.openai.codex"'` delivers `kAEQuitApplication`. This + is the graceful path: the app runs its termination handlers and the helper tree + goes with the root. Delivery is synchronous, **termination is not** — the caller + must poll for the root pid to disappear. +- `kill -TERM ` bypasses `applicationShouldTerminate:`. Usable as the fallback + when the Apple event cannot be delivered, not as the first choice. +- `open -b com.openai.codex` starts the app through LaunchServices and **works when + the app is not running**. That is the relaunch primitive. +- `open -n` requests a second instance. An Electron app holding + `requestSingleInstanceLock()` hands the request to the existing instance instead, + so `-n` does not reliably produce a second instance — and we do not want one. + Relaunch uses plain `open -b`. + +**Precondition, measured rather than assumed:** `open -b` launches into the invoking +user's **GUI session**. Issued over ssh to a Mac where that user has no logged-in +window session, it relaunches into nothing. Both macOS hosts here have an active +session, so the wp4 proof holds — but a headless macOS host would stop the app and +not visibly bring it back, and the operator-facing text must not promise otherwise. + +An unknown bundle id makes `open` exit non-zero with +`LSCopyApplicationURLsForBundleIdentifier() failed`, which is a usable fail-closed +signal rather than a silent no-op. + +### 1.3 The local host cannot prove its own restart + +The shell running `ocx` on the local host is a descendant of the app: + +``` +31497 -> 16733 (bundled codex app-server) -> 15901 (ChatGPT) -> 1 (launchd) +``` + +Quitting the app kills the session issuing the command. This is not a corner case to +document away — it is the maintainer's normal working shape, and it is what wp5 +exists for. `macmini-cf` is the macOS host used for the destructive proof, because +the command there is issued over ssh and is not inside the app tree. + +## 2. Linux + +Host `lidge`, Ubuntu 24.04.4 LTS, x86_64. + +- Package: `chatgpt` 26.903.71938 amd64 ("ChatGPT by OpenAI"), installed via dpkg. +- Binary root: `/usr/lib/chatgpt/ChatGPT`. +- Launcher: `/usr/bin/chatgpt`. +- Desktop entry: `/usr/share/applications/chatgpt.desktop`, `Exec=chatgpt %U`, + registering `x-scheme-handler/codex` among its MIME types. +- User data directory: `~/.config/Codex` — note the directory is `Codex` even though + the package and binary are `chatgpt`. +- Codex CLI also present at `/usr/local/bin/codex` (codex-cli 0.154.0); the desktop + app and the CLI are separate installs on this host. + +### 2.1 Process shape + +``` +3284901 /usr/lib/chatgpt/ChatGPT +3284907 /usr/lib/chatgpt/browser_crashpad_handler --database=~/.config/Codex/Crash Reports ... +3284913 /usr/lib/chatgpt/ChatGPT --type=zygote --user-data-dir=/home/lidgeai/.config/Codex ... +3284951 /usr/lib/chatgpt/ChatGPT --type=gpu-process ... +3284953 /usr/lib/chatgpt/ChatGPT --type=utility --utility-sub-type=network.mojom.NetworkService ... +``` + +Every process in the tree runs an executable under `/usr/lib/chatgpt/`, which is the +install root and therefore the membership test. Electron child processes are +distinguished only by `--type=`; the root is the one without it. + +`~/.codex/app-server-control/` on this host contains `app-server-control.sock` and +`desktop-ssh-websocket-v0.sock`, which is how the desktop app is reached over SSH. + +### 2.2 Relaunch from a non-graphical session + +This is the part with no Windows or macOS analogue. `open -b` and +`Start-Process shell:AppsFolder\...` both hand the launch to a session-aware +service. Linux has no such indirection: a process started from an ssh session has +no `DISPLAY`, no `WAYLAND_DISPLAY`, no `DBUS_SESSION_BUS_ADDRESS` and no +`XDG_RUNTIME_DIR`, and the relaunched app would fail to reach the user's compositor. + +The environment must therefore be **inherited from the process being replaced**: +read `/proc//environ` before terminating it, carry forward only the graphical +session variables, and start the launcher detached with `setsid`. Nothing else in +that environment is copied — it is a process environment belonging to another +session and may contain credentials. + +## 3. Windows + +Host `mini`, Windows (measured through MSYS/MINGW64). + +- Package: `OpenAI.Codex_26.903.9818.0_x64__2p2nqsd0c76g0` (MSIX). +- Processes: `ChatGPT.exe` (pids 13860, 16944, 19696 at measurement time). +- `ocx` present at `/c/nvm4w/nodejs/ocx`, reporting opencodex 2.52.0. + +This matches what `src/codex/desktop-app-restart.ts` already implements: runtime +package discovery through `Get-AppxPackage -Name OpenAI.Codex` with an +`OpenAI.CodexBeta` fallback, current-user scoping through `GetOwner`, graceful +`CloseMainWindow()`, forced `taskkill /PID /T /F`, and relaunch through +`Start-Process 'shell:AppsFolder\!App'`. + +The Windows behaviour is the reference the other two platforms are being brought up +to, and it is not being changed except where the shared ladder replaces duplicated +logic. + +## 4. What generalises and what does not + +| Step | macOS | Linux | Windows | +|---|---|---|---| +| Identity | bundle id `com.openai.codex` | install root `/usr/lib/chatgpt` | Appx package family | +| Discovery | LaunchServices / known bundle path | launcher + dpkg install root | `Get-AppxPackage` | +| Membership | exe under bundle, same uid | exe under install root, same uid | exe under `InstallLocation`, `GetOwner` = me | +| Graceful stop | `osascript` quit Apple event | `SIGTERM` to root | `CloseMainWindow()` | +| Forced stop | `SIGKILL` | `SIGKILL` | `taskkill /T /F` | +| Relaunch | `open -b ` | `setsid ` + inherited session env | `Start-Process shell:AppsFolder\` | +| PID-reuse guard | process start time | `/proc//stat` start time | `CreationDate` | + +The **ladder** — discover, enumerate, find roots, check self-ancestry, graceful, +wait, re-verify identity, force, wait, refuse-or-relaunch — is identical on all +three. Only the seven rows above differ, which is what makes a single shared +orchestrator with three small adapters the right shape rather than three parallel +implementations. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/002_audit_findings.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/002_audit_findings.md new file mode 100644 index 0000000000..b53c36d9ff --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/002_audit_findings.md @@ -0,0 +1,131 @@ +# A-phase audit findings and dispositions + +Three independent audits were run against `000`-`040` before any implementation: +adversarial process-termination safety, platform-primitive verification against live +hosts, and repository-coverage completeness. + +Verdicts: safety **FAIL** (3 blockers), coverage **FAIL** (3 blockers), primitives +**PASS** (0 blockers, 6 nits). Every blocker is folded below. Nothing is rebutted +away. + +## Blockers + +### B1 — the ancestry bound failed open (safety) + +`ancestryPids` is bounded at 16 hops, and the plan never said what happens when the +bound is **hit**. A truncated list makes the self-ancestry intersection miss, so the +direct path would terminate the caller's own tree — the exact failure wp5 exists to +prevent. Deep nesting is not hypothetical here: agent shell, app-server, nested +`ocx`, tmux. + +**Disposition: accepted.** Hitting the bound returns `[]`, identical to an unreadable +hop, so the ladder fails closed into `self_ancestry` and therefore into a handoff. +Recorded in `010` §3.4. + +A second, opposite semantic had to be pinned at the same time (primitives nit 1): a +parent pid that names **no live process** is a clean end of chain, not a read +failure. Windows never reparents orphans, so the handoff helper always has a dead +parent link; treating that as unreadable would make the helper refuse forever and +wp5 would never work on Windows. Both semantics live in the same paragraph because +getting one right and the other wrong breaks the feature in opposite directions. + +### B2 — concurrent handoffs could kill the relaunched app (safety) + +Two `ocx sync --restart-codex` runs inside the app, or a handoff racing an ssh-issued +direct run, spawn two ladders. Helper A quits and relaunches; helper B re-enumerates +during the relaunch, sees the **new** root as a target, and kills it. Repeatedly. + +**Disposition: accepted.** An atomic singleton lock now guards every restart that +acts, direct or handoff. Recorded in `020` §4.1. + +### B3 — path-prefix membership was not boundary-aware (safety) + +"`comm` starts with `/`" and "under `/`" implemented as a raw +`startsWith` admits `/usr/lib/chatgpt-evil/...` and `/Applications/ChatGPT.app-evil/...` +as members. + +**Disposition: accepted.** The root is `realpath`-resolved once at discovery and +compared with an explicit trailing separator. Recorded in `010` §2.1. + +### B4 — `catalog pull` was missing, and the plan contradicted a documented exclusion (coverage) + +`030` §3.1 claimed one shared scope helper served `catalog pull`, but `src/cli/catalog.ts` +appeared nowhere in the file table, its `knownFlags` set would **reject** the new +flags as a usage error, and it is a fourth `afterCatalogWriteHandleAppServers` call +site. Meanwhile `docs-site` states in English plus `zh-cn`, `zh-tw`, `tr` and `ru` +that desktop restart is not part of that command. + +**Disposition: accepted, resolved toward consistency.** `catalog pull` gets the +merged meaning. + +The documented exclusion and the `sync` split are not the same kind of statement, and +that is what decides it. The `sync` split was a **consent** decision, argued on the +grounds that quitting the app ends live conversations +(`devlog/_fin/260822_backlog_disposition_program/040_wp4_issue_2292_windows_picker.md`). +The `catalog pull` sentence is a **scope** statement: the capability was Windows-only +and nobody wired it there. Reversing a consent decision needs the maintainer +instruction in `000` §4, which exists. Reversing a scope statement needs only the +capability, which this unit builds. + +A flag that means two different things depending on which subcommand it follows is +the confusion this unit is removing, so `--restart-codex` means one thing everywhere. +Full file list in `030` §3.4 and the locale list in `030` §7. + +### B5 — the wire `restartCodex` field was unspecified across a machine boundary (coverage) + +`POST /api/machine/sync` accepts `restartCodex` from a remote hub +(`src/client/machine-api.ts:79-95`), and `syncConnectedClient` takes it and +deliberately ignores it (`src/client/connect.ts:649-650`, the `_options` underscore). +Under merged semantics the same name would silently mean "quit the user's desktop +app" over a network boundary. + +**Disposition: accepted, resolved as no change in meaning.** The wire field keeps +app-server-only semantics and stays unhonored. A remote hub does not get to end a +local user's conversations because a field name changed underneath it; that is a +consent boundary, and the maintainer instruction in `000` §4 is about the local CLI +flag, not about remote callers. + +This is pinned by a test rather than left to a comment, because the failure mode is +a future contributor "finishing" an obviously-dead parameter. Recorded in `030` §4.1. + +### B6 — the post-write helper returned `void` (coverage) + +`catalog pull` derives `codexRestarted` and its `restart_incomplete` code from the +restart result, which a `void` helper cannot provide. + +**Disposition: accepted.** The helper returns a `RestartScopeOutcome`. Recorded in +`030` §3.3. + +## Nits accepted + +| # | Finding | Where folded | +|---|---|---| +| N1 | Linux install root must be trusted, not just `dirname(realpath(launcher))` | `010` §5 discovery | +| N2 | macOS `lstart` is 1-second granular; same-second reuse by another member defeats equality | `010` §2.1 residual | +| N3 | `/proc//stat` field 22 must be parsed after the **last** `)` | `010` §5 | +| N4 | member ordering must sort start time numerically, not lexically | `010` §5 | +| N5 | macOS multi-install: path-scoped membership vs bundle-id-scoped quit/relaunch | `010` §4 | +| N6 | Linux relaunch env needs `HOME`/`USER`/`LANG`/minimal `PATH`, not only the five session vars | `010` §5 | +| N7 | `excludePids` can go stale between enumeration and the signal pass | `030` §2 | +| N8 | the hidden handoff command is intentionally unauthenticated; say why | `020` §5 | +| N9 | `open -b` needs a logged-in GUI session; headless macOS relaunches into nothing | `001` §1.2 | +| N10 | `taskkill /T` pid-recycle race could pull the helper into the kill set | `020` §8 | +| N11 | helper spawn `args` recipe is unspecified for installed vs checkout `ocx` | `020` §4.2 | +| N12 | `open -b` foregrounds the app; make it deliberate | `010` §4 | +| N13 | `010` §3.1's reason union is extended by wp5 | `010` §3.1 | +| N14 | `warnIfStaleCodexAppServersAfterStartupWrite` stays warn-only | `030` §5 | +| N15 | `layout.explicit` must equal the fixture table if anyone adds an entry | `030` §6 | + +## Confirmed by live probe, not assumed + +The primitives audit reproduced every platform claim on real hosts rather than +trusting the plan: `open -b` launching a non-running app and failing loudly on an +unknown bundle id, `osascript` `quit app id` syntax, macOS `ps -o comm=` returning +untruncated full paths over 140 characters, `/usr/bin/setsid` present on `lidge` with +`spawn({detached:true})` + `setsid` being redundant-but-safe, `/proc//environ` +being 1902 NUL bytes while children carry `XDG_RUNTIME_DIR`, and a detached +`unref()`ed Bun child outliving its parent. It validated the destructive primitives +against Calculator rather than the Codex app. + +That matters for one claim in particular: `ps -o comm=` truncation would have +silently broken macOS membership, and it was checked rather than reasoned about. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/010_phase1_shared_restart_surface.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/010_phase1_shared_restart_surface.md new file mode 100644 index 0000000000..37915d7402 --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/010_phase1_shared_restart_surface.md @@ -0,0 +1,411 @@ +# wp2 — Cross-platform shared desktop-app restart surface + +Diff-level design. Executes after `000`/`001` are locked. Depends on nothing but the +current `dev` tree. + +## 1. Shape + +`src/codex/desktop-app-restart.ts` stays the public entry point — it is what +`src/cli/dispatch.ts` imports and what `tests/clients/desktop-app-restart.test.ts` +drives — but its body becomes a platform-independent ladder over three adapters. + +``` +src/codex/desktop-app-restart.ts MODIFY public entry + shared ladder +src/codex/desktop-app/types.ts NEW adapter contract and shared result types +src/codex/desktop-app/darwin.ts NEW bundle discovery, Apple-event quit, open -b +src/codex/desktop-app/linux.ts NEW install-root discovery, SIGTERM, setsid relaunch +src/codex/desktop-app/windows.ts NEW the existing Appx/CIM/taskkill logic, moved verbatim +``` + +`src/codex/` is already claimed in `structure/manifest.json`, so the new +subdirectory inherits ownership and does not create an unclaimed `src/` area. + +## 2. The adapter contract — `src/codex/desktop-app/types.ts` (NEW) + +```ts +/** One discovered installation of the Codex desktop app. */ +export interface DesktopAppInstall { + /** Stable platform-specific identity, logged and used for relaunch. */ + id: string; + /** Absolute path every member process's executable must live under. */ + root: string; + /** Opaque relaunch descriptor the adapter understands. */ + relaunch: string; +} + +export interface DesktopProcess { + pid: number; + parentPid: number; + /** Platform-native start-time token. Guards against PID reuse. Never parsed. */ + createdAt: string; +} + +export interface DesktopAppExecOptions { + timeout?: number; + windowsHide?: boolean; +} + +export type DesktopExec = ( + file: string, + args: readonly string[], + options?: DesktopAppExecOptions, +) => string; + +export interface DesktopAppAdapter { + /** null = discovery failed. Never throws. */ + discover(exec: DesktopExec): DesktopAppInstall | null; + /** null = the probe could not run. [] = it ran and found nothing. */ + listProcesses(exec: DesktopExec, install: DesktopAppInstall): DesktopProcess[] | null; + /** Ancestry of the current process, innermost first. [] = unreadable. */ + ancestryPids(exec: DesktopExec): number[]; + /** Ask the app to quit. Best effort; the ladder decides what happens next. */ + requestQuit(exec: DesktopExec, install: DesktopAppInstall, root: DesktopProcess): void; + /** Unconditional termination of one root and its tree. */ + forceStop(exec: DesktopExec, root: DesktopProcess): void; + /** + * Capture whatever the relaunch will need from the LIVE tree, before anything + * is stopped. Linux needs the graphical session environment; the other two + * return an empty record. + */ + captureRelaunchContext( + exec: DesktopExec, + install: DesktopAppInstall, + processes: readonly DesktopProcess[], + ): Record; + /** Start the app again. Throws on failure; the ladder reports relaunch_failed. */ + relaunch( + exec: DesktopExec, + install: DesktopAppInstall, + context: Record, + ): void; +} +``` + +`captureRelaunchContext` is on the contract rather than hidden inside the Linux +adapter because of its **ordering obligation**: it must run while the tree is still +alive. A shared ladder that called it after termination would work on macOS and +Windows and silently produce an app that cannot reach the compositor on Linux. +Putting it in the contract makes the ordering a property of the ladder, checked in +one place. + +### 2.1 Membership is boundary-aware (audit B3, N2) + +"Executable lives under the install root" is a **path boundary** test, not a string +test. Implemented as a raw `startsWith`, an install root of `/usr/lib/chatgpt` also +matches `/usr/lib/chatgpt-evil/ChatGPT`, and `/Applications/ChatGPT.app` matches +`/Applications/ChatGPT.app-evil/...`. Both are plantable by the same user whose +processes we are about to signal, so uid scoping does not cover it. + +Each adapter therefore resolves its root through `realpath` **once, at discovery**, +stores the resolved form, and compares candidates against `resolvedRoot + sep`. A +candidate equal to the root itself is also a member. Nothing compares unresolved +paths, so a symlinked sibling cannot smuggle itself in. + +**Residual, stated rather than hidden:** macOS `lstart` has one-second granularity. +If a member pid is recycled into *another member of the same tree* within the same +second, `createdAt` equality passes on a different process. The victim is still +inside the package tree, so the `§8` UNSAFE boundary holds and nothing outside the app +is ever signalled — but the guard's promise is "same process", and at one-second +resolution it is really "same process, or a same-second replacement inside the same +app". Linux (`starttime` jiffies) and Windows (`CreationDate`) do not have this gap. + +## 3. The shared ladder — `src/codex/desktop-app-restart.ts` (MODIFY) + +Exports that must not change, because callers and tests bind to them: +`restartCodexDesktopApp`, `DesktopAppRestartIo`, `DesktopAppRestartResult`, +`DesktopAppRestartReason`, `DesktopAppExecOptions`. + +### 3.1 Reason codes + +```ts +export type DesktopAppRestartReason = + | "unsupported_platform" // RENAMED from "windows_only" + | "package_discovery_failed" + | "process_probe_failed" + | "no_targets" + | "self_ancestry" // retained; wp5 turns this into a handoff + | "targets_survived" + | "restart_in_flight" // another restart holds the singleton lock (020 §4.1) + | "relaunch_failed"; // NEW +``` + +`relaunch_failed` is new because the current code is dishonest about it: a failed +`Start-Process` returns `reason: "targets_survived"` with an empty `surviving` array +(`src/codex/desktop-app-restart.ts:349-351`), which tells the operator that +processes would not die when in fact everything died and the relaunch is what +failed. Those are different problems with different manual recoveries. + +`windows_only` is renamed rather than kept as an alias. It is a closed union +consumed by one `switch` in `src/cli/dispatch.ts`; keeping a value that can no +longer occur would leave dead prose in the CLI telling users about a restriction +that no longer exists. + +wp5 extends this union with `handoff_started` (`020` §6). Treat it as open until that +phase lands, and make the `switch` in `src/cli/dispatch.ts` exhaustive against the +final union, not this one. + +`restart_in_flight` is declared here rather than in wp5 even though the lock is a wp5 +concern, because a reason code the design *guarantees* will occur cannot live outside +the union its only `switch` is checked against. The lock is taken by +`restartCodexDesktopApp` itself — step 0 below — and not by any caller, so every +entry point (the CLI, the handoff helper, the management service) gets the same +mutual exclusion and the same reason code without each having to remember it. + +### 3.2 Ladder + +``` + 0. take the singleton lock (020 §4.1) -> restart_in_flight + 1. adapter = ADAPTERS[platform] ?? null -> unsupported_platform + 2. install = adapter.discover(exec) -> package_discovery_failed + 3. processes = adapter.listProcesses(exec, install) + null -> process_probe_failed (could not look != nothing there) + 4. roots = rootProcesses(processes) -> no_targets when empty + 5. ancestry = io.ancestryPids?.() ?? adapter.ancestryPids(exec) + [] -> self_ancestry (unreadable fails closed) + intersects processes -> self_ancestry (wp5: handoff instead) + 6. context = adapter.captureRelaunchContext(exec, install, processes) <-- tree alive + 7. for each root: + re-verify identity (listProcesses, match pid AND createdAt) + unverifiable -> treat as already stopped, do not signal + adapter.requestQuit(...) + waitForExit(GRACEFUL_EXIT_TIMEOUT_MS) -> stopped + re-verify identity again <-- the wait window allows PID reuse + adapter.forceStop(...) + waitForExit(FORCED_EXIT_TIMEOUT_MS) -> stopped | surviving + 8. surviving.length > 0 -> { relaunch: "skipped", reason: "targets_survived" } + 9. adapter.relaunch(exec, install, context) + throws -> { relaunch: "skipped", reason: "relaunch_failed" } +10. { attempted: true, stopped, surviving: [], relaunch: "started" } +``` + +Step 0 is held until step 10 returns, released in a `finally` — except on the wp5 +handoff path, where ownership is transferred to the helper instead of released +(`020` §4.1). + +Steps 4, 7 and 8 are lifted unchanged from the current Windows implementation — +`rootProcesses`, `stillSameProcess`, `waitForExit` and the two timeout constants move +into the ladder as-is. This is deliberate: that code already survived a review round +about PID reuse across the graceful-close window, and re-deriving it per platform is +how that lesson gets lost. + +### 3.3 Root selection, corrected for macOS + +`rootProcesses` currently returns members whose parent is not itself a member. On +macOS that is not sufficient: `001` §1.1 measured crashpad handlers at ppid 1 that +outlived an app instance that had already exited. Under the current rule a stale +crashpad handler is a "root" and therefore a termination target and, worse, a +potential `surviving` entry that blocks the relaunch forever. + +The rule becomes: a root is a member whose parent is not a member **and** whose +executable is the app shell itself, not a helper. Each adapter supplies the shell +predicate, because "the shell" is `Contents/MacOS/ChatGPT`, `/usr/lib/chatgpt/ChatGPT` +without a `--type=` argument, and `ChatGPT.exe` respectively. Helpers are still +enumerated — they are what `captureRelaunchContext` reads on Linux — but they are +not signalled directly; terminating the shell takes them. + +Helpers are also never counted as `surviving`. Measured on this machine: the live app +(root 15901) owns crashpad handlers 15903 and 15905 at **ppid 1**, and an app instance +that had already exited had left 72689 and 72691 behind, also at ppid 1. Crashpad +handlers are launchd children by design and can outlive the shell. If a surviving +helper blocked the relaunch, the very first restart on any macOS machine would leave +the user with no app at all. + +### 3.4 Ancestry-walk semantics (audit B1, primitives N1) + +Two opposite mistakes are possible here, and each breaks the feature in a different +direction, so both are pinned: + +- **A parent pid that names no live process is a clean end of chain**, not a read + failure. Windows never reparents orphans, so the wp5 handoff helper *always* has a + dead parent link once its caller exits. An implementation that read that as + "unreadable" would fail closed into `self_ancestry` forever and the helper could + never do the one job it exists for. The same applies to `ps -o ppid= -p ` on + macOS returning empty output. +- **Hitting the 16-hop bound returns `[]`**, identical to an unreadable hop. A + truncated chain silently defeats the self-ancestry intersection, and the direct + path would then terminate the caller's own tree. Sixteen hops is not a generous + margin in this environment — agent shell, app-server, nested `ocx`, tmux, a login + shell — so the bound being reached is a real state, and the safe reading of it is + "I could not establish that I am outside the tree". + +Fail-closed here means a handoff, not a refusal, once wp5 lands. That is what makes +the conservative reading cheap enough to always take. + +## 4. macOS adapter — `src/codex/desktop-app/darwin.ts` (NEW) + +**discover.** Ask LaunchServices first, fall back to the conventional path: + +``` +/usr/bin/mdfind "kMDItemCFBundleIdentifier == 'com.openai.codex'" -> first line +fallback: /Applications/ChatGPT.app +``` + +Either way the candidate is confirmed by reading +`Contents/Info.plist:CFBundleIdentifier` with `/usr/libexec/PlistBuddy` and requiring +`com.openai.codex`. `001` §1 is why the check is on the identifier and not the name: +the bundle is called `ChatGPT.app` and shares that name with a different product. + +`install = { id: "com.openai.codex", root: "", relaunch: "com.openai.codex" }`. + +**Multi-install ambiguity (N5).** Membership is path-scoped while `osascript` quit and +`open -b` are bundle-id-scoped. If two bundles claim `com.openai.codex`, the ladder +could enumerate one and quit the other. Discovery therefore prefers the bundle that +the **running root process** is executing out of, and only falls back to `mdfind` and +then `/Applications/ChatGPT.app` when nothing is running. Whatever is quit is then +the thing that was enumerated. + +**listProcesses.** `/bin/ps -Ao pid=,ppid=,lstart=,uid=,comm=`, keep rows whose +`comm` starts with `/` and whose uid equals `process.getuid()`. +`createdAt` is the raw `lstart` string, compared verbatim and never parsed. + +**ancestryPids.** Walk `/bin/ps -o ppid= -p ` from `process.pid` to 1, bounded at +16 hops, breaking on a repeat. An unreadable hop returns `[]`, which the ladder +fails closed on. + +**requestQuit.** `/usr/bin/osascript -e 'quit app id "com.openai.codex"'`. This is the +Apple event, so the app runs its own termination path. Delivery is synchronous and +termination is not, which is why the ladder always waits and re-verifies. If +`osascript` throws, the ladder proceeds to `forceStop`. + +**forceStop.** `process.kill(pid, "SIGKILL")`. + +**captureRelaunchContext.** `{}` — LaunchServices supplies the session. + +**relaunch.** `/usr/bin/open -b com.openai.codex`. Not `open -n`: `001` §1.2 records +that it does not reliably produce a second instance, and a second instance is not +wanted regardless. Deliberately **without** `-g`: the operator asked for a restart and +expects the app back in front of them, so foregrounding is the intended behaviour +rather than an oversight. An unknown bundle id makes `open` exit non-zero with +`LSCopyApplicationURLsForBundleIdentifier() failed`, which the ladder reports as +`relaunch_failed`. + + +## 5. Linux adapter — `src/codex/desktop-app/linux.ts` (NEW) + +**discover.** Resolve `/usr/bin/chatgpt` (then `/usr/local/bin/chatgpt`) through +`realpath`; `001` §2 measured it as a symlink to `/usr/lib/chatgpt/codex-launcher`, +a two-line `sh` script that execs `/usr/lib/chatgpt/ChatGPT`. The directory holding +that launcher is the install root, and the shell binary must exist inside it. + +**The resolved root must be trusted (N1).** `dirname(realpath(launcher))` alone is not +enough: `/usr/local/bin` is group-writable on some systems, so a planted +`chatgpt -> ~/x/codex-launcher` beside a `~/x/ChatGPT` would make an attacker-chosen, +user-writable directory the membership boundary and the relaunch target. Discovery +therefore requires the resolved root and the shell binary to be owned by uid 0 and +not group- or world-writable. A root that fails that check is `package_discovery_failed`, +not a fallback. Same-uid scoping limits the blast radius to the attacker's own +processes, but the relaunch would execute an attacker-chosen binary, which is the +part worth closing. + +`install = { id: "chatgpt", root: "/usr/lib/chatgpt", relaunch: "/usr/bin/chatgpt" }`. + +No PATH lookup: the launcher path is checked as an absolute candidate, so a +`chatgpt` earlier on PATH cannot redirect a kill or a launch. + +**listProcesses.** Read `/proc`: for each numeric entry, `readlink /proc//exe`, +keep it when the target is under `/`, and require `/proc//status` `Uid:` +real uid to equal `process.getuid()`. `parentPid` comes from `PPid:`. `createdAt` is +field 22 of `/proc//stat` (`starttime`) as a raw string — the same field +`readLinuxProcStartMs` already reads in `src/codex/app-server-processes.ts:573-589`, +but kept as an opaque token here because the ladder only ever compares it. Reuse that +function's parsing rather than re-deriving it: field 22 must be located **after the +last `)`** in the line, because `comm` can itself contain spaces and parentheses — +and this app's helpers are literally named `Codex (Service)` (N3). + +A `/proc` that cannot be read throws, and the adapter returns `null` so the ladder +reports `process_probe_failed`. This mirrors `listUnixProcSnapshots`, which already +treats a missing `/proc` as an enumeration failure rather than an empty result +(`src/codex/app-server-processes.ts:342-345`). + +**Shell predicate.** The shell is `/ChatGPT` with no `--type=` argument in +`/proc//cmdline`. `001` §2.1 shows every helper carries `--type=zygote`, +`--type=gpu-process`, `--type=utility` and so on. + +**ancestryPids.** `PPid:` from `/proc//status`, walked to 1, bounded at 16. + +**requestQuit.** `process.kill(pid, "SIGTERM")`. + +> Honest note for the implementation and the docs: `001` §2.2 read +> `/proc/3284901/status` and found SIGTERM in neither `SigCgt` nor `SigIgn`, so the +> Linux shell has the **default** SIGTERM disposition. SIGTERM there is termination, +> not a graceful shutdown request. There is no better primitive available — the app +> registers no DBus quit method and has no systemd unit — so this is the honest +> ceiling on Linux, and the operator-facing text must not claim a graceful quit it +> does not perform. + +**forceStop.** `process.kill(pid, "SIGKILL")`. + +**captureRelaunchContext.** The one genuinely novel piece. + +`001` §2.2 measured that `/proc//environ` is **1902 bytes of NUL**: Chromium +scrubs its environment block after startup. Reading the root is therefore useless. +The values survive in children that inherited them before the scrub — the embedded +app-server was the one that still had them. + +So: iterate the enumerated members, oldest-first, reading `/proc//environ` until +one yields a non-empty `XDG_RUNTIME_DIR`. "Oldest-first" sorts `starttime` +**numerically** — it is a jiffies integer in a string, and a lexical sort misorders it +(N4). Copy forward exactly five keys and nothing else: + +``` +DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR XDG_SESSION_TYPE DBUS_SESSION_BUS_ADDRESS +``` + +The measured values on `lidge` were `DISPLAY=:1`, `XDG_SESSION_TYPE=x11`, +`XDG_RUNTIME_DIR=/run/user/1000`, +`DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus`. + +The allowlist is not tidiness. `/proc//environ` is another process's full +environment and routinely carries API keys and session tokens; copying it wholesale +into a spawn would move credentials between security contexts for no benefit. + +**relaunch.** `setsid ` with `detached: true`, `stdio: "ignore"`, the +captured five variables merged over a minimal environment, followed by `unref()`. + +That minimal environment is not empty (N6): it carries `HOME`, `USER`, `LOGNAME`, +`LANG` and a fixed `PATH` of `/usr/local/bin:/usr/bin:/bin` from this process's own +environment. The launcher is a `sh` script and Electron resolves its user-data +directory from `HOME`; starting it with only the five session variables would produce +an app that launches and then behaves as a different user profile. + +`detached: true` and the `setsid` binary overlap — `detached` already calls +`setsid(2)`, and the `setsid` binary then auto-forks because it finds itself a group +leader. The audit confirmed this is redundant but harmless, and no `--fork` is +needed. Both are kept because the redundant one is the cheap insurance against a +runtime that changes `detached` semantics. +`setsid` is required so the relaunched app is not in the ssh session's process group +and does not die when that session ends — `001` §2 confirmed `/usr/bin/setsid` is +present. If `XDG_RUNTIME_DIR` was not recovered, `relaunch` throws rather than +starting an app that cannot reach the compositor, and the ladder reports +`relaunch_failed` with a message naming the missing session. + +## 6. Windows adapter — `src/codex/desktop-app/windows.ts` (NEW, moved) + +Everything currently in `src/codex/desktop-app-restart.ts` lines 76-262 moves here +unchanged in behaviour: `discoverPackage` (`Get-AppxPackage -Name OpenAI.Codex` then +`OpenAI.CodexBeta`, runtime discovery never a literal AUMID), `listPackageProcesses` +(`ChatGPT.exe` under `InstallLocation`, `GetOwner` scoped to the current user, the +newline-joined script that #2557 fixed), `windowsAncestryPids` (CIM parent walk), +`CloseMainWindow()`, `taskkill /PID /T /F`, and +`Start-Process 'shell:AppsFolder\!App'`. + +The only change is shape: these become the adapter's methods, `createdAt` is the +existing `CreationDate` ISO string, and `captureRelaunchContext` returns `{}`. + +Comments that explain *why* each guard exists — the newline-vs-space PowerShell bug, +the shared-`WindowsApps` multi-user reason for `GetOwner`, the PID-reuse window — +move with the code. They are the reason the code is shaped the way it is. + +## 7. Focused verification for this phase + +No product suite (`000` §2). The evidence this phase produces is the live +three-host behaviour recorded in `040`, plus hosted CI at the final head. + +## 8. Risks + +- **Terminating something outside the tree.** Mitigated by requiring the executable + to live under the discovered root, requiring the current uid/owner, and + re-verifying `pid`+`createdAt` immediately before each signal. +- **Stale macOS crashpad handlers blocking relaunch forever.** Mitigated by §3.3. +- **Linux relaunch into no session.** Mitigated by failing `relaunch_failed` instead + of starting a headless app that the user cannot see and that holds the single-instance lock. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md new file mode 100644 index 0000000000..182ac64b5f --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md @@ -0,0 +1,294 @@ +# wp5 — Detached self-handoff restart + +Diff-level design. Depends on wp2 (`010`). Runs before wp3. + +## 1. The problem this solves + +The self-ancestry guard refuses to restart the desktop app when the calling process +is inside it. That refusal is correct — terminating your own tree kills the command +mid-flight and leaves the operator with neither a restarted app nor an explanation. + +But `001` §1.3 measured the maintainer's actual shell: + +``` +31497 (zsh) -> 16733 (bundled codex app-server) -> 15901 (ChatGPT) -> 1 (launchd) +``` + +Anything run from a Codex terminal, a Codex agent session, or the app's own shell is +inside the tree. Without this phase, the merged `--restart-codex` would refuse in +precisely the situation that produced the original "it doesn't work" report, and the +unit would ship a flag that fails for its primary user. + +## 2. Design + +Keep the guard. Change what happens when it fires: instead of refusing, hand the +work to a process that will still be alive after the app dies. + +``` +ocx (inside the tree) + |-- writes a handoff plan to a private temp file + |-- spawns a DETACHED helper, unref()s it, and returns immediately + |-- prints "handed off" and exits + | + helper (outside the session, orphaned once ocx exits) + |-- waits for the caller pid to exit (bounded) + |-- re-runs the wp2 ladder from scratch + |-- appends the outcome to a log the operator can read +``` + +### 2.1 Why waiting for the caller matters + +The helper is spawned from inside the app tree, so at spawn time it is still a +descendant. Two things make it safe: + +- It **waits for the calling `ocx` process to exit** before doing anything. At that + moment it is orphaned and reparented (`launchd`/`init`/`systemd --user`), so it is + no longer reachable by a tree walk from the app root. +- It **re-enumerates and re-runs the ancestry check itself**. It does not trust the + caller's finding. If it somehow still sits inside the tree, it refuses exactly as + the direct path would, and records that refusal. + +On Windows the ordering matters more than on Unix, because `taskkill /T` walks live +parent-child links: an orphan whose parent pid is dead is not traversed. On Unix the +ladder only signals pids it enumerated as package members, and the helper's +executable is `ocx`/`bun`, never under the app root, so it is never a member. + +### 2.2 Spawn primitives + +| Platform | Spawn | Detach | +|---|---|---| +| darwin / linux | `spawn(execPath, args, { detached: true, stdio: "ignore" })` then `unref()` | `detached: true` creates a new process group; the parent exits immediately | +| win32 | `spawn` with `detached: true`, `windowsHide: true`, `stdio: "ignore"`, then `unref()` | the helper is orphaned as soon as `ocx` exits | + +No shell is involved on any platform, so no quoting surface exists. + +## 3. Files + +``` +src/codex/desktop-app/handoff.ts NEW plan file, spawn, and the helper's run loop +src/cli/internal-command.ts NEW hidden "ocx internal desktop-restart-handoff" +src/cli/dispatch.ts MODIFY route the hidden command +src/codex/desktop-app-restart.ts MODIFY self_ancestry -> attempt handoff +``` + +## 4. `src/codex/desktop-app/handoff.ts` (NEW) + +```ts +export interface DesktopRestartHandoffPlan { + schemaVersion: 1; + /** Pid the helper waits on before acting. */ + callerPid: number; + /** Advisory only; the helper re-discovers and re-enumerates. */ + expectedInstallId: string; + createdAtMs: number; +} + +export type HandoffOutcome = + | { kind: "started"; helperPid: number; logPath: string } + | { kind: "failed"; reason: "spawn_failed" | "plan_write_failed" | "no_executable" }; + +export function startDesktopRestartHandoff(io?: HandoffIo): HandoffOutcome; +export function runDesktopRestartHandoff(planPath: string, io?: HandoffIo): Promise; +``` + +### 4.1 A restart that acts is a singleton (audit B2) + +Nothing in the first draft stopped two ladders from running at once, and the +interleaving is destructive rather than merely wasteful: helper A quits the app and +relaunches it, helper B re-enumerates during that window, sees the **freshly started** +root as a target, and kills it. Two `ocx sync --restart-codex` runs inside the app, or +one handoff racing an ssh-issued direct run, are enough. + +So every restart attempt — direct path and handoff alike — first takes an atomic lock +at `/desktop-restart.lock`, created with `wx` and holding an owner pid +and a timestamp. A caller that cannot take the lock does not queue and does not wait: +it reports `restart_in_flight` and exits. Queueing would rebuild the same race one +step later. + +The lock is held **across the whole ladder including the relaunch**. Releasing after +the last kill would reopen exactly the window this closes. + +**The lock is transferred to the helper, not contended for.** This is the part that +makes the handoff work at all. The obvious reading — "every restart that acts takes +the lock" — deadlocks the feature: the caller takes the lock, discovers it is inside +the tree, spawns a helper, and the helper then waits for a lock its own parent holds. + +The sequence is therefore: + +``` +caller: take lock (owner = caller pid) +caller: ancestry check -> inside the tree +caller: spawn detached helper +caller: REWRITE the lock owner to the helper pid, atomically +caller: exit WITHOUT releasing +helper: wait for caller pid to exit (up to 20 s) +helper: assert the lock names ITS OWN pid, else exit without acting +helper: run the ladder +helper: release in finally +``` + +"Release in finally" means **release a lock this process owns**. A helper that finds +the lock naming a different live pid exits without touching it; deleting somebody +else's live lock would destroy the mutual exclusion this section exists for. The +release is therefore a compare-and-delete on the owner pid, never an unconditional +`unlink`. + +The helper never takes the lock; it inherits one already made out to it. A concurrent +caller arriving at any point sees a lock owned by a live pid and reports +`restart_in_flight`, which is the behaviour B2 asked for. + +Mechanically this is **own-pid reentrancy**, not a second code path. Step 0 of the +ladder (`010` §3.2) runs unconditionally in every process, and acquisition treats a +lock already naming *this* pid as successfully held rather than as contention. The +helper therefore executes the same step 0 as everyone else and finds the lock the +caller made out to it. A helper invoked directly, with no lock waiting for it, +acquires one normally. One acquisition rule covers all three cases, which is why +there is no "helper mode" branch to get wrong. + +If the spawn fails, the caller releases the lock on the ordinary `finally` path and +reports `self_ancestry`. The rewrite happens only after a successful spawn, so a +failed handoff can never strand the lock on a pid that does not exist. + +The helper asserting ownership is what keeps the hidden command honest: an +arbitrarily invoked `ocx internal desktop-restart-handoff` that was not handed a lock +finds one owned by somebody else, or none at all, and in the latter case takes it +normally like any direct caller. + +A lock whose owner pid is dead, or which is older than five minutes, is stale and is +replaced atomically. That staleness rule is what recovers from a helper killed by the +`taskkill /T` race in §8: the lock is left owned by a dead pid and the next restart +reclaims it rather than being blocked until someone deletes a file. + +Both the staleness check and the helper's caller-exit poll read liveness by pid, so +both inherit the same small exposure: a recycled pid inside the window reads as +"still alive". Each fails in the safe direction — a false `restart_in_flight` and a +false `caller_still_running` respectively, so the outcome is a restart that did not +happen rather than one that happened to the wrong process — and both are bounded by +the five-minute staleness rule. + +### 4.3 Who may hand off + +`allowHandoff` is a caller policy, not a global. It is `true` for the CLI, whose +process is short-lived and whose exit is exactly the signal the helper waits for. It +is `false` for the management service (`030` §4.2), because a long-lived proxy never +exits and the helper would spend its whole window waiting for something that cannot +happen, after the operator was already told the restart was handed off. The helper +itself also passes `false`, which is what makes recursion structurally impossible. + +### 4.2 What the helper is actually spawned as (nit N11) + +`spawn(process.execPath, args)` is under-specified, because `process.execPath` and the +right `args` differ between the ways `ocx` can be running: `bun run src/cli/index.ts` +from a checkout, an npm-installed `ocx` shim, and `bunx`. + +Resolution order, decided at spawn time: + +1. If `process.argv[1]` names an existing file, spawn `[process.execPath, argv[1], "internal", ...]`. + This covers the checkout and the npm shim, which is how every measured host runs it. +2. Otherwise, if `process.execPath` is itself the packaged CLI (basename `ocx`), spawn + `[process.execPath, "internal", ...]`. +3. Otherwise return `{ kind: "failed", reason: "no_executable" }` and let the caller + report the ordinary `self_ancestry` refusal. + +Failing to resolve is a refusal, never a guess. Spawning the wrong interpreter with a +path that does not exist would produce a helper that silently exits and an operator +who was told a restart was handed off. + +**Plan file.** Written under the opencodex home with mode `0600`, named +`desktop-restart-handoff--.json`. It holds no secret — pids and a +timestamp — but it is a file whose path is passed to a spawned process, so it is +created with `wx` (exclusive) and deleted by the helper after it reads it. + +**Helper wait.** Poll `isProcessAlive(callerPid)` every 100 ms up to 20 s. When the +caller is gone, proceed. If the caller is still alive at the deadline, **refuse** and +record `caller_still_running`: a caller that outlives the window is not the +short-lived `ocx sync` this was designed for, and killing the app out from under an +unknown long-running process is not something to guess about. + +There is also a guard for a plan that is not ours to run: if `createdAtMs` is more +than five minutes old, the helper exits without acting. A stale plan file that +survived a crash must not restart the app hours later. + +**Log.** Appended to `/desktop-restart-handoff.log`, one JSON line +per run: timestamp, outcome, reason, stopped/surviving counts. Counts, not command +lines — the same projection `CodexRestartResponse` already applies, for the same +reason. + +## 5. `src/cli/internal-command.ts` (NEW) + +One hidden command, not registered in `src/cli/registry.ts` and therefore absent +from help, from `src/cli/capabilities.ts`, and from the generated skill surface: + +``` +ocx internal desktop-restart-handoff --plan +``` + +It is not a user-facing capability and must not become one. It exists so the helper +is the same audited binary running the same audited ladder, rather than a second +implementation in a shell script. `tests/ci-workflows/skill-ocx.test.ts` asserts the +documented pages name only registry commands, so keeping this out of the registry is +what keeps that gate green. + +Unknown `internal` subcommands exit non-zero with a one-line usage string on stderr. + +**It is intentionally unauthenticated (nit N8), and that is not a finding.** Any +process running as this user can invoke it with a hand-written plan file naming any +`callerPid`. It gains nothing: the helper only does what the public `--restart-codex` +flag already does for that same user, and a same-uid process could call `kill` +directly. Adding a token here would protect nothing and would imply a boundary that +does not exist. Recording the reasoning so a later reviewer does not file it as a gap. + +## 6. `src/codex/desktop-app-restart.ts` (MODIFY) + +```ts + if (processes.some(p => ancestry.has(p.pid))) { +- return skipped("self_ancestry"); ++ if (io.allowHandoff === false) return skipped("self_ancestry"); ++ const handoff = (io.startHandoff ?? startDesktopRestartHandoff)(); ++ if (handoff.kind === "failed") return skipped("self_ancestry"); ++ return { ++ attempted: false, stopped: [], surviving: [], ++ relaunch: "skipped", reason: "handoff_started", ++ handoff: { helperPid: handoff.helperPid, logPath: handoff.logPath }, ++ }; + } +``` + +`handoff_started` joins the reason union. `allowHandoff: false` is what the helper +itself passes, which is what makes recursion structurally impossible rather than +merely unlikely: the helper can only ever take the direct path or refuse. + +## 7. CLI reporting + +`handleDesktopAppRestart` gains one case: + +``` +case "handoff_started": + log.log( + "This command is running inside the Codex app, so the restart was handed off to a " + + `detached helper (pid ${result.handoff.helperPid}). The app will quit and relaunch ` + + `in a moment; this session will end with it. Outcome: ${result.handoff.logPath}`, + ); +``` + +Saying "this session will end with it" is the point. The operator is about to lose +the terminal they typed into, and a message that does not say so reads as a hang. + +## 8. Risks + +- **Orphan helper never runs.** Bounded: 20 s caller wait, 5 min plan expiry, then exit. +- **Helper killed with the app.** Addressed by §2.1 (wait for caller exit, re-enumerate). +- **Recursion.** Structurally prevented by `allowHandoff: false` in the helper. +- **Surprise for scripted callers.** A CI script calling `ocx sync --restart-codex` + from outside the app is unaffected: the guard does not fire, and the direct path runs. +- **Concurrent ladders.** Closed by the singleton lock in §4.1. +- **Windows `taskkill /T` pid-recycle race (nit N10).** The helper escapes the kill + set because its parent pid is dead and `/T` walks live parent links. If that dead pid + is recycled into a live process that is itself inside the tree being killed, the + helper is momentarily reachable through the new link and can be terminated with the + app. The window is small and the outcome is a failed restart rather than a wrong + kill: the app still dies, the helper dies before relaunching, and the operator gets + no relaunch. Accepted and named rather than engineered around, because the + alternative — an intermediate re-parenting service — costs far more than the + failure it prevents. The handoff log records nothing in this case, which is itself + the signal that it happened. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md new file mode 100644 index 0000000000..241974a7be --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md @@ -0,0 +1,400 @@ +# wp3 — CLI and management contract merge, docs, generated surfaces + +Diff-level design. Depends on wp2 (`010`) and wp5 (`020`). + +## 1. The flag contract, before and after + +| Flag | Before | After | +|---|---|---| +| `--restart-codex` | SIGTERM to matching app-server / code-mode-host processes only | app-server restart **and** a full desktop-app quit + relaunch, all platforms | +| `--restart-desktop-app` | Windows-only opt-in, never implied | deprecated alias of `--restart-codex`, prints a deprecation line | +| `--restart-app-server-only` | — | NEW: the old `--restart-codex` behaviour | + +Nobody loses a capability. The narrow scope moves to a flag that names it, which is +better than the old arrangement where the narrow scope was the unnamed default and +the wide scope needed a flag. + +## 2. Ordering: do not interrupt the same turn twice + +`001` §1.1 and §2.1 measured the app-server as a **child of the desktop app** on both +macOS (`16733` under `15901`) and Linux (`3285204` under `3284901`). Running the +existing app-server pass and then the desktop restart therefore signals the same +process twice: SIGTERM to the app-server, the app respawns it, then the whole app is +quit. The operator's in-flight turn is interrupted, recovered, and interrupted again. + +So when a desktop restart is going to run, app-servers that are **members of the +discovered desktop tree** are excluded from the SIGTERM pass. Quitting the app +terminates them anyway. Standalone app-servers — the npm `codex app-server` pair that +`devlog/_plan/260826_restart_codex_linux_survivor/000_repro_and_root_cause.md` +documents, and SSH bootstraps — are not members and are still signalled. + +`afterCatalogWriteHandleAppServers` gains one option: + +```ts +export interface AfterCatalogWriteAppServerOptions { + restart: boolean; + log?: Pick | null; + io?: CodexAppServerProcessIo; ++ /** Pids already covered by a desktop-app restart in this same command. */ ++ excludePids?: readonly number[]; +} +``` + +The caller computes the exclusion by asking the wp2 module to enumerate without +acting. `010` §2 already exposes what that needs; wp3 adds the thin read-only entry: + +```ts +export function listCodexDesktopAppPids(io?: DesktopAppRestartIo): number[] | null; +``` + +`null` (discovery or probe failure) means **no exclusion** — falling back to the old +behaviour of signalling everything, which is the safe direction: a missed exclusion +costs an extra interruption, a wrong exclusion leaves a stale app-server alive. + +**`excludePids` can go stale (nit N7).** Between enumeration and the signal pass, a +listed pid can exit and be recycled into a standalone app-server, which would then +escape signalling because its pid is on the exclusion list. The window is short and +the cost is one stale app-server rather than a wrong kill. `restartCodexAppServers` +already re-resolves pid+command-line identity immediately before signalling +(`src/codex/app-server-processes.ts:1146-1154`), so a recycled pid is never +*signalled* on a stale identity. Only the skip can be wrong, never the kill. + +## 3. `src/cli/dispatch.ts` (MODIFY) + +### 3.1 Flag parsing, `sync` (currently lines 381-384) + +```ts +- const restartCodex = syncArgs.includes("--restart-codex"); +- // Separate flag on purpose: --restart-codex promises app-server-only scope, +- // and quitting the desktop app ends live conversations. +- const restartDesktopApp = syncArgs.includes("--restart-desktop-app"); ++ const restartScope = readRestartScope(syncArgs, console); +``` + +with one shared helper so `sync`, `sync-cache` and `catalog pull` cannot drift: + +```ts +export interface RestartScope { + /** Signal matching app-server / code-mode-host processes. */ + appServers: boolean; + /** Fully quit and relaunch the Codex desktop app. */ + desktopApp: boolean; +} + +export function readRestartScope( + args: readonly string[], + log: Pick, +): RestartScope { + const appServerOnly = args.includes("--restart-app-server-only"); + const legacyDesktop = args.includes("--restart-desktop-app"); + const restartCodex = args.includes("--restart-codex"); + if (legacyDesktop) { + log.error( + "--restart-desktop-app is deprecated: --restart-codex now restarts the Codex " + + "desktop app on every platform. The flag still works and will be removed in a " + + "future release.", + ); + } + if (appServerOnly && (restartCodex || legacyDesktop)) { + // Contradictory scopes. The narrower one wins: a user who typed the + // app-server-only flag asked not to lose their conversations. + log.error( + "--restart-app-server-only overrides --restart-codex/--restart-desktop-app; " + + "the desktop app was left running.", + ); + return { appServers: true, desktopApp: false }; + } + if (appServerOnly) return { appServers: true, desktopApp: false }; + if (restartCodex || legacyDesktop) return { appServers: true, desktopApp: true }; + return { appServers: false, desktopApp: false }; +} +``` + +The contradiction resolution is the narrow scope on purpose. Losing live +conversations is unrecoverable; a stale model picker is not. + +### 3.2 Post-write handling (currently lines 437-438, 517-518, 1023-1028) + +```ts +- afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); +- if (restartDesktopApp) await handleDesktopAppRestart(console); ++ await handleRestartScopeAfterWrite(restartScope, console); +``` + +### 3.3 The helper returns its outcome (audit B6) + +`catalog pull` derives its JSON envelope from the restart result +(`src/cli/catalog.ts:65-93`), so a `void` helper cannot serve it: + +```ts +export interface RestartScopeOutcome { + appServers?: AfterCatalogWriteAppServerResult; + desktopApp?: DesktopAppRestartResult; +} + +async function handleRestartScopeAfterWrite( + scope: RestartScope, + log: Pick, +): Promise { + const excludePids = scope.desktopApp ? (listCodexDesktopAppPids() ?? []) : []; + const appServers = afterCatalogWriteHandleAppServers({ + restart: scope.appServers, log, excludePids, + }); + const desktopApp = scope.desktopApp ? await handleDesktopAppRestart(log) : undefined; + return { appServers, desktopApp }; +} +``` + +`handleDesktopAppRestart` therefore returns the `DesktopAppRestartResult` it already +switches on, instead of `void`. + +All four call sites collapse to this one helper, which is what makes the +source-oracle assertion in §6 checkable in one place instead of four. + +### 3.4 `catalog pull` joins the merged contract (audit B4) + +`src/cli/catalog.ts` was missing from the first draft. It is a fourth +`afterCatalogWriteHandleAppServers` call site, and its `knownFlags` set is **closed**, +so the new flags would be rejected as `code: "usage"` rather than ignored. + +| Location | Change | +|---|---| +| `src/cli/catalog.ts:24` | parse through `readRestartScope` instead of a local `includes` | +| `src/cli/catalog.ts:31` | `knownFlags` gains `--restart-desktop-app` and `--restart-app-server-only` | +| `src/cli/catalog.ts:41` | usage string lists the three flags | +| `src/cli/catalog.ts:65` | call `handleRestartScopeAfterWrite` | +| `src/cli/catalog.ts:5-14` | `CatalogPullEnvelope` gains optional `desktopAppRestarted?: boolean` | +| `src/cli/registry.ts:148` | `catalog pull` usage line | +| `src/cli/help.ts:49` | `catalog pull` usage line | + +`codexRestarted` keeps its current meaning — app-servers only — and the desktop +outcome gets its own optional field, so a script reading the existing field is not +silently handed a different answer. The field is emitted only when a desktop restart +was requested, which keeps `schemaVersion: 1` honest. + +`desktopAppRestarted` is `true` only for `relaunch: "started"`. Every other outcome — +`restart_in_flight`, `targets_survived`, `relaunch_failed`, `self_ancestry`, +`handoff_started` — is `false`, because none of them left a restarted app behind. A +handoff in particular is **not** a success: the restart has not happened yet when the +envelope is written, and a script that read `true` there would proceed on a promise. + +The docs currently say desktop restart is not part of this command, in English plus +`zh-cn`, `zh-tw`, `tr` and `ru`. `002` §B4 records why that is reversed: it is a scope +statement about a capability that did not exist cross-platform, not the consent +decision that split the `sync` flags. + + +### 3.5 `handleDesktopAppRestart` messages (currently lines 977-1017) + +```ts +- case "windows_only": +- log.error("--restart-desktop-app is supported on Windows only; nothing was stopped."); +- return; ++ case "unsupported_platform": ++ log.error( ++ `Restarting the Codex desktop app is not supported on ${process.platform}; ` ++ + "app-servers were still restarted.", ++ ); ++ return; ++ case "handoff_started": ... // see 020 §7 ++ case "restart_in_flight": ++ log.error( ++ "Another Codex desktop-app restart is already running; this one did nothing. " ++ + "Wait for it to finish and check again.", ++ ); ++ return; ++ case "relaunch_failed": ++ log.error( ++ "The Codex desktop app was stopped but could not be started again. " ++ + "Launch it manually.", ++ ); ++ return; +``` + +and the `self_ancestry` text drops its `--restart-desktop-app` reference, since a +handoff now happens instead and the refusal only survives for the helper itself. + +## 4. `ocx system codex-restart` (MODIFY) + +`src/codex/app-server-restart-service.ts` runs the desktop restart after its +app-server pass, using the same exclusion from §2. The contract in +`src/lib/codex-restart-contract.ts` gains one **optional** field: + +```ts ++export interface CodexDesktopRestartSummary { ++ attempted: boolean; ++ stopped: number[]; ++ surviving: number[]; ++ relaunch: "started" | "skipped"; ++ reason?: string; ++} + + export interface CodexRestartResponse { + ... ++ /** Absent on a proxy older than this change. */ ++ desktopApp?: CodexDesktopRestartSummary; + } +``` + +Optional, not required, because `isCodexRestartResponse` is a **version-skew guard** +consumed by the GUI: a dashboard talking to an older proxy must keep working. The +guard validates the field's shape and its own cross-field invariants when present +(`relaunch === "started"` implies `surviving` is empty) and ignores it when absent. + +`scalar-only` still holds: pid arrays and a closed-vocabulary reason string, never a +command line, a path or an OS error message. + +`reason` carries the `DesktopAppRestartReason` value verbatim, which is what keeps it +a closed vocabulary rather than free text — `restart_in_flight` included. + +### 4.2 The service refuses instead of handing off (re-audit blocker 2) + +`performCodexRestart` runs **inside the long-lived proxy process**, not in a +short-lived CLI. The wp5 handoff is built on "wait for the calling pid to exit" +(`020` §4.2), and a proxy does not exit. If the proxy were inside the desktop tree, +every handoff it started would sit out its 20-second window and end in +`caller_still_running` — after the operator had already been told the restart was +handed off. + +So the service passes `allowHandoff: false`. When it is inside the tree it reports +`self_ancestry` and says what to do instead: + +``` +"The proxy is running inside the Codex app, so restarting the app from here would + kill this request. Run 'ocx sync --restart-codex' from a terminal instead." +``` + +An honest refusal beats a promise the architecture cannot keep. + +**This is not the normal case.** Measured on the maintainer's machine while writing +this: the proxy listening on :10100 is pid 60304, whose parent chain is +`bun -> node -> launchd` with no `ChatGPT.app` process in it, while the app root is +pid 15901. A proxy installed as a service sits outside the app tree, so the service +path performs the restart directly and `000` §7 is satisfied. The refusal covers the +case where someone started the proxy from a shell inside the app — a real thing +developers do, and a bad thing to mishandle silently. +### 4.1 The wire `restartCodex` field does not change meaning (audit B5) + +`POST /api/machine/sync` accepts a `restartCodex` boolean from a remote hub +(`src/client/machine-api.ts:79-95`) and hands it to `syncConnectedClient`, which +deliberately ignores it (`src/client/connect.ts:649-650`). The desktop restart on the +connected path is performed **locally**, by the CLI, in +`handleConnectedSyncCatalogWrite` (`src/cli/dispatch.ts:1023-1028`). + +That stays exactly as it is. The wire field keeps app-server-only semantics and +remains unhonored. A remote hub must not end a local user's conversations because a +field name acquired a wider meaning underneath it — the maintainer instruction in +`000` §4 widens a **local CLI flag** and says nothing about remote callers. Version +skew sharpens the argument: an older hub that never heard of this change would be +sending a boolean whose meaning silently grew. + +Pinned by a regression test rather than a comment, because the realistic failure is a +future contributor "finishing" a parameter that looks obviously unused: the +machine-sync route must never reach `restartCodexDesktopApp`. +`tests/clients/client-machine-listener.test.ts:206` already exercises the route. + +**The GUI is deliberately not changed.** `gui/src/codex-restart.ts`, +`use-codex-restart.ts` and `components/codex-stale-banner.tsx` keep rendering the +app-server outcome and ignore the new optional field. Surfacing the desktop result in +the dashboard is a separate, purely presentational unit with its own design and +screenshot obligation; folding it in here would widen a process-termination change +into a UI change and drag the `enforce-target` screenshot gate onto a PR whose risk +is entirely in process handling. Making the field optional is what allows that split. + +## 5. Text surfaces (MODIFY) + +| File | Change | +|---|---| +| `src/cli/registry.ts:128-143` | usage gains `[--restart-app-server-only]`; the "(Windows only, opt-in)" and "Never implied by --restart-codex" sentences are replaced by the merged contract | +| `src/cli/capabilities.ts:788-789` | `--restart-codex` summary widened; `--restart-desktop-app` marked deprecated; `--restart-app-server-only` added | +| `src/cli/capabilities.ts:712-722` | `system codex-restart` details: it now restarts the desktop app too | +| `src/cli/help.ts:46-47` | one-line usage refresh | +| `src/codex/app-server-processes.ts:19-21` | `STALE_CODEX_APP_SERVER_HINT` drops the Windows sentence and names one flag | +| `src/codex/app-server-processes.ts:565-567` | `formatStaleCodexAppServerWarning` likewise | +| `src/cli/doctor.ts:1369` | WARN action collapses to `ocx sync --restart-codex` | +| `src/codex/desktop-app-restart.ts:2` | module header rewritten: cross-platform, and why the flags merged | + +`warnIfStaleCodexAppServersAfterStartupWrite` (`src/codex/app-server-processes.ts:1245`) +consumes the same hint strings but stays **warn-only** and never gains a restart +(nit N14). Its reason for existing is that an unattended startup is not consent to +interrupt a turn, and this unit does not touch that argument. + +## 6. Tests to rewrite + +Two assertions encode the contract being reversed and must be inverted, not deleted. +Deleting them would leave the new guarantee unenforced. + +**`tests/codex-integration/codex-app-server-processes.test.ts:726-739`** — +`"--restart-desktop-app is a separate opt-in that --restart-codex never implies (#2292)"`. +It reads `src/cli/dispatch.ts` as text and asserts the handlers contain +`includes("--restart-desktop-app")`, match `/if \(restartDesktopApp\) await handleDesktopAppRestart\(...\)/`, +and do **not** contain `restartDesktopApp = restartCodex`. + +Replaced by `"--restart-codex restarts the desktop app on every platform (#2292 follow-up)"`, +asserting on the same source text that both handlers route through +`handleRestartScopeAfterWrite`, that the write gate still precedes it, and that +`--restart-app-server-only` is the only path producing `desktopApp: false`. + +**`tests/clients/desktop-app-restart.test.ts:57-62`** — darwin returns +`reason: "windows_only"` with zero `execFile` calls. Replaced by a case asserting +darwin now discovers, and an `unsupported_platform` case on a platform with no +adapter. + +The other 15 cases in that file stay: they encode fail-closed discovery, the +PID-reuse re-verification, current-user scoping, bounded probe timeouts and the +`process_probe_failed` vs `no_targets` distinction — all of which the shared ladder +must keep satisfying on Windows. + +New coverage lands in `tests/clients/desktop-app-restart-posix.test.ts`. The +`clients` domain seed in `scripts/test-layout/layout.json` is +`^(?:desktop|omp|pi|prime|remote|sync)-`, so a `desktop-`prefixed file in +`tests/clients/` resolves without an `explicit` entry — and therefore needs no +matching addition to `tests/fixtures/test-layout-expected.json`. + +If anyone adds an `explicit` entry later it must go into **both** tables: +`tests/test-layout-tooling.test.ts:250` asserts `layout.explicit` equals the fixture +exactly, so a half-entry fails the gate (nit N15). + +New tests this phase owes, beyond the two rewrites above: + +- `readRestartScope`: each flag alone, the deprecation line, and the contradiction + case where `--restart-app-server-only` beats `--restart-codex`. +- The machine-sync route never triggers a desktop restart (§4.1). +- `catalog pull` accepts the new flags instead of returning `code: "usage"`. +- The handoff singleton lock refuses a second concurrent restart (`020` §4.1). + +## 7. Documentation + +English, hand-written, heaviest edit in +`docs-site/src/content/docs/reference/cli/lifecycle.md:264-333` — including line 315, +"it has no Windows `--restart-desktop-app`", which becomes false. Also +`reference/cli/agents.md:346`, `reference/management-api.md:440`, +`guides/codex-integration.md:734`, `guides/factory-droid.md:148`. + +Locales that exist and mention `--restart-codex`: `fr`, `ja`, `ko`, `ru`, `tr`, +`zh-cn`, `zh-tw`. Only the English lifecycle reference ever named +`--restart-desktop-app`, so the locale work is a semantic update of the +`--restart-codex` description in each, not a new section. The repository rule is that +translations must not contradict the English source; leaving seven locales saying +"app-server only" would do exactly that. + +One correction from the audit: `zh-cn`, `zh-tw`, `tr` and `ru` **do** carry the +`catalog pull` desktop-restart exclusion sentence (zh-cn's +"Desktop 应用重启不属于此命令", for example). Those four pages need that sentence removed +as well as the `--restart-codex` description updated, so the locale edit is not +uniform across the seven. + +## 8. Generated surfaces + +`skills/ocx/references/01_management_surface.md` is generated from +`src/cli/capabilities.ts` by `bun run skill:surface`, and +`tests/ci-workflows/skill-ocx.test.ts` fails if the committed file drifts. It is +regenerated, never hand-edited. + +This is a code generator, not a product test, build, typecheck or install, so running +it does not breach `000` §2. It is required for correctness: the committed artifact +must match its source or CI fails. + +`structure/INDEX.md` is regenerated only if a `structure/` document changes. No +`structure/` file references these flags today; if the new `src/codex/desktop-app/` +directory needs an ownership note, `bun run structure:index` follows the edit. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/040_phase4_verification_and_delivery.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/040_phase4_verification_and_delivery.md new file mode 100644 index 0000000000..f21dfd8585 --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/040_phase4_verification_and_delivery.md @@ -0,0 +1,145 @@ +# wp4 — Live three-host verification, hosted CI, PR, merge + +Procedure and evidence contract. Depends on wp3 (`030`). + +## 1. What counts as proof + +A restart is proven when the **root shell process identity changes** and the app is +running again. Not "the command exited 0", not "the picker looks right". + +For each host, capture before and after: + +``` +root pid + start time -> run the command -> root pid + start time +``` + +A new pid with a later start time, and a live process, is the proof. A same-pid +reading is a failure regardless of what the command printed. + +## 2. Host assignment + +| Platform | Host | Why this host | +|---|---|---| +| macOS | `macmini-cf` | the command arrives over ssh and is **not** inside the app tree, so the direct path is exercised | +| Linux | `lidge` | Ubuntu 24.04, deb install, real GNOME session on `:1` | +| Windows | `mini` | MSIX install, the platform the original implementation targeted | +| macOS handoff | local | the only host where the caller is inside the tree (`001` §1.3); proves wp5 | + +The local machine cannot prove the macOS **direct** path: `001` §1.3 measured this +shell as a descendant of the app, so the guard fires by design. It is instead the +only host that can prove the **handoff** path, which is the harder case. Running the +local handoff proof terminates this session, so it is the last action of the unit, +after the PR is merged, and its evidence is read back from the handoff log +afterwards rather than from the terminal that issued it. + +## 3. Getting the branch code onto each host + +Each host runs the branch from a checkout, not from its installed `ocx`: + +- `macmini-cf`: `~/Developer/opencodex` exists; `~/.bun/bin/bun` 1.3.14. + `node` is absent from the non-interactive PATH, so every command uses absolute + paths and `bun`, never the `~/.bun/bin/ocx` npm shim (which fails with + `env: node: No such file or directory`). +- `lidge`: `~/.local/bin/ocx` is opencodex 2.50.0; locate or create a checkout. +- `mini`: `/c/nvm4w/nodejs/ocx` is opencodex 2.52.0; locate or create a checkout. + +Invocation is `bun run src/cli/index.ts sync --restart-codex` from the checkout. + +`bun install` on a verification host is **setup for the remote proof**, not the local +product suite that `000` §2 forbids. The prohibition is about substituting local +green for hosted CI; it does not prevent making a remote host able to execute the +code at all. No suite, build, or typecheck runs on any of these hosts. + +## 4. Per-host procedure + +### macOS (`macmini-cf`) + +``` +before: ps -Ao pid=,lstart=,comm= | grep 'ChatGPT.app/Contents/MacOS/ChatGPT' +run: cd && ~/.bun/bin/bun run src/cli/index.ts sync --restart-codex +after: same ps, plus confirm the pid is alive +``` + +Watch for the Sparkle updater: `001` §1 recorded `Autoupdate com.openai.codex` and an +`Updater.app` staged since Sep 10 on this host. If the relaunch produces a different +bundle version than the one that was stopped, that is Sparkle applying the staged +update on restart, not a defect — record it rather than treating it as noise. + +### Linux (`lidge`) + +``` +before: pgrep -a -f '^/usr/lib/chatgpt/ChatGPT$' ; stat -c %Y /proc/ +run: cd && bun run src/cli/index.ts sync --restart-codex +after: same, and confirm the new root's parent is init/systemd (relaunched detached), + not gnome-shell (which would mean a human launched it) +``` + +Also confirm the captured session variables actually took: the new root's children +must show `--user-data-dir=/home/lidgeai/.config/Codex` and a live GPU process with +`--ozone-platform=x11`. An app that started but cannot reach the compositor would +otherwise look identical from a pid check alone. + +### Windows (`mini`) + +``` +before: powershell -NoProfile -Command "Get-Process ChatGPT | Select Id,StartTime" +run: cd && bun run src/cli/index.ts sync --restart-codex +after: same +``` + +This is the regression check: Windows already worked through +`--restart-desktop-app`, and the shared ladder must not have lost anything. + +### `ocx system codex-restart --yes` + +Run on `lidge` against its own proxy, proving the route path reaches the same module. +Capture the JSON envelope and the root pid change. + +## 5. Hosted CI + +No local suite (`000` §2). The gate is `gh run list --commit ` with +every required check `success` **at that exact sha**. A run against an earlier head, a +cancelled run, a skipped run and a queued run are none of them proof. If the head +moves for any reason — a review fix, a rebase — the previous green is void and the +gate is re-read at the new sha. + +## 6. PR and merge + +- Branch `codex/260913-cross-platform-desktop-restart`, base `dev`. +- `.github/PULL_REQUEST_TEMPLATE.md` filled completely: Summary, Verification, + Checklist. `enforce-target` rejects thin or malformed descriptions. +- The description must not describe this as a GUI change — it is not one (`030` §4) — + so the screenshot gate does not apply. +- Verification section carries the three before/after pid tables and the exact-head + CI run id. Local suite state is stated as NOT RUN rather than left implied. +- `#2292` is referenced as the issue whose Windows-only decision this supersedes, with + the reasoning from `000` §4, so a future reader finds the reversal explained rather + than silently contradicted. +- Merge into `dev` once exact-head CI is green. + +## 7. Evidence to record before D + +1. Three before/after root-identity tables, one per platform. +2. The `ocx system codex-restart --yes` envelope and its pid change. +3. The exact-head CI run id and per-check conclusions at the merged sha. +4. The merge commit sha on `dev`. +5. The local handoff log line proving wp5 (captured after the fact). + +Anything missing is named as missing. A platform without a pid change is not +described as working. + +## 8. What is proven by test rather than by a live host + +Named here so the PR does not imply live coverage it does not have. These rest on the +focused tests in `030` §6 plus hosted CI: + +- `--restart-desktop-app` still working and printing its deprecation line. +- `--restart-app-server-only` reproducing the old narrow behaviour. +- `POST /api/machine/sync`'s `restartCodex` staying unhonored (`030` §4.1). +- The `restart_in_flight` singleton refusal. + +`catalog pull --restart-codex` is the one borderline case: it is a real behaviour +change to a real command, so it gets one live invocation on `lidge` against a +loopback catalog URL, checked for the desktop pid change and the +`desktopAppRestarted` envelope field. A flag whose meaning changed deserves better +than a unit test on the host where the change is observable anyway. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/041_execution_record.md b/devlog/_plan/260913_cross_platform_desktop_app_restart/041_execution_record.md new file mode 100644 index 0000000000..f9274b5c03 --- /dev/null +++ b/devlog/_plan/260913_cross_platform_desktop_app_restart/041_execution_record.md @@ -0,0 +1,84 @@ +# wp4 execution record + +Terminal outcome for this unit. PR #4510 merged into `dev` as +`d7c7b493bfc8e9248b8bb98c692203d74c2ee6cc` on 2026-09-13T13:23:25Z. + +## 1. Live three-host proof + +All three runs were made at `c8c1c1a9cc`. Only `tests/` and `devlog/` changed between +that commit and the merged head, so the runtime proven here is byte-identical to the one +CI was green on. + +| platform | host | root pid before -> after | corroboration | +|---|---|---|---| +| Linux | `lidge` | 3425189 -> 3431091 | reparented to init, so the relaunch came from the detached `setsid` spawn and not a human; a live `--type=gpu-process --ozone-platform=x11` child confirms the app reached the compositor | +| macOS | `macmini-cf` | 74114 -> 99260 | bundled app-server respawned under the new root; singleton lock released | +| Windows | `mini` | 29132 -> 23136 | `{"attempted":true,"stopped":[29132],"surviving":[],"relaunch":"started"}` matching the observed tree | + +The Linux corroboration carries the most weight. `001` §2.2 measured the root zeroing its +own environment block, so the session variables must come from a child. Had that been +wrong, the pid would still have changed and the command would still have reported +success, while the user got no visible app. The GPU process is what separates those two +outcomes, and a pid table alone cannot. + +## 2. What running it found that ten audit rounds did not + +Five work phases and ten audit rounds found eighteen blockers by reading code. Windows +held two more, and both surfaced within seconds of a real host. + +**A false success.** The ladder returned +`{"stopped":[27788],"surviving":[],"relaunch":"started"}` while the app kept its original +pid **and** start time throughout. Two causes in one helper: a stop was claimed on +pid-liveness alone, and `stillSameProcess` returned a boolean over three distinct +situations, so a re-probe that merely **failed** was read as "already exited". Reporting a +restart that did not happen is worse than the stale picker this unit exists to fix. + +**Then its mirror image.** Requiring the enumeration to confirm exposed the opposite +defect: `Win32_Process` lags after a kill, so the single confirming query still listed a +process that was already dead. The ladder declared `targets_survived`, skipped the +relaunch, and left the host with the app killed and never restarted. It was restored +immediately. + +Both came from treating one weak reading as proof. Confirmation now polls until the +platform's own process list agrees, with a final look after the deadline, and a probe that +cannot run keeps the loop going rather than deciding either way. The kill and relaunch +primitives were correct on Windows the whole time; only the confirmation was wrong, in +both directions. + +The lesson generalises past this unit: a design that says "fails closed" is not evidence +that the code does, and neither is a green focused test whose double models the world +more simply than the world behaves. The doubles here modelled exit purely through +`isAlive` and kept listing terminated processes, which is precisely why no amount of +review could surface either defect. They now drop a process from the enumeration once +liveness reports it dead. + +## 3. What hosted CI found that local runs could not + +- The macOS cases pointed at `/Applications/ChatGPT.app`, and discovery resolves the + bundle through `realpathSync`, which touches the real filesystem and cannot be + intercepted by the exec seam. They passed locally only because that machine has Codex + installed. They now build a real bundle under a temp directory, realpathed there so the + fixture and the adapter agree across the `/var` symlink. +- `privacy:scan` found a second user's home path in two files of this unit. + +Neither was visible locally: the first because of what the machine happened to have +installed, the second because the scan was never run there. + +## 4. Gate status at the merged head + +- Hosted CI at `156e10776b`: 24 pass, 0 fail, 2 skipping. +- Local product suite, build and typecheck: **NOT RUN**, per `000` §2. +- Focused desktop-restart files: 50 pass / 0 fail. + +## 5. Carried forward + +- **Windows app-server exclusion is a no-op.** The probe enumerates `ChatGPT.exe` while + Windows app-servers run as `codex.exe` / `codex-code-mode-host`, so they still receive + SIGTERM before the app quits. The restart is correct; the cost is one extra interrupted + turn. Widening that query changes what may be killed, so it needs its own verification. + Documented at the decision point in `src/cli/restart-scope.ts`. +- **`ocx system codex-restart` refuses rather than handing off** when the proxy itself runs + inside the Codex app, because a proxy never exits and the handoff waits for the caller. +- **The local-macOS handoff path is unproven on a live host.** It is implemented and + unit-tested, and proving it ends the session that issues it, so it is deliberately the + last action rather than an omission. diff --git a/devlog/_plan/260913_devin_image_passthrough/000_plan.md b/devlog/_plan/260913_devin_image_passthrough/000_plan.md new file mode 100644 index 0000000000..d8b484d089 --- /dev/null +++ b/devlog/_plan/260913_devin_image_passthrough/000_plan.md @@ -0,0 +1,108 @@ +# 000 — Devin 이미지 패스스루 + +- 단위: `260913_devin_image_passthrough` +- 세션: `01a0985e-ce1a-7d12-81b9-c2e93a2bce67` (HOTL, cxc-loop) +- 기준: `origin/dev` + +## 증상 + +사용자가 Codex composer에 이미지를 붙여넣고 devin/swe-2에 보냈더니 턴이 0초에 죽었다. +이미지가 전달되지 않아 tesseract OCR로 우회하려던 상황이었다. + +## 원인 — 세 층이 겹쳐서 + +와이어 계층(`src/adapters/devin/cloud-direct/chat.ts`)은 이미 멀티모달이다. +`ContentPart`에 `{type:"image", mimeType, base64Data, caption}`이 있고 +`encodeImageData`(:189-196)가 이를 `ChatMessagePrompt` 필드 #10 `ImageData` +`{#1 base64_data, #2 mime_type, #3 caption}`로 인코딩한다. extension.js 대조 검증 완료. + +그런데 매핑 계층(`src/adapters/devin.ts`)이 이미지를 버린다. + +| 함수 | 줄 | 하는 일 | +|---|---|---| +| `textFromParts` | :205-209 | `type:"text"`만 뽑아 문자열로 반환 — 이미지는 빈 문자열 기여 | +| `mapOneMessage` (user) | :293-294 | 텍스트만 남기고 `if (!text) return undefined` — **이미지만 있는 메시지가 통째로 사라짐** | +| `toolResultText` | :211-214 | 같은 방식으로 툴 결과의 이미지도 버림 | + +사용자의 스크린샷에서 data: URI가 텍스트 첨부로 보인 것은 UI 표시이고, 실제로는 +`OcxImageContent`(`types/request.ts:189-195`)의 `imageUrl`이 data: URL로 들어온다. +매핑이 그것을 인식하지 못하고 텍스트 추출에서 빈 문자열을 얻어 메시지를 드롭한다. + +## 수정 — `src/adapters/devin.ts` + +### 1) NEW: `mapOcxContentToWire` 헬퍼 + +```ts +import type { ContentPart } from "./devin/cloud-direct/chat"; + +/** + * Convert inbound content parts to the wire shape the encoder accepts. + * + * The wire layer is already multimodal (ChatMessagePrompt field #10 ImageData), + * but every image was discarded here: textFromParts returned text-only strings, + * and a message whose only content was an image was dropped entirely. A data: + * URL carries everything field #10 needs; a remote https URL cannot be inlined + * without a fetch, so it stays as an explicit text reference rather than + * pretending the model can see a picture it cannot. Video has no Devin field. + */ +function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): string | ContentPart[] { + if (typeof content === "string" || !Array.isArray(content)) return content ?? ""; + const out: ContentPart[] = []; + for (const part of content) { + if (part.type === "text" && part.text) out.push({ type: "text", text: part.text }); + else if (part.type === "image") { + const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/); + if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! }); + else out.push({ type: "text", text: "[image url: " + part.imageUrl + "]" }); + } + } + return out; +} +``` + +### 2) MODIFY: `mapOneMessage` user/developer 분기 + +```ts +// before + const text = textFromParts(message.content).trim(); + if (!text) return undefined; + return { role: ..., content: text }; + +// after + const content = mapOcxContentToWire(message.content); + // 텍스트 없이 이미지만 있는 메시지도 유효하다 — 드롭하면 안 된다. + if (typeof content === "string" ? !content.trim() : content.length === 0) return undefined; + return { role: ..., content }; +``` + +### 3) MODIFY: 툴 결과 + +```ts +// before + content: toolResultText(message), + +// after — 오류 접두사는 유지하되, 이미지가 있으면 ContentPart[]로 넘긴다 + const wireContent = mapOcxContentToWire(message.content); + content: message.isError + ? (typeof wireContent === "string" ? "ERROR: " + wireContent + : [{ type: "text", text: "ERROR:" }, ...wireContent]) + : wireContent, +``` + +## NEW: tests/providers/devin-image-passthrough.test.ts + +| 케이스 | 기대 | +|---|---| +| data: URL 이미지 파트가 ContentPart image로 변환 | `{type:"image", mimeType:"image/png", base64Data:"iVBOR..."}` | +| 이미지만 있는 user 메시지가 드롭되지 않음 | items에 존재 | +| 텍스트 + 이미지 혼합 | 순서 보존 | +| https URL 이미지 | 텍스트 참조로 남음 | +| 툴 결과의 이미지 | ContentPart[]로 전달 | +| 툴 결과 오류 + 이미지 | ERROR 접두사 유지 | +| 와이어 인코딩 | buildGetChatMessageRequestForTests가 field #10을 냄 | + +## 레이아웃 등록 + +- `scripts/test-layout/layout.json` explicit → providers +- `tests/fixtures/test-layout-expected.json` + diff --git a/devlog/_plan/260914_codex_history_preflight_scope/000_plan.md b/devlog/_plan/260914_codex_history_preflight_scope/000_plan.md new file mode 100644 index 0000000000..0dc5d7dde5 --- /dev/null +++ b/devlog/_plan/260914_codex_history_preflight_scope/000_plan.md @@ -0,0 +1,126 @@ +# 000 — History preflight stands down only paginated relabel on apply + +- Unit: `260914_codex_history_preflight_scope` +- Opened 2026-09-14 +- PR: https://github.com/lidge-jun/opencodex/pull/4531 +- Class C4 (Codex-home config write + conversation-history safety; public `ocx sync` contract) + +The first shipped draft scoped every history preflight, in both +directions, and treated already-tagged `opencodex` rows as a +pre-existing limitation. Automated review on the pull request found +that both claims were wrong. This file describes the narrowed +contract that actually shipped. The two P1s — restore/remove must +keep the hard refusal, and a provider table the home already +published must be kept — came from the automated Codex reviewer, not +from the original analysis. The incident review was `structure/`-aware. + +## Objective + +On apply, `history_paginated_requires_native_writer` stands the +conversation-history relabel unit down and still writes the config / +profile / catalog half. That is the only reason that stands down, +and apply is the only direction that does. + +Restore and remove keep their original hard refusal. Every other +preflight reason keeps the original hard refusal and the +compensating rollback. The uninstall deadlock on an already-paginated +home is not this unit's to close. + +The on-disk catalog was already correct. The picker showed six +built-in OpenAI models because `model_catalog_json` never reached +`~/.codex/config.toml`. `sync.ts` then downgraded that apply veto to +a successful catalog-only result. The operator saw `Model catalog +synchronized`. The picker did not. + +## Symptom + +On Codex `0.154.0-alpha.6.2`, the model picker in both the desktop app and +the CLI showed only the six built-in OpenAI models. + +`ocx sync --restart-app-server-only` printed `Model catalog synchronized` +and restarted the app-server, so the failure looked like a success. The +on-disk catalog `~/.codex/opencodex-catalog.json` was correct the whole +time (24 models). Evidence for the chain that produced this is in `010`. +The contract that replaces the apply-path veto is in `020`. + +## Constraints + +- **No local product suite.** `bun test`, `bun run test`, and + `bun run test:changed` are NOT RUN for this unit. The local suite was + deliberately never run. Hosted CI is the verification gate. +- Paginated rollout bytes and thread rows are never modified while the + preflight refuses. The native writer stays the only writer of that + shape. +- Restore and remove stay hard-refused. Softening them without a + keep-the-table seam on restore orphans conversations that still + reference `[model_providers.opencodex]`. +- Only `history_paginated_requires_native_writer` is a stand-down. + Treating a transient reason as one would let + `resolveCodexHistoryTransition` record the transition as converged + and suppress the relabel permanently. +- Do not invent facts beyond the chain and contract recorded in `010` + and `020`. No security-sensitive or pre-disclosure material belongs + here. + +## Work-phase map + +| wp | Doc | Output | +|---|---|---| +| wp0 | this file | objective and completion criteria | +| wp1 | `010_rootcause_evidence.md` | verified cause chain and file:line | +| wp2 | `020_fix_and_contract_change.md` | shipped contract, review corrections, open follow-up | + +## Completion criteria + +- Apply writes the config / profile / catalog half when the preflight + reason is `history_paginated_requires_native_writer` (held in + `HISTORY_RELABEL_STANDS_DOWN`). The relabel job is skipped without + spawning a Worker. The reason is reported in the human message and + in `historyPreflightFailureReason`, alongside `success: true`. A + mid-transaction observation of that same reason retires the relabel + unit instead of rolling the config back. +- Every other apply-path reason + (`history_injection_preflight_unavailable`, + `history_state_database_missing`, rollout-integrity codes) keeps the + original hard refusal and the compensating rollback, including when + observed mid-transaction, where it throws + `CodexHistoryPreflightRefusal`. +- `removeCodexConfig`, `restoreCodexConfigInlineImpl`, + `restoreNativeCodex`, and `restoreNativeCodexAsync` keep the + original hard refusal on a history preflight failure. The uninstall + deadlock on an already-paginated home remains open follow-up; a + later fix needs a keep-the-table seam on the restore path. +- When the relabel stands down, a `[model_providers.opencodex]` table + the home already published is kept. The injector snapshots + `hadOcxProviderTableOnDisk` before its idempotent cleanup and + re-appends the table before the write witness is built if the form + is not already table-based. Removing that table would be a new + regression, not a pre-existing limitation. +- `src/codex/sync.ts` no longer special-cases the paginated-history + reason into a catalog-only `ok: true`. A surviving refusal is a + real failure again. +- Tests match the narrowed contract: + `tests/codex-integration/codex-inject-integration.test.ts` (commit- + boundary and paginated-history cases inverted; new case pinning that + `model_catalog_json` reaches `config.toml` on a paginated home) and + `tests/codex-integration/codex-sync-api.test.ts` (the two + `catalog-only` downgrade tests replaced). +- `bun run typecheck`, `bun run structure:check`, and + `bun run privacy:scan` pass. Full CI on PR #4531 went green on the + earlier revision (all four test shards plus macOS); the narrowed + revision is being re-run. Local suite: NOT RUN. Live recovery on + the affected machine is user-confirmed: the model picker shows the + routed models again in both the Codex app and the CLI. + +## Terminal outcomes + +- **DONE** — `010` and `020` record the chain and the narrowed + contract; the apply-path completion criteria above hold; the + uninstall deadlock is recorded as open follow-up, not claimed + closed. +- **BLOCKED** — a fact required by `010` or `020` cannot be stated + without invention. Stop rather than fill the gap. +- **UNSAFE** — any design that writes paginated rollout bytes or + thread rows under a preflight refusal, or that strips + `[model_providers.opencodex]` while tagged rows still reference it. + Stop and redesign. diff --git a/devlog/_plan/260914_codex_history_preflight_scope/010_rootcause_evidence.md b/devlog/_plan/260914_codex_history_preflight_scope/010_rootcause_evidence.md new file mode 100644 index 0000000000..f0f389f395 --- /dev/null +++ b/devlog/_plan/260914_codex_history_preflight_scope/010_rootcause_evidence.md @@ -0,0 +1,113 @@ +# 010 — Root-cause evidence + +The chain below was verified by direct execution on the affected machine. +Nothing here is inferred from logs alone. + +The original write-up treated step 6 as a defect this unit would close +in the same pass as the apply-path veto. That was wrong. Automated +review on https://github.com/lidge-jun/opencodex/pull/4531 (the +incident review was `structure/`-aware; the P1 came from the +automated Codex reviewer) kept the remove and restore hard refusals. +Step 6 remains a description of the original home, not a completion +claim. + +## Symptom, restated as observed state + +| Surface | Observed | +|---|---| +| Codex version | `0.154.0-alpha.6.2` | +| Desktop picker | six built-in OpenAI models only | +| CLI picker | six built-in OpenAI models only | +| `ocx sync --restart-app-server-only` | printed `Model catalog synchronized`; restarted the app-server | +| `~/.codex/opencodex-catalog.json` | correct the whole time; 24 models | +| `~/.codex/config.toml` | only `experimental_realtime_ws_base_url`; no `model_catalog_json` | + +The catalog file was never the defect. A successful-looking sync that +leaves `config.toml` without `model_catalog_json` is the failure. + +## Verified chain + +### 1. Current Codex writes paginated rollout files + +The first JSONL line of a current rollout carries an `ordinal` field. + +### 2. `assertLegacyHistoryRecord` rejects that shape + +`src/codex/history-provider.ts:279-290` throws +`CodexHistoryIntegrityError("history_paginated_requires_native_writer")` +for any record with `ordinal` present or +`payload.history_mode === "paginated"`. + +### 3. Preflight catches the throw and returns the reason string + +`preflightCodexHistoryInjection` (`src/codex/history-provider.ts:337-376`) +catches that error and returns `history_paginated_requires_native_writer`. + +On the affected machine both of these returned that reason: + +- `preflightCodexHistoryInjection(true, true)` +- `preflightCodexHistoryInjection(false, false)` + +### 4. `injectCodexConfig` used the reason as a hard veto + +`injectCodexConfig` treated the preflight reason as a veto on the entire +config write and returned `success: false` before any file was touched. +`model_catalog_json` and the routing keys never reached +`~/.codex/config.toml`. + +Verified on disk: `config.toml` contained only +`experimental_realtime_ws_base_url` and no `model_catalog_json`. + +### 5. `syncModelsToCodex` hid the veto + +`syncModelsToCodex` (`src/codex/sync.ts`) special-cased exactly that +reason and downgraded it to a catalog-only result with `ok: true` and +the message + +```text +Model catalog synchronized; Codex config and conversation history left unchanged because paginated history requires its native writer. +``` + +That is what made the apply-path regression silent. The operator, and +`--restart-app-server-only`, were told the catalog had synchronized. + +### 6. The same preflight gated remove and restore — and still does + +The same preflight also gated `removeCodexConfig`, +`restoreCodexConfigInlineImpl`, `restoreNativeCodex`, and +`restoreNativeCodexAsync`. + +On the affected machine, 3 thread rows out of 14164 were tagged +`model_provider = 'opencodex'`. Those 3 rows were enough to deadlock +apply, remove, **and** restore at the same time. + +This unit closes only the apply side of that deadlock, and only for +`history_paginated_requires_native_writer`. Remove and restore keep +the original hard refusal. The first draft claimed those directions +could proceed because they open no state database and no rollout. +Review rejected that: stripping `[model_providers.opencodex]` while +thread rows still reference it makes those conversations +unresolvable, and the restore path has no seam for keeping a +compatibility provider table. The uninstall deadlock on an +already-paginated home is therefore still open. A later fix needs +that keep-the-table seam; it is not implied by the apply-path +stand-down. + +## What this chain does and does not prove + +It proves the picker failure is an apply-path config-write veto, not a +catalog-file miss and not an app-server restart miss. The sidecar +catalog was already right; the app-server did restart; `config.toml` +never gained `model_catalog_json`. + +It does not authorize writing paginated rollout bytes or retagging +thread rows. The native-writer refusal on the history unit remains +correct for that shape: Codex allocates paginated rollout ordinals in +its own writer, and no retry changes that. That is why only +`history_paginated_requires_native_writer` stands the relabel unit +down. Other reasons stay hard refusals so a transient miss cannot be +recorded as a converged transition. + +It does not prove that remove and restore were vetoed by mistake. +The original analysis asserted they were; review showed the opposite. +Those refusals stay. diff --git a/devlog/_plan/260914_codex_history_preflight_scope/020_fix_and_contract_change.md b/devlog/_plan/260914_codex_history_preflight_scope/020_fix_and_contract_change.md new file mode 100644 index 0000000000..d1ad3ee973 --- /dev/null +++ b/devlog/_plan/260914_codex_history_preflight_scope/020_fix_and_contract_change.md @@ -0,0 +1,129 @@ +# 020 — Fix and contract change + +PR: https://github.com/lidge-jun/opencodex/pull/4531 + +On apply, `history_paginated_requires_native_writer` stands the +conversation-history relabel unit down and still writes the config / +profile / catalog half. Restore and remove keep their original hard +refusal. That is narrower than the first draft, which claimed a +history preflight never vetoed the config write in either direction. + +The two P1s that forced the narrowing — restore/remove stay refused, +and a provider table the home already published must be kept — came +from the automated Codex reviewer on the pull request, not from the +original analysis. The original write-up asserted the opposite on +both points. The incident review was `structure/`-aware. + +## Apply + +Only one reason stands the relabel unit down: +`history_paginated_requires_native_writer`, held in the module +constant `HISTORY_RELABEL_STANDS_DOWN`. Codex allocates paginated +rollout ordinals in its own writer; no retry changes that. + +When that reason is what preflight returns, config is written. The +relabel job is skipped without spawning a Worker. The reason is +reported in the human message and in the structured +`historyPreflightFailureReason` field, alongside `success: true`. A +mid-transaction observation of that same reason retires the relabel +unit instead of rolling the config back. + +Every other reason keeps the original hard refusal and the +compensating rollback: + +- `history_injection_preflight_unavailable` +- `history_state_database_missing` +- rollout-integrity codes + +That includes a mid-transaction observation of those reasons, which +throws `CodexHistoryPreflightRefusal`. Treating a transient failure +as a stand-down would let `resolveCodexHistoryTransition` record the +transition as converged and suppress the relabel permanently. + +## Restore / remove — hard refusal kept + +`removeCodexConfig`, `restoreCodexConfigInlineImpl`, +`restoreNativeCodex`, and `restoreNativeCodexAsync` keep their +original hard refusal on a history preflight failure. The first +draft softened those paths and argued they opened no state database +and no rollout, so a history preflight had never authorized them. +That argument is withdrawn. + +Stripping the `[model_providers.opencodex]` definition while thread +rows still reference it makes those conversations unresolvable. The +restore path has no seam for keeping a compatibility provider table. +So the uninstall deadlock on an already-paginated home is **not** +fixed by this unit. + +Open follow-up: lift the remove/restore deadlock only after the +restore path gains a keep-the-table seam. Until that seam exists, +the hard refusal stays. + +## Provider table the home already published + +Rows tagged `opencodex` resolve only through +`[model_providers.opencodex]`. The loopback (Design B) form normally +retires that table because the relabel migrates those rows back to +`openai` in the same pass. With the relabel stood down, retiring it +would orphan those conversations. + +The injector therefore snapshots `hadOcxProviderTableOnDisk` before +its idempotent cleanup and re-appends the table before the write +witness is built when the relabel stood down and the form is not +already table-based. + +The first draft recorded those rows as a pre-existing limitation: +they were equally unresolvable while the refusal blocked the write, +so keeping or dropping the table did not matter. That is true only +for a home that never published the table. For a home that **had** +the table, removing it would have been a new regression. Review +caught that; the original analysis had asserted the opposite. + +## `sync.ts` + +`syncModelsToCodex` lost the `catalog-only` downgrade. A surviving +refusal is a real failure again. The silent `ok: true` / +`Model catalog synchronized; Codex config and conversation history left +unchanged because paginated history requires its native writer.` path +is gone. + +## Safety argument + +Paginated rollout bytes and thread rows are not rewritten, and no +Worker is spawned to relabel them. The stand-down is not a write +grant over history bytes. + +The stand-down is also not a write grant over remove or restore. +Those directions still refuse, because the only safe restore that +would accompany a config unwind is one that can keep +`[model_providers.opencodex]` for rows that still name it, and that +seam does not exist yet. + +Keeping a table the home already published is a preservation of +resolvability, not a new provider install. A home that never had the +table still does not gain one from this path. + +## Tests changed + +`tests/codex-integration/codex-inject-integration.test.ts` + +- The commit-boundary test and the paginated-history test were inverted + to the apply-path stand-down contract. +- A new test pins that `model_catalog_json` reaches `config.toml` on a + paginated home. + +`tests/codex-integration/codex-sync-api.test.ts` + +- The two `catalog-only` downgrade tests were replaced. A surviving + refusal is a failure, not a successful catalog-only sync. + +## Verification so far + +- `bun run typecheck`, `bun run structure:check`, and + `bun run privacy:scan` pass. +- Full CI on PR #4531 went green on the earlier revision (all four + test shards plus macOS). The narrowed revision is being re-run. +- The local product suite was deliberately never run. +- Live recovery on the affected machine is user-confirmed: the model + picker shows the routed models again in both the Codex app and the + CLI. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 4f07ffaced..9a603b910a 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -336,8 +336,9 @@ S'il manque un modèle dans Codex, ou si l'ordre ou la visibilité du catalogue 6. **Processus Codex `app-server` actif** — réécrire le catalogue sur disque ne suffit pas tant qu'un processus Codex `app-server` de longue durée — Codex Desktop ou hôte d'arrière-plan de la CLI — conserve l'ancienne liste en mémoire. `ocx sync` et `ocx sync-cache` émettent un avertissement lorsqu'ils détectent ces processus. - Redémarrez-les avec `ocx sync --restart-codex`, ou arrêtez vous-même les processus `app-server` concernés, - puis laissez Codex les recréer afin que la nouvelle liste apparaisse. + `ocx sync --restart-codex` les redémarre et quitte puis relance entièrement l'application Codex Desktop sous + macOS, Linux et Windows, afin que le sélecteur relise le catalogue. Pour laisser l'application Desktop ouverte, + passez `--restart-app-server-only` ou arrêtez vous-même les processus `app-server` concernés. :::caution[Autres processus d'écriture locaux] Les écritures du catalogue (`opencodex-catalog.json`, `config.toml`) sont atomiques **au sein** d'opencodex. @@ -418,6 +419,6 @@ Codex. Seule l'exécution explicite de `ocx stop` ou `ocx service stop` restaure ## Refus de sécurité pour l’historique paginé -Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. OpenCodex conserve configuration, profil, catalogue, historique et preuves de restauration au lieu d’attribuer des numéros hors de Codex. Les sorties sans transition, comme la préservation d’un fournisseur externe, restent disponibles. +Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` et la suppression de la configuration Codex refusent toujours sur `history_paginated_requires_native_writer`. Retirer la définition `[model_providers.opencodex]` alors que des lignes de conversation la référencent encore rendrait ces conversations irrésolubles, et le chemin de restauration n’a aucun moyen de conserver une table de fournisseur de compatibilité. Un répertoire d’accueil déjà paginé ne peut pas actuellement être désinstallé par le produit ; c’est un travail ouvert connu, et non le comportement voulu. -Ne supprimez pas un fournisseur encore référencé, ne répétez pas `ocx sync` ou une restauration legacy et ne réécrivez pas un historique actif. Conservez les fichiers, fermez la conversation avant toute récupération et signalez l’erreur exacte et les versions sans publier de données privées. Utilisez un correctif vérifié coordonné avec le processus natif d’écriture. Une sauvegarde ou le succès d’un script ne prouve pas le rétablissement de l’affichage : vérifiez la conversation après réouverture de Codex. +Ne réécrivez pas un historique paginé actif ni une ligne de conversation pour forcer une migration. Fermez la conversation avant toute récupération et signalez l’erreur exacte et les versions sans publier de données privées. Une sauvegarde ou le succès d’un script ne prouve pas le rétablissement de l’affichage : vérifiez la conversation après réouverture de Codex. diff --git a/docs-site/src/content/docs/fr/guides/factory-droid.md b/docs-site/src/content/docs/fr/guides/factory-droid.md index 515c3ac66b..4328811bc2 100644 --- a/docs-site/src/content/docs/fr/guides/factory-droid.md +++ b/docs-site/src/content/docs/fr/guides/factory-droid.md @@ -120,14 +120,14 @@ Cette commande crée l’entrée de configuration `providers.droid`. Dans le tab Les identifiants de modèle ne sont que des exemples. Ne conservez que les modèles utilisables par `droid exec` avec le compte Factory connecté. N’ajoutez pas d’en-têtes d’inférence propres à Factory à ce fournisseur : son service en amont est le pont local, et non un point de terminaison HTTP Factory. -Après avoir enregistré un fournisseur ou modifié son catalogue statique, synchronisez puis redémarrez le serveur d’application Codex afin que les nouvelles sessions lisent le catalogue à jour : +Après avoir enregistré un fournisseur ou modifié son catalogue statique, synchronisez puis redémarrez Codex afin que les nouvelles sessions lisent le catalogue à jour : ```bash ocx sync --restart-codex ocx doctor ``` -Le redémarrage des processus du serveur d’application Codex interrompt les travaux Codex actifs. Ne le lancez qu’après avoir terminé ou enregistré ces sessions. +`--restart-codex` redémarre les app-servers correspondants et quitte puis relance entièrement l’application Codex Desktop, ce qui termine les conversations en cours. Utilisez `--restart-app-server-only` pour laisser l’application Desktop ouverte. Ne lancez le redémarrage qu’après avoir terminé ou enregistré ces sessions. ## Vérifier la route complète diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 798862c525..378dc215d2 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -363,6 +363,11 @@ Le préréglage DeepSeek intégré route également `deepseek-v4-flash` par son et conserve le streaming SSE en amont. Si ce modèle termine tous les éléments de sortie mais omet l'événement Responses final, opencodex applique une réparation après un délai de grâce de cinq secondes, limitée à ce modèle ; les flux mal formés ou partiels sont fermés comme incomplets, et non déclarés réussis. +Le modèle DeepSeek de première partie `deepseek-flash` déclare nativement les entrées `text` et `image` ; +les requêtes contenant une image sont donc envoyées directement à DeepSeek par défaut sans passer par le +sidecar de vision. Les déclarations explicites `noVisionModels` ou texte seul restent prioritaires. Les modèles +de première partie `deepseek-chat`, `deepseek-reasoner` et `deepseek-v4-flash` restent desservis par le sidecar +par défaut ; les routes Zen sont inchangées et n'ont pas été sondées dans cette mise à jour. > **Trois routes de facturation Volcengine :** `volcengine` correspond à l'API Ark facturée à l'usage, > `volcengine-coding-plan` consomme le quota Coding Plan et `volcengine-agent-plan` le quota Agent Plan. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 69d4ac39d0..31dcb9ac94 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -258,6 +258,8 @@ le CLI, l’API, et le GUI utilisent les mêmes octets. Gérez les paramètres d'exécution sans tête, le démarrage, la synchronisation, les diagnostics et les mises à jour. +`ocx system codex-restart --yes` redémarre les serveurs d'application Codex et quitte puis relance entièrement l'application Codex Desktop, via le même module que `ocx sync --restart-codex`. Lorsque le proxy lui-même s'exécute dans l'application Codex, la commande refuse avec un message actionnable au lieu de promettre un transfert qu'elle ne peut pas mener à bien. + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index aa2254733e..b7959f558f 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -143,19 +143,25 @@ La section **OAuth reliability** indique si le stockage des identifiants est acc ## Synchronisation du catalogue -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` Récupère la liste active des modèles de chaque fournisseur configuré et réinjecte le catalogue fusionné dans Codex. Exécutez cette commande après l’ajout d’un fournisseur ou pour actualiser les modèles disponibles. Avant la découverte des fournisseurs ou le remplacement du catalogue et du cache, `ocx sync` vérifie que la configuration Codex gérée peut recevoir l’injection. Si cette validation refuse la configuration, la commande renvoie un code non nul, affiche la cause précise sur stderr et laisse le catalogue ainsi que le cache existants inchangés. `ocx restore back` effectue la même vérification préalable sans écriture avant de réactiver le routage. -Si des processus Codex `app-server` de longue durée sont encore actifs, `ocx sync` avertit qu’ils peuvent continuer à servir l’ancienne liste de modèles conservée en mémoire, même après la mise à jour de `opencodex-catalog.json` / `models_cache.json`. Ajoutez `--restart-codex` pour envoyer `SIGTERM` uniquement aux processus `codex … app-server` et `codex-code-mode-host` correspondants qui appartiennent à l’utilisateur actuel ; les tours actifs peuvent être interrompus. La recherche générale `pkill -f codex` est volontairement évitée. +Si des processus Codex `app-server` de longue durée sont encore actifs, `ocx sync` avertit qu’ils peuvent continuer à servir l’ancienne liste de modèles conservée en mémoire, même après la mise à jour de `opencodex-catalog.json` / `models_cache.json`. Ajoutez `--restart-codex` pour redémarrer les processus `codex … app-server` et `codex-code-mode-host` correspondants **et** quitter puis relancer entièrement l’application Codex Desktop, sous macOS, Linux et Windows, afin que le sélecteur de modèles relise le catalogue. Les conversations en cours se terminent. La recherche générale `pkill -f codex` est volontairement évitée. -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app` est un alias déprécié de `--restart-codex`. Il fonctionne encore, affiche un avis de dépréciation, et n’est pas limité à Windows. -Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit reconstruit à partir du catalogue opencodex actif. Le même avertissement concernant un `app-server` obsolète et le même comportement facultatif `--restart-codex` que pour `ocx sync` s’appliquent. +`--restart-app-server-only` rétablit le comportement étroit d’avant : `SIGTERM` uniquement aux processus app-server et code-mode-host correspondants appartenant à l’utilisateur actuel, l’application Desktop restant ouverte. Les tours actifs peuvent encore être interrompus. Combiné avec `--restart-codex` ou `--restart-desktop-app`, c’est la portée étroite qui l’emporte, car perdre des conversations en cours est irrécupérable, contrairement à un sélecteur périmé. -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +Lorsque la commande s’exécute depuis l’application Codex, le redémarrage est confié à un assistant détaché et cette session se termine avec l’application. + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` + +Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit reconstruit à partir du catalogue opencodex actif. Le même avertissement concernant un `app-server` obsolète et les mêmes options de redémarrage que pour `ocx sync` s’appliquent. + +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` Installe un catalogue complet servi par le point de terminaison `/v1/catalog` d'une autre instance OpenCodex, puis synchronise `models_cache.json`. L'URL doit être en HTTPS ; le HTTP est accepté @@ -166,10 +172,11 @@ d'environnement (`--auth-env`), jamais depuis argv. Le catalogue et le cache sont écrits sous le verrou de catalogue Codex partagé ; un échec préserve les derniers fichiers valides connus. Des octets identiques constituent une non-opération qui -préserve les mtimes. `--restart-codex` ne s'applique qu'après une écriture réelle. Les requêtes -conditionnelles `ETag` et le redémarrage de l'application Desktop ne font pas partie de cette -commande. Voir la [référence anglaise](/reference/cli/lifecycle/) pour l'enveloppe `--json` -complète et les codes de sortie. +préserve les mtimes. `--restart-codex`, `--restart-app-server-only` et l'alias déprécié +`--restart-desktop-app` ont ici le même sens que pour `ocx sync` et `ocx sync-cache`, et ne +s'appliquent qu'après une écriture réelle. Les requêtes conditionnelles `ETag` ne font pas partie +de cette commande. Voir la [référence anglaise](/reference/cli/lifecycle/) pour l'enveloppe +`--json` complète et les codes de sortie. ## Service d’arrière-plan diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index e7da3b7bfb..8bd738a0c4 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -269,7 +269,7 @@ lui-même s'il souhaite ajouter une étoile au dépôt. | `POST /api/system/restart` | Amorcer un redémarrage du processus qui attend l'évacuation des requêtes, sans retirer l'injection du client | Renvoie 202 ; les appels répétés signalent l'évacuation déjà en cours | | `POST /api/stop` | Arrêter le service, restaurer Codex en mode natif, retirer l'injection Grok gérée et évacuer les requêtes du proxy | 409 conflit de propriété du service; 409 `respawnable_service` lorsqu'un wrapper du Planificateur de tâches Windows pourrait relancer le proxy et que l'appelant n'est pas `ocx stop` (rien n'est modifié) ; 409 lorsque le gestionnaire installé refuse de s'arrêter ; 409 `service_state_unknown` lorsque l'état du Planificateur de tâches ne peut pas être lu (rien n'est modifié ; réparez la requête puis réessayez) | | `GET /api/system/codex-app-server` | Indiquer si les serveurs d'application Codex en cours d'exécution sont antérieurs au catalogue de modèles actuel | — | -| `POST /api/system/codex-restart` | Actualiser le catalogue, puis demander aux serveurs d'application Codex obsolètes de s'arrêter afin que le sélecteur de modèles se recharge | Renvoie 200 avec `code: partially_stopped` lorsqu'une cible ne s'arrête pas | +| `POST /api/system/codex-restart` | Actualiser le catalogue, puis redémarrer les serveurs d'application Codex obsolètes et quitter puis relancer entièrement l'application Codex Desktop afin que le sélecteur de modèles se recharge. Lorsque le proxy lui-même s'exécute dans l'application Codex, le redémarrage Desktop est refusé plutôt que transféré. | Renvoie 200 avec `code: partially_stopped` lorsqu'une cible ne s'arrête pas | ### Délégation de l'authentification Codex diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 8219dd2281..209469f41d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -73,13 +73,15 @@ the resulting list; otherwise the native default is used when present, then the choice. Stored custom configuration is unchanged, and repeated syncs do not add `max` back to a narrow custom list. -This requires the exact provider, destination, and capability-backed model identity. An arbitrary -gateway such as `YYLJ/gpt-6-astra` does not inherit native capabilities from its name. Its explicit -custom ladder continues to override discovered provider metadata under the normal routed rules. +The same catalog bound applies when the custom model id has pinned native capability metadata, +including an arbitrary gateway such as `YYLJ/gpt-6-astra`. Desktop validates the model id, so +`none` and `minimal` are stripped from that catalog row. Full native identity still requires the +exact provider, destination, and capability-backed model identity; a gateway does not inherit +Responses Lite, multi-agent, or native windows from its name. Codex's native Astra `ultra` choice is retained: it is a client delegation mode converted to a supported wire effort, distinct from the [API model's effort list](https://developers.openai.com/api/docs/models/gpt-6-astra). -Catalog normalization does not rewrite existing thread settings or establish support for a -particular installed Desktop version. +Catalog normalization does not rewrite existing thread settings. Request-time native effort +clamps remain canonical-forward only. When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If `codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ef237a3be2..edfca8ca6b 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -730,9 +730,10 @@ If a model is missing from Codex, or the catalog order/visibility looks wrong, c default `300000`). Run `ocx sync` to force a fresh fetch and rewrite the catalog immediately. 6. **Running Codex `app-server`** — rewriting the on-disk catalog is not enough while a long-lived Codex `app-server` (Desktop / CLI background host) keeps the previous list in memory. `ocx sync` - and `ocx sync-cache` warn when those processes are detected. Restart them with - `ocx sync --restart-codex` (or stop the matching `app-server` processes yourself), then let Codex - recreate them so the new list appears. + and `ocx sync-cache` warn when those processes are detected. `ocx sync --restart-codex` restarts + those processes and fully quits and relaunches the Codex desktop app on macOS, Linux, and + Windows so the picker re-reads the catalog. To leave the desktop app running, pass + `--restart-app-server-only` or stop the matching `app-server` processes yourself. :::caution[Other local writers] Catalog writes (`opencodex-catalog.json`, `config.toml`) are atomic **inside** opencodex, which only @@ -864,8 +865,8 @@ When a routed preferred model may receive V2 work from a native ChatGPT parent, ## Paginated history safety refusal -When an affected history store supports paginated records, a provider transition may return `history_paginated_requires_native_writer`. OpenCodex preserves the current configuration, profile, catalog, rollout and restore provenance instead of assigning ordinals outside Codex. This includes legacy rows in a migration-capable store. No-transition exits, such as preserving an external provider, remain available. +When an affected history store supports paginated records, a provider transition may return `history_paginated_requires_native_writer`. That reason no longer refuses the Codex configuration, the reference profile, or the model catalog. `ocx sync` and `ocx start` still write those files and set `model_catalog_json`, so the Codex model picker keeps showing every OpenCodex-routed model. Only this one reason stands the conversation-history relabel down, because Codex allocates paginated rollout ordinals in its own writer and no retry changes that. Any other history preflight reason — an unreadable state database, a rollout whose identity changed, or a preflight that could not run — still refuses the whole transition and rolls it back, because those may succeed on a later attempt. OpenCodex never modifies paginated rollout files or thread rows in this state. Existing conversations keep whatever provider they are already tagged with and are not migrated; new conversations route through the proxy normally. When the relabel stands down, a `[model_providers.opencodex]` table that the home already had is kept rather than retired, even in the root-override (loopback) form, so conversations whose rows are tagged `opencodex` keep a provider id that still exists. This includes legacy rows in a migration-capable store. The CLI prints `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. -Native restore checks again after restoring the journal or removing owned configuration. If migration is detected during that write interval, it puts back the prior configuration, profile and journal, skips catalog/history restoration, and rolls back any coordinated remove transition. This compensation does not lock out Codex's own writer or exclude changes after the final check. +`ocx restore` and Codex config removal still refuse on `history_paginated_requires_native_writer`. Stripping the `[model_providers.opencodex]` definition while thread rows still reference it would make those conversations unresolvable, and the restore path has no way to keep a compatibility provider table. A home that is already paginated cannot currently be uninstalled through the product; that is known open work rather than intended behaviour. -Do not delete a provider definition still referenced by a conversation, repeatedly run `ocx sync` or legacy recovery, or rewrite an active rollout to work around this refusal. Keep the current files, close the affected conversation before any recovery, and report the exact error and versions without uploading private history. Use a verified fix with native-writer coordination; a backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. +Do not rewrite an active paginated rollout or thread row to migrate those conversations yourself. Close the affected conversation before any recovery, and report the exact error and versions without uploading private history. A backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. diff --git a/docs-site/src/content/docs/guides/factory-droid.md b/docs-site/src/content/docs/guides/factory-droid.md index 55a8983792..cca156892c 100644 --- a/docs-site/src/content/docs/guides/factory-droid.md +++ b/docs-site/src/content/docs/guides/factory-droid.md @@ -141,16 +141,17 @@ The model IDs are examples. Keep only models that `droid exec` can use for the s account. Do not add Factory-specific inference headers to this provider: its upstream is the local bridge, not a Factory HTTP endpoint. -After saving a provider or changing its static catalog, synchronize and restart the Codex -app-server so new sessions read the updated catalog: +After saving a provider or changing its static catalog, synchronize and restart Codex so new +sessions read the updated catalog: ```bash ocx sync --restart-codex ocx doctor ``` -Restarting Codex app-server processes interrupts active Codex work. Run the restart only after -finishing or saving those sessions. +`--restart-codex` restarts matching app-servers and fully quits and relaunches the Codex desktop +app, which ends live conversations. Use `--restart-app-server-only` to leave the desktop app +running. Run the restart only after finishing or saving those sessions. ## Verify the complete route diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7deb669c24..f45f653c39 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -524,6 +524,11 @@ The built-in DeepSeek preset also routes `deepseek-v4-flash` over its native Res keeps upstream SSE streaming enabled. If that model finishes every output item but omits the final Responses event, opencodex applies a five-second model-scoped grace repair; malformed or partial streams close as incomplete rather than being reported as successful. +The first-party `deepseek-flash` model advertises native `text` and `image` input, so image requests +are sent directly to DeepSeek by default instead of through the vision sidecar. Explicit +`noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, +`deepseek-reasoner`, and `deepseek-v4-flash` remain sidecar-backed by default. Zen routes are +unchanged and were not probed in this update. > **Three Volcengine billing routes:** `volcengine` is the pay-as-you-go Ark API, > `volcengine-coding-plan` consumes Coding Plan quota, and `volcengine-agent-plan` consumes Agent diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index cee0da3f07..d87c5b9513 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -135,6 +135,10 @@ allow attachments instead of blocking them before the sidecar runs. When use the `gpt-5.6-luna` fallback. Startup still migrates an explicitly persisted legacy `gpt-5.4-mini` value to `gpt-5.6-luna`; that migration applies to a stored value, not to an absent model field. +The first-party DeepSeek `deepseek-flash` model is native multimodal (`text` and `image`) and does +not use this sidecar by default. Explicit `noVisionModels` or text-only declarations remain +authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` remain +sidecar-backed by default; Zen routes are unchanged and were not probed in this update. - Images can come from user, developer, and tool-result messages, including Codex's `view_image`. - On the OpenAI path (ChatGPT-login passthrough), each image is sent to the configured vision model diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index e621f1fd40..cc44ec28c3 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -217,8 +217,7 @@ ocx sync-cache `/models` ではなく `GetUsableModels` RPC であるため、カーソル側の変更により、他のプロバイダーとは独立して表示される ID が変更される可能性があります。 5. **キャッシュと `ocx sync`** - ライブ カタログは約 5 分間キャッシュされます (`modelCacheTtlMs`、 デフォルト `300000`)。 `ocx sync` を実行して新しいフェッチを強制し、カタログをすぐに再書き込みします。 -6. **Codex `app-server` の実行** - 有効期間が長い間、ディスク上のカタログを書き換えるだけでは十分ではありません -Codex `app-server` (デスクトップ/CLI バックグラウンド ホスト) は、以前のリストをメモリに保持します。 `ocx sync` および `ocx sync-cache` は、これらのプロセスが検出されると警告します。 `ocx sync --restart-codex` でそれらを再起動し (または、一致する `app-server` プロセスを自分で停止し)、Codex でそれらを再作成すると、新しいリストが表示されます。 +6. **Codex `app-server` の実行** - 有効期間が長い Codex `app-server` (デスクトップ/CLI バックグラウンド ホスト) が以前のリストをメモリに保持している間は、ディスク上のカタログを書き換えるだけでは十分ではありません。 `ocx sync` および `ocx sync-cache` は、これらのプロセスが検出されると警告します。 `ocx sync --restart-codex` はそれらを再起動し、macOS、Linux、Windows で Codex デスクトップ アプリを完全に終了して再起動し、ピッカーがカタログを読み直すようにします。デスクトップ アプリを起動したままにするには `--restart-app-server-only` を渡すか、一致する `app-server` プロセスを自分で停止してください。 :::caution[その他の地元作家] カタログ書き込み (`opencodex-catalog.json`、`config.toml`) はアトミック **内部** opencodex であり、opencodex が所有する 2 つのライターが競合する場合にのみ、ファイルの書きかけが防止されます。これは、opencodex が書き込まれた後に、別のローカル プロセス、ファイル ウォッチャー、または同期エージェントがカタログの可視性や順序を書き換えることを**阻止するものではありません。 Codex は個別の `models_cache.json` を保持しており、それを個別に更新して、`opencodex-catalog.json` を書き換えることなく表示リストを変更できます。プロキシの実行中にモデルが予期せず反転した場合は、競合するライターを停止または再構成してから、`ocx sync` を実行します。これは外部ライターの危険であり、確認された opencodex の欠陥ではありません。 @@ -282,6 +281,6 @@ opencodex が管理対象 [バックグラウンドサービス](/reference/cli/ ## ページ分割履歴の保護による拒否 -対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` で拒否されることがあります。Codex の外で番号を割り当てず、設定、プロファイル、カタログ、履歴ファイル、復元情報を保持します。移行可能なストアの legacy 行も対象です。外部プロバイダーを保持するだけの経路は利用できます。 +対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore` と Codex 設定の削除は、いまも `history_paginated_requires_native_writer` で拒否されます。スレッド行がまだ参照しているのに `[model_providers.opencodex]` 定義を外すと、それらの会話は解決できなくなり、復元経路には互換プロバイダー表を残す手段がありません。すでにページ分割されているホームは、現状では製品からアンインストールできません。意図した動作ではなく、既知の未解決作業です。 -会話が参照するプロバイダー定義を削除したり、`ocx sync` や旧式の復元を繰り返したり、使用中の履歴を書き換えたりしないでください。ファイルを保持し、復元前に対象の会話を閉じ、個人の履歴を公開せず正確なエラーとバージョンを報告してください。ネイティブの書き込み処理と連携する検証済みの修正が必要です。バックアップやスクリプトの成功だけでは表示の復元は証明されません。再度開いた Codex で確認してください。 +会話を移行しようとして使用中のページ分割履歴やスレッド行を書き換えないでください。復元前に対象の会話を閉じ、個人の履歴を公開せず正確なエラーとバージョンを報告してください。バックアップやスクリプトの成功だけでは表示の復元は証明されません。再度開いた Codex で確認してください。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index e2431ff41f..29dc8cd730 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -184,6 +184,8 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex ヘッドレス ランタイムの設定、起動、同期、診断、更新を管理します。 +`ocx system codex-restart --yes` は `ocx sync --restart-codex` と同じモジュールで Codex app-server を再起動し、デスクトップ アプリも完全に終了して再起動します。プロキシ自体が Codex アプリ内で動いている場合、完了できない引き渡しを約束せず、実行可能な案内とともに拒否します。 + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 6fd2dc6333..c7a3379fca 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -148,17 +148,23 @@ ocx status --json ## カタログの同期 -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` 構成されているすべてのプロバイダーからライブ モデル リストを取得し、マージされたカタログを Codex に再挿入します。プロバイダーを追加した後、または利用可能なモデルを更新するために実行します。 -存続期間の長い Codex `app-server` プロセスがまだ実行されている場合、`ocx sync` は、`opencodex-catalog.json` / `models_cache.json` が更新されても、以前のメモリ内モデル リストを提供し続ける可能性があることを警告します。現在のユーザーが所有する一致する `codex … app-server` および `codex-code-mode-host` プロセスにのみ `SIGTERM` を送信するには、`--restart-codex` を渡します (アクティブなターンが中断される可能性があります)。広範な `pkill -f codex` 一致は意図的に回避されます。 +存続期間の長い Codex `app-server` プロセスがまだ実行されている場合、`ocx sync` は、`opencodex-catalog.json` / `models_cache.json` が更新されても、以前のメモリ内モデル リストを提供し続ける可能性があることを警告します。`--restart-codex` を渡すと、一致する `codex … app-server` および `codex-code-mode-host` プロセスを再起動し、さらに macOS、Linux、Windows で Codex デスクトップ アプリを完全に終了して再起動します。モデル ピッカーがカタログを読み直すためです。進行中の会話は終了します。広範な `pkill -f codex` 一致は意図的に回避されます。 -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app` は `--restart-codex` の非推奨エイリアスです。引き続き動作し、非推奨の案内を出力し、Windows 専用ではありません。 -Codex のローカル モデル ピッカー キャッシュを無効にし、アクティブな opencodex カタログから再構築されるようにします。 `ocx sync` と同じ、古い `app-server` 警告とオプションの `--restart-codex` 動作が適用されます。 +`--restart-app-server-only` は以前の狭い動作を復元します。現在のユーザーが所有する一致する app-server / code-mode-host プロセスにのみ `SIGTERM` を送り、デスクトップ アプリは起動したままにします (アクティブなターンは中断される可能性があります)。`--restart-codex` または `--restart-desktop-app` と同時に指定した場合は狭い範囲が優先されます。進行中の会話を失うことは取り返しがつかず、古いピッカーはそうではないからです。 -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +コマンドを Codex アプリ内から実行すると、再起動は切り離されたヘルパーに引き渡され、このセッションはアプリとともに終了します。 + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` + +Codex のローカル モデル ピッカー キャッシュを無効にし、アクティブな opencodex カタログから再構築されるようにします。 `ocx sync` と同じ、古い `app-server` 警告とオプションの再起動フラグが適用されます。 + +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` 別の OpenCodex インスタンスの `/v1/catalog` エンドポイントが提供する完全なカタログをインストール し、続いて `models_cache.json` を同期します。URL は HTTPS が必須で、HTTP はループバックのみ許可 @@ -167,9 +173,10 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 のみ読み取られ、argv からは読み取られません。 カタログとキャッシュは共有の Codex カタログロックの下で書き込まれ、失敗時は last-known-good の -ファイルが保持されます。バイトが同一の場合は mtime を保持する no-op です。`--restart-codex` は -実際の書き込みの後にのみ適用されます。`ETag` 条件付きリクエストと Desktop アプリの再起動は、この -コマンドには含まれません。`--json` エンベロープと終了コードの詳細は +ファイルが保持されます。バイトが同一の場合は mtime を保持する no-op です。`--restart-codex`、 +`--restart-app-server-only`、非推奨エイリアス `--restart-desktop-app` は、実際の書き込みの後に +のみ適用され、`ocx sync` および `ocx sync-cache` と同じ意味です。`ETag` 条件付きリクエストは +このコマンドには含まれません。`--json` エンベロープと終了コードの詳細は [英語版リファレンス](/reference/cli/lifecycle/)を参照してください。 ## バックグラウンドサービス diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 88675c21fc..7e353c50f9 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -314,7 +314,7 @@ Codex에서 model이 빠졌거나 catalog 순서/가시성이 이상해 보이 이 규칙은 라이브 발견 실패 시 폴백 동작을 바꾸지 않습니다. 4. **Cursor `GetUsableModels`** - Cursor adapter는 `/models`가 아니라 protobuf `GetUsableModels` RPC로 model을 찾습니다. 그래서 Cursor 쪽 변경이 다른 provider와 무관하게 어떤 id가 보이는지 바꿀 수 있습니다. 5. **캐시와 `ocx sync`** - live catalog는 약 5분(`modelCacheTtlMs`, 기본값 `300000`) 동안 캐시됩니다. `ocx sync`를 실행하면 새로 가져와서 catalog를 즉시 다시 쓸 수 있습니다. -6. **실행 중인 Codex `app-server`** - 오래 살아 있는 Codex `app-server`(Desktop / CLI background host)가 이전 목록을 메모리에 쥐고 있으면 디스크 catalog를 다시 쓰는 것만으로는 부족합니다. `ocx sync`와 `ocx sync-cache`는 그런 process를 감지하면 경고합니다. `ocx sync --restart-codex`로 다시 시작하거나(아니면 일치하는 `app-server` process를 직접 중지한 뒤), Codex가 다시 만들게 해서 새 목록이 보이게 하세요. +6. **실행 중인 Codex `app-server`** - 오래 살아 있는 Codex `app-server`(Desktop / CLI background host)가 이전 목록을 메모리에 쥐고 있으면 디스크 catalog를 다시 쓰는 것만으로는 부족합니다. `ocx sync`와 `ocx sync-cache`는 그런 process를 감지하면 경고합니다. `ocx sync --restart-codex`는 그 process를 재시작하고 macOS·Linux·Windows에서 Codex 데스크톱 앱을 완전히 종료한 뒤 다시 띄워 선택기가 카탈로그를 다시 읽게 합니다. 데스크톱 앱을 그대로 두려면 `--restart-app-server-only`를 쓰거나 일치하는 `app-server` process를 직접 중지하세요. :::caution[다른 로컬 writer] catalog write(`opencodex-catalog.json`, `config.toml`)는 opencodex 내부에서만 원자적입니다. 이것은 두 개의 opencodex 소유 writer가 경합할 때 반쯤만 써진 파일을 막아줄 뿐입니다. 다른 로컬 process, file watcher, sync agent가 opencodex가 쓴 뒤에 catalog visibility나 순서를 다시 쓸 가능성은 막지 못합니다. Codex는 별도의 `models_cache.json`을 유지하고 독립적으로 갱신할 수 있으므로, 이 과정에서 `opencodex-catalog.json`을 다시 쓰지 않고도 보이는 목록이 바뀔 수 있습니다. proxy가 실행 중인데 model이 예상치 않게 바뀌면, 경쟁 writer를 중지하거나 재설정한 뒤 `ocx sync`를 실행하세요. 이것은 외부 writer 위험이지, 확인된 opencodex 결함이 아닙니다. @@ -378,6 +378,6 @@ opencodex가 managed [background service](/reference/cli/#ocx-service)로 실행 ## 페이지 분할 기록 보호에 따른 거부 -영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`로 거부될 수 있습니다. OpenCodex는 Codex 밖에서 순번을 지정하는 대신 현재 설정, 프로필, 카탈로그, 대화 원본과 복원 근거를 보존합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. 외부 프로바이더 보존처럼 전환을 하지 않는 경로는 계속 사용할 수 있습니다. +영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`와 Codex 설정 제거는 여전히 `history_paginated_requires_native_writer`로 거부됩니다. 스레드 행이 아직 참조하는데 `[model_providers.opencodex]` 정의를 걷어내면 그 대화를 해석할 수 없고, 복원 경로에는 호환 프로바이더 테이블을 남겨 둘 방법이 없습니다. 이미 페이지 분할된 홈은 지금은 제품으로 제거할 수 없습니다. 의도한 동작이 아니라 알려진 미해결 작업입니다. -대화가 참조하는 프로바이더 정의를 삭제하거나, `ocx sync`·레거시 복구를 반복하거나, 실행 중인 대화 원본을 고쳐 우회하지 마세요. 현재 파일을 보존하고 복구 전에 해당 대화를 닫은 뒤, 개인 대화 내용을 올리지 말고 정확한 오류와 버전을 보고하세요. 네이티브 기록 작성자와 조정하는 검증된 수정이 필요합니다. 백업이나 스크립트 성공만으로 표시 복구가 증명되지는 않으므로 Codex를 다시 열어 확인하세요. +대화를 강제로 이전하려고 실행 중인 페이지 분할 대화 원본이나 스레드 행을 고치지 마세요. 복구 전에 해당 대화를 닫은 뒤, 개인 대화 내용을 올리지 말고 정확한 오류와 버전을 보고하세요. 백업이나 스크립트 성공만으로 표시 복구가 증명되지는 않으므로 Codex를 다시 열어 확인하세요. diff --git a/docs-site/src/content/docs/ko/guides/factory-droid.md b/docs-site/src/content/docs/ko/guides/factory-droid.md index 995602e53a..43e13b4180 100644 --- a/docs-site/src/content/docs/ko/guides/factory-droid.md +++ b/docs-site/src/content/docs/ko/guides/factory-droid.md @@ -143,16 +143,17 @@ ocx provider add droid \ 남기세요. 이 프로바이더의 업스트림은 Factory HTTP 엔드포인트가 아니라 로컬 브리지이므로 Factory 추론 전용 헤더를 추가하지 않습니다. -프로바이더를 저장하거나 정적 카탈로그를 바꾼 뒤에는 새 세션이 갱신된 카탈로그를 읽도록 Codex -app-server를 동기화하고 재시작합니다. +프로바이더를 저장하거나 정적 카탈로그를 바꾼 뒤에는 새 세션이 갱신된 카탈로그를 읽도록 Codex를 +동기화하고 재시작합니다. ```bash ocx sync --restart-codex ocx doctor ``` -Codex app-server 재시작은 진행 중인 Codex 작업을 중단합니다. 해당 세션을 끝내거나 저장한 뒤에만 -재시작하세요. +`--restart-codex`는 일치하는 app-server를 재시작하고 Codex 데스크톱 앱을 완전히 종료한 뒤 다시 +띄우므로, 진행 중인 대화가 끝납니다. 데스크톱 앱을 그대로 두려면 `--restart-app-server-only`를 +쓰세요. 해당 세션을 끝내거나 저장한 뒤에만 재시작하세요. ## 전체 경로 검증 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 073df754a2..be2834963a 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -211,6 +211,11 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 헤드리스 런타임 설정, 시작, 동기화, 진단, 업데이트를 관리합니다. +`ocx system codex-restart --yes`는 `ocx sync --restart-codex`와 같은 모듈로 Codex +app-server를 재시작하고 데스크톱 앱도 완전히 종료한 뒤 다시 띄웁니다. 프록시 자체가 +Codex 앱 안에서 실행 중이면 넘길 수 없는 handoff를 약속하지 않고, 대신 실행 가능한 +안내와 함께 거절합니다. + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index e5b3e8a69b..96ed5be6ff 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -220,23 +220,36 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 ## 카탈로그 동기화 -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` 설정된 모든 공급자에서 라이브 모델 목록을 가져와 병합된 카탈로그를 Codex에 다시 주입합니다. 공급자를 추가한 뒤나 사용 가능한 모델을 새로 고칠 때 실행합니다. 오래 실행 중인 Codex `app-server` 프로세스가 아직 살아 있으면, `opencodex-catalog.json` / `models_cache.json`가 업데이트되었더라도 이전 인메모리 모델 목록을 계속 서비스할 수 있다고 경고합니다. -`--restart-codex`를 붙이면 현재 사용자가 소유한 `codex … app-server`와 `codex-code-mode-host` -프로세스 중 일치하는 것에만 `SIGTERM`을 보냅니다(활성 작업이 중단될 수 있습니다). 광범위한 +`--restart-codex`를 붙이면 일치하는 `codex … app-server`와 `codex-code-mode-host` 프로세스를 +재시작하는 데 더해, macOS·Linux·Windows에서 Codex 데스크톱 앱을 완전히 종료했다가 다시 띄웁니다. +모델 선택기가 카탈로그를 다시 읽도록 하기 위해서이며, 진행 중인 대화는 끝납니다. 광범위한 `pkill -f codex` 매칭은 의도적으로 피합니다. -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app`은 `--restart-codex`의 폐기 예정 별칭입니다. 여전히 동작하고 폐기 +안내를 출력하며, Windows 전용이 아닙니다. + +`--restart-app-server-only`는 예전처럼 좁은 범위만 수행합니다. 현재 사용자가 소유한 일치 +app-server / code-mode-host 프로세스에만 `SIGTERM`을 보내고 데스크톱 앱은 그대로 둡니다(활성 +작업이 중단될 수 있습니다). `--restart-codex`나 `--restart-desktop-app`과 함께 쓰면 좁은 +범위가 이깁니다. 진행 중인 대화를 잃는 것은 되돌릴 수 없고, 오래된 선택기는 그렇지 않기 +때문입니다. + +명령을 Codex 앱 안에서 실행하면 재시작은 분리된 helper에 넘기고, 이 세션은 앱과 함께 +종료됩니다. + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카탈로그에서 다시 빌드되게 합니다. -`ocx sync`와 같은 오래된 `app-server` 경고와 선택적 `--restart-codex` 동작이 적용됩니다. +`ocx sync`와 같은 오래된 `app-server` 경고와 선택적 재시작 플래그가 적용됩니다. -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` 다른 OpenCodex 인스턴스의 `/v1/catalog` 엔드포인트가 제공하는 완성된 카탈로그를 설치한 뒤 `models_cache.json`을 맞춥니다. URL은 HTTPS여야 하고 HTTP는 루프백만 허용합니다. URL에 박힌 @@ -244,9 +257,11 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 거절합니다. 인증은 선택이며 환경변수 이름(`--auth-env`)으로만 읽고 argv로는 받지 않습니다. 카탈로그와 캐시는 공유 Codex 카탈로그 잠금 아래에서 쓰고, 실패하면 직전까지 정상이던 파일을 -그대로 둡니다. 바이트가 같으면 mtime까지 건드리지 않는 no-op입니다. `--restart-codex`는 실제로 -쓴 뒤에만 적용됩니다. `ETag` 조건부 요청과 Desktop 앱 재시작은 이 명령에 없습니다. `--json` -envelope 필드와 종료 코드는 [영문 레퍼런스](/reference/cli/lifecycle/)를 보세요. +그대로 둡니다. 바이트가 같으면 mtime까지 건드리지 않는 no-op입니다. `--restart-codex`, +`--restart-app-server-only`, 폐기 예정 별칭 `--restart-desktop-app`은 실제로 쓴 뒤에만 +적용되며, `ocx sync` / `ocx sync-cache`와 같은 뜻입니다. `ETag` 조건부 요청은 이 명령에 +없습니다. `--json` envelope 필드와 종료 코드는 [영문 레퍼런스](/reference/cli/lifecycle/)를 +보세요. ## 백그라운드 서비스 diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 9baeed283d..51ada47a5b 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -347,6 +347,11 @@ the CLI, the API, and the GUI use the same bytes. Manage headless runtime settings, startup, sync, diagnostics, and updates. +`ocx system codex-restart --yes` restarts Codex app-servers and fully quits and relaunches the +Codex desktop app, through the same module as `ocx sync --restart-codex`. When the proxy itself +is running inside the Codex app, the command refuses with an actionable message instead of +promising a handoff it cannot complete. + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e49dc08a08..39be1d5326 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -261,7 +261,7 @@ not fabricate official-client metadata. Doctor never mutates credentials or appl ## Catalog sync -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` Fetch the live model list from every configured provider and re-inject the merged catalog into Codex. Run it after adding a provider or to refresh available models. @@ -273,16 +273,29 @@ nonzero, prints the concrete reason on stderr, and leaves the existing catalog a If long-lived Codex `app-server` processes are still running, `ocx sync` warns that they may keep serving the previous in-memory model list even though `opencodex-catalog.json` / `models_cache.json` -were updated. Pass `--restart-codex` to send `SIGTERM` only to matching `codex … app-server` and -`codex-code-mode-host` processes owned by the current user (active turns may be interrupted). Broad +were updated. Pass `--restart-codex` to restart matching `codex … app-server` and +`codex-code-mode-host` processes **and** fully quit and relaunch the Codex desktop app, on macOS, +Linux, and Windows, so the model picker re-reads the catalog. Live conversations end. Broad `pkill -f codex` matching is intentionally avoided. -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app` is a deprecated alias of `--restart-codex`. It still works, prints a +deprecation notice, and is not Windows-only. + +`--restart-app-server-only` restores the older, narrower behaviour: `SIGTERM` only to matching +app-server and code-mode-host processes owned by the current user, with the desktop app left +running. Active turns may still be interrupted. If it is combined with `--restart-codex` or +`--restart-desktop-app`, the narrow scope wins, because losing live conversations is unrecoverable +and a stale picker is not. + +When the command runs from inside the Codex app, the restart is handed off to a detached helper +and this session ends with the app. + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. The -same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx sync` apply. +same stale-`app-server` warning and optional restart flags as `ocx sync` apply. -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` Install a complete catalog served by another OpenCodex instance's `/v1/catalog` endpoint, then synchronize `models_cache.json`. Unlike `ocx sync`, this command does not discover configured @@ -304,22 +317,24 @@ The value is sent as a Bearer token but is never accepted as an argv value. Redi so authorization cannot cross origins. Catalog and cache writes use the shared Codex catalog lock and atomic writer. A failed fetch, validation, lock acquisition, catalog write, or cache rebuild preserves the last-known-good files. Identical catalog bytes are a no-op that preserves mtimes and -never touches processes. `--restart-codex` applies only after a real write and remains explicit; -Desktop restart is not part of this command. +never touches processes. `--restart-codex`, `--restart-app-server-only`, and the deprecated +`--restart-desktop-app` alias mean the same thing here as they do on `ocx sync` and +`ocx sync-cache`, and they apply only after a real write. The URL must name `/v1/catalog` at the host root. A reverse proxy that serves the endpoint under a path prefix is not supported by this command. -Two behaviors are deliberately out of scope in this first cut. The command downloads the full -catalog and compares bytes locally instead of issuing an `ETag` / `If-None-Match` conditional -request, and it has no Windows `--restart-desktop-app`. Identical bytes are treated as a complete -no-op, so a home whose catalog is correct but whose `models_cache.json` is missing or stale is not -repaired by this command; use `ocx sync-cache` for that. +The command downloads the full catalog and compares bytes locally instead of issuing an `ETag` / +`If-None-Match` conditional request. Identical bytes are treated as a complete no-op, so a home +whose catalog is correct but whose `models_cache.json` is missing or stale is not repaired by this +command; use `ocx sync-cache` for that. `--json` emits one stable envelope on stdout. `schemaVersion`, `ok`, `status`, `catalogWritten`, -`cacheSynced`, and `codexRestarted` are always present. `status` is `updated`, `unchanged`, or -`failed`. A successful pull adds `modelCount`; a failure adds `code`, which is the field a script -branches on: +`cacheSynced`, and `codexRestarted` are always present. `codexRestarted` still means app-servers +only. `desktopAppRestarted` is present only when a desktop restart was requested, and is `true` +only when the relaunch actually started; a handoff is not a success. `status` is `updated`, +`unchanged`, or `failed`. A successful pull adds `modelCount`; a failure adds `code`, which is the +field a script branches on: | `code` | Meaning | Exit | | --- | --- | --- | @@ -330,7 +345,7 @@ branches on: | `body_too_large`, `body_invalid`, `catalog_invalid` | The response was refused before any local write | 1 | | `write_failed`, `lock_database`, `unsafe_path` | The coordinated write did not complete; files are unchanged | 1 | | `lock_busy` | Another writer holds the Codex catalog lock | 3 | -| `restart_incomplete` | The catalog and cache landed, but a Codex app-server survived `--restart-codex` | 1 | +| `restart_incomplete` | The catalog and cache landed, but a Codex app-server survived `--restart-codex` or `--restart-app-server-only` | 1 | `restart_incomplete` is the one failure that reports real writes: `catalogWritten` and `cacheSynced` stay true and `ok` is false, because a surviving app-server still serves the diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a80eab897f..75f7ea9cf1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -201,7 +201,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | -| `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not (Ollama Cloud GLM/DeepSeek) answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. Only the `ollama` backend has an executor; the other ids in the union are accepted and stay inert. The `ollama` backend reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. Streaming turns only; a turn that mixes `web_search` with another client tool call fails closed rather than dropping the client's call. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | +| `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` providers only. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | @@ -231,11 +231,12 @@ provider request is sent. Changing away and back also ends that continuation. St use the new selection. Selection changes before the first provider send retain normal reselection. Custom-model `reasoningEfforts` normally override discovered provider metadata. The bounded -exception is an explicit Astra or Daybreak custom row on the canonical `openai` Codex-forward -destination: its advertised list is intersected with that model's pinned native capabilities. -An explicit empty list remains empty with no default; a nonempty incompatible list falls back -to the native default as a single choice. Defaults must belong to the final list. This changes -the catalog projection, not stored configuration or arbitrary gateway models sharing a GPT name. +exception is an explicit custom row whose model id has pinned native Codex capabilities, +including Astra or Daybreak on an arbitrary gateway: its advertised list is intersected with +that model's pinned native capabilities. Full native identity still requires the canonical +`openai` Codex-forward destination. An explicit empty list remains empty with no default; a +nonempty incompatible list falls back to the native default as a single choice. Defaults must +belong to the final list. This changes the catalog projection, not stored configuration. See [custom native catalog examples](/guides/codex-app-models/). ### Operator-pinned reasoning effort diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 47509393f4..b973add5d9 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -437,7 +437,7 @@ whether to star the repository. | `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection | Returns 202; repeated calls report the existing drain | | `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 `self_unload_service` when this proxy is running as the installed launchd/systemd service, because stopping the manager from inside it would end the process before native Codex is restored — run `ocx stop` instead (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | | `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — | -| `POST /api/system/codex-restart` | Refresh the catalog, then ask stale Codex app-servers to exit so the model picker reloads | Returns 200 with `code: partially_stopped` when a target survives | +| `POST /api/system/codex-restart` | Refresh the catalog, then restart stale Codex app-servers and fully quit and relaunch the Codex desktop app so the model picker reloads. When the proxy itself is running inside the Codex app, the desktop restart is refused rather than handed off. | Returns 200 with `code: partially_stopped` when a target survives | ### Codex authentication delegation diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 6fd9561221..e2e8145845 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -688,3 +688,17 @@ that repair, it becomes a normal user message. If a current v2 task remains genu but the selected routed target cannot read native ChatGPT ciphertext, opencodex fails with `unreadable_encrypted_agent_task` instead of sending unreadable bytes to that provider. See [Sub-agent Surface](/guides/sub-agent-surface/) for the client behavior around worker tasks. + +History is handled too, and differently, because losing a replayed message should not end a +conversation. A replayed `agent_message` that mixes readable text with backend ciphertext cannot +be lowered to a public message, so a routed Responses destination would otherwise receive the +ciphertext along with an item type only the ChatGPT backend declares. Before dispatch, opencodex +replaces that ciphertext with `[encrypted content omitted]` — the same marker it already +substitutes after an upstream decrypt failure — which leaves the item lowerable and the readable +text intact. The provider never sees the ciphertext or the private item, and the conversation +continues. Combo targets are repaired individually, since each receives its own copy of the +request. The canonical ChatGPT Codex backend is exempt because it is the destination that minted +and can read those bytes; a `forward` provider pointed at any other origin is not exempt. +Explicitly trusted `allowEncryptedV2AgentTasks` routes and translated Chat or Anthropic wires are +unaffected, as are other item types such as reasoning and tool-output blobs, which keep their +existing decrypt-failure recovery. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 2a2087c994..1cca5f59fd 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -328,9 +328,11 @@ Codex на встроенный провайдер `openai` и удалите л переписать каталог. 6. **Запущенный Codex `app-server`** — переписать каталог на диске недостаточно, если долгоживущий `app-server` Codex (Desktop / CLI background host) держит в памяти старый список. - `ocx sync` и `ocx sync-cache` предупреждают, когда находят такие процессы. Перезапустите их - через `ocx sync --restart-codex` (или остановите подходящие процессы `app-server` вручную), а - затем дайте Codex создать их заново. + `ocx sync` и `ocx sync-cache` предупреждают, когда находят такие процессы. `ocx sync + --restart-codex` перезапускает эти процессы и полностью закрывает и заново запускает + Desktop-приложение Codex на macOS, Linux и Windows, чтобы picker перечитал каталог. Чтобы + оставить Desktop-приложение запущенным, передайте `--restart-app-server-only` или остановите + подходящие процессы `app-server` вручную. :::caution[Другие локальные writer'ы] Записи каталога (`opencodex-catalog.json`, `config.toml`) атомарны **только внутри** opencodex, то @@ -411,6 +413,6 @@ ocx restore back # point plain Codex at the running proxy again ## Защитный отказ для постраничной истории -Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. OpenCodex сохраняет конфигурацию, профиль, каталог, историю и данные восстановления, не назначая номера вне Codex. Пути без смены провайдера, например сохранение внешнего провайдера, остаются доступны. +Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` и удаление конфигурации Codex по-прежнему отказывают по `history_paginated_requires_native_writer`. Удаление определения `[model_providers.opencodex]`, пока строки тредов на него ссылаются, сделало бы эти разговоры неразрешимыми, а путь восстановления не умеет оставлять таблицу совместимости провайдера. Домашний каталог, уже переведённый на постраничную историю, сейчас нельзя удалить средствами продукта; это известная открытая задача, а не задуманное поведение. -Не удаляйте используемое разговором определение провайдера, не повторяйте `ocx sync` или legacy-восстановление и не переписывайте активную историю. Сохраните файлы, закройте разговор перед восстановлением и сообщите точную ошибку и версии без публикации личной истории. Требуется проверенное исправление с согласованием с нативным процессом записи. Наличие резервной копии или успешный скрипт не доказывает восстановление отображения: проверьте разговор после повторного открытия Codex. +Не переписывайте активную постраничную историю или строку треда, чтобы самостоятельно перенести разговоры. Закройте разговор перед восстановлением и сообщите точную ошибку и версии без публикации личной истории. Наличие резервной копии или успешный скрипт не доказывает восстановление отображения: проверьте разговор после повторного открытия Codex. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 3cb310a90a..f2175ec154 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -241,6 +241,8 @@ env-reference, либо несекретную loopback-заглушку. Loopba Управляйте headless runtime-setting'ами, startup, sync, diagnostics и update. +`ocx system codex-restart --yes` перезапускает Codex app-server'ы и полностью закрывает и заново запускает Desktop-приложение Codex тем же модулем, что и `ocx sync --restart-codex`. Если сам proxy запущен внутри приложения Codex, команда отказывается с actionable-сообщением вместо handoff, который она не может завершить. + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 374e16202d..eabdcbdec0 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -213,7 +213,7 @@ credential'ы и не выполняет repair. ## Синхронизация каталога -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` Получить живой список моделей от каждого настроенного провайдера и заново внедрить объединённый каталог в Codex. Запускайте после добавления провайдера или когда нужно обновить доступные @@ -222,17 +222,30 @@ credential'ы и не выполняет repair. Если всё ещё работают долгоживущие процессы Codex `app-server`, `ocx sync` предупредит, что они могут продолжать отдавать старый in-memory список моделей, хотя файлы `opencodex-catalog.json` / `models_cache.json` уже обновлены. Передайте `--restart-codex`, чтобы -послать `SIGTERM` только подходящим процессам `codex … app-server` и `codex-code-mode-host`, -принадлежащим текущему пользователю (активные turn'ы при этом могут оборваться). Широкий +перезапустить подходящие процессы `codex … app-server` и `codex-code-mode-host` **и** полностью +закрыть и заново запустить Desktop-приложение Codex на macOS, Linux и Windows, чтобы model +picker перечитал каталог. Живые conversation'ы при этом завершаются. Широкий `pkill -f codex` намеренно не используется. -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app` — устаревший alias `--restart-codex`. Он по-прежнему работает, печатает +предупреждение об устаревании и не ограничен Windows. + +`--restart-app-server-only` возвращает прежнее узкое поведение: `SIGTERM` только подходящим +процессам app-server / code-mode-host текущего пользователя, Desktop-приложение остаётся +запущенным (активные turn'ы всё ещё могут оборваться). Если флаг указан вместе с +`--restart-codex` или `--restart-desktop-app`, побеждает узкий scope: потеря живых +conversation'ов невосстановима, а устаревший picker — нет. + +Если команда запущена изнутри приложения Codex, restart передаётся detached helper'у, и эта +сессия завершается вместе с приложением. + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` Инвалидировать локальный кэш model picker'а Codex, чтобы он пересобрался из активного каталога -opencodex. Предупреждение о stale-`app-server` и optional `--restart-codex` работают так же, как +opencodex. Предупреждение о stale-`app-server` и те же optional restart-флаги работают так же, как и у `ocx sync`. -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` Устанавливает полный каталог, который отдаёт эндпоинт `/v1/catalog` другого экземпляра OpenCodex, и затем синхронизирует `models_cache.json`. URL должен быть HTTPS; HTTP допускается только на @@ -241,9 +254,10 @@ loopback. Учётные данные в URL, query, фрагменты, ред имени переменной окружения (`--auth-env`), но не из argv. Каталог и кэш пишутся под общей блокировкой каталога Codex; при сбое сохраняются last-known-good -файлы. Идентичные байты — это no-op, сохраняющий mtime. `--restart-codex` применяется только после -реальной записи. Условные запросы `ETag` и перезапуск Desktop-приложения в эту команду не входят. -Полная `--json`-обёртка и коды выхода описаны в +файлы. Идентичные байты — это no-op, сохраняющий mtime. `--restart-codex`, +`--restart-app-server-only` и устаревший alias `--restart-desktop-app` здесь означают то же, что +у `ocx sync` и `ocx sync-cache`, и применяются только после реальной записи. Условные запросы +`ETag` в эту команду не входят. Полная `--json`-обёртка и коды выхода описаны в [английской справке](/reference/cli/lifecycle/). ## Фоновая служба diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index a36594e577..436c995bdb 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -379,10 +379,11 @@ sırayla kontrol edin: 6. **Çalışan Codex `app-server`** — uzun ömürlü bir Codex `app-server` (Desktop / CLI arka plan ana bilgisayarı) önceki listeyi bellekte tuttuğu sürece diskteki kataloğu yeniden yazmak yeterli değildir. `ocx sync` ve `ocx - sync-cache` bu süreçler algılandığında uyarır. Bunları `ocx sync - --restart-codex` ile yeniden başlatın (veya eşleşen `app-server` süreçlerini - kendiniz durdurun), ardından yeni listenin görünmesi için Codex'in bunları - yeniden oluşturmasına izin verin. + sync-cache` bu süreçler algılandığında uyarır. `ocx sync --restart-codex` bu + süreçleri yeniden başlatır ve seçicinin kataloğu yeniden okuması için Codex + masaüstü uygulamasını macOS, Linux ve Windows'ta tamamen kapatıp yeniden + başlatır. Masaüstü uygulamasını çalışır bırakmak için `--restart-app-server-only` + iletin veya eşleşen `app-server` süreçlerini kendiniz durdurun. :::caution[Diğer yerel yazıcılar] Katalog yazmaları (`opencodex-catalog.json`, `config.toml`) opencodex **içinde** @@ -469,6 +470,6 @@ service stop` yerel Codex'i geri yükler. ## Sayfalanmış geçmiş için güvenlik reddi -Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. OpenCodex, Codex dışında sıra numarası atamak yerine yapılandırmayı, profili, kataloğu, geçmişi ve geri yükleme kanıtlarını korur. Harici sağlayıcıyı korumak gibi değişim yapmayan yollar kullanılabilir. +Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` ve Codex yapılandırmasının kaldırılması `history_paginated_requires_native_writer` nedeniyle hâlâ reddedilir. İş parçacığı satırları hâlâ ona başvuruyken `[model_providers.opencodex]` tanımını kaldırmak o konuşmaları çözülemez yapar ve geri yükleme yolu uyumluluk sağlayıcı tablosunu tutamaz. Zaten sayfalanmış bir ev dizini şu anda ürün üzerinden kaldırılamaz; bu amaçlanan davranış değil, bilinen açık iştir. -Konuşmanın kullandığı sağlayıcı tanımını silmeyin, `ocx sync` veya legacy kurtarmayı tekrarlamayın ve etkin geçmişi yeniden yazmayın. Dosyaları koruyun, kurtarmadan önce konuşmayı kapatın ve özel geçmişi yayımlamadan tam hatayı ve sürümleri bildirin. Yerel yazıcıyla koordineli, doğrulanmış bir düzeltme gerekir. Yedek veya başarılı betik görüntünün düzeldiğini kanıtlamaz; Codex’i yeniden açıp konuşmayı kontrol edin. +Konuşmaları kendiniz taşımak için etkin sayfalanmış geçmişi veya iş parçacığı satırını yeniden yazmayın. Kurtarmadan önce konuşmayı kapatın ve özel geçmişi yayımlamadan tam hatayı ve sürümleri bildirin. Yedek veya başarılı betik görüntünün düzeldiğini kanıtlamaz; Codex’i yeniden açıp konuşmayı kontrol edin. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index b6b35b96a5..b7914c4f42 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -409,6 +409,11 @@ yönlendirir ve yukarı akış SSE akışını etkin tutar. Bu model tüm çıkt bitirir ancak son Responses olayını atlarsa opencodex beş saniyelik model kapsamlı bir yetkisiz kullanım onarımı uygular; hatalı biçimlendirilmiş veya kısmi akışlar başarılı olarak bildirilmek yerine tamamlanmamış olarak kapanır. +Birinci taraf `deepseek-flash` modeli yerel olarak `text` ve `image` girdilerini bildirir; bu nedenle +görüntü içeren istekler varsayılan olarak vision sidecar üzerinden geçmeden doğrudan DeepSeek'e gönderilir. +Açık `noVisionModels` veya yalnızca metin bildirimleri önceliğini korur. Birinci taraf `deepseek-chat`, +`deepseek-reasoner` ve `deepseek-v4-flash` varsayılan olarak sidecar üzerinden çalışmaya devam eder; Zen +rotaları değişmedi ve bu güncellemede yoklanmadı. > **Üç Volcengine faturalandırma rotası:** `volcengine` kullandıkça öde Ark API'sidir, `volcengine-coding-plan` Coding Plan kotasını tüketir ve `volcengine-agent-plan` Agent Plan kotasını tüketir. Aynı ürün için verilen anahtarı ve uç noktayı kullanın; sıradan `/api/v3` uç noktası bir Plan aboneliği mevcut olduğunda bile kullandıkça öde ücretlerine neden olabilir. Önayarlar özenle seçilmiş statik model katalogları kullanır çünkü Ark'ın `/models` yanıtı yerleştirme, görsel, video ve 3D kaynaklarını da içerir, Coding ağ geçidi aynı geniş kataloğu döndürür ve Agent Plan ağ geçidinin `/models` kaynağı yoktur. Kullandıkça öde varsayılan olarak `doubao-seed-2-1-pro-260628`'dir; seçilmiş kataloğu güncel DeepSeek ve GLM metin modellerini de içerir. Coding Plan varsayılan olarak `ark-code-latest`, Agent Plan ise varsayılan olarak `deepseek-v4-flash`'dur. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index f4564d3bba..f533038cf5 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -288,6 +288,12 @@ sekmesinde işlenir; böylece CLI, API ve GUI aynı baytları kullanır. Başsız çalışma zamanı ayarlarını, başlatmayı, senkronizasyonu, tanılamayı ve güncellemeleri yönetin. +`ocx system codex-restart --yes`, `ocx sync --restart-codex` ile aynı modül +üzerinden Codex app-server'larını yeniden başlatır ve Codex masaüstü +uygulamasını tamamen kapatıp yeniden başlatır. Proxy'nin kendisi Codex +uygulamasının içinde çalışıyorsa, tamamlayamayacağı bir devri vaat etmek +yerine eyleme geçirilebilir bir iletiyle reddeder. + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index dd6568b5ea..a0d8f6a6c0 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -215,7 +215,7 @@ Doctor asla kimlik bilgilerini değiştirmez veya onarımlar uygulamaz. ## Katalog senkronizasyonu -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` Yapılandırılmış her sağlayıcıdan canlı model listesini alın ve birleştirilmiş kataloğu Codex'e yeniden enjekte edin. Bir sağlayıcı ekledikten sonra veya @@ -231,18 +231,33 @@ kontrolü kullanır. Uzun ömürlü Codex `app-server` süreçleri hala çalışıyorsa `ocx sync`, `opencodex-catalog.json` / `models_cache.json` güncellenmiş olsa bile önceki bellek içi model listesini sunmaya devam edebilecekleri konusunda uyarır. -Yalnızca geçerli kullanıcıya ait eşleşen `codex … app-server` ve -`codex-code-mode-host` süreçlerine `SIGTERM` göndermek için `--restart-codex` -iletin (aktif turlar kesintiye uğrayabilir). Geniş `pkill -f codex` +Eşleşen `codex … app-server` ve `codex-code-mode-host` süreçlerini yeniden +başlatmak **ve** model seçicinin kataloğu yeniden okuması için Codex masaüstü +uygulamasını macOS, Linux ve Windows'ta tamamen kapatıp yeniden başlatmak üzere +`--restart-codex` iletin. Canlı konuşmalar sona erer. Geniş `pkill -f codex` eşleştirmesinden kasıtlı olarak kaçınılır. -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app`, `--restart-codex` için kullanımdan kaldırılmış bir +takma addır. Hâlâ çalışır, bir kullanımdan kaldırma bildirimi basar ve yalnızca +Windows'a özgü değildir. + +`--restart-app-server-only` eski dar davranışı geri getirir: yalnızca geçerli +kullanıcıya ait eşleşen app-server / code-mode-host süreçlerine `SIGTERM` +gönderir, masaüstü uygulamasını çalışır bırakır (aktif turlar yine kesintiye +uğrayabilir). `--restart-codex` veya `--restart-desktop-app` ile birlikte +verilirse dar kapsam kazanır; çünkü canlı konuşmaları kaybetmek geri +alınamaz, eski bir seçici ise alınabilir. + +Komut Codex uygulamasının içinden çalıştırıldığında yeniden başlatma ayrılmış +bir yardımcıya devredilir ve bu oturum uygulamayla birlikte sona erer. + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` Codex'in yerel model seçici önbelleğini geçersiz kılın, böylece aktif opencodex kataloğundan yeniden oluşturulur. `ocx sync` ile aynı eski `app-server` uyarısı -ve isteğe bağlı `--restart-codex` davranışı geçerlidir. +ve isteğe bağlı yeniden başlatma bayrakları geçerlidir. -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` Başka bir OpenCodex örneğinin `/v1/catalog` uç noktasının sunduğu eksiksiz kataloğu kurar ve ardından `models_cache.json` dosyasını eşitler. URL HTTPS olmalıdır; HTTP yalnızca loopback için @@ -253,9 +268,10 @@ alınmaz. Katalog ve önbellek, paylaşılan Codex katalog kilidi altında yazılır; bir hata durumunda last-known-good dosyalar korunur. Aynı baytlar, mtime değerlerini koruyan bir no-op'tur. -`--restart-codex` yalnızca gerçek bir yazmadan sonra uygulanır. `ETag` koşullu istekleri ve Desktop -uygulamasının yeniden başlatılması bu komutun kapsamında değildir. Tam `--json` zarfı ve çıkış -kodları için [İngilizce referansa](/reference/cli/lifecycle/) bakın. +`--restart-codex`, `--restart-app-server-only` ve kullanımdan kaldırılmış takma ad +`--restart-desktop-app` yalnızca gerçek bir yazmadan sonra uygulanır ve `ocx sync` / +`ocx sync-cache` ile aynı anlama gelir. `ETag` koşullu istekleri bu komutun kapsamında +değildir. Tam `--json` zarfı ve çıkış kodları için [İngilizce referansa](/reference/cli/lifecycle/) bakın. ## Arka plan servisi diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index b19d35b00c..dfdac6d98a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -278,8 +278,9 @@ provider 形式一样,从 `OPENCODEX_API_AUTH_TOKEN` 传入 `x-opencodex-api-k 运行 `ocx sync` 可以强制立即重新抓取并重写 catalog。 6. **正在运行的 Codex `app-server`** - 当长生命周期的 Codex `app-server`(Desktop / CLI 后台宿主)还在 内存中保留旧列表时,只重写磁盘上的 catalog 还不够。`ocx sync` 和 `ocx sync-cache` 会在检测到这些进程时给出 - 警告。请用 `ocx sync --restart-codex` 重新启动它们(或者你自己停掉匹配的 `app-server` 进程),然后让 Codex - 重新创建它们,这样新列表才会出现。 + 警告。`ocx sync --restart-codex` 会重启这些进程,并在 macOS、Linux 和 Windows 上完全退出再重新启动 Codex + 桌面应用,让选择器重新读取 catalog。若要让桌面应用继续运行,请传入 `--restart-app-server-only`,或自行停掉匹配的 + `app-server` 进程。 :::caution[其他本地写入者] 在 opencodex 内部,catalog 写入(`opencodex-catalog.json`、`config.toml`)是原子的,这只能防止两个 @@ -355,6 +356,6 @@ ocx restore back # point plain Codex at the running proxy again ## 分页历史记录安全拒绝 -如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。OpenCodex 会保留当前配置、配置档、模型目录、历史文件及恢复依据,而不是在 Codex 之外分配序号;可迁移存储中的 legacy 记录也受保护。仅保留外部提供商而不执行切换的路径仍然可用。 +如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 和移除 Codex 配置仍会因 `history_paginated_requires_native_writer` 被拒绝。线程行仍在引用时撤掉 `[model_providers.opencodex]` 定义会使这些会话无法解析,而恢复路径没有办法留下兼容提供商表。已经分页的主目录目前无法通过产品卸载;这是已知的未完成工作,而非预期行为。 -不要删除会话仍在引用的提供商定义、反复运行 `ocx sync` 或旧版恢复,也不要改写正在使用的历史文件来绕过拒绝。保留文件,在恢复前关闭相关会话,并只报告准确的错误和版本,不要公开私人历史。需要与原生写入器协调的已验证修复。备份或脚本成功并不能证明显示已恢复;重新打开 Codex 后检查会话。 +不要改写正在使用的分页历史文件或线程行来自行迁移这些会话。恢复前关闭相关会话,并只报告准确的错误和版本,不要公开私人历史。备份或脚本成功并不能证明显示已恢复;重新打开 Codex 后检查会话。 diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 07e693ce73..ade0047960 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -235,6 +235,10 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 内置 DeepSeek preset 同样会让 `deepseek-v4-flash` 使用原生 Responses 端点,并保留上游 SSE 流式输出。如果该模型已经完成全部输出项却缺少最终 Responses 事件,opencodex 会应用模型级 5 秒宽限修复;不完整或格式异常的流会以 incomplete 结束,不会被误报为成功。 +第一方 `deepseek-flash` 模型原生声明支持 `text` 和 `image` 输入,因此图像请求默认会直接发送给 +DeepSeek,不经过 vision sidecar。显式的 `noVisionModels` 或纯文本声明仍然优先。第一方 +`deepseek-chat`、`deepseek-reasoner` 和 `deepseek-v4-flash` 默认仍使用 sidecar;Zen 路由保持不变, +本次更新未进行探测。 > **三条火山方舟计费线路:**`volcengine` 是按量付费方舟 API,`volcengine-coding-plan` > 消耗 Coding Plan 额度,`volcengine-agent-plan` 消耗 Agent Plan 额度。密钥与端点需要属于 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 2153f895c5..da81deadd6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -191,6 +191,8 @@ opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 管理无头运行时设置、启动、同步、诊断和更新。 +`ocx system codex-restart --yes` 通过与 `ocx sync --restart-codex` 相同的模块重启 Codex app-server,并完全退出再重新启动 Codex 桌面应用。若代理本身运行在 Codex 应用内部,该命令会给出可执行提示并拒绝,而不是承诺无法完成的移交。 + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 98ff929156..ff209deb28 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -144,21 +144,27 @@ ocx status --json ## 目录同步 -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` 从每个已配置的提供方获取实时模型列表,并将合并后的目录重新注入 Codex。在添加提供方后运行,或用于刷新可用模型。 -如果仍有长期运行的 Codex `app-server` 进程,`ocx sync` 会警告它们可能继续提供旧的内存模型列表,即使 `opencodex-catalog.json` / `models_cache.json` 已更新。传入 `--restart-codex` 会仅向当前用户拥有、匹配 `codex … app-server` 和 `codex-code-mode-host` 的进程发送 `SIGTERM`(当前活跃会话可能会被打断)。故意避免使用宽泛的 `pkill -f codex` 匹配。 +如果仍有长期运行的 Codex `app-server` 进程,`ocx sync` 会警告它们可能继续提供旧的内存模型列表,即使 `opencodex-catalog.json` / `models_cache.json` 已更新。传入 `--restart-codex` 会重启匹配的 `codex … app-server` 和 `codex-code-mode-host` 进程,并在 macOS、Linux 和 Windows 上完全退出再重新启动 Codex 桌面应用,让模型选择器重新读取目录。进行中的对话会结束。故意避免使用宽泛的 `pkill -f codex` 匹配。 -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app` 是 `--restart-codex` 的已弃用别名。它仍然可用,会打印弃用提示,并且不再仅限 Windows。 -使 Codex 的本地模型选择器缓存失效,让它根据当前激活的 opencodex 目录重新生成。与 `ocx sync` 相同的陈旧 `app-server` 警告和可选 `--restart-codex` 行为同样适用。 +`--restart-app-server-only` 恢复以前的窄范围行为:仅向当前用户拥有、匹配的 app-server / code-mode-host 进程发送 `SIGTERM`,桌面应用保持运行(当前活跃会话仍可能被打断)。如果与 `--restart-codex` 或 `--restart-desktop-app` 一起使用,窄范围优先,因为丢失进行中的对话无法恢复,而过期的选择器可以。 -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +当命令在 Codex 应用内部运行时,重启会交给分离的 helper,当前会话会随应用一起结束。 + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` + +使 Codex 的本地模型选择器缓存失效,让它根据当前激活的 opencodex 目录重新生成。与 `ocx sync` 相同的陈旧 `app-server` 警告和可选重启标志同样适用。 + +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` 安装由另一个 OpenCodex 实例的 `/v1/catalog` 端点提供的完整目录,然后同步 `models_cache.json`。URL 必须是 HTTPS;仅回环地址允许 HTTP。URL 内嵌凭据、查询、片段、重定向、超出大小的响应以及无效目录,都会在任何本地写入之前被拒绝。认证是可选的,并且只通过环境变量名(`--auth-env`)读取,不接受 argv 传入。 -目录和缓存在共享的 Codex 目录锁下写入;失败时保留 last-known-good 文件。字节完全相同时是保留 mtime 的空操作。`--restart-codex` 仅在发生真实写入之后生效。`ETag` 条件请求和 Desktop 应用重启不属于此命令。完整的 `--json` 信封与退出码请参见[英文参考](/reference/cli/lifecycle/)。 +目录和缓存在共享的 Codex 目录锁下写入;失败时保留 last-known-good 文件。字节完全相同时是保留 mtime 的空操作。`--restart-codex`、`--restart-app-server-only` 以及已弃用别名 `--restart-desktop-app` 仅在发生真实写入之后生效,含义与 `ocx sync` / `ocx sync-cache` 相同。`ETag` 条件请求不属于此命令。完整的 `--json` 信封与退出码请参见[英文参考](/reference/cli/lifecycle/)。 ## 后台服务 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 38744c8e13..284034b54b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -284,8 +284,9 @@ OpenCodex 直接注入路由,請先將 Codex 切回內建 `openai` provider, `ocx sync` 可強制重新抓取並立即重寫目錄。 6. **正在執行的 Codex `app-server`**:長時間執行的 Codex `app-server`(Desktop/CLI 背景 host)可能 仍在記憶體保留舊列表,因此只重寫磁碟目錄還不夠。`ocx sync` 與 `ocx sync-cache` 偵測到這些 - process 時會警告。可執行 `ocx sync --restart-codex` 重新啟動,或自行停止對應的 `app-server` - process,再讓 Codex 重新建立它們,讓新列表出現。 + process 時會警告。`ocx sync --restart-codex` 會重啟這些 process,並在 macOS、Linux 與 Windows + 上完全結束再重新啟動 Codex 桌面應用程式,讓選擇器重新讀取目錄。若要讓桌面應用程式繼續執行,請傳入 + `--restart-app-server-only`,或自行停止對應的 `app-server` process。 :::caution[其他本機寫入者] 目錄寫入(`opencodex-catalog.json`、`config.toml`)在 opencodex **內部**是原子的;這只避免兩個 @@ -362,6 +363,6 @@ ocx restore back # 讓普通 Codex 再次指向仍在執行的 proxy ## 分頁歷史記錄安全拒絕 -如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。OpenCodex 會保留目前設定、設定檔、模型目錄、歷史檔案及復原依據,而不在 Codex 之外分配序號;可遷移儲存區中的 legacy 記錄也受保護。不執行切換、僅保留外部提供者的路徑仍可使用。 +如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 與移除 Codex 設定仍會因 `history_paginated_requires_native_writer` 被拒絕。執行緒列仍在參照時撤掉 `[model_providers.opencodex]` 定義會使這些對話無法解析,而復原路徑沒有辦法留下相容提供者表。已經分頁的家目錄目前無法透過產品解除安裝;這是已知的未完成工作,而非預期行為。 -請勿刪除對話仍參照的提供者定義、反覆執行 `ocx sync` 或舊版復原,也不要改寫使用中的歷史檔案來繞過拒絕。保留檔案,復原前關閉相關對話,只回報確切錯誤與版本,不要公開私人歷史。需要與原生寫入器協調的已驗證修正。備份或指令碼成功不能證明顯示已復原;重新開啟 Codex 後確認對話。 +請勿改寫使用中的分頁歷史檔案或執行緒列來自行遷移這些對話。復原前關閉相關對話,只回報確切錯誤與版本,不要公開私人歷史。備份或指令碼成功不能證明顯示已復原;重新開啟 Codex 後確認對話。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 8a38d30988..e1db617b1f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -315,6 +315,10 @@ provider,例如 **Xiaomi MiMo**,使用 `anthropic` adapter(`x-api-key`) 原生 Responses endpoint,並保持上游 SSE streaming。若該模型完成所有 output item 卻省略最後的 Responses event,opencodex 會套用 5 秒、model-scoped 的 grace repair;malformed 或 partial stream 會以 incomplete 關閉,不會被誤報為成功。 +第一方 `deepseek-flash` 模型原生宣告支援 `text` 與 `image` 輸入,因此圖片請求預設會直接送往 +DeepSeek,不經過 vision sidecar。明確的 `noVisionModels` 或純文字宣告仍然優先。第一方 +`deepseek-chat`、`deepseek-reasoner` 與 `deepseek-v4-flash` 預設仍使用 sidecar;Zen 路由維持不變, +本次更新未進行探測。 > **三條 Volcengine 計費路徑:** `volcengine` 是 pay-as-you-go Ark API, > `volcengine-coding-plan` 消耗 Coding Plan quota,`volcengine-agent-plan` 消耗 Agent Plan quota。請使用 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 6a724b7d16..7da97b61dc 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -194,6 +194,8 @@ gjc 是例外:`OPENCODEX_GAJAE_API_KEY` 只會從環境提供 provider 憑證 管理無頭執行階段設定、啟動、同步、診斷與更新。 +`ocx system codex-restart --yes` 透過與 `ocx sync --restart-codex` 相同的模組重啟 Codex app-server,並完全結束再重新啟動 Codex 桌面應用程式。若代理本身在 Codex 應用程式內部執行,此命令會給出可執行提示並拒絕,而不是承諾無法完成的移交。 + ```bash ocx system settings --stream-mode eager-relay ``` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 015590cd3c..1771936b0b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -138,21 +138,27 @@ ocx status --json ## 目錄同步 -### `ocx sync [--restart-codex]` +### `ocx sync [--restart-codex] [--restart-app-server-only]` 從每個已設定的供應商擷取即時模型清單,並將合併後的目錄重新注入 Codex。在新增供應商後或要重新整理可用模型時執行它。 -若長壽的 Codex `app-server` 仍在執行,`ocx sync` 會警告它們可能繼續提供先前的記憶體內模型清單,即使 `opencodex-catalog.json` / `models_cache.json` 已更新。傳入 `--restart-codex` 以僅對目前使用者擁有的相符 `codex … app-server` 與 `codex-code-mode-host` 進程發送 `SIGTERM`(執行中的回合可能被中斷)。刻意避免廣泛的 `pkill -f codex` 比對。 +若長壽的 Codex `app-server` 仍在執行,`ocx sync` 會警告它們可能繼續提供先前的記憶體內模型清單,即使 `opencodex-catalog.json` / `models_cache.json` 已更新。傳入 `--restart-codex` 會重啟相符的 `codex … app-server` 與 `codex-code-mode-host` 進程,並在 macOS、Linux 與 Windows 上完全結束再重新啟動 Codex 桌面應用程式,讓模型選擇器重新讀取目錄。進行中的對話會結束。刻意避免廣泛的 `pkill -f codex` 比對。 -### `ocx sync-cache [--restart-codex]` +`--restart-desktop-app` 是 `--restart-codex` 的已棄用別名。它仍然可用、會印出棄用提示,且不再僅限 Windows。 -使 Codex 的本機模型選擇器快取失效,使其從現用的 opencodex 目錄重建。與 `ocx sync` 相同的過時 `app-server` 警告與可選的 `--restart-codex` 行為適用。 +`--restart-app-server-only` 恢復先前的窄範圍行為:僅對目前使用者擁有的相符 app-server / code-mode-host 進程發送 `SIGTERM`,桌面應用程式保持執行(執行中的回合仍可能被中斷)。若與 `--restart-codex` 或 `--restart-desktop-app` 一起使用,窄範圍優先,因為失去進行中的對話無法復原,過期的選擇器可以。 -### `ocx catalog pull [--auth-env ] [--json] [--restart-codex]` +當命令在 Codex 應用程式內部執行時,重啟會交給分離的 helper,此工作階段會隨應用程式一起結束。 + +### `ocx sync-cache [--restart-codex] [--restart-app-server-only]` + +使 Codex 的本機模型選擇器快取失效,使其從現用的 opencodex 目錄重建。與 `ocx sync` 相同的過時 `app-server` 警告與可選重啟旗標適用。 + +### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` 安裝由另一個 OpenCodex 執行個體的 `/v1/catalog` 端點提供的完整目錄,接著同步 `models_cache.json`。URL 必須是 HTTPS;僅回送位址允許 HTTP。URL 內嵌憑證、查詢、片段、重新導向、超出大小的回應以及無效目錄,都會在任何本機寫入之前遭拒。驗證為選用,且只透過環境變數名稱(`--auth-env`)讀取,不接受 argv 傳入。 -目錄與快取在共用的 Codex 目錄鎖之下寫入;失敗時保留 last-known-good 檔案。位元組完全相同時是保留 mtime 的無操作。`--restart-codex` 僅在實際寫入之後生效。`ETag` 條件式請求與 Desktop 應用程式重新啟動不屬於此命令。完整的 `--json` 信封與結束碼請參見[英文參考](/reference/cli/lifecycle/)。 +目錄與快取在共用的 Codex 目錄鎖之下寫入;失敗時保留 last-known-good 檔案。位元組完全相同時是保留 mtime 的無操作。`--restart-codex`、`--restart-app-server-only` 以及已棄用別名 `--restart-desktop-app` 僅在實際寫入之後生效,含義與 `ocx sync` / `ocx sync-cache` 相同。`ETag` 條件式請求不屬於此命令。完整的 `--json` 信封與結束碼請參見[英文參考](/reference/cli/lifecycle/)。 ## 背景服務 diff --git a/package.json b/package.json index 8317a507fb..b876b215f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.53.0-preview.20260913", + "version": "2.54.0-preview.20260914", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 016b20c274..c65f7c378b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -651,6 +651,7 @@ "devin-login.test.ts": "providers", "devin-provider-merge-migration.test.ts": "providers", "devin-hardening.test.ts": "providers", + "devin-image-passthrough.test.ts": "providers", "devin-prompt-cache.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index dcf62fa98e..da019297d1 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -764,6 +764,7 @@ Restart the Codex app-server. JSON mode: `payload`. - `sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request. +- Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request. - --yes is mandatory because this interrupts a running editor session, which must never happen because an agent guessed a subcommand. ### `ocx integration native` @@ -826,8 +827,9 @@ Synchronize client catalogs, including Aside profiles through the running server | Flag | Value | Meaning | |---|---|---| -| `--restart-codex` | boolean | Restart Codex app-servers after a catalog or cache write. | -| `--restart-desktop-app` | boolean | Restart the Codex desktop app after a catalog or cache write. | +| `--restart-codex` | boolean | Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog or cache write, on macOS, Linux and Windows. | +| `--restart-app-server-only` | boolean | Restart only the Codex app-servers and leave the desktop app running; wins over --restart-codex when both are given. | +| `--restart-desktop-app` | boolean | Deprecated alias of --restart-codex. | JSON mode: `none`. diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 513077746c..06fafe3eae 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -10,6 +10,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx import { namespacedToolName } from "../types"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct"; +import type { ContentPart } from "./devin/cloud-direct/chat"; import { getCachedCatalog } from "./devin/cloud-direct/catalog"; import { collapseDevinModelUid } from "./devin/live-models"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; @@ -208,9 +209,31 @@ function textFromParts(content: string | OcxContentPart[] | undefined): string { return content.map((part) => (part.type === "text" ? part.text : "")).filter(Boolean).join("\n"); } -function toolResultText(message: OcxToolResultMessage): string { - const body = textFromParts(message.content); - return message.isError ? ("ERROR: " + body) : body; +/** + * Convert inbound content parts to the multimodal shape the wire encoder accepts. + * + * The wire layer already carries images (ChatMessagePrompt field #10 ImageData), + * but every image was discarded at this boundary: textFromParts returned a + * text-only string and a message whose only content was an image was dropped + * entirely, which is why a pasted screenshot killed the turn and the only + * workaround was running OCR before sending. A data: URL carries everything + * field #10 needs; a remote https URL cannot be inlined without a fetch, so it + * stays as an explicit text reference rather than pretending the model can see + * a picture it cannot. Video has no Devin field and is skipped. + */ +function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): string | ContentPart[] { + if (typeof content === "string" || !Array.isArray(content)) return content ?? ""; + const out: ContentPart[] = []; + for (const part of content) { + if (part.type === "text" && part.text) { + out.push({ type: "text", text: part.text }); + } else if (part.type === "image") { + const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/); + if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! }); + else out.push({ type: "text", text: `[image url: ${part.imageUrl}]` }); + } + } + return out; } function assistantToolCalls(message: OcxAssistantMessage): Array<{ id: string; name: string; arguments: string }> { @@ -290,9 +313,12 @@ export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined { if (message.role === "user" || message.role === "developer") { - const text = textFromParts(message.content).trim(); - if (!text) return undefined; - return { role: message.role === "developer" ? "system" : "user", content: text }; + const content = mapOcxContentToWire(message.content); + // An image with no caption text is a complete user message on its own. + // Dropping it — which is what the text-only extraction did — is why a + // pasted screenshot killed the turn before the model ever saw anything. + if (typeof content === "string" ? !content.trim() : content.length === 0) return undefined; + return { role: message.role === "developer" ? "system" : "user", content }; } if (message.role === "assistant") { const toolCalls = assistantToolCalls(message); @@ -309,9 +335,15 @@ function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined { }; } if (message.role === "toolResult") { + const wireContent = mapOcxContentToWire(message.content); + const toolContent = message.isError + ? (typeof wireContent === "string" + ? `ERROR: ${wireContent}` + : [{ type: "text", text: "ERROR:" } as ContentPart, ...wireContent]) + : wireContent; return { role: "tool", - content: toolResultText(message), + content: toolContent, tool_call_id: message.toolCallId, }; } diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index cebafec88f..36aa8124cc 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -720,6 +720,7 @@ export const CAPABILITIES: readonly Capability[] = [ json: "payload", details: [ "`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.", + "Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.", "--yes is mandatory because this interrupts a running editor session, which must never happen because an agent guessed a subcommand.", ], }, @@ -785,8 +786,9 @@ export const CAPABILITIES: readonly Capability[] = [ summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.", routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }], flags: [ - { name: "--restart-codex", value: "boolean", summary: "Restart Codex app-servers after a catalog or cache write." }, - { name: "--restart-desktop-app", value: "boolean", summary: "Restart the Codex desktop app after a catalog or cache write." }, + { name: "--restart-codex", value: "boolean", summary: "Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog or cache write, on macOS, Linux and Windows." }, + { name: "--restart-app-server-only", value: "boolean", summary: "Restart only the Codex app-servers and leave the desktop app running; wins over --restart-codex when both are given." }, + { name: "--restart-desktop-app", value: "boolean", summary: "Deprecated alias of --restart-codex." }, ], mutates: true, json: "none", diff --git a/src/cli/catalog.ts b/src/cli/catalog.ts index e3d9aa3883..b730aaf5ca 100644 --- a/src/cli/catalog.ts +++ b/src/cli/catalog.ts @@ -1,4 +1,4 @@ -import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; +import { handleRestartScopeAfterWrite, readRestartScope } from "./restart-scope"; import { pullRemoteCatalog, RemoteCatalogError } from "../codex/catalog/remote"; import { hasHelpFlag, printSubcommandUsage } from "./help"; @@ -9,6 +9,14 @@ export interface CatalogPullEnvelope { catalogWritten: boolean; cacheSynced: boolean; codexRestarted: boolean; + /** + * Whether the desktop app was actually restarted. Only ever true for a completed + * relaunch: a handoff is not a success, because the restart has not happened yet when + * this envelope is written and a script reading true would proceed on a promise. + * Separate from codexRestarted so a script reading the existing field is not silently + * handed a different answer. + */ + desktopAppRestarted?: boolean; modelCount?: number; code?: string; } @@ -21,14 +29,18 @@ function optionValue(args: string[], name: string): string | undefined { export async function handleCatalogCommand(args: string[]): Promise { if (hasHelpFlag(args)) { printSubcommandUsage("catalog"); return 0; } const json = args.includes("--json"); - const restartCodex = args.includes("--restart-codex"); + const restartScope = readRestartScope(args, console); const authEnv = optionValue(args, "--auth-env"); const positionals = args.filter((arg, index) => { if (arg === "--auth-env") return false; if (index > 0 && args[index - 1] === "--auth-env") return false; return !arg.startsWith("-"); }); - const knownFlags = new Set(["--json", "--restart-codex", "--auth-env"]); + // A closed set: an unknown flag is a usage error, so the new scope flags have to be + // listed here or catalog pull would reject the very flags sync accepts. + const knownFlags = new Set([ + "--json", "--restart-codex", "--restart-desktop-app", "--restart-app-server-only", "--auth-env", + ]); const unknown = args.find((arg, index) => arg.startsWith("-") && !knownFlags.has(arg) && args[index - 1] !== "--auth-env"); const validEnvName = authEnv === undefined || /^[A-Za-z_][A-Za-z0-9_]*$/.test(authEnv); if (positionals[0] !== "pull" || positionals.length !== 2 || unknown || !validEnvName @@ -38,7 +50,7 @@ export async function handleCatalogCommand(args: string[]): Promise { cacheSynced: false, codexRestarted: false, code: "usage", }; if (json) console.log(JSON.stringify(envelope)); - else console.error("Usage: ocx catalog pull [--auth-env ] [--json] [--restart-codex]"); + else console.error("Usage: ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]"); return 2; } let token: string | undefined; @@ -57,21 +69,35 @@ export async function handleCatalogCommand(args: string[]): Promise { try { const result = await pullRemoteCatalog(positionals[1]!, { token }); let codexRestarted = false; + let desktopAppRestarted = false; let restartIncomplete = false; if (result.catalogWritten) { const processLog = json ? { log: (...values: unknown[]) => console.error(...values), error: (...values: unknown[]) => console.error(...values) } : console; - const processResult = afterCatalogWriteHandleAppServers({ restart: restartCodex, log: processLog }); - const restart = processResult.restart; + const outcome = await handleRestartScopeAfterWrite(restartScope, processLog); + const processResult = outcome.appServers; + const restart = processResult?.restart; + desktopAppRestarted = outcome.desktopApp?.relaunch === "started"; + // A desktop restart that was asked for and did not relaunch is an incomplete + // restart, exactly like a surviving app-server. Without this the pull reports + // ok: true while the picker the operator was fixing is still stale. + // "Desktop app is not running" is the same nothing-to-do the app-server half + // already treats as success, so it must not read as an incomplete restart. + if (restartScope.desktopApp && !desktopAppRestarted + && outcome.desktopApp?.reason !== "no_targets") { + restartIncomplete = true; + } if (restart) { // A partial stop is not a restart. `restartCodexAppServers` reports failures and // survivors without throwing, so counting `stopped` alone reported success while a // stale app-server was still serving the previous catalog from memory. codexRestarted = restart.failed.length === 0 && restart.surviving.length === 0 - && restart.stopped.length === processResult.processes.length; - restartIncomplete = !codexRestarted; + && restart.stopped.length === (processResult?.processes.length ?? -1); + // Do not ASSIGN here: the desktop half may already have set this, and assigning + // would discard a failed desktop restart whenever any app-server was signalled. + if (!codexRestarted) restartIncomplete = true; } } if (restartIncomplete) { @@ -81,7 +107,9 @@ export async function handleCatalogCommand(args: string[]): Promise { const envelope: CatalogPullEnvelope = { schemaVersion: 1, ok: false, status: result.status, catalogWritten: result.catalogWritten, cacheSynced: result.cacheSynced, - codexRestarted: false, modelCount: result.modelCount, code: "restart_incomplete", + codexRestarted: false, + ...(restartScope.desktopApp ? { desktopAppRestarted } : {}), + modelCount: result.modelCount, code: "restart_incomplete", }; if (json) console.log(JSON.stringify(envelope)); else console.error("Remote Codex catalog installed, but a Codex app-server is still running the previous catalog."); @@ -90,7 +118,8 @@ export async function handleCatalogCommand(args: string[]): Promise { const envelope: CatalogPullEnvelope = { schemaVersion: 1, ok: true, status: result.status, catalogWritten: result.catalogWritten, cacheSynced: result.cacheSynced, - codexRestarted, modelCount: result.modelCount, + codexRestarted, + ...(restartScope.desktopApp ? { desktopAppRestarted } : {}), modelCount: result.modelCount, }; if (json) console.log(JSON.stringify(envelope)); else if (result.status === "unchanged") console.log("Remote Codex catalog is unchanged; no files or processes were touched."); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 7ec21f9499..9eaab69fd5 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -26,7 +26,7 @@ import { syncModelsToCodex } from "../codex/sync"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { restoreNativeCodexAsync } from "../codex/inject"; import { stripGrokConfig } from "../grok/inject"; -import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; +import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { isJsonOption, takeFlag } from "./runtime-api"; import type { ClientConnectionState } from "../client/state"; @@ -378,10 +378,12 @@ const commandRunners: Record = { }, sync: async deps => { const syncArgs = deps.args.slice(1); - const restartCodex = syncArgs.includes("--restart-codex"); - // Separate flag on purpose: --restart-codex promises app-server-only scope, - // and quitting the desktop app ends live conversations. - const restartDesktopApp = syncArgs.includes("--restart-desktop-app"); + const restartScope = readRestartScope(syncArgs, console); + // The wire field keeps APP-SERVER-ONLY meaning and is deliberately not widened. A + // remote hub must not end a local user's conversations because a field name acquired + // a wider meaning underneath it; the maintainer decision widened a local CLI flag and + // said nothing about remote callers. syncConnectedClient ignores it either way. + const restartCodex = restartScope.appServers; const { readClientConnectionState } = await import("../client/state"); const clientState = readClientConnectionState(); if (clientState.kind === "invalid" || clientState.kind === "mismatched") { @@ -395,7 +397,7 @@ const commandRunners: Record = { console.log(result.stale ? "Hub unavailable; retained and applied the last-known-good remote catalog (stale)." : "Remote hub catalog synchronized."); - await handleConnectedSyncCatalogWrite(result, restartCodex, restartDesktopApp); + await handleConnectedSyncCatalogWrite(result, restartScope); // `process.exitCode` rather than a literal 0, for the same reason every other // runner does it (tests/cli/cli-transport-honesty.test.ts): the catalog-write helper // drives app-server restarts, and one of those recording a failure must not be @@ -434,8 +436,7 @@ const commandRunners: Record = { // so a sync can fail (`ok: false`) after the catalog was already rewritten — which is // exactly when a long-lived app-server is holding the stale list. if (synced.catalogWritten || synced.cacheSynced) { - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); - if (restartDesktopApp) await handleDesktopAppRestart(console); + await handleRestartScopeAfterWrite(restartScope, console); } // `ocx sync` is a direct CLI path; it does not call the management // `/api/sync` route. Refresh already-connected file integrations here too, @@ -496,8 +497,7 @@ const commandRunners: Record = { }, "sync-cache": async deps => { const cacheArgs = deps.args.slice(1); - const restartCodex = cacheArgs.includes("--restart-codex"); - const restartDesktopApp = cacheArgs.includes("--restart-desktop-app"); + const restartScope = readRestartScope(cacheArgs, console); const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); const { getCodexHome } = await import("../codex/paths"); @@ -514,8 +514,7 @@ const commandRunners: Record = { : console; // Only warn/restart when models_cache was actually rewritten from a readable catalog. if (invalidated.kind === "completed" && invalidated.value) { - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: jsonSafeLog }); - if (restartDesktopApp) await handleDesktopAppRestart(jsonSafeLog); + await handleRestartScopeAfterWrite(restartScope, jsonSafeLog); } else if (desiredDisabled && !cacheJson) { // Worth saying in the human path, because it explains why nothing was written. // Under --json this belongs on the envelope, not as a second stdout line. @@ -961,6 +960,12 @@ export async function dispatchCommand(head: CliHead, deps: CliDispatchDeps): Pro printUsage(); return 0; } + if (command === "internal") { + // Routed here rather than as a runner key so it stays out of DISPATCH_COMMANDS and + // therefore out of the registry-parity gate. See src/cli/internal-command.ts. + const { handleInternalCommand } = await import("./internal-command"); + return await handleInternalCommand(deps.args.slice(1)); + } const runner = commandRunners[resolveDispatchCommand(command) ?? ""]; if (!runner) { console.error(`Unknown command: ${command}`); @@ -970,62 +975,12 @@ export async function dispatchCommand(head: CliHead, deps: CliDispatchDeps): Pro return await runner(deps); } -/** - * Report the outcome of an opt-in desktop-app restart. Kept next to the two - * callers so `sync` and `sync-cache` cannot drift in what they tell the user. - */ -async function handleDesktopAppRestart(log: Pick): Promise { - const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart"); - const result = restartCodexDesktopApp(); - switch (result.reason) { - case "windows_only": - log.error("--restart-desktop-app is supported on Windows only; nothing was stopped."); - return; - case "package_discovery_failed": - log.error( - "Could not identify the installed Codex desktop package. Quit and relaunch the desktop app " - + "manually to refresh the model picker.", - ); - return; - case "self_ancestry": - log.error( - "Refusing to restart the desktop app because this command is running inside it. " - + "Run 'ocx sync --restart-desktop-app' from an external terminal instead.", - ); - return; - case "process_probe_failed": - // Distinct from `no_targets`: we could not look, which is not the same as looking and - // finding nothing. Saying "not running" here sent users away believing there was nothing - // to restart (#2557). - log.error( - "Could not enumerate Codex desktop processes, so the app was not restarted. " - + "Quit and relaunch the desktop app manually to refresh the model picker.", - ); - return; - case "no_targets": - log.log("Codex desktop app is not running; nothing to restart."); - return; - case "targets_survived": - log.error( - `Codex desktop app PID(s) ${result.surviving.join(", ")} did not exit, so it was not relaunched. ` - + "Quit the desktop app manually to refresh the model picker.", - ); - return; - default: - if (result.relaunch === "started") { - log.log("Codex desktop app restarted; its model picker will re-read the catalog."); - } - } -} - async function handleConnectedSyncCatalogWrite( result: { catalogWritten: boolean; cacheSynced: boolean }, - restartCodex: boolean, - restartDesktopApp: boolean, + scope: RestartScope, ): Promise { if (!result.catalogWritten && !result.cacheSynced) return; - afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); - if (restartDesktopApp) await handleDesktopAppRestart(console); + await handleRestartScopeAfterWrite(scope, console); } async function reconcileClientJournalBeforeLifecycle( diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 0f8fffedf6..df03ab8384 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1366,7 +1366,7 @@ export async function runDoctor(args: string[] = []): Promise { const { collectCodexAppServerCatalogState } = await import("../codex/app-server-processes"); const catalogState = collectCodexAppServerCatalogState(); if (catalogState.state === "stale") { - console.log(` [WARN] Codex app-server (PID(s): ${catalogState.processes.map(p => p.pid).join(", ")}) started before the on-disk catalog changed; its in-memory model list disagrees with ocx. Action: restart Codex (or run \`ocx sync --restart-codex\`; on Windows the desktop app may need \`ocx sync --restart-desktop-app\`)`); + console.log(` [WARN] Codex app-server (PID(s): ${catalogState.processes.map(p => p.pid).join(", ")}) started before the on-disk catalog changed; its in-memory model list disagrees with ocx. Action: run \`ocx sync --restart-codex\`, which restarts the app-servers and the Codex desktop app`); } else if (catalogState.state === "unknown") { console.log(" [WARN] Could not verify whether the running Codex app-server's model catalog is current (start time or catalog unreadable). Action: if the model list looks stale, restart Codex"); } else if (catalogState.state === "fresh") { diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts new file mode 100644 index 0000000000..ef2e596155 --- /dev/null +++ b/src/cli/internal-command.ts @@ -0,0 +1,44 @@ +/** + * Hidden `ocx internal ...` commands. + * + * Deliberately NOT in `src/cli/registry.ts` and NOT in `src/cli/capabilities.ts`, so it + * is absent from help, from the generated skill surface, and from the registry-parity + * gate. It is not a user-facing capability and must not become one: it exists so the + * detached restart helper is the same audited binary running the same audited ladder, + * rather than a second implementation in a shell script. + * + * It is routed before the dispatch table for the same reason `help` is - adding it as a + * runner key would make it a command the registry-parity test expects to find + * documented. + * + * It is intentionally unauthenticated, and that is not an oversight. Any process running + * as this user can invoke it with a hand-written plan file, and it gains nothing by + * doing so: the helper only does what the public `--restart-codex` flag already does for + * that same user, and a same-uid process could call `kill` directly. A token here would + * protect nothing and would imply a boundary that does not exist. + */ +const USAGE = "Usage: ocx internal desktop-restart-handoff --plan "; + +function optionValue(args: readonly string[], name: string): string | undefined { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +export async function handleInternalCommand(args: readonly string[]): Promise { + const sub = args[0]; + if (sub !== "desktop-restart-handoff") { + console.error(`Unknown internal command: ${sub ?? "(none)"}. ${USAGE}`); + return 2; + } + const plan = optionValue(args, "--plan"); + if (!plan) { + console.error(USAGE); + return 2; + } + const { runDesktopRestartHandoff } = await import("../codex/desktop-app/handoff"); + const outcome = await runDesktopRestartHandoff(plan); + // The operator is not watching this process - its terminal died with the app. The + // exit code exists for a supervisor, and the readable record is the handoff log. + return outcome === "restarted" ? 0 : 1; +} + diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 6a8a98478d..bfbe845c5b 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -125,27 +125,29 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "sync", - usage: "ocx sync [--restart-codex] [--restart-desktop-app]", + usage: "ocx sync [--restart-codex] [--restart-app-server-only]", summary: "Fetch provider models and inject them into Codex config.", details: [ "After writing the catalog, warns if long-lived Codex app-server processes are still running.", - "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).", - "--restart-desktop-app (Windows only, opt-in) fully restarts the Codex desktop app so its model picker re-reads the catalog. Never implied by --restart-codex: it ends live conversations.", + "--restart-codex restarts the app-servers AND fully quits and relaunches the Codex desktop app on macOS, Linux and Windows, so its model picker re-reads the catalog. It ends live conversations.", + "--restart-app-server-only keeps the narrow behaviour: SIGTERM to matching app-server / code-mode-host processes, desktop app left running. It wins over --restart-codex when both are given.", + "--restart-desktop-app is a deprecated alias of --restart-codex and prints a notice.", ], }, { name: "sync-cache", - usage: "ocx sync-cache [--restart-codex] [--restart-desktop-app]", + usage: "ocx sync-cache [--restart-codex] [--restart-app-server-only]", summary: "Refresh Codex's model cache from the active catalog.", details: [ "Warns when Codex app-server processes still hold an in-memory model list.", - "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).", - "--restart-desktop-app (Windows only, opt-in) fully restarts the Codex desktop app so its model picker re-reads the catalog. Never implied by --restart-codex: it ends live conversations.", + "--restart-codex restarts the app-servers AND fully quits and relaunches the Codex desktop app on macOS, Linux and Windows, so its model picker re-reads the catalog. It ends live conversations.", + "--restart-app-server-only keeps the narrow behaviour: SIGTERM to matching app-server / code-mode-host processes, desktop app left running. It wins over --restart-codex when both are given.", + "--restart-desktop-app is a deprecated alias of --restart-codex and prints a notice.", ], }, { name: "catalog", - usage: "ocx catalog pull [--auth-env ] [--json] [--restart-codex]", + usage: "ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]", summary: "Install a validated remote /v1/catalog snapshot into Codex.", details: [ "Authentication is read only from the named environment variable and sent as a Bearer token.", diff --git a/src/cli/restart-scope.ts b/src/cli/restart-scope.ts new file mode 100644 index 0000000000..4f8660b2d9 --- /dev/null +++ b/src/cli/restart-scope.ts @@ -0,0 +1,184 @@ +/** + * The restart scope a command was asked for, and the post-write restart itself. + * + * Its own module because `sync`, `sync-cache` and `catalog pull` all need it, and + * having `catalog.ts` import it from `dispatch.ts` would make the two files circular. + */ +import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; +import type { AfterCatalogWriteAppServerResult } from "../codex/app-server-processes"; +import type { DesktopAppRestartResult } from "../codex/desktop-app-restart"; + +/** + * Which restart a command was asked for. + * + * `--restart-codex` used to mean app-server-only, and the desktop restart was a + * separate Windows-only opt-in. That split existed because quitting the app ends live + * conversations, which is a bigger consent than restarting a background helper. The + * reasoning was sound and is superseded by an explicit maintainer decision: + * `--restart-codex` now means the app is fully stopped and started again. The narrow + * behaviour did not disappear, it moved to a flag that names it. + * + * One reader for every command, so `sync`, `sync-cache` and `catalog pull` cannot + * drift in what the same flag means. + */ +export interface RestartScope { + /** Signal matching app-server / code-mode-host processes. */ + appServers: boolean; + /** Fully quit and relaunch the Codex desktop app. */ + desktopApp: boolean; +} + +export function readRestartScope( + args: readonly string[], + log: Pick, +): RestartScope { + const appServerOnly = args.includes("--restart-app-server-only"); + const legacyDesktop = args.includes("--restart-desktop-app"); + const restartCodex = args.includes("--restart-codex"); + if (legacyDesktop) { + log.error( + "--restart-desktop-app is deprecated: --restart-codex now restarts the Codex " + + "desktop app on every platform. The flag still works and will be removed in a " + + "future release.", + ); + } + if (appServerOnly && (restartCodex || legacyDesktop)) { + // Contradictory scopes, and the NARROW one wins. Losing live conversations is + // unrecoverable; a stale model picker is not. A user who typed the app-server-only + // flag asked not to lose them. + log.error( + "--restart-app-server-only overrides --restart-codex/--restart-desktop-app; " + + "the desktop app was left running.", + ); + return { appServers: true, desktopApp: false }; + } + if (appServerOnly) return { appServers: true, desktopApp: false }; + if (restartCodex || legacyDesktop) return { appServers: true, desktopApp: true }; + return { appServers: false, desktopApp: false }; +} + +export interface RestartScopeOutcome { + appServers?: AfterCatalogWriteAppServerResult; + desktopApp?: DesktopAppRestartResult; +} + +/** + * The post-write restart, for every command that performs one. + * + * App-servers that belong to the desktop tree are excluded when a desktop restart is + * also going to run: the app-server is a CHILD of the app on every platform, so + * signalling it first and then quitting the app interrupts the operator's in-flight + * turn twice in one command. A discovery or probe failure yields no exclusion, which + * is the safe direction - a missed exclusion costs an extra interruption, a wrong one + * leaves a stale app-server serving a roster that no longer exists. + */ +export async function handleRestartScopeAfterWrite( + scope: RestartScope, + log: Pick, +): Promise { + const { listCodexDesktopAppPids } = await import("../codex/desktop-app-restart"); + // KNOWN LIMITATION on Windows. The exclusion matches pids against the discovered + // desktop tree, and the Windows probe enumerates only ChatGPT.exe, while Windows + // app-servers run as codex.exe / codex-code-mode-host. They therefore never match and + // still receive SIGTERM before the app quits, so Windows keeps the double interruption + // this exclusion removes on macOS and Linux - where the app-server executable does live + // under the bundle or install root and is enumerated. + // + // Closing it means widening the Windows probe past ChatGPT.exe, which is the same query + // that decides what may be killed, so it needs its own verification rather than being + // appended here. The restart itself is correct on Windows either way; the cost is one + // extra interrupted turn. + const excludePids = scope.desktopApp ? (listCodexDesktopAppPids() ?? []) : []; + const appServers = afterCatalogWriteHandleAppServers({ + restart: scope.appServers, log, excludePids, + }); + const desktopApp = scope.desktopApp ? await handleDesktopAppRestart(log) : undefined; + return { appServers, desktopApp }; +} + +/** + * Report the outcome of a desktop-app restart. Kept next to the callers so every + * command tells the user the same thing. + */ +export async function handleDesktopAppRestart( + log: Pick, +): Promise { + const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart"); + const { startDesktopRestartHandoff } = await import("../codex/desktop-app/handoff"); + const result = restartCodexDesktopApp({ + // The CLI is the one caller whose exit is exactly the signal the helper waits for, + // so it is the one caller allowed to hand off. The management service is not (it + // runs in a proxy that never exits) and the helper itself is not (recursion). + startHandoff: () => { + const outcome = startDesktopRestartHandoff(); + return outcome.kind === "started" + ? { helperPid: outcome.helperPid, logPath: outcome.logPath } + : null; + }, + }); + switch (result.reason) { + case "unsupported_platform": + log.error( + `Restarting the Codex desktop app is not supported on ${process.platform}; ` + + "nothing was stopped.", + ); + return result; + case "restart_in_flight": + log.error( + "Another Codex desktop-app restart is already running; this one did nothing. " + + "Wait for it to finish and check again.", + ); + return result; + case "relaunch_failed": + log.error( + "The Codex desktop app was stopped but could not be started again. Launch it manually.", + ); + return result; + case "package_discovery_failed": + log.error( + "Could not identify the installed Codex desktop package. Quit and relaunch the desktop app " + + "manually to refresh the model picker.", + ); + return result; + case "handoff_started": + // Saying the session will end is the point. The operator is about to lose the + // terminal they typed into, and a message that omits that reads as a hang. + log.log( + "This command is running inside the Codex app, so the restart was handed off to " + + `a detached helper (pid ${result.handoff?.helperPid ?? 0}). The app will quit and ` + + `relaunch in a moment; this session will end with it. Outcome: ${result.handoff?.logPath ?? ""}`, + ); + return result; + case "self_ancestry": + log.error( + "Refusing to restart the desktop app because this command is running inside it, " + + "and the restart could not be handed off to a detached helper. " + + "Run 'ocx sync --restart-codex' from a terminal outside the app instead.", + ); + return result; + case "process_probe_failed": + // Distinct from `no_targets`: we could not look, which is not the same as looking and + // finding nothing. Saying "not running" here sent users away believing there was nothing + // to restart (#2557). + log.error( + "Could not enumerate Codex desktop processes, so the app was not restarted. " + + "Quit and relaunch the desktop app manually to refresh the model picker.", + ); + return result; + case "no_targets": + log.log("Codex desktop app is not running; nothing to restart."); + return result; + case "targets_survived": + log.error( + `Codex desktop app PID(s) ${result.surviving.join(", ")} did not exit, so it was not relaunched. ` + + "Quit the desktop app manually to refresh the model picker.", + ); + return result; + default: + if (result.relaunch === "started") { + log.log("Codex desktop app restarted; its model picker will re-read the catalog."); + } + return result; + } +} + diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index f0e2bbbe8b..4962aa79c0 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -17,8 +17,8 @@ import { import { readCodexCatalogPath } from "./catalog/parsing"; export const STALE_CODEX_APP_SERVER_HINT = - "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex). " - + "On Windows the desktop app itself may also need a full restart (ocx sync --restart-desktop-app)."; + "If Codex still shows an older model list, run `ocx sync --restart-codex`: it restarts the long-lived app-server " + + "processes and fully restarts the Codex desktop app, whose model picker is what actually holds the stale list."; /** Attach the shared dashboard hint only after a catalog or models_cache write. */ export function attachStaleAppServerHint | null; io?: CodexAppServerProcessIo; + /** + * Pids already covered by a desktop-app restart in this same command. + * + * The app-server is a CHILD of the Codex desktop app on every platform, so signalling + * it and then quitting the app interrupts the operator's in-flight turn twice in one + * command. Excluding the desktop tree leaves the quit to do that work once. + * + * Standalone app-servers - the npm wrapper pair, SSH bootstraps - are not members of + * that tree and are still signalled. An empty list means no exclusion, which is what a + * failed discovery or probe yields: a missed exclusion costs an extra interruption, a + * wrong one leaves a stale app-server serving a roster that no longer exists. + */ + excludePids?: readonly number[]; } export interface AfterCatalogWriteAppServerResult { @@ -1189,7 +1202,9 @@ export interface AfterCatalogWriteAppServerResult { export function afterCatalogWriteHandleAppServers( options: AfterCatalogWriteAppServerOptions, ): AfterCatalogWriteAppServerResult { - const processes = listCodexAppServerProcesses(options.io); + const excluded = new Set(options.excludePids ?? []); + const processes = listCodexAppServerProcesses(options.io) + .filter(process => !excluded.has(process.pid)); const hint = STALE_CODEX_APP_SERVER_HINT; if (processes.length === 0) { return { processes, warned: false, hint }; diff --git a/src/codex/app-server-restart-service.ts b/src/codex/app-server-restart-service.ts index 47a3cf4855..d919fac86c 100644 --- a/src/codex/app-server-restart-service.ts +++ b/src/codex/app-server-restart-service.ts @@ -28,11 +28,26 @@ import { import type { CodexAppServerProcessIo } from "./app-server-processes"; import type { CodexAppServerStateResponse, + CodexDesktopRestartSummary, CodexRestartResponse, } from "../lib/codex-restart-contract"; import { getServerListenPort } from "../server/lifecycle"; +async function defaultRestartDesktopApp(): Promise { + const { restartCodexDesktopApp } = await import("./desktop-app-restart"); + const outcome = restartCodexDesktopApp({ allowHandoff: false }); + return { + attempted: outcome.attempted, + stopped: outcome.stopped, + surviving: outcome.surviving, + relaunch: outcome.relaunch, + ...(outcome.reason === undefined ? {} : { reason: outcome.reason }), + }; +} + export interface CodexRestartServiceIo { + /** Desktop-restart seam, so a route test cannot terminate the developer's own Codex. */ + restartDesktopApp?: () => Promise; /** Process-layer seam, forwarded to every app-server-processes call. */ processIo?: CodexAppServerProcessIo; /** Catalog refresh seam. Resolves to whether a catalog or cache write happened. */ @@ -114,7 +129,18 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise ({ + desktopApp: desktop, success: true, stateBefore: before.state, synced, @@ -174,6 +200,7 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise string; + /** Overrides the adapter chosen from `platform`. Tests drive every branch through this. */ + adapter?: DesktopAppAdapter; + execFile?: DesktopExec; /** Process ancestry of the current process, innermost first. Used for the self-kill guard. */ ancestryPids?: () => number[]; isAlive?: (pid: number) => boolean; sleep?: (ms: number) => void; now?: () => number; + lock?: DesktopRestartLockIo; + /** + * Hand the restart to a detached helper when this process is inside the tree. + * Supplied by wp5; absent here means the ladder refuses instead, which is the + * behaviour that shipped before the handoff existed. + */ + startHandoff?: () => DesktopAppRestartHandoff | null; + /** + * False forbids a handoff. The helper passes it so recursion is structurally + * impossible, and the management service passes it because it runs inside a proxy + * that never exits — a handoff waiting for the caller to exit would always time out + * after telling the operator it had been handed off. + */ + allowHandoff?: boolean; } export type DesktopAppRestartReason = - | "windows_only" + | "unsupported_platform" | "package_discovery_failed" | "process_probe_failed" | "no_targets" | "self_ancestry" - | "targets_survived"; + | "restart_in_flight" + | "handoff_started" + | "targets_survived" + | "relaunch_failed"; export interface DesktopAppRestartResult { attempted: boolean; @@ -54,302 +96,245 @@ export interface DesktopAppRestartResult { surviving: number[]; relaunch: "started" | "skipped"; reason?: DesktopAppRestartReason; + handoff?: DesktopAppRestartHandoff; } -/** How long a graceful close is given before the forced pass. */ -const GRACEFUL_EXIT_TIMEOUT_MS = 15_000; -/** How long a forced kill is given before the target counts as surviving. */ -const FORCED_EXIT_TIMEOUT_MS = 5_000; -/** Every probe is bounded; PowerShell module loading is the slow part. */ -const PROBE_TIMEOUT_MS = 10_000; - -interface DesktopPackage { - family: string; - installLocation: string; - aumid: string; -} +const ADAPTERS: Partial> = { + darwin: { adapter: darwinDesktopAppAdapter, exec: darwinDefaultExec }, + linux: { adapter: linuxDesktopAppAdapter, exec: linuxDefaultExec }, + win32: { adapter: windowsDesktopAppAdapter, exec: windowsDefaultExec }, +}; -/** - * Runtime discovery, never a hardcoded identifier. The beta MSIX package family - * changes between builds, so a literal AUMID would silently stop matching and - * then either do nothing or — worse — match a package we did not mean. - */ -function discoverPackage(exec: NonNullable): DesktopPackage | null { - const script = [ - "$ErrorActionPreference='SilentlyContinue'", - "Import-Module Appx -ErrorAction SilentlyContinue", - "$p = Get-AppxPackage -Name OpenAI.Codex", - "if (-not $p) { $p = Get-AppxPackage -Name OpenAI.CodexBeta }", - "if (-not $p -or -not $p.InstallLocation) { 'MISS' } else {", - " $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"", - "}", - ].join("; "); - let stdout: string; +function defaultIsAlive(pid: number): boolean { try { - stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], { - timeout: PROBE_TIMEOUT_MS, - windowsHide: true, - }); + process.kill(pid, 0); + return true; } catch { - return null; + return false; } - const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0); - if (lines.length < 3 || lines[0] === "MISS") return null; - const [family, installLocation, aumid] = lines; - if (!family || !installLocation || !aumid) return null; - return { family, installLocation, aumid }; } -interface DesktopProcess { - pid: number; - parentPid: number; - /** Win32_Process CreationDate. Guards against PID reuse across the wait window. */ - createdAt: string; +function defaultSleep(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } /** - * Only `ChatGPT.exe` processes whose image lives under the discovered install - * location AND owned by the current user. The install location alone is not - * enough: an MSIX package under `WindowsApps` is shared, so on a multi-user - * machine another account's Codex desktop matches the same path. The app-server - * collector already pays for `GetOwner` for exactly this reason. + * True when the pid still names the process we verified. * - * `CreationDate` is captured so a PID can be re-verified before it is signalled; - * a graceful-close window is long enough for Windows to recycle a PID. + * Between listing and signalling there is a graceful-close window long enough for the + * OS to recycle a pid, and the next step is a hard kill. A pid alone is not an + * identity across that window; the start-time token is what distinguishes a process + * from its replacement. */ -function listPackageProcesses( - exec: NonNullable, - installLocation: string, -): DesktopProcess[] | null { - const literal = installLocation.replace(/'/g, "''"); - const script = [ - "$ErrorActionPreference='SilentlyContinue'", - `$root = '${literal}'`, - "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name", - "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |", - " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |", - " ForEach-Object {", - " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner", - " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {", - " $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }", - " if ($owner -ieq $me) {", - " \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))\"", - " }", - " }", - " }", - // Statements must be newline-separated. Joining with a space concatenates - // `$ErrorActionPreference='SilentlyContinue' $root = '...'` into one malformed statement, - // which PowerShell rejects — so the probe threw and every caller read "not running" (#2557). - ].join("\n"); - let stdout: string; - try { - stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], { - timeout: PROBE_TIMEOUT_MS, - windowsHide: true, - }); - } catch { - // A probe that could not run is NOT proof the app is absent. Returning [] here made a - // failed enumeration indistinguishable from "no targets", so the CLI reported the app as - // not running and skipped a restart the user had explicitly asked for. - return null; - } - const processes: DesktopProcess[] = []; - for (const line of stdout.split(/\r?\n/)) { - const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line); - if (!match) continue; - const pid = Number(match[1]); - const parentPid = Number(match[2]); - const createdAt = match[3]!; - if (Number.isSafeInteger(pid) && Number.isSafeInteger(parentPid)) { - processes.push({ pid, parentPid, createdAt }); - } - } - return processes; -} +type IdentityCheck = "same" | "gone" | "unknown"; -/** - * True when the PID still names the same process we verified. Between listing - * and signalling there is a graceful-close window, and a `taskkill /T /F` on a - * recycled PID would tear down an unrelated process tree. - */ -function stillSameProcess( - exec: NonNullable, - installLocation: string, +function checkIdentity( + adapter: DesktopAppAdapter, + exec: DesktopExec, + install: Parameters[1], target: DesktopProcess, -): boolean { - const processes = listPackageProcesses(exec, installLocation); - // Fail CLOSED on a failed re-probe: this guards a kill, and "we could not look" must not be - // read as "the pid was recycled and is now someone else's process". - if (processes === null) return false; - const current = processes.find(p => p.pid === target.pid); - return current !== undefined && current.createdAt === target.createdAt; -} - -/** Roots are the package processes whose parent is not itself in the package tree. */ -function rootProcesses(processes: readonly DesktopProcess[]): DesktopProcess[] { - const inTree = new Set(processes.map(p => p.pid)); - return processes.filter(p => !inTree.has(p.parentPid)); +): IdentityCheck { + const processes = adapter.listProcesses(exec, install); + // THREE outcomes, not two. Collapsing them into a boolean is what made this ladder + // claim a restart it never performed: a re-probe that could not RUN looked identical + // to a process that had exited, and the caller recorded the pid as stopped, skipped + // the forced pass, and relaunched into an app that was still running - reporting + // success the whole way. Measured on a real Windows host, where the app kept its + // original pid and start time through a restart that said it had stopped it. + if (processes === null) return "unknown"; + const current = processes.find(entry => entry.pid === target.pid); + if (current === undefined) return "gone"; + // Same pid, different start time: the pid was recycled and now belongs to somebody + // else. Treated as gone, because the process we meant to stop no longer exists and + // signalling this pid would hit an unrelated process. + return current.createdAt === target.createdAt ? "same" : "gone"; } /** - * Full Windows parent chain for this process, innermost first. + * Pids of the running desktop-app tree, or null when discovery or the probe failed. * - * `process.ppid` is one level, which is not enough: a terminal hosted inside the - * desktop app sits several hops below `ChatGPT.exe`, so a one-level check would - * miss the exact case the guard exists for and we would terminate our own host. - * The chain therefore comes from CIM, with a bound so a corrupted parent cycle - * cannot spin. + * Read-only. Used by the CLI to exclude app-servers the desktop restart is about to + * take anyway, so an operator\u2019s in-flight turn is not interrupted twice in one command. */ -function windowsAncestryPids(exec: NonNullable): number[] { - const chain: number[] = [process.pid]; - let current = process.pid; - for (let hop = 0; hop < 16; hop++) { - let stdout: string; - try { - stdout = exec(resolveTrustedWindowsPowerShellExe(), [ - "-NoProfile", "-NonInteractive", "-Command", - `$ErrorActionPreference='SilentlyContinue'; (Get-CimInstance Win32_Process -Filter "ProcessId=${current}").ParentProcessId`, - ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true }); - } catch { - // An unreadable chain must not be read as "not our ancestor". - return []; - } - const parent = Number(stdout.trim()); - if (!Number.isSafeInteger(parent) || parent <= 0 || chain.includes(parent)) break; - chain.push(parent); - current = parent; - } - return chain; -} - -function defaultExecFile(file: string, args: readonly string[], options?: DesktopAppExecOptions): string { - return execFileSync(file, [...args], { - encoding: "utf-8", - timeout: options?.timeout ?? PROBE_TIMEOUT_MS, - windowsHide: options?.windowsHide ?? true, - }); -} - -function defaultIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function defaultSleep(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function waitForExit( - pid: number, +/** + * Poll the platform's own process list until it stops listing this process. + * + * A single post-kill enumeration is not enough. Measured on Windows: `taskkill /T /F` + * succeeds, the process is genuinely dead a moment later, and yet the very next + * `Win32_Process` query still lists it. Checking once turned that lag into a reported + * survivor, which blocked the relaunch and left the machine with no app at all - the + * failure mode is the mirror of claiming a stop that never happened, and just as bad. + * + * Liveness is polled first because it is cheap; the enumeration is what decides. A probe + * that cannot run keeps the loop going rather than deciding either way, and if the + * deadline passes without a clean "gone" the caller treats it as a survivor. + */ +function waitUntilGone( + adapter: DesktopAppAdapter, + exec: DesktopExec, + install: Parameters[1], + target: DesktopProcess, timeoutMs: number, isAlive: (pid: number) => boolean, sleep: (ms: number) => void, now: () => number, ): boolean { const deadline = now() + timeoutMs; - while (now() < deadline) { - if (!isAlive(pid)) return true; + for (;;) { + if (!isAlive(target.pid) && checkIdentity(adapter, exec, install, target) === "gone") return true; + if (now() >= deadline) break; sleep(250); } - return !isAlive(pid); + // One last look after the deadline, so a process that exited during the final sleep is + // not reported as surviving purely because of poll timing. + return checkIdentity(adapter, exec, install, target) === "gone"; } -/** - * Stop every package-tree root gracefully, force the stragglers, then relaunch - * through the discovered AUMID. Returns without relaunching if anything - * survived, because launching a second shell beside a stuck one is worse than - * leaving the user to restart it. - */ -export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult { +export function listCodexDesktopAppPids(io: DesktopAppRestartIo = {}): number[] | null { const platform = io.platform ?? process.platform; + const selected = ADAPTERS[platform]; + const adapter = io.adapter ?? selected?.adapter; + if (!adapter) return null; + const exec = io.execFile ?? selected?.exec; + if (!exec) return null; + const install = adapter.discover(exec); + if (!install) return null; + const processes = adapter.listProcesses(exec, install); + return processes === null ? null : processes.map(entry => entry.pid); +} + +export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopAppRestartResult { const skipped = (reason: DesktopAppRestartReason): DesktopAppRestartResult => ({ attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason, }); - if (platform !== "win32") return skipped("windows_only"); - const exec = io.execFile ?? defaultExecFile; - const pkg = discoverPackage(exec); - if (!pkg) return skipped("package_discovery_failed"); + const platform = io.platform ?? process.platform; + const selected = ADAPTERS[platform]; + const adapter = io.adapter ?? selected?.adapter; + const exec = io.execFile ?? selected?.exec; + if (!adapter || !exec) return skipped("unsupported_platform"); - const processes = listPackageProcesses(exec, pkg.installLocation); - // A probe that could not run is not evidence of absence. Reporting it as `no_targets` told - // the user the app was not running and silently skipped the restart they asked for (#2557). - if (processes === null) return skipped("process_probe_failed"); - const roots = rootProcesses(processes); - if (roots.length === 0) return skipped("no_targets"); + // Step 0. Two restarts at once are destructive rather than merely wasteful: the + // first quits and relaunches, the second sees the freshly started shell as a target + // and kills it. Own-pid reentrancy means the wp5 helper runs this same step and + // finds the lock its caller made out to it. + const acquisition = acquireDesktopRestartLock(io.lock); + if (!acquisition.acquired) return skipped("restart_in_flight"); - const ancestryPids = io.ancestryPids ? io.ancestryPids() : windowsAncestryPids(exec); - if (ancestryPids.length === 0) { - // Fail closed: an unreadable ancestry chain cannot prove we are outside the - // tree we are about to terminate. - return skipped("self_ancestry"); - } - const ancestry = new Set(ancestryPids); - if (processes.some(p => ancestry.has(p.pid))) { - // Terminating our own tree would kill this command mid-flight and leave the - // user with neither a restarted app nor an explanation. - return skipped("self_ancestry"); - } + let handedOff = false; + try { + const install = adapter.discover(exec); + if (!install) return skipped("package_discovery_failed"); - const isAlive = io.isAlive ?? defaultIsAlive; - const sleep = io.sleep ?? defaultSleep; - const now = io.now ?? (() => Date.now()); - const stopped: number[] = []; - const surviving: number[] = []; + const processes = adapter.listProcesses(exec, install); + // A probe that could not run is not evidence of absence. Reporting it as no_targets + // told users the app was not running and silently skipped the restart they asked + // for (#2557). + if (processes === null) return skipped("process_probe_failed"); - for (const root of roots) { - const pid = root.pid; - // Re-verify immediately before the graceful close: the listing is already - // one probe old. - if (!stillSameProcess(exec, pkg.installLocation, root)) { - stopped.push(pid); - continue; - } - try { - exec(resolveTrustedWindowsPowerShellExe(), [ - "-NoProfile", "-NonInteractive", "-Command", - `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { [void]$p.CloseMainWindow() }`, - ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true }); - } catch { - /* a refused graceful close still gets the forced pass below */ + const shells = rootShells(processes, install, adapter); + if (shells.length === 0) return skipped("no_targets"); + + const ancestryPids = io.ancestryPids ? io.ancestryPids() : adapter.ancestryPids(exec); + // An empty chain means "could not establish that we are outside the tree", which + // covers both an unreadable hop and a walk that hit its bound. + const insideTree = ancestryPids.length === 0 + || processes.some(entry => ancestryPids.includes(entry.pid)); + if (insideTree) { + if (io.allowHandoff === false || !io.startHandoff) return skipped("self_ancestry"); + const handoff = io.startHandoff(); + if (!handoff) return skipped("self_ancestry"); + handedOff = true; + return { + attempted: false, stopped: [], surviving: [], + relaunch: "skipped", reason: "handoff_started", handoff, + }; } - if (waitForExit(pid, GRACEFUL_EXIT_TIMEOUT_MS, isAlive, sleep, now)) { - stopped.push(pid); - continue; + + // Captured while the tree is still ALIVE. On Linux the relaunch needs the + // graphical session variables, and after termination there is nothing to read them + // from. Ordering this wrongly works on macOS and Windows and produces a Linux app + // that cannot reach the compositor. + const context = adapter.captureRelaunchContext(exec, install, processes); + + const isAlive = io.isAlive ?? defaultIsAlive; + const sleep = io.sleep ?? defaultSleep; + const now = io.now ?? (() => Date.now()); + const stopped: number[] = []; + const surviving: number[] = []; + + for (const shell of shells) { + const pid = shell.pid; + // The listing is already one probe old. + const before = checkIdentity(adapter, exec, install, shell); + if (before === "gone") { + stopped.push(pid); + continue; + } + if (before === "unknown") { + // We could not look, so we cannot claim this exited and we must not signal a + // process we failed to re-verify. Reporting it as surviving is the honest answer: + // it blocks the relaunch, which is exactly right when the tree state is unknown. + surviving.push(pid); + continue; + } + try { + adapter.requestQuit(exec, install, shell); + } catch { + /* a refused graceful close still gets the forced pass below */ + } + // Liveness AND enumeration have to agree before a stop is claimed. A pid-based + // liveness probe is a weaker instrument than the platform's own process list, and + // on a packaged app the two disagree in BOTH directions. + if (waitUntilGone(adapter, exec, install, shell, GRACEFUL_EXIT_TIMEOUT_MS, isAlive, sleep, now)) { + stopped.push(pid); + continue; + } + // The wait window is long enough for a pid to be recycled, and the next step is a + // hard kill. Confirm it is still the process we verified, or leave it alone. + const afterGraceful = checkIdentity(adapter, exec, install, shell); + if (afterGraceful === "gone") { + stopped.push(pid); + continue; + } + if (afterGraceful === "unknown") { + surviving.push(pid); + continue; + } + try { + adapter.forceStop(exec, shell); + } catch { + /* the process state decides, not the exit code */ + } + // Same rule after the forced pass: only an enumeration that no longer contains this + // process proves it stopped. Everything else is a survivor, and a survivor blocks + // the relaunch rather than producing a second shell beside a live one. + if (waitUntilGone(adapter, exec, install, shell, FORCED_EXIT_TIMEOUT_MS, isAlive, sleep, now)) { + stopped.push(pid); + } else { + surviving.push(pid); + } } - // The wait window is long enough for Windows to recycle a PID, and the next - // step is `/T /F` against a whole tree. Confirm the PID is still the process - // we verified, or leave it alone. - if (!stillSameProcess(exec, pkg.installLocation, root)) { - stopped.push(pid); - continue; + + if (surviving.length > 0) { + // Launching a second shell beside a stuck one is worse than leaving the operator + // to restart it. + return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" }; } + try { - exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"], { - timeout: PROBE_TIMEOUT_MS, windowsHide: true, - }); + adapter.relaunch(exec, install, context); } catch { - /* fall through to the liveness check: the process state decides, not the exit code */ + // Distinct from targets_survived on purpose. Everything DID die and the relaunch + // is what failed; the old code reported the two as one and sent operators looking + // for processes that were not there. + return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "relaunch_failed" }; } - if (waitForExit(pid, FORCED_EXIT_TIMEOUT_MS, isAlive, sleep, now)) stopped.push(pid); - else surviving.push(pid); - } - - if (surviving.length > 0) { - return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" }; + return { attempted: true, stopped, surviving: [], relaunch: "started" }; + } finally { + // On the handoff path ownership was transferred to the helper, so releasing here + // would drop a lock that is still protecting a restart about to happen. + if (!handedOff) releaseDesktopRestartLock(io.lock); } - - try { - exec(resolveTrustedWindowsPowerShellExe(), [ - "-NoProfile", "-NonInteractive", "-Command", - `Start-Process 'shell:AppsFolder\\${pkg.aumid}'`, - ], { timeout: PROBE_TIMEOUT_MS, windowsHide: true }); - } catch { - return { attempted: true, stopped, surviving, relaunch: "skipped", reason: "targets_survived" }; - } - return { attempted: true, stopped, surviving, relaunch: "started" }; } + diff --git a/src/codex/desktop-app/darwin.ts b/src/codex/desktop-app/darwin.ts new file mode 100644 index 0000000000..b4c7d46ce7 --- /dev/null +++ b/src/codex/desktop-app/darwin.ts @@ -0,0 +1,268 @@ +/** + * macOS adapter for the Codex desktop-app restart. + * + * Measured shape (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md): + * + * 15901 1 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT + * 16733 15901 /Applications/ChatGPT.app/Contents/Resources/codex ... app-server ... + * 15903 1 .../Contents/Frameworks/Codex Framework.framework/.../browser_crashpad_handler + * + * The bundle is named ChatGPT.app but its identifier is com.openai.codex, and the + * display name is shared with a different OpenAI product. Every identity decision + * here therefore keys on the identifier, never on the name. + */ +import { realpathSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { + isUnderRoot, + type DesktopAppAdapter, + type DesktopAppInstall, + type DesktopExec, + type DesktopProcess, +} from "./types"; + +const BUNDLE_ID = "com.openai.codex"; +const CONVENTIONAL_BUNDLE = "/Applications/ChatGPT.app"; +const SHELL_SUFFIX = "/Contents/MacOS/ChatGPT"; + +/** Absolute system locations only. PATH is never consulted for any of these. */ +const PS = "/bin/ps"; +const OSASCRIPT = "/usr/bin/osascript"; +const OPEN = "/usr/bin/open"; +const MDFIND = "/usr/bin/mdfind"; +const PLIST_BUDDY = "/usr/libexec/PlistBuddy"; + +const PROBE_TIMEOUT_MS = 10_000; +const MAX_ANCESTRY_HOPS = 16; + +function shellPath(bundle: string): string { + return join(bundle, "Contents", "MacOS", "ChatGPT"); +} + +function readBundleIdentifier(exec: DesktopExec, bundle: string): string | null { + try { + return exec(PLIST_BUDDY, [ + "-c", "Print :CFBundleIdentifier", + join(bundle, "Contents", "Info.plist"), + ], { timeout: PROBE_TIMEOUT_MS }).trim(); + } catch { + return null; + } +} + +function confirmBundle(exec: DesktopExec, candidate: string): DesktopAppInstall | null { + if (!candidate) return null; + let resolved: string; + try { + // Resolved ONCE here so membership is a pure comparison against a trusted value. + // A prefix test against an unresolved path admits sibling directories such as + // /Applications/ChatGPT.app-evil/..., which the same user can create. + resolved = realpathSync(candidate); + } catch { + return null; + } + if (readBundleIdentifier(exec, resolved) !== BUNDLE_ID) return null; + return { id: BUNDLE_ID, root: resolved, relaunch: BUNDLE_ID }; +} + +interface PsSnapshot { + pid: number; + parentPid: number; + createdAt: string; + uid: number; + executable: string; +} + +/** + * Column order matters. lstart is five whitespace-separated tokens, and the executable + * path itself contains spaces and parentheses on this app (Codex (Service).app), so + * everything after the uid is taken as the remainder of the line rather than split. + * + * comm as the FINAL -o column yields the full, untruncated executable path; this was + * checked against a 150+ character helper path rather than assumed. The 16-character + * truncation people expect belongs to ucomm. + */ +function parsePsLine(line: string): PsSnapshot | null { + const match = /^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(\d+)\s+(.+)$/.exec(line); + if (!match) return null; + const pid = Number(match[1]); + const parentPid = Number(match[2]); + const uid = Number(match[4]); + if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parentPid) || !Number.isSafeInteger(uid)) { + return null; + } + return { + pid, + parentPid, + createdAt: (match[3] ?? "").trim(), + uid, + executable: (match[5] ?? "").trim(), + }; +} + +function readPsSnapshots(exec: DesktopExec): PsSnapshot[] | null { + let stdout: string; + try { + stdout = exec(PS, ["-Ao", "pid=,ppid=,lstart=,uid=,comm="], { timeout: PROBE_TIMEOUT_MS }); + } catch { + // A probe that could not RUN is not evidence of absence. + return null; + } + const out: PsSnapshot[] = []; + for (const line of stdout.split(/\r?\n/)) { + const parsed = parsePsLine(line); + if (parsed) out.push(parsed); + } + return out; +} + +/** + * Prefer the bundle the RUNNING shell executes out of. + * + * Membership is path-scoped while the quit and the relaunch are bundle-id-scoped. If + * two bundles claim com.openai.codex, discovering by identifier alone could enumerate + * one installation and quit the other. Starting from the live process makes the thing + * we quit the same thing we counted. + */ +function discoverFromRunningShell(exec: DesktopExec): string | null { + for (const snapshot of readPsSnapshots(exec) ?? []) { + if (!snapshot.executable.endsWith(SHELL_SUFFIX)) continue; + return snapshot.executable.slice(0, -SHELL_SUFFIX.length); + } + return null; +} + +function currentUid(): number | undefined { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } +} + +let killProcess: (pid: number, signal: NodeJS.Signals) => void = (pid, signal) => { + process.kill(pid, signal); +}; + +/** Test-only seam, so a kill can be observed without ending a developer's own Codex. */ +export function setDarwinKillForTests( + next: ((pid: number, signal: NodeJS.Signals) => void) | null, +): void { + killProcess = next ?? ((pid, signal) => { process.kill(pid, signal); }); +} + +export const darwinDesktopAppAdapter: DesktopAppAdapter = { + discover(exec): DesktopAppInstall | null { + const running = discoverFromRunningShell(exec); + if (running) { + const confirmed = confirmBundle(exec, running); + if (confirmed) return confirmed; + } + let spotlight = ""; + try { + const query = "kMDItemCFBundleIdentifier == '" + BUNDLE_ID + "'"; + spotlight = exec(MDFIND, [query], { timeout: PROBE_TIMEOUT_MS }) + .split(/\r?\n/) + .map(entry => entry.trim()) + .find(entry => entry.length > 0) ?? ""; + } catch { + spotlight = ""; + } + return confirmBundle(exec, spotlight) ?? confirmBundle(exec, CONVENTIONAL_BUNDLE); + }, + + listProcesses(exec, install): DesktopProcess[] | null { + const snapshots = readPsSnapshots(exec); + if (snapshots === null) return null; + const uid = currentUid(); + // Without a uid there is no way to scope the result to this user, and reporting an + // empty list would tell the caller the app is not running (#2557's failure mode in a + // different disguise). This is a probe failure. + if (uid === undefined) return null; + const out: DesktopProcess[] = []; + for (const snapshot of snapshots) { + if (!isUnderRoot(snapshot.executable, install.root)) continue; + // Same user only. + if (snapshot.uid !== uid) continue; + out.push({ + pid: snapshot.pid, + parentPid: snapshot.parentPid, + createdAt: snapshot.createdAt, + executable: snapshot.executable, + }); + } + return out; + }, + + isShell(entry, install): boolean { + return entry.executable === shellPath(install.root); + }, + + ancestryPids(exec): number[] { + const chain: number[] = [process.pid]; + let current = process.pid; + for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) { + let stdout: string; + try { + stdout = exec(PS, ["-o", "ppid=", "-p", String(current)], { timeout: PROBE_TIMEOUT_MS }); + } catch (error) { + // ps -p exits 1 with empty output when the pid does not exist, and + // execFileSync turns a non-zero exit into a throw. Without this branch the + // clean-end handling below is unreachable in production, every dead parent reads + // as unreadable, and the orphaned handoff helper refuses the one job it exists + // for. Hop 0 is this process, which always exists, so a failure there is real. + const status = (error as { status?: unknown } | null)?.status; + if (hop > 0 && status === 1) return chain; + // Anything else: could not look, so we cannot conclude we are outside the tree. + return []; + } + const trimmed = stdout.trim(); + // Empty output means the pid has no live parent entry: a CLEAN end of chain, not + // a read failure. The detached handoff helper reaches exactly this state once its + // caller exits, and reading it as unreadable would make the helper refuse the one + // job it exists for. + if (trimmed === "") return chain; + const parent = Number(trimmed); + if (!Number.isSafeInteger(parent) || parent <= 0) return chain; + if (chain.includes(parent)) return chain; + chain.push(parent); + if (parent === 1) return chain; + current = parent; + } + // Bound reached without finding the top. A truncated chain silently defeats the + // self-ancestry intersection, so this reports "could not establish" instead. + return []; + }, + + requestQuit(exec, install): void { + // The Apple event, so the app runs its own termination path. Delivery is + // synchronous; termination is not, which is why the ladder always waits and + // re-verifies identity afterwards. + exec(OSASCRIPT, ["-e", 'quit app id "' + install.id + '"'], { timeout: PROBE_TIMEOUT_MS }); + }, + + forceStop(_exec, root): void { + killProcess(root.pid, "SIGKILL"); + }, + + captureRelaunchContext(): Record { + // LaunchServices supplies the session, so nothing needs carrying forward. + return {}; + }, + + relaunch(exec, install): void { + // Deliberately without -g: the operator asked for a restart and expects the app in + // front of them. An unknown bundle id exits non-zero with + // LSCopyApplicationURLsForBundleIdentifier() failed, which the ladder turns into + // relaunch_failed rather than a silent no-op. Not -n either: a second instance is + // both unreliable to obtain and unwanted. + exec(OPEN, ["-b", install.relaunch], { timeout: PROBE_TIMEOUT_MS }); + }, +}; + +export const darwinDefaultExec: DesktopExec = (file, args, options) => execFileSync(file, [...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: options?.timeout ?? PROBE_TIMEOUT_MS, +}); diff --git a/src/codex/desktop-app/handoff.ts b/src/codex/desktop-app/handoff.ts new file mode 100644 index 0000000000..e0b54683ad --- /dev/null +++ b/src/codex/desktop-app/handoff.ts @@ -0,0 +1,303 @@ +/** + * Restarting the Codex app you are running inside. + * + * The self-ancestry guard is correct to refuse a direct restart: terminating your own + * tree kills the command mid-flight and leaves the operator with neither a restarted + * app nor an explanation. But on a developer machine that refusal fires in the normal + * case, not a corner case - the measured shell is + * `zsh -> bundled codex app-server -> ChatGPT -> launchd`, so anything run from a Codex + * terminal or agent session is inside the tree. Without a handoff, the merged + * `--restart-codex` would refuse in exactly the situation that produced the original + * "it does nothing" report. + * + * So the refusal becomes a handoff: a detached helper outlives the caller, waits for it + * to exit, re-enumerates, and performs the restart from outside the tree. + * + * Two things make the helper safe to kill the app around: + * + * - It waits for the calling process to exit first. At that moment it is orphaned and + * reparented, so it is no longer reachable by a tree walk from the app root. This + * matters most on Windows, where `taskkill /T` follows live parent links and never + * reparents orphans. + * - It re-runs the ancestry check itself rather than trusting the caller's finding, and + * it passes `allowHandoff: false` so it can only ever take the direct path or refuse. + * Recursion is structurally impossible rather than merely unlikely. + * + * Design: devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md + */ +import { spawn } from "node:child_process"; +import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { + readDesktopRestartLockOwner, + releaseDesktopRestartLock, + transferDesktopRestartLock, + type DesktopRestartLockIo, +} from "./lock"; + +/** How long the helper waits for its caller to exit before giving up. */ +const CALLER_EXIT_TIMEOUT_MS = 20_000; +const CALLER_POLL_MS = 100; +/** A plan older than this is not ours to run. */ +const PLAN_MAX_AGE_MS = 5 * 60_000; + +export interface DesktopRestartHandoffPlan { + schemaVersion: 1; + /** Pid the helper waits on before acting. */ + callerPid: number; + createdAtMs: number; +} + +export type HandoffStartOutcome = + | { kind: "started"; helperPid: number; logPath: string } + | { kind: "failed"; reason: "no_executable" | "plan_write_failed" | "spawn_failed" | "lock_transfer_failed" }; + +export interface HandoffIo { + now?: () => number; + pid?: number; + execPath?: string; + argv?: readonly string[]; + homeDir?: string; + lock?: DesktopRestartLockIo; + spawnHelper?: (command: string, args: readonly string[]) => { pid?: number | undefined; unref(): void }; + isAlive?: (pid: number) => boolean; + sleep?: (ms: number) => void; +} + +export function handoffLogPath(io: HandoffIo = {}): string { + return join(io.homeDir ?? getConfigDir(), "desktop-restart-handoff.log"); +} + +/** + * How to re-invoke this CLI as the helper. + * + * `process.execPath` alone is not enough, because it differs between running from a + * checkout, through the installed npm shim, and as a packaged binary. Resolution is + * explicit and a failure to resolve is a REFUSAL rather than a guess: spawning the + * wrong interpreter with a path that does not exist produces a helper that exits + * immediately and an operator who was told the restart was handed off. + */ +export function resolveHelperCommand(io: HandoffIo = {}): { command: string; args: string[] } | null { + const execPath = io.execPath ?? process.execPath; + const argv = io.argv ?? process.argv; + const entry = argv[1]; + if (entry && existsSync(entry)) return { command: execPath, args: [entry] }; + if (basename(execPath).replace(/\.exe$/i, "") === "ocx") return { command: execPath, args: [] }; + return null; +} + +function writePlan(path: string, plan: DesktopRestartHandoffPlan): boolean { + try { + mkdirSync(dirname(path), { recursive: true }); + // Exclusive create: the path is handed to another process, so it must not be + // possible to hand over a file somebody else authored. + const fd = openSync(path, "wx", 0o600); + try { + writeSync(fd, JSON.stringify(plan)); + } finally { + closeSync(fd); + } + return true; + } catch { + return false; + } +} + +export function startDesktopRestartHandoff(io: HandoffIo = {}): HandoffStartOutcome { + const resolved = resolveHelperCommand(io); + if (!resolved) return { kind: "failed", reason: "no_executable" }; + + const now = io.now ?? Date.now; + const callerPid = io.pid ?? process.pid; + const home = io.homeDir ?? getConfigDir(); + const planPath = join(home, `desktop-restart-handoff-${callerPid}-${Math.random().toString(36).slice(2)}.json`); + const plan: DesktopRestartHandoffPlan = { schemaVersion: 1, callerPid, createdAtMs: now() }; + if (!writePlan(planPath, plan)) return { kind: "failed", reason: "plan_write_failed" }; + + const args = [...resolved.args, "internal", "desktop-restart-handoff", "--plan", planPath]; + let child: { pid?: number | undefined; unref(): void }; + try { + child = (io.spawnHelper ?? defaultSpawnHelper)(resolved.command, args); + } catch { + try { unlinkSync(planPath); } catch { /* best effort */ } + return { kind: "failed", reason: "spawn_failed" }; + } + if (child.pid === undefined) { + // A detached child reports a failed launch asynchronously, to a parent that is about + // to exit. An absent pid is the only synchronous evidence the spawn happened. + try { unlinkSync(planPath); } catch { /* best effort */ } + return { kind: "failed", reason: "spawn_failed" }; + } + child.unref(); + + // Hand the lock over only AFTER a successful spawn. Doing it earlier would strand the + // lock on a pid that never came into being, and the next restart would have to wait + // out the staleness window for nothing. + // + // A FAILED transfer is not cosmetic. The lock would still name this process, which is + // about to exit, so it reads as stale for the whole helper wait and a concurrent + // restart could reclaim it and run a second ladder - the dual-kill the lock exists to + // prevent. Reporting failure here is safe because the helper independently refuses to + // act unless the lock names IT, so the spawned process becomes a no-op rather than an + // unsupervised restart. + if (!transferDesktopRestartLock(child.pid, io.lock)) { + return { kind: "failed", reason: "lock_transfer_failed" }; + } + return { kind: "started", helperPid: child.pid, logPath: handoffLogPath(io) }; +} + +const defaultSpawnHelper = (command: string, args: readonly string[]): { pid?: number | undefined; unref(): void } => + spawn(command, [...args], { detached: true, stdio: "ignore", windowsHide: true }); + +export type HandoffRunOutcome = + | "restarted" + | "caller_still_running" + | "plan_unreadable" + | "plan_expired" + | "not_lock_owner" + | "restart_incomplete"; + +function readPlan(path: string): DesktopRestartHandoffPlan | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + if (typeof parsed !== "object" || parsed === null) return null; + const view = parsed as Record; + if (view.schemaVersion !== 1) return null; + const callerPid = view.callerPid; + const createdAtMs = view.createdAtMs; + if (typeof callerPid !== "number" || !Number.isSafeInteger(callerPid) || callerPid <= 0) return null; + if (typeof createdAtMs !== "number" || !Number.isFinite(createdAtMs)) return null; + return { schemaVersion: 1, callerPid, createdAtMs }; + } catch { + return null; + } +} + +/** True when the path is inside the opencodex home AND named like a plan this CLI writes. */ +export function isOwnPlanPath(planPath: string, io: HandoffIo = {}): boolean { + const home = io.homeDir ?? getConfigDir(); + let resolvedPlan: string; + let resolvedHome: string; + try { + resolvedHome = resolve(home); + resolvedPlan = resolve(planPath); + } catch { + return false; + } + if (dirname(resolvedPlan) !== resolvedHome) return false; + return /^desktop-restart-handoff-\d+-[a-z0-9]+\.json$/.test(basename(resolvedPlan)); +} + +function defaultIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function defaultSleep(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Append one JSON line per run. Counts, never command lines or OS error text: the same + * projection the management restart response already applies, for the same reason. + */ +function appendLog(io: HandoffIo, entry: Record): void { + try { + const path = handoffLogPath(io); + mkdirSync(dirname(path), { recursive: true }); + appendFileSync(path, JSON.stringify({ at: new Date((io.now ?? Date.now)()).toISOString(), ...entry }) + "\n"); + } catch { + /* the restart matters more than the record of it */ + } +} + +export interface HandoffRunIo extends HandoffIo { + restart?: (allowHandoff: false) => { relaunch: "started" | "skipped"; reason?: string; stopped: number[]; surviving: number[] }; + readLockOwner?: () => number | null; +} + +export async function runDesktopRestartHandoff( + planPath: string, + io: HandoffRunIo = {}, +): Promise { + const now = io.now ?? Date.now; + const self = io.pid ?? process.pid; + // Only ever touch a file this CLI could have written. Unlinking whatever --plan points + // at turned a hidden helper command into an unlink oracle: a same-uid caller could pass + // a config path and have it deleted on the way to being told the plan was unreadable. + if (!isOwnPlanPath(planPath, io)) { + appendLog(io, { outcome: "plan_unreadable" }); + releaseDesktopRestartLock(io.lock); + return "plan_unreadable"; + } + const plan = readPlan(planPath); + // Unlink only AFTER the shape is confirmed, so a file that merely lives in the right + // directory under the right name is still not destroyed by a malformed read. + if (plan) { + try { unlinkSync(planPath); } catch { /* the plan is single-use either way */ } + } + if (!plan) { + appendLog(io, { outcome: "plan_unreadable" }); + releaseDesktopRestartLock(io.lock); + return "plan_unreadable"; + } + if (now() - plan.createdAtMs > PLAN_MAX_AGE_MS) { + // A plan left behind by a crash must not restart the app hours later. + appendLog(io, { outcome: "plan_expired" }); + releaseDesktopRestartLock(io.lock); + return "plan_expired"; + } + + const isAlive = io.isAlive ?? defaultIsAlive; + const sleep = io.sleep ?? defaultSleep; + const deadline = now() + CALLER_EXIT_TIMEOUT_MS; + // Bounded by polls as well as by the clock. The clock alone is not enough: if sleep + // does not actually advance time - a frozen clock, a no-op sleep - this becomes a hot + // spin that never exits, inside a detached process nobody is watching. + const maxPolls = Math.ceil(CALLER_EXIT_TIMEOUT_MS / CALLER_POLL_MS) + 1; + for (let poll = 0; poll < maxPolls && now() < deadline && isAlive(plan.callerPid); poll++) { + sleep(CALLER_POLL_MS); + } + if (isAlive(plan.callerPid)) { + // A caller that outlives the window is not the short-lived `ocx sync` this was built + // for, and quitting the app out from under an unknown long-running process is not + // something to guess about. + appendLog(io, { outcome: "caller_still_running", callerPid: plan.callerPid }); + releaseDesktopRestartLock(io.lock); + return "caller_still_running"; + } + + // The lock must name THIS process. It was made out to us by the caller; if it names + // anybody else, the transfer failed or somebody reclaimed it, and acting now would be + // the unsynchronised second ladder the lock exists to prevent. + const owner = (io.readLockOwner ?? (() => readDesktopRestartLockOwner(io.lock)))(); + if (owner !== self) { + appendLog(io, { outcome: "not_lock_owner" }); + return "not_lock_owner"; + } + + try { + const restart = io.restart + ? io.restart(false) + : (await import("../desktop-app-restart")).restartCodexDesktopApp({ + allowHandoff: false, + lock: io.lock, + }); + const ok = restart.relaunch === "started"; + appendLog(io, { + outcome: ok ? "restarted" : "restart_incomplete", + reason: restart.reason, + stopped: restart.stopped.length, + surviving: restart.surviving.length, + }); + return ok ? "restarted" : "restart_incomplete"; + } finally { + releaseDesktopRestartLock(io.lock); + } +} + diff --git a/src/codex/desktop-app/linux.ts b/src/codex/desktop-app/linux.ts new file mode 100644 index 0000000000..a750903937 --- /dev/null +++ b/src/codex/desktop-app/linux.ts @@ -0,0 +1,388 @@ +/** + * Linux adapter for the Codex desktop-app restart. + * + * Measured shape (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md): + * + * /usr/bin/chatgpt -> /usr/lib/chatgpt/codex-launcher (2-line sh script) + * that execs /usr/lib/chatgpt/ChatGPT + * + * 3284901 /usr/lib/chatgpt/ChatGPT (root: no --type=) + * 3284913 /usr/lib/chatgpt/ChatGPT --type=zygote + * 3284951 /usr/lib/chatgpt/ChatGPT --type=gpu-process + * 3284953 /usr/lib/chatgpt/ChatGPT --type=utility ... + * + * The root's /proc//environ is 1902 bytes of NUL: Chromium scrubs it after + * startup. Session variables survive only in children that inherited them before + * the scrub. Measured on lidge: DISPLAY=:1, XDG_SESSION_TYPE=x11, + * XDG_RUNTIME_DIR=/run/user/1000, + * DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus. + */ +import { spawn, execFileSync } from "node:child_process"; +import { + existsSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + statSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { + isUnderRoot, + type DesktopAppAdapter, + type DesktopAppInstall, + type DesktopExec, + type DesktopProcess, +} from "./types"; + +const INSTALL_ID = "chatgpt"; +const SHELL_NAME = "ChatGPT"; +/** Absolute candidates only. A chatgpt earlier on PATH must not redirect a kill or a launch. */ +const LAUNCHER_CANDIDATES = ["/usr/bin/chatgpt", "/usr/local/bin/chatgpt"] as const; +const SETSID = "/usr/bin/setsid"; +const PROC_ROOT = "/proc"; +const MAX_ANCESTRY_HOPS = 16; +/** Group-write 0o020 | world-write 0o002. Sticky/setgid bits are not a trust failure. */ +const GROUP_OR_WORLD_WRITE = 0o022; + +/** + * Copied into the relaunch environment and nothing else. + * + * /proc//environ is another process's full environment and routinely carries + * API keys and session tokens. Copying it wholesale would move credentials between + * security contexts for no benefit. + */ +const SESSION_ENV_KEYS = [ + "DISPLAY", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + "XDG_SESSION_TYPE", + "DBUS_SESSION_BUS_ADDRESS", +] as const; + +const MINIMAL_ENV_KEYS = ["HOME", "USER", "LOGNAME", "LANG"] as const; +const RELAUNCH_PATH = "/usr/local/bin:/usr/bin:/bin"; + +function procPath(pid: number, leaf: string): string { + return PROC_ROOT + "/" + String(pid) + "/" + leaf; +} + +function readProcExe(pid: number): string { + const link = readlinkSync(procPath(pid, "exe")); + return typeof link === "string" ? link : Buffer.from(link).toString("utf8"); +} + +function currentUid(): number | undefined { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } +} + +/** + * Root and shell must be uid 0 and not group/world writable. + * + * dirname(realpath(launcher)) alone is not enough: /usr/local/bin is group-writable + * on some systems, so a planted chatgpt -> ~/x/codex-launcher beside a ~/x/ChatGPT + * would make an attacker-chosen directory the membership boundary and the relaunch + * target. A failed check is a discovery failure, never a fallback to the next + * candidate -- otherwise the planted /usr/local/bin/chatgpt becomes the target. + * + * stat (follow) rather than lstat: a root-owned symlink to a user-writable directory + * must fail on the target's mode, not pass on the symlink's. + */ +function isTrustedSystemPath(path: string): boolean { + try { + const st = statSync(path); + return st.uid === 0 && (st.mode & GROUP_OR_WORLD_WRITE) === 0; + } catch { + return false; + } +} + +function discoverFromCandidate(candidate: string): DesktopAppInstall | null | "absent" { + let resolvedLauncher: string; + try { + resolvedLauncher = realpathSync(candidate); + } catch { + return "absent"; + } + const root = dirname(resolvedLauncher); + const shell = join(root, SHELL_NAME); + if (!isTrustedSystemPath(root) || !isTrustedSystemPath(shell)) return null; + return { id: INSTALL_ID, root, relaunch: candidate }; +} + +function parsePpidAndRealUid(status: string): { parentPid: number; uid: number } | null { + const ppidMatch = /^PPid:\s+(\d+)/m.exec(status); + const uidMatch = /^Uid:\s+(\d+)/m.exec(status); + if (!ppidMatch || !uidMatch) return null; + const parentPid = Number(ppidMatch[1]); + const uid = Number(uidMatch[1]); + if (!Number.isSafeInteger(parentPid) || !Number.isSafeInteger(uid)) return null; + return { parentPid, uid }; +} + +/** + * Field 22 (starttime) as an opaque token. Located after the LAST ')' because comm + * can contain spaces and parentheses -- this app's helpers are literally named + * "Codex (Service)". + */ +function parseStarttimeToken(stat: string): string | null { + const close = stat.lastIndexOf(")"); + if (close < 0) return null; + const fields = stat.slice(close + 2).split(/\s+/); + const starttime = fields[19]; + return starttime ? starttime : null; +} + +function cmdlineHasElectronType(pid: number): boolean | "unreadable" { + try { + const args = readFileSync(procPath(pid, "cmdline")).toString("utf8").split("\0"); + return args.some(arg => arg.startsWith("--type=")); + } catch { + return "unreadable"; + } +} + +function parseEnviron(buf: Buffer): Record { + const out: Record = {}; + for (const entry of buf.toString("utf8").split("\0")) { + if (!entry) continue; + const eq = entry.indexOf("="); + if (eq <= 0) continue; + out[entry.slice(0, eq)] = entry.slice(eq + 1); + } + return out; +} + +function sessionEnvFrom(source: Record): Record { + const out: Record = {}; + for (const key of SESSION_ENV_KEYS) { + const value = source[key]; + if (typeof value === "string" && value.length > 0) out[key] = value; + } + return out; +} + +function byStarttimeAscending(a: DesktopProcess, b: DesktopProcess): number { + // starttime is a jiffies integer stored in a string. A lexical sort misorders + // it ("100" < "99"), so the oldest child -- the one most likely to still hold + // the pre-scrub session -- would not be tried first. + return Number(a.createdAt) - Number(b.createdAt); +} + +function minimalRelaunchEnv(): Record { + const env: Record = { PATH: RELAUNCH_PATH }; + for (const key of MINIMAL_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + return env; +} + +let killProcess: (pid: number, signal: NodeJS.Signals) => void = (pid, signal) => { + process.kill(pid, signal); +}; + +/** Test-only seam, so a kill can be observed without ending a developer's own Codex. */ +export function setLinuxKillForTests( + next: ((pid: number, signal: NodeJS.Signals) => void) | null, +): void { + killProcess = next ?? ((pid, signal) => { process.kill(pid, signal); }); +} + +type LinuxSpawn = ( + command: string, + args: readonly string[], + options: { detached: boolean; stdio: "ignore"; env: NodeJS.ProcessEnv }, +) => { pid?: number | undefined; unref(): void }; + +const defaultSpawn: LinuxSpawn = (command, args, options) => { + const child = spawn(command, [...args], { + detached: options.detached, + stdio: options.stdio, + env: options.env, + shell: false, + }); + // Headless ENOENT arrives as an async 'error'; without a listener it is an + // uncaught exception that kills the caller after relaunch has already returned. + child.on("error", () => {}); + return child; +}; + +let spawnProcess: LinuxSpawn = defaultSpawn; + +/** Test-only seam, so relaunch can be observed without starting ChatGPT. */ +export function setLinuxSpawnForTests(next: LinuxSpawn | null): void { + spawnProcess = next ?? defaultSpawn; +} + +export const linuxDesktopAppAdapter: DesktopAppAdapter = { + discover(_exec): DesktopAppInstall | null { + for (const candidate of LAUNCHER_CANDIDATES) { + const found = discoverFromCandidate(candidate); + if (found === "absent") continue; + return found; + } + return null; + }, + + listProcesses(_exec, install): DesktopProcess[] | null { + // A missing or unreadable /proc is an enumeration failure, not absence. Collapsing + // those told users the app was not running and skipped a restart they asked for. + if (!existsSync(PROC_ROOT)) return null; + let names: string[]; + try { + names = readdirSync(PROC_ROOT); + } catch { + return null; + } + const uid = currentUid(); + if (uid === undefined) return null; + + const out: DesktopProcess[] = []; + for (const name of names) { + if (!/^\d+$/.test(name)) continue; + const pid = Number(name); + if (!Number.isSafeInteger(pid)) continue; + try { + const executable = readProcExe(pid); + if (!isUnderRoot(executable, install.root)) continue; + const identity = parsePpidAndRealUid(readFileSync(procPath(pid, "status"), "utf8")); + if (!identity || identity.uid !== uid) continue; + const createdAt = parseStarttimeToken(readFileSync(procPath(pid, "stat"), "utf8")); + if (!createdAt) continue; + out.push({ + pid, + parentPid: identity.parentPid, + createdAt, + executable, + }); + } catch { + // Per-pid EACCES/ENOENT (and a pid that vanished mid-scan) skip that pid. + // They are not an enumeration failure. + continue; + } + } + return out; + }, + + isShell(entry, install): boolean { + if (entry.executable !== join(install.root, SHELL_NAME)) return false; + // Unreadable cmdline cannot prove this is the shell, so it is not a root. + return cmdlineHasElectronType(entry.pid) === false; + }, + + ancestryPids(_exec): number[] { + const chain: number[] = [process.pid]; + let current = process.pid; + for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) { + let status: string; + try { + status = readFileSync(procPath(current, "status"), "utf8"); + } catch (error) { + // Two different situations arrive here and they must not be merged. + // + // ENOENT means the pid is simply gone. That is a CLEAN end of chain and the + // normal state above an orphaned handoff helper, so the chain collected so far + // is returned and the caller can still be judged outside the tree. + // + // Any OTHER error means we could not look, and "could not look" must never be + // read as "we are outside the tree": that reading lets the ladder signal the + // shell hosting the caller's own session. Hop 0 is this process itself, which + // always exists, so a failure there is always a read failure. + const code = (error as NodeJS.ErrnoException | null)?.code; + if (hop > 0 && code === "ENOENT") return chain; + return []; + } + const parsed = parsePpidAndRealUid(status); + if (!parsed || parsed.parentPid <= 0) return chain; + const parent = parsed.parentPid; + if (chain.includes(parent)) return chain; + chain.push(parent); + if (parent === 1) return chain; + current = parent; + } + // Bound reached without finding the top. A truncated chain silently defeats + // the self-ancestry intersection, so this reports "could not establish". + return []; + }, + + requestQuit(_exec, _install, root): void { + // Honest ceiling: /proc//status on lidge had SIGTERM in neither SigCgt + // nor SigIgn, so the Linux shell has the default SIGTERM disposition. + // SIGTERM here is termination, not a graceful shutdown request. The app + // registers no DBus quit method and has no systemd unit. + killProcess(root.pid, "SIGTERM"); + }, + + forceStop(_exec, root): void { + killProcess(root.pid, "SIGKILL"); + }, + + captureRelaunchContext(_exec, _install, processes): Record { + const ordered = processes.slice().sort(byStarttimeAscending); + for (const entry of ordered) { + let buf: Buffer; + try { + buf = readFileSync(procPath(entry.pid, "environ")); + } catch { + continue; + } + const environ = parseEnviron(buf); + // The root is typically oldest and all-NUL after Chromium's scrub. Keep + // walking until a child that inherited the session before the scrub. + if (!environ.XDG_RUNTIME_DIR) continue; + return sessionEnvFrom(environ); + } + return {}; + }, + + relaunch(_exec, install, context): void { + if (!context.XDG_RUNTIME_DIR) { + // An app started without a session cannot reach the compositor, but + // Electron still takes the single-instance lock, so the user's real + // session then cannot start either. + throw new Error( + "missing graphical session: XDG_RUNTIME_DIR was not recovered from the live process tree", + ); + } + const env = { ...minimalRelaunchEnv(), ...sessionEnvFrom(context) }; + // The launcher is a sh script and Electron resolves its user-data directory + // from HOME. Starting it with only the five session variables would produce + // an app that launches and then behaves as a different user profile. + const child = spawnProcess(SETSID, [install.relaunch], { + detached: true, + stdio: "ignore", + env, + }); + // A detached child reports a failed launch asynchronously, and this process is + // about to stop caring about it, so the 'error' event has nobody to reach. An + // absent pid is the synchronous signal that the spawn never happened - without + // this check a missing /usr/bin/setsid still reported relaunch: "started". + if (child.pid === undefined) { + throw new Error("failed to spawn " + SETSID + " for the Codex desktop app relaunch"); + } + // detached already calls setsid(2); the setsid binary then auto-forks because + // it finds itself a group leader. The overlap is deliberate belt-and-braces + // against a runtime that changes detached semantics. No --fork is needed. + child.unref(); + }, +}; + +/** + * Linux needs no subprocess for discovery or enumeration - everything comes from + * /proc and the filesystem - so this exists only to satisfy the shared contract and + * to keep the ladder's adapter selection uniform. It is deliberately execFileSync + * with a bounded timeout rather than a throwing stub, so a future adapter method that + * does need a subprocess gets the same trusted-path, bounded-probe treatment as the + * other two platforms instead of inventing its own. + */ +export const linuxDefaultExec: DesktopExec = (file, args, options) => execFileSync(file, [...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: options?.timeout ?? 10_000, +}); + diff --git a/src/codex/desktop-app/lock.ts b/src/codex/desktop-app/lock.ts new file mode 100644 index 0000000000..50830ac0c3 --- /dev/null +++ b/src/codex/desktop-app/lock.ts @@ -0,0 +1,226 @@ +/** + * Singleton lock for desktop-app restarts. + * + * Two restarts running at once are not merely wasteful, they are destructive: the + * first quits the app and relaunches it, the second re-enumerates during that window, + * sees the FRESHLY STARTED shell as a target, and kills it. Two + * `ocx sync --restart-codex` runs, or one handoff racing an ssh-issued direct run, + * are enough to produce it. + * + * Contended callers do not queue. Queueing would rebuild the same race one step + * later, so a caller that cannot take the lock reports `restart_in_flight` and stops. + * + * Two properties make the wp5 handoff work on top of this: + * + * - **Own-pid reentrancy.** A lock already naming this pid counts as held, not as + * contention. That is what lets the detached helper run the ordinary ladder — the + * caller hands it a lock already made out to it, and the helper takes no special + * path. Without this the feature deadlocks: the caller holds the lock, discovers + * it is inside the tree, and spawns a helper that waits for a lock its own parent + * is holding. + * - **Compare-and-delete release.** A process only ever deletes a lock naming + * itself. An unconditional unlink would let a late release destroy somebody else's + * live lock, which is exactly the mutual exclusion this file exists to provide. + * + * Design: `devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md` §4.1. + */ +import { mkdirSync, openSync, closeSync, writeSync, readFileSync, unlinkSync, renameSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { getConfigDir } from "../../config/paths"; + +/** A lock older than this is stale regardless of what its owner pid says. */ +const LOCK_MAX_AGE_MS = 5 * 60_000; + +export interface DesktopRestartLockRecord { + ownerPid: number; + createdAtMs: number; +} + +export interface DesktopRestartLockIo { + lockPath?: string; + isAlive?: (pid: number) => boolean; + now?: () => number; + pid?: number; +} + +function defaultIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function readRecord(path: string): DesktopRestartLockRecord | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + if (typeof parsed !== "object" || parsed === null) return null; + const view = parsed as Record; + const ownerPid = view.ownerPid; + const createdAtMs = view.createdAtMs; + if (typeof ownerPid !== "number" || !Number.isSafeInteger(ownerPid) || ownerPid <= 0) return null; + if (typeof createdAtMs !== "number" || !Number.isFinite(createdAtMs)) return null; + return { ownerPid, createdAtMs }; + } catch { + // Unreadable or malformed: treat as absent so a corrupted file cannot wedge every + // future restart. The staleness rules below still bound how long a real lock holds. + return null; + } +} + +/** + * Exclusive create ON THE LOCK PATH. This is the whole mutual exclusion. + * + * The obvious-looking alternative - write a staging file with `wx` and `rename` it + * over the lock - is NOT exclusive: `wx` on a unique staging name always succeeds, and + * both racers then rename, so both believe they hold the lock and each kills the + * other's freshly relaunched app. The exclusivity has to come from `O_EXCL` on the + * contended path itself. + */ +function tryCreateExclusive(path: string, record: DesktopRestartLockRecord): boolean { + mkdirSync(dirname(path), { recursive: true }); + let fd: number; + try { + fd = openSync(path, "wx", 0o600); + } catch { + return false; + } + try { + writeSync(fd, JSON.stringify(record)); + } finally { + closeSync(fd); + } + return true; +} + +/** + * Atomic replace, used ONLY by an owner handing the lock to its helper. + * + * Safe there precisely because it is not the contended path: the caller already holds + * the lock, so there is no race to lose. + */ +function writeRecord(path: string, record: DesktopRestartLockRecord): void { + mkdirSync(dirname(path), { recursive: true }); + const staging = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}`; + const fd = openSync(staging, "wx", 0o600); + try { + writeSync(fd, JSON.stringify(record)); + } finally { + closeSync(fd); + } + renameSync(staging, path); +} + +export type DesktopRestartLockAcquisition = + | { acquired: true; record: DesktopRestartLockRecord } + | { acquired: false; heldBy: number }; + +/** + * Take the lock, or report who holds it. + * + * Staleness is decided by owner liveness first and age second. Both read liveness by + * pid, so both inherit a small exposure: a recycled pid inside the window reads as + * "still alive" and produces a false `restart_in_flight`. That fails in the safe + * direction — a restart that did not happen, rather than one that happened to the + * wrong process — and the age bound stops it lasting. + */ +export function acquireDesktopRestartLock( + io: DesktopRestartLockIo = {}, +): DesktopRestartLockAcquisition { + const path = io.lockPath ?? defaultLockPath(); + const isAlive = io.isAlive ?? defaultIsAlive; + const now = io.now ?? Date.now; + const self = io.pid ?? process.pid; + + const existing = readRecord(path); + if (existing) { + // Own-pid reentrancy: a lock handed to us, or one we already hold. + if (existing.ownerPid === self) return { acquired: true, record: existing }; + const stale = !isAlive(existing.ownerPid) || now() - existing.createdAtMs > LOCK_MAX_AGE_MS; + if (!stale) return { acquired: false, heldBy: existing.ownerPid }; + // Stale. Clear it and then compete for the exclusive create like anyone else rather + // than writing straight over it: two processes can observe the same stale lock, and + // only O_EXCL decides which of them actually gets it. + try { + unlinkSync(path); + } catch { + /* somebody else cleared it first, which is fine - the create below still decides */ + } + } + + const record: DesktopRestartLockRecord = { ownerPid: self, createdAtMs: now() }; + if (tryCreateExclusive(path, record)) return { acquired: true, record }; + + const winner = readRecord(path); + if (winner && winner.ownerPid === self) return { acquired: true, record: winner }; + if (winner) { + // Lost the race fairly. Reporting contention is the honest answer; retrying would be + // the queue this deliberately avoids, and queueing rebuilds the same race one step + // later. + return { acquired: false, heldBy: winner.ownerPid }; + } + + // The file exists but names nobody: truncated, corrupt, or left behind by a writer + // that died between creating it and writing it. Without this it would block every + // future restart forever, reported as contention with an owner of 0 - a lock nobody + // holds and nobody can clear. Remove it and make exactly one more attempt, so a real + // winner that appears in between still keeps the lock. + try { + unlinkSync(path); + } catch { + /* somebody else cleared it first */ + } + if (tryCreateExclusive(path, record)) return { acquired: true, record }; + const successor = readRecord(path); + if (successor && successor.ownerPid === self) return { acquired: true, record: successor }; + return { acquired: false, heldBy: successor?.ownerPid ?? 0 }; +} + +/** + * Hand the lock to a process that does not exist yet as far as the lock is concerned. + * + * Called only after a successful helper spawn. Doing it before would strand the lock + * on a pid that never came into being, and the next restart would have to wait out + * the staleness window for no reason. + */ +export function transferDesktopRestartLock( + toPid: number, + io: DesktopRestartLockIo = {}, +): boolean { + const path = io.lockPath ?? defaultLockPath(); + const now = io.now ?? Date.now; + const self = io.pid ?? process.pid; + const existing = readRecord(path); + if (!existing || existing.ownerPid !== self) return false; + try { + writeRecord(path, { ownerPid: toPid, createdAtMs: now() }); + return true; + } catch { + return false; + } +} + +/** Who currently holds the lock, or null when nobody does or it is unreadable. */ +export function readDesktopRestartLockOwner(io: DesktopRestartLockIo = {}): number | null { + return readRecord(io.lockPath ?? defaultLockPath())?.ownerPid ?? null; +} + +/** Compare-and-delete. Never removes a lock owned by another process. */ +export function releaseDesktopRestartLock(io: DesktopRestartLockIo = {}): void { + const path = io.lockPath ?? defaultLockPath(); + const self = io.pid ?? process.pid; + const existing = readRecord(path); + if (!existing || existing.ownerPid !== self) return; + try { + unlinkSync(path); + } catch { + /* already gone */ + } +} + +export function defaultLockPath(): string { + // getConfigDir owns OPENCODEX_HOME resolution, including ~ expansion and the caching + // every other consumer sees. Re-deriving it here would drift from it. + return join(getConfigDir(), "desktop-restart.lock"); +} diff --git a/src/codex/desktop-app/types.ts b/src/codex/desktop-app/types.ts new file mode 100644 index 0000000000..2a4d8774b4 --- /dev/null +++ b/src/codex/desktop-app/types.ts @@ -0,0 +1,141 @@ +/** + * Platform contract for the Codex desktop-app restart. + * + * One ladder in `../desktop-app-restart.ts` drives every platform; the only things + * that actually differ are identity, discovery, membership, the two stop primitives + * and relaunch. Keeping those seven behind this interface is what stopped the + * PID-reuse and fail-closed reasoning from being re-derived three times, once per + * operating system, with two of the three getting it subtly wrong. + * + * Design and audit history: `devlog/_plan/260913_cross_platform_desktop_app_restart/`. + */ +import { sep } from "node:path"; + +/** Bounded subprocess options. A hung probe must never wedge `ocx sync`. */ +export interface DesktopAppExecOptions { + timeout?: number; + windowsHide?: boolean; +} + +/** Returns stdout. Options are part of the seam so the timeout is testable. */ +export type DesktopExec = ( + file: string, + args: readonly string[], + options?: DesktopAppExecOptions, +) => string; + +/** One discovered installation of the Codex desktop app. */ +export interface DesktopAppInstall { + /** Stable platform-specific identity, used in messages and for relaunch. */ + id: string; + /** + * Absolute, ALREADY `realpath`-RESOLVED directory every member executable must + * live under. Resolution happens once here rather than per candidate so that + * {@link isUnderRoot} is a pure string comparison against a trusted value. + */ + root: string; + /** Opaque relaunch descriptor only the owning adapter interprets. */ + relaunch: string; +} + +export interface DesktopProcess { + pid: number; + parentPid: number; + /** + * Platform-native start-time token, compared verbatim and never parsed. + * + * This is the field that distinguishes a process from a replacement that reused + * its pid. A pid alone is not an identity across a graceful-close window long + * enough for the OS to recycle one. + */ + createdAt: string; + /** Absolute executable path, used for membership and the shell predicate. */ + executable: string; +} + +export interface DesktopAppAdapter { + /** `null` means discovery failed. Never throws. */ + discover(exec: DesktopExec): DesktopAppInstall | null; + /** + * `null` means the probe could not RUN. `[]` means it ran and found nothing. + * + * The distinction is not pedantic: collapsing them told users the app was not + * running and silently skipped a restart they had explicitly asked for (#2557). + */ + listProcesses(exec: DesktopExec, install: DesktopAppInstall): DesktopProcess[] | null; + /** + * True when this member is the app shell rather than a helper. + * + * "Parent is not a member" is not sufficient on its own. macOS crashpad handlers + * are launchd children, so they sit at ppid 1 and would otherwise be classified + * as roots — including stale ones left by an instance that already exited, which + * would be signalled and could never be made to "survive" cleanly. + */ + isShell(process: DesktopProcess, install: DesktopAppInstall): boolean; + /** + * Ancestry of the current process, innermost first. + * + * `[]` means "could not establish that we are outside the tree" and the ladder + * fails closed on it. A parent pid naming no live process is NOT that case: it is + * a clean end of chain, which is the normal state of the detached handoff helper + * on Windows, where orphans are never reparented. + */ + ancestryPids(exec: DesktopExec): number[]; + /** Ask the app to quit. Best effort; the ladder decides what happens next. */ + requestQuit(exec: DesktopExec, install: DesktopAppInstall, root: DesktopProcess): void; + /** Unconditional termination of one shell and its tree. */ + forceStop(exec: DesktopExec, root: DesktopProcess): void; + /** + * Capture what the relaunch will need, from the LIVE tree, before anything stops. + * + * This is on the contract rather than inside the Linux adapter because of its + * ordering obligation. A ladder that called it after termination would work on + * macOS and Windows and produce a Linux app that cannot reach the compositor — + * the failure would look platform-specific when it is really an ordering bug. + */ + captureRelaunchContext( + exec: DesktopExec, + install: DesktopAppInstall, + processes: readonly DesktopProcess[], + ): Record; + /** Start the app again. THROWS on failure; the ladder reports `relaunch_failed`. */ + relaunch( + exec: DesktopExec, + install: DesktopAppInstall, + context: Record, + ): void; +} + +/** + * Path-boundary membership test. + * + * A raw `startsWith` admits siblings: an install root of `/usr/lib/chatgpt` would + * also match `/usr/lib/chatgpt-evil/ChatGPT`, and `/Applications/ChatGPT.app` would + * match `/Applications/ChatGPT.app-evil/...`. Both are plantable by the same user + * whose processes are about to be signalled, so same-uid scoping does not cover it. + * + * `root` is expected to be `realpath`-resolved by discovery already. + */ +export function isUnderRoot(executable: string, root: string): boolean { + if (!executable || !root) return false; + if (executable === root) return true; + const prefix = root.endsWith(sep) ? root : root + sep; + return executable.startsWith(prefix); +} + +/** + * Shells whose parent is not itself a member of the tree. + * + * Helpers are deliberately enumerated but never returned here: they are what + * {@link DesktopAppAdapter.captureRelaunchContext} reads on Linux, and terminating + * the shell takes them anyway. + */ +export function rootShells( + processes: readonly DesktopProcess[], + install: DesktopAppInstall, + adapter: Pick, +): DesktopProcess[] { + const memberPids = new Set(processes.map(entry => entry.pid)); + return processes.filter(entry => + adapter.isShell(entry, install) && !memberPids.has(entry.parentPid)); +} diff --git a/src/codex/desktop-app/windows.ts b/src/codex/desktop-app/windows.ts new file mode 100644 index 0000000000..863a20057a --- /dev/null +++ b/src/codex/desktop-app/windows.ts @@ -0,0 +1,239 @@ +/** + * Windows adapter for the Codex desktop-app restart. + * + * Moved from `desktop-app-restart.ts`. Behaviour is the Appx/CIM/taskkill path + * that already shipped: runtime package discovery, current-user GetOwner + * scoping, CloseMainWindow then taskkill /T /F, relaunch through the discovered + * AUMID. The shape is DesktopAppAdapter so the ladder, not this file, owns + * PID-reuse re-verification and the fail-closed sequencing. + * + * Measured (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md §3): + * OpenAI.Codex MSIX, ChatGPT.exe, InstallLocation under WindowsApps. + */ +import { execFileSync } from "node:child_process"; +import { sep, win32 } from "node:path"; +import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsTaskkillExe } from "../../lib/windows-elevation"; +import { + isUnderRoot, + type DesktopAppAdapter, + type DesktopAppInstall, + type DesktopExec, + type DesktopProcess, +} from "./types"; + +/** Every probe is bounded; PowerShell module loading is the slow part. */ +const PROBE_TIMEOUT_MS = 10_000; +const MAX_ANCESTRY_HOPS = 16; +const SHELL_BASENAME = "chatgpt.exe"; + +const POWERSHELL_PROBE_OPTIONS = { timeout: PROBE_TIMEOUT_MS, windowsHide: true } as const; + +/** + * isUnderRoot prefixes with the host path.sep and is case-sensitive. Windows + * membership is case-insensitive, and this file is executed by Unix CI against + * backslash paths, so both sides are folded onto the host separator first. + * The boundary itself — sibling `OpenAI.Codex-evil` must not match root + * `OpenAI.Codex` — is still isUnderRoot's, which is why the PowerShell + * StartsWith is only a cheap pre-filter. + */ +function toHostMembershipPath(windowsPath: string): string { + const lowered = windowsPath.toLowerCase(); + return sep === "\\" ? lowered : lowered.replaceAll("\\", "/"); +} + +function isMemberExecutable(executable: string, root: string): boolean { + return isUnderRoot(toHostMembershipPath(executable), toHostMembershipPath(root)); +} + +/** + * Runtime discovery, never a hardcoded identifier. The beta MSIX package family + * changes between builds, so a literal AUMID would silently stop matching and + * then either do nothing or — worse — match a package we did not mean. + */ +function discoverPackage(exec: DesktopExec): DesktopAppInstall | null { + const script = [ + "$ErrorActionPreference='SilentlyContinue'", + "Import-Module Appx -ErrorAction SilentlyContinue", + "$p = Get-AppxPackage -Name OpenAI.Codex", + "if (-not $p) { $p = Get-AppxPackage -Name OpenAI.CodexBeta }", + "if (-not $p -or -not $p.InstallLocation) { 'MISS' } else {", + " $p.PackageFamilyName; $p.InstallLocation; \"$($p.PackageFamilyName)!App\"", + "}", + ].join("; "); + let stdout: string; + try { + stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], POWERSHELL_PROBE_OPTIONS); + } catch { + return null; + } + const lines = stdout.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0); + if (lines.length < 3 || lines[0] === "MISS") return null; + const [family, installLocation, aumid] = lines; + if (!family || !installLocation || !aumid) return null; + return { id: family, root: installLocation, relaunch: aumid }; +} + +/** + * Only `ChatGPT.exe` processes whose image lives under the discovered install + * location AND owned by the current user. The install location alone is not + * enough: an MSIX package under `WindowsApps` is shared, so on a multi-user + * machine another account's Codex desktop matches the same path. The app-server + * collector already pays for `GetOwner` for exactly this reason. + * + * `CreationDate` is captured so a PID can be re-verified before it is signalled; + * a graceful-close window is long enough for Windows to recycle a PID. + * + * ExecutablePath is included so membership can be decided by {@link isUnderRoot} + * rather than by PowerShell's `StartsWith`, which is a prefix test and would + * admit a sibling `OpenAI.Codex-evil` directory. + */ +function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): DesktopProcess[] | null { + const literal = install.root.replace(/'/g, "''"); + const script = [ + "$ErrorActionPreference='SilentlyContinue'", + `$root = '${literal}'`, + "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name", + "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |", + " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |", + " ForEach-Object {", + " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner", + " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {", + " $owner = if ($o.Domain) { \"$($o.Domain)\\$($o.User)\" } else { $o.User }", + " if ($owner -ieq $me) {", + " \"$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o')) $($_.ExecutablePath)\"", + " }", + " }", + " }", + // Statements must be newline-separated. Joining with a space concatenates + // `$ErrorActionPreference='SilentlyContinue' $root = '...'` into one malformed statement, + // which PowerShell rejects — so the probe threw and every caller read "not running" (#2557). + ].join("\n"); + let stdout: string; + try { + stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], POWERSHELL_PROBE_OPTIONS); + } catch { + // A probe that could not run is NOT proof the app is absent. Returning [] here made a + // failed enumeration indistinguishable from "no targets", so the CLI reported the app as + // not running and skipped a restart the user had explicitly asked for. + return null; + } + const processes: DesktopProcess[] = []; + for (const line of stdout.split(/\r?\n/)) { + const parsed = parseProcessLine(line, install.root); + if (parsed) processes.push(parsed); + } + return processes; +} + +function parseProcessLine(line: string, root: string): DesktopProcess | null { + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.+))?$/.exec(line); + if (!match) return null; + const pid = Number(match[1]); + const parentPid = Number(match[2]); + const createdAt = match[3] ?? ""; + const listed = (match[4] ?? "").trim(); + // The live probe emits ExecutablePath. Historical listings, and the tests that + // script them, were three tokens because the PowerShell filter is already + // Name='ChatGPT.exe' under root. Synthesize that image so executable is + // populated without treating a missing path as a different process. + const executable = listed.length > 0 ? listed : `${root.replace(/[\\/]+$/, "")}\\ChatGPT.exe`; + if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parentPid) || !createdAt || !executable) { + return null; + } + // Authoritative membership. PowerShell StartsWith already cheap-filtered, but + // that test is a string prefix and is how a sibling install would sneak in. + if (!isMemberExecutable(executable, root)) return null; + return { pid, parentPid, createdAt, executable }; +} + +/** + * Full Windows parent chain for this process, innermost first. + * + * `process.ppid` is one level, which is not enough: a terminal hosted inside the + * desktop app sits several hops below `ChatGPT.exe`, so a one-level check would + * miss the exact case the guard exists for and we would terminate our own host. + * The chain therefore comes from CIM, with a bound so a corrupted parent cycle + * cannot spin. + */ +function windowsAncestryPids(exec: DesktopExec): number[] { + const chain: number[] = [process.pid]; + let current = process.pid; + for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) { + let stdout: string; + try { + stdout = exec(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", "-NonInteractive", "-Command", + `$ErrorActionPreference='SilentlyContinue'; (Get-CimInstance Win32_Process -Filter "ProcessId=${current}").ParentProcessId`, + ], POWERSHELL_PROBE_OPTIONS); + } catch { + // An unreadable chain must not be read as "not our ancestor". + return []; + } + const trimmed = stdout.trim(); + // Empty output means the pid has no live CIM entry: a CLEAN end of chain, not + // a read failure. Windows never reparents orphans, so the detached handoff + // helper always has a dead parent link once its caller exits. Reading that as + // unreadable would make the helper refuse forever and the feature would never + // work on Windows. + if (trimmed === "") return chain; + const parent = Number(trimmed); + if (!Number.isSafeInteger(parent) || parent <= 0) return chain; + if (chain.includes(parent)) return chain; + chain.push(parent); + current = parent; + } + // Bound reached without finding the top. A truncated chain silently defeats the + // self-ancestry intersection, so this reports "could not establish" instead. + return []; +} + +export const windowsDesktopAppAdapter: DesktopAppAdapter = { + discover(exec): DesktopAppInstall | null { + return discoverPackage(exec); + }, + + listProcesses(exec, install): DesktopProcess[] | null { + return listPackageProcesses(exec, install); + }, + + isShell(entry): boolean { + return win32.basename(entry.executable).toLowerCase() === SHELL_BASENAME; + }, + + ancestryPids(exec): number[] { + return windowsAncestryPids(exec); + }, + + requestQuit(exec, _install, root): void { + exec(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", "-NonInteractive", "-Command", + `$p = Get-Process -Id ${root.pid} -ErrorAction SilentlyContinue; if ($p) { [void]$p.CloseMainWindow() }`, + ], POWERSHELL_PROBE_OPTIONS); + }, + + forceStop(exec, root): void { + exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(root.pid), "/T", "/F"], POWERSHELL_PROBE_OPTIONS); + }, + + captureRelaunchContext(): Record { + // The session is supplied by the shell:AppsFolder launch, so nothing needs + // carrying forward. + return {}; + }, + + relaunch(exec, install): void { + // Throws on failure so the ladder reports relaunch_failed. The old code + // returned targets_survived here, which was dishonest: everything HAD died + // and it was the relaunch that failed. + exec(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", "-NonInteractive", "-Command", + `Start-Process 'shell:AppsFolder\\${install.relaunch}'`, + ], POWERSHELL_PROBE_OPTIONS); + }, +}; + +export const windowsDefaultExec: DesktopExec = (file, args, options) => execFileSync(file, [...args], { + encoding: "utf-8", + timeout: options?.timeout ?? PROBE_TIMEOUT_MS, + windowsHide: options?.windowsHide ?? true, +}); diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 473e311ec0..9cbddf45fe 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -895,7 +895,13 @@ export function chooseCatalogPathForInjection( export interface CodexInjectResult { success: boolean; message: string; - /** Structured read-only history preflight refusal; never parsed from display text. */ + /** + * Structured read-only history preflight refusal; never parsed from display text. + * + * On the apply direction this reports that the conversation-history relabel unit stood + * down while the config was still written, so a caller must not read it as a failure. + * The restore and remove directions still refuse outright and say so in `message`. + */ historyPreflightFailureReason?: string; status?: "skipped"; /** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */ @@ -903,6 +909,12 @@ export interface CodexInjectResult { nativeSubagentDefaultsWarning?: string; } +/** + * The one history preflight reason that is permanent rather than operational: Codex owns + * paginated rollout ordinals, so no retry makes the legacy relabel protocol available again. + */ +const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer"; + class CodexHistoryPreflightRefusal extends Error {} class CodexRestoreRefusal extends Error { constructor(readonly config: CodexRestoreConfigResult) { @@ -929,7 +941,7 @@ export async function injectCodexConfig( ): Promise { try { return await injectCodexConfigImpl(port, config, options); } catch (error) { - if (error instanceof CodexHistoryPreflightRefusal) return { success: false, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` }; + if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` }; throw error; } } @@ -1055,7 +1067,10 @@ async function injectCodexConfigImpl( journaledInjectedOpenaiBaseUrl({ readOnly: !!options.beforeClientWrite }), journaledInjectedRealtimeWsBaseUrl({ readOnly: !!options.beforeClientWrite }), ); - if (hasOcxProviderTable(content)) { + // Whether this home already published the provider id that its thread rows may reference. + // Design B strips the table below; it may only stay stripped if those rows can be relabeled. + const hadOcxProviderTableOnDisk = hasOcxProviderTable(content); + if (hadOcxProviderTableOnDisk) { content = removeOcxSection(content); } content = removeProfileSection(content); @@ -1190,16 +1205,56 @@ async function injectCodexConfigImpl( return "history_injection_preflight_unavailable"; } }; - const historyPreflightError = historyPreflight(); - if (historyPreflightError) { + /* + * ONE refusal stands the relabel unit down instead of vetoing the config transition, and + * only because it is permanent. Codex allocates paginated rollout ordinals in its own + * writer, so `assertLegacyHistoryRecord` refuses every rollout on a current install and no + * amount of retrying changes that. While it vetoed the write, `model_catalog_json` never + * reached config.toml, so the app and the CLI both fell back to their built-in model list + * while `ocx sync` still reported success. + * + * Every other reason — an unreadable state database, a rollout whose identity changed, a + * preflight that could not run — describes a store that may well be relabelable on the next + * attempt. Treating those as a stand-down would record the transition as converged and + * suppress the relabel permanently, so they keep the hard refusal and the rollback. + */ + /* + * Re-observed inside the artifact transaction. A store that migrates to paginated history + * mid-write retires the relabel unit, because the config half writes no history and rolling + * it back is what left every paginated home with no OpenCodex models. Any other reason is + * still treated as a failed transition so compensation can restore the pre-images. + */ + const observeHistoryRefusalOrThrow = (known: string | null): string | null => { + if (known) return known; + const observed = historyPreflight(); + if (observed && observed !== HISTORY_RELABEL_STANDS_DOWN) throw new CodexHistoryPreflightRefusal(observed); + return observed; + }; + const observedHistoryRefusal = historyPreflight(); + if (observedHistoryRefusal && observedHistoryRefusal !== HISTORY_RELABEL_STANDS_DOWN) { return { success: false, - historyPreflightFailureReason: historyPreflightError, - message: `Codex config injection refused: ${historyPreflightError}. ` + historyPreflightFailureReason: observedHistoryRefusal, + message: `Codex config injection refused: ${observedHistoryRefusal}. ` + "Existing provider definitions and conversation files were preserved. " + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly.", }; } + let historyRelabelRefusal = observedHistoryRefusal; + + /* + * Rows this home may have tagged `opencodex` resolve only through a provider table. Design B + * normally retires that table because the relabel migrates those rows back to `openai` in + * the same pass; with the relabel stood down, stripping it anyway would leave every such + * conversation pointing at a provider id that no longer exists. Keep what was already + * published, and keep it BEFORE the witness so the lock admits the bytes actually written. + */ + if (historyRelabelRefusal && hadOcxProviderTableOnDisk && !providerTableMode) { + content = applyEol( + content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})), + eol, + ); + } /* * The witness, built from the FINAL bytes. Everything it hashes is either the @@ -1286,14 +1341,14 @@ async function injectCodexConfigImpl( if (options.validateOnly) { return { success: true, + ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), message: "Codex config injection preflight passed; no files were changed.", }; } const applyNativeArtifacts = (): void => { beforeHistoryArtifactCommitForTests?.(eligibility.kind); - const historyError = historyPreflight(); - if (historyError) throw new CodexHistoryPreflightRefusal(historyError); + historyRelabelRefusal = observeHistoryRefusalOrThrow(historyRelabelRefusal); const preImages = captureCodexPreImages(); try { historyArtifactStageForTests?.("after-preflight"); @@ -1329,9 +1384,7 @@ async function injectCodexConfigImpl( }); historyArtifactStageForTests?.("after-artifacts"); // Detect migration throughout the artifact transaction, not just at entry. - // This is compensation, not a native-writer lock or permission to append ordinals. - const finalHistoryError = historyPreflight(); - if (finalHistoryError) throw new CodexHistoryPreflightRefusal(finalHistoryError); + historyRelabelRefusal = observeHistoryRefusalOrThrow(historyRelabelRefusal); } catch (error) { const compensated = restoreCodexPreImages(preImages); if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); @@ -1487,15 +1540,19 @@ async function injectCodexConfigImpl( // serialized one of them and an opposite-direction process could overtake // through the other two. The operation is derived from admitted intent here and // handed down fixed; the Worker never takes a direction from its caller. - const historyOutcome = await runCodexHistoryJob({ - ...resolveCodexHistoryJobTarget(), - expectedDesiredEnabled: true, - operation: deriveCodexHistoryOperation({ - direction: "apply", - resumeHistory: config?.syncResumeHistory !== false && !keepRootOverrideAlongsideTable, - legacyMode: providerTableMode, - }), - }); + // A stood-down relabel unit spawns no Worker: the preflight it would run first has + // already refused, and the config half is committed either way. + const historyOutcome: CodexHistoryJobOutcome = historyRelabelRefusal + ? { kind: "skipped" } + : await runCodexHistoryJob({ + ...resolveCodexHistoryJobTarget(), + expectedDesiredEnabled: true, + operation: deriveCodexHistoryOperation({ + direction: "apply", + resumeHistory: config?.syncResumeHistory !== false && !keepRootOverrideAlongsideTable, + legacyMode: providerTableMode, + }), + }); // A blocked or failed unit is reported, not silently counted as zero work: // `failed` is what makes the caller's message say so. const history: { rows: number; files: number; failed?: true } = @@ -1528,6 +1585,8 @@ async function injectCodexConfigImpl( ? (keptUserBaseUrl ? ` Codex resume history: left unchanged; threads already tagged openai follow your configured root openai_base_url.\n` : ` Codex resume history: left unchanged; existing threads keep reaching the proxy through the retained openai_base_url override.\n`) + : historyRelabelRefusal + ? ` ⚠️ Codex resume history: left to Codex's native writer (${historyRelabelRefusal}); existing threads keep the provider they are tagged with. Routing and the model catalog were still installed, so new threads reach the proxy.\n` : config?.syncResumeHistory === false ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n` : history.failed @@ -1548,6 +1607,7 @@ async function injectCodexConfigImpl( return { success: true, ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), + ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), message: `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n` + ` Your root openai_base_url was left exactly as you set it, so opencodex did not add its own.\n` + @@ -1566,6 +1626,7 @@ async function injectCodexConfigImpl( ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), + ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), message: `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` + catalogMessage + @@ -1585,6 +1646,7 @@ async function injectCodexConfigImpl( return { success: true, ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), + ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), message: headline + catalogMessage + diff --git a/src/codex/sync.ts b/src/codex/sync.ts index f26028b550..d9217cc0a1 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -19,7 +19,7 @@ export interface CodexSyncResult { /** * `skipped` is policy truth, never evidence that Codex was written. * `catalog-only` means an explicit sync refreshed the catalog/cache while - * config/history injection was skipped (OFF, externally owned, or protected history). + * config/history injection was skipped (integration OFF, or externally owned). */ status: "applied" | "skipped" | "catalog-only" | "refused"; ok: boolean; @@ -51,8 +51,9 @@ export interface CodexSyncOptions { * Explicit `ocx sync` is also the refresh path for side profiles that consume * the OpenCodex catalog without injection. When set, the sync still refreshes * the catalog and models cache even if the Codex integration toggle is OFF or - * an external `model_provider` owns config.toml, or paginated history refuses - * injection. Config/history injection is skipped in those cases. + * an external `model_provider` owns config.toml. Config/history injection is + * skipped in those two cases. A paginated-history refusal is NOT one of them: + * the injector writes config and stands only its relabel unit down. */ catalogEvenWhenNotInjected?: boolean; } @@ -209,25 +210,12 @@ export async function syncModelsToCodex( // working catalog/cache into the partial result of an otherwise unnecessary refresh. const preflight = await deps.injectCodexConfig(p, config, { validateOnly: true }); if (!preflight.success) { - // Explicit model refresh does not require legacy history relabeling. Keep the - // injector's refusal intact and publish only through the existing catalog owner. - // Unattended sync and other config/integrity refusals retain their hard failure. - if (catalogEvenWhenNotInjected - && preflight.historyPreflightFailureReason === "history_paginated_requires_native_writer") { - applyProxyEnv(config); - const refreshed = await refreshCatalogForSync(config, deps, undefined, log); - const ok = refreshed.refreshOutcome === "committed" && refreshed.catalogExists; - const message = ok - ? "Model catalog synchronized; Codex config and conversation history left unchanged because paginated history requires its native writer." - : "Model catalog refresh did not complete; Codex config and conversation history were left unchanged."; - reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); - return { - ...refreshed, - status: "catalog-only", - ok, - message, - }; - } + // A paginated-history refusal used to be downgraded here to a `catalog-only` success. + // That is what made the model picker regression silent: the catalog file was rewritten + // and reported synchronized while config.toml kept no provider table and no catalog + // path, so Codex offered only its native models. The injector now writes config and + // stands the relabel unit down instead, so nothing reaches this branch for that reason + // and a surviving refusal is a real config/integrity failure again. log?.error(preflight.message); reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); return { @@ -300,6 +288,12 @@ export async function syncModelsToCodex( } if (result.success) log?.log(result.message); else log?.error(result.message); + // The config was written; only the relabel unit stood down. Carry that as a warning so a + // caller reading the structured result sees it without parsing the display message. + if (result.success && result.historyPreflightFailureReason) { + const historyWarning = `Codex conversation-history relabel left to Codex's native writer: ${result.historyPreflightFailureReason}.`; + warning = warning ? `${warning} ${historyWarning}` : historyWarning; + } reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() }); return { diff --git a/src/lib/codex-restart-contract.ts b/src/lib/codex-restart-contract.ts index 274bec19fe..e0dce042c4 100644 --- a/src/lib/codex-restart-contract.ts +++ b/src/lib/codex-restart-contract.ts @@ -32,6 +32,19 @@ export interface CodexAppServerStateResponse { runningCount: number; } +/** + * The desktop half of a restart. Scalar-only for the same reason as the rest of this + * contract: pid lists and a closed-vocabulary reason, never a command line or an OS + * error message, both of which routinely embed a path or a username. + */ +export interface CodexDesktopRestartSummary { + attempted: boolean; + stopped: number[]; + surviving: number[]; + relaunch: "started" | "skipped"; + reason?: string; +} + /** POST response. All four arrays are pid lists — never command lines. */ export interface CodexRestartResponse { success: boolean; @@ -44,6 +57,12 @@ export interface CodexRestartResponse { surviving: number[]; failed: number[]; code: CodexRestartCode; + /** + * Absent on a proxy older than this change, which is why it is optional rather than + * required: this guard is a version-skew check the GUI runs, and a dashboard talking + * to an older proxy has to keep working. + */ + desktopApp?: CodexDesktopRestartSummary; } const APP_SERVER_STATES: readonly string[] = ["fresh", "stale", "not_running", "unknown"]; @@ -103,6 +122,18 @@ export function isCodexRestartResponse(value: unknown): value is CodexRestartRes if ((code === "nothing_running" || code === "enumeration_unavailable") && stopped.length > 0) { return false; } + if ("desktopApp" in view && view.desktopApp !== undefined) { + const desktop = view.desktopApp; + if (typeof desktop !== "object" || desktop === null) return false; + const d = desktop as Record; + if (typeof d.attempted !== "boolean") return false; + if (!isPidList(d.stopped) || !isPidList(d.surviving)) return false; + if (d.relaunch !== "started" && d.relaunch !== "skipped") return false; + if (d.reason !== undefined && typeof d.reason !== "string") return false; + // A started relaunch cannot have left anything behind: the ladder refuses to + // relaunch beside a survivor precisely so a second shell never appears. + if (d.relaunch === "started" && (d.surviving as number[]).length > 0) return false; + } return true; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e921e9a4e7..e724b93533 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -683,10 +683,9 @@ const DEEPSEEK_V4_LEGACY_MODELS = ["deepseek-v4-flash"]; const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", "deepseek-v4-flash"]; const DEEPSEEK_GATEWAY_THINKING_MODELS = ["deepseek-v4.1-flash", "deepseek-v4-flash"]; /* - * DeepSeek's experimental vision preview (released 2026-08-21, api-docs.deepseek.com): - * text+image input on the V4 Flash base. DeepSeek positions it as a preview id; - * the expectation is that vision merges into `deepseek-v4-flash` proper later, - * at which point this id retires the same way deepseek-chat/reasoner did. + * DeepSeek's legacy vision preview id (released 2026-08-21). First-party probes + * in #4436 resolve it to image-capable `deepseek-flash`; retain the existing + * declarations because gateway support is specific to each served identifier. */ const DEEPSEEK_VISION_PREVIEW_MODEL = "deepseek-v4-flash-vision-exp"; /** @@ -2165,9 +2164,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // the list only as compatibility aliases so existing saved configs and requests // keep validating and routing (they previously mapped to v4-flash; devlog // _fin/260710_provider_hardening/002_research_cn.md). The current offerings are - // the V4 ids — defaultModel and the model-specific wiring above use them. - // deepseek-v4-flash-vision-exp: experimental vision preview (2026-08-21) — - // expected to merge into deepseek-v4-flash later; see DEEPSEEK_VISION_PREVIEW_MODEL. + // V4.1-Flash — defaultModel and the model-specific wiring below use its live id. + // Keep the legacy vision-preview alias; see DEEPSEEK_VISION_PREVIEW_MODEL. models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], // V4.1-Flash is the current first-party offering; `deepseek-v4-flash` now routes there // as a compatibility alias, so a new install should ask for the live id by name. @@ -2175,7 +2173,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576 // for both V4 models; the older 1,000,000 figure was a rounded approximation. modelContextWindows: { "deepseek-flash": 1_048_576, "deepseek-v4-flash": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, - modelInputModalities: { [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"] }, + modelInputModalities: { + "deepseek-flash": ["text", "image"], + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + }, // DeepSeek documents both V4 models as native Responses API models adapted for Codex // (model table marks Responses API ✓ for flash and pro; the /responses reference lists // both ids as accepted `model` values — verified 2026-08-13 with the V4 Pro GA, @@ -2245,10 +2246,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, true])), preserveReasoningContentModels: DEEPSEEK_NATIVE_THINKING_MODELS, - // Issue #88: every DeepSeek API model is text-only input (no image support upstream) — the - // vision sidecar describes attached images for them, and the catalog advertises image input - // on their behalf (same treatment as opencode-go's DeepSeek V4 entries above). - noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS], + // #4436: first-party deepseek-flash accepts native images on Chat and Responses. + // Keep unprobed compatibility aliases on the #88 sidecar path. This must be fixed + // here: router enrichment unions this list with saved config, so config cannot remove it. + noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"], }, // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, diff --git a/src/server/responses.ts b/src/server/responses.ts index 2a446619a2..4187e4ff2e 100644 --- a/src/server/responses.ts +++ b/src/server/responses.ts @@ -5,7 +5,7 @@ import { requestPacingOverloadResponse } from "./responses/pacing-overload"; export { buildToolBridgeMaps, isV1CollabSurface, collabSurface, multiAgentGuidanceText, V2_GUIDANCE_CHAR_BUDGET, injectDeveloperMessage } from "./responses/collaboration"; export type { MultiAgentGuidanceOptions, MultiAgentGuidanceDeps } from "./responses/collaboration"; -export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload"; +export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace, stripAgentMessageCiphertextInPlace } from "./responses/encrypted-payload"; export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse } from "./responses/compact"; export { disableResponsesRequestTimeout, safeHostLabel, fetchWithHeaderTimeout } from "./responses/fetch-helpers"; export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, linkAbortSignal } from "./responses/core"; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 87124ab57f..3fc8917c5d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -158,9 +158,11 @@ import { import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { - createOllamaBridgeExecutor, + createPassthroughWebSearchBridgeExecutor, createPassthroughWebSearchBridgeStream, planPassthroughWebSearchBridge, + resolvePassthroughWebSearchBridgeAuth, + shouldResolveOpenAiPassthroughWebSearchBridge, } from "../../web-search/passthrough-bridge"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; @@ -394,7 +396,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; -import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload"; +import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace, stripAgentMessageCiphertextInPlace } from "./encrypted-payload"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, type ProviderFetchOptions } from "./fetch-helpers"; import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability"; import { @@ -4024,6 +4026,49 @@ async function handleResponsesInner( return unreadableEncryptedAgentTaskResponse(recoveryFailureReason); } + // The guard above asks whether the CURRENT worker task is readable, and it only inspects the + // tail item. An `agent_message` that mixes readable text with backend ciphertext answers + // "readable" to that question at every position, so it passed -- and then + // `normalizeRoutedAgentMessages` refused to lower it, because lowering requires every part to + // be representable. The raw Responses passthrough serialized the private item as it stood, so + // backend ciphertext and an item type only the Codex backend declares reached a third-party + // provider, which answered `422 unknown item type "agent_message"` (#4454). + // + // The opaque-blob path already knows the repair: replace the undecryptable part with an + // omission marker, which leaves the item lowerable. It applied that repair only AFTER an + // upstream rejection. For a destination that cannot accept the private item under any + // circumstances, that round trip was never going to succeed and sent the ciphertext to find + // out, so do the repair here instead. Recovery above has already had its chance to turn the + // same bytes into real plaintext; only what it could not rescue reaches this. + if (inboundWire === "responses" && !finalRouteCanPassThroughEncryptedTask) { + // Only the raw Responses passthrough puts input items on the wire verbatim, so that is the + // only wire this has to repair: translated wires rebuild the body from parsed messages, where + // `inputContentParts` drops an encrypted part instead of forwarding it. The exemption is the + // canonical Codex backend alone, because it is the one destination that minted these bytes and + // can read them. `authMode: "forward"` is NOT that test -- a noncanonical forward gateway is + // somebody else's server that happens to be configured for passthrough, and it receives the + // ciphertext like any other third party. + // + // Combo children run this too. Each child carries its own `structuredClone` of the body + // (`concreteComboRequestBody`) and its own concrete route, so a sibling's repair is invisible + // here and a target that resolves to a routed Responses wire would otherwise send the + // ciphertext that the parent's own dispatch no longer does. + const wireProvider = resolveWireProtocolOverride( + route.providerName, + route.modelId, + route.provider, + inboundWire, + ); + if (wireProvider.adapter === "openai-responses" && !isCanonicalOpenAiForwardProvider(wireProvider)) { + const repaired = stripAgentMessageCiphertextInPlace((body as { input?: unknown } | undefined)?.input); + if (repaired > 0) { + console.warn( + `[opencodex] replaced ciphertext in ${repaired} replayed agent message(s) with an omission marker; the selected provider cannot read native ChatGPT ciphertext`, + ); + } + } + } + // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream // I/O instead of stripping the id and silently forwarding a context-free delta (#702). @@ -4726,7 +4771,8 @@ async function handleResponsesInner( const needsOpenAiVision = !visionDescribeTerminal && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed); const needsOpenAiSearch = !routedCompaction && !adapter.runTurn - && shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough); + && (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough) + || shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough)); if (needsOpenAiVision || needsOpenAiSearch) { try { const candidates = listOpenAiForwardSidecarCandidates(config); @@ -6199,9 +6245,15 @@ async function handleResponsesInner( // conversation upstream, and hands back ordinary Responses SSE — so every rewrite below, // including the guard itself, still inspects the client-facing stream. Default OFF: without // the opt-in this is one planner call and the relay is byte-identical to before. + const webSearchBridgeAuth = resolvePassthroughWebSearchBridgeAuth( + route.provider.webSearchBridge?.backend, + config, + openAiSidecar, + ); const webSearchBridgePlan = planPassthroughWebSearchBridge(parsed, route.provider, { isPassthrough: true, stream: parsed.stream === true, + auth: webSearchBridgeAuth, }); // Capture the binding that actually served the first leg, after its permitted reselection. const webSearchBridgeBinding = requestBindings.get(request); @@ -6235,7 +6287,13 @@ async function handleResponsesInner( }), false, ), - execute: createOllamaBridgeExecutor(webSearchBridgePlan, route.provider.apiKey ?? ""), + execute: createPassthroughWebSearchBridgeExecutor(webSearchBridgePlan, { + providerApiKey: route.provider.apiKey ?? "", + auth: webSearchBridgeAuth, + hostedTool: parsed._webSearch, + describeImages: isModelTextOnly(route.provider, route.modelId), + sidecar: config.webSearchSidecar, + }), // Appending a search result can push the continuation past the ceiling the first leg // was admitted under, so the same limit is re-applied before every later send. checkOutboundBody: (continuationBody: string) => { diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 82d4b0514e..066bb9b522 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -319,6 +319,167 @@ export function hasEncryptedContentPart(content: unknown): boolean { } +/** The marker `prepareOpaqueBlobRecovery` already substitutes for an undecryptable part. */ +export const OMITTED_ENCRYPTED_CONTENT_TEXT = "[encrypted content omitted]"; + +/** + * The Fernet WIRE shape, without validating the body: version prefix, base64url alphabet, and a + * canonical encoded length. Free text is judged by this rather than by + * `looksLikeBackendCiphertext`, which is length >= 64 over a character class that a SHA-256 hex + * digest matches exactly at 64 characters -- as do a SHA-512 digest, a long key, and adjacent + * short encoded fragments. An `encrypted_content` slot carries ciphertext by definition and is + * stripped whatever it holds; a text part does not, and replacing a digest a child deliberately + * printed would destroy readable content to protect bytes that were never secret. + */ +const FERNET_SHAPED = /^g[A-Za-z0-9_-]+={0,2}$/; + +function looksLikeFernetToken(text: string): boolean { + return text.length >= 100 && text.length % 4 === 0 && FERNET_SHAPED.test(text); +} + +function textWithRunsOmitted(payload: string, runs: readonly FernetTokenRun[]): string { + let last = 0; + let out = ""; + for (const run of runs) { + out += payload.slice(last, run.index) + OMITTED_ENCRYPTED_CONTENT_TEXT; + last = run.index + run.token.length; + } + return out + payload.slice(last); +} + +/** + * Replace ciphertext inside `agent_message` items with an omission marker, so + * `normalizeRoutedAgentMessages` can lower them onto public messages. Returns how many items + * were repaired. Items are replaced rather than mutated, and every other item type is left + * alone: reasoning and function-output blobs keep their own reactive recovery. + * + * `hasUnreadableEncryptedAgentTask` above answers a different question: can the CURRENT worker + * task be read at all? It inspects only the tail item and reports false the moment any plaintext + * survives the envelope. `normalizeRoutedAgentMessages` asks the opposite question -- is EVERY + * part lowerable? -- and forwards the private item verbatim when one is not. A mixed + * `input_text` + `encrypted_content` item answers "readable" to the first and "not lowerable" + * to the second, so it fell between them: the guard never fired, the adapter refused to lower it, + * and the raw Responses passthrough put a private item and backend ciphertext on the wire + * (#4454). Position was never the discriminator -- a replayed child result lands mid-history and + * the tail-only scan cannot see it -- but the tail is equally exposed when it is mixed. + * + * This is the repair `prepareOpaqueBlobRecovery` performs after an upstream rejection, applied + * before dispatch for a destination that cannot accept the private item under any circumstances. + * The round trip it replaces was never going to succeed, and it sent ciphertext to a third party + * to find that out. Nothing is decrypted, and nothing readable is lost: the parent could not read + * these bytes either. + * + * The two kinds of slot are judged differently, because they carry different guarantees. An + * `encrypted_content` slot holds ciphertext by definition, so it is stripped whatever it holds: + * demanding a well-formed token there would reopen this defect one payload later, since a + * truncated token, a standard-base64 blob carrying `+` or `/`, an unexpected version byte, or a + * run past the recovery size limits would each keep the item and forward the bytes. + * + * A text part carries no such guarantee, so it is matched strictly: embedded runs that validate + * as Fernet, or a whole slot with the Fernet wire shape. A loose character-class test would be + * worse than the defect for that half -- a SHA-256 digest is exactly 64 characters of + * `[A-Za-z0-9]` and would be replaced with a marker, silently deleting something a child + * deliberately printed. + */ +export function stripAgentMessageCiphertextInPlace(input: unknown): number { + if (!Array.isArray(input)) return 0; + let repaired = 0; + for (let index = 0; index < input.length; index += 1) { + const item = input[index]; + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + if (record.type !== "agent_message") continue; + const content = record.content; + if (typeof content === "string") { + const replaced = textWithoutCiphertext(content); + if (replaced === content) continue; + input[index] = { ...record, content: replaced }; + repaired += 1; + continue; + } + if (!Array.isArray(content)) continue; + const parts = contentWithoutCiphertext(content); + if (parts === content) continue; + input[index] = { ...record, content: parts }; + repaired += 1; + } + return repaired; +} + +/** Free text: drop embedded token runs, and replace a slot that is nothing but a token. */ +function textWithoutCiphertext(text: string): string { + const runs = fernetTokenRuns(text); + if (runs.length > 0) return textWithRunsOmitted(text, runs); + return looksLikeFernetToken(text.trim()) ? OMITTED_ENCRYPTED_CONTENT_TEXT : text; +} + +function ciphertextTextOfPart(part: unknown): string | undefined { + if (!part || typeof part !== "object") return undefined; + const record = part as { type?: unknown; text?: unknown }; + return (record.type === "input_text" || record.type === "text") && typeof record.text === "string" + ? record.text + : undefined; +} + +/** + * Adjacent text slots that are one token between them. Each fragment can be too short to judge on + * its own, which is the text-side twin of the split `encrypted_content` run. The join must still + * be Fernet-shaped, so two ordinary encoded fragments do not become a marker by being adjacent. + */ +function joinedCiphertextTextParts(content: readonly unknown[]): Set { + const flagged = new Set(); + let run: Array<{ part: object; text: string }> = []; + const finish = (): void => { + if (run.length > 1 && looksLikeFernetToken(run.map(entry => entry.text).join(""))) { + for (const entry of run) flagged.add(entry.part); + } + run = []; + }; + for (const part of content) { + const text = ciphertextTextOfPart(part); + if (text === undefined || text.trim().length === 0 || !/^[A-Za-z0-9_-]+={0,2}$/.test(text)) { + finish(); + continue; + } + run.push({ part: part as object, text }); + } + finish(); + return flagged; +} + +function contentWithoutCiphertext(content: unknown[]): unknown[] { + let changed = false; + const joined = joinedCiphertextTextParts(content); + const parts = content.map((part: unknown) => { + if (!part || typeof part !== "object") return part; + if (joined.has(part)) { + changed = true; + return { type: "input_text", text: OMITTED_ENCRYPTED_CONTENT_TEXT }; + } + const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown }; + if (record.type === "encrypted_content" && typeof record.encrypted_content === "string") { + changed = true; + // Keep whatever plaintext a recognizable slot carries around its token; a slot this + // cannot parse is replaced whole rather than forwarded on the chance that it is benign. + const runs = fernetTokenRuns(record.encrypted_content); + return { + type: "input_text", + text: runs.length > 0 + ? textWithRunsOmitted(record.encrypted_content, runs) + : OMITTED_ENCRYPTED_CONTENT_TEXT, + }; + } + const text = ciphertextTextOfPart(part); + if (text === undefined) return part; + const replaced = textWithoutCiphertext(text); + if (replaced === text) return part; + changed = true; + return { ...record, text: replaced }; + }); + return changed ? parts : content; +} + + export function sanitizeEncryptedContentInPlace(input: unknown): number { if (!Array.isArray(input)) return 0; diff --git a/src/types/provider.ts b/src/types/provider.ts index eb2858fce7..49c9132b0a 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -87,11 +87,11 @@ export interface RateLimitRetryPolicy { } /** - * Backend ids admitted by `providers..webSearchBridge.backend`. Only `"ollama"` has a - * shipped executor; every other id is explicit-only and inert, the same contract the top-level - * `webSearchSidecar` uses for backends whose executor has not landed. Naming one of them keeps - * the bridge disarmed rather than silently falling back to a different search provider — in - * particular it never auto-selects a paid Luna or Exa search. + * Backend ids admitted by `providers..webSearchBridge.backend`. Each id is explicit-only: + * an omitted backend keeps the bridge disarmed rather than silently falling back to a paid + * Luna or Exa search. `ollama` spends this provider's API key on the search endpoint. + * `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and + * that executor's own credential; a missing credential leaves the bridge disarmed. */ export const PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS = [ "ollama", @@ -118,6 +118,7 @@ export type ProviderWebSearchBridgeBackend = typeof PROVIDER_WEB_SEARCH_BRIDGE_B * * Never armed for `authMode: "forward"` (ChatGPT) or for a provider that executes hosted search * upstream; see `planPassthroughWebSearchBridge` in `src/web-search/passthrough-bridge.ts`. + * A mixed `web_search` + client tool call still fails closed. Assistant text is not a search call. */ export interface ProviderWebSearchBridgeConfig { /** Master switch. Absent or false keeps today's relay-and-fail behavior exactly. */ diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 8b73b95645..e700e48a15 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -7,11 +7,17 @@ import { isCodexReserveRequestEligible } from "../codex/loopback-target"; import type { DataPlaneAdmission } from "../server/auth-cors"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import { resolveSidecarAuth } from "../sidecar/auth"; -import { getAccountSet } from "../oauth/store"; import { validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; import type { OcxWebSearchSidecarConfig } from "../types"; import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout"; import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; +import { + findAnthropicSidecarProvider, + findGeminiSidecarProvider, + findXaiSidecarProvider, + xaiSearchOptionsFromConfig, + type AnthropicSidecarProvider, +} from "./sidecar-providers"; export { runWithWebSearch } from "./loop"; export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME }; @@ -19,6 +25,13 @@ export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-exe export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor"; export { runExaWebSearch, mapExaSearchResponse } from "./exa-executor"; +export { + findAnthropicSidecarProvider, + findGeminiSidecarProvider, + findXaiSidecarProvider, + xaiSearchOptionsFromConfig, + type AnthropicSidecarProvider, +}; const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset). @@ -85,72 +98,6 @@ export function webSearchStallTimeoutSec( return Math.min(Number.MAX_VALUE, Math.ceil(largestUnitSec) + STALL_MARGIN_SEC); } -/** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */ -export interface AnthropicSidecarProvider { - providerName: string; - provider: OcxProviderConfig; -} - -/** - * First enabled anthropic-adapter OAuth provider whose ACTIVE account holds a usable credential — the - * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by - * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a - * terminally-invalid account); token refresh happens later at executor time. - * Delegates to the shared sidecar auth module (#2188) so web-search and vision - * cannot drift on what "Anthropic auth present" means. - */ -export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined { - const auth = resolveSidecarAuth(config); - if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined; - return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; -} - -/** - * First enabled provider whose stored Grok OAuth account is active and not marked for - * reauth — the only credential the xai web-search executor may spend. Same account-set - * predicate the shared sidecar auth module applies to Anthropic. - */ -export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { - // The stored Grok credential lives under the provider named "xai" (registry id); - // OAuth account sets are keyed by provider name, so the name IS the credential key. - const provider = config.providers["xai"]; - if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; - const set = getAccountSet("xai"); - const active = set?.accounts.find(account => account.id === set.activeAccountId); - if (active && active.needsReauth !== true) return { providerName: "xai", provider }; - return undefined; -} - -/** - * First usable Antigravity credential holder: the "google-antigravity" provider - * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose - * active stored account is healthy AND carries a discovered CCA projectId — the - * executor cannot form the envelope without it. - */ -export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { - const provider = config.providers["google-antigravity"]; - if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; - const set = getAccountSet("google-antigravity"); - const active = set?.accounts.find(account => account.id === set.activeAccountId); - if (!active || active.needsReauth === true) return undefined; - const projectId = (active.credential as { projectId?: string } | undefined)?.projectId; - if (!projectId) return undefined; - return { providerName: "google-antigravity", provider }; -} - -/** Lift the persisted xSearch config block into executor options (absent block = web_search only). */ -export function xaiSearchOptionsFromConfig(cfg: Pick): XaiSearchOptions { - const x = cfg.xSearch; - if (!x || x.enabled !== true) return {}; - return { - xSearch: true, - ...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}), - ...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}), - ...(x.fromDate ? { fromDate: x.fromDate } : {}), - ...(x.toDate ? { toDate: x.toDate } : {}), - }; -} - /** Every backend id the config union admits. New ids are explicit-only and inert until their executor ships. */ export type WebSearchBackendId = "openai" | "anthropic" | "xai" | "gemini" | "exa"; diff --git a/src/web-search/passthrough-bridge.ts b/src/web-search/passthrough-bridge.ts index 6212e25c82..37fff58767 100644 --- a/src/web-search/passthrough-bridge.ts +++ b/src/web-search/passthrough-bridge.ts @@ -22,11 +22,17 @@ * explicit error. Answering both would need the raw mixed-tool continuation contract the * 2.47 track deferred (devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md), * and silently half-doing it would drop the client's own tool call. + * - Assistant text is never treated as a search instruction. The bridge intercepts structured + * function_call / custom_tool_call items named web_search, not XML-like prose. + * - Non-Ollama backends reuse the sidecar executors and those executors' own credentials. + * The passthrough provider's API key is sent only to an ollama search endpoint the operator + * authorized. A backend whose credential is missing stays disarmed rather than falling + * through to a different paid search. * - Continuation legs use a direct send rather than the core recovery ladder: the first leg * still goes through it, and a KEY-auth destination has no OAuth refresh path to replay. - * The caller's outbound body ceiling is re-applied to every continuation body. - * - The client stream is renumbered (sequence_number and output_index) because events are both - * dropped and injected; a plain relay cannot preserve upstream numbering through that. +* The caller's outbound body ceiling is re-applied to every continuation body. +* - The client stream is renumbered (sequence_number and output_index) because events are both +* dropped and injected; a plain relay cannot preserve upstream numbering through that. * * The stream this module produces is ordinary Responses SSE and is handed back to the core relay, * so the undeclared-tool guard, the provider payload rewrites, terminal-outcome recording, and the @@ -35,11 +41,28 @@ */ import { nextSseBlock, sseDataPayload } from "../server/sse-payload-rewrite"; import { toolChoiceToolPredicate } from "../types"; -import type { OcxParsedRequest, OcxProviderConfig, ProviderWebSearchBridgeBackend } from "../types"; -import type { SidecarOutcome } from "./executor"; +import type { + OcxConfig, + OcxParsedRequest, + OcxProviderConfig, + OcxWebSearchSidecarConfig, + ProviderWebSearchBridgeBackend, +} from "../types"; +import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; +import { runWebSearch, type SidecarOutcome, type SidecarSettings } from "./executor"; import { buildWebSearchTool, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool"; import { safeWebSearchSources } from "./sources"; import { runOllamaWebSearch } from "./ollama-executor"; +import { runAnthropicWebSearch } from "./anthropic-executor"; +import { runXaiWebSearch, validateXaiSearchOptions } from "./xai-executor"; +import { runGeminiWebSearch } from "./gemini-executor"; +import { runExaWebSearch } from "./exa-executor"; +import { + findAnthropicSidecarProvider, + findGeminiSidecarProvider, + findXaiSidecarProvider, + xaiSearchOptionsFromConfig, +} from "./sidecar-providers"; /** Canonical Ollama Cloud origin. The only origin the "ollama" backend derives on its own. */ export const OLLAMA_CLOUD_ORIGIN = "https://ollama.com"; @@ -67,10 +90,10 @@ const CLIENT_EXECUTED_ITEM_TYPES = new Set([ ]); export interface PassthroughWebSearchBridgePlan { - /** Resolved executor id. Only "ollama" has a shipped executor today. */ + /** Resolved executor id. Absent credential for that backend leaves the bridge disarmed. */ backend: ProviderWebSearchBridgeBackend; - /** Absolute search-API URL the executor posts to. */ - endpoint: string; + /** Absolute search-API URL for the ollama backend. Other backends ignore this. */ + endpoint?: string; /** Searches actually executed per turn before further calls are refused. */ maxSearches: number; /** Per-search deadline in milliseconds. */ @@ -112,6 +135,66 @@ export function resolveOllamaWebSearchEndpoint( : undefined; } +/** Credentials that may run a non-Ollama passthrough-bridge search. The key never rides the plan. */ +export interface PassthroughWebSearchBridgeAuth { + openAiSidecar?: ResolvedOpenAiForwardSidecar; + anthropic?: { providerName: string; provider: OcxProviderConfig }; + xai?: { providerName: string; provider: OcxProviderConfig }; + gemini?: { providerName: string; provider: OcxProviderConfig }; + exaApiKey?: string; +} + +/** + * Resolve the credential handle for one explicit bridge backend. Only that backend is inspected, + * so naming `exa` cannot spend a ChatGPT or Grok login, and naming `openai` cannot spend Exa. + */ +export function resolvePassthroughWebSearchBridgeAuth( + backend: ProviderWebSearchBridgeBackend | undefined, + config: OcxConfig, + openAiSidecar?: ResolvedOpenAiForwardSidecar, +): PassthroughWebSearchBridgeAuth { + switch (backend) { + case "openai": + return openAiSidecar ? { openAiSidecar } : {}; + case "anthropic": { + const anthropic = findAnthropicSidecarProvider(config); + return anthropic ? { anthropic } : {}; + } + case "xai": { + const xai = findXaiSidecarProvider(config); + if (!xai) return {}; + if (validateXaiSearchOptions(xaiSearchOptionsFromConfig(config.webSearchSidecar ?? {}))) { + return {}; + } + return { xai }; + } + case "gemini": { + const gemini = findGeminiSidecarProvider(config); + return gemini ? { gemini } : {}; + } + case "exa": { + const exaApiKey = config.webSearchSidecar?.exaApiKey; + return typeof exaApiKey === "string" && exaApiKey.length > 0 ? { exaApiKey } : {}; + } + default: + return {}; + } +} + +/** True when this passthrough turn may need the ChatGPT sidecar for an openai-backed bridge. */ +export function shouldResolveOpenAiPassthroughWebSearchBridge( + provider: OcxProviderConfig, + parsed: OcxParsedRequest, + isPassthrough: boolean, +): boolean { + if (!isPassthrough || parsed.stream !== true || !parsed._webSearch) return false; + if (provider.authMode !== "key") return false; + if (provider.webSearchBridge?.enabled !== true || provider.webSearchBridge.backend !== "openai") { + return false; + } + return toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool()); +} + /** * Decide whether this passthrough turn may run the web-search bridge. * @@ -126,7 +209,11 @@ export function resolveOllamaWebSearchEndpoint( export function planPassthroughWebSearchBridge( parsed: OcxParsedRequest, provider: OcxProviderConfig, - options: { isPassthrough: boolean; stream: boolean }, + options: { + isPassthrough: boolean; + stream: boolean; + auth?: PassthroughWebSearchBridgeAuth; + }, ): PassthroughWebSearchBridgePlan | undefined { if (!options.isPassthrough || !options.stream) return undefined; if (!parsed._webSearch) return undefined; @@ -137,10 +224,9 @@ export function planPassthroughWebSearchBridge( if (!bridge || bridge.enabled !== true) return undefined; // A tool_choice that excludes web search excludes the bridge too; the model may not search. if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined; - // Explicit-only, and inert for every backend whose executor has not shipped. - if (bridge.backend !== "ollama") return undefined; - const endpoint = resolveOllamaWebSearchEndpoint(provider); - if (!endpoint) return undefined; + // Explicit-only: an omitted backend never defaults to a paid sidecar search. + const backend = bridge.backend; + if (!backend) return undefined; const maxSearches = Number.isInteger(bridge.maxSearches) && bridge.maxSearches! >= 1 && bridge.maxSearches! <= 10 @@ -151,7 +237,18 @@ export function planPassthroughWebSearchBridge( && bridge.timeoutMs! <= 600_000 ? bridge.timeoutMs! : DEFAULT_BRIDGE_TIMEOUT_MS; - return { backend: "ollama", endpoint, maxSearches, timeoutMs }; + if (backend === "ollama") { + const endpoint = resolveOllamaWebSearchEndpoint(provider); + if (!endpoint) return undefined; + return { backend, endpoint, maxSearches, timeoutMs }; + } + const auth = options.auth; + if (backend === "openai" && auth?.openAiSidecar) return { backend, maxSearches, timeoutMs }; + if (backend === "anthropic" && auth?.anthropic) return { backend, maxSearches, timeoutMs }; + if (backend === "xai" && auth?.xai) return { backend, maxSearches, timeoutMs }; + if (backend === "gemini" && auth?.gemini) return { backend, maxSearches, timeoutMs }; + if (backend === "exa" && auth?.exaApiKey) return { backend, maxSearches, timeoutMs }; + return undefined; } /** One intercepted search call, carried from the upstream stream into the next request body. */ @@ -571,27 +668,154 @@ export function createOllamaBridgeExecutor( plan: PassthroughWebSearchBridgePlan, apiKey: string, ): PassthroughWebSearchBridgeExecutor { - return async (queries, signal) => { - const texts: string[] = []; - const sources: SidecarOutcome["sources"] = []; - const errors: string[] = []; - for (const query of queries) { - if (signal?.aborted) break; - const outcome = await runOllamaWebSearch(query, apiKey, plan.endpoint, plan.timeoutMs, signal); - if (outcome.error) { - errors.push(outcome.error); - continue; + return createPassthroughWebSearchBridgeExecutor(plan, { providerApiKey: apiKey }); +} + +/** Per-search credentials and sidecar settings. Secrets stay off the plan object. */ +export interface PassthroughWebSearchBridgeExecutorContext { + providerApiKey?: string; + auth?: PassthroughWebSearchBridgeAuth; + hostedTool?: Record; + describeImages?: boolean; + sidecar?: Pick; +} + +const DEFAULT_OPENAI_BRIDGE_MODEL = "gpt-5.6-luna"; +const DEFAULT_ANTHROPIC_BRIDGE_MODEL = "claude-sonnet-5"; +const DEFAULT_XAI_BRIDGE_MODEL = "grok-4.6"; +const DEFAULT_GEMINI_BRIDGE_MODEL = "gemini-3.8-flash"; +const DEFAULT_BRIDGE_REASONING = "low"; + +function sidecarSettingsForBridge( + backend: ProviderWebSearchBridgeBackend, + plan: PassthroughWebSearchBridgePlan, + context: PassthroughWebSearchBridgeExecutorContext, +): SidecarSettings { + const sidecar = context.sidecar ?? {}; + const model = backend === "anthropic" ? sidecar.model ?? DEFAULT_ANTHROPIC_BRIDGE_MODEL + : backend === "xai" ? sidecar.model ?? DEFAULT_XAI_BRIDGE_MODEL + : backend === "gemini" ? sidecar.model ?? DEFAULT_GEMINI_BRIDGE_MODEL + : sidecar.model ?? DEFAULT_OPENAI_BRIDGE_MODEL; + return { + model, + reasoning: sidecar.reasoning ?? DEFAULT_BRIDGE_REASONING, + timeoutMs: plan.timeoutMs, + describeImages: context.describeImages === true, + }; +} + +async function executeBridgeQueries( + queries: string[], + runOne: (query: string, signal?: AbortSignal) => Promise, + signal?: AbortSignal, +): Promise { + const texts: string[] = []; + const sources: SidecarOutcome["sources"] = []; + const errors: string[] = []; + for (const query of queries) { + if (signal?.aborted) break; + const outcome = await runOne(query, signal); + if (outcome.error) { + errors.push(outcome.error); + continue; + } + texts.push(queries.length > 1 ? "Results for \"" + query + "\":\n" + outcome.text : outcome.text); + for (const source of outcome.sources) { + if (!sources.some(existing => existing.url === source.url)) sources.push(source); + } + } + if (texts.length === 0) { + return { text: "", sources: [], error: errors[0] ?? "web search produced no results" }; + } + return { text: texts.join("\n\n"), sources }; +} + +/** + * Bind the executor for a planned backend. Ollama spends this provider's API key on the planned + * endpoint; every other backend spends the sidecar credential that armed the plan. + */ +export function createPassthroughWebSearchBridgeExecutor( + plan: PassthroughWebSearchBridgePlan, + context: PassthroughWebSearchBridgeExecutorContext, +): PassthroughWebSearchBridgeExecutor { + const settings = sidecarSettingsForBridge(plan.backend, plan, context); + return (queries, signal) => executeBridgeQueries(queries, async (query, querySignal) => { + switch (plan.backend) { + case "ollama": + if (!plan.endpoint) { + return { text: "", sources: [], error: "ollama web-search backend selected without an endpoint" }; + } + return runOllamaWebSearch( + query, + context.providerApiKey ?? "", + plan.endpoint, + plan.timeoutMs, + querySignal, + ); + case "openai": { + const sidecar = context.auth?.openAiSidecar; + if (!sidecar) { + return { text: "", sources: [], error: "openai web-search bridge selected without a ChatGPT sidecar" }; + } + return runWebSearch( + query, + context.hostedTool ?? { type: "web_search" }, + sidecar.provider, + sidecar.headers, + settings, + querySignal, + sidecar.recordOutcome, + ); } - texts.push(queries.length > 1 ? "Results for \"" + query + "\":\n" + outcome.text : outcome.text); - for (const source of outcome.sources) { - if (!sources.some(existing => existing.url === source.url)) sources.push(source); + case "anthropic": { + const anthropic = context.auth?.anthropic; + if (!anthropic) { + return { text: "", sources: [], error: "anthropic web-search bridge selected without stored Anthropic OAuth" }; + } + return runAnthropicWebSearch( + query, + anthropic.providerName, + anthropic.provider, + settings, + querySignal, + ); + } + case "xai": { + const xai = context.auth?.xai; + if (!xai) { + return { text: "", sources: [], error: "xai web-search bridge selected without stored Grok OAuth" }; + } + return runXaiWebSearch( + query, + xai.providerName, + xai.provider, + settings, + xaiSearchOptionsFromConfig(context.sidecar ?? {}), + querySignal, + ); + } + case "gemini": { + const gemini = context.auth?.gemini; + if (!gemini) { + return { text: "", sources: [], error: "gemini web-search bridge selected without stored Antigravity OAuth" }; + } + return runGeminiWebSearch( + query, + gemini.providerName, + gemini.provider, + settings, + querySignal, + ); + } + case "exa": { + const exaApiKey = context.auth?.exaApiKey; + if (!exaApiKey) { + return { text: "", sources: [], error: "exa web-search bridge selected without an exaApiKey" }; + } + return runExaWebSearch(query, exaApiKey, settings, querySignal); } } - if (texts.length === 0) { - return { text: "", sources: [], error: errors[0] ?? "web search produced no results" }; - } - return { text: texts.join("\n\n"), sources }; - }; + }, signal); } /** diff --git a/src/web-search/sidecar-providers.ts b/src/web-search/sidecar-providers.ts new file mode 100644 index 0000000000..9a718f6163 --- /dev/null +++ b/src/web-search/sidecar-providers.ts @@ -0,0 +1,76 @@ +/** + * Sidecar credential locators shared by the web-search loop and the key-auth + * passthrough bridge. Kept out of `index.ts` so the bridge can resolve a backend + * without importing the barrel (a cycle: core loads both, and the barrel is still + * evaluating when the bridge asks for these names). + */ +import type { OcxConfig, OcxProviderConfig, OcxWebSearchSidecarConfig } from "../types"; +import { resolveSidecarAuth } from "../sidecar/auth"; +import { getAccountSet } from "../oauth/store"; +import type { XaiSearchOptions } from "./xai-executor"; + +/** A configured anthropic-adapter OAuth provider whose ACTIVE stored account is usable (not needs-reauth). */ +export interface AnthropicSidecarProvider { + providerName: string; + provider: OcxProviderConfig; +} + +/** + * First enabled anthropic-adapter OAuth provider whose ACTIVE account holds a usable credential — the + * only path that can run web_search_20250305 without a ChatGPT forward provider. Presence is decided by + * getAccountSet + the active account's `needsReauth` marker (audit F1: getCredential alone can pick a + * terminally-invalid account); token refresh happens later at executor time. + * Delegates to the shared sidecar auth module (#2188) so web-search and vision + * cannot drift on what "Anthropic auth present" means. + */ +export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSidecarProvider | undefined { + const auth = resolveSidecarAuth(config); + if (!auth.isAnthropicAuth || !auth.anthropicProviderName || !auth.anthropicProvider) return undefined; + return { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }; +} + +/** + * First enabled provider whose stored Grok OAuth account is active and not marked for + * reauth — the only credential the xai web-search executor may spend. Same account-set + * predicate the shared sidecar auth module applies to Anthropic. + */ +export function findXaiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { + // The stored Grok credential lives under the provider named "xai" (registry id); + // OAuth account sets are keyed by provider name, so the name IS the credential key. + const provider = config.providers["xai"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; + const set = getAccountSet("xai"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (active && active.needsReauth !== true) return { providerName: "xai", provider }; + return undefined; +} + +/** + * First usable Antigravity credential holder: the "google-antigravity" provider + * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose + * active stored account is healthy AND carries a discovered CCA projectId — the + * executor cannot form the envelope without it. + */ +export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { + const provider = config.providers["google-antigravity"]; + if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; + const set = getAccountSet("google-antigravity"); + const active = set?.accounts.find(account => account.id === set.activeAccountId); + if (!active || active.needsReauth === true) return undefined; + const projectId = (active.credential as { projectId?: string } | undefined)?.projectId; + if (!projectId) return undefined; + return { providerName: "google-antigravity", provider }; +} + +/** Lift the persisted xSearch config block into executor options (absent block = web_search only). */ +export function xaiSearchOptionsFromConfig(cfg: Pick): XaiSearchOptions { + const x = cfg.xSearch; + if (!x || x.enabled !== true) return {}; + return { + xSearch: true, + ...(x.allowedXHandles ? { allowedXHandles: x.allowedXHandles } : {}), + ...(x.excludedXHandles ? { excludedXHandles: x.excludedXHandles } : {}), + ...(x.fromDate ? { fromDate: x.fromDate } : {}), + ...(x.toDate ? { toDate: x.toDate } : {}), + }; +} diff --git a/structure/catalog.md b/structure/catalog.md index 153b52f111..8cce6268b0 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -45,13 +45,16 @@ custom catalog remains the native metadata/template authority even when a bundle warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. -Custom Astra and Daybreak rows acquire native reasoning capability only through the existing -canonical `openai` forward destination and explicit capability-source predicate. The shared -custom-row producer bounds their merged effort lists against pinned per-model Codex metadata, -preserves an explicit empty list without a default, and recovers an incompatible nonempty list -to the native default singleton. A default must belong to the projected list. Other custom rows -keep their declaration precedence; a GPT model name, display alias, or arbitrary gateway is not -native provenance. Stored configuration and native capability maps are unchanged. +Custom Astra and Daybreak rows acquire native identity -- Responses Lite, multi-agent, context +windows, display names -- only through the canonical `openai` forward destination and explicit +capability-source predicate. Catalog-advertised reasoning lists are a narrower bound: when a +custom row's model id has pinned native capability metadata, the shared producer intersects an +explicit declared ladder with that pinned list even on an arbitrary gateway such as +`YYLJ/gpt-6-astra`. Desktop validates the model id, so `none` and `minimal` must not survive on +those catalog rows. An explicit empty list remains empty; a nonempty incompatible list falls back +to the native default singleton. A default must belong to the projected list. Full native identity +is still not inferred from a GPT name. Stored configuration and native capability maps are +unchanged. Request-time native effort clamps remain canonical-forward only. The observed-state merge tracks the current invocation's freshly generated custom row objects after detaching its inputs. Those rows already own their complete reasoning projection, so the @@ -60,9 +63,7 @@ ordinary retained provider rows still receive the existing mock-tier policy. A p marker alone never grants this exemption. Both gather entry points, retained sync, management convergence and direct Codex model discovery use the same producer. The legacy runtime effort union clamp remains separate; it is not a per-model or per-client-version grammar oracle. -Existing thread settings and the reported Desktop 0.153.4 gateway rejection require separate -runtime evidence. Codex's native `ultra` mode is preserved and is not a literal API wire promise. - +Codex's native `ultra` mode is preserved and is not a literal API wire promise. When account selectors are enabled, the sync path may also observe exact, visible, API-supported OpenAI-family ids from Codex's user-owned catalog/cache. Only rows with native catalog provenance are trusted; unknown ids are carried through startup cache invalidation as hidden observations and diff --git a/structure/config.md b/structure/config.md index 9a07952c4e..f799ebd2c5 100644 --- a/structure/config.md +++ b/structure/config.md @@ -158,13 +158,37 @@ journal creation, and the background history restoration guardian. `ocx sync` and `ocx restore back` run the injector's non-writing preflight before provider discovery or catalog/cache replacement. Deterministic config and ownership refusals therefore leave the existing catalog and cache untouched, and their concrete messages are emitted on stderr. -One refusal is deliberately not terminal for an explicit `ocx sync`. When the preflight reports -`history_paginated_requires_native_writer`, the refusal itself stands — config and conversation -files are not touched — but the catalog and models cache still refresh through their existing -owner, and the sync reports `catalog-only`. An explicit sync is also the refresh path for side -profiles that read the OpenCodex catalog without injection, and a home whose history simply -requires its native writer is not a reason to let their model list go stale. Unattended sync, -`POST /api/sync`, and every other config or ownership refusal keep the hard failure above. +Exactly one conversation-history refusal scopes the relabel unit instead of vetoing the apply +transition, and only because it is permanent. Codex allocates paginated rollout ordinals inside +its own writer, so `history_paginated_requires_native_writer` is not retryable: the transition +writes config, profile, and `model_catalog_json`, the relabel job is skipped without spawning +its Worker, and the reason travels in the human message and in the structured +`historyPreflightFailureReason` field *alongside* `success: true`. Every other reason — an +unreadable state database, a rollout whose identity changed, a preflight that could not run — +describes a store that may be relabelable on the next attempt, so those keep the hard refusal +and the compensating rollback. Recording them as a stand-down would mark the transition +converged and suppress the relabel permanently. + +Standing the relabel down changes what the routing form may retire. Rows this home tagged +`opencodex` resolve only through a `[model_providers.opencodex]` table; the loopback form +normally retires that table precisely because the relabel migrates those rows back to `openai` +in the same pass. With the relabel stood down, a table the home already published survives the +write, so those conversations keep a provider id that exists. Paginated rollout bytes and thread +rows are never modified in this state. + +Treating the refusal as a veto is what made every current Codex home unusable: paginated +rollouts refuse unconditionally, so `model_catalog_json` never reached config.toml and both the +app and the CLI fell back to their built-in model list. `ocx sync` reported success anyway, +because that reason was special-cased into a `catalog-only` result — the downgrade is gone, so a +refusal that survives is a real config or integrity failure again. + +Restore and removal keep the refusal. There the argument reverses: stripping the provider +definition while its threads still point at it would orphan them, and those paths have no seam +for keeping a compatibility table. A home that was already paginated therefore cannot yet be +uninstalled through the product; that is tracked as open work, not as settled contract. + +Unattended sync, `POST /api/sync`, and every other config or ownership refusal keep the hard +failure above. The real injection still revalidates under its normal write boundary after catalog convergence; the preflight is an early no-write guard, not an authorization token for a later write. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 64de0e514c..6213ce1449 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -96,6 +96,10 @@ Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows Provider-scoped catalog hints remain isolated by provider in `src/providers/registry.ts`. The OpenCode Go `deepseek-v4.1-flash` 1,048,576-token context hint does not change xAI model metadata or transport behavior. +The first-party DeepSeek `deepseek-flash` native `text`/`image` declaration is likewise scoped to +the DeepSeek provider and does not alter xAI metadata or transport behavior; explicit capability +overrides remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and +`deepseek-v4-flash` remain sidecar-backed by default. Zen routes are unchanged and unprobed here. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. diff --git a/structure/runtime.md b/structure/runtime.md index de83f3734c..a09d007d7b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -201,6 +201,10 @@ Provider-scoped capability hints remain authoritative when discovery returns an capabilities. In particular, `src/providers/registry.ts` assigns OpenCode Go's live `deepseek-v4.1-flash` route the official 1,048,576-token window instead of the conservative 128k routed-model fallback. +The same registry declares the first-party `deepseek-flash` model with `text` and `image` input, +so it bypasses the vision sidecar by default; explicit `noVisionModels` or text-only declarations +remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` +remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row @@ -235,6 +239,13 @@ dispatch keeps its normal reselection policy. `tests/web-search/web-search-passt covers drift during search, while pacing, and before first-leg headers return, plus successful first-dispatch reselection and result preservation. +`providers..webSearchBridge.backend` is explicit-only. `ollama` spends that provider's API key +on the planned search endpoint. `openai`, `anthropic`, `xai`, `gemini`, and `exa` reuse the matching +sidecar executor and that executor's own credential; a missing credential leaves the bridge +disarmed rather than falling through to another paid search. A leg that mixes an intercepted +`web_search` call with another client-executed tool still fails closed. Assistant text is not +treated as a search instruction. + ## Remote Hub hardening ownership `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. diff --git a/structure/subagents.md b/structure/subagents.md index 98c827ffa7..69047b076b 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -131,6 +131,10 @@ featured or picker rank. Canonical `opencode-go` rows retain their configured re and provider-scoped context metadata both when generated and when merged from retained catalog state; `deepseek-v4.1-flash` therefore keeps its 1,048,576-token window, while synthetic max/ultra choices are not added to that provider's declared ladder. +The first-party DeepSeek `deepseek-flash` row declares native `text` and `image` input and therefore +does not require the vision sidecar by default; explicit `noVisionModels` or text-only declarations +remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` +remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent_v2_and_history_perf/013_five_cap_v1_vs_v2.md`. @@ -148,6 +152,66 @@ unreadable split-token shapes. The sanitizer preserves just those fragment objec normalizing independent plaintext slots. Detection never authorizes reconstruction or recovery; other fragment layouts and mixed readable content retain their documented residual boundaries. +## Routed agent-message ciphertext egress + +Two questions about an `agent_message` were asked in two places, and the gap between them was +open. `hasUnreadableEncryptedAgentTask` asks whether the CURRENT worker task can be read and +inspects only the tail item; `normalizeRoutedAgentMessages` asks whether EVERY part can be lowered +onto a public message and forwards the private item verbatim when one cannot. An item mixing +`input_text` with `encrypted_content` is readable by the first measure and unlowerable by the +second, so it passed the guard, kept its private type through the raw Responses passthrough, and +left the process as backend ciphertext plus an item type only the Codex backend declares. The +destination answered `422 unknown item type "agent_message"` after the bytes were already sent. +Position was incidental: a replayed child result sits mid-history, where a tail-only scan cannot +see it, and the tail is exposed the same way once it is mixed. + +The repair already existed reactively. `prepareOpaqueBlobRecovery` replaces an undecryptable part +with `[encrypted content omitted]`, which leaves the item lowerable, and it ran after an upstream +rejection. A destination that cannot accept the private item under any circumstances was never +going to answer that request, so the round trip only served to send the ciphertext. +`stripAgentMessageCiphertextInPlace` in `src/server/responses/encrypted-payload.ts` applies the +same repair before dispatch, and `src/server/responses/core.ts` runs it against the final route, +after `expandPreviousResponseInput`, after the sanitizer has rewritten plaintext parked in +encrypted slots, and after encrypted-task recovery has had its chance to produce real plaintext +instead of a marker. + +The two kinds of slot are judged differently, because they carry different guarantees. An +`encrypted_content` slot holds ciphertext by definition, so it is stripped whatever it holds: +demanding a well-formed token there would reopen the same defect one payload later, since a +truncated token, a standard-base64 blob carrying `+` or `/`, an unexpected version byte, or a run +past the recovery size limits would each keep the item and forward the bytes. A text part carries +no such guarantee, so it is matched strictly -- embedded runs that validate as Fernet, or a whole +slot with the Fernet wire shape, which is the version prefix, the base64url alphabet and a +canonical length of at least 100 divisible by four. Adjacent text fragments are joined before that +test, so a token split across slots is still caught. `looksLikeBackendCiphertext` is deliberately +NOT used on text: it is length >= 64 over a character class that a SHA-256 digest matches exactly +at 64 characters, and replacing a digest a child deliberately printed would delete readable content +to protect bytes that were never secret. Other item types are untouched: reasoning and +function-output blobs keep the reactive opaque-blob recovery, which still rescues a destination +that merely failed to decrypt something it was entitled to read, and which stays reachable for the +canonical backend and for explicitly trusted routes. + +The repair resolves the same wire override the adapter is built from rather than restating routing +policy, and runs for `openai-responses` whenever the destination is not the canonical Codex +backend. `authMode: "forward"` is deliberately not that test: it describes how this proxy treats +credentials, not who answers, and a forward-configured gateway at another origin receives the +ciphertext like any third party. Only `isCanonicalOpenAiForwardProvider` is exempt, because it +alone minted these bytes and can read them. The wire override matters for the reported destination, +where the provider row names the Chat wire and a registry model default moves the model onto +Responses. Translated wires are untouched because `inputContentParts` drops an encrypted part +instead of forwarding it, and `canPassThroughEncryptedV2AgentTask` keeps an explicitly trusted +route exempt. Combo children run the repair themselves: `concreteComboRequestBody` gives each +target its own `structuredClone` and its own concrete route, so a sibling's repair is invisible to +them and a target resolving to a routed Responses wire would otherwise send what the parent's own +dispatch no longer does. + +Nothing here decrypts, and the tail NEW_TASK envelope keeps `unreadable_encrypted_agent_task` and +its opt-in recovery unchanged: an unreadable current task still fails closed rather than reaching a +child with a marker where its assignment should be. An `agent_message` carrying unknown parts but +no ciphertext still reaches the wire unchanged and still draws the destination's own 422, which is +a compatibility gap rather than an egress one. Covered by +`tests/server/v2-agent-message-failfast.test.ts`. + ## Subagents New non-OAuth provider registrations carry `initialModelSelection` with a unique diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index e344f73126..10b5be6a6b 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -33,6 +33,11 @@ surface is listed here so a maintainer can find the owner without grepping: | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts`, `src/providers/registry.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. Provider-scoped hints fill capabilities omitted by live rosters; OpenCode Go's `deepseek-v4.1-flash` keeps its 1,048,576-token context window. Codex quota DTOs suppress retired Spark evidence under the [OpenAI scope contract](../providers/openai-tiers.md#public-provider-contract), retaining ordinary custom windows. | +The registry's first-party `deepseek-flash` row declares native `text` and `image` input, so image +requests bypass the vision sidecar by default; explicit `noVisionModels` or text-only declarations +remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` +remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. + > Decision record: [ADR-0072](../decisions/ADR-0072-transport-inventory.md) Cursor external-model continuations attach data-URL screenshots from the contiguous active diff --git a/structure/transports/responses.md b/structure/transports/responses.md index c00d08f18c..1f1bd3cd2f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -288,12 +288,12 @@ call when `isXaiResponsesDestination` recognizes HTTPS `api.x.ai` or `cli-chat-p on the standard port. A nonblank string becomes one `input_text` part with the original text; the same author/recipient attribution is retained and the private transport item id is removed. -This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, -malformed, ciphertext-only and mixed unknown/encrypted content retains the existing fail-closed -path. Forward destinations never enable the option. The parser and encrypted-task recovery -owners are unchanged, and no broad content-schema validation or adapter-wide string conversion -is introduced. Mocked server fixtures cover parent, child, and parent-result continuation over -SSE and JSON while preserving actual tool-call/result pairs. +This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, malformed, +ciphertext-only and mixed unknown/encrypted content stays unlowered here; backend ciphertext is replaced by the +[omission marker](../subagents.md#routed-agent-message-ciphertext-egress) before it can reach a routed destination. +Forward destinations never enable the option. The parser and encrypted-task recovery owners are unchanged, and no +broad content-schema validation or adapter-wide string conversion is introduced. Mocked server fixtures cover +parent, child, and parent-result continuation over SSE and JSON while preserving actual tool-call/result pairs. OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and diff --git a/tests/claude-integration/claude-models-discovery.test.ts b/tests/claude-integration/claude-models-discovery.test.ts index 5c29fd7ef0..e0cdc22d0e 100644 --- a/tests/claude-integration/claude-models-discovery.test.ts +++ b/tests/claude-integration/claude-models-discovery.test.ts @@ -141,7 +141,7 @@ test("per-surface id style: ?ids= wins, claude-code UA gets readable, unknown UA } }); -test("Codex discovery bounds proven custom Astra before any disk sync and preserves a gateway namesake", async () => { +test("Codex discovery bounds proven custom Astra before any disk sync including a gateway namesake", async () => { const config = configWithStaticModels(); config.providers.openai = { adapter: "openai-responses", @@ -164,8 +164,8 @@ test("Codex discovery bounds proven custom Astra before any disk sync and preser expect(canonical?.supported_reasoning_levels.map(level => level.effort)).toEqual(["low"]); expect(canonical?.default_reasoning_level).toBe("low"); const gateway = catalog.models.find(row => row.slug === "YYLJ/gpt-6-astra"); - expect(gateway?.supported_reasoning_levels.map(level => level.effort)).toEqual(["none", "minimal", "low", "max", "ultra"]); - expect(gateway?.default_reasoning_level).toBe("minimal"); + expect(gateway?.supported_reasoning_levels.map(level => level.effort)).toEqual(["low"]); + expect(gateway?.default_reasoning_level).toBe("low"); } finally { await server.stop(true); } diff --git a/tests/clients/desktop-app-restart-posix.test.ts b/tests/clients/desktop-app-restart-posix.test.ts new file mode 100644 index 0000000000..dd1bbd8ec6 --- /dev/null +++ b/tests/clients/desktop-app-restart-posix.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { isUnderRoot } from "../../src/codex/desktop-app/types"; +import { + acquireDesktopRestartLock, + releaseDesktopRestartLock, + transferDesktopRestartLock, +} from "../../src/codex/desktop-app/lock"; + +/** + * The macOS and Linux halves of the desktop restart, plus the singleton lock. + * + * The Windows cases live in desktop-app-restart.test.ts and still pass unchanged, + * which is what shows the move to a shared ladder preserved that platform. + */ + +/** + * A REAL directory, not the conventional `/Applications/ChatGPT.app`. + * + * Discovery resolves the bundle through `realpathSync`, which touches the actual + * filesystem and cannot be intercepted by the exec seam. Pointing these cases at the + * conventional path made them pass on a machine with Codex installed and fail on a + * Linux CI runner without it - the local pass was an accident of the developer's own + * machine. Building the bundle under a temp directory makes the case hermetic and + * exercises the same code path everywhere. + */ +const BUNDLE = (() => { + const root = join(mkdtempSync(join(tmpdir(), "ocx-bundle-")), "ChatGPT.app"); + mkdirSync(join(root, "Contents", "MacOS"), { recursive: true }); + // realpath it HERE so the fixture and the adapter agree. Discovery resolves the + // bundle, and on macOS the temp directory lives under /var, which is a symlink to + // /private/var - leaving the fixture unresolved makes every enumerated process fall + // outside the resolved root and the tree reads as empty. + return realpathSync(root); +})(); +const SHELL = BUNDLE + "/Contents/MacOS/ChatGPT"; +const HELPER = BUNDLE + "/Contents/Frameworks/Codex Framework.framework/Versions/152.0.7977.83/Helpers/Codex (Service).app/Contents/MacOS/Codex (Service)"; +const CRASHPAD = BUNDLE + "/Contents/Frameworks/Codex Framework.framework/Versions/152.0.7977.83/Helpers/browser_crashpad_handler"; +const WHEN = "Sun Sep 13 18:06:22 2026"; + +interface Call { file: string; args: string[] } + +function isolatedLock(): { lockPath: string } { + return { lockPath: join(mkdtempSync(join(tmpdir(), "ocx-posix-restart-")), "lock") }; +} + +/** Rows are "pid ppid lstart uid comm", exactly as /bin/ps -o ... prints them. */ +function psRows(rows: Array<[number, number, string]>): string { + return rows.map(([pid, ppid, exe]) => `${pid} ${ppid} ${WHEN} ${process.getuid?.() ?? 0} ${exe}`).join("\n"); +} + +function darwinIo(options: { + calls: Call[]; + rows?: Array<[number, number, string]>; + ancestry?: number[]; + psThrows?: boolean; + bundleId?: string; +}): DesktopAppRestartIo { + // A process that has exited must also STOP BEING LISTED. Modelling exit only through + // isAlive is what let a ladder claim a stop the enumeration still contradicted. + const dead = new Set(); + return { + platform: "darwin", + lock: isolatedLock(), + ancestryPids: () => options.ancestry ?? [99_999], + isAlive: (pid: number) => { dead.add(pid); return false; }, + sleep: () => {}, + now: (() => { let t = 0; return () => (t += 500); })(), + execFile: (file, args) => { + options.calls.push({ file, args: [...args] }); + if (file === "/bin/ps") { + if (options.psThrows) throw new Error("ps failed"); + const rows = (options.rows ?? [[15901, 1, SHELL]] as Array<[number, number, string]>) + .filter(([pid]) => !dead.has(pid)); + return psRows(rows); + } + if (file === "/usr/libexec/PlistBuddy") return options.bundleId ?? "com.openai.codex"; + // Spotlight resolves to the fixture bundle. Returning nothing here let discovery + // fall through to the conventional /Applications path, which exists on a developer + // Mac and not on a CI runner - so a case meant to exercise a FAILED PROCESS PROBE + // reported a failed package discovery instead, depending on the machine. + if (file === "/usr/bin/mdfind") return BUNDLE; + return ""; + }, + }; +} + +describe("desktop restart membership is a path boundary, not a prefix", () => { + test("a sibling directory sharing the prefix is not a member", () => { + expect(isUnderRoot(BUNDLE + "/Contents/MacOS/ChatGPT", BUNDLE)).toBe(true); + expect(isUnderRoot(BUNDLE, BUNDLE)).toBe(true); + // The whole reason isUnderRoot exists: the same user can create these. + expect(isUnderRoot("/Applications/ChatGPT.app-evil/Contents/MacOS/ChatGPT", BUNDLE)).toBe(false); + expect(isUnderRoot("/usr/lib/chatgpt-evil/ChatGPT", "/usr/lib/chatgpt")).toBe(false); + expect(isUnderRoot("/usr/lib/chatgpt/ChatGPT", "/usr/lib/chatgpt")).toBe(true); + }); +}); + +describe("macOS desktop restart", () => { + test("quits through the Apple event and relaunches by bundle id", () => { + const calls: Call[] = []; + const result = restartCodexDesktopApp(darwinIo({ calls })); + expect(result.relaunch).toBe("started"); + expect(result.stopped).toEqual([15901]); + const quit = calls.find(call => call.file === "/usr/bin/osascript"); + expect(quit?.args.join(" ")).toContain('quit app id "com.openai.codex"'); + const open = calls.find(call => call.file === "/usr/bin/open"); + // Relaunch is by the DISCOVERED identifier, and never -n: a second instance is + // both unreliable to obtain and unwanted. + expect(open?.args).toEqual(["-b", "com.openai.codex"]); + expect(calls.some(call => call.args.includes("-n"))).toBe(false); + }); + + test("a crashpad handler at ppid 1 is never a restart target", () => { + // Measured live: the running app owns crashpad handlers at ppid 1, and an instance + // that already exited leaves more behind. Under a plain "parent is not a member" + // rule every one of them is a root, so they would be signalled and a survivor would + // block the relaunch forever. + const calls: Call[] = []; + const result = restartCodexDesktopApp(darwinIo({ + calls, + rows: [[15901, 1, SHELL], [15903, 1, CRASHPAD], [15905, 1, CRASHPAD], [15910, 15901, HELPER]], + })); + expect(result.stopped).toEqual([15901]); + const quits = calls.filter(call => call.file === "/usr/bin/osascript"); + expect(quits).toHaveLength(1); + }); + + test("an executable path containing spaces and parentheses still parses", () => { + // ps prints the full untruncated path in comm, and this app's helpers are literally + // named "Codex (Service)". A parser that split on whitespace would drop them. + const calls: Call[] = []; + restartCodexDesktopApp(darwinIo({ calls, rows: [[15901, 1, SHELL], [15910, 15901, HELPER]] })); + expect(calls.some(call => call.file === "/usr/bin/open")).toBe(true); + }); + + test("a ps probe that throws reports process_probe_failed, not no_targets", () => { + // #2557 in its macOS form: "we could not look" must never be reported as + // "the app is not running". + const calls: Call[] = []; + const result = restartCodexDesktopApp(darwinIo({ calls, psThrows: true })); + expect(result.reason).toBe("process_probe_failed"); + expect(result.attempted).toBe(false); + }); + + test("a bundle whose identifier is not com.openai.codex is not discovered", () => { + // The bundle is named ChatGPT.app and that name is shared with another product, so + // identity has to come from the identifier. + const calls: Call[] = []; + const result = restartCodexDesktopApp(darwinIo({ calls, bundleId: "com.openai.chat" })); + expect(result.reason).toBe("package_discovery_failed"); + }); + + test("being inside the app tree refuses instead of killing its own session", () => { + const calls: Call[] = []; + const result = restartCodexDesktopApp(darwinIo({ calls, ancestry: [4242, 15901, 1] })); + expect(result.reason).toBe("self_ancestry"); + expect(calls.some(call => call.file === "/usr/bin/osascript")).toBe(false); + }); + + test("an unreadable ancestry chain fails closed", () => { + const calls: Call[] = []; + const result = restartCodexDesktopApp(darwinIo({ calls, ancestry: [] })); + expect(result.reason).toBe("self_ancestry"); + expect(calls.some(call => call.file === "/usr/bin/osascript")).toBe(false); + }); + + test("a failed relaunch is reported as relaunch_failed, not targets_survived", () => { + // Everything DID die; it is the relaunch that failed. Reporting the two as one sent + // operators looking for processes that were not there. + const calls: Call[] = []; + // This double overrides execFile wholesale, so it has to model exit itself: the + // shell stops being listed once liveness has reported it dead, exactly as the real + // enumeration behaves. + const dead = new Set(); + const result = restartCodexDesktopApp({ + ...darwinIo({ calls }), + isAlive: (pid: number) => { dead.add(pid); return false; }, + execFile: (file, args) => { + calls.push({ file, args: [...args] }); + if (file === "/usr/bin/open") throw new Error("LSCopyApplicationURLsForBundleIdentifier() failed"); + if (file === "/bin/ps") { + return psRows(([[15901, 1, SHELL]] as Array<[number, number, string]>) + .filter(([pid]) => !dead.has(pid))); + } + if (file === "/usr/libexec/PlistBuddy") return "com.openai.codex"; + return ""; + }, + }); + expect(result.reason).toBe("relaunch_failed"); + expect(result.surviving).toEqual([]); + expect(result.stopped).toEqual([15901]); + }); +}); + +describe("a stop is only ever claimed when the enumeration agrees (measured on Windows)", () => { + // The defect this pins was invisible to ten rounds of code review and surfaced in the + // first thirty seconds of running the ladder on a real Windows host: it reported + // {"stopped":[27788],"surviving":[],"relaunch":"started"} while the app kept its + // original pid AND start time throughout. A pid-based liveness probe is a weaker + // instrument than the platform's own process list, and when the two disagree the list + // wins - otherwise the ladder relaunches into an app that never quit and tells the + // operator it restarted. + test("liveness saying dead does not override an enumeration that still lists the process", () => { + const calls: Call[] = []; + const result = restartCodexDesktopApp({ + platform: "darwin", + lock: isolatedLock(), + ancestryPids: () => [99_999], + // Liveness lies: it claims the process is gone. + isAlive: () => false, + sleep: () => {}, + now: (() => { let t = 0; return () => (t += 500); })(), + execFile: (file, args) => { + calls.push({ file, args: [...args] }); + // The enumeration keeps listing it, unchanged, which is the truth. + if (file === "/bin/ps") return psRows([[15901, 1, SHELL]]); + if (file === "/usr/libexec/PlistBuddy") return "com.openai.codex"; + return ""; + }, + }); + expect(result.stopped).toEqual([]); + expect(result.surviving).toEqual([15901]); + expect(result.relaunch).toBe("skipped"); + expect(result.reason).toBe("targets_survived"); + // And crucially: no relaunch beside a live app. + expect(calls.some(call => call.file === "/usr/bin/open")).toBe(false); + }); + + test("a re-probe that cannot run is a survivor, never a silent success", () => { + // "We could not look" must not read as "it exited". Reporting a survivor blocks the + // relaunch, which is the right outcome when the tree state is unknown. + const calls: Call[] = []; + let probes = 0; + const result = restartCodexDesktopApp({ + platform: "darwin", + lock: isolatedLock(), + ancestryPids: () => [99_999], + isAlive: () => false, + sleep: () => {}, + now: (() => { let t = 0; return () => (t += 500); })(), + execFile: (file, args) => { + calls.push({ file, args: [...args] }); + if (file === "/usr/libexec/PlistBuddy") return "com.openai.codex"; + if (file === "/bin/ps") { + probes += 1; + // Discovery and the first enumeration succeed; the re-verification fails. + if (probes > 2) throw new Error("ps failed"); + return psRows([[15901, 1, SHELL]]); + } + return ""; + }, + }); + expect(result.stopped).toEqual([]); + expect(result.surviving).toEqual([15901]); + expect(result.reason).toBe("targets_survived"); + expect(calls.some(call => call.file === "/usr/bin/open")).toBe(false); + }); +}); + +describe("the restart singleton lock", () => { + const alive = new Set([1001, 1002, 2001]); + const io = (lockPath: string, pid: number) => ({ + lockPath, pid, isAlive: (p: number) => alive.has(p), now: () => 1_000_000, + }); + + test("a second caller is refused rather than queued", () => { + // Two ladders at once are destructive, not merely wasteful: the second re-enumerates + // during the first's relaunch and kills the app that was just started. + const { lockPath } = isolatedLock(); + expect(acquireDesktopRestartLock(io(lockPath, 1001)).acquired).toBe(true); + expect(acquireDesktopRestartLock(io(lockPath, 1002))).toEqual({ acquired: false, heldBy: 1001 }); + }); + + test("the owner re-acquires its own lock, which is what lets a helper inherit one", () => { + const { lockPath } = isolatedLock(); + acquireDesktopRestartLock(io(lockPath, 1001)); + expect(acquireDesktopRestartLock(io(lockPath, 1001)).acquired).toBe(true); + expect(transferDesktopRestartLock(2001, io(lockPath, 1001))).toBe(true); + expect(acquireDesktopRestartLock(io(lockPath, 2001)).acquired).toBe(true); + expect(acquireDesktopRestartLock(io(lockPath, 1002))).toEqual({ acquired: false, heldBy: 2001 }); + }); + + test("releasing a lock owned by somebody else is a no-op", () => { + const { lockPath } = isolatedLock(); + acquireDesktopRestartLock(io(lockPath, 1001)); + releaseDesktopRestartLock(io(lockPath, 1002)); + expect(acquireDesktopRestartLock(io(lockPath, 1002))).toEqual({ acquired: false, heldBy: 1001 }); + }); + + test("a lock whose owner is gone is reclaimed", () => { + const { lockPath } = isolatedLock(); + acquireDesktopRestartLock(io(lockPath, 1001)); + alive.delete(1001); + expect(acquireDesktopRestartLock(io(lockPath, 1002)).acquired).toBe(true); + alive.add(1001); + }); + + test("a corrupt lock file does not wedge every future restart", () => { + // Reachable whenever a writer dies between creating the file and writing it. + const { lockPath } = isolatedLock(); + writeFileSync(lockPath, "{not json"); + expect(acquireDesktopRestartLock(io(lockPath, 1001)).acquired).toBe(true); + }); +}); + +describe("a restart already in flight does not start a second one", () => { + test("the ladder reports restart_in_flight and touches nothing", () => { + const { lockPath } = isolatedLock(); + const holder = process.pid + 1; + // Liveness is stated on BOTH sides. Leaving the restart's own lock io to the real + // isAlive made the case depend on whether pid+1 happened to exist: alone it passed, + // alongside other files the holder read as dead, the lock was reclaimed as stale and + // the restart proceeded. The behaviour under test is contention, not pid roulette. + acquireDesktopRestartLock({ lockPath, pid: holder, isAlive: () => true }); + const calls: Call[] = []; + const result = restartCodexDesktopApp({ + ...darwinIo({ calls }), + lock: { lockPath, isAlive: () => true }, + }); + expect(result.reason).toBe("restart_in_flight"); + expect(calls).toEqual([]); + }); +}); + diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index 632d88acae..4df9c7a6e9 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; @@ -25,6 +28,15 @@ function withTrustedExes(run: () => T): T { interface Call { file: string; args: string[] } /** Scripted exec seam: discovery, then process list, then whatever the branch does. */ +/** + * A lock path this case owns. The restart takes a singleton lock, so a case using the + * real default path would fail with restart_in_flight after any interrupted run, and + * would write into a directory the tests do not own. + */ +function isolatedLock(): { lockPath: string } { + return { lockPath: join(mkdtempSync(join(tmpdir(), "ocx-desktop-restart-")), "lock") }; +} + function scriptedIo(options: { discovery?: string; processes?: string; @@ -34,22 +46,44 @@ function scriptedIo(options: { throwOn?: (file: string, args: readonly string[]) => boolean; }): DesktopAppRestartIo { const polls = new Map(); + // A process that has exited must also STOP BEING LISTED. Modelling exit only through + // isAlive made these doubles unable to express the defect measured on a real Windows + // host, where the ladder recorded a stop that never happened; the enumeration is the + // authoritative signal and the double has to behave like one. + const dead = new Set(); return { platform: "win32", + // A per-case lock path. The restart now takes a singleton lock, and without this + // the suite would contend on the developer's real ~/.opencodex lock - a leftover + // from an interrupted run would then fail every case with restart_in_flight, and a + // passing run would leave state behind in a directory the tests do not own. + lock: isolatedLock(), ancestryPids: () => options.ancestry ?? [4242], sleep: () => {}, now: (() => { let t = 0; return () => (t += 500); })(), isAlive: pid => { const n = (polls.get(pid) ?? 0) + 1; polls.set(pid, n); - return options.aliveFor ? options.aliveFor(pid, n) : false; + const alive = options.aliveFor ? options.aliveFor(pid, n) : false; + if (!alive) dead.add(pid); + return alive; }, execFile: (file, args) => { options.calls.push({ file, args: [...args] }); if (options.throwOn?.(file, args)) throw new Error("exec failed"); const joined = args.join(" "); if (joined.includes("Get-AppxPackage")) return options.discovery ?? "MISS"; - if (joined.includes("Win32_Process")) return options.processes ?? ""; + if (joined.includes("Win32_Process")) { + const listing = options.processes ?? ""; + if (dead.size === 0) return listing; + return listing + .split("\n") + .filter(line => { + const pid = Number(line.trim().split(/\s+/)[0]); + return !Number.isSafeInteger(pid) || !dead.has(pid); + }) + .join("\n"); + } return ""; }, }; @@ -58,10 +92,20 @@ function scriptedIo(options: { const DISCOVERY = [AUMID.replace("!App", ""), INSTALL, AUMID].join("\n"); describe("Codex desktop app restart (#2292)", () => { - test("is a no-op off Windows and never execs anything", () => { + // macOS and Linux are no longer no-ops: they have real adapters. What survives from the + // original assertion is that a platform with NO adapter still refuses without execing + // anything, which is the fail-closed property the old windows_only case was really + // protecting. + test("is a no-op on a platform with no adapter and never execs anything", () => { const calls: Call[] = []; - const result = restartCodexDesktopApp({ platform: "darwin", execFile: (f, a) => { calls.push({ file: f, args: [...a] }); return ""; } }); - expect(result).toEqual({ attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "windows_only" }); + const result = restartCodexDesktopApp({ + lock: isolatedLock(), + platform: "freebsd", + execFile: (f, a) => { calls.push({ file: f, args: [...a] }); return ""; }, + }); + expect(result).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "unsupported_platform", + }); expect(calls).toEqual([]); }); @@ -179,6 +223,7 @@ describe("Codex desktop app restart (#2292)", () => { test("every probe is bounded by a timeout", () => { const seen: (number | undefined)[] = []; withTrustedExes(() => restartCodexDesktopApp({ + lock: isolatedLock(), platform: "win32", ancestryPids: () => [4242], sleep: () => {}, @@ -238,6 +283,7 @@ describe("Codex desktop app restart — kill-authority guards (#2292)", () => { test("an unreadable ancestry chain fails closed instead of assuming we are outside it", () => { const calls: Call[] = []; const result = withTrustedExes(() => restartCodexDesktopApp({ + lock: isolatedLock(), platform: "win32", sleep: () => {}, isAlive: () => false, @@ -261,6 +307,7 @@ describe("Codex desktop app restart — kill-authority guards (#2292)", () => { const calls: Call[] = []; const parents: Record = { 900: "800", 800: "1000", 1000: "0" }; const result = withTrustedExes(() => restartCodexDesktopApp({ + lock: isolatedLock(), platform: "win32", sleep: () => {}, isAlive: () => false, diff --git a/tests/clients/desktop-restart-handoff.test.ts b/tests/clients/desktop-restart-handoff.test.ts new file mode 100644 index 0000000000..f299bbba0a --- /dev/null +++ b/tests/clients/desktop-restart-handoff.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + handoffLogPath, + resolveHelperCommand, + runDesktopRestartHandoff, + startDesktopRestartHandoff, + type HandoffIo, +} from "../../src/codex/desktop-app/handoff"; +import { acquireDesktopRestartLock } from "../../src/codex/desktop-app/lock"; + +/** + * The handoff exists because the self-ancestry refusal fires in the NORMAL case on a + * developer machine: anything run from a Codex terminal is inside the app tree it is + * being asked to restart. + */ + +function home(): string { + return mkdtempSync(join(tmpdir(), "ocx-handoff-")); +} + +describe("resolving how to re-invoke this CLI as the helper", () => { + test("an existing argv[1] is used, which covers both the checkout and the npm shim", () => { + const resolved = resolveHelperCommand({ execPath: "/usr/bin/bun", argv: ["/usr/bin/bun", __filename] }); + expect(resolved).toEqual({ command: "/usr/bin/bun", args: [__filename] }); + }); + + test("a packaged ocx binary needs no entry script", () => { + expect(resolveHelperCommand({ execPath: "/usr/local/bin/ocx", argv: ["/usr/local/bin/ocx"] })) + .toEqual({ command: "/usr/local/bin/ocx", args: [] }); + }); + + test("an unresolvable invocation REFUSES rather than guessing", () => { + // Spawning the wrong interpreter with a path that does not exist produces a helper + // that exits immediately and an operator who was told the restart was handed off. + expect(resolveHelperCommand({ execPath: "/usr/bin/node", argv: ["/usr/bin/node", "/nope/missing.js"] })) + .toBeNull(); + }); +}); + +describe("starting a handoff", () => { + function startIo(dir: string, spawned: { command?: string; args?: string[] }, pid: number | undefined): HandoffIo { + return { + homeDir: dir, + pid: 4242, + now: () => 1_000, + execPath: "/usr/local/bin/ocx", + argv: ["/usr/local/bin/ocx"], + lock: { lockPath: join(dir, "lock"), pid: 4242, isAlive: () => true, now: () => 1_000 }, + spawnHelper: (command, args) => { + spawned.command = command; + spawned.args = [...args]; + return { pid, unref: () => {} }; + }, + }; + } + + test("spawns the hidden command with a plan and hands the lock to the helper", () => { + const dir = home(); + const spawned: { command?: string; args?: string[] } = {}; + const io = startIo(dir, spawned, 9001); + acquireDesktopRestartLock(io.lock); + const outcome = startDesktopRestartHandoff(io); + expect(outcome).toEqual({ kind: "started", helperPid: 9001, logPath: handoffLogPath({ homeDir: dir }) }); + expect(spawned.command).toBe("/usr/local/bin/ocx"); + expect(spawned.args?.slice(0, 3)).toEqual(["internal", "desktop-restart-handoff", "--plan"]); + // The helper must inherit ownership, not compete for it: a helper waiting on a lock + // its own parent holds is the deadlock this design exists to avoid. + const owner = JSON.parse(readFileSync(join(dir, "lock"), "utf-8")).ownerPid; + expect(owner).toBe(9001); + }); + + test("a lock transfer that did not take is reported as a failure, not a handoff", () => { + // Otherwise the caller skips its release, the lock keeps naming a process that is + // about to exit, and it reads as stale for the whole helper wait. + const dir = home(); + const spawned: { command?: string; args?: string[] } = {}; + const io = startIo(dir, spawned, 9001); + // Nobody owns the lock, so the owner-check inside transfer fails. + const outcome = startDesktopRestartHandoff(io); + expect(outcome).toEqual({ kind: "failed", reason: "lock_transfer_failed" }); + }); + + test("a spawn that produced no pid is a failure, and the plan is cleaned up", () => { + const dir = home(); + const spawned: { command?: string; args?: string[] } = {}; + const outcome = startDesktopRestartHandoff(startIo(dir, spawned, undefined)); + expect(outcome).toEqual({ kind: "failed", reason: "spawn_failed" }); + const planArg = spawned.args?.[3]; + expect(planArg).toBeDefined(); + expect(existsSync(planArg as string)).toBe(false); + }); +}); + +describe("running the handoff", () => { + function runIo(dir: string, extra: Partial[1]> = {}) { + return { + homeDir: dir, + pid: 9001, + // An ADVANCING clock. A constant one makes the caller-wait loop depend entirely on + // the poll bound, which is not what these cases are checking. + now: (() => { let t = 2_000; return () => (t += 50); })(), + lock: { lockPath: join(dir, "lock"), pid: 9001, isAlive: () => true, now: () => 2_000 }, + isAlive: () => false, + sleep: () => {}, + ...extra, + }; + } + + // The name matters: the helper refuses any --plan outside the opencodex home or not + // named like a plan this CLI writes, so a generic "plan.json" is correctly rejected. + function writePlan(dir: string, plan: unknown): string { + const path = join(dir, "desktop-restart-handoff-4242-abc123.json"); + writeFileSync(path, JSON.stringify(plan)); + return path; + } + + test("waits for the caller to exit, then restarts and records the outcome", async () => { + const dir = home(); + const path = writePlan(dir, { schemaVersion: 1, callerPid: 4242, createdAtMs: 1_900 }); + const outcome = await runDesktopRestartHandoff(path, runIo(dir, { + readLockOwner: () => 9001, + restart: () => ({ relaunch: "started" as const, stopped: [15901], surviving: [] }), + })); + expect(outcome).toBe("restarted"); + // Single-use: a plan that survived would let a crash restart the app later. + expect(existsSync(path)).toBe(false); + const logged = JSON.parse(readFileSync(handoffLogPath({ homeDir: dir }), "utf-8").trim()); + expect(logged.outcome).toBe("restarted"); + // Counts, never command lines or OS error text. + expect(logged.stopped).toBe(1); + }); + + test("a caller that outlives the window is refused rather than guessed about", async () => { + const dir = home(); + const path = writePlan(dir, { schemaVersion: 1, callerPid: 4242, createdAtMs: 1_900 }); + let restarted = false; + const outcome = await runDesktopRestartHandoff(path, runIo(dir, { + isAlive: () => true, + restart: () => { restarted = true; return { relaunch: "skipped" as const, stopped: [], surviving: [] }; }, + })); + expect(outcome).toBe("caller_still_running"); + expect(restarted).toBe(false); + }); + + test("a stale plan does not restart the app hours later", async () => { + const dir = home(); + const path = writePlan(dir, { schemaVersion: 1, callerPid: 4242, createdAtMs: 0 }); + let restarted = false; + const outcome = await runDesktopRestartHandoff(path, runIo(dir, { + now: () => 10 * 60_000, + restart: () => { restarted = true; return { relaunch: "started" as const, stopped: [], surviving: [] }; }, + })); + expect(outcome).toBe("plan_expired"); + expect(restarted).toBe(false); + }); + + test("an unreadable plan is refused and NOT deleted", async () => { + const dir = home(); + const path = join(dir, "desktop-restart-handoff-4242-abc123.json"); + writeFileSync(path, "{not json"); + expect(await runDesktopRestartHandoff(path, runIo(dir))).toBe("plan_unreadable"); + // Deleting on a failed parse would destroy a file that merely sits in the right + // place under the right name. + expect(existsSync(path)).toBe(true); + }); + + test("a --plan outside the opencodex home is refused without being deleted", async () => { + // Unlinking whatever --plan points at would turn this hidden helper command into an + // unlink oracle for any same-uid caller. + const dir = home(); + const outside = join(home(), "config.json"); + writeFileSync(outside, JSON.stringify({ schemaVersion: 1, callerPid: 4242, createdAtMs: 1_900 })); + expect(await runDesktopRestartHandoff(outside, runIo(dir))).toBe("plan_unreadable"); + expect(existsSync(outside)).toBe(true); + }); + + test("a plan whose name is not one this CLI writes is refused", async () => { + const dir = home(); + const path = join(dir, "plan.json"); + writeFileSync(path, JSON.stringify({ schemaVersion: 1, callerPid: 4242, createdAtMs: 1_900 })); + expect(await runDesktopRestartHandoff(path, runIo(dir))).toBe("plan_unreadable"); + expect(existsSync(path)).toBe(true); + }); + + test("the helper refuses to act unless the lock names it", async () => { + // A failed transfer, or somebody reclaiming the lock, must not produce a second + // unsynchronised ladder. + const dir = home(); + const path = writePlan(dir, { schemaVersion: 1, callerPid: 4242, createdAtMs: 1_900 }); + let restarted = false; + const outcome = await runDesktopRestartHandoff(path, runIo(dir, { + readLockOwner: () => 12_345, + restart: () => { restarted = true; return { relaunch: "started" as const, stopped: [], surviving: [] }; }, + })); + expect(outcome).toBe("not_lock_owner"); + expect(restarted).toBe(false); + }); + + test("the helper never hands off again, so recursion is impossible", async () => { + const dir = home(); + const path = writePlan(dir, { schemaVersion: 1, callerPid: 4242, createdAtMs: 1_900 }); + const seen: boolean[] = []; + await runDesktopRestartHandoff(path, runIo(dir, { + readLockOwner: () => 9001, + restart: allowHandoff => { + seen.push(allowHandoff); + return { relaunch: "started" as const, stopped: [], surviving: [] }; + }, + })); + expect(seen).toEqual([false]); + }); +}); + diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index d98f5abbc3..343d193cea 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -711,33 +711,56 @@ describe("CLI /api sync wiring for stale app-servers (#476)", () => { test("ocx sync only handles app-servers after a catalog/cache write and forwards --restart-codex", () => { const syncCase = dispatchSource.slice(dispatchSource.indexOf("sync: async"), dispatchSource.indexOf("v2: async")); - expect(syncCase).toContain('includes("--restart-codex")'); + expect(syncCase).toContain("readRestartScope(syncArgs"); expect(syncCase).toContain("synced.catalogWritten || synced.cacheSynced"); - expect(syncCase).toContain("afterCatalogWriteHandleAppServers"); - expect(syncCase).toContain("restart: restartCodex"); + expect(syncCase).toContain("handleRestartScopeAfterWrite"); + expect(syncCase).toContain("handleRestartScopeAfterWrite(restartScope"); expect(syncCase.indexOf("catalogWritten || synced.cacheSynced")) - .toBeLessThan(syncCase.indexOf("afterCatalogWriteHandleAppServers")); + .toBeLessThan(syncCase.indexOf("handleRestartScopeAfterWrite")); // No-write path must not call the handler outside the gate. const gatedBlock = syncCase.slice(syncCase.indexOf("if (synced.catalogWritten")); - expect(gatedBlock).toContain("afterCatalogWriteHandleAppServers"); - expect(syncCase.replace(gatedBlock, "")).not.toContain("afterCatalogWriteHandleAppServers"); + expect(gatedBlock).toContain("handleRestartScopeAfterWrite"); + expect(syncCase.replace(gatedBlock, "")).not.toContain("handleRestartScopeAfterWrite"); }); - test("--restart-desktop-app is a separate opt-in that --restart-codex never implies (#2292)", () => { + test("--restart-codex restarts the desktop app on every platform (#2292 follow-up)", () => { + // This assertion is the inverse of the one it replaces, and deliberately so. The + // original encoded a consent decision - quitting the app ends live conversations, so + // --restart-codex promised app-server-only scope and the desktop restart was a + // separate Windows-only opt-in. That decision was superseded by an explicit + // maintainer instruction, and the narrow scope did not disappear: it moved to + // --restart-app-server-only, which is what this now pins. for (const [name, endMarker] of [["sync: async", "v2: async"], ['"sync-cache": async', "gui: async"]] as const) { const handler = dispatchSource.slice(dispatchSource.indexOf(name), dispatchSource.indexOf(endMarker)); - // Two independent flag reads. If the desktop restart were derived from - // restartCodex, quitting the user's app would ride along on a flag whose - // documented contract is app-server-only. - expect(handler).toContain('includes("--restart-desktop-app")'); - expect(handler).toMatch(/if \(restartDesktopApp\) await handleDesktopAppRestart\((console|jsonSafeLog)\)/); - expect(handler).not.toContain("restartDesktopApp = restartCodex"); - // Gated behind the same real-write condition as the app-server handling. - const desktopAt = handler.indexOf("restartDesktopApp) await handleDesktopAppRestart"); - expect(handler.indexOf("afterCatalogWriteHandleAppServers")).toBeLessThan(desktopAt); + // One reader for every command, so the same flag cannot mean different things in + // sync, sync-cache and catalog pull. + expect(handler).toContain("readRestartScope("); + expect(handler).toContain("handleRestartScopeAfterWrite(restartScope"); + // The app-server pass and the desktop restart are no longer two independent + // decisions at the call site; they are one scope computed once. + expect(handler).not.toContain('includes("--restart-desktop-app")'); + // Still gated behind a real catalog or cache write. + const gateAt = Math.min( + ...[handler.indexOf("synced.catalogWritten"), handler.indexOf("invalidated.kind")] + .filter(index => index >= 0), + ); + expect(gateAt).toBeGreaterThanOrEqual(0); + expect(gateAt).toBeLessThan(handler.indexOf("handleRestartScopeAfterWrite(restartScope")); } }); + test("only --restart-app-server-only leaves the desktop app running", () => { + const scopeSource = readFileSync(repoPath("src", "cli", "restart-scope.ts"), "utf-8"); + // The narrow scope wins a conflict. Losing live conversations is unrecoverable and a + // stale model picker is not, so a user who asked for app-server-only keeps them even + // if another flag says otherwise. + expect(scopeSource).toContain('includes("--restart-app-server-only")'); + expect(scopeSource).toMatch(/if \(appServerOnly\) return \{ appServers: true, desktopApp: false \}/); + expect(scopeSource).toMatch(/if \(restartCodex \|\| legacyDesktop\) return \{ appServers: true, desktopApp: true \}/); + // The deprecated alias still works and says so. + expect(scopeSource).toContain("--restart-desktop-app is deprecated"); + }); + test("ocx sync-cache only handles app-servers after a successful models_cache write", () => { const syncCacheCase = dispatchSource.slice( dispatchSource.indexOf('"sync-cache": async'), @@ -752,11 +775,11 @@ describe("CLI /api sync wiring for stale app-servers (#476)", () => { expect(syncCacheCase).toContain("invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true })"); const gate = 'if (invalidated.kind === "completed" && invalidated.value)'; expect(syncCacheCase).toContain(gate); - expect(syncCacheCase).toContain("afterCatalogWriteHandleAppServers"); + expect(syncCacheCase).toContain("handleRestartScopeAfterWrite"); expect(syncCacheCase.indexOf(gate)) - .toBeLessThan(syncCacheCase.indexOf("afterCatalogWriteHandleAppServers")); + .toBeLessThan(syncCacheCase.indexOf("handleRestartScopeAfterWrite")); const gatedBlock = syncCacheCase.slice(syncCacheCase.indexOf(gate)); - expect(gatedBlock).toContain("afterCatalogWriteHandleAppServers"); + expect(gatedBlock).toContain("handleRestartScopeAfterWrite"); expect(syncCacheCase.replace(gatedBlock, "")).not.toContain("afterCatalogWriteHandleAppServers"); }); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index cae7a2d00f..fe46b224b2 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -4062,14 +4062,14 @@ describe("Codex catalog routed normalization", () => { }); test.each([ - { name: "YYLJ", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", modelId: "gpt-6-astra" }, - { name: "openai", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "forward", modelId: "gpt-6-astra" }, - { name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra" }, - { name: "openai", adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra" }, - { name: "openai-apikey", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", modelId: "gpt-6-astra" }, - { name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", modelId: "gpt-unproven" }, - ] satisfies Array<{ name: string; adapter: OcxProviderConfig["adapter"]; baseUrl: string; authMode: OcxProviderConfig["authMode"]; modelId: string }>)( - "custom $name/$modelId does not infer native effort capability from $baseUrl / $authMode / $adapter", + { name: "YYLJ", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] }, + { name: "openai", adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "forward", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] }, + { name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] }, + { name: "openai", adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] }, + { name: "openai-apikey", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key", modelId: "gpt-6-astra", efforts: ["low"], defaultEffort: "low", catalogEfforts: ["low"] }, + { name: "openai", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", modelId: "gpt-unproven", efforts: ["none", "minimal", "low"], defaultEffort: "minimal", catalogEfforts: ["none", "minimal", "low", "max", "ultra"] }, + ] satisfies Array<{ name: string; adapter: OcxProviderConfig["adapter"]; baseUrl: string; authMode: OcxProviderConfig["authMode"]; modelId: string; efforts: string[]; defaultEffort: string; catalogEfforts: string[] }>)( + "custom $name/$modelId does not inherit native identity from $baseUrl / $authMode / $adapter", async fixture => { const models = await gatherRoutedModels({ port: 10100, @@ -4079,15 +4079,42 @@ describe("Codex catalog routed normalization", () => { }); const custom = models.find(row => row.provider === fixture.name && row.id === fixture.modelId); expect(custom?.codexForwardNativeCapabilityAlias).toBeUndefined(); - expect(custom?.reasoningEfforts).toEqual(["none", "minimal", "low"]); - expect(custom?.defaultReasoningEffort).toBe("minimal"); + expect(custom?.reasoningEfforts).toEqual(fixture.efforts); + expect(custom?.defaultReasoningEffort).toBe(fixture.defaultEffort); const entries = buildCatalogEntries(nativeTemplate(), [], models); const row = entries.find(entry => entry.slug === `${fixture.name}/${fixture.modelId}`); - expect(row ? catalogEntryEfforts(row) : undefined) - .toEqual(["none", "minimal", "low", "max", "ultra"]); + expect(row ? catalogEntryEfforts(row) : undefined).toEqual(fixture.catalogEfforts); + expect(row?.use_responses_lite).toBeUndefined(); + expect(row?.multi_agent_version).toBeUndefined(); }, ); + test("gateway custom Astra bounds catalog efforts without native identity (#3775)", async () => { + const config = { + port: 10100, + defaultProvider: "YYLJ", + providers: { YYLJ: { adapter: "openai-responses" as const, baseUrl: "https://gateway.example.test/v1", authMode: "key" as const, liveModels: false, models: ["gpt-6-astra"] } }, + customModels: [{ + id: "yylj-astra", + provider: "YYLJ", + modelId: "gpt-6-astra", + reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + defaultReasoningEffort: "minimal", + }], + }; + const beforeConfig = JSON.stringify(config); + const models = await gatherRoutedModels(config); + const custom = models.find(row => row.provider === "YYLJ" && row.id === "gpt-6-astra"); + expect(custom?.codexForwardNativeCapabilityAlias).toBeUndefined(); + expect(custom?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(custom?.defaultReasoningEffort).toBe("low"); + const row = buildCatalogEntries(nativeTemplate(), [], models).find(entry => entry.slug === "YYLJ/gpt-6-astra"); + expect(row ? catalogEntryEfforts(row) : undefined).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(row?.default_reasoning_level).toBe("low"); + expect(row?.use_responses_lite).toBeUndefined(); + expect(JSON.stringify(config)).toBe(beforeConfig); + }); + test("fresh none-only custom rows keep their ladder while retained provider rows still gain max", async () => { const models = await gatherRoutedModels({ port: 10100, diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 0882ecfacb..d0dda14d01 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -164,7 +164,7 @@ describe("injectCodexConfig integration (Design B)", () => { }); for (const stage of ["before-preflight", "after-preflight", "after-config", "after-artifacts"]) { - test.each([false,true])(`commit-boundary history refusal returns a result after rollback (${stage}, legacy=%s)`,(legacy)=>{ + test.each([false,true])(`a store that migrates mid-transaction retires the relabel unit and keeps the config (${stage}, legacy=%s)`,(legacy)=>{ const original=legacy ? DESIGN_B_BLOCK+"\n" : 'model="test"\n'; writeFileSync(join(codexHome,"config.toml"),original); if(legacy) writeFileSync(join(codexHome,"opencodex.config.toml"),"[invalid profile\n"); @@ -191,12 +191,17 @@ describe("injectCodexConfig integration (Design B)", () => { expect(child.status).toBe(0); const value=JSON.parse(child.stdout); expect(value.kind).toBe(legacy?"legacy-uncoordinated":"coordinated"); - expect(value.result).toMatchObject({success:false}); - expect(value.result.message).toContain("history_paginated_requires_native_writer"); - expect(readFileSync(join(codexHome,"config.toml"),"utf8")).toBe(original); - expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); - if(legacy) expect(readFileSync(join(codexHome,"opencodex.config.toml"),"utf8")).toBe("[invalid profile\n"); - else expect(existsSync(join(codexHome,"opencodex.config.toml"))).toBe(false); + // A migration observed at ANY point in the transaction stands the relabel unit down and + // says so. It never rolls the config back: the config half writes no history, and + // rolling it back is what left every paginated home with no OpenCodex models at all. + expect(value.result).toMatchObject({success:true,historyPreflightFailureReason:"history_paginated_requires_native_writer"}); + expect(value.result.message).toContain("left to Codex's native writer"); + // The profile is replaced inside the artifact transaction, so a profile that is no longer + // the fixture's is proof the config half ran to completion instead of compensating away. + const profileAfter=readFileSync(join(codexHome,"opencodex.config.toml"),"utf8"); + expect(profileAfter).not.toBe("[invalid profile\n"); + expect(profileAfter.length).toBeGreaterThan(0); + expect(readFileSync(join(codexHome,"config.toml"),"utf8")).toContain("127.0.0.1:10100"); }); } @@ -361,7 +366,7 @@ describe("injectCodexConfig integration (Design B)", () => { }); } - test.each([false, true])("paginated history preserves config and profile before provider transition (authless=%s)", (authless) => { + test.each([false, true])("a paginated home still gets its config written, and keeps the provider table its rows need (authless=%s)", (authless) => { const original = 'model_provider = "opencodex"\n[model_providers.opencodex]\nname="OpenCodex"\nbase_url="http://127.0.0.1:10100/v1"\nwire_api="responses"\n'; const configPath = join(codexHome, "config.toml"); const profilePath = join(codexHome, "opencodex.config.toml"); @@ -374,14 +379,26 @@ describe("injectCodexConfig integration (Design B)", () => { db.run("CREATE TABLE threads (id TEXT, rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); db.run("INSERT INTO threads VALUES ('fixture', ?, 'opencodex', 'paginated')", rollout); db.close(); + // Apply: the config transitions and the relabel unit stands down by name. The rollout is + // the thing that must not move, because its ordinals belong to Codex's own writer. const result = runInject(codexHome, ocxHome, JSON.stringify({codexDesktopAuthless:authless})); expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({success:false}); - expect(result.stdout).toContain("history_paginated_requires_native_writer"); - expect(readFileSync(configPath,"utf8")).toBe(original); - expect(readFileSync(profilePath,"utf8")).toBe("# preserve profile\n"); + expect(JSON.parse(result.stdout)).toMatchObject({ + success: true, + historyPreflightFailureReason: "history_paginated_requires_native_writer", + }); + expect(result.stdout).toContain("left to Codex's native writer"); expect(readFileSync(rollout,"utf8")).toBe(bytes); - expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); + // The profile is replaced inside the artifact transaction; no longer holding the fixture + // sentinel is proof the config half committed rather than being compensated away. + expect(readFileSync(profilePath,"utf8")).not.toBe("# preserve profile\n"); + // The relabel stood down, so the rows still say `opencodex`. Retiring the table that + // publishes that provider id would leave those conversations pointing at nothing, so a + // table this home already had survives the write even in the root-override form. + expect(readFileSync(configPath,"utf8")).toContain("[model_providers.opencodex]"); + + // Removing routing while those rows stay routed would orphan them, so restore keeps its + // refusal here. Making an already-paginated home uninstallable is tracked separately. const restoreScript = ` const { restoreNativeCodex, restoreNativeCodexAsync, removeCodexConfig } = require("./src/codex/inject"); const results = [restoreNativeCodex(), await restoreNativeCodexAsync(), removeCodexConfig()]; @@ -393,10 +410,36 @@ describe("injectCodexConfig integration (Design B)", () => { }); expect(restored.status).toBe(0); for (const outcome of JSON.parse(restored.stdout)) expect(outcome.success).toBe(false); - expect(readFileSync(configPath,"utf8")).toBe(original); - expect(readFileSync(profilePath,"utf8")).toBe("# preserve profile\n"); + expect(readFileSync(configPath,"utf8")).toContain("[model_providers.opencodex]"); expect(readFileSync(rollout,"utf8")).toBe(bytes); - expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); + }); + + test("a paginated home still receives the model catalog path the picker reads", () => { + // The user-visible regression this pins. A paginated rollout made the injector refuse + // the whole write, so `model_catalog_json` never reached config.toml: the Codex app and + // CLI both fell back to their native model list while `ocx sync` still said synchronized. + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.5"\n'); + const catalogPath = join(codexHome, "opencodex-catalog.json"); + writeFileSync(catalogPath, JSON.stringify({ models: [{ slug: "xai/grok-4.6", display_name: "Grok 4.6" }] })); + const rollout = join(codexHome, "paginated.jsonl"); + const bytes = JSON.stringify({ ordinal: 0, type: "session_meta", payload: { id: "paginated", history_mode: "paginated", model_provider: "opencodex" } }) + "\n"; + writeFileSync(rollout, bytes); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.run("CREATE TABLE threads (id TEXT, rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + db.run("INSERT INTO threads VALUES ('paginated', ?, 'opencodex', 'paginated')", rollout); + db.close(); + + const result = runInject(codexHome, ocxHome); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + success: true, + historyPreflightFailureReason: "history_paginated_requires_native_writer", + }); + const written = readFileSync(configPath, "utf8"); + expect(written).toContain("model_catalog_json"); + expect(written).toContain(catalogPath); + expect(readFileSync(rollout, "utf8")).toBe(bytes); }); test("remote target validate-only writes nothing; commit journals client ownership and restores exact preimage", () => { diff --git a/tests/codex-integration/codex-sync-api.test.ts b/tests/codex-integration/codex-sync-api.test.ts index 9993d55863..cf95f322ca 100644 --- a/tests/codex-integration/codex-sync-api.test.ts +++ b/tests/codex-integration/codex-sync-api.test.ts @@ -191,7 +191,7 @@ describe("GUI/CLI Codex sync backend", () => { expect(errors).toEqual([refusal]); }); - test("an explicit sync refreshes the catalog when paginated history refuses injection", async () => { + test("a stood-down relabel unit still injects the config and is reported as a warning", async () => { let refreshCalls = 0; const errors: string[] = []; @@ -210,49 +210,61 @@ describe("GUI/CLI Codex sync backend", () => { }; }, injectCodexConfig: async () => ({ - success: false, + success: true, historyPreflightFailureReason: "history_paginated_requires_native_writer", - message: "Codex config injection refused: history_paginated_requires_native_writer.", + message: "Pointed Codex's built-in openai provider at the opencodex proxy.", }), currentExternalCodexModelProvider: () => null, collectCodexHomeDiagnostic: () => homeDiagnostic(), }, { catalogEvenWhenNotInjected: true }); - // The refusal is the injector's, and it stands: only the catalog owner publishes. + // Paginated history retires the relabel unit only. Reporting this as a `catalog-only` + // success while config.toml kept no catalog path is what hid the model-picker + // regression: Codex offered its six native models and the sync still said synchronized. expect(refreshCalls).toBe(1); - expect(result.status).toBe("catalog-only"); + expect(result.status).toBe("applied"); expect(result.ok).toBe(true); expect(result.added).toBe(2); expect(result.catalogWritten).toBe(true); - expect(result.message).toContain("paginated history requires its native writer"); + expect(result.warning).toContain("history_paginated_requires_native_writer"); + expect(result.warning).toContain("native writer"); expect(errors).toEqual([]); }); - test("a refused catalog refresh keeps an explicit history-blocked sync unsuccessful", async () => { + test("an explicit sync no longer downgrades a surviving injector refusal to catalog-only", async () => { + let refreshCalls = 0; + const refusal = "Codex config injection refused: history_paginated_requires_native_writer."; + const result = await syncModelsToCodex(12345, config, null, { admitCodexWrite: admittedSync, - refreshCodexModelCatalog: async () => ({ - added: 0, - path: "/tmp/opencodex-catalog.json", - catalogExists: true, - catalogWritten: false, - cacheSynced: false, - comboOmissions: [], - refreshOutcome: "refused" as const, - }), + refreshCodexModelCatalog: async () => { + refreshCalls++; + return { + added: 0, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + comboOmissions: [], + refreshOutcome: "refused" as const, + }; + }, injectCodexConfig: async () => ({ success: false, historyPreflightFailureReason: "history_paginated_requires_native_writer", - message: "Codex config injection refused: history_paginated_requires_native_writer.", + message: refusal, }), currentExternalCodexModelProvider: () => null, collectCodexHomeDiagnostic: () => homeDiagnostic(), }, { catalogEvenWhenNotInjected: true }); - expect(result.status).toBe("catalog-only"); + // The injector no longer refuses for this reason, so a refusal that does arrive is a + // real config/integrity failure and must not be dressed up as a catalog success. + expect(refreshCalls).toBe(0); + expect(result.status).toBe("applied"); expect(result.ok).toBe(false); - expect(result.cacheSynced).toBe(false); - expect(result.message).toContain("did not complete"); + expect(result.catalogWritten).toBe(false); + expect(result.message).toBe(refusal); }); test("an unattended sync keeps the hard failure on the same history refusal", async () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index be611619d0..145cbfc22b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -481,6 +481,7 @@ "devin-adapter.test.ts": "providers", "devin-effort-ladder.test.ts": "providers", "devin-hardening.test.ts": "providers", + "devin-image-passthrough.test.ts": "providers", "devin-prompt-cache.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", diff --git a/tests/providers/devin-image-passthrough.test.ts b/tests/providers/devin-image-passthrough.test.ts new file mode 100644 index 0000000000..e9445abe2f --- /dev/null +++ b/tests/providers/devin-image-passthrough.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { mapOcxMessagesToDevin } from "../../src/adapters/devin"; +import { buildGetChatMessageRequestForTests } from "../../src/adapters/devin/cloud-direct/chat"; +import type { OcxMessage, OcxParsedRequest } from "../../src/types"; + +const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg"; + +function parsedWith(messages: OcxMessage[]): OcxParsedRequest { + return { + context: { + provider: "devin", + model: "swe-2", + systemPrompt: [], + tools: [], + messages, + }, + options: {}, + } as unknown as OcxParsedRequest; +} + +describe("user image passthrough", () => { + test("a data: URL image part becomes a wire image with mime and base64", () => { + const items = mapOcxMessagesToDevin(parsedWith([{ + role: "user", + content: [ + { type: "text", text: "이거 읽을수 있어?" }, + { type: "image", imageUrl: dataUrl }, + ], + }])); + const user = items.find(i => i.role === "user")!; + expect(Array.isArray(user.content)).toBe(true); + const parts = user.content as Array>; + expect(parts[0]).toEqual({ type: "text", text: "이거 읽을수 있어?" }); + expect(parts[1]).toEqual({ type: "image", mimeType: "image/png", base64Data: "iVBORw0KGgoAAAANSUhEUg" }); + }); + + test("an image-only user message is not dropped", () => { + // This is the reported failure: a pasted screenshot with no caption killed + // the turn at 0s because the text-only extraction produced an empty string + // and the whole message was discarded. + const items = mapOcxMessagesToDevin(parsedWith([{ + role: "user", + content: [{ type: "image", imageUrl: dataUrl }], + }])); + expect(items.filter(i => i.role === "user")).toHaveLength(1); + }); + + test("a remote https image stays as an explicit text reference", () => { + const items = mapOcxMessagesToDevin(parsedWith([{ + role: "user", + content: [{ type: "image", imageUrl: "https://example.com/pic.png" }], + }])); + const user = items.find(i => i.role === "user")!; + expect(user.content).toEqual([{ type: "text", text: "[image url: https://example.com/pic.png]" }]); + }); +}); + +describe("tool-result image passthrough", () => { + test("a tool result carrying an image keeps it", () => { + const items = mapOcxMessagesToDevin(parsedWith([{ + role: "toolResult", + toolCallId: "call_1", + content: [ + { type: "text", text: "screenshot captured" }, + { type: "image", imageUrl: dataUrl }, + ], + } as unknown as OcxMessage])); + const tool = items.find(i => i.role === "tool")!; + const parts = tool.content as Array>; + expect(parts.some(p => p.type === "image" && p.base64Data === "iVBORw0KGgoAAAANSUhEUg")).toBe(true); + }); + + test("an error tool result still carries the ERROR prefix alongside images", () => { + const items = mapOcxMessagesToDevin(parsedWith([{ + role: "toolResult", + toolCallId: "call_1", + isError: true, + content: [{ type: "image", imageUrl: dataUrl }], + } as unknown as OcxMessage])); + const tool = items.find(i => i.role === "tool")!; + const parts = tool.content as Array>; + expect(parts[0]).toMatchObject({ type: "text", text: "ERROR:" }); + expect(parts.some(p => p.type === "image")).toBe(true); + }); +}); + +describe("the wire encoder receives the image on field #10", () => { + test("a user image produces an ImageData submessage in the request frame", () => { + const items = mapOcxMessagesToDevin(parsedWith([{ + role: "user", + content: [{ type: "image", imageUrl: dataUrl }], + }])); + const buf = buildGetChatMessageRequestForTests({ + apiKey: "k", + modelUid: "swe-2-medium", + messages: items, + cascadeId: "c1", + sessionId: "s1", + requestId: 1n, + triggerId: "t1", + } as never); + // ImageData: field 10 (tag 0x52), containing base64 (field 1, string) + // and mime_type (field 2, string). The base64 payload is present. + expect(buf.includes(Buffer.from("iVBORw0KGgoAAAANSUhEUg"))).toBe(true); + expect(buf.includes(Buffer.from("image/png"))).toBe(true); + }); +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 8911bc4a1f..46579009ce 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -20,6 +20,7 @@ import { FREE_PROVIDER_DIRECTORY } from "../../src/providers/free-directory"; import { applyProviderConfigHints } from "../../src/codex/catalog"; import { routeModel } from "../../src/router"; import { resolveAdapter } from "../../src/server"; +import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; function nativeTemplate(): Record { @@ -112,7 +113,7 @@ describe("provider registry parity", () => { expect(Object.keys(map ?? {})).toContain("deepseek-flash"); } expect(nativeDeepseek?.preserveReasoningContentModels).toContain("deepseek-flash"); - expect(nativeDeepseek?.noVisionModels).toContain("deepseek-flash"); + expect(nativeDeepseek?.noVisionModels).not.toContain("deepseek-flash"); // The new id keeps the Flash ladder, not the Pro one, through isDeepseekFlashModel. expect(nativeDeepseek?.modelReasoningEfforts?.["deepseek-flash"]) .toEqual(nativeDeepseek?.modelReasoningEfforts?.["deepseek-v4-flash"]); @@ -239,9 +240,9 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.max).toBe("max"); expect(KEY_LOGIN_PROVIDERS.deepseek.preserveReasoningContentModels) .toEqual(["deepseek-flash", "deepseek-v4-flash"]); - // Issue #88: every DeepSeek API model is text-only input — the vision sidecar covers them. + // #4436: first-party Flash accepts images; unprobed compatibility aliases keep the sidecar. expect(KEY_LOGIN_PROVIDERS.deepseek.noVisionModels).toEqual([ - "deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", + "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", ]); }); @@ -456,6 +457,53 @@ describe("provider registry parity", () => { expect(neuralwatt?.preserveReasoningContentModels).not.toContain("moonshotai/Kimi-K2.5"); }); + test("first-party DeepSeek Flash advertises native images without widening gateway aliases (#4436)", () => { + const provider = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "deepseek")!); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelInputModalities?.["deepseek-flash"]).toEqual(["text", "image"]); + expect(provider.modelInputModalities?.["deepseek-flash"]).toEqual(["text", "image"]); + expect(isModelVisionSidecarConsumer(provider, "deepseek-flash")).toBe(false); + expect(isModelVisionSidecarConsumer(provider, "deepseek-v4-flash-vision-exp")).toBe(false); + for (const model of ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"]) { + expect(isModelVisionSidecarConsumer(provider, model)).toBe(true); + } + for (const id of ["opencode-go", "opencode-zen"]) { + const gateway = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === id)!); + expect(isModelVisionSidecarConsumer(gateway, "deepseek-v4.1-flash")).toBe(true); + expect(isModelVisionSidecarConsumer(gateway, "deepseek-v4-flash")).toBe(true); + } + const free = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "opencode-free")!); + expect(isModelVisionSidecarConsumer(free, "deepseek-v4-flash-free")).toBe(true); + // Saved providers without explicit modality overrides inherit the fix during routing. + const config: OcxConfig = { + port: 0, defaultProvider: "deepseek", + providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://api.deepseek.com", authMode: "key" } }, + }; + const route = routeModel(config, "deepseek/deepseek-flash"); + expect(isModelVisionSidecarConsumer(route.provider, route.modelId)).toBe(false); + const model = applyProviderConfigHints("deepseek", route.provider, { provider: "deepseek", id: route.modelId }); + expect(model.inputModalities).toEqual(["text", "image"]); + const catalog = buildCatalogEntries(nativeTemplate(), [], [model]); + expect(catalog.find(entry => entry.slug === "deepseek/deepseek-flash")?.input_modalities).toEqual(["text", "image"]); + + // Existing saved providers that previously persisted the old seed continue using the sidecar + // until deepseek-flash is removed from their saved noVisionModels list. + const legacyConfig: OcxConfig = { + port: 0, defaultProvider: "deepseek", + providers: { + deepseek: { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", authMode: "key", + noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash"], + }, + }, + }; + const legacyRoute = routeModel(legacyConfig, "deepseek/deepseek-flash"); + expect(isModelVisionSidecarConsumer(legacyRoute.provider, legacyRoute.modelId)).toBe(true); + // Once deepseek-flash is removed from saved config, native vision is unlocked. + legacyConfig.providers.deepseek.noVisionModels = ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"]; + const upgradedRoute = routeModel(legacyConfig, "deepseek/deepseek-flash"); + expect(isModelVisionSidecarConsumer(upgradedRoute.provider, upgradedRoute.modelId)).toBe(false); + }); + test("Z.AI and Kimi context aliases route with bracket-suffix stripping", () => { const zai = PROVIDER_REGISTRY.find(entry => entry.id === "zai"); const optedInProviders = PROVIDER_REGISTRY diff --git a/tests/responses/responses-opaque-blob-recovery.test.ts b/tests/responses/responses-opaque-blob-recovery.test.ts index 87eb74f923..d36e67e483 100644 --- a/tests/responses/responses-opaque-blob-recovery.test.ts +++ b/tests/responses/responses-opaque-blob-recovery.test.ts @@ -155,9 +155,11 @@ function serializedOutboundWithEncryptedAgentMessage(): string { } /** - * What a routed destination receives on the retry: recovery has replaced the undecryptable - * part with an omission marker, which leaves the item entirely plaintext, so the adapter - * converts it into the public user message a routed Responses schema can accept. + * What a routed destination receives: the undecryptable part has been replaced with an omission + * marker, which leaves the item entirely plaintext, so the adapter converts it into the public + * user message a routed Responses schema can accept. Since #4454 that repair runs before the + * first dispatch rather than after an upstream rejection, so this is the FIRST body such a + * destination sees, not a retry. */ function recoveredAgentMessage(): Record { return { @@ -171,6 +173,19 @@ function recoveredAgentMessage(): Record { }; } +/** The function-output twin: the reactive repair still owns this item type. */ +function recoveredFunctionOutput(): Record { + return { + type: "function_call_output", + call_id: "call-encrypted-output", + output: [ + { type: "input_text", text: "[encrypted content omitted]" }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ], + }; +} + function config(): OcxConfig { return { defaultProvider: "first", @@ -249,6 +264,43 @@ function agentMessageRequest(stream = false): Request { }); } +/** + * The canonical Codex backend is exempt from the pre-dispatch repair (#4454), because it is the + * one destination that minted this ciphertext and can read it. That keeps + * `prepareOpaqueBlobRecovery`'s `agent_message` arm live exactly where it still makes sense: the + * backend failing to decrypt its own bytes. + */ +function nativeConfig(): OcxConfig { + return { + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + } as OcxConfig; +} + +function nativeAgentMessageRequest(stream = false): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": "thread-native-encrypted-agent-message", + authorization: "Bearer caller-codex-token", + }, + body: JSON.stringify({ + model: "gpt-5.5", + stream, + store: false, + input: agentMessageReplayInput(), + }), + }); +} + function decryptStreamResponse(wire: string, contentType: string | null): Response { // A string body would implicitly add text/plain even when headers are omitted. const response = new Response(new TextEncoder().encode(wire), { @@ -548,11 +600,15 @@ describe("opaque blob recovery through /v1/responses", () => { }); }); - test("retries a ChatGPT agent-message decrypt failure once with an omission marker", async () => { + test("omits agent-message ciphertext before the first dispatch, with no decrypt round trip", async () => { + // This used to send the blob, collect `502 could not be decrypted`, repair, and retry. A + // routed destination was never going to decrypt a ChatGPT-minted blob, so the repair now runs + // first and the rejection never happens (#4454). The transient-5xx retry below is unrelated + // and still carries the already-repaired body. const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { outbound.push(JSON.parse(String(init?.body)) as Record); - return outbound.length <= 3 + return outbound.length <= 1 ? new Response(CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR, { status: 502, headers: { "content-type": "application/json" }, @@ -565,31 +621,79 @@ describe("opaque blob recovery through /v1/responses", () => { expect(response.status).toBe(200); await response.text(); + expect(outbound).toHaveLength(2); + for (const sent of outbound) { + const input = sent.input as Array>; + expect(input.at(0)).toEqual(recoveredAgentMessage()); + expect(JSON.stringify(sent)).not.toContain(FUNCTION_OUTPUT_BLOB); + } + expect((outbound.at(1)?.input as Array>).at(1)) + .toEqual(agentMessageReplayInput().at(1)); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["transient-5xx"]); + }); + + test("omits agent-message ciphertext before the first streamed dispatch", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return streamedSuccess("resp-stream-agent-message-repaired"); + }) as typeof fetch; + + const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.completed"); + expect(outbound).toHaveLength(1); + const sentInput = outbound.at(0)?.input as Array> | undefined; + expect(sentInput?.at(0)).toEqual(recoveredAgentMessage()); + expect(JSON.stringify(outbound.at(0))).not.toContain(FUNCTION_OUTPUT_BLOB); + }); + + test("still retries a ChatGPT agent-message decrypt failure on the canonical backend", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length <= 3 + ? new Response(CHATGPT_FUNCTION_OUTPUT_DECRYPT_ERROR, { + status: 502, + headers: { "content-type": "application/json" }, + }) + : success("resp-native-agent-message-recovered"); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(nativeAgentMessageRequest(), nativeConfig(), logCtx); + expect(response.status).toBe(200); + await response.text(); + expect(outbound).toHaveLength(4); - const retriedInput = outbound.at(3)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); - expect(retriedInput?.at(1)).toEqual(agentMessageReplayInput().at(1)); + // The blob reaches this destination, which is the point of the exemption, and only the + // post-rejection repair takes it back off the wire. + expect(JSON.stringify(outbound.at(0))).toContain(FUNCTION_OUTPUT_BLOB); + expect(JSON.stringify(outbound.at(3))).not.toContain(FUNCTION_OUTPUT_BLOB); + expect(JSON.stringify(outbound.at(3))).toContain("[encrypted content omitted]"); expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["transient-5xx", "opaque-blob-rejection"]); }); - test("recovers a zero-output streamed agent-message decrypt failure before client relay", async () => { + test("still hides a streamed agent-message decrypt failure from the client on the canonical backend", async () => { const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { outbound.push(JSON.parse(String(init?.body)) as Record); return outbound.length === 1 ? streamedFunctionOutputDecryptFailure() - : streamedSuccess("resp-stream-agent-message-recovered"); + : streamedSuccess("resp-native-stream-agent-message-recovered"); }) as typeof fetch; - const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const response = await handleResponses(nativeAgentMessageRequest(true), nativeConfig(), { model: "", provider: "" }); const body = await response.text(); expect(response.status).toBe(200); expect(body).toContain("response.completed"); expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); - const retriedInput = outbound.at(1)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); + expect(JSON.stringify(outbound.at(0))).toContain(FUNCTION_OUTPUT_BLOB); + expect(JSON.stringify(outbound.at(1))).not.toContain(FUNCTION_OUTPUT_BLOB); }); test("recovers a zero-output error-event decrypt failure before client relay", async () => { @@ -601,7 +705,9 @@ describe("opaque blob recovery through /v1/responses", () => { : streamedSuccess("resp-stream-error-event-recovered"); }) as typeof fetch; - const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + // Carried by the function-output fixture: an agent message reaches a routed destination with + // its ciphertext already omitted, so it no longer has a blob for the upstream to reject. + const response = await handleResponses(functionOutputRequest(true), config(), { model: "", provider: "" }); const body = await response.text(); expect(response.status).toBe(200); @@ -609,7 +715,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); + expect(retriedInput?.at(1)).toEqual(recoveredFunctionOutput()); }); for (const streamMode of ["legacy-tee", "eager-relay"] as const) { @@ -644,7 +750,7 @@ describe("opaque blob recovery through /v1/responses", () => { const terminals: string[] = []; let markTerminal!: () => void; const terminal = new Promise(resolve => { markTerminal = resolve; }); - const response = await handleResponses(agentMessageRequest(true), { + const response = await handleResponses(functionOutputRequest(true), { ...config(), streamMode, }, logCtx, { onNativePassthroughTerminal: status => { terminals.push(status); @@ -684,7 +790,7 @@ describe("opaque blob recovery through /v1/responses", () => { return sends === 1 ? streamedFunctionOutputDecryptErrorEvent(true) : streamedSuccess("resp-identity"); }, { preconnect: originalFetch.preconnect }); try { - const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const response = await handleResponses(functionOutputRequest(true), config(), { model: "", provider: "" }); const body = await response.text(); expect(body).toContain("response.completed"); expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); @@ -736,7 +842,7 @@ describe("opaque blob recovery through /v1/responses", () => { : streamedSuccess("resp-missing-ct-error-event-recovered"); }) as typeof fetch; - const response = await handleResponses(agentMessageRequest(true), config(), { model: "", provider: "" }); + const response = await handleResponses(functionOutputRequest(true), config(), { model: "", provider: "" }); const body = await response.text(); expect(response.status).toBe(200); @@ -744,7 +850,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); + expect(retriedInput?.at(1)).toEqual(recoveredFunctionOutput()); }); test("absent Content-Type decrypt stream does not recover a non-stream request", async () => { diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index 9b6eb68d4d..d8b92dfdc7 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -431,7 +431,7 @@ describe("routeModel registry effort defaults", () => { expect(route.provider.modelReasoningEfforts?.["umans-kimi-k2.7"]).toEqual(["low", "medium", "high", "xhigh", "max"]); }); - test("minimal persisted DeepSeek config inherits the registry text-only classification (issue #88)", () => { + test("minimal persisted DeepSeek config inherits registry vision classification (#4436)", () => { const config: OcxConfig = { port: 10100, defaultProvider: "deepseek", @@ -447,7 +447,7 @@ describe("routeModel registry effort defaults", () => { const route = routeModel(config, "deepseek/deepseek-v4-flash"); expect(route.provider.noVisionModels).toEqual([ - "deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", + "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", ]); }); diff --git a/tests/server/v2-agent-message-failfast.test.ts b/tests/server/v2-agent-message-failfast.test.ts index 0d2b77dcdd..0e0dc66a24 100644 --- a/tests/server/v2-agent-message-failfast.test.ts +++ b/tests/server/v2-agent-message-failfast.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { structurallyValidFernetTokens } from "../../src/server/responses/encrypted-payload"; import { handleResponses, hasUnreadableEncryptedAgentTask, @@ -506,3 +508,359 @@ describe("V2 routed agent-message ciphertext guard", () => { expect(forwardedBody).toContain(FERNET_TASK); }); }); + +/** + * #4454. The guard above asks whether the CURRENT worker task is readable, and reads only the + * tail item. The adapter asks whether EVERY part can be lowered onto a public message. An item + * that mixes readable text with ciphertext answers "readable" to the first and "not lowerable" + * to the second, so it passed the guard, kept its private `agent_message` type through the raw + * Responses passthrough, and reached the provider as backend ciphertext plus an item type only + * the Codex backend declares. Position is incidental: a replayed child result simply tends to + * sit mid-history, where the tail-only scan could never have seen it. + * + * The repair is the one the opaque-blob path already applies after an upstream rejection. It + * runs before dispatch here, because a destination that cannot accept the private item was + * never going to answer that request anyway. + */ +describe("routed Responses agent-message ciphertext repair", () => { + function routedResponsesConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "relay", + providers: { + relay: { + adapter: "openai-responses", + baseUrl: "https://relay.example/v1", + authMode: "key", + apiKey: "test-relay-key", + }, + }, + } as OcxConfig; + } + + // The reported destination: the provider-wide adapter is the Chat wire, and the registry moves + // grok-4.6 onto the raw Responses passthrough for an OAuth caller speaking Responses. Reading + // `route.provider.adapter` would miss it, so the repair resolves the same wire override the + // adapter is built from. + function xaiOAuthResponsesConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "xai", + providers: { + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }, + }, + } as OcxConfig; + } + + function mixedChildResult(): Record { + return { + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [ + { type: "input_text", text: "the child finished the migration" }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }; + } + + const userTurn = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + + function captureOutbound(model: string): () => string[] { + const bodies: string[] = []; + globalThis.fetch = (async (_input, init) => { + bodies.push(typeof init?.body === "string" ? init.body : ""); + return Response.json({ + id: "resp_repaired", + object: "response", + status: "completed", + model, + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + return () => bodies; + } + + test("repairs a mixed child result replayed behind a later user turn", async () => { + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", [mixedChildResult(), userTurn]); + + expect(response.status).toBe(200); + expect(outbound()).toHaveLength(1); + const sent = outbound()[0]!; + expect(sent).not.toContain(FERNET_TASK); + expect(sent).not.toContain("gAAAA"); + expect(sent).not.toContain("agent_message"); + expect(sent).toContain("[encrypted content omitted]"); + // The readable half of the item survives: only the bytes nobody could read are replaced. + expect(sent).toContain("the child finished the migration"); + }); + + test("repairs the same shape at the tail, where the readability guard reports readable", async () => { + const input = [mixedChildResult()]; + // The gap itself: this is the guard that was supposed to be the boundary. + expect(hasUnreadableEncryptedAgentTask(input)).toBe(false); + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", input); + + expect(response.status).toBe(200); + expect(outbound()[0]).not.toContain(FERNET_TASK); + expect(outbound()[0]).not.toContain("agent_message"); + }); + + test("omits ciphertext that arrives as text rather than in an encrypted slot", async () => { + // #3021 saw a delegated reply reach the parent as raw `gAAAA...` text. The readability guard + // reports it readable, and the xAI lowering path would have forwarded it as prose. + const input = [{ type: "agent_message", author: "/root/child", recipient: "/root", content: FERNET_TASK }]; + expect(hasUnreadableEncryptedAgentTask(input)).toBe(false); + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", input); + + expect(response.status).toBe(200); + expect(outbound()[0]).not.toContain(FERNET_TASK); + expect(outbound()[0]).toContain("[encrypted content omitted]"); + }); + + test("the reported xAI destination resolves onto the raw Responses wire", () => { + // The repair has to see this destination as the passthrough it becomes, not as the Chat wire + // the provider row names. The dispatch itself needs an OAuth credential this fixture has no + // business minting, so the wire resolution is asserted directly. + const provider = xaiOAuthResponsesConfig().providers.xai!; + expect(resolveWireProtocolOverride("xai", "grok-4.6", provider, "responses").adapter) + .toBe("openai-responses"); + expect(provider.adapter).toBe("openai-chat"); + }); + + test("leaves a fully readable child result exactly as the adapter already lowered it", async () => { + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", [{ + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [{ type: "input_text", text: "the child finished the migration" }], + }, userTurn]); + + expect(response.status).toBe(200); + expect(outbound()[0]).toContain("the child finished the migration"); + expect(outbound()[0]).not.toContain("[encrypted content omitted]"); + expect(outbound()[0]).not.toContain("agent_message"); + }); + + test("leaves a translated Chat destination on its existing path", async () => { + // The private item never reaches that wire: the parser rebuilds the body from messages and + // drops an encrypted part outright, so there is nothing to repair and no marker to add. + let forwardedBody = ""; + globalThis.fetch = (async (_input, init) => { + forwardedBody = typeof init?.body === "string" ? init.body : ""; + return Response.json({ + id: "chatcmpl_routed", + object: "chat.completion", + model: "grok-4.5", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + const response = await post(routedConfig(), "xai/grok-4.5", [mixedChildResult(), userTurn]); + + expect(response.status).toBe(200); + expect(forwardedBody).toContain("the child finished the migration"); + expect(forwardedBody).not.toContain(FERNET_TASK); + expect(forwardedBody).not.toContain("[encrypted content omitted]"); + }); + + test("leaves a forward destination's private item and ciphertext untouched", async () => { + let forwardedBody = ""; + globalThis.fetch = (async (_input, init) => { + forwardedBody = typeof init?.body === "string" ? init.body : ""; + return Response.json({ + id: "resp_native_mixed", + object: "response", + status: "completed", + model: "gpt-5.5", + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + const response = await post(nativeConfig(), "gpt-5.5", [mixedChildResult(), userTurn], { + authorization: "Bearer caller-codex-token", + }); + + expect(response.status).toBe(200); + expect(forwardedBody).toContain(FERNET_TASK); + expect(forwardedBody).toContain("agent_message"); + }); + + test("repairs a noncanonical forward gateway, which is not the backend that minted the bytes", async () => { + // `authMode: "forward"` describes how this proxy treats credentials, not who is on the other + // end. Only the canonical Codex backend can read its own ciphertext, so a forward-configured + // gateway at somebody else's origin is a third party like any other. + const config = { + port: 0, + defaultProvider: "relayfwd", + providers: { + relayfwd: { adapter: "openai-responses", baseUrl: "https://relay.example/v1", authMode: "forward" }, + }, + } as OcxConfig; + const outbound = captureOutbound("relay-model"); + + const response = await post(config, "relayfwd/child-model", [mixedChildResult(), userTurn]); + + expect(response.status).toBe(200); + expect(outbound()[0]).not.toContain(FERNET_TASK); + expect(outbound()[0]).toContain("[encrypted content omitted]"); + }); + + test("repairs a combo child, which carries its own clone of the body", async () => { + // `concreteComboRequestBody` structuredClones the body per target, so a repair applied on the + // parent's own dispatch is invisible here. A combo target that resolves to a routed Responses + // wire has to run the repair itself or it sends the ciphertext the parent no longer does. + const config = { + port: 0, + defaultProvider: "relay", + providers: { + relay: { + adapter: "openai-responses", + baseUrl: "https://relay.example/v1", + authMode: "key", + apiKey: "test-relay-key", + }, + }, + combos: { routed: { strategy: "failover", targets: [{ provider: "relay", model: "child-model" }] } }, + } as OcxConfig; + const outbound = captureOutbound("relay-model"); + + const response = await post(config, "combo/routed", [mixedChildResult(), userTurn]); + + expect(response.status).toBe(200); + expect(outbound()).toHaveLength(1); + expect(outbound()[0]).not.toContain(FERNET_TASK); + expect(outbound()[0]).not.toContain("agent_message"); + expect(outbound()[0]).toContain("[encrypted content omitted]"); + }); + + test("repairs a run split across consecutive encrypted slots", async () => { + // Each half fails structural validation on its own and only the join is a real token. A + // matcher that judged slots individually would forward both halves. + const first = FERNET_TASK.slice(0, 60); + const second = FERNET_TASK.slice(60); + expect(structurallyValidFernetTokens(first)).toEqual([]); + expect(structurallyValidFernetTokens(second)).toEqual([]); + expect(structurallyValidFernetTokens(`${first}${second}`)).toEqual([FERNET_TASK]); + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", [{ + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [ + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child\nPayload:" }, + { type: "encrypted_content", encrypted_content: first }, + { type: "encrypted_content", encrypted_content: second }, + ], + }, userTurn]); + + expect(response.status).toBe(200); + expect(outbound()[0]).not.toContain(first); + expect(outbound()[0]).not.toContain(second); + expect(outbound()[0]).not.toContain("agent_message"); + expect(outbound()[0]).toContain("[encrypted content omitted]"); + }); + + test("repairs a token embedded inside a text part and keeps the prose around it", async () => { + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", [{ + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [{ type: "input_text", text: `the child replied ${FERNET_TASK} and stopped` }], + }, userTurn]); + + expect(response.status).toBe(200); + expect(outbound()[0]).not.toContain(FERNET_TASK); + expect(outbound()[0]).toContain("the child replied [encrypted content omitted] and stopped"); + }); + + test("leaves readable text that only resembles an encoded blob", async () => { + // An `encrypted_content` slot carries ciphertext by definition, so it is stripped whatever it + // holds. A text part does not. Judging text by a loose character class would be worse than the + // defect for that half: a SHA-256 digest is exactly 64 characters of the same alphabet, and a + // child that deliberately printed one would have it silently deleted. + const readable = { + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + sha512: "cf83e1357eefb8bd".repeat(8), + apiKey: `sk-proj-${"A".repeat(120)}`, + }; + for (const [name, text] of Object.entries(readable)) { + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", [{ + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [{ type: "input_text", text: `digest ${text}` }, { type: "input_text", text }], + }, userTurn]); + + expect(response.status, name).toBe(200); + expect(outbound()[0], name).toContain(text); + expect(outbound()[0], name).not.toContain("[encrypted content omitted]"); + } + }); + + test("repairs a token split across adjacent text parts", async () => { + // The text-side twin of the split encrypted slot. The join must still be Fernet-shaped, so + // two ordinary encoded fragments do not become a marker merely by being adjacent. + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", [{ + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [ + { type: "input_text", text: FERNET_TASK.slice(0, 60) }, + { type: "input_text", text: FERNET_TASK.slice(60) }, + ], + }, userTurn]); + + expect(response.status).toBe(200); + expect(outbound()[0]).not.toContain(FERNET_TASK.slice(0, 60)); + expect(outbound()[0]).toContain("[encrypted content omitted]"); + }); + + test("repairs a slot that is not a well-formed token, including standard base64", async () => { + // The original defect reached the wire because an item was not lowerable. Recognizing only + // canonical Fernet would reopen it one payload later: a truncated token, a bad version byte, + // or standard base64 carrying + and / would each keep the item and forward the bytes. + const nearMisses = { + truncated: FERNET_TASK.slice(0, 96), + standardBase64: `gAAA+${"B".repeat(120)}/x==`, + badVersion: `h${FERNET_TASK.slice(1)}`, + }; + for (const [name, blob] of Object.entries(nearMisses)) { + expect(structurallyValidFernetTokens(blob)).toEqual([]); + const outbound = captureOutbound("relay-model"); + + const response = await post(routedResponsesConfig(), "relay/child-model", [{ + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [ + { type: "input_text", text: "visible child result" }, + { type: "encrypted_content", encrypted_content: blob }, + ], + }, userTurn]); + + expect(response.status, name).toBe(200); + expect(outbound()[0], name).not.toContain(blob); + expect(outbound()[0], name).not.toContain("agent_message"); + expect(outbound()[0], name).toContain("visible child result"); + } + }); +}); diff --git a/tests/vision/vision-sidecar-e2e.test.ts b/tests/vision/vision-sidecar-e2e.test.ts index d88b0070df..29c06c4fe9 100644 --- a/tests/vision/vision-sidecar-e2e.test.ts +++ b/tests/vision/vision-sidecar-e2e.test.ts @@ -413,6 +413,52 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); + test.each(["openai-chat", "openai-responses"] as const)("DeepSeek Flash preserves native images on the %s wire without a sidecar call (#4436)", async adapter => { + let upstreamBody = ""; + let sidecarHits = 0; + upstream = adapter === "openai-chat" + ? serveUpstream(b => { upstreamBody = b; }) + : serveResponsesUpstream(b => { upstreamBody = b; }); + sidecar = serveResponsesUpstream(() => { sidecarHits += 1; }); + const deepseek = PROVIDER_REGISTRY.find(entry => entry.id === "deepseek")!; + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "deepseeklike", + providers: { + // Carry the real registry classification to a loopback fixture on each wire. + deepseeklike: { + adapter, authMode: "key", baseUrl: upstream.url.toString().replace(/\/$/, ""), + allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", + noVisionModels: deepseek.noVisionModels, + modelInputModalities: deepseek.modelInputModalities, + }, + helper: { + adapter: "openai-responses", authMode: "key", baseUrl: sidecar.url.toString().replace(/\/$/, ""), + allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", + modelInputModalities: { "vision-model": ["text", "image"] }, + }, + }, + visionSidecar: { enabled: true, backend: "routed", model: "helper/vision-model" }, + }; + saveConfig(config); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify(baseRequest("deepseeklike/deepseek-flash")), + }); + expect(res.status).toBe(200); + expect(sidecarHits).toBe(0); + const body = JSON.parse(upstreamBody); + const content = adapter === "openai-chat" ? body.messages[0].content : body.input[0].content; + expect(content).toContainEqual(adapter === "openai-chat" + ? expect.objectContaining({ type: "image_url", image_url: expect.objectContaining({ url: PNG_DATA_URL }) }) + : expect.objectContaining({ type: "input_image", image_url: PNG_DATA_URL })); + expect(upstreamBody).not.toContain("[image omitted"); + } finally { + await server.stop(true); + } + }); + /* * #1043 activation evidence. The registry classification is only useful if the * strip actually fires for a Zen model, so this drives the real path with the diff --git a/tests/web-search/web-search-passthrough-bridge.test.ts b/tests/web-search/web-search-passthrough-bridge.test.ts index 655c16831b..3bfe28ce2d 100644 --- a/tests/web-search/web-search-passthrough-bridge.test.ts +++ b/tests/web-search/web-search-passthrough-bridge.test.ts @@ -14,6 +14,8 @@ import { createPassthroughWebSearchBridgeStream, planPassthroughWebSearchBridge, resolveOllamaWebSearchEndpoint, + resolvePassthroughWebSearchBridgeAuth, + shouldResolveOpenAiPassthroughWebSearchBridge, WEB_SEARCH_BRIDGE_ERROR_CODE, WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE, type PassthroughWebSearchBridgePlan, @@ -146,7 +148,7 @@ describe("planPassthroughWebSearchBridge arming", () => { )).toBeUndefined(); }); - test("backends without a shipped executor stay inert rather than falling back", () => { + test("backends without resolved credentials stay inert rather than falling back", () => { for (const backend of ["openai", "anthropic", "xai", "gemini", "exa"] as const) { expect(planPassthroughWebSearchBridge( parsedFixture(), @@ -182,6 +184,82 @@ describe("planPassthroughWebSearchBridge arming", () => { expect(plan?.maxSearches).toBe(3); expect(plan?.timeoutMs).toBe(60_000); }); + + test("an openai backend arms only when the ChatGPT sidecar is present", () => { + const provider = providerFixture({ enabled: true, backend: "openai" }, { baseUrl: "https://gateway.example/v1" }); + expect(planPassthroughWebSearchBridge(parsedFixture(), provider, { + isPassthrough: true, + stream: true, + })).toBeUndefined(); + const openAiSidecar = { + providerName: "openai" as const, + provider: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + accountMode: "direct" as const, + authContext: { kind: "main" as const, accountId: null }, + headers: new Headers({ authorization: "Bearer chatgpt" }), + }; + const planned = planPassthroughWebSearchBridge(parsedFixture(), provider, { + isPassthrough: true, + stream: true, + auth: { openAiSidecar }, + }); + expect(planned).toEqual({ backend: "openai", maxSearches: 3, timeoutMs: 60_000 }); + expect(shouldResolveOpenAiPassthroughWebSearchBridge(provider, parsedFixture(), true)).toBe(true); + expect(shouldResolveOpenAiPassthroughWebSearchBridge(providerFixture(armed), parsedFixture(), true)).toBe(false); + }); + + test("sidecar backends arm only with their own credential handle", () => { + const gateway = { baseUrl: "https://gateway.example/v1" }; + const anthropic = { providerName: "claude", provider: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" } }; + const xai = { providerName: "xai", provider: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "oauth" } }; + const gemini = { providerName: "google-antigravity", provider: { adapter: "google-antigravity", baseUrl: "https://cloudcode-pa.googleapis.com", authMode: "oauth" } }; + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "anthropic" }, gateway), + { isPassthrough: true, stream: true, auth: { anthropic } }, + )?.backend).toBe("anthropic"); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "xai" }, gateway), + { isPassthrough: true, stream: true, auth: { xai } }, + )?.backend).toBe("xai"); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "gemini" }, gateway), + { isPassthrough: true, stream: true, auth: { gemini } }, + )?.backend).toBe("gemini"); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "exa" }, gateway), + { isPassthrough: true, stream: true, auth: { exaApiKey: "exa-canary" } }, + )?.backend).toBe("exa"); + // A named backend does not borrow a different credential. + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "exa" }, gateway), + { isPassthrough: true, stream: true, auth: { anthropic, xai, gemini } }, + )).toBeUndefined(); + expect(planPassthroughWebSearchBridge( + parsedFixture(), + providerFixture({ enabled: true, backend: "openai" }, gateway), + { isPassthrough: true, stream: true, auth: { exaApiKey: "exa-canary" } }, + )).toBeUndefined(); + }); + + test("resolvePassthroughWebSearchBridgeAuth inspects only the named backend", () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: {}, + webSearchSidecar: { exaApiKey: "exa-canary" }, + } as unknown as OcxConfig; + expect(resolvePassthroughWebSearchBridgeAuth("exa", cfg)).toEqual({ exaApiKey: "exa-canary" }); + expect(resolvePassthroughWebSearchBridgeAuth("openai", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("anthropic", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("xai", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("gemini", cfg)).toEqual({}); + expect(resolvePassthroughWebSearchBridgeAuth("ollama", cfg)).toEqual({}); + }); }); const plan: PassthroughWebSearchBridgePlan = { @@ -390,6 +468,137 @@ describe("the bridged client stream", () => { expect((cell!.item as Record).status).toBe("failed"); }); + test("already-hosted web_search_call items pass through without a proxy search", async () => { + let sends = 0; + let executes = 0; + const hosted = { + type: "web_search_call", + id: "ws_hosted", + status: "completed", + action: { type: "search", query: "latest status" }, + }; + const hostedLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...hosted, status: "in_progress" } }), + frame("response.output_item.done", { output_index: 0, item: hosted }), + frame("response.output_item.added", { output_index: 1, item: { ...answer, content: [] } }), + frame("response.output_item.done", { output_index: 1, item: answer }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [hosted, answer] }, + }), + ); + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(hostedLeg), + requestBody: initialBody, + send: async () => { + sends += 1; + return new Response(null, { status: 500 }); + }, + execute: async () => { + executes += 1; + return { text: "unused", sources: [] }; + }, + }); + const body = await new Response(stream).text(); + expect(sends).toBe(0); + expect(executes).toBe(0); + expect(body).toContain("\"type\":\"web_search_call\""); + expect(body).toContain("The current release is 2.50.0."); + expect(body).not.toContain("response.failed"); + }); + + test("probe B mixed hosted cells plus exec plus web_search still fail closed", async () => { + let sends = 0; + let executes = 0; + const hosted = { + type: "web_search_call", + id: "ws_hosted", + status: "completed", + action: { type: "search", query: "already searched" }, + }; + const execCall = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"cmd\":\"python fetch.py\"}", + }; + const probeB = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...hosted, status: "in_progress" } }), + frame("response.output_item.done", { output_index: 0, item: hosted }), + frame("response.output_item.added", { output_index: 1, item: { ...execCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 1, item: execCall }), + frame("response.output_item.added", { output_index: 2, item: { ...searchCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 2, item: searchCall }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [hosted, execCall, searchCall] }, + }), + ); + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(probeB), + requestBody: initialBody, + send: async () => { + sends += 1; + return new Response(null, { status: 500 }); + }, + execute: async () => { + executes += 1; + return { text: "unused", sources: [] }; + }, + }); + const body = await new Response(stream).text(); + expect(sends).toBe(0); + expect(executes).toBe(0); + expect(body).not.toContain("\"name\":\"exec\""); + const failed = clientEvents(body).find(event => event.type === "response.failed"); + expect((failed!.response as { error: Record }).error.code) + .toBe(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + }); + + test("DeepSeek-style XML assistant text is not dispatched as a search", async () => { + let sends = 0; + let executes = 0; + const xmlAnswer = { + type: "message", + id: "msg_xml", + role: "assistant", + content: [{ + type: "output_text", + text: "I'll search for that information now.\n\n\nDeepSeek V4.1-Flash API price\n\n\nI don't have a web_search tool available.", + }], + }; + const xmlLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...xmlAnswer, content: [] } }), + frame("response.output_item.done", { output_index: 0, item: xmlAnswer }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [xmlAnswer] }, + }), + ); + const stream = createPassthroughWebSearchBridgeStream({ + plan, + firstLeg: streamFromText(xmlLeg), + requestBody: initialBody, + send: async () => { + sends += 1; + return new Response(null, { status: 500 }); + }, + execute: async () => { + executes += 1; + return { text: "unused", sources: [] }; + }, + }); + const body = await new Response(stream).text(); + expect(sends).toBe(0); + expect(executes).toBe(0); + expect(body).toContain(""); + expect(body).toContain("DeepSeek V4.1-Flash API price"); + expect(body).not.toContain("response.failed"); + expect(clientEvents(body).some(event => + event.type === "response.output_item.added" + && (event.item as Record).type === "web_search_call")).toBe(false); + }); + test("a search that is not the last item keeps its streamed position", async () => { // The model searches first and keeps talking; the hosted cell must open where the call stood. const leg = sseBody( @@ -583,21 +792,37 @@ describe("the reported turn, end to end through handleResponses", () => { outbound: string[]; destinations: Array<{ url: string; authorization: string | null }>; searches: number; + searchUrls: string[]; + searchHeaders: Array<{ url: string; authorization: string | null; xApiKey: string | null }>; }> { const savedFetch = globalThis.fetch; const outbound: string[] = []; const destinations: Array<{ url: string; authorization: string | null }> = []; + const searchUrls: string[] = []; + const searchHeaders: Array<{ url: string; authorization: string | null; xApiKey: string | null }> = []; let searches = 0; let leg = 0; globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; - if (url.includes("/api/web_search")) { + if (url.includes("/api/web_search") || url.includes("api.exa.ai/search")) { searches += 1; + searchUrls.push(url); + const headers = new Headers(init?.headers); + searchHeaders.push({ + url, + authorization: headers.get("authorization"), + xApiKey: headers.get("x-api-key"), + }); hooks.onSearch?.(); return new Response(JSON.stringify({ - results: [{ title: "Releases", url: "https://example.test/rel", content: "opencodex 2.50.0" }], + results: [{ + title: "Releases", + url: "https://example.test/rel", + content: "opencodex 2.50.0", + text: "opencodex 2.50.0", + }], }), { headers: { "content-type": "application/json" } }); } outbound.push(String(init?.body ?? "")); @@ -610,10 +835,10 @@ describe("the reported turn, end to end through handleResponses", () => { try { const response = await handleResponses(new Request("http://localhost/v1/responses", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", authorization: "Bearer caller-inbound" }, body: clientRequest, }), ocxConfig, { model: "", provider: "" }); - return { body: await response.text(), outbound, destinations, searches }; + return { body: await response.text(), outbound, destinations, searches, searchUrls, searchHeaders }; } finally { globalThis.fetch = savedFetch; } @@ -816,4 +1041,91 @@ describe("the reported turn, end to end through handleResponses", () => { expect(result.body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); expect(result.body).toContain("frobnicate"); }); + + test("an exa-backed gateway executes hosted-only search without the ollama origin", async () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "fixture-key", + webSearchBridge: { enabled: true, backend: "exa" }, + }, + }, + webSearchSidecar: { exaApiKey: "exa-canary" }, + } as unknown as OcxConfig; + const result = await post(cfg, [searchLeg(), answerLeg()]); + expect(result.searchUrls).toEqual(["https://api.exa.ai/search"]); + expect(result.searchHeaders).toEqual([ + { url: "https://api.exa.ai/search", authorization: null, xApiKey: "exa-canary" }, + ]); + expect(result.body).not.toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(result.body).toContain("\"type\":\"web_search_call\""); + expect(result.body).not.toContain("\"name\":\"web_search\""); + expect(result.body).toContain("The current release is 2.50.0."); + expect(result.destinations.map(destination => destination.url)).toEqual([ + "https://gateway.example/v1/responses", + "https://gateway.example/v1/responses", + ]); + expect(result.destinations.every(destination => destination.authorization === "Bearer fixture-key")).toBe(true); + }); + + test("an exa-backed mixed exec/search turn still fails closed", async () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "fixture-key", + webSearchBridge: { enabled: true, backend: "exa" }, + }, + }, + webSearchSidecar: { exaApiKey: "exa-canary" }, + } as unknown as OcxConfig; + const execCall = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{}", + }; + const mixedLeg = sseBody( + frame("response.output_item.added", { output_index: 0, item: { ...searchCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 0, item: searchCall }), + frame("response.output_item.added", { output_index: 1, item: { ...execCall, arguments: "" } }), + frame("response.output_item.done", { output_index: 1, item: execCall }), + frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [searchCall, execCall] }, + }), + ); + const result = await post(cfg, [mixedLeg]); + expect(result.searches).toBe(0); + expect(result.body).toContain(WEB_SEARCH_BRIDGE_MIXED_TOOLS_ERROR_CODE); + expect(result.body).not.toContain("\"name\":\"exec\""); + }); + + test("exa without a key stays disarmed on a non-ollama gateway", async () => { + const cfg = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "fixture-key", + webSearchBridge: { enabled: true, backend: "exa" }, + }, + }, + } as unknown as OcxConfig; + const result = await post(cfg, [searchLeg()]); + expect(result.searches).toBe(0); + expect(result.body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); });