commit 17d56da6b3808a59155d2afbc938cd8484cc43b7 from: Isaac Meerleo date: Fri Jul 17 14:45:29 2026 UTC finish sandbox, terminal, and Codex design updates commit - babda3eaa7c8cc9a67a59ab6e6de39a31910713f commit + 17d56da6b3808a59155d2afbc938cd8484cc43b7 blob - /dev/null blob + 49e397a2af6dd576dd3c51bd0798fae3a53ce7d2 (mode 644) --- /dev/null +++ .scratch/codex-subscription-adapter/issues/01-implement-pure-c-codex-subscription-adapter.md @@ -0,0 +1,336 @@ +# Implement the pure-C Codex subscription Adapter + +Status: needs-triage +Type: task + +## What to build + +Add a fourth Provider, `codex`, that lets the one Fugu user consume their +personal ChatGPT Codex subscription from Fugu without per-token Platform API +billing and without adding Node.js, Python, an SDK, the Codex executable, +app-server, a proxy, or another daemon. + +This is one experimental compatibility route through Fugu's existing Provider +Seam. Fugu remains the agent: the Coordinator owns the canonical Projection, +Turn mechanics, Tool Palette, Journal, and resume behavior. The new Adapter +only translates model requests and Responses events. + +The implementation must follow: + +- `docs/research/codex-subscription-native-clients.md` +- `docs/design/codex-subscription.md` + +## User story + +As Fugu's sole user, I want to run Codex models against the ChatGPT +subscription I already pay for, while preserving Fugu's native OpenBSD +dependency ceiling, process isolation, Tool authorization, durable Turns, and +single-user release pattern. + +## Research verdict + +The direct path is technically feasible. OpenAI's Rust client and independent +TypeScript, Go, and Python clients converge on the same behavior: + +- ChatGPT authentication selects + `https://chatgpt.com/backend-api/codex/responses` rather than the metered + Platform endpoint. +- The request uses a bearer access token plus `ChatGPT-Account-ID`, whose value + comes from a namespaced claim in the token. +- Requests use the Responses format with `stream:true`, `store:false`, full + history, and `include:["reasoning.encrypted_content"]`. +- Current OpenAI prefers WebSocket but retains HTTP SSE fallback; maintained + direct clients still demonstrate SSE. +- Encrypted reasoning is continuation state, not display-only thinking. +- Responses function calls carry both a canonical `call_id` and an `fc_...` + output-item id. The latter may be paired with the encrypted reasoning item + and must survive replay without leaking into canonical Tool ids. +- Mature clients durably persist replacement refresh credentials after OAuth + refresh. In-memory refresh without atomic persistence is not safe. + +No maintained pure-C direct client was found. Implement the behavior cleanly +against pinned sources and Fugu fixtures; do not port another project's +runtime, request builder, stream parser, credential store, or WebSocket stack. + +The raw ChatGPT backend is not a documented third-party API. This route must +remain explicitly experimental and fail closed on profile drift. + +## Fixed decisions + +### Provider and configuration + +The minimal route is: + +```conf +provider "codex" +model "" +oauth_token "" +``` + +Named routes use `type "codex"` with the same model and token fields. + +- Add `PROVIDER_CODEX`; do not overload the existing metered `openai` + Provider. +- Keep the token directly in protected Fugu configuration. +- Extract the one account id from the token in `fugu-api`; add no `owner`, + account selector, account pool, or account-routing plumbing. +- Reject `api_key`, `api_key_file`, `api_host`, `api_port`, and `api_path` for + this Provider. +- Never fall back to a Platform API key or another Provider. +- Reject unsupported FedRAMP/data-residency/compute-residency token profiles + before sending a request rather than guessing routing headers. + +### Compatibility profile + +Create one compile-time profile, initially +`chatgpt-codex-2026-07-17`, containing: + +- fixed host `chatgpt.com`, port `443`, and path + `/backend-api/codex/responses`; +- the pinned OpenAI Codex source commit and snapshot date; +- JWT claim paths and bounds; +- exact account, user-agent, session, request, and content headers; +- exact request flags, event names, terminal mappings, and usage fields; and +- model-specific request differences proven by fixtures. + +Use `User-Agent: fugu/`. Do not claim +`originator: codex_cli_rs` or another first-party identity. Start with no +optional originator; only an honest `fugu` value may be considered through a +reviewed profile update. If honest identification is rejected, stop the +Adapter instead of probing fingerprints. + +Do not send cookies, redirects, `previous_response_id`, a Platform key, or the +WebSocket beta header. Never follow a bearer credential through an HTTP +redirect. + +### Request and stream Adapter + +Implement one deep `codex_sub` Module split only for Role linkage: + +- credential-free request translation linked into the Coordinator and + read-only Subagent Role; +- JWT/account extraction, credentialed headers, Responses SSE decoding, and + bounded error handling linked only into `fugu-api`. + +The request builder must: + +- render the Fugu system block as Responses instructions; +- render canonical user/assistant text, function calls, and function-call + outputs from the Projection without mutating it; +- advertise only Fugu's offered function Tools, never Codex built-ins; +- set `stream:true`, `store:false`, request encrypted reasoning, and send full + history; +- omit `previous_response_id` and, for the first profile, + `max_output_tokens`; +- preserve Fugu's `call_id` as the canonical Tool id while adding a matching + provider `fc_...` item id only from validated continuity state; and +- use deterministic, nonsecret session/prompt-cache metadata. + +Because the observed backend rejects or ignores the normal output-token field, +`max_tokens` is not an exact provider-side output limit in v1. Document this +plainly; Fugu's independent response-byte and Turn caps remain authoritative. + +The dedicated Responses SSE decoder must handle output text, function calls, +fragmented arguments, usage, completed reasoning items, completion, failure, +incomplete responses, malformed sequences, every byte split, and all existing +Fugu bounds. Unknown output-item or terminal semantics fail closed. + +Start with the existing HTTP/1.1 libevent/libtls transport. Do not add +WebSocket in this ticket. If the opt-in live gate proves SSE is no longer +accepted, stop and create a separately reviewed WebSocket ticket. + +### Durable provider continuity + +Extend `struct msg` with one optional generic provider-state attachment, +separate from canonical text and Tool blocks. The logical envelope is: + +```text +provider = codex +profile = chatgpt-codex-2026-07-17 +model = +kind = responses-continuity +data = { + reasoning: [, ...], + calls: [, ...] +} +``` + +- Retain only final reasoning items with nonempty encrypted content and final + function-call identity records. +- Cross-check `response.output_item.done` with any copy in + `response.completed.output`; duplicate conflicts fail. +- Bound the complete attachment to 2 MiB per Generation on every side of the + stream/imsg/Generation/Journal/request seams. +- Credential-reflection filter it before it leaves `fugu-api`. +- Send it in generic `FUGU_IMSG_A_PROVIDER_STATE` chunks immediately before a + successful terminal event. +- Attach it only to a normally completed assistant message. +- Encode opaque bytes as a JSON string in the Journal; never splice raw + provider JSON into NDJSON record syntax. +- Make it atomic with the assistant message under existing Turn commit and + abandonment rules. +- Preserve it on committed resume; discard it with messages on abandonment, + `/clear`, and `/compact`. +- Keep Subagent state only in the Subagent's existing in-memory conversation. +- Replay only exact Provider/profile/model matches. Other Providers ignore it. +- Join call metadata to canonical Tool blocks by exact `call_id`; provider + state never supplies Tool names or arguments and never gains Tool authority. +- Do not concatenate the two Responses ids into `block.tool_id`. + +Provider-state bytes count toward the serialized 16 MiB request ceiling but +not the visible Turn data budget or Context token meter. + +### Credential lifetime + +The first experimental slice accepts one access token from `oauth_token` and +fails hard when its JWT expiry has passed or the backend returns 401/403. +Replacement is manual. + +Do not add in-memory refresh. Refresh can rotate the refresh token, while +`fugu-api` has no durable credential store and must not send replacement +secrets back to the Coordinator. Automatic device login and refresh require a +separate credential-custody design with atomic persistence, crash recovery, +and new pledge/unveil proofs. + +## Security architecture + +The existing Role topology must remain intact: + +- `fugu-api` is the sole long-lived model-credential Custodian and sole Role + parsing the hostile subscription network stream. +- The production endpoint is compile-time fixed and independently checked by + `fugu-api` after HELLO. +- The Coordinator briefly relays the configured token during startup, wipes + its copy, and never receives it again. +- `fugu-api` retains only `stdio inet dns`; it gains no file write, open, or + execution authority. +- The Coordinator receives canonical events and bounded, credential-filtered + continuity bytes, not an OAuth token or endpoint selector. +- Every model-selected Tool still passes through the existing offered-Palette, + imsg, Turn, and kernel-confinement checks. +- Journals remain mode 0600 and contain no credential. +- No Worker gains a new channel or capability. + +Before making the Provider selectable, add a new ADR and update the charter, +invariants, `CONTEXT.md`, behavior, and verification language that currently +names only the fixed Anthropic subscription endpoint or only three Providers. +Do not silently broaden the Claude-specific ADR-0007. + +## Implementation sequence + +Implement as reviewable commits in this order: + +1. Evidence, proposed ADR, and normative contract changes; no selectable code. +2. Generic provider-state attachment, Generation/imsg carrier, Journal + transaction/replay, bounds, and hermetic tests; existing Providers do not + emit or consume it. +3. Pure request/profile/SSE codecs with synthetic text, Tool, usage, terminal, + and continuity fixtures; still not selectable. +4. `PROVIDER_CODEX`, config validation, fixed endpoint, JWT/account parsing, + headers, redaction, wiping, and one text-only local TLS path. +5. Complete Lead Tool round with encrypted-state replay, fresh-process resume, + and abandoned-turn exclusion. +6. Named routing, configured-model discovery, read-only Subagents, + cancellation, retries, and accounting. +7. Explicitly opt-in credentialed live gate with honest client identity. +8. Manuals, sample configuration, live-check target, linkage/package checks, + and full normal/hardened release gates. + +Each commit keeps Anthropic, OpenAI, and Claude Subscription regressions green. +No intermediate commit selects `codex` by default. + +## Acceptance criteria + +- [ ] A new accepted ADR authorizes the experimental fixed OpenAI subscription + endpoint, direct configured access token, manual expiry behavior, honest + client identity, and no metered fallback. +- [ ] `provider "codex"` and named `type "codex"` routes parse, print with + secrets redacted, wipe secrets, and reject API-key/endpoint override forms. +- [ ] A synthetic access JWT yields exactly one bounded account header; + malformed, expired, ambiguous, oversized, residency-routed, NUL, and CR/LF + cases fail before network use. +- [ ] The Coordinator and Subagent request fixtures pin exact Responses JSON + for text, mixed Tool history, Tool errors, suffixes, compaction history, + continuity metadata, and mismatch omission. +- [ ] The SSE decoder passes adversarial split, ordering, duplicate, conflict, + malformed JSON/UTF-8, usage, terminal, and every cap test. +- [ ] Canonical Tool ids remain Responses `call_id`; provider item ids survive + only in validated provider state and replay with their paired reasoning. +- [ ] Continuity state is capped at 2 MiB, reflection-filtered, invisible to the + UI, transactionally journaled, resumable, and removed by abandonment, + `/clear`, and `/compact`. +- [ ] A fresh-process local TLS fixture completes + `user -> function call -> function result -> final assistant text` using the + journaled continuity state and no `previous_response_id`. +- [ ] Only `fugu-api` adds bearer/account headers; captured Coordinator request + bodies and every imsg other than startup secret custody remain credential + free. +- [ ] 401/403 fail once with a bounded replacement hint; 429/5xx use existing + pre-output retry policy; no condition triggers a metered request or account + rotation. +- [ ] Input/cache usage does not double-count cached tokens, and Subagent usage + remains excluded from the Lead Context display. +- [ ] Existing ktrace and hardened checks prove that `fugu-api` still cannot + open, write, or execute and that no new Role gains authority. +- [ ] An opt-in, non-root live check proves HTTP SSE, honest Fugu identity, one + text response, one harmless `read` Tool round, and continuity capture without + exposing token, account id, prompt, or raw response in terminal output, + Journal, syslog, or diagnostics. +- [ ] README/manual/config examples explain the experimental unsupported + contract, direct token placement, expiry replacement, no exact + `max_tokens` enforcement, fixed endpoint, and lack of API fallback. +- [ ] Normal, hardened, package/linkage, and release live gates pass without + Node, Python, an SDK, Codex, app-server, proxy, or added `WANTLIB` entries. + +## Triage decisions + +1. Accept manual access-token replacement at JWT expiry for the first + experimental release, or require a separate credential-persistence design + before the Provider becomes selectable. +2. Accept the fixed honest HTTP identity policy: Fugu user-agent, no optional + originator initially, and fail closed if first-party identity is required. +3. Accept the 2 MiB continuity-state cap and omission of provider-side + `max_output_tokens` for the first compatibility profile. +4. Select the first configured Codex model and Effective context window for the + credentialed fixture; do not add model discovery in this ticket. + +## Code-cost analysis + +Baseline, including durable continuity: + +| Work | Production | Regressions/docs | +|---|---:|---:| +| Request/profile/JWT helpers | 500-800 C lines | 500-800 lines | +| Responses decoder and state extraction | 650-1,000 C lines | 850-1,300 lines | +| Generic state carrier, imsg, Generation, Journal | 450-750 C lines | 650-1,000 lines | +| Config/custody/routing integration | 300-500 C lines | 400-700 lines | +| Whole-Turn/Subagent fixtures and release docs | 150-300 C/shell lines | 600-1,000 lines | +| **Total** | **2,050-3,350** | **3,000-4,800** | + +Not included: + +- durable device login/refresh: approximately 700-1,300 production and + 700-1,200 regression/documentation lines; +- WebSocket transport: approximately 600-1,000 production and 700-1,200 + adversarial test lines. + +The Responses decoder, provider-state lifecycle, credentialed whole-Role +fixture, and live profile gate dominate risk. The provider enum and parser +grammar are not the expensive parts. + +## Evidence + +- [OpenAI Codex source snapshot](https://github.com/openai/codex/tree/315195492c80fdade38e917c18f9584efd599304) +- [OpenAI ChatGPT-plan guidance](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan) +- [pi direct Responses request](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/api/openai-codex-responses.ts#L482-L585) +- [pi reasoning and function-call replay](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/api/openai-responses-shared.ts#L170-L228) +- [openai-oauth stateless direct request](https://github.com/EvanZhouDev/openai-oauth/blob/ec7dab2fcd8dab9da970a7a2b5dc34046c94905e/packages/core/src/runtime.ts#L570-L663) +- [Docker Agent credential refresh](https://github.com/docker/docker-agent/blob/1ec6c9a1835ed4d9476960faabda57c1107a7d22/pkg/chatgpt/token.go#L35-L121) + +## Blocked by + +None - ADR and hermetic implementation can start immediately. Credentialed +live acceptance requires explicit Owner opt-in and a current personal token. + +## Comments + blob - /dev/null blob + 0da8205ea342c0aabcd71a680123c13b631dc6c1 (mode 644) --- /dev/null +++ .scratch/interactive-welcome/issues/01-dismissing-empty-transcript-welcome.md @@ -0,0 +1,100 @@ +# Add a dismissible empty-transcript welcome to the curses interface + +Status: needs-triage +Type: task + +## What to build + +Replace the unsolicited Claude Subscription profile warning in the curses +transcript with a presentation-only, Vim-inspired welcome shown while the +interactive transcript has not yet received Owner input. The welcome is +centered in the transcript viewport and uses this copy: + +```text +I like Fugu — Japanese for pufferfish. +by Isaac Meerleo +git ssh://anonymous@got.fugu.farm +Fugu is open source and freely available! + +type / to explore commands +type /model to choose a model +press Esc, then v to compose in your editor +type /quit to exit +``` + +Unused transcript rows remain blank; do not copy Vim's `~` gutter markers. + +While the welcome is visible, add one plain-language caption row immediately +above the persistent status bar. Align `agent state`, `input mode`, and +`context used / limit` above the corresponding `ready`/working marker, +`[insert]`/`[normal]` marker, and `Context current/max N%` field. These captions +are welcome-screen help, not a permanent extra status row, and disappear with +the welcome. + +The welcome is a local `fugu-tty` rendering state, not a transcript item. It +must never enter the Lead Projection, Session Journal, scrollback, queue, or +machine-readable output. It disappears permanently for the invocation as soon +as an editing action first makes the prompt non-empty, including ordinary +typing, bracketed paste, completion, recalled text, or an external-editor +result. Deleting that text must not make the welcome reappear. + +Remove the current long profile warning only from curses startup so it cannot +become the first transcript item and suppress the welcome. Keep the explicit +risk disclosure in `fugu -n`, manuals/README, the accepted subscription ADR, +and profile-specific failure diagnostics. Keep line and print mode's stderr +startup warning unchanged. This preserves ADR-0007's experimental-route +disclosure while making the blank interactive surface quiet. + +## Acceptance criteria + +- [ ] Starting the curses interface with an empty on-screen transcript shows + the exact welcome copy centered horizontally and approximately vertically in + the transcript viewport; the editor, queue area, and status bar retain their + normal geometry. +- [ ] Empty rows contain spaces only: no Vim-style `~` markers or fake buffer + coordinates are rendered. +- [ ] The byline is exactly `by Isaac Meerleo`, and the repository location is + exactly `git ssh://anonymous@got.fugu.farm`. +- [ ] A caption row labels the live bottom fields as `agent state`, `input + mode`, and `context used / limit`, aligned over the values they describe and + omitted after welcome dismissal. +- [ ] The first edit that makes the prompt non-empty dismisses the welcome and + captions before submission. Typing then deleting, cancelling the line, + `/clear`, resize, or a failed Turn never resurrects them during that + invocation. +- [ ] Ordinary typing, bracketed paste, slash completion, history recall, and + accepted external-editor text all use the same one-way dismissal rule. +- [ ] Opening the drawer or model picker before typing overlays the welcome + normally; closing it restores the still-undismissed welcome without adding + scrollback content. +- [ ] Narrow terminals omit lower-priority hint/caption lines rather than + overlap, wrap into the editor, or displace the status bar. Resize recomputes + centering without changing dismissal state. +- [ ] `ascii yes` transliterates the em dash consistently, and color and + monochrome terminals remain readable without making the welcome look like + model output. +- [ ] Configuring a default or named `claude` Provider no longer inserts + `experimental Claude subscription profile claude-code-2.1.185 ...` into the + curses transcript. Line/print warnings, redacted `fugu -n` profile output, + manuals, ADR-0007, and profile-naming failure tests remain intact. +- [ ] Renderer tests cover centering, blank rows, captions, narrow/resize + behavior, ASCII mode, and one-way dismissal. A curses PTY regression proves + the welcome is visible before input, gone after the first prompt begins, and + absent from the Journal and Provider request. +- [ ] `handoff/behavior.md`, `docs/design/m7-curses.md`, `fugu(1)`, and the + verification matrix document the presentation-only welcome and warning + placement. + +## Code-cost analysis + +Expected size: **small-to-medium, roughly 220-420 lines including tests and +documentation, across 2-4 focused commits**. The visual copy is small; most of +the cost is keeping centering, narrow-terminal fallback, status captions, and +one-way dismissal independent from transcript and Journal state. + +## Blocked by + +None - can start immediately. + +## Comments + blob - /dev/null blob + 20e317c55c98227ffc193a3ccf09f7efb3011c69 (mode 644) --- /dev/null +++ docs/design/codex-subscription.md @@ -0,0 +1,606 @@ +# Experimental pure-C Codex subscription adapter + +Status: proposed design, 2026-07-17. No production code has been written. +Protocol constants and the evidence behind them are pinned in +[`docs/research/codex-subscription-native-clients.md`](../research/codex-subscription-native-clients.md). + +## Outcome + +Add a fourth Provider type, `codex`, that sends the Owner's personal ChatGPT +Codex subscription traffic directly from OpenBSD. The route is implemented in +C, uses only base-system libraries, and requires no Node.js, Python, Codex +executable, app-server, SDK, proxy, or daemon at runtime. + +The first experimental slice uses a directly configured access token and +requires manual replacement when it expires. Automatic OAuth login and refresh +are deliberately outside this Adapter because rotated refresh credentials need +a new durable-custody design. This limitation is acceptable for proving the +native inference path, but it must be prominent in setup documentation. + +This is not the existing `openai` Provider under another credential. The two +routes remain deliberately distinct: + +- `openai` is the supported, configurable `/v1/chat/completions` dialect using + a Platform API key and usage-based billing; +- `codex` is an experimental, fixed-endpoint compatibility profile using a + personal ChatGPT Codex credential and subscription quota. + +There is no fallback between them. A stale Codex profile, expired credential, +quota rejection, or incompatible response fails the Generation without making +a metered Platform request. + +## Hard constraints + +The implementation must preserve all of these together: + +1. OpenBSD-only C and base libraries; the port's `WANTLIB` ceiling remains + `c curses event tls util`. +2. No language runtime, SDK, Codex child process, app-server, local proxy, or + listening socket. +3. Fugu remains the agent. The Coordinator owns Turn mechanics and the + Projection; Codex supplies only model inference. +4. `fugu-api` remains the sole model-credential Custodian and the only Role + that parses the hostile subscription network stream. +5. Every model-selected Tool still settles through Fugu's existing Palette, + imsg checks, and kernel-confined Tool Roles. +6. The production inference host and path are compile-time profile data and + cannot be redirected by configuration. +7. The credential is stored directly in the protected Fugu configuration. + No Owner/account-routing plumbing or credential-store discovery is added. +8. Subscription mode is personal and interactive. It does not add hosting, + account pooling, quota aggregation, or traffic for another human. +9. The route is visibly experimental and versioned. It never probes alternate + fingerprints or attempts to evade quotas, safety controls, or account + enforcement. +10. A `store:false` conversation remains resumable from Fugu's Journal. Opaque + Codex reasoning continuity is bounded and journaled transactionally; no + hidden server-side conversation id becomes authoritative. + +## The seam + +The existing Provider seam is real: Anthropic, OpenAI Chat Completions, and +Claude Subscription already translate the same canonical Projection. Codex +Subscription is a fourth Adapter at that seam. Do not add a generic Provider +vtable merely to remove four small switches; that would expose wire-specific +state and make the interface shallower. + +The Codex Subscription adapter is one deep Module with an interface split by +Role linkage: + +```c +/* Credential-free: linked into the Coordinator and read-only Subagent Role. */ +int codex_sub_build_request(struct buf *, const struct codex_sub_req *); + +/* Credentialed/hostile: linked only into fugu-api. */ +int codex_sub_build_headers(char *, size_t, const struct codex_sub_auth *, + const struct codex_sub_wire *); +struct codex_sub_stream *codex_sub_stream_new(astream_cb, void *); +int codex_sub_stream_feed(struct codex_sub_stream *, const void *, size_t); +const char *codex_sub_stream_error(const struct codex_sub_stream *); +void codex_sub_stream_free(struct codex_sub_stream *); +``` + +`struct codex_sub_req` borrows only the model, output ceiling, system +instructions, Tool definitions, canonical Projection, optional suffix, and +non-secret session/request metadata. Callers do not know Codex item shapes, +header names, event names, or compatibility constants. The stream emits the +existing `astream_event` vocabulary plus one generic provider-state span, so +Lead and Subagent Turn mechanics do not acquire Codex branches. + +Internally, keep profile constants, request translation, JWT/account +extraction, credentialed headers, and Responses event decoding in separate C +files for least linkage. They are one Module conceptually; those internal +seams exist because the Coordinator must not link the hostile parser and the +Custodian must not link canonical Projection construction. + +## Configuration and credential contract + +The minimal configuration is intentionally the same shape as Claude +Subscription: + +```conf +provider "codex" +model "" +oauth_token "" +``` + +For named routes: + +```conf +provider "personal-codex" { + type "codex" + model "" + oauth_token "" +} +``` + +For `codex`, reject `api_key`, `api_key_file`, `api_host`, `api_port`, and +`api_path`. Do not add a configurable account selector. The credentialed half +decodes the bounded JWT payload and extracts the one ChatGPT account identifier +carried by the token; absence, ambiguity, an unsupported claim shape, or an +expired token fails closed before inference. + +The first profile supports an ordinary personal account only. If token claims +indicate FedRAMP, data-residency, compute-residency, or another routing regime, +Fugu reports that the account profile is unsupported and sends nothing. It +does not guess or omit a routing header required by that account. + +This local JWT parse is not signature verification and must never be described +as authentication. Fugu has no reason to import an RSA/JWK stack or use raw +libcrypto: the token comes from protected configuration over the trusted +startup path, and the fixed OpenAI TLS endpoint remains authoritative for its +signature, entitlement, account match, and expiry. Local issuer/audience/expiry +checks are profile-consistency diagnostics plus header-injection defense. Every +decoded claim is still length-bounded, type-checked, and NUL/CR/LF-rejected. + +The fixture must also prove that the current token fits `FUGU_SECRET_MAX` and +the bounded HELLO payload. If it does not, increase those bounds deliberately +and add chunk/aggregate tests; never truncate a JWT or quietly accept an +unbounded auth bundle. + +Parse the token once in `fugu-api` while accepting HELLO, before any Provider +request. Retain the raw token plus only the bounded account identifier and +expiry needed by the wire profile. The Coordinator never decodes JWT claims, +and request builders never receive either value. + +### Why v1 does not refresh automatically + +A personal ChatGPT access token is expiring state. Refresh can also produce a +replacement refresh token. Persisting that replacement safely is not a small +HTTP feature: + +- `fugu-api` cannot open or write files after loading the CA store (I1/I9); +- the Coordinator can write its already-open Journal but must not receive a + model credential back from the Custodian (I1/I2); +- `/etc/fugu.conf` is commonly root-owned and group-readable, not writable by + the invoking process; +- rewriting an arbitrary yacc configuration with includes, macros, and + comments would itself be a new security-sensitive configuration editor. + +Therefore the first profile accepts one already-provisioned access token and +fails hard when it expires or receives an authentication rejection. Token +replacement is manual, like the current Claude profile. The provisioning +procedure must never print another field from a credential store or retain a +token in the repository, shell history, Journal, test fixture, or logs. + +A later, separately reviewed `fugu-codex-auth` utility may implement the +documented device flow and print a configuration stanza to the controlling +terminal. It would be a short-lived, non-setgid C program with `stdio inet +dns`, no listener, no project access, and no ability to edit the configuration. +It is not part of the inference adapter and is not required for the first live +slice. + +Do not implement in-memory refresh until the upstream rotation contract is +known and there is a persistence design that keeps the replacement credential +inside one Custodian. Silently discarding a rotated refresh token would make a +successful session break the next invocation. + +## Compatibility profile + +Create `src/common/codex_sub.h` and keep every volatile constant in +`codex_sub_profile.c`: + +- profile identifier `chatgpt-codex-2026-07-17` and snapshot date; +- pinned upstream Codex version and commit used as the behavioral reference; +- fixed inference host `chatgpt.com`, TLS port `443`, and path + `/backend-api/codex/responses`; +- observed OAuth issuer/audience and JWT claim paths used for bounded local + profile-consistency checks (not signature verification); +- required bearer, account, user-agent, session, and request headers; +- required Responses request flags and include fields; +- model-specific request differences, if the evidence proves any; +- event names and finish-status mappings that are part of the observed + profile. + +The HTTP profile identifies the client with `User-Agent: fugu/` and +does not send `originator` initially. Current direct-client evidence shows that +the header is not intrinsic to bearer/account authentication, while reports +about accepted values conflict. Fugu never sends `originator: codex_cli_rs` or +another first-party identity. If the live gate requires an originator, only an +honest `fugu` value is eligible; rejection of that value stops the Adapter. + +The SSE profile does not send the WebSocket-only `OpenAI-Beta` value. Headers +are a single compile-time profile, not a retry matrix. The implementation does +not vary capitalization, user-agent, beta values, or client identity in search +of an accepted fingerprint. + +The production endpoint check follows `provider_endpoint_ok()` for Claude: +only the compiled host/port/path is accepted. A non-setgid-only +`FUGU_REGRESS_CODEX_PORT` substitution may target `127.0.0.1` for TLS fixtures; +`fugu-api` independently validates that substitution after HELLO. + +Updating the profile is a source change. It requires new exact fixtures, the +full normal and hardened gates, and an explicit personal live check. Fugu does +not negotiate a profile, scrape the installed Codex version, or retry with +alternate headers. + +## Request translation + +`codex_sub_build_request()` re-renders the full canonical Projection into the +observed Responses input format. It never mutates or relinks canonical +messages. The implementation owns all of these details behind its interface: + +1. The composed Fugu system block becomes the profile's `instructions` + representation. +2. User text becomes Responses user input text. +3. Assistant text becomes Responses assistant output text. +4. Canonical Tool use becomes a function-call item. Canonical Tool result + becomes the matching function-call-output item. Fugu's canonical Tool id is + the Responses `call_id`; a separate `fc_...` output-item id, when present, + comes only from the matching provider-state attachment. +5. Tool definitions become only the function tools Fugu offered for this + Generation. Built-in Codex shell, patch, web, computer, and MCP tools are + never advertised. +6. `stream` is true, `store` is false, and `previous_response_id` is absent. + The request asks for `reasoning.encrypted_content`. +7. The pinned first profile omits `max_output_tokens`: current direct clients + remove it for Codex compatibility. Fugu's independent Turn and response + byte ceilings remain authoritative. Consequently, `max_tokens` is not a + provider-side output-token limit for `codex` v1; the manual and ADR must say + so rather than implying exact enforcement. +8. Reasoning and text options use one pinned profile default. Configurable + thinking effort belongs to the separate model-thinking ticket and does not + widen this slice. +9. Prompt-cache/session metadata is deterministic per Fugu Session and never + contains a credential, path, prompt excerpt, invoking username, or account + identifier. +10. A matching opaque continuity state attached to an assistant message + restores reasoning items and function-call item ids while rendering text, + Tool names, arguments, and results from canonical blocks. Mismatched + Provider, profile, or model state is omitted. + +Tool names remain canonical unless the pinned fixture proves a reversible wire +mapping is required. Never copy Codex's built-in tool definitions or system +prompt: Fugu is the agent and its Palette is the authority. + +### Opaque continuation state + +Encrypted reasoning is required v1 state, not a discovery gate. OpenAI and +independent clients request, retain, and replay the completed reasoning item so +that a `store:false` function-call conversation can continue. Responses also +uses both a canonical `call_id` and a provider output-item id such as `fc_...`; +independent clients preserve the pair because reasoning/function-call pairing +can be validated by those item ids. Fugu carries both through a generic +envelope while keeping Codex item semantics inside the Adapter. + +Extend `struct msg` with an optional provider-state attachment separate from +its canonical content blocks. The attachment is one bounded byte string with +this logical envelope: + +```text +provider = codex +profile = chatgpt-codex-2026-07-17 +model = +kind = responses-continuity +data = { + reasoning: [, ...], + calls: [, ...] +} +``` + +The envelope is generic infrastructure; `msg` and the Journal copy, free, and +round-trip it but do not understand the data. It is not a text block, Tool +block, UI event, summary, or token credential. Other Provider request builders +ignore it. `codex_sub_build_request()` is the only credential-free consumer and +revalidates the envelope and each raw JSON object before emitting state that +matches the active Provider, profile, and model. + +The request Adapter joins call metadata to canonical Tool blocks by exact +`call_id`; it never trusts state for the Tool name or arguments. Missing state +may omit an optional item id, but duplicate, conflicting, unknown, or +many-to-one mappings fail request construction. The completed output indices +preserve reasoning/function-call order. Do not concatenate ids into +`block.tool_id`: Tool execution and Tool results continue to use only the +canonical `call_id`. + +The hostile path is explicit: + +1. `codex_sub_stream` accepts only final reasoning and function-call metadata; + partial items are never retained. A reasoning item requires nonempty + `encrypted_content`; a call record requires bounded, distinct `call_id` and + provider item id. It normally takes `response.output_item.done`. If + `response.completed.output` repeats an item, identity and content must agree; + if terminal output is the only final copy, it supplies the item. Duplicate + or conflicting copies fail. +2. It emits `ASTREAM_PROVIDER_STATE` records to `fugu-api`. The Custodian + validates each complete record, applies credential-reflection filtering, + and builds one deterministic continuity object under a 2 MiB + per-Generation cap. +3. Immediately before a successful `A_DONE`, `fugu-api` sends the envelope in + `FUGU_IMSG_A_PROVIDER_STATE` chunks. `generation` buffers it under the same + cap and attaches it only when building a normally completed assistant + message. +4. `turn_txn_accept()` journals the assistant message and its attachment before + Projection visibility. Existing Turn commit/abandon replay rules therefore + make the state atomic with the Tool call that needs it. + +Journal `msg` records encode the opaque data as a JSON string, not as raw +record syntax. Replay requires the exact provider/profile/model/kind fields, a +single valid data object, unique output/call/item identities, and all +byte/count bounds. A malformed attachment +invalidates the containing Turn just as a malformed Tool block does; it is +never partially recovered. + +Lead state survives a committed resume and is discarded by abandonment, +`/clear`, and `/compact` with the messages it describes. Subagent state lives +in the Subagent's existing in-memory conversation for its Tool rounds and is +freed with that conversation; it never enters the Lead Journal. State from an +older profile or another model remains inert history and is not replayed to the +backend. + +The state bytes count toward the 16 MiB serialized request ceiling but not the +visible 4 MiB Turn text/Tool budget or Context token meter. The independent 2 +MiB state cap prevents opaque reasoning from consuming the full request or +Journal-record allowance. + +Do not substitute `previous_response_id`. Hidden server state would make +resume fidelity, `/clear`, crash recovery, and retention semantics depend on +the backend. + +## Streaming translation + +`codex_sub_stream` is a dedicated Responses SSE decoder, not a mode in the +Chat Completions decoder. It incrementally parses bounded `data:` events and +emits the existing canonical `astream_event` values plus the generic +`ASTREAM_PROVIDER_STATE` carrier described above. No Codex event type crosses +the seam. + +The decoder must: + +- accept output-text deltas with explicit lengths; +- correlate function calls by bounded output index and call identifier; +- buffer fragmented function names and argument JSON under the existing + per-call and aggregate Tool caps; +- validate every completed argument as exactly one JSON object before exposing + any completed Tool batch; +- map one completed response to exactly one `ASTREAM_DONE`; +- map response failure, cancellation, or incomplete terminal status to a + bounded Provider error rather than success; +- emit at most one usage event, with nonnegative bounded counters; +- treat Codex `input_tokens` as inclusive of cached input and expose cached + input only as the `cache_read` detail, matching the current OpenAI accounting + convention; +- reject output Tool kinds Fugu did not offer; +- retain only completed reasoning items and function-call identity records, + require nonempty encrypted content and distinct bounded ids, and preserve + their response order in one bounded continuity object; +- ignore only well-formed informational events explicitly classified by the + profile; unknown terminal or output-item semantics fail closed; +- reject data after a terminal event, duplicate terminals, conflicting item + identities, malformed UTF-8/JSON where the existing JSON contract requires + it, embedded NUL in identifiers, and every cap overrun. + +Reasoning summary text is not assistant output. It is ignored unless a future +Fugu thinking interface explicitly adopts it. Opaque reasoning bytes use the +provider-state carrier and never cross the existing text event. + +All decoded Tool names still pass through the existing canonical Turn and Tool +authorization path before work can execute; `fugu-api` performs the hostile +identifier, sequence, bound, and credential-reflection checks but does not gain +Tool authority. Credential-reflection filtering covers assistant text, Tool +identifiers, Tool arguments, error bodies, stop reasons, and every configured +subscription token. + +## Transport, retries, and accounting + +Reuse the existing HTTP/1.1 libevent/libtls request state machine and Responses +SSE. Codex does not add a socket type, daemon, proxy, cookie jar, HTTP library, +or WebSocket implementation in v1. OpenAI's current client prefers WebSocket, +but it and independent clients retain SSE fallback. If the live endpoint no +longer accepts SSE, implementation stops for a separate WebSocket design; it +does not silently grow a second transport inside this slice. + +- `Authorization: Bearer` and all profile identity/account headers are built + only in `fugu-api`, immediately before the HTTP request head. +- No cookies, Platform API key, `previous_response_id`, first-party + `originator`, or WebSocket beta header are sent. +- Redirects fail closed. A bearer token is never followed to a Location chosen + by the backend, even when the destination appears to be another OpenAI host. +- HTTP 429 and 5xx before the first canonical output event use Fugu's existing + five-attempt backoff and bounded `Retry-After` handling. +- A 401/403 is never refreshed, retried with another identity, or sent to the + Platform endpoint. It names the active profile and tells the Owner the token + may need replacement. +- Once text, a Tool call, or another externally visible canonical event has + streamed, failure ends the Generation; it is never replayed automatically. +- Subscription quota headers may be retained in bounded diagnostic state, but + they are not token counts and do not alter the Context meter. +- `PROVIDER_CODEX` follows OpenAI accounting: input tokens already include + cached input, so `lead_context_tokens()` must not add `cache_read` again. + Subagent usage remains excluded from the Lead's Context display. + +Model discovery is not assumed. `/model` contributes the configured Codex +model and its compiled/configured Effective window, just as Claude Subscription +does. No subscription token is sent to `/v1/models`. + +## Security review + +The design preserves the existing Role topology: + +```text +canonical Projection hostile subscription SSE + | | +Coordinator / Subagent v + | credential-free JSON fugu-api Custodian + +------------------------------>| | + | | | fixed TLS endpoint + | canonical events + bounded | | Bearer + account headers + | validated provider state | v + |<------------------------------+ ChatGPT Codex backend + v +mode-0600 Lead Journal +``` + +No Worker gains a new channel. The Coordinator briefly holds the configured +token only during startup, passes it once through HELLO, wipes its buffers and +configuration copy, and never receives it again. `fugu-api` retains `stdio +inet dns`, cannot open or write files, cannot execute, and remains the sole +Role linked with libtls and the hostile Codex stream parser. + +The only new data crossing back from the Custodian is the bounded reasoning +envelope. It is not an OAuth token, cannot select a Tool or endpoint, is +credential-reflection filtered, and is never displayed. The Coordinator treats +live bytes opaquely; journal replay and the request Adapter independently +revalidate their exact JSON envelope before they can be sent back to the same +fixed Provider/profile. This adds data handling, not authority, to the +Coordinator. + +The profile does require normative wording changes before acceptance: + +- invariant I1 currently names only a fixed Anthropic subscription endpoint; + it must permit each accepted Subscription adapter's separately fixed TLS + endpoint; +- the charter currently names only Claude Subscription in the native-runtime + and experimental-protocol clauses; +- `CONTEXT.md` defines Subscription adapter as Claude-specific; +- behavior and verification currently enumerate three Provider routes. + +Those are specification amendments, not implementation details. Record the +decision in a new ADR rather than silently broadening ADR-0007, which remains +the Claude-specific decision. + +## Source-change map + +Expected production changes: + +| Area | Files | Change | +|---|---|---| +| Profile interface | `src/common/codex_sub.h`, `codex_sub_profile.c` | Fixed profile, token/account validation, constants | +| Request codec | `src/common/codex_sub_req.c` | Canonical Projection to Responses input | +| Stream codec | `src/common/codex_sub_stream.c` | Responses SSE to `astream_event` | +| Credentialed wire | `src/common/codex_sub_wire.c` | Bearer/account/profile headers and bounded errors | +| State carrier | `src/common/msg.[ch]`, `generation.[ch]`, `provider_stream.h` | Generic bounded attachment and event span | +| Durable state | `src/fugu/journal.c`, `turn_txn.c`, `proto.h` | Transactional encoding/replay and chunked imsg | +| Provider taxonomy | `src/fugu/proto.h`, `conf.[ch]`, `parse.y`, `agentcfg.c` | `PROVIDER_CODEX`, config validation, route availability | +| Lead/Subagent | `src/fugu/coord.c`, `src/fugu-tool/agent.c` | Invoke the credential-free request Adapter | +| Custodian | `src/fugu-api/main.c` | Fixed endpoint validation, auth bundle, stream selection, accounting | +| Build | three role Makefiles, `regress/Makefile` | Link only the required profile half | +| Documentation | README, manuals, CONTEXT, handoff, ADR, live checks | Experimental contract and setup | + +Do not combine this work with a general Provider registry refactor, Responses +support for Platform API keys, configurable reasoning effort, automatic +compaction, or UI redesign. + +## Verification + +Add `regress/codex_subscription/` with fixture-driven tests for: + +1. exact request JSON for text, mixed text/Tool history, Tool-only assistant + output, Tool errors, steers, compaction suffixes, empty optional fields, + matching continuity state, preserved function-call item ids, and omitted + mismatched profile/model state; +2. exact fixed headers with a synthetic JWT and account claim; wrong issuer, + audience, expiry, claim type, NUL, CR/LF, oversize token, and ambiguous + account fail closed; +3. every SSE byte split, multi-event reads, interleaved function calls, + fragmented names/arguments, usage, normal completion, explicit failure, + incomplete response, duplicate/conflicting items, output after terminal, + completed versus partial reasoning items, multiple reasoning items, + `call_id`/item-id pairing and conflicts, unknown Tool types, malformed JSON, + embedded NUL, and every byte/count cap; +4. credential reflection split across adjacent text, Tool, reasoning-state, + error, and HTTP-body fragments; +5. config acceptance/redaction/wiping for implicit and named `codex` routes, + and rejection of API keys, endpoint overrides, match-block secrets, missing + token, and silent fallback; +6. full TLS worker tests proving the Coordinator sends credential-free JSON, + only `fugu-api` adds auth, Lead and Subagent Tool rounds complete, 401/403 + fail once, 429/5xx follow bounded retry, and cancellation preserves the Turn + transaction; +7. provider-state imsg chunking, normal-completion attachment, Journal + round-trip, fresh-process Tool continuation, abandoned-turn exclusion, + `/clear`, `/compact`, corrupted records, profile/model mismatch, and the 2 + MiB cap on both sides of each seam; +8. model switching, Context accounting without cache double-counting, cold + cache state, and configured-model-only discovery; +9. existing ktrace proofs that the Custodian cannot open, write, or execute. + +Fixtures contain only synthetic tokens and captured response shapes scrubbed +of prompts, account identifiers, request identifiers, and credentials. Exact +fixture provenance records the upstream version/commit and capture date. + +The opt-in live check uses the ordinary Owner's configured route and spends +subscription quota deliberately. It performs one exact text response and one +harmless `read` Tool round, confirms HTTP SSE with the honest fixed header +profile, and verifies that the completed continuity state is present in the +mode-0600 Journal while no configured token appears in terminal output, +Journal, syslog, or captured diagnostics. It never runs in `make check` and +never runs as root. + +## Implementation slices + +Land the work as small, independently reviewable commits: + +1. **Evidence and ADR.** Accept the profile risk, fixed endpoints, manual token + lifetime, and normative wording changes. No code. +2. **Provider-state carrier.** Add the generic `msg` attachment, Generation and + imsg chunking, Journal transaction/replay, caps, and hermetic regressions. + No existing Provider emits or consumes it. +3. **Pure codec.** Add profile/request/stream Modules for text, function calls, + usage, terminal events, and reasoning state. Synthetic regressions exercise + the entire codec; no configuration path can select it. +4. **Credential custody.** Add `PROVIDER_CODEX`, config validation, fixed HELLO + endpoint, JWT/account extraction, header attachment, redaction, and 401 + behavior. A text-only local TLS fixture proves the complete Role path. +5. **Canonical Tool and resume loop.** Prove one Lead Tool round, state replay + on the continuation request, committed resume in a fresh process, and + abandonment without state leakage. +6. **Subagents and routing.** Add named route selection, configured-model + discovery, concurrent read-only Subagents, cancellation, and accounting. +7. **Credentialed live gate.** With explicit Owner opt-in, prove the exact SSE + profile, account header, text response, Tool continuation, encrypted-state + capture, and quota/error behavior. Stop if honest identity is rejected. +8. **Release surface.** Manuals, sample configuration, profile disclosure, + live-check opt-in, package/linkage checks, and full normal/hardened gates. +9. **Provisioning follow-up.** Only after the adapter is stable, decide whether + a separate pure-C device-flow utility is worth its authentication surface. + +Each commit keeps all existing direct Anthropic, OpenAI, and Claude +Subscription regressions green. No intermediate commit silently selects the +new route as a default. + +## Cost estimate + +Opaque provider state is included in the baseline: + +| Work | Production | Regressions/docs | +|---|---:|---:| +| Request/profile/JWT helpers | 500-800 C lines | 500-800 test lines | +| Responses stream decoder and state extraction | 650-1,000 C lines | 850-1,300 test lines | +| Generic state carrier, imsg, Generation, and Journal | 450-750 C lines | 650-1,000 test lines | +| Config/custody/routing integration | 300-500 C lines | 400-700 test lines | +| Whole-Turn/Subagent fixtures and release docs | 150-300 C/shell lines | 600-1,000 test/doc lines | +| **Total** | **2,050-3,350** | **3,000-4,800** | + +A device-flow provisioning utility plus durable refresh store is another +roughly 700-1,300 production lines and 700-1,200 regression/doc lines because +atomic credential persistence, rotation, crash recovery, and pledge/unveil +proofs dominate the HTTP exchange. A WebSocket fallback would separately add +roughly 600-1,000 C lines and 700-1,200 adversarial test lines. Neither is in +the v1 estimate. + +The stream decoder and credentialed whole-Role fixtures dominate risk, not the +provider enum or configuration grammar. The first useful tracer bullet is +therefore one fixed-profile, text-only Generation through a local TLS stub, +followed immediately by the real subscription text-and-Tool live gate. + +## Acceptance gates + +Research closes the static questions: the account id comes from the token; +the fixed endpoint is known; Responses fields, SSE events, and encrypted-state +continuity are pinned; and multiple direct implementations prove that an SDK +process is unnecessary. Implementation still has these stop/go gates: + +| Gate | How it closes | Failure consequence | +|---|---|---| +| Normative authorization | New ADR and charter/invariant wording accept the experimental OpenAI endpoint and manual token lifetime | Do not make `codex` selectable | +| Token envelope | Synthetic and real tokens fit the configured secret/HELLO bounds; issuer, audience, expiry, account, and residency claims match the profile | Increase bounds deliberately or reject the profile; never truncate | +| Honest HTTP identity | Opt-in live request succeeds over HTTP SSE with Fugu's fixed user-agent, no cookies, and no first-party impersonation | Stop; do not probe identities | +| Model/request profile | Configured model accepts the exact instructions, tools, reasoning defaults, and omitted output ceiling | Pin a reviewed source change or stop | +| Durable Tool continuation | Local TLS and live Tool rounds replay completed encrypted state; fresh-process resume sends the same bounded state | Do not release Tool-enabled Codex support | +| Manual-token usability | The Owner accepts replacement at JWT expiry for the experimental release | Design credential persistence before release | +| Security gates | Normal, hardened, ktrace, secret-reflection, Journal, and package checks all pass | Do not ship the route | + +No credentialed experiment may log a real token, full prompt, account +identifier, or unredacted response. A failed gate narrows or stops the design; +it does not justify fingerprint probing, account rotation, or metered fallback. blob - 4bf8c11f404c33c0431efa925ed9dcb13de14855 blob + d246d795a3bbf6ae26e363738c23e91767f82a41 --- docs/design/m5-tools.md +++ docs/design/m5-tools.md @@ -47,7 +47,11 @@ file inherited by tool roles; this avoids the imsg pay The worker validates and `pread`s the snapshot, unveiling every path with empty permissions before any untrusted input is touched (I5/I9). The working directory is inherited through `fork`, so no cwd path is -sent. +sent. Every tool role also unveils `/dev/null` with `rwc` permissions so +ordinary shell redirection works without generating an unveil-denial +accounting record. The `c` permission is needed because shells open output +redirections with `O_CREAT`; it applies only to that exact device path and +does not expose any other path. ### Why the subprocess pledge is broad @@ -132,8 +136,8 @@ offered here. - **tools** — unit-level executor behavior on a temp tree under the real unveil/pledge: read windowing and caps, atomic mode-preserving write, edit uniqueness, multi_edit all-or-nothing, shell cap and - timeout, grep/find/ls caps, NUL rejection, freshness staleness, - protect ENOENT-shaping. + timeout, `/dev/null` redirection, grep/find/ls caps, NUL rejection, + freshness staleness, protect ENOENT-shaping. - **sandbox** — ktrace/kdump PLDG records: a `shell` subprocess that attempts `connect` trips a pledge violation (I4); an out-of-tree write and a write to a `protect` path fail ENOENT-shaped (I5); under blob - b276003cb91cfc767e9147af854ff9031fdd8fab blob + a2d0f85c129b3981c63a794afb366ca4adefbf63 --- docs/design/m7-curses.md +++ docs/design/m7-curses.md @@ -106,6 +106,10 @@ bracketed paste (`ESC[200~` … `ESC[201~`) collected event (NUL rejected, other ESC stripped, 64 KiB cap, overflow discarded with a notice). Decoded events feed the pure editor/UI model; nothing about input depends on `wgetch`, so it is testable. +`Ctrl-L` records a reflow-stable display boundary and forces a physical clear. +The tail view starts after that boundary, while scrolling up can still reach +earlier lines. It sends no coordinator message and does not alter conversation +context, journal state, current input, prompt history, or queued messages. Bracketed-paste mode is enabled on taking the terminal and disabled on every teardown path the process survives; a pledge-violation death blob - /dev/null blob + 48ff278dc4595ac90125f107827d360be3388b88 (mode 644) --- /dev/null +++ docs/research/codex-subscription-native-clients.md @@ -0,0 +1,278 @@ +# Native clients for ChatGPT Codex subscription inference + +Research snapshot: 2026-07-17. + +## Answer + +A pure-C Codex subscription Adapter is technically feasible. The observed +inference path is ordinary HTTPS plus JSON and a Responses event stream; it +does not intrinsically require the Codex executable, Node.js, Python, or an +OpenAI SDK. Several independent clients perform the same subscription +inference directly in TypeScript or Go. + +No maintained pure-C implementation was found in repository and code searches +for the endpoint, account header, OAuth client, and encrypted-reasoning field. +That is not proof that none exists. It does mean Fugu cannot sensibly vendor +or port a known C library: it must implement and test a small compatibility +profile against the primary sources. + +The important qualification is contractual rather than technical. OpenAI +documents ChatGPT-plan access through Codex and documents app-server/SDK +integration, but it does not document the raw ChatGPT backend as a stable +third-party API. A direct client is therefore an unsupported, volatile +compatibility implementation. It must be experimental, identify itself as +Fugu, use one personal account, and fail closed rather than impersonating +Codex, probing fingerprints, pooling accounts, or falling back to metered API +billing. + +## Research method + +The assessment used: + +- current OpenAI product documentation for the supported boundary; +- a pinned checkout of OpenAI's Codex source as the primary wire reference; +- pinned source checkouts of independent clients, following the actual request, + stream, history, and refresh paths rather than README claims; +- broad GitHub/web searches for the endpoint, `ChatGPT-Account-ID`, + `reasoning.encrypted_content`, Codex OAuth, and C implementations. + +No real credential, account identifier, prompt, or response was captured for +this report. A credentialed Fugu fixture remains an explicit implementation +gate. + +## Supported product boundary + +OpenAI says Codex is included with eligible ChatGPT plans and that signing in +with ChatGPT is the way to use that allowance. Supplying a Platform API key is +a different authentication mode billed through the API account. The +documented integration surfaces are the Codex +[app-server](https://learn.chatgpt.com/docs/app-server) and +[SDK](https://learn.chatgpt.com/docs/codex-sdk); the SDK starts a Codex runtime +and its supported language packages are not a native C inference library. See +also [Using Codex with your ChatGPT plan](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan). + +That supported route conflicts with Fugu's two key constraints: Fugu remains +the agent, and the model-credential Custodian does not gain filesystem or +execution authority. The direct Adapter is useful precisely because it keeps +those constraints, but it receives no stability guarantee from the product +documentation. + +## Primary wire reference: OpenAI Codex + +Reference checkout: + +- repository: [openai/codex](https://github.com/openai/codex) +- commit: [`315195492c80fdade38e917c18f9584efd599304`](https://github.com/openai/codex/tree/315195492c80fdade38e917c18f9584efd599304) +- license: [Apache-2.0](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/LICENSE) + +The source establishes the following profile: + +1. ChatGPT authentication selects + `https://chatgpt.com/backend-api/codex`, while API-key authentication uses + the Platform endpoint. The provider's wire API is Responses + ([provider selection](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/model-provider-info/src/lib.rs#L35-L60), + [auth-dependent URL](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/model-provider-info/src/lib.rs#L241-L259)). +2. Requests carry a bearer access token and, when present, the ChatGPT account + identifier. Residency-aware accounts add a separate routing header + ([auth headers](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/model-provider/src/bearer_auth_provider.rs#L31-L45)). +3. The token bundle contains access, ID, and refresh tokens. The account id is + read from the namespaced ChatGPT auth claim, not from Fugu configuration + routing + ([token and claim model](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/login/src/token_data.rs#L10-L160)). +4. The request is `store:false`, streams a full Responses input, and asks for + `reasoning.encrypted_content` + ([request shape](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/codex-api/src/common.rs#L215-L239), + [client construction](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/core/src/client.rs#L890-L907)). +5. The decoder handles text, function calls, reasoning items, usage, completed, + failed, and incomplete Responses events + ([SSE event mapping](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/codex-api/src/sse/responses.rs#L327-L472)). +6. Current Codex prefers a Responses WebSocket when available but retains an + HTTP event-stream fallback + ([transport selection and fallback](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/core/src/client.rs#L1766-L1825)). +7. Access tokens are refreshed proactively and the resulting access, ID, and + refresh tokens are persisted together + ([refresh decision](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/login/src/auth/manager.rs#L2506-L2528), + [persist and reload](https://github.com/openai/codex/blob/315195492c80fdade38e917c18f9584efd599304/codex-rs/login/src/auth/manager.rs#L2593-L2610)). + +The last point matters architecturally: automatic refresh is a durable +credential-state feature, not merely another HTTP request. + +## Independent implementations + +| Project | Runtime | Direct subscription path | Transport | Durable/continuation behavior | +|---|---|---|---|---| +| [pi-mono](https://github.com/badlogic/pi-mono/tree/64f83c85d9f36c831e96b29477750f92cd5a2230) | TypeScript | Yes | WebSocket first, SSE fallback | Persists and replays encrypted reasoning with full history | +| [openai-oauth](https://github.com/EvanZhouDev/openai-oauth/tree/ec7dab2fcd8dab9da970a7a2b5dc34046c94905e) | TypeScript | Yes | HTTP Responses/SSE | Expands cached references into bounded, stateless full history | +| [docker-agent](https://github.com/docker/docker-agent/tree/1ec6c9a1835ed4d9476960faabda57c1107a7d22) | Go | Yes | WebSocket with pooled connection state | Refreshes and atomically stores rotated credentials | +| [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI/tree/9f4f53ca5a4d1474e3f7eb61d6ffc984995f1f66) | Go | Yes, behind a translation proxy | SSE and WebSocket | Explicit reasoning-replay cache and invalid-signature recovery | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent/tree/abc22cdf1a5c0fe30bf1a226bfe3caf489e8316e) | Python | Yes; it also offers app-server mode | Responses transport | Persists endpoint-tagged reasoning and its own refresh-token pair | + +### pi-mono + +pi is the clearest small direct-client reference. Its request sets +`store:false`, streams full input, asks for encrypted reasoning, and uses +WebSocket with SSE fallback +([request body](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/api/openai-codex-responses.ts#L482-L585), +[SSE and WebSocket paths](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/api/openai-codex-responses.ts#L693-L803)). +Its shared Responses history code explicitly retains the encrypted item so a +stateless `store:false` request can continue +([reasoning replay](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/api/openai-responses-shared.ts#L392-L408), +[capture on completion](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/api/openai-responses-shared.ts#L533-L591)). +Its OAuth implementation persists access, refresh, expiry, and account state +([OAuth exchange and refresh](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/auth/oauth/openai-codex.ts#L126-L187), +[account extraction and storage](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/auth/oauth/openai-codex.ts#L395-L414)). + +### openai-oauth + +This project is useful because its current path is stateless HTTP rather than +a Codex wrapper. It forces `store:false`, `stream:true`, instructions, and +encrypted reasoning, then sends directly to the ChatGPT backend +([request normalization](https://github.com/EvanZhouDev/openai-oauth/blob/ec7dab2fcd8dab9da970a7a2b5dc34046c94905e/packages/core/src/runtime.ts#L570-L663), +[auth headers and request](https://github.com/EvanZhouDev/openai-oauth/blob/ec7dab2fcd8dab9da970a7a2b5dc34046c94905e/packages/core/src/runtime.ts#L811-L827), +[direct fetch](https://github.com/EvanZhouDev/openai-oauth/blob/ec7dab2fcd8dab9da970a7a2b5dc34046c94905e/packages/core/src/runtime.ts#L914-L1010)). +Its bounded state cache expands response references back to full history +([state expansion](https://github.com/EvanZhouDev/openai-oauth/blob/ec7dab2fcd8dab9da970a7a2b5dc34046c94905e/packages/core/src/state.ts#L111-L203)); its compatibility endpoint deliberately rejects hidden +`previous_response_id` state +([stateless contract](https://github.com/EvanZhouDev/openai-oauth/blob/ec7dab2fcd8dab9da970a7a2b5dc34046c94905e/packages/openai-oauth/src/responses.ts#L4-L37)). + +This is strong evidence that HTTP SSE remains a viable first transport and +that Fugu can preserve resume fidelity without a server-side conversation id. + +### docker-agent + +Docker Agent directly advertises ChatGPT subscription support and refreshes +when the token is near expiry +([credential lifecycle](https://github.com/docker/docker-agent/blob/1ec6c9a1835ed4d9476960faabda57c1107a7d22/pkg/chatgpt/chatgpt.go#L33-L85)). +It handles the refresh response and persists any replacement refresh token +([token refresh](https://github.com/docker/docker-agent/blob/1ec6c9a1835ed4d9476960faabda57c1107a7d22/pkg/chatgpt/token.go#L35-L121)). +The model provider adds bearer/account/session headers and forces the Codex +Responses settings +([provider request](https://github.com/docker/docker-agent/blob/1ec6c9a1835ed4d9476960faabda57c1107a7d22/pkg/model/provider/openai/chatgpt.go#L61-L133)). + +It is useful behavioral evidence, but not a dependency candidate: its +implementation brings the OpenAI Go SDK, a WebSocket package, and a Go runtime, +all outside Fugu's native dependency ceiling. + +### CLIProxyAPI and Hermes + +CLIProxyAPI is a larger translation proxy, but its source independently +confirms the same endpoint, HTTP event stream, WebSocket beta, request flags, +and replay of the final encrypted reasoning item. In particular, it removes +`previous_response_id`, asks for `reasoning.encrypted_content`, and caches the +completed reasoning item for the next request +([request and endpoint](https://github.com/router-for-me/CLIProxyAPI/blob/9f4f53ca5a4d1474e3f7eb61d6ffc984995f1f66/internal/runtime/executor/codex_executor.go#L779-L849), +[WebSocket profile and HTTP fallback](https://github.com/router-for-me/CLIProxyAPI/blob/9f4f53ca5a4d1474e3f7eb61d6ffc984995f1f66/internal/runtime/executor/codex_websockets_executor.go#L37-L47), +[reasoning replay](https://github.com/router-for-me/CLIProxyAPI/blob/9f4f53ca5a4d1474e3f7eb61d6ffc984995f1f66/internal/runtime/executor/codex_executor.go#L466-L520), +[Codex request normalization](https://github.com/router-for-me/CLIProxyAPI/blob/9f4f53ca5a4d1474e3f7eb61d6ffc984995f1f66/internal/translator/codex/openai/responses/codex_openai-responses_request.go#L15-L45)). + +Hermes is additional operational evidence for a direct Python path. Its +reasoning records are tagged by issuing endpoint before replay, and its auth +store deliberately remains separate from Codex's because refresh-token +rotation can invalidate a session +([reasoning issuer boundary](https://github.com/NousResearch/hermes-agent/blob/abc22cdf1a5c0fe30bf1a226bfe3caf489e8316e/agent/codex_responses_adapter.py#L20-L52), +[separate token custody](https://github.com/NousResearch/hermes-agent/blob/abc22cdf1a5c0fe30bf1a226bfe3caf489e8316e/hermes_cli/auth.py#L3311-L3365)). + +### A claimed implementation that is not evidence + +[`Securiteru/codex-openai-proxy`](https://github.com/Securiteru/codex-openai-proxy/tree/8be4f5616a4a9da0706c05444a97f4afe8f7c709) +advertises a Rust Codex-to-OpenAI proxy. At the pinned commit, however, the +active request handler returns a canned response while the apparent backend +function is dead code +([active handler](https://github.com/Securiteru/codex-openai-proxy/blob/8be4f5616a4a9da0706c05444a97f4afe8f7c709/src/main.rs#L223-L249)). +It is excluded from the feasibility conclusion. This is why the report treats +running source paths, tests, and persistence behavior as evidence rather than +repository descriptions. + +## Cross-implementation findings + +### 1. The SDK is avoidable; the protocol risk is not + +Every credible direct implementation constructs JSON, adds a bearer and +account header, and decodes Responses events itself. Fugu can do those things +with its existing JSON, libevent, libtls, HTTP, and chunked-imsg Modules. A +language runtime would add no necessary protocol capability. + +The endpoint and accepted request profile are still undocumented. Compile-time +profile constants, exact fixtures, and an opt-in live check are therefore part +of the Adapter, not optional release polish. + +### 2. Encrypted reasoning is continuation state + +OpenAI requests it, and independent clients persist/replay it specifically for +subsequent function-call rounds under `store:false`. Fugu must retain a +bounded, profile-tagged reasoning item transactionally with the assistant +message. Dropping it and hoping text/tool history is sufficient would ship a +known continuity defect. + +Responses function calls also carry both `call_id` and an output-item id. pi +stores the pair and notes that the backend associates `fc_...` call items with +reasoning items +([function-call replay identity](https://github.com/badlogic/pi-mono/blob/64f83c85d9f36c831e96b29477750f92cd5a2230/packages/ai/src/api/openai-responses-shared.ts#L206-L224)). +Fugu should keep `call_id` as its canonical Tool id and retain the provider +item id beside the reasoning item in the same bounded attachment. It should +not overload Tool ids with a provider-specific composite string. + +`previous_response_id` is not an acceptable substitute. It delegates resume, +`/clear`, crash recovery, and provider availability to hidden server state. +Fugu should continue sending complete canonical history plus the matching +opaque reasoning items. + +### 3. HTTP SSE is the right first transport + +OpenAI now prefers WebSocket and several clients implement it, but OpenAI and +pi retain fallback and openai-oauth demonstrates a current HTTP-only path. +Fugu already has a confined HTTP/SSE transport. Implementing WebSocket in v1 +would add handshake, masking, frame, ping/close, timeout, and reconnect state +without proving it is necessary. The first credentialed gate must verify SSE; +failure at that gate stops the slice and triggers a separate WebSocket design. + +### 4. Account identity comes from the token + +The access/ID token carries one namespaced ChatGPT account identifier used in +`ChatGPT-Account-ID`. Fugu does not need an `owner`, account selector, or +account-routing field. Local decoding only selects and bounds the header; TLS +and the backend remain authoritative for token validity. + +### 5. Refresh requires writable durable credential state + +All mature clients store access and refresh tokens together and write the +replacement after refresh. Sharing another client's refresh token or +discarding a rotated token creates races and restart failures. Under Fugu's +current topology, `fugu-api` has no writable credential store and must not send +secrets back to the Coordinator. The first Adapter can accept a directly +configured access token and fail hard at expiry, but automatic login/refresh +requires a separate custody and persistence design. + +### 6. Client identity is an acceptance gate + +Independent implementations disagree about `originator`: some send their own +name, some omit it, and some imitate Codex. There are also reports of unknown +originators being rejected. Fugu must not claim to be `codex_cli_rs`. The +live gate will try one deterministic, honest profile (`fugu` or no optional +originator, as fixed by the accepted ADR). If the backend requires +first-party impersonation, this design fails closed. + +### 7. Clean-room implementation is small but not trivial + +The checked projects are Apache-2.0 or MIT licensed, but their runtime and +agent architectures do not fit Fugu. The useful output is the convergent +behavioral contract. Fugu should implement its own bounded codec and tests, +using no copied request builder, stream parser, OAuth store, or WebSocket +client. + +## Implications for Fugu + +The implementation design is recorded in +[`docs/design/codex-subscription.md`](../design/codex-subscription.md). Its +non-negotiable consequences from this research are: + +- a distinct `codex` Provider, never an API-key fallback mode; +- fixed ChatGPT endpoint and an explicit compatibility snapshot; +- pure-C Responses request and SSE decoder behind the existing Provider seam; +- account-id extraction in the credential Custodian, with no owner plumbing; +- full-history `store:false` requests; +- bounded opaque reasoning and function-call identity state carried in the + Turn transaction and Journal; +- manual access-token replacement for the first experimental slice; +- an honest client identity and a live stop/go gate before general use; +- no Node, Python, SDK, Codex executable, app-server, proxy, or new daemon. blob - 001035f60bfd1222df5c9ec3cc0a4c23a32e90b1 blob + 4a797f13fee9166dbbba6ce13a27ab6b59a9a652 --- handoff/behavior.md +++ handoff/behavior.md @@ -136,7 +136,10 @@ Input editing is modal, vi-like, starting in insert mo - Either mode: `Enter` submits; `Tab`/`Shift-Tab` cycle slash-command completions when the line starts with `/` (built-ins first, then skills alphabetically); `PgUp`/`PgDn` and the mouse wheel scroll the - transcript; `Up`/`Down` recall prompt history (this session); `Ctrl-L` repaints; + transcript; `Up`/`Down` recall prompt history (this session); `Ctrl-L` + clears the transcript viewport while retaining earlier lines in scrollback + and without resetting conversation context, current input, history, or + queued messages; `Ctrl-C` clears a non-empty line, quits on an empty one; `Ctrl-D` quits on an empty line; `Esc` enters normal mode. - Normal mode: `h l 0 $ ^ w b e` motions; `i a A I O` (and `o` is blob - 73e5c50c9a204d60a4dcd85f6e9591e1e66115b9 blob + d99806e29c16f1832f8c21dee5c341e3d117c21b --- handoff/invariants.md +++ handoff/invariants.md @@ -102,7 +102,8 @@ them. MUST throughout. untrusted input is touched (a pure narrowing, per I9) so their contents are kernel-unreachable — the error is ENOENT-shaped, though names may remain visible in listings — the system executable and - library paths (read/execute), and + library paths (read/execute), `/dev/null` (`rwc`; shell output + redirection opens it with `O_CREAT`), and nothing else. Writes outside the working tree are impossible, not forbidden; access to a protected path fails like any out-of-tree path and the tools normalize it to the same clean error. When blob - 798f9e90dbe3a9a6332e8b66247410dc489e689f blob + 5cb831a06e63f1c702d3fc803249456ec2d0e736 --- regress/editor/editor_test.c +++ regress/editor/editor_test.c @@ -324,7 +324,7 @@ main(void) CHECK(ed_nitems(&e) == 0); /* non-empty: cleared */ CHECK(ed_feed(&e, K_CTRL_C, 0, NULL, 0) == R_QUIT); CHECK(ed_feed(&e, K_CTRL_D, 0, NULL, 0) == R_QUIT); - CHECK(ed_feed(&e, K_CTRL_L, 0, NULL, 0) == R_REPAINT); + CHECK(ed_feed(&e, K_CTRL_L, 0, NULL, 0) == R_CLEAR_SCREEN); ed_free(&e); /* ---- normal-mode routing keys ---- */ blob - 825cd8859876138e433d10148b60be87682a21c1 blob + e3cb883d0b5c9c2820c1174582e015b39cb195ee --- regress/tools/tools_test.c +++ regress/tools/tools_test.c @@ -197,10 +197,12 @@ main(int argc, char *argv[]) make_long_line("longline.txt", 1024 * 1024 + 1); make_big("bigcat.txt", 300 * 1024); /* > 256 KiB */ - /* fugu-tool's confinement: the tree rwxc, system paths rx, the - * protect dir subtracted, no network */ + /* fugu-tool's confinement: the tree rwxc, system paths rx, + * /dev/null rwc, the protect dir subtracted, no network */ if (unveil(".", "rwxc") == -1) err(1, "unveil ."); + if (unveil("/dev/null", "rwc") == -1) + err(1, "unveil /dev/null"); if (unveil("/usr", "rx") == -1 || unveil("/bin", "rx") == -1 || unveil("/sbin", "rx") == -1 || unveil("/lib", "rx") == -1 || unveil("/libexec", "rx") == -1) @@ -367,6 +369,13 @@ main(int argc, char *argv[]) run("shell", "{\"command\":\"printf fast-tail-marker\"}"); CHECK(outhas("fast-tail-marker")); + /* Shell redirection must use the real device without tripping unveil. + * Exercise both write and read; ksh opens the output with O_CREAT, so + * this also exercises the otherwise non-obvious "c" permission. */ + run("shell", "{\"command\":\"printf dev-null-hidden >/dev/null && " + "/bin/cat n == 0) return (R_QUIT); @@ -650,7 +650,7 @@ feed_normal(struct editor *e, enum ed_key k, uint32_t case K_PGDN: return (R_PAGE_DOWN); case K_CTRL_L: - return (R_REPAINT); + return (R_CLEAR_SCREEN); case K_CTRL_C: if (e->n == 0) return (R_QUIT); blob - e1002aeb280f617e8da7ed1cef8f8a3bc4fcd134 blob + 22dacce44c398472f114e73eda2ebf38ea3dd66b --- src/fugu-tty/editor.h +++ src/fugu-tty/editor.h @@ -75,7 +75,7 @@ enum ed_result { R_SCROLL_LINE_DOWN, R_PAGE_UP, R_PAGE_DOWN, - R_REPAINT, /* Ctrl-L */ + R_CLEAR_SCREEN, /* Ctrl-L: clear the displayed transcript */ R_COMPOSE, /* v: run the external editor on ed_text */ R_DRAWER, /* o: open the conversation drawer */ R_QUEUE /* q: focus the pending-message queue */ blob - a01c9a8bd283e2f1a2f45eca0eebf5effc9fb437 blob + 9c20233d383357a221d7316c0cccb7b58dfad897 --- src/fugu-tty/ui.c +++ src/fugu-tty/ui.c @@ -40,7 +40,7 @@ #include "ui.h" /* transcript item kinds */ -enum { IT_USER, IT_ASSIST, IT_TOOL, IT_DIFF, IT_NOTE, IT_ERROR }; +enum { IT_USER, IT_ASSIST, IT_TOOL, IT_DIFF, IT_NOTE, IT_ERROR, IT_CLEAR }; struct titem { int kind; @@ -115,6 +115,7 @@ struct ui { size_t nlines, lcap; int dirty; int scroll; /* top line; -1 follows the tail */ + int clear_line; /* latest Ctrl-L display boundary */ /* input */ struct editor ed; @@ -391,6 +392,7 @@ rebuild(struct ui *u) int w = wrap_width(u); lines_reset(u); + u->clear_line = 0; for (i = 0; i < u->nitems; i++) { struct titem *it = &u->items[i]; const char *t = (const char *)it->raw.data; @@ -420,6 +422,9 @@ rebuild(struct ui *u) render_plain(u, t, n, w, u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL); break; + case IT_CLEAR: + u->clear_line = (int)u->nlines; + break; } } u->dirty = 0; @@ -476,6 +481,7 @@ items_clear(struct ui *u) u->nitems = 0; u->dirty = 1; u->scroll = -1; + u->clear_line = 0; } /* ---- drawing -------------------------------------------------------- */ @@ -858,6 +864,18 @@ draw_picker(struct ui *u) attrset(A_NORMAL); } +/* Tail view starts after the latest Ctrl-L boundary until enough new output + * fills the viewport. Explicit scrollback may still cross the boundary. */ +static int +tail_top(const struct ui *u, int height) +{ + int top = (int)u->nlines - height; + + if (top < u->clear_line) + top = u->clear_line; + return (top < 0 ? 0 : top); +} + static void draw(struct ui *u) { @@ -876,13 +894,13 @@ draw(struct ui *u) rebuild(u); if (u->scroll == -1) - top = (int)u->nlines - th; + top = tail_top(u, th); else top = u->scroll; if (top < 0) top = 0; - if (top > (int)u->nlines - 1 && u->nlines > 0) - top = (int)u->nlines - 1; + if (top > (int)u->nlines) + top = (int)u->nlines; erase(); for (r = 0; r < th; r++) { @@ -912,9 +930,7 @@ scroll_by(struct ui *u, int delta) if (u->dirty) rebuild(u); /* refresh nlines before deriving maxtop */ - maxtop = (int)u->nlines - th; - if (maxtop < 0) - maxtop = 0; + maxtop = tail_top(u, th); if (u->scroll == -1) u->scroll = maxtop; u->scroll += delta; @@ -1281,7 +1297,13 @@ handle_result(struct ui *u, enum ed_result r) case R_SCROLL_LINE_DOWN: scroll_by(u, 1); break; case R_PAGE_UP: scroll_by(u, -(th - 1)); break; case R_PAGE_DOWN: scroll_by(u, th - 1); break; - case R_REPAINT: clearok(stdscr, TRUE); break; + case R_CLEAR_SCREEN: + /* Add a reflow-stable display boundary. Scrollback remains behind + * it; coordinator state, input, history, and queue remain intact. */ + (void)item_new(u, IT_CLEAR); + u->scroll = -1; + clearok(stdscr, TRUE); + break; case R_COMPOSE: u->notice[0] = '\0'; compose_begin(u);