@bitkyc08/opencodex 2.8.0 → 2.9.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/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import type { CatalogModel } from "../../codex/catalog";
|
|
4
4
|
import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
@@ -22,8 +22,9 @@ import {
|
|
|
22
22
|
startLoginFlow,
|
|
23
23
|
submitManualLoginCode,
|
|
24
24
|
} from "../../oauth";
|
|
25
|
-
import { removeCredential } from "../../oauth/store";
|
|
25
|
+
import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
|
|
26
26
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
27
|
+
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
27
28
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
28
29
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
29
30
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
@@ -59,15 +60,57 @@ import { drainAndShutdown } from "../lifecycle";
|
|
|
59
60
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
60
61
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
61
62
|
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
62
|
-
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
63
|
+
import { AUTH_MATRIX, isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
63
64
|
import { applySystemEnvToggle } from "../system-env";
|
|
64
65
|
import { buildApiAccessEndpoints } from "./api-access";
|
|
65
66
|
|
|
66
67
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
67
68
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
68
69
|
import type { ManagementContext } from "./context";
|
|
70
|
+
import { readManagementJsonBody, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body";
|
|
69
71
|
import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Parses a bounded JSON object body, or null. Malformed JSON is swallowed; an
|
|
75
|
+
* oversized body still throws so the management dispatcher can return 413.
|
|
76
|
+
* a malformed body, which used to surface as a 500 from the key routes.
|
|
77
|
+
*/
|
|
78
|
+
async function readJsonBody(req: Request): Promise<Record<string, unknown> | null> {
|
|
79
|
+
try {
|
|
80
|
+
const parsed: unknown = await readManagementJsonBody(req);
|
|
81
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
82
|
+
? parsed as Record<string, unknown>
|
|
83
|
+
: null;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
rethrowManagementBodyTooLarge(error);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The single place key-name rules live. The config read schema is deliberately
|
|
92
|
+
* permissive so an existing config can never become unloadable; this is the write
|
|
93
|
+
* boundary that keeps new junk out. A non-string name used to reach `.trim()` and
|
|
94
|
+
* throw.
|
|
95
|
+
*/
|
|
96
|
+
function validateKeyName(
|
|
97
|
+
raw: unknown,
|
|
98
|
+
opts: { required: boolean },
|
|
99
|
+
): { value: string } | { error: string } {
|
|
100
|
+
if (raw === undefined || raw === null) {
|
|
101
|
+
return opts.required ? { error: "name required" } : { value: "" };
|
|
102
|
+
}
|
|
103
|
+
if (typeof raw !== "string") return { error: "name must be a string" };
|
|
104
|
+
// Check the RAW string: trimming first would silently accept "deploy\n" by
|
|
105
|
+
// deleting the very character being rejected.
|
|
106
|
+
// eslint-disable-next-line no-control-regex
|
|
107
|
+
if (/[\u0000-\u001f\u007f]/.test(raw)) return { error: "invalid name" };
|
|
108
|
+
const value = raw.trim();
|
|
109
|
+
if (opts.required && !value) return { error: "name required" };
|
|
110
|
+
if (value.length > 64) return { error: "name too long" };
|
|
111
|
+
return { value };
|
|
112
|
+
}
|
|
113
|
+
|
|
71
114
|
export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
72
115
|
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
73
116
|
|
|
@@ -85,7 +128,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
85
128
|
// the provider's loopback callback server (inside this process) captures the redirect in the
|
|
86
129
|
// background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
|
|
87
130
|
if (url.pathname === "/api/oauth/login" && req.method === "POST") {
|
|
88
|
-
const body = await req
|
|
131
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
|
|
89
132
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
90
133
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
91
134
|
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
|
|
@@ -111,7 +154,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
111
154
|
// startLoginFlow returns the authorization URL before background persistence completes.
|
|
112
155
|
// Three-way reconcile settled disk changes so a failed login cannot leave a provider
|
|
113
156
|
// live-only and an in-flight management mutation cannot be erased before it saves.
|
|
114
|
-
onSettled: () =>
|
|
157
|
+
onSettled: () => {
|
|
158
|
+
reconcileLiveConfigFromDisk(config, persistedBaseline);
|
|
159
|
+
reconcileLiveStateStores();
|
|
160
|
+
},
|
|
115
161
|
});
|
|
116
162
|
if (authUrl && !deviceCode) {
|
|
117
163
|
// Open the browser server-side (the proxy runs on the user's machine) — the GUI's
|
|
@@ -121,6 +167,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
121
167
|
}
|
|
122
168
|
return jsonResponse({ url: authUrl, instructions, deviceCode });
|
|
123
169
|
} catch (err) {
|
|
170
|
+
if (err instanceof OAuthMutationBusyError) throw err;
|
|
124
171
|
return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
|
|
125
172
|
}
|
|
126
173
|
}
|
|
@@ -128,7 +175,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
128
175
|
// Cancel an in-progress browser/device OAuth login (GUI "Cancel" / modal close). Guarded by
|
|
129
176
|
// the same public predicate as /api/oauth/login — only publicly startable flows are cancellable.
|
|
130
177
|
if (url.pathname === "/api/oauth/login/cancel" && req.method === "POST") {
|
|
131
|
-
const body = await req
|
|
178
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string };
|
|
132
179
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
133
180
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
134
181
|
const { cancelLoginFlow } = await import("../../oauth");
|
|
@@ -139,7 +186,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
139
186
|
// Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
|
|
140
187
|
// when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
|
|
141
188
|
if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
|
|
142
|
-
const body = await req
|
|
189
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; input?: string; code?: string };
|
|
143
190
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
144
191
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
145
192
|
const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
|
|
@@ -161,6 +208,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
161
208
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
162
209
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
163
210
|
await removeCredential(provider);
|
|
211
|
+
reconcileLiveStateStores();
|
|
164
212
|
clearLoginState(provider);
|
|
165
213
|
// Drop cached/last-good quota rows tied to the removed credential.
|
|
166
214
|
const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
|
|
@@ -223,7 +271,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
223
271
|
});
|
|
224
272
|
}
|
|
225
273
|
if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
|
|
226
|
-
const body = await req
|
|
274
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; accountId?: string };
|
|
227
275
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
228
276
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
229
277
|
if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
@@ -253,7 +301,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
253
301
|
});
|
|
254
302
|
}
|
|
255
303
|
if (url.pathname === "/api/oauth/accounts/pool" && (req.method === "PUT" || req.method === "PATCH")) {
|
|
256
|
-
const parsedBody = await req
|
|
304
|
+
const parsedBody = await readManagementJsonBodyOr(req, {});
|
|
257
305
|
if (!isPlainRecord(parsedBody)) {
|
|
258
306
|
return jsonResponse({ error: "body must be an object" }, 400);
|
|
259
307
|
}
|
|
@@ -306,6 +354,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
306
354
|
...(stickyLimit !== undefined ? { stickyLimit } : {}),
|
|
307
355
|
};
|
|
308
356
|
saveConfigPreservingClaudeCode(config);
|
|
357
|
+
reconcileLiveStateStores();
|
|
309
358
|
return jsonResponse({
|
|
310
359
|
ok: true,
|
|
311
360
|
provider,
|
|
@@ -317,7 +366,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
317
366
|
});
|
|
318
367
|
}
|
|
319
368
|
if (url.pathname === "/api/oauth/accounts/clear-cooldown" && req.method === "POST") {
|
|
320
|
-
const body = await req
|
|
369
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown };
|
|
321
370
|
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
322
371
|
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
323
372
|
if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400);
|
|
@@ -328,7 +377,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
328
377
|
}
|
|
329
378
|
|
|
330
379
|
if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
|
|
331
|
-
const body = await req
|
|
380
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown; alias?: unknown };
|
|
332
381
|
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
333
382
|
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
334
383
|
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
@@ -348,6 +397,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
348
397
|
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
349
398
|
const { removeAccount, getAccountSet } = await import("../../oauth/store");
|
|
350
399
|
if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
|
|
400
|
+
reconcileLiveStateStores();
|
|
351
401
|
if (provider === "anthropic") {
|
|
352
402
|
const { clearAnthropicAccountCooldown, clearAnthropicSessionAffinityForAccount } = await import("../../oauth/anthropic-routing");
|
|
353
403
|
clearAnthropicAccountCooldown(id);
|
|
@@ -370,7 +420,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
370
420
|
return jsonResponse(listProviderApiKeys(config, name));
|
|
371
421
|
}
|
|
372
422
|
if (url.pathname === "/api/providers/keys" && req.method === "POST") {
|
|
373
|
-
const body = await req
|
|
423
|
+
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
|
|
374
424
|
const name = (body.name ?? "").trim();
|
|
375
425
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
376
426
|
if (typeof body.key !== "string" || !body.key.trim()) return jsonResponse({ error: "key is required" }, 400);
|
|
@@ -386,7 +436,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
386
436
|
return jsonResponse({ ok: true, id: result.id }, 201);
|
|
387
437
|
}
|
|
388
438
|
if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
|
|
389
|
-
const body = await req
|
|
439
|
+
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
|
|
390
440
|
const name = (body.name ?? "").trim();
|
|
391
441
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
392
442
|
if (!body.id) return jsonResponse({ error: "missing id" }, 400);
|
|
@@ -401,7 +451,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
401
451
|
return jsonResponse({ ok: true, name, activeId: body.id });
|
|
402
452
|
}
|
|
403
453
|
if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
|
|
404
|
-
const body = await req
|
|
454
|
+
const body = await readManagementJsonBodyOr(req, {}) as { name?: unknown; id?: unknown; alias?: unknown };
|
|
405
455
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
406
456
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
407
457
|
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
@@ -440,32 +490,71 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
440
490
|
requestHost: req.headers.get("host"),
|
|
441
491
|
requestOrigin: req.headers.get("origin"),
|
|
442
492
|
});
|
|
493
|
+
const { readApiKeyUsageRollup } = await import("./api-key-usage");
|
|
494
|
+
const { rollup, attributionSince, historyTruncated } = await readApiKeyUsageRollup(keys.map(k => k.id), config.managementUsageMaxReadBytes);
|
|
443
495
|
return jsonResponse({
|
|
444
|
-
|
|
496
|
+
// 8 random hex past the fixed `ocx_data_` literal: enough to tell two keys
|
|
497
|
+
// apart in a list, with 128 bits of the tail still unrevealed. Masking only
|
|
498
|
+
// 8 characters showed `ocx_data...` for every key ever generated.
|
|
499
|
+
keys: keys.map(k => ({
|
|
500
|
+
id: k.id,
|
|
501
|
+
name: k.name,
|
|
502
|
+
prefix: k.key.slice(0, 17) + "...",
|
|
503
|
+
createdAt: k.createdAt,
|
|
504
|
+
usage: rollup.get(k.id) ?? { requests7d: 0, totalRequests: 0 },
|
|
505
|
+
})),
|
|
506
|
+
// Dataset-level and singular: it describes the usage log, not any one key.
|
|
507
|
+
...(attributionSince ? { attributionSince } : {}),
|
|
508
|
+
...(historyTruncated ? { historyTruncated: true } : {}),
|
|
509
|
+
authMatrix: AUTH_MATRIX,
|
|
445
510
|
...endpoints,
|
|
446
511
|
}, 200, req, config);
|
|
447
512
|
}
|
|
448
513
|
|
|
449
514
|
if (url.pathname === "/api/keys" && req.method === "POST") {
|
|
450
|
-
const body = await req
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
515
|
+
const body = await readJsonBody(req);
|
|
516
|
+
if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
|
|
517
|
+
const nameField = validateKeyName(body.name, { required: false });
|
|
518
|
+
if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
|
|
519
|
+
const name = nameField.value || "default";
|
|
520
|
+
// A direct random draw. The previous derivation hashed every configured
|
|
521
|
+
// provider API key into the input, which was never needed for uniqueness and
|
|
522
|
+
// made this secret's safety argument depend on string concatenation rather
|
|
523
|
+
// than the RNG. 20 bytes is the same 40 hex characters as before, so nothing
|
|
524
|
+
// that pattern-matches the key shape changes.
|
|
525
|
+
const key = "ocx_data_" + randomBytes(20).toString("hex");
|
|
526
|
+
const entry = { id: randomUUID(), name, key, createdAt: new Date().toISOString() };
|
|
459
527
|
config.apiKeys = [...(config.apiKeys ?? []), entry];
|
|
460
528
|
saveConfigPreservingClaudeCode(config);
|
|
529
|
+
reconcileLiveStateStores();
|
|
461
530
|
return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
|
|
462
531
|
}
|
|
463
532
|
|
|
533
|
+
if (url.pathname === "/api/keys" && req.method === "PATCH") {
|
|
534
|
+
const body = await readJsonBody(req);
|
|
535
|
+
if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
|
|
536
|
+
if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
|
|
537
|
+
const nameField = validateKeyName(body.name, { required: true });
|
|
538
|
+
if ("error" in nameField) return jsonResponse({ error: nameField.error }, 400, req, config);
|
|
539
|
+
const entry = (config.apiKeys ?? []).find(k => k.id === body.id);
|
|
540
|
+
if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config);
|
|
541
|
+
entry.name = nameField.value;
|
|
542
|
+
saveConfigPreservingClaudeCode(config);
|
|
543
|
+
reconcileLiveStateStores();
|
|
544
|
+
// Never echo key material from a rename.
|
|
545
|
+
return jsonResponse({ id: entry.id, name: entry.name, createdAt: entry.createdAt }, 200, req, config);
|
|
546
|
+
}
|
|
547
|
+
|
|
464
548
|
if (url.pathname === "/api/keys" && req.method === "DELETE") {
|
|
465
|
-
const body = await req
|
|
466
|
-
if (!body
|
|
549
|
+
const body = await readJsonBody(req);
|
|
550
|
+
if (!body) return jsonResponse({ error: "invalid body" }, 400, req, config);
|
|
551
|
+
if (typeof body.id !== "string" || !body.id) return jsonResponse({ error: "id required" }, 400, req, config);
|
|
552
|
+
const before = (config.apiKeys ?? []).length;
|
|
467
553
|
config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
|
|
554
|
+
// A stale id must not read as a successful revocation.
|
|
555
|
+
if (config.apiKeys.length === before) return jsonResponse({ error: "key not found" }, 404, req, config);
|
|
468
556
|
saveConfigPreservingClaudeCode(config);
|
|
557
|
+
reconcileLiveStateStores();
|
|
469
558
|
return jsonResponse({ success: true }, 200, req, config);
|
|
470
559
|
}
|
|
471
560
|
return null;
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "../../oauth";
|
|
24
24
|
import { removeCredential } from "../../oauth/store";
|
|
25
25
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
|
+
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
26
27
|
import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
|
|
27
28
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
28
29
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
@@ -67,6 +68,7 @@ import { applySystemEnvToggle } from "../system-env";
|
|
|
67
68
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
68
69
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
69
70
|
import type { ManagementContext } from "./context";
|
|
71
|
+
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
70
72
|
|
|
71
73
|
export async function handleProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
72
74
|
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
@@ -96,7 +98,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
96
98
|
// which would re-save the masked keys from GET). Live routing picks it up immediately.
|
|
97
99
|
if (url.pathname === "/api/providers" && req.method === "POST") {
|
|
98
100
|
let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
|
|
99
|
-
try { body = await req
|
|
101
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
100
102
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
101
103
|
const providerError = providerManagementConfigError(name, body.provider);
|
|
102
104
|
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
@@ -118,6 +120,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
118
120
|
const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(prov);
|
|
119
121
|
const resolvedError = await providerDestinationResolvedError(name, prov, { allowBenchmarkAddresses });
|
|
120
122
|
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
|
|
123
|
+
if (body.setDefault !== undefined && typeof body.setDefault !== "boolean") {
|
|
124
|
+
return jsonResponse({ error: "setDefault must be a boolean" }, 400);
|
|
125
|
+
}
|
|
126
|
+
if (body.setDefault === true && prov.disabled) {
|
|
127
|
+
return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
|
|
128
|
+
}
|
|
121
129
|
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
|
|
122
130
|
// doesn't send — merge it in so the sidecars are gated correctly.
|
|
123
131
|
enrichProviderFromCatalog(name, prov);
|
|
@@ -127,8 +135,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
127
135
|
const existingPool = config.providers[name]?.apiKeyPool;
|
|
128
136
|
if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
|
|
129
137
|
config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
|
|
130
|
-
if (body.setDefault) config.defaultProvider = name;
|
|
138
|
+
if (body.setDefault === true) config.defaultProvider = name;
|
|
131
139
|
save(config);
|
|
140
|
+
reconcileLiveStateStores();
|
|
132
141
|
if (prov.apiKey && prov.apiKeyPool) {
|
|
133
142
|
const { addProviderApiKey } = await import("../../providers/api-keys");
|
|
134
143
|
addProviderApiKey(config, name, prov.apiKey);
|
|
@@ -143,10 +152,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
143
152
|
const name = url.searchParams.get("name")?.trim();
|
|
144
153
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
145
154
|
let rawBody: unknown;
|
|
146
|
-
try { rawBody = await req
|
|
155
|
+
try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
147
156
|
if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider patch body must be a plain object" }, 400);
|
|
148
157
|
const keys = Object.keys(rawBody);
|
|
149
158
|
const hasMode = Object.hasOwn(rawBody, "codexAccountMode");
|
|
159
|
+
const hasSetDefault = Object.hasOwn(rawBody, "setDefault");
|
|
150
160
|
|
|
151
161
|
// codexAccountMode keeps its dedicated side-effect path (quota cache clear, thread map
|
|
152
162
|
// clear, pool prime) and is mutually exclusive with every other patch field.
|
|
@@ -166,6 +176,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
166
176
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
167
177
|
config.providers.openai = { ...provider, codexAccountMode: mode };
|
|
168
178
|
save(config);
|
|
179
|
+
reconcileLiveStateStores();
|
|
169
180
|
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
170
181
|
(deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
171
182
|
if (mode === "pool") {
|
|
@@ -179,6 +190,23 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
179
190
|
return jsonResponse({ success: true, name: "openai", codexAccountMode: mode });
|
|
180
191
|
}
|
|
181
192
|
|
|
193
|
+
// Default-provider changes must be a deliberate, standalone action. This keeps
|
|
194
|
+
// routing changes out of ordinary provider edits and lets the dashboard expose a
|
|
195
|
+
// simple "Set as default" control without round-tripping the full config.
|
|
196
|
+
if (hasSetDefault) {
|
|
197
|
+
if (keys.length !== 1 || rawBody.setDefault !== true) {
|
|
198
|
+
return jsonResponse({ error: "setDefault must be true and cannot be combined with other patch fields" }, 400);
|
|
199
|
+
}
|
|
200
|
+
if (config.providers[name]!.disabled) {
|
|
201
|
+
return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
|
|
202
|
+
}
|
|
203
|
+
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
204
|
+
config.defaultProvider = name;
|
|
205
|
+
save(config);
|
|
206
|
+
reconcileLiveStateStores();
|
|
207
|
+
return jsonResponse({ success: true, name, defaultProvider: name });
|
|
208
|
+
}
|
|
209
|
+
|
|
182
210
|
// Field-mask editor: apply recognized fields onto a copy, then validate the MERGED
|
|
183
211
|
// provider (canonical-seed guard covers openai; local-guard covers registry key providers).
|
|
184
212
|
// API keys are never writable here — the api-keys endpoints own pool-integrated key writes.
|
|
@@ -302,6 +330,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
302
330
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
303
331
|
config.providers[name] = stripRegistryOnlyStaticHeaders(name, next);
|
|
304
332
|
save(config);
|
|
333
|
+
reconcileLiveStateStores();
|
|
305
334
|
if (editorTouched) {
|
|
306
335
|
const { clearModelCache } = await import("../../codex/model-cache");
|
|
307
336
|
clearModelCache(name);
|
|
@@ -417,7 +446,22 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
417
446
|
if (url.pathname === "/api/providers" && req.method === "DELETE") {
|
|
418
447
|
const name = url.searchParams.get("name")?.trim();
|
|
419
448
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
420
|
-
|
|
449
|
+
// Config validation requires a default provider. Reassigning before deletion keeps
|
|
450
|
+
// the persisted config valid and makes removal of the current default a one-step UI
|
|
451
|
+
// operation. Prefer the first remaining *enabled* provider so DELETE cannot leave a
|
|
452
|
+
// disabled default that setDefault / disable already refuse. Object-key order is the
|
|
453
|
+
// documented configuration order and is stable through JSON persistence.
|
|
454
|
+
const fallbackDefault = name === config.defaultProvider
|
|
455
|
+
? Object.entries(config.providers)
|
|
456
|
+
.find(([provider, providerConfig]) => provider !== name && providerConfig.disabled !== true)
|
|
457
|
+
?.[0]
|
|
458
|
+
: undefined;
|
|
459
|
+
if (name === config.defaultProvider && !fallbackDefault) {
|
|
460
|
+
return jsonResponse({
|
|
461
|
+
error: "cannot delete the default provider when no enabled replacement remains",
|
|
462
|
+
code: "last_provider",
|
|
463
|
+
}, 409);
|
|
464
|
+
}
|
|
421
465
|
const dependentCombos = Object.entries(config.combos ?? {})
|
|
422
466
|
.filter(([, combo]) => combo.targets.some(target => target.provider === name))
|
|
423
467
|
.map(([id]) => id)
|
|
@@ -425,17 +469,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
425
469
|
if (dependentCombos.length > 0) {
|
|
426
470
|
return jsonResponse({
|
|
427
471
|
error: `cannot delete provider "${name}" while combos depend on it`,
|
|
472
|
+
code: "provider_has_dependent_combos",
|
|
428
473
|
combos: dependentCombos,
|
|
429
474
|
}, 409);
|
|
430
475
|
}
|
|
431
476
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
477
|
+
if (fallbackDefault) config.defaultProvider = fallbackDefault;
|
|
432
478
|
delete config.providers[name];
|
|
433
479
|
setProviderContextCap(config, name, false);
|
|
434
480
|
save(config);
|
|
481
|
+
reconcileLiveStateStores();
|
|
435
482
|
const { clearModelCache: clearCache } = await import("../../codex/model-cache");
|
|
436
483
|
clearCache(name);
|
|
437
484
|
await refreshCodexCatalogBestEffort();
|
|
438
|
-
return jsonResponse({ success: true });
|
|
485
|
+
return jsonResponse({ success: true, ...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}) });
|
|
439
486
|
}
|
|
440
487
|
|
|
441
488
|
if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
|
|
@@ -444,7 +491,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
444
491
|
|
|
445
492
|
if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
|
|
446
493
|
let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
|
|
447
|
-
try { body = await req
|
|
494
|
+
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
448
495
|
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
449
496
|
const { clearModelCache } = await import("../../codex/model-cache");
|
|
450
497
|
const respond = () => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
@@ -457,6 +504,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
457
504
|
const affected = Object.keys(providerContextCaps(config));
|
|
458
505
|
setGlobalContextCapValue(config, body.value);
|
|
459
506
|
save(config);
|
|
507
|
+
reconcileLiveStateStores();
|
|
460
508
|
for (const provider of affected) clearModelCache(provider);
|
|
461
509
|
await refreshCodexCatalogBestEffort();
|
|
462
510
|
return respond();
|
|
@@ -471,6 +519,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
471
519
|
const names = Object.keys(config.providers);
|
|
472
520
|
setAllProviderContextCaps(config, names, body.setAll);
|
|
473
521
|
save(config);
|
|
522
|
+
reconcileLiveStateStores();
|
|
474
523
|
for (const provider of new Set([...before, ...names])) clearModelCache(provider);
|
|
475
524
|
await refreshCodexCatalogBestEffort();
|
|
476
525
|
return respond();
|
|
@@ -489,6 +538,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
489
538
|
}
|
|
490
539
|
setProviderContextCap(config, provider, body.enabled);
|
|
491
540
|
save(config);
|
|
541
|
+
reconcileLiveStateStores();
|
|
492
542
|
clearModelCache(provider);
|
|
493
543
|
await refreshCodexCatalogBestEffort();
|
|
494
544
|
return respond();
|
|
@@ -214,14 +214,14 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
|
|
|
214
214
|
|
|
215
215
|
/** Shared Desktop profile DTO builder for the management API and CLI. */
|
|
216
216
|
export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) {
|
|
217
|
-
const { filterCatalogVisibleModels, nativeOpenAiContextWindow,
|
|
217
|
+
const { filterCatalogVisibleModels, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
|
|
218
218
|
const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p");
|
|
219
219
|
const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile");
|
|
220
220
|
const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
|
|
221
221
|
const profileModels: DesktopProfileModel[] = [
|
|
222
222
|
// Native rows carry their real context window from the same accessor the Grok sync
|
|
223
223
|
// uses — otherwise Sol's 372k and gpt-5.5's 272k render as blank on Desktop.
|
|
224
|
-
...
|
|
224
|
+
...desktopVisibleNativeSlugs(config).map(id => {
|
|
225
225
|
const contextWindow = nativeOpenAiContextWindow(id);
|
|
226
226
|
return { route: `native/${id}`, label: `${id} (native)`,
|
|
227
227
|
...(contextWindow !== undefined ? { contextWindow } : {}) };
|
|
@@ -233,6 +233,19 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
|
|
|
233
233
|
})),
|
|
234
234
|
];
|
|
235
235
|
const profile = reconcileDesktopProfile(stored ?? config.claudeCode?.desktopProfile, profileModels);
|
|
236
|
+
if (config.claudeCode?.desktopNativeModels === false) {
|
|
237
|
+
for (const route of Object.keys(profile.assignments)) {
|
|
238
|
+
if (route.startsWith("native/")) delete profile.assignments[route];
|
|
239
|
+
}
|
|
240
|
+
for (const family of ["opus", "fable", "sonnet", "haiku"] as const) {
|
|
241
|
+
const current = profile.defaults[family];
|
|
242
|
+
if (current?.startsWith("native/")) {
|
|
243
|
+
profile.defaults[family] = Object.keys(profile.assignments)
|
|
244
|
+
.filter(route => profile.assignments[route]?.family === family)
|
|
245
|
+
.sort()[0] ?? null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
236
249
|
const available = new Set(profileModels.map(model => model.route));
|
|
237
250
|
const modelByRoute = new Map(profileModels.map(model => [model.route, model]));
|
|
238
251
|
// Effort support: routed models with a non-empty reasoningEfforts ladder support effort;
|
|
@@ -241,7 +254,7 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
|
|
|
241
254
|
for (const m of routed) {
|
|
242
255
|
effortByRoute.set(`${m.provider}/${m.id}`, Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0);
|
|
243
256
|
}
|
|
244
|
-
for (const id of
|
|
257
|
+
for (const id of desktopVisibleNativeSlugs(config)) {
|
|
245
258
|
effortByRoute.set(`native/${id}`, true);
|
|
246
259
|
}
|
|
247
260
|
const models = Object.keys(profile.assignments).sort().map(route => ({
|
|
@@ -8,10 +8,42 @@
|
|
|
8
8
|
* output is ever serialized here — starring runs through the user's own `gh` CLI and
|
|
9
9
|
* this surface only learns the yes/no answer. `gh` writes the authenticated account
|
|
10
10
|
* name to stderr, so that output is discarded at the source rather than forwarded.
|
|
11
|
+
*
|
|
12
|
+
* The star POST additionally refuses agent-driven programmatic callers. Management
|
|
13
|
+
* auth proves the caller reached the admin token, not that a person chose to star:
|
|
14
|
+
* a coding agent runs on the user's machine and can read that token from disk, so
|
|
15
|
+
* the CLI's "ask the user" deferral would be bypassable with one `curl` here.
|
|
16
|
+
*
|
|
17
|
+
* The dashboard button must keep working even when the proxy itself was started by
|
|
18
|
+
* an agent, which is the common case — the person is at the browser, not at the
|
|
19
|
+
* spawning shell. A GUI click is therefore distinguished by its browser session
|
|
20
|
+
* evidence (a same-origin `Origin` plus the minted CSRF header, both already
|
|
21
|
+
* verified by the management auth gate) rather than by the proxy's own env.
|
|
11
22
|
*/
|
|
12
23
|
import { jsonResponse } from "../auth-cors";
|
|
24
|
+
import { agentDrivenMarkers, isAgentDriven } from "../../cli/agent-driven";
|
|
13
25
|
import type { ManagementContext } from "./context";
|
|
14
26
|
|
|
27
|
+
/**
|
|
28
|
+
* True when this request carries the browser-session evidence a dashboard click
|
|
29
|
+
* always has: an `Origin` (only a browser sends one) plus the per-session CSRF
|
|
30
|
+
* token, which `requireManagementAuth` has already matched against the minted
|
|
31
|
+
* session before dispatch. A shell/HTTP caller holding only the admin token has
|
|
32
|
+
* neither, which is exactly the case the agent guard is aimed at.
|
|
33
|
+
*/
|
|
34
|
+
function hasBrowserSessionEvidence(req: Request): boolean {
|
|
35
|
+
return !!req.headers.get("Origin")?.trim()
|
|
36
|
+
&& !!req.headers.get("x-opencodex-csrf-token")?.trim()
|
|
37
|
+
&& !!req.headers.get("x-opencodex-gui-origin")?.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Known edge, deliberately fail-closed: a non-loopback operator dashboard signs in
|
|
41
|
+
// with the raw admin token instead of a minted GUI session, so its clicks carry no
|
|
42
|
+
// CSRF header. If that proxy was *also* started from an agent shell, the button is
|
|
43
|
+
// refused and the response names the one-line `gh` command to run by hand. A
|
|
44
|
+
// service-run proxy (`OCX_SERVICE`, the usual remote setup) is not agent-driven and
|
|
45
|
+
// is unaffected.
|
|
46
|
+
|
|
15
47
|
export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
16
48
|
const { req, url } = ctx;
|
|
17
49
|
|
|
@@ -21,7 +53,24 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
|
|
|
21
53
|
}
|
|
22
54
|
|
|
23
55
|
if (url.pathname === "/api/github/star" && req.method === "POST") {
|
|
24
|
-
const { starRepository } = await import("../../github/star-state");
|
|
56
|
+
const { STAR_REPO, STAR_REPO_URL, starRepository } = await import("../../github/star-state");
|
|
57
|
+
// Starring uses the user's GitHub identity, so consent must come from the
|
|
58
|
+
// account owner. An agent-driven caller without browser-session evidence
|
|
59
|
+
// cannot have obtained it, and must relay the question instead of answering
|
|
60
|
+
// it with an HTTP call.
|
|
61
|
+
if (isAgentDriven() && !hasBrowserSessionEvidence(req)) {
|
|
62
|
+
return jsonResponse({
|
|
63
|
+
ok: false,
|
|
64
|
+
state: "not-starred",
|
|
65
|
+
repo: STAR_REPO,
|
|
66
|
+
url: STAR_REPO_URL,
|
|
67
|
+
code: "agent_consent_required",
|
|
68
|
+
message:
|
|
69
|
+
`Refused: agent session detected (${agentDrivenMarkers().slice(0, 3).join(", ")}) and this request `
|
|
70
|
+
+ `carries no dashboard session. Starring writes to the user's own GitHub account, so ask the user `
|
|
71
|
+
+ `directly and only if they say yes run: gh api -X PUT /user/starred/${STAR_REPO}`,
|
|
72
|
+
}, 403);
|
|
73
|
+
}
|
|
25
74
|
const result = await starRepository();
|
|
26
75
|
return jsonResponse({
|
|
27
76
|
...result.status,
|
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* recycle to reclaim RSS, not a teardown.
|
|
7
7
|
*
|
|
8
8
|
* Respawn policy (matches real supervisor configs in src/service.ts):
|
|
9
|
-
* - Supervised child (`OCX_SERVICE=1` + service
|
|
9
|
+
* - Supervised child (`OCX_SERVICE=1` + viable service): exit(1) so
|
|
10
10
|
* failure-only supervisors (systemd Restart=on-failure, WinSW onfailure,
|
|
11
11
|
* Task Scheduler ERRORLEVEL loop) bring the proxy back.
|
|
12
12
|
* - Otherwise: detached `ocx start --port <live>` (bypasses ensure's
|
|
13
13
|
* codexAutoStart gate), mark recycle so exit cleanup keeps injection, exit(0).
|
|
14
|
+
* Installed-but-stale/missing service assets are NOT treated as supervised —
|
|
15
|
+
* exit(1) would leave the proxy dead with `Service: installed, stale or missing
|
|
16
|
+
* service assets` and a /healthz timeout.
|
|
14
17
|
* - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
|
|
15
18
|
* markRecycling — after drain the listen socket is already closed, so a latch
|
|
16
19
|
* reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
|
|
@@ -27,7 +30,7 @@ import {
|
|
|
27
30
|
markRecyclingForExit,
|
|
28
31
|
setDraining,
|
|
29
32
|
} from "../lifecycle";
|
|
30
|
-
import {
|
|
33
|
+
import { isServiceViable } from "../../service";
|
|
31
34
|
import { readRuntimePort } from "../../config";
|
|
32
35
|
|
|
33
36
|
/** Fixed v1 drain window for the memory-card action (not config-driven). */
|
|
@@ -35,7 +38,8 @@ export const MEMORY_DRAIN_RESTART_MS = 60_000;
|
|
|
35
38
|
|
|
36
39
|
export interface SystemRestartIo {
|
|
37
40
|
drainAndShutdown?: typeof drainAndShutdown;
|
|
38
|
-
|
|
41
|
+
/** True when a background service can actually respawn this process after exit(1). */
|
|
42
|
+
isServiceViable?: () => boolean;
|
|
39
43
|
isSupervisedServiceChild?: () => boolean;
|
|
40
44
|
/** Must resolve only after the replacement process has actually started. */
|
|
41
45
|
spawnStart?: (port?: number) => void | Promise<void>;
|
|
@@ -66,8 +70,11 @@ function resolveListenPort(): number | undefined {
|
|
|
66
70
|
return undefined;
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
function isSupervisedServiceChild(): boolean {
|
|
70
|
-
|
|
73
|
+
function isSupervisedServiceChild(io: SystemRestartIo = {}): boolean {
|
|
74
|
+
if (process.env.OCX_SERVICE !== "1") return false;
|
|
75
|
+
// Presence is not enough: stale/missing service assets report installed but will not
|
|
76
|
+
// respawn after exit(1). Dashboard status/recovery must fall through to detached start.
|
|
77
|
+
return (io.isServiceViable ?? isServiceViable)();
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
/** Stable, path-free spawn failure label for logs (never interpolate err.message). */
|
|
@@ -137,7 +144,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
|
|
|
137
144
|
schedule(async () => {
|
|
138
145
|
const drain = io.drainAndShutdown ?? drainAndShutdown;
|
|
139
146
|
await drain(undefined, MEMORY_DRAIN_RESTART_MS);
|
|
140
|
-
const supervised = (io.isSupervisedServiceChild ?? isSupervisedServiceChild)();
|
|
147
|
+
const supervised = (io.isSupervisedServiceChild ?? (() => isSupervisedServiceChild(io)))();
|
|
141
148
|
if (supervised) {
|
|
142
149
|
// Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
|
|
143
150
|
(io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);
|