@bitkyc08/opencodex 2.10.2 → 2.11.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 +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/config.ts
CHANGED
|
@@ -27,8 +27,12 @@ import {
|
|
|
27
27
|
isValidCodexAccountNamespaceTarget,
|
|
28
28
|
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
|
|
29
29
|
} from "./codex/account-namespace-match";
|
|
30
|
+
import { isCodexAccountPriorityKey } from "./codex/account-priority";
|
|
31
|
+
import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health";
|
|
32
|
+
import { parseAccountPriority } from "./codex/pool-rotation";
|
|
30
33
|
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
|
|
31
34
|
import { routingProfileIssues } from "./routing/profile";
|
|
35
|
+
import { POLICY_NAMESPACE } from "./routing/profile-namespace";
|
|
32
36
|
import {
|
|
33
37
|
forgetEphemeralSecretPath,
|
|
34
38
|
hardenSecretDir,
|
|
@@ -945,6 +949,33 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
|
945
949
|
}
|
|
946
950
|
}).pipe(z.record(z.string(), z.string()));
|
|
947
951
|
|
|
952
|
+
const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
|
|
953
|
+
"codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
|
|
954
|
+
const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
|
|
955
|
+
"selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
|
|
956
|
+
const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
|
|
957
|
+
"selection order must be an integer between -100 and 100";
|
|
958
|
+
|
|
959
|
+
const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
960
|
+
|
|
961
|
+
const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
|
|
962
|
+
(value): value is Record<string, unknown> => !!value
|
|
963
|
+
&& typeof value === "object"
|
|
964
|
+
&& !Array.isArray(value)
|
|
965
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
966
|
+
{ error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
|
|
967
|
+
).superRefine((priorities, ctx) => {
|
|
968
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
969
|
+
for (const [accountId, priority] of Object.entries(priorities)) {
|
|
970
|
+
if (!isCodexAccountPriorityKey(accountId)) {
|
|
971
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
|
|
972
|
+
}
|
|
973
|
+
if (parseAccountPriority(priority) === null) {
|
|
974
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}).pipe(z.record(z.string(), z.number().int()));
|
|
978
|
+
|
|
948
979
|
/**
|
|
949
980
|
* Deliberately permissive. A user's config is not ours to invalidate: a strict
|
|
950
981
|
* entry fails the whole parse, and loadConfig's fallback then backs the file up
|
|
@@ -987,11 +1018,18 @@ const apiKeyEntrySchema = z.object({
|
|
|
987
1018
|
const clientIntegrationsSchema = z.object({
|
|
988
1019
|
codex: z.boolean().optional().catch(undefined),
|
|
989
1020
|
grok: z.boolean().optional().catch(undefined),
|
|
1021
|
+
"claude-desktop": z.boolean().optional().catch(undefined),
|
|
990
1022
|
}).passthrough();
|
|
991
1023
|
|
|
992
1024
|
const configSchema = z.object({
|
|
993
1025
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
994
1026
|
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
|
|
1027
|
+
// Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
|
|
1028
|
+
upstreamHostCircuitThreshold: z.number().int()
|
|
1029
|
+
.min(0)
|
|
1030
|
+
.max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
|
|
1031
|
+
.optional()
|
|
1032
|
+
.catch(undefined),
|
|
995
1033
|
appOwnedMemoryBudgetMb: z.number().int()
|
|
996
1034
|
.min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
|
|
997
1035
|
.max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
|
|
@@ -1004,6 +1042,14 @@ const configSchema = z.object({
|
|
|
1004
1042
|
// is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
|
|
1005
1043
|
// rejection lives in validateConfigCandidate() so bad values still surface to the caller.
|
|
1006
1044
|
hostname: z.string().trim().min(1).optional().catch(undefined),
|
|
1045
|
+
// Discriminated on `enabled` so a disabled entry cannot be forced to carry a port, and an
|
|
1046
|
+
// enabled one cannot omit it (#1102). A malformed value degrades to undefined rather than
|
|
1047
|
+
// failing the whole parse: this is an opt-in convenience surface, and a hand-edit typo here
|
|
1048
|
+
// must never reset providers/apiKeys through the backup-and-defaults repair path.
|
|
1049
|
+
unauthenticatedLoopbackListener: z.union([
|
|
1050
|
+
z.object({ enabled: z.literal(false) }),
|
|
1051
|
+
z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }),
|
|
1052
|
+
]).optional().catch(undefined),
|
|
1007
1053
|
providers: z.record(z.string(), providerConfigSchema),
|
|
1008
1054
|
defaultProvider: z.string().min(1).default("openai"),
|
|
1009
1055
|
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
@@ -1024,6 +1070,15 @@ const configSchema = z.object({
|
|
|
1024
1070
|
codexShimAutoRestore: z.boolean().optional(),
|
|
1025
1071
|
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
1026
1072
|
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
1073
|
+
// Selection order is a preference, not a safety control like pause: a malformed
|
|
1074
|
+
// map degrades to "no ordering" rather than failing the parse, so a hand-edited
|
|
1075
|
+
// typo cannot trip the backup-and-defaults repair path and wipe providers or
|
|
1076
|
+
// pool accounts. Warning emitted in loadConfig.
|
|
1077
|
+
codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
|
|
1078
|
+
activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
|
|
1079
|
+
// A malformed hand edit must degrade to false without discarding providers, accounts,
|
|
1080
|
+
// or the exact selector map. Live writes remain strict.
|
|
1081
|
+
codexAccountPickerEnabled: z.boolean().optional().catch(false),
|
|
1027
1082
|
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
1028
1083
|
grokExcludedModels: z.array(z.string()).optional(),
|
|
1029
1084
|
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
@@ -1076,6 +1131,7 @@ const configSchema = z.object({
|
|
|
1076
1131
|
const configuredProviderNamespaces = new Set([
|
|
1077
1132
|
COMBO_NAMESPACE,
|
|
1078
1133
|
OPENAI_CODEX_PROVIDER_ID,
|
|
1134
|
+
POLICY_NAMESPACE,
|
|
1079
1135
|
...Object.keys(config.providers),
|
|
1080
1136
|
].map(codexProviderNamespaceKey));
|
|
1081
1137
|
const namespaceTargets = new Set(
|
|
@@ -1087,7 +1143,7 @@ const configSchema = z.object({
|
|
|
1087
1143
|
ctx.addIssue({
|
|
1088
1144
|
code: "custom",
|
|
1089
1145
|
path: ["codexAccountNamespaces", namespace],
|
|
1090
|
-
message: "account selectors must not collide with configured provider or
|
|
1146
|
+
message: "account selectors must not collide with configured provider, combo, or routing policy namespaces",
|
|
1091
1147
|
});
|
|
1092
1148
|
}
|
|
1093
1149
|
if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
|
|
@@ -1493,6 +1549,32 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
|
|
|
1493
1549
|
}
|
|
1494
1550
|
}
|
|
1495
1551
|
|
|
1552
|
+
/**
|
|
1553
|
+
* Companion to {@link warnDegradedStreamMode} for a malformed selection-order map.
|
|
1554
|
+
* Priority is a preference, so the schema drops the whole map rather than failing
|
|
1555
|
+
* the parse — say so once, otherwise the pool silently reverts to flat ordering.
|
|
1556
|
+
*/
|
|
1557
|
+
function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
|
|
1558
|
+
const record = rawConfigRecord(rawParsed);
|
|
1559
|
+
const warnings: string[] = [];
|
|
1560
|
+
// The pin degrades silently otherwise, which reads as the manual selection simply
|
|
1561
|
+
// not having survived the restart.
|
|
1562
|
+
if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
|
|
1563
|
+
warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned");
|
|
1564
|
+
}
|
|
1565
|
+
const raw = record?.codexAccountPriorities;
|
|
1566
|
+
if (raw !== undefined && validated.codexAccountPriorities === undefined) {
|
|
1567
|
+
warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
|
|
1568
|
+
}
|
|
1569
|
+
return warnings;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void {
|
|
1573
|
+
for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) {
|
|
1574
|
+
console.warn(`⚠️ config.json ${warning}`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1496
1578
|
/**
|
|
1497
1579
|
* The apiKeys schema salvages entry by entry rather than failing the parse, so a
|
|
1498
1580
|
* dropped key is otherwise invisible — and it will not be re-saved by the next
|
|
@@ -1621,6 +1703,23 @@ function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
|
1621
1703
|
}
|
|
1622
1704
|
}
|
|
1623
1705
|
|
|
1706
|
+
function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null {
|
|
1707
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1708
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
1709
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
1710
|
+
if (threshold === undefined) return null;
|
|
1711
|
+
if (typeof threshold === "number"
|
|
1712
|
+
&& Number.isInteger(threshold)
|
|
1713
|
+
&& threshold >= 0
|
|
1714
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
1715
|
+
return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void {
|
|
1719
|
+
const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
1720
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1624
1723
|
type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
|
|
1625
1724
|
|
|
1626
1725
|
function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
|
|
@@ -1650,6 +1749,18 @@ function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField
|
|
|
1650
1749
|
return `${field} ignored: expected ${expected}`;
|
|
1651
1750
|
}
|
|
1652
1751
|
|
|
1752
|
+
function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null {
|
|
1753
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1754
|
+
if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null;
|
|
1755
|
+
if (typeof raw.codexAccountPickerEnabled === "boolean") return null;
|
|
1756
|
+
return "codexAccountPickerEnabled ignored: expected a boolean";
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function warnDegradedCodexAccountPicker(rawParsed: unknown): void {
|
|
1760
|
+
const warning = malformedCodexAccountPickerWarning(rawParsed);
|
|
1761
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1653
1764
|
function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
|
|
1654
1765
|
if (config.syncCodexSubagentDefaults !== true) return null;
|
|
1655
1766
|
const malformed = malformedNativeSubagentFields(rawParsed);
|
|
@@ -1698,8 +1809,11 @@ export function loadConfig(): OcxConfig {
|
|
|
1698
1809
|
warnDegradedStreamMode(parsed, config);
|
|
1699
1810
|
warnDegradedHostname(parsed, config);
|
|
1700
1811
|
warnDegradedApiKeys(parsed, config);
|
|
1812
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1701
1813
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1702
1814
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1815
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
1816
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1703
1817
|
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
1704
1818
|
}
|
|
1705
1819
|
// Schema validation failed — merge defaults into the raw object instead of
|
|
@@ -1717,8 +1831,11 @@ export function loadConfig(): OcxConfig {
|
|
|
1717
1831
|
const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
|
|
1718
1832
|
warnDegradedHostname(parsed, config);
|
|
1719
1833
|
warnDegradedApiKeys(parsed, config);
|
|
1834
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1720
1835
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1721
1836
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1837
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
1838
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1722
1839
|
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
1723
1840
|
}
|
|
1724
1841
|
// Merge couldn't fix it — truly broken config
|
|
@@ -1763,10 +1880,15 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
1763
1880
|
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1764
1881
|
const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
|
|
1765
1882
|
const warnings = configPlaceholderWarnings(normalized);
|
|
1883
|
+
warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
|
|
1766
1884
|
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1767
1885
|
warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
|
|
1768
1886
|
}
|
|
1769
1887
|
warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
|
|
1888
|
+
const pickerWarning = malformedCodexAccountPickerWarning(rawParsed);
|
|
1889
|
+
if (pickerWarning) warnings.push(pickerWarning);
|
|
1890
|
+
const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
1891
|
+
if (hostCircuitWarning) warnings.push(hostCircuitWarning);
|
|
1770
1892
|
if (syncDisabledReason) {
|
|
1771
1893
|
warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
|
|
1772
1894
|
}
|
|
@@ -1836,6 +1958,44 @@ function appOwnedMemoryBudgetError(value: unknown): string | null {
|
|
|
1836
1958
|
return null;
|
|
1837
1959
|
}
|
|
1838
1960
|
|
|
1961
|
+
function upstreamHostCircuitThresholdError(value: unknown): string | null {
|
|
1962
|
+
const raw = rawConfigRecord(value);
|
|
1963
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
1964
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
1965
|
+
if (threshold === undefined) return null;
|
|
1966
|
+
if (typeof threshold === "number"
|
|
1967
|
+
&& Number.isInteger(threshold)
|
|
1968
|
+
&& threshold >= 0
|
|
1969
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
1970
|
+
return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
|
|
1975
|
+
* malformed selection-order map to undefined, which on a write would drop every entry the
|
|
1976
|
+
* user had accumulated and still report success. A load-time degrade leaves the raw map in
|
|
1977
|
+
* the file to be repaired by hand; a degraded write erases it. One bad `ocx config set`
|
|
1978
|
+
* must not cost the whole map, so a live caller is told instead.
|
|
1979
|
+
*/
|
|
1980
|
+
function codexAccountPrioritiesError(value: unknown): string | null {
|
|
1981
|
+
const raw = rawConfigRecord(value);
|
|
1982
|
+
if (!raw) return null;
|
|
1983
|
+
if (raw.codexAccountPriorities !== undefined) {
|
|
1984
|
+
const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
|
|
1985
|
+
if (!parsed.success) {
|
|
1986
|
+
return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
// Tested as a string rather than coerced: `String(123)` matches the id pattern, so a
|
|
1990
|
+
// coercing guard waves a non-string pin through to the schema, where `.catch(undefined)`
|
|
1991
|
+
// drops it and reports the write as a success — the exact silent-degrade this guards.
|
|
1992
|
+
const pin = raw.activeCodexAccountPinned;
|
|
1993
|
+
if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
|
|
1994
|
+
return "schema_invalid: activeCodexAccountPinned: must be an account id";
|
|
1995
|
+
}
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1839
1999
|
function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
|
|
1840
2000
|
const raw = rawConfigRecord(value);
|
|
1841
2001
|
if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
|
|
@@ -1844,12 +2004,71 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu
|
|
|
1844
2004
|
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
|
|
1845
2005
|
}
|
|
1846
2006
|
|
|
2007
|
+
function codexAccountPickerEnabledError(value: unknown): string | null {
|
|
2008
|
+
const raw = rawConfigRecord(value);
|
|
2009
|
+
if (!raw) return null;
|
|
2010
|
+
const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled");
|
|
2011
|
+
if (!descriptor) {
|
|
2012
|
+
return "codexAccountPickerEnabled" in raw
|
|
2013
|
+
? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"
|
|
2014
|
+
: null;
|
|
2015
|
+
}
|
|
2016
|
+
if (!("value" in descriptor)) {
|
|
2017
|
+
return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted";
|
|
2018
|
+
}
|
|
2019
|
+
const enabled = descriptor.value;
|
|
2020
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2021
|
+
return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted";
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1847
2024
|
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
2025
|
+
/**
|
|
2026
|
+
* Reject a loopback-listener port that collides with the proxy port (#1102).
|
|
2027
|
+
*
|
|
2028
|
+
* The schema can only check the shape of each field on its own; the two ports being distinct
|
|
2029
|
+
* is a relationship between them. Letting the pair through would surface as a startup failure
|
|
2030
|
+
* after the public listener already bound, which reads like an unrelated port conflict.
|
|
2031
|
+
*
|
|
2032
|
+
* This is write-time only, matching `blankHostnameError`: a live caller can be told the value
|
|
2033
|
+
* is wrong, whereas a hand-edited config on the read path degrades to undefined rather than
|
|
2034
|
+
* resetting the whole file.
|
|
2035
|
+
*/
|
|
2036
|
+
function loopbackListenerPortError(value: unknown): string | null {
|
|
2037
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2038
|
+
const listener = (value as Record<string, unknown>).unauthenticatedLoopbackListener;
|
|
2039
|
+
if (listener === undefined) return null;
|
|
2040
|
+
if (!listener || typeof listener !== "object" || Array.isArray(listener)) {
|
|
2041
|
+
return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted";
|
|
2042
|
+
}
|
|
2043
|
+
const entry = listener as Record<string, unknown>;
|
|
2044
|
+
// `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE
|
|
2045
|
+
// a `"true"` string entry and report success, leaving an operator convinced they enabled an
|
|
2046
|
+
// unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand
|
|
2047
|
+
// edit must not reset the file — but a live caller gets told.
|
|
2048
|
+
if (typeof entry.enabled !== "boolean") {
|
|
2049
|
+
return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean";
|
|
2050
|
+
}
|
|
2051
|
+
if (entry.enabled !== true) return null;
|
|
2052
|
+
const listenerPort = entry.port;
|
|
2053
|
+
if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) {
|
|
2054
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled";
|
|
2055
|
+
}
|
|
2056
|
+
const proxyPort = (value as Record<string, unknown>).port;
|
|
2057
|
+
if (typeof proxyPort === "number" && proxyPort === listenerPort) {
|
|
2058
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port";
|
|
2059
|
+
}
|
|
2060
|
+
return null;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
1848
2063
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
1849
2064
|
const boundaryError = blankHostnameError(value)
|
|
1850
2065
|
?? claudeSubagentEffortError(value)
|
|
1851
2066
|
?? appOwnedMemoryBudgetError(value)
|
|
1852
|
-
??
|
|
2067
|
+
?? upstreamHostCircuitThresholdError(value)
|
|
2068
|
+
?? googleAntigravityStaticCatalogVersionError(value)
|
|
2069
|
+
?? codexAccountPrioritiesError(value)
|
|
2070
|
+
?? codexAccountPickerEnabledError(value)
|
|
2071
|
+
?? loopbackListenerPortError(value);
|
|
1853
2072
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
1854
2073
|
const result = configSchema.safeParse(value);
|
|
1855
2074
|
if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
|
package/src/images/loop.ts
CHANGED
|
@@ -900,7 +900,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
900
900
|
}
|
|
901
901
|
|
|
902
902
|
const sse = bridgeToResponsesSSE(
|
|
903
|
-
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
|
|
903
|
+
produce(), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
|
|
904
904
|
internalAbort.abort("client closed responses stream");
|
|
905
905
|
}, 2_000,
|
|
906
906
|
{
|
|
@@ -78,6 +78,25 @@ function claimNamesDifferentHome(
|
|
|
78
78
|
return false;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Map a service-manager claim backend to the `ServiceInstallState.backend`
|
|
83
|
+
* value it corresponds to. `scheduler` (Task Scheduler) and `winsw` (native)
|
|
84
|
+
* are the two Windows manager backends; launchd/systemd claims have no Windows
|
|
85
|
+
* backend and can never mismatch a v2 state file.
|
|
86
|
+
*/
|
|
87
|
+
function claimBackendToStateBackend(backend: ServiceManagerClaim["backend"]): "scheduler" | "native" | null {
|
|
88
|
+
if (backend === "scheduler") return "scheduler";
|
|
89
|
+
if (backend === "winsw") return "native";
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when the recorded state backend disagrees with the manager claim. Legacy v1 means scheduler. */
|
|
94
|
+
function claimBackendMismatchesState(claim: ServiceManagerClaim, state: { backend?: "scheduler" | "native" }): boolean {
|
|
95
|
+
const expected = claimBackendToStateBackend(claim.backend);
|
|
96
|
+
if (expected === null) return false;
|
|
97
|
+
return (state.backend ?? "scheduler") !== expected;
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
export interface OwnershipDeps extends ProbeDeps {
|
|
82
101
|
/**
|
|
83
102
|
* Which state paths to consult. Injectable because the default set includes
|
|
@@ -125,7 +144,14 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
|
|
|
125
144
|
};
|
|
126
145
|
}
|
|
127
146
|
|
|
128
|
-
|
|
147
|
+
// The manager assets live under the effective OPENCODEX_HOME. Production
|
|
148
|
+
// callers do not inject ProbeDeps.configDir, so derive it from the same
|
|
149
|
+
// current-home snapshot used for ownership comparison rather than silently
|
|
150
|
+
// falling back to <homedir>/.opencodex.
|
|
151
|
+
const manager = inspectServiceManagerInstallation({
|
|
152
|
+
...deps,
|
|
153
|
+
configDir: deps.configDir ?? current.opencodexHome,
|
|
154
|
+
});
|
|
129
155
|
if (manager.kind === "unknown") {
|
|
130
156
|
return { ownership: "unknown", reason: manager.reason };
|
|
131
157
|
}
|
|
@@ -146,6 +172,17 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
|
|
|
146
172
|
reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`,
|
|
147
173
|
};
|
|
148
174
|
}
|
|
175
|
+
// A manager backend that disagrees with the recorded state (e.g. state says
|
|
176
|
+
// native/WinSW but a scheduler task is found) is an interrupted backend
|
|
177
|
+
// switch: it does not prove which manager owns the installation. v1 state
|
|
178
|
+
// predates the field and is scheduler by contract.
|
|
179
|
+
const stateBackendMismatch = valid.find(state => manager.claims.some(claim => claimBackendMismatchesState(claim, state.state)));
|
|
180
|
+
if (stateBackendMismatch) {
|
|
181
|
+
return {
|
|
182
|
+
ownership: "unknown",
|
|
183
|
+
reason: `the service state records backend ${stateBackendMismatch.state.backend ?? "scheduler"} but ${manager.claims[0]?.backend ?? "a service manager"} is installed`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
149
186
|
// Definition agrees. Valid state agreeing with it is ownership; no state at
|
|
150
187
|
// all beside an installed definition is not, because the definition is the
|
|
151
188
|
// claim and nothing here recorded making it.
|
|
@@ -162,4 +199,4 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
|
|
|
162
199
|
return valid.length === 0
|
|
163
200
|
? { ownership: "owned", reason: "no service state and no service manager claim" }
|
|
164
201
|
: { ownership: "owned", reason: "the recorded service state names these homes" };
|
|
165
|
-
}
|
|
202
|
+
}
|
|
@@ -94,7 +94,7 @@ export function decideEagerRelay(
|
|
|
94
94
|
* Windows preserves the decision for no-rewrite traffic. Darwin permits only
|
|
95
95
|
* explicit config opt-in; `auto` remains tee even on a future fixed runtime.
|
|
96
96
|
* Returns the normalized effective decision, or null when platform policy,
|
|
97
|
-
* rewrite needs, or a Darwin non-config-eager mode selects tee.
|
|
97
|
+
* Windows rewrite needs, or a Darwin non-config-eager mode selects tee.
|
|
98
98
|
*/
|
|
99
99
|
export function selectEagerPath(
|
|
100
100
|
platform: NodeJS.Platform,
|
|
@@ -103,12 +103,12 @@ export function selectEagerPath(
|
|
|
103
103
|
version: string = Bun.version,
|
|
104
104
|
minFixed: string | null = MIN_FIXED_BUN_VERSION,
|
|
105
105
|
): EagerRelayDecision | null {
|
|
106
|
-
if (
|
|
106
|
+
if (platform !== "win32" && platform !== "darwin") {
|
|
107
107
|
return null;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
const decision = decideEagerRelay(mode, version, minFixed);
|
|
111
|
-
if (platform === "win32") return decision;
|
|
111
|
+
if (platform === "win32") return needsClientRewrite ? null : decision;
|
|
112
112
|
return decision.reason === "config-eager" ? decision : null;
|
|
113
113
|
}
|
|
114
114
|
|
package/src/lib/sse-decoder.ts
CHANGED
|
@@ -13,6 +13,47 @@ export type SseRecord =
|
|
|
13
13
|
| { kind: "event"; event?: string; data: string }
|
|
14
14
|
| { kind: "comment"; comment: string };
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Extract one SSE field value from a single line, or null when the line is a different field.
|
|
18
|
+
*
|
|
19
|
+
* The space after the colon is OPTIONAL in text/event-stream: `data:{"a":1}` is as valid as
|
|
20
|
+
* `data: {"a":1}`. Parsers that hardcoded `startsWith("data: ")` silently dropped every frame
|
|
21
|
+
* from a producer that omits it, which surfaced as a completed turn with no content (#1170).
|
|
22
|
+
*
|
|
23
|
+
* Strips at most ONE leading space — the same rule `decodeServerSentEvents` applies below — so a
|
|
24
|
+
* payload that legitimately begins with whitespace keeps the rest of it. Does not trim the value:
|
|
25
|
+
* callers own that choice, and some of them intentionally keep trailing bytes.
|
|
26
|
+
*/
|
|
27
|
+
export function sseFieldValue(line: string, field: string): string | null {
|
|
28
|
+
if (!line.startsWith(field)) return null;
|
|
29
|
+
const rest = line.slice(field.length);
|
|
30
|
+
// A colonless field line is the field with an empty value per the SSE rules, and
|
|
31
|
+
// `decodeServerSentEvents` below treats it that way (`colon < 0` -> valueStart = line.length).
|
|
32
|
+
// These helpers must not disagree with the decoder they mirror.
|
|
33
|
+
if (rest.length === 0) return "";
|
|
34
|
+
if (!rest.startsWith(":")) return null;
|
|
35
|
+
return rest.startsWith(": ") ? rest.slice(2) : rest.slice(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Offset-only variant of {@link sseFieldValue} for parsers that index into a larger buffer.
|
|
40
|
+
*
|
|
41
|
+
* Returns the index where the field's value begins within `text`, or -1 when the line at
|
|
42
|
+
* `[lineStart, lineEnd)` is a different field. Slicing nothing matters for the live Claude relay,
|
|
43
|
+
* whose translator-budget accounting reserves bytes by offset — materializing the line first would
|
|
44
|
+
* allocate the very string the budget exists to bound.
|
|
45
|
+
*/
|
|
46
|
+
export function sseFieldOffset(text: string, lineStart: number, lineEnd: number, field: string): number {
|
|
47
|
+
if (!text.startsWith(field, lineStart)) return -1;
|
|
48
|
+
let valueStart = lineStart + field.length;
|
|
49
|
+
// Colonless field line: empty value, positioned at end-of-line (matches the decoder).
|
|
50
|
+
if (valueStart >= lineEnd) return lineEnd;
|
|
51
|
+
if (text[valueStart] !== ":") return -1;
|
|
52
|
+
valueStart += 1;
|
|
53
|
+
if (valueStart < lineEnd && text[valueStart] === " ") valueStart += 1;
|
|
54
|
+
return valueStart;
|
|
55
|
+
}
|
|
56
|
+
|
|
16
57
|
/**
|
|
17
58
|
* Decode text/event-stream records across arbitrary fetch chunk boundaries.
|
|
18
59
|
*
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isLocalAttestationSecret } from "./local-management-attestation";
|
|
3
|
+
|
|
4
|
+
export const SYSTEM_RESTART_METHOD = "POST";
|
|
5
|
+
export const SYSTEM_RESTART_PATH = "/api/system/restart";
|
|
6
|
+
export const SYSTEM_RESTART_CAPABILITY_VERSION = "v1";
|
|
7
|
+
export const SYSTEM_RESTART_EXPECTED_PID_HEADER = "x-opencodex-restart-expected-pid";
|
|
8
|
+
export const SYSTEM_RESTART_NONCE_HEADER = "x-opencodex-restart-nonce";
|
|
9
|
+
export const SYSTEM_RESTART_CAPABILITY_HEADER = "x-opencodex-restart-capability";
|
|
10
|
+
|
|
11
|
+
/** Fixed drain and replacement budgets shared by the server, CLI, and tray. */
|
|
12
|
+
export const MEMORY_DRAIN_RESTART_MS = 60_000;
|
|
13
|
+
export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
|
|
14
|
+
|
|
15
|
+
const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
|
|
16
|
+
|
|
17
|
+
export type ExpectedSystemRestartPid =
|
|
18
|
+
| { kind: "absent" }
|
|
19
|
+
| { kind: "invalid" }
|
|
20
|
+
| { kind: "present"; pid: number };
|
|
21
|
+
|
|
22
|
+
export function parseExpectedSystemRestartPid(value: string | null): ExpectedSystemRestartPid {
|
|
23
|
+
if (value === null) return { kind: "absent" };
|
|
24
|
+
if (!/^[1-9]\d*$/.test(value)) return { kind: "invalid" };
|
|
25
|
+
const pid = Number(value);
|
|
26
|
+
return Number.isSafeInteger(pid) ? { kind: "present", pid } : { kind: "invalid" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function restartCapabilityPayload(
|
|
30
|
+
nonce: string,
|
|
31
|
+
method: string,
|
|
32
|
+
path: string,
|
|
33
|
+
pid: number,
|
|
34
|
+
port: number,
|
|
35
|
+
): string | null {
|
|
36
|
+
if (!BASE64URL_256.test(nonce)) return null;
|
|
37
|
+
if (method !== SYSTEM_RESTART_METHOD || path !== SYSTEM_RESTART_PATH) return null;
|
|
38
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
39
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
40
|
+
return `opencodex-system-restart-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Process-scoped, operation-only authorization. It is not a reusable management credential. */
|
|
44
|
+
export function createSystemRestartCapability(
|
|
45
|
+
secret: string,
|
|
46
|
+
nonce: string,
|
|
47
|
+
method: string,
|
|
48
|
+
path: string,
|
|
49
|
+
pid: number,
|
|
50
|
+
port: number,
|
|
51
|
+
): string | null {
|
|
52
|
+
if (!isLocalAttestationSecret(secret)) return null;
|
|
53
|
+
const payload = restartCapabilityPayload(nonce, method, path, pid, port);
|
|
54
|
+
if (!payload) return null;
|
|
55
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function verifySystemRestartCapability(
|
|
59
|
+
secret: string,
|
|
60
|
+
nonce: string | null,
|
|
61
|
+
method: string,
|
|
62
|
+
path: string,
|
|
63
|
+
pid: number,
|
|
64
|
+
port: number,
|
|
65
|
+
capability: string | null,
|
|
66
|
+
): boolean {
|
|
67
|
+
if (!nonce || !capability || !BASE64URL_256.test(capability)) return false;
|
|
68
|
+
const expected = createSystemRestartCapability(secret, nonce, method, path, pid, port);
|
|
69
|
+
if (!expected) return false;
|
|
70
|
+
const expectedBytes = Buffer.from(expected);
|
|
71
|
+
const actualBytes = Buffer.from(capability);
|
|
72
|
+
return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
|
|
73
|
+
}
|