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