@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.
- package/gui/dist/assets/{App-_MrbTrBe.js → App-BKML41Kr.js} +4 -4
- package/gui/dist/assets/{Tray-5PuUalET.js → Tray-DFOclkLw.js} +1 -1
- package/gui/dist/assets/index-BKsHGDTE.css +1 -0
- package/gui/dist/assets/{index-DKrO2eF3.js → index-DSLWDj_0.js} +11 -11
- package/gui/dist/assets/{quota-summary-BCLivc0Q.js → quota-summary-Cit0cOn7.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-model-contract.ts +51 -8
- package/src/adapters/anthropic.ts +6 -2
- package/src/adapters/openai-chat/summary-budget.ts +11 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/intercept/local-ca.ts +25 -1
- package/src/claude/intercept/picker-ca.ts +132 -0
- package/src/cli/capabilities.ts +2 -1
- package/src/cli/claude-desktop.ts +2 -1
- package/src/cli/cross-home-owner.ts +221 -22
- package/src/cli/index.ts +3 -1
- package/src/codex/catalog-auto-refresh.ts +8 -1
- package/src/config/owner-registry.ts +154 -0
- package/src/config/process-state.ts +8 -0
- package/src/generated/compatibility-version.json +32 -20
- package/src/integrations/cursor-detect.ts +3 -2
- package/src/integrations/cursor-local-installer.ts +150 -0
- package/src/server/management/cursor-integration-routes.ts +20 -0
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/port-reclaim.ts +57 -7
- package/src/server/proxy-liveness.ts +46 -19
- package/src/service/windows-process-priority.ts +39 -0
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/web-search/anthropic-executor.ts +3 -2
- 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 {
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
if (
|
|
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
|
|
123
|
-
if (
|
|
124
|
-
|
|
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 &&
|
|
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
|
/**
|