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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ ocx login anthropic
아닙니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다.
추가 계정과 다른 공급자는 계속 사용할 수 있습니다.

보호 기능이 켜져 있으면 소유권이 확인된 시작 과정에서 native 프로필 복구와 정리를 마친 뒤
메인 인증정보의 메모리 내 식별 연결을 복원하므로, 저장된 99% 차단이 재시작 후에도 유지됩니다.
연결을 준비하는 동안 호출자 인증정보를 쓰는 Direct 또는 메인 fallback 요청은 잠시 503을
받을 수 있습니다. 이 초기화를 위해 다른 서비스 소유이거나 소유권이 미확인인 홈의 인증정보를 읽지는 않습니다.

차단 중에는 해당 메인 계정의 Luna Reserve도 쓸 수 없습니다. 일반 사용량이 소진되지 않으면
Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 처리 방식으로 돌아가지만 서버가
허용하는 사용량이 늘어나지는 않습니다. 계정의 사용량 새로고침으로 최신 수치를 확인할 수 있으며,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ by default. This protects new requests using the identified main account, not th
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.
Caller-owned Direct or main-fallback requests can briefly receive 503 while that binding is
pending. No credential is read from a foreign or unconfirmed service home for this initialization.

While this policy blocks main, Luna Reserve on that account is blocked too. Staying below ordinary
quota exhaustion may prevent Reserve activation. Disabling the switch restores normal local
handling, not additional upstream entitlement. Use the account quota refresh action to obtain a
Expand Down
31 changes: 29 additions & 2 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import {
} from "../config";
import { removeCodexAccountCredential } from "./account-store";
import { clearAccountNeedsReauth } from "./account-runtime-state";
import { getMainChatgptAccountId } from "./auth-collision";
import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision";
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
import { clearAccountQuota } from "./quota";
import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache";
import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache";
import { extractAccountId } from "../oauth/chatgpt";
import { forgetCodexAccountPause } from "./account-pause";
import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state";
Expand Down Expand Up @@ -75,6 +76,32 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
return true;
}

/**
* Rebuild the memory-only policy binding from a startup-owned, recovered auth path.
* The caller holds the native owner and exclusive claim; an incoming bearer is never evidence.
* A failed read creates no binding and cannot revoke a prior verified observation or its block.
* Only a valid replacement observation or confirmed account transition supersedes that evidence.
*/
export function initializeMainAccountPolicyBinding(authPath: string): boolean {
const result = readCodexTokensResult(authPath);
if (result.status !== "ok") return false;
const { tokens } = result;
if (typeof tokens.access_token !== "string" || !tokens.access_token
|| typeof tokens.account_id !== "string" || !tokens.account_id) return false;
if (tokens.id_token != null && typeof tokens.id_token !== "string") return false;
const accountId = tokens.account_id;
// An owned file may contain an opaque bearer, but every decoded identity must agree.
const idTokenAccountId = extractAccountId(tokens.id_token);
const accessTokenAccountId = extractAccountId(undefined, tokens.access_token);
if ((idTokenAccountId !== undefined && idTokenAccountId !== accountId)
|| (accessTokenAccountId !== undefined && accessTokenAccountId !== accountId)) return false;
const previousAccountId = observedMainChatgptAccountId;
observedMainChatgptAccountId = accountId;
if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
observeMainQuotaIdentity(accountId);
return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
}

