From 3e5e46a72b809e48e46ab407a363a47dc7977036 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 15:56:30 +0900 Subject: [PATCH 01/14] docs(devlog): plan lane L4 Codex integration, CLI and service bundle --- .../260924_l4_codex_cli_service/000_roadmap.md | 14 ++++++++++++++ .../010_carry_ready_prs.md | 14 ++++++++++++++ .../020_subagent_identity_5221.md | 7 +++++++ .../030_agent_message_recovery_5009.md | 6 ++++++ .../040_main_hard_lock_default_5694.md | 18 ++++++++++++++++++ .../260924_l4_codex_cli_service/050_publish.md | 3 +++ 6 files changed, 62 insertions(+) create mode 100644 devlog/_plan/260924_l4_codex_cli_service/000_roadmap.md create mode 100644 devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md create mode 100644 devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md create mode 100644 devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md create mode 100644 devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md create mode 100644 devlog/_plan/260924_l4_codex_cli_service/050_publish.md diff --git a/devlog/_plan/260924_l4_codex_cli_service/000_roadmap.md b/devlog/_plan/260924_l4_codex_cli_service/000_roadmap.md new file mode 100644 index 0000000000..ec3ed89728 --- /dev/null +++ b/devlog/_plan/260924_l4_codex_cli_service/000_roadmap.md @@ -0,0 +1,14 @@ +# L4 roadmap — Codex integration, CLI and service + +Branch codex/260924-l4-codex-cli-service from origin/dev be0b5294e5. One PR to dev. Merge is the coordinator's. + +| Unit | Doc | Items | Method | +|---|---|---|---| +| wp1 | 010 | #5713, #5703, #5548 slice | squash-diff apply per PR, one commit each with Co-authored-by | +| wp2 | 020 | #5221 | rebuild on dev by DeepSeek writer; sibling test files | +| wp3 | 030 | #5009 | squash-diff apply, review sender/admission checks | +| wp4 | 040 | #5694 | DeepSeek writers: default-on 98% hard lock | +| wp5 | 050 | publish | rebase, validate, push, PR, checks | + +Ratchet: tests/fixtures/file-size-baseline.json caps only move down; every commit re-runs tests/test-layout.test.ts and the file-size test. + diff --git a/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md b/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md new file mode 100644 index 0000000000..e633c32e8a --- /dev/null +++ b/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md @@ -0,0 +1,14 @@ +# 010 — carry #5713, #5703, #5548 slice + +## #5713 (fixes #5699) — author 정우철 +Files: src/client/connect.ts, src/client/state.ts (pending-connect fingerprint marker client-connect-pending), src/service/cli.ts (removeServiceTokenAfterUninstall under client lifecycle + config mutation locks: removed|absent|retained|unverified), structure/clients/claude-desktop.md, structure/runtime.md, docs-site guides/remote-hub.md (en+ko), tests/clients/client-connect.test.ts, tests/service/service-secrets.test.ts. +Method: git diff merge-base..carry-5713 | git apply -3. Check other docs-site locales of remote-hub.md for consistency (PR touched en and ko only). +Tests: bun test tests/service/service-secrets.test.ts tests/clients/client-connect.test.ts. +Security: credential deletion boundary — uninstall deletes the service token only when client state is disconnected and no pending marker owns the fingerprint. + +## #5703 (fixes #5701) — Konstantinos <37538071+konstantinosbotonakis@users.noreply.github.com> +Files: src/codex/native-residue.ts, structure/config.md, tests/codex-integration/codex-native-residue.test.ts. Check file-size caps for the test file. + +## #5548 slice — Vadevious +Only src/codex/home.ts (import expandUserPath from ../config/paths), structure/codex-home.md line, tests/codex-integration/codex-home-wsl.test.ts (new: register in layout.json explicit + test-layout-expected.json if not matched by a seed). Excluded: tests/cli/cli-help.test.ts, tests/service/service-probe-docker.test.ts, tests/service/service.test.ts. +Audit fold: codex-home-wsl.test.ts already exists on dev (from #5720) and is registered; carry only the PR's added fresh-process case into it. home.ts:4 currently imports from ../config (barrel) — the fix switches to ../config/paths. diff --git a/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md b/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md new file mode 100644 index 0000000000..a14d305fc6 --- /dev/null +++ b/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md @@ -0,0 +1,7 @@ +# 020 — rebuild #5221 (fixes #5217) — sbc1-code <207095575+sbc1-code@users.noreply.github.com> + +Change: src/adapters/identity.ts gains ROUTED_IDENTITY_RE, NEUTRAL_IDENTITY_RE, hasRoutedIdentity, repairRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity; identifyRoutedModel also rewrites neutral + routed lines. Catalog (src/codex/catalog/derive-entry.ts, metadata.ts) writes neutralizeIdentity instead of identifyRoutedModel. src/responses/parser.ts repairs developer items to data.model. src/adapters/openai-responses/passthrough.ts repairs raw body (routed rewrite, forward strip). +Rebuild on current dev (221 commits drift): re-read current versions of those files; keep dev behaviour elsewhere. +Tests: new tests/adapters/identity-subagent.test.ts (from PR). Catalog expectation edits: codex-catalog.test.ts is at cap, so changed expectations must not grow it; new catalog cases go to a sibling file (e.g. tests/codex-integration/codex-catalog-identity-neutral.test.ts) registered in both manifests. +Check files at cap: parser.ts, passthrough.ts, identity.ts in file-size-baseline. +Audit fold: neutralizeIdentity and NEUTRAL_IDENTITY_LINE already exist in identity.ts; the six routed-identity helpers do not. parser/passthrough/identity are uncapped (<2000 lines). codex-catalog.test.ts cap 7985 vs 7974 actual: expectation edits may not add net lines beyond headroom; new cases still go to a sibling file. diff --git a/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md b/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md new file mode 100644 index 0000000000..a785573838 --- /dev/null +++ b/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md @@ -0,0 +1,6 @@ +# 030 — carry #5009 — Zhaofeng Li + +Files: src/server/responses/agent-task-recovery.ts (FOLLOWUP_TASK, FINAL_ANSWER with optional Task name; author===sender check kept; recipient cross-check when task name present; JSON tuple cache key including recipient; foreign-family echo rejected), src/server/responses/encrypted-payload.ts (guard regex covers four types), structure/subagents.md, docs-site subagent-v1-default.md, configuration/agents.md, providers.md (check locales), tests/server/agent-task-recovery.test.ts, server-agent-task-recovery-replay.test.ts, v2-agent-message-failfast.test.ts, tests/helpers/agent-task-recovery.ts. +Method: squash diff from merge-base e9643875f0 applied with -3; check caps on test files (agent-task-recovery.test.ts +161). +Keep: recoveryAdmission before cache; agentTaskRecovery.enabled default-off. + diff --git a/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md b/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md new file mode 100644 index 0000000000..11a69f0f13 --- /dev/null +++ b/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md @@ -0,0 +1,18 @@ +# 040 — #5694 98% main-account hard lock by default + +Existing mechanism: codexMainAccountHardLock (src/types/config.ts, schema .catch(false)), MAIN_ACCOUNT_HARD_LOCK_PERCENT=99 in src/codex/quota-types.ts, getMainAccountHardLockStatus in src/codex/main-account-hard-lock.ts, consumers gate on === true in src/codex/auth-context.ts, src/codex/native-profile-startup.ts, src/server/management/config-routes.ts, gui/src/components/MainAccountHardLockSetting.tsx. +Change: +- MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98. +- New resolver isMainAccountHardLockEnabled(config) = config.codexMainAccountHardLock !== false; replace every === true gate. +- Schema: invalid value -> undefined (default on), explicit false persists opt-out; management PUT false stores false (not delete), true deletes key or stores true — decide by reading config-routes semantics. +- GUI: toggle shows on when unset; copy 99% -> 98% in all locales; confirmation dialog still shown when enabling. +- Docs: providers-accounts.md (en, ko, others), configuration/providers.md, guides/providers.md, ru; structure/providers/openai-tiers.md. +- Tests: default-on status, explicit false off, 98 threshold (97.9 ready, 98 blocked), config-route round trip. +Trade-off for PR: default lock can keep main-account Luna Reserve from activating (Reserve needs exhausted normal window); opt-out by setting false. + +## Audit folds (wp0 A) +- src/codex/main-account-hard-lock.ts:26 gates on !== true: switch to resolver. +- config-schema.ts:152 .catch(false) -> .catch(undefined) so malformed values fall back to the default (on). +- config-routes.ts:598 deletes the key on PUT false: must store false; PUT true deletes the key (default on). Projections at :351 and :706 become resolver-based, else GUI invariant at MainAccountHardLockSetting.tsx:14 fails. +- quota.ts:262 and :388 also use MAIN_ACCOUNT_HARD_LOCK_PERCENT (blocking-evidence retention); they follow the constant. +- auth-context.ts:512 hardcoded "99%" message -> derive from constant. diff --git a/devlog/_plan/260924_l4_codex_cli_service/050_publish.md b/devlog/_plan/260924_l4_codex_cli_service/050_publish.md new file mode 100644 index 0000000000..e3126314d1 --- /dev/null +++ b/devlog/_plan/260924_l4_codex_cli_service/050_publish.md @@ -0,0 +1,3 @@ +# 050 — publish +Rebase on origin/dev; bun run typecheck; focused tests for every item; bun run test:changed; bun run privacy:scan; bun run structure:check; bun run lint:gui; push branch; gh pr create --base dev with template + Security review + Carries/Closes; watch automatic checks; fix failures on head. + From 9f33647c0145352f2de8a8e7c2ca32f3b7a4971c Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:01:04 +0900 Subject: [PATCH 02/14] fix(service): preserve connected client key on uninstall (#5713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ocx service uninstall unconditionally deleted service-api-token, which on a remote-hub client is the per-client data-plane key issued by the hub (#5699). Uninstall now deletes the token only under the client lifecycle and config mutation locks, and only when persisted client state is disconnected and no pending connect marker owns the current key's fingerprint. Connect publishes that fingerprint marker before writing the key and clears it on commit or rollback. Lock, state-read and unlink failures report cleanup as unverified. On top of #5713: rollback checks the pending marker against its own fingerprint, so a valid marker for a different key is left alone instead of adding a spurious rollback failure. Carries #5713. Closes #5699. Co-authored-by: 정우철 --- .../src/content/docs/guides/remote-hub.md | 6 + .../src/content/docs/ko/guides/remote-hub.md | 1 + src/client/connect.ts | 14 ++- src/client/state.ts | 40 ++++++- src/service/cli.ts | 37 +++++- structure/clients/claude-desktop.md | 4 + structure/runtime.md | 2 +- tests/clients/client-connect.test.ts | 33 +++++- tests/service/service-secrets.test.ts | 112 ++++++++++++++++++ 9 files changed, 237 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 13878d54ae..1cca04995d 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -60,6 +60,12 @@ The hub automatically issues a per-client key. The client writes it to the exist `service-api-token` file, never `config.json`. While connected, usage comes from the hub usage store filtered to that client's stable `apiKeyId`. After disconnect, usage comes from the local store. OpenCodex does not mirror usage between the two stores. +`ocx service uninstall` removes the local service but preserves an existing key when the client is +connected, its connection metadata is invalid or mismatched, or a pending connection marker matches +the current key. A valid marker for an older key does not retain an unrelated service key. +If a marker is unsafe, malformed, or unreadable, token cleanup cannot be verified; +the command warns instead of claiming the key was kept. Use `ocx disconnect` to remove a connected +client's local key and state. If a client saved a remote `http://` Hub URL before the secure transport rule, its Hub operations now return `insecure_http_refused`. Run `ocx disconnect` locally, then reconnect diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index f4ec3fea80..585b6ea645 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -39,6 +39,7 @@ ocx sync 이 줄을 직접 만들 필요는 없습니다. 허브에서 `ocx hub invite`를 실행하면 코드를 발급하고, 두 Origin이 모두 채워진 명령을 그대로 출력합니다. [다른 컴퓨터 초대하기](#다른-컴퓨터-초대하기)를 보세요. 허브가 발급한 클라이언트별 키는 권한이 제한된 `service-api-token` 파일에 저장됩니다. `config.json`에는 저장되지 않습니다. 연결 중 사용량은 허브 기록에서 해당 `apiKeyId`만 조회하고, 연결을 끊은 뒤에는 로컬 기록을 봅니다. 두 기록은 서로 복제되지 않습니다. +`ocx service uninstall`은 로컬 서비스를 제거하지만 클라이언트가 연결되어 있거나, 연결 중 표시의 지문이 현재 키와 일치하거나, 연결 정보가 잘못되었거나 일치하지 않으면 기존 키를 보존합니다. 이전 키의 유효한 표시는 다른 서비스 키를 보존하지 않습니다. 표시 파일이 안전하지 않거나 손상되었거나 읽을 수 없어 키 정리를 확인할 수 없으면 보존했다고 단정하지 않고 경고합니다. 연결된 클라이언트의 로컬 키와 상태를 제거하려면 `ocx disconnect`를 사용하세요. ### 연결된 클라이언트의 상태 표시 diff --git a/src/client/connect.ts b/src/client/connect.ts index d949038609..afaa1edc20 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -37,6 +37,7 @@ import { replaceServiceApiTokenFile, restoreTokenBackup, serviceApiTokenBackupPath, + serviceApiTokenFingerprint, writeTokenBackup, writeServiceApiTokenFile, } from "../lib/service-secrets"; @@ -65,6 +66,7 @@ import { clearClientConnection, commitClientConnection, readClientConnectionState, + markClientConnectPending, clearClientConnectPending, pendingClientConnectMayOwnToken, assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner, } from "./state"; import { assertClientCatalogCompatible, type CatalogCompatibilityDeps } from "./catalog-compatibility"; @@ -492,6 +494,7 @@ function assertConnectingState(expectedTokenFingerprint?: string): void { } } +/** Enroll a client key, keeping its pending ownership visible until commit or rollback. */ export async function connectClient( options: ConnectOptions, deps: ClientConnectDeps = {}, @@ -501,6 +504,7 @@ export async function connectClient( let issued: IssuedClientKey | null = null; let cleanupCredential: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null; let tokenFingerprint: string | null = null; + let pendingConnectFingerprint: string | null = null; let priorCatalog: CatalogSnapshot | null = null; let writtenCatalogFingerprint: string | null = null; let injectionCommitted = false; @@ -536,6 +540,9 @@ export async function connectClient( const initialFiles = withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertConnectingState(); + const fingerprint = serviceApiTokenFingerprint(issued!.key); + markClientConnectPending(fingerprint); + pendingConnectFingerprint = fingerprint; return { prior: catalogSnapshot(), persisted: writeServiceApiTokenFile(issued!.key) }; }), deps.lifecycleLockDeps); priorCatalog = initialFiles.prior; @@ -602,6 +609,7 @@ export async function connectClient( }; withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertConnectingState(persisted.fingerprint); + clearClientConnectPending(persisted.fingerprint); commitClientConnection(connection); committed = true; }), deps.lifecycleLockDeps); @@ -618,9 +626,11 @@ export async function connectClient( if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) { rollbackFailures.push("catalog rollback did not match the written artifact"); } - if (tokenFingerprint) { - const removed = removeServiceApiTokenFileIfOwned(tokenFingerprint); + if (pendingConnectFingerprint) { + const removed = removeServiceApiTokenFileIfOwned(pendingConnectFingerprint); if (removed === "changed") rollbackFailures.push("service token changed during rollback"); + // Final commit may fail after this attempt already cleared its marker under the same lock. + else if (pendingClientConnectMayOwnToken(pendingConnectFingerprint)) clearClientConnectPending(pendingConnectFingerprint); } }), deps.lifecycleLockDeps); } catch { rollbackFailures.push("client cleanup ownership unavailable"); } diff --git a/src/client/state.ts b/src/client/state.ts index fd4045d482..e8374f309f 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -1,5 +1,7 @@ -import { readFileSync } from "node:fs"; +import { lstatSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; import { + getConfigDir, getConfigPath, deleteConfigTopLevelKey, getDefaultConfig, @@ -8,6 +10,7 @@ import { saveConfig, withConfigMutationLockSync, } from "../config"; +import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; import type { OcxClientConnectionConfig } from "../types"; import { inspectRemoteDesktopStore, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store"; import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "./lifecycle-lock"; @@ -23,6 +26,41 @@ export type ClientConnectionState = | { kind: "invalid"; reason: string } | { kind: "mismatched"; reason: string }; +const pendingConnectPath = (): string => join(getConfigDir(), "client-connect-pending"); + +/** Validate pending ownership; an optional fingerprint restricts it to that exact key. */ +export function pendingClientConnectMayOwnToken(fingerprint?: string): boolean { + const path = pendingConnectPath(); + let stat; + try { stat = lstatSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + if (!stat.isFile() || stat.nlink !== 1 || stat.size !== 65) { + throw new Error("pending client connection owner is unsafe"); + } + const marker = readFileSync(path, "utf8"); + if (!/^[a-f0-9]{64}\n$/.test(marker)) throw new Error("pending client connection owner is malformed"); + return fingerprint === undefined || marker === `${fingerprint}\n`; +} + +/** Publish only the token fingerprint, before the key file, under the client lifecycle lock. */ +export function markClientConnectPending(fingerprint: string): void { + if (!/^[a-f0-9]{64}$/.test(fingerprint)) throw new Error("invalid pending client fingerprint"); + atomicWriteFileNoFollowUnclaimed(pendingConnectPath(), `${fingerprint}\n`); +} + +/** Clear only the marker for this connect attempt while the client lifecycle lock is held. */ +export function clearClientConnectPending(fingerprint: string): void { + const path = pendingConnectPath(); + const stat = lstatSync(path); + if (!stat.isFile() || stat.nlink !== 1 || stat.size !== 65 || readFileSync(path, "utf8") !== `${fingerprint}\n`) { + throw new Error("pending client connection owner changed"); + } + unlinkSync(path); +} + export type ClientRotationRecoveryGate = | { kind: "clean" } | { kind: "orphan-cleaned" } diff --git a/src/service/cli.ts b/src/service/cli.ts index d8fea9b069..560fa6c6ed 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -1,9 +1,12 @@ -import { existsSync, unlinkSync } from "node:fs"; +import { existsSync, lstatSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { restoreNativeCodexAsync } from "../codex/inject"; import { describeRetainedCodexProviderTable } from "../codex/inject/restore"; import { stripGrokConfig } from "../grok/inject"; -import { serviceApiTokenFilePath } from "../lib/service-secrets"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { pendingClientConnectMayOwnToken, readClientConnectionState } from "../client/state"; +import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; import { statusWinswRaw, type WinswStatus } from "../lib/winsw"; import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock"; import { maybeShowStarPrompt } from "../cli/star-prompt"; @@ -182,6 +185,32 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs { return { sub: normalizeServiceSubcommand(sub), backend, invalid }; } +/** Remove the service credential only when no client connection can own it. */ +export function removeServiceTokenAfterUninstall( + lockDeps: ClientLifecycleLockDeps = {}, +): "removed" | "absent" | "retained" | "unverified" { + try { + return withClientLifecycleSync(() => withConfigMutationLockSync(() => { + const path = serviceApiTokenFilePath(); + try { lstatSync(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent"; + throw error; + } + if (readClientConnectionState().kind !== "disconnected") return "retained"; + const token = readServiceApiTokenState(); + if (token.kind !== "present") return token.kind === "absent" ? "absent" : "unverified"; + if (pendingClientConnectMayOwnToken(token.fingerprint)) return "retained"; + unlinkSync(path); + return "removed"; + }), lockDeps); + } catch { + // Lock, state-read and unlink failures all leave cleanup unverified, not successful. + return "unverified"; + } +} + +/** Execute a service verb while preserving client-owned credentials during uninstall. */ export async function serviceCommand(...args: (string | undefined)[]): Promise { const filteredArgs = args.filter((a): a is string => Boolean(a)); const execute = async (): Promise => { @@ -424,7 +453,9 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { /** A catalog the user already had before ever connecting. */ const PRIOR_CATALOG_BYTES = '{"models":[{"slug":"local/only-model"}]}'; +/** Exercise enrollment and rollback in a fresh process with isolated client homes. */ function runTransactionScenario( - stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog" | "coordinator", + stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog" | "coordinator" | "uninstall-during-catalog", options: { script?: string; timeoutMs?: number } = {}, ) { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-home-")); @@ -430,6 +431,7 @@ function runTransactionScenario( const stage = ${JSON.stringify(stage)}; markTransaction("module_ready"); let commitFaultTriggered = false; + let uninstallDuringCatalog = null; const catalog = '{"models":[]}'; const etag = '"sha256-' + createHash("sha256").update(catalog).digest("base64url") + '"'; const calls = []; @@ -447,6 +449,13 @@ function runTransactionScenario( if (url.endsWith("/api/keys") && init.method === "DELETE") return Response.json({ success: true }); if (url.endsWith("/v1/catalog")) { if (stage === "catalog") return Response.json({ error: "down" }, { status: 503 }); + if (stage === "uninstall-during-catalog") { + const { removeServiceTokenAfterUninstall } = require("./src/service/cli"); + uninstallDuringCatalog = { + cleanup: removeServiceTokenAfterUninstall({ lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" }), + tokenExists: existsSync(serviceApiTokenFilePath()), + }; + } return new Response(catalog, { headers: { ETag: etag, "Content-Type": "application/json" } }); } throw new Error("unexpected request " + url); @@ -500,7 +509,7 @@ function runTransactionScenario( if ((stage === "success" || stage === "prior-catalog") && connected) disconnected = await disconnectClient({}, { lifecycleLockDeps: { lockPath: process.env.OPENCODEX_HOME + "/lifecycle.sqlite" } }); const catalogAfter = existsSync(DEFAULT_CATALOG_PATH) ? readFileSync(DEFAULT_CATALOG_PATH, "utf8") : null; const hubStateCacheAfter = existsSync(hubStateCachePath()); - writeSync(1, JSON.stringify({ connected, error, coordinatorUnavailable, beforeDisconnect, artifacts, disconnected, catalogAfter, hubStateCacheBefore, hubStateCacheAfter, after: readClientConnectionState(), calls, commitFaultTriggered }) + "\\n"); + writeSync(1, JSON.stringify({ connected, error, coordinatorUnavailable, beforeDisconnect, artifacts, disconnected, catalogAfter, hubStateCacheBefore, hubStateCacheAfter, after: readClientConnectionState(), calls, commitFaultTriggered, uninstallDuringCatalog, pendingAtResult: existsSync(process.env.OPENCODEX_HOME + "/client-connect-pending") }) + "\\n"); markTransaction("result_published"); })(); `; @@ -669,7 +678,19 @@ describe("connect transaction and offline disconnect", () => { expect(run.parsed.after).toEqual({ kind: "disconnected" }); expect(run.parsed.calls.filter((call: any) => call.method === "DELETE")).toEqual([]); } finally { run.cleanup(); } - }); + }, SPAWN_BUDGET_MS); + + test("service uninstall during catalog download retains the pending client key", () => { + const run = runTransactionScenario("uninstall-during-catalog"); + try { + expect(run.status).toBe(0); + expect(run.parsed.uninstallDuringCatalog).toEqual({ cleanup: "retained", tokenExists: true }); + expect(run.parsed.error).toBeNull(); + expect(run.parsed.connected.apiKeyId).toBe("issued-id"); + expect(run.parsed.beforeDisconnect.kind).toBe("connected"); + expect(run.parsed.pendingAtResult).toBe(false); + } finally { run.cleanup(); } + }, SPAWN_BUDGET_MS); test("disconnect puts back the catalog the user had before connecting", () => { // Connect overwrites whatever catalog is already on disk. Disconnect used to delete the @@ -684,7 +705,7 @@ describe("connect transaction and offline disconnect", () => { expect(run.parsed.catalogAfter).toBe(PRIOR_CATALOG_BYTES); expect(run.parsed.after).toEqual({ kind: "disconnected" }); } finally { run.cleanup(); } - }); + }, SPAWN_BUDGET_MS); test("disconnect removes the catalog when the user had none", () => { // The other half of the same contract: `priorCatalog: ""` records "there genuinely was @@ -694,7 +715,7 @@ describe("connect transaction and offline disconnect", () => { expect(run.parsed.disconnected).toMatchObject({ catalogRemoved: true, catalogRestored: false }); expect(run.parsed.catalogAfter).toBeNull(); } finally { run.cleanup(); } - }); + }, SPAWN_BUDGET_MS); for (const stage of ["catalog", "preflight", "commit"] as const) { test(`rolls back local artifacts when ${stage} fails before final commit`, () => { @@ -704,11 +725,13 @@ describe("connect transaction and offline disconnect", () => { expect(run.parsed.connected).toBeNull(); expect(run.parsed.beforeDisconnect).toEqual({ kind: "disconnected" }); expect(run.parsed.artifacts.token).toBe(false); + expect(run.parsed.pendingAtResult).toBe(false); expect(run.parsed.artifacts.catalog).toBe(false); expect(run.parsed.artifacts.credentialZeroed).toBe(true); expect(run.parsed.calls.some((call: any) => call.method === "DELETE")).toBe(true); if (stage === "commit") { expect(run.parsed.commitFaultTriggered).toBe(true); + expect(run.parsed.error).not.toContain("client cleanup ownership unavailable"); expect(run.parsed.calls.some((call: any) => call.method === "POST" && call.url.endsWith("/api/keys"))).toBe(true); } expect(run.configBytes).not.toContain("issued-id"); diff --git a/tests/service/service-secrets.test.ts b/tests/service/service-secrets.test.ts index 3653e8b367..7196649016 100644 --- a/tests/service/service-secrets.test.ts +++ b/tests/service/service-secrets.test.ts @@ -5,6 +5,7 @@ import { chmodSync, existsSync, lstatSync, + mkdirSync, mkdtempSync, readFileSync, renameSync, @@ -32,6 +33,10 @@ import { writeTokenBackup, } from "../../src/lib/service-secrets"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { loadConfig, saveConfig } from "../../src/config"; +import { readClientConnectionState } from "../../src/client/state"; +import { clearClientConnectPending, markClientConnectPending } from "../../src/client/state"; +import { removeServiceTokenAfterUninstall } from "../../src/service/cli"; let home = ""; const previousHome = process.env.OPENCODEX_HOME; @@ -47,6 +52,113 @@ afterEach(() => { if (home) removeTreeWithRetry(home); }); +describe("service uninstall credential ownership", () => { + /** Run cleanup under the same synthetic lifecycle lock as a client connection. */ + function cleanup(): "removed" | "absent" | "retained" | "unverified" { + return removeServiceTokenAfterUninstall({ lockPath: join(home, "lifecycle.sqlite") }); + } + + test("keeps the connected client's key while removing the service", () => { + const token = writeServiceApiTokenFile("ocx_client_key"); + const config = loadConfig(); + config.runtimeRole = "client"; + config.client = { + serverUrl: "https://hub.example.test", managementUrl: "https://hub.example.test", + managementTransport: "direct", selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId: "client-key", + tokenFingerprint: token.fingerprint, protocolVersion: 1, + connectedAt: "2026-09-06T00:00:00.000Z", + }; + saveConfig(config); + expect(readClientConnectionState().kind).toBe("connected"); + + expect(cleanup()).toBe("retained"); + expect(readFileSync(token.path, "utf8")).toBe("ocx_client_key\n"); + rmSync(token.path); + expect(cleanup()).toBe("absent"); + }); + + test("removes an unowned service token", () => { + const token = writeServiceApiTokenFile("ocx_service_key"); + expect(readClientConnectionState().kind).toBe("disconnected"); + expect(cleanup()).toBe("removed"); + expect(existsSync(token.path)).toBe(false); + expect(cleanup()).toBe("absent"); + }); + + test("retains a key owned by a pending connection", () => { + const token = writeServiceApiTokenFile("ocx_pending_client_key"); + markClientConnectPending(token.fingerprint); + expect(readClientConnectionState().kind).toBe("disconnected"); + expect(cleanup()).toBe("retained"); + expect(readFileSync(token.path, "utf8")).toBe("ocx_pending_client_key\n"); + clearClientConnectPending(token.fingerprint); + expect(cleanup()).toBe("removed"); + }); + + test("a stale pending marker does not retain a replacement service key", () => { + const staleFingerprint = serviceApiTokenFingerprint("ocx_old_client_key"); + markClientConnectPending(staleFingerprint); + const token = writeServiceApiTokenFile("ocx_replacement_service_key"); + expect(cleanup()).toBe("removed"); + expect(existsSync(token.path)).toBe(false); + expect(readFileSync(join(home, "client-connect-pending"), "utf8")).toBe(`${staleFingerprint}\n`); + }); + + for (const marker of ["", "z".repeat(64) + "\n", "a".repeat(66)]) { + test(`malformed pending marker of length ${marker.length} leaves cleanup unverified`, () => { + const token = writeServiceApiTokenFile("ocx_uncertain_pending_key"); + writeFileSync(join(home, "client-connect-pending"), marker); + expect(cleanup()).toBe("unverified"); + expect(readFileSync(token.path, "utf8")).toBe("ocx_uncertain_pending_key\n"); + }); + } + + test("unsafe or unreadable pending markers preserve the key without claiming ownership", () => { + const token = writeServiceApiTokenFile("ocx_unreadable_pending_key"); + const markerPath = join(home, "client-connect-pending"); + mkdirSync(markerPath); + expect(cleanup()).toBe("unverified"); + nodeFs.rmdirSync(markerPath); + markClientConnectPending(token.fingerprint); + const original = nodeFs.readFileSync; + const read = spyOn(nodeFs, "readFileSync").mockImplementation(((path: any, ...args: any[]) => { + if (path === markerPath) throw Object.assign(new Error("fixture marker read failure"), { code: "EACCES" }); + return original(path, ...args); + }) as typeof nodeFs.readFileSync); + try { expect(cleanup()).toBe("unverified"); } + finally { read.mockRestore(); } + expect(readFileSync(token.path, "utf8")).toBe("ocx_unreadable_pending_key\n"); + }); + + test("keeps the token when client metadata is incomplete", () => { + const token = writeServiceApiTokenFile("ocx_uncertain_key"); + writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "client" })); + expect(readClientConnectionState().kind).toBe("mismatched"); + + expect(cleanup()).toBe("retained"); + expect(readFileSync(token.path, "utf8")).toBe("ocx_uncertain_key\n"); + }); + + test("keeps the token when client metadata is invalid", () => { + const token = writeServiceApiTokenFile("ocx_invalid_client_key"); + writeFileSync(join(home, "config.json"), JSON.stringify({ runtimeRole: "client", client: {} })); + expect(readClientConnectionState().kind).toBe("invalid"); + + expect(cleanup()).toBe("retained"); + expect(readFileSync(token.path, "utf8")).toBe("ocx_invalid_client_key\n"); + }); + + test("reports unavailable lifecycle ownership separately from retained keys", () => { + const token = writeServiceApiTokenFile("ocx_unverified_key"); + const blockedLock = join(home, "blocked-lifecycle-lock"); + mkdirSync(blockedLock); + + expect(removeServiceTokenAfterUninstall({ lockPath: blockedLock })).toBe("unverified"); + expect(readFileSync(token.path, "utf8")).toBe("ocx_unverified_key\n"); + }); +}); + /** * #4236. A service boot always saw OPENCODEX_API_AUTH_TOKEN, because the launchd plist and the * systemd unit cat the token file into the environment before exec. A foreground `ocx start` saw From 13a69bd07419b86abd70a886ebdc658ce53c3844 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:01:16 +0900 Subject: [PATCH 03/14] fix(codex): bound aggregate rollout inspection during startup (#5703) The native-residue classifier limited each referenced rollout to 64 MiB but not the total, so the read-only startup preflight could parse gigabytes of conversation history on every Codex launch (#5701). One observation now reads at most 64 MiB of rollout content across the history database and backup manifest together; a file that would exceed the remaining budget is reported indeterminate before it is read, and classification stops at the first indeterminate surface. Exhausting the budget never reports clean. Carries #5703. Closes #5701. Co-authored-by: Konstantinos <37538071+konstantinosbotonakis@users.noreply.github.com> --- src/codex/native-residue.ts | 38 +++++++++++---- structure/config.md | 7 +++ .../codex-native-residue.test.ts | 48 +++++++++++++++++++ 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index 9bc73f0279..c7898b1241 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -82,6 +82,9 @@ const JOURNAL_FILE_NAME = "opencodex-journal.json"; const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → "; const MAX_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024; const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024; +// Bound one observation across every referenced rollout, not just each file. +const MAX_TOTAL_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024; +type RolloutInspectionBudget = { remainingBytes: number }; function errorCode(error: unknown): string | undefined { return (error as NodeJS.ErrnoException | undefined)?.code; @@ -424,6 +427,7 @@ function classifyPartialWrites(targetPaths: string[]): NativeRoutedResidueResult function classifyReferencedRollout( surface: "history" | "history-backup", reference: RolloutReference, + budget: RolloutInspectionBudget, ): NativeRoutedResidueResult { const resolved = resolveRegularFile(reference.path); if (resolved.kind === "absent") { @@ -451,6 +455,14 @@ function classifyReferencedRollout( `referenced rollout exceeds the ${MAX_ROLLOUT_INSPECTION_BYTES} byte inspection limit`, ); } + if (opened.size > budget.remainingBytes) { + return indeterminate( + surface, + resolved.path, + `referenced rollouts exceed the ${MAX_TOTAL_ROLLOUT_INSPECTION_BYTES} byte aggregate inspection limit`, + ); + } + budget.remainingBytes -= opened.size; const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); const buffer = Buffer.allocUnsafe(ROLLOUT_READ_CHUNK_BYTES); while (totalRead < opened.size) { @@ -528,15 +540,16 @@ function classifyReferencedRollout( function classifyReferencedRollouts( surface: "history" | "history-backup", references: RolloutReference[], + budget: RolloutInspectionBudget, ): NativeRoutedResidueResult { for (const reference of references) { - const result = classifyReferencedRollout(surface, reference); + const result = classifyReferencedRollout(surface, reference, budget); if (result.kind !== "clean") return result; } return { kind: "clean" }; } -function classifyHistoryDatabase(path: string): NativeRoutedResidueResult { +function classifyHistoryDatabase(path: string, budget: RolloutInspectionBudget): NativeRoutedResidueResult { const resolved = resolveRegularFile(path); if (resolved.kind === "absent") { for (const suffix of ["-wal", "-shm"]) { @@ -576,6 +589,7 @@ function classifyHistoryDatabase(path: string): NativeRoutedResidueResult { rows .filter(row => row.model_provider !== "opencodex") .map(row => ({ id: row.id, path: row.rollout_path })), + budget, ); if (rollouts.kind !== "clean") return rollouts; const after = statSync(resolved.path); @@ -600,7 +614,7 @@ function historyBackupPath(stateDatabasePath: string): string { return legacy !== canonical && existsSync(legacy) ? legacy : canonical; } -function classifyHistoryBackup(path: string, stateDatabasePath: string): NativeRoutedResidueResult { +function classifyHistoryBackup(path: string, stateDatabasePath: string, budget: RolloutInspectionBudget): NativeRoutedResidueResult { const read = readRegularFile(path); if (read.kind === "absent") return { kind: "clean" }; if (read.kind === "indeterminate") return indeterminate("history-backup", path, read.reason); @@ -630,7 +644,7 @@ function classifyHistoryBackup(path: string, stateDatabasePath: string): NativeR for (const entry of Object.values(validated.manifest.entries)) { references.push({ id: entry.id, path: entry.rolloutPath }); } - const rollouts = classifyReferencedRollouts("history-backup", references); + const rollouts = classifyReferencedRollouts("history-backup", references, budget); if (rollouts.kind !== "clean") return rollouts; return entries.length > 0 ? { kind: "residue", surface: "history-backup", path: read.path } @@ -672,6 +686,7 @@ export function classifyNativeRoutedResidue(): NativeRoutedResidueResult { journalPath, ...config.catalogTargets.map(target => target.path), ]; + const budget = { remainingBytes: MAX_TOTAL_ROLLOUT_INSPECTION_BYTES }; const classifiers = [ () => classifyPartialWrites(atomicWriteTargets), () => config.classification, @@ -679,11 +694,14 @@ export function classifyNativeRoutedResidue(): NativeRoutedResidueResult { ...config.catalogTargets.map(target => () => classifyCatalogLike("catalog", target.path, target.configured)), () => classifyCatalogLike("models-cache", modelsCachePath), () => classifyJournal(journalPath), - () => classifyHistoryDatabase(stateDatabasePath), - () => classifyHistoryBackup(historyBackupPath(stateDatabasePath), stateDatabasePath), + () => classifyHistoryDatabase(stateDatabasePath, budget), + () => classifyHistoryBackup(historyBackupPath(stateDatabasePath), stateDatabasePath, budget), ]; - const results = classifiers.map(classify => classify()); - return results.find(result => result.kind === "indeterminate") - ?? results.find(result => result.kind === "residue") - ?? { kind: "clean" }; + let firstResidue: NativeRoutedResidueResult = { kind: "clean" }; + for (const classify of classifiers) { + const result = classify(); + if (result.kind === "indeterminate") return result; + if (result.kind === "residue" && firstResidue.kind === "clean") firstResidue = result; + } + return firstResidue; } diff --git a/structure/config.md b/structure/config.md index 9c11738e07..d05cd9b6e6 100644 --- a/structure/config.md +++ b/structure/config.md @@ -248,6 +248,13 @@ either file. History Worker job targets use that same canonical-first lookup rat `history-provider.ts` remains the strict mutation owner and maps shared validation failures to its restore/no-op integrity states. `native-residue.ts` remains a read-only observer and maps the same result to clean, residue, or indeterminate before inspecting referenced rollout files. +One observation reads at most 64 MiB of rollout content across the history database and backup +manifest together. The budget resets on each observation. A file that would exceed the remaining +budget produces `indeterminate` before its content is read; exhausting the budget never proves +that the history is clean. Classification stops at the first indeterminate surface, while a +residue result still allows later surfaces to report uncertainty. This bounds repeated CLI +startup checks on large conversation histories without rewriting history or weakening the +coordinator's existing refusal and compatibility paths. > Decision record: [ADR-0018](decisions/ADR-0018-config-injection.md) diff --git a/tests/codex-integration/codex-native-residue.test.ts b/tests/codex-integration/codex-native-residue.test.ts index 07dcffe46a..0a870265e6 100644 --- a/tests/codex-integration/codex-native-residue.test.ts +++ b/tests/codex-integration/codex-native-residue.test.ts @@ -999,3 +999,51 @@ test("a missing coordinator with only the generated profile refuses initializati message: "A missing coordinator row cannot be initialized while native Codex routing residue exists.", }); }); + +// Performance regression scenarios: small native history remains clean; routed +// metadata remains residue; malformed history stays indeterminate; the existing +// per-file limit stays intact; many individually valid files cannot cause an +// unbounded aggregate scan. The original code fails the last scenario. +test("history inspection refuses aggregate rollout work above 64 MiB", () => { + createHistoryDatabase("openai"); + const event = JSON.stringify({ type: "event_msg", payload: { message: "x".repeat(1000) } }) + "\n"; + const body = event.repeat(Math.ceil(34 * 1024 * 1024 / event.length)); + writeFileSync(pathInCodexHome("rollout.jsonl"), sessionMeta("thread-1", "openai") + "\n" + body); + const second = pathInCodexHome("rollout-2.jsonl"); + writeFileSync(second, sessionMeta("thread-2", "openai") + "\n" + body); + const db = new Database(pathInCodexHome("state_5.sqlite")); + db.query("INSERT INTO threads VALUES (?, ?, 'openai', 'cli', 'native history', 1)").run("thread-2", second); + db.close(); + const result = classifyNativeRoutedResidue(); + expect(result.kind).toBe("indeterminate"); + if (result.kind !== "indeterminate") throw new Error("aggregate work must not be classified as clean"); + expect(result.surface).toBe("history"); + expect(result.reason).toContain("aggregate inspection limit"); + // Classification is read-only: neither file nor the native provider is changed. + expect(lstatSync(second).size).toBe(Buffer.byteLength(sessionMeta("thread-2", "openai") + "\n" + body)); + const verify = new Database(pathInCodexHome("state_5.sqlite"), { readonly: true }); + expect(verify.query("SELECT count(*) AS n FROM threads WHERE model_provider = 'openai'").get()).toEqual({ n: 2 }); + verify.close(); +}); + +test("rollout budget resets for each classification", () => { + createHistoryDatabase("openai"); + const line = JSON.stringify({ type: "event_msg", payload: { message: "x".repeat(1000) } }) + "\n"; + writeFileSync(pathInCodexHome("rollout.jsonl"), sessionMeta("thread-1", "openai") + "\n" + line.repeat(Math.ceil(34 * 1024 * 1024 / line.length))); + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); +}); + +test("history and backup rollout scans share the aggregate budget", () => { + createHistoryDatabase("openai"); + const line = JSON.stringify({ type: "event_msg", payload: { message: "x".repeat(1000) } }) + "\n"; + writeFileSync(pathInCodexHome("rollout.jsonl"), sessionMeta("thread-1", "openai") + "\n" + line.repeat(Math.ceil(34 * 1024 * 1024 / line.length))); + writeFileSync(historyBackupPath(), JSON.stringify(validHistoryBackupFixture( + canonicalPathInCodexHome("state_5.sqlite"), canonicalPathInCodexHome("rollout.jsonl"), + ))); + const result = classifyNativeRoutedResidue(); + expect(result.kind).toBe("indeterminate"); + if (result.kind !== "indeterminate") throw new Error("aggregate work must fail closed"); + expect(result.surface).toBe("history-backup"); + expect(result.reason).toContain("aggregate inspection limit"); +}); From 3d6b95a7270c9c25c34b7f1138744fd5b1bc5e1c Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:01:24 +0900 Subject: [PATCH 04/14] fix(codex-home): import path expansion without the config facade (#5548) src/codex/home.ts imported expandUserPath from the ../config barrel, which re-enters the config facade before the Codex home resolver has initialized. On a fresh WSL process that import cycle could fail before the home was resolved. The resolver now imports expandUserPath from src/config/paths.ts directly, and a fresh-process regression test loads the resolver first with a tilde CODEX_HOME. Only the import fix, its structure note and the fresh-process test are carried; the PR's cli-help, service and docker expectation edits target the documented container-only failures and stay out. Carries #5548 (partial). Co-authored-by: Vadevious --- src/codex/home.ts | 2 +- structure/codex-home.md | 1 + .../codex-integration/codex-home-wsl.test.ts | 31 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/codex/home.ts b/src/codex/home.ts index 71a2ad0a0a..49eb0a0be0 100644 --- a/src/codex/home.ts +++ b/src/codex/home.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, posix, resolve, win32 } from "node:path"; -import { expandUserPath } from "../config"; +import { expandUserPath } from "../config/paths"; import { redactUserPath } from "../lib/redact"; export type CodexHomeDeps = { diff --git a/structure/codex-home.md b/structure/codex-home.md index ae7c6afca3..bf3683b265 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -41,6 +41,7 @@ user off a Windows home they were running against, and a stat failure other than local home rather than switching to a different one. Codex runtime discovery (src/codex/runtime.ts) also reads this home on Linux: after an explicit runtime, PATH, and the ordinary install locations, it enumerates the direct children of /bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly set path that is unreadable or not a directory is an error, not a fallback: silently using a different home than the operator named would write provider state where nobody is looking for it. A fresh install can have that directory but no `config.toml` yet; applying the integration then creates an empty `config.toml` there (never overwriting an existing file) and continues, while a missing home directory is refused with instructions to start Codex once or set `CODEX_HOME` (issue 5422). +`src/codex/home.ts` imports path expansion directly from `src/config/paths.ts`, so a fresh WSL process can resolve its Codex home without re-entering the config facade before the resolver initializes. The managed files are: ```text diff --git a/tests/codex-integration/codex-home-wsl.test.ts b/tests/codex-integration/codex-home-wsl.test.ts index e56c60d169..f2de8acb6b 100644 --- a/tests/codex-integration/codex-home-wsl.test.ts +++ b/tests/codex-integration/codex-home-wsl.test.ts @@ -1,10 +1,41 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaultCodexHome, wslAutomountRoot, listWslWindowsCodexHomes } from "../../src/codex/home"; import { isWindowsInteropDir } from "../../src/codex/shim"; import { currentServiceHomes, serviceCodexHomeMatchesInstall } from "../../src/service"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; describe("wsl.conf automount root", () => { + test("loads and expands the home resolver first in a fresh WSL-like process", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-wsl-import-")); + try { + const child = Bun.spawn([process.execPath, "--eval", ` + const { wslAutomountRoot, resolveCodexHomeDir } = await import("./src/codex/home.ts"); + console.log(wslAutomountRoot({ wslConf: null })); + console.log(resolveCodexHomeDir()); + `], { + cwd: repoPath(), + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: "~/.codex", WSL_DISTRO_NAME: "Ubuntu" }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout.trim().split(/\r?\n/)).toEqual(["/mnt", join(home, ".codex")]); + } finally { + removeTreeWithRetry(home); + } + }, 15_000); + test("defaults to /mnt when wsl.conf is absent or silent", () => { expect(wslAutomountRoot({ wslConf: null })).toBe("/mnt"); expect(wslAutomountRoot({ wslConf: "[boot]\nsystemd=true\n" })).toBe("/mnt"); From 197c12fd495f8747651d793bb0c566fc318ac3be Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:04:53 +0900 Subject: [PATCH 05/14] docs(remote-hub): translate the service uninstall key-retention note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the #5713 uninstall paragraph to the fr, ja, ru, tr, zh-cn and zh-tw remote hub guides so no locale describes the old unconditional token deletion. Co-authored-by: 정우철 --- .../260924_l4_codex_cli_service/010_carry_ready_prs.md | 6 ++++++ docs-site/src/content/docs/fr/guides/remote-hub.md | 1 + docs-site/src/content/docs/ja/guides/remote-hub.md | 1 + docs-site/src/content/docs/ru/guides/remote-hub.md | 1 + docs-site/src/content/docs/tr/guides/remote-hub.md | 1 + docs-site/src/content/docs/zh-cn/guides/remote-hub.md | 1 + docs-site/src/content/docs/zh-tw/guides/remote-hub.md | 1 + 7 files changed, 12 insertions(+) diff --git a/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md b/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md index e633c32e8a..ca9be0de84 100644 --- a/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md +++ b/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md @@ -12,3 +12,9 @@ Files: src/codex/native-residue.ts, structure/config.md, tests/codex-integration ## #5548 slice — Vadevious Only src/codex/home.ts (import expandUserPath from ../config/paths), structure/codex-home.md line, tests/codex-integration/codex-home-wsl.test.ts (new: register in layout.json explicit + test-layout-expected.json if not matched by a seed). Excluded: tests/cli/cli-help.test.ts, tests/service/service-probe-docker.test.ts, tests/service/service.test.ts. Audit fold: codex-home-wsl.test.ts already exists on dev (from #5720) and is registered; carry only the PR's added fresh-process case into it. home.ts:4 currently imports from ../config (barrel) — the fix switches to ../config/paths. + +## wp1 P (executable) +- All three squash diffs pass git apply --check -3 on 34fb6d649c (/tmp/l4-5713.diff, /tmp/l4-5703.diff, /tmp/l4-5548.diff limited to 3 files). +- Commit order: #5713, #5703, #5548 slice; each commit carries Co-authored-by for the PR author. +- #5713 docs: en/ko remote-hub.md updated by the PR; DeepSeek writer adds the same paragraph to fr, ja, ru, tr, zh-cn, zh-tw remote-hub.md next to the service-api-token paragraph. +- Focused tests: tests/service/service-secrets.test.ts tests/clients/client-connect.test.ts tests/codex-integration/codex-native-residue.test.ts tests/codex-integration/codex-home-wsl.test.ts tests/test-layout.test.ts; plus the file-size ratchet test. diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index 881a3fc9c1..96cbb63a0e 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -18,6 +18,7 @@ ocx sync Les diagnostics de disponibilité lisibles par un humain affichent les caractères de contrôle des valeurs du catalogue sous forme d’échappements hexadécimaux visibles, aussi bien à la première connexion que lorsque `ocx sync` refuse un catalogue de hub actualisé. Le statut JSON conserve la valeur de diagnostic d’origine. La clé client est écrite dans le fichier privé `service-api-token`, jamais dans `config.json`. En mode connecté, l’usage provient du hub et est filtré par `apiKeyId`; après déconnexion, il provient du stockage local. Il n’existe aucune réplication entre les deux. +`ocx service uninstall` supprime le service local mais conserve une clé existante lorsque le client est connecté, que ses métadonnées de connexion sont invalides ou non concordantes, ou qu’un marqueur de connexion en attente correspond à la clé actuelle. Un marqueur valide pour une clé plus ancienne ne conserve pas une clé de service sans rapport. Si un marqueur est peu sûr, malformé ou illisible, le nettoyage du jeton ne peut pas être vérifié ; la commande avertit au lieu d’affirmer que la clé a été conservée. Utilisez `ocx disconnect` pour supprimer la clé locale et l’état d’un client connecté. Le jeton admin permet la gestion ordinaire mais ne peut jamais créer une session de consentement. Les actions de consentement exigent une `gui-session`, une Origin correspondante et un jeton CSRF. `Tailscale-User-Login` n’est fiable que sur l’entrée de gestion dédiée; renseignez les identités exactes dans `remoteGui.allowedTailscaleUsers`. diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index cefc0247a8..d8dd1f202d 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -18,6 +18,7 @@ ocx sync 準備状況を人が読む出力では、カタログ値に含まれる C0/C1 制御文字、DEL、Unicode の行・段落区切り文字(U+2028、U+2029)を目に見える 16 進エスケープとして表示します。初回の接続だけでなく、`ocx sync` が取得し直したハブのカタログを拒否したときも同じです。JSON 形式の状態には元の診断値をそのまま残します。 発行されたキーは所有者だけが読める `service-api-token` に保存され、`config.json` には入りません。接続中の使用量は hub 側で同じ `apiKeyId` に絞り込まれ、切断後はローカル保存分を表示します。両者はミラーリングされません。 +`ocx service uninstall` はローカルサービスを削除しますが、クライアントが接続中である場合、接続メタデータが無効または不一致である場合、または保留中の接続マーカーが現在のキーと一致する場合は、既存のキーを保持します。古いキーに対する有効なマーカーがあっても、無関係なサービスキーは保持されません。マーカーが安全でない、不正な形式、または読み取れない場合、トークンのクリーンアップを確認できないため、キーを保持したと断定せずに警告します。接続中のクライアントのローカルキーと状態を削除するには `ocx disconnect` を使用します。 管理トークンは通常の管理だけに使え、同意セッションを作ることは永久にできません。同意操作にはサーバー発行の `gui-session`、一致する Origin、CSRF が必要です。`Tailscale-User-Login` は専用管理リスナーでのみ信頼し、許可する ID を `remoteGui.allowedTailscaleUsers` に正確に設定します。 diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index 0aa639deda..53f64a64b2 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -18,6 +18,7 @@ ocx sync В читаемой человеком диагностике готовности управляющие символы C0/C1, DEL и разделители строк и абзацев Unicode (U+2028 и U+2029) из значений каталога показываются как видимые шестнадцатеричные escape-последовательности — и при первом подключении, и когда `ocx sync` отклоняет обновлённый каталог hub. В JSON-статусе исходное значение диагностики сохраняется без изменений. Ключ клиента записывается в защищённый `service-api-token`, а не в `config.json`. При подключении статистика читается с hub и фильтруется по `apiKeyId`; после отключения используется локальное хранилище. Зеркалирования нет. +`ocx service uninstall` удаляет локальную службу, но сохраняет существующий ключ, если клиент подключён, его метаданные подключения недействительны или не совпадают, либо ожидающий маркер подключения совпадает с текущим ключом. Действительный маркер для старого ключа не сохраняет посторонний ключ службы. Если маркер небезопасен, повреждён или недоступен для чтения, очистку токена проверить нельзя, поэтому команда выдаёт предупреждение вместо утверждения, что ключ сохранён. Используйте `ocx disconnect`, чтобы удалить локальный ключ и состояние подключённого клиента. Admin token разрешает обычное управление, но никогда не создаёт consent session. Для действий с согласием нужны `gui-session`, совпадающий Origin и CSRF. Заголовок `Tailscale-User-Login` доверен только отдельному management ingress; точные логины задаются в `remoteGui.allowedTailscaleUsers`. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index e822493a94..83dbfde4a6 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -18,6 +18,7 @@ ocx sync İnsanın okuyacağı hazırlık tanılarında katalog değerlerindeki denetim karakterleri görünür onaltılık kaçış dizileri olarak yazılır; bu hem ilk bağlanışta hem de `ocx sync` yenilenen hub kataloğunu reddettiğinde geçerlidir. JSON durumu özgün tanı değerini olduğu gibi korur. İstemci anahtarı yalnızca sahibinin okuyabildiği `service-api-token` dosyasına yazılır, `config.json` içine yazılmaz. Bağlı kullanım hub deposundan aynı `apiKeyId` ile filtrelenir; bağlantı kesilince yerel depo kullanılır. İki depo birbirini yansıtmaz. +`ocx service uninstall` yerel servisi kaldırır ancak istemci bağlıysa, bağlantı meta verisi geçersiz veya uyuşmuyorsa ya da bekleyen bir bağlantı işaretleyicisi geçerli anahtarla eşleşiyorsa mevcut anahtarı korur. Eski bir anahtara ait geçerli bir işaretleyici, ilgisiz bir servis anahtarını korumaz. Bir işaretleyici güvenli değilse, bozuksa veya okunamıyorsa anahtar temizliği doğrulanamaz; komut anahtarın korunduğunu iddia etmek yerine uyarır. Bağlı bir istemcinin yerel anahtarını ve durumunu kaldırmak için `ocx disconnect` kullanın. Admin token sıradan yönetim yapabilir ancak hiçbir zaman onay oturumu oluşturamaz. Onay işlemleri sunucu tarafından verilen `gui-session`, eşleşen Origin ve CSRF ister. `Tailscale-User-Login` yalnızca ayrı yönetim girişinde güvenilirdir; tam kimlikleri `remoteGui.allowedTailscaleUsers` içinde belirtin. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 2f5994aa92..dcdfa00c0e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -18,6 +18,7 @@ ocx sync 面向人阅读的就绪诊断会把目录值中的 C0/C1 控制字符、DEL 以及 Unicode 行分隔符和段落分隔符(U+2028、U+2029)显示为可见的十六进制转义,首次连接时如此,`ocx sync` 拒绝重新获取的 hub 目录时也一样。JSON 状态仍保留原始的诊断值。 客户端密钥写入仅所有者可读的 `service-api-token`,绝不会写入 `config.json`。连接期间,使用记录来自 hub 并按稳定的 `apiKeyId` 过滤;断开后显示本地记录。两者不会镜像。 +`ocx service uninstall` 会移除本地服务,但在客户端处于连接状态、其连接元数据无效或不匹配,或待处理的连接标记与当前密钥一致时保留现有密钥。旧密钥的有效标记不会保留无关的服务密钥。如果标记不安全、格式错误或无法读取,则无法确认令牌清理结果,此时命令会发出警告,而不会声称密钥已保留。要移除已连接客户端的本地密钥和状态,请使用 `ocx disconnect`。 Admin token 只能执行普通管理,永远不能创建用户同意会话。用户同意操作必须使用服务器签发的 `gui-session`、匹配的 Origin 和 CSRF。`Tailscale-User-Login` 只在独立管理入口可信;请在 `remoteGui.allowedTailscaleUsers` 中填写准确登录名。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md index 6de466f325..9b473c95b0 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md @@ -18,6 +18,7 @@ ocx sync 供人閱讀的就緒診斷會把目錄值中的控制字元顯示為可見的十六進位逸出序列,首次連線時如此,`ocx sync` 拒絕重新取得的 hub 目錄時也一樣。JSON 狀態仍保留原始的診斷值。 用戶端金鑰會寫入只有擁有者可讀的 `service-api-token`,絕不寫入 `config.json`。連線期間,用量來自 hub 並依穩定的 `apiKeyId` 篩選;中斷後則顯示本機記錄。兩者不會互相鏡像。 +`ocx service uninstall` 會移除本機服務,但在用戶端處於連線狀態、其連線中繼資料無效或不相符,或待處理的連線標記與目前金鑰一致時保留現有金鑰。舊金鑰的有效標記不會保留無關的服務金鑰。若標記不安全、格式錯誤或無法讀取,就無法驗證權杖是否已清理;此時命令會發出警告,而不會宣稱金鑰已保留。如要移除已連線用戶端的本機金鑰與狀態,請使用 `ocx disconnect`。 Admin token 只能執行一般管理,永遠不能建立使用者同意工作階段。同意操作必須使用伺服器簽發的 `gui-session`、相符的 Origin 與 CSRF。`Tailscale-User-Login` 只在獨立管理入口可信;請在 `remoteGui.allowedTailscaleUsers` 填入完整且正確的登入名稱。 From 9b0d217ba0f2dfa04526347bd7c62ced551ed449 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:09:37 +0900 Subject: [PATCH 06/14] fix(identity): name the worker's own model when a sub-agent is spawned (#5221) Codex stores a session's instruction block once and replays it verbatim into a sub-agent spawned on a different model, so a worker inherited the parent's identity sentence and answered identity questions with the parent's model id (#5217). The catalog no longer bakes a model id into base_instructions; it writes the model-neutral line. The destination is named at request time: the request parser names the neutral line or a stale routed sentence in the top-level instructions and developer items, adapters that call identifyRoutedModel still settle on the wire model id, and the Responses passthrough renames on a routed destination and drops the sentence on a native/forward one. Only sentences this proxy generated are matched. Rebuilt on current dev. On top of #5221: the parser also covers instructions, so adapters that build their own system text (devin, ollama-native, cursor, qoder, codebuddy, mimo-free) keep a named identity; a forward strip that empties instructions or a developer item removes it instead of sending it blank; catalog cases live in a sibling test file because codex-catalog.test.ts is at its size cap; slug-codec and configured-native-models expectations follow the neutral catalog. Carries #5221. Closes #5217. Co-authored-by: sbc1-code <207095575+sbc1-code@users.noreply.github.com> --- scripts/test-layout/layout.json | 2 + src/adapters/identity.ts | 156 ++++++++++++- src/adapters/openai-responses/passthrough.ts | 12 + src/codex/catalog/derive-entry.ts | 10 +- src/codex/catalog/metadata.ts | 6 +- src/responses/parser.ts | 43 +++- structure/providers-and-adapters.md | 11 + tests/adapters/identity-subagent.test.ts | 211 ++++++++++++++++++ .../codex-catalog-identity-neutral.test.ts | 110 +++++++++ .../codex-catalog-sync-hardening.test.ts | 4 +- tests/codex-integration/codex-catalog.test.ts | 14 +- .../configured-native-models.test.ts | 5 +- tests/codex-integration/slug-codec.test.ts | 8 +- tests/fixtures/test-layout-expected.json | 2 + 14 files changed, 571 insertions(+), 23 deletions(-) create mode 100644 tests/adapters/identity-subagent.test.ts create mode 100644 tests/codex-integration/codex-catalog-identity-neutral.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0a9e15e139..afef69d8bb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -509,6 +509,7 @@ "codex-catalog-admission.test.ts": "codex-integration", "codex-catalog-exclusions.test.ts": "codex-integration", "codex-catalog-golden.test.ts": "codex-integration", + "codex-catalog-identity-neutral.test.ts": "codex-integration", "codex-catalog-ladders.test.ts": "codex-integration", "codex-catalog-model-picker-order.test.ts": "codex-integration", "codex-catalog-refresh-status.test.ts": "codex-integration", @@ -923,6 +924,7 @@ "hub-usage.test.ts": "server", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", + "identity-subagent.test.ts": "adapters", "init-backup-cleanup.test.ts": "service", "init-eof.test.ts": "service", "initial-model-selection.test.ts": "providers", diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index 190f2b0616..cb4486f0fa 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -72,11 +72,163 @@ function safeRoutedModelIdentity(modelName: string): string | null { * native template. */ export function identifyRoutedModel(systemText: string, modelName: string): string { + const replacement = routedIdentityLine(modelName); + return systemText + .replace(CODEX_GPT5_IDENTITY_RE, () => replacement) + .replace(NEUTRAL_IDENTITY_RE, () => replacement) + .replace(ROUTED_IDENTITY_RE, () => replacement); +} + +function routedIdentityLine(modelName: string): string { const identity = safeRoutedModelIdentity(modelName); - const replacement = identity + return identity ? `You are a coding agent powered by the ${identity}. If asked which model you are, identify as ${identity}. Do not claim to be a different model or to have a different creator.` : "You are a coding agent powered by the configured model. If asked which model you are, identify as configured model. Do not claim to be GPT-5 or made by OpenAI."; - return systemText.replace(CODEX_GPT5_IDENTITY_RE, () => replacement); +} + +/** + * This proxy's OWN generated identity sentence (both the named and the `configured model` + * fallback form). Codex stores a session's instructions once and replays them verbatim when it + * spawns a sub-agent on a DIFFERENT model (#5217), so a worker inherits the parent's sentence and + * then answers identity questions with the parent's model id. + * + * The pattern is deliberately anchored on the exact wording this module emits — the leading + * "You are a coding agent powered by the " and the matching "If asked which model you are, + * identify as " clause — so it can only ever rewrite text the proxy generated. The model id is + * matched with the same character class `safeRoutedModelIdentity` allows, never `.*`, so user + * prose, fenced code and provider-native identity blocks are out of reach. + */ +const ROUTED_IDENTITY_RE = + /You are a coding agent powered by the (?:configured model|[A-Za-z0-9._/@:+\-[\]~]+)\. If asked which model you are, identify as (?:configured model|[A-Za-z0-9._/@:+\-[\]~]+)\. Do not claim to be (?:a different model or to have a different creator|GPT-5 or made by OpenAI)\./g; + +/** + * The model-neutral catalog line. Since #5217 the catalog no longer bakes a model id into + * `base_instructions` — a stored instruction block is replayed to sub-agents on other models — + * so the id is written at request time instead, where the destination is known. + */ +const NEUTRAL_IDENTITY_RE = new RegExp( + NEUTRAL_IDENTITY_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + "g", +); + +/** + * True when `text` carries an identity sentence this proxy generated — either a sentence that names + * a model already, or the model-neutral catalog line. Both need request-time attention: the named + * form is renamed for the destination, the neutral form is named for the first time. + */ +export function hasRoutedIdentity(text: string): boolean { + ROUTED_IDENTITY_RE.lastIndex = 0; + NEUTRAL_IDENTITY_RE.lastIndex = 0; + return ROUTED_IDENTITY_RE.test(text) || NEUTRAL_IDENTITY_RE.test(text); +} + +/** + * Request-time repair for a routed destination: rewrite an inherited identity sentence so it names + * the model this request is actually sent to. Text without one is returned unchanged. + */ +export function repairRoutedIdentity(text: string, modelName: string): string { + const replacement = routedIdentityLine(modelName); + return text.replace(ROUTED_IDENTITY_RE, () => replacement); +} + +/** + * Request-time naming for text that will be handed to a routed model from a stored instruction + * block: rewrite the model-neutral catalog line AND any sentence naming an earlier model so both + * name this request's destination. + * + * This is the parser's entry point. A routed adapter that never calls `identifyRoutedModel` (the + * native-wire adapters build their own system text) would otherwise ship the catalog's neutral line + * with no model name at all, which is what `base_instructions` used to carry before #5217 made the + * on-disk block model-neutral. + */ +export function nameRoutedIdentity(text: string, modelName: string): string { + const replacement = routedIdentityLine(modelName); + // Name the neutral line first, then let the rename pass settle any earlier model id the block + // still carries — including the one just written, which the second pass rewrites to itself. + return repairRoutedIdentity(text.replace(NEUTRAL_IDENTITY_RE, () => replacement), modelName); +} + +/** A content part carrying no text is not content; every other part shape stays. */ +function isEmptyTextPart(part: unknown): boolean { + if (!part || typeof part !== "object" || Array.isArray(part)) return false; + const record = part as Record; + return typeof record.text === "string" && record.text.length === 0; +} + +/** + * Apply `repair` to the instruction text of a Responses request body: the top-level `instructions` + * string and every developer/system `input` message. Nothing else is in reach — user turns, tool + * output and assistant history are the caller's content. + * + * Stripping consumes text rather than replacing it, so a value can end up empty. An empty + * `instructions` string is a different payload from an absent key, and an empty developer message is + * a message the caller never wrote, so both are removed instead of sent blank. + */ +export function repairIdentityInResponsesBody(body: unknown, repair: (text: string) => string): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + let changed = false; + const mapText = (text: string): string => { + if (!hasRoutedIdentity(text)) return text; + const next = repair(text); + if (next !== text) changed = true; + return next; + }; + let instructions = record.instructions; + if (typeof instructions === "string") { + const next = mapText(instructions); + if (next !== instructions) { + changed = true; + instructions = next.length > 0 ? next : undefined; + } + } + const mapInputItem = (item: unknown): unknown => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const message = item as Record; + if (message.type !== undefined && message.type !== "message") return item; + if (message.role !== "developer" && message.role !== "system") return item; + if (typeof message.content === "string") { + const next = mapText(message.content); + if (next === message.content) return item; + return next.length > 0 ? { ...message, content: next } : undefined; + } + if (!Array.isArray(message.content)) return item; + let partChanged = false; + const content = message.content.map((part: unknown) => { + if (!part || typeof part !== "object" || Array.isArray(part)) return part; + const partRecord = part as Record; + if (typeof partRecord.text !== "string") return part; + const next = mapText(partRecord.text); + if (next === partRecord.text) return part; + partChanged = true; + return { ...partRecord, text: next }; + }); + if (!partChanged) return item; + return content.every(isEmptyTextPart) ? undefined : { ...message, content }; + }; + const input = Array.isArray(record.input) + ? record.input.flatMap((item: unknown) => { + const next = mapInputItem(item); + return next === undefined ? [] : [next]; + }) + : record.input; + if (!changed) return body; + const next: Record = { ...record, input }; + // `...record` carries the original key, so a removed instruction has to be deleted rather than + // merely left out of the spread. + if (instructions === undefined) delete next.instructions; + else next.instructions = instructions; + return next; +} + +/** + * Request-time repair for a native (Codex/OpenAI) destination: drop an inherited routed identity + * sentence instead of rewriting it. A native worker keeps Codex's own identity wording, which the + * client already sends in its `model_switch` block; re-stating a routed sentence there would tell + * a first-party model it is some third-party model. + */ +export function stripRoutedIdentity(text: string): string { + return text.replace(ROUTED_IDENTITY_RE, () => "").replace(/\n{3,}/g, "\n\n").trim(); } /** The catalog (static, on-disk) replacement for `base_instructions`. Same neutral wording. */ diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 0b5ce24702..b68d00dd6f 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -1,4 +1,5 @@ import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; +import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../../providers/xai-transport"; @@ -268,6 +269,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { allowStringContent: isXaiResponsesDestination(provider), }); + // #5217: a sub-agent inherits the parent session's instruction block, so the identity + // sentence this proxy generated for the PARENT's model rides along to a worker running a + // different one. On a routed destination it is renamed to that destination — including the + // model-neutral catalog sentence, which this adapter never names itself; on a native/forward + // destination our sentence is dropped, because Codex's own identity wording (sent in the + // client's model_switch block) is the correct one there. Text the proxy did not generate — + // user turns, tool output, fenced code, provider-native blocks — is untouched. + outBody = repairIdentityInResponsesBody( + outBody, + forward ? stripRoutedIdentity : (text: string) => nameRoutedIdentity(text, parsed.modelId), + ); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts index 1d0a09e46c..c6b0a9253a 100644 --- a/src/codex/catalog/derive-entry.ts +++ b/src/codex/catalog/derive-entry.ts @@ -1,6 +1,6 @@ import type { OcxConfig } from "../../types"; import { effectiveProviderAlias } from "../../providers/default-aliases"; -import { identifyRoutedModel } from "../../adapters/identity"; +import { neutralizeIdentity } from "../../adapters/identity"; import { COMBO_NAMESPACE } from "../../combos"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, @@ -145,13 +145,13 @@ export function deriveEntry( delete e.max_context_window; delete e.auto_compact_token_limit; } - // Native id for identity text + metadata lookups — the slug may be an encoded - // alias (`provider/vendor-model`); the model object carries the native id. - const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1); if (typeof e.base_instructions === "string") { // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy // (leaking that into base_instructions is a non-first-party signature → ToS risk). - e.base_instructions = identifyRoutedModel(e.base_instructions, modelName); + // Model-neutral on disk (#5217): Codex stores this block as the session's instructions and + // replays it verbatim into a sub-agent spawned on a DIFFERENT model, so a baked-in model id + // follows the worker and misnames it. The destination id is written at request time instead. + e.base_instructions = neutralizeIdentity(e.base_instructions); } applyReasoningLevels( e, diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 096b159f41..5e2dacf7ca 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -16,7 +16,7 @@ import { getProviderRegistryEntry, providerCodexAccountMode } from "../../provid import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; -import { identifyRoutedModel } from "../../adapters/identity"; +import { neutralizeIdentity } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; @@ -584,14 +584,14 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined { alias.display_name = presentation.displayName; alias.description = presentation.description; if (typeof alias.base_instructions === "string") { - alias.base_instructions = identifyRoutedModel(alias.base_instructions, slug); + alias.base_instructions = neutralizeIdentity(alias.base_instructions); } if (alias.model_messages && typeof alias.model_messages === "object" && !Array.isArray(alias.model_messages)) { const modelMessages = alias.model_messages as Record; if (typeof modelMessages.instructions_template === "string") { alias.model_messages = { ...modelMessages, - instructions_template: identifyRoutedModel(modelMessages.instructions_template, slug), + instructions_template: neutralizeIdentity(modelMessages.instructions_template), }; } } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 7b12246ba4..4e7823dcd6 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -18,6 +18,7 @@ import { lookupReplayThoughtSignature } from "./thought-signature-replay"; import { compactionItemToText, isCompactionItemType } from "./compaction"; import { previousResponseReplayPrefixLength } from "./state"; import { decodeReasoningEnvelope } from "./reasoning-envelope"; +import { hasRoutedIdentity, nameRoutedIdentity } from "../adapters/identity"; import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool"; import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat"; @@ -121,6 +122,33 @@ export function hasValidatedActiveReasoningEffort(options: Pick { + if (part.type !== "text" || !hasRoutedIdentity(part.text)) return part; + changed = true; + return { ...part, text: nameRoutedIdentity(part.text, modelId) }; + }); + return changed ? parts : content; +} + export function parseRequest( body: unknown, parseOptions?: { replayCacheScope?: OcxReasoningReplayScopeRef }, @@ -168,7 +196,10 @@ export function parseRequest( let continuationConversationMessageIndex: number | undefined; if (typeof data.instructions === "string" && data.instructions.length > 0) { - systemPrompt.push(data.instructions); + // #5217: this is the stored session instruction block. A sub-agent spawned on a DIFFERENT model + // receives the parent's copy verbatim, so the identity sentence inside it names the parent + // unless it is renamed here, where the destination model is known. + systemPrompt.push(nameDestinationText(data.instructions, data.model)); } if (typeof data.input === "string") { @@ -281,7 +312,15 @@ export function parseRequest( case "developer": { pendingReasoning.length = 0; const content = inputContentParts(msg.content); - messages.push({ role: msg.role, content, timestamp: now }); + messages.push({ + role: msg.role, + // #5217: Codex replays the PARENT session's instruction block as the worker's + // developer message, so a sub-agent on another model inherits an identity sentence + // naming the parent. Only this proxy's own sentence is rewritten, and only on a + // developer item; user turns are the caller's content and stay byte-identical. + content: msg.role === "developer" ? nameDestinationContent(content, data.model) : content, + timestamp: now, + }); break; } case "assistant": { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 25076e841a..e1f5a4ed12 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -34,6 +34,17 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest Anthropic model-scoped quota labels in `src/providers/quota/vendor-probes-oauth.ts` publish only canonical Fable, Opus, or Sonnet labels after removing terminal controls; unknown upstream display names are omitted. +The routed identity sentence a catalog row carries is model-neutral on disk: `base_instructions`, +and a native capability alias's `model_messages.instructions_template`, hold `NEUTRAL_IDENTITY_LINE` +rather than a model id, because Codex stores a session's instruction block once and replays it +verbatim into a sub-agent spawned on a DIFFERENT model, where a baked id makes the worker answer +identity questions with the parent's id (#5217). The destination model is therefore named at request +time — `src/responses/parser.ts` names it in the top-level `instructions` string and in developer +items, and each adapter that builds its own system text calls `identifyRoutedModel` with the wire id. +The Responses passthrough rewrites the sentence on a routed destination and strips it on a native or +forward one, where Codex's own identity wording already supplies it; +`tests/adapters/identity-neutralize.test.ts` pins the rewrite rules. + | Path | Responsibility | | --- | --- | | `src/providers/registry.ts` | Compatibility facade; canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata live in `src/providers/registry/entries-core.ts` and `entries-extended.ts`, with model seeds in `model-seeds.ts`. | diff --git a/tests/adapters/identity-subagent.test.ts b/tests/adapters/identity-subagent.test.ts new file mode 100644 index 0000000000..881c4175b8 --- /dev/null +++ b/tests/adapters/identity-subagent.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { + CODEX_GPT5_IDENTITY_LINE, + identifyRoutedModel, + nameRoutedIdentity, + NEUTRAL_IDENTITY_LINE, + repairIdentityInResponsesBody, + repairRoutedIdentity, + stripRoutedIdentity, +} from "../../src/adapters/identity"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { parseRequest } from "../../src/responses/parser"; +import type { OcxProviderConfig, OcxTextContent } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +/** The sentence the proxy generated for the PARENT session, which a spawned worker inherits (#5217). */ +const PARENT_IDENTITY = "You are a coding agent powered by the deepseek-v4.1-flash. If asked which model you are, identify as deepseek-v4.1-flash. Do not claim to be a different model or to have a different creator."; + +const WORKER_MODEL = "gpt-6-astra"; + +function developerItem(text: string) { + return { type: "message", role: "developer", content: [{ type: "input_text", text }] }; +} + +function systemPromptOf(instructions: string): string { + return parseRequest({ model: WORKER_MODEL, instructions, input: [] }).context.systemPrompt!.join("\n\n"); +} + +function textOf(content: unknown): string { + return typeof content === "string" + ? content + : (content as OcxTextContent[]).map(part => part.text).join(""); +} + +/** The Responses passthrough forwards `_rawBody` mostly verbatim, so identity repair is its own step. */ +function passthroughBody(provider: OcxProviderConfig, instructions: string): Record { + const request = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest({ + modelId: WORKER_MODEL, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: WORKER_MODEL, instructions, input: "ping" }, + }, { headers: new Headers() }); + return JSON.parse(request.body) as Record; +} + +describe("sub-agent identity inheritance (#5217)", () => { + test("a stale routed identity sentence is rewritten to the destination model", () => { + const out = repairRoutedIdentity(`${PARENT_IDENTITY}\n\nUse tools carefully.`, WORKER_MODEL); + expect(out).toContain(`identify as ${WORKER_MODEL}`); + expect(out).not.toContain("deepseek-v4.1-flash"); + expect(out).toContain("Use tools carefully."); + }); + + test("identifyRoutedModel also repairs a stale sentence and the neutral catalog line", () => { + expect(identifyRoutedModel(PARENT_IDENTITY, WORKER_MODEL)).toContain(`identify as ${WORKER_MODEL}`); + expect(identifyRoutedModel(NEUTRAL_IDENTITY_LINE, WORKER_MODEL)).toContain(`identify as ${WORKER_MODEL}`); + }); + + test("nameRoutedIdentity names the model-neutral catalog line", () => { + // The catalog block is model-neutral on disk (#5217), so a routed adapter that builds its own + // system text has to be given the destination id here — the neutral text alone names no model. + const out = nameRoutedIdentity(`${NEUTRAL_IDENTITY_LINE}\n\nUse tools carefully.`, WORKER_MODEL); + expect(out).toContain(`powered by the ${WORKER_MODEL}`); + expect(out).toContain(`identify as ${WORKER_MODEL}`); + expect(out).not.toContain(NEUTRAL_IDENTITY_LINE); + expect(out).toContain("Use tools carefully."); + }); + + test("a native destination drops the routed sentence instead of renaming it", () => { + const out = stripRoutedIdentity(`${PARENT_IDENTITY}\n\nYou and the user share one workspace.`); + expect(out).not.toContain("powered by the"); + expect(out).not.toContain("deepseek-v4.1-flash"); + expect(out).toBe("You and the user share one workspace."); + }); + + test("text the proxy did not generate is never rewritten", () => { + for (const text of [ + "The user asked: which model are you?", + "```\nYou are a coding agent powered by the thing I wrote myself.\n```", + "You are a Claude agent built by Anthropic.", + CODEX_GPT5_IDENTITY_LINE, + ]) { + expect(repairRoutedIdentity(text, WORKER_MODEL)).toBe(text); + expect(nameRoutedIdentity(text, WORKER_MODEL)).toBe(text); + expect(stripRoutedIdentity(text)).toBe(text.trim()); + } + }); + + test("the parser names the destination in the top-level instructions block", () => { + // This is a routed adapter's path: some never call identifyRoutedModel, so the stored block + // reaches the wire as-is unless the parser names the model the request is going to. + expect(systemPromptOf(PARENT_IDENTITY)).toContain(`identify as ${WORKER_MODEL}`); + expect(systemPromptOf(PARENT_IDENTITY)).not.toContain("deepseek-v4.1-flash"); + expect(systemPromptOf(NEUTRAL_IDENTITY_LINE)).toContain(`identify as ${WORKER_MODEL}`); + }); + + test("the parser leaves Codex's own GPT identity line alone", () => { + // Adapters handle that line; naming a routed model is this layer's job only. + expect(systemPromptOf(CODEX_GPT5_IDENTITY_LINE)).toBe(CODEX_GPT5_IDENTITY_LINE); + }); + + test("the parser repairs the worker's developer item and leaves user turns alone", () => { + const parsed = parseRequest({ + model: WORKER_MODEL, + input: [ + developerItem(PARENT_IDENTITY), + developerItem(NEUTRAL_IDENTITY_LINE), + { type: "message", role: "user", content: [{ type: "input_text", text: PARENT_IDENTITY }] }, + ], + }); + const [developer, neutralDeveloper, user] = parsed.context.messages; + expect(textOf(developer!.content)).toContain(`identify as ${WORKER_MODEL}`); + expect(textOf(developer!.content)).not.toContain("deepseek-v4.1-flash"); + expect(textOf(neutralDeveloper!.content)).toContain(`identify as ${WORKER_MODEL}`); + // A user turn is the caller's own content; it stays byte-identical. + expect(textOf(user!.content)).toBe(PARENT_IDENTITY); + }); + + test("a routed chat destination sends the worker's own model in the system message", async () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://api.example.invalid", + apiKey: "key", + } as unknown as OcxProviderConfig; + const parsed = parseRequest({ + model: "some/routed-worker", + input: [ + developerItem(PARENT_IDENTITY), + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ], + }); + const { body } = await createOpenAIChatAdapter(provider).buildRequest(parsed); + const system = (JSON.parse(body).messages as { role: string; content: string }[]) + .find(message => message.role === "system")!; + expect(system.content).toContain("identify as some/routed-worker"); + expect(system.content).not.toContain("deepseek-v4.1-flash"); + }); + + test("the Responses body repair covers instructions and developer items only", () => { + const body = { + model: WORKER_MODEL, + instructions: PARENT_IDENTITY, + input: [ + developerItem(PARENT_IDENTITY), + { type: "message", role: "user", content: [{ type: "input_text", text: PARENT_IDENTITY }] }, + ], + }; + const routed = repairIdentityInResponsesBody(body, text => nameRoutedIdentity(text, WORKER_MODEL)) as typeof body; + expect(routed.instructions).toContain(`identify as ${WORKER_MODEL}`); + expect((routed.input[0]!.content as { text: string }[])[0]!.text).toContain(`identify as ${WORKER_MODEL}`); + expect((routed.input[1]!.content as { text: string }[])[0]!.text).toBe(PARENT_IDENTITY); + }); + + test("a forward destination drops a stripped instructions value instead of sending it empty", () => { + const body = { model: WORKER_MODEL, instructions: PARENT_IDENTITY, input: [developerItem(PARENT_IDENTITY)] }; + const native = repairIdentityInResponsesBody(body, stripRoutedIdentity) as { + instructions?: string; + input: { content: { text: string }[] }[]; + }; + // `""` is a different request from an absent key, and an instruction item that held only our + // sentence is a message the caller never wrote. + expect(native).not.toHaveProperty("instructions"); + expect(native.input).toHaveLength(0); + + const kept = repairIdentityInResponsesBody({ + model: WORKER_MODEL, + instructions: `${PARENT_IDENTITY}\n\nKeep this.`, + input: [{ + type: "message", + role: "developer", + content: [{ type: "input_text", text: PARENT_IDENTITY }, { type: "input_image", image_url: "data:x" }], + }], + }, stripRoutedIdentity) as { instructions: string; input: { content: { type: string }[] }[] }; + expect(kept.instructions).toBe("Keep this."); + // The image part is content, so an item holding one is not empty and stays. + expect(kept.input).toHaveLength(1); + }); + + test("a body with no proxy identity is returned unchanged", () => { + const body = { model: WORKER_MODEL, input: [developerItem("plain instructions")] }; + expect(repairIdentityInResponsesBody(body, text => nameRoutedIdentity(text, WORKER_MODEL))).toBe(body); + expect(repairIdentityInResponsesBody(body, stripRoutedIdentity)).toBe(body); + }); + + test("the Responses passthrough names the destination on a routed destination", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://api.example.invalid/v1", + authMode: "key", + apiKey: "key", + } as unknown as OcxProviderConfig; + const body = passthroughBody(provider, `${NEUTRAL_IDENTITY_LINE}\n\nUse tools carefully.`); + expect(body.instructions).toContain(`identify as ${WORKER_MODEL}`); + expect(body.instructions).not.toContain(NEUTRAL_IDENTITY_LINE); + expect(body.instructions).toContain("Use tools carefully."); + }); + + test("the Responses passthrough drops our sentence on a forward destination", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + } as unknown as OcxProviderConfig; + // Codex's own identity wording is the correct one at a first-party destination, and an empty + // instruction string is a different payload from an absent key. + expect(passthroughBody(provider, `${PARENT_IDENTITY}\n\nKeep this.`).instructions).toBe("Keep this."); + expect(passthroughBody(provider, PARENT_IDENTITY)).not.toHaveProperty("instructions"); + }); +}); diff --git a/tests/codex-integration/codex-catalog-identity-neutral.test.ts b/tests/codex-integration/codex-catalog-identity-neutral.test.ts new file mode 100644 index 0000000000..39bc942885 --- /dev/null +++ b/tests/codex-integration/codex-catalog-identity-neutral.test.ts @@ -0,0 +1,110 @@ +// 260924: the catalog block is model-neutral on disk (#5217) — the assertion side of the change. +// +// Codex stores a session's instruction block once and replays it verbatim when it spawns a +// sub-agent on a DIFFERENT model, so a model id baked into `base_instructions` follows the worker +// and makes it answer identity questions with the parent's id. The destination id is written at +// request time instead. These cases exist so the negative half of that contract — no row may bake +// an id in — is asserted against the rows themselves, not only against the sentence that replaced +// it. `codex-catalog.test.ts` sits at its line cap, so the coverage lives here. +import { describe, expect, test } from "bun:test"; +import { NEUTRAL_IDENTITY_LINE } from "../../src/adapters/identity"; +import { + buildCatalogEntries, + deriveComboCatalogModel, + NATIVE_DAYBREAK_BLUE_MODEL, + NATIVE_GPT6_ASTRA_MODEL, + upstreamNativeEntry, +} from "../../src/codex/catalog"; +import type { NormalizedComboConfig } from "../../src/combos/types"; + +/** The pinned native template every routed row derives from; its identity line is Codex's own. */ +function nativeTemplate(): Record { + return { + slug: "gpt-5.5", + display_name: "gpt-5.5", + description: "Native GPT model", + priority: 1, + visibility: "list", + base_instructions: "You are Codex, a coding agent based on GPT-5.\nUse tools carefully.", + model_messages: { instructions_template: "You are Codex, a coding agent based on GPT-5." }, + tool_mode: "code", + multi_agent_version: "v2", + }; +} + +function comboConfig(): NormalizedComboConfig { + return { + strategy: "failover", + stickyLimit: 1, + defaultEffort: "medium", + reasoningEffortMode: "strict", + imageInput: "auto", + alias: null, + nativeAlias: false, + displayName: null, + targets: [ + { provider: "a", model: "m1", weight: 1 }, + { provider: "b", model: "m2", weight: 1 }, + ], + }; +} + +/** Two identically capable members: the combo row derives from the intersection of all of them. */ +const members = ["m1", "m2"].map((id, index) => ({ + provider: index === 0 ? "a" : "b", + id, + contextWindow: 200_000, + maxInputTokens: 180_000, + inputModalities: ["text"], + reasoningEfforts: ["low"], + parallelToolCalls: true, +})); + +describe("catalog identity text is model-neutral on disk (#5217)", () => { + test("a routed row names no model and keeps the rest of its instructions", () => { + const rows = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "zenmux", id: "moonshotai/kimi-k3-free" }, + ]); + const routed = rows.find(row => row.slug === "zenmux/moonshotai-kimi-k3-free")!; + expect(routed.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); + expect(routed.base_instructions).not.toContain("powered by the"); + expect(routed.base_instructions).not.toContain("moonshotai/kimi-k3-free"); + expect(routed.base_instructions).toContain("Use tools carefully."); + }); + + test("a native-slug custom row is neutral as well", () => { + const rows = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "openai", id: NATIVE_GPT6_ASTRA_MODEL }, + ]); + const astra = rows.find(row => row.slug === `openai/${NATIVE_GPT6_ASTRA_MODEL}`)!; + expect(astra.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); + expect(astra.base_instructions).not.toContain("powered by the"); + }); + + test("a combo alias row names the alias nowhere in its instructions", () => { + const combo = deriveComboCatalogModel("mixed", comboConfig(), members)!; + const rows = buildCatalogEntries(nativeTemplate(), [], [combo], undefined, false, "default", new Set(["mixed"])); + expect(rows[0]!.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); + expect(rows[0]!.base_instructions).not.toContain("powered by the"); + }); + + test("a native capability alias row is neutral in both instruction fields", () => { + // Daybreak borrows Sol's pinned row, so both its `base_instructions` and the model_messages + // template are rewritten on the way out of the pinned snapshot. + const source = upstreamNativeEntry(NATIVE_DAYBREAK_BLUE_MODEL)!; + expect(source.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); + expect(source.base_instructions).not.toContain("powered by the"); + expect(source.base_instructions).not.toContain(NATIVE_DAYBREAK_BLUE_MODEL); + const template = (source.model_messages as { instructions_template?: string })?.instructions_template; + expect(template).toContain(NEUTRAL_IDENTITY_LINE); + expect(template).not.toContain("powered by the"); + }); + + test("a self-described native row keeps Codex's own line", () => { + // Neutralization is the routed/alias path. Astra ships its own pinned row, so its identity line + // is Codex's — the guard against a widening that would rewrite first-party rows too. + const astra = upstreamNativeEntry(NATIVE_GPT6_ASTRA_MODEL)!; + expect(astra.base_instructions).toContain("You are Codex"); + expect(astra.base_instructions).not.toContain("powered by the"); + }); +}); diff --git a/tests/codex-integration/codex-catalog-sync-hardening.test.ts b/tests/codex-integration/codex-catalog-sync-hardening.test.ts index 5ff47cb14d..957701a4f6 100644 --- a/tests/codex-integration/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-integration/codex-catalog-sync-hardening.test.ts @@ -6,6 +6,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../src/codex/catalog/native-models"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { NEUTRAL_IDENTITY_LINE } from "../../src/adapters/identity"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); @@ -646,7 +647,8 @@ describe("Codex catalog sync hardening", () => { multi_agent_version: "v2", opencodex_catalog_kind: "custom-model-v1", }); - expect(daybreak?.base_instructions).toContain("powered by the gpt-daybreak-blue-latest"); + expect(daybreak?.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); + expect(daybreak?.base_instructions).not.toContain("powered by the"); // The explicit custom row is independent of native account entitlement. With no confirmed // account roster, the account-gated bare row stays absent instead of collapsing into it. expect(rows.filter(row => row.slug === "gpt-daybreak-blue-latest")).toHaveLength(0); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 7f42b4ad9c..92f4f977bc 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -66,6 +66,7 @@ import { convergeCodexCatalog } from "../../src/codex/convergence"; import { resetCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { NEUTRAL_IDENTITY_LINE } from "../../src/adapters/identity"; const originalFetch = globalThis.fetch; @@ -414,7 +415,7 @@ describe("combo catalog capability intersection", () => { expect(row.slug).toBe(alias); expect(row.display_name).toBe(alias); expect(row.owned_by).toBe("combo"); - expect(row.base_instructions).toContain("mixed"); + expect(row.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); expect(row).not.toHaveProperty("model_messages"); expect(row.tool_mode).toBe("code_mode_only"); expect(row.web_search_tool_type).toBe("text_and_image"); @@ -3426,7 +3427,7 @@ describe("Codex catalog routed normalization", () => { expect(routed?.supports_search_tool).toBe(true); expect(routed?.supports_reasoning_summaries).toBe(false); expect(routed?.base_instructions).not.toBe(nativeTemplate().base_instructions); - expect(routed?.base_instructions).toContain("claude-sonnet-4-6"); + expect(routed?.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); expect(routed?.default_reasoning_level).toBe("medium"); }); @@ -3764,10 +3765,9 @@ describe("Codex catalog routed normalization", () => { multi_agent_version: "v2", }); expect(source).not.toHaveProperty("availability_nux"); - expect(source?.base_instructions).toContain("powered by the gpt-daybreak-blue-latest"); + expect(source?.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); expect(source?.base_instructions).not.toContain("based on GPT-5"); - expect((source?.model_messages as { instructions_template?: string })?.instructions_template) - .toContain("powered by the gpt-daybreak-blue-latest"); + expect((source?.model_messages as { instructions_template?: string })?.instructions_template).toContain(NEUTRAL_IDENTITY_LINE); // NATIVE_OPENAI_MODELS already contains the slug; passing it again would double it. const projected = buildCatalogEntries( @@ -3947,7 +3947,7 @@ describe("Codex catalog routed normalization", () => { multi_agent_version: "v2", opencodex_catalog_kind: CODEX_CUSTOM_MODEL_CATALOG_KIND, }); - expect(daybreak?.base_instructions).toContain("powered by the gpt-daybreak-blue-latest"); + expect(daybreak?.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); expect(daybreak?.model_messages).toBeDefined(); expect(entries.some(entry => entry.slug === NATIVE_DAYBREAK_BLUE_MODEL)).toBe(false); expect(entries.some(entry => entry.slug === `main/${NATIVE_DAYBREAK_BLUE_MODEL}`)).toBe(false); @@ -3995,7 +3995,7 @@ describe("Codex catalog routed normalization", () => { display_name: "GPT-6-Astra", multi_agent_reasoning_effort: "xhigh", service_tiers: [{ id: "priority", name: "Fast", description: "2x speed, increased usage" }], }); - expect(astra?.base_instructions).toContain("powered by the gpt-6-astra"); + expect(astra?.base_instructions).toContain(NEUTRAL_IDENTITY_LINE); expect(astra?.base_instructions).not.toContain("daybreak"); }); diff --git a/tests/codex-integration/configured-native-models.test.ts b/tests/codex-integration/configured-native-models.test.ts index 39723a9ac5..d3984c7de3 100644 --- a/tests/codex-integration/configured-native-models.test.ts +++ b/tests/codex-integration/configured-native-models.test.ts @@ -25,6 +25,7 @@ import { resetConfiguredNativeOpenAiModelsForTests, setConfiguredNativeOpenAiModels, } from "../../src/codex/catalog/native-models"; +import { NEUTRAL_IDENTITY_LINE } from "../../src/adapters/identity"; import { isGpt56NativeSlug, nativeLadderIncludesUltra } from "../../src/codex/catalog/effort"; import { isUnsupportedOpenAiNativeSlug, nativeOpenAiCapabilityDisplayName } from "../../src/codex/catalog/metadata"; import { CANONICAL_NATIVE_CATALOG_CONTENT_POLICY } from "../../src/codex/catalog/build-entries"; @@ -105,7 +106,9 @@ describe("configured native GPT models", () => { expect(row.slug).toBe(NOVA); expect(row.display_name).toBe("GPT-6-Nova"); expect(efforts(row)).toEqual(SOL_LADDER); - expect(String(row.base_instructions)).toContain(NOVA); + // #5217: the on-disk block is model-neutral; the destination id is written at request time. + expect(String(row.base_instructions)).toContain(NEUTRAL_IDENTITY_LINE); + expect(String(row.base_instructions)).not.toContain("powered by the"); const entries = buildCatalogEntries(null, [NATIVE_GPT6_SOL_MODEL, NOVA], []); const nova = entries.find(entry => entry.slug === NOVA); diff --git a/tests/codex-integration/slug-codec.test.ts b/tests/codex-integration/slug-codec.test.ts index a7e1550411..3a07001def 100644 --- a/tests/codex-integration/slug-codec.test.ts +++ b/tests/codex-integration/slug-codec.test.ts @@ -18,6 +18,7 @@ import { import { knownModelIdsForProvider, routeModel } from "../../src/router"; import { buildCatalogEntries, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; import { clearModelCache, setCached } from "../../src/codex/model-cache"; +import { NEUTRAL_IDENTITY_LINE } from "../../src/adapters/identity"; import { getModelMetadata } from "../../src/generated/model-metadata"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import { registryModelIdKeys } from "../../src/providers/registry/model-ids"; @@ -393,8 +394,11 @@ describe("catalog emission (Codex-facing)", () => { expect(routed?.slug).toBe("zenmux/moonshotai-kimi-k3-free"); expect((routed?.slug as string).split("/")).toHaveLength(2); expect(routed?.display_name).toBe("zenmux/moonshotai-kimi-k3-free"); - // Identity text uses the NATIVE model name, not the encoded alias. - expect(String(routed?.base_instructions)).toContain("moonshotai/kimi-k3-free"); + // #5217: the identity text is model-neutral on disk. Codex replays a stored instruction block + // into a sub-agent spawned on a DIFFERENT model, so a baked-in id would misname that worker; + // the destination model is named at request time instead, for the native id and the alias alike. + expect(String(routed?.base_instructions)).toContain(NEUTRAL_IDENTITY_LINE); + expect(String(routed?.base_instructions)).not.toContain("powered by the"); }); test("jawcode metadata resolves on the native id (template + null-template)", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4192eaea89..e4df773c9e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -336,6 +336,7 @@ "codex-catalog-admission.test.ts": "codex-integration", "codex-catalog-exclusions.test.ts": "codex-integration", "codex-catalog-golden.test.ts": "codex-integration", + "codex-catalog-identity-neutral.test.ts": "codex-integration", "codex-catalog-ladders.test.ts": "codex-integration", "codex-catalog-model-picker-order.test.ts": "codex-integration", "codex-catalog-refresh-status.test.ts": "codex-integration", @@ -750,6 +751,7 @@ "hub-usage.test.ts": "server", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", + "identity-subagent.test.ts": "adapters", "init-backup-cleanup.test.ts": "service", "init-eof.test.ts": "service", "initial-model-selection.test.ts": "providers", From 5c52090d6ce0f873d0c45637e5be0b0313453a1e Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:13:27 +0900 Subject: [PATCH 07/14] fix(responses): recover FOLLOWUP_TASK and FINAL_ANSWER agent messages (#5009) Opt-in encrypted agent-task recovery handled only NEW_TASK and MESSAGE, so an unreadable FOLLOWUP_TASK or FINAL_ANSWER item either failed without recovery or, for an unrecognised envelope shape, measured as readable and was forwarded as raw ciphertext. Recovery and the unreadable-ciphertext guard now recognise all four codex-rs agent-message types, including a FINAL_ANSWER without a Task name line. Sender and admission checks are unchanged: credential admission still runs before any cache access, the structured author must equal the header sender, and the recipient is cross-checked whenever the header names one. The cache key is a JSON-encoded fixed-order tuple that binds the structured recipient, and a recovered assignment that echoes the other routing family is rejected. agentTaskRecovery stays default-off. Carries #5009. Co-authored-by: Zhaofeng Li --- .../docs/guides/subagent-v1-default.md | 7 +- .../docs/reference/configuration/agents.md | 8 +- .../docs/reference/configuration/providers.md | 14 +- src/server/responses/agent-task-recovery.ts | 75 +++++--- src/server/responses/encrypted-payload.ts | 29 ++-- structure/subagents.md | 13 +- tests/helpers/agent-task-recovery.ts | 13 ++ tests/server/agent-task-recovery.test.ts | 163 +++++++++++++++++- .../server-agent-task-recovery-replay.test.ts | 33 +++- .../server/v2-agent-message-failfast.test.ts | 48 +++++- 10 files changed, 344 insertions(+), 59 deletions(-) diff --git a/docs-site/src/content/docs/guides/subagent-v1-default.md b/docs-site/src/content/docs/guides/subagent-v1-default.md index 6c6c69d55e..5f4fd79b58 100644 --- a/docs-site/src/content/docs/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/guides/subagent-v1-default.md @@ -105,9 +105,10 @@ Four routes, in the order most people should try them: 3. **Trust a direct key-auth Responses relay.** A provider you explicitly mark with `allowEncryptedV2AgentTasks: true` receives the opaque payload instead of the 400. Only do this for a destination you know can consume it. -4. **Enable `agentTaskRecovery`.** Experimental and off by default. It recovers most fresh spawns - through the ChatGPT backend, at the cost of quota, latency and a dependency on undocumented - behavior, and it still loses message-type follow-ups and multipart envelopes. +4. **Enable `agentTaskRecovery`.** Experimental and off by default. It recovers unreadable encrypted + `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER` items through the ChatGPT backend, at + the cost of quota, latency and a dependency on undocumented behavior; combo recovery remains + limited to spawned-child turns, and split-token fragments stay unsupported. See [Sub-agent Surface](/guides/sub-agent-surface/) for the full mechanics of each, and [Agent configuration](/reference/configuration/agents/) for the settings themselves. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 62be3ae5ea..e530576505 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -235,7 +235,13 @@ explicitly enabled and the final routed task contains an otherwise unreadable Fe opencodex uses a raw Responses passthrough request to the fixed `https://chatgpt.com/backend-api/codex/responses` endpoint with forward-mode authentication. ChatGPT returns the plaintext assignment through a forced function call; opencodex then converts -only that task item to a standard user message before routed-provider dispatch. +only that task item to a standard user message before routed-provider dispatch. Direct routed +recovery, cached history replay, and the unreadable-task detector recognise all four codex-rs +agent-message types: `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER`. Combo recovery +remains limited to spawned-child turns. A `FINAL_ANSWER` envelope may omit its `Task name` line. +Recovery then has no header address to compare with the item's recipient, so that single cross-check +does not run; the sender comparison and the cache scope, which still binds the structured recipient, +are unchanged. This is not local decryption and does not fix the Codex wire protocol. It depends on undocumented ChatGPT backend behavior and may stop working after a backend change. The recovered assignment is diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 26898b968e..764706feeb 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1286,12 +1286,14 @@ whitespace-only strings remain unchanged, as do incomplete and mixed encrypted/u Encrypted and unknown content is not normalized; native encrypted tasks still require the separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). -With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only -after validating the caller and matching the parent-thread scope. Replay restoration -does not make a new recovery request or extend cache expiry. Expired or unseen -ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same -opt-in recovery path, including native-parent `send_message` delivery. Message type, -sender, recipient, parent scope and caller credentials remain part of validation or cache identity. +With task recovery enabled, replayed `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and `FINAL_ANSWER` +items reuse a cached assignment only after validating the caller and matching the parent-thread +scope. Replay restoration does not make a new recovery request or extend cache expiry. Expired or +unseen ciphertext is not replaced. Fresh encrypted `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, and +`FINAL_ANSWER` items use the same opt-in recovery path, including native-parent `send_message` +delivery. Message type, sender, recipient, parent scope and caller credentials remain part of +validation or cache identity. A `FINAL_ANSWER` without a `Task name` line has no header address to +cross-check, but its recipient still scopes the cache. When a request contains several agent messages, cached replay restoration checks each message independently. The cache separates message type, sender, recipient and ciphertext diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index 8f44661d22..362dcfdf8c 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -75,15 +75,17 @@ interface AgentEnvelope { encryptedStartIndex: number; inputSnapshot: string; headerText: string; - messageType: "NEW_TASK" | "MESSAGE"; - taskName: string; + messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER"; + taskName: string | null; sender: string; ciphertexts: readonly string[]; author: string; recipient: string; } -const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; +const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE|FOLLOWUP_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; +// FINAL_ANSWER omits the Task name line when the sender declares no recipient. +const FINAL_ANSWER_HEADER = /(?:^|\n)Message Type\s*:\s*FINAL_ANSWER\s*\n(?:Task name\s*:\s*(\S+)\s*\n)?Sender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(input)) return null; @@ -104,7 +106,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(content)) return null; let headerText: string | null = null; - let messageType: "NEW_TASK" | "MESSAGE" | null = null; + let messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER" | null = null; let taskName: string | null = null; let sender: string | null = null; let encryptedStartIndex = -1; @@ -119,16 +121,24 @@ function findEnvelope(input: unknown): AgentEnvelope | null { && typeof part.text === "string" ) { const match = ROUTING_HEADER.exec(part.text); - if (match) { + const finalMatch = match ? null : FINAL_ANSWER_HEADER.exec(part.text); + if (match || finalMatch) { if (headerText !== null) return null; + const m = match ?? finalMatch!; if ( - part.text.slice(0, match.index).trim().length > 0 - || part.text.slice(match.index + match[0].length).trim().length > 0 + part.text.slice(0, m.index).trim().length > 0 + || part.text.slice(m.index + m[0].length).trim().length > 0 ) return null; - headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0]; - messageType = match[1] as "NEW_TASK" | "MESSAGE"; - taskName = match[2]!; - sender = match[3]!; + headerText = m[0].startsWith("\n") ? m[0].slice(1) : m[0]; + if (match) { + messageType = match[1] as "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK"; + taskName = match[2]!; + sender = match[3]!; + } else { + messageType = "FINAL_ANSWER"; + taskName = finalMatch![1] ?? null; + sender = finalMatch![2]!; + } } } if (part.type !== "encrypted_content") continue; @@ -145,7 +155,6 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if ( !headerText || !messageType - || !taskName || !sender || encryptedStartIndex < 0 || ciphertexts.length === 0 @@ -153,7 +162,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null { const itemRecord = item as { author?: unknown; recipient?: unknown }; if (typeof itemRecord.author !== "string" || typeof itemRecord.recipient !== "string") return null; - if (itemRecord.author !== sender || itemRecord.recipient !== taskName) return null; + // A FINAL_ANSWER without a Task name line declares no recipient, so the structured + // recipient is only cross-checked when a task name is present; admission is the + // trust boundary either way. + if ( + itemRecord.author !== sender + || (taskName !== null && itemRecord.recipient !== taskName) + ) return null; return { itemIndex, @@ -170,10 +185,15 @@ function findEnvelope(input: unknown): AgentEnvelope | null { } function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null { - const match = ROUTING_HEADER.exec(assignment); + const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER; + const foreign = header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER; + if (foreign.test(assignment)) return null; + const match = header.exec(assignment); if (!match) return assignment; if (match.index !== 0) return null; - if ( + if (envelope.messageType === "FINAL_ANSWER") { + if ((match[1] ?? null) !== envelope.taskName || match[2] !== envelope.sender) return null; + } else if ( match[1] !== envelope.messageType || match[2] !== envelope.taskName || match[3] !== envelope.sender @@ -295,18 +315,21 @@ function admittedRecovery( if (!envelope) return { admitted: false, reason: "unsupported_envelope" }; const admission = recoveryAdmission(req, config); if (!admission) return { admitted: false, reason: "admission_denied" }; + // A JSON-encoded fixed-order tuple, not a delimiter-joined string: a field that carries the + // delimiter shifts every boundary after it, so two envelopes could hash to one entry and one + // recovery would replay for the other. A FINAL_ANSWER that omits its Task name line carries no + // addressing in the header, which leaves the structured recipient as the only field separating + // two such envelopes and makes the boundary the whole difference. const cacheKey = createHash("sha256") - .update(admission.cacheScope) - .update("\0") - .update(parentThreadId ?? "") - .update("\0") - .update(envelope.messageType) - .update("\0") - .update(envelope.taskName) - .update("\0") - .update(envelope.sender) - .update("\0") - .update(JSON.stringify(envelope.ciphertexts)) + .update(JSON.stringify([ + admission.cacheScope, + parentThreadId ?? "", + envelope.messageType, + envelope.taskName ?? "", + envelope.recipient, + envelope.sender, + envelope.ciphertexts, + ])) .digest("hex"); return { admitted: true, recovery: { envelope, admission, cacheKey } }; } diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 82da49bca9..2ff158e473 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -198,21 +198,21 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]) /** * The routing header codex-rs writes above a delegated agent payload. * - * `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK -- - * recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the - * parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector - * decides "unreadable" by stripping the envelope and asking whether any plaintext - * survives, so an envelope shape it does not recognise counts as surviving text: a - * `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded - * verbatim. + * All four codex-rs message types are recognised: NEW_TASK, MESSAGE, FOLLOWUP_TASK, + * and FINAL_ANSWER, whose Task name line is optional. #3021 reported a subagent + * `MESSAGE` arriving in the parent conversation as raw `gAAAA...` ciphertext after an + * `adapter_eof`. The detector decides "unreadable" by stripping the envelope and asking + * whether any plaintext survives, so an envelope shape it does not recognise counts as + * surviving text: an unrecognised type whose entire body is one Fernet token measured as + * READABLE and would be forwarded verbatim. * - * Widening the strip is not the same as widening recovery. Recovery decrypts, and - * decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a - * payload the parent's session may have no right to read. This only lets the proxy - * NOTICE that what it is about to forward is unreadable ciphertext, which is what the - * report asks for: fail closed with a structured error rather than paste the token. + * This strip must therefore stay in step with the message types the opt-in recovery + * recognises (see agent-task-recovery.ts). The strip itself is still only detection: it + * lets the proxy notice that what it is about to forward is unreadable ciphertext and + * fail closed with a structured error rather than paste the token. Recovery admission, + * not the strip, is the trust boundary for decryption. */ -export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; +export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE|FOLLOWUP_TASK)[^\n]*\n\s*Task name\s*:[^\n]*\n\s*Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n\s*(?:Task name\s*:[^\n]*\n\s*)?Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)/gi; // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph: // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are @@ -262,7 +262,8 @@ function splitFernetParts(content: unknown[]): Set { export function hasUnreadableEncryptedAgentTask(input: unknown): boolean { if (!Array.isArray(input)) return false; - // codex-rs appends one NEW_TASK agent_message at the current input tail. Historical + // codex-rs appends one agent_message (any of the four codex-rs message types) at the + // current input tail. Historical // agent messages may be adjacent in full-history bodies; they must not poison the // later task. compaction_trigger/additional_tools are trailing metadata rather than // a newer user turn. diff --git a/structure/subagents.md b/structure/subagents.md index 3b67eebdd7..e372b1f85a 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -185,10 +185,16 @@ Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent `src/server/responses/agent-task-recovery.ts` admits at most 32 consecutive, individually complete Fernet-shaped parts with a combined 2 MiB ciphertext limit. Every encrypted slot must belong to -that run. The existing credential admission precedes cache access; the cache key includes an -unambiguous ordered sequence. One fixed-endpoint request forwards separate parts, and assignment +that run. The existing credential admission precedes cache access; the cache key is a JSON-encoded +fixed-order tuple of every addressing field (scope, parent thread, message type, task name, +recipient, sender, ciphertexts) rather than a delimiter-joined string, so no field content can shift +a boundary. One fixed-endpoint request forwards separate parts, and assignment replacement compares the complete original item snapshot before splicing the run. Recovery output is model-transcribed plaintext, not cryptographic fidelity proof, and no internal outage retry is added. +Recovery recognises all four codex-rs message types (NEW_TASK, MESSAGE, FOLLOWUP_TASK, +FINAL_ANSWER); a FINAL_ANSWER envelope may omit the Task name line, in which case the +structured recipient is not cross-checked because the envelope names no recipient, and +admission remains the trust boundary. `src/server/responses/encrypted-payload.ts` uses bounded concatenation only to recognize otherwise unreadable split-token shapes. The sanitizer preserves just those fragment objects and continues @@ -248,7 +254,8 @@ target its own `structuredClone` and its own concrete route, so a sibling's repa them and a target resolving to a routed Responses wire would otherwise send what the parent's own dispatch no longer does. -Nothing here decrypts, and the tail NEW_TASK envelope keeps `unreadable_encrypted_agent_task` and +Nothing here decrypts, and the tail agent_message envelope (any of the four codex-rs +message types) keeps `unreadable_encrypted_agent_task` and its opt-in recovery unchanged: an unreadable current task still fails closed rather than reaching a child with a marker where its assignment should be. An `agent_message` carrying unknown parts but no ciphertext still reaches the wire unchanged and still draws the destination's own 422, which is diff --git a/tests/helpers/agent-task-recovery.ts b/tests/helpers/agent-task-recovery.ts index 4a6a95c5ae..1d5178bc88 100644 --- a/tests/helpers/agent-task-recovery.ts +++ b/tests/helpers/agent-task-recovery.ts @@ -47,6 +47,19 @@ function routingEnvelope( export const ROUTING_ENVELOPE = routingEnvelope(); +function finalAnswerEnvelope(withTaskName: boolean, taskName = "/root/worker", sender = "/root"): string { + return [ + "Message Type: FINAL_ANSWER", + ...(withTaskName ? [`Task name: ${taskName}`] : []), + `Sender: ${sender}`, + "Payload:", + "", + ].join("\n"); +} + +export const FINAL_ANSWER_ENVELOPE = finalAnswerEnvelope(false); +export const FINAL_ANSWER_TASK_ENVELOPE = finalAnswerEnvelope(true); + export function agentMessage(content: Array>): unknown[] { return [{ type: "agent_message", diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index ffbeb29627..b51737753e 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -16,6 +16,8 @@ import { codexHeaders, encryptedInput, FERNET_TASK, + FINAL_ANSWER_ENVELOPE, + FINAL_ANSWER_TASK_ENVELOPE, originalFetch, post, providerResponse, @@ -48,7 +50,7 @@ describe("agent task recovery (opt-in, default off)", () => { resetAgentTaskRecoveryState(); }); - for (const messageType of ["NEW_TASK", "MESSAGE"] as const) { + for (const messageType of ["NEW_TASK", "MESSAGE", "FOLLOWUP_TASK"] as const) { test(`typed ${messageType} recovery preserves boolean, replay and discard contracts`, async () => { const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); const config = routedConfig(); @@ -1002,7 +1004,7 @@ describe("bounded multipart encrypted task recovery", () => { ...tokens.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })), ]); - test.each(["NEW_TASK", "MESSAGE"] as const)("recovers ordered %s parts in one request and isolates sequence caches", async messageType => { + test.each(["NEW_TASK", "MESSAGE", "FOLLOWUP_TASK"] as const)("recovers ordered %s parts in one request and isolates sequence caches", async messageType => { let sends = 0; const sent: Array<{ input: Array<{ content: Array<{ encrypted_content?: string }> }> }> = []; globalThis.fetch = (async (_url, init) => { @@ -1126,3 +1128,160 @@ describe("bounded multipart encrypted task recovery", () => { expect(sends).toBe(1); }); }); + +describe("FINAL_ANSWER encrypted task recovery", () => { + beforeEach(() => resetAgentTaskRecoveryState()); + afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + + test("recovers a FINAL_ANSWER without a Task name line and replays it from cache", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered final answer.")); + }) as typeof fetch; + const input = () => agentMessage([ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + const typedInput = input(); + expect(await recoverEncryptedAgentTaskWithResult(req, typedInput, {}, routedConfig())).toEqual({ recovered: true }); + expect(typedInput).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "input_text", text: "Recovered final answer." }, + ], + }]); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(1); + expect(fetches).toBe(1); + discardEncryptedAgentTaskRecovery(req, input(), routedConfig()); + expect(restoreCachedEncryptedAgentTasks(req, input(), routedConfig())).toBe(0); + }); + + test("does not share a cache entry when a NUL byte moves between recipient and sender", async () => { + // Both envelopes below carry the same admission scope, parent thread, message type, absent + // Task name and ciphertext, and their recipient/sender fields concatenate to the same bytes + // once a separator is placed between them. Moving where the NUL sits must not move the key. + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered final answer.")); + }) as typeof fetch; + const input = (recipient: string, sender: string) => [{ + type: "agent_message", + author: sender, + recipient, + content: [ + { + type: "input_text", + text: ["Message Type: FINAL_ANSWER", `Sender: ${sender}`, "Payload:", ""].join("\n"), + }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }]; + expect(await recoverEncryptedAgentTaskWithResult(req, input("r", "s\0t"), {}, routedConfig())) + .toEqual({ recovered: true }); + expect(fetches).toBe(1); + // A different split of the same concatenation is a different envelope, not a cache hit. + expect(restoreCachedEncryptedAgentTasks(req, input("r\0s", "t"), routedConfig())).toBe(0); + // The envelope the cache was actually filled from still replays, so the line above is not + // passing because nothing was cached at all. + expect(restoreCachedEncryptedAgentTasks(req, input("r", "s\0t"), routedConfig())).toBe(1); + }); + + test("recovers a FINAL_ANSWER whose Task name matches the structured recipient", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered final answer.")); + }) as typeof fetch; + const input = agentMessage([ + { type: "input_text", text: FINAL_ANSWER_TASK_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: true }); + expect(fetches).toBe(1); + }); + + test("accepts a FINAL_ANSWER assignment that echoes its own header", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse(`${FINAL_ANSWER_ENVELOPE}Recovered final answer.`)); + }) as typeof fetch; + const input = agentMessage([ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: true }); + expect(input).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "input_text", text: "Recovered final answer." }, + ], + }]); + expect(fetches).toBe(1); + }); + + test.each([ + ["sender mismatch", () => [{ + type: "agent_message", + author: "/other", + recipient: "/root/worker", + content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }]], + ["recipient mismatch against the Task name line", () => agentMessage([ + { type: "input_text", text: FINAL_ANSWER_TASK_ENVELOPE.replace("/root/worker", "/root/other-worker") }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ])], + ] as const)("refuses %s FINAL_ANSWER without a recovery dispatch", async (_label, makeInput) => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("must not run")); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const input = makeInput(); + const before = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: false, reason: "unsupported_envelope" }); + expect(input).toEqual(before); + expect(fetches).toBe(0); + }); +}); + +describe("recovery refuses a wrong-family echoed routing header", () => { + beforeEach(() => resetAgentTaskRecoveryState()); + afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + + const echoCases: Array<[string, string, string]> = [ + ["FINAL_ANSWER envelope echoing a NEW_TASK header", FINAL_ANSWER_ENVELOPE, `${ROUTING_ENVELOPE}Recovered final answer.`], + ["FINAL_ANSWER envelope echoing a NEW_TASK header mid-assignment", FINAL_ANSWER_ENVELOPE, `Recovered final answer.\n\n${ROUTING_ENVELOPE}`], + ["FINAL_ANSWER echo followed by a NEW_TASK header", FINAL_ANSWER_ENVELOPE, `${FINAL_ANSWER_ENVELOPE}${ROUTING_ENVELOPE}Recovered final answer.`], + ["NEW_TASK envelope echoing a FINAL_ANSWER header", ROUTING_ENVELOPE, `${FINAL_ANSWER_ENVELOPE}Recovered final answer.`], + ["NEW_TASK echo followed by a FINAL_ANSWER header", ROUTING_ENVELOPE, `${ROUTING_ENVELOPE}${FINAL_ANSWER_ENVELOPE}Recovered task.`], + ["MESSAGE envelope echoing a FINAL_ANSWER header", ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"), `${FINAL_ANSWER_ENVELOPE}Recovered final answer.`], + ]; + + test.each(echoCases)("refuses %s", async (_label, envelopeText, assignment) => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse(assignment)); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const input = agentMessage([ + { type: "input_text", text: envelopeText }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + const before = structuredClone(input); + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, routedConfig())).toEqual({ recovered: false, reason: "recovery_invalid_output" }); + expect(input).toEqual(before); + expect(fetches).toBe(1); + }); +}); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts index 9c660b2e29..ceed8a241b 100644 --- a/tests/server/server-agent-task-recovery-replay.test.ts +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -6,7 +6,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../sr import { conversationIdFromResponsesRequest } from "../../src/server/request-log-conversation"; import type { OcxParsedRequest } from "../../src/types"; import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; -import { codexHeaders, encryptedInput, fakeChatGptJwt, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +import { codexHeaders, encryptedInput, fakeChatGptJwt, FINAL_ANSWER_ENVELOPE, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); @@ -275,6 +275,37 @@ test("MESSAGE cache remains isolated by message type, account, parent and sender expect(calls).toBe(1); }); +test("FINAL_ANSWER cache stays isolated by structured recipient when the envelope names no task", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(recoverySse(calls === 1 ? "Worker assignment." : "Other worker assignment.")); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const scope = { parentThreadId: "parent" }; + // Same ciphertext, sender, credentials, and Task-name-less header for both; only the + // structured recipient differs, so the header alone cannot separate these envelopes. + const finalAnswer = (recipient: string): unknown[] => [{ + type: "agent_message", + author: "/root", + recipient, + content: [ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }]; + expect(await recoverEncryptedAgentTask(req, finalAnswer("/root/worker"), {}, config, scope)).toBe(true); + expect(calls).toBe(1); + const other = finalAnswer("/root/other-worker"); + expect(restoreCachedEncryptedAgentTasks(req, other, config, scope)).toBe(0); + expect(JSON.stringify(other)).toContain(FERNET_TASK); + expect(JSON.stringify(other)).not.toContain("Worker assignment."); + expect(await recoverEncryptedAgentTask(req, other, {}, config, scope)).toBe(true); + expect(calls).toBe(2); + expect(JSON.stringify(other)).toContain("Other worker assignment."); +}); + test("mixed history restores cached NEW_TASK and MESSAGE separately before recovering only the new tail", async () => { let calls = 0; diff --git a/tests/server/v2-agent-message-failfast.test.ts b/tests/server/v2-agent-message-failfast.test.ts index 0ff7259c76..edf866a954 100644 --- a/tests/server/v2-agent-message-failfast.test.ts +++ b/tests/server/v2-agent-message-failfast.test.ts @@ -43,6 +43,15 @@ const ROUTING_ENVELOPE = [ // The same envelope a delegated agent uses to REPLY, as opposed to being spawned. // #3021 saw one of these reach the parent conversation as raw `gAAAA...` text. const MESSAGE_ROUTING_ENVELOPE = ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"); +// FOLLOWUP_TASK shares the four-line header; a FINAL_ANSWER completion may omit the +// Task name line entirely, in which case the envelope names no recipient. +const FOLLOWUP_ROUTING_ENVELOPE = ROUTING_ENVELOPE.replace("NEW_TASK", "FOLLOWUP_TASK"); +const FINAL_ANSWER_ENVELOPE = [ + "Message Type: FINAL_ANSWER", + "Sender: /root", + "Payload:", + "", +].join("\n"); afterEach(() => { // Release the lease before later teardown can replace the preload sandbox home. @@ -169,9 +178,8 @@ describe("V2 routed agent-message ciphertext guard", () => { * as surviving text. The envelope pattern matched only NEW_TASK, so a MESSAGE whose * entire body was one Fernet token measured as READABLE and was forwarded verbatim. * - * This is the detection half only. Recovery stays NEW_TASK-only on purpose: - * decrypting a MESSAGE on the parent's behalf would build a plaintext oracle out of - * a payload the parent's session may not be entitled to read. + * This is the detection half only. The opt-in recovery recognises the same envelope + * types; its admission gate, not the detector, is the trust boundary for decryption. */ test("blocks a MESSAGE reply envelope followed only by a Fernet payload", () => { expect(hasUnreadableEncryptedAgentTask(agentMessage([ @@ -200,6 +208,40 @@ describe("V2 routed agent-message ciphertext guard", () => { ]))).toBe(false); }); + test("blocks a FOLLOWUP_TASK envelope followed only by a Fernet payload", () => { + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: FOLLOWUP_ROUTING_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(true); + }); + + test.each([ + FOLLOWUP_ROUTING_ENVELOPE, + FINAL_ANSWER_ENVELOPE, + ])("blocks an encrypted envelope with a blank line after its type", envelope => { + const input = agentMessage([ + { type: "input_text", text: envelope.replace("\n", "\n\n") }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + expect(hasUnreadableEncryptedAgentTask(input)).toBe(true); + }); + + test("blocks a FINAL_ANSWER envelope without a Task name followed only by a Fernet payload", () => { + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: FINAL_ANSWER_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(true); + }); + + test("a FINAL_ANSWER reply that carries real text stays readable", () => { + // The control. The widened strip must not turn a FINAL_ANSWER carrying real text + // into a blocked one. + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: `${FINAL_ANSWER_ENVELOPE}the worker finished the migration` }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(false); + }); + test("blocks a control preamble mixed into the Fernet slot before sanitization", async () => { const input = agentMessage([ { type: "input_text", text: ROUTING_ENVELOPE }, From fd1d1dbcc30b65ed55bdb6ed7418c970720fc1fa Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:21:51 +0900 Subject: [PATCH 08/14] docs(subagents): describe four-type agent-task recovery in every locale The localized sub-agent guides still said agentTaskRecovery loses message-type follow-ups, which contradicts the #5009 recovery of FOLLOWUP_TASK and FINAL_ANSWER. Co-authored-by: Zhaofeng Li --- .../020_subagent_identity_5221.md | 7 +++++++ .../030_agent_message_recovery_5009.md | 4 ++++ .../src/content/docs/fr/guides/subagent-v1-default.md | 9 +++++---- .../src/content/docs/ja/guides/subagent-v1-default.md | 2 +- .../src/content/docs/ko/guides/subagent-v1-default.md | 2 +- .../src/content/docs/ru/guides/subagent-v1-default.md | 9 +++++---- .../src/content/docs/tr/guides/subagent-v1-default.md | 2 +- .../src/content/docs/zh-cn/guides/subagent-v1-default.md | 2 +- .../src/content/docs/zh-tw/guides/subagent-v1-default.md | 2 +- 9 files changed, 26 insertions(+), 13 deletions(-) diff --git a/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md b/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md index a14d305fc6..feed2618b9 100644 --- a/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md +++ b/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md @@ -5,3 +5,10 @@ Rebuild on current dev (221 commits drift): re-read current versions of those fi Tests: new tests/adapters/identity-subagent.test.ts (from PR). Catalog expectation edits: codex-catalog.test.ts is at cap, so changed expectations must not grow it; new catalog cases go to a sibling file (e.g. tests/codex-integration/codex-catalog-identity-neutral.test.ts) registered in both manifests. Check files at cap: parser.ts, passthrough.ts, identity.ts in file-size-baseline. Audit fold: neutralizeIdentity and NEUTRAL_IDENTITY_LINE already exist in identity.ts; the six routed-identity helpers do not. parser/passthrough/identity are uncapped (<2000 lines). codex-catalog.test.ts cap 7985 vs 7974 actual: expectation edits may not add net lines beyond headroom; new cases still go to a sibling file. + +## wp2 P (executable) +- /tmp/l4-5221.diff (src, 5 files) passes git apply --check -3 on current HEAD. Current request-time callers of identifyRoutedModel: anthropic.ts:772, google.ts:300, kiro/payload.ts:145, command-code.ts:607, openai-chat/messages.ts:144 — all receive the extended rewrite (GPT line, neutral line, routed line), so a neutral catalog still names the wire model. +- Writer applies the src diff, adds tests/adapters/identity-subagent.test.ts from carry-5221, and rewrites the catalog expectations the PR changed (codex-catalog.test.ts, codex-catalog-sync-hardening.test.ts) so codex-catalog.test.ts grows by 0 net lines; any added catalog assertion goes to new tests/codex-integration/codex-catalog-identity-neutral.test.ts registered in scripts/test-layout/layout.json explicit and tests/fixtures/test-layout-expected.json. +- Writer also checks every other test that asserted a baked model id in base_instructions (rg 'powered by the' tests) and updates it. +- structure: add a line to the structure doc owning src/adapters/identity.ts (find via structure/manifest.json) describing request-time identity repair. +- Focused: bun test tests/adapters/identity-subagent.test.ts tests/adapters/identity-neutralize.test.ts tests/codex-integration/codex-catalog.test.ts tests/codex-integration/codex-catalog-sync-hardening.test.ts plus new sibling, tests/test-layout.test.ts, tests/ci-workflows/file-size-ratchet.test.ts. diff --git a/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md b/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md index a785573838..62632fcbb5 100644 --- a/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md +++ b/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md @@ -4,3 +4,7 @@ Files: src/server/responses/agent-task-recovery.ts (FOLLOWUP_TASK, FINAL_ANSWER Method: squash diff from merge-base e9643875f0 applied with -3; check caps on test files (agent-task-recovery.test.ts +161). Keep: recoveryAdmission before cache; agentTaskRecovery.enabled default-off. +## wp3 P (executable) +- /tmp/l4-5009.diff (squash from merge-base e9643875f0, 10 files) passes git apply --check -3 on HEAD af0a3713ea. Test files are below the 2000-line ratchet threshold (1128/438/907 before +163/+33/+48). +- DeepSeek writer updates item 4 of guides/subagent-v1-default.md in fr, ja, ko, ru, tr, zh-cn, zh-tw: they still say recovery loses message-type follow-ups, which contradicts the carried English text. +- Focused: bun test tests/server/agent-task-recovery.test.ts tests/server/server-agent-task-recovery-replay.test.ts tests/server/v2-agent-message-failfast.test.ts tests/responses/*encrypted* (rg for encrypted-payload tests). diff --git a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md index 9448ac1ec9..cafcd8b6a3 100644 --- a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md @@ -119,10 +119,11 @@ devraient les essayer : de l'erreur 400. Réservez ce réglage aux destinations dont vous savez qu'elles peuvent consommer cette charge. 4. **Enable `agentTaskRecovery`.** Fonction expérimentale désactivée par - défaut. Elle récupère la plupart des créations récentes via le backend - ChatGPT, au prix de quota, de latence et d'une dépendance à un comportement - non documenté. Elle perd encore les suivis de type message et les - enveloppes en plusieurs parties. + défaut. Elle récupère via le backend ChatGPT les éléments chiffrés + illisibles `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK` et `FINAL_ANSWER`, au + prix de quota, de latence et d'une dépendance à un comportement non + documenté ; la récupération combo reste limitée aux tours des enfants + lancés, et les fragments de jetons découpés restent non pris en charge. Consultez [Surface des sous-agents](/fr/guides/sub-agent-surface/) pour le fonctionnement détaillé de chaque option et diff --git a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md index 2e88fe0d85..7cdae1cc72 100644 --- a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md @@ -76,7 +76,7 @@ base が先頭でなく 2 番目なのは、委任元としてよく使われる 1. **ChatGPT を v1 に維持する。** v2 内で `keepNativeChatGptOnV1` を有効にすると、Sol と Terra は v1 のままで Grok や Claude の子を作れ、ルーティングされた親には v2 が適用されます。両方を使うのに最も近い設定です。 2. **同じプロバイダー内で委任する。** ルーティングされた親からルーティングされた子へのタスクは v2 でも平文で、通常どおり動作します。 3. **直接キー認証の Responses リレーを信頼する。** `allowEncryptedV2AgentTasks: true` を明示的に設定したプロバイダーには、400 エラーの代わりに不透明なペイロードが渡されます。処理できると分かっている接続先だけに設定してください。 -4. **`agentTaskRecovery` を有効にする。** 実験的で、既定では無効です。ChatGPT バックエンド経由で多くの新しい生成を回復しますが、クォータと遅延が増え、非公開の動作に依存します。メッセージ型の後続タスクと複数部分のエンベロープは依然として失われます。 +4. **`agentTaskRecovery` を有効にする。** 実験的で、既定では無効です。ChatGPT バックエンド経由で、読み取れない暗号化された `NEW_TASK`、`MESSAGE`、`FOLLOWUP_TASK`、`FINAL_ANSWER` の各項目を回復しますが、クォータと遅延、非公開の動作への依存という代償を伴います。コンボの回復は生成された子ターンに限られ、分割トークンの断片は引き続きサポートされません。 各方式の詳細は[サブエージェント サーフェス](/ja/guides/sub-agent-surface/)、設定は[エージェント設定](/ja/reference/configuration/agents/)を参照してください。 diff --git a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md index 904c89049a..9bb8994418 100644 --- a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md @@ -76,7 +76,7 @@ base가 첫 번째가 아니라 두 번째인 이유는 대부분의 위임 출 1. **ChatGPT를 v1로 유지합니다.** v2에서 `keepNativeChatGptOnV1` 스위치를 켜면 Sol과 Terra는 v1에 남아 Grok이나 Claude를 스폰할 수 있고, 라우팅된 부모는 v2를 사용합니다. 두 서피스를 함께 쓰는 데 가장 가깝습니다. 2. **한 프로바이더 안에서 위임합니다.** 라우팅된 부모가 라우팅된 자식을 스폰하면 v2에서도 평문이므로 정상 작동합니다. 3. **직접 키 인증 Responses 릴레이를 신뢰합니다.** `allowEncryptedV2AgentTasks: true`로 명시한 프로바이더는 400 대신 불투명한 페이로드를 받습니다. 이를 처리할 수 있다고 확실히 아는 목적지에만 사용하세요. -4. **`agentTaskRecovery`를 켭니다.** 실험적 기능으로 기본값은 꺼져 있습니다. ChatGPT 백엔드를 거쳐 새로운 스폰 대부분을 복구하지만 할당량과 지연 시간이 늘고 문서화되지 않은 동작에 의존합니다. 메시지 유형의 후속 작업과 여러 부분으로 된 엔벌로프는 여전히 잃습니다. +4. **`agentTaskRecovery`를 켭니다.** 실험적 기능으로 기본값은 꺼져 있습니다. ChatGPT 백엔드를 거쳐 읽을 수 없는 암호화된 `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK`, `FINAL_ANSWER` 항목을 복구하지만, 할당량과 지연 시간, 문서화되지 않은 동작에 대한 의존이라는 비용을 치릅니다. 콤보 복구는 스폰된 자식 턴으로 제한되며, 분할된 토큰 조각은 여전히 지원되지 않습니다. 각 방식의 전체 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/), 설정 자체는 [에이전트 설정](/ko/reference/configuration/agents/)을 참고하세요. diff --git a/docs-site/src/content/docs/ru/guides/subagent-v1-default.md b/docs-site/src/content/docs/ru/guides/subagent-v1-default.md index b5413806ff..022b974c43 100644 --- a/docs-site/src/content/docs/ru/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ru/guides/subagent-v1-default.md @@ -118,10 +118,11 @@ base стоит вторым, а не первым, потому что его данные вместо ошибки 400. Делайте это только для назначения, которое точно способно их обработать. 4. **Включить `agentTaskRecovery`.** Экспериментальная настройка, выключенная - по умолчанию. Она восстанавливает большинство новых запусков через backend - ChatGPT ценой квоты, задержки и зависимости от недокументированного поведения, - но по-прежнему теряет последующие сообщения соответствующего типа и - составные оболочки. + по умолчанию. Она восстанавливает нечитаемые зашифрованные элементы + `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK` и `FINAL_ANSWER` через backend + ChatGPT ценой квоты, задержки и зависимости от недокументированного + поведения; восстановление combo ограничено ходами созданных подагентов, а + фрагменты разделённых токенов по-прежнему не поддерживаются. Подробности каждого варианта см. в разделе [Режимы подагентов](/ru/guides/sub-agent-surface/), а сами настройки — в [Конфигурации агентов](/ru/reference/configuration/agents/). diff --git a/docs-site/src/content/docs/tr/guides/subagent-v1-default.md b/docs-site/src/content/docs/tr/guides/subagent-v1-default.md index 064a03fe45..3249a294bd 100644 --- a/docs-site/src/content/docs/tr/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/tr/guides/subagent-v1-default.md @@ -106,7 +106,7 @@ gösteriyorsa `ocx sync` çalıştırıp o Codex arayüzünü yeniden başlatın 1. **ChatGPT'yi v1'de tutun.** v2 içindeki `keepNativeChatGptOnV1` anahtarı, Sol ve Terra'yı v1 arayüzünde bırakır; böylece Grok veya Claude oluşturabilirler. Yönlendirilen ebeveynler ise v2 alır. İkisini birlikte kullanmaya en yakın yol budur. 2. **Tek sağlayıcı içinde görev devredin.** Yönlendirilen ebeveynden yönlendirilen çocuğa görev v2'de düz metindir ve normal çalışır. 3. **Doğrudan anahtar kimlik doğrulamalı Responses geçidine güvenin.** `allowEncryptedV2AgentTasks: true` ile açıkça işaretlediğiniz sağlayıcı, 400 yerine opak yükü alır. Bunu yalnızca yükü tüketebildiğini bildiğiniz hedef için yapın. -4. **`agentTaskRecovery` etkinleştirin.** Deneyseldir ve varsayılan olarak kapalıdır. Kota, gecikme ve belgelenmemiş davranışa bağımlılık pahasına çoğu yeni spawn'ı ChatGPT arka ucundan kurtarır; ileti türü takipleri ve çok parçalı zarfları yine kaybeder. +4. **`agentTaskRecovery` etkinleştirin.** Deneyseldir ve varsayılan olarak kapalıdır. ChatGPT arka ucu üzerinden okunamayan şifreli `NEW_TASK`, `MESSAGE`, `FOLLOWUP_TASK` ve `FINAL_ANSWER` öğelerini kurtarır. Bunun bedeli kota, gecikme ve belgelenmemiş davranışa bağımlılıktır; kombo kurtarma yalnızca spawn edilmiş çocuk turlarıyla sınırlı kalır ve bölünmüş belirteç parçaları hâlâ desteklenmez. Her birinin ayrıntıları için [Alt Ajan Arayüzü](/tr/guides/sub-agent-surface/), ayarlar için [Ajan yapılandırması](/tr/reference/configuration/agents/) sayfasına diff --git a/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md b/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md index ec2b0cfed2..bbe261970d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md @@ -76,7 +76,7 @@ base 排在第二而非第一,是因为它将大多数人用于*发起*委派 1. **让 ChatGPT 保持 v1。** 在 v2 模式中,`keepNativeChatGptOnV1` 开关让 Sol 和 Terra 继续使用 v1 界面,从而仍能启动 Grok 或 Claude;路由父代理则使用 v2。这最接近两者兼得。 2. **在同一提供商内委派。** v2 上,路由父代理启动路由子代理使用明文,正常可用。 3. **信任直接使用密钥认证的 Responses 中继。** 明确设置 `allowEncryptedV2AgentTasks: true` 的提供商会收到不透明载荷,而不是 400。只有确定目标能够处理该载荷时才这样做。 -4. **启用 `agentTaskRecovery`。** 此功能为实验性,默认关闭。它通过 ChatGPT 后端恢复大多数新启动的任务,代价是消耗额度、增加延迟并依赖未文档化的行为;消息类型的后续交互和多部分信封仍会丢失。 +4. **启用 `agentTaskRecovery`。** 此功能为实验性,默认关闭。它通过 ChatGPT 后端恢复无法读取的加密 `NEW_TASK`、`MESSAGE`、`FOLLOWUP_TASK` 和 `FINAL_ANSWER` 项,代价是消耗额度、增加延迟并依赖未文档化的行为;combo 恢复仍仅限于已启动子代理的轮次,而拆分令牌片段仍不受支持。 完整机制见[子代理界面](/zh-cn/guides/sub-agent-surface/),具体设置见[代理配置](/zh-cn/reference/configuration/agents/)。 diff --git a/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md b/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md index 8e5263bea6..4215a41835 100644 --- a/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md @@ -76,7 +76,7 @@ base 排在第二而非第一,因為其釘選讓大多數人用來*發起*委 1. **讓 ChatGPT 維持 v1。** 在 v2 模式下,`keepNativeChatGptOnV1` 開關讓 Sol 與 Terra 保持 v1 介面,仍可啟動 Grok 或 Claude;路由父代理則使用 v2。這最接近同時擁有兩者。 2. **在同一供應商內委派。** 路由父代理在 v2 上啟動路由子代理時會使用明文,能正常運作。 3. **信任使用金鑰直接驗證的 Responses relay。** 若明確將供應商標記為 `allowEncryptedV2AgentTasks: true`,它就會收到不透明的內容,而非 400。只在確定目的地能處理時才這麼做。 -4. **啟用 `agentTaskRecovery`。** 這是預設關閉的實驗功能。它透過 ChatGPT 後端救回多數新啟動的子任務,但會消耗配額、增加延遲,並依賴未記載的行為;訊息型別的後續互動與 multipart envelope 仍會遺失。 +4. **啟用 `agentTaskRecovery`。** 這是預設關閉的實驗功能。它透過 ChatGPT 後端救回無法讀取的加密 `NEW_TASK`、`MESSAGE`、`FOLLOWUP_TASK` 與 `FINAL_ANSWER` 項目,但會消耗配額、增加延遲,並依賴未記載的行為;combo 復原仍僅限於已啟動子代理的回合,而拆分權杖片段仍不受支援。 各方式的完整機制請見[子代理介面](/zh-tw/guides/sub-agent-surface/),設定項目請見[代理設定](/zh-tw/reference/configuration/agents/)。 From b0aca180c0ee9e75f30613f56cad0bd69bd3a87f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:44:22 +0900 Subject: [PATCH 09/14] feat(codex): lock the main account at 98% by default (#5694) Codex Desktop disables its send button once the ChatGPT account window reads 0% remaining, even when the user routes every request to another provider. The main-account hard lock already stops ocx from admitting new identity-matched main-account requests at a usage threshold, but it was opt-in at 99%. The lock is now on by default at 98%: an absent or true codexMainAccountHardLock enables it, and only an explicit false opts out. Every gate reads one resolver (isMainAccountHardLockEnabled); the settings route persists false and deletes the key for true, like fastRows; a malformed hand edit falls back to the default; the blocked-request message derives its percent from the constant. Trade-offs: while blocked, the main account's Luna Reserve cannot activate; switching the setting off lets the main account run to exhaustion. The old switch deleted the key when turned off, so an earlier opt-out now reads as on and has to be switched off once more. GUI copy (all locales), the English and Korean accounts reference, and structure/providers/openai-tiers.md describe the new default. Refs #5694. --- .../040_main_hard_lock_default_5694.md | 17 ++ .../ko/reference/cli/providers-accounts.md | 35 ++-- .../docs/reference/cli/providers-accounts.md | 41 +++-- gui/src/i18n/de.ts | 12 +- gui/src/i18n/en.ts | 12 +- gui/src/i18n/fr.ts | 12 +- gui/src/i18n/ja.ts | 12 +- gui/src/i18n/ko.ts | 14 +- gui/src/i18n/ru.ts | 12 +- gui/src/i18n/tr.ts | 12 +- gui/src/i18n/vi.ts | 12 +- gui/src/i18n/zh-TW.ts | 12 +- gui/src/i18n/zh.ts | 12 +- scripts/test-layout/layout.json | 1 + src/codex/auth-context.ts | 25 ++- src/codex/main-account-hard-lock.ts | 15 +- src/codex/native-profile-startup.ts | 11 +- src/codex/quota-types.ts | 9 +- src/config/schema/config-schema.ts | 4 +- src/server/management/config-routes.ts | 15 +- src/types/config.ts | 8 +- structure/providers/openai-tiers.md | 74 +++++---- .../main-account-hard-lock-auth.test.ts | 33 ++-- .../main-account-hard-lock-default.test.ts | 154 ++++++++++++++++++ .../main-account-hard-lock-policy.test.ts | 25 +-- .../main-quota-evidence-validation.test.ts | 8 +- .../main-quota-window-observation.test.ts | 6 +- .../settings-main-account-hard-lock.test.ts | 33 ++-- tests/fixtures/test-layout-expected.json | 1 + .../main-account-policy-startup-child.ts | 2 +- 30 files changed, 456 insertions(+), 183 deletions(-) create mode 100644 tests/codex-integration/main-account-hard-lock-default.test.ts diff --git a/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md b/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md index 11a69f0f13..a35ecb810e 100644 --- a/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md +++ b/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md @@ -16,3 +16,20 @@ Trade-off for PR: default lock can keep main-account Luna Reserve from activatin - config-routes.ts:598 deletes the key on PUT false: must store false; PUT true deletes the key (default on). Projections at :351 and :706 become resolver-based, else GUI invariant at MainAccountHardLockSetting.tsx:14 fails. - quota.ts:262 and :388 also use MAIN_ACCOUNT_HARD_LOCK_PERCENT (blocking-evidence retention); they follow the constant. - auth-context.ts:512 hardcoded "99%" message -> derive from constant. + +## wp4 P (executable) +Semantics: codexMainAccountHardLock absent or true = on; explicit false = off (persisted). Threshold 98 (MAIN_ACCOUNT_HARD_LOCK_PERCENT). Same convention as fastRows in config-routes.ts. +Writer A (core, write scope: src/**, tests/**, structure/**, scripts/test-layout/layout.json): +- src/codex/quota-types.ts: MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98 (#5694). +- src/codex/main-account-hard-lock.ts: export isMainAccountHardLockEnabled(config) = config.codexMainAccountHardLock !== false; getMainAccountHardLockStatus uses it. +- Replace every === true gate: auth-context.ts 960/973/1017/1545/1623, native-profile-startup.ts 191/228/239/389/708, config-routes.ts projections 351/706. +- config-routes.ts PUT: true deletes key, false stores false (mirror fastRows); rollback path unchanged. +- config-schema.ts: .catch(undefined) so malformed values mean default-on. +- types/config.ts doc comment; auth-context.ts:512 message uses the constant. +- Tests: update existing hard-lock tests; add tests/codex-integration/main-account-hard-lock-default.test.ts (absent=on, false=off, malformed=on, 97.9 ready, 98 blocked, settings GET/PUT round trip) registered in layout.json explicit + test-layout-expected.json. codex-auth-api.test.ts cap 6549 (6514 now): no net growth beyond cap. +- structure/providers/openai-tiers.md:291-315 rewrite (on by default, 98%, opt-out persists false, Reserve trade-off). +Writer B (gui/src/i18n/*.ts only): 99 -> 98 in mainHardLock* strings in all locales; desc adds "On by default." +Writer C (docs-site/** only): providers-accounts.md en + ko (and other locales mentioning it) -> 98%, on by default, opt-out; check configuration/providers.md:71 and guides/providers.md:377. +Previous opt-outs deleted the key, so they cannot be distinguished; PR notes it. +Focused: main-account-hard-lock-*.test.ts, settings-main-account-hard-lock, main-quota-*, reserve-*, reserve-claude-policy, codex-quota-auto-refresh-main-admission, codex-auth-api, codex-account-threshold-api; bun run lint:gui; GUI i18n tests. + diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 5541f8e259..3abc6bed78 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -74,15 +74,23 @@ ocx login anthropic ## 계정과 키 풀 -### 메인 계정 99% 보호 +### 메인 계정 98% 보호 -**Codex 설정 → 다중 인증 → 고급 설정**에서 Ultra Fast 옆의 **메인 계정 99% 차단**을 -켤 수 있습니다. 켜기 전에 영향 안내가 나오며, 취소하면 설정은 바뀌지 않습니다. -고급 설정을 접어도 메인 계정 카드에 보호 상태와 사용량 확인 필요 여부, 현재 차단 여부가 표시됩니다. +**Codex 설정 → 다중 인증 → 고급 설정**에서 Ultra Fast 옆의 **메인 계정 98% 차단**이 +기본으로 켜져 있습니다. 끄면 바로 적용되고, 다시 켤 때는 영향 안내가 먼저 나오며 취소하면 설정은 +바뀌지 않습니다. 고급 설정을 접어도 메인 계정 카드에 보호 상태와 사용량 확인 필요 여부, 현재 차단 +여부가 표시됩니다. + +이 기본값은 메인 계정을 완전히 소진했을 때 Codex Desktop에서 벌어지는 일 때문입니다. ChatGPT +계정 창이 **0%**를 보고하면 Desktop은 전송 버튼을 비활성화하고, 창이 리셋될 때까지 그 계정으로 +턴을 보낼 수 없습니다. ocx 트래픽이 그 지점까지 닿지 않게 막아 계정을 계속 쓸 수 있게 +유지합니다 ([#5694](https://github.com/lidge-jun/opencodex/issues/5694)). 대가는 Luna +Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할 수 없으므로, 메인 계정을 끝까지 +쓰고 Reserve로 넘어가려면 스위치를 끄세요. **5h 창이 있으면 5h 사용률**, 없으면 주간 사용률을 봅니다. 월간 전용 계정은 월간을 기준으로 합니다. 여러 창에서 가장 높은 값을 고르는 방식은 아닙니다. 새 사용률이 **0%**로 -리셋되면 자동으로 차단을 풀고, 스위치는 켜 둡니다. 이후 다시 99%가 되면 차단합니다. +리셋되면 자동으로 차단을 풀고, 스위치는 켜 둡니다. 이후 다시 98%가 되면 차단합니다. 값이 빠진 응답을 0%로 보지 않으며, 이미 확인한 차단 수치를 누락된 응답만으로 지우지도 않습니다. 예정된 리셋 시간이 지났다는 이유만으로 풀지는 않습니다. 차단 중에는 기존 1분 주기 점검에서 실제 사용량을 다시 확인하며, 조회 실패나 잘못된 수치는 차단을 풀지 않습니다. @@ -91,17 +99,20 @@ ocx login anthropic 새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고, 2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다. 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. -현재 창에는 동일한 99% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 +현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 도착한 경우에는 이전 차단을 해제하지 않습니다. -저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock": true`이며, -기본값은 꺼짐입니다. 식별된 메인 계정의 새 요청을 막는 기능이지 마지막 1%를 예약하는 기능은 -아닙니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다. -추가 계정과 다른 공급자는 계속 사용할 수 있습니다. +저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock"`입니다. 값이 없거나 +`true`이면 켜짐이고, `false`일 때만 꺼집니다. 스위치를 끄면 이 `false`가 저장됩니다. 기본값이 +바뀐 지점입니다. 예전에는 옵트인이었고 스위치를 끌 때 키를 지웠기 때문에, 꺼 두었던 설치도 +지금은 켜진 상태로 읽힙니다. 예전 동작을 유지하려면 스위치를 한 번 꺼서 옵트아웃을 기록하세요. +식별된 메인 계정의 새 요청을 막는 기능이지 마지막 2%를 예약하는 기능은 아닙니다. 진행 중 요청, +식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다. 추가 계정과 다른 공급자는 +계속 사용할 수 있습니다. 보호 기능이 켜져 있으면 소유권이 확인된 시작 과정에서 native 프로필 복구와 정리를 마친 뒤 -메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 99% 차단이 재시작 후에도 유지됩니다. +메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 98% 차단이 재시작 후에도 유지됩니다. 연결을 준비하는 동안 호출자 인증정보를 쓰는 Direct, 메인 계정 지정, 메인 fallback, 메인 pin 요청은 잠시 503을 받을 수 있고, 저장된 Pool 계정은 그동안에도 그대로 쓸 수 있습니다. 이 초기화를 위해 다른 서비스 소유이거나 소유권이 미확인인 홈의 인증정보를 읽지는 않습니다. @@ -137,7 +148,7 @@ Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 요청을 거절합니다. 다른 계정이나 일반 Luna로 몰래 바꾸지 않습니다. 일반 사용량 조회는 기존 허용을 취소할 수 있지만 새로 허용하지는 않습니다. -전체 쿨다운, 일시정지, 재인증, 99% 하드락은 여전히 적용됩니다. 소진된 메인 계정에서 Reserve를 +전체 쿨다운, 일시정지, 재인증, 98% 하드락은 여전히 적용됩니다. 소진된 메인 계정에서 Reserve를 쓰려면 하드락을 꺼야 하지만, 껐다고 서버의 사용 권한이 생기지는 않습니다. 이 호환 경로는 대화와 대화 압축용입니다. 이미지 설명·웹 검색 보조 모델이나 독립 검색 릴레이에 Reserve를 지정하는 용도는 지원하지 않으므로, 그 기능에는 다른 모델을 선택하세요. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 21c8f82a5f..522cf73bfe 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -136,17 +136,24 @@ Remove the stored OAuth credential for a provider. ## Accounts and key pools -### Main-account 99% protection +### Main-account 98% protection -In **Codex settings → Multi-auth → Advanced settings**, **Block main account at 99%** -is an independent opt-in beside Ultra Fast. Enabling it first shows the consequences; cancelling -does not change the setting. The main-account card shows monitoring, unknown usage, or a current -policy block even when Advanced settings is closed. +In **Codex settings → Multi-auth → Advanced settings**, **Block main account at 98%** +is on by default beside Ultra Fast. Switching it off applies immediately; turning it back on first +shows the consequences, and cancelling does not change the setting. The main-account card shows +monitoring, unknown usage, or a current policy block even when Advanced settings is closed. + +The default follows from what a drained main account does to Codex Desktop: once the ChatGPT +account window reports **0%** remaining, Desktop disables its send button and the account stops +accepting turns until the window resets. Holding ocx's own traffic below that point keeps the +account usable ([#5694](https://github.com/lidge-jun/opencodex/issues/5694)). The cost is Luna +Reserve: while the block is in force, Reserve on that main account cannot activate. To let the main +account run to exhaustion and hand over to Reserve, turn the switch off. The policy uses the **5h window when present**, otherwise the weekly window. Monthly-only accounts use their monthly window. It does not take the highest percentage across windows. A fresh **0%** observation automatically releases the block while the switch stays on; the next -99% observation blocks again. Unknown usage does not fabricate a zero, and a missing reading does +98% observation blocks again. Unknown usage does not fabricate a zero, and a missing reading does not erase an already measured blocking tuple. A predicted reset time alone does not unlock it. While blocked, the existing once-per-minute background cycle checks fresh owned usage; failed or invalid readings retain the block. Other pause, reauthentication, and upstream limits remain independent. @@ -155,16 +162,20 @@ Protection treats one fresh valid WHAM usage response as a replacement for the o its primary window explicitly lasts **at least 24 hours** and secondary/tertiary windows are explicitly `null` or also explicitly last at least 24 hours and report their usage. This follows the parser's short/long boundary, so a one-day window qualifies as well as weekly/monthly windows. The current window still uses the same -99% threshold. This relies on the single reported snapshot; repeated observations are not required. +98% threshold. This relies on the single reported snapshot; repeated observations are not required. Omitted secondary/tertiary fields, an unknown primary duration, or partial response headers cannot clear a previous block. -The persisted option is `"codexMainAccountHardLock": true` in OpenCodex's `config.json`; it is off -by default. This protects new requests using the identified main account, not the last 1% itself: -already-running requests, unmatched caller-owned keyring credentials, and traffic outside the -proxy can still spend quota. Added accounts and other providers remain available. - -With protection enabled, an owned startup restores the main credential's in-memory identity -binding after native-profile recovery and cleanup, so a persisted 99% block survives a restart. +The persisted option is `"codexMainAccountHardLock"` in OpenCodex's `config.json`. An absent key or +`true` means on; only an explicit `false` turns it off, and that is what switching the setting off +stores. The default changed here: the policy used to be opt-in and the old switch removed the key +when it was turned off, so an install that had switched it off now reads as on. If you want the old +behavior, switch it off once to record the opt-out. Protection covers new requests using the +identified main account, not the last 2% itself: already-running requests, unmatched caller-owned +keyring credentials, and traffic outside the proxy can still spend quota. Added accounts and other +providers remain available. + +With protection on, an owned startup restores the main credential's in-memory identity +binding after native-profile recovery and cleanup, so a persisted 98% block survives a restart. Caller-owned Direct, exact-main, main-fallback, and main-pin requests can briefly receive 503 while that binding is pending; healthy stored Pool accounts stay eligible throughout. No credential is read from a foreign or unconfirmed service home for this initialization. @@ -198,7 +209,7 @@ Each compatibility request checks a credential-bound server authorization, cache requires ordinary usage to be disallowed, the Luna Reserve banner, and exactly one allowed Reserve bucket. Missing, denied, stale or mismatched evidence refuses the request; it does not switch accounts or silently use ordinary Luna. Passive usage can revoke authorization but cannot create it. -Global cooldown, pause, reauthentication and the 99% hard lock still apply. Disable the hard lock if +Global cooldown, pause, reauthentication and the 98% hard lock still apply. Turn the hard lock off if you want to use Reserve on an exhausted main account; doing so does not grant server entitlement. This compatibility path supports conversation requests and compaction, not Reserve as a vision or web-search helper or a standalone search-relay model. Choose another model for those helpers. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3d6318e2d7..456a7c6489 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1573,17 +1573,17 @@ export const de: Record = { "codexAuth.add": "Hinzufügen", "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.ultraFastTitle": "Ultra-Fast-Diensttarif", - "codexAuth.mainHardLockTitle": "Hauptkonto bei 99 % sperren", - "codexAuth.mainHardLockDesc": "Verwendet das 5-Stunden-Fenster, falls vorhanden, sonst das Wochenfenster (bei rein monatlichen Konten das Monatsfenster). Ein neuer Wert von 0 % hebt die Sperre automatisch auf; der Schutz bleibt aktiv.", - "codexAuth.mainHardLockConfirmTitle": "99-%-Schutz für das Hauptkonto aktivieren?", + "codexAuth.mainHardLockTitle": "Hauptkonto bei 98 % sperren", + "codexAuth.mainHardLockDesc": "Verwendet das 5-Stunden-Fenster, falls vorhanden, sonst das Wochenfenster (bei rein monatlichen Konten das Monatsfenster). Ein neuer Wert von 0 % hebt die Sperre automatisch auf; der Schutz bleibt aktiv. Standardmäßig aktiviert.", + "codexAuth.mainHardLockConfirmTitle": "98-%-Schutz für das Hauptkonto aktivieren?", "codexAuth.mainHardLockConfirmBody": "Während der Sperre ist auch Luna Reserve für das Hauptkonto nicht verfügbar. Ohne vollständigen Verbrauch des normalen Kontingents wird Reserve möglicherweise nicht aktiviert. Zusätzliche Konten und andere Anbieter bleiben nutzbar. Laufende Anfragen, nicht zugeordnete Schlüsselbund-Zugangsdaten und Anfragen außerhalb dieses Proxys sind nicht geschützt.", "codexAuth.mainHardLockConfirm": "Schutz aktivieren", - "codexAuth.mainHardLockEnabled": "99-%-Schutz ist aktiviert.", - "codexAuth.mainHardLockDisabled": "99-%-Schutz ist deaktiviert. Andere Kontolimits gelten weiterhin.", + "codexAuth.mainHardLockEnabled": "98-%-Schutz ist aktiviert.", + "codexAuth.mainHardLockDisabled": "98-%-Schutz ist deaktiviert. Andere Kontolimits gelten weiterhin.", "codexAuth.mainHardLockLoadFailed": "Einstellung konnte nicht geladen werden. Erneut versuchen, um den aktuellen Zustand zu prüfen.", "codexAuth.mainHardLockSaveFailed": "Speicherung konnte nicht bestätigt werden. Einstellung vor einem neuen Versuch erneut laden.", "codexAuth.mainHardLockRefreshFailed": "Einstellung gespeichert, aber Kontostatus konnte nicht aktualisiert werden. Bitte erneut versuchen.", - "codexAuth.mainHardLockBlocked": "Durch 99-%-Schutz gesperrt", + "codexAuth.mainHardLockBlocked": "Durch 98-%-Schutz gesperrt", "codexAuth.mainHardLockUnknown": "Schutz aktiv · Nutzung unbekannt", "codexAuth.mainHardLockMonitoring": "Schutz aktiv · Überwachung", "codexAuth.mainHardLockManage": "Schutzeinstellung anzeigen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b6a9c700e4..dde1025f25 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2161,17 +2161,17 @@ export const en = { "codexAuth.add": "Add", "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.ultraFastTitle": "Ultra Fast service tier", - "codexAuth.mainHardLockTitle": "Block main account at 99%", - "codexAuth.mainHardLockDesc": "Uses 5h usage when available, otherwise weekly (monthly for monthly-only accounts). A fresh 0% reading unlocks automatically; protection stays on.", - "codexAuth.mainHardLockConfirmTitle": "Enable the main account 99% lock?", + "codexAuth.mainHardLockTitle": "Block main account at 98%", + "codexAuth.mainHardLockDesc": "Uses 5h usage when available, otherwise weekly (monthly for monthly-only accounts). A fresh 0% reading unlocks automatically; protection stays on. On by default.", + "codexAuth.mainHardLockConfirmTitle": "Enable the main account 98% lock?", "codexAuth.mainHardLockConfirmBody": "While blocked, the main account cannot use Luna Reserve. Keeping normal usage below exhaustion may prevent Reserve activation. Added accounts and other providers remain available. Running requests, unmatched keyring credentials, and traffic outside this proxy are not protected.", "codexAuth.mainHardLockConfirm": "Enable protection", - "codexAuth.mainHardLockEnabled": "99% protection is on.", - "codexAuth.mainHardLockDisabled": "99% protection is off. Other account limits still apply.", + "codexAuth.mainHardLockEnabled": "98% protection is on.", + "codexAuth.mainHardLockDisabled": "98% protection is off. Other account limits still apply.", "codexAuth.mainHardLockLoadFailed": "Could not load this setting. Retry to check its current state.", "codexAuth.mainHardLockSaveFailed": "Could not confirm the save. Reload the setting before trying again.", "codexAuth.mainHardLockRefreshFailed": "Setting saved, but account status could not be refreshed. Please retry.", - "codexAuth.mainHardLockBlocked": "Blocked by 99% protection", + "codexAuth.mainHardLockBlocked": "Blocked by 98% protection", "codexAuth.mainHardLockUnknown": "Protection on · usage unknown", "codexAuth.mainHardLockMonitoring": "Protection on · monitoring", "codexAuth.mainHardLockManage": "View protection setting", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3915f020b1..d15a3bdeff 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2093,17 +2093,17 @@ export const fr: Record = { "codexAuth.add": "Ajouter", "codexAuth.refreshQuota": "Actualiser les quotas", "codexAuth.ultraFastTitle": "Niveau de service Ultra Fast", - "codexAuth.mainHardLockTitle": "Bloquer le compte principal à 99 %", - "codexAuth.mainHardLockDesc": "Utilise la fenêtre de 5 h si elle existe, sinon la semaine (le mois pour les comptes mensuels uniquement). Une nouvelle mesure à 0 % lève le blocage automatiquement ; la protection reste active.", - "codexAuth.mainHardLockConfirmTitle": "Activer la protection à 99 % du compte principal ?", + "codexAuth.mainHardLockTitle": "Bloquer le compte principal à 98 %", + "codexAuth.mainHardLockDesc": "Utilise la fenêtre de 5 h si elle existe, sinon la semaine (le mois pour les comptes mensuels uniquement). Une nouvelle mesure à 0 % lève le blocage automatiquement ; la protection reste active. Activé par défaut.", + "codexAuth.mainHardLockConfirmTitle": "Activer la protection à 98 % du compte principal ?", "codexAuth.mainHardLockConfirmBody": "Pendant le blocage, Luna Reserve est également indisponible sur le compte principal. Ne pas épuiser le quota normal peut empêcher l’activation de Reserve. Les comptes ajoutés et les autres fournisseurs restent utilisables. Les requêtes en cours, les identifiants du trousseau non reconnus et le trafic hors de ce proxy ne sont pas protégés.", "codexAuth.mainHardLockConfirm": "Activer la protection", - "codexAuth.mainHardLockEnabled": "La protection à 99 % est active.", - "codexAuth.mainHardLockDisabled": "La protection à 99 % est désactivée. Les autres limites du compte restent applicables.", + "codexAuth.mainHardLockEnabled": "La protection à 98 % est active.", + "codexAuth.mainHardLockDisabled": "La protection à 98 % est désactivée. Les autres limites du compte restent applicables.", "codexAuth.mainHardLockLoadFailed": "Impossible de charger ce réglage. Réessayez pour vérifier son état.", "codexAuth.mainHardLockSaveFailed": "Impossible de confirmer l’enregistrement. Rechargez le réglage avant de réessayer.", "codexAuth.mainHardLockRefreshFailed": "Réglage enregistré, mais l’état du compte n’a pas pu être actualisé. Réessayez.", - "codexAuth.mainHardLockBlocked": "Bloqué par la protection à 99 %", + "codexAuth.mainHardLockBlocked": "Bloqué par la protection à 98 %", "codexAuth.mainHardLockUnknown": "Protection active · utilisation inconnue", "codexAuth.mainHardLockMonitoring": "Protection active · surveillance", "codexAuth.mainHardLockManage": "Voir le réglage de protection", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 93d04f38a9..7d181f791a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2016,17 +2016,17 @@ export const ja: Record = { "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.ultraFastTitle": "Ultra Fast サービスティア", - "codexAuth.mainHardLockTitle": "メインアカウントを99%で停止", - "codexAuth.mainHardLockDesc": "5時間枠があればその使用率、なければ週間使用率を使います(月間のみのアカウントは月間)。0%にリセットされると自動解除し、設定は有効のままです。", - "codexAuth.mainHardLockConfirmTitle": "メインアカウントの99%保護を有効にしますか?", + "codexAuth.mainHardLockTitle": "メインアカウントを98%で停止", + "codexAuth.mainHardLockDesc": "5時間枠があればその使用率、なければ週間使用率を使います(月間のみのアカウントは月間)。0%にリセットされると自動解除し、設定は有効のままです。既定でオンです。", + "codexAuth.mainHardLockConfirmTitle": "メインアカウントの98%保護を有効にしますか?", "codexAuth.mainHardLockConfirmBody": "停止中はメインアカウントのLuna Reserveも使えません。通常枠を使い切らない場合、Reserveが有効にならないことがあります。追加アカウントや他のプロバイダーは引き続き使えます。実行中のリクエスト、照合できないキーチェーン認証情報、このプロキシ外の通信は対象外です。", "codexAuth.mainHardLockConfirm": "保護を有効にする", - "codexAuth.mainHardLockEnabled": "99%保護を有効にしました。", - "codexAuth.mainHardLockDisabled": "99%保護を無効にしました。他のアカウント制限は引き続き適用されます。", + "codexAuth.mainHardLockEnabled": "98%保護を有効にしました。", + "codexAuth.mainHardLockDisabled": "98%保護を無効にしました。他のアカウント制限は引き続き適用されます。", "codexAuth.mainHardLockLoadFailed": "設定を読み込めませんでした。再試行して現在の状態を確認してください。", "codexAuth.mainHardLockSaveFailed": "保存を確認できませんでした。設定を再読み込みしてから再試行してください。", "codexAuth.mainHardLockRefreshFailed": "設定は保存されましたが、アカウント状態を更新できませんでした。再試行してください。", - "codexAuth.mainHardLockBlocked": "99%保護により停止中", + "codexAuth.mainHardLockBlocked": "98%保護により停止中", "codexAuth.mainHardLockUnknown": "保護有効・使用率不明", "codexAuth.mainHardLockMonitoring": "保護有効・監視中", "codexAuth.mainHardLockManage": "保護設定を表示", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 66a2f3300c..30cf76a295 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1609,19 +1609,19 @@ export const ko: Record = { "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.ultraFastTitle": "Ultra Fast 서비스 티어", - "codexAuth.mainHardLockTitle": "메인 계정 99% 차단", - "codexAuth.mainHardLockDesc": "5h 창이 있으면 5h, 없으면 주간 사용률을 기준으로 합니다. 월간 전용 계정은 월간을 봅니다. 0%로 리셋되면 자동으로 풀리고 설정은 유지됩니다.", - "codexAuth.mainHardLockConfirmTitle": "메인 계정 99% 차단을 켤까요?", + "codexAuth.mainHardLockTitle": "메인 계정 98% 차단", + "codexAuth.mainHardLockDesc": "5h 창이 있으면 5h, 없으면 주간 사용률을 기준으로 합니다. 월간 전용 계정은 월간을 봅니다. 0%로 리셋되면 자동으로 풀리고 설정은 유지됩니다. 기본값으로 켜져 있습니다.", + "codexAuth.mainHardLockConfirmTitle": "메인 계정 98% 차단을 켤까요?", "codexAuth.mainHardLockConfirmBody": "차단 중에는 메인 계정의 Luna Reserve도 사용할 수 없습니다. 일반 사용량이 소진되지 않으면 Reserve가 활성화되지 않을 수 있습니다. 추가 계정과 다른 공급자는 계속 사용할 수 있습니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청에는 적용되지 않습니다.", "codexAuth.mainHardLockConfirm": "확인하고 켜기", - "codexAuth.mainHardLockEnabled": "99% 보호 설정을 켰습니다.", - "codexAuth.mainHardLockDisabled": "99% 보호 설정을 껐습니다. 다른 계정 제한은 그대로 적용됩니다.", + "codexAuth.mainHardLockEnabled": "98% 보호 설정을 켰습니다.", + "codexAuth.mainHardLockDisabled": "98% 보호 설정을 껐습니다. 다른 계정 제한은 그대로 적용됩니다.", "codexAuth.mainHardLockLoadFailed": "설정을 불러오지 못했습니다. 다시 시도해 현재 상태를 확인하세요.", "codexAuth.mainHardLockSaveFailed": "저장 여부를 확인하지 못했습니다. 설정을 다시 불러온 뒤 시도하세요.", "codexAuth.mainHardLockRefreshFailed": "설정은 저장됐지만 계정 상태를 다시 확인하지 못했습니다. 다시 시도하세요.", - "codexAuth.mainHardLockBlocked": "99% 보호로 차단 중", + "codexAuth.mainHardLockBlocked": "98% 보호로 차단 중", "codexAuth.mainHardLockUnknown": "보호 켜짐 · 사용량 확인 필요", - "codexAuth.mainHardLockMonitoring": "99% 보호 켜짐", + "codexAuth.mainHardLockMonitoring": "98% 보호 켜짐", "codexAuth.mainHardLockManage": "차단 설정 보기", "codexAuth.ultraFastDesc": "직접 설정한 ultrafast 서비스 티어가 카탈로그를 다시 만들 때 지워지지 않게 하고, 요청 로그에 그 티어 이름을 남깁니다. 모델 피커에 Ultra Fast를 추가하지는 않습니다. 업스트림은 Fast만 알리기 때문에, 피커에 칸을 만들면 실제로 낼 수 없는 속도를 고르게 하는 셈입니다.", "codexAuth.ultraFastLoadFailed": "Ultra Fast 설정을 읽지 못했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 29428d8126..2cf5f7d2c8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2088,17 +2088,17 @@ export const ru: Record = { "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.ultraFastTitle": "Уровень обслуживания Ultra Fast", - "codexAuth.mainHardLockTitle": "Блокировать основной аккаунт при 99%", - "codexAuth.mainHardLockDesc": "Используется окно 5 ч, если оно есть, иначе недельное (месячное для аккаунтов только с месячным лимитом). Новое значение 0% автоматически снимает блокировку; защита остаётся включённой.", - "codexAuth.mainHardLockConfirmTitle": "Включить защиту основного аккаунта при 99%?", + "codexAuth.mainHardLockTitle": "Блокировать основной аккаунт при 98%", + "codexAuth.mainHardLockDesc": "Используется окно 5 ч, если оно есть, иначе недельное (месячное для аккаунтов только с месячным лимитом). Новое значение 0% автоматически снимает блокировку; защита остаётся включённой. Включено по умолчанию.", + "codexAuth.mainHardLockConfirmTitle": "Включить защиту основного аккаунта при 98%?", "codexAuth.mainHardLockConfirmBody": "Во время блокировки Luna Reserve основного аккаунта тоже недоступна. Если обычная квота не исчерпана, Reserve может не активироваться. Дополнительные аккаунты и другие провайдеры остаются доступны. Текущие запросы, несопоставленные данные связки ключей и запросы вне этого прокси не защищены.", "codexAuth.mainHardLockConfirm": "Включить защиту", - "codexAuth.mainHardLockEnabled": "Защита при 99% включена.", - "codexAuth.mainHardLockDisabled": "Защита при 99% выключена. Остальные лимиты аккаунта сохраняются.", + "codexAuth.mainHardLockEnabled": "Защита при 98% включена.", + "codexAuth.mainHardLockDisabled": "Защита при 98% выключена. Остальные лимиты аккаунта сохраняются.", "codexAuth.mainHardLockLoadFailed": "Не удалось загрузить настройку. Повторите попытку, чтобы проверить её состояние.", "codexAuth.mainHardLockSaveFailed": "Не удалось подтвердить сохранение. Перезагрузите настройку перед повторной попыткой.", "codexAuth.mainHardLockRefreshFailed": "Настройка сохранена, но состояние аккаунта не обновилось. Повторите попытку.", - "codexAuth.mainHardLockBlocked": "Заблокирован защитой при 99%", + "codexAuth.mainHardLockBlocked": "Заблокирован защитой при 98%", "codexAuth.mainHardLockUnknown": "Защита включена · расход неизвестен", "codexAuth.mainHardLockMonitoring": "Защита включена · наблюдение", "codexAuth.mainHardLockManage": "Открыть настройку защиты", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 8814780b1c..ea0d6403e6 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2118,17 +2118,17 @@ export const tr: Record = { "codexAuth.add": "Ekle", "codexAuth.refreshQuota": "Kotaları yenile", "codexAuth.ultraFastTitle": "Ultra Fast hizmet katmanı", - "codexAuth.mainHardLockTitle": "Ana hesabı %99’da durdur", - "codexAuth.mainHardLockDesc": "Varsa 5 saatlik, yoksa haftalık kullanım esas alınır (yalnızca aylık hesaplarda aylık kullanım). Yeni %0 ölçümü engeli otomatik kaldırır; koruma açık kalır.", - "codexAuth.mainHardLockConfirmTitle": "Ana hesap için %99 koruması açılsın mı?", + "codexAuth.mainHardLockTitle": "Ana hesabı %98’da durdur", + "codexAuth.mainHardLockDesc": "Varsa 5 saatlik, yoksa haftalık kullanım esas alınır (yalnızca aylık hesaplarda aylık kullanım). Yeni %0 ölçümü engeli otomatik kaldırır; koruma açık kalır. Varsayılan olarak açık.", + "codexAuth.mainHardLockConfirmTitle": "Ana hesap için %98 koruması açılsın mı?", "codexAuth.mainHardLockConfirmBody": "Engel sürerken ana hesabın Luna Reserve erişimi de kullanılamaz. Normal kotanın tükenmemesi Reserve’in etkinleşmesini önleyebilir. Ek hesaplar ve diğer sağlayıcılar kullanılmaya devam eder. Çalışan istekler, eşleştirilemeyen anahtarlık kimlik bilgileri ve bu proxy dışındaki trafik korunmaz.", "codexAuth.mainHardLockConfirm": "Korumayı aç", - "codexAuth.mainHardLockEnabled": "%99 koruması açık.", - "codexAuth.mainHardLockDisabled": "%99 koruması kapalı. Diğer hesap sınırları geçerliliğini korur.", + "codexAuth.mainHardLockEnabled": "%98 koruması açık.", + "codexAuth.mainHardLockDisabled": "%98 koruması kapalı. Diğer hesap sınırları geçerliliğini korur.", "codexAuth.mainHardLockLoadFailed": "Ayar yüklenemedi. Güncel durumu kontrol etmek için yeniden deneyin.", "codexAuth.mainHardLockSaveFailed": "Kayıt doğrulanamadı. Yeniden denemeden önce ayarı tekrar yükleyin.", "codexAuth.mainHardLockRefreshFailed": "Ayar kaydedildi ancak hesap durumu yenilenemedi. Yeniden deneyin.", - "codexAuth.mainHardLockBlocked": "%99 koruması nedeniyle engellendi", + "codexAuth.mainHardLockBlocked": "%98 koruması nedeniyle engellendi", "codexAuth.mainHardLockUnknown": "Koruma açık · kullanım bilinmiyor", "codexAuth.mainHardLockMonitoring": "Koruma açık · izleniyor", "codexAuth.mainHardLockManage": "Koruma ayarını göster", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 78fd3b1e6d..6bad31ff04 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2116,17 +2116,17 @@ export const vi: Record = { "codexAuth.add": "Thêm", "codexAuth.refreshQuota": "Làm mới quota", "codexAuth.ultraFastTitle": "Dịch vụ cấp độ Ultra Fast", - "codexAuth.mainHardLockTitle": "Khóa tài khoản chính ở mức 99%", - "codexAuth.mainHardLockDesc": "Sử dụng mức 5h nếu có, nếu không thì dùng hàng tuần (hoặc hàng tháng đối với tài khoản chỉ có hàng tháng). Một kết quả đọc 0% mới sẽ tự động mở khóa; bảo vệ vẫn tiếp tục bật.", - "codexAuth.mainHardLockConfirmTitle": "Bật khóa 99% tài khoản chính?", + "codexAuth.mainHardLockTitle": "Khóa tài khoản chính ở mức 98%", + "codexAuth.mainHardLockDesc": "Sử dụng mức 5h nếu có, nếu không thì dùng hàng tuần (hoặc hàng tháng đối với tài khoản chỉ có hàng tháng). Một kết quả đọc 0% mới sẽ tự động mở khóa; bảo vệ vẫn tiếp tục bật. Mặc định bật.", + "codexAuth.mainHardLockConfirmTitle": "Bật khóa 98% tài khoản chính?", "codexAuth.mainHardLockConfirmBody": "Khi bị khóa, tài khoản chính không thể sử dụng Luna Reserve. Giữ lượng dùng dưới mức cạn kiệt có thể ngăn việc kích hoạt Reserve. Các tài khoản được thêm vào và provider khác vẫn khả dụng. Các request đang chạy, credential chưa khớp, và lưu lượng ngoài proxy này không được bảo vệ.", "codexAuth.mainHardLockConfirm": "Bật bảo vệ", - "codexAuth.mainHardLockEnabled": "Bảo vệ 99% đang bật.", - "codexAuth.mainHardLockDisabled": "Bảo vệ 99% đang tắt. Các giới hạn tài khoản khác vẫn được áp dụng.", + "codexAuth.mainHardLockEnabled": "Bảo vệ 98% đang bật.", + "codexAuth.mainHardLockDisabled": "Bảo vệ 98% đang tắt. Các giới hạn tài khoản khác vẫn được áp dụng.", "codexAuth.mainHardLockLoadFailed": "Không thể tải cài đặt này. Hãy thử lại để kiểm tra trạng thái hiện tại của nó.", "codexAuth.mainHardLockSaveFailed": "Không thể xác nhận việc lưu. Tải lại cài đặt trước khi thử lại.", "codexAuth.mainHardLockRefreshFailed": "Cài đặt đã được lưu, nhưng không thể làm mới trạng thái tài khoản. Vui lòng thử lại.", - "codexAuth.mainHardLockBlocked": "Bị khóa bởi bảo vệ 99%", + "codexAuth.mainHardLockBlocked": "Bị khóa bởi bảo vệ 98%", "codexAuth.mainHardLockUnknown": "Bảo vệ đang bật · lượng sử dụng chưa rõ", "codexAuth.mainHardLockMonitoring": "Bảo vệ đang bật · đang theo dõi", "codexAuth.mainHardLockManage": "Xem thiết lập bảo vệ", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 583fe6c421..c453e360f1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1589,17 +1589,17 @@ export const zhTW: Record = { "codexAuth.add": "新增", "codexAuth.refreshQuota": "重新整理額度", "codexAuth.ultraFastTitle": "Ultra Fast 服務層級", - "codexAuth.mainHardLockTitle": "主帳戶用量達 99% 時阻擋請求", - "codexAuth.mainHardLockDesc": "有 5 小時額度時以該額度為準,否則使用週額度(僅有月額度的帳戶使用月額度)。新用量重設為 0% 後會自動解除阻擋,保護設定仍保持開啟。", - "codexAuth.mainHardLockConfirmTitle": "開啟主帳戶 99% 保護?", + "codexAuth.mainHardLockTitle": "主帳戶用量達 98% 時阻擋請求", + "codexAuth.mainHardLockDesc": "有 5 小時額度時以該額度為準,否則使用週額度(僅有月額度的帳戶使用月額度)。新用量重設為 0% 後會自動解除阻擋,保護設定仍保持開啟。預設開啟。", + "codexAuth.mainHardLockConfirmTitle": "開啟主帳戶 98% 保護?", "codexAuth.mainHardLockConfirmBody": "阻擋期間,主帳戶也無法使用 Luna Reserve。一般額度未用盡時,Reserve 可能不會啟用。新增帳戶與其他供應商仍可使用。進行中的請求、無法比對的鑰匙圈憑證,以及此代理之外的流量不受此保護。", "codexAuth.mainHardLockConfirm": "開啟保護", - "codexAuth.mainHardLockEnabled": "99% 保護已開啟。", - "codexAuth.mainHardLockDisabled": "99% 保護已關閉,其他帳戶限制仍然適用。", + "codexAuth.mainHardLockEnabled": "98% 保護已開啟。", + "codexAuth.mainHardLockDisabled": "98% 保護已關閉,其他帳戶限制仍然適用。", "codexAuth.mainHardLockLoadFailed": "無法載入此設定。請重試以確認目前狀態。", "codexAuth.mainHardLockSaveFailed": "無法確認是否已儲存。請重新載入設定後再試。", "codexAuth.mainHardLockRefreshFailed": "設定已儲存,但無法更新帳戶狀態。請重試。", - "codexAuth.mainHardLockBlocked": "已被 99% 保護阻擋", + "codexAuth.mainHardLockBlocked": "已被 98% 保護阻擋", "codexAuth.mainHardLockUnknown": "保護已開啟 · 用量未知", "codexAuth.mainHardLockMonitoring": "保護已開啟 · 監測中", "codexAuth.mainHardLockManage": "查看保護設定", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5d222962c0..651476b0e4 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1590,17 +1590,17 @@ export const zh: Record = { "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", "codexAuth.ultraFastTitle": "Ultra Fast 服务层级", - "codexAuth.mainHardLockTitle": "主账户用量达 99% 时阻止请求", - "codexAuth.mainHardLockDesc": "有 5 小时额度时以该额度为准,否则使用周额度(仅有月额度的账户使用月额度)。新用量重置为 0% 后会自动解除阻止,保护设置仍保持开启。", - "codexAuth.mainHardLockConfirmTitle": "开启主账户 99% 保护?", + "codexAuth.mainHardLockTitle": "主账户用量达 98% 时阻止请求", + "codexAuth.mainHardLockDesc": "有 5 小时额度时以该额度为准,否则使用周额度(仅有月额度的账户使用月额度)。新用量重置为 0% 后会自动解除阻止,保护设置仍保持开启。默认开启。", + "codexAuth.mainHardLockConfirmTitle": "开启主账户 98% 保护?", "codexAuth.mainHardLockConfirmBody": "阻止期间,主账户也无法使用 Luna Reserve。普通额度未耗尽时,Reserve 可能不会激活。附加账户和其他提供商仍可使用。正在进行的请求、无法匹配的钥匙串凭据以及此代理之外的流量不受此保护。", "codexAuth.mainHardLockConfirm": "开启保护", - "codexAuth.mainHardLockEnabled": "99% 保护已开启。", - "codexAuth.mainHardLockDisabled": "99% 保护已关闭,其他账户限制仍然适用。", + "codexAuth.mainHardLockEnabled": "98% 保护已开启。", + "codexAuth.mainHardLockDisabled": "98% 保护已关闭,其他账户限制仍然适用。", "codexAuth.mainHardLockLoadFailed": "无法加载此设置。请重试以确认当前状态。", "codexAuth.mainHardLockSaveFailed": "无法确认是否已保存。请重新加载设置后再试。", "codexAuth.mainHardLockRefreshFailed": "设置已保存,但无法刷新账户状态。请重试。", - "codexAuth.mainHardLockBlocked": "已被 99% 保护阻止", + "codexAuth.mainHardLockBlocked": "已被 98% 保护阻止", "codexAuth.mainHardLockUnknown": "保护已开启 · 用量未知", "codexAuth.mainHardLockMonitoring": "保护已开启 · 监测中", "codexAuth.mainHardLockManage": "查看保护设置", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index afef69d8bb..638a61a7a6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1047,6 +1047,7 @@ "loopback-listener-integration.test.ts": "server", "macos-serial-lanes.test.ts": "ci-workflows", "main-account-hard-lock-auth.test.ts": "codex-integration", + "main-account-hard-lock-default.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", "main-account-hard-lock-recovery.test.ts": "codex-integration", "main-device-reauth-api.test.ts": "codex-integration", diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 06971c714d..7fa3288b1b 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -67,7 +67,12 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { extractAccountId, extractEmail } from "../oauth/chatgpt"; -import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock"; +import { + MAIN_ACCOUNT_HARD_LOCK_PERCENT, + getMainAccountHardLockStatus, + isMainAccountHardLockEnabled, + isMainAccountHardLocked, +} from "./main-account-hard-lock"; import { captureMainAccountIdentityGeneration, captureMainQuotaWriter, @@ -508,7 +513,7 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError { super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0); this.name = "CodexMainAccountHardLockError"; this.resetAt = resetAt; - this.message = "Codex main account is blocked by the 99% main-account quota policy." + this.message = `Codex main account is blocked by the ${MAIN_ACCOUNT_HARD_LOCK_PERCENT}% main-account quota policy.` + " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings."; } } @@ -957,7 +962,7 @@ export async function resolveCodexAuthContext( const requestOwnedMainPinCandidate = mainPinState().candidate; // During an owned startup, equality cannot be established until recovery and the // memory-only policy binding finish. This read-only fence never probes a foreign home. - if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) { + if (isMainAccountHardLockEnabled(policy) && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) { throw new CodexMainProfileDrainingError(); } const preserveRequestOwnedMainPin = () => mainPinState().preserve; @@ -970,7 +975,7 @@ export async function resolveCodexAuthContext( // Trusted substitution still has to claim and validate stored main below. if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); if (!substituteStoredMain) { - if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) { + if (isMainAccountHardLockEnabled(policy) && isMainAccountPolicyBindingPending()) { throw new CodexMainProfileDrainingError(); } if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); @@ -1014,7 +1019,7 @@ export async function resolveCodexAuthContext( ) { throw new CodexMainProfileDrainingError(); } - if (policy.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); + if (isMainAccountHardLockEnabled(policy)) reconcileMainCodexAccountRuntimeState(); assertMainAccountPolicy(policy); if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = entitledCodexAccountIdsForModel( @@ -1542,7 +1547,11 @@ export function materializeCodexUpstreamAuth( if (accountId) selected.set("chatgpt-account-id", accountId); } if (ctx.kind === "main" && options.substituteMainCredential === true) { - if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); + // No config object is "no policy input", not the persisted opt-out: only a config that + // actually says `false` disables the default-on lock (#5694). + if (options.config !== undefined && isMainAccountHardLockEnabled(options.config)) { + reconcileMainCodexAccountRuntimeState(); + } const writer = captureObservedMainWriter(); const stored = getMainAccountToken(); // Fail BEFORE any upstream I/O. Falling through here would send the admission secret. @@ -1620,7 +1629,9 @@ export async function materializeCodexUpstreamAuthAsync( const value = headers.get(name); if (value) selected.set(name, value); } - if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); + if (options.config !== undefined && isMainAccountHardLockEnabled(options.config)) { + reconcileMainCodexAccountRuntimeState(); + } const writer = captureObservedMainWriter(); const stored = await getValidMainAccountToken({ signal: options.signal, diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts index 7c1315a0e3..ced8a2c052 100644 --- a/src/codex/main-account-hard-lock.ts +++ b/src/codex/main-account-hard-lock.ts @@ -13,6 +13,19 @@ export interface MainAccountHardLockStatus { type PolicyConfig = Pick; +/** + * Whether the main-account hard lock applies to this config (#5694). + * + * Absent key or `true` means on; only the persisted `false` opt-out turns it off. The + * `undefined` branch is the trap this resolver cannot close on its own: no config object is + * "the caller supplied no policy", not "the operator opted out", so a call site holding an + * optional config must test for one before asking. Sites that hold a config always (a loaded + * config, a resolved policy) call this directly. + */ +export function isMainAccountHardLockEnabled(config: PolicyConfig | undefined): boolean { + return config?.codexMainAccountHardLock !== false; +} + function resetTimestamp(value: number | undefined): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; return value < 10_000_000_000 ? value * 1000 : value; @@ -23,7 +36,7 @@ export function getMainAccountHardLockStatus( config: PolicyConfig, now = Date.now(), ): MainAccountHardLockStatus { - if (config.codexMainAccountHardLock !== true) return { enabled: false, state: "off" }; + if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off" }; const quota = getMainPolicyQuota(); if (!quota) return { enabled: true, state: "unknown" }; // Account window priority is deliberate: a 5h account uses that window, even if diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 1d14037af1..911dc38a0f 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -1,6 +1,7 @@ import { NativeProfileManager } from "./native-profile-manager"; import { loadConfig } from "../config"; import { initializeMainAccountPolicyBinding } from "./account-lifecycle"; +import { isMainAccountHardLockEnabled } from "./main-account-hard-lock"; import { clearAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { @@ -188,7 +189,7 @@ function scheduleStageSweep(entry: StartupEntry): void { || entry.epoch !== sweepEpoch || entry.policyBindingPending) return; if (!safe) snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" }; else if (snapshot.homeId === entry.homeId && snapshot.status === "blocked" && snapshot.reason === "stage-cleanup-required") { - if (loadConfig().codexMainAccountHardLock === true) rearmOwnedMainPolicyBinding(entry); + if (isMainAccountHardLockEnabled(loadConfig())) rearmOwnedMainPolicyBinding(entry); else snapshot = ready(entry.homeId); } })().finally(() => { @@ -225,7 +226,7 @@ function convergeOwnedStartup(entry: StartupEntry): void { )); const stageSweepSafe = recoveryState === "none" ? await runOwnedStageSweep(entry) : false; if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none" && stageSweepSafe) { - if (loadConfig().codexMainAccountHardLock === true) { + if (isMainAccountHardLockEnabled(loadConfig())) { await withNativeMainOwnerOperation(entry.manager.context, () => withNativeMainExclusiveClaim( entry.manager.context, async () => { @@ -236,7 +237,7 @@ function convergeOwnedStartup(entry: StartupEntry): void { } // The HMAC is deliberately not persisted. Bind only the pinned owned home, // after recovery/cleanup, and before caller-owned admission can observe ready. - if (loadConfig().codexMainAccountHardLock === true) { + if (isMainAccountHardLockEnabled(loadConfig())) { initializeMainAccountPolicyBinding(entry.manager.context.authPath); } clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -386,7 +387,7 @@ export function startNativeMainStartupLifecycle( entry.unsubscribe = owner.subscribe(ownerState => observeOwner(entry!, ownerState)); } else if (!entry.policyBindingPending && snapshot.status === "ready" && snapshot.homeId === homeId - && loadConfig().codexMainAccountHardLock === true) { + && isMainAccountHardLockEnabled(loadConfig())) { // A new same-process listener can enable protection or follow a credential replacement. // Re-read its pinned home through the held owner before admitting caller-owned main. rearmOwnedMainPolicyBinding(entry); @@ -705,7 +706,7 @@ export function blockNativeMainRecovery( export function completeNativeMainRecovery(homeId: string): boolean { if (snapshot.status !== "blocked" || snapshot.homeId !== homeId) return false; const entry = startupEntries.get(homeId); - if (entry && loadConfig().codexMainAccountHardLock === true) return rearmOwnedMainPolicyBinding(entry); + if (entry && isMainAccountHardLockEnabled(loadConfig())) return rearmOwnedMainPolicyBinding(entry); epoch += 1; clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); snapshot = ready(homeId); diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index ca7574bc6b..c5e8946d80 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -1,5 +1,12 @@ /** Quota wire/storage shapes. This leaf must not import credential or config owners. */ -export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99; +/** + * Observed usage at which new identity-matched main-account requests are refused (#5694). + * + * The policy is on by default, so this constant is what every installation without an explicit + * `codexMainAccountHardLock: false` admits against. 98 leaves one point of headroom under the + * exhausted reading Codex Desktop already treats as its own disabled send button. + */ +export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98; /** * How recently a 100% burst reading must have been observed to exclude an account when it diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index eb2c2c9182..5c67a8c336 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -149,7 +149,9 @@ export const configSchema = z.object({ // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand // edit turns the tier off rather than rejecting the config that carries it. ultraFastTier: z.boolean().optional().catch(false), - codexMainAccountHardLock: z.boolean().optional().catch(false), + // Default-on policy (#5694): absence and malformed hand edits both mean "on", and only an + // explicit `false` written by the settings PUT opts out. + codexMainAccountHardLock: z.boolean().optional().catch(undefined), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index b46c024661..982db67cb6 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -59,7 +59,10 @@ import { codexQuotaAutoRefreshStatus, runCodexQuotaAutoRefresh, } from "../../codex/quota-auto-refresh"; -import { getMainAccountHardLockStatus } from "../../codex/main-account-hard-lock"; +import { + getMainAccountHardLockStatus, + isMainAccountHardLockEnabled, +} from "../../codex/main-account-hard-lock"; import { codexAccountPickerEnabled, initializeDefaultCodexAccountNamespaces, @@ -348,7 +351,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index ba8ce6a555..47b40376d2 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -288,18 +288,38 @@ Regression coverage lives in `tests/codex-integration/codex-quota-parser-parity. `tests/codex-integration/main-account-hard-lock-policy.test.ts`, `tests/usage/quota-reset-observation.test.ts`, and `tests/usage/quota-reset-seen-store.test.ts`. -`codexMainAccountHardLock` is a separate opt-in local admission policy, off by default. -It blocks newly admitted identity-matched main-account requests at 99% of the 5h/short window -when present, otherwise the weekly window (monthly for monthly-only accounts). It does not take -the maximum across those windows. Pool alternatives remain eligible; explicit main selection and stored Direct -substitution do not override it. It neither pauses the account nor clears upstream cooldown/reauth -state, and management quota refresh remains available. Only a fresh valid reading below 99%, including -0%, releases a measured block; passing a reset timestamp alone does not. While blocked, the existing -once-per-minute background sweep refreshes owned main usage, with bounded/coalesced reads and no -inference or reset-credit consumption. Failed, missing, non-finite or out-of-range readings do not -release the block. Policy validation precedes legacy clamping. Supplementary monthly data cannot -become the fallback governing window without a monthly-only plan or explicit primary-monthly evidence. -Previously unobserved usage is unknown, not fabricated headroom. +`codexMainAccountHardLock` is a local admission policy that is **on by default** since #5694, at +`MAIN_ACCOUNT_HARD_LOCK_PERCENT` = 98% of the 5h/short window when present, otherwise the weekly +window (monthly for monthly-only accounts). It does not take the maximum across those windows. +It blocks newly admitted identity-matched main-account requests. Pool alternatives remain eligible; +explicit main selection and stored Direct substitution do not override it. It neither pauses the +account nor clears upstream cooldown/reauth state, and management quota refresh remains available. +Only a fresh valid reading below 98%, including 0%, releases a measured block; passing a reset +timestamp alone does not. While blocked, the existing once-per-minute background sweep refreshes +owned main usage, with bounded/coalesced reads and no inference or reset-credit consumption. Failed, +missing, non-finite or out-of-range readings do not release the block. Policy validation precedes +legacy clamping. Supplementary monthly data cannot become the fallback governing window without a +monthly-only plan or explicit primary-monthly evidence. Previously unobserved usage is unknown, not +fabricated headroom. + +`isMainAccountHardLockEnabled` is the single resolver: an absent key and `true` both enable the +policy, and only an explicit `false` opts out. The settings PUT stores that `false` rather than +deleting the key, and writing `true` deletes it, so the stored shape cannot disagree with the +projection the dashboard renders. Two consequences are deliberate: an opt-out written before #5694 +deleted the key and therefore now reads as on, and `src/config/schema/config-schema.ts` degrades a +malformed value to `undefined`, which is also on, so a hand-edit typo cannot silently disable the +policy. + +The trade-off is admission, not accounting. The main account's Luna Reserve needs an exhausted +ordinary window to activate, so while the lock is blocking Reserve cannot engage; an operator who +wants Reserve turns the setting off rather than deleting the key. This is not a reservation of the +last 2%: already-admitted, parallel, unmatched-keyring, or direct upstream traffic can still reach +exhaustion. Settings and the main-account DTO report enabled state separately from the current +`off`, `unknown`, `ready`, or `blocked` status. Status semantics stay in +`tests/codex-integration/main-account-hard-lock-policy.test.ts`; the default-on resolver, the 98% +boundary, the admission consequence, and the settings opt-out round trip are covered by the +hard-lock tests registered in `scripts/test-layout/layout.json`, including +`tests/config/settings-main-account-hard-lock.test.ts`. A single fresh valid WHAM response with an explicitly long primary window can replace an obsolete short-window tuple when secondary and tertiary windows are explicitly null or also explicitly long with a valid usage reading. @@ -308,7 +328,7 @@ qualifies, not only a seven-day or monthly window. The policy trusts that one re it does not require repeated observations or independently confirm upstream window completeness. Omitted secondary/tertiary fields, a long auxiliary window without a usage reading, an unknown primary duration, partial headers, or invalid usage cannot prove that the short window disappeared. Replacement proof belongs only to that observation and is never persisted; -the resulting weekly/monthly window still blocks at 99%. This prevents old short-window exhaustion +the resulting weekly/monthly window still blocks at 98%. This prevents old short-window exhaustion from surviving indefinitely on a now weekly/monthly account. Coverage lives in `tests/codex-integration/main-quota-evidence-validation.test.ts`, `tests/codex-integration/main-quota-provenance.test.ts`, and @@ -321,22 +341,18 @@ workspace already observed under native ownership; an unrelated or unmatched key is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. -When protection is enabled, owned startup rebuilds this binding from its pinned auth path under -the native owner and exclusive claim, after journal recovery and stage cleanup, before publishing -ready. Caller-owned Direct, exact-main, fallback, and main-pin admission stays temporarily fenced -during that initialization; stored Pool alternatives remain eligible. Foreign/unknown service-home -paths neither initialize the binding nor trigger an ownership reprobe from caller-owned admission. -A new listener with protection enabled rearms the same guarded path on an existing ready lifecycle, -including when the physical credential was replaced after the earlier listener started. -Failed initialization creates no new binding. A previously verified same-process binding and its -safety state remain until a valid replacement observation or confirmed account transition; malformed -or conflicting input alone is not replacement evidence. - -This is not a reservation of the last 1%: already-admitted, parallel, unmatched-keyring, or direct -upstream traffic can still reach exhaustion. While blocked, main cannot use Luna reserve either. -Keeping ordinary usage below exhaustion may prevent Reserve activation; the policy never changes -OpenAI's Reserve grants or `ordinary_usage_allowed` response. Settings and the main-account DTO -report enabled state separately from current `off`, `unknown`, `ready`, or `blocked` status. +Owned startup rebuilds this binding from its pinned auth path under the native owner and exclusive +claim, after journal recovery and stage cleanup, before publishing ready. That work now runs for +every owned startup instead of only for an explicit opt-in, because the policy is on by default. +Caller-owned Direct, exact-main, fallback, and main-pin admission stays temporarily fenced during +that initialization; stored Pool alternatives remain eligible. Foreign/unknown service-home paths +neither initialize the binding nor trigger an ownership reprobe from caller-owned admission. A new +listener, and a completed manual recovery, rearms the same guarded path on an existing ready +lifecycle, including when the physical credential was replaced after the earlier listener started. +Failed initialization creates no new binding and does not withhold readiness: an absent, malformed, +or identity-conflicting pinned credential leaves the gate ready with no policy binding. A previously +verified same-process binding and its safety state remain until a valid replacement observation or +confirmed account transition; malformed or conflicting input alone is not replacement evidence. `codexAccountPriorities` is a persisted Pool *ordering* boundary and never an eligibility one. It maps an account id to an integer from -100 to 100, higher used earlier, with absence meaning 0. Selection diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts index 1519d49c68..877a0877a9 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -205,13 +205,13 @@ describe("main quota policy at native admission", () => { }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); - test.each([...(["owned-99", "owned-98", "foreign", "unknown", "recovery", "second-listener", + test.each([...(["owned-97", "owned-98", "owned-99", "foreign", "unknown", "recovery", "second-listener", "invalid-access-token", "invalid-account-id", "invalid-id-token", "mismatched-identity", "renewed-listener", "stage-retry", "manual-recovery", "stale-sweep", "retained-unknown-binding", "conflicting-token-identities", "conflicting-claims", "owned-opaque-99"] as const) .map(scenario => [scenario, "global-zero"] as const), ["owned-99", "account-zero"] as const, - ["owned-98", "account-zero"] as const, + ["owned-97", "account-zero"] as const, ["recovery", "account-zero"] as const])( "fresh startup restores durable main policy only after owned recovery (%s, %s)", (scenario, thresholdMode) => { const restoredId = scenario === "recovery" ? "hard-lock-recovered-main" : accountId; @@ -221,7 +221,10 @@ describe("main quota policy at native admission", () => { "https://api.openai.com/auth": { chatgpt_account_id: scenario === "conflicting-token-identities" ? "hard-lock-conflicting-access-account" : scenario === "conflicting-claims" ? "hard-lock-conflicting-claim-account" : restoredId } })).toString("base64url")}.signature`; - const quota = { weeklyPercent: scenario === "owned-98" ? 98 : 99, updatedAt: Date.now() - 7 * 60 * 60_000 }; + // 97 is the admitted side of the 98% default lock; 98 and 99 pin the boundary itself. + const belowHardLock = scenario === "owned-97"; + const quota = { weeklyPercent: scenario === "owned-97" ? 97 : scenario === "owned-98" ? 98 : 99, + updatedAt: Date.now() - 7 * 60 * 60_000 }; const identityKey = createHash("sha256").update("opencodex-main-quota-v1\0").update(restoredId).digest("hex"); if (scenario.startsWith("invalid-") || scenario === "mismatched-identity") { writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { @@ -285,9 +288,9 @@ describe("main quota policy at native admission", () => { expect(result.primaryUpstreamCalls).toBe(1); } else { expect(result.after).toMatchObject({ matched: true, policy: quota }); - expect(result.response.status).toBe(scenario === "owned-98" ? 200 : 429); - expect(result.primaryUpstreamCalls).toBe(scenario === "owned-98" ? 1 : 0); - if (scenario !== "owned-98") expect(result.response.hardLockError).toBe(true); + expect(result.response.status).toBe(belowHardLock ? 200 : 429); + expect(result.primaryUpstreamCalls).toBe(belowHardLock ? 1 : 0); + if (!belowHardLock) expect(result.response.hardLockError).toBe(true); } if (scenario === "recovery") { expect(result.heldRecovery.observation).toMatchObject({ matched: false, policy: null, tokenReads: 0 }); @@ -356,7 +359,7 @@ describe("main quota policy at native admission", () => { const cfg = accountZeroConfig(); addAlternative(cfg); setAccountQuotaFromParsed("hard-lock-pool", { weeklyPercent: 1, shortPercent: 1 }); - setAccountQuotaFromParsed(MAIN, { weeklyPercent: 98.99, shortPercent: 98.99 }, undefined, + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 97.99, shortPercent: 97.99 }, undefined, captureMainQuotaWriter(accountId)); // Above global 95: ignoring the explicit zero would proactively leave main here. await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) @@ -394,7 +397,7 @@ describe("main quota policy at native admission", () => { test("per-account zero cannot bypass hard-lock when selected main headers materialize", async () => { const cfg = accountZeroConfig(); - quota(98.99); + quota(97.99); const context = await resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN }); expect(context.kind).toBe("main-pool"); quota(99); @@ -450,7 +453,7 @@ describe("main quota policy at native admission", () => { })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); }); - for (const percent of [98.99, 99]) { + for (const percent of [97.99, 99]) { test(`Pool cooldown caller fallback keeps the main ${percent}% policy boundary`, async () => { const cfg = config(); addAlternative(cfg); @@ -468,7 +471,7 @@ describe("main quota policy at native admission", () => { const context = resolveCodexAuthContext(caller(), cfg, "pool", { requestScopedMainCredential: true, modelId: "gpt-5.6-terra", }); - if (percent < 99) { + if (percent < 98) { await expect(context).resolves.toMatchObject({ kind: "main", accountId: null }); } else { await expect(context).rejects.toBeInstanceOf(CodexMainAccountHardLockError); @@ -492,7 +495,7 @@ describe("main quota policy at native admission", () => { test("selection writer survives to headers; live quota and toggle are checked at materialization", async () => { const cfg = config(); - quota(98.99); + quota(97.99); const ctx = await resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN }); expect(ctx.kind).toBe("main-pool"); if (ctx.kind !== "main-pool") throw new Error("expected stored main context"); @@ -503,12 +506,12 @@ describe("main quota policy at native admission", () => { expect(headersForCodexAuthContext(new Headers(), ctx, cfg).get("authorization")).toBe(`Bearer ${bearer()}`); cfg.codexMainAccountHardLock = true; expect(() => headersForCodexAuthContext(new Headers(), ctx, cfg)).toThrow(CodexMainAccountHardLockError); - quota(98.99); + quota(97.99); expect(() => headersForCodexAuthContext(new Headers(), ctx, cfg)).not.toThrow(); }); test("quota changing while selected main refresh awaits rejects without quarantining it", async () => { - quota(98.99); + quota(97.99); await expect(resolveCodexAuthContext(new Headers(), config(), "pool", { accountId: MAIN, getValidMainAccountToken: async () => { @@ -523,7 +526,7 @@ describe("main quota policy at native admission", () => { test("actual Direct substitution rechecks after awaited native refresh", async () => { writeMain(bearer(true)); - quota(98.99); + quota(97.99); const cfg = config(); cfg.codexMainAccountHardLock = false; await expect(materializeCodexUpstreamAuthAsync(caller("proxy-admission"), { kind: "main", accountId: null }, { @@ -643,7 +646,7 @@ describe("main quota policy at native admission", () => { }); test("stale writer is retained for rejection rather than converted into an untrusted write", async () => { - quota(98.99); + quota(97.99); const ctx = await resolveCodexAuthContext(new Headers(), config(), "pool", { accountId: MAIN }); if (ctx.kind !== "main-pool") throw new Error("expected stored main context"); const writer = ctx.mainQuotaWriter; diff --git a/tests/codex-integration/main-account-hard-lock-default.test.ts b/tests/codex-integration/main-account-hard-lock-default.test.ts new file mode 100644 index 0000000000..346cbca191 --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-default.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { + MAIN_ACCOUNT_HARD_LOCK_PERCENT, + getMainAccountHardLockStatus, + isMainAccountHardLockEnabled, + isMainAccountHardLocked, +} from "../../src/codex/main-account-hard-lock"; +import { + captureMainQuotaWriter, + clearMainAccountInfoCache, + observeMainQuotaIdentity, +} from "../../src/codex/main-account-cache"; +import { clearAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { codexAccountUnusableReason, isCodexAccountUsable } from "../../src/codex/account-usability"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { handleManagementAPI, type ManagementApiDeps } from "../../src/server/management-api"; +import { invalidateStartupHealthCache } from "../../src/server/startup-health-cache"; +import type { OcxConfig } from "../../src/types"; +import { startupHealthFixture } from "../helpers/startup-health"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ACCOUNT = "hard-lock-default-fixture"; +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; + +const config = (): OcxConfig => ({ + port: 10100, + defaultProvider: "example", + providers: { example: { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "fixture" } }, +}); + +function request(cfg: OcxConfig, body?: unknown) { + const req = new Request("http://127.0.0.1:10100/api/settings", { + method: body === undefined ? "GET" : "PUT", + headers: { host: "127.0.0.1:10100", "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return handleManagementAPI(req, new URL(req.url), cfg, { + getCachedStartupHealth: async () => startupHealthFixture(), + } satisfies Partial); +} + +function observe(quota: { weeklyPercent?: number; shortPercent?: number }): void { + const writer = captureMainQuotaWriter(ACCOUNT); + if (!writer) throw new Error("fixture identity was not observed"); + setAccountQuotaFromParsed("__main__", quota, undefined, writer); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-hard-lock-default-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + invalidateStartupHealthCache(); + clearAccountQuota(); + clearMainAccountInfoCache(); + observeMainQuotaIdentity(ACCOUNT); +}); + +afterEach(() => { + invalidateStartupHealthCache(); + clearAccountQuota(); + clearMainAccountInfoCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +describe("main-account hard lock default (#5694)", () => { + test("the threshold is 98 percent", () => { + expect(MAIN_ACCOUNT_HARD_LOCK_PERCENT).toBe(98); + }); + + test("an absent key or true enables the policy; only false opts out", () => { + expect(isMainAccountHardLockEnabled({})).toBe(true); + expect(isMainAccountHardLockEnabled({ codexMainAccountHardLock: undefined })).toBe(true); + expect(isMainAccountHardLockEnabled({ codexMainAccountHardLock: true })).toBe(true); + expect(isMainAccountHardLockEnabled({ codexMainAccountHardLock: false })).toBe(false); + // Absent is not "no signal" for the status reader either: it reports the default as on. + expect(getMainAccountHardLockStatus({})).toEqual({ enabled: true, state: "unknown" }); + }); + + test("a malformed hand edit parses to the default instead of disabling the policy", () => { + const parsed = configSchema.parse({ + ...config(), + codexMainAccountHardLock: "yes", + }) as OcxConfig; + expect(parsed.codexMainAccountHardLock).toBeUndefined(); + expect(isMainAccountHardLockEnabled(parsed)).toBe(true); + // A well-formed false still survives the same parse. + expect(isMainAccountHardLockEnabled( + configSchema.parse({ ...config(), codexMainAccountHardLock: false }) as OcxConfig, + )).toBe(false); + }); + + test("97.9 percent is ready and 98 percent is blocked without any key present", () => { + observe({ weeklyPercent: 97.9 }); + expect(getMainAccountHardLockStatus({}).state).toBe("ready"); + observe({ shortPercent: 98 }); + expect(getMainAccountHardLockStatus({})).toEqual({ enabled: true, state: "blocked" }); + expect(isMainAccountHardLocked({})).toBe(true); + // The opt-out keeps the same observation admissible. + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: false })) + .toEqual({ enabled: false, state: "off" }); + }); + + test("GET reports the lock as on when no key is stored", async () => { + observe({ weeklyPercent: 98 }); + const response = await request(config()); + expect(await response!.json()).toMatchObject({ + codexMainAccountHardLock: true, + mainAccountHardLock: { enabled: true, state: "blocked" }, + }); + }); + + test("an absent key withholds the main account from admission", () => { + observe({ weeklyPercent: 98 }); + expect(codexAccountUnusableReason(config(), MAIN_CODEX_ACCOUNT_ID, { nativeMainSelectionOnly: true })) + .toBe("main_hard_locked"); + expect(isCodexAccountUsable(config(), MAIN_CODEX_ACCOUNT_ID, { nativeMainSelectionOnly: true })).toBe(false); + const optedOut = { ...config(), codexMainAccountHardLock: false }; + expect(codexAccountUnusableReason(optedOut, MAIN_CODEX_ACCOUNT_ID, { nativeMainSelectionOnly: true })) + .toBeUndefined(); + expect(isCodexAccountUsable(optedOut, MAIN_CODEX_ACCOUNT_ID, { nativeMainSelectionOnly: true })).toBe(true); + }); + + test("PUT false persists the opt-out and PUT true restores the default", async () => { + const cfg = config(); + saveConfig(cfg); + const disabled = await request(cfg, { codexMainAccountHardLock: false }); + expect(await disabled!.json()).toMatchObject({ ok: true, codexMainAccountHardLock: false }); + expect(cfg.codexMainAccountHardLock).toBe(false); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).codexMainAccountHardLock).toBe(false); + expect(loadConfig().codexMainAccountHardLock).toBe(false); + expect(isMainAccountHardLockEnabled(loadConfig())).toBe(false); + + const enabled = await request(cfg, { codexMainAccountHardLock: true }); + expect(await enabled!.json()).toMatchObject({ ok: true, codexMainAccountHardLock: true }); + expect(Object.hasOwn(cfg, "codexMainAccountHardLock")).toBe(false); + // On is the default, so it is stored as absence: a written key would be a decision nobody made. + expect(Object.hasOwn(JSON.parse(readFileSync(getConfigPath(), "utf8")), "codexMainAccountHardLock")).toBe(false); + expect(loadConfig().codexMainAccountHardLock).toBeUndefined(); + expect(isMainAccountHardLockEnabled(loadConfig())).toBe(true); + }); +}); diff --git a/tests/codex-integration/main-account-hard-lock-policy.test.ts b/tests/codex-integration/main-account-hard-lock-policy.test.ts index 9093445b8c..a178c1b768 100644 --- a/tests/codex-integration/main-account-hard-lock-policy.test.ts +++ b/tests/codex-integration/main-account-hard-lock-policy.test.ts @@ -36,10 +36,13 @@ function observe(quota: Omit): void { } describe("identity-bound main-account hard-lock policy", () => { - test("absent and disabled preserve admission even at 100", () => { + test("an explicit opt-out preserves admission even at 100", () => { observe({ weeklyPercent: 100 }); - expect(getMainAccountHardLockStatus({}, now)).toEqual({ enabled: false, state: "off" }); + // The key is default-on since #5694: absent means enabled, only `false` opts out. + expect(getMainAccountHardLockStatus({}, now)).toEqual({ enabled: true, state: "blocked" }); expect(isMainAccountHardLocked({ codexMainAccountHardLock: false }, now)).toBe(false); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: false }, now)) + .toEqual({ enabled: false, state: "off" }); }); test("unknown is not a fabricated empty or exhausted quota", () => { @@ -48,13 +51,13 @@ describe("identity-bound main-account hard-lock policy", () => { expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); }); - test.each([98.99, 99, 100])("raw %s percent is compared without GUI rounding", percent => { + test.each([97.99, 98, 100])("raw %s percent is compared without GUI rounding", percent => { observe({ weeklyPercent: percent }); - expect(getMainAccountHardLockStatus(enabled, now).state).toBe(percent < 99 ? "ready" : "blocked"); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe(percent < 98 ? "ready" : "blocked"); }); - test("a short-only 99 reading blocks despite the rotation scorer's unknown sentinel", () => { - observe({ shortPercent: 99 }); + test("a short-only 98 reading blocks despite the rotation scorer's unknown sentinel", () => { + observe({ shortPercent: 98 }); expect(isMainAccountHardLocked(enabled, now)).toBe(true); }); @@ -108,17 +111,17 @@ describe("identity-bound main-account hard-lock policy", () => { expect(isMainAccountHardLocked(enabled, now + 24 * 60 * 60_000)).toBe(true); }); - test.each(["shortPercent", "weeklyPercent"] as const)("%s resets to zero, unlocks, and rearms at 99 without disabling", field => { - observe({ [field]: 99 }); + test.each(["shortPercent", "weeklyPercent"] as const)("%s resets to zero, unlocks, and rearms at 98 without disabling", field => { + observe({ [field]: 98 }); expect(isMainAccountHardLocked(enabled, now)).toBe(true); observe({ [field]: 0 }); expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "ready" }); - observe({ [field]: 99 }); + observe({ [field]: 98 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); }); test("5h usage wins over a higher weekly window", () => { - observe({ shortPercent: 98, shortWindowSeconds: 18_000, weeklyPercent: 100 }); + observe({ shortPercent: 97, shortWindowSeconds: 18_000, weeklyPercent: 100 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); observe({ shortPercent: 99, shortWindowSeconds: 18_000, weeklyPercent: 20 }); expect(isMainAccountHardLocked(enabled, now)).toBe(true); @@ -137,7 +140,7 @@ describe("identity-bound main-account hard-lock policy", () => { }); test("weekly-only accounts do not use a higher monthly bar", () => { - observe({ weeklyPercent: 98, monthlyPercent: 100 }); + observe({ weeklyPercent: 97, monthlyPercent: 100 }); expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); }); diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index 6a45ba56c8..6b523b6aab 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -177,7 +177,7 @@ describe("main policy window replacement", () => { }); for (const [field, seconds] of [["weeklyPercent", weeklySeconds], ["monthlyPercent", monthlySeconds]] as const) { - test.each([0, 35, 98.99, 99, 100])(`fresh ${field}=%s replaces a retired persisted short window`, percent => { + test.each([0, 35, 97.99, 98, 99, 100])(`fresh ${field}=%s replaces a retired persisted short window`, percent => { retainedShort(); publish({ rate_limit: { primary_window: { used_percent: percent, limit_window_seconds: seconds }, secondary_window: null, tertiary_window: null, @@ -189,7 +189,7 @@ describe("main policy window replacement", () => { } // Replacement proof is per-observation, never a persisted permission to drop future evidence. expect(policy).not.toHaveProperty("shortWindowAbsent"); - expect(getMainAccountHardLockStatus(cfg).state).toBe(percent < 99 ? "ready" : "blocked"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(percent < 98 ? "ready" : "blocked"); publish({ rate_limit: { primary_window: { used_percent: 99, limit_window_seconds: 18_000 } } }); expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); @@ -345,10 +345,10 @@ describe("cold persisted policy percentage ranges", () => { }, ); - test.each([0, 98.99, 99, 100])(`${field}=%s survives disk hydration without clamping`, value => { + test.each([0, 97.99, 98, 99, 100])(`${field}=%s survives disk hydration without clamping`, value => { const disk = writeColdPolicy({ [field]: value }); expect(getMainPolicyQuota()).toEqual(disk); - expect(getMainAccountHardLockStatus(cfg).state).toBe(value < 99 ? "ready" : "blocked"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(value < 98 ? "ready" : "blocked"); }); } diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts index ddacc0010a..2383eff072 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -224,7 +224,7 @@ describe("declared short-window producer evidence", () => { } } - test.each([0, "0", 98.99, "98.99", 99, "99", 100, "100"])("owned WHAM and headers accept valid boundary %s", async value => { + test.each([0, "0", 97.99, "97.99", 98, "98", 99, "99", 100, "100"])("owned WHAM and headers accept valid boundary %s", async value => { const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; setIcaclsRunnerForTests(() => aclOk); setAsyncIcaclsRunnerForTests(async () => aclOk); @@ -240,12 +240,12 @@ describe("declared short-window producer evidence", () => { await fetchMainAccountInfo(true); const cfg = { codexMainAccountHardLock: true }; expect(getMainPolicyQuota()?.weeklyPercent).toBe(Number(value)); - expect(getMainAccountHardLockStatus(cfg).state).toBe(Number(value) < 99 ? "ready" : "blocked"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(Number(value) < 98 ? "ready" : "blocked"); clearAccountQuota(); applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ "x-codex-primary-used-percent": String(value) }), undefined, writerFor()); expect(getMainPolicyQuota()?.weeklyPercent).toBe(Number(value)); - expect(getMainAccountHardLockStatus(cfg).state).toBe(Number(value) < 99 ? "ready" : "blocked"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(Number(value) < 98 ? "ready" : "blocked"); expect(calls).toBe(1); }); diff --git a/tests/config/settings-main-account-hard-lock.test.ts b/tests/config/settings-main-account-hard-lock.test.ts index 3bc3225130..5f3ca54b1e 100644 --- a/tests/config/settings-main-account-hard-lock.test.ts +++ b/tests/config/settings-main-account-hard-lock.test.ts @@ -48,33 +48,41 @@ afterEach(() => { removeTreeWithRetry(home); }); -describe("main-account 99 percent setting", () => { - test("GET reports off without an implicit opt-in", async () => { +describe("main-account 98 percent setting", () => { + test("GET reports the default-on lock without a stored key", async () => { const response = await request(config()); expect(await response!.json()).toMatchObject({ - codexMainAccountHardLock: false, - mainAccountHardLock: { enabled: false, state: "off" }, + codexMainAccountHardLock: true, + // The observation state depends on quota this file never seeds; the enabling decision + // is the projection under test. + mainAccountHardLock: { enabled: true }, }); }); - test("PUT acknowledges the stored boolean and survives reload", async () => { + test("PUT true removes the stored key and the default survives reload", async () => { const cfg = config(); + cfg.codexMainAccountHardLock = false; saveConfig(cfg); const response = await request(cfg, { codexMainAccountHardLock: true }); expect(response!.status).toBe(200); expect(await response!.json()).toMatchObject({ ok: true, codexMainAccountHardLock: true }); - expect(loadConfig().codexMainAccountHardLock).toBe(true); + expect(Object.hasOwn(cfg, "codexMainAccountHardLock")).toBe(false); + expect(Object.hasOwn(JSON.parse(readFileSync(getConfigPath(), "utf8")), "codexMainAccountHardLock")).toBe(false); + // The projection cannot distinguish an absent key from an explicit true: both are on. + expect(loadConfig().codexMainAccountHardLock).toBeUndefined(); expect(cfg.providers.example.baseUrl).toBe("https://example.test/v1"); }); - test("disabling deletes only this key and preserves other account controls", async () => { + test("opting out persists false and preserves other account controls", async () => { const cfg = { ...config(), codexMainAccountHardLock: true, pausedCodexAccountIds: ["__main__"], autoSwitchThreshold: 73 }; saveConfig(cfg); const response = await request(cfg, { codexMainAccountHardLock: false }); expect(await response!.json()).toMatchObject({ ok: true, codexMainAccountHardLock: false }); - expect(Object.hasOwn(cfg, "codexMainAccountHardLock")).toBe(false); + // Off is the decision, so it is written rather than deleted: a deleted key reads as on. + expect(cfg.codexMainAccountHardLock).toBe(false); const disk = JSON.parse(readFileSync(getConfigPath(), "utf8")); - expect(Object.hasOwn(disk, "codexMainAccountHardLock")).toBe(false); + expect(disk.codexMainAccountHardLock).toBe(false); + expect(loadConfig().codexMainAccountHardLock).toBe(false); expect(cfg.pausedCodexAccountIds).toEqual(["__main__"]); expect(cfg.autoSwitchThreshold).toBe(73); }); @@ -98,11 +106,14 @@ describe("main-account 99 percent setting", () => { } }); - test("malformed hand edits remain off", () => { + test("malformed hand edits fall back to the default on", async () => { saveConfig(config()); const path = getConfigPath(); const disk = JSON.parse(readFileSync(path, "utf8")); writeFileSync(path, JSON.stringify({ ...disk, codexMainAccountHardLock: "yes" })); - expect(loadConfig().codexMainAccountHardLock).toBe(false); + const loaded = loadConfig(); + expect(loaded.codexMainAccountHardLock).toBeUndefined(); + const response = await request(loaded); + expect(await response!.json()).toMatchObject({ codexMainAccountHardLock: true }); }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e4df773c9e..b51f38f16d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -874,6 +874,7 @@ "loopback-listener-integration.test.ts": "server", "macos-serial-lanes.test.ts": "ci-workflows", "main-account-hard-lock-auth.test.ts": "codex-integration", + "main-account-hard-lock-default.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", "main-account-hard-lock-recovery.test.ts": "codex-integration", "main-device-reauth-api.test.ts": "codex-integration", diff --git a/tests/helpers/main-account-policy-startup-child.ts b/tests/helpers/main-account-policy-startup-child.ts index b50f7bdb9f..bf309cffd7 100644 --- a/tests/helpers/main-account-policy-startup-child.ts +++ b/tests/helpers/main-account-policy-startup-child.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; interface Fixture { - scenario: "owned-99" | "owned-98" | "foreign" | "unknown" | "recovery" | "second-listener" + scenario: "owned-97" | "owned-98" | "owned-99" | "foreign" | "unknown" | "recovery" | "second-listener" | "invalid-access-token" | "invalid-account-id" | "invalid-id-token" | "mismatched-identity" | "renewed-listener" | "stage-retry" | "manual-recovery" | "stale-sweep" | "retained-unknown-binding" | "conflicting-token-identities" | "conflicting-claims" | "owned-opaque-99"; From 1db2a04fd97471bd93b0c67a202a8cbb88d63378 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:47:42 +0900 Subject: [PATCH 10/14] docs(devlog): record L4 publish plan --- devlog/_plan/260924_l4_codex_cli_service/050_publish.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devlog/_plan/260924_l4_codex_cli_service/050_publish.md b/devlog/_plan/260924_l4_codex_cli_service/050_publish.md index e3126314d1..89e1484903 100644 --- a/devlog/_plan/260924_l4_codex_cli_service/050_publish.md +++ b/devlog/_plan/260924_l4_codex_cli_service/050_publish.md @@ -1,3 +1,9 @@ # 050 — publish Rebase on origin/dev; bun run typecheck; focused tests for every item; bun run test:changed; bun run privacy:scan; bun run structure:check; bun run lint:gui; push branch; gh pr create --base dev with template + Security review + Carries/Closes; watch automatic checks; fix failures on head. +## wp5 P (executable) +- origin/dev moved to df61bcecd5 (#5737, #5738). Overlapping files: docs-site reference/configuration/providers.md, scripts/test-layout/layout.json, structure/config.md, structure/subagents.md, tests/fixtures/test-layout-expected.json. Resolve by keeping both sides' entries. +- Before rebase: git mv devlog/_plan/260924_l4_codex_cli_service -> devlog/_fin/ is deferred until after merge (unit stays open while the PR is open). +- Validation after rebase: bun run typecheck; focused union of all item tests; bun run test:changed (worktree under /Users/jun/.codex trips the test-home guard for some temp-dir tests — compare any failure with a pristine git archive copy); bun run privacy:scan; bun run structure:check; bun run lint:gui. +- GUI screenshot: run the dashboard (bun run src/cli/index.ts start on a spare port with a throwaway OPENCODEX_HOME), open Codex settings > Multi-auth > Advanced, capture the 98% card to .tmp/. Hosting: pr-assets push only if the user authorizes; otherwise the coordinator uploads it. +- PR body: Summary per item with Carries/Closes lines, Security review for #5713, Verification with commands, Checklist, coordinator decisions (Reserve trade-off, previous opt-outs re-enabled, stale connect marker retention, 64 MiB indeterminate). From 0d69b33c9f2bbe0d05e803825bc0bf5c6e3ce7c6 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 18:09:03 +0900 Subject: [PATCH 11/14] test(gui): expect the 98% hard-lock blocked label The main-card blocked label follows the #5694 threshold change. --- gui/tests/main-account-hard-lock-setting.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/tests/main-account-hard-lock-setting.test.tsx b/gui/tests/main-account-hard-lock-setting.test.tsx index b895485073..a510f8ede2 100644 --- a/gui/tests/main-account-hard-lock-setting.test.tsx +++ b/gui/tests/main-account-hard-lock-setting.test.tsx @@ -275,7 +275,7 @@ function MainCard({ state }: { state: MainAccountHardLockStatus["state"] }) { autoSwitchDisabled={false} switchingId={null} onOpenReset={() => {}} />; } test.each([ - ["blocked", "Blocked by 99% protection", false], + ["blocked", "Blocked by 98% protection", false], ["unknown", "Protection on · usage unknown", true], ["ready", "Protection on · monitoring", true], ] as const)("main card uses server %s state, not rounded weekly usage", async (state, label, canSwitch) => { From 12802508e637a742dfa309b4fba48a71d884f3e2 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 18:28:32 +0900 Subject: [PATCH 12/14] fix(identity): drop the neutral catalog line on native destinations After #5221 the catalog stores the model-neutral identity line, so a native worker spawned from a routed parent received "Do not claim to be GPT-5 or to be made by OpenAI", contradicting Codex's own model_switch identity. The forward strip now removes the neutral line as well as a routed sentence; an instructions value that becomes empty is still removed. Co-authored-by: sbc1-code <207095575+sbc1-code@users.noreply.github.com> --- src/adapters/identity.ts | 12 +++++- tests/adapters/identity-subagent.test.ts | 49 ++++++++++++++++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index cb4486f0fa..299847b53f 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -226,9 +226,19 @@ export function repairIdentityInResponsesBody(body: unknown, repair: (text: stri * sentence instead of rewriting it. A native worker keeps Codex's own identity wording, which the * client already sends in its `model_switch` block; re-stating a routed sentence there would tell * a first-party model it is some third-party model. + * + * The model-neutral catalog line goes too. It is proxy-authored text as well, and unlike the named + * form it does not even have to be inherited from a routed parent: the on-disk catalog block + * carries it since #5217, so it rides along on every instruction block Codex replays to a worker. + * Left in place it reaches a first-party model as an instruction that contradicts the model_switch + * identity Codex sends for the same request. */ export function stripRoutedIdentity(text: string): string { - return text.replace(ROUTED_IDENTITY_RE, () => "").replace(/\n{3,}/g, "\n\n").trim(); + return text + .replace(ROUTED_IDENTITY_RE, () => "") + .replace(NEUTRAL_IDENTITY_RE, () => "") + .replace(/\n{3,}/g, "\n\n") + .trim(); } /** The catalog (static, on-disk) replacement for `base_instructions`. Same neutral wording. */ diff --git a/tests/adapters/identity-subagent.test.ts b/tests/adapters/identity-subagent.test.ts index 881c4175b8..a0eebba2f5 100644 --- a/tests/adapters/identity-subagent.test.ts +++ b/tests/adapters/identity-subagent.test.ts @@ -34,17 +34,30 @@ function textOf(content: unknown): string { } /** The Responses passthrough forwards `_rawBody` mostly verbatim, so identity repair is its own step. */ -function passthroughBody(provider: OcxProviderConfig, instructions: string): Record { +function passthroughBody( + provider: OcxProviderConfig, + instructions: string, + input: unknown = "ping", +): Record { const request = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest({ modelId: WORKER_MODEL, context: { messages: [] }, stream: true, options: {}, - _rawBody: { model: WORKER_MODEL, instructions, input: "ping" }, + _rawBody: { model: WORKER_MODEL, instructions, input }, }, { headers: new Headers() }); return JSON.parse(request.body) as Record; } +/** A first-party destination: Codex's own model_switch identity is authoritative there. */ +function forwardProvider(): OcxProviderConfig { + return { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + } as unknown as OcxProviderConfig; +} + describe("sub-agent identity inheritance (#5217)", () => { test("a stale routed identity sentence is rewritten to the destination model", () => { const out = repairRoutedIdentity(`${PARENT_IDENTITY}\n\nUse tools carefully.`, WORKER_MODEL); @@ -75,6 +88,15 @@ describe("sub-agent identity inheritance (#5217)", () => { expect(out).toBe("You and the user share one workspace."); }); + test("a native destination drops the model-neutral catalog line too", () => { + // Since #5217 the on-disk catalog block carries this line, so it reaches a native worker with + // no routed parent involved — and there it contradicts the identity Codex sends itself. + const out = stripRoutedIdentity(`${NEUTRAL_IDENTITY_LINE}\n\nYou and the user share one workspace.`); + expect(out).not.toContain("Do not claim to be GPT-5"); + expect(out).not.toContain(NEUTRAL_IDENTITY_LINE); + expect(out).toBe("You and the user share one workspace."); + }); + test("text the proxy did not generate is never rewritten", () => { for (const text of [ "The user asked: which model are you?", @@ -198,14 +220,27 @@ describe("sub-agent identity inheritance (#5217)", () => { }); test("the Responses passthrough drops our sentence on a forward destination", () => { - const provider = { - adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - authMode: "forward", - } as unknown as OcxProviderConfig; + const provider = forwardProvider(); // Codex's own identity wording is the correct one at a first-party destination, and an empty // instruction string is a different payload from an absent key. expect(passthroughBody(provider, `${PARENT_IDENTITY}\n\nKeep this.`).instructions).toBe("Keep this."); expect(passthroughBody(provider, PARENT_IDENTITY)).not.toHaveProperty("instructions"); }); + + test("the Responses passthrough drops the neutral line from instructions and developer items", () => { + const body = passthroughBody(forwardProvider(), `${NEUTRAL_IDENTITY_LINE}\n\nKeep this.`, [ + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: `${NEUTRAL_IDENTITY_LINE}\n\nAlso keep this.` }], + }, + ]); + expect(body.instructions).toBe("Keep this."); + const item = (body.input as { content: { text: string }[] }[])[0]!; + expect(item.content[0]!.text).toBe("Also keep this."); + }); + + test("the Responses passthrough drops an instructions value that was only the neutral line", () => { + expect(passthroughBody(forwardProvider(), NEUTRAL_IDENTITY_LINE)).not.toHaveProperty("instructions"); + }); }); From 60d7b6d2e8a5ac964d06f9987389aae95c3a4eab Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 19:55:51 +0900 Subject: [PATCH 13/14] fix(gui): use the Turkish locative -de after %98 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninety-eight ends in a front vowel, so the hard-lock title reads %98’de. --- gui/src/i18n/tr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ea0d6403e6..1ce4c8832b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2118,7 +2118,7 @@ export const tr: Record = { "codexAuth.add": "Ekle", "codexAuth.refreshQuota": "Kotaları yenile", "codexAuth.ultraFastTitle": "Ultra Fast hizmet katmanı", - "codexAuth.mainHardLockTitle": "Ana hesabı %98’da durdur", + "codexAuth.mainHardLockTitle": "Ana hesabı %98’de durdur", "codexAuth.mainHardLockDesc": "Varsa 5 saatlik, yoksa haftalık kullanım esas alınır (yalnızca aylık hesaplarda aylık kullanım). Yeni %0 ölçümü engeli otomatik kaldırır; koruma açık kalır. Varsayılan olarak açık.", "codexAuth.mainHardLockConfirmTitle": "Ana hesap için %98 koruması açılsın mı?", "codexAuth.mainHardLockConfirmBody": "Engel sürerken ana hesabın Luna Reserve erişimi de kullanılamaz. Normal kotanın tükenmemesi Reserve’in etkinleşmesini önleyebilir. Ek hesaplar ve diğer sağlayıcılar kullanılmaya devam eder. Çalışan istekler, eşleştirilemeyen anahtarlık kimlik bilgileri ve bu proxy dışındaki trafik korunmaz.", From d2fd97561c4ff118d95075d1e741439aa8cfca7c Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 19:55:51 +0900 Subject: [PATCH 14/14] fix(identity): name the final wire model and system-role items Two review findings on the #5221 carry. The request parser names the identity sentence from the client selector, which can be an alias, a namespaced slug or a combo name, and adapters that never call identifyRoutedModel (devin, ollama-native, cursor, qoder, codebuddy, coding-agent) shipped that name. The final route normalization now renames our sentence to route.modelId, where the dispatched id is known and every dispatch path reads the context afterwards. The parser also names system-role input items, not only developer items. Co-authored-by: sbc1-code <207095575+sbc1-code@users.noreply.github.com> --- src/adapters/identity.ts | 50 ++++++++ src/responses/parser.ts | 5 +- src/server/responses/core-normalize.ts | 7 ++ structure/providers-and-adapters.md | 13 ++- tests/adapters/identity-subagent.test.ts | 139 ++++++++++++++++++++++- 5 files changed, 208 insertions(+), 6 deletions(-) diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index 299847b53f..5a4807b190 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -1,3 +1,5 @@ +import type { OcxContext } from "../types"; + /** * Central routed-model identity repair. * @@ -148,6 +150,54 @@ export function nameRoutedIdentity(text: string, modelName: string): string { return repairRoutedIdentity(text.replace(NEUTRAL_IDENTITY_RE, () => replacement), modelName); } +/** + * Settle this proxy's identity sentence on the model a routed request is actually dispatched to. + * + * The parser writes the CLIENT-selected id (an alias, a namespaced slug, a combo name) because + * routing has not run yet, and only some adapters rename the sentence afterwards: the ones that + * build their own system text and call `identifyRoutedModel`. Every other adapter — the native-wire + * ones and the `runTurn` ones — ships whatever the parser wrote, so a request routed anywhere else + * would hand the upstream an id it never sees, and an inherited sub-agent block the parent's id. + * + * The route owner knows the model id that will be sent, and this is the one place every dispatch + * path (passthrough, runTurn, request build) reads the context from. Text without a sentence of + * ours is returned unchanged, and the same by reference: the guard is a regex test, not a scan of + * every turn's text. + */ +export function renameRoutedIdentityInContext(context: OcxContext, wireModelId: string): OcxContext { + let changed = false; + const mapText = (text: string): string => { + if (!hasRoutedIdentity(text)) return text; + const next = repairRoutedIdentity(text, wireModelId); + if (next !== text) changed = true; + return next; + }; + const systemPrompt = context.systemPrompt?.map(mapText); + const messages = context.messages.map((message) => { + // Instruction text only. A user turn is the caller's own content, and every other role is + // either model output or tool output — none of it is ours to rewrite. + if (message.role !== "developer") return message; + const content = message.content; + if (typeof content === "string") { + const next = mapText(content); + return next === content ? message : { ...message, content: next }; + } + let partChanged = false; + const parts = content.map((part) => { + if (part.type !== "text" || !hasRoutedIdentity(part.text)) return part; + const next = repairRoutedIdentity(part.text, wireModelId); + if (next === part.text) return part; + partChanged = true; + return { ...part, text: next }; + }); + if (!partChanged) return message; + changed = true; + return { ...message, content: parts }; + }); + if (!changed) return context; + return { ...context, ...(systemPrompt ? { systemPrompt } : {}), messages }; +} + /** A content part carrying no text is not content; every other part shape stays. */ function isEmptyTextPart(part: unknown): boolean { if (!part || typeof part !== "object" || Array.isArray(part)) return false; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 4e7823dcd6..5b7aa40cfa 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -305,7 +305,10 @@ export function parseRequest( const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).join(""); - if (flat.length > 0) systemPrompt.push(flat); + // #5217: a system-role item is instruction text, exactly like `instructions` and a + // developer item, so it needs the same request-time naming — it lands in the system + // block verbatim, and Codex replays the parent's copy to a sub-agent on another model. + if (flat.length > 0) systemPrompt.push(nameDestinationText(flat, data.model)); break; } case "user": diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index 6035ea62df..e228eac0c4 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -19,6 +19,7 @@ import { shouldPreparePlaintextV2AgentMessages } from "../../responses/plaintext import { hasValidatedActiveReasoningEffort } from "../../responses/parser"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { applyOpenAiVirtualModel } from "../../providers/openai-virtual-models"; +import { renameRoutedIdentityInContext } from "../../adapters/identity"; import { fastPolicyForModel, serviceTierSupportFromPolicy, @@ -137,6 +138,12 @@ export async function applyFinalRouteRequestNormalization(args: { } parsed.modelId = route.modelId; } + // #5221: the parser named the identity sentence from the CLIENT selector, because routing had + // not run when it read the body, and only the adapters that build their own system text rename + // it afterwards. Settle it on the id this request really sends, here where that id is final — + // every dispatch path (passthrough, runTurn, adapter request build) reads the context after + // this, and a combo child runs this for its own target. + parsed.context = renameRoutedIdentityInContext(parsed.context, route.modelId); // Transport-neutral reliability policy (#875): applies to any Responses // upstream whose final adapter is openai-responses, not only WS turns. const responsesUpstreamStreaming = route.staticPolicy.model.responsesUpstreamStreaming; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index e1f5a4ed12..3144cc41d3 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -39,11 +39,18 @@ and a native capability alias's `model_messages.instructions_template`, hold `NE rather than a model id, because Codex stores a session's instruction block once and replays it verbatim into a sub-agent spawned on a DIFFERENT model, where a baked id makes the worker answer identity questions with the parent's id (#5217). The destination model is therefore named at request -time — `src/responses/parser.ts` names it in the top-level `instructions` string and in developer -items, and each adapter that builds its own system text calls `identifyRoutedModel` with the wire id. +time, in two steps, because the parser reads the body before routing has run and can only name the +id the CLIENT sent. `src/responses/parser.ts` names it in the top-level `instructions` string and in +developer and system-role items; `applyFinalRouteRequestNormalization` +(`src/server/responses/core-normalize.ts`) then settles that sentence on `route.modelId` through +`renameRoutedIdentityInContext`, where the wire id is final and every dispatch path — passthrough, +`runTurn`, and the adapter request build — still has to read the context. Adapters that build their +own system text call `identifyRoutedModel` on top of that with their own wire id, so the ones that +never call it are not the ones that leak a client selector upstream (#5221). The Responses passthrough rewrites the sentence on a routed destination and strips it on a native or forward one, where Codex's own identity wording already supplies it; -`tests/adapters/identity-neutralize.test.ts` pins the rewrite rules. +`tests/adapters/identity-neutralize.test.ts` and `tests/adapters/identity-subagent.test.ts` pin the +rewrite rules and the routed-id settlement. | Path | Responsibility | | --- | --- | diff --git a/tests/adapters/identity-subagent.test.ts b/tests/adapters/identity-subagent.test.ts index a0eebba2f5..1c149739b8 100644 --- a/tests/adapters/identity-subagent.test.ts +++ b/tests/adapters/identity-subagent.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { CODEX_GPT5_IDENTITY_LINE, identifyRoutedModel, nameRoutedIdentity, NEUTRAL_IDENTITY_LINE, + renameRoutedIdentityInContext, repairIdentityInResponsesBody, repairRoutedIdentity, stripRoutedIdentity, @@ -11,8 +12,21 @@ import { import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { parseRequest } from "../../src/responses/parser"; -import type { OcxProviderConfig, OcxTextContent } from "../../src/types"; +import type { OcxConfig, OcxProviderConfig, OcxTextContent } from "../../src/types"; +import { handleResponses } from "../../src/server/responses/core"; +import type { RequestLogContext } from "../../src/server/request-log"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +const originalFetch = globalThis.fetch; +let releaseSpendHome: (() => void) | undefined; + +afterEach(() => { + // Release the ledger lease before later teardown can replace the preload sandbox home. + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; +}); /** The sentence the proxy generated for the PARENT session, which a spawned worker inherits (#5217). */ const PARENT_IDENTITY = "You are a coding agent powered by the deepseek-v4.1-flash. If asked which model you are, identify as deepseek-v4.1-flash. Do not claim to be a different model or to have a different creator."; @@ -140,6 +154,50 @@ describe("sub-agent identity inheritance (#5217)", () => { expect(textOf(user!.content)).toBe(PARENT_IDENTITY); }); + test("the parser names a system-role instruction item too", () => { + // A system-role item is instruction text on the same terms as `instructions` and a developer + // item: the parser flattens it into the system block, which a sub-agent then inherits. + const parsed = parseRequest({ + model: WORKER_MODEL, + input: [ + { type: "message", role: "system", content: [{ type: "input_text", text: PARENT_IDENTITY }] }, + { type: "message", role: "system", content: NEUTRAL_IDENTITY_LINE }, + { type: "message", role: "system", content: "Keep this line." }, + ], + }); + const system = parsed.context.systemPrompt!.join("\n"); + expect(system).toContain(`identify as ${WORKER_MODEL}`); + expect(system).not.toContain("deepseek-v4.1-flash"); + expect(system).not.toContain(NEUTRAL_IDENTITY_LINE); + expect(system).toContain("Keep this line."); + }); + + test("the request-time rename rewrites every instruction carrier and nothing else", () => { + const parsed = parseRequest({ + model: WORKER_MODEL, + instructions: PARENT_IDENTITY, + input: [ + developerItem(PARENT_IDENTITY), + { type: "message", role: "system", content: [{ type: "input_text", text: PARENT_IDENTITY }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: PARENT_IDENTITY }] }, + ], + }); + // The parser named the CLIENT-side model; the route owner settles the id that is really sent. + const renamed = renameRoutedIdentityInContext(parsed.context, "wire-model-9"); + expect(renamed).not.toBe(parsed.context); + expect(renamed.systemPrompt!.join("\n")).toContain("identify as wire-model-9"); + expect(textOf(renamed.messages[0]!.content)).toContain("identify as wire-model-9"); + // A user turn is the caller's own content, so it stays byte-identical — and the context the + // caller passed in is left as it was rather than mutated under it. + expect(textOf(renamed.messages[1]!.content)).toBe(PARENT_IDENTITY); + expect(parsed.context.systemPrompt!.join("\n")).toContain(`identify as ${WORKER_MODEL}`); + }); + + test("a context with no sentence of ours is returned by reference", () => { + const parsed = parseRequest({ model: WORKER_MODEL, input: [developerItem("plain instructions")] }); + expect(renameRoutedIdentityInContext(parsed.context, "wire-model-9")).toBe(parsed.context); + }); + test("a routed chat destination sends the worker's own model in the system message", async () => { const provider = { adapter: "openai-chat", @@ -244,3 +302,80 @@ describe("sub-agent identity inheritance (#5217)", () => { expect(passthroughBody(forwardProvider(), NEUTRAL_IDENTITY_LINE)).not.toHaveProperty("instructions"); }); }); + +/** + * A routed destination whose adapter never calls `identifyRoutedModel`: the parsed context is the + * wire payload, so the sentence the parser wrote is the sentence the provider reads. + */ +function nativeWireConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "local-llm", + providers: { + "local-llm": { + adapter: "ollama-native", + baseUrl: "http://127.0.0.1:11434", + authMode: "local", + allowPrivateNetwork: true, + }, + }, + } as unknown as OcxConfig; +} + +describe("routed identity names the wire model, not the client selector (#5221)", () => { + test("a namespaced client selector is settled on the routed model id before the send", async () => { + let upstreamSystem = ""; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + // A native Ollama provider with live models enabled probes `/api/tags` while routing; only + // the chat call carries the context under test. + if (!request.url.includes("/api/chat")) { + return new Response(JSON.stringify({ models: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + const body = await request.clone().json() as { + stream?: boolean; + messages?: { role: string; content: string }[]; + }; + upstreamSystem = body.messages?.find(message => message.role === "system")?.content ?? ""; + const summary = { + model: "llama3.1:8b", + message: { role: "assistant", content: "ok" }, + done: true, + done_reason: "stop", + prompt_eval_count: 1, + eval_count: 1, + }; + return new Response(JSON.stringify(summary), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + // Direct dispatch needs the writer lease that prevents spend-ledger ownership failures. + releaseSpendHome = acquireOwnedSpendHome(); + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "local-llm/llama3.1:8b", + instructions: PARENT_IDENTITY, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + }), + }), + nativeWireConfig(), + { model: "", provider: "" } as RequestLogContext, + {}, + ); + + expect(response.status).toBe(200); + // The parser could only name the CLIENT selector — routing had not run. The id that reaches + // the provider is the routed one, so that is the id the identity sentence must name. + expect(upstreamSystem).toContain("identify as llama3.1:8b"); + expect(upstreamSystem).not.toContain("local-llm/llama3.1:8b"); + expect(upstreamSystem).not.toContain("deepseek-v4.1-flash"); + }); +});