@bitkyc08/opencodex 2.7.42 → 2.7.43
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 +13 -14
- package/gui/dist/assets/index-Czw-jpTU.css +1 -0
- package/gui/dist/assets/index-cmds12BG.js +67 -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/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 +49 -6
- 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/inbound.ts +26 -9
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/claude.ts +3 -3
- package/src/cli/doctor.ts +23 -18
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +88 -19
- package/src/cli/interactive-confirm.ts +129 -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 +55 -16
- package/src/cli/status.ts +47 -13
- 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-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/parsing.ts +16 -1
- package/src/codex/catalog/provider-fetch.ts +48 -12
- package/src/codex/catalog/sync.ts +18 -3
- package/src/codex/catalog.ts +1 -1
- 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 +15 -0
- package/src/codex/routing.ts +695 -106
- 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 +566 -25
- 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/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +122 -4
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +164 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- 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 +283 -72
- package/src/oauth/key-providers.ts +17 -6
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +322 -10
- package/src/oauth/kiro.ts +423 -43
- package/src/oauth/login-cli.ts +32 -5
- package/src/oauth/store.ts +54 -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 +6 -0
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +11 -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 +14 -4
- package/src/server/auth-cors.ts +110 -22
- 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/management/agent-settings-routes.ts +28 -4
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +5 -2
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +35 -7
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +2 -2
- 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 +583 -152
- 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/system-env.ts +28 -10
- package/src/service.ts +49 -2
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +366 -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 +242 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/tray/windows.ts +2 -0
- package/src/types.ts +96 -1
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +103 -12
- 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/log.ts +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/config.ts
CHANGED
|
@@ -3,8 +3,16 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
8
|
+
codexAccountNamespaceForModel,
|
|
9
|
+
codexProviderNamespaceKey,
|
|
10
|
+
isValidCodexAccountNamespaceTarget,
|
|
11
|
+
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
|
|
12
|
+
} from "./codex/account-namespace-match";
|
|
13
|
+
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
|
|
14
|
+
import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl";
|
|
15
|
+
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
8
16
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
9
17
|
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
10
18
|
import {
|
|
@@ -12,12 +20,13 @@ import {
|
|
|
12
20
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
13
21
|
OPENAI_PROVIDER_TIER_VERSION,
|
|
14
22
|
REASONING_SUMMARY_DELIVERY_VALUES,
|
|
23
|
+
type OcxClaudeCodeConfig,
|
|
15
24
|
type OcxConfig,
|
|
16
25
|
type OcxProviderConfig,
|
|
17
26
|
} from "./types";
|
|
18
|
-
import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers";
|
|
27
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
19
28
|
import { parseDesktopProfile } from "./claude/desktop-profile";
|
|
20
|
-
import { modelRecordValue } from "./reasoning-effort";
|
|
29
|
+
import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
|
|
21
30
|
|
|
22
31
|
let _atomicSeq = 0;
|
|
23
32
|
|
|
@@ -90,6 +99,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
|
|
|
90
99
|
truncate: target => truncateSync(target, 0),
|
|
91
100
|
unlink: unlinkSync,
|
|
92
101
|
}): void {
|
|
102
|
+
recordOwnedConfigPath(resolveConfigDir(), path);
|
|
93
103
|
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
94
104
|
let hardened = false;
|
|
95
105
|
try {
|
|
@@ -128,6 +138,90 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
|
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
|
|
142
|
+
export interface AtomicWriteAsyncIO {
|
|
143
|
+
write: (path: string, content: string) => void | Promise<void>;
|
|
144
|
+
harden: (path: string) => void | Promise<void>;
|
|
145
|
+
rename: (source: string, destination: string) => void | Promise<void>;
|
|
146
|
+
truncate: (path: string) => void | Promise<void>;
|
|
147
|
+
unlink: (path: string) => void | Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
|
|
151
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
152
|
+
try {
|
|
153
|
+
renameSync(source, destination);
|
|
154
|
+
return;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
157
|
+
const transientWindowsError = process.platform === "win32"
|
|
158
|
+
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
|
|
159
|
+
if (!transientWindowsError || attempt >= 2) throw error;
|
|
160
|
+
await Bun.sleep(25 * (attempt + 1));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Async atomic write (#612): same temp+harden+rename and residual-temp policy as
|
|
167
|
+
* atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
|
|
168
|
+
* by the final destination path (not the unique temp, not the parent directory).
|
|
169
|
+
*/
|
|
170
|
+
export async function atomicWriteFileAsync(
|
|
171
|
+
path: string,
|
|
172
|
+
content: string,
|
|
173
|
+
io?: AtomicWriteAsyncIO,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const effective: AtomicWriteAsyncIO = io ?? {
|
|
176
|
+
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
177
|
+
harden: async target => {
|
|
178
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
179
|
+
if (process.platform === "win32") {
|
|
180
|
+
await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
rename: renameAtomicFileAsync,
|
|
184
|
+
truncate: target => truncateSync(target, 0),
|
|
185
|
+
unlink: unlinkSync,
|
|
186
|
+
};
|
|
187
|
+
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
188
|
+
let hardened = false;
|
|
189
|
+
try {
|
|
190
|
+
await effective.write(tmp, content);
|
|
191
|
+
await effective.harden(tmp);
|
|
192
|
+
hardened = true;
|
|
193
|
+
await effective.rename(tmp, path);
|
|
194
|
+
} catch (cause) {
|
|
195
|
+
let scrubbed = false;
|
|
196
|
+
try {
|
|
197
|
+
await effective.truncate(tmp);
|
|
198
|
+
scrubbed = true;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
201
|
+
else {
|
|
202
|
+
try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
let removed = false;
|
|
206
|
+
try {
|
|
207
|
+
await effective.unlink(tmp);
|
|
208
|
+
removed = true;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (isMissingPathError(error)) removed = true;
|
|
211
|
+
else {
|
|
212
|
+
try { await effective.unlink(tmp); removed = true; }
|
|
213
|
+
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
|
|
217
|
+
if (!removed && !hardened) {
|
|
218
|
+
try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
219
|
+
}
|
|
220
|
+
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
221
|
+
throw cause;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
131
225
|
export class OpenAiTierBackupCleanupError extends Error {
|
|
132
226
|
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
|
|
133
227
|
}
|
|
@@ -339,6 +433,7 @@ const warnedConfigFallbacks = new Set<string>();
|
|
|
339
433
|
const providerConfigSchema = z.object({
|
|
340
434
|
adapter: z.string().min(1),
|
|
341
435
|
baseUrl: z.string().min(1),
|
|
436
|
+
apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
|
|
342
437
|
responsesPath: z.string().min(1).optional(),
|
|
343
438
|
allowPrivateNetwork: z.boolean().optional(),
|
|
344
439
|
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
@@ -410,6 +505,23 @@ export function providerHeadersConfigError(headers: unknown): string | null {
|
|
|
410
505
|
return null;
|
|
411
506
|
}
|
|
412
507
|
|
|
508
|
+
/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
|
|
509
|
+
export function apiKeyTransportConfigError(
|
|
510
|
+
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
|
|
511
|
+
): string | null {
|
|
512
|
+
if (provider.apiKeyTransport === undefined) return null;
|
|
513
|
+
if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") {
|
|
514
|
+
return 'apiKeyTransport must be "x-api-key" or "bearer"';
|
|
515
|
+
}
|
|
516
|
+
if (provider.adapter !== "anthropic") {
|
|
517
|
+
return "apiKeyTransport is supported only by the anthropic adapter";
|
|
518
|
+
}
|
|
519
|
+
if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") {
|
|
520
|
+
return "apiKeyTransport requires Anthropic API-key authentication";
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
413
525
|
export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
|
|
414
526
|
if (value === undefined) return null;
|
|
415
527
|
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
@@ -507,15 +619,77 @@ export function modelAdapterRecordConfigError(
|
|
|
507
619
|
return null;
|
|
508
620
|
}
|
|
509
621
|
|
|
622
|
+
const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
|
|
623
|
+
"codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
|
|
624
|
+
const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
|
|
625
|
+
"account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys";
|
|
626
|
+
const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR =
|
|
627
|
+
"account selector targets must be @main or valid Codex pool-account ids";
|
|
628
|
+
const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR =
|
|
629
|
+
"account selectors must not collide with configured Codex pool-account ids or account selector targets";
|
|
630
|
+
|
|
631
|
+
function configuredCodexPoolAccountIds(value: unknown): Set<string> {
|
|
632
|
+
const accountIds = new Set<string>();
|
|
633
|
+
if (!Array.isArray(value)) return accountIds;
|
|
634
|
+
for (const account of value) {
|
|
635
|
+
if (!account || typeof account !== "object" || Array.isArray(account)) continue;
|
|
636
|
+
const { id, isMain } = account as { id?: unknown; isMain?: unknown };
|
|
637
|
+
if (typeof id === "string" && isMain !== true) accountIds.add(id);
|
|
638
|
+
}
|
|
639
|
+
return accountIds;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
643
|
+
(value): value is Record<string, unknown> => !!value
|
|
644
|
+
&& typeof value === "object"
|
|
645
|
+
&& !Array.isArray(value)
|
|
646
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
647
|
+
{ error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR },
|
|
648
|
+
).superRefine((accountNamespaces, ctx) => {
|
|
649
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
650
|
+
for (const [namespace, accountId] of Object.entries(accountNamespaces)) {
|
|
651
|
+
if (!isValidProviderName(namespace)) {
|
|
652
|
+
ctx.addIssue({
|
|
653
|
+
code: "custom",
|
|
654
|
+
path: [namespace],
|
|
655
|
+
message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (!isValidCodexAccountNamespaceTarget(accountId)) {
|
|
659
|
+
ctx.addIssue({
|
|
660
|
+
code: "custom",
|
|
661
|
+
path: [namespace],
|
|
662
|
+
message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}).pipe(z.record(z.string(), z.string()));
|
|
667
|
+
|
|
510
668
|
const configSchema = z.object({
|
|
511
669
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
670
|
+
// A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()`
|
|
671
|
+
// carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one
|
|
672
|
+
// away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to
|
|
673
|
+
// defaults, which is strictly worse than the bind bug this validation exists for. Degrading
|
|
674
|
+
// is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
|
|
675
|
+
// rejection lives in validateConfigCandidate() so bad values still surface to the caller.
|
|
676
|
+
hostname: z.string().trim().min(1).optional().catch(undefined),
|
|
512
677
|
providers: z.record(z.string(), providerConfigSchema),
|
|
513
678
|
defaultProvider: z.string().min(1).default("openai"),
|
|
514
679
|
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
515
680
|
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
516
681
|
contextCapValue: z.number().int().positive().optional(),
|
|
517
682
|
multiAgentGuidanceEnabled: z.boolean().optional(),
|
|
683
|
+
// These selections pre-date schema validation and used to pass through as
|
|
684
|
+
// unknown fields. Invalid hand edits must disable only the optional
|
|
685
|
+
// delegation/native-default feature, not reject the whole config and hide
|
|
686
|
+
// otherwise valid providers, accounts, or the configured listen port.
|
|
687
|
+
injectionModel: z.string().optional().catch(undefined),
|
|
688
|
+
injectionEffort: z.string().optional().catch(undefined),
|
|
689
|
+
syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
|
|
518
690
|
codexShimAutoRestore: z.boolean().optional(),
|
|
691
|
+
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
692
|
+
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
519
693
|
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
520
694
|
grokExcludedModels: z.array(z.string()).optional(),
|
|
521
695
|
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
@@ -526,15 +700,48 @@ const configSchema = z.object({
|
|
|
526
700
|
const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
|
|
527
701
|
if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
|
|
528
702
|
ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" });
|
|
529
|
-
} else if (claudeCode
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
703
|
+
} else if (claudeCode) {
|
|
704
|
+
const claude = claudeCode as { desktopProfile?: unknown };
|
|
705
|
+
if (claude.desktopProfile !== undefined) {
|
|
706
|
+
try {
|
|
707
|
+
parseDesktopProfile(claude.desktopProfile);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
ctx.addIssue({
|
|
710
|
+
code: "custom",
|
|
711
|
+
path: ["claudeCode", "desktopProfile"],
|
|
712
|
+
message: error instanceof Error ? error.message : String(error),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const accountNamespaces = config.codexAccountNamespaces;
|
|
719
|
+
if (accountNamespaces) {
|
|
720
|
+
const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts);
|
|
721
|
+
const configuredProviderNamespaces = new Set([
|
|
722
|
+
COMBO_NAMESPACE,
|
|
723
|
+
OPENAI_CODEX_PROVIDER_ID,
|
|
724
|
+
...Object.keys(config.providers),
|
|
725
|
+
].map(codexProviderNamespaceKey));
|
|
726
|
+
const namespaceTargets = new Set(
|
|
727
|
+
Object.values(accountNamespaces)
|
|
728
|
+
.filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET),
|
|
729
|
+
);
|
|
730
|
+
for (const namespace of Object.keys(accountNamespaces)) {
|
|
731
|
+
if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) {
|
|
732
|
+
ctx.addIssue({
|
|
733
|
+
code: "custom",
|
|
734
|
+
path: ["codexAccountNamespaces", namespace],
|
|
735
|
+
message: "account selectors must not collide with configured provider or combo namespaces",
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
|
|
739
|
+
ctx.addIssue({
|
|
740
|
+
code: "custom",
|
|
741
|
+
path: ["codexAccountNamespaces", namespace],
|
|
742
|
+
message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
538
745
|
}
|
|
539
746
|
}
|
|
540
747
|
for (const name of Object.keys(config.providers)) {
|
|
@@ -600,6 +807,14 @@ const configSchema = z.object({
|
|
|
600
807
|
message: headersError,
|
|
601
808
|
});
|
|
602
809
|
}
|
|
810
|
+
const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
|
|
811
|
+
if (apiKeyTransportError) {
|
|
812
|
+
ctx.addIssue({
|
|
813
|
+
code: "custom",
|
|
814
|
+
path: ["providers", name, "apiKeyTransport"],
|
|
815
|
+
message: apiKeyTransportError,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
603
818
|
const modelAdaptersError = modelAdapterRecordConfigError(
|
|
604
819
|
(provider as { modelAdapters?: unknown }).modelAdapters,
|
|
605
820
|
"modelAdapters",
|
|
@@ -698,6 +913,16 @@ const configSchema = z.object({
|
|
|
698
913
|
ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" });
|
|
699
914
|
} else {
|
|
700
915
|
for (const [id, raw] of Object.entries(combos as Record<string, unknown>)) {
|
|
916
|
+
const alias = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
917
|
+
? (raw as { alias?: unknown }).alias
|
|
918
|
+
: undefined;
|
|
919
|
+
if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) {
|
|
920
|
+
ctx.addIssue({
|
|
921
|
+
code: "custom",
|
|
922
|
+
path: ["combos", id, "alias"],
|
|
923
|
+
message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
701
926
|
// Pass the full map so cross-combo rules (alias uniqueness) apply at load time
|
|
702
927
|
// too, not just via the management API; each combo is excluded from its own check.
|
|
703
928
|
for (const issue of comboConfigIssues(id, raw, config.providers, {
|
|
@@ -773,6 +998,114 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void
|
|
|
773
998
|
}
|
|
774
999
|
}
|
|
775
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
|
|
1003
|
+
* falls back to loopback, which is the safe direction but not what the file asked for —
|
|
1004
|
+
* say so once instead of silently ignoring the field.
|
|
1005
|
+
*/
|
|
1006
|
+
function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
|
|
1007
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
1008
|
+
const raw = (rawParsed as Record<string, unknown>).hostname;
|
|
1009
|
+
if (raw !== undefined && validated.hostname === undefined) {
|
|
1010
|
+
console.warn(`⚠️ config.json hostname ${JSON.stringify(raw)} is not a usable bind address — falling back to 127.0.0.1`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
1015
|
+
|
|
1016
|
+
function isClaudeSubagentEffort(value: unknown): value is NonNullable<OcxClaudeCodeConfig["subagentEffort"]> {
|
|
1017
|
+
return typeof value === "string" && CLAUDE_SUBAGENT_EFFORTS.includes(value as typeof CLAUDE_SUBAGENT_EFFORTS[number]);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function rawClaudeSubagentEffort(rawParsed: unknown): unknown {
|
|
1021
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1022
|
+
const claudeCode = raw?.claudeCode;
|
|
1023
|
+
if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return undefined;
|
|
1024
|
+
return (claudeCode as Record<string, unknown>).subagentEffort;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCode"] {
|
|
1028
|
+
if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) {
|
|
1029
|
+
return claudeCode as OcxConfig["claudeCode"];
|
|
1030
|
+
}
|
|
1031
|
+
const normalized = { ...claudeCode } as Record<string, unknown>;
|
|
1032
|
+
if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
|
|
1033
|
+
delete normalized.subagentEffort;
|
|
1034
|
+
}
|
|
1035
|
+
return normalized as OcxConfig["claudeCode"];
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function normalizeClaudeSubagentEffort(config: OcxConfig, rawParsed: unknown): OcxConfig {
|
|
1039
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1040
|
+
if (rawEffort === undefined || isClaudeSubagentEffort(rawEffort)) return config;
|
|
1041
|
+
return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
1045
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1046
|
+
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1047
|
+
console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
|
|
1052
|
+
|
|
1053
|
+
function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
|
|
1054
|
+
return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed)
|
|
1055
|
+
? rawParsed as Record<string, unknown>
|
|
1056
|
+
: null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] {
|
|
1060
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1061
|
+
if (!raw) return [];
|
|
1062
|
+
const malformed: NativeSubagentPersistedField[] = [];
|
|
1063
|
+
if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") {
|
|
1064
|
+
malformed.push("injectionModel");
|
|
1065
|
+
}
|
|
1066
|
+
if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") {
|
|
1067
|
+
malformed.push("injectionEffort");
|
|
1068
|
+
}
|
|
1069
|
+
if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") {
|
|
1070
|
+
malformed.push("syncCodexSubagentDefaults");
|
|
1071
|
+
}
|
|
1072
|
+
return malformed;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string {
|
|
1076
|
+
const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string";
|
|
1077
|
+
return `${field} ignored: expected ${expected}`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
|
|
1081
|
+
if (config.syncCodexSubagentDefaults !== true) return null;
|
|
1082
|
+
const malformed = malformedNativeSubagentFields(rawParsed);
|
|
1083
|
+
if (malformed.includes("injectionModel")) return "injectionModel must be a string";
|
|
1084
|
+
if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required";
|
|
1085
|
+
if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted";
|
|
1086
|
+
if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) {
|
|
1087
|
+
return "injectionEffort must be a supported Codex reasoning effort";
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig {
|
|
1093
|
+
if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config;
|
|
1094
|
+
const normalized = { ...config };
|
|
1095
|
+
delete normalized.syncCodexSubagentDefaults;
|
|
1096
|
+
return normalized;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void {
|
|
1100
|
+
for (const field of malformedNativeSubagentFields(rawParsed)) {
|
|
1101
|
+
console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`);
|
|
1102
|
+
}
|
|
1103
|
+
const reason = nativeSubagentSyncDisabledReason(config, rawParsed);
|
|
1104
|
+
if (reason) {
|
|
1105
|
+
console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
776
1109
|
export function loadConfig(): OcxConfig {
|
|
777
1110
|
const dir = getConfigDir();
|
|
778
1111
|
const configPath = getConfigPath();
|
|
@@ -787,8 +1120,12 @@ export function loadConfig(): OcxConfig {
|
|
|
787
1120
|
const parsed = JSON.parse(raw);
|
|
788
1121
|
const result = configSchema.safeParse(parsed);
|
|
789
1122
|
if (result.success) {
|
|
790
|
-
|
|
791
|
-
|
|
1123
|
+
const config = result.data as OcxConfig;
|
|
1124
|
+
warnDegradedStreamMode(parsed, config);
|
|
1125
|
+
warnDegradedHostname(parsed, config);
|
|
1126
|
+
warnDegradedClaudeSubagentEffort(parsed);
|
|
1127
|
+
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1128
|
+
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
792
1129
|
}
|
|
793
1130
|
// Schema validation failed — merge defaults into the raw object instead of
|
|
794
1131
|
// discarding it entirely, so pool accounts and providers survive a missing
|
|
@@ -802,7 +1139,11 @@ export function loadConfig(): OcxConfig {
|
|
|
802
1139
|
const retryResult = configSchema.safeParse(merged);
|
|
803
1140
|
if (retryResult.success) {
|
|
804
1141
|
warnConfigRepaired(configPath, result.error);
|
|
805
|
-
|
|
1142
|
+
const config = retryResult.data as OcxConfig;
|
|
1143
|
+
warnDegradedHostname(parsed, config);
|
|
1144
|
+
warnDegradedClaudeSubagentEffort(parsed);
|
|
1145
|
+
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1146
|
+
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
806
1147
|
}
|
|
807
1148
|
// Merge couldn't fix it — truly broken config
|
|
808
1149
|
warnAndBackupInvalidConfig(configPath, result.error);
|
|
@@ -832,16 +1173,35 @@ function configPlaceholderWarnings(config: OcxConfig): string[] {
|
|
|
832
1173
|
return warnings;
|
|
833
1174
|
}
|
|
834
1175
|
|
|
835
|
-
function validFileConfigDiagnostics(config: OcxConfig): ConfigDiagnostics {
|
|
836
|
-
|
|
1176
|
+
function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics {
|
|
1177
|
+
// Unsafe hand-edited optional values are disabled in memory instead of rejecting
|
|
1178
|
+
// the entire config, which would hide unrelated providers/accounts. The next
|
|
1179
|
+
// ordinary save persists the normalized absence.
|
|
1180
|
+
const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed);
|
|
1181
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1182
|
+
const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
|
|
1183
|
+
const warnings = configPlaceholderWarnings(normalized);
|
|
1184
|
+
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1185
|
+
warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
|
|
1186
|
+
}
|
|
1187
|
+
warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
|
|
1188
|
+
if (syncDisabledReason) {
|
|
1189
|
+
warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
|
|
1190
|
+
}
|
|
837
1191
|
return {
|
|
838
|
-
config,
|
|
1192
|
+
config: normalized,
|
|
839
1193
|
source: "file",
|
|
840
1194
|
error: null,
|
|
841
1195
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
842
1196
|
};
|
|
843
1197
|
}
|
|
844
1198
|
|
|
1199
|
+
export function subagentDefaultSyncEffective(
|
|
1200
|
+
config: Pick<OcxConfig, "syncCodexSubagentDefaults" | "injectionModel">,
|
|
1201
|
+
): boolean {
|
|
1202
|
+
return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim());
|
|
1203
|
+
}
|
|
1204
|
+
|
|
845
1205
|
function mergeConfigDefaults(parsed: unknown): unknown {
|
|
846
1206
|
if (!parsed || typeof parsed !== "object") return parsed;
|
|
847
1207
|
const defaults = getDefaultConfig();
|
|
@@ -861,8 +1221,32 @@ function schemaDiagnosticsError(error: z.ZodError): string {
|
|
|
861
1221
|
return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid";
|
|
862
1222
|
}
|
|
863
1223
|
|
|
1224
|
+
/**
|
|
1225
|
+
* Reject a hostname the schema deliberately degrades on read. Load-time has to keep a
|
|
1226
|
+
* blank value non-fatal (see the `hostname` field comment), but an incoming write is a
|
|
1227
|
+
* live caller who can be told the value is wrong — silently rewriting it to loopback
|
|
1228
|
+
* would look like the bind succeeded on the address they asked for.
|
|
1229
|
+
*/
|
|
1230
|
+
function blankHostnameError(value: unknown): string | null {
|
|
1231
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1232
|
+
const hostname = (value as Record<string, unknown>).hostname;
|
|
1233
|
+
if (hostname === undefined) return null;
|
|
1234
|
+
if (typeof hostname !== "string" || !hostname.trim()) {
|
|
1235
|
+
return "schema_invalid: hostname: must be a nonblank bind address";
|
|
1236
|
+
}
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function claudeSubagentEffortError(value: unknown): string | null {
|
|
1241
|
+
const effort = rawClaudeSubagentEffort(value);
|
|
1242
|
+
if (effort === undefined || isClaudeSubagentEffort(effort)) return null;
|
|
1243
|
+
return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
864
1246
|
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
865
1247
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
1248
|
+
const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value);
|
|
1249
|
+
if (boundaryError) return { ok: false, error: boundaryError };
|
|
866
1250
|
const result = configSchema.safeParse(value);
|
|
867
1251
|
if (result.success) return { ok: true, config: result.data as OcxConfig };
|
|
868
1252
|
return { ok: false, error: schemaDiagnosticsError(result.error) };
|
|
@@ -878,12 +1262,12 @@ export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
|
878
1262
|
const parsed = JSON.parse(raw);
|
|
879
1263
|
const result = configSchema.safeParse(parsed);
|
|
880
1264
|
if (result.success) {
|
|
881
|
-
return validFileConfigDiagnostics(result.data as OcxConfig);
|
|
1265
|
+
return validFileConfigDiagnostics(result.data as OcxConfig, parsed);
|
|
882
1266
|
}
|
|
883
1267
|
|
|
884
1268
|
const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
|
|
885
1269
|
if (retryResult.success) {
|
|
886
|
-
return validFileConfigDiagnostics(retryResult.data as OcxConfig);
|
|
1270
|
+
return validFileConfigDiagnostics(retryResult.data as OcxConfig, parsed);
|
|
887
1271
|
}
|
|
888
1272
|
|
|
889
1273
|
return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
|
|
@@ -928,6 +1312,16 @@ export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolea
|
|
|
928
1312
|
*/
|
|
929
1313
|
const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
|
|
930
1314
|
|
|
1315
|
+
/**
|
|
1316
|
+
* The live config retains the address of the socket Bun actually opened, while
|
|
1317
|
+
* this map retains the operator's desired address for the next process start.
|
|
1318
|
+
* Keeping them separate prevents an unrelated live save from restoring a stale
|
|
1319
|
+
* externally exposed bind after OAuth adopted a newer loopback disk config.
|
|
1320
|
+
*/
|
|
1321
|
+
type PersistedServerBinding = Pick<OcxConfig, "port" | "hostname">;
|
|
1322
|
+
|
|
1323
|
+
const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding>();
|
|
1324
|
+
|
|
931
1325
|
/**
|
|
932
1326
|
* Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on
|
|
933
1327
|
* first save — arming lazily would lose exactly the hand edit made before that first
|
|
@@ -964,6 +1358,116 @@ function deepEqual(a: unknown, b: unknown): boolean {
|
|
|
964
1358
|
return true;
|
|
965
1359
|
}
|
|
966
1360
|
|
|
1361
|
+
const MISSING_CONFIG_VALUE = Symbol("missing-config-value");
|
|
1362
|
+
type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE;
|
|
1363
|
+
|
|
1364
|
+
function isPlainConfigRecord(value: ConfigMergeValue): value is Record<string, unknown> {
|
|
1365
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1366
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1367
|
+
return prototype === Object.prototype || prototype === null;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function ownConfigValue(record: Record<string, unknown>, key: string): ConfigMergeValue {
|
|
1371
|
+
return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
|
|
1375
|
+
return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function reconcileConfigRecord(
|
|
1379
|
+
live: Record<string, unknown>,
|
|
1380
|
+
baseline: Record<string, unknown>,
|
|
1381
|
+
persisted: Record<string, unknown>,
|
|
1382
|
+
skippedKeys?: ReadonlySet<string>,
|
|
1383
|
+
): void {
|
|
1384
|
+
const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
|
|
1385
|
+
for (const key of keys) {
|
|
1386
|
+
if (skippedKeys?.has(key)) continue;
|
|
1387
|
+
const merged = reconcileConfigValue(
|
|
1388
|
+
ownConfigValue(baseline, key),
|
|
1389
|
+
ownConfigValue(live, key),
|
|
1390
|
+
ownConfigValue(persisted, key),
|
|
1391
|
+
);
|
|
1392
|
+
if (merged === MISSING_CONFIG_VALUE) delete live[key];
|
|
1393
|
+
else live[key] = merged;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function reconcileConfigValue(
|
|
1398
|
+
baseline: ConfigMergeValue,
|
|
1399
|
+
live: ConfigMergeValue,
|
|
1400
|
+
persisted: ConfigMergeValue,
|
|
1401
|
+
): ConfigMergeValue {
|
|
1402
|
+
const liveChanged = !deepEqual(live, baseline);
|
|
1403
|
+
const persistedChanged = !deepEqual(persisted, baseline);
|
|
1404
|
+
|
|
1405
|
+
if (!liveChanged) {
|
|
1406
|
+
if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) {
|
|
1407
|
+
live.splice(0, live.length, ...structuredClone(persisted));
|
|
1408
|
+
return live;
|
|
1409
|
+
}
|
|
1410
|
+
if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) {
|
|
1411
|
+
reconcileConfigRecord(
|
|
1412
|
+
live,
|
|
1413
|
+
isPlainConfigRecord(baseline) ? baseline : {},
|
|
1414
|
+
persisted,
|
|
1415
|
+
);
|
|
1416
|
+
return live;
|
|
1417
|
+
}
|
|
1418
|
+
return cloneConfigValue(persisted);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
if (!persistedChanged) return live;
|
|
1422
|
+
|
|
1423
|
+
if (isPlainConfigRecord(live)
|
|
1424
|
+
&& isPlainConfigRecord(persisted)
|
|
1425
|
+
&& (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) {
|
|
1426
|
+
reconcileConfigRecord(
|
|
1427
|
+
live,
|
|
1428
|
+
isPlainConfigRecord(baseline) ? baseline : {},
|
|
1429
|
+
persisted,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
// Same-leaf conflicts prefer the pending live management mutation.
|
|
1433
|
+
return live;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Reconcile an async OAuth disk commit into the shared live config without erasing
|
|
1438
|
+
* management mutations that have not saved yet. The baseline is a normalized disk
|
|
1439
|
+
* snapshot from immediately before login; disjoint object edits merge recursively,
|
|
1440
|
+
* while same-leaf conflicts prefer live state.
|
|
1441
|
+
*/
|
|
1442
|
+
export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void {
|
|
1443
|
+
const diagnostics = readConfigDiagnostics();
|
|
1444
|
+
if (diagnostics.source === "fallback") {
|
|
1445
|
+
throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
|
|
1446
|
+
}
|
|
1447
|
+
const persisted = diagnostics.config;
|
|
1448
|
+
const claudeGuardArmed = claudeCodeBaseline.has(config);
|
|
1449
|
+
const pendingLiveClaudeMutation = claudeGuardArmed
|
|
1450
|
+
&& !deepEqual(config.claudeCode, claudeCodeBaseline.get(config));
|
|
1451
|
+
|
|
1452
|
+
persistedLiveServerBinding.set(config, {
|
|
1453
|
+
port: persisted.port,
|
|
1454
|
+
...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}),
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
reconcileConfigRecord(
|
|
1458
|
+
config as unknown as Record<string, unknown>,
|
|
1459
|
+
persistedBaseline as unknown as Record<string, unknown>,
|
|
1460
|
+
persisted as unknown as Record<string, unknown>,
|
|
1461
|
+
new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]),
|
|
1462
|
+
);
|
|
1463
|
+
|
|
1464
|
+
if (claudeGuardArmed && !pendingLiveClaudeMutation) {
|
|
1465
|
+
if (persisted.claudeCode === undefined) delete config.claudeCode;
|
|
1466
|
+
else config.claudeCode = structuredClone(persisted.claudeCode);
|
|
1467
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
967
1471
|
/** The literal file, with no schema merge or default injection. */
|
|
968
1472
|
function readRawConfigJson(): Record<string, unknown> | undefined {
|
|
969
1473
|
try {
|
|
@@ -979,6 +1483,28 @@ function readRawConfigJson(): Record<string, unknown> | undefined {
|
|
|
979
1483
|
}
|
|
980
1484
|
}
|
|
981
1485
|
|
|
1486
|
+
/**
|
|
1487
|
+
* Read only schema-valid binding fields from the literal file. Missing fields mean
|
|
1488
|
+
* their schema defaults; malformed fields keep the last known persisted value.
|
|
1489
|
+
*/
|
|
1490
|
+
function readPersistedServerBinding(
|
|
1491
|
+
raw: Record<string, unknown>,
|
|
1492
|
+
baseline: PersistedServerBinding,
|
|
1493
|
+
): PersistedServerBinding {
|
|
1494
|
+
const port = raw.port === undefined
|
|
1495
|
+
? 10100
|
|
1496
|
+
: (typeof raw.port === "number"
|
|
1497
|
+
&& Number.isInteger(raw.port)
|
|
1498
|
+
&& raw.port >= 0
|
|
1499
|
+
&& raw.port <= 65535
|
|
1500
|
+
? raw.port
|
|
1501
|
+
: baseline.port);
|
|
1502
|
+
const hostname = raw.hostname === undefined
|
|
1503
|
+
? undefined
|
|
1504
|
+
: (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname);
|
|
1505
|
+
return { port, ...(hostname !== undefined ? { hostname } : {}) };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
982
1508
|
/**
|
|
983
1509
|
* The save entry point for every writer holding a LIVE server config.
|
|
984
1510
|
*
|
|
@@ -993,18 +1519,33 @@ function readRawConfigJson(): Record<string, unknown> | undefined {
|
|
|
993
1519
|
* guarantee.
|
|
994
1520
|
*/
|
|
995
1521
|
export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
1522
|
+
const bindingBaseline = persistedLiveServerBinding.get(config);
|
|
1523
|
+
const onDisk = claudeCodeBaseline.has(config) || bindingBaseline
|
|
1524
|
+
? readRawConfigJson()
|
|
1525
|
+
: undefined;
|
|
996
1526
|
if (claudeCodeBaseline.has(config)) {
|
|
997
|
-
const onDisk = readRawConfigJson();
|
|
998
1527
|
if (onDisk !== undefined) {
|
|
999
1528
|
const baseline = claudeCodeBaseline.get(config);
|
|
1000
|
-
const
|
|
1529
|
+
const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
|
|
1530
|
+
const diskChanged = !deepEqual(persistedClaudeCode, baseline);
|
|
1001
1531
|
const weChanged = !deepEqual(config.claudeCode, baseline);
|
|
1002
1532
|
if (diskChanged && !weChanged) {
|
|
1003
|
-
config.claudeCode =
|
|
1533
|
+
config.claudeCode = persistedClaudeCode;
|
|
1004
1534
|
}
|
|
1005
1535
|
}
|
|
1006
1536
|
}
|
|
1007
|
-
|
|
1537
|
+
const persistedBinding = bindingBaseline && onDisk
|
|
1538
|
+
? readPersistedServerBinding(onDisk, bindingBaseline)
|
|
1539
|
+
: bindingBaseline;
|
|
1540
|
+
if (persistedBinding) {
|
|
1541
|
+
const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port };
|
|
1542
|
+
if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
|
|
1543
|
+
else persistedConfig.hostname = persistedBinding.hostname;
|
|
1544
|
+
saveConfig(persistedConfig);
|
|
1545
|
+
persistedLiveServerBinding.set(config, persistedBinding);
|
|
1546
|
+
} else {
|
|
1547
|
+
saveConfig(config);
|
|
1548
|
+
}
|
|
1008
1549
|
if (claudeCodeBaseline.has(config)) {
|
|
1009
1550
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1010
1551
|
}
|