@bitkyc08/opencodex 2.8.0 → 2.9.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 +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/config.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join, resolve } from "node:path";
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
5
7
|
import * as z from "zod/v4";
|
|
6
8
|
import {
|
|
7
9
|
CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
@@ -11,7 +13,13 @@ import {
|
|
|
11
13
|
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
|
|
12
14
|
} from "./codex/account-namespace-match";
|
|
13
15
|
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
forgetHardenedSecretPath,
|
|
18
|
+
hardenSecretDir,
|
|
19
|
+
hardenSecretPath,
|
|
20
|
+
hardenSecretPathAsync,
|
|
21
|
+
windowsSecretAclApplies,
|
|
22
|
+
} from "./lib/windows-secret-acl";
|
|
15
23
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
16
24
|
import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
|
|
17
25
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
@@ -23,11 +31,17 @@ import {
|
|
|
23
31
|
REASONING_SUMMARY_DELIVERY_VALUES,
|
|
24
32
|
type OcxClaudeCodeConfig,
|
|
25
33
|
type OcxConfig,
|
|
34
|
+
type OcxApiKeyEntry,
|
|
26
35
|
type OcxProviderConfig,
|
|
27
36
|
} from "./types";
|
|
28
37
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
29
38
|
import { parseDesktopProfile } from "./claude/desktop-profile";
|
|
30
39
|
import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
|
|
40
|
+
import {
|
|
41
|
+
DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES,
|
|
42
|
+
MAX_APP_OWNED_MEMORY_BUDGET_MB,
|
|
43
|
+
MIN_APP_OWNED_MEMORY_BUDGET_MB,
|
|
44
|
+
} from "./lib/app-owned-memory";
|
|
31
45
|
|
|
32
46
|
let _atomicSeq = 0;
|
|
33
47
|
|
|
@@ -108,6 +122,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
|
|
|
108
122
|
io.harden(tmp);
|
|
109
123
|
hardened = true;
|
|
110
124
|
io.rename(tmp, path);
|
|
125
|
+
forgetHardenedSecretPath(tmp);
|
|
111
126
|
} catch (cause) {
|
|
112
127
|
let scrubbed = false;
|
|
113
128
|
try {
|
|
@@ -134,6 +149,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
|
|
|
134
149
|
if (!removed && !hardened) {
|
|
135
150
|
try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
136
151
|
}
|
|
152
|
+
if (removed) forgetHardenedSecretPath(tmp);
|
|
137
153
|
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
138
154
|
throw cause;
|
|
139
155
|
}
|
|
@@ -192,6 +208,7 @@ export async function atomicWriteFileAsync(
|
|
|
192
208
|
await effective.harden(tmp);
|
|
193
209
|
hardened = true;
|
|
194
210
|
await effective.rename(tmp, path);
|
|
211
|
+
forgetHardenedSecretPath(tmp);
|
|
195
212
|
} catch (cause) {
|
|
196
213
|
let scrubbed = false;
|
|
197
214
|
try {
|
|
@@ -218,6 +235,7 @@ export async function atomicWriteFileAsync(
|
|
|
218
235
|
if (!removed && !hardened) {
|
|
219
236
|
try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
220
237
|
}
|
|
238
|
+
if (removed) forgetHardenedSecretPath(tmp);
|
|
221
239
|
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
222
240
|
throw cause;
|
|
223
241
|
}
|
|
@@ -292,7 +310,10 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
292
310
|
write: (target, bytes) => writeFileSync(target, bytes),
|
|
293
311
|
harden: target => {
|
|
294
312
|
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
295
|
-
|
|
313
|
+
// Soft-fail: a wedged/failed icacls on CI temp volumes must not abort
|
|
314
|
+
// startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR).
|
|
315
|
+
// chmod above still applies; live credential writes keep required:true.
|
|
316
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: false });
|
|
296
317
|
},
|
|
297
318
|
publishNoReplace: (temp, backup) => linkSync(temp, backup),
|
|
298
319
|
truncate: target => truncateSync(target, 0),
|
|
@@ -331,7 +352,6 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
331
352
|
|
|
332
353
|
const scrubUnpublishedTemp = (): void => {
|
|
333
354
|
cleanupAttempted = true;
|
|
334
|
-
if (!io.exists(temp)) return;
|
|
335
355
|
let scrubbed = false;
|
|
336
356
|
try {
|
|
337
357
|
io.truncate(temp);
|
|
@@ -347,14 +367,19 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
347
367
|
io.unlink(temp);
|
|
348
368
|
removed = true;
|
|
349
369
|
} catch (error) {
|
|
350
|
-
if (isMissingPathError(error)
|
|
370
|
+
if (isMissingPathError(error)) {
|
|
371
|
+
removed = true;
|
|
372
|
+
}
|
|
351
373
|
else {
|
|
352
374
|
try { io.unlink(temp); removed = true; }
|
|
353
375
|
catch (retryError) {
|
|
354
|
-
if (isMissingPathError(retryError)
|
|
376
|
+
if (isMissingPathError(retryError)) {
|
|
377
|
+
removed = true;
|
|
378
|
+
}
|
|
355
379
|
}
|
|
356
380
|
}
|
|
357
381
|
}
|
|
382
|
+
if (removed) forgetHardenedSecretPath(temp);
|
|
358
383
|
if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
|
|
359
384
|
if (!removed) throw new OpenAiTierBackupCleanupError();
|
|
360
385
|
};
|
|
@@ -375,10 +400,18 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
375
400
|
published = true;
|
|
376
401
|
try {
|
|
377
402
|
io.unlink(temp);
|
|
378
|
-
|
|
379
|
-
|
|
403
|
+
forgetHardenedSecretPath(temp);
|
|
404
|
+
} catch (firstError) {
|
|
405
|
+
if (isMissingPathError(firstError)) {
|
|
406
|
+
forgetHardenedSecretPath(temp);
|
|
407
|
+
} else try {
|
|
380
408
|
io.unlink(temp);
|
|
381
|
-
|
|
409
|
+
forgetHardenedSecretPath(temp);
|
|
410
|
+
} catch (secondError) {
|
|
411
|
+
if (isMissingPathError(secondError)) {
|
|
412
|
+
forgetHardenedSecretPath(temp);
|
|
413
|
+
return "created";
|
|
414
|
+
}
|
|
382
415
|
// temp and backup are hard links to the same inode. Roll back the backup
|
|
383
416
|
// link before any truncation so the downgrade snapshot is never zeroed.
|
|
384
417
|
try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); }
|
|
@@ -430,12 +463,25 @@ function resolveRuntimePortPath(): string {
|
|
|
430
463
|
}
|
|
431
464
|
|
|
432
465
|
const warnedConfigFallbacks = new Set<string>();
|
|
466
|
+
let lastWarningReconciledGeneration = 0;
|
|
467
|
+
|
|
468
|
+
export function reconcileConfigWarningMemos(generation: number): number {
|
|
469
|
+
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
470
|
+
const removed = warnedConfigFallbacks.size;
|
|
471
|
+
warnedConfigFallbacks.clear();
|
|
472
|
+
lastWarningReconciledGeneration = generation;
|
|
473
|
+
return removed;
|
|
474
|
+
}
|
|
433
475
|
|
|
434
476
|
const providerConfigSchema = z.object({
|
|
435
477
|
adapter: z.string().min(1),
|
|
436
478
|
baseUrl: z.string().min(1),
|
|
479
|
+
mcpMaxTools: z.number().int().positive().optional(),
|
|
480
|
+
mcpMaxSchemaBytes: z.number().int().positive().optional(),
|
|
481
|
+
mcpMaxResultBytes: z.number().int().positive().optional(),
|
|
437
482
|
apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
|
|
438
483
|
responsesPath: z.string().min(1).optional(),
|
|
484
|
+
statelessResponses: z.boolean().optional(),
|
|
439
485
|
allowPrivateNetwork: z.boolean().optional(),
|
|
440
486
|
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
441
487
|
responsesItemIdRepair: z.object({
|
|
@@ -666,8 +712,44 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
|
666
712
|
}
|
|
667
713
|
}).pipe(z.record(z.string(), z.string()));
|
|
668
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Deliberately permissive. A user's config is not ours to invalidate: a strict
|
|
717
|
+
* entry fails the whole parse, and loadConfig's fallback then backs the file up
|
|
718
|
+
* and returns defaults — losing providers and pool accounts because one key name
|
|
719
|
+
* was too long. Length and charset rules live at the POST/PATCH boundary, where
|
|
720
|
+
* rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key
|
|
721
|
+
* properties across a load -> mutate -> save round trip.
|
|
722
|
+
*
|
|
723
|
+
* Only `key` is load-bearing: admission compares that string and nothing else
|
|
724
|
+
* (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one
|
|
725
|
+
* field that must be a usable string, and every piece of metadata around it
|
|
726
|
+
* degrades instead of taking the credential down with it. Dropping a working key
|
|
727
|
+
* because its `name` was hand-edited to a number would be a silent revocation —
|
|
728
|
+
* and on a remote bind, potentially a server that refuses to start.
|
|
729
|
+
*
|
|
730
|
+
* "Usable" matches admission exactly. The presented token is trimmed before the
|
|
731
|
+
* comparison but the stored value is not, so a key with surrounding whitespace
|
|
732
|
+
* can never match either form of itself. Keeping one would be worse than dropping
|
|
733
|
+
* it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
|
|
734
|
+
* clients, so a junk first entry would mask a valid later one.
|
|
735
|
+
*/
|
|
736
|
+
const apiKeyEntrySchema = z.object({
|
|
737
|
+
key: z.string().refine(isUsableApiKeySecret),
|
|
738
|
+
// Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
|
|
739
|
+
// which fills it deterministically so the id is stable across loads.
|
|
740
|
+
id: z.string().catch(""),
|
|
741
|
+
name: z.string().catch(""),
|
|
742
|
+
createdAt: z.string().catch(""),
|
|
743
|
+
}).passthrough();
|
|
744
|
+
|
|
669
745
|
const configSchema = z.object({
|
|
670
746
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
747
|
+
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
|
|
748
|
+
appOwnedMemoryBudgetMb: z.number().int()
|
|
749
|
+
.min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
|
|
750
|
+
.max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
|
|
751
|
+
.default(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024))
|
|
752
|
+
.catch(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)),
|
|
671
753
|
// A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()`
|
|
672
754
|
// carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one
|
|
673
755
|
// away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to
|
|
@@ -701,6 +783,23 @@ const configSchema = z.object({
|
|
|
701
783
|
// non-string must not trip the backup-and-defaults repair path. Unset then
|
|
702
784
|
// takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
|
|
703
785
|
experimentalRealtimeWsBaseUrl: z.string().optional().catch(undefined),
|
|
786
|
+
// Salvage element by element, and never fail the parse. Two spellings were
|
|
787
|
+
// measured on this zod version and both lose data:
|
|
788
|
+
// `z.array(entry).catch(undefined)` -> one bad entry discards EVERY key
|
|
789
|
+
// `z.array(z.unknown())` -> a non-array value still raises
|
|
790
|
+
// invalid_type, reaching the
|
|
791
|
+
// backup-and-defaults repair path
|
|
792
|
+
// Starting from `unknown` is what makes both survivable. A key the user still
|
|
793
|
+
// has deployed must not be collateral damage for one bad neighbour, and on a
|
|
794
|
+
// remote bind an emptied array is worse than cosmetic: assertServerAuthConfig
|
|
795
|
+
// refuses to start without a data credential.
|
|
796
|
+
apiKeys: z.unknown().optional().transform(value => {
|
|
797
|
+
if (value === undefined) return undefined;
|
|
798
|
+
if (!Array.isArray(value)) return undefined;
|
|
799
|
+
return value
|
|
800
|
+
.filter(row => apiKeyEntrySchema.safeParse(row).success)
|
|
801
|
+
.map(row => apiKeyEntrySchema.parse(row) as OcxApiKeyEntry);
|
|
802
|
+
}),
|
|
704
803
|
}).passthrough().superRefine((config, ctx) => {
|
|
705
804
|
const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
|
|
706
805
|
if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
|
|
@@ -1016,6 +1115,97 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
|
|
|
1016
1115
|
}
|
|
1017
1116
|
}
|
|
1018
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* The apiKeys schema salvages entry by entry rather than failing the parse, so a
|
|
1120
|
+
* dropped key is otherwise invisible — and it will not be re-saved by the next
|
|
1121
|
+
* mutation. Say so out loud. Compares the raw array against the validated one,
|
|
1122
|
+
* the same shape as the degrade warnings above.
|
|
1123
|
+
*/
|
|
1124
|
+
/** One definition of "usable secret", shared by the schema and the warnings. */
|
|
1125
|
+
function isUsableApiKeySecret(value: unknown): value is string {
|
|
1126
|
+
return typeof value === "string" && value.length > 0 && value === value.trim();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Give every salvaged key a stable, targetable id.
|
|
1131
|
+
*
|
|
1132
|
+
* Pure and deterministic on purpose. Two earlier spellings were wrong: minting a
|
|
1133
|
+
* UUID inside the schema transform handed out a different id on every parse, and
|
|
1134
|
+
* repairing-then-writing during `loadConfig` put a file write on the read path,
|
|
1135
|
+
* where it could clobber a concurrent legitimate save with a stale snapshot.
|
|
1136
|
+
*
|
|
1137
|
+
* So the replacement id is derived from the entry's position, which is already
|
|
1138
|
+
* how the file orders these rows: same file in, same ids out, no I/O and no
|
|
1139
|
+
* randomness. It is not derived from the secret — a public identifier should
|
|
1140
|
+
* never be a function of key material.
|
|
1141
|
+
*/
|
|
1142
|
+
function normalizeApiKeyIds(config: OcxConfig): OcxConfig {
|
|
1143
|
+
const keys = config.apiKeys;
|
|
1144
|
+
if (!keys?.length) return config;
|
|
1145
|
+
// Reserve every explicit id BEFORE synthesizing any, or a synthetic
|
|
1146
|
+
// `salvaged-1` assigned to row 1 would push a row that legitimately owns that
|
|
1147
|
+
// id onto `salvaged-2`. An id the user already has is the one thing this
|
|
1148
|
+
// repair must never take away.
|
|
1149
|
+
const reserved = new Set<string>();
|
|
1150
|
+
for (const entry of keys) {
|
|
1151
|
+
if (entry.id) reserved.add(entry.id);
|
|
1152
|
+
}
|
|
1153
|
+
const taken = new Set<string>(reserved);
|
|
1154
|
+
const kept = new Set<string>();
|
|
1155
|
+
keys.forEach((entry, index) => {
|
|
1156
|
+
// The first row holding an explicit id keeps it; later collisions are the
|
|
1157
|
+
// ones that move.
|
|
1158
|
+
if (entry.id && !kept.has(entry.id)) {
|
|
1159
|
+
kept.add(entry.id);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
let candidate = `salvaged-${index + 1}`;
|
|
1163
|
+
let suffix = 1;
|
|
1164
|
+
while (taken.has(candidate)) candidate = `salvaged-${index + 1}-${++suffix}`;
|
|
1165
|
+
entry.id = candidate;
|
|
1166
|
+
taken.add(candidate);
|
|
1167
|
+
kept.add(candidate);
|
|
1168
|
+
});
|
|
1169
|
+
return config;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function warnDegradedApiKeys(rawParsed: unknown, validated: OcxConfig): void {
|
|
1173
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
1174
|
+
const raw = (rawParsed as Record<string, unknown>).apiKeys;
|
|
1175
|
+
if (raw === undefined) return;
|
|
1176
|
+
if (!Array.isArray(raw)) {
|
|
1177
|
+
console.warn(`⚠️ config.json apiKeys is not an array — ignoring it; generate a new key from the API tab`);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const dropped = raw.length - (validated.apiKeys?.length ?? 0);
|
|
1181
|
+
if (dropped > 0) {
|
|
1182
|
+
console.warn(`⚠️ config.json apiKeys: skipped ${dropped} malformed entr${dropped === 1 ? "y" : "ies"} — the remaining keys still work`);
|
|
1183
|
+
}
|
|
1184
|
+
// Same-length repairs are invisible to the count above, and they are the ones
|
|
1185
|
+
// that show up as a blank name or an unknown date in the dashboard. Say so.
|
|
1186
|
+
const repaired = raw.filter(row => {
|
|
1187
|
+
if (!row || typeof row !== "object") return false;
|
|
1188
|
+
const entry = row as Record<string, unknown>;
|
|
1189
|
+
// Must match the schema exactly: a row whose key is unusable was DROPPED, and
|
|
1190
|
+
// saying "the key still works" about it would be a lie.
|
|
1191
|
+
if (!isUsableApiKeySecret(entry.key)) return false;
|
|
1192
|
+
return typeof entry.id !== "string" || !entry.id
|
|
1193
|
+
|| typeof entry.name !== "string"
|
|
1194
|
+
|| typeof entry.createdAt !== "string";
|
|
1195
|
+
}).length;
|
|
1196
|
+
if (repaired > 0) {
|
|
1197
|
+
console.warn(`⚠️ config.json apiKeys: repaired metadata on ${repaired} entr${repaired === 1 ? "y" : "ies"} — the key still works, but its name or date may read as unknown`);
|
|
1198
|
+
}
|
|
1199
|
+
// A duplicate id is repaired too, and it is not visible in either count above.
|
|
1200
|
+
const ids = raw.filter(row => row && typeof row === "object" && isUsableApiKeySecret((row as Record<string, unknown>).key))
|
|
1201
|
+
.map(row => (row as Record<string, unknown>).id)
|
|
1202
|
+
.filter((id): id is string => typeof id === "string" && !!id);
|
|
1203
|
+
const duplicates = ids.length - new Set(ids).size;
|
|
1204
|
+
if (duplicates > 0) {
|
|
1205
|
+
console.warn(`⚠️ config.json apiKeys: ${duplicates} entr${duplicates === 1 ? "y" : "ies"} shared an id — reassigned so each key can be renamed and revoked on its own`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1019
1209
|
const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
1020
1210
|
|
|
1021
1211
|
function isClaudeSubagentEffort(value: unknown): value is NonNullable<OcxClaudeCodeConfig["subagentEffort"]> {
|
|
@@ -1125,9 +1315,10 @@ export function loadConfig(): OcxConfig {
|
|
|
1125
1315
|
const parsed = JSON.parse(raw);
|
|
1126
1316
|
const result = configSchema.safeParse(parsed);
|
|
1127
1317
|
if (result.success) {
|
|
1128
|
-
const config = result.data as OcxConfig;
|
|
1318
|
+
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
1129
1319
|
warnDegradedStreamMode(parsed, config);
|
|
1130
1320
|
warnDegradedHostname(parsed, config);
|
|
1321
|
+
warnDegradedApiKeys(parsed, config);
|
|
1131
1322
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1132
1323
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1133
1324
|
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
@@ -1144,8 +1335,9 @@ export function loadConfig(): OcxConfig {
|
|
|
1144
1335
|
const retryResult = configSchema.safeParse(merged);
|
|
1145
1336
|
if (retryResult.success) {
|
|
1146
1337
|
warnConfigRepaired(configPath, result.error);
|
|
1147
|
-
const config = retryResult.data as OcxConfig;
|
|
1338
|
+
const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
|
|
1148
1339
|
warnDegradedHostname(parsed, config);
|
|
1340
|
+
warnDegradedApiKeys(parsed, config);
|
|
1149
1341
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1150
1342
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1151
1343
|
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
@@ -1167,6 +1359,12 @@ export type ConfigDiagnostics = {
|
|
|
1167
1359
|
warnings?: string[];
|
|
1168
1360
|
};
|
|
1169
1361
|
|
|
1362
|
+
type ConfigFileSnapshot = {
|
|
1363
|
+
diagnostics: ConfigDiagnostics;
|
|
1364
|
+
/** Exact file contents, including a possible BOM, used as the optimistic revision. */
|
|
1365
|
+
raw?: string;
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1170
1368
|
function configPlaceholderWarnings(config: OcxConfig): string[] {
|
|
1171
1369
|
const warnings: string[] = [];
|
|
1172
1370
|
for (const [name, provider] of Object.entries(config.providers)) {
|
|
@@ -1248,31 +1446,37 @@ function claudeSubagentEffortError(value: unknown): string | null {
|
|
|
1248
1446
|
return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`;
|
|
1249
1447
|
}
|
|
1250
1448
|
|
|
1449
|
+
function appOwnedMemoryBudgetError(value: unknown): string | null {
|
|
1450
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1451
|
+
const budget = (value as Record<string, unknown>).appOwnedMemoryBudgetMb;
|
|
1452
|
+
if (budget === undefined) return null;
|
|
1453
|
+
if (typeof budget !== "number" || !Number.isInteger(budget)
|
|
1454
|
+
|| budget < MIN_APP_OWNED_MEMORY_BUDGET_MB || budget > MAX_APP_OWNED_MEMORY_BUDGET_MB) {
|
|
1455
|
+
return `schema_invalid: appOwnedMemoryBudgetMb: must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}`;
|
|
1456
|
+
}
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1251
1460
|
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
1252
1461
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
1253
|
-
const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value);
|
|
1462
|
+
const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value);
|
|
1254
1463
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
1255
1464
|
const result = configSchema.safeParse(value);
|
|
1256
|
-
if (result.success) return { ok: true, config: result.data as OcxConfig };
|
|
1465
|
+
if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
|
|
1257
1466
|
return { ok: false, error: schemaDiagnosticsError(result.error) };
|
|
1258
1467
|
}
|
|
1259
1468
|
|
|
1260
|
-
|
|
1261
|
-
const configPath = getConfigPath();
|
|
1262
|
-
if (!existsSync(configPath)) {
|
|
1263
|
-
return { config: getDefaultConfig(), source: "default", error: null };
|
|
1264
|
-
}
|
|
1469
|
+
function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
|
|
1265
1470
|
try {
|
|
1266
|
-
const
|
|
1267
|
-
const parsed = JSON.parse(raw);
|
|
1471
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
1268
1472
|
const result = configSchema.safeParse(parsed);
|
|
1269
1473
|
if (result.success) {
|
|
1270
|
-
return validFileConfigDiagnostics(result.data as OcxConfig, parsed);
|
|
1474
|
+
return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
|
|
1271
1475
|
}
|
|
1272
1476
|
|
|
1273
1477
|
const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
|
|
1274
1478
|
if (retryResult.success) {
|
|
1275
|
-
return validFileConfigDiagnostics(retryResult.data as OcxConfig, parsed);
|
|
1479
|
+
return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
|
|
1276
1480
|
}
|
|
1277
1481
|
|
|
1278
1482
|
return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
|
|
@@ -1281,23 +1485,224 @@ export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
|
1281
1485
|
}
|
|
1282
1486
|
}
|
|
1283
1487
|
|
|
1284
|
-
|
|
1488
|
+
function readConfigFileSnapshot(): ConfigFileSnapshot {
|
|
1489
|
+
try {
|
|
1490
|
+
const raw = readFileSync(getConfigPath(), "utf-8");
|
|
1491
|
+
return { diagnostics: configDiagnosticsFromRaw(raw), raw };
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
if (isMissingPathError(error)) {
|
|
1494
|
+
return {
|
|
1495
|
+
diagnostics: { config: getDefaultConfig(), source: "default", error: null },
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
diagnostics: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
1505
|
+
return readConfigFileSnapshot().diagnostics;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
|
|
1509
|
+
const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
|
|
1510
|
+
let warnedConfigMutationDirectoryAcl = false;
|
|
1511
|
+
|
|
1512
|
+
export class ConfigMutationLockError extends Error {
|
|
1513
|
+
readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE";
|
|
1514
|
+
|
|
1515
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
1516
|
+
super(message, options);
|
|
1517
|
+
this.name = "ConfigMutationLockError";
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function configMutationDatabasePath(): string {
|
|
1285
1522
|
const dir = getConfigDir();
|
|
1286
|
-
// First statement on purpose: a rejected
|
|
1287
|
-
// freshly created/chmod'd directory. See src/lib/test-home-guard.ts.
|
|
1523
|
+
// First statement on purpose: a rejected mutation must leave nothing behind, not a
|
|
1524
|
+
// freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts.
|
|
1288
1525
|
assertNotRealHomeUnderTest(dir);
|
|
1289
1526
|
if (!existsSync(dir)) {
|
|
1290
1527
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1291
1528
|
} else {
|
|
1292
1529
|
try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
|
|
1293
1530
|
}
|
|
1294
|
-
if (
|
|
1295
|
-
|
|
1531
|
+
if (windowsSecretAclApplies()) {
|
|
1532
|
+
try {
|
|
1533
|
+
// Distinct timeout memo from management-token directory harden: a required
|
|
1534
|
+
// management-dir timeout must not poison config mutation on the same home
|
|
1535
|
+
// (windows-latest server-management-auth cases).
|
|
1536
|
+
hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` });
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
if (!warnedConfigMutationDirectoryAcl) {
|
|
1539
|
+
warnedConfigMutationDirectoryAcl = true;
|
|
1540
|
+
const diagnostics = error instanceof Error ? error.message : "ACL hardening failed";
|
|
1541
|
+
console.warn(
|
|
1542
|
+
`[opencodex] Config mutation coordination directory ACL hardening did not complete; continuing without it. ${diagnostics}`,
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
const path = join(dir, CONFIG_MUTATION_DB_FILENAME);
|
|
1548
|
+
recordOwnedConfigPath(dir, path);
|
|
1549
|
+
for (const suffix of CONFIG_MUTATION_DB_SIDECARS) {
|
|
1550
|
+
recordOwnedConfigPath(dir, `${path}${suffix}`);
|
|
1551
|
+
}
|
|
1552
|
+
return path;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
let configMutationLockDepth = 0;
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Serialize synchronous config and Codex credential-generation commits across processes with an
|
|
1559
|
+
* OS-backed SQLite write transaction. `busy_timeout=0` is deliberate: runtime request paths must
|
|
1560
|
+
* fail immediately under contention rather than freeze the Bun event loop. Process exit releases
|
|
1561
|
+
* SQLite locks without stale-owner deletion or lease recovery races.
|
|
1562
|
+
*
|
|
1563
|
+
* Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`.
|
|
1564
|
+
*/
|
|
1565
|
+
export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
1566
|
+
if (configMutationLockDepth > 0) {
|
|
1567
|
+
configMutationLockDepth += 1;
|
|
1568
|
+
try {
|
|
1569
|
+
return fn();
|
|
1570
|
+
} finally {
|
|
1571
|
+
configMutationLockDepth -= 1;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
const path = configMutationDatabasePath();
|
|
1575
|
+
let database: Database | undefined;
|
|
1576
|
+
let transactionOpen = false;
|
|
1577
|
+
try {
|
|
1578
|
+
database = new Database(path, { create: true });
|
|
1579
|
+
try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
|
|
1580
|
+
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
1581
|
+
transactionOpen = true;
|
|
1582
|
+
} catch (cause) {
|
|
1583
|
+
try { database?.close(); } catch { /* acquisition already failed */ }
|
|
1584
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
1585
|
+
? String((cause as { code?: unknown }).code)
|
|
1586
|
+
: "";
|
|
1587
|
+
throw new ConfigMutationLockError(
|
|
1588
|
+
code === "SQLITE_BUSY" ? "Config mutation already in progress" : "Could not acquire config mutation transaction",
|
|
1589
|
+
{ cause },
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
configMutationLockDepth = 1;
|
|
1594
|
+
try {
|
|
1595
|
+
const value = fn();
|
|
1596
|
+
database.exec("COMMIT");
|
|
1597
|
+
transactionOpen = false;
|
|
1598
|
+
return value;
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
if (transactionOpen) {
|
|
1601
|
+
try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
1602
|
+
transactionOpen = false;
|
|
1603
|
+
}
|
|
1604
|
+
throw error;
|
|
1605
|
+
} finally {
|
|
1606
|
+
configMutationLockDepth = 0;
|
|
1607
|
+
try { database.close(); } catch { /* the OS lock is released with the handle */ }
|
|
1296
1608
|
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function persistConfigUnlocked(config: OcxConfig): void {
|
|
1297
1612
|
const configPath = getConfigPath();
|
|
1298
1613
|
atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1299
1614
|
}
|
|
1300
1615
|
|
|
1616
|
+
export function saveConfig(config: OcxConfig): void {
|
|
1617
|
+
// Keep the real-home assertion ahead of even lock-directory preparation.
|
|
1618
|
+
assertNotRealHomeUnderTest(getConfigDir());
|
|
1619
|
+
withConfigMutationLockSync(() => persistConfigUnlocked(config));
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
export type PersistedConfigMutation<T> = {
|
|
1623
|
+
changed: boolean;
|
|
1624
|
+
value: T;
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1627
|
+
export type PersistedConfigMutationOutcome<T> =
|
|
1628
|
+
| { status: "committed" | "unchanged"; value: T }
|
|
1629
|
+
| { status: "unavailable"; reason: "missing" | "invalid" | "conflict" };
|
|
1630
|
+
|
|
1631
|
+
const CONFIG_MUTATION_MAX_REBASE_ATTEMPTS = 3;
|
|
1632
|
+
let persistedConfigMutationBeforeCommitForTests: (() => void) | null = null;
|
|
1633
|
+
|
|
1634
|
+
/** Test-only one-shot seam: inject a competing mutation after the first decision, before freshness revalidation. */
|
|
1635
|
+
export function setPersistedConfigMutationBeforeCommitForTests(hook: (() => void) | null): void {
|
|
1636
|
+
persistedConfigMutationBeforeCommitForTests = hook;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function unavailableConfigMutationReason(snapshot: ConfigFileSnapshot): "missing" | "invalid" {
|
|
1640
|
+
return snapshot.diagnostics.source === "default" ? "missing" : "invalid";
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Patch a schema-valid on-disk config under the shared mutation lock. Cooperating writers are
|
|
1645
|
+
* serialized; the callback is rerun on the newest snapshot so observed direct byte changes rebase
|
|
1646
|
+
* and credential predicates are re-evaluated immediately before the atomic commit. A writer that
|
|
1647
|
+
* ignores the coordinator can still change bytes after the final check because the filesystem has
|
|
1648
|
+
* no portable conditional rename. Missing or malformed config always fails closed and is never
|
|
1649
|
+
* recreated from a prior snapshot.
|
|
1650
|
+
*/
|
|
1651
|
+
export function mutatePersistedConfig<T>(
|
|
1652
|
+
mutate: (config: OcxConfig) => PersistedConfigMutation<T>,
|
|
1653
|
+
): PersistedConfigMutationOutcome<T> {
|
|
1654
|
+
// Avoid creating/opening the coordinator database for a read-path update that already knows
|
|
1655
|
+
// there is no valid config. The same check runs again under the transaction for authority.
|
|
1656
|
+
const observed = readConfigFileSnapshot();
|
|
1657
|
+
if (observed.diagnostics.source !== "file" || observed.raw === undefined) {
|
|
1658
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(observed) };
|
|
1659
|
+
}
|
|
1660
|
+
return withConfigMutationLockSync(() => {
|
|
1661
|
+
let base = readConfigFileSnapshot();
|
|
1662
|
+
for (let attempt = 0; attempt < CONFIG_MUTATION_MAX_REBASE_ATTEMPTS; attempt += 1) {
|
|
1663
|
+
if (base.diagnostics.source !== "file" || base.raw === undefined) {
|
|
1664
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(base) };
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const tentativeConfig = structuredClone(base.diagnostics.config);
|
|
1668
|
+
const tentative = mutate(tentativeConfig);
|
|
1669
|
+
if (!tentative.changed) return { status: "unchanged", value: tentative.value };
|
|
1670
|
+
|
|
1671
|
+
const hook = persistedConfigMutationBeforeCommitForTests;
|
|
1672
|
+
persistedConfigMutationBeforeCommitForTests = null;
|
|
1673
|
+
hook?.();
|
|
1674
|
+
|
|
1675
|
+
const latest = readConfigFileSnapshot();
|
|
1676
|
+
if (latest.diagnostics.source !== "file" || latest.raw === undefined) {
|
|
1677
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(latest) };
|
|
1678
|
+
}
|
|
1679
|
+
if (latest.raw !== base.raw) {
|
|
1680
|
+
base = latest;
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Re-run against a fresh clone even when config bytes are unchanged: a Codex credential
|
|
1685
|
+
// generation lives in a separate file and may have changed at the injected seam.
|
|
1686
|
+
const confirmedConfig = structuredClone(latest.diagnostics.config);
|
|
1687
|
+
const confirmed = mutate(confirmedConfig);
|
|
1688
|
+
if (!confirmed.changed) return { status: "unchanged", value: confirmed.value };
|
|
1689
|
+
|
|
1690
|
+
const commitBase = readConfigFileSnapshot();
|
|
1691
|
+
if (commitBase.diagnostics.source !== "file" || commitBase.raw === undefined) {
|
|
1692
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(commitBase) };
|
|
1693
|
+
}
|
|
1694
|
+
if (commitBase.raw !== latest.raw) {
|
|
1695
|
+
base = commitBase;
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
persistConfigUnlocked(confirmedConfig);
|
|
1700
|
+
return { status: "committed", value: confirmed.value };
|
|
1701
|
+
}
|
|
1702
|
+
return { status: "unavailable", reason: "conflict" };
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1301
1706
|
export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
|
|
1302
1707
|
return config.websockets === true;
|
|
1303
1708
|
}
|
|
@@ -1527,36 +1932,38 @@ function readPersistedServerBinding(
|
|
|
1527
1932
|
* guarantee.
|
|
1528
1933
|
*/
|
|
1529
1934
|
export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
if (
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1935
|
+
withConfigMutationLockSync(() => {
|
|
1936
|
+
const bindingBaseline = persistedLiveServerBinding.get(config);
|
|
1937
|
+
const onDisk = claudeCodeBaseline.has(config) || bindingBaseline
|
|
1938
|
+
? readRawConfigJson()
|
|
1939
|
+
: undefined;
|
|
1940
|
+
if (claudeCodeBaseline.has(config)) {
|
|
1941
|
+
if (onDisk !== undefined) {
|
|
1942
|
+
const baseline = claudeCodeBaseline.get(config);
|
|
1943
|
+
const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
|
|
1944
|
+
const diskChanged = !deepEqual(persistedClaudeCode, baseline);
|
|
1945
|
+
const weChanged = !deepEqual(config.claudeCode, baseline);
|
|
1946
|
+
if (diskChanged && !weChanged) {
|
|
1947
|
+
config.claudeCode = persistedClaudeCode;
|
|
1948
|
+
}
|
|
1542
1949
|
}
|
|
1543
1950
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
}
|
|
1951
|
+
const persistedBinding = bindingBaseline && onDisk
|
|
1952
|
+
? readPersistedServerBinding(onDisk, bindingBaseline)
|
|
1953
|
+
: bindingBaseline;
|
|
1954
|
+
if (persistedBinding) {
|
|
1955
|
+
const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port };
|
|
1956
|
+
if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
|
|
1957
|
+
else persistedConfig.hostname = persistedBinding.hostname;
|
|
1958
|
+
persistConfigUnlocked(persistedConfig);
|
|
1959
|
+
persistedLiveServerBinding.set(config, persistedBinding);
|
|
1960
|
+
} else {
|
|
1961
|
+
persistConfigUnlocked(config);
|
|
1962
|
+
}
|
|
1963
|
+
if (claudeCodeBaseline.has(config)) {
|
|
1964
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1560
1967
|
}
|
|
1561
1968
|
|
|
1562
1969
|
export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">): boolean {
|
|
@@ -1584,6 +1991,8 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
1584
1991
|
// Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
|
|
1585
1992
|
return {
|
|
1586
1993
|
port: 10100,
|
|
1994
|
+
managementUsageMaxReadBytes: 64 * 1024 * 1024,
|
|
1995
|
+
appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
|
|
1587
1996
|
// Fresh/re-initialized configs are already written in the current three-tier
|
|
1588
1997
|
// OpenAI shape. Mark them as such so startup does not mistake them for a
|
|
1589
1998
|
// legacy config and collide with an immutable backup from an earlier setup.
|
|
@@ -1767,15 +2176,61 @@ export function parsePidFile(raw: string): number | null {
|
|
|
1767
2176
|
export function isOcxStartCommandLine(commandLine: string): boolean {
|
|
1768
2177
|
const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
|
|
1769
2178
|
// "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current.
|
|
2179
|
+
// `@bitkyc08/.opencodex-*` is npm's in-place rename of the global package during
|
|
2180
|
+
// `npm install -g` — a Windows service wrapper can respawn from that temp tree
|
|
2181
|
+
// mid-update, and must still count as ocx for port reclaim.
|
|
1770
2182
|
const hasOcxEntrypoint = normalized.includes("src/cli.ts")
|
|
1771
2183
|
|| normalized.includes("src/cli/index.ts")
|
|
1772
2184
|
|| normalized.includes("@bitkyc08/opencodex")
|
|
2185
|
+
|| /@bitkyc08\/\.opencodex-/.test(normalized)
|
|
1773
2186
|
|| /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
|
|
1774
2187
|
return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
|
|
1775
2188
|
}
|
|
1776
2189
|
|
|
1777
2190
|
/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
|
|
1778
2191
|
const ocxStartProcessCache = new Map<number, boolean>();
|
|
2192
|
+
let ocxStartProcessSweepCursor = 0;
|
|
2193
|
+
let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); };
|
|
2194
|
+
|
|
2195
|
+
export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void {
|
|
2196
|
+
ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); });
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
export function setOcxStartProcessCacheForTests(entries: Iterable<readonly [number, boolean]>): void {
|
|
2200
|
+
ocxStartProcessCache.clear();
|
|
2201
|
+
for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value);
|
|
2202
|
+
ocxStartProcessSweepCursor = 0;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
export function sweepDeadOcxStartProcessCache(maxProbes = 64): number {
|
|
2206
|
+
const pids: number[] = [];
|
|
2207
|
+
let removed = 0;
|
|
2208
|
+
for (const pid of ocxStartProcessCache.keys()) {
|
|
2209
|
+
if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
|
|
2210
|
+
else if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
2211
|
+
}
|
|
2212
|
+
if (pids.length === 0 || maxProbes <= 0) {
|
|
2213
|
+
ocxStartProcessSweepCursor = 0;
|
|
2214
|
+
return removed;
|
|
2215
|
+
}
|
|
2216
|
+
const probeCount = Math.min(Math.floor(maxProbes), pids.length);
|
|
2217
|
+
const start = ocxStartProcessSweepCursor % pids.length;
|
|
2218
|
+
for (let offset = 0; offset < probeCount; offset += 1) {
|
|
2219
|
+
const pid = pids[(start + offset) % pids.length]!;
|
|
2220
|
+
try {
|
|
2221
|
+
ocxStartProcessProbe(pid);
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
|
|
2224
|
+
if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
ocxStartProcessSweepCursor = (start + probeCount) % pids.length;
|
|
2228
|
+
return removed;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
export function ocxStartProcessCacheSizeForTests(): number {
|
|
2232
|
+
return ocxStartProcessCache.size;
|
|
2233
|
+
}
|
|
1779
2234
|
|
|
1780
2235
|
function isLikelyOcxStartProcess(pid: number): boolean {
|
|
1781
2236
|
const cached = ocxStartProcessCache.get(pid);
|