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

Filter by extension

Filter by extension

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

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

차단 중에는 해당 메인 계정의 Luna Reserve도 쓸 수 없습니다. 일반 사용량이 소진되지 않으면
Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 처리 방식으로 돌아가지만 서버가
허용하는 사용량이 늘어나지는 않습니다. 계정의 사용량 새로고침으로 최신 수치를 확인할 수 있으며,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ 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, 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.

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
37 changes: 35 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 { extractAccountIdClaims } 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,38 @@ 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 {
// Startup observes the pinned owned path inside the exclusive claim: bound the read so a
// replaced non-regular or oversized file cannot stall startup inside that claim.
const result = readCodexTokensResult(authPath, { bounded: true });
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 —
// including the two account-id encodings within a single token.
const idTokenClaims = extractAccountIdClaims(tokens.id_token);
const accessTokenClaims = extractAccountIdClaims(tokens.access_token);
if (idTokenClaims.conflict || accessTokenClaims.conflict) return false;
const idTokenAccountId = idTokenClaims.accountId;
const accessTokenAccountId = accessTokenClaims.accountId;
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
14 changes: 12 additions & 2 deletions src/codex/auth-collision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveCodexHomeDir } from "./home";
import { extractAccountId } from "../oauth/chatgpt";
import { isSelectableCodexPoolAccount } from "./account-id";
import { codexPlanKey } from "./plan";
import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";

export interface CodexTokens {
access_token: string;
Expand All @@ -31,12 +32,21 @@ 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.
* `bounded` opts into the native-profile bounded reader (regular file, size-capped, no-follow,
* non-blocking) for startup observation paths that run inside the owner claim; bounded violations
* classify as `unreadable`. Legacy callers keep the unbounded read.
* Never returns or logs the raw error or any token material.
*/
export function readCodexTokensResult(): CodexTokenReadResult {
export function readCodexTokensResult(
authPath = join(resolveCodexHomeDir(), "auth.json"),
options?: { bounded?: boolean },
): CodexTokenReadResult {
let raw: string;
try {
raw = readFileSync(join(resolveCodexHomeDir(), "auth.json"), "utf-8");
raw = options?.bounded === true
? readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8")
: 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);
}
Comment thread
lidge-jun marked this conversation as resolved.
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
4 changes: 2 additions & 2 deletions src/codex/native-profile-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const KEYRING_SERVICE = "opencodex.native-main-profile.v1";
const SHARED_METADATA_DIR = ".opencodex-native-main-profiles";
const INSTANCE_STAGING_DIR = "native-main-profile-staging";
const LEGACY_METADATA_DIR = "native-main-profiles";
const MAX_AUTH_BYTES = 4 * 1024 * 1024;
export const MAX_AUTH_BYTES = 4 * 1024 * 1024;
export const MAX_NATIVE_PROFILE_METADATA_BYTES = 4 * 1024 * 1024;
export const MAX_NATIVE_PROFILE_JOURNAL_BYTES = 17 * 1024 * 1024;
export const MAX_NATIVE_PROFILES = 32;
Expand Down Expand Up @@ -376,7 +376,7 @@ export function resolveNativeProfileContext(options: { codexHome?: string; confi
};
}

function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer {
export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer {
let fd: number | undefined;
let failed = false;
try {
Expand Down
Loading
Loading