/**
* Apply a transaction-confirmed physical native-login change without waiting for
* a later auth.json observation. The caller owns credential commit/rollback.
Expand Down
5 changes: 3 additions & 2 deletions src/codex/auth-collision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ function hasErrnoCode(error: unknown, code: string): boolean {
/**
* Reads the Codex CLI credential file and classifies the outcome. Reads once instead of doing an
* `existsSync` pre-check, so a file replaced between check and read cannot be misread as absent.
* An already-owned lifecycle may supply its pinned auth path instead of resolving ambient home.
* Never returns or logs the raw error or any token material.
*/
export function readCodexTokensResult(): CodexTokenReadResult {
export function readCodexTokensResult(authPath = join(resolveCodexHomeDir(), "auth.json")): CodexTokenReadResult {
let raw: string;
try {
raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8");
raw = readFileSync(authPath, "utf-8");
} catch (error) {
return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" };
}
Expand Down
15 changes: 12 additions & 3 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
isMainAccountTokenLive,
type NativeMainRefreshDependencies,
} from "./main-account";
import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
import type { NativeMainStartupBlockReason } from "./native-profile-startup";
import {
codexQuotaScopeForModel,
Expand Down Expand Up @@ -598,20 +598,29 @@ export async function resolveCodexAuthContext(
throw new CodexReserveUnavailableError();
}
const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
const preserveRequestOwnedMainPin = requestScopedMainCredential
const requestOwnedMainPinCandidate = requestScopedMainCredential
&& fixedAccountId === undefined
&& config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
&& isEffectiveCodexAccountPinned(config)
&& !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
&& !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))
&& requestOwnedMainPinHasQuotaHeadroom(config);
// 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()) {
throw new CodexMainProfileDrainingError();
}
const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
&& !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
throw new Error("Codex auth context cannot select and exclude an account simultaneously");
}
const resolveCallerOwnedMainContext = async (): Promise<CodexAuthContext> => {
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
const substituteStoredMain = options.substituteMainCredentialForDirect === true;
if (!substituteStoredMain) {
if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) {
throw new CodexMainProfileDrainingError();
}
if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
if (reserve) {
const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
Expand Down
77 changes: 72 additions & 5 deletions src/codex/native-profile-startup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { NativeProfileManager } from "./native-profile-manager";
import { loadConfig } from "../config";
import { initializeMainAccountPolicyBinding } from "./account-lifecycle";
import { clearAccountNeedsReauth } from "./account-runtime-state";
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
import {
Expand Down Expand Up @@ -73,6 +75,7 @@ interface StartupEntry {
owner: NativeMainOwnerReference;
unsubscribe: () => void;
recoveryStarted: boolean;
policyBindingPending: boolean;
settled: Promise<NativeMainStartupGateSnapshot>;
resolveAcquisition?: (value: NativeMainStartupGateSnapshot) => void;
deps: NativeMainStartupGateDeps;
Expand Down Expand Up @@ -172,17 +175,21 @@ async function runOwnedStageSweep(entry: StartupEntry): Promise<boolean> {
}

function scheduleStageSweep(entry: StartupEntry): void {
if (entry.sweepStopping || entry.sweepTimer || startupEntries.get(entry.homeId) !== entry) return;
if (entry.sweepStopping || entry.sweepTimer || entry.sweepInFlight || entry.policyBindingPending
|| startupEntries.get(entry.homeId) !== entry) return;
const intervalMs = Math.max(10, entry.deps.stageSweepIntervalMs ?? NATIVE_STAGE_SWEEP_INTERVAL_MS);
entry.sweepTimer = setTimeout(() => {
entry.sweepTimer = undefined;
if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return;
const sweepEpoch = entry.epoch;
entry.sweepInFlight = (async () => {
const safe = await runOwnedStageSweep(entry);
if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return;
if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry
|| 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") {
snapshot = ready(entry.homeId);
if (loadConfig().codexMainAccountHardLock === true) rearmOwnedMainPolicyBinding(entry);
else snapshot = ready(entry.homeId);
}
})().finally(() => {
entry.sweepInFlight = undefined;
Expand Down Expand Up @@ -218,8 +225,29 @@ 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) {
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
snapshot = ready(entry.homeId);
if (loadConfig().codexMainAccountHardLock === true) {
await withNativeMainOwnerOperation(entry.manager.context, () => withNativeMainExclusiveClaim(
entry.manager.context,
async () => {
if (startupEntries.get(entry.homeId) !== entry || entry.epoch !== currentEpoch) return;
if (probe(entry.manager.context) !== "none") {
snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" };
return;
}
// 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) {
initializeMainAccountPolicyBinding(entry.manager.context.authPath);
}
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
snapshot = ready(entry.homeId);
},
{ waitMs: 10_000 },
));
} else {
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
snapshot = ready(entry.homeId);
}
} else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none") {
snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" };
} else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) {
Expand All @@ -230,12 +258,35 @@ function convergeOwnedStartup(entry: StartupEntry): void {
snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" };
}
}
entry.policyBindingPending = false;
if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) scheduleStageSweep(entry);
return snapshot;
})();
if (acquisitionWaiter) void entry.settled.then(acquisitionWaiter);
}

/** Join an active startup, or rearm its held owner before publishing another ready transition. */
function rearmOwnedMainPolicyBinding(entry: StartupEntry): boolean {
if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return false;
const owner = entry.owner.snapshot();
if (entry.policyBindingPending && (owner.status === "held" || owner.status === "acquiring")) {
snapshot = { status: "blocked", homeId: entry.homeId, reason: "recovery-pending" };
settled = entry.settled;
return true;
}
if (owner.status !== "held") {
snapshot = { status: "blocked", homeId: entry.homeId, reason: ownerBlockedReason(owner) };
return false;
}
if (entry.sweepTimer) clearTimeout(entry.sweepTimer);
entry.sweepTimer = undefined;
entry.epoch = ++epoch;
entry.policyBindingPending = true;
entry.recoveryStarted = false;
convergeOwnedStartup(entry);
return true;
}

function observeOwner(entry: StartupEntry, owner: NativeMainOwnerSnapshot): void {
if (startupEntries.get(entry.homeId) !== entry) return;
if (owner.status === "acquiring") {
Expand Down Expand Up @@ -283,6 +334,7 @@ export function startNativeMainStartupLifecycle(
owner,
unsubscribe: () => {},
recoveryStarted: false,
policyBindingPending: true,
settled: acquisition,
resolveAcquisition,
deps,
Expand All @@ -291,6 +343,12 @@ export function startNativeMainStartupLifecycle(
};
startupEntries.set(homeId, entry);
entry.unsubscribe = owner.subscribe(ownerState => observeOwner(entry!, ownerState));
} else if (!entry.policyBindingPending
&& snapshot.status === "ready" && snapshot.homeId === homeId
&& loadConfig().codexMainAccountHardLock === true) {
// 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);
}
entry.refs += 1;
let released = false;
Expand Down Expand Up @@ -602,6 +660,8 @@ 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);
epoch += 1;
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
snapshot = ready(homeId);
Expand All @@ -615,6 +675,13 @@ export function nativeMainStartupGateSnapshot(): NativeMainStartupGateSnapshot {
return { ...snapshot };
}

/** Read-only: caller-owned credentials must not trigger physical-main ownership reprobes. */
export function isMainAccountPolicyBindingPending(): boolean {
const current = nativeMainStartupGateSnapshot();
return current.status === "blocked" && current.reason === "recovery-pending"
&& current.homeId !== null && startupEntries.get(current.homeId)?.policyBindingPending === true;
}

export function waitForNativeMainStartupGate(): Promise<NativeMainStartupGateSnapshot> {
const reason = activeServiceOwnershipBlockReason();
if (reason) return Promise.resolve(serviceOwnershipSnapshot(reason));
Expand Down
11 changes: 11 additions & 0 deletions structure/08_openai-provider-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ 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
Expand Down
Loading
Loading