@bitkyc08/opencodex 2.70.0 → 2.71.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 (31) hide show
  1. package/gui/dist/assets/{App-_MrbTrBe.js → App-BKML41Kr.js} +4 -4
  2. package/gui/dist/assets/{Tray-5PuUalET.js → Tray-DFOclkLw.js} +1 -1
  3. package/gui/dist/assets/index-BKsHGDTE.css +1 -0
  4. package/gui/dist/assets/{index-DKrO2eF3.js → index-DSLWDj_0.js} +11 -11
  5. package/gui/dist/assets/{quota-summary-BCLivc0Q.js → quota-summary-Cit0cOn7.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-model-contract.ts +51 -8
  9. package/src/adapters/anthropic.ts +6 -2
  10. package/src/adapters/openai-chat/summary-budget.ts +11 -1
  11. package/src/claude/desktop-picker.ts +6 -2
  12. package/src/claude/intercept/local-ca.ts +25 -1
  13. package/src/claude/intercept/picker-ca.ts +132 -0
  14. package/src/cli/capabilities.ts +2 -1
  15. package/src/cli/claude-desktop.ts +2 -1
  16. package/src/cli/cross-home-owner.ts +221 -22
  17. package/src/cli/index.ts +3 -1
  18. package/src/codex/catalog-auto-refresh.ts +8 -1
  19. package/src/config/owner-registry.ts +154 -0
  20. package/src/config/process-state.ts +8 -0
  21. package/src/generated/compatibility-version.json +32 -20
  22. package/src/integrations/cursor-detect.ts +3 -2
  23. package/src/integrations/cursor-local-installer.ts +150 -0
  24. package/src/server/management/cursor-integration-routes.ts +20 -0
  25. package/src/server/management/route-registry.ts +1 -0
  26. package/src/server/port-reclaim.ts +57 -7
  27. package/src/server/proxy-liveness.ts +46 -19
  28. package/src/service/windows-process-priority.ts +39 -0
  29. package/src/vision/anthropic-describe.ts +1 -1
  30. package/src/web-search/anthropic-executor.ts +3 -2
  31. package/gui/dist/assets/index-BF9QnKO_.css +0 -1
@@ -13,12 +13,80 @@ import { markSiblingStart, siblingOfLivePort } from "../codex/sibling-start";
13
13
  import { readClientConnectionState } from "../client/state";
14
14
  import { findManagedRegion, resolveGrokHome } from "../grok/inject";
15
15
  import { providerTableString } from "../codex/injected-marker";
16
- import { probePortOwner, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness";
16
+ import { isLocalAttestationSecret } from "../lib/local-management-attestation";
17
+ import { readOwnerRegistry } from "../config/owner-registry";
18
+ import {
19
+ classifyHealthz,
20
+ loopbackProbeHosts,
21
+ proveLiveProxyOwnedByHome,
22
+ proxyIdentityAt,
23
+ START_OWNERSHIP_LIVENESS,
24
+ type LivenessIo,
25
+ } from "../server/proxy-liveness";
26
+ import { directLocalHttpFetch } from "../server/direct-local-http";
27
+ import type { RuntimePortState } from "../config/process-state";
17
28
 
18
29
  const MAX_HINT_BYTES = 256 * 1024;
19
30
  // Far above any real Grok config; discovery must not stall startup when Grok sync is off.
20
31
  const MAX_GROK_CONFIG_BYTES = 16 * 1024 * 1024;
21
32
 
33
+ /**
34
+ * One registered home's runtime record as discovery sees it.
35
+ *
36
+ * 'attestable' distinguishes the two shapes a well-formed record can take: a current
37
+ * record carries the attestation secret another home can challenge, while a legacy
38
+ * record (written before secrets existed, or with the secret stripped) can only name
39
+ * a pid/port pair. A live legacy record is never "no owner" - it is an owner the
40
+ * reader cannot verify, and a shared-write decision must fail closed on it.
41
+ */
42
+ type CandidateRecord = {
43
+ home: string;
44
+ pid: number;
45
+ port: number;
46
+ hostname?: string;
47
+ siblingOfPort?: number;
48
+ attestable: boolean;
49
+ record: RuntimePortState;
50
+ };
51
+
52
+ function parseCandidateRecord(home: string, raw: string): CandidateRecord | null {
53
+ try {
54
+ const record: unknown = JSON.parse(raw);
55
+ if (!record || typeof record !== "object") return null;
56
+ const state = record as Record<string, unknown>;
57
+ if (!Number.isSafeInteger(state.pid) || Number(state.pid) <= 0 || !validPort(state.port)) return null;
58
+ if (state.hostname !== undefined && typeof state.hostname !== "string") return null;
59
+ if (state.siblingOfPort !== undefined
60
+ && !(Number.isInteger(state.siblingOfPort) && Number(state.siblingOfPort) > 0 && Number(state.siblingOfPort) <= 65535)) {
61
+ return null;
62
+ }
63
+ const attestable = isLocalAttestationSecret(state.attestationSecret);
64
+ return {
65
+ home,
66
+ pid: Number(state.pid),
67
+ port: Number(state.port),
68
+ hostname: typeof state.hostname === "string" ? state.hostname : undefined,
69
+ siblingOfPort: typeof state.siblingOfPort === "number" ? state.siblingOfPort : undefined,
70
+ attestable,
71
+ record: state as RuntimePortState,
72
+ };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The discovery verdict. "owner" names the port a sibling defers to. "indeterminate"
80
+ * means something on the shared clients' path could be an owner the reader could not
81
+ * verify - a live listener no record attests, a legacy record, or an unreadable
82
+ * transport - and a shared-write caller must fail closed on it. 'port' carries the
83
+ * best location hint for the sibling marker.
84
+ */
85
+ export type CrossHomeOwnerVerdict =
86
+ | { kind: "owner"; port: number }
87
+ | { kind: "indeterminate"; port: number | null; reason: string }
88
+ | { kind: "none" };
89
+
22
90
  /** Nonblocking open (a FIFO cannot stall startup), regular files only, capped at maxBytes. */
23
91
  function readBoundedRegularFile(path: string, maxBytes: number): string | null {
24
92
  let fd: number | undefined;
@@ -60,20 +128,44 @@ function loopbackPort(raw: string | null): number | null {
60
128
  }
61
129
 
62
130
  /** Returns only a different process with an identity-checked /healthz response. */
63
- export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Promise<number | null> {
131
+ export async function findCrossHomeOwner(options: { homeDir?: string; io?: LivenessIo } = {}): Promise<number | null> {
132
+ const verdict = await findCrossHomeOwnerDetailed(options);
133
+ return verdict.kind === "owner" ? verdict.port : null;
134
+ }
135
+
136
+ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io?: LivenessIo } = {}): Promise<CrossHomeOwnerVerdict> {
137
+ const io = options.io ?? {};
138
+ const nowFn = io.nowFn ?? Date.now;
139
+ const timeoutMs = io.timeoutMs ?? START_OWNERSHIP_LIVENESS.timeoutMs ?? 1_500;
140
+ const attempts = io.attempts ?? START_OWNERSHIP_LIVENESS.attempts ?? 3;
141
+ // One deadline bounds the whole discovery: every identity probe, attestation and
142
+ // fallback classification shares it, so a pile of stale hints cannot stretch the
143
+ // start path the way per-candidate budgets did (CodeRabbit on #6198).
144
+ const deadlineAt = io.deadlineAt ?? nowFn() + timeoutMs * attempts * 4;
145
+ const probeIo: LivenessIo = { ...io, timeoutMs, attempts, deadlineAt };
146
+
64
147
  const candidates = new Set<number>();
148
+ const records = new Map<number, CandidateRecord[]>();
149
+ const ownHome = resolve(getConfigDir());
150
+ const recordHome = (home: string): void => {
151
+ if (resolve(home) === ownHome) return;
152
+ const raw = readBoundedRegularFile(join(home, "runtime-port.json"), MAX_HINT_BYTES);
153
+ if (!raw) return;
154
+ const parsed = parseCandidateRecord(home, raw);
155
+ if (!parsed) return;
156
+ candidates.add(parsed.port);
157
+ const list = records.get(parsed.port) ?? [];
158
+ list.push(parsed);
159
+ records.set(parsed.port, list);
160
+ };
161
+
65
162
  const defaultHome = join(options.homeDir ?? homedir(), ".opencodex");
66
- if (resolve(getConfigDir()) !== resolve(defaultHome)) {
67
- const raw = readBoundedRegularFile(join(defaultHome, "runtime-port.json"), MAX_HINT_BYTES);
68
- if (raw) {
69
- try {
70
- const record: unknown = JSON.parse(raw);
71
- if (record && typeof record === "object" && validPort((record as { port?: unknown }).port)) {
72
- candidates.add((record as { port: number }).port);
73
- }
74
- } catch { /* stale or malformed hint */ }
75
- }
76
- }
163
+ recordHome(defaultHome);
164
+ // The shared registry is what lets one custom home find another: every runtime
165
+ // that published a runtime record registered its home beside the shared clients.
166
+ const registry = readOwnerRegistry();
167
+ for (const home of registry.homes) recordHome(home);
168
+ const registryTruncated = registry.truncated;
77
169
 
78
170
  // Grok's writer reads its config in full; cap discovery separately so startup stays bounded.
79
171
  const grok = readBoundedRegularFile(join(resolveGrokHome(), "config.toml"), MAX_GROK_CONFIG_BYTES);
@@ -96,17 +188,123 @@ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Pr
96
188
  }
97
189
  } catch { /* an absent or invalid client home is not owner evidence */ }
98
190
 
99
- for (const port of candidates) {
100
- const owner = await probePortOwner(port, {}, START_OWNERSHIP_LIVENESS);
101
- if (owner && Number.isSafeInteger(owner.pid) && owner.pid! > 0 && owner.pid !== process.pid) return port;
191
+ let indeterminatePort: number | null = null;
192
+ let indeterminateReason: string | null = null;
193
+ const noteIndeterminate = (port: number | null, reason: string): void => {
194
+ if (indeterminateReason === null) {
195
+ indeterminatePort = port;
196
+ indeterminateReason = reason;
197
+ }
198
+ };
199
+
200
+ if (registryTruncated) {
201
+ // A truncated registry can hide the live owner's home entirely; "no owner
202
+ // found" is then indistinguishable from "owner never read". Fail closed.
203
+ noteIndeterminate(null, "the owner registry listing was truncated before every pointer could be checked");
204
+ }
205
+
206
+ const queue = [...candidates];
207
+ const probed = new Set<number>();
208
+ for (const port of queue) {
209
+ if (probed.has(port)) continue;
210
+ probed.add(port);
211
+ const portRecords = records.get(port) ?? [];
212
+ // The recorded hostnames are tried first; every remaining loopback family is a
213
+ // candidate too, because IPv4 and IPv6 listeners on one port are independent.
214
+ const hosts: string[] = [];
215
+ for (const rec of portRecords) {
216
+ for (const host of loopbackProbeHosts(rec.hostname)) {
217
+ if (!hosts.includes(host)) hosts.push(host);
218
+ }
219
+ }
220
+ if (hosts.length === 0) hosts.push(...loopbackProbeHosts(undefined));
221
+
222
+ for (const hostname of hosts) {
223
+ if (deadlineAt - nowFn() <= 0) {
224
+ noteIndeterminate(port, "ownership discovery ran out of its shared time budget");
225
+ break;
226
+ }
227
+ const identity = await proxyIdentityAt(port, { hostname }, probeIo);
228
+ if (identity === null) {
229
+ // A null identity is not "free": distinguish a unanimous connect refusal
230
+ // (the family is genuinely empty) from a transport or shape that stayed
231
+ // unreadable. On a managed port an unreadable answer is owner evidence the
232
+ // caller cannot dismiss.
233
+ const remainingMs = deadlineAt - nowFn();
234
+ if (remainingMs <= 0) {
235
+ noteIndeterminate(port, "ownership discovery ran out of its shared time budget");
236
+ break;
237
+ }
238
+ const classification = await classifyHealthz(
239
+ "http://" + hostname + ":" + port + "/healthz",
240
+ probeIo.fetchFn ?? directLocalHttpFetch,
241
+ Math.min(timeoutMs, remainingMs),
242
+ );
243
+ if (classification === "dead") continue;
244
+ noteIndeterminate(port, "a managed client port answered but its listener could not be classified");
245
+ continue;
246
+ }
247
+ if (identity.pid === null) {
248
+ noteIndeterminate(port, "a live opencodex listener reported no pid to attest");
249
+ continue;
250
+ }
251
+ if (identity.pid === process.pid) continue;
252
+
253
+ const matching = portRecords.filter(rec => rec.pid === identity.pid);
254
+ if (matching.length === 0) {
255
+ // Our opencodex answers on a managed port but no registered record names it:
256
+ // it may be an owner whose home never registered (an install predating the
257
+ // registry, or a CODEX_HOME the writer could not reach). Unverifiable is not
258
+ // absent - fail closed instead of starting a second owner beside it.
259
+ noteIndeterminate(port, "a live opencodex listener on a managed port matches no registered owner record");
260
+ continue;
261
+ }
262
+ for (const rec of matching) {
263
+ if (rec.siblingOfPort !== undefined) {
264
+ // The attested process is itself a sibling; its runtime record names the
265
+ // real owner's port. Follow it instead of deferring to the sibling.
266
+ if (validPort(rec.siblingOfPort)) queue.push(rec.siblingOfPort);
267
+ continue;
268
+ }
269
+ if (!rec.attestable) {
270
+ noteIndeterminate(port, "a live listener matches a runtime record that carries no attestation secret");
271
+ continue;
272
+ }
273
+ const proof = await proveLiveProxyOwnedByHome(
274
+ { ...identity, hostname, port, source: "runtime" },
275
+ { ...probeIo, readRuntimeFn: () => rec.record },
276
+ );
277
+ if (proof === "proven") return { kind: "owner", port };
278
+ if (proof === "indeterminate") {
279
+ noteIndeterminate(port, "the recorded owner's attestation could not be completed");
280
+ }
281
+ // "refuted" means this listener definitively is not the recorded owner on
282
+ // this record; other records and hosts still get their turn.
283
+ }
284
+ }
102
285
  }
103
- return null;
286
+
287
+ if (indeterminateReason !== null) {
288
+ return { kind: "indeterminate", port: indeterminatePort, reason: indeterminateReason };
289
+ }
290
+ return { kind: "none" };
104
291
  }
105
292
 
106
293
  /** Mark this process before any shared-client write when another home owns the clients. */
107
294
  export async function markCrossHomeSibling(): Promise<boolean> {
108
- const port = await findCrossHomeOwner();
109
- if (port === null) return false;
295
+ const verdict = await findCrossHomeOwnerDetailed();
296
+ if (verdict.kind === "none") return false;
297
+ if (verdict.port === null) throw new Error("Shared-client owner could not be located; refusing startup before any shared-client write.");
298
+ if (verdict.kind === "indeterminate") {
299
+ // Fail closed: an owner the reader could not verify still owns the shared
300
+ // writes. Marking the best port hint keeps every sibling gate engaged even
301
+ // while the answer stays unproven (#6198).
302
+ console.warn(
303
+ "A shared-client owner could not be verified (" + verdict.reason + "); "
304
+ + "treating this instance as a sibling so Codex, Grok and Claude configs are left alone.",
305
+ );
306
+ }
307
+ const port = verdict.port;
110
308
  markSiblingStart(port);
111
309
  return true;
112
310
  }
@@ -119,9 +317,10 @@ export async function markLiveHomeSibling(live: { pid: number | null; port: numb
119
317
  markSiblingStart(runtime.siblingOfPort);
120
318
  return true;
121
319
  }
122
- const otherPort = await findCrossHomeOwner();
123
- if (otherPort === null || otherPort === live.port) return false;
124
- markSiblingStart(otherPort);
320
+ const verdict = await findCrossHomeOwnerDetailed();
321
+ if (verdict.kind === "none" || verdict.port === live.port) return false;
322
+ if (verdict.port === null) throw new Error("Shared-client owner could not be located; refusing startup before any shared-client write.");
323
+ markSiblingStart(verdict.port);
125
324
  return true;
126
325
  }
127
326
 
package/src/cli/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { serviceStayOutExitCode, WINDOWS_WRAPPER_PROTOCOL_ENV } from "../service/windows-wrapper-exit";
3
3
  import { isSupervisedServiceChild, serviceChildOwnershipDecisionForClassifiedChild } from "../service/service-child-ownership";
4
4
  import { SERVICE_MANAGED_ENV } from "../service/state";
5
+ import { raiseWindowsProxyPriority } from "../service/windows-process-priority";
5
6
  import { spawn } from "node:child_process";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
@@ -541,6 +542,7 @@ async function handleStart(options: { block?: boolean } = {}) {
541
542
  // Port selection is check-then-bind. The lease prevents every cooperating start or
542
543
  // updater from turning that check into a different ownership decision.
543
544
  let port = await chooseListenPort(requestedPort, { sibling: siblingStart });
545
+ raiseWindowsProxyPriority();
544
546
  const serverModule = await import("../server");
545
547
  const readinessGate = createReadinessGate();
546
548
  const localAttestationSecret = createLocalAttestationSecret();
@@ -1271,7 +1273,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1271
1273
  if (siblingStopFoundOwner(siblingOfPort, live)) {
1272
1274
  record.proxy = "not-running";
1273
1275
  console.log(`The sibling instance is already gone; the proxy on port ${siblingOfPort} was left running.`);
1274
- } else if (live?.pid && !(await proveLiveProxyOwnedByHome(live))) {
1276
+ } else if (live?.pid && (await proveLiveProxyOwnedByHome(live)) !== "proven") {
1275
1277
  stopFailed = true;
1276
1278
  ownershipBlocked = true;
1277
1279
  record.proxy = "ownership-refused";
@@ -114,7 +114,7 @@ export function selectDriftHealCatalogPath(
114
114
 
115
115
  /** Repair only the missing config roots; catalog convergence remains the later tick step. */
116
116
  async function healCodexConfigDrift(config: OcxConfig, entryGeneration: number): Promise<"none" | "healed" | "not-healed"> {
117
- const [{ codexConfigDrift }, { JOURNAL_PATH, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl }, { shouldSyncCodexOnStart }, { injectCodexConfig }, { DEFAULT_CATALOG_PATH, resolveCodexConfigPath }, { loadConfig }] =
117
+ const [{ codexConfigDrift }, { JOURNAL_PATH, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl }, { shouldSyncCodexOnStart }, { injectCodexConfig }, { DEFAULT_CATALOG_PATH, resolveCodexConfigPath }, { loadConfig }, { inspectNativeCodexOwnership }] =
118
118
  await Promise.all([
119
119
  import("./config-drift-heal"),
120
120
  import("./journal"),
@@ -122,6 +122,7 @@ async function healCodexConfigDrift(config: OcxConfig, entryGeneration: number):
122
122
  import("./inject"),
123
123
  import("./paths"),
124
124
  import("../config"),
125
+ import("../integrations/native/ownership-preflight"),
125
126
  ]);
126
127
  const capturedSettings = JSON.stringify(config);
127
128
  const current = () => entryGeneration === generation && JSON.stringify(loadConfig()) === capturedSettings;
@@ -142,11 +143,17 @@ async function healCodexConfigDrift(config: OcxConfig, entryGeneration: number):
142
143
  // Keep the missing routing roots visible to the next tick. Catalog-only convergence below
143
144
  // may recreate the file now, but it does not re-inject config.toml in this tick.
144
145
  if (catalogPath === null) return "not-healed";
146
+ // Ownership is probed directly, not through admission: admission can refuse on the
147
+ // config or generation authority before it ever inspects native ownership, and a
148
+ // veto that rides that result would let those refusals pass on a foreign home.
149
+ const serviceHomeOwned = () => inspectNativeCodexOwnership().ownership === "owned";
150
+ if (!serviceHomeOwned()) return "not-healed";
145
151
  await injectCodexConfig(runtime.port, config, {
146
152
  catalogPath,
147
153
  lockTimeoutMs: TICK_DEADLINE_MS,
148
154
  beforeClientWrite: () => {
149
155
  if (!current()) throw new Error("Catalog drift heal tick is stale");
156
+ if (!serviceHomeOwned()) throw new Error("Catalog drift heal lost service-home ownership");
150
157
  },
151
158
  }).catch(() => null);
152
159
  if (!current()) return "none";
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The cross-home owner registry.
3
+ *
4
+ * findCrossHomeOwner proves an owner through a home's protected runtime-port.json,
5
+ * but it used to know exactly one home: the default ~/.opencodex. A custom-home
6
+ * owner therefore stayed invisible to every other home (#6198): a second custom home
7
+ * saw the shared clients' managed URL, could not prove the answering process, and
8
+ * started as a competing owner that re-pointed shared Codex/Grok/Claude routing.
9
+ *
10
+ * This registry is the protected stable locator for those homes. Every runtime that
11
+ * publishes runtime-port.json also drops one tiny pointer file here so another home
12
+ * can find the record to attest against. The entries carry the home path only - the
13
+ * attestation secret stays inside the home's own record - so a forged or stale entry
14
+ * can never grant ownership; it can only send the reader to a record that still has
15
+ * to prove itself.
16
+ *
17
+ * The anchor is the default OpenCodex home (~/.opencodex), an OpenCodex-owned
18
+ * namespace every runtime for this user can reach regardless of which
19
+ * OPENCODEX_HOME or CODEX_HOME it serves. Codex, Grok and Claude homes stay
20
+ * untouched: discovery metadata must not write into the client state it exists
21
+ * to protect, so OFF or foreign-owned client homes never see it. Entries are
22
+ * written atomically and never read back as truth - they only nominate a home
23
+ * for the caller's own record + liveness verification.
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join, resolve } from "node:path";
30
+ import { atomicWriteFile } from "./atomic-write";
31
+ import { getConfigDir } from "./paths";
32
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
33
+
34
+ const REGISTRY_DIR_NAME = "ocx-homes";
35
+ /**
36
+ * Test seam, not a user knob: os.homedir() resolves the passwd database on POSIX,
37
+ * so rewriting HOME inside a test process cannot move the anchor and the armed
38
+ * test-home guard would rightly refuse the write. Suites point this at their own
39
+ * fixture home; production always leaves it unset so the default home stays the
40
+ * one locator every sibling can find.
41
+ */
42
+ const REGISTRY_DIR_ENV = "OCX_OWNER_REGISTRY_DIR";
43
+ const REGISTRY_ENTRY_SUFFIX = ".json";
44
+ const MAX_REGISTRY_ENTRIES = 64;
45
+ // Directory listing itself is bounded so a cluttered folder cannot stall startup;
46
+ // the 64-entry result cap applies AFTER validation so dead names cannot crowd out
47
+ // live owners.
48
+ const MAX_REGISTRY_LISTING = 4096;
49
+ const MAX_ENTRY_BYTES = 4096;
50
+ const HOME_KEY_LENGTH = 24;
51
+
52
+ function registryBaseDir(): string {
53
+ // Always the default home, never the caller's OPENCODEX_HOME: the pointer must
54
+ // sit where every sibling runtime can find it no matter which custom home is
55
+ // serving.
56
+ return join(homedir(), ".opencodex");
57
+ }
58
+
59
+ /** The shared directory the registry lives under. */
60
+ export function ownerRegistryDir(): string {
61
+ const override = process.env[REGISTRY_DIR_ENV]?.trim();
62
+ if (override) return resolve(override);
63
+ return join(registryBaseDir(), REGISTRY_DIR_NAME);
64
+ }
65
+
66
+ function registryEntryPath(dir: string, home: string): string {
67
+ const key = createHash("sha256").update(resolve(home)).digest("hex").slice(0, HOME_KEY_LENGTH);
68
+ return join(dir, key + REGISTRY_ENTRY_SUFFIX);
69
+ }
70
+
71
+ /**
72
+ * Record 'home' in the shared registry. Best-effort and never throws: a failed write
73
+ * only degrades cross-home discovery, it must not break the publish that owns state.
74
+ */
75
+ export function registerOwnerRegistryHome(home: string): void {
76
+ try {
77
+ const dir = ownerRegistryDir();
78
+ // Guard every ancestor of the write target: an override that still resolves
79
+ // under the protected ~/.opencodex must not slip past the test home guard.
80
+ for (let ancestor = dir; ;) {
81
+ assertNotRealHomeUnderTest(ancestor);
82
+ const parent = dirname(ancestor);
83
+ if (parent === ancestor) break;
84
+ ancestor = parent;
85
+ }
86
+ mkdirSync(dir, { recursive: true });
87
+ atomicWriteFile(
88
+ registryEntryPath(dir, home),
89
+ JSON.stringify({ home: resolve(home), v: 1 }) + "\n",
90
+ );
91
+ } catch { /* discovery aid only */ }
92
+ }
93
+
94
+ export interface OwnerRegistryRead {
95
+ homes: string[];
96
+ /**
97
+ * The listing or the validated result hit a bound before every pointer could be
98
+ * checked. Callers must treat truncation as "discovery may have missed a live
99
+ * owner" and fail closed rather than concluding no owner exists.
100
+ */
101
+ truncated: boolean;
102
+ }
103
+
104
+ /**
105
+ * Every registered home path, including this process's own (the caller filters it
106
+ * out). Entries are pointers, not facts: malformed, oversized, or unreadable entries
107
+ * are skipped rather than trusted, and a pointer whose home no longer publishes a
108
+ * runtime record nominates nothing - it is pruned before the entry cap so a pile
109
+ * of dead homes cannot crowd out a live owner. Bounded so a cluttered directory
110
+ * cannot stall startup discovery; when a bound actually cuts off unchecked
111
+ * pointers, {@link OwnerRegistryRead.truncated} says the answer is incomplete.
112
+ */
113
+ export function readOwnerRegistry(): OwnerRegistryRead {
114
+ const dir = ownerRegistryDir();
115
+ let names: string[];
116
+ try {
117
+ names = readdirSync(dir)
118
+ .filter(name => name.endsWith(REGISTRY_ENTRY_SUFFIX));
119
+ } catch {
120
+ return { homes: [], truncated: false };
121
+ }
122
+ let truncated = names.length > MAX_REGISTRY_LISTING;
123
+ const homes: string[] = [];
124
+ for (const name of names.slice(0, MAX_REGISTRY_LISTING)) {
125
+ const path = join(dir, name);
126
+ try {
127
+ const stat = statSync(path);
128
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_ENTRY_BYTES) continue;
129
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
130
+ const home = parsed && typeof parsed === "object"
131
+ ? (parsed as Record<string, unknown>).home
132
+ : undefined;
133
+ if (typeof home !== "string" || home.length === 0) continue;
134
+ // The record is written before the pointer, so a registered home without
135
+ // runtime-port.json is a dead entry: skip it before it can spend the cap.
136
+ if (!existsSync(join(home, "runtime-port.json"))) continue;
137
+ if (homes.length >= MAX_REGISTRY_ENTRIES) { truncated = true; break; }
138
+ homes.push(home);
139
+ } catch { /* a bad entry names nothing */ }
140
+ }
141
+ return { homes, truncated };
142
+ }
143
+
144
+ /** Retire 'home' from the shared registry. Best-effort like registration. */
145
+ export function unregisterOwnerRegistryHome(home: string): void {
146
+ try {
147
+ unlinkSync(registryEntryPath(ownerRegistryDir(), home));
148
+ } catch { /* a missing pointer needs no removal */ }
149
+ }
150
+
151
+ /** Register this process's own home after its runtime record is published. */
152
+ export function registerOwnHome(): void {
153
+ registerOwnerRegistryHome(getConfigDir());
154
+ }
@@ -9,6 +9,7 @@ import {
9
9
  } from "../lib/windows-elevation";
10
10
  import { atomicWriteFile } from "./atomic-write";
11
11
  import { getConfigDir, hardenConfigDir } from "./paths";
12
+ import { registerOwnHome, unregisterOwnerRegistryHome } from "./owner-registry";
12
13
 
13
14
  export function getPidPath(): string {
14
15
  return join(getConfigDir(), "ocx.pid");
@@ -64,6 +65,10 @@ function isValidRuntimePortState(value: unknown): value is RuntimePortState {
64
65
  export function writeRuntimePort(state: RuntimePortState): void {
65
66
  ensureProcessStateDir();
66
67
  atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
68
+ // The record proves this home's owner only to a reader that knows where it lives.
69
+ // One pointer in the shared registry makes the record findable from every home;
70
+ // it is best-effort because ownership never depends on the registry write landing.
71
+ registerOwnHome();
67
72
  }
68
73
 
69
74
  export function parsePidFile(raw: string): number | null {
@@ -100,6 +105,9 @@ export function removePid(expectedPid?: number): void {
100
105
  export function removeRuntimePort(expectedPid?: number): void {
101
106
  if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
102
107
  try { unlinkSync(getRuntimePortPath()); } catch { /* ignore */ }
108
+ // The record is gone, so the registry pointer names a dead home. Retire it
109
+ // beside the record or stale pointers accumulate toward the reader's cap.
110
+ unregisterOwnerRegistryHome(getConfigDir());
103
111
  }
104
112
 
105
113
  /**