@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
package/src/images/loop.ts
CHANGED
|
@@ -537,6 +537,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
537
537
|
// hop-by-hop header alone (oven-sh/bun#20492).
|
|
538
538
|
return requestFetch(request.url, applyUpstreamRecoveryInit({
|
|
539
539
|
method: request.method,
|
|
540
|
+
redirect: "manual",
|
|
540
541
|
headers: h,
|
|
541
542
|
body: request.body,
|
|
542
543
|
signal: headerDeadline.signal,
|
|
@@ -80,6 +80,7 @@ export async function submitVideoJob(
|
|
|
80
80
|
|
|
81
81
|
const resp = await fetch(`${auth.baseUrl}/videos/generations`, {
|
|
82
82
|
method: "POST",
|
|
83
|
+
redirect: "manual",
|
|
83
84
|
headers: {
|
|
84
85
|
"Authorization": `Bearer ${auth.token}`,
|
|
85
86
|
"Content-Type": "application/json",
|
|
@@ -118,6 +119,7 @@ export async function pollVideoJob(
|
|
|
118
119
|
|
|
119
120
|
const resp = await fetch(`${auth.baseUrl}/videos/${encodeURIComponent(requestId)}`, {
|
|
120
121
|
method: "GET",
|
|
122
|
+
redirect: "manual",
|
|
121
123
|
headers: {
|
|
122
124
|
"Authorization": `Bearer ${auth.token}`,
|
|
123
125
|
},
|
|
@@ -190,6 +190,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
|
|
|
190
190
|
id: "hermes",
|
|
191
191
|
configPath: (env = process.env, home = homedir()) => hermesConfigPath(env, home),
|
|
192
192
|
detectDir: (env = process.env, home = homedir()) => hermesHomeDir(env, home),
|
|
193
|
+
sourcePreservingYaml: { path: ["providers", "opencodex"] },
|
|
193
194
|
},
|
|
194
195
|
openclaw: {
|
|
195
196
|
id: "openclaw",
|
package/src/lib/errors.ts
CHANGED
|
@@ -209,6 +209,14 @@ export function classifyError(status: number, type: string, message: string): Oc
|
|
|
209
209
|
if (type === "input_admission_refused") {
|
|
210
210
|
return { message, type: "invalid_request_error", code: "input_admission_refused" };
|
|
211
211
|
}
|
|
212
|
+
// A LOCAL inbound admission refusal (#3573) keeps its own code for the same reason as the
|
|
213
|
+
// preflight refusal above. #4112 gave the UPSTREAM 413 on this surface
|
|
214
|
+
// `context_length_exceeded`; without a distinct code here a client cannot tell a body the
|
|
215
|
+
// proxy never read from a turn the provider itself rejected, and only one of the two is
|
|
216
|
+
// fixed by raising `maxInboundBodyBytes`.
|
|
217
|
+
if (type === "inbound_body_too_large") {
|
|
218
|
+
return { message, type: "invalid_request_error", code: "inbound_body_too_large" };
|
|
219
|
+
}
|
|
212
220
|
if (
|
|
213
221
|
text.includes("context_length_exceeded") ||
|
|
214
222
|
text.includes("context window") ||
|
package/src/lib/privacy.ts
CHANGED
|
@@ -10,6 +10,31 @@ export function maskEmail(value: string | null | undefined): string | null {
|
|
|
10
10
|
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Whether stored account emails are masked in management and CLI projections (#3859).
|
|
15
|
+
*
|
|
16
|
+
* Fail closed on every ambiguity: a missing config, a missing `privacy` block, and a missing or
|
|
17
|
+
* malformed `maskEmails` all mask. Only the literal boolean `false` unmasks, so a hand-edited
|
|
18
|
+
* `"false"` string or a typo cannot silently disclose an address. Callers read this once at the
|
|
19
|
+
* request boundary and pass the answer down — the projection helpers take a boolean rather than
|
|
20
|
+
* a config so that `getLoginStatus` stays free of config I/O.
|
|
21
|
+
*/
|
|
22
|
+
export function emailMaskingEnabled(config: { privacy?: { maskEmails?: boolean } } | null | undefined): boolean {
|
|
23
|
+
return config?.privacy?.maskEmails !== false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Project a stored account email for a surface outside the proxy (#3859).
|
|
28
|
+
*
|
|
29
|
+
* One redaction decision for every call site, rather than each one forking on the flag: an
|
|
30
|
+
* unmasked projection still normalises an empty or absent address to `null`, so a consumer's
|
|
31
|
+
* "is there an email" test cannot start answering differently just because masking is off.
|
|
32
|
+
*/
|
|
33
|
+
export function projectEmail(value: string | null | undefined, mask: boolean): string | null {
|
|
34
|
+
if (!mask) return value ? value : null;
|
|
35
|
+
return maskEmail(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
export function maskAccountId(value: string | null | undefined): string | null {
|
|
14
39
|
if (!value) return null;
|
|
15
40
|
const id = value.trim();
|
|
@@ -66,12 +66,30 @@ export function gracefulStopHost(hostname: string | undefined): string {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
69
|
+
* `"refused"` forbids forced stop. `"teardown-unconfirmed"` means the process exited,
|
|
70
|
+
* but its assigned shared teardown was not confirmed; callers must not kill it again.
|
|
71
71
|
*/
|
|
72
|
-
export type GracefulStopResult = boolean | "refused";
|
|
72
|
+
export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed";
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* The server's own explanation for the most recent 409, captured so `stopProxy` can report
|
|
76
|
+
* the real reason. There is more than one: a scheduler wrapper under another home, or the
|
|
77
|
+
* proxy being the installed service itself (#4023). Module-scoped because
|
|
78
|
+
* `GracefulStopResult` is a public contract with several callers, and widening it to carry
|
|
79
|
+
* the text would change every one of them for a message only this file reports.
|
|
80
|
+
*/
|
|
81
|
+
let lastRefusalMessage: string | null = null;
|
|
82
|
+
|
|
83
|
+
/** The server's explanation for the most recent 409, or `null` when it sent none. */
|
|
84
|
+
export function lastStopRefusalMessage(): string | null {
|
|
85
|
+
return lastRefusalMessage;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a
|
|
90
|
+
* scheduler wrapper under another home, or the proxy being the installed service itself
|
|
91
|
+
* (#4023) — so the server's own message is carried through rather than guessed at.
|
|
92
|
+
*/
|
|
75
93
|
export class ProxyOwnershipRefusedError extends Error {}
|
|
76
94
|
|
|
77
95
|
/**
|
|
@@ -82,6 +100,8 @@ export class ProxyOwnershipRefusedError extends Error {}
|
|
|
82
100
|
* chance to run its shutdown handlers. Returns false when the proxy can't be reached
|
|
83
101
|
* or doesn't exit in time — callers fall back to {@link killProxy}. Returns `"refused"`
|
|
84
102
|
* when the proxy declines the stop (HTTP 409), which callers must NOT force past.
|
|
103
|
+
* True requires the expected shared-teardown response and an observed exit. It does not
|
|
104
|
+
* attest the process exit code or completion of every drain/shutdown hook.
|
|
85
105
|
*/
|
|
86
106
|
export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
|
|
87
107
|
const readRuntime = io.readRuntime ?? readRuntimePort;
|
|
@@ -92,6 +112,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
92
112
|
const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
|
|
93
113
|
if (token) headers["x-opencodex-api-key"] = token;
|
|
94
114
|
const fetchFn = io.fetchFn ?? fetch;
|
|
115
|
+
let sharedTeardownConfirmed = false;
|
|
95
116
|
try {
|
|
96
117
|
// `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
|
|
97
118
|
// after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
|
|
@@ -111,8 +132,23 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
111
132
|
// would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to
|
|
112
133
|
// SIGTERM here would run the daemon's cleanup and strip shared config out from under the
|
|
113
134
|
// still-running service. Report the refusal instead of forcing.
|
|
114
|
-
if (res.status === 409)
|
|
135
|
+
if (res.status === 409) {
|
|
136
|
+
lastRefusalMessage = await res.json()
|
|
137
|
+
.then(body => {
|
|
138
|
+
const message = (body as { message?: unknown } | null)?.message;
|
|
139
|
+
return typeof message === "string" && message.trim() ? message.trim() : null;
|
|
140
|
+
})
|
|
141
|
+
.catch(() => null);
|
|
142
|
+
return "refused";
|
|
143
|
+
}
|
|
115
144
|
if (!res.ok) return false;
|
|
145
|
+
const body: unknown = await res.json().catch(() => null);
|
|
146
|
+
const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
|
|
147
|
+
sharedTeardownConfirmed = body !== null
|
|
148
|
+
&& typeof body === "object"
|
|
149
|
+
&& !Array.isArray(body)
|
|
150
|
+
&& "success" in body && body.success === true
|
|
151
|
+
&& "sharedTeardown" in body && body.sharedTeardown === expectedTeardown;
|
|
116
152
|
} catch {
|
|
117
153
|
return false;
|
|
118
154
|
}
|
|
@@ -120,7 +156,8 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
120
156
|
// Honor the server's own drain window: /api/stop answers 200 first, then drains for
|
|
121
157
|
// config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain.
|
|
122
158
|
const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs();
|
|
123
|
-
|
|
159
|
+
if (!waitExit(pid, exitTimeoutMs)) return false;
|
|
160
|
+
return sharedTeardownConfirmed ? true : "teardown-unconfirmed";
|
|
124
161
|
}
|
|
125
162
|
|
|
126
163
|
function drainDeadlineMs(): number {
|
|
@@ -140,10 +177,17 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<b
|
|
|
140
177
|
// The proxy refused on purpose (foreign service owns it). Forcing would strip shared
|
|
141
178
|
// config while that service keeps the proxy alive.
|
|
142
179
|
throw new ProxyOwnershipRefusedError(
|
|
143
|
-
|
|
144
|
-
|
|
180
|
+
lastRefusalMessage
|
|
181
|
+
?? "The running proxy refused to stop: a service installed under a different "
|
|
182
|
+
+ "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
|
|
145
183
|
);
|
|
146
184
|
}
|
|
185
|
+
if (graceful === "teardown-unconfirmed") {
|
|
186
|
+
// Exit was observed, so do not enter the forced-stop fallback. Returning false keeps
|
|
187
|
+
// shared restoration with `ocx stop` instead of claiming that the proxy completed it.
|
|
188
|
+
await waitForStoppedPort(runtime, pid);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
147
191
|
if (graceful) {
|
|
148
192
|
await waitForStoppedPort(runtime, pid);
|
|
149
193
|
return true;
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -40,6 +40,62 @@ export function extractAccountId(idToken?: string, accessToken?: string): string
|
|
|
40
40
|
return undefined;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Three-way answer to "is this token marked as belonging to the ChatGPT account domain".
|
|
45
|
+
* Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the
|
|
46
|
+
* https://api.openai.com/auth namespace claim. A generic organizations claim is NOT domain
|
|
47
|
+
* evidence. JWT claims are decoded locally as routing markers, never as authenticity proof.
|
|
48
|
+
*
|
|
49
|
+
* absent — no JWT, a payload that is not a JSON object, or an object carrying neither
|
|
50
|
+
* marker key: the token may be a foreign credential and legacy foreign handling
|
|
51
|
+
* applies. This function is total; it never throws on an attacker-shaped token.
|
|
52
|
+
* invalid — a marker key is present but yields no usable account id (non-string, blank,
|
|
53
|
+
* namespace that is not an object, namespace without the claim) or the two
|
|
54
|
+
* markers disagree. Presence is decided by the KEY, not by its shape, so a token
|
|
55
|
+
* that claims this domain can never fall through to foreign handling just
|
|
56
|
+
* because its marker is malformed.
|
|
57
|
+
* valid — one consistent, non-blank ChatGPT account id.
|
|
58
|
+
*/
|
|
59
|
+
export type ChatGptDomainClaim =
|
|
60
|
+
| { kind: "absent" }
|
|
61
|
+
| { kind: "invalid" }
|
|
62
|
+
| { kind: "valid"; accountId: string };
|
|
63
|
+
|
|
64
|
+
const CHATGPT_AUTH_NAMESPACE = "https://api.openai.com/auth";
|
|
65
|
+
|
|
66
|
+
/** A usable account id is a non-blank string; blank or non-string values are malformed. */
|
|
67
|
+
function usableAccountId(value: unknown): string | undefined {
|
|
68
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim {
|
|
72
|
+
const payload: unknown = decodeJwtPayload(token);
|
|
73
|
+
// decodeJwtPayload returns whatever the payload segment parses to, which may be a
|
|
74
|
+
// primitive or an array. Those carry no marker and must not reach the key lookups,
|
|
75
|
+
// where `in`/hasOwn would throw and take the whole request down.
|
|
76
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "absent" };
|
|
77
|
+
const claims = payload as Record<string, unknown>;
|
|
78
|
+
// Presence is the KEY being there, as an own key. A reserved namespace that is null, a
|
|
79
|
+
// primitive, an array, or an object without the claim is a present-but-broken marker, so
|
|
80
|
+
// it stays invalid instead of being treated as a foreign token.
|
|
81
|
+
const topPresent = Object.hasOwn(claims, "chatgpt_account_id");
|
|
82
|
+
const nsPresent = Object.hasOwn(claims, CHATGPT_AUTH_NAMESPACE);
|
|
83
|
+
if (!topPresent && !nsPresent) return { kind: "absent" };
|
|
84
|
+
const topId = topPresent ? usableAccountId(claims.chatgpt_account_id) : undefined;
|
|
85
|
+
if (topPresent && !topId) return { kind: "invalid" };
|
|
86
|
+
let nsId: string | undefined;
|
|
87
|
+
if (nsPresent) {
|
|
88
|
+
const ns = claims[CHATGPT_AUTH_NAMESPACE];
|
|
89
|
+
const nsObj = ns !== null && typeof ns === "object" && !Array.isArray(ns)
|
|
90
|
+
? ns as Record<string, unknown> : undefined;
|
|
91
|
+
nsId = nsObj ? usableAccountId(nsObj.chatgpt_account_id) : undefined;
|
|
92
|
+
if (!nsId) return { kind: "invalid" };
|
|
93
|
+
}
|
|
94
|
+
if (topId && nsId && topId !== nsId) return { kind: "invalid" };
|
|
95
|
+
const accountId = topId ?? nsId;
|
|
96
|
+
return accountId ? { kind: "valid", accountId } : { kind: "invalid" };
|
|
97
|
+
}
|
|
98
|
+
|
|
43
99
|
export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
|
|
44
100
|
for (const token of [idToken, accessToken]) {
|
|
45
101
|
if (!token) continue;
|
|
@@ -50,6 +106,33 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
|
|
|
50
106
|
return undefined;
|
|
51
107
|
}
|
|
52
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the
|
|
111
|
+
* existing extractAccountId precedence (top-level, then namespaced, then organizations[0]).
|
|
112
|
+
* `conflict` is true only when the two chatgpt_account_id encodings are both present and
|
|
113
|
+
* disagree — organizations entries are workspace memberships, not identity, so they never
|
|
114
|
+
* participate. Never logs token material.
|
|
115
|
+
*/
|
|
116
|
+
export function extractAccountIdClaims(token?: string): { accountId: string | undefined; conflict: boolean } {
|
|
117
|
+
if (!token) return { accountId: undefined, conflict: false };
|
|
118
|
+
const payload = decodeJwtPayload(token);
|
|
119
|
+
if (!payload) return { accountId: undefined, conflict: false };
|
|
120
|
+
const top = typeof payload.chatgpt_account_id === "string" ? payload.chatgpt_account_id : undefined;
|
|
121
|
+
const ns = payload["https://api.openai.com/auth"];
|
|
122
|
+
const namespaced = ns && typeof ns === "object"
|
|
123
|
+
&& typeof (ns as Record<string, unknown>).chatgpt_account_id === "string"
|
|
124
|
+
? (ns as Record<string, unknown>).chatgpt_account_id as string
|
|
125
|
+
: undefined;
|
|
126
|
+
const orgs = payload.organizations;
|
|
127
|
+
const org = Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string"
|
|
128
|
+
? orgs[0].id as string
|
|
129
|
+
: undefined;
|
|
130
|
+
return {
|
|
131
|
+
accountId: top ?? namespaced ?? org,
|
|
132
|
+
conflict: top !== undefined && namespaced !== undefined && top !== namespaced,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
53
136
|
export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
54
137
|
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
|
|
55
138
|
// This parses a response from an external boundary, so the access token is
|
package/src/oauth/health.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing";
|
|
2
2
|
import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
|
|
3
3
|
import { isAccountNeedsReauth } from "../codex/account-runtime-state";
|
|
4
|
-
import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store";
|
|
4
|
+
import { getCodexAccountCredential, listCodexAccountIds, readCodexAccountRecord } from "../codex/account-store";
|
|
5
5
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
6
6
|
import { readRuntimePort } from "../config/process-state";
|
|
7
7
|
import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability";
|
|
@@ -15,7 +15,7 @@ export type OAuthAccountHealth =
|
|
|
15
15
|
| { status: "healthy" }
|
|
16
16
|
| { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
|
|
17
17
|
| { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" }
|
|
18
|
-
| { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" };
|
|
18
|
+
| { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" | "validation_pending" };
|
|
19
19
|
|
|
20
20
|
export type OAuthHealthLabel =
|
|
21
21
|
| "Healthy"
|
|
@@ -24,7 +24,8 @@ export type OAuthHealthLabel =
|
|
|
24
24
|
| "Reauthentication required"
|
|
25
25
|
| "Refresh failed"
|
|
26
26
|
| "Metadata mismatch"
|
|
27
|
-
| "Credential conflict"
|
|
27
|
+
| "Credential conflict"
|
|
28
|
+
| "Validation pending";
|
|
28
29
|
|
|
29
30
|
/** Shared masked-id fallback when `maskAccountId` returns nullish. */
|
|
30
31
|
export const MASKED_ACCOUNT_FALLBACK = "account-…????";
|
|
@@ -88,6 +89,9 @@ export function projectOAuthAccountHealth(input: {
|
|
|
88
89
|
export const CODEX_REAUTH_ACTION = "reauthenticate via the dashboard Codex account pool";
|
|
89
90
|
|
|
90
91
|
function actionFor(provider: string, health: OAuthAccountHealth): string | undefined {
|
|
92
|
+
if (health.status === "warning" && health.reason === "validation_pending") {
|
|
93
|
+
return "wait for quota recovery, then click Refresh quotas in the dashboard Codex account pool to finish validation";
|
|
94
|
+
}
|
|
91
95
|
if (health.status === "reauth_required") {
|
|
92
96
|
if (provider === "codex") return CODEX_REAUTH_ACTION;
|
|
93
97
|
return `run \`ocx login ${provider}\``;
|
|
@@ -112,6 +116,8 @@ export function oauthHealthLabel(health: OAuthAccountHealth): OAuthHealthLabel {
|
|
|
112
116
|
return health.reason === "refresh_failed" ? "Refresh failed" : "Reauthentication required";
|
|
113
117
|
case "warning":
|
|
114
118
|
switch (health.reason) {
|
|
119
|
+
case "validation_pending":
|
|
120
|
+
return "Validation pending";
|
|
115
121
|
case "refresh_conflict":
|
|
116
122
|
return "Credential conflict";
|
|
117
123
|
case "metadata_mismatch":
|
|
@@ -198,11 +204,42 @@ export function projectCodexAccountHealth(input: {
|
|
|
198
204
|
needsReauth: boolean;
|
|
199
205
|
now?: number;
|
|
200
206
|
}): OAuthAccountHealth {
|
|
207
|
+
// One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
|
|
208
|
+
// file, and the main account lives in the native Codex auth file rather than the pool store,
|
|
209
|
+
// so a lookup for it could only ever miss.
|
|
210
|
+
const stored = input.accountId !== MAIN_CODEX_ACCOUNT_ID ? readCodexAccountRecord(input.accountId) : null;
|
|
211
|
+
const record = stored?.deletedAt == null ? stored : null;
|
|
212
|
+
|
|
213
|
+
// A successful quota read is not evidence that model authorization recovered.
|
|
214
|
+
// Preserve this guidance until validation succeeds or reauthentication replaces it.
|
|
215
|
+
const validationAuthFailed = record !== null
|
|
216
|
+
&& record.codexValidationPending === true
|
|
217
|
+
&& record.lastCodexValidationStatus === "failed"
|
|
218
|
+
&& (record.lastCodexValidationError === "http_status:401" || record.lastCodexValidationError === "http_status:403");
|
|
219
|
+
|
|
220
|
+
// A persisted terminal verdict outranks the in-memory reauth flag rather than duplicating it:
|
|
221
|
+
// the flag lives in this process and a revoked grant does not. Without it, an account whose
|
|
222
|
+
// grant was revoked upstream keeps its login-time `lastCodexValidationStatus: "ok"` and every
|
|
223
|
+
// surface reports it healthy until someone tries to use it (#4120). Only a re-login clears the
|
|
224
|
+
// marker, so `reauth_required` is the accurate projection — and it is deliberately checked
|
|
225
|
+
// ahead of any cooldown, because telling an operator to wait out a rate limit on a credential
|
|
226
|
+
// that will never work again is a false promise.
|
|
227
|
+
const terminalGrantFailure = record !== null
|
|
228
|
+
&& record.lastCodexValidationTerminal === true
|
|
229
|
+
&& record.lastCodexValidationStatus === "failed";
|
|
230
|
+
|
|
231
|
+
const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure;
|
|
232
|
+
|
|
233
|
+
// Deferred validation is only worth reporting while the credential itself is still viable. A
|
|
234
|
+
// revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first.
|
|
235
|
+
if (!needsReauth && record?.codexValidationPending) {
|
|
236
|
+
return { status: "warning", reason: "validation_pending" };
|
|
237
|
+
}
|
|
201
238
|
const now = input.now ?? Date.now();
|
|
202
239
|
const snap = getCodexAccountHealthSnapshot(input.accountId, now);
|
|
203
240
|
return projectOAuthAccountHealth({
|
|
204
|
-
needsReauth
|
|
205
|
-
reauthReason:
|
|
241
|
+
needsReauth,
|
|
242
|
+
reauthReason: needsReauth ? "refresh_failed" : undefined,
|
|
206
243
|
cooldownUntilMs: snap?.cooldownUntil,
|
|
207
244
|
cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
|
|
208
245
|
now,
|
|
@@ -272,13 +309,11 @@ function collectLocalCodexEntries(now: number): OAuthHealthEntry[] {
|
|
|
272
309
|
const hasPoolCredential = accountId !== MAIN_CODEX_ACCOUNT_ID && getCodexAccountCredential(accountId) !== null;
|
|
273
310
|
if (!hasPoolCredential && !needsReauth && !snap) continue;
|
|
274
311
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
now,
|
|
281
|
-
});
|
|
312
|
+
// Call the projector rather than inlining a second copy of it. This collector serves the CLI
|
|
313
|
+
// (`ocx status`, `ocx doctor`) while the dashboard DTO goes through projectCodexAccountHealth,
|
|
314
|
+
// and the duplicated body is exactly how the CLI would have kept reporting a revoked account
|
|
315
|
+
// as healthy after the dashboard stopped.
|
|
316
|
+
const health = projectCodexAccountHealth({ accountId, needsReauth, now });
|
|
282
317
|
pushEntry(entries, "codex", accountId, health);
|
|
283
318
|
}
|
|
284
319
|
return entries;
|
package/src/oauth/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { parseCallbackInput } from "./callback-server";
|
|
|
4
4
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
5
5
|
import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config";
|
|
6
6
|
import { resolveProviderApiKey } from "../providers/key-store";
|
|
7
|
-
import {
|
|
7
|
+
import { projectEmail } from "../lib/privacy";
|
|
8
8
|
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
|
|
9
9
|
import {
|
|
10
10
|
OAuthMutationBusyError,
|
|
@@ -1781,19 +1781,54 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
|
|
|
1781
1781
|
return { ok: true };
|
|
1782
1782
|
}
|
|
1783
1783
|
|
|
1784
|
-
export interface OAuthAccountSummary {
|
|
1784
|
+
export interface OAuthAccountSummary {
|
|
1785
|
+
id: string;
|
|
1786
|
+
alias?: string;
|
|
1787
|
+
email?: string;
|
|
1788
|
+
active: boolean;
|
|
1789
|
+
needsReauth?: boolean;
|
|
1790
|
+
expiresAt?: number;
|
|
1791
|
+
/**
|
|
1792
|
+
* Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
|
|
1793
|
+
* weighting a multi-account pool by seat size needs no per-provider branching (#3777).
|
|
1794
|
+
*
|
|
1795
|
+
* Always present and explicitly `null` when the tier is unknown. The distinction matters:
|
|
1796
|
+
* an ABSENT key means the proxy is too old to report a tier at all, while `null` means this
|
|
1797
|
+
* version looked and upstream did not say. Omitting it would make those indistinguishable and
|
|
1798
|
+
* invite a consumer to assume a tier.
|
|
1799
|
+
*
|
|
1800
|
+
* Every OAuth provider reports `null` today. Anthropic's `/api/oauth/usage` returns quota
|
|
1801
|
+
* buckets only — `five_hour`, `seven_day`, the model-scoped weekly windows and `limits[]` —
|
|
1802
|
+
* and carries no subscription/tier field, and its token response carries none either. See
|
|
1803
|
+
* `fetchAnthropicUsageQuota` in `src/providers/quota.ts`.
|
|
1804
|
+
*/
|
|
1805
|
+
plan: string | null;
|
|
1806
|
+
}
|
|
1785
1807
|
|
|
1786
|
-
|
|
1808
|
+
/**
|
|
1809
|
+
* Token-safe login state for one provider.
|
|
1810
|
+
*
|
|
1811
|
+
* `maskEmails` is an explicit boolean rather than a config read (#3859). This module must not
|
|
1812
|
+
* acquire a dependency on config I/O to answer a redaction question: the caller already holds
|
|
1813
|
+
* the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
|
|
1814
|
+
* The default masks, so every existing caller keeps today's behaviour.
|
|
1815
|
+
*/
|
|
1816
|
+
export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
|
|
1787
1817
|
const cred = getCredential(provider);
|
|
1788
1818
|
const st = loginState.get(provider);
|
|
1789
1819
|
const set = getAccountSet(provider);
|
|
1790
1820
|
const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
|
|
1791
1821
|
id: a.id,
|
|
1792
1822
|
...(a.alias ? { alias: a.alias } : {}),
|
|
1793
|
-
email:
|
|
1823
|
+
email: projectEmail(a.credential.email, maskEmails) ?? undefined,
|
|
1794
1824
|
active: a.id === set.activeAccountId,
|
|
1795
1825
|
...(a.needsReauth ? { needsReauth: true } : {}),
|
|
1796
1826
|
expiresAt: a.credential.expires,
|
|
1827
|
+
// Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
|
|
1828
|
+
// exposes a subscription tier today, so there is nothing truthful to put here; deriving one
|
|
1829
|
+
// from quota percentages is not possible, because they are normalized per account and a
|
|
1830
|
+
// half-consumed small seat is indistinguishable from a half-consumed large one.
|
|
1831
|
+
plan: null,
|
|
1797
1832
|
}));
|
|
1798
1833
|
|
|
1799
1834
|
// A stored credential counts as "logged in" when it exists and is not marked for
|
|
@@ -1805,7 +1840,7 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
|
|
|
1805
1840
|
.find(a => a.id === set.activeAccountId)?.needsReauth === true;
|
|
1806
1841
|
return {
|
|
1807
1842
|
loggedIn: !!cred && !activeNeedsReauth,
|
|
1808
|
-
email:
|
|
1843
|
+
email: projectEmail(cred?.email, maskEmails) ?? undefined,
|
|
1809
1844
|
source: cred?.source,
|
|
1810
1845
|
error: st?.error,
|
|
1811
1846
|
done: st?.done ?? false,
|
|
@@ -1813,10 +1848,13 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
|
|
|
1813
1848
|
};
|
|
1814
1849
|
}
|
|
1815
1850
|
|
|
1816
|
-
/**
|
|
1817
|
-
|
|
1851
|
+
/**
|
|
1852
|
+
* Token-safe per-provider login state for the CLI `ocx status` logins section. Never tokens; the
|
|
1853
|
+
* email follows the operator's `privacy.maskEmails` policy, masked by default (#3859).
|
|
1854
|
+
*/
|
|
1855
|
+
export function oauthLoginSummary(maskEmails = true): Array<{ provider: string; loggedIn: boolean; email?: string }> {
|
|
1818
1856
|
return listOAuthProviders().map(provider => {
|
|
1819
|
-
const status = getLoginStatus(provider);
|
|
1857
|
+
const status = getLoginStatus(provider, maskEmails);
|
|
1820
1858
|
return { provider, loggedIn: status.loggedIn, ...(status.email ? { email: status.email } : {}) };
|
|
1821
1859
|
});
|
|
1822
1860
|
}
|
|
@@ -211,21 +211,30 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
211
211
|
if (!cred) continue;
|
|
212
212
|
const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
|
|
213
213
|
const needsWarmup = opts.codexWarmupEnabled
|
|
214
|
+
&& !record.codexValidationPending
|
|
214
215
|
&& (record.lastCodexValidatedAt === undefined || nowMs - record.lastCodexValidatedAt > opts.codexWarmupMaxAgeSeconds * 1000);
|
|
215
216
|
if (!needsRefresh && !needsWarmup) continue;
|
|
216
217
|
const key = `codex:${id}`;
|
|
217
218
|
if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
|
|
219
|
+
// The generation this sweep is acting on. A successful refresh commits a new one, and a
|
|
220
|
+
// failure that follows belongs to THAT credential, so the fence has to move with it.
|
|
221
|
+
let observedGeneration = record.generation;
|
|
218
222
|
tasks.push(async () => {
|
|
223
|
+
let warmupGeneration: number | undefined;
|
|
219
224
|
try {
|
|
220
225
|
const token = await getValidCodexToken(id);
|
|
226
|
+
observedGeneration = token.generation;
|
|
221
227
|
if (needsRefresh) result.refreshed.push(key);
|
|
222
|
-
|
|
228
|
+
const current = readCodexAccountRecord(id);
|
|
229
|
+
if (needsWarmup && current?.credential && current.deletedAt == null
|
|
230
|
+
&& !current.codexValidationPending && current.generation === token.generation) {
|
|
231
|
+
warmupGeneration = token.generation;
|
|
223
232
|
await warmCodexAccount({
|
|
224
233
|
accessToken: token.accessToken,
|
|
225
234
|
chatgptAccountId: token.chatgptAccountId,
|
|
226
235
|
model: opts.codexWarmupModel,
|
|
227
236
|
});
|
|
228
|
-
markCodexAccountValidated(id, Date.now());
|
|
237
|
+
markCodexAccountValidated(id, Date.now(), token.generation);
|
|
229
238
|
result.warmed.push(key);
|
|
230
239
|
}
|
|
231
240
|
backoff.delete(key);
|
|
@@ -235,11 +244,28 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
235
244
|
result.skippedBackoff.push(key);
|
|
236
245
|
return;
|
|
237
246
|
}
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
const terminal = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired")
|
|
248
|
+
? err
|
|
249
|
+
: undefined;
|
|
250
|
+
if (terminal) {
|
|
251
|
+
// A revoked or expired refresh grant is the strongest terminal evidence there is, and
|
|
252
|
+
// it used to be the one class that never reached the record: the persisted-verdict
|
|
253
|
+
// branch below requires `needsWarmup`, which is false in the default configuration,
|
|
254
|
+
// and additionally excluded every TokenRefreshError. The verdict landed only in the
|
|
255
|
+
// in-memory backoff map, which no health surface reads and no restart survives, so the
|
|
256
|
+
// account kept its login-time "ok" while every request with it 401'd (#4120).
|
|
257
|
+
markCodexAccountValidationFailed(id, `refresh_${terminal.reason}`, {
|
|
258
|
+
expectedGeneration: observedGeneration,
|
|
259
|
+
terminal: true,
|
|
260
|
+
});
|
|
261
|
+
} else if (warmupGeneration !== undefined && !(err instanceof TokenRefreshError)) {
|
|
262
|
+
// warmupGeneration is set only once the warmup actually started against a record
|
|
263
|
+
// still at the token's generation, so it is a tighter fence than the pre-sweep read.
|
|
264
|
+
markCodexAccountValidationFailed(id, codexWarmupFailureReason(err), {
|
|
265
|
+
expectedGeneration: warmupGeneration,
|
|
266
|
+
});
|
|
241
267
|
}
|
|
242
|
-
recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds,
|
|
268
|
+
recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, terminal !== undefined, writerGeneration);
|
|
243
269
|
result.failed.push(key);
|
|
244
270
|
}
|
|
245
271
|
});
|