@bitkyc08/opencodex 2.30.0-preview.20260821 → 2.31.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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-DkcRs1fL.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +7 -2
  7. package/src/adapters/cursor/effort-map.ts +6 -0
  8. package/src/adapters/cursor/h2-pool.ts +123 -0
  9. package/src/adapters/cursor/live-models.ts +21 -26
  10. package/src/adapters/cursor/live-transport.ts +213 -3
  11. package/src/adapters/cursor/native-exec-common.ts +17 -0
  12. package/src/adapters/cursor/native-exec.ts +9 -4
  13. package/src/adapters/cursor/protobuf-events.ts +5 -1
  14. package/src/adapters/cursor/protobuf-request.ts +11 -4
  15. package/src/adapters/cursor/tool-definitions.ts +20 -0
  16. package/src/adapters/cursor/transport.ts +10 -0
  17. package/src/adapters/cursor.ts +23 -5
  18. package/src/adapters/google.ts +16 -3
  19. package/src/adapters/openai-responses.ts +66 -20
  20. package/src/adapters/xai-web-search.ts +185 -0
  21. package/src/cli/agent.ts +2 -1
  22. package/src/cli/dispatch.ts +2 -2
  23. package/src/cli/doctor.ts +89 -0
  24. package/src/cli/help.ts +2 -0
  25. package/src/cli/registry.ts +7 -2
  26. package/src/codex/auth-context.ts +41 -2
  27. package/src/codex/catalog/effort.ts +1 -1
  28. package/src/codex/catalog/parsing.ts +2 -0
  29. package/src/codex/catalog/provider-fetch.ts +20 -5
  30. package/src/codex/coordinator-doctor.ts +332 -0
  31. package/src/codex/inject-coordination.ts +39 -6
  32. package/src/codex/transition-state.ts +12 -12
  33. package/src/generated/compatibility-version.json +74 -50
  34. package/src/lib/errors.ts +8 -2
  35. package/src/oauth/cursor.ts +21 -0
  36. package/src/providers/cursor-pool.ts +72 -0
  37. package/src/providers/derive.ts +3 -0
  38. package/src/providers/fastwire.ts +12 -1
  39. package/src/providers/openai-sidecar.ts +1 -0
  40. package/src/providers/registry.ts +25 -0
  41. package/src/providers/service-tier.ts +22 -7
  42. package/src/responses/custom-tool-compat.ts +24 -8
  43. package/src/responses/namespace-tool-compat.ts +2 -3
  44. package/src/router.ts +3 -0
  45. package/src/server/chat-completions.ts +4 -0
  46. package/src/server/chat-native.ts +20 -0
  47. package/src/server/management/agent-settings-routes.ts +16 -5
  48. package/src/server/management/config-routes.ts +25 -5
  49. package/src/server/management/vision-sidecar-options.ts +54 -19
  50. package/src/server/responses/compact.ts +1 -2
  51. package/src/server/responses/core.ts +54 -13
  52. package/src/service.ts +122 -14
  53. package/src/types/config.ts +9 -3
  54. package/src/types/provider.ts +6 -0
  55. package/src/usage/cost.ts +52 -38
  56. package/src/usage/expected-prices.ts +79 -9
  57. package/src/vision/backends.ts +97 -0
  58. package/src/vision/eligibility.ts +43 -22
  59. package/src/vision/index.ts +73 -5
  60. package/src/vision/routed-describe.ts +175 -0
  61. package/gui/dist/assets/index-eBA05kYB.js +0 -102
package/src/cli/doctor.ts CHANGED
@@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome
25
25
  import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
26
26
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
27
27
  import { countPendingOpencodexHistory } from "../codex/history-provider";
