@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 +8 -1
- package/bin/ocx.mjs +47 -22
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +64 -7
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/alias.ts +94 -14
- package/src/claude/inbound.ts +26 -9
- package/src/claude/outbound.ts +6 -3
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +35 -10
- package/src/cli/doctor.ts +71 -19
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +93 -19
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +79 -18
- package/src/cli/status.ts +47 -13
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-store.ts +2 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +42 -2
- package/src/codex/catalog/provider-fetch.ts +264 -70
- package/src/codex/catalog/sync.ts +45 -8
- package/src/codex/catalog.ts +2 -2
- package/src/codex/features.ts +524 -5
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +92 -2
- package/src/codex/routing.ts +695 -106
- package/src/codex/runtime.ts +10 -1
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +574 -25
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +132 -7
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +167 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +310 -75
- package/src/oauth/key-providers.ts +38 -8
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +373 -12
- package/src/oauth/kiro.ts +424 -43
- package/src/oauth/login-cli.ts +33 -6
- package/src/oauth/store.ts +56 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +8 -2
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +125 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +19 -7
- package/src/server/auth-cors.ts +114 -24
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +106 -8
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +22 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +73 -10
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +5 -3
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +614 -172
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +284 -19
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +387 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +253 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +34 -4
- package/src/types.ts +107 -1
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +111 -16
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +12 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-CjKFJHSC.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { removeCredential } from "../../oauth/store";
|
|
25
25
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
26
|
import { isStreamMode } from "../../lib/bun-stream-caps";
|
|
27
|
+
import { shadowSourceModels } from "../../lib/shadow-call";
|
|
27
28
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
28
29
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
29
30
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
@@ -140,16 +141,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
if (url.pathname === "/api/startup-action" && req.method === "POST") {
|
|
143
|
-
let body: { action?: unknown };
|
|
144
|
+
let body: { action?: unknown; repair?: unknown };
|
|
144
145
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
145
146
|
if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
|
|
146
147
|
return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
|
|
147
148
|
}
|
|
149
|
+
if (body.repair !== undefined && typeof body.repair !== "boolean") {
|
|
150
|
+
return jsonResponse({ error: "repair must be a boolean when provided" }, 400);
|
|
151
|
+
}
|
|
148
152
|
try {
|
|
149
153
|
const action = body.action as StartupInstallAction;
|
|
150
|
-
const
|
|
154
|
+
const repair = body.repair === true;
|
|
155
|
+
const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair });
|
|
151
156
|
invalidateStartupHealthCache();
|
|
152
|
-
return jsonResponse({ ok: true, action, message: result.message });
|
|
157
|
+
return jsonResponse({ ok: true, action, repair, message: result.message });
|
|
153
158
|
} catch (error) {
|
|
154
159
|
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
155
160
|
}
|
|
@@ -225,10 +230,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
225
230
|
|
|
226
231
|
if (url.pathname === "/api/sync" && req.method === "POST") {
|
|
227
232
|
const { syncModelsToCodex } = await import("../../codex/sync");
|
|
233
|
+
const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
|
|
228
234
|
const result = await syncModelsToCodex(undefined, config, null);
|
|
229
235
|
return jsonResponse({
|
|
230
|
-
...result,
|
|
231
|
-
|
|
236
|
+
...attachStaleAppServerHint(result),
|
|
237
|
+
...(result.ok ? {} : { error: result.message }),
|
|
232
238
|
}, result.ok ? 200 : 500);
|
|
233
239
|
}
|
|
234
240
|
|
|
@@ -349,7 +355,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
349
355
|
|
|
350
356
|
if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
|
|
351
357
|
const sci = config.shadowCallIntercept ?? {};
|
|
352
|
-
return jsonResponse({
|
|
358
|
+
return jsonResponse({
|
|
359
|
+
enabled: sci.enabled === true,
|
|
360
|
+
model: sci.model ?? "",
|
|
361
|
+
sourceModels: shadowSourceModels(sci.sourceModels),
|
|
362
|
+
});
|
|
353
363
|
}
|
|
354
364
|
|
|
355
365
|
if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
|
|
@@ -371,7 +381,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
371
381
|
}
|
|
372
382
|
saveConfigPreservingClaudeCode(config);
|
|
373
383
|
const sci = config.shadowCallIntercept;
|
|
374
|
-
return jsonResponse({
|
|
384
|
+
return jsonResponse({
|
|
385
|
+
ok: true,
|
|
386
|
+
enabled: sci.enabled === true,
|
|
387
|
+
model: sci.model ?? "",
|
|
388
|
+
sourceModels: shadowSourceModels(sci.sourceModels),
|
|
389
|
+
});
|
|
375
390
|
}
|
|
376
391
|
return null;
|
|
377
392
|
}
|
|
@@ -4,10 +4,20 @@ import type { StartupInstallAction } from "../startup-action-control";
|
|
|
4
4
|
export interface ManagementApiDeps {
|
|
5
5
|
toggleCodexMultiAgentV2?: (enabled: boolean) => void;
|
|
6
6
|
refreshCodexCatalog?: () => Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Persistence seam for route-level tests. Production leaves this unset and uses
|
|
9
|
+
* `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config
|
|
10
|
+
* MUST inject a no-op/spy so the fixture can never overwrite the user's real
|
|
11
|
+
* OPENCODEX_HOME (incident: devlog 260730.../070).
|
|
12
|
+
*/
|
|
13
|
+
saveConfigPreservingClaudeCode?: (config: OcxConfig) => void;
|
|
7
14
|
clearThreadAccountMap?: () => void;
|
|
8
15
|
clearProviderQuotaCache?: () => void;
|
|
9
16
|
primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void> | void;
|
|
10
|
-
runStartupInstallAction?: (
|
|
17
|
+
runStartupInstallAction?: (
|
|
18
|
+
action: StartupInstallAction,
|
|
19
|
+
options?: { repair?: boolean },
|
|
20
|
+
) => Promise<{ message: string }>;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
|
|
@@ -34,7 +34,19 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
|
34
34
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
35
35
|
import { resolveCodexHomeDir } from "../../codex/home";
|
|
36
36
|
import { scanStorage } from "../../storage/scanner";
|
|
37
|
-
import { executeArchivedCleanup, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode } from "../../storage/cleanup";
|
|
37
|
+
import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
|
|
38
|
+
import { runArchivedCleanupJob } from "../../storage/cleanup-job";
|
|
39
|
+
import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
|
|
40
|
+
import {
|
|
41
|
+
normalizeStorageCleanupPolicy,
|
|
42
|
+
parseStorageCleanupPolicyInput,
|
|
43
|
+
writeStorageCleanupPolicyToConfig,
|
|
44
|
+
} from "../../storage/policy";
|
|
45
|
+
import {
|
|
46
|
+
getStorageCleanupPolicyJobState,
|
|
47
|
+
getStorageCleanupPolicyTestStreamResponse,
|
|
48
|
+
requestStorageCleanupPolicyRun,
|
|
49
|
+
} from "../../storage/policy-job";
|
|
38
50
|
import {
|
|
39
51
|
currentUsageLogRevision,
|
|
40
52
|
readUsageSnapshotForManagement,
|
|
@@ -278,7 +290,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
278
290
|
? pickWireCleanupTestHooks(body._test)
|
|
279
291
|
: undefined;
|
|
280
292
|
try {
|
|
281
|
-
const result =
|
|
293
|
+
const result = await runArchivedCleanupJob({
|
|
282
294
|
percent,
|
|
283
295
|
mode: mode as CleanupMode,
|
|
284
296
|
digest,
|
|
@@ -286,14 +298,20 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
286
298
|
});
|
|
287
299
|
if (!result.ok) {
|
|
288
300
|
const status =
|
|
289
|
-
result.error === "codex_busy"
|
|
301
|
+
result.error === "codex_busy"
|
|
302
|
+
|| result.error === "stale_preview"
|
|
303
|
+
|| result.error === "referenced_history"
|
|
304
|
+
|| result.error === "storage_mutation_busy"
|
|
305
|
+
|| result.error === "restore_pending_overlap"
|
|
290
306
|
? 409
|
|
291
307
|
: result.error === "invalid_mode" || result.error === "invalid_digest"
|
|
292
308
|
? 400
|
|
293
309
|
: 500;
|
|
294
310
|
const messages: Record<string, string> = {
|
|
295
311
|
codex_busy: "Codex is using state.sqlite — try again after quitting Codex.",
|
|
312
|
+
storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.",
|
|
296
313
|
stale_preview: "Archived files changed since preview — run Preview again.",
|
|
314
|
+
restore_pending_overlap: "Selected archives overlap an incomplete trash restore — finish or retry restore first.",
|
|
297
315
|
referenced_history: "Selected archives are still referenced by forked or paginated history.",
|
|
298
316
|
invalid_digest: "Preview digest is missing or invalid.",
|
|
299
317
|
invalid_mode: "mode must be quarantine or permanent.",
|
|
@@ -326,5 +344,151 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
326
344
|
}
|
|
327
345
|
}
|
|
328
346
|
|
|
347
|
+
if (url.pathname === "/api/storage/trash" && req.method === "GET") {
|
|
348
|
+
try {
|
|
349
|
+
const entries = listTrashEntries();
|
|
350
|
+
return jsonResponse({
|
|
351
|
+
entries: entries.map(({ id, epoch, fileCount, bytes, quarantinedAt, mode }) => ({
|
|
352
|
+
id,
|
|
353
|
+
epoch,
|
|
354
|
+
fileCount,
|
|
355
|
+
bytes,
|
|
356
|
+
...(quarantinedAt !== undefined ? { quarantinedAt } : {}),
|
|
357
|
+
...(mode ? { mode } : {}),
|
|
358
|
+
})),
|
|
359
|
+
});
|
|
360
|
+
} catch {
|
|
361
|
+
return jsonResponse({ error: "trash_list_failed", entries: [] }, 500);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (url.pathname === "/api/storage/trash/restore/test-stream" && req.method === "GET") {
|
|
366
|
+
if (process.env.OPENCODEX_CLEANUP_TEST_HOOKS === "1") {
|
|
367
|
+
const stream = getRestoreTrashTestStreamResponse();
|
|
368
|
+
if (stream) return stream;
|
|
369
|
+
}
|
|
370
|
+
// Always answer this test-only path — never fall through to the GUI SPA (200 HTML).
|
|
371
|
+
return jsonResponse({ error: "not_available" }, 404);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (url.pathname === "/api/storage/trash/restore" && req.method === "POST") {
|
|
375
|
+
let body: { id?: unknown };
|
|
376
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
|
|
377
|
+
const id = typeof body?.id === "string" ? body.id : "";
|
|
378
|
+
if (!id.trim()) {
|
|
379
|
+
return jsonResponse({ error: "invalid_trash", message: "Trash entry id is required." }, 400);
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const result = await runRestoreTrashEntryJob(id);
|
|
383
|
+
if (!result.ok) {
|
|
384
|
+
const status =
|
|
385
|
+
result.error === "codex_busy"
|
|
386
|
+
|| result.error === "dest_exists"
|
|
387
|
+
|| result.error === "storage_mutation_busy"
|
|
388
|
+
? 409
|
|
389
|
+
: result.error === "missing_trash"
|
|
390
|
+
? 404
|
|
391
|
+
: result.error === "invalid_trash"
|
|
392
|
+
? 400
|
|
393
|
+
: 500;
|
|
394
|
+
const messages: Record<RestoreErrorCode, string> = {
|
|
395
|
+
invalid_trash: "Trash entry id is missing or invalid.",
|
|
396
|
+
missing_trash: "Trash entry was not found.",
|
|
397
|
+
codex_busy: "Codex is using state.sqlite — try again after quitting Codex.",
|
|
398
|
+
storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.",
|
|
399
|
+
dest_exists: "Restore destination already exists — remove or rename the archived file and retry.",
|
|
400
|
+
fs_failed: "Filesystem restore failed. Some files may already be restored — check archived_sessions and .trash.",
|
|
401
|
+
db_reconcile_failed: "Could not restore Codex state database rows.",
|
|
402
|
+
restore_failed: "Restore failed.",
|
|
403
|
+
restore_worker_timeout: "Restore took too long (over 10 minutes) and was stopped.",
|
|
404
|
+
restore_worker_aborted: "Restore was cancelled during shutdown.",
|
|
405
|
+
restore_worker_failed: "Restore worker crashed or failed unexpectedly.",
|
|
406
|
+
};
|
|
407
|
+
const errorCode = result.error ?? "restore_failed";
|
|
408
|
+
const baseMessage = messages[errorCode] ?? messages.restore_failed;
|
|
409
|
+
const message =
|
|
410
|
+
result.message && errorCode === "restore_worker_failed"
|
|
411
|
+
? `${baseMessage} (${result.message})`
|
|
412
|
+
: baseMessage;
|
|
413
|
+
return jsonResponse({
|
|
414
|
+
ok: false,
|
|
415
|
+
error: errorCode,
|
|
416
|
+
message,
|
|
417
|
+
count: result.count,
|
|
418
|
+
bytes: result.bytes,
|
|
419
|
+
restoredPaths: result.restoredPaths,
|
|
420
|
+
...(result.trashDir ? { trashDir: result.trashDir } : {}),
|
|
421
|
+
}, status);
|
|
422
|
+
}
|
|
423
|
+
return jsonResponse({
|
|
424
|
+
ok: true,
|
|
425
|
+
trashDir: result.trashDir,
|
|
426
|
+
count: result.count,
|
|
427
|
+
bytes: result.bytes,
|
|
428
|
+
restoredPaths: result.restoredPaths,
|
|
429
|
+
});
|
|
430
|
+
} catch {
|
|
431
|
+
return jsonResponse({
|
|
432
|
+
ok: false,
|
|
433
|
+
error: "restore_failed",
|
|
434
|
+
message: "Restore failed.",
|
|
435
|
+
}, 500);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (url.pathname === "/api/storage/cleanup-policy/test-stream" && req.method === "GET") {
|
|
440
|
+
const stream = getStorageCleanupPolicyTestStreamResponse();
|
|
441
|
+
if (stream) return stream;
|
|
442
|
+
// Production: hook is off. Return an explicit JSON 404 — do not fall through to the GUI.
|
|
443
|
+
return jsonResponse({ error: "not_found" }, 404);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (url.pathname === "/api/storage/cleanup-policy" && req.method === "GET") {
|
|
447
|
+
const policy = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
|
|
448
|
+
return jsonResponse({
|
|
449
|
+
...policy,
|
|
450
|
+
job: getStorageCleanupPolicyJobState(),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (url.pathname === "/api/storage/cleanup-policy" && req.method === "PUT") {
|
|
455
|
+
let raw: unknown;
|
|
456
|
+
try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
|
|
457
|
+
const previous = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
|
|
458
|
+
const parsed = parseStorageCleanupPolicyInput(raw, previous);
|
|
459
|
+
if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400);
|
|
460
|
+
// Never enable implicitly: if client omitted enabled, keep previous (default false).
|
|
461
|
+
const body = raw as Record<string, unknown>;
|
|
462
|
+
if (body.enabled === undefined) parsed.policy.enabled = previous.enabled;
|
|
463
|
+
const saved = writeStorageCleanupPolicyToConfig(parsed.policy);
|
|
464
|
+
config.storageCleanupPolicy = saved;
|
|
465
|
+
return jsonResponse({ ok: true, policy: saved, job: getStorageCleanupPolicyJobState() });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (url.pathname === "/api/storage/cleanup-policy/run" && req.method === "POST") {
|
|
469
|
+
try {
|
|
470
|
+
const accepted = requestStorageCleanupPolicyRun({ reason: "manual", force: true });
|
|
471
|
+
if (!accepted.accepted) {
|
|
472
|
+
return jsonResponse({
|
|
473
|
+
ok: false,
|
|
474
|
+
started: false,
|
|
475
|
+
error: "already_running",
|
|
476
|
+
message: "A cleanup policy run is already in progress.",
|
|
477
|
+
job: accepted.state,
|
|
478
|
+
policy: normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
|
|
479
|
+
}, 409);
|
|
480
|
+
}
|
|
481
|
+
// Return promptly — clients poll GET for skip/defer/success/error outcomes.
|
|
482
|
+
return jsonResponse({
|
|
483
|
+
ok: true,
|
|
484
|
+
started: true,
|
|
485
|
+
job: accepted.state,
|
|
486
|
+
policy: normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
|
|
487
|
+
});
|
|
488
|
+
} catch {
|
|
489
|
+
return jsonResponse({ ok: false, error: "cleanup_failed" }, 500);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
329
493
|
return null;
|
|
330
494
|
}
|
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum
|
|
6
|
+
* value makes it reject the ENTIRE catalog file — plugins, apps and MCP servers all stop
|
|
7
|
+
* loading over one model's metadata (#759).
|
|
8
|
+
*
|
|
9
|
+
* The catalog writer normalizes on the way out, but a rejected value stored here would still
|
|
10
|
+
* be handed back to the GUI and CLI as if it were real, and the offline `ocx models add` path
|
|
11
|
+
* already refuses it. Validate at ingress so all three paths agree.
|
|
12
|
+
*/
|
|
13
|
+
const ALLOWED_INPUT_MODALITIES = new Set(["text", "image", "audio"]);
|
|
14
|
+
|
|
15
|
+
function readInputModalities(raw: unknown): { values?: string[]; error?: string } {
|
|
16
|
+
if (raw === undefined) return {};
|
|
17
|
+
if (!Array.isArray(raw)) return { error: "inputModalities must be an array" };
|
|
18
|
+
// Reject non-strings rather than filtering them out. Dropping them silently accepted a
|
|
19
|
+
// malformed POST and, worse, let a PUT of `[42]` clear the stored modalities while
|
|
20
|
+
// answering 200 — the opposite of the contract this validator exists to state. An empty
|
|
21
|
+
// array stays valid: that is how `ocx models edit --modalities -` clears the field.
|
|
22
|
+
const rejected: string[] = [];
|
|
23
|
+
for (const value of raw) {
|
|
24
|
+
if (typeof value !== "string") return { error: "inputModalities must contain only strings" };
|
|
25
|
+
if (!ALLOWED_INPUT_MODALITIES.has(value)) rejected.push(value);
|
|
26
|
+
}
|
|
27
|
+
if (rejected.length > 0) {
|
|
28
|
+
return { error: `unsupported input modality: ${rejected.join(", ")} (allowed: text, image, audio)` };
|
|
29
|
+
}
|
|
30
|
+
return { values: raw as string[] };
|
|
31
|
+
}
|
|
3
32
|
import type { CatalogModel } from "../../codex/catalog";
|
|
4
33
|
import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
5
34
|
import { getProviderLiveModelCount } from "../../codex/model-cache";
|
|
@@ -63,6 +92,11 @@ import type { ManagementContext } from "./context";
|
|
|
63
92
|
|
|
64
93
|
export async function handleModelRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
65
94
|
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
95
|
+
// A handler persists the exact config object passed in. Production defaults to
|
|
96
|
+
// the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not
|
|
97
|
+
// bypass this seam with a dynamic config import — doing so replaced a user's
|
|
98
|
+
// ~/.opencodex/config.json with the `existing-uuid` test fixture.
|
|
99
|
+
const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
66
100
|
|
|
67
101
|
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
68
102
|
const models = await fetchAllModels(config);
|
|
@@ -123,8 +157,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
123
157
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
124
158
|
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
|
|
125
159
|
config.disabledModels = disabled;
|
|
126
|
-
|
|
127
|
-
save(config);
|
|
160
|
+
persistConfig(config);
|
|
128
161
|
await refreshCodexCatalogBestEffort();
|
|
129
162
|
return jsonResponse({ ok: true, disabled });
|
|
130
163
|
}
|
|
@@ -223,7 +256,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
223
256
|
}
|
|
224
257
|
|
|
225
258
|
config.disabledModels = disabled;
|
|
226
|
-
|
|
259
|
+
persistConfig(config);
|
|
227
260
|
await refreshCodexCatalogBestEffort();
|
|
228
261
|
return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled });
|
|
229
262
|
}
|
|
@@ -244,7 +277,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
244
277
|
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
|
|
245
278
|
if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
246
279
|
const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
247
|
-
const
|
|
280
|
+
const modalities = readInputModalities(body.inputModalities);
|
|
281
|
+
if (modalities.error) return jsonResponse({ error: modalities.error }, 400);
|
|
282
|
+
const inputModalities = modalities.values;
|
|
248
283
|
const existing = config.customModels ?? [];
|
|
249
284
|
const newSlug = routedSlug(provider, modelId);
|
|
250
285
|
if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
|
|
@@ -260,8 +295,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
260
295
|
addedAt: new Date().toISOString(),
|
|
261
296
|
};
|
|
262
297
|
config.customModels = [...existing, entry];
|
|
263
|
-
|
|
264
|
-
save(config);
|
|
298
|
+
persistConfig(config);
|
|
265
299
|
await refreshCodexCatalogBestEffort();
|
|
266
300
|
return jsonResponse(entry, 201);
|
|
267
301
|
}
|
|
@@ -289,7 +323,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
289
323
|
cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
290
324
|
}
|
|
291
325
|
if (body.inputModalities !== undefined) {
|
|
292
|
-
|
|
326
|
+
const edited = readInputModalities(body.inputModalities);
|
|
327
|
+
if (edited.error) return jsonResponse({ error: edited.error }, 400);
|
|
328
|
+
cm.inputModalities = edited.values && edited.values.length > 0 ? edited.values : undefined;
|
|
293
329
|
}
|
|
294
330
|
const updatedSlug = routedSlug(cm.provider, cm.modelId);
|
|
295
331
|
if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
|
|
@@ -297,8 +333,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
297
333
|
}
|
|
298
334
|
list[idx] = cm;
|
|
299
335
|
config.customModels = list;
|
|
300
|
-
|
|
301
|
-
save(config);
|
|
336
|
+
persistConfig(config);
|
|
302
337
|
await refreshCodexCatalogBestEffort();
|
|
303
338
|
return jsonResponse(cm);
|
|
304
339
|
}
|
|
@@ -312,8 +347,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
312
347
|
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
313
348
|
list.splice(idx, 1);
|
|
314
349
|
config.customModels = list.length > 0 ? list : undefined;
|
|
315
|
-
|
|
316
|
-
save(config);
|
|
350
|
+
persistConfig(config);
|
|
317
351
|
await refreshCodexCatalogBestEffort();
|
|
318
352
|
return jsonResponse({ ok: true });
|
|
319
353
|
}
|
|
@@ -349,8 +383,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
349
383
|
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
350
384
|
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
351
385
|
else delete config.providers[provider].selectedModels;
|
|
352
|
-
|
|
353
|
-
save(config);
|
|
386
|
+
persistConfig(config);
|
|
354
387
|
await refreshCodexCatalogBestEffort();
|
|
355
388
|
return jsonResponse({ ok: true, provider, selected: models });
|
|
356
389
|
}
|