@bitkyc08/opencodex 2.78.0 → 2.80.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-CSJvmjNs.css +1 -0
- package/gui/dist/assets/App-MbDVfUeC.js +54 -0
- package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-CzkxK82A.js} +1 -1
- package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
- package/gui/dist/assets/index-UJt6npXk.js +94 -0
- package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-DU3sD6Z-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/cursor/live-transport.ts +5 -3
- package/src/adapters/cursor/native-exec-fs.ts +12 -9
- package/src/adapters/cursor/native-exec.ts +13 -5
- package/src/adapters/cursor/protobuf-request.ts +13 -7
- package/src/adapters/cursor/request-builder.ts +23 -10
- package/src/adapters/cursor/tool-definitions.ts +9 -6
- package/src/adapters/cursor/tool-guidance.ts +29 -8
- package/src/adapters/cursor/tool-naming.ts +40 -7
- package/src/adapters/cursor/tool-wording.ts +46 -0
- package/src/adapters/cursor.ts +3 -0
- package/src/adapters/devin.ts +37 -2
- package/src/adapters/openai-chat/messages.ts +3 -1
- package/src/claude/desktop-picker.ts +11 -9
- package/src/claude/inbound-content-options.ts +29 -2
- package/src/claude/inbound.ts +9 -0
- package/src/claude/intercept/local-ca-files.ts +265 -0
- package/src/claude/intercept/local-ca-windows.ts +73 -0
- package/src/claude/intercept/local-ca.ts +12 -24
- package/src/claude/intercept/picker-ca-persistence.ts +106 -0
- package/src/claude/intercept/picker-ca-startup.ts +16 -0
- package/src/claude/intercept/picker-ca-store.ts +70 -0
- package/src/claude/intercept/picker-ca.ts +43 -9
- package/src/claude/intercept/picker-runtime.ts +14 -4
- package/src/claude/intercept/runtime.ts +32 -23
- package/src/cli/ensure-readiness.ts +48 -0
- package/src/cli/gui-pair-client.ts +15 -7
- package/src/cli/gui.ts +4 -1
- package/src/cli/hub.ts +1 -0
- package/src/cli/index.ts +10 -11
- package/src/cli/status.ts +2 -2
- package/src/cli/update-restart-child.ts +4 -1
- package/src/codex/account-credit-use.ts +8 -2
- package/src/codex/auth-api/account-pause-group.ts +113 -0
- package/src/codex/auth-api/routes.ts +18 -12
- package/src/codex/auth-context.ts +45 -3
- package/src/codex/autostart-health.ts +13 -3
- package/src/codex/journal.ts +36 -5
- package/src/codex/pool-credit-policy.ts +67 -0
- package/src/codex/shim-overlay.ts +9 -1
- package/src/combos/jev.ts +3 -1
- package/src/generated/compatibility-version.json +135 -63
- package/src/lib/gui-pair-capability.ts +1 -1
- package/src/lib/gui-pair-intent.ts +170 -0
- package/src/lib/windows-owner-acl.ts +143 -0
- package/src/providers/openai-sidecar-credit.ts +26 -0
- package/src/providers/openai-sidecar.ts +35 -0
- package/src/server/claude-messages.ts +1 -0
- package/src/server/gui-pair-delivery.ts +23 -0
- package/src/server/index/serve-options.ts +5 -8
- package/src/server/index.ts +12 -14
- package/src/server/live-native-calls.ts +94 -0
- package/src/server/live.ts +28 -5
- package/src/server/local-management-read-client.ts +15 -11
- package/src/server/management/oauth-account-routes.ts +15 -0
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/shared.ts +9 -3
- package/src/server/messages-native-scope.ts +60 -0
- package/src/server/messages-native.ts +9 -0
- package/src/server/responses/adapter-dispatch.ts +31 -2
- package/src/server/responses/combo-requested-effort.ts +14 -0
- package/src/server/responses/compact.ts +17 -2
- package/src/server/responses/core-auth.ts +18 -1
- package/src/server/responses/core-codex-account.ts +18 -8
- package/src/server/responses/core-combo.ts +5 -7
- package/src/server/responses/non-replayable-error.ts +48 -0
- package/src/server/responses/passthrough-delivery.ts +5 -1
- package/src/server/responses/passthrough-dispatch.ts +21 -19
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/sidecar-execution.ts +1 -0
- package/src/server/search.ts +4 -0
- package/src/server/startup-health-cache.ts +2 -6
- package/src/service/desktop-startup.ts +100 -3
- package/src/vision/describe.ts +17 -10
- package/src/vision/index.ts +1 -0
- package/src/web-search/executor.ts +17 -10
- package/src/web-search/format-result.ts +304 -20
- package/src/web-search/header-deadline.ts +67 -0
- package/src/web-search/loop.ts +7 -6
- package/src/web-search/passthrough-bridge.ts +1 -0
- package/src/web-search/run-turn-loop.ts +1 -1
- package/gui/dist/assets/App-BtfG5id-.js +0 -54
- package/gui/dist/assets/App-lCIslZ1L.css +0 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID, X509Certificate } from "node:crypto";
|
|
2
2
|
import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { canonicalPickerConfigDir, ensurePersistentPickerCa } from "./picker-ca-persistence";
|
|
5
|
+
import type { PickerCaStore } from "./picker-ca-store";
|
|
4
6
|
import { withClientLifecycleSync } from "../../client/lifecycle-lock";
|
|
5
7
|
import {
|
|
6
8
|
ALL_IP_ADDRESS_BASES,
|
|
@@ -405,29 +407,58 @@ export function acknowledgePendingPickerCaUntrust(
|
|
|
405
407
|
* unconditional so a later failed rotation can never leave that key behind.
|
|
406
408
|
*/
|
|
407
409
|
export function discardPickerCaKey(configDir: string): void {
|
|
410
|
+
try {
|
|
411
|
+
const stat = lstatSync(pickerStateDir(configDir));
|
|
412
|
+
if (!stat.isDirectory() || (process.platform !== "win32" && stat.uid !== process.getuid!())) {
|
|
413
|
+
throw new Error("picker_ca_directory_unsafe");
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error !== null && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") return;
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
408
419
|
rmSync(join(pickerStateDir(configDir), "ca.key"), { force: true });
|
|
409
420
|
}
|
|
410
421
|
|
|
411
|
-
export function ensurePickerCa(configDir: string, options: { rotation?: "startup" } = {}): PickerCa {
|
|
422
|
+
export function ensurePickerCa(configDir: string, options: { rotation?: "startup"; persistent?: boolean; store?: PickerCaStore } = {}): PickerCa {
|
|
423
|
+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
424
|
+
configDir = canonicalPickerConfigDir(configDir);
|
|
412
425
|
const dir = pickerStateDir(configDir);
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// restored) by an older release on every call, including cache hits.
|
|
426
|
+
// Persistent startup stores the signing key in the OS credential store; plain calls keep an
|
|
427
|
+
// in-process authority. Both paths remove legacy plaintext ca.key files, including cache hits.
|
|
416
428
|
discardPickerCaKey(configDir);
|
|
417
429
|
const cached = processAuthorities.get(dir);
|
|
418
|
-
const
|
|
430
|
+
const create = () => cached ?? (() => {
|
|
419
431
|
const ca = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] });
|
|
420
432
|
return { ...ca, fingerprint: pickerCaFingerprints(ca.certPem).sha256 };
|
|
421
433
|
})();
|
|
422
434
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
423
|
-
lockedPickerCa(configDir, () => {
|
|
435
|
+
const pickerCa = lockedPickerCa(configDir, () => {
|
|
424
436
|
if (readPendingPickerCaUntrust(configDir)) throw new Error("picker_ca_pending_untrust");
|
|
437
|
+
if (options.persistent) {
|
|
438
|
+
return ensurePersistentPickerCa({
|
|
439
|
+
configDir, stateDir: dir, store: options.store, acceptsAuthority: acceptsPickerAuthority, create,
|
|
440
|
+
predecessor: ca => {
|
|
441
|
+
const published = publishedPickerCa(configDir);
|
|
442
|
+
if (published === ca.certPem || published === null) return null;
|
|
443
|
+
if (livePublishedOwner(configDir, published)) throw new Error("picker_ca_live_owner");
|
|
444
|
+
const outgoing = publicCertificate(published);
|
|
445
|
+
if (outgoing && options.rotation !== "startup") throw new Error("picker_ca_rotation_requires_startup");
|
|
446
|
+
return outgoing?.certPem ?? null;
|
|
447
|
+
},
|
|
448
|
+
publishMetadata: publishPem,
|
|
449
|
+
publish: (ca, predecessor) => {
|
|
450
|
+
if (predecessor) writePendingPickerCaUntrust(configDir, publicCertificate(predecessor)!);
|
|
451
|
+
publishAuthority(configDir, ca);
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
const pickerCa = create();
|
|
425
456
|
const published = publishedPickerCa(configDir);
|
|
426
457
|
if (published === pickerCa.certPem) {
|
|
427
458
|
// The signing key is still in this process. A missing/stale owner file must not let a
|
|
428
459
|
// second process rotate this live authority after an otherwise harmless cached ensure.
|
|
429
460
|
if (!currentProcessOwnsPublishedCa(configDir, pickerCa)) publishOwner(configDir, pickerCa);
|
|
430
|
-
return;
|
|
461
|
+
return pickerCa;
|
|
431
462
|
}
|
|
432
463
|
if (published !== null && livePublishedOwner(configDir, published)) {
|
|
433
464
|
throw new Error("picker_ca_live_owner");
|
|
@@ -438,14 +469,17 @@ export function ensurePickerCa(configDir: string, options: { rotation?: "startup
|
|
|
438
469
|
writePendingPickerCaUntrust(configDir, outgoing);
|
|
439
470
|
}
|
|
440
471
|
publishAuthority(configDir, pickerCa);
|
|
472
|
+
return pickerCa;
|
|
441
473
|
});
|
|
442
|
-
if (!cached) processAuthorities.set(dir, pickerCa);
|
|
474
|
+
if (!cached || options.persistent) processAuthorities.set(dir, pickerCa);
|
|
443
475
|
return pickerCa;
|
|
444
476
|
}
|
|
445
477
|
|
|
446
478
|
/** Fingerprint of this process's authority only — null until ensurePickerCa has run. */
|
|
447
479
|
export function publishedPickerCaSha256(configDir: string): string | null {
|
|
448
|
-
|
|
480
|
+
let canonical: string;
|
|
481
|
+
try { canonical = canonicalPickerConfigDir(configDir); } catch { return null; }
|
|
482
|
+
const cached = processAuthorities.get(pickerStateDir(canonical));
|
|
449
483
|
return cached ? pickerCaFingerprints(cached.certPem).sha256 : null;
|
|
450
484
|
}
|
|
451
485
|
|
|
@@ -19,6 +19,8 @@ import type { ClaudeDesktopMode } from "../desktop-first-party";
|
|
|
19
19
|
import type { TunnelDecision } from "./connect-proxy";
|
|
20
20
|
import type { PemKeyPair } from "./local-ca";
|
|
21
21
|
import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca";
|
|
22
|
+
import { preparePersistentPickerAuthority } from "./picker-ca-startup";
|
|
23
|
+
import type { PickerCaStore } from "./picker-ca-store";
|
|
22
24
|
import { startPickerListener, type PickerListenerHandle } from "./picker-listener";
|
|
23
25
|
import { createPickerModelSnapshot, type PickerModelSnapshot, type PickerRouteInput } from "./picker-models";
|
|
24
26
|
import { inspectPickerTrust, type PickerTrustState, type SecurityRunner } from "./picker-trust";
|
|
@@ -81,6 +83,8 @@ export interface CreatePickerRuntimeOptions {
|
|
|
81
83
|
isBusy?: () => boolean;
|
|
82
84
|
configDir: string;
|
|
83
85
|
loadRoutes: () => Promise<PickerRouteInput>;
|
|
86
|
+
/** Production opt-in; pure/direct runtime callers remain ephemeral. */
|
|
87
|
+
persistentAuthority?: { store?: PickerCaStore };
|
|
84
88
|
security?: SecurityRunner;
|
|
85
89
|
platform?: NodeJS.Platform;
|
|
86
90
|
now?: () => number;
|
|
@@ -161,9 +165,11 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
|
|
|
161
165
|
if (pending) void pending.then(handle => handle.close()).catch(() => {});
|
|
162
166
|
}
|
|
163
167
|
|
|
164
|
-
function ensureMaterial(): { ca: PickerCa; leaf: PemKeyPair; sha1: string } {
|
|
168
|
+
async function ensureMaterial(): Promise<{ ca: PickerCa; leaf: PemKeyPair; sha1: string }> {
|
|
165
169
|
if (!ca || !leaf || !caSha1) {
|
|
166
|
-
ca =
|
|
170
|
+
ca = options.persistentAuthority
|
|
171
|
+
? await preparePersistentPickerAuthority({ configDir: options.configDir, store: options.persistentAuthority.store, security: options.security, platform })
|
|
172
|
+
: ensurePickerCa(options.configDir);
|
|
167
173
|
leaf = issuePickerLeaf(ca, options.configDir);
|
|
168
174
|
caSha1 = pickerCaFingerprints(ca.certPem).sha1;
|
|
169
175
|
}
|
|
@@ -176,7 +182,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
|
|
|
176
182
|
}
|
|
177
183
|
|
|
178
184
|
async function inspectTrust(force: boolean): Promise<PickerTrustState> {
|
|
179
|
-
const { sha1 } = ensureMaterial();
|
|
185
|
+
const { sha1 } = await ensureMaterial();
|
|
180
186
|
if (!force && trustFor === sha1 && now() - trustCheckedAt < trustTtlMs) return trust;
|
|
181
187
|
trust = await inspectPickerTrust(pickerLeafCertPath(options.configDir), sha1, options.security, platform);
|
|
182
188
|
trustFor = sha1;
|
|
@@ -186,7 +192,8 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
|
|
|
186
192
|
|
|
187
193
|
/** CA, leaf, snapshot and listener. Resolves false when a disarm overtook it. */
|
|
188
194
|
async function startPieces(gen: number): Promise<boolean> {
|
|
189
|
-
const material = ensureMaterial();
|
|
195
|
+
const material = await ensureMaterial();
|
|
196
|
+
if (gen !== generation || latched || stopped) return false;
|
|
190
197
|
ensureSnapshot().refreshIfStale(PICKER_MODELS_MAX_AGE_MS);
|
|
191
198
|
if (!listenerPromise) {
|
|
192
199
|
const current = startListener({
|
|
@@ -266,6 +273,9 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
|
|
|
266
273
|
});
|
|
267
274
|
},
|
|
268
275
|
async refreshTrust() {
|
|
276
|
+
// A dormant production picker has no authority to inspect. Status/off must not initialize
|
|
277
|
+
// credentials; an already initialized issuer still gets authoritative OS trust inspection.
|
|
278
|
+
if (options.persistentAuthority && ca === null) return trust;
|
|
269
279
|
return inspectTrust(true);
|
|
270
280
|
},
|
|
271
281
|
async refresh() {
|
|
@@ -8,10 +8,11 @@ import { createCliCatalogProvider } from "./cli-picker";
|
|
|
8
8
|
import { CLAUDE_INTERCEPT_HOSTS, isBrowserConnect, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
|
|
9
9
|
import { startClaudeInterceptListener } from "./listener";
|
|
10
10
|
import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
|
|
11
|
-
import { discardPickerCaKey
|
|
12
|
-
import {
|
|
11
|
+
import { discardPickerCaKey } from "./picker-ca";
|
|
12
|
+
import { preparePersistentPickerAuthority } from "./picker-ca-startup";
|
|
13
|
+
import type { PickerCaStore } from "./picker-ca-store";
|
|
13
14
|
import type { PickerRouteInput } from "./picker-models";
|
|
14
|
-
import { createPickerRuntime, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
|
|
15
|
+
import { createPickerRuntime, pickerDesired, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
|
|
15
16
|
import type { SecurityRunner } from "./picker-trust";
|
|
16
17
|
import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./proxy-auth";
|
|
17
18
|
import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./settings";
|
|
@@ -31,6 +32,15 @@ import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./setti
|
|
|
31
32
|
* tunnels may be terminated by the picker runtime (src/claude/intercept/picker-runtime.ts).
|
|
32
33
|
*/
|
|
33
34
|
|
|
35
|
+
const PICKER_AUTHORITY_ERROR_CODES = new Set([
|
|
36
|
+
"picker_ca_store_unavailable", "picker_ca_store_missing", "picker_ca_store_invalid",
|
|
37
|
+
"picker_ca_store_readback_failed", "picker_ca_metadata_mismatch", "picker_ca_metadata_unsafe",
|
|
38
|
+
"picker_ca_metadata_invalid", "picker_ca_live_owner", "picker_ca_pending_untrust",
|
|
39
|
+
"picker_ca_pending_untrust_invalid", "picker_ca_pending_untrust_unsafe", "picker_ca_pending_untrust_changed",
|
|
40
|
+
"picker_ca_config_unsafe", "picker_ca_directory_unsafe", "picker_ca_rotation_requires_startup",
|
|
41
|
+
"picker_ca_unsupported_platform",
|
|
42
|
+
]);
|
|
43
|
+
|
|
34
44
|
export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;
|
|
35
45
|
|
|
36
46
|
export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
|
|
@@ -135,6 +145,7 @@ export interface StartClaudeInterceptOptions<T> {
|
|
|
135
145
|
/** Test seam: bind real CONNECT handlers on kernel-assigned ports without probe-and-release races. */
|
|
136
146
|
startProxy?: typeof startConnectProxy;
|
|
137
147
|
/** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */
|
|
148
|
+
pickerCaStore?: PickerCaStore;
|
|
138
149
|
pickerSecurity?: SecurityRunner;
|
|
139
150
|
pickerPlatform?: NodeJS.Platform;
|
|
140
151
|
}
|
|
@@ -200,10 +211,8 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
|
|
|
200
211
|
let pickerReason: ClaudeInterceptState["pickerReason"] = null;
|
|
201
212
|
let pickerFailurePort: number | undefined;
|
|
202
213
|
try {
|
|
203
|
-
if (options.loadPickerRoutes) {
|
|
204
|
-
//
|
|
205
|
-
// next to the published certificate. Drop that key before anything else: a cleanup failure
|
|
206
|
-
// below must never leave a signing key on disk that outlives this process.
|
|
214
|
+
if (options.loadPickerRoutes && (options.pickerPlatform ?? process.platform) === "darwin") {
|
|
215
|
+
// Remove any legacy plaintext key before inspecting or activating picker state.
|
|
207
216
|
discardPickerCaKey(configDir);
|
|
208
217
|
const { inspectDesktopPickerProfile } = await import("../desktop-picker-profile");
|
|
209
218
|
// The applied profile row is durable evidence of the user's picker selection. Rotation keeps
|
|
@@ -217,22 +226,23 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
|
|
|
217
226
|
const pickerProfileApplied = pickerProfile.kind === "applied";
|
|
218
227
|
let pickerBlocked = false;
|
|
219
228
|
try {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
ensurePickerCa(configDir, { rotation: "startup" });
|
|
226
|
-
if (!await drain()) pickerBlocked = true;
|
|
229
|
+
const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../desktop-first-party");
|
|
230
|
+
const fresh = options.config;
|
|
231
|
+
const platform = options.pickerPlatform ?? process.platform;
|
|
232
|
+
if (pickerProfileApplied || pickerDesired(fresh, resolveClaudeDesktopMode(fresh, observeClaudeDesktopMode(fresh)), platform)) {
|
|
233
|
+
await preparePersistentPickerAuthority({ configDir, store: options.pickerCaStore, security: options.pickerSecurity, platform });
|
|
227
234
|
}
|
|
228
235
|
} catch (error) {
|
|
229
236
|
pickerBlocked = true;
|
|
230
237
|
pickerReason = "failed";
|
|
231
|
-
|
|
238
|
+
// Native and filesystem errors may include private contents or paths. Emit only an exact
|
|
239
|
+
// application-owned code; arbitrary diagnostic text never crosses this boundary.
|
|
240
|
+
const code = error instanceof Error && PICKER_AUTHORITY_ERROR_CODES.has(error.message) ? error.message : null;
|
|
241
|
+
console.warn(code === "picker_ca_pending_untrust"
|
|
242
|
+
? "⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted"
|
|
243
|
+
: `⚠ Claude Desktop picker disabled: picker authority unavailable${code ? ` (${code})` : ""}`);
|
|
232
244
|
}
|
|
233
245
|
if (pickerBlocked) {
|
|
234
|
-
pickerReason = "failed";
|
|
235
|
-
console.warn("⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted");
|
|
236
246
|
if (pickerProfile.kind === "applied") {
|
|
237
247
|
// Keep Desktop's actual pinned egress alive without ever constructing a TLS terminator.
|
|
238
248
|
const port = Number(new URL(pickerProfile.proxyUrl).port);
|
|
@@ -253,6 +263,7 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
|
|
|
253
263
|
picker = pickerBlocked ? null : (options.createPicker ?? createPickerRuntime)({
|
|
254
264
|
config: options.config,
|
|
255
265
|
configDir,
|
|
266
|
+
persistentAuthority: { store: options.pickerCaStore },
|
|
256
267
|
loadRoutes: options.loadPickerRoutes,
|
|
257
268
|
// The controller's lock: while it is held, periodic refreshes never arm.
|
|
258
269
|
isBusy: () => controller?.busy() ?? false,
|
|
@@ -312,17 +323,15 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
|
|
|
312
323
|
persistPreference,
|
|
313
324
|
proxyPort: () => (pickerProxyLive && boundProxy ? boundProxy.port : null),
|
|
314
325
|
configDir,
|
|
326
|
+
persistentAuthority: { store: options.pickerCaStore },
|
|
315
327
|
...(options.pickerSecurity ? { security: options.pickerSecurity } : {}),
|
|
316
328
|
...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
|
|
317
329
|
});
|
|
318
330
|
await picker.start();
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
// Desktop was pinned to the picker before the restart, the regular enable flow re-trusts
|
|
322
|
-
// the new authority and rewrites the profile row in place, so a restart does not silently
|
|
323
|
-
// turn the picker off.
|
|
331
|
+
// Restore inspects the retained identity without installing trust. Explicit enable/trust
|
|
332
|
+
// owns consent when trust was revoked or legacy migration introduced a new authority.
|
|
324
333
|
if (pickerProfileApplied && controller) {
|
|
325
|
-
void controller.enable({ persist: false, context: "server" })
|
|
334
|
+
void controller.enable({ persist: false, context: "server", allowTrustPrompt: false })
|
|
326
335
|
.catch(error => console.warn(`⚠ Claude Desktop picker restore failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
327
336
|
}
|
|
328
337
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readiness wait for a proxy this process just asked to start.
|
|
3
|
+
*
|
|
4
|
+
* ocx ensure spawns a detached ocx start and returns once this home's proxy answers.
|
|
5
|
+
* It used to give up after a flat 8 s. A cold start can take longer than that on a
|
|
6
|
+
* busy Windows host (Windows CI measured 10-45 s for the first proxy child of a
|
|
7
|
+
* batch), so ensure reported "did not become healthy" for a proxy that came up
|
|
8
|
+
* moments later, and the Codex shim or autostart that called it saw a failure.
|
|
9
|
+
*
|
|
10
|
+
* The wait is now as long as the service start's follow-up (40 s) while the
|
|
11
|
+
* spawned child is alive. A child that has exited keeps the old 8 s bound, so a
|
|
12
|
+
* start that failed outright is reported no later than before.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const ENSURE_READY_TIMEOUT_MS = 40_000;
|
|
16
|
+
export const ENSURE_EXITED_CHILD_TIMEOUT_MS = 8_000;
|
|
17
|
+
const POLL_MS = 150;
|
|
18
|
+
|
|
19
|
+
export interface LiveProxyWaitOptions<T> {
|
|
20
|
+
find: () => Promise<T | null>;
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
/** Return false to stop polling early; one observation is always made first. */
|
|
23
|
+
keepWaiting?: () => boolean;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function waitForLiveProxy<T>(options: LiveProxyWaitOptions<T>): Promise<T | null> {
|
|
29
|
+
const now = options.now ?? Date.now;
|
|
30
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
|
|
31
|
+
const deadline = now() + options.timeoutMs;
|
|
32
|
+
while (now() < deadline) {
|
|
33
|
+
const live = await options.find();
|
|
34
|
+
if (live) return live;
|
|
35
|
+
if (options.keepWaiting && !options.keepWaiting()) return null;
|
|
36
|
+
await sleep(POLL_MS);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keep waiting while the spawned child lives; once it exits, only inside the old 8 s bound. */
|
|
42
|
+
export function ensureKeepWaiting(
|
|
43
|
+
spawnedAt: number,
|
|
44
|
+
childExited: () => boolean,
|
|
45
|
+
now: () => number = Date.now,
|
|
46
|
+
): () => boolean {
|
|
47
|
+
return () => !childExited() || now() - spawnedAt < ENSURE_EXITED_CHILD_TIMEOUT_MS;
|
|
48
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readRuntimePort, type RuntimePortState } from "../config/process-state";
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { createGuiPairIntent, GUI_PAIR_INTENT_HEADER, type GuiPairIntent } from "../lib/gui-pair-intent";
|
|
3
4
|
import {
|
|
4
5
|
LOCAL_ATTESTATION_CHALLENGE_HEADER,
|
|
5
6
|
LOCAL_ATTESTATION_PROOF_HEADER,
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
|
|
31
32
|
export type GuiPairRequestResult =
|
|
32
33
|
| { kind: "created"; grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number }
|
|
33
|
-
| { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" };
|
|
34
|
+
| { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" | "local-intent" };
|
|
34
35
|
|
|
35
36
|
export interface GuiPairClientDeps {
|
|
36
37
|
fetchImpl?: typeof fetch;
|
|
@@ -38,6 +39,9 @@ export interface GuiPairClientDeps {
|
|
|
38
39
|
createChallenge?: () => string;
|
|
39
40
|
now?: () => number;
|
|
40
41
|
timeoutMs?: number;
|
|
42
|
+
/** Hub invitation policy is unchanged; standalone commands require local write intent. */
|
|
43
|
+
requireLocalIntent?: boolean;
|
|
44
|
+
createIntent?: typeof createGuiPairIntent;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
const GUI_PAIR_REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -136,9 +140,12 @@ export async function requestBoundGuiPairingGrant(
|
|
|
136
140
|
expiresAt,
|
|
137
141
|
);
|
|
138
142
|
if (!capability) return { kind: "unavailable", reason: "capability" };
|
|
139
|
-
let
|
|
143
|
+
let intent: GuiPairIntent | undefined;
|
|
140
144
|
try {
|
|
141
|
-
|
|
145
|
+
if (deps.requireLocalIntent !== false) intent = (deps.createIntent ?? createGuiPairIntent)(capability);
|
|
146
|
+
} catch { return { kind: "unavailable", reason: "local-intent" }; }
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetchImpl(`${baseUrl}${GUI_PAIR_PATH}`, {
|
|
142
149
|
method: GUI_PAIR_METHOD,
|
|
143
150
|
headers: {
|
|
144
151
|
"Content-Length": "0",
|
|
@@ -147,13 +154,14 @@ export async function requestBoundGuiPairingGrant(
|
|
|
147
154
|
[GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt),
|
|
148
155
|
[GUI_PAIR_BROWSER_ORIGIN_HEADER]: browserOrigin,
|
|
149
156
|
[GUI_PAIR_CAPABILITY_HEADER]: capability,
|
|
157
|
+
...(intent ? { [GUI_PAIR_INTENT_HEADER]: intent.proof } : {}),
|
|
150
158
|
},
|
|
151
159
|
signal: AbortSignal.timeout(timeoutMs),
|
|
152
160
|
});
|
|
161
|
+
if (!response.ok) return { kind: "unavailable", reason: "rejected" };
|
|
162
|
+
const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin);
|
|
163
|
+
return result ?? { kind: "unavailable", reason: "rejected" };
|
|
153
164
|
} catch {
|
|
154
165
|
return { kind: "unavailable", reason: "transport" };
|
|
155
|
-
}
|
|
156
|
-
if (!response.ok) return { kind: "unavailable", reason: "rejected" };
|
|
157
|
-
const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin);
|
|
158
|
-
return result ?? { kind: "unavailable", reason: "rejected" };
|
|
166
|
+
} finally { intent?.dispose(); }
|
|
159
167
|
}
|
package/src/cli/gui.ts
CHANGED
|
@@ -77,9 +77,12 @@ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promi
|
|
|
77
77
|
}
|
|
78
78
|
const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, canonicalOrigin, {
|
|
79
79
|
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
80
|
+
requireLocalIntent: config.runtimeRole !== "hub",
|
|
80
81
|
});
|
|
81
82
|
if (result.kind !== "created") {
|
|
82
|
-
console.error(
|
|
83
|
+
console.error(result.reason === "local-intent"
|
|
84
|
+
? "GUI pairing requires write access to the private OpenCodex home; no code was issued."
|
|
85
|
+
: `GUI pairing failed (${result.reason}).`);
|
|
83
86
|
return 1;
|
|
84
87
|
}
|
|
85
88
|
if (parsed.json) {
|
package/src/cli/hub.ts
CHANGED
|
@@ -328,6 +328,7 @@ async function runInvite(args: string[], deps: HubCommandDeps): Promise<number>
|
|
|
328
328
|
const dataUrl = resolved.dataUrl;
|
|
329
329
|
const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, browserOrigin, {
|
|
330
330
|
...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
|
|
331
|
+
requireLocalIntent: false,
|
|
331
332
|
});
|
|
332
333
|
if (result.kind !== "created") {
|
|
333
334
|
console.error(`Minting a pairing code failed (${result.reason}).`);
|
package/src/cli/index.ts
CHANGED
|
@@ -152,6 +152,7 @@ import {
|
|
|
152
152
|
grokSyncFailureMessage,
|
|
153
153
|
reconcileEnsureDesiredIntegrations,
|
|
154
154
|
} from "./ensure-desired-integrations";
|
|
155
|
+
import { ENSURE_READY_TIMEOUT_MS, ensureKeepWaiting, waitForLiveProxy } from "./ensure-readiness";
|
|
155
156
|
import { refreshOwnedCatalogIntegrations } from "../integrations/catalog-refresh";
|
|
156
157
|
import { loadExportModels } from "../server/management/model-rows";
|
|
157
158
|
|
|
@@ -235,16 +236,10 @@ function parseStartCliOptions(): ReturnType<typeof parseStartOptions> {
|
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
|
|
238
|
-
async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// fallback port, and never mistakes a foreign 200 for our proxy.
|
|
243
|
-
const live = await findLiveProxy();
|
|
244
|
-
if (live) return live;
|
|
245
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
246
|
-
}
|
|
247
|
-
return null;
|
|
239
|
+
async function waitForProxy(timeoutMs = 8_000, keepWaiting?: () => boolean): Promise<LiveProxy | null> {
|
|
240
|
+
// Runtime-state-first with identity: finds the proxy even when it started on a
|
|
241
|
+
// fallback port, and never mistakes a foreign 200 for our proxy.
|
|
242
|
+
return waitForLiveProxy({ find: findLiveProxy, timeoutMs, keepWaiting });
|
|
248
243
|
}
|
|
249
244
|
|
|
250
245
|
class StartCommandExit extends Error {
|
|
@@ -846,9 +841,13 @@ async function handleEnsure(options: { existingIsSuccess?: boolean; forceStart?:
|
|
|
846
841
|
env: detachedStartEnvironment(),
|
|
847
842
|
});
|
|
848
843
|
options.onSpawn?.(child);
|
|
844
|
+
const spawnedAt = Date.now();
|
|
845
|
+
let childExited = false;
|
|
846
|
+
child.once("exit", () => { childExited = true; });
|
|
849
847
|
child.unref();
|
|
850
848
|
|
|
851
|
-
|
|
849
|
+
// A cold start can outlast 8 s on a busy Windows host; see ensure-readiness.ts.
|
|
850
|
+
const port = (await waitForProxy(ENSURE_READY_TIMEOUT_MS, ensureKeepWaiting(spawnedAt, () => childExited)))?.port;
|
|
852
851
|
if (!port) {
|
|
853
852
|
console.error("❌ Proxy did not become healthy after starting.");
|
|
854
853
|
process.exitCode = 1;
|
package/src/cli/status.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../cod
|
|
|
7
7
|
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
8
8
|
import type { OcxConfig } from "../types";
|
|
9
9
|
import { diagnoseService, serviceLogPath } from "../service";
|
|
10
|
-
import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
|
|
10
|
+
import { collectStartupHealth, startupHealthReadBudgetMs, type StartupHealth } from "../codex/autostart-health";
|
|
11
11
|
import { getCodexRoutingKind } from "../codex/inject";
|
|
12
12
|
import { missingOwnedCatalogPath } from "../codex/inject/config-toml";
|
|
13
13
|
import { CODEX_CONFIG_PATH } from "../codex/paths";
|
|
@@ -251,7 +251,7 @@ export async function fetchLiveStartupHealth(
|
|
|
251
251
|
deps: Parameters<typeof fetchBoundLocalManagementRead>[2] = {},
|
|
252
252
|
): Promise<StartupHealth | null> {
|
|
253
253
|
const result = await fetchBoundLocalManagementRead(
|
|
254
|
-
live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs:
|
|
254
|
+
live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: startupHealthReadBudgetMs(), ...deps, requireResponseProof: true },
|
|
255
255
|
);
|
|
256
256
|
if (result.kind !== "response" || !result.response.ok) return null;
|
|
257
257
|
let payload: unknown;
|
|
@@ -18,8 +18,11 @@ function parseMarker(raw: string): UpdateRestartChildMarker {
|
|
|
18
18
|
|| value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
|
|
19
19
|
|| !Number.isSafeInteger(value.home.revision)) throw new Error("update_restart_child_marker_invalid");
|
|
20
20
|
for (const directory of [value.home.config, value.home.codex]) {
|
|
21
|
+
// Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above
|
|
22
|
+
// 2^53. Parent and child read them through the same stat API, so any finite integer is a
|
|
23
|
+
// faithful identity token; assertUpdateRestartHome still compares path, dev and ino exactly.
|
|
21
24
|
if (!directory || typeof directory.path !== "string" || !directory.path
|
|
22
|
-
|| !Number.
|
|
25
|
+
|| !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)) {
|
|
23
26
|
throw new Error("update_restart_child_marker_invalid");
|
|
24
27
|
}
|
|
25
28
|
}
|
|
@@ -3,6 +3,7 @@ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
|
|
|
3
3
|
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
4
4
|
import {
|
|
5
5
|
CODEX_EXHAUSTED_USAGE_PERCENT,
|
|
6
|
+
TERMINAL_SHORT_WINDOW_FRESHNESS_MS,
|
|
6
7
|
isTerminalShortWindow,
|
|
7
8
|
hasSpendableCodexCredits,
|
|
8
9
|
resetAtToMs,
|
|
@@ -73,8 +74,13 @@ export function codexUsageLimitResetAt(quota: StoredAccountQuota | null, plan: u
|
|
|
73
74
|
if (!quota) return undefined;
|
|
74
75
|
const full: number[] = [];
|
|
75
76
|
if (isTerminalShortWindow(quota, now)) {
|
|
76
|
-
// A reset-less burst reading
|
|
77
|
-
|
|
77
|
+
// A reset-less burst reading holds until its observation goes stale: the freshness horizon
|
|
78
|
+
// is the deadline a retried request still meets, so report it rather than `now` (which
|
|
79
|
+
// collapsed the refusal's Retry-After to 1s against an account blocked for minutes).
|
|
80
|
+
// `isTerminalShortWindow` guarantees `shortObservedAt` is finite on this branch.
|
|
81
|
+
full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0
|
|
82
|
+
? resetAtToMs(quota.shortResetAt)
|
|
83
|
+
: (quota.shortObservedAt ?? now) + TERMINAL_SHORT_WINDOW_FRESHNESS_MS);
|
|
78
84
|
}
|
|
79
85
|
const longWindows: Array<[number | undefined, number | undefined]> = isThirtyDayOnlyCodexPlan(plan)
|
|
80
86
|
? [[quota.monthlyPercent, quota.monthlyResetAt]]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
2
|
+
import type { OcxConfig } from "../../types";
|
|
3
|
+
import { inspectChatGptDomainClaim, extractEmail } from "../../oauth/chatgpt";
|
|
4
|
+
import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../account-id";
|
|
5
|
+
import { readCodexTokensResult } from "../auth-collision";
|
|
6
|
+
import type { CodexTokenReadResult } from "../auth-collision";
|
|
7
|
+
import { getCodexAccountCredential } from "../account-store";
|
|
8
|
+
import { resolveCodexHomeDir } from "../home";
|
|
9
|
+
import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
|
|
10
|
+
import { withNativeMainSharedClaim } from "../native-main-claim";
|
|
11
|
+
import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store";
|
|
12
|
+
import { NativeProfileError } from "../native-profile-types";
|
|
13
|
+
import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
|
|
14
|
+
|
|
15
|
+
type AccountIdentity = { accountId: string; email: string };
|
|
16
|
+
type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
|
|
17
|
+
|
|
18
|
+
/** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
|
|
19
|
+
function readMainAuth(authPath: string): MainAuth {
|
|
20
|
+
const main = readCodexTokensResult(authPath, { bounded: true });
|
|
21
|
+
if (main.status !== "invalid") return main;
|
|
22
|
+
try {
|
|
23
|
+
const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
|
|
24
|
+
if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
|
|
25
|
+
const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
|
|
26
|
+
const apiKeyOnly = envelope.tokens == null
|
|
27
|
+
&& (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
|
|
28
|
+
&& typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
|
|
29
|
+
return apiKeyOnly ? { status: "api-key-only" } : main;
|
|
30
|
+
} catch {
|
|
31
|
+
return main;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function identity(accountId: unknown, email: unknown): AccountIdentity | undefined {
|
|
36
|
+
const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : undefined;
|
|
37
|
+
return typeof accountId === "string" && accountId.trim() && normalizedEmail
|
|
38
|
+
? { accountId: accountId.trim(), email: normalizedEmail }
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolve only existing entries, under native-main ownership, never on the request path. */
|
|
43
|
+
function linkedAccountIds(config: OcxConfig, selectedId: string, main: MainAuth): string[] | undefined {
|
|
44
|
+
// Unknown main identity must not produce a successful but incomplete pause.
|
|
45
|
+
if (main.status === "unreadable" || main.status === "invalid") return undefined;
|
|
46
|
+
const identities = new Map<string, AccountIdentity | undefined>();
|
|
47
|
+
if (main.status === "ok") {
|
|
48
|
+
const tokens = main.tokens;
|
|
49
|
+
if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string"
|
|
50
|
+
|| (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return undefined;
|
|
51
|
+
const claims = [tokens.id_token, tokens.access_token]
|
|
52
|
+
.filter((token): token is string => typeof token === "string")
|
|
53
|
+
.map(inspectChatGptDomainClaim);
|
|
54
|
+
if (claims.some(claim => claim.kind === "invalid")) return undefined;
|
|
55
|
+
const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []);
|
|
56
|
+
// organizations[] describes memberships, not the selected workspace. Never use its first row.
|
|
57
|
+
const accountId = tokens.account_id.trim() || claimIds[0];
|
|
58
|
+
if (claimIds.some(claim => claim !== accountId)) return undefined;
|
|
59
|
+
// Both tokens must name the same member; otherwise the bearer's identity is unknown.
|
|
60
|
+
const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)]
|
|
61
|
+
.flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : []));
|
|
62
|
+
if (emails.size > 1) return undefined;
|
|
63
|
+
identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, [...emails][0]));
|
|
64
|
+
}
|
|
65
|
+
for (const account of config.codexAccounts ?? []) {
|
|
66
|
+
if (!isSelectableCodexPoolAccount(account)) continue;
|
|
67
|
+
const credential = getCodexAccountCredential(account.id);
|
|
68
|
+
identities.set(account.id, identity(credential?.chatgptAccountId, account.email));
|
|
69
|
+
}
|
|
70
|
+
const selected = identities.get(selectedId);
|
|
71
|
+
// Missing identity evidence never links unrelated logins, including members of one workspace.
|
|
72
|
+
if (!selected) return [selectedId];
|
|
73
|
+
return [selectedId, ...[...identities].flatMap(([id, candidate]) => (
|
|
74
|
+
id !== selectedId && candidate?.accountId === selected.accountId && candidate.email === selected.email
|
|
75
|
+
? [id] : []
|
|
76
|
+
))];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Hold admission throughout; claim the physical main whenever its home exists. */
|
|
80
|
+
export async function withCodexAccountPauseGroup(
|
|
81
|
+
config: OcxConfig,
|
|
82
|
+
selectedId: string,
|
|
83
|
+
publish: (accountIds: string[]) => Response,
|
|
84
|
+
): Promise<Response> {
|
|
85
|
+
const lease = tryAcquireNativeMainProfileClaim();
|
|
86
|
+
if (!lease) return nativeMainProfileBusyResponse();
|
|
87
|
+
try {
|
|
88
|
+
const codexHome = resolveCodexHomeDir();
|
|
89
|
+
try {
|
|
90
|
+
lstatSync(codexHome);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Only positive absence permits Pool-only publication. A dangling symlink or access
|
|
93
|
+
// failure is not absence; do not create a home just to coordinate an unused main login.
|
|
94
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") return nativeMainProfileBusyResponse();
|
|
95
|
+
const ids = linkedAccountIds(config, selectedId, { status: "missing" });
|
|
96
|
+
return ids ? publish(ids) : nativeMainProfileBusyResponse();
|
|
97
|
+
}
|
|
98
|
+
const context = resolveNativeProfileContext({ codexHome });
|
|
99
|
+
return await withNativeMainSharedClaim(context, async () => {
|
|
100
|
+
const main = readMainAuth(context.authPath);
|
|
101
|
+
const ids = linkedAccountIds(config, selectedId, main);
|
|
102
|
+
return ids ? publish(ids) : nativeMainProfileBusyResponse();
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (isNativeMainClaimUnavailable(error)
|
|
106
|
+
|| (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
|
|
107
|
+
return nativeMainProfileBusyResponse();
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
lease.release();
|
|
112
|
+
}
|
|
113
|
+
}
|