28
+ import {
29
+ inspectCodexCoordinator,
30
+ recoverZeroByteCodexCoordinator,
31
+ type CodexCoordinatorDiagnostic,
32
+ } from "../codex/coordinator-doctor";
28
33
  import {
29
34
  inspectAbandonedResponseStateTemps,
30
35
  reclaimAbandonedResponseStateTemps,
@@ -684,6 +689,7 @@ export async function fetchServiceMemory(
684
689
  const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`;
685
690
 
686
691
  export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps";
692
+ export const RECOVER_ZERO_BYTE_COORDINATOR_FLAG = "--recover-zero-byte-coordinator";
687
693
  /** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */
688
694
  const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096;
689
695
  /** Names the subsystem: other components mint temps with the same shape and are not covered. */
@@ -734,6 +740,60 @@ export function formatResponseTempLines(
734
740
  return lines;
735
741
  }
736
742
 
743
+ export function formatCoordinatorDoctorLines(diagnostic: CodexCoordinatorDiagnostic): string[] {
744
+ const pathLine = diagnostic.path ? [` path: ${diagnostic.path}`] : [];
745
+ const evidenceLines = "evidence" in diagnostic && diagnostic.evidence
746
+ ? [
747
+ ` size: ${diagnostic.evidence.sizeBytes} bytes; user_version: ${diagnostic.evidence.schemaVersion}`,
748
+ ` tables: ${diagnostic.evidence.tables.length === 0 ? "none" : diagnostic.evidence.tables.join(", ")}`,
749
+ ` transition rows: ${diagnostic.evidence.transitionRows ?? "not inspected"}; singleton=1 rows: ${diagnostic.evidence.singletonRows ?? "not inspected"}`,
750
+ ]
751
+ : [];
752
+ switch (diagnostic.kind) {
753
+ case "absent":
754
+ return [" ok native-write coordinator not created yet", ...pathLine];
755
+ case "ready":
756
+ return [" ok native-write coordinator has an authoritative transition row", ...pathLine, ...evidenceLines];
757
+ case "zero-byte":
758
+ return [
759
+ " !! native-write coordinator is a zero-byte remnant and has no authority",
760
+ ...pathLine,
761
+ ...evidenceLines,
762
+ ` Action: stop the OpenCodex proxy/service, then run ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`,
763
+ ];
764
+ case "unversioned-empty":
765
+ return [
766
+ " !! native-write coordinator is a non-empty unversioned database; automatic recovery is refused",
767
+ ...pathLine,
768
+ ...evidenceLines,
769
+ ];
770
+ case "rowless":
771
+ return [
772
+ " !! native-write coordinator has schema version 1 but no authoritative row; automatic recovery is refused",
773
+ ...pathLine,
774
+ ...evidenceLines,
775
+ ];
776
+ case "unversioned-nonempty":
777
+ return [
778
+ " !! native-write coordinator is unversioned and contains unknown tables; automatic recovery is refused",
779
+ ...pathLine,
780
+ ...evidenceLines,
781
+ ];
782
+ case "unsupported":
783
+ return [
784
+ ` !! native-write coordinator schema version ${diagnostic.version} is unsupported; automatic recovery is refused`,
785
+ ...pathLine,
786
+ ...evidenceLines,
787
+ ];
788
+ case "changed":
789
+ return [" -- native-write coordinator changed during diagnosis; re-run ocx doctor", ...pathLine];
790
+ case "unsafe":
791
+ return [` !! native-write coordinator path is unsafe: ${diagnostic.reason}`, ...pathLine];
792
+ case "unreadable":
793
+ return [` !! native-write coordinator is unreadable: ${diagnostic.reason}`, ...pathLine, ...evidenceLines];
794
+ }
795
+ }
796
+
737
797
  /** Render the doctor "Memory / runtime" section lines (testable without console capture). */
738
798
  export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] {
739
799
  const lines: string[] = [];
@@ -846,6 +906,33 @@ export async function runDoctor(args: string[] = []): Promise<void> {
846
906
  return;
847
907
  }
848
908
 
909
+ if (args.includes(RECOVER_ZERO_BYTE_COORDINATOR_FLAG)) {
910
+ if (!args.includes("--yes")) {
911
+ console.log(`Recovery is explicit and creates a same-directory backup. Re-run: ocx doctor ${RECOVER_ZERO_BYTE_COORDINATOR_FLAG} --yes`);
912
+ process.exitCode = 1;
913
+ return;
914
+ }
915
+ const diagnostics = readConfigDiagnostics().config;
916
+ const live = await findLiveProxy({
917
+ configFn: () => ({ port: diagnostics.port, hostname: diagnostics.hostname }),
918
+ });
919
+ if (live) {
920
+ console.log(`Recovery refused: OpenCodex proxy pid ${live.pid} is still running. Stop the proxy/service and retry.`);
921
+ process.exitCode = 1;
922
+ return;
923
+ }
924
+ const recovered = recoverZeroByteCodexCoordinator();
925
+ if (!recovered.ok) {
926
+ console.log(`Recovery refused: ${recovered.reason}.`);
927
+ process.exitCode = 1;
928
+ return;
929
+ }
930
+ console.log(`Moved the non-authoritative coordinator to ${recovered.backupPath}`);
931
+ console.log("Run `ocx sync` to retry Codex config injection. The backup was preserved and no Codex config/catalog file was changed by recovery.");
932
+ process.exitCode = 0;
933
+ return;
934
+ }
935
+
849
936
  console.log("opencodex doctor\n");
850
937
 
851
938
  // Ordering note: the memory/runtime section renders after "Running proxy
@@ -1005,6 +1092,8 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1005
1092
  const reason = cause instanceof CodexUserIdentityRefusal ? cause.message : String(cause);
1006
1093
  console.log(` -- history coordinator namespace refused: ${reason}`);
1007
1094
  }
1095
+ console.log("\nCodex native-write coordinator");
1096
+ for (const line of formatCoordinatorDoctorLines(inspectCodexCoordinator())) console.log(line);
1008
1097
  const pending = countPendingOpencodexHistory();
1009
1098
  if (pending.failed) {
1010
1099
  console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown");
package/src/cli/help.ts CHANGED
@@ -38,6 +38,8 @@ Usage:
38
38
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
39
39
  ocx doctor --reclaim-response-temps
40
40
  Reclaim abandoned response-state temp files (works without a running proxy)
41
+ ocx doctor --recover-zero-byte-coordinator --yes
42
+ Back up a proven zero-byte Codex coordinator after stopping the proxy
41
43
  ocx debug <scope> provider/usage/injection/claude on|off|status|reset
42
44
  ocx login <provider> OAuth or API-key provider login
43
45
  ocx logout <provider> Remove a stored OAuth login
@@ -61,10 +61,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
61
61
  },
62
62
  {
63
63
  name: "service",
64
- usage: "ocx service [install|start|stop|status|uninstall|remove]",
64
+ usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]",
65
65
  summary: "Run as a background service.",
66
66
  details: [
67
- "With no subcommand, installs/updates and starts the background service.",
67
+ "With no subcommand, installs when absent or repairs/restarts an existing service.",
68
+ "`restart` is an alias of `repair` and does not re-register an installed service.",
68
69
  "Use `ocx service status` to see diagnostics and log paths.",
69
70
  ],
70
71
  },
@@ -108,6 +109,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
108
109
  name: "doctor",
109
110
  usage: "ocx doctor",
110
111
  summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability).",
112
+ details: [
113
+ "Default mode is observe-only and reports the native-write coordinator state and exact path.",
114
+ "After stopping the proxy/service, `--recover-zero-byte-coordinator --yes` moves only a proven zero-byte coordinator to a same-directory backup.",
115
+ ],
111
116
  },
112
117
  {
113
118
  name: "debug",
@@ -1,3 +1,4 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
1
2
  import {
2
3
  CodexCredentialGenerationConflictError,
3
4
  CodexCredentialRefreshLockTimeoutError,
@@ -38,6 +39,38 @@ import { getAccountQuota } from "./quota";
38
39
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
39
40
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
40
41
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
42
+ import { retainedUtf8Bytes } from "../lib/admission";
43
+
44
+ const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
45
+ const CODEX_APP_AFFINITY_KEY = randomBytes(32);
46
+
47
+ function boundedCodexAffinityComponent(value: string | null): string | undefined {
48
+ const normalized = value?.trim();
49
+ if (!normalized) return undefined;
50
+ if (retainedUtf8Bytes(normalized) > CODEX_AFFINITY_COMPONENT_MAX_BYTES) return undefined;
51
+ return normalized;
52
+ }
53
+
54
+ /**
55
+ * Preserve Codex's parent-thread affinity when present. Desktop App requests can omit that
56
+ * header while retaining a stable session/thread pair, so derive an opaque process-local key
57
+ * only from the complete bounded pair. Raw identifiers and durable hashes never enter Pool state.
58
+ */
59
+ export function codexPoolAffinityKey(headers: Headers): string | undefined {
60
+ const parentThreadId = boundedCodexAffinityComponent(headers.get("x-codex-parent-thread-id"));
61
+ if (parentThreadId) return parentThreadId;
62
+
63
+ const sessionId = boundedCodexAffinityComponent(headers.get("session-id"));
64
+ const threadId = boundedCodexAffinityComponent(headers.get("thread-id"));
65
+ if (!sessionId || !threadId) return undefined;
66
+
67
+ return `app:${createHmac("sha256", CODEX_APP_AFFINITY_KEY)
68
+ .update("opencodex-app-pool-affinity-v1\0")
69
+ .update(sessionId)
70
+ .update("\0")
71
+ .update(threadId)
72
+ .digest("base64url")}`;
73
+ }
41
74
 
42
75
  export type CodexAuthContext =
43
76
  | { kind: "main"; accountId: null }
@@ -50,6 +83,8 @@ export type CodexAuthContext =
50
83
  chatgptAccountId: string;
51
84
  /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
52
85
  fixedAccount?: boolean;
86
+ /** Pool binding key; the Desktop fallback is an opaque process-local HMAC. */
87
+ affinityKey?: string;
53
88
  /**
54
89
  * Set when this request was admitted through an active quota cooldown as
55
90
  * the account's single probe. Must be echoed into the upstream outcome so
@@ -71,6 +106,8 @@ export type CodexAuthContext =
71
106
  chatgptAccountId: string;
72
107
  /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
73
108
  fixedAccount?: boolean;
109
+ /** See `pool.affinityKey`. */
110
+ affinityKey?: string;
74
111
  /** See `pool.probeLeaseId`. */
75
112
  probeLeaseId?: string;
76
113
  quotaScope?: CodexQuotaScope;
@@ -343,6 +380,7 @@ export async function resolveCodexAuthContext(
343
380
  }
344
381
  return { kind: "main", accountId: null };
345
382
  }
383
+ const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined;
346
384
  const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
347
385
  ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config)
348
386
  : undefined;
@@ -369,7 +407,6 @@ export async function resolveCodexAuthContext(
369
407
  // routing inspect it. Selectors arriving after the fence skip reconciliation
370
408
  // and may still route to non-main pool accounts without touching switch state.
371
409
  if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
372
- const threadId = headers.get("x-codex-parent-thread-id");
373
410
  const resolution = fixedAccountId !== undefined
374
411
  ? { status: "selected" as const, accountId: fixedAccountId }
375
412
  : options.excludeAccountId
@@ -385,7 +422,7 @@ export async function resolveCodexAuthContext(
385
422
  ? { status: "selected" as const, accountId: selected }
386
423
  : { status: "none" as const };
387
424
  })()
388
- : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope, selectionOptions);
425
+ : resolveCodexAccountForThreadDetailed(affinityKey ?? null, config, Date.now(), quotaScope, selectionOptions);
389
426
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
390
427
  const selected = resolution.status === "selected" ? resolution.accountId : null;
391
428
  if (!selected) {
@@ -500,6 +537,7 @@ export async function resolveCodexAuthContext(
500
537
  accessToken: token.accessToken,
501
538
  chatgptAccountId: token.chatgptAccountId,
502
539
  ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
540
+ ...(affinityKey ? { affinityKey } : {}),
503
541
  ...(quotaScope ? { quotaScope } : {}),
504
542
  ...(probeLeaseId ? { probeLeaseId } : {}),
505
543
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
@@ -516,6 +554,7 @@ export async function resolveCodexAuthContext(
516
554
  accessToken: token.accessToken,
517
555
  chatgptAccountId: token.chatgptAccountId,
518
556
  ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
557
+ ...(affinityKey ? { affinityKey } : {}),
519
558
  ...(quotaScope ? { quotaScope } : {}),
520
559
  ...(probeLeaseId ? { probeLeaseId } : {}),
521
560
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
@@ -147,7 +147,7 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
147
147
  entry.service_tiers = [{
148
148
  id: "priority",
149
149
  name: "Fast",
150
- description: "1.5x speed, increased usage",
150
+ description: model.fastTierDescription ?? "1.5x speed, increased usage",
151
151
  }];
152
152
  entry.additional_speed_tiers = ["fast"];
153
153
  }
@@ -125,6 +125,8 @@ export interface CatalogModel {
125
125
  supportsVerbosity?: boolean;
126
126
  /** Whether this exact routed model has a verified OpenAI-compatible service tier. */
127
127
  supportsServiceTier?: boolean;
128
+ /** Optional provider-specific copy for the advertised Fast tier. */
129
+ fastTierDescription?: string;
128
130
  supportsReasoningSummaries?: boolean;
129
131
  /**
130
132
  * Codex tool calling mode for this routed model.
@@ -34,7 +34,8 @@ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, r
34
34
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
35
35
  import {
36
36
  captureFastPolicyAuthority,
37
- serviceTierSupportForModel,
37
+ fastPolicyForModel,
38
+ serviceTierSupportFromPolicy,
38
39
  } from "../../providers/service-tier";
39
40
  import type { FastPolicyAuthority } from "../../providers/fastwire";
40
41
  import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
@@ -647,8 +648,13 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
647
648
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
648
649
  const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
649
650
  const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
650
- const supportsServiceTier = serviceTierSupportForModel(prov, model.id, name);
651
- const { supportsServiceTier: _staleServiceTier, ...modelWithoutServiceTier } = model;
651
+ const fastPolicy = fastPolicyForModel(prov, model.id, name);
652
+ const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy);
653
+ const {
654
+ supportsServiceTier: _staleServiceTier,
655
+ fastTierDescription: _staleFastTierDescription,
656
+ ...modelWithoutServiceTier
657
+ } = model;
652
658
  // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。
653
659
  const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
654
660
  ? model.contextWindow
@@ -671,6 +677,9 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
671
677
  ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
672
678
  ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
673
679
  ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
680
+ ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined
681
+ ? { fastTierDescription: fastPolicy.fastTierDescription }
682
+ : {}),
674
683
  ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}),
675
684
  // Default-on for openai-chat providers (explicit false opts out); other adapters
676
685
  // advertise only on explicit opt-in.
@@ -1845,8 +1854,11 @@ async function gatherRoutedModelsUncached(
1845
1854
  ? nativeDefaultReasoningEffort(cm.modelId)
1846
1855
  : undefined;
1847
1856
  const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId);
1848
- const supportsServiceTier = effectiveProvider
1849
- ? serviceTierSupportForModel(effectiveProvider, cm.modelId, cm.provider)
1857
+ const fastPolicy = effectiveProvider
1858
+ ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider)
1859
+ : undefined;
1860
+ const supportsServiceTier = fastPolicy
1861
+ ? serviceTierSupportFromPolicy(fastPolicy)
1850
1862
  : undefined;
1851
1863
  const base: CatalogModel = {
1852
1864
  id: cm.modelId,
@@ -1883,6 +1895,9 @@ async function gatherRoutedModelsUncached(
1883
1895
  ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
1884
1896
  ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
1885
1897
  ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
1898
+ ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined
1899
+ ? { fastTierDescription: fastPolicy.fastTierDescription }
1900
+ : {}),
1886
1901
  ...(cm.codexToolMode !== undefined
1887
1902
  ? { codexToolMode: cm.codexToolMode }
1888
1903
  : effectiveProvider?.codexToolMode !== undefined
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Observe and explicitly quarantine non-authoritative native-write coordinators.
3
+ *
4
+ * Default doctor runs use immutable SQLite reads so diagnostics cannot create
5
+ * WAL/SHM sidecars. Recovery is deliberately opt-in and moves, never deletes,
6
+ * only a file that is still the same private regular file observed beforehand.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import {
10
+ existsSync,
11
+ lstatSync,
12
+ realpathSync,
13
+ renameSync,
14
+ type Stats,
15
+ } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { pathToFileURL } from "node:url";
18
+
19
+ import { Database, constants as sqliteConstants } from "bun:sqlite";
20
+
21
+ import { resolveCodexHomeDir } from "./home";
22
+ import {
23
+ CodexUserIdentityRefusal,
24
+ probeCodexCoordinatorNamespace,
25
+ resolveEffectiveUserIdentity,
26
+ samePathIdentity,
27
+ } from "./user-identity";
28
+ import {
29
+ CODEX_COORDINATOR_SCHEMA_VERSION,
30
+ readCodexCoordinatorState,
31
+ } from "./transition-state";
32
+
33
+ const IMMUTABLE_READONLY_FLAGS =
34
+ sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI;
35
+
36
+ export type FileIdentity = Pick<Stats, "dev" | "ino" | "size" | "mtimeMs" | "ctimeMs">;
37
+
38
+ export interface CodexCoordinatorDiagnosticEvidence {
39
+ sizeBytes: number;
40
+ schemaVersion: number;
41
+ tables: readonly string[];
42
+ transitionRows: number | null;
43
+ singletonRows: number | null;
44
+ }
45
+
46
+ export type CodexCoordinatorDiagnostic =
47
+ | { kind: "absent"; path: string | null }
48
+ | { kind: "zero-byte"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
49
+ | { kind: "unversioned-empty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
50
+ | { kind: "unversioned-nonempty"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
51
+ | { kind: "rowless"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
52
+ | { kind: "ready"; path: string; identity: FileIdentity; evidence: CodexCoordinatorDiagnosticEvidence }
53
+ | { kind: "unsupported"; path: string; identity: FileIdentity; version: number; evidence: CodexCoordinatorDiagnosticEvidence }
54
+ | { kind: "changed"; path: string }
55
+ | { kind: "unsafe"; path: string | null; reason: string }
56
+ | { kind: "unreadable"; path: string; reason: string; evidence?: CodexCoordinatorDiagnosticEvidence };
57
+
58
+ export type CodexCoordinatorRecoveryResult =
59
+ | { ok: true; backupPath: string }
60
+ | { ok: false; reason: string };
61
+
62
+ function errorCode(error: unknown): string {
63
+ return error && typeof error === "object" && "code" in error
64
+ ? String((error as { code?: unknown }).code)
65
+ : "";
66
+ }
67
+
68
+ function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
69
+ return left.dev === right.dev
70
+ && left.ino === right.ino
71
+ && left.size === right.size
72
+ && left.mtimeMs === right.mtimeMs
73
+ && left.ctimeMs === right.ctimeMs;
74
+ }
75
+
76
+ function sameNodeAndSize(left: FileIdentity, right: FileIdentity): boolean {
77
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size;
78
+ }
79
+
80
+ function coordinatorPathWithoutCreation(): { kind: "absent"; path: string | null } | { kind: "path"; path: string } {
81
+ const identity = resolveEffectiveUserIdentity();
82
+ const canonicalCodexHome = realpathSync.native(resolveCodexHomeDir());
83
+ const namespace = probeCodexCoordinatorNamespace(identity);
84
+ if (namespace.status === "missing") return { kind: "absent", path: null };
85
+
86
+ const locks = join(namespace.root, "native-write-locks");
87
+ let locksEntry: Stats;
88
+ try {
89
+ locksEntry = lstatSync(locks);
90
+ } catch (cause) {
91
+ if (errorCode(cause) === "ENOENT") {
92
+ const digest = createHash("sha256").update(canonicalCodexHome).digest("hex");
93
+ return { kind: "absent", path: join(locks, `${digest}.sqlite`) };
94
+ }
95
+ throw new CodexUserIdentityRefusal("The coordinator lock directory cannot be inspected.", { cause });
96
+ }
97
+ if (locksEntry.isSymbolicLink() || !locksEntry.isDirectory()) {
98
+ throw new CodexUserIdentityRefusal("The coordinator lock namespace is not a real directory.");
99
+ }
100
+ if (identity.platform === "posix") {
101
+ if (locksEntry.uid !== identity.uid || (locksEntry.mode & 0o777) !== 0o700) {
102
+ throw new CodexUserIdentityRefusal("The coordinator lock namespace has unsafe ownership or permissions.");
103
+ }
104
+ } else if (!samePathIdentity(realpathSync.native(locks), locks, "win32")) {
105
+ throw new CodexUserIdentityRefusal("The coordinator lock namespace is redirected by a junction or reparse point.");
106
+ }
107
+
108
+ const digest = createHash("sha256").update(canonicalCodexHome).digest("hex");
109
+ return { kind: "path", path: join(locks, `${digest}.sqlite`) };
110
+ }
111
+
112
+ function inspectTarget(
113
+ path: string,
114
+ options: { allowSqliteSidecars?: boolean } = {},
115
+ ): { kind: "absent" } | { kind: "file"; identity: FileIdentity } | { kind: "unsafe"; reason: string } {
116
+ let entry: Stats;
117
+ try {
118
+ entry = lstatSync(path);
119
+ } catch (cause) {
120
+ if (errorCode(cause) === "ENOENT") return { kind: "absent" };
121
+ return { kind: "unsafe", reason: "the coordinator file cannot be inspected" };
122
+ }
123
+ if (entry.isSymbolicLink() || !entry.isFile()) {
124
+ return { kind: "unsafe", reason: "the coordinator path is not a real file" };
125
+ }
126
+ try {
127
+ if (!samePathIdentity(realpathSync.native(path), path)) {
128
+ return { kind: "unsafe", reason: "the coordinator path is redirected" };
129
+ }
130
+ } catch {
131
+ return { kind: "unsafe", reason: "the coordinator path cannot be resolved" };
132
+ }
133
+ if (process.platform !== "win32") {
134
+ const uid = process.getuid?.();
135
+ if (uid === undefined || entry.uid !== uid || (entry.mode & 0o777) !== 0o600) {
136
+ return { kind: "unsafe", reason: "the coordinator file has unsafe ownership or permissions" };
137
+ }
138
+ }
139
+ if (!options.allowSqliteSidecars) {
140
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
141
+ if (existsSync(`${path}${suffix}`)) {
142
+ return { kind: "unsafe", reason: `the coordinator has an active SQLite ${suffix.slice(1)} sidecar` };
143
+ }
144
+ }
145
+ }
146
+ return { kind: "file", identity: entry };
147
+ }
148
+
149
+ function classifyOpenedDatabase(
150
+ database: Database,
151
+ path: string,
152
+ identity: FileIdentity,
153
+ ): CodexCoordinatorDiagnostic {
154
+ const version = database.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
155
+ const tables = database.query<{ name: string }, []>(
156
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
157
+ ).all().map(row => row.name);
158
+ const baseEvidence = {
159
+ sizeBytes: identity.size,
160
+ schemaVersion: version,
161
+ tables,
162
+ transitionRows: null,
163
+ singletonRows: null,
164
+ } satisfies CodexCoordinatorDiagnosticEvidence;
165
+ if (version === 0) {
166
+ const evidence = tables.length === 0
167
+ ? { ...baseEvidence, transitionRows: 0, singletonRows: 0 }
168
+ : baseEvidence;
169
+ return tables.length === 0
170
+ ? { kind: "unversioned-empty", path, identity, evidence }
171
+ : { kind: "unversioned-nonempty", path, identity, evidence };
172
+ }
173
+ if (version !== CODEX_COORDINATOR_SCHEMA_VERSION) {
174
+ return { kind: "unsupported", path, identity, version, evidence: baseEvidence };
175
+ }
176
+ if (tables.length !== 1 || tables[0] !== "codex_transition_state") {
177
+ return tables.length === 0
178
+ ? { kind: "rowless", path, identity, evidence: baseEvidence }
179
+ : { kind: "unreadable", path, reason: "the coordinator contains unexpected tables", evidence: baseEvidence };
180
+ }
181
+ let rowCounts: { total: number; singleton: number } | null;
182
+ try {
183
+ rowCounts = database.query<{ total: number; singleton: number }, []>(
184
+ "SELECT count(*) AS total, sum(CASE WHEN singleton = 1 THEN 1 ELSE 0 END) AS singleton FROM codex_transition_state",
185
+ ).get() ?? null;
186
+ } catch {
187
+ return {
188
+ kind: "unreadable",
189
+ path,
190
+ reason: "the transition table schema is not recognized",
191
+ evidence: baseEvidence,
192
+ };
193
+ }
194
+ const evidence = {
195
+ ...baseEvidence,
196
+ transitionRows: rowCounts?.total ?? null,
197
+ singletonRows: rowCounts?.singleton ?? null,
198
+ };
199
+ if (!rowCounts || rowCounts.total === 0) return { kind: "rowless", path, identity, evidence };
200
+ if (rowCounts.total !== 1 || rowCounts.singleton !== 1) {
201
+ return {
202
+ kind: "unreadable",
203
+ path,
204
+ reason: "the coordinator does not contain exactly one singleton row",
205
+ evidence,
206
+ };
207
+ }
208
+ try {
209
+ readCodexCoordinatorState(database);
210
+ } catch {
211
+ return {
212
+ kind: "unreadable",
213
+ path,
214
+ reason: "the authoritative transition row is malformed",
215
+ evidence,
216
+ };
217
+ }
218
+ return { kind: "ready", path, identity, evidence };
219
+ }
220
+
221
+ export function inspectCodexCoordinator(): CodexCoordinatorDiagnostic {
222
+ let resolved: ReturnType<typeof coordinatorPathWithoutCreation>;
223
+ try {
224
+ resolved = coordinatorPathWithoutCreation();
225
+ } catch (cause) {
226
+ return {
227
+ kind: "unsafe",
228
+ path: null,
229
+ reason: cause instanceof Error ? cause.message : String(cause),
230
+ };
231
+ }
232
+ if (resolved.kind === "absent") return resolved;
233
+ return inspectCodexCoordinatorPath(resolved.path);
234
+ }
235
+
236
+ /** Inspect one already-resolved coordinator path without creating SQLite state. */
237
+ export function inspectCodexCoordinatorPath(path: string): CodexCoordinatorDiagnostic {
238
+ const target = inspectTarget(path);
239
+ if (target.kind === "absent") return { kind: "absent", path };
240
+ if (target.kind === "unsafe") return { kind: "unsafe", path, reason: target.reason };
241
+
242
+ let database: Database | undefined;
243
+ try {
244
+ const uri = `${pathToFileURL(path).href}?immutable=1`;
245
+ database = new Database(uri, IMMUTABLE_READONLY_FLAGS);
246
+ const result = classifyOpenedDatabase(database, path, target.identity);
247
+ const after = inspectTarget(path);
248
+ if (after.kind !== "file" || !sameIdentity(target.identity, after.identity)) {
249
+ return { kind: "changed", path };
250
+ }
251
+ // Size alone is not evidence that this is a non-authoritative remnant.
252
+ // Query the immutable snapshot too, so the recovery label means all three
253
+ // facts were observed together: zero bytes, schema version zero, no tables.
254
+ if (target.identity.size === 0 && result.kind === "unversioned-empty") {
255
+ return { kind: "zero-byte", path, identity: target.identity, evidence: result.evidence };
256
+ }
257
+ return result;
258
+ } catch (cause) {
259
+ return { kind: "unreadable", path, reason: cause instanceof Error ? cause.message : String(cause) };
260
+ } finally {
261
+ try { database?.close(); } catch { /* diagnostics already completed */ }
262
+ }
263
+ }
264
+
265
+ function recoverable(diagnostic: CodexCoordinatorDiagnostic): diagnostic is Extract<
266
+ CodexCoordinatorDiagnostic,
267
+ { kind: "zero-byte" }
268
+ > {
269
+ return diagnostic.kind === "zero-byte";
270
+ }
271
+
272
+ function backupTimestamp(now: Date): string {
273
+ return now.toISOString().replace(/[-:.]/g, "");
274
+ }
275
+
276
+ export function recoverZeroByteCodexCoordinator(now = new Date()): CodexCoordinatorRecoveryResult {
277
+ const observed = inspectCodexCoordinator();
278
+ if (!recoverable(observed)) {
279
+ if (observed.kind === "unsafe" || observed.kind === "unreadable") {
280
+ return { ok: false, reason: `coordinator state is ${observed.kind}: ${observed.reason}` };
281
+ }
282
+ return { ok: false, reason: `coordinator state is ${observed.kind}, not a recoverable zero-byte remnant` };
283
+ }
284
+
285
+ let database: Database | undefined;
286
+ let transactionOpen = false;
287
+ try {
288
+ database = new Database(observed.path, { readwrite: true, create: false });
289
+ database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
290
+ transactionOpen = true;
291
+ const lockedEntry = inspectTarget(observed.path, { allowSqliteSidecars: true });
292
+ // SQLite may update file timestamps merely by opening a zero-byte database
293
+ // for BEGIN IMMEDIATE. Device/inode/size are the stable identity here; the
294
+ // transaction excludes content writers while we reclassify the database.
295
+ if (lockedEntry.kind !== "file" || !sameNodeAndSize(observed.identity, lockedEntry.identity)) {
296
+ return { ok: false, reason: "the coordinator changed before recovery acquired its SQLite lock" };
297
+ }
298
+ if (lockedEntry.identity.size !== 0) {
299
+ return { ok: false, reason: "the coordinator stopped being zero-byte before recovery" };
300
+ }
301
+ database.exec("ROLLBACK");
302
+ transactionOpen = false;
303
+ database.close();
304
+ database = undefined;
305
+
306
+ const finalEntry = inspectTarget(observed.path);
307
+ if (finalEntry.kind !== "file" || !sameIdentity(lockedEntry.identity, finalEntry.identity)) {
308
+ return { ok: false, reason: "the coordinator changed before the backup move" };
309
+ }
310
+ const backupPath = `${observed.path}.zero-byte-backup-${backupTimestamp(now)}`;
311
+ if (existsSync(backupPath)) return { ok: false, reason: "the same-directory backup path already exists" };
312
+ renameSync(observed.path, backupPath);
313
+ const backupEntry = inspectTarget(backupPath);
314
+ // The rename itself can advance ctime, so post-move verification uses the
315
+ // stable filesystem object and byte size. The full timestamp identity was
316
+ // already revalidated immediately before rename while the source existed.
317
+ if (backupEntry.kind !== "file" || !sameNodeAndSize(finalEntry.identity, backupEntry.identity) || existsSync(observed.path)) {
318
+ return { ok: false, reason: "the coordinator backup move could not be verified" };
319
+ }
320
+ return { ok: true, backupPath };
321
+ } catch (cause) {
322
+ const message = cause instanceof Error ? cause.message : String(cause);
323
+ const busy = errorCode(cause) === "SQLITE_BUSY" || errorCode(cause) === "SQLITE_LOCKED"
324
+ || /database (?:is|table is) locked/i.test(message);
325
+ return { ok: false, reason: busy ? "the coordinator is busy; stop active sync/service writers and retry" : message };
326
+ } finally {
327
+ if (transactionOpen) {
328
+ try { database?.exec("ROLLBACK"); } catch { /* close releases the lock */ }
329
+ }
330
+ try { database?.close(); } catch { /* recovery already completed */ }
331
+ }
332
+ }