@bitkyc08/opencodex 2.39.0 → 2.40.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 +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
package/src/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
apiKeyTransportConfigError,
|
|
9
9
|
booleanRecordConfigError,
|
|
10
10
|
modelAdapterRecordConfigError,
|
|
11
|
+
modelDisplayNamesConfigError,
|
|
11
12
|
nonBlankStringArrayConfigError,
|
|
12
13
|
normalizeNonBlankStringArray,
|
|
13
14
|
positiveIntegerConfigError,
|
|
@@ -61,6 +62,7 @@ import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
|
61
62
|
import { redactSecretString } from "./lib/redact";
|
|
62
63
|
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
63
64
|
import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases";
|
|
65
|
+
import { MODEL_DISCOVERY_MAX_MODELS } from "./providers/model-discovery-limits";
|
|
64
66
|
import { vercelGatewayRoutingConfigError } from "./providers/vercel-gateway-routing";
|
|
65
67
|
import {
|
|
66
68
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
@@ -74,6 +76,7 @@ import {
|
|
|
74
76
|
type FastWire,
|
|
75
77
|
type ProviderCostOverlay,
|
|
76
78
|
} from "./types";
|
|
79
|
+
import type { OcxRuntimeRole } from "./types/config";
|
|
77
80
|
import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
78
81
|
import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
|
|
79
82
|
import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
|
|
@@ -118,6 +121,11 @@ export {
|
|
|
118
121
|
type AtomicWriteIO,
|
|
119
122
|
} from "./config/atomic-write";
|
|
120
123
|
import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
|
|
124
|
+
import {
|
|
125
|
+
describeProxyForLog,
|
|
126
|
+
readWindowsSystemProxy,
|
|
127
|
+
type WindowsProxyRegistryReader,
|
|
128
|
+
} from "./lib/windows-system-proxy";
|
|
121
129
|
export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
|
|
122
130
|
export {
|
|
123
131
|
getPidPath,
|
|
@@ -493,6 +501,17 @@ const fastWireSchema = z.object({
|
|
|
493
501
|
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
494
502
|
}).transform(fastWire => fastWire as FastWire);
|
|
495
503
|
|
|
504
|
+
const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => {
|
|
505
|
+
const error = modelDisplayNamesConfigError(value);
|
|
506
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
507
|
+
}).transform(value => {
|
|
508
|
+
const labels = Object.create(null) as Record<string, string>;
|
|
509
|
+
for (const [modelId, displayName] of Object.entries(value as Record<string, string>)) {
|
|
510
|
+
labels[modelId] = displayName;
|
|
511
|
+
}
|
|
512
|
+
return labels;
|
|
513
|
+
});
|
|
514
|
+
|
|
496
515
|
/**
|
|
497
516
|
* Zod schema for one provider entry: known fields are validated strictly while unknown
|
|
498
517
|
* fields pass through (preserved for runtime extensions).
|
|
@@ -502,6 +521,7 @@ const providerConfigSchema = z.object({
|
|
|
502
521
|
baseUrl: z.string().min(1),
|
|
503
522
|
alias: z.string().optional(),
|
|
504
523
|
modelAliases: z.record(z.string(), z.string()).optional(),
|
|
524
|
+
modelDisplayNames: modelDisplayNamesSchema.optional(),
|
|
505
525
|
defaultAliases: z.boolean().optional(),
|
|
506
526
|
requestPacing: requestPacingSchema.optional().catch(undefined),
|
|
507
527
|
mcpMaxTools: z.number().int().positive().optional(),
|
|
@@ -524,10 +544,20 @@ const providerConfigSchema = z.object({
|
|
|
524
544
|
upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
|
|
525
545
|
.nullish()
|
|
526
546
|
.transform(value => value ?? undefined),
|
|
547
|
+
// Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g.
|
|
548
|
+
// aggregators whose WebSocket ingress is measurably faster than SSE). The
|
|
549
|
+
// canonical ChatGPT backend WS selection is independent of this flag.
|
|
550
|
+
upstreamWebsocket: z.boolean().optional(),
|
|
527
551
|
directGeminiWireRenames: z.boolean().optional(),
|
|
528
552
|
noStructuredOutputModels: z.array(z.string().min(1))
|
|
529
553
|
.transform(normalizeNonBlankStringArray)
|
|
530
554
|
.optional(),
|
|
555
|
+
retainModels: z.array(z.string().min(1))
|
|
556
|
+
.transform(normalizeNonBlankStringArray)
|
|
557
|
+
.optional(),
|
|
558
|
+
omitReasoningEffortWithToolsModels: z.array(z.string().min(1))
|
|
559
|
+
.transform(normalizeNonBlankStringArray)
|
|
560
|
+
.optional(),
|
|
531
561
|
retryOn429: retryOn429PolicySchema.optional(),
|
|
532
562
|
transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(),
|
|
533
563
|
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
@@ -551,6 +581,7 @@ export {
|
|
|
551
581
|
apiKeyTransportConfigError,
|
|
552
582
|
booleanRecordConfigError,
|
|
553
583
|
modelAdapterRecordConfigError,
|
|
584
|
+
modelDisplayNamesConfigError,
|
|
554
585
|
nonBlankStringArrayConfigError,
|
|
555
586
|
normalizeNonBlankStringArray,
|
|
556
587
|
positiveIntegerConfigError,
|
|
@@ -830,6 +861,13 @@ const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
|
|
|
830
861
|
* it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
|
|
831
862
|
* clients, so a junk first entry would mask a valid later one.
|
|
832
863
|
*/
|
|
864
|
+
const pendingApiKeyRotationSchema = z.object({
|
|
865
|
+
id: z.string().trim().min(1).max(256),
|
|
866
|
+
key: z.string().refine(isUsableApiKeySecret),
|
|
867
|
+
createdAt: z.string().datetime({ offset: true }),
|
|
868
|
+
expiresAt: z.string().datetime({ offset: true }),
|
|
869
|
+
}).strict();
|
|
870
|
+
|
|
833
871
|
const apiKeyEntrySchema = z.object({
|
|
834
872
|
key: z.string().refine(isUsableApiKeySecret),
|
|
835
873
|
// Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
|
|
@@ -837,6 +875,8 @@ const apiKeyEntrySchema = z.object({
|
|
|
837
875
|
id: z.string().catch(""),
|
|
838
876
|
name: z.string().catch(""),
|
|
839
877
|
createdAt: z.string().catch(""),
|
|
878
|
+
// A damaged overlap record must never discard the still-authoritative key.
|
|
879
|
+
pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined),
|
|
840
880
|
}).passthrough();
|
|
841
881
|
|
|
842
882
|
/**
|
|
@@ -861,8 +901,115 @@ const agentTaskRecoverySchema = z.object({
|
|
|
861
901
|
cacheEntries: z.number().int().min(1).max(512).optional(),
|
|
862
902
|
}).strict();
|
|
863
903
|
|
|
904
|
+
const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]);
|
|
905
|
+
|
|
906
|
+
function canonicalHttpOrigin(value: string): string | null {
|
|
907
|
+
try {
|
|
908
|
+
const parsed = new URL(value);
|
|
909
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
910
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
|
|
911
|
+
return parsed.origin;
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const hubConfigSchema = z.object({
|
|
918
|
+
managementPublicOrigin: z.string().transform((value, ctx) => {
|
|
919
|
+
const origin = canonicalHttpOrigin(value);
|
|
920
|
+
if (!origin) {
|
|
921
|
+
ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
|
|
922
|
+
return z.NEVER;
|
|
923
|
+
}
|
|
924
|
+
return origin;
|
|
925
|
+
}).optional(),
|
|
926
|
+
// A malformed hand edit disables only the optional ingress. Live writes are rejected by
|
|
927
|
+
// managementIngressConfigError before this load-time degradation can hide the mistake.
|
|
928
|
+
managementIngress: z.union([
|
|
929
|
+
z.object({ enabled: z.literal(false) }).strict(),
|
|
930
|
+
z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(),
|
|
931
|
+
]).optional().catch(undefined),
|
|
932
|
+
}).strict();
|
|
933
|
+
|
|
934
|
+
const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => {
|
|
935
|
+
if (new TextEncoder().encode(value).byteLength > 320) {
|
|
936
|
+
ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" });
|
|
937
|
+
}
|
|
938
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
939
|
+
ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" });
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
const remoteGuiConfigSchema = z.object({
|
|
944
|
+
allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => {
|
|
945
|
+
const seen = new Set<string>();
|
|
946
|
+
for (let index = 0; index < users.length; index++) {
|
|
947
|
+
const user = users[index]!;
|
|
948
|
+
if (seen.has(user)) {
|
|
949
|
+
ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" });
|
|
950
|
+
}
|
|
951
|
+
seen.add(user);
|
|
952
|
+
}
|
|
953
|
+
}).optional(),
|
|
954
|
+
// Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by
|
|
955
|
+
// the pairing path. Removing it from a strict schema would reject the whole config.
|
|
956
|
+
allowInsecureHttp: z.boolean().optional(),
|
|
957
|
+
}).strict();
|
|
958
|
+
|
|
959
|
+
const connectedClientIdSchema = z.enum(["codex", "claude"]);
|
|
960
|
+
const clientTimestampSchema = z.string().datetime({ offset: true });
|
|
961
|
+
const clientOriginSchema = z.string().transform((value, ctx) => {
|
|
962
|
+
const origin = canonicalHttpOrigin(value);
|
|
963
|
+
if (!origin) {
|
|
964
|
+
ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
|
|
965
|
+
return z.NEVER;
|
|
966
|
+
}
|
|
967
|
+
return origin;
|
|
968
|
+
});
|
|
969
|
+
const clientConnectionSchema = z.object({
|
|
970
|
+
serverUrl: clientOriginSchema,
|
|
971
|
+
managementUrl: clientOriginSchema,
|
|
972
|
+
managementTransport: z.enum(["direct", "relay"]),
|
|
973
|
+
selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => {
|
|
974
|
+
if (new Set(clients).size !== clients.length) {
|
|
975
|
+
ctx.addIssue({ code: "custom", message: "must contain unique client ids" });
|
|
976
|
+
}
|
|
977
|
+
}),
|
|
978
|
+
tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"),
|
|
979
|
+
apiKeyId: z.string().trim().min(1).max(256),
|
|
980
|
+
tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
|
981
|
+
protocolVersion: z.literal(1),
|
|
982
|
+
connectedAt: clientTimestampSchema,
|
|
983
|
+
catalogFingerprint: z.string().min(1).max(512).optional(),
|
|
984
|
+
// base64 of the pre-connect catalog, or "" for "there was none". Bounded above the
|
|
985
|
+
// catalog size cap so a legitimate snapshot round-trips.
|
|
986
|
+
priorCatalog: z.string().max(64 * 1024 * 1024).optional(),
|
|
987
|
+
catalogSyncedAt: clientTimestampSchema.optional(),
|
|
988
|
+
pendingOperation: z.object({
|
|
989
|
+
kind: z.literal("rotate"),
|
|
990
|
+
rotationId: z.string().trim().min(1).max(256),
|
|
991
|
+
newKeyIssuedAt: clientTimestampSchema,
|
|
992
|
+
oldKeyBackupPath: z.string().min(1),
|
|
993
|
+
}).strict().superRefine((operation, ctx) => {
|
|
994
|
+
const expected = join(getConfigDir(), "service-api-token.prev");
|
|
995
|
+
if (operation.oldKeyBackupPath !== expected) {
|
|
996
|
+
ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` });
|
|
997
|
+
}
|
|
998
|
+
}).optional(),
|
|
999
|
+
}).strict();
|
|
1000
|
+
|
|
864
1001
|
const configSchema = z.object({
|
|
865
1002
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
1003
|
+
// A malformed hand edit must disable only remote-role behavior, not discard
|
|
1004
|
+
// providers or data-plane keys. Live writes are rejected explicitly below.
|
|
1005
|
+
runtimeRole: runtimeRoleSchema.optional().catch(undefined),
|
|
1006
|
+
// Malformed optional remote blocks disable only remote GUI behavior. Live
|
|
1007
|
+
// candidates are rejected explicitly by remoteGuiConfigError below.
|
|
1008
|
+
hub: hubConfigSchema.optional().catch(undefined),
|
|
1009
|
+
remoteGui: remoteGuiConfigSchema.optional().catch(undefined),
|
|
1010
|
+
// A malformed present client block must remain diagnosable from raw config and
|
|
1011
|
+
// fail closed through src/client/state.ts; unrelated provider state still loads.
|
|
1012
|
+
client: clientConnectionSchema.optional().catch(undefined),
|
|
866
1013
|
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
|
|
867
1014
|
// Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
|
|
868
1015
|
upstreamHostCircuitThreshold: z.number().int()
|
|
@@ -870,6 +1017,12 @@ const configSchema = z.object({
|
|
|
870
1017
|
.max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
|
|
871
1018
|
.optional()
|
|
872
1019
|
.catch(undefined),
|
|
1020
|
+
// Opt-in outbound body ceiling. An invalid hand edit disables only this guard, matching the
|
|
1021
|
+
// circuit threshold above: a malformed number must not make the proxy refuse traffic.
|
|
1022
|
+
maxUpstreamBodyBytes: z.number().int()
|
|
1023
|
+
.min(0)
|
|
1024
|
+
.optional()
|
|
1025
|
+
.catch(undefined),
|
|
873
1026
|
appOwnedMemoryBudgetMb: z.number().int()
|
|
874
1027
|
.min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
|
|
875
1028
|
.max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
|
|
@@ -924,6 +1077,7 @@ const configSchema = z.object({
|
|
|
924
1077
|
z.array(z.string().trim().min(1)).min(1),
|
|
925
1078
|
).optional().catch(undefined),
|
|
926
1079
|
codexShimAutoRestore: z.boolean().optional(),
|
|
1080
|
+
codexDesktopAuthless: z.boolean().optional().catch(undefined),
|
|
927
1081
|
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
928
1082
|
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
929
1083
|
// Selection order is a preference, not a safety control like pause: a malformed
|
|
@@ -938,6 +1092,10 @@ const configSchema = z.object({
|
|
|
938
1092
|
// Same degrade-not-reject rule: a malformed hand edit hides Spark rather than discarding the
|
|
939
1093
|
// whole config. Hidden is also the default, so `catch(false)` and the default agree.
|
|
940
1094
|
showCodexSparkQuota: z.boolean().optional().catch(false),
|
|
1095
|
+
resetCreditAutoRedeem: z.object({
|
|
1096
|
+
enabled: z.boolean().optional(),
|
|
1097
|
+
leadTimeMinutes: z.number().int().min(1).max(60).optional(),
|
|
1098
|
+
}).optional().catch(undefined),
|
|
941
1099
|
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
942
1100
|
grokExcludedModels: z.array(z.string()).optional(),
|
|
943
1101
|
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
@@ -1109,6 +1267,16 @@ const configSchema = z.object({
|
|
|
1109
1267
|
message: modelCostsError,
|
|
1110
1268
|
});
|
|
1111
1269
|
}
|
|
1270
|
+
const modelDisplayNamesError = modelDisplayNamesConfigError(
|
|
1271
|
+
(provider as { modelDisplayNames?: unknown }).modelDisplayNames,
|
|
1272
|
+
);
|
|
1273
|
+
if (modelDisplayNamesError) {
|
|
1274
|
+
ctx.addIssue({
|
|
1275
|
+
code: "custom",
|
|
1276
|
+
path: ["providers", redactSecretString(name), "modelDisplayNames"],
|
|
1277
|
+
message: modelDisplayNamesError,
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1112
1280
|
const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
|
|
1113
1281
|
if (apiKeyTransportError) {
|
|
1114
1282
|
ctx.addIssue({
|
|
@@ -1242,6 +1410,28 @@ const configSchema = z.object({
|
|
|
1242
1410
|
message: structuredOutputOptOutError,
|
|
1243
1411
|
});
|
|
1244
1412
|
}
|
|
1413
|
+
const retainModelsError = nonBlankStringArrayConfigError(
|
|
1414
|
+
(provider as { retainModels?: unknown }).retainModels,
|
|
1415
|
+
"retainModels",
|
|
1416
|
+
);
|
|
1417
|
+
if (retainModelsError) {
|
|
1418
|
+
ctx.addIssue({
|
|
1419
|
+
code: "custom",
|
|
1420
|
+
path: ["providers", redactSecretString(name), "retainModels"],
|
|
1421
|
+
message: retainModelsError,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
const toolReasoningOptOutError = nonBlankStringArrayConfigError(
|
|
1425
|
+
(provider as { omitReasoningEffortWithToolsModels?: unknown }).omitReasoningEffortWithToolsModels,
|
|
1426
|
+
"omitReasoningEffortWithToolsModels",
|
|
1427
|
+
);
|
|
1428
|
+
if (toolReasoningOptOutError) {
|
|
1429
|
+
ctx.addIssue({
|
|
1430
|
+
code: "custom",
|
|
1431
|
+
path: ["providers", redactSecretString(name), "omitReasoningEffortWithToolsModels"],
|
|
1432
|
+
message: toolReasoningOptOutError,
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1245
1435
|
if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
|
|
1246
1436
|
// Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
|
|
1247
1437
|
// Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
|
|
@@ -1706,6 +1896,47 @@ function warnDegradedAgentTaskRecovery(rawParsed: unknown): void {
|
|
|
1706
1896
|
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1707
1897
|
}
|
|
1708
1898
|
|
|
1899
|
+
function malformedRuntimeRoleWarning(rawParsed: unknown): string | null {
|
|
1900
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1901
|
+
if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
|
|
1902
|
+
if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
|
|
1903
|
+
return 'runtimeRole ignored: expected "standalone", "hub", or "client"; falling back to "standalone"';
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function warnDegradedRuntimeRole(rawParsed: unknown): void {
|
|
1907
|
+
const warning = malformedRuntimeRoleWarning(rawParsed);
|
|
1908
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function malformedOptionalRemoteBlockWarning(
|
|
1912
|
+
rawParsed: unknown,
|
|
1913
|
+
key: "hub" | "remoteGui",
|
|
1914
|
+
): string | null {
|
|
1915
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1916
|
+
if (!raw || !Object.hasOwn(raw, key) || raw[key] === undefined) return null;
|
|
1917
|
+
const schema = key === "hub" ? hubConfigSchema : remoteGuiConfigSchema;
|
|
1918
|
+
const result = schema.safeParse(raw[key]);
|
|
1919
|
+
if (result.success) return null;
|
|
1920
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
1921
|
+
return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function malformedClientConnectionWarning(rawParsed: unknown): string | null {
|
|
1925
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1926
|
+
if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null;
|
|
1927
|
+
const result = clientConnectionSchema.safeParse(raw.client);
|
|
1928
|
+
if (result.success) return null;
|
|
1929
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
1930
|
+
return `client${field ? `.${field}` : ""} invalid: remote client mode is disabled until config.json is repaired`;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void {
|
|
1934
|
+
for (const key of ["hub", "remoteGui"] as const) {
|
|
1935
|
+
const warning = malformedOptionalRemoteBlockWarning(rawParsed, key);
|
|
1936
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1709
1940
|
type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
|
|
1710
1941
|
|
|
1711
1942
|
function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
|
|
@@ -1844,6 +2075,7 @@ export function loadConfig(): OcxConfig {
|
|
|
1844
2075
|
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
1845
2076
|
const parsed = JSON.parse(raw);
|
|
1846
2077
|
sanitizeAliasesForLoad(parsed);
|
|
2078
|
+
sanitizeModelDisplayNamesForLoad(parsed);
|
|
1847
2079
|
sanitizeRetryOn429ForLoad(parsed);
|
|
1848
2080
|
sanitizeModelCostsForLoad(parsed);
|
|
1849
2081
|
const result = configSchema.safeParse(parsed);
|
|
@@ -1859,6 +2091,8 @@ export function loadConfig(): OcxConfig {
|
|
|
1859
2091
|
warnDegradedCodexAccountPicker(parsed);
|
|
1860
2092
|
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1861
2093
|
warnDegradedAgentTaskRecovery(parsed);
|
|
2094
|
+
warnDegradedRuntimeRole(parsed);
|
|
2095
|
+
warnDegradedOptionalRemoteBlocks(parsed);
|
|
1862
2096
|
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
1863
2097
|
}
|
|
1864
2098
|
// Schema validation failed — merge defaults into the raw object instead of
|
|
@@ -1883,6 +2117,8 @@ export function loadConfig(): OcxConfig {
|
|
|
1883
2117
|
warnDegradedCodexAccountPicker(parsed);
|
|
1884
2118
|
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1885
2119
|
warnDegradedAgentTaskRecovery(parsed);
|
|
2120
|
+
warnDegradedRuntimeRole(parsed);
|
|
2121
|
+
warnDegradedOptionalRemoteBlocks(parsed);
|
|
1886
2122
|
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
1887
2123
|
}
|
|
1888
2124
|
// Still failing, but if every complaint is about one or more named entries
|
|
@@ -1903,6 +2139,8 @@ export function loadConfig(): OcxConfig {
|
|
|
1903
2139
|
warnDegradedCodexAccountPicker(parsed);
|
|
1904
2140
|
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1905
2141
|
warnDegradedAgentTaskRecovery(parsed);
|
|
2142
|
+
warnDegradedRuntimeRole(parsed);
|
|
2143
|
+
warnDegradedOptionalRemoteBlocks(parsed);
|
|
1906
2144
|
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
1907
2145
|
}
|
|
1908
2146
|
}
|
|
@@ -1952,6 +2190,38 @@ function sanitizeAliasesForLoad(raw: unknown): void {
|
|
|
1952
2190
|
}
|
|
1953
2191
|
}
|
|
1954
2192
|
|
|
2193
|
+
/** Hand-edited display-name mistakes disable only the bad label. */
|
|
2194
|
+
function sanitizeModelDisplayNamesForLoad(raw: unknown): void {
|
|
2195
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
2196
|
+
const root = raw as Record<string, unknown>;
|
|
2197
|
+
if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
|
|
2198
|
+
for (const [providerName, providerValue] of Object.entries(root.providers as Record<string, unknown>)) {
|
|
2199
|
+
if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue;
|
|
2200
|
+
const provider = providerValue as Record<string, unknown>;
|
|
2201
|
+
const value = provider.modelDisplayNames;
|
|
2202
|
+
if (value === undefined) continue;
|
|
2203
|
+
const providerLabel = JSON.stringify(redactSecretString(providerName));
|
|
2204
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
2205
|
+
|| Object.entries(value).length > MODEL_DISCOVERY_MAX_MODELS) {
|
|
2206
|
+
console.warn(`Ignoring invalid modelDisplayNames map for provider ${providerLabel} in config.json`);
|
|
2207
|
+
delete provider.modelDisplayNames;
|
|
2208
|
+
continue;
|
|
2209
|
+
}
|
|
2210
|
+
const labels = value as Record<string, unknown>;
|
|
2211
|
+
for (const [modelId, rawDisplayName] of Object.entries(labels)) {
|
|
2212
|
+
const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : rawDisplayName;
|
|
2213
|
+
if (modelDisplayNamesConfigError({ [modelId]: displayName })) {
|
|
2214
|
+
const safeModelId = JSON.stringify(redactSecretString(modelId));
|
|
2215
|
+
console.warn(`Ignoring invalid modelDisplayNames entry ${safeModelId} for provider ${providerLabel} in config.json`);
|
|
2216
|
+
delete labels[modelId];
|
|
2217
|
+
} else {
|
|
2218
|
+
labels[modelId] = displayName;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
if (Object.keys(labels).length === 0) delete provider.modelDisplayNames;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1955
2225
|
/** Refresh the user cost-overlay registry from `config` and return it unchanged. */
|
|
1956
2226
|
function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
|
|
1957
2227
|
refreshUserCostOverlays(config);
|
|
@@ -2003,6 +2273,14 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
2003
2273
|
if (hostCircuitWarning) warnings.push(hostCircuitWarning);
|
|
2004
2274
|
const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed);
|
|
2005
2275
|
if (recoveryWarning) warnings.push(recoveryWarning);
|
|
2276
|
+
const runtimeRoleWarning = malformedRuntimeRoleWarning(rawParsed);
|
|
2277
|
+
if (runtimeRoleWarning) warnings.push(runtimeRoleWarning);
|
|
2278
|
+
const hubWarning = malformedOptionalRemoteBlockWarning(rawParsed, "hub");
|
|
2279
|
+
if (hubWarning) warnings.push(hubWarning);
|
|
2280
|
+
const remoteGuiWarning = malformedOptionalRemoteBlockWarning(rawParsed, "remoteGui");
|
|
2281
|
+
if (remoteGuiWarning) warnings.push(remoteGuiWarning);
|
|
2282
|
+
const clientWarning = malformedClientConnectionWarning(rawParsed);
|
|
2283
|
+
if (clientWarning) warnings.push(clientWarning);
|
|
2006
2284
|
if (syncDisabledReason) {
|
|
2007
2285
|
warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
|
|
2008
2286
|
}
|
|
@@ -2094,6 +2372,53 @@ function agentTaskRecoveryError(value: unknown): string | null {
|
|
|
2094
2372
|
return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
2095
2373
|
}
|
|
2096
2374
|
|
|
2375
|
+
function runtimeRoleError(value: unknown): string | null {
|
|
2376
|
+
const raw = rawConfigRecord(value);
|
|
2377
|
+
if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
|
|
2378
|
+
if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
|
|
2379
|
+
return 'schema_invalid: runtimeRole: must be one of "standalone", "hub", or "client"';
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function remoteGuiConfigError(value: unknown): string | null {
|
|
2383
|
+
const raw = rawConfigRecord(value);
|
|
2384
|
+
if (!raw) return null;
|
|
2385
|
+
for (const [key, schema] of [
|
|
2386
|
+
["hub", hubConfigSchema],
|
|
2387
|
+
["remoteGui", remoteGuiConfigSchema],
|
|
2388
|
+
] as const) {
|
|
2389
|
+
if (!Object.hasOwn(raw, key) || raw[key] === undefined) continue;
|
|
2390
|
+
const result = schema.safeParse(raw[key]);
|
|
2391
|
+
if (result.success) continue;
|
|
2392
|
+
const issue = result.error.issues[0];
|
|
2393
|
+
const field = issue?.path.join(".");
|
|
2394
|
+
return `schema_invalid: ${key}${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
2395
|
+
}
|
|
2396
|
+
return null;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function clientConnectionConfigError(value: unknown): string | null {
|
|
2400
|
+
const raw = rawConfigRecord(value);
|
|
2401
|
+
if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null;
|
|
2402
|
+
const result = clientConnectionSchema.safeParse(raw.client);
|
|
2403
|
+
if (result.success) return null;
|
|
2404
|
+
const issue = result.error.issues[0];
|
|
2405
|
+
const field = issue?.path.join(".");
|
|
2406
|
+
return `schema_invalid: client${field ? `.${field}` : ""}: ${issue?.message ?? "invalid client connection"}`;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function clientRolePairError(value: unknown): string | null {
|
|
2410
|
+
const raw = rawConfigRecord(value);
|
|
2411
|
+
if (!raw) return null;
|
|
2412
|
+
const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
|
|
2413
|
+
if (raw.runtimeRole === "client" && !hasClient) {
|
|
2414
|
+
return "schema_invalid: runtimeRole client requires a complete client connection";
|
|
2415
|
+
}
|
|
2416
|
+
if (hasClient && raw.runtimeRole !== "client") {
|
|
2417
|
+
return "schema_invalid: client connection requires runtimeRole client";
|
|
2418
|
+
}
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2097
2422
|
/**
|
|
2098
2423
|
* Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
|
|
2099
2424
|
* malformed selection-order map to undefined, which on a write would drop every entry the
|
|
@@ -2200,6 +2525,52 @@ function loopbackListenerPortError(value: unknown): string | null {
|
|
|
2200
2525
|
return null;
|
|
2201
2526
|
}
|
|
2202
2527
|
|
|
2528
|
+
/**
|
|
2529
|
+
* Validate the hub management ingress at the live-write boundary.
|
|
2530
|
+
*
|
|
2531
|
+
* The persisted schema intentionally degrades a malformed hand edit to disabled so a typo in
|
|
2532
|
+
* this opt-in listener cannot discard providers or credentials. A live config mutation must not
|
|
2533
|
+
* get that leniency: it receives an exact field error before the degrading schema is applied.
|
|
2534
|
+
*/
|
|
2535
|
+
function managementIngressConfigError(value: unknown): string | null {
|
|
2536
|
+
const raw = rawConfigRecord(value);
|
|
2537
|
+
if (!raw) return null;
|
|
2538
|
+
const hub = rawConfigRecord(raw.hub);
|
|
2539
|
+
if (!hub || !Object.hasOwn(hub, "managementIngress") || hub.managementIngress === undefined) return null;
|
|
2540
|
+
const ingress = rawConfigRecord(hub.managementIngress);
|
|
2541
|
+
if (!ingress) {
|
|
2542
|
+
return "schema_invalid: hub.managementIngress: must be an object or omitted";
|
|
2543
|
+
}
|
|
2544
|
+
if (typeof ingress.enabled !== "boolean") {
|
|
2545
|
+
return "schema_invalid: hub.managementIngress.enabled: must be a boolean";
|
|
2546
|
+
}
|
|
2547
|
+
const keys = Object.keys(ingress);
|
|
2548
|
+
if (ingress.enabled === false) {
|
|
2549
|
+
return keys.length === 1
|
|
2550
|
+
? null
|
|
2551
|
+
: "schema_invalid: hub.managementIngress: disabled ingress accepts only enabled";
|
|
2552
|
+
}
|
|
2553
|
+
if (keys.some(key => key !== "enabled" && key !== "port")) {
|
|
2554
|
+
return "schema_invalid: hub.managementIngress: contains an unsupported field";
|
|
2555
|
+
}
|
|
2556
|
+
const ingressPort = ingress.port;
|
|
2557
|
+
if (typeof ingressPort !== "number" || !Number.isInteger(ingressPort) || ingressPort < 1 || ingressPort > 65535) {
|
|
2558
|
+
return "schema_invalid: hub.managementIngress.port: must be an integer port when enabled";
|
|
2559
|
+
}
|
|
2560
|
+
if (raw.runtimeRole !== "hub") {
|
|
2561
|
+
return "schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub";
|
|
2562
|
+
}
|
|
2563
|
+
const proxyPort = typeof raw.port === "number" ? raw.port : 10100;
|
|
2564
|
+
if (proxyPort === ingressPort) {
|
|
2565
|
+
return "schema_invalid: hub.managementIngress.port: must differ from the proxy port";
|
|
2566
|
+
}
|
|
2567
|
+
const loopback = rawConfigRecord(raw.unauthenticatedLoopbackListener);
|
|
2568
|
+
if (loopback?.enabled === true && loopback.port === ingressPort) {
|
|
2569
|
+
return "schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port";
|
|
2570
|
+
}
|
|
2571
|
+
return null;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2203
2574
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
2204
2575
|
const boundaryError = blankHostnameError(value)
|
|
2205
2576
|
?? claudeSubagentEffortError(value)
|
|
@@ -2211,7 +2582,12 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
2211
2582
|
?? codexAccountPickerEnabledError(value)
|
|
2212
2583
|
?? emptyCompletionRetryError(value)
|
|
2213
2584
|
?? oauthOpenBrowserError(value)
|
|
2214
|
-
??
|
|
2585
|
+
?? runtimeRoleError(value)
|
|
2586
|
+
?? remoteGuiConfigError(value)
|
|
2587
|
+
?? clientConnectionConfigError(value)
|
|
2588
|
+
?? clientRolePairError(value)
|
|
2589
|
+
?? loopbackListenerPortError(value)
|
|
2590
|
+
?? managementIngressConfigError(value);
|
|
2215
2591
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
2216
2592
|
const result = configSchema.safeParse(value);
|
|
2217
2593
|
if (result.success) {
|
|
@@ -2227,6 +2603,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
|
|
|
2227
2603
|
// Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
|
|
2228
2604
|
// schema and send the caller a default-config fallback (the config command could then
|
|
2229
2605
|
// persist that fallback over the user's providers/keys).
|
|
2606
|
+
sanitizeModelDisplayNamesForLoad(parsed);
|
|
2230
2607
|
sanitizeRetryOn429ForLoad(parsed);
|
|
2231
2608
|
sanitizeModelCostsForLoad(parsed);
|
|
2232
2609
|
const result = configSchema.safeParse(parsed);
|
|
@@ -2563,6 +2940,9 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync
|
|
|
2563
2940
|
*/
|
|
2564
2941
|
function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
2565
2942
|
const configPath = getConfigPath();
|
|
2943
|
+
const rawBeforeWrite = readRawConfigJson();
|
|
2944
|
+
const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config);
|
|
2945
|
+
if (clientPersistenceError) throw new Error(clientPersistenceError);
|
|
2566
2946
|
// External editors can add provider rows the live config deliberately does
|
|
2567
2947
|
// not route with yet; merge them at the serialization boundary so an
|
|
2568
2948
|
// unrelated in-process save cannot erase the provider or its overlay.
|
|
@@ -2690,7 +3070,7 @@ export function mutatePersistedConfig<T>(
|
|
|
2690
3070
|
|
|
2691
3071
|
const projected = projectCustomModelCatalogMigration(
|
|
2692
3072
|
commitBase.diagnostics.config,
|
|
2693
|
-
confirmedConfig,
|
|
3073
|
+
projectConfigRebaseProvenance(confirmedConfig),
|
|
2694
3074
|
);
|
|
2695
3075
|
if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
|
|
2696
3076
|
return { status: "committed", value: confirmed.value };
|
|
@@ -2699,6 +3079,31 @@ export function mutatePersistedConfig<T>(
|
|
|
2699
3079
|
});
|
|
2700
3080
|
}
|
|
2701
3081
|
|
|
3082
|
+
function failClosedClientPersistenceError(
|
|
3083
|
+
raw: Record<string, unknown> | undefined,
|
|
3084
|
+
candidate: OcxConfig,
|
|
3085
|
+
): string | null {
|
|
3086
|
+
if (!raw) return null;
|
|
3087
|
+
const rawHasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
|
|
3088
|
+
const rawRole = raw.runtimeRole;
|
|
3089
|
+
const rawRoleValid = rawRole === undefined
|
|
3090
|
+
|| rawRole === "standalone"
|
|
3091
|
+
|| rawRole === "hub"
|
|
3092
|
+
|| rawRole === "client";
|
|
3093
|
+
const rawClientValid = !rawHasClient || clientConnectionSchema.safeParse(raw.client).success;
|
|
3094
|
+
const rawPairValid = rawRoleValid
|
|
3095
|
+
&& ((rawRole === "client" && rawHasClient && rawClientValid)
|
|
3096
|
+
|| (rawRole !== "client" && !rawHasClient));
|
|
3097
|
+
if (rawPairValid) return null;
|
|
3098
|
+
|
|
3099
|
+
const candidateValid = candidate.runtimeRole === "client"
|
|
3100
|
+
&& clientConnectionSchema.safeParse(candidate.client).success;
|
|
3101
|
+
const deletions = configRebaseDeletionKeys(candidate);
|
|
3102
|
+
const explicitClear = deletions.has("client") && deletions.has("runtimeRole");
|
|
3103
|
+
if (candidateValid || explicitClear) return null;
|
|
3104
|
+
return "config write refused: malformed or mismatched remote client state must be repaired or explicitly cleared";
|
|
3105
|
+
}
|
|
3106
|
+
|
|
2702
3107
|
export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
|
|
2703
3108
|
return config.websockets === true;
|
|
2704
3109
|
}
|
|
@@ -3123,6 +3528,10 @@ export function multiAgentGuidanceEnabled(
|
|
|
3123
3528
|
return config.multiAgentGuidanceEnabled !== false;
|
|
3124
3529
|
}
|
|
3125
3530
|
|
|
3531
|
+
export function runtimeRole(config: Pick<OcxConfig, "runtimeRole">): OcxRuntimeRole {
|
|
3532
|
+
return config.runtimeRole ?? "standalone";
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3126
3535
|
export function getDefaultConfig(): OcxConfig {
|
|
3127
3536
|
// Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
|
|
3128
3537
|
// gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
|
|
@@ -3189,6 +3598,14 @@ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements
|
|
|
3189
3598
|
* that makes outbound provider requests (server start, catalog sync).
|
|
3190
3599
|
*/
|
|
3191
3600
|
export function applyProxyEnv(config: OcxConfig): void {
|
|
3601
|
+
applyProxyEnvWith(config);
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
/** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */
|
|
3605
|
+
export function applyProxyEnvWith(
|
|
3606
|
+
config: OcxConfig,
|
|
3607
|
+
auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {},
|
|
3608
|
+
): void {
|
|
3192
3609
|
// `proxy` and `noProxy` are not declared in the top-level schema, which ends in
|
|
3193
3610
|
// `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value
|
|
3194
3611
|
// reached string-only methods and threw out of this function, and it runs once per
|
|
@@ -3196,13 +3613,40 @@ export function applyProxyEnv(config: OcxConfig): void {
|
|
|
3196
3613
|
// malformed values with a privacy-safe warning instead: they cannot express a routing
|
|
3197
3614
|
// intent, and refusing to start is a worse answer than starting without them.
|
|
3198
3615
|
const rawProxy = config.proxy;
|
|
3199
|
-
|
|
3616
|
+
let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined;
|
|
3200
3617
|
if (!proxy) {
|
|
3201
3618
|
if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy");
|
|
3202
3619
|
return;
|
|
3203
3620
|
}
|
|
3621
|
+
if (proxy.trim().toLowerCase() === "auto") {
|
|
3622
|
+
// #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal
|
|
3623
|
+
// "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was.
|
|
3624
|
+
if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim()
|
|
3625
|
+
|| process.env.HTTPS_PROXY?.trim() || process.env.https_proxy?.trim()) {
|
|
3626
|
+
console.log("[opencodex] proxy \"auto\": existing HTTP_PROXY/HTTPS_PROXY environment wins; system proxy not consulted");
|
|
3627
|
+
proxy = undefined;
|
|
3628
|
+
} else {
|
|
3629
|
+
const found = readWindowsSystemProxy(auto.reader, auto.platform);
|
|
3630
|
+
if (found.kind === "proxy") {
|
|
3631
|
+
console.log(`[opencodex] proxy "auto": using Windows system proxy ${describeProxyForLog(found.url)}`);
|
|
3632
|
+
proxy = found.url;
|
|
3633
|
+
} else {
|
|
3634
|
+
const reason = found.kind === "unsupported"
|
|
3635
|
+
? "only Windows system proxy discovery is supported; using direct egress on this OS"
|
|
3636
|
+
: found.kind === "disabled"
|
|
3637
|
+
? "Windows system proxy is disabled; using direct egress"
|
|
3638
|
+
: found.kind === "socks-only"
|
|
3639
|
+
? "Windows system proxy is SOCKS-only, which HTTP_PROXY cannot express; using direct egress"
|
|
3640
|
+
: "Windows proxy settings could not be read; using direct egress";
|
|
3641
|
+
console.log(`[opencodex] proxy "auto": ${reason}`);
|
|
3642
|
+
proxy = undefined;
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
if (proxy) {
|
|
3204
3647
|
if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
|
|
3205
3648
|
if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
|
|
3649
|
+
}
|
|
3206
3650
|
const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
|
|
3207
3651
|
const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
|
|
3208
3652
|
const seen = new Set(entries.map(e => e.toLowerCase()));
|