Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions devlog/_plan/261001_quota_send_lock_split/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Quota send-lock contributor split: roadmap

Status: roadmap locked by the docs-only cycle; implementation runs one PABCD cycle per PR.
Date: 2026-10-01. Baseline: `origin/dev` `64294638a69e25ca0c7a4e2102e2349973161f71`.

## Problem

The Codex desktop composer disables Send when the signed-in ChatGPT quota reports
exhausted, even for models opencodex routes to independently funded providers
([#6196](https://github.com/lidge-jun/opencodex/issues/6196), duplicate
[#4878](https://github.com/lidge-jun/opencodex/issues/4878)). Two contributor PRs
address it:

- [#5947](https://github.com/lidge-jun/opencodex/pull/5947) (lcxhh521, head
`61947c04d9`, +7317 / 54 files) mixes three mechanisms: (a) a local-CA TLS
intercept of chatgpt.com traffic (send-unblock), (b) a PAC fallback entry so the
intercept survives opencodex stopping, (c) an app-server shim: a `CODEX_CLI_PATH`
launcher that execs the bundled `codex app-server` and filters only its stdout,
clearing the plain-quota gate in `account/rateLimits/read` and
`account/rateLimits/updated`.
- [#5879](https://github.com/lidge-jun/opencodex/pull/5879) (MateuszJuszczyk, head
`9a5b6d9d72`) toggles `codexDesktopAuthless` automatically on quota exhaustion.

## Evidence

- #6196 (TooSpace, 2026-09-27..29): with (a)+(b) deployed, the loopback listener saw
**zero established connections over ~20 h**; the bundled app-server owns the
chatgpt.com sockets, so the intercept never sees the gate reads.
- #6196 (TooSpace, 2026-10-01 10:47 Asia/Taipei): Plus account, 5-hour window 100%,
weekly 51%, Desktop 26.928.21956 / bundled CLI 0.159.2: launching through the shim
launcher re-enabled Send before the window reset; `dynamic_app_tools_peer_rejected`
did not recur. Ingwannu's note: the run had intercept + shim + restart together,
so it does not isolate the shim as sole cause.
- `devlog/_plan/260928_macos_quota_gate/000_design.md` (maintainer design) prefers
upstream provider-aware admission and rejects CA installation and quota rewriting.
The split PRs below are explicitly experimental/opt-in candidates the maintainers
may close against that decision.

## Existing defence and its gaps

`codexMainAccountHardLock` (default on since #5694, `src/codex/main-account-hard-lock.ts`)
stops opencodex from sending main-account traffic at 98% on any governing window.
Gaps: usage outside opencodex, observation lag (a long turn crosses 98%), the small
Plus 5-hour window, and `unknown` state admitting.

## PR map (dependency order)

| Doc | Work-phase | Branch | Base | Credit |
| --- | --- | --- | --- | --- |
| 010 | wp1 hard-lock hardening | `codex/main-hard-lock-window-thresholds` | `dev` | none (maintainer code) |
| 020 | wp2 app-server shim | `codex/chatgpt-app-server-shim` | `dev` | Co-authored-by: lcxhh521 |
| 030 | wp3 local-CA send-unblock intercept | `codex/chatgpt-send-unblock-intercept` | wp2 branch (stacked) | Co-authored-by: lcxhh521 |
| 040 | wp4 PAC fallback | `codex/chatgpt-pac-fallback` | wp3 branch (stacked) | Co-authored-by: lcxhh521 |
| 050 | wp5 #5879 disposition | none (rationale only) | - | - |
| 060 | wp6 closeout | - | - | - |

Stacking reason: wp2, wp3 and wp4 all extend the same new `ocx chatgpt` command and
`chatgptDesktop` config block. Opening them independently against `dev` would
make the second one to land conflict on `src/cli/chatgpt-command.ts`, the config
type/validator, the CLI registry, the guide and `structure/clients/chatgpt-desktop.md`.
The PAC fallback has no function without the TLS listener (its CONNECT entry only
splices into that listener), so wp4 cannot target `dev` alone. The shim has no
dependency on either and is the piece with field evidence, so it sits at the bottom.
Stack lifecycle:

- Parent merged (squash): rebase the child with
`git rebase --onto origin/dev <old parent head> <child>`, retarget it to `dev`, and
wait for fresh CI on the new head. Cascade the same rebase to every descendant
(wp4 onto the new wp3 head) and wait for fresh CI on each. Land bottom-up only.
- Parent closed: its children carry its commits and dependencies, so they close with
it unless a maintainer asks for a reconstruction on a different base. The children
are themselves "may close" candidates, so this is the expected path if the shim is
rejected.

wp1 is independent of all three.

## Common constraints

- New branches from latest `origin/dev`; `codex/` prefix; no git config, no GIT_* env.
- Respect `tests/fixtures/file-size-baseline.json`; unlisted files stay below 2000 lines.
- New test files: register in `scripts/test-layout/layout.json` `explicit` and
`tests/fixtures/test-layout-expected.json`.
- `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts` must not
reach new optional modules (core-lab boundary).
- GUI strings in all ten locale catalogs; GUI PRs need a screenshot on `pr-assets`.
- Verification per PR: `bun run typecheck`, focused tests, `bun run test:changed`,
`bun run structure:check`, `bun run privacy:scan`, `bun run skill:surface:check` when
the CLI changes, the core-lab boundary test, and the full `bun run test` (or the
documented resource exception recorded in the PR), then required CI green on the
exact pushed head.
- wp2-wp4 touch process exec and credential interception: PR descriptions request
explicit security review per MAINTAINERS.md.
- Merge/close decisions belong to maintainers; this unit opens PRs only.

141 changes: 141 additions & 0 deletions devlog/_plan/261001_quota_send_lock_split/010_hard_lock_hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 010 — wp1: window-aware hard-lock thresholds and outside-usage warning

Branch `codex/main-hard-lock-window-thresholds` from `origin/dev`. Maintainer code.

## Behaviour

1. The 5-hour (short) window locks at a lower percentage than the weekly/monthly
(long) windows. Defaults: short **90**, long **98** (unchanged). Both configurable.
2. When the main account's usage rises while opencodex has not used the main
account, the dashboard and `ocx status` show a warning: usage is being consumed
outside opencodex, so the lock may not prevent exhaustion.

## Config (NEW key)

`src/types/config.ts` next to `codexMainAccountHardLock`:

```ts
/** Per-window lock thresholds in percent (#6196). short: 5h window, default 90; long: weekly/monthly, default 98. */
codexMainAccountHardLockThresholds?: { short?: number; long?: number };
```

`src/config/schema/config-schema.ts`: object of two optional integers in
`[MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, 100]`, `.catch(undefined)` per field so a
malformed disk value degrades to defaults (same pattern as the boolean at :188).

`PUT /api/settings` (`src/server/management/config-routes.ts`): accept the object,
reject non-integer/out-of-range values and `short > long` with 400 before any write,
include it in the rollback snapshot; GET returns the effective thresholds in the
existing hard-lock projection.

## Constants (MODIFY `src/codex/quota-types.ts`)

```ts
export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98; // long default, unchanged export
export const MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT = 90; // NEW short default
export const MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT = 80; // NEW lowest configurable value
```

## Lock (MODIFY `src/codex/main-account-hard-lock.ts`)

- NEW `resolveMainAccountHardLockThresholds(config): { short: number; long: number }`
— defaults above, clamps invalid in-memory values back to defaults, forces
`short <= long`.
- `WindowReading` gains `kind: "short" | "long"`; `governingWindows()` tags the 5h
reading short, weekly and monthly-only long.
- `getMainAccountHardLockStatus()` compares each window with its own threshold and
returns `thresholds` plus `window: "short" | "long"` of the first blocking reading.
- `PolicyConfig` adds `codexMainAccountHardLockThresholds`.

## Evidence retention (MODIFY `src/codex/quota.ts`)

The three policy-evidence retention guards (`assignCarriedShort` ~:263,
`preserveKnownWeekly` ~:374, `preserveKnownShort` ~:407) compare against 98. With a
configurable threshold a reading between the threshold and 98 could be dropped by an
elapsed reset or partial update and silently unlock. Change the comparison to
`MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT` so any reading that can block under some
allowed configuration is retained until a fresh lower reading arrives. Retaining a
non-blocking reading only keeps a stale percentage visible; it never blocks.

## Error copy (MODIFY `src/codex/auth-context.ts` ~:517)

`CodexMainAccountHardLockError` message stops naming 98: "Codex main account is
blocked by the main-account quota policy (5h ≥ X%, weekly ≥ Y%)." The constructor
takes the thresholds from the status. Update tests that assert the old string.

## Outside-usage detector (NEW `src/codex/main-account-external-usage.ts`)

Process-local, no persistence, no timers. It keeps its own baseline of fresh readings
and never reads the merged policy snapshot, which can contain carried windows.

```ts
export const EXTERNAL_USAGE_QUIET_MS = 30 * 60_000;
export const EXTERNAL_USAGE_TTL_MS = 6 * 60 * 60_000;
type FreshWindow = { kind: "short" | "long"; percent: number; resetAtMs: number };
export function noteMainAccountActivity(now = Date.now()): void;
/** Only windows with a validated percent AND a known reset in this observation. */
export function observeMainAccountUsage(identityKey: string, windows: FreshWindow[], now = Date.now()): void;
export function forgetMainAccountUsage(): void;
export function getMainAccountExternalUsageWarning(identityKey: string | undefined, now = Date.now()):
{ window: "short" | "long"; fromPercent: number; toPercent: number; observedAt: number } | undefined;
export function resetMainAccountExternalUsageForTests(): void;
```

Rule: the module holds `{ identityKey, perWindow: { percent, resetAtMs, observedAt } }`.
A new `identityKey` replaces the baseline and drops any warning. For a fresh window
with the same kind and the same normalized `resetAtMs` (both known; a missing reset
never proves the same episode), a rise of at least 1 point with no main-account
activity noted since `baseline.observedAt - EXTERNAL_USAGE_QUIET_MS` records a warning.
A changed reset rebaselines that window and clears its warning. Warnings expire after
`EXTERNAL_USAGE_TTL_MS` or once the recorded `resetAtMs` passes, whichever is first; the getter returns nothing for a different identity.
Over-counting activity only suppresses warnings. A single turn longer than the quiet
margin can still cause a false positive, so the copy says "possible usage outside
opencodex".

Hooks:

- `src/codex/auth-context.ts` `assertMainAccountPolicy()` (:703) runs on every
main-credential attach (main-pool, substituted main, caller bearer matching observed
main) and on some admission checks before refusal. Call `noteMainAccountActivity()`
there before the `!config` early return; the extra calls only suppress warnings.
- `src/codex/quota.ts` `setAccountQuotaFromParsed()` (~:318): when `isMain`,
`mainWriter` is present and `policyQuota` (this call's validated observation) is
non-null and carries usage, build `FreshWindow`s from `policyQuota` only (short:
`shortPercent` + `shortResetAt`; long: `weeklyPercent` + `weeklyResetAt`, or monthly
when `monthlyIsPrimaryWindow`), normalize with `resetAtToMs`, and call
`observeMainAccountUsage(mainWriter.identityKey, windows)`.
- `clearAccountQuota()` (main or all) calls `forgetMainAccountUsage()`.
- Readers pass `getObservedMainQuotaIdentityKey()` to the getter.

## Surfaces

- `src/codex/auth-api/account-list.ts` (~:374): the main-account hard-lock DTO gains
`thresholds` and optional `externalUsage`.
- GUI `gui/src/components/MainAccountHardLockSetting.tsx` and
`codex-account-pool-main-card.tsx`: copy uses the effective thresholds; amber notice
when `externalUsage` is present. Locale keys in all ten catalogs; replace fixed-98
strings (`en.ts:2312-2325`).
- `ocx status`: `src/oauth/health.ts` `fetchCodexHealthFromLiveProxy()` already reads
`/api/codex-auth/accounts`; project the main hard-lock state, thresholds and
external-usage warning, and print one line in `src/cli/status-oauth.ts` (human) and
include it in JSON. Keep `src/cli/index.ts` (1979 lines) untouched.

## Tests (NEW sibling files; register in both layout files)

- `tests/codex-integration/main-account-hard-lock-thresholds.test.ts`: short 90 blocks
at 90 with weekly 30; weekly 95 admits by default and blocks with long 95;
configured short/long; invalid config falls back; retention of a 92% short reading
across an elapsed reset.
- `tests/codex-integration/main-account-external-usage.test.ts`: rise without
activity warns; activity inside the quiet margin suppresses; reset change clears;
identity change clears; expiry.
- `tests/config/settings-main-account-hard-lock.test.ts` (MODIFY, 119 lines): PUT
validation and rollback for thresholds.
- GUI test for the warning notice next to the existing hard-lock setting tests.

## Docs

`docs-site/src/content/docs/reference/cli/providers-accounts.md` (+ Korean),
`docs-site/src/content/docs/reference/configuration/server.md` key row,
`structure/providers/openai-accounts.md`, `structure/config.md`.

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# 020 — wp2: app-server shim (experimental, macOS, default off)

Branch `codex/chatgpt-app-server-shim` from `origin/dev`. Code from #5947 with
`Co-authored-by: lcxhh521 <lcxhh521@users.noreply.github.com>` (use the author's
commit email from `git log pr/5947` if public).

## Scope

Only mechanism (c). No listener, CA, PAC, launch watcher, or `unblockSend`.

## Files

NEW `src/chatgpt/app-server-shim/`:

- `gate-rewrite.ts` — pure copy of #5947 `rewrite.ts` `isRecord`, `PLAIN_QUOTA_REACHED_TYPE`,
`hasNonQuotaBlock`, `unlockRateLimitGate` (pr/5947 rewrite.ts:40-47, 157-246).
No conversation/endpoint/SSE code.
- `app-server-rewrite.ts` — #5947 file, import from `./gate-rewrite`.
- `filter.ts` — #5947 `app-server-shim.ts` line filter, without its `import.meta.main`
entry. NEW `runChatgptAppServerFilter({ selfTest })`: with `selfTest` it rewrites a
built-in fixture line, checks the result and exits 0; otherwise it filters stdin to
stdout. If the rewrite machinery throws outside the per-line guard, it switches to
raw byte passthrough for the rest of the stream.
- Entry point: hidden `ocx internal chatgpt-app-server-filter [--self-test]` in
`src/cli/internal-command.ts` (hidden commands stay out of the registry and skill
surface by design). This works for source installs and compiled standalone builds,
because the launcher re-enters the current CLI through `process.execPath` +
`selfLaunchArgv()` (`src/lib/self-launch-argv.ts`) instead of pointing Bun at a `.ts`
file that a compiled build does not ship.
- `launcher.ts` — `buildChatgptShimLauncher(argv, real)`, `writeChatgptShimLauncher()`,
`chatgptShimLauncherPath()` (adapted from #5947 runtime.ts:123-161). Launcher body:

```bash
#!/bin/bash
# opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter.
REAL='<bundled codex>'
FILTER=('<execPath>' ['<cli entry>'] internal chatgpt-app-server-filter)
if [ "$(uname -s)" = "Darwin" ] && [ -x "${FILTER[0]}" ] \
&& "${FILTER[@]}" --self-test >/dev/null 2>&1; then
exec "$REAL" "$@" > >(exec "${FILTER[@]}")
fi
exec "$REAL" "$@"
```

Guarantee, stated exactly: a failed precondition or a failed self-test runs the
original binary with untouched stdout. A filter that passes the self-test and then
dies mid-session closes the pipe. Expected (not yet validated against the bundled
app-server): the server gets SIGPIPE or a write error and Desktop respawns it through
the same launcher. The filter's passthrough mode limits this to an exit/crash case.

NEW `src/cli/chatgpt-command.ts` — `ocx chatgpt launch | restore | status` (macOS only;
other platforms print "macOS only" and exit 1). `launch` refuses unless
`chatgptDesktop.appServerShim === true`, writes the launcher, quits the app if
running, and relaunches with `open -a ChatGPT --env CODEX_CLI_PATH=<launcher>`.
`restore` relaunches without the variable and deletes the launcher. `status` prints
`app-server shim (experimental): on|off`, launcher presence and whether a running
app carries the variable. All help text says "experimental".

MODIFY `src/cli/dispatch.ts`, `src/cli/help.ts`, `src/cli/registry.ts`
plus `src/cli/capabilities.ts` and the regenerated skill surface (`bun run skill:surface`).

Config: `src/types/config.ts` `chatgptDesktop?: { appServerShim?: boolean }`;
`leaf-validators.ts` strict object, `config-schema.ts` registration with
`.catch(undefined)`; `src/config/diagnostics.ts` warns when set on non-macOS.

## Tests

NEW `tests/chatgpt-unblock/app-server-shim.test.ts`: gate rewrite cases from #5947
`unblock-app-server-shim.test.ts` (re-pointed to the new modules), self-test exit code,
passthrough after an internal rewrite failure.
NEW `tests/chatgpt-unblock/app-server-shim-launcher.test.ts`: launcher text for source
argv and compiled argv (`selfLaunchArgv` with `isStandaloneExecutable`); executing the
generated script on a stub `REAL` with (a) missing runtime, (b) failing self-test →
stub sees untouched stdout, (c) passing self-test → stub output filtered, (d) passing
self-test then filter exits → stub terminates instead of hanging (mock behaviour only).
NEW `tests/chatgpt-unblock/chatgpt-desktop-config.test.ts`: absent/false/malformed → off,
strict write rejection. CLI registry/capabilities parity via existing tests after
`bun run skill:surface`. Register new files in both layout files.

## Docs

`docs-site/src/content/docs/guides/chatgpt-desktop.md` (English, marked experimental,
security notes: generated executable, `CODEX_CLI_PATH`, quota gate fields rewritten
while displayed usage stays honest), sidebar entry in `astro.config.mjs`;
`structure/clients/chatgpt-desktop.md` + `structure/manifest.json` ownership of
`src/chatgpt/`; `structure/INDEX.md` via `bun run structure:index`.

## PR description must state

The 260928 maintainer design rejects rewriting quota gate data; this PR is offered
as an opt-in experiment because it is the only path with field evidence on a real
exhausted Plus account (#6196, 2026-10-01), and that run also had the intercept
enabled.

Loading
Loading