@bitkyc08/opencodex 2.7.40 → 2.7.41
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 +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- package/gui/dist/assets/index-cydcmbzC.js +0 -52
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
multiAgentGuidanceEnabled,
|
|
11
11
|
providerBaseUrlConfigError,
|
|
12
12
|
providerHeadersConfigError,
|
|
13
|
-
|
|
13
|
+
saveConfigPreservingClaudeCode,
|
|
14
14
|
} from "../../config";
|
|
15
15
|
import {
|
|
16
16
|
clearLoginState,
|
|
@@ -56,13 +56,48 @@ import type { PersistedUsageAttempt } from "../../usage/log";
|
|
|
56
56
|
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
57
57
|
import { applySystemEnvToggle } from "../system-env";
|
|
58
58
|
|
|
59
|
-
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
59
|
+
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared";
|
|
60
60
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
61
|
+
|
|
62
|
+
let grokApplyChain: Promise<unknown> = Promise.resolve();
|
|
63
|
+
/**
|
|
64
|
+
* Serializes Grok applies: injectGrokConfig is read-modify-write over a single file,
|
|
65
|
+
* so two concurrent clicks must not interleave two cycles.
|
|
66
|
+
*/
|
|
67
|
+
function queueGrokApply<T>(run: () => Promise<T>): Promise<T> {
|
|
68
|
+
const next = grokApplyChain.then(run, run);
|
|
69
|
+
grokApplyChain = next.catch(() => {});
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
61
72
|
import type { ManagementContext } from "./context";
|
|
62
73
|
|
|
63
74
|
export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
64
75
|
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
65
76
|
|
|
77
|
+
/** Best-effort Desktop 3P config auto-reconcile when providers change. */
|
|
78
|
+
async function autoApplyDesktopBestEffort(): Promise<void> {
|
|
79
|
+
try {
|
|
80
|
+
if (config.claudeCode?.desktopAutoApply === false) return;
|
|
81
|
+
if (!config.claudeCode?.desktopProfile) return;
|
|
82
|
+
const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
|
|
83
|
+
const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog");
|
|
84
|
+
const allModels = await fetchAllModels(config);
|
|
85
|
+
const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
|
|
86
|
+
const result = writeDesktop3pConfig(
|
|
87
|
+
config.port ?? 10100,
|
|
88
|
+
[...visibleNativeSlugs(config)],
|
|
89
|
+
routed,
|
|
90
|
+
config.apiKeys?.[0]?.key,
|
|
91
|
+
"static",
|
|
92
|
+
config.claudeCode.desktopProfile,
|
|
93
|
+
);
|
|
94
|
+
if (result.written && result.fingerprint) {
|
|
95
|
+
config.claudeCode = { ...config.claudeCode, desktopProfile: { ...config.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } };
|
|
96
|
+
saveConfigPreservingClaudeCode(config);
|
|
97
|
+
}
|
|
98
|
+
} catch { /* best-effort */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
// multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads
|
|
67
102
|
// boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS
|
|
68
103
|
// the catalog so multi-agent surface metadata stays fresh. The catalog build
|
|
@@ -121,7 +156,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
121
156
|
if (wantsMode) {
|
|
122
157
|
if (mode === "default") delete config.multiAgentMode;
|
|
123
158
|
else config.multiAgentMode = mode;
|
|
124
|
-
|
|
159
|
+
saveConfigPreservingClaudeCode(config);
|
|
125
160
|
warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
|
|
126
161
|
}
|
|
127
162
|
await refreshCodexCatalogBestEffort();
|
|
@@ -219,7 +254,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
219
254
|
if (nextPrompt) config.injectionPrompt = nextPrompt;
|
|
220
255
|
else delete config.injectionPrompt;
|
|
221
256
|
|
|
222
|
-
|
|
257
|
+
saveConfigPreservingClaudeCode(config);
|
|
223
258
|
return jsonResponse({
|
|
224
259
|
ok: true,
|
|
225
260
|
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
|
|
@@ -253,7 +288,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
253
288
|
}
|
|
254
289
|
config[key] = value;
|
|
255
290
|
}
|
|
256
|
-
|
|
291
|
+
saveConfigPreservingClaudeCode(config);
|
|
257
292
|
return jsonResponse({ ok: true, effortCap: config.effortCap ?? null, subagentEffortCap: config.subagentEffortCap ?? null });
|
|
258
293
|
}
|
|
259
294
|
|
|
@@ -281,13 +316,271 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
281
316
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
282
317
|
const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : [];
|
|
283
318
|
config.subagentModels = chosen;
|
|
284
|
-
const {
|
|
319
|
+
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
285
320
|
save(config);
|
|
286
321
|
await refreshCodexCatalogBestEffort();
|
|
287
322
|
await syncClaudeAgentDefsBestEffort();
|
|
323
|
+
await autoApplyDesktopBestEffort();
|
|
288
324
|
return jsonResponse({ ok: true, applied: chosen });
|
|
289
325
|
}
|
|
290
326
|
|
|
327
|
+
// Priority-ordered subagent model fallback chain for quota-aware spawn routing.
|
|
328
|
+
if (url.pathname === "/api/subagent-model-fallback" && req.method === "GET") {
|
|
329
|
+
const models = await fetchAllModels(config);
|
|
330
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
331
|
+
const { listCatalogNativeSlugs } = await import("../../codex/catalog");
|
|
332
|
+
const visibleRouted = [...new Set(models
|
|
333
|
+
.filter(m => ![...disabled].some(stored =>
|
|
334
|
+
stored === catalogModelSlug(m) || slugEquals(stored, m.provider, m.id)
|
|
335
|
+
))
|
|
336
|
+
.map(catalogModelSlug))];
|
|
337
|
+
const available = [
|
|
338
|
+
...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
|
|
339
|
+
...visibleRouted,
|
|
340
|
+
];
|
|
341
|
+
return jsonResponse({
|
|
342
|
+
models: config.subagentModelFallback ?? [],
|
|
343
|
+
pollMs: config.subagentModelFallbackPollMs ?? 60_000,
|
|
344
|
+
available,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (url.pathname === "/api/subagent-model-fallback" && req.method === "PUT") {
|
|
348
|
+
let body: { models?: unknown; pollMs?: unknown };
|
|
349
|
+
try {
|
|
350
|
+
body = await req.json();
|
|
351
|
+
} catch {
|
|
352
|
+
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
353
|
+
}
|
|
354
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
355
|
+
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
356
|
+
}
|
|
357
|
+
let nextModels = config.subagentModelFallback;
|
|
358
|
+
let nextPollMs = config.subagentModelFallbackPollMs;
|
|
359
|
+
if ("models" in body) {
|
|
360
|
+
if (!Array.isArray(body.models)) return jsonResponse({ error: "models must be an array" }, 400);
|
|
361
|
+
const models: string[] = [];
|
|
362
|
+
for (let i = 0; i < body.models.length; i++) {
|
|
363
|
+
const entry = body.models[i];
|
|
364
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
365
|
+
return jsonResponse({
|
|
366
|
+
error: `models[${i}] must be a non-empty string`,
|
|
367
|
+
index: i,
|
|
368
|
+
value: entry,
|
|
369
|
+
}, 400);
|
|
370
|
+
}
|
|
371
|
+
models.push(entry.trim());
|
|
372
|
+
}
|
|
373
|
+
nextModels = models.length > 0 ? models : undefined;
|
|
374
|
+
}
|
|
375
|
+
if ("pollMs" in body) {
|
|
376
|
+
const pollMs = body.pollMs;
|
|
377
|
+
if (pollMs === null || pollMs === "") nextPollMs = undefined;
|
|
378
|
+
else if (typeof pollMs === "number" && Number.isInteger(pollMs) && pollMs >= 5_000 && pollMs <= 600_000) {
|
|
379
|
+
nextPollMs = pollMs;
|
|
380
|
+
} else {
|
|
381
|
+
return jsonResponse({ error: "pollMs must be an integer between 5000 and 600000" }, 400);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (nextModels !== undefined) config.subagentModelFallback = nextModels;
|
|
385
|
+
else delete config.subagentModelFallback;
|
|
386
|
+
if (nextPollMs !== undefined) config.subagentModelFallbackPollMs = nextPollMs;
|
|
387
|
+
else delete config.subagentModelFallbackPollMs;
|
|
388
|
+
saveConfigPreservingClaudeCode(config);
|
|
389
|
+
return jsonResponse({
|
|
390
|
+
ok: true,
|
|
391
|
+
models: config.subagentModelFallback ?? [],
|
|
392
|
+
pollMs: config.subagentModelFallbackPollMs ?? 60_000,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Grok Build: view of the managed fence in ~/.grok/config.toml plus the candidate
|
|
397
|
+
// catalog and the user's selection. The fence itself is still written ONLY by
|
|
398
|
+
// injectGrokConfig — the write routes below carry no path/host/port/body input.
|
|
399
|
+
if (url.pathname === "/api/grok" && req.method === "GET") {
|
|
400
|
+
try {
|
|
401
|
+
const { readGrokStatus } = await import("../../grok/status");
|
|
402
|
+
// `candidates` is the full visible catalog the fence WOULD carry, so the page can
|
|
403
|
+
// show a switch for a model the user has already excluded. Aliases come from
|
|
404
|
+
// `status.models` — the writer's output — never computed client-side.
|
|
405
|
+
return jsonResponse({
|
|
406
|
+
...readGrokStatus(),
|
|
407
|
+
candidates: await fetchGrokCandidateModels(config),
|
|
408
|
+
excluded: config.grokExcludedModels ?? [],
|
|
409
|
+
});
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Writes CONFIG only. ~/.grok/config.toml is still written exclusively by
|
|
416
|
+
// injectGrokConfig, through the apply route below — this route cannot touch that file.
|
|
417
|
+
if (url.pathname === "/api/grok/selection" && req.method === "PUT") {
|
|
418
|
+
let body: { excluded?: unknown };
|
|
419
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
420
|
+
const raw = body.excluded;
|
|
421
|
+
if (!Array.isArray(raw) || raw.some(entry => typeof entry !== "string" || entry.length === 0)) {
|
|
422
|
+
return jsonResponse({ error: "excluded must be an array of model ids" }, 400);
|
|
423
|
+
}
|
|
424
|
+
// Dedupe + sort so the stored list is stable, and cap it so a hostile client
|
|
425
|
+
// cannot grow config.json without bound.
|
|
426
|
+
const excluded = [...new Set(raw as string[])].sort();
|
|
427
|
+
if (excluded.length > 2000) return jsonResponse({ error: "excluded list is too large" }, 400);
|
|
428
|
+
if (excluded.length === 0) delete config.grokExcludedModels;
|
|
429
|
+
else config.grokExcludedModels = excluded;
|
|
430
|
+
saveConfigPreservingClaudeCode(config);
|
|
431
|
+
return jsonResponse({ ok: true, excluded });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Re-runs the SAME sync the CLI runs. All guards (no-grok-home, non-loopback refusal,
|
|
435
|
+
// orphaned marker, backup, alias reservation) live in injectGrokConfig and are not
|
|
436
|
+
// duplicated here. Accepts no body: every input comes from persisted state.
|
|
437
|
+
if (url.pathname === "/api/grok/apply" && req.method === "POST") {
|
|
438
|
+
try {
|
|
439
|
+
const { syncGrokConfig } = await import("../../grok/sync");
|
|
440
|
+
const { readRuntimePort } = await import("../../config");
|
|
441
|
+
// The host/port the proxy ACTUALLY bound — not the request authority (caller-
|
|
442
|
+
// influenced) and not config.hostname, which sync.ts warns may have drifted.
|
|
443
|
+
// `ocx ensure` passes live.hostname for the same reason; the runtime-port record
|
|
444
|
+
// is the in-process equivalent, written at startup.
|
|
445
|
+
const runtime = readRuntimePort(process.pid);
|
|
446
|
+
const port = runtime?.port ?? config.port;
|
|
447
|
+
const hostname = runtime?.hostname ?? config.hostname;
|
|
448
|
+
const result = await queueGrokApply(() => syncGrokConfig(
|
|
449
|
+
port,
|
|
450
|
+
config,
|
|
451
|
+
hostname !== undefined ? { hostname } : {},
|
|
452
|
+
));
|
|
453
|
+
// A policy skip (non-loopback, no ~/.grok) is not a server error: report it as a
|
|
454
|
+
// result the page can explain rather than a 500 the user cannot act on.
|
|
455
|
+
return jsonResponse({
|
|
456
|
+
ok: result.ok,
|
|
457
|
+
changed: result.changed,
|
|
458
|
+
message: result.message,
|
|
459
|
+
...(result.skippedReason ? { skippedReason: result.skippedReason } : {}),
|
|
460
|
+
}, result.ok ? 200 : 500);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Claude Desktop profile: routed/native model assignments for the Desktop 3P config.
|
|
467
|
+
if (url.pathname === "/api/claude-desktop" && req.method === "GET") {
|
|
468
|
+
try {
|
|
469
|
+
const state = await buildClaudeDesktopState(config);
|
|
470
|
+
const runtimePort = Number(url.port) || config.port;
|
|
471
|
+
return jsonResponse({ ...state, port: runtimePort });
|
|
472
|
+
} catch (error) {
|
|
473
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (url.pathname === "/api/claude-desktop" && req.method === "PUT") {
|
|
477
|
+
let body: { profile?: unknown };
|
|
478
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
479
|
+
try {
|
|
480
|
+
const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile");
|
|
481
|
+
const parsed = parseDesktopProfile(body.profile);
|
|
482
|
+
const current = await buildClaudeDesktopState(config);
|
|
483
|
+
for (const model of current.models.filter(item => !item.available)) {
|
|
484
|
+
const before = current.profile.assignments[model.route];
|
|
485
|
+
const after = parsed.assignments[model.route];
|
|
486
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
|
487
|
+
throw new Error(`현재 사용할 수 없는 모델은 옮길 수 없습니다: ${model.route}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
for (const family of ["opus", "fable", "sonnet", "haiku"] as const) {
|
|
491
|
+
const nextDefault = parsed.defaults[family];
|
|
492
|
+
const target = nextDefault ? current.models.find(model => model.route === nextDefault) : undefined;
|
|
493
|
+
if (target && !target.available && current.profile.defaults[family] !== nextDefault) {
|
|
494
|
+
throw new Error(`현재 사용할 수 없는 모델은 기본값으로 지정할 수 없습니다: ${nextDefault}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const state = await buildClaudeDesktopState(config, parsed);
|
|
498
|
+
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconcileDesktopProfile(state.profile, state.models) };
|
|
499
|
+
saveConfigPreservingClaudeCode(config);
|
|
500
|
+
const saved = await buildClaudeDesktopState(config);
|
|
501
|
+
const runtimePort = Number(url.port) || config.port;
|
|
502
|
+
return jsonResponse({ ok: true, ...saved, port: runtimePort });
|
|
503
|
+
} catch (error) {
|
|
504
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") {
|
|
508
|
+
try {
|
|
509
|
+
const state = await buildClaudeDesktopState(config);
|
|
510
|
+
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
|
|
511
|
+
saveConfigPreservingClaudeCode(config);
|
|
512
|
+
const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
|
|
513
|
+
const { visibleNativeSlugs } = await import("../../codex/catalog");
|
|
514
|
+
const routed = state.models
|
|
515
|
+
.filter(model => model.available && !model.route.startsWith("native/"))
|
|
516
|
+
.map(model => {
|
|
517
|
+
const slash = model.route.indexOf("/");
|
|
518
|
+
return { provider: model.route.slice(0, slash), id: model.route.slice(slash + 1), contextWindow: model.contextWindow };
|
|
519
|
+
});
|
|
520
|
+
const result = writeDesktop3pConfig(
|
|
521
|
+
Number(url.port) || config.port,
|
|
522
|
+
[...visibleNativeSlugs(config)],
|
|
523
|
+
routed,
|
|
524
|
+
config.apiKeys?.[0]?.key,
|
|
525
|
+
"static",
|
|
526
|
+
state.profile,
|
|
527
|
+
);
|
|
528
|
+
if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500);
|
|
529
|
+
// Persist applied fingerprint + timestamp so GUI can show saved-vs-applied state.
|
|
530
|
+
if (result.fingerprint) {
|
|
531
|
+
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: { ...state.profile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } };
|
|
532
|
+
saveConfigPreservingClaudeCode(config);
|
|
533
|
+
}
|
|
534
|
+
return jsonResponse({ ok: true, saved: true, applied: true, path: result.path, fingerprint: result.fingerprint });
|
|
535
|
+
} catch (error) {
|
|
536
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Desktop applied-state + health status.
|
|
541
|
+
if (url.pathname === "/api/claude-desktop/status" && req.method === "GET") {
|
|
542
|
+
try {
|
|
543
|
+
const { readFileSync: readFile, existsSync } = await import("node:fs");
|
|
544
|
+
const { createHash } = await import("node:crypto");
|
|
545
|
+
const { join } = await import("node:path");
|
|
546
|
+
const { homedir } = await import("node:os");
|
|
547
|
+
const libraryPath = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim()
|
|
548
|
+
|| join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary");
|
|
549
|
+
const metaPath = join(libraryPath, "_meta.json");
|
|
550
|
+
let onDiskFingerprint: string | null = null;
|
|
551
|
+
let configPath: string | null = null;
|
|
552
|
+
if (existsSync(metaPath)) {
|
|
553
|
+
try {
|
|
554
|
+
const meta = JSON.parse(readFile(metaPath, "utf8"));
|
|
555
|
+
const entry = Array.isArray(meta.entries) ? meta.entries.find((e: { name?: string }) => e?.name === "opencodex") : undefined;
|
|
556
|
+
if (entry?.id) {
|
|
557
|
+
configPath = join(libraryPath, `${entry.id}.json`);
|
|
558
|
+
if (existsSync(configPath)) {
|
|
559
|
+
const onDisk = readFile(configPath, "utf8");
|
|
560
|
+
onDiskFingerprint = createHash("sha256").update(onDisk).digest("hex").slice(0, 16);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} catch { /* unreadable metadata */ }
|
|
564
|
+
}
|
|
565
|
+
const savedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
|
|
566
|
+
const appliedAt = config.claudeCode?.desktopProfile?.appliedAt ?? null;
|
|
567
|
+
const stale = savedFingerprint !== null && onDiskFingerprint !== null && savedFingerprint !== onDiskFingerprint;
|
|
568
|
+
const { getDesktopHealth } = await import("../../claude/desktop-health");
|
|
569
|
+
const health = getDesktopHealth();
|
|
570
|
+
return jsonResponse({
|
|
571
|
+
applied: savedFingerprint !== null,
|
|
572
|
+
appliedAt,
|
|
573
|
+
savedFingerprint,
|
|
574
|
+
onDiskFingerprint,
|
|
575
|
+
configPath,
|
|
576
|
+
stale,
|
|
577
|
+
health,
|
|
578
|
+
});
|
|
579
|
+
} catch (error) {
|
|
580
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
291
584
|
// Claude Code inbound settings (GUI "Claude ON" toggle + Claude page).
|
|
292
585
|
if (url.pathname === "/api/claude-code" && req.method === "GET") {
|
|
293
586
|
const models = await fetchAllModels(config);
|
|
@@ -317,11 +610,28 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
317
610
|
const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models);
|
|
318
611
|
const webSearchOverride = config.claudeCode?.webSearchSidecar;
|
|
319
612
|
const visionOverride = config.claudeCode?.visionSidecar;
|
|
613
|
+
// Auto is a RESOLUTION, recomputed per request — never stored state. Detection is
|
|
614
|
+
// daemon-side, so it cannot see a key exported only in the user's terminal; the
|
|
615
|
+
// GUI labels the badge with detectionScope for exactly that reason.
|
|
616
|
+
const { defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } = await import("../../claude/auth-detect");
|
|
617
|
+
const { authModeIntent, resolveClaudeAuthMode } = await import("../../claude/auth-mode");
|
|
618
|
+
const authDetection = detectClaudeAuth(defaultAuthDetectDeps(process.env, ownAdmissionTokens(config)));
|
|
619
|
+
const resolvedAuthMode = resolveClaudeAuthMode(config, authDetection);
|
|
320
620
|
return jsonResponse({
|
|
321
621
|
enabled: config.claudeCode?.enabled !== false,
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
622
|
+
// Three-state intent (devlog 260726_claude_auth_auto): an absent key is AUTO, not
|
|
623
|
+
// subscription. The old coercion made every save convert an untouched auto config
|
|
624
|
+
// into a sticky manual subscription with no way back.
|
|
625
|
+
authMode: authModeIntent(config),
|
|
626
|
+
/** Does the opencodex dummy marker get injected — NOT a claim about native auth. */
|
|
627
|
+
markerMode: resolvedAuthMode.markerMode,
|
|
628
|
+
authModeOrigin: resolvedAuthMode.origin,
|
|
629
|
+
...(resolvedAuthMode.foundBy ? { authFoundBy: resolvedAuthMode.foundBy } : {}),
|
|
630
|
+
authDetectionUnknown: authDetection.presence === "unknown",
|
|
631
|
+
// Separate axis: with an admission key configured a token is injected regardless
|
|
632
|
+
// of mode, so the GUI must never present subscription as "no token anywhere".
|
|
633
|
+
admissionKeyActive: (config.apiKeys?.length ?? 0) > 0,
|
|
634
|
+
detectionScope: "daemon",
|
|
325
635
|
model: config.claudeCode?.model ?? "",
|
|
326
636
|
smallFastModel: config.claudeCode?.smallFastModel ?? "",
|
|
327
637
|
tierModels: config.claudeCode?.tierModels ?? {},
|
|
@@ -398,15 +708,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
398
708
|
next.enabled = body.enabled;
|
|
399
709
|
}
|
|
400
710
|
if (body.authMode !== undefined) {
|
|
401
|
-
// "proxy"
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
711
|
+
// Three-state intent: "proxy" and "subscription" are stored literally and stick
|
|
712
|
+
// forever; "auto" DELETES the key so the mode is resolved from detected Claude
|
|
713
|
+
// auth on every launch. The 260720 round-trip contract survives as a superset —
|
|
714
|
+
// storing "subscription" literally is also backward-safe, since older readers
|
|
715
|
+
// only ever recognised "proxy".
|
|
716
|
+
if (body.authMode !== "proxy" && body.authMode !== "subscription" && body.authMode !== "auto") {
|
|
717
|
+
return jsonResponse({ error: "authMode must be \"auto\", \"proxy\", or \"subscription\"" }, 400);
|
|
407
718
|
}
|
|
408
|
-
if (body.authMode === "
|
|
409
|
-
else
|
|
719
|
+
if (body.authMode === "auto") delete next.authMode;
|
|
720
|
+
else next.authMode = body.authMode;
|
|
410
721
|
}
|
|
411
722
|
if (body.systemEnv !== undefined) {
|
|
412
723
|
if (typeof body.systemEnv !== "boolean") return jsonResponse({ error: "systemEnv must be a boolean" }, 400);
|
|
@@ -514,7 +825,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
514
825
|
}
|
|
515
826
|
}
|
|
516
827
|
config.claudeCode = next;
|
|
517
|
-
|
|
828
|
+
// Stamp the migration sentinel on EVERY persist of this block. The migration reads
|
|
829
|
+
// "a claudeCode block with no authMode" as a pre-upgrade subscriber and pins it to
|
|
830
|
+
// literal subscription — correct for a config written before `auto` existed, fatal
|
|
831
|
+
// for one written after. Without this, choosing Auto (which DELETES authMode) or
|
|
832
|
+
// merely toggling Claude on (App.tsx PUTs `{enabled}` alone and creates the block)
|
|
833
|
+
// would be converted into a sticky manual subscription by the next startServer, and
|
|
834
|
+
// auto would survive exactly one proxy lifetime with no way back.
|
|
835
|
+
if (!next.authModeMigratedAt) next.authModeMigratedAt = new Date().toISOString();
|
|
836
|
+
const { saveConfigPreservingClaudeCode: save } = await import("../../config");
|
|
518
837
|
save(config);
|
|
519
838
|
const warnings: string[] = [];
|
|
520
839
|
// authMode changes must reconcile the injected system env too: switching back to
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
import { probeHostname } from "../proxy-liveness";
|
|
3
|
+
|
|
4
|
+
export interface ApiAccessEndpoints {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
responsesEndpoint: string;
|
|
7
|
+
chatCompletionsEndpoint: string;
|
|
8
|
+
messagesEndpoint: string;
|
|
9
|
+
modelsEndpoint: string;
|
|
10
|
+
claudeCodeEnabled: boolean;
|
|
11
|
+
/** Back-compat alias for older GUI clients. */
|
|
12
|
+
endpoint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type BuildApiAccessEndpointsOptions = {
|
|
16
|
+
/** Full request URL (preferred when the bind host is a wildcard). */
|
|
17
|
+
requestUrl?: string | URL | null;
|
|
18
|
+
/** Raw `Host` header from the inbound request. */
|
|
19
|
+
requestHost?: string | null;
|
|
20
|
+
/** Raw `Origin` header from the inbound request. */
|
|
21
|
+
requestOrigin?: string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function isWildcardBindHost(hostname: string | undefined): boolean {
|
|
25
|
+
const trimmed = (hostname ?? "").trim();
|
|
26
|
+
return !trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Bracket bare IPv6 literals for URL authority composition. */
|
|
30
|
+
export function formatAuthorityHost(hostname: string): string {
|
|
31
|
+
const trimmed = hostname.trim();
|
|
32
|
+
if (!trimmed) return "127.0.0.1";
|
|
33
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
|
|
34
|
+
return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hostnameFromAuthority(authority: string): string | null {
|
|
38
|
+
const raw = authority.trim();
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
if (raw.startsWith("[")) {
|
|
41
|
+
const end = raw.indexOf("]");
|
|
42
|
+
if (end > 0) return raw.slice(1, end) || null;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Host headers for IPv6 are bracketed; treat multi-colon values as unusable.
|
|
46
|
+
if (raw.includes(":") && raw.split(":").length > 2) return null;
|
|
47
|
+
const colon = raw.lastIndexOf(":");
|
|
48
|
+
if (colon > 0 && /^\d+$/.test(raw.slice(colon + 1))) {
|
|
49
|
+
return raw.slice(0, colon) || null;
|
|
50
|
+
}
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function originBaseUrl(raw: string): string | null {
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(raw);
|
|
57
|
+
if (!url.hostname || isWildcardBindHost(url.hostname)) return null;
|
|
58
|
+
return `${url.protocol}//${url.host}/v1`;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Prefer the client's request host/origin when the proxy is bound to a wildcard.
|
|
66
|
+
* Falls back to loopback only when no usable request context is available.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveApiAccessBaseUrl(
|
|
69
|
+
config: Pick<OcxConfig, "hostname" | "port">,
|
|
70
|
+
opts: BuildApiAccessEndpointsOptions = {},
|
|
71
|
+
): string {
|
|
72
|
+
const port = config.port ?? 10100;
|
|
73
|
+
|
|
74
|
+
if (!isWildcardBindHost(config.hostname)) {
|
|
75
|
+
return `http://${probeHostname(config.hostname)}:${port}/v1`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fromOrigin = opts.requestOrigin ? originBaseUrl(opts.requestOrigin) : null;
|
|
79
|
+
if (fromOrigin) return fromOrigin;
|
|
80
|
+
|
|
81
|
+
if (opts.requestUrl) {
|
|
82
|
+
try {
|
|
83
|
+
const url = typeof opts.requestUrl === "string" ? new URL(opts.requestUrl) : opts.requestUrl;
|
|
84
|
+
if (url.hostname && !isWildcardBindHost(url.hostname)) {
|
|
85
|
+
return `${url.protocol}//${url.host}/v1`;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore malformed request URL */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hostHeader = opts.requestHost?.trim();
|
|
93
|
+
if (hostHeader) {
|
|
94
|
+
const hostname = hostnameFromAuthority(hostHeader);
|
|
95
|
+
if (hostname && !isWildcardBindHost(hostname)) {
|
|
96
|
+
// Preserve an explicit port from the Host header; otherwise use the bind port.
|
|
97
|
+
const hasPort = hostHeader.startsWith("[")
|
|
98
|
+
? /\]:\d+$/.test(hostHeader)
|
|
99
|
+
: /:\d+$/.test(hostHeader) && hostHeader.split(":").length === 2;
|
|
100
|
+
const authority = hasPort
|
|
101
|
+
? (hostHeader.startsWith("[") ? hostHeader : hostHeader)
|
|
102
|
+
: `${formatAuthorityHost(hostname)}:${port}`;
|
|
103
|
+
return `http://${authority}/v1`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return `http://127.0.0.1:${port}/v1`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @deprecated Prefer resolveApiAccessBaseUrl; retained for focused host-format tests. */
|
|
111
|
+
export function resolveApiAccessDisplayHost(
|
|
112
|
+
configHostname: string | undefined,
|
|
113
|
+
opts: BuildApiAccessEndpointsOptions = {},
|
|
114
|
+
): string {
|
|
115
|
+
if (!isWildcardBindHost(configHostname)) {
|
|
116
|
+
return probeHostname(configHostname);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return new URL(resolveApiAccessBaseUrl({ hostname: configHostname, port: 10100 }, opts)).hostname
|
|
120
|
+
|| "127.0.0.1";
|
|
121
|
+
} catch {
|
|
122
|
+
return "127.0.0.1";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildApiAccessEndpoints(
|
|
127
|
+
config: OcxConfig,
|
|
128
|
+
opts: BuildApiAccessEndpointsOptions = {},
|
|
129
|
+
): ApiAccessEndpoints {
|
|
130
|
+
const baseUrl = resolveApiAccessBaseUrl(config, opts);
|
|
131
|
+
const responsesEndpoint = `${baseUrl}/responses`;
|
|
132
|
+
return {
|
|
133
|
+
baseUrl,
|
|
134
|
+
responsesEndpoint,
|
|
135
|
+
chatCompletionsEndpoint: `${baseUrl}/chat/completions`,
|
|
136
|
+
messagesEndpoint: `${baseUrl}/messages`,
|
|
137
|
+
modelsEndpoint: `${baseUrl}/models`,
|
|
138
|
+
claudeCodeEnabled: config.claudeCode?.enabled !== false,
|
|
139
|
+
endpoint: responsesEndpoint,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
multiAgentGuidanceEnabled,
|
|
11
11
|
providerBaseUrlConfigError,
|
|
12
12
|
providerHeadersConfigError,
|
|
13
|
-
|
|
13
|
+
saveConfigPreservingClaudeCode,
|
|
14
14
|
} from "../../config";
|
|
15
15
|
import {
|
|
16
16
|
clearLoginState,
|
|
@@ -179,7 +179,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
179
179
|
config.claudeCode = claudeCode;
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
-
|
|
182
|
+
saveConfigPreservingClaudeCode(config);
|
|
183
183
|
clearComboSelectionState(id);
|
|
184
184
|
clearComboTargetCooldowns(id);
|
|
185
185
|
if (renameFrom) {
|
|
@@ -200,7 +200,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
200
200
|
const { clearComboSelectionState, clearComboTargetCooldowns } = await import("../../combos");
|
|
201
201
|
delete config.combos![id];
|
|
202
202
|
if (Object.keys(config.combos!).length === 0) delete config.combos;
|
|
203
|
-
|
|
203
|
+
saveConfigPreservingClaudeCode(config);
|
|
204
204
|
clearComboSelectionState(id);
|
|
205
205
|
clearComboTargetCooldowns(id);
|
|
206
206
|
await refreshCodexCatalogBestEffort();
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
multiAgentGuidanceEnabled,
|
|
11
11
|
providerBaseUrlConfigError,
|
|
12
12
|
providerHeadersConfigError,
|
|
13
|
-
|
|
13
|
+
saveConfigPreservingClaudeCode,
|
|
14
14
|
} from "../../config";
|
|
15
15
|
import {
|
|
16
16
|
clearLoginState,
|
|
@@ -208,7 +208,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
208
208
|
config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
|
-
|
|
211
|
+
saveConfigPreservingClaudeCode(config);
|
|
212
212
|
invalidateStartupHealthCache();
|
|
213
213
|
return jsonResponse({
|
|
214
214
|
ok: true,
|
|
@@ -334,7 +334,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
334
334
|
config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn;
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
|
-
|
|
337
|
+
saveConfigPreservingClaudeCode(config);
|
|
338
338
|
const ws = config.webSearchSidecar ?? {};
|
|
339
339
|
const vs = config.visionSidecar ?? {};
|
|
340
340
|
return jsonResponse({
|
|
@@ -370,7 +370,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
370
370
|
if (body.model === "") delete config.shadowCallIntercept.model;
|
|
371
371
|
else config.shadowCallIntercept.model = body.model;
|
|
372
372
|
}
|
|
373
|
-
|
|
373
|
+
saveConfigPreservingClaudeCode(config);
|
|
374
374
|
const sci = config.shadowCallIntercept;
|
|
375
375
|
return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
|
|
376
376
|
}
|