@bitkyc08/opencodex 2.78.0 → 2.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-BOUa-ek3.js +54 -0
- package/gui/dist/assets/App-CSJvmjNs.css +1 -0
- package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-DUtzB4zF.js} +1 -1
- package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
- package/gui/dist/assets/index-DwHNDF4s.js +94 -0
- package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-BuL4WIBo.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/devin.ts +22 -1
- package/src/claude/desktop-picker.ts +11 -9
- package/src/claude/inbound-content-options.ts +29 -2
- package/src/claude/intercept/local-ca-files.ts +265 -0
- package/src/claude/intercept/local-ca-windows.ts +73 -0
- package/src/claude/intercept/local-ca.ts +12 -24
- package/src/claude/intercept/picker-ca-persistence.ts +106 -0
- package/src/claude/intercept/picker-ca-startup.ts +16 -0
- package/src/claude/intercept/picker-ca-store.ts +70 -0
- package/src/claude/intercept/picker-ca.ts +43 -9
- package/src/claude/intercept/picker-runtime.ts +14 -4
- package/src/claude/intercept/runtime.ts +32 -23
- package/src/cli/ensure-readiness.ts +48 -0
- package/src/cli/gui-pair-client.ts +15 -7
- package/src/cli/gui.ts +4 -1
- package/src/cli/hub.ts +1 -0
- package/src/cli/index.ts +10 -11
- package/src/cli/status.ts +2 -2
- package/src/cli/update-restart-child.ts +4 -1
- package/src/codex/account-credit-use.ts +8 -2
- package/src/codex/auth-api/account-pause-group.ts +113 -0
- package/src/codex/auth-api/routes.ts +18 -12
- package/src/codex/auth-context.ts +45 -3
- package/src/codex/autostart-health.ts +10 -0
- package/src/codex/pool-credit-policy.ts +67 -0
- package/src/generated/compatibility-version.json +106 -46
- package/src/lib/gui-pair-capability.ts +1 -1
- package/src/lib/gui-pair-intent.ts +170 -0
- package/src/lib/windows-owner-acl.ts +143 -0
- package/src/providers/openai-sidecar-credit.ts +26 -0
- package/src/providers/openai-sidecar.ts +35 -0
- package/src/server/claude-messages.ts +1 -0
- package/src/server/gui-pair-delivery.ts +23 -0
- package/src/server/index/serve-options.ts +5 -8
- package/src/server/live-native-calls.ts +94 -0
- package/src/server/live.ts +28 -5
- package/src/server/local-management-read-client.ts +15 -11
- package/src/server/management/oauth-account-routes.ts +15 -0
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/shared.ts +9 -3
- package/src/server/messages-native-scope.ts +57 -0
- package/src/server/messages-native.ts +9 -0
- package/src/server/responses/adapter-dispatch.ts +31 -2
- package/src/server/responses/compact.ts +17 -2
- package/src/server/responses/core-auth.ts +18 -1
- package/src/server/responses/core-codex-account.ts +18 -8
- package/src/server/responses/non-replayable-error.ts +48 -0
- package/src/server/responses/passthrough-delivery.ts +5 -1
- package/src/server/responses/passthrough-dispatch.ts +21 -19
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/sidecar-execution.ts +1 -0
- package/src/server/search.ts +4 -0
- package/src/server/startup-health-cache.ts +2 -6
- package/src/vision/describe.ts +17 -10
- package/src/vision/index.ts +1 -0
- package/src/web-search/executor.ts +17 -10
- package/src/web-search/format-result.ts +304 -20
- package/src/web-search/loop.ts +2 -1
- package/src/web-search/passthrough-bridge.ts +1 -0
- package/src/web-search/run-turn-loop.ts +1 -1
- package/gui/dist/assets/App-BtfG5id-.js +0 -54
- package/gui/dist/assets/App-lCIslZ1L.css +0 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readiness wait for a proxy this process just asked to start.
|
|
3
|
+
*
|
|
4
|
+
* ocx ensure spawns a detached ocx start and returns once this home's proxy answers.
|
|
5
|
+
* It used to give up after a flat 8 s. A cold start can take longer than that on a
|
|
6
|
+
* busy Windows host (Windows CI measured 10-45 s for the first proxy child of a
|
|
7
|
+
* batch), so ensure reported "did not become healthy" for a proxy that came up
|
|
8
|
+
* moments later, and the Codex shim or autostart that called it saw a failure.
|
|
9
|
+
*
|
|
10
|
+
* The wait is now as long as the service start's follow-up (40 s) while the
|
|
11
|
+
* spawned child is alive. A child that has exited keeps the old 8 s bound, so a
|
|
12
|
+
* start that failed outright is reported no later than before.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const ENSURE_READY_TIMEOUT_MS = 40_000;
|
|
16
|
+
export const ENSURE_EXITED_CHILD_TIMEOUT_MS = 8_000;
|
|
17
|
+
const POLL_MS = 150;
|
|
18
|
+
|
|
19
|
+
export interface LiveProxyWaitOptions<T> {
|
|
20
|
+
find: () => Promise<T | null>;
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
/** Return false to stop polling early; one observation is always made first. */
|
|
23
|
+
keepWaiting?: () => boolean;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function waitForLiveProxy<T>(options: LiveProxyWaitOptions<T>): Promise<T | null> {
|
|
29
|
+
const now = options.now ?? Date.now;
|
|
30
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
|
|
31
|
+
const deadline = now() + options.timeoutMs;
|
|
32
|
+
while (now() < deadline) {
|
|
33
|
+
const live = await options.find();
|
|
34
|
+
if (live) return live;
|
|
35
|
+
if (options.keepWaiting && !options.keepWaiting()) return null;
|
|
36
|
+
await sleep(POLL_MS);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keep waiting while the spawned child lives; once it exits, only inside the old 8 s bound. */
|
|
42
|
+
export function ensureKeepWaiting(
|
|
43
|
+
spawnedAt: number,
|
|
44
|
+
childExited: () => boolean,
|
|
45
|
+
now: () => number = Date.now,
|
|
46
|
+
): () => boolean {
|
|
47
|
+
return () => !childExited() || now() - spawnedAt < ENSURE_EXITED_CHILD_TIMEOUT_MS;
|
|
48
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readRuntimePort, type RuntimePortState } from "../config/process-state";
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { createGuiPairIntent, GUI_PAIR_INTENT_HEADER, type GuiPairIntent } from "../lib/gui-pair-intent";
|
|
3
4
|
import {
|
|
4
5
|
LOCAL_ATTESTATION_CHALLENGE_HEADER,
|
|
5
6
|
LOCAL_ATTESTATION_PROOF_HEADER,
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
|
|
31
32
|
export type GuiPairRequestResult =
|
|
32
33
|
| { kind: "created"; grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number }
|
|
33
|
-
| { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" };
|
|
34
|
+
| { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" | "local-intent" };
|
|
34
35
|
|
|
35
36
|
export interface GuiPairClientDeps {
|
|
36
37
|
fetchImpl?: typeof fetch;
|
|
@@ -38,6 +39,9 @@ export interface GuiPairClientDeps {
|
|
|
38
39
|
createChallenge?: () => string;
|
|
39
40
|
now?: () => number;
|
|
40
41
|
timeoutMs?: number;
|
|
42
|
+
/** Hub invitation policy is unchanged; standalone commands require local write intent. */
|
|
43
|
+
requireLocalIntent?: boolean;
|
|
44
|
+
createIntent?: typeof createGuiPairIntent;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
const GUI_PAIR_REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -136,9 +140,12 @@ export async function requestBoundGuiPairingGrant(
|
|
|
136
140
|
expiresAt,
|
|
137
141
|
);
|
|
138
142
|
if (!capability) return { kind: "unavailable", reason: "capability" };
|
|
139
|
-
let
|
|
143
|
+
let intent: GuiPairIntent | undefined;
|
|
140
144
|
try {
|
|
141
|
-
|
|
145
|
+
if (deps.requireLocalIntent !== false) intent = (deps.createIntent ?? createGuiPairIntent)(capability);
|
|
146
|
+
} catch { return { kind: "unavailable", reason: "local-intent" }; }
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetchImpl(`${baseUrl}${GUI_PAIR_PATH}`, {
|
|
142
149
|
method: GUI_PAIR_METHOD,
|
|
143
150
|
headers: {
|
|
144
151
|
"Content-Length": "0",
|
|
@@ -147,13 +154,14 @@ export async function requestBoundGuiPairingGrant(
|
|
|
147
154
|
[GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt),
|
|
148
155
|
[GUI_PAIR_BROWSER_ORIGIN_HEADER]: browserOrigin,
|
|
149
156
|
[GUI_PAIR_CAPABILITY_HEADER]: capability,
|
|
157
|
+
...(intent ? { [GUI_PAIR_INTENT_HEADER]: intent.proof } : {}),
|
|
150
158
|
},
|
|
151
159
|
signal: AbortSignal.timeout(timeoutMs),
|
|
152
160
|
});
|
|
161
|
+
if (!response.ok) return { kind: "unavailable", reason: "rejected" };
|
|
162
|
+
const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin);
|
|
163
|
+
return result ?? { kind: "unavailable", reason: "rejected" };
|
|
153
164
|
} catch {
|
|
154
165
|
return { kind: "unavailable", reason: "transport" };
|
|
155
|
-
}
|
|
156
|
-
if (!response.ok) return { kind: "unavailable", reason: "rejected" };
|
|
157
|
-
const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin);
|
|
158
|
-
return result ?? { kind: "unavailable", reason: "rejected" };
|
|
166
|
+
} finally { intent?.dispose(); }
|
|
159
167
|
}
|
package/src/cli/gui.ts
CHANGED
|
@@ -77,9 +77,12 @@ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promi
|
|
|
77
77
|
}
|
|
78
78
|
const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, canonicalOrigin, {
|
|
79
79
|
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
80
|
+
requireLocalIntent: config.runtimeRole !== "hub",
|
|
80
81
|
});
|
|
81
82
|
if (result.kind !== "created") {
|
|
82
|
-
console.error(
|
|
83
|
+
console.error(result.reason === "local-intent"
|
|
84
|
+
? "GUI pairing requires write access to the private OpenCodex home; no code was issued."
|
|
85
|
+
: `GUI pairing failed (${result.reason}).`);
|
|
83
86
|
return 1;
|
|
84
87
|
}
|
|
85
88
|
if (parsed.json) {
|
package/src/cli/hub.ts
CHANGED
|
@@ -328,6 +328,7 @@ async function runInvite(args: string[], deps: HubCommandDeps): Promise<number>
|
|
|
328
328
|
const dataUrl = resolved.dataUrl;
|
|
329
329
|
const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, browserOrigin, {
|
|
330
330
|
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
331
|
+
requireLocalIntent: false,
|
|
331
332
|
});
|
|
332
333
|
if (result.kind !== "created") {
|
|
333
334
|
console.error(`Minting a pairing code failed (${result.reason}).`);
|
package/src/cli/index.ts
CHANGED
|
@@ -152,6 +152,7 @@ import {
|
|
|
152
152
|
grokSyncFailureMessage,
|
|
153
153
|
reconcileEnsureDesiredIntegrations,
|
|
154
154
|
} from "./ensure-desired-integrations";
|
|
155
|
+
import { ENSURE_READY_TIMEOUT_MS, ensureKeepWaiting, waitForLiveProxy } from "./ensure-readiness";
|
|
155
156
|
import { refreshOwnedCatalogIntegrations } from "../integrations/catalog-refresh";
|
|
156
157
|
import { loadExportModels } from "../server/management/model-rows";
|
|
157
158
|
|
|
@@ -235,16 +236,10 @@ function parseStartCliOptions(): ReturnType<typeof parseStartOptions> {
|
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
|
|
238
|
-
async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// fallback port, and never mistakes a foreign 200 for our proxy.
|
|
243
|
-
const live = await findLiveProxy();
|
|
244
|
-
if (live) return live;
|
|
245
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
246
|
-
}
|
|
247
|
-
return null;
|
|
239
|
+
async function waitForProxy(timeoutMs = 8_000, keepWaiting?: () => boolean): Promise<LiveProxy | null> {
|
|
240
|
+
// Runtime-state-first with identity: finds the proxy even when it started on a
|
|
241
|
+
// fallback port, and never mistakes a foreign 200 for our proxy.
|
|
242
|
+
return waitForLiveProxy({ find: findLiveProxy, timeoutMs, keepWaiting });
|
|
248
243
|
}
|
|
249
244
|
|
|
250
245
|
class StartCommandExit extends Error {
|
|
@@ -846,9 +841,13 @@ async function handleEnsure(options: { existingIsSuccess?: boolean; forceStart?:
|
|
|
846
841
|
env: detachedStartEnvironment(),
|
|
847
842
|
});
|
|
848
843
|
options.onSpawn?.(child);
|
|
844
|
+
const spawnedAt = Date.now();
|
|
845
|
+
let childExited = false;
|
|
846
|
+
child.once("exit", () => { childExited = true; });
|
|
849
847
|
child.unref();
|
|
850
848
|
|
|
851
|
-
|
|
849
|
+
// A cold start can outlast 8 s on a busy Windows host; see ensure-readiness.ts.
|
|
850
|
+
const port = (await waitForProxy(ENSURE_READY_TIMEOUT_MS, ensureKeepWaiting(spawnedAt, () => childExited)))?.port;
|
|
852
851
|
if (!port) {
|
|
853
852
|
console.error("❌ Proxy did not become healthy after starting.");
|
|
854
853
|
process.exitCode = 1;
|
package/src/cli/status.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../cod
|
|
|
7
7
|
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
8
8
|
import type { OcxConfig } from "../types";
|
|
9
9
|
import { diagnoseService, serviceLogPath } from "../service";
|
|
10
|
-
import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
|
|
10
|
+
import { collectStartupHealth, startupHealthReadBudgetMs, type StartupHealth } from "../codex/autostart-health";
|
|
11
11
|
import { getCodexRoutingKind } from "../codex/inject";
|
|
12
12
|
import { missingOwnedCatalogPath } from "../codex/inject/config-toml";
|
|
13
13
|
import { CODEX_CONFIG_PATH } from "../codex/paths";
|
|
@@ -251,7 +251,7 @@ export async function fetchLiveStartupHealth(
|
|
|
251
251
|
deps: Parameters<typeof fetchBoundLocalManagementRead>[2] = {},
|
|
252
252
|
): Promise<StartupHealth | null> {
|
|
253
253
|
const result = await fetchBoundLocalManagementRead(
|
|
254
|
-
live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs:
|
|
254
|
+
live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: startupHealthReadBudgetMs(), ...deps, requireResponseProof: true },
|
|
255
255
|
);
|
|
256
256
|
if (result.kind !== "response" || !result.response.ok) return null;
|
|
257
257
|
let payload: unknown;
|
|
@@ -18,8 +18,11 @@ function parseMarker(raw: string): UpdateRestartChildMarker {
|
|
|
18
18
|
|| value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
|
|
19
19
|
|| !Number.isSafeInteger(value.home.revision)) throw new Error("update_restart_child_marker_invalid");
|
|
20
20
|
for (const directory of [value.home.config, value.home.codex]) {
|
|
21
|
+
// Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above
|
|
22
|
+
// 2^53. Parent and child read them through the same stat API, so any finite integer is a
|
|
23
|
+
// faithful identity token; assertUpdateRestartHome still compares path, dev and ino exactly.
|
|
21
24
|
if (!directory || typeof directory.path !== "string" || !directory.path
|
|
22
|
-
|| !Number.
|
|
25
|
+
|| !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)) {
|
|
23
26
|
throw new Error("update_restart_child_marker_invalid");
|
|
24
27
|
}
|
|
25
28
|
}
|
|
@@ -3,6 +3,7 @@ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
|
|
|
3
3
|
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
4
4
|
import {
|
|
5
5
|
CODEX_EXHAUSTED_USAGE_PERCENT,
|
|
6
|
+
TERMINAL_SHORT_WINDOW_FRESHNESS_MS,
|
|
6
7
|
isTerminalShortWindow,
|
|
7
8
|
hasSpendableCodexCredits,
|
|
8
9
|
resetAtToMs,
|
|
@@ -73,8 +74,13 @@ export function codexUsageLimitResetAt(quota: StoredAccountQuota | null, plan: u
|
|
|
73
74
|
if (!quota) return undefined;
|
|
74
75
|
const full: number[] = [];
|
|
75
76
|
if (isTerminalShortWindow(quota, now)) {
|
|
76
|
-
// A reset-less burst reading
|
|
77
|
-
|
|
77
|
+
// A reset-less burst reading holds until its observation goes stale: the freshness horizon
|
|
78
|
+
// is the deadline a retried request still meets, so report it rather than `now` (which
|
|
79
|
+
// collapsed the refusal's Retry-After to 1s against an account blocked for minutes).
|
|
80
|
+
// `isTerminalShortWindow` guarantees `shortObservedAt` is finite on this branch.
|
|
81
|
+
full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0
|
|
82
|
+
? resetAtToMs(quota.shortResetAt)
|
|
83
|
+
: (quota.shortObservedAt ?? now) + TERMINAL_SHORT_WINDOW_FRESHNESS_MS);
|
|
78
84
|
}
|
|
79
85
|
const longWindows: Array<[number | undefined, number | undefined]> = isThirtyDayOnlyCodexPlan(plan)
|
|
80
86
|
? [[quota.monthlyPercent, quota.monthlyResetAt]]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import type { OcxConfig } from "../../types";
|
|
3
|
+
import { inspectChatGptDomainClaim, extractEmail } from "../../oauth/chatgpt";
|
|
4
|
+
import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../account-id";
|
|
5
|
+
import { readCodexTokensResult } from "../auth-collision";
|
|
6
|
+
import type { CodexTokenReadResult } from "../auth-collision";
|
|
7
|
+
import { getCodexAccountCredential } from "../account-store";
|
|
8
|
+
import { resolveCodexHomeDir } from "../home";
|
|
9
|
+
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
10
|
+
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
11
|
+
import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store";
|
|
12
|
+
import { NativeProfileError } from "../native-profile-types";
|
|
13
|
+
import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
|
|
14
|
+
|
|
15
|
+
type AccountIdentity = { accountId: string; email: string };
|
|
16
|
+
type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
|
|
17
|
+
|
|
18
|
+
/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
|
|
19
|
+
function readMainAuth(authPath: string): MainAuth {
|
|
20
|
+
const main = readCodexTokensResult(authPath, { bounded: true });
|
|
21
|
+
if (main.status !== "invalid") return main;
|
|
22
|
+
try {
|
|
23
|
+
const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
|
|
24
|
+
if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
|
|
25
|
+
const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
|
|
26
|
+
const apiKeyOnly = envelope.tokens == null
|
|
27
|
+
&& (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
|
|
28
|
+
&& typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
|
|
29
|
+
return apiKeyOnly ? { status: "api-key-only" } : main;
|
|
30
|
+
} catch {
|
|
31
|
+
return main;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function identity(accountId: unknown, email: unknown): AccountIdentity | undefined {
|
|
36
|
+
const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : undefined;
|
|
37
|
+
return typeof accountId === "string" && accountId.trim() && normalizedEmail
|
|
38
|
+
? { accountId: accountId.trim(), email: normalizedEmail }
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolve only existing entries, under native-main ownership, never on the request path. */
|
|
43
|
+
function linkedAccountIds(config: OcxConfig, selectedId: string, main: MainAuth): string[] | undefined {
|
|
44
|
+
// Unknown main identity must not produce a successful but incomplete pause.
|
|
45
|
+
if (main.status === "unreadable" || main.status === "invalid") return undefined;
|
|
46
|
+
const identities = new Map<string, AccountIdentity | undefined>();
|
|
47
|
+
if (main.status === "ok") {
|
|
48
|
+
const tokens = main.tokens;
|
|
49
|
+
if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string"
|
|
50
|
+
|| (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return undefined;
|
|
51
|
+
const claims = [tokens.id_token, tokens.access_token]
|
|
52
|
+
.filter((token): token is string => typeof token === "string")
|
|
53
|
+
.map(inspectChatGptDomainClaim);
|
|
54
|
+
if (claims.some(claim => claim.kind === "invalid")) return undefined;
|
|
55
|
+
const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []);
|
|
56
|
+
// organizations[] describes memberships, not the selected workspace. Never use its first row.
|
|
57
|
+
const accountId = tokens.account_id.trim() || claimIds[0];
|
|
58
|
+
if (claimIds.some(claim => claim !== accountId)) return undefined;
|
|
59
|
+
// Both tokens must name the same member; otherwise the bearer's identity is unknown.
|
|
60
|
+
const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)]
|
|
61
|
+
.flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : []));
|
|
62
|
+
if (emails.size > 1) return undefined;
|
|
63
|
+
identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, [...emails][0]));
|
|
64
|
+
}
|
|
65
|
+
for (const account of config.codexAccounts ?? []) {
|
|
66
|
+
if (!isSelectableCodexPoolAccount(account)) continue;
|
|
67
|
+
const credential = getCodexAccountCredential(account.id);
|
|
68
|
+
identities.set(account.id, identity(credential?.chatgptAccountId, account.email));
|
|
69
|
+
}
|
|
70
|
+
const selected = identities.get(selectedId);
|
|
71
|
+
// Missing identity evidence never links unrelated logins, including members of one workspace.
|
|
72
|
+
if (!selected) return [selectedId];
|
|
73
|
+
return [selectedId, ...[...identities].flatMap(([id, candidate]) => (
|
|
74
|
+
id !== selectedId && candidate?.accountId === selected.accountId && candidate.email === selected.email
|
|
75
|
+
? [id] : []
|
|
76
|
+
))];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Hold admission throughout; claim the physical main whenever its home exists. */
|
|
80
|
+
export async function withCodexAccountPauseGroup(
|
|
81
|
+
config: OcxConfig,
|
|
82
|
+
selectedId: string,
|
|
83
|
+
publish: (accountIds: string[]) => Response,
|
|
84
|
+
): Promise<Response> {
|
|
85
|
+
const lease = tryAcquireNativeMainProfileClaim();
|
|
86
|
+
if (!lease) return nativeMainProfileBusyResponse();
|
|
87
|
+
try {
|
|
88
|
+
const codexHome = resolveCodexHomeDir();
|
|
89
|
+
try {
|
|
90
|
+
lstatSync(codexHome);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Only positive absence permits Pool-only publication. A dangling symlink or access
|
|
93
|
+
// failure is not absence; do not create a home just to coordinate an unused main login.
|
|
94
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") return nativeMainProfileBusyResponse();
|
|
95
|
+
const ids = linkedAccountIds(config, selectedId, { status: "missing" });
|
|
96
|
+
return ids ? publish(ids) : nativeMainProfileBusyResponse();
|
|
97
|
+
}
|
|
98
|
+
const context = resolveNativeProfileContext({ codexHome });
|
|
99
|
+
return await withNativeMainSharedClaim(context, async () => {
|
|
100
|
+
const main = readMainAuth(context.authPath);
|
|
101
|
+
const ids = linkedAccountIds(config, selectedId, main);
|
|
102
|
+
return ids ? publish(ids) : nativeMainProfileBusyResponse();
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (isNativeMainClaimUnavailable(error)
|
|
106
|
+
|| (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
|
|
107
|
+
return nativeMainProfileBusyResponse();
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
lease.release();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -24,6 +24,7 @@ import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service
|
|
|
24
24
|
import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config";
|
|
25
25
|
import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
|
|
26
26
|
import { getEffectiveCodexAutoSwitchThreshold, isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold, setCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch";
|
|
27
|
+
import { withCodexAccountPauseGroup } from "./account-pause-group";
|
|
27
28
|
|
|
28
29
|
export async function handleCodexAuthAPI(
|
|
29
30
|
req: Request,
|
|
@@ -100,18 +101,23 @@ export async function handleCodexAuthAPI(
|
|
|
100
101
|
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
101
102
|
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
const paused = body.paused;
|
|
105
|
+
return withCodexAccountPauseGroup(runtimeConfig, id, accountIds => {
|
|
106
|
+
// Publish every exclusion before reconciling: a duplicate must never become the fallback.
|
|
107
|
+
for (const accountId of accountIds) setCodexAccountPaused(runtimeConfig, accountId, paused);
|
|
108
|
+
if (paused) {
|
|
109
|
+
for (const accountId of accountIds) clearThreadAccountMapForAccount(accountId);
|
|
110
|
+
for (const accountId of accountIds) selectFallbackAfterPause(runtimeConfig, accountId);
|
|
111
|
+
}
|
|
112
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
113
|
+
return jsonResponse({
|
|
114
|
+
ok: true,
|
|
115
|
+
id,
|
|
116
|
+
paused,
|
|
117
|
+
affectedAccountIds: accountIds,
|
|
118
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
119
|
+
appliesImmediately: true,
|
|
120
|
+
});
|
|
115
121
|
});
|
|
116
122
|
}
|
|
117
123
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt } from "./pool-credit-policy";
|
|
1
2
|
import { hasSpendableCodexCredits } from "./quota-types";
|
|
2
3
|
import { noteMainAccountActivity } from "./main-account-external-usage";
|
|
3
4
|
import { codexAccountPriorityFailbackEnabled } from "./account-priority";
|
|
@@ -525,6 +526,23 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
|
|
|
525
526
|
}
|
|
526
527
|
}
|
|
527
528
|
|
|
529
|
+
/** A stored pool account's spending policy is independent of account selection. */
|
|
530
|
+
export class CodexPoolAccountCreditsOffError extends CodexAccountCooldownError {
|
|
531
|
+
readonly resetAt: number;
|
|
532
|
+
|
|
533
|
+
constructor(accountId: string, resetAt: number) {
|
|
534
|
+
super(accountId, resetAt);
|
|
535
|
+
this.name = "CodexPoolAccountCreditsOffError";
|
|
536
|
+
this.resetAt = resetAt;
|
|
537
|
+
this.message = "The selected Codex account reached its usage limit, and spending credits is off or no fresh spendable balance is available."
|
|
538
|
+
+ " Wait for the limit to reset, choose another account, or explicitly allow credits for this account in Codex Auth.";
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function assertPoolAccountCredits(accountId: string, resetAt: number | undefined): void {
|
|
543
|
+
if (resetAt !== undefined) throw new CodexPoolAccountCreditsOffError(accountId, resetAt);
|
|
544
|
+
}
|
|
545
|
+
|
|
528
546
|
/** The main login may not spend credits and one of its usage windows is full (#6334). */
|
|
529
547
|
export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError {
|
|
530
548
|
readonly resetAt?: number;
|
|
@@ -597,7 +615,7 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
|
|
|
597
615
|
|
|
598
616
|
export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
|
|
599
617
|
"codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
|
|
600
|
-
| "creditCodexAccountIds"
|
|
618
|
+
| "creditCodexAccountIds" | "codexAccounts"
|
|
601
619
|
>>;
|
|
602
620
|
|
|
603
621
|
interface CodexAuthMaterializationOptions {
|
|
@@ -725,6 +743,22 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
|
|
|
725
743
|
* Same evidence the hard lock reads, and no plan lookup: the plan can live in the physical auth
|
|
726
744
|
* file, which several callers are forbidden to open, so every long window counts instead.
|
|
727
745
|
*/
|
|
746
|
+
/** Recheck live account policy at the physical send, after pacing or retry backoff. */
|
|
747
|
+
export function createCodexAuthDispatchGuard(
|
|
748
|
+
ctx: CodexAuthContext,
|
|
749
|
+
config: CodexAuthPolicyConfig,
|
|
750
|
+
modelId: string,
|
|
751
|
+
admission?: Pick<DataPlaneAdmission, "source">,
|
|
752
|
+
terminalHelper = false,
|
|
753
|
+
): ((headers: Headers) => void) | undefined {
|
|
754
|
+
const reserveGuard = createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper);
|
|
755
|
+
if (ctx.kind !== "pool") return reserveGuard;
|
|
756
|
+
return headers => {
|
|
757
|
+
assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, config));
|
|
758
|
+
reserveGuard?.(headers);
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
728
762
|
function mainCreditsHoldResetAt(config: Pick<OcxConfig, "creditCodexAccountIds">): number | undefined {
|
|
729
763
|
if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)
|
|
730
764
|
&& hasSpendableCodexCredits(getMainPolicyQuota())) return undefined;
|
|
@@ -868,6 +902,7 @@ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSele
|
|
|
868
902
|
// A credits-off refusal is a configuration policy, not a cooldown: clearing a cooldown
|
|
869
903
|
// cannot lift it, so its own wording (wait for the reset or allow credits) is the remedy.
|
|
870
904
|
|| err instanceof CodexMainAccountCreditsOffError
|
|
905
|
+
|| err instanceof CodexPoolAccountCreditsOffError
|
|
871
906
|
|| err instanceof CodexReserveUnavailableError
|
|
872
907
|
// A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
|
|
873
908
|
// one, and the quota recovery advice below would send the operator after a cooldown that
|
|
@@ -917,6 +952,7 @@ export class CodexThreadAffinityExpiredError extends Error {
|
|
|
917
952
|
export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
|
|
918
953
|
return !(cause instanceof CodexMainAccountHardLockError)
|
|
919
954
|
&& !(cause instanceof CodexMainAccountCreditsOffError)
|
|
955
|
+
&& !(cause instanceof CodexPoolAccountCreditsOffError)
|
|
920
956
|
&& !(cause instanceof CodexAccountValidationPendingError)
|
|
921
957
|
&& !(cause instanceof CodexReserveUnavailableError)
|
|
922
958
|
&& !(cause instanceof CodexCredentialGenerationConflictError)
|
|
@@ -1408,6 +1444,7 @@ export async function resolveCodexAuthContext(
|
|
|
1408
1444
|
// deferred credential must never become request auth through that fallback.
|
|
1409
1445
|
try {
|
|
1410
1446
|
assertCodexAccountValidationReady(accountId);
|
|
1447
|
+
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
|
|
1411
1448
|
} catch (cause) {
|
|
1412
1449
|
// Nothing will reach upstream, so give the trial back instead of leaving the held account
|
|
1413
1450
|
// unprobeable until the lease deadline lapses (#4701).
|
|
@@ -1533,7 +1570,8 @@ export async function resolveCodexAuthContext(
|
|
|
1533
1570
|
try {
|
|
1534
1571
|
const token = await getValidCodexToken(accountId, { signal: options.signal });
|
|
1535
1572
|
assertCodexAccountValidationReady(accountId);
|
|
1536
|
-
|
|
1573
|
+
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
|
|
1574
|
+
return bindPoolCreditPolicy({
|
|
1537
1575
|
kind: "pool",
|
|
1538
1576
|
accountId,
|
|
1539
1577
|
writerGeneration,
|
|
@@ -1548,11 +1586,12 @@ export async function resolveCodexAuthContext(
|
|
|
1548
1586
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1549
1587
|
...(affinityDecision ? { affinityDecision } : {}),
|
|
1550
1588
|
...(transientProbe ? { transientProbe } : {}),
|
|
1551
|
-
};
|
|
1589
|
+
}, config, policy);
|
|
1552
1590
|
} catch (cause) {
|
|
1553
1591
|
releaseTransientProbeGrant();
|
|
1554
1592
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1555
1593
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1594
|
+
if (cause instanceof CodexPoolAccountCreditsOffError) throw cause;
|
|
1556
1595
|
if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
1557
1596
|
markAccountNeedsReauth(accountId, writerGeneration);
|
|
1558
1597
|
}
|
|
@@ -1562,6 +1601,7 @@ export async function resolveCodexAuthContext(
|
|
|
1562
1601
|
|
|
1563
1602
|
export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
|
|
1564
1603
|
if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
|
|
1604
|
+
if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
|
|
1565
1605
|
// A context holding the probe lease was deliberately admitted through the cooldown.
|
|
1566
1606
|
if (ctx.probeLeaseId) return;
|
|
1567
1607
|
const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope);
|
|
@@ -1577,6 +1617,7 @@ export function applyCodexAuthContextToProvider(
|
|
|
1577
1617
|
): OcxRuntimeProviderConfig {
|
|
1578
1618
|
if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
|
|
1579
1619
|
assertCodexAccountValidationReady(ctx.accountId);
|
|
1620
|
+
if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
|
|
1580
1621
|
return {
|
|
1581
1622
|
...provider,
|
|
1582
1623
|
_codexAccountOverride: {
|
|
@@ -1620,6 +1661,7 @@ export function materializeCodexUpstreamAuth(
|
|
|
1620
1661
|
}
|
|
1621
1662
|
if (ctx.kind === "pool" || ctx.kind === "main-pool") {
|
|
1622
1663
|
assertCodexAccountValidationReady(ctx.accountId);
|
|
1664
|
+
if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, options.config));
|
|
1623
1665
|
selected.set("authorization", `Bearer ${ctx.accessToken}`);
|
|
1624
1666
|
selected.set("chatgpt-account-id", ctx.chatgptAccountId);
|
|
1625
1667
|
if (ctx.kind === "main-pool") {
|
|
@@ -10,6 +10,16 @@ export type StartupProtection = "service" | "desktop" | "shim" | "none";
|
|
|
10
10
|
export type StartupHealthStatus = "native" | "protected" | "at-risk";
|
|
11
11
|
export type ShimCoverage = "full" | "cli-only" | "none";
|
|
12
12
|
|
|
13
|
+
/** Bound the isolated service-manager probe and let its reader outlive that probe. */
|
|
14
|
+
export function startupHealthProbeBudgetMs(platform: NodeJS.Platform = process.platform): number {
|
|
15
|
+
return platform === "win32" ? 15_000 : 5_000;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function startupHealthReadBudgetMs(platform: NodeJS.Platform = process.platform): number {
|
|
19
|
+
// The endpoint waits an extra 500ms for child settlement; reserve another second for HTTP.
|
|
20
|
+
return startupHealthProbeBudgetMs(platform) + 1_500;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
export interface StartupHealthInputs {
|
|
14
24
|
desktop?: DesktopStartupDiagnostic;
|
|
15
25
|
routingKind: CodexRoutingKind;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import { codexUsageLimitResetAt, isCodexAccountHeldForCredits } from "./account-credit-use";
|
|
3
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
4
|
+
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
5
|
+
import { getAccountQuota } from "./quota";
|
|
6
|
+
|
|
7
|
+
export type PoolCreditPolicyConfig = Readonly<Pick<OcxConfig, "creditCodexAccountIds" | "codexAccounts">>;
|
|
8
|
+
type PoolContext = { kind: "pool"; accountId: string };
|
|
9
|
+
|
|
10
|
+
// Keep live policy references private: they must not enter DTOs or serialized auth context.
|
|
11
|
+
const policies = new WeakMap<object, {
|
|
12
|
+
config: PoolCreditPolicyConfig;
|
|
13
|
+
policy: PoolCreditPolicyConfig;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
/** Read cached usage only. This helper never opens or refreshes a credential. */
|
|
17
|
+
export function poolCreditHoldResetAt(
|
|
18
|
+
policy: PoolCreditPolicyConfig,
|
|
19
|
+
accountId: string,
|
|
20
|
+
catalog: PoolCreditPolicyConfig = policy,
|
|
21
|
+
now = Date.now(),
|
|
22
|
+
): number | undefined {
|
|
23
|
+
// Native main has an independent identity-bound quota policy. Never read its physical plan.
|
|
24
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return undefined;
|
|
25
|
+
const quota = getAccountQuota(accountId);
|
|
26
|
+
const plan = catalog.codexAccounts
|
|
27
|
+
?.find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
|
|
28
|
+
if (!isCodexAccountHeldForCredits(policy, accountId, quota, plan, now)) return undefined;
|
|
29
|
+
return codexUsageLimitResetAt(quota, plan, now);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Remember policy by reference so later credential materialization rechecks current values. */
|
|
33
|
+
export function bindPoolCreditPolicy<T extends PoolContext>(
|
|
34
|
+
context: T,
|
|
35
|
+
config: PoolCreditPolicyConfig,
|
|
36
|
+
policy: PoolCreditPolicyConfig = config,
|
|
37
|
+
): T {
|
|
38
|
+
policies.set(context, { config, policy });
|
|
39
|
+
return context;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Carry the resolver's live policy onto a context rebuilt by spread copy. The WeakMap keys on
|
|
44
|
+
* object identity, so `{ ...ctx }` alone loses the binding and every caller that omits an
|
|
45
|
+
* explicit override would skip credit-hold enforcement on the copy. A source with no binding
|
|
46
|
+
* leaves the target unbound rather than inventing a policy.
|
|
47
|
+
*/
|
|
48
|
+
export function rebindPoolCreditPolicy<S extends PoolContext, T extends object>(source: S, target: T): T {
|
|
49
|
+
const bound = policies.get(source);
|
|
50
|
+
if (bound) policies.set(target, bound);
|
|
51
|
+
return target;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Explicit policy wins; omitted materialization options retain the resolver's policy. */
|
|
55
|
+
export function poolContextCreditHoldResetAt(
|
|
56
|
+
context: PoolContext,
|
|
57
|
+
override?: PoolCreditPolicyConfig,
|
|
58
|
+
now = Date.now(),
|
|
59
|
+
): number | undefined {
|
|
60
|
+
const bound = policies.get(context);
|
|
61
|
+
const policy = override ?? bound?.policy;
|
|
62
|
+
// Unbound contexts skip credit-hold enforcement entirely: callers that require
|
|
63
|
+
// enforcement must supply `override` (or resolve through bindPoolCreditPolicy).
|
|
64
|
+
if (!policy) return undefined;
|
|
65
|
+
const catalog = override?.codexAccounts ? override : bound?.config ?? policy;
|
|
66
|
+
return poolCreditHoldResetAt(policy, context.accountId, catalog, now);
|
|
67
|
+
}
|