@bitkyc08/opencodex 2.48.0 → 2.50.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/AGENTS_INSTALL.md +9 -1
- package/README.md +11 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +29 -2
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +113 -26
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +96 -7
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +144 -11
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +128 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +71 -15
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +59 -0
- package/src/generated/compatibility-version.json +178 -114
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +140 -1
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +30 -13
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +17 -7
- package/src/server/images.ts +3 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +5 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +33 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +15 -1
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +16 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +285 -57
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +18 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +128 -9
- package/src/storage/cleanup.ts +77 -45
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +43 -1
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
|
@@ -21,7 +21,7 @@ import { MAX_COST4_RATE } from "../usage/expected-prices";
|
|
|
21
21
|
import { isValidCost4Rate } from "../usage/user-cost-overlays";
|
|
22
22
|
|
|
23
23
|
const USAGE = `Usage:
|
|
24
|
-
ocx models live [--provider <name>] [--json]
|
|
24
|
+
ocx models live [--provider <name>] [--free-only] [--json]
|
|
25
25
|
ocx models price <provider/model> [--json]
|
|
26
26
|
ocx models set-price <provider/model> --input N --output N [--cache-read N] [--cache-write N] [--json]
|
|
27
27
|
ocx models set-price <provider/model> --auto [--json]
|
|
@@ -52,17 +52,22 @@ type ModelRow = {
|
|
|
52
52
|
custom?: boolean;
|
|
53
53
|
customId?: string;
|
|
54
54
|
displayName?: string;
|
|
55
|
+
pricingStatus?: "free" | "paid";
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
async function live(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
58
59
|
const args = [...argv];
|
|
59
60
|
const wantsJson = takeFlag(args, "--json");
|
|
60
61
|
const provider = takeOption(args, "--provider");
|
|
62
|
+
// Absent pricingStatus means the provider published no usable per-token pair, so it is
|
|
63
|
+
// excluded here for the same fail-closed reason the classifier omits it (#3666).
|
|
64
|
+
const freeOnly = takeFlag(args, "--free-only");
|
|
61
65
|
rejectArgs(args, USAGE);
|
|
62
66
|
const rows = await runtimeRequest<ModelRow[]>("/api/models", {}, deps);
|
|
63
|
-
const
|
|
67
|
+
const byProvider = provider ? rows.filter(row => row.provider === provider) : rows;
|
|
68
|
+
const filtered = freeOnly ? byProvider.filter(row => row.pricingStatus === "free") : byProvider;
|
|
64
69
|
printData(filtered, wantsJson, filtered.map(row => {
|
|
65
|
-
const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean);
|
|
70
|
+
const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.pricingStatus === "free" ? "free" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean);
|
|
66
71
|
return `${row.namespaced ?? `${row.provider}/${row.id}`} [${flags.join(", ")}]`;
|
|
67
72
|
}));
|
|
68
73
|
}
|
package/src/cli/observe.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { redactSecretString } from "../lib/redact";
|
|
|
17
17
|
|
|
18
18
|
const USAGE = `Usage:
|
|
19
19
|
ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
|
|
20
|
-
[--conversation <id>] [--limit <n>] [--follow] [--json|--jsonl]
|
|
20
|
+
[--conversation <id>] [--account <label>] [--limit <n>] [--follow] [--json|--jsonl]
|
|
21
21
|
ocx logs explain <request-id> [--json]
|
|
22
22
|
ocx logs rebuild-index
|
|
23
23
|
ocx logs index-status
|
|
@@ -58,7 +58,14 @@ function formatLog(row: LogEntry): string {
|
|
|
58
58
|
const conversation = typeof row.conversationId === "string" && row.conversationId.length > 0
|
|
59
59
|
? `conv=${row.conversationId}`
|
|
60
60
|
: "";
|
|
61
|
-
|
|
61
|
+
// The account label is printed for the same reason, and for one more: it is the answer to
|
|
62
|
+
// "which of my accounts served this?" (#4057). It is only ever the stable non-PII label the
|
|
63
|
+
// proxy already persists (`main`, `p<hex6>`, `o<hex6>`) — never an email, a key, or an
|
|
64
|
+
// upstream account id. Rows from a single-account provider carry no label and print none.
|
|
65
|
+
const account = typeof row.accountLogLabel === "string" && row.accountLogLabel.length > 0
|
|
66
|
+
? `acct=${row.accountLogLabel}`
|
|
67
|
+
: "";
|
|
68
|
+
return [time, String(status), route, duration, account, conversation].filter(Boolean).join(" ");
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
@@ -72,6 +79,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
72
79
|
// Both spellings, because the server accepts both (`request-log.ts:1032`) and an operator
|
|
73
80
|
// should not have to remember which one this surface wanted.
|
|
74
81
|
const conversationId = takeOption(args, "--conversation") ?? takeOption(args, "--conversationId");
|
|
82
|
+
// Server-side, so `--limit` caps the rows that MATCHED rather than the rows scanned; a
|
|
83
|
+
// client-side filter after a 200-row cap would silently hide older matches.
|
|
84
|
+
const account = takeOption(args, "--account");
|
|
75
85
|
const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200;
|
|
76
86
|
rejectArgs(args, USAGE);
|
|
77
87
|
if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE);
|
|
@@ -80,7 +90,7 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
80
90
|
}
|
|
81
91
|
let seen = new Set<string>();
|
|
82
92
|
do {
|
|
83
|
-
const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps);
|
|
93
|
+
const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, account, limit })}`, {}, deps);
|
|
84
94
|
const rows = logRows(data);
|
|
85
95
|
if (!follow && wantsJson) printData(data, true);
|
|
86
96
|
else {
|
|
@@ -204,7 +204,8 @@ async function presets(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
204
204
|
const rows = Array.isArray(result) ? result : result.providers ?? [];
|
|
205
205
|
printData(result, wantsJson, rows.map(row => {
|
|
206
206
|
const record = row as Record<string, unknown>;
|
|
207
|
-
|
|
207
|
+
const sponsor = record.sponsor ? ` (sponsor: ${String(record.sponsor)})` : "";
|
|
208
|
+
return `${String(record.id ?? record.name ?? "?")} ${String(record.label ?? record.adapter ?? "")}${sponsor}`.trimEnd();
|
|
208
209
|
}));
|
|
209
210
|
}
|
|
210
211
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -36,8 +36,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
name: "recover-history",
|
|
39
|
-
usage: "ocx recover-history --legacy-openai --yes",
|
|
40
|
-
summary: "
|
|
39
|
+
usage: "ocx recover-history (--legacy-openai | --ocx-compaction <thread-id>) --yes",
|
|
40
|
+
summary: "Recover legacy provider metadata or one OpenCodeX-compacted thread for native replay.",
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
name: "uninstall",
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
const USAGE = `Usage:
|
|
15
15
|
ocx system [status] [--json]
|
|
16
16
|
ocx system settings [--auto-start <on|off>] [--stream-mode <auto|legacy-tee|eager-relay>]
|
|
17
|
-
[--desktop-authless <on|off>] [--json]
|
|
17
|
+
[--desktop-authless <on|off>] [--client-compaction <on|off>] [--json]
|
|
18
18
|
ocx system startup <health|install-service|install-shim> [--json]
|
|
19
19
|
ocx system diagnostics [--json]
|
|
20
20
|
ocx system sync [--json]
|
|
@@ -23,7 +23,11 @@ const USAGE = `Usage:
|
|
|
23
23
|
ocx system codex-cli-update check [--json]
|
|
24
24
|
ocx system update check [--channel <latest|preview>] [--json]
|
|
25
25
|
ocx system update run [--channel <latest|preview>] [--restart <on|off>] --yes [--json]
|
|
26
|
-
ocx system update status <job-id> [--json]
|
|
26
|
+
ocx system update status <job-id> [--json]
|
|
27
|
+
|
|
28
|
+
--client-compaction favors native replay portability for future compactions while
|
|
29
|
+
keeping OpenCodeX routing active; the configured provider may process summaries
|
|
30
|
+
and consume its quota.`;
|
|
27
31
|
|
|
28
32
|
async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
29
33
|
const args = [...argv];
|
|
@@ -44,8 +48,10 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
44
48
|
const autoStart = takeBooleanOption(args, "--auto-start");
|
|
45
49
|
const streamMode = takeOption(args, "--stream-mode");
|
|
46
50
|
const desktopAuthless = takeBooleanOption(args, "--desktop-authless");
|
|
51
|
+
const clientCompaction = takeBooleanOption(args, "--client-compaction");
|
|
47
52
|
rejectArgs(args, USAGE);
|
|
48
|
-
if (autoStart === undefined && streamMode === undefined
|
|
53
|
+
if (autoStart === undefined && streamMode === undefined
|
|
54
|
+
&& desktopAuthless === undefined && clientCompaction === undefined) {
|
|
49
55
|
const result = await runtimeRequest("/api/settings", {}, deps);
|
|
50
56
|
printData(result, wantsJson, summaryLines(result));
|
|
51
57
|
return;
|
|
@@ -54,6 +60,7 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
54
60
|
...(autoStart !== undefined ? { codexAutoStart: autoStart } : {}),
|
|
55
61
|
...(streamMode !== undefined ? { streamMode } : {}),
|
|
56
62
|
...(desktopAuthless !== undefined ? { codexDesktopAuthless: desktopAuthless } : {}),
|
|
63
|
+
...(clientCompaction !== undefined ? { codexClientCompaction: clientCompaction } : {}),
|
|
57
64
|
};
|
|
58
65
|
const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps);
|
|
59
66
|
printData(result, wantsJson, ["System settings updated."]);
|
package/src/cli/usage-report.ts
CHANGED
|
@@ -59,8 +59,11 @@ interface UsageReportInput {
|
|
|
59
59
|
|
|
60
60
|
const MAX_MODEL_ROWS = 10;
|
|
61
61
|
|
|
62
|
-
function terminalText(value:
|
|
63
|
-
|
|
62
|
+
function terminalText(value: unknown): string {
|
|
63
|
+
const text = typeof value === "string" ? value
|
|
64
|
+
: value === null || value === undefined ? ""
|
|
65
|
+
: typeof value === "number" || typeof value === "boolean" ? String(value) : "[invalid]";
|
|
66
|
+
return text.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, character => {
|
|
64
67
|
const code = character.charCodeAt(0);
|
|
65
68
|
return code <= 0x7f
|
|
66
69
|
? `\\x${code.toString(16).padStart(2, "0")}`
|
|
@@ -69,7 +72,8 @@ function terminalText(value: string): string {
|
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
function count(value: number | undefined): string {
|
|
72
|
-
|
|
75
|
+
if (value === undefined || value === null) return "0";
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US") : "—";
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
/**
|
|
@@ -111,7 +115,7 @@ export function formatUsageReport(data: UsageReportInput): string[] {
|
|
|
111
115
|
.filter(Boolean).join(" and ");
|
|
112
116
|
lines.push(`No usage recorded for ${terminalText(what)} in this range.`);
|
|
113
117
|
lines.push("Check the spelling against `ocx usage --json`, or widen --range.");
|
|
114
|
-
return lines;
|
|
118
|
+
return lines.map(terminalText);
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
const tokenSplit = [
|
|
@@ -185,5 +189,5 @@ export function formatUsageReport(data: UsageReportInput): string[] {
|
|
|
185
189
|
|
|
186
190
|
lines.push("");
|
|
187
191
|
lines.push("Not a billing receipt. Subscription usage or provider credits may apply instead.");
|
|
188
|
-
return lines;
|
|
192
|
+
return lines.map(terminalText);
|
|
189
193
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// ZCode config export.
|
|
2
2
|
import type { ExportContext, ManagedContribution } from "./contracts";
|
|
3
3
|
import { normalizeExportModels, inputModalitiesForClient, exportModelLabel, authoritativeContextWindow, singleFragment } from "./model-metadata";
|
|
4
|
+
import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
|
|
4
5
|
import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants";
|
|
5
6
|
|
|
6
7
|
|
|
@@ -15,6 +16,16 @@ export interface ZcodeModelEntry {
|
|
|
15
16
|
name?: string;
|
|
16
17
|
limit?: { context: number; output?: number };
|
|
17
18
|
modalities: { input: string[]; output: string[] };
|
|
19
|
+
/**
|
|
20
|
+
* On-disk Thought Level block. ZCode 3.x persists `variants`/`defaultVariant`
|
|
21
|
+
* and parses them into in-memory `levels`/`defaultLevel`. Omit the field when
|
|
22
|
+
* the catalog has no selectable ladder, so the picker stays hidden.
|
|
23
|
+
*/
|
|
24
|
+
reasoning?: {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
variants: string[];
|
|
27
|
+
defaultVariant?: string;
|
|
28
|
+
};
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export interface ZcodeProviderBlock {
|
|
@@ -61,6 +72,19 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig
|
|
|
61
72
|
if (context !== undefined) {
|
|
62
73
|
entry.limit = { context };
|
|
63
74
|
}
|
|
75
|
+
// `none` is a Codex omit-sentinel, not a ZCode picker option. Keep catalog
|
|
76
|
+
// `ultra` when present: ZCode forwards the selected variant as
|
|
77
|
+
// `reasoning_effort`. Set `defaultVariant` only when it survives that filter.
|
|
78
|
+
const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts)
|
|
79
|
+
?.filter(effort => effort !== "none");
|
|
80
|
+
if (efforts && efforts.length > 0) {
|
|
81
|
+
const defaultVariant = model.defaultReasoningEffort?.trim().toLowerCase();
|
|
82
|
+
entry.reasoning = {
|
|
83
|
+
enabled: true,
|
|
84
|
+
variants: efforts,
|
|
85
|
+
...(defaultVariant && efforts.includes(defaultVariant) ? { defaultVariant } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
64
88
|
models[model.namespaced] = entry;
|
|
65
89
|
}
|
|
66
90
|
return {
|
|
@@ -7,12 +7,13 @@ import {
|
|
|
7
7
|
} from "../config";
|
|
8
8
|
import { removeCodexAccountCredential } from "./account-store";
|
|
9
9
|
import { clearAccountNeedsReauth } from "./account-runtime-state";
|
|
10
|
-
import { getMainChatgptAccountId } from "./auth-collision";
|
|
10
|
+
import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision";
|
|
11
11
|
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
12
12
|
import { clearAccountQuota } from "./quota";
|
|
13
13
|
import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
|
|
14
14
|
import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
|
|
15
|
-
import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache";
|
|
15
|
+
import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache";
|
|
16
|
+
import { extractAccountIdClaims } from "../oauth/chatgpt";
|
|
16
17
|
import { forgetCodexAccountPause } from "./account-pause";
|
|
17
18
|
import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
|
|
18
19
|
import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state";
|
|
@@ -75,6 +76,38 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
|
|
|
75
76
|
return true;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Rebuild the memory-only policy binding from a startup-owned, recovered auth path.
|
|
81
|
+
* The caller holds the native owner and exclusive claim; an incoming bearer is never evidence.
|
|
82
|
+
* A failed read creates no binding and cannot revoke a prior verified observation or its block.
|
|
83
|
+
* Only a valid replacement observation or confirmed account transition supersedes that evidence.
|
|
84
|
+
*/
|
|
85
|
+
export function initializeMainAccountPolicyBinding(authPath: string): boolean {
|
|
86
|
+
// Startup observes the pinned owned path inside the exclusive claim: bound the read so a
|
|
87
|
+
// replaced non-regular or oversized file cannot stall startup inside that claim.
|
|
88
|
+
const result = readCodexTokensResult(authPath, { bounded: true });
|
|
89
|
+
if (result.status !== "ok") return false;
|
|
90
|
+
const { tokens } = result;
|
|
91
|
+
if (typeof tokens.access_token !== "string" || !tokens.access_token
|
|
92
|
+
|| typeof tokens.account_id !== "string" || !tokens.account_id) return false;
|
|
93
|
+
if (tokens.id_token != null && typeof tokens.id_token !== "string") return false;
|
|
94
|
+
const accountId = tokens.account_id;
|
|
95
|
+
// An owned file may contain an opaque bearer, but every decoded identity must agree —
|
|
96
|
+
// including the two account-id encodings within a single token.
|
|
97
|
+
const idTokenClaims = extractAccountIdClaims(tokens.id_token);
|
|
98
|
+
const accessTokenClaims = extractAccountIdClaims(tokens.access_token);
|
|
99
|
+
if (idTokenClaims.conflict || accessTokenClaims.conflict) return false;
|
|
100
|
+
const idTokenAccountId = idTokenClaims.accountId;
|
|
101
|
+
const accessTokenAccountId = accessTokenClaims.accountId;
|
|
102
|
+
if ((idTokenAccountId !== undefined && idTokenAccountId !== accountId)
|
|
103
|
+
|| (accessTokenAccountId !== undefined && accessTokenAccountId !== accountId)) return false;
|
|
104
|
+
const previousAccountId = observedMainChatgptAccountId;
|
|
105
|
+
observedMainChatgptAccountId = accountId;
|
|
106
|
+
if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
|
|
107
|
+
observeMainQuotaIdentity(accountId);
|
|
108
|
+
return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
78
111
|
/**
|
|
79
112
|
* Apply a transaction-confirmed physical native-login change without waiting for
|
|
80
113
|
* a later auth.json observation. The caller owns credential commit/rollback.
|
|
@@ -60,6 +60,11 @@ export function isAccountNeedsReauth(id: string): boolean {
|
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export function clearAccountNeedsReauth(id: string): void {
|
|
63
|
+
export function clearAccountNeedsReauth(id: string, credentialGeneration?: number): void {
|
|
64
|
+
// A model response proves only the credential it used. Keep account-wide
|
|
65
|
+
// quarantine and evidence from another generation intact.
|
|
66
|
+
if (credentialGeneration !== undefined
|
|
67
|
+
&& (reauthAccounts.get(id) !== credentialGeneration
|
|
68
|
+
|| !isCodexAccountGenerationLive(id, credentialGeneration))) return;
|
|
64
69
|
reauthAccounts.delete(id);
|
|
65
70
|
}
|
|
@@ -58,7 +58,9 @@ function isCredentialRecord(value: unknown): value is CodexAccountCredentialReco
|
|
|
58
58
|
&& (value.replacedAt === undefined || typeof value.replacedAt === "number")
|
|
59
59
|
&& (value.lastCodexValidatedAt === undefined || typeof value.lastCodexValidatedAt === "number")
|
|
60
60
|
&& (value.lastCodexValidationStatus === undefined || value.lastCodexValidationStatus === "ok" || value.lastCodexValidationStatus === "failed")
|
|
61
|
-
&& (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string")
|
|
61
|
+
&& (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string")
|
|
62
|
+
&& (value.codexValidationPending === undefined || typeof value.codexValidationPending === "boolean")
|
|
63
|
+
&& (value.lastCodexValidationTerminal === undefined || typeof value.lastCodexValidationTerminal === "boolean");
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export function refreshGrantFingerprintForToken(refreshToken: string): string {
|
|
@@ -118,11 +120,21 @@ function persistCredentialMutation(store: CodexAccountStore): void {
|
|
|
118
120
|
advanceCodexCredentialMutationEpoch();
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Validation metadata that survives a credential write.
|
|
125
|
+
*
|
|
126
|
+
* `lastCodexValidationTerminal` is deliberately NOT in this list. Every credential write —
|
|
127
|
+
* re-login, the CAS refresh commit, same-grant alias propagation — rebuilds the record from this
|
|
128
|
+
* pick list, so leaving the marker out is what makes a successful refresh or a re-authentication
|
|
129
|
+
* erase a terminal verdict. Both events disprove "the grant was revoked", and a verdict that
|
|
130
|
+
* could only ever be set would brand an account dead forever on one spurious `invalid_grant`.
|
|
131
|
+
*/
|
|
121
132
|
function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
|
|
122
133
|
CodexAccountCredentialRecord,
|
|
123
|
-
"lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
|
|
134
|
+
"lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError" | "codexValidationPending"
|
|
124
135
|
> {
|
|
125
136
|
return {
|
|
137
|
+
...(record?.codexValidationPending === true ? { codexValidationPending: true } : {}),
|
|
126
138
|
...(record?.lastCodexValidatedAt !== undefined ? { lastCodexValidatedAt: record.lastCodexValidatedAt } : {}),
|
|
127
139
|
...(record?.lastCodexValidationStatus !== undefined ? { lastCodexValidationStatus: record.lastCodexValidationStatus } : {}),
|
|
128
140
|
...(record?.lastCodexValidationError !== undefined ? { lastCodexValidationError: record.lastCodexValidationError } : {}),
|
|
@@ -135,8 +147,12 @@ export function getCodexAccountCredential(id: string): CodexAccountCredentials |
|
|
|
135
147
|
return record.credential ?? null;
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
export function saveCodexAccountCredential(
|
|
139
|
-
|
|
150
|
+
export function saveCodexAccountCredential(
|
|
151
|
+
id: string,
|
|
152
|
+
cred: CodexAccountCredentials,
|
|
153
|
+
options: { validationPending?: boolean } = {},
|
|
154
|
+
): number {
|
|
155
|
+
return withCredentialMutationLockSync(() => {
|
|
140
156
|
const store = loadCodexAccountRecordStore();
|
|
141
157
|
const current = store[id];
|
|
142
158
|
const refreshGrantFingerprint = current?.credential?.refreshToken === cred.refreshToken
|
|
@@ -148,37 +164,84 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
|
|
|
148
164
|
refreshGrantFingerprint,
|
|
149
165
|
replacedAt: current ? Date.now() : undefined,
|
|
150
166
|
...preservedValidationMetadata(current),
|
|
167
|
+
...(options.validationPending ? {
|
|
168
|
+
codexValidationPending: true,
|
|
169
|
+
lastCodexValidatedAt: undefined,
|
|
170
|
+
lastCodexValidationStatus: undefined,
|
|
171
|
+
lastCodexValidationError: undefined,
|
|
172
|
+
} : {}),
|
|
151
173
|
};
|
|
152
174
|
persistCredentialMutation(store);
|
|
175
|
+
return store[id].generation;
|
|
153
176
|
});
|
|
154
177
|
}
|
|
155
178
|
|
|
156
|
-
export function markCodexAccountValidated(id: string, atMs: number = Date.now()): void {
|
|
179
|
+
export function markCodexAccountValidated(id: string, atMs: number = Date.now(), generation?: number): void {
|
|
157
180
|
withCredentialMutationLockSync(() => {
|
|
158
181
|
const store = loadCodexAccountRecordStore();
|
|
159
182
|
const current = store[id];
|
|
160
183
|
if (!current || current.deletedAt != null || !current.credential) return;
|
|
184
|
+
if (current.codexValidationPending && generation === undefined) return;
|
|
185
|
+
if (generation !== undefined && current.generation !== generation) return;
|
|
161
186
|
store[id] = {
|
|
162
187
|
...current,
|
|
163
188
|
lastCodexValidatedAt: atMs,
|
|
164
189
|
lastCodexValidationStatus: "ok",
|
|
165
190
|
lastCodexValidationError: undefined,
|
|
191
|
+
codexValidationPending: undefined,
|
|
192
|
+
// A completed validation is the direct refutation of a terminal verdict, and this
|
|
193
|
+
// spread would otherwise carry the old marker forward.
|
|
194
|
+
lastCodexValidationTerminal: undefined,
|
|
166
195
|
};
|
|
167
|
-
|
|
196
|
+
// Becoming routable invalidates credential-derived caches; a timestamp-only
|
|
197
|
+
// update on an already validated account preserves the existing epoch policy.
|
|
198
|
+
if (current.codexValidationPending) persistCredentialMutation(store);
|
|
199
|
+
else persist(store);
|
|
168
200
|
});
|
|
169
201
|
}
|
|
170
202
|
|
|
171
|
-
export
|
|
172
|
-
|
|
203
|
+
export interface MarkCodexAccountValidationFailedOptions {
|
|
204
|
+
/**
|
|
205
|
+
* Write only while the stored record is still at this generation.
|
|
206
|
+
*
|
|
207
|
+
* A validation attempt is not atomic with the store: an operator can re-authenticate the
|
|
208
|
+
* account, or another writer can commit a refresh, while a probe is still in flight. Without
|
|
209
|
+
* this fence the late failure lands on whatever credential happens to be there and brands a
|
|
210
|
+
* freshly installed one dead. Declining to write is the safe direction — the failure cannot be
|
|
211
|
+
* attributed to a credential the caller never observed.
|
|
212
|
+
*/
|
|
213
|
+
expectedGeneration?: number;
|
|
214
|
+
/** The grant itself is revoked or expired; only a re-login clears it. */
|
|
215
|
+
terminal?: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Returns whether the verdict was actually persisted (false when the fence declined it). */
|
|
219
|
+
export function markCodexAccountValidationFailed(
|
|
220
|
+
id: string,
|
|
221
|
+
reason: string,
|
|
222
|
+
options: MarkCodexAccountValidationFailedOptions = {},
|
|
223
|
+
): boolean {
|
|
224
|
+
return withCredentialMutationLockSync(() => {
|
|
173
225
|
const store = loadCodexAccountRecordStore();
|
|
174
226
|
const current = store[id];
|
|
175
|
-
if (!current || current.deletedAt != null || !current.credential) return;
|
|
227
|
+
if (!current || current.deletedAt != null || !current.credential) return false;
|
|
228
|
+
// Deferred validation is settled only by a caller that names the generation it observed.
|
|
229
|
+
// An unfenced write must never resolve a pending account, whichever verdict it carries.
|
|
230
|
+
if (current.codexValidationPending && options.expectedGeneration === undefined) return false;
|
|
231
|
+
if (options.expectedGeneration !== undefined && current.generation !== options.expectedGeneration) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
176
234
|
store[id] = {
|
|
177
235
|
...current,
|
|
178
236
|
lastCodexValidationStatus: "failed",
|
|
179
237
|
lastCodexValidationError: reason,
|
|
238
|
+
// Only ever set here. A transient failure must not clear a terminal marker set earlier,
|
|
239
|
+
// and it must not invent one either, so the flag is written only when the caller proves
|
|
240
|
+
// the grant is dead.
|
|
241
|
+
...(options.terminal ? { lastCodexValidationTerminal: true } : {}),
|
|
180
242
|
};
|
|
181
243
|
persist(store);
|
|
244
|
+
return true;
|
|
182
245
|
});
|
|
183
246
|
}
|
|
184
247
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readCodexAccountRecord } from "./account-store";
|
|
2
2
|
import { isAccountNeedsReauth } from "./account-runtime-state";
|
|
3
3
|
import {
|
|
4
4
|
MAIN_CODEX_ACCOUNT_ID,
|
|
@@ -48,5 +48,6 @@ export function isCodexAccountUsable(
|
|
|
48
48
|
.some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
|
|
49
49
|
if (!exists) return false;
|
|
50
50
|
if (isAccountNeedsReauth(accountId)) return false;
|
|
51
|
-
|
|
51
|
+
const record = readCodexAccountRecord(accountId);
|
|
52
|
+
return !!record?.credential && record.deletedAt == null && !record.codexValidationPending;
|
|
52
53
|
}
|