@bitkyc08/opencodex 2.84.0 → 2.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -4
- package/bin/ocx.mjs +61 -3
- package/gui/dist/assets/App-BjlttVLy.js +58 -0
- package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
- package/gui/dist/assets/index-BMssP4k3.js +94 -0
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +87 -8
- package/src/adapters/anthropic/client-identity.ts +1 -1
- package/src/adapters/anthropic/passthrough.ts +33 -4
- package/src/adapters/azure.ts +19 -1
- package/src/adapters/client-fingerprint.ts +24 -0
- package/src/adapters/coding-agent/turn.ts +22 -9
- package/src/adapters/cursor/arg-codec.ts +17 -5
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/freeform-args.ts +75 -0
- package/src/adapters/cursor/protobuf-events.ts +36 -23
- package/src/adapters/google-antigravity-wire.ts +22 -1
- package/src/adapters/google.ts +2 -2
- package/src/adapters/openai-chat/response-events.ts +59 -1
- package/src/adapters/openai-chat.ts +8 -5
- package/src/adapters/qoder/adapter.ts +40 -1
- package/src/advisor/consult.ts +220 -0
- package/src/advisor/context.ts +226 -0
- package/src/advisor/disclosure.ts +20 -0
- package/src/advisor/runtime.ts +305 -0
- package/src/advisor/settings.ts +135 -0
- package/src/advisor/state.ts +369 -0
- package/src/advisor/synthetic-tool.ts +33 -0
- package/src/bridge/errors.ts +5 -0
- package/src/chat/inbound.ts +30 -6
- package/src/chat/outbound.ts +5 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +22 -8
- package/src/claude/desktop-picker-profile.ts +11 -4
- package/src/claude/desktop-profile.ts +10 -1
- package/src/claude/desktop-remote-store-io.ts +5 -3
- package/src/claude/inbound.ts +121 -50
- package/src/claude/intercept/local-ca.ts +8 -1
- package/src/claude/model-default-settings.ts +154 -0
- package/src/claude/model-info.ts +7 -7
- package/src/claude/outbound.ts +13 -6
- package/src/cli/advisor.ts +151 -0
- package/src/cli/auxiliary-listener-preflight.ts +37 -0
- package/src/cli/capabilities-agents-routing.ts +18 -0
- package/src/cli/capabilities-base.ts +1 -1
- package/src/cli/chatgpt-command.ts +35 -14
- package/src/cli/claude.ts +46 -6
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +9 -0
- package/src/cli/ensure-desktop-cold-start.ts +329 -0
- package/src/cli/export-command.ts +1 -1
- package/src/cli/help.ts +2 -1
- package/src/cli/index.ts +13 -4
- package/src/cli/integration-plan-dto.ts +2 -0
- package/src/cli/registry.ts +15 -3
- package/src/cli/restart-scope.ts +3 -1
- package/src/clients/config-export/contracts.ts +1 -0
- package/src/clients/config-export/model-metadata.ts +3 -2
- package/src/clients/config-export/qoder.ts +93 -0
- package/src/clients/config-export.ts +17 -4
- package/src/codex/auth-api/pool-mode-gate.ts +5 -2
- package/src/codex/auth-context.ts +79 -5
- package/src/codex/autostart-health.ts +4 -0
- package/src/codex/catalog/build-entries.ts +11 -2
- package/src/codex/catalog/configured-tiers.ts +50 -0
- package/src/codex/catalog/effort.ts +4 -1
- package/src/codex/catalog/model-hints.ts +21 -3
- package/src/codex/catalog/native-ultrafast.ts +106 -0
- package/src/codex/catalog/parsing.ts +5 -0
- package/src/codex/catalog/provider-models.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +2 -0
- package/src/codex/catalog/routed-gather.ts +22 -4
- package/src/codex/catalog/vendor-context.ts +82 -0
- package/src/codex/config-write-lock.ts +40 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desktop-app/types.ts +2 -0
- package/src/codex/desktop-app/windows.ts +29 -16
- package/src/codex/desktop-app-restart.ts +4 -2
- package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
- package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
- package/src/codex/desktop-compatibility/connection-store.ts +87 -0
- package/src/codex/desktop-compatibility/installed-build.ts +55 -0
- package/src/codex/desktop-compatibility/json-body.ts +29 -0
- package/src/codex/desktop-compatibility/native-identity.ts +62 -0
- package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
- package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
- package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
- package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
- package/src/codex/desktop-compatibility/runtime.ts +173 -0
- package/src/codex/desktop-compatibility/service.ts +30 -0
- package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
- package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
- package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
- package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
- package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
- package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
- package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
- package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
- package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
- package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
- package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
- package/src/codex/history-provider.ts +1 -1
- package/src/codex/inject/config-write-section.ts +4 -1
- package/src/codex/inject/remove.ts +18 -6
- package/src/codex/journal.ts +16 -1
- package/src/codex/main-account.ts +99 -15
- package/src/codex/native-main-refresh-mode.ts +217 -0
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/pool-recovery.ts +79 -0
- package/src/codex/shim-diagnostics.ts +6 -0
- package/src/codex/shim-overlay.ts +7 -2
- package/src/config/diagnostics.ts +2 -1
- package/src/config/live-reconcile.ts +13 -0
- package/src/config/load-degrade.ts +6 -1
- package/src/config/paths.ts +10 -3
- package/src/config/schema/config-schema.ts +17 -0
- package/src/config/schema/desktop-compatibility.ts +9 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +451 -163
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +20 -2
- package/src/integrations/journal.ts +15 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/store.ts +3 -0
- package/src/integrations/writer.ts +10 -1
- package/src/lib/advisor-activation.ts +16 -0
- package/src/lib/background-work.ts +65 -0
- package/src/lib/bounded-file-read.ts +58 -0
- package/src/lib/bun-path-runtime.d.mts +10 -1
- package/src/lib/bun-path-runtime.mjs +62 -3
- package/src/lib/desktop-proxy-route.ts +30 -0
- package/src/lib/desktop-upstream-tunnel.ts +107 -0
- package/src/lib/errors.ts +7 -0
- package/src/lib/local-internal-call-capability.ts +55 -0
- package/src/lib/package-tree-integrity.ts +2 -2
- package/src/lib/socks5-fetch.ts +48 -71
- package/src/lib/socks5-handshake.ts +112 -0
- package/src/lib/translator-budget.ts +7 -1
- package/src/oauth/generic-account-failover.ts +29 -5
- package/src/oauth/index.ts +4 -2
- package/src/oauth/kiro-device-login.ts +2 -1
- package/src/oauth/kiro.ts +7 -1
- package/src/protocols/codecs/chat-ir.ts +467 -0
- package/src/protocols/codecs/chat.ts +10 -1
- package/src/protocols/codecs/messages-ir.ts +500 -0
- package/src/protocols/features.ts +10 -2
- package/src/protocols/path.ts +11 -1
- package/src/protocols/plan-snapshot.ts +39 -16
- package/src/protocols/plan.ts +9 -1
- package/src/protocols/settings.ts +8 -0
- package/src/providers/antigravity-account-roster.ts +102 -0
- package/src/providers/antigravity-models.ts +85 -7
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/model-rename-migration.ts +5 -3
- package/src/providers/registry/entries-core.ts +7 -6
- package/src/providers/registry/entries-extended.ts +19 -0
- package/src/responses/parser.ts +30 -19
- package/src/responses/state.ts +32 -29
- package/src/routing/history/build-file.ts +92 -0
- package/src/routing/history/indexer.ts +248 -216
- package/src/routing/history/ingest.ts +238 -0
- package/src/routing/history/rebuild-job.ts +111 -0
- package/src/routing/history/rebuild-protocol.ts +15 -0
- package/src/routing/history/rebuild-worker.ts +34 -0
- package/src/server/admission-model-scope.ts +4 -1
- package/src/server/antigravity-direct-eligibility.ts +208 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/chat-completions.ts +75 -9
- package/src/server/claude-messages.ts +110 -20
- package/src/server/index/desktop-compatibility-startup.ts +56 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index/startup-warnings.ts +9 -0
- package/src/server/index/websocket-handler.ts +9 -0
- package/src/server/index.ts +13 -10
- package/src/server/inference/client-encoder-delivery.ts +6 -5
- package/src/server/login-shell-path.ts +146 -0
- package/src/server/management/advisor-routes.ts +191 -0
- package/src/server/management/agent-settings-routes.ts +22 -4
- package/src/server/management/companion-routes.ts +4 -0
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/desktop-compatibility-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
- package/src/server/management/model-routes.ts +2 -2
- package/src/server/management/protocol-settings-patch.ts +1 -0
- package/src/server/management/route-registry.ts +11 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management-api.ts +6 -0
- package/src/server/messages-antigravity-direct.ts +195 -0
- package/src/server/messages-native.ts +5 -1
- package/src/server/request-log.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +35 -18
- package/src/server/responses/adapter-delivery.ts +24 -2
- package/src/server/responses/adapter-dispatch.ts +29 -2
- package/src/server/responses/advisor-plan-slot.ts +27 -0
- package/src/server/responses/advisor-slot.ts +336 -0
- package/src/server/responses/code-mode-collab-surface.ts +116 -8
- package/src/server/responses/core-combo-failure.ts +10 -1
- package/src/server/responses/core-combo.ts +21 -0
- package/src/server/responses/core-lifetime.ts +2 -0
- package/src/server/responses/core-normalize.ts +11 -0
- package/src/server/responses/core-options.ts +46 -0
- package/src/server/responses/core-replay.ts +7 -4
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +16 -10
- package/src/server/responses/passthrough-dispatch.ts +14 -3
- package/src/server/responses/request-prepare.ts +68 -5
- package/src/server/responses/request-transport.ts +16 -0
- package/src/server/responses/run-turn-execution.ts +70 -8
- package/src/server/responses/sidecar-execution.ts +78 -3
- package/src/server/responses/terminal-guard.ts +2 -1
- package/src/server/responses-undeclared-tool-guard.ts +30 -9
- package/src/server/startup-action-control.ts +16 -2
- package/src/server/startup-health-cache.ts +1 -0
- package/src/service/desktop-startup.ts +14 -0
- package/src/service/health-sentinel-main.ts +23 -4
- package/src/service/health-sentinel-probe.ts +24 -7
- package/src/service/logging.ts +72 -0
- package/src/tray/windows-tray.ps1 +43 -24
- package/src/types/config.ts +63 -6
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +15 -0
- package/src/types/tools.ts +2 -0
- package/src/update/async-check.ts +1 -1
- package/src/update/badge.ts +2 -2
- package/src/update/index.ts +88 -15
- package/src/update/job-supervised-restart.ts +107 -0
- package/src/update/job.ts +32 -32
- package/src/update/notify.ts +1 -1
- package/src/update/refresh-scheduler.ts +2 -2
- package/src/update/supervised-recovery.d.mts +27 -0
- package/src/update/supervised-recovery.mjs +157 -0
- package/src/usage/cache-diagnostic.ts +2 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/gemini-executor.ts +2 -2
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/App-CZtqiGcw.js +0 -56
- package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
- package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
|
@@ -31,6 +31,12 @@ import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch
|
|
|
31
31
|
import { withNativeMainExclusiveClaim } from "./native-main-claim";
|
|
32
32
|
import { resolveNativeProfileContext } from "./native-profile-store";
|
|
33
33
|
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
|
|
34
|
+
import { loadConfig } from "../config";
|
|
35
|
+
import {
|
|
36
|
+
requestCodexAuthStatus,
|
|
37
|
+
resolveNativeMainRefreshMode,
|
|
38
|
+
type CodexAuthStatus,
|
|
39
|
+
} from "./native-main-refresh-mode";
|
|
34
40
|
|
|
35
41
|
export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
|
|
36
42
|
|
|
@@ -48,13 +54,11 @@ type MainAuthJsonCredential = {
|
|
|
48
54
|
path: string;
|
|
49
55
|
rawSha256: string;
|
|
50
56
|
/**
|
|
51
|
-
* Filesystem identity of the file the hash was taken from
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* hash
|
|
55
|
-
*
|
|
56
|
-
* still the same file, not merely one with the same bytes. `null` when the target could
|
|
57
|
-
* not be stat'ed, which is treated as "cannot prove identity" rather than "matches".
|
|
57
|
+
* Filesystem identity of the file the hash was taken from. It catches a writer that
|
|
58
|
+
* replaces auth.json by rename with identical bytes. It does not see Codex's own writes:
|
|
59
|
+
* Codex rewrites auth.json in place (truncate + write), which keeps dev and ino, so only
|
|
60
|
+
* the content hash notices those, and only once the new bytes are visible (#2999).
|
|
61
|
+
* `null` when the target could not be stat'ed, which fails closed.
|
|
58
62
|
*/
|
|
59
63
|
identity: { dev: number; ino: number } | null;
|
|
60
64
|
root: Record<string, unknown>;
|
|
@@ -66,6 +70,8 @@ type MainAuthJsonCredential = {
|
|
|
66
70
|
|
|
67
71
|
export interface NativeMainRefreshDependencies {
|
|
68
72
|
refreshToken?: (refreshToken: string, options: { signal: AbortSignal }) => Promise<OAuthCredentials>;
|
|
73
|
+
/** Codex-mode seam: one app-server `getAuthStatus` call. Used only when Codex owns the refresh. */
|
|
74
|
+
codexAuthStatus?: (options: { refresh: boolean; signal: AbortSignal }) => Promise<CodexAuthStatus>;
|
|
69
75
|
signal?: AbortSignal;
|
|
70
76
|
/** Metadata-only refresh must not retract a concurrent traffic reauth quarantine. */
|
|
71
77
|
preserveReauth?: boolean;
|
|
@@ -264,10 +270,9 @@ function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential):
|
|
|
264
270
|
if (!current || current.path !== expected.path || current.rawSha256 !== expected.rawSha256) {
|
|
265
271
|
throw new MainAuthJsonChangedDuringRefreshError();
|
|
266
272
|
}
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
// unreadable identity on either side fails closed: unprovable is not the same as equal.
|
|
273
|
+
// Also refuse a rename-based replacement with identical bytes. An unreadable identity on
|
|
274
|
+
// either side fails closed. This narrows the window; rename(2) still replaces
|
|
275
|
+
// unconditionally after this check returns.
|
|
271
276
|
assertMainAuthJsonIdentityUnchanged(expected);
|
|
272
277
|
}
|
|
273
278
|
|
|
@@ -288,9 +293,7 @@ function persistRefreshedMainAuthJson(
|
|
|
288
293
|
assertNotRealCodexHomeUnderTest(resolveCodexHomeDir());
|
|
289
294
|
const accessToken = refreshed.access;
|
|
290
295
|
const refreshToken = refreshed.refresh || expected.refreshToken!;
|
|
291
|
-
const chatgptAccountId = refreshed
|
|
292
|
-
?? extractAccountId(undefined, accessToken)
|
|
293
|
-
?? expected.chatgptAccountId;
|
|
296
|
+
const { chatgptAccountId } = inFlightMainAccountToken(expected, refreshed);
|
|
294
297
|
const tokens = {
|
|
295
298
|
...expected.tokens,
|
|
296
299
|
access_token: accessToken,
|
|
@@ -475,6 +478,12 @@ async function resolveMainAccountToken(
|
|
|
475
478
|
const signal = dependencies.signal
|
|
476
479
|
? AbortSignal.any([dependencies.signal, refreshTimeout])
|
|
477
480
|
: refreshTimeout;
|
|
481
|
+
// An injected exchange is the guarded writer by definition. Otherwise Codex refreshes its own
|
|
482
|
+
// login whenever a supported binary exists, unless the operator chose "opencodex" (#2999).
|
|
483
|
+
const refreshMode = dependencies.refreshToken
|
|
484
|
+
? null
|
|
485
|
+
: await resolveNativeMainRefreshMode(loadConfig());
|
|
486
|
+
if (signal.aborted) throw signal.reason;
|
|
478
487
|
const lockKey = refreshGrantFingerprintForToken(initial.refreshToken);
|
|
479
488
|
// Two locks, because they guard two different things that live in two different
|
|
480
489
|
// homes. `withCodexRefreshFileLock` is keyed on the grant fingerprint and lives
|
|
@@ -508,6 +517,11 @@ async function resolveMainAccountToken(
|
|
|
508
517
|
&& mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
|
|
509
518
|
return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
|
|
510
519
|
}
|
|
520
|
+
if (refreshMode?.mode === "codex") {
|
|
521
|
+
const command = refreshMode.command!;
|
|
522
|
+
return refreshThroughCodex(locked, rejectedAccessToken, dependencies, refreshTimeout,
|
|
523
|
+
dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(command, options)));
|
|
524
|
+
}
|
|
511
525
|
const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
|
|
512
526
|
let refreshed: OAuthCredentials;
|
|
513
527
|
if (signal.aborted) throw signal.reason;
|
|
@@ -539,7 +553,19 @@ async function resolveMainAccountToken(
|
|
|
539
553
|
// Once the exchange starts, only its own timeout may abort it: a successful
|
|
540
554
|
// rotating grant retires the old refresh token. Save the only live grant before
|
|
541
555
|
// honoring caller cancellation, while retaining the external-writer guards.
|
|
542
|
-
|
|
556
|
+
let result: { accessToken: string; chatgptAccountId: string };
|
|
557
|
+
try {
|
|
558
|
+
result = persistRefreshedMainAuthJson(locked, refreshed);
|
|
559
|
+
} catch (cause) {
|
|
560
|
+
if (!(cause instanceof MainAuthJsonChangedDuringRefreshError)) throw cause;
|
|
561
|
+
// Another writer owns auth.json now (#2999). The exchange already succeeded, so
|
|
562
|
+
// failing the request would waste it. Serve the new access token to this request
|
|
563
|
+
// only: leave the file as the other writer left it, keep nothing as the main
|
|
564
|
+
// credential, and do not retire the grant.
|
|
565
|
+
noteNativeMainRefresh("publish-refused-served-in-flight");
|
|
566
|
+
if (dependencies.signal?.aborted) throw dependencies.signal.reason;
|
|
567
|
+
return inFlightMainAccountToken(locked, refreshed);
|
|
568
|
+
}
|
|
543
569
|
// A permitted in-flight success speaks only for its own profile and grant.
|
|
544
570
|
clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
|
|
545
571
|
if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
@@ -556,6 +582,64 @@ async function resolveMainAccountToken(
|
|
|
556
582
|
}
|
|
557
583
|
}
|
|
558
584
|
|
|
585
|
+
/** The refreshed bearer for one request whose save was refused; never stored anywhere. */
|
|
586
|
+
function inFlightMainAccountToken(
|
|
587
|
+
expected: MainAuthJsonCredential,
|
|
588
|
+
refreshed: OAuthCredentials,
|
|
589
|
+
): { accessToken: string; chatgptAccountId: string } {
|
|
590
|
+
return {
|
|
591
|
+
accessToken: refreshed.access,
|
|
592
|
+
chatgptAccountId: refreshed.accountId
|
|
593
|
+
?? extractAccountId(undefined, refreshed.access)
|
|
594
|
+
?? expected.chatgptAccountId,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Codex mode (#2999): Codex refreshes and stores its own login; OpenCodex only uses the bearer.
|
|
600
|
+
*
|
|
601
|
+
* auth.json is not read again after the call. In keyring mode Codex deletes the file once the
|
|
602
|
+
* keyring save succeeds, so the RPC answer is the only place the new token exists for us.
|
|
603
|
+
* The call runs on the refresh timeout alone: killing app-server mid-exchange could retire
|
|
604
|
+
* the grant before Codex saves its successor.
|
|
605
|
+
*/
|
|
606
|
+
async function refreshThroughCodex(
|
|
607
|
+
locked: MainAuthJsonCredential,
|
|
608
|
+
rejectedAccessToken: string | undefined,
|
|
609
|
+
dependencies: NativeMainRefreshDependencies,
|
|
610
|
+
refreshTimeout: AbortSignal,
|
|
611
|
+
authStatus: NonNullable<NativeMainRefreshDependencies["codexAuthStatus"]>,
|
|
612
|
+
): Promise<{ accessToken: string; chatgptAccountId: string }> {
|
|
613
|
+
let status: CodexAuthStatus;
|
|
614
|
+
try {
|
|
615
|
+
status = await authStatus({ refresh: true, signal: refreshTimeout });
|
|
616
|
+
} catch (cause) {
|
|
617
|
+
noteNativeMainRefresh("codex-unavailable");
|
|
618
|
+
throw new MainAccountTokenRefreshError("transient", { cause });
|
|
619
|
+
}
|
|
620
|
+
if (status.authMethod === "chatgpt" && !status.authToken) {
|
|
621
|
+
// The only permanent-failure signal the response carries: Codex withholds the token once
|
|
622
|
+
// its refresh was refused for good. The specific OAuth code is not on the wire.
|
|
623
|
+
noteNativeMainRefresh("codex-reauth");
|
|
624
|
+
markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
|
|
625
|
+
throw new MainAccountTokenRefreshError("reauth");
|
|
626
|
+
}
|
|
627
|
+
const token = status.authToken;
|
|
628
|
+
if (status.authMethod !== "chatgpt" || !token) {
|
|
629
|
+
noteNativeMainRefresh("codex-no-chatgpt-login");
|
|
630
|
+
throw new MainAccountTokenRefreshError("transient");
|
|
631
|
+
}
|
|
632
|
+
// A transient Codex refresh failure still answers with its cached bearer.
|
|
633
|
+
if (token === rejectedAccessToken || !mainAccessTokenFresh(token, Date.now(), 0)) {
|
|
634
|
+
noteNativeMainRefresh("codex-transient");
|
|
635
|
+
throw new MainAccountTokenRefreshError("transient");
|
|
636
|
+
}
|
|
637
|
+
noteNativeMainRefresh("codex-ok");
|
|
638
|
+
if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
639
|
+
if (dependencies.signal?.aborted) throw dependencies.signal.reason;
|
|
640
|
+
return { accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked.chatgptAccountId };
|
|
641
|
+
}
|
|
642
|
+
|
|
559
643
|
/** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */
|
|
560
644
|
export function getValidMainAccountToken(
|
|
561
645
|
dependencies: NativeMainRefreshDependencies = {},
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who refreshes the native main ChatGPT credential (#2999).
|
|
3
|
+
*
|
|
4
|
+
* Codex owns `CODEX_HOME/auth.json`. It rewrites the file in place and serializes its own
|
|
5
|
+
* refreshes only inside one process, so any second writer can race it, and a lost race
|
|
6
|
+
* retires the grant. In "codex" mode OpenCodex never writes the file: it asks the Codex
|
|
7
|
+
* binary to refresh through `app-server` `getAuthStatus` and uses the bearer that call
|
|
8
|
+
* returns. In "opencodex" mode the guarded writer in `main-account.ts` refreshes as before.
|
|
9
|
+
*
|
|
10
|
+
* The default is "codex" only when a version-checked binary is available. Without one, or
|
|
11
|
+
* when the operator sets `nativeMainRefresh: "opencodex"`, the guarded writer stays.
|
|
12
|
+
*/
|
|
13
|
+
import { codexExecInvocation } from "./exec-invocation";
|
|
14
|
+
import { resolveCodexHomeDir } from "./home";
|
|
15
|
+
import { compareCodexVersions, displayCodexRuntimePath, resolveCodexRuntimeAsync } from "./runtime";
|
|
16
|
+
import type { ResolveCodexRuntimeResult } from "./runtime";
|
|
17
|
+
import { isTestHomeGuardArmed } from "../lib/test-home-guard";
|
|
18
|
+
import type { OcxConfig } from "../types/config";
|
|
19
|
+
|
|
20
|
+
export type NativeMainRefreshMode = "codex" | "opencodex";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Supported floor for app-server `getAuthStatus` with `includeToken`/`refreshToken` that
|
|
24
|
+
* withholds the token after a permanent refresh failure, the only reauth signal on the wire.
|
|
25
|
+
* Releases from about 0.118 already withhold it; 0.130.0 is the first with the handler in
|
|
26
|
+
* `request_processors/account_processor.rs` and is chosen conservatively.
|
|
27
|
+
*/
|
|
28
|
+
export const CODEX_AUTH_STATUS_MIN_VERSION = "0.130.0";
|
|
29
|
+
|
|
30
|
+
export type NativeMainRefreshReason =
|
|
31
|
+
| "codex-compatible"
|
|
32
|
+
| "configured-opencodex"
|
|
33
|
+
| "codex-missing"
|
|
34
|
+
| "codex-too-old"
|
|
35
|
+
| "test-guard";
|
|
36
|
+
|
|
37
|
+
export interface NativeMainRefreshResolution {
|
|
38
|
+
readonly mode: NativeMainRefreshMode;
|
|
39
|
+
readonly configured: NativeMainRefreshMode | "auto";
|
|
40
|
+
readonly reason: NativeMainRefreshReason;
|
|
41
|
+
readonly command?: string;
|
|
42
|
+
readonly version?: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CodexAuthStatus {
|
|
46
|
+
readonly authMethod: string | null;
|
|
47
|
+
readonly authToken: string | null;
|
|
48
|
+
readonly requiresOpenaiAuth: boolean | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class CodexAuthStatusError extends Error {
|
|
52
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
53
|
+
super(message, options);
|
|
54
|
+
this.name = "CodexAuthStatusError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let resolutionForTests: NativeMainRefreshResolution | null = null;
|
|
59
|
+
|
|
60
|
+
/** Test seam: pin the resolution so no test spawns the developer's real Codex binary. */
|
|
61
|
+
export function setNativeMainRefreshResolutionForTests(value: NativeMainRefreshResolution | null): void {
|
|
62
|
+
resolutionForTests = value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function configuredMode(config: Pick<OcxConfig, "nativeMainRefresh"> | null | undefined): NativeMainRefreshMode | "auto" {
|
|
66
|
+
const value = config?.nativeMainRefresh;
|
|
67
|
+
return value === "codex" || value === "opencodex" ? value : "auto";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function resolveNativeMainRefreshMode(
|
|
71
|
+
config: Pick<OcxConfig, "nativeMainRefresh"> | null | undefined,
|
|
72
|
+
deps: { resolveRuntime?: () => Promise<ResolveCodexRuntimeResult> } = {},
|
|
73
|
+
): Promise<NativeMainRefreshResolution> {
|
|
74
|
+
if (resolutionForTests) return resolutionForTests;
|
|
75
|
+
const configured = configuredMode(config);
|
|
76
|
+
if (configured === "opencodex") return { mode: "opencodex", configured, reason: "configured-opencodex" };
|
|
77
|
+
// Tests run against throwaway CODEX_HOMEs; delegating would spawn the real binary.
|
|
78
|
+
if (!deps.resolveRuntime && isTestHomeGuardArmed()) return { mode: "opencodex", configured, reason: "test-guard" };
|
|
79
|
+
const resolved = await (deps.resolveRuntime ?? (() => resolveCodexRuntimeAsync({ discoverAlternatives: false })))();
|
|
80
|
+
const { command, version, source } = resolved.runtime;
|
|
81
|
+
if (source === "fallback" && version === null) {
|
|
82
|
+
return { mode: "opencodex", configured, reason: "codex-missing" };
|
|
83
|
+
}
|
|
84
|
+
if (version === null || compareCodexVersions(version, CODEX_AUTH_STATUS_MIN_VERSION) < 0) {
|
|
85
|
+
return { mode: "opencodex", configured, reason: "codex-too-old", command, version };
|
|
86
|
+
}
|
|
87
|
+
return { mode: "codex", configured, reason: "codex-compatible", command, version };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One `getAuthStatus` round trip against a short-lived `codex app-server` on stdio.
|
|
92
|
+
*
|
|
93
|
+
* `model_provider="openai"` is forced because an authless Desktop injection sets
|
|
94
|
+
* `requires_openai_auth = false` on the OpenCodex provider, and then the server reports no
|
|
95
|
+
* credential at all. The process inherits CODEX_HOME so it reads the same login (file or
|
|
96
|
+
* keyring) Codex itself uses.
|
|
97
|
+
*/
|
|
98
|
+
export async function requestCodexAuthStatus(
|
|
99
|
+
command: string,
|
|
100
|
+
options: { refresh: boolean; signal: AbortSignal; codexHome?: string },
|
|
101
|
+
): Promise<CodexAuthStatus> {
|
|
102
|
+
if (options.signal.aborted) throw options.signal.reason;
|
|
103
|
+
const invocation = codexExecInvocation(command, ["-c", 'model_provider="openai"', "app-server"]);
|
|
104
|
+
let proc: ReturnType<typeof Bun.spawn<"pipe", "pipe", "ignore">>;
|
|
105
|
+
try {
|
|
106
|
+
proc = Bun.spawn([invocation.file, ...invocation.args], {
|
|
107
|
+
env: { ...process.env, CODEX_HOME: options.codexHome ?? resolveCodexHomeDir() },
|
|
108
|
+
stdin: "pipe",
|
|
109
|
+
stdout: "pipe",
|
|
110
|
+
stderr: "ignore",
|
|
111
|
+
windowsVerbatimArguments: invocation.options.windowsVerbatimArguments,
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
});
|
|
114
|
+
} catch (cause) {
|
|
115
|
+
throw new CodexAuthStatusError("could not start codex app-server", { cause });
|
|
116
|
+
}
|
|
117
|
+
// Abort settles the call by itself. Waiting for stdout to close would hang on a child that
|
|
118
|
+
// ignores SIGTERM or a launcher descendant that keeps the pipe open.
|
|
119
|
+
let onAbort = () => {};
|
|
120
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
121
|
+
onAbort = () => reject(options.signal.reason);
|
|
122
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
123
|
+
});
|
|
124
|
+
aborted.catch(() => { /* observed through the race below */ });
|
|
125
|
+
try {
|
|
126
|
+
const send = (message: unknown) => proc.stdin.write(JSON.stringify(message) + "\n");
|
|
127
|
+
send({ id: 1, method: "initialize", params: { clientInfo: { name: "opencodex", title: null, version: "0" } } });
|
|
128
|
+
send({ method: "initialized" });
|
|
129
|
+
send({ id: 2, method: "getAuthStatus", params: { includeToken: true, refreshToken: options.refresh } });
|
|
130
|
+
await Promise.race([proc.stdin.flush(), aborted]);
|
|
131
|
+
const response = await Promise.race([readResponse(proc.stdout, 2), aborted]);
|
|
132
|
+
if (options.signal.aborted) throw options.signal.reason;
|
|
133
|
+
return parseAuthStatus(response);
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
if (options.signal.aborted) throw options.signal.reason;
|
|
136
|
+
if (cause instanceof CodexAuthStatusError) throw cause;
|
|
137
|
+
throw new CodexAuthStatusError("codex app-server getAuthStatus failed", { cause });
|
|
138
|
+
} finally {
|
|
139
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
140
|
+
stopAppServer(proc);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const APP_SERVER_KILL_GRACE_MS = 2_000;
|
|
145
|
+
|
|
146
|
+
/** SIGTERM now, SIGKILL if the child is still alive after a grace period; never awaited. */
|
|
147
|
+
function stopAppServer(proc: { stdin: { end(): unknown }; kill(signal?: number | NodeJS.Signals): void; exited: Promise<number> }): void {
|
|
148
|
+
try { proc.stdin.end(); } catch { /* already closed */ }
|
|
149
|
+
let exited = false;
|
|
150
|
+
void proc.exited.then(() => { exited = true; }, () => { exited = true; });
|
|
151
|
+
try { proc.kill("SIGTERM"); } catch { /* already gone */ }
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
if (!exited) try { proc.kill("SIGKILL"); } catch { /* already gone */ }
|
|
154
|
+
}, APP_SERVER_KILL_GRACE_MS);
|
|
155
|
+
(timer as { unref?: () => void }).unref?.();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const MAX_APP_SERVER_LINE_BYTES = 1_048_576;
|
|
159
|
+
|
|
160
|
+
async function readResponse(stream: ReadableStream<Uint8Array>, id: number): Promise<Record<string, unknown>> {
|
|
161
|
+
const decoder = new TextDecoder();
|
|
162
|
+
let buffered = "";
|
|
163
|
+
for await (const chunk of stream) {
|
|
164
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
165
|
+
let newline: number;
|
|
166
|
+
while ((newline = buffered.indexOf("\n")) >= 0) {
|
|
167
|
+
const line = buffered.slice(0, newline).trim();
|
|
168
|
+
buffered = buffered.slice(newline + 1);
|
|
169
|
+
if (!line) continue;
|
|
170
|
+
let message: unknown;
|
|
171
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
172
|
+
if (!message || typeof message !== "object") continue;
|
|
173
|
+
const record = message as Record<string, unknown>;
|
|
174
|
+
if (record.id !== id) continue;
|
|
175
|
+
if (record.error !== undefined) throw new CodexAuthStatusError("codex app-server rejected getAuthStatus");
|
|
176
|
+
if (!record.result || typeof record.result !== "object") {
|
|
177
|
+
throw new CodexAuthStatusError("codex app-server returned no getAuthStatus result");
|
|
178
|
+
}
|
|
179
|
+
return record.result as Record<string, unknown>;
|
|
180
|
+
}
|
|
181
|
+
if (buffered.length > MAX_APP_SERVER_LINE_BYTES) throw new CodexAuthStatusError("codex app-server line too long");
|
|
182
|
+
}
|
|
183
|
+
throw new CodexAuthStatusError("codex app-server exited before answering getAuthStatus");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseAuthStatus(result: Record<string, unknown>): CodexAuthStatus {
|
|
187
|
+
const authMethod = typeof result.authMethod === "string" ? result.authMethod : null;
|
|
188
|
+
const authToken = typeof result.authToken === "string" && result.authToken ? result.authToken : null;
|
|
189
|
+
const requiresOpenaiAuth = typeof result.requiresOpenaiAuth === "boolean" ? result.requiresOpenaiAuth : null;
|
|
190
|
+
return { authMethod, authToken, requiresOpenaiAuth };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `ocx doctor` lines for the active refresh owner. Never includes a credential. */
|
|
194
|
+
export function formatNativeMainRefreshDoctorLines(resolution: NativeMainRefreshResolution): string[] {
|
|
195
|
+
const configured = resolution.configured === "auto" ? "auto" : `nativeMainRefresh: "${resolution.configured}"`;
|
|
196
|
+
const binary = resolution.command
|
|
197
|
+
? `${displayCodexRuntimePath(resolution.command)} (${resolution.version ?? "unknown"})`
|
|
198
|
+
: "none";
|
|
199
|
+
switch (resolution.reason) {
|
|
200
|
+
case "codex-compatible":
|
|
201
|
+
return [` ok codex: Codex refreshes the main login through app-server (${binary}; ${configured})`];
|
|
202
|
+
case "configured-opencodex":
|
|
203
|
+
return [` -- opencodex: OpenCodex refreshes auth.json with its guarded writer (${configured})`];
|
|
204
|
+
case "test-guard":
|
|
205
|
+
return [" -- opencodex: test guard is armed; Codex delegation is disabled"];
|
|
206
|
+
case "codex-missing":
|
|
207
|
+
return [
|
|
208
|
+
` !! opencodex: no Codex binary found, so OpenCodex refreshes auth.json itself (${configured})`,
|
|
209
|
+
` Install Codex ${CODEX_AUTH_STATUS_MIN_VERSION} or newer to let Codex own its login.`,
|
|
210
|
+
];
|
|
211
|
+
case "codex-too-old":
|
|
212
|
+
return [
|
|
213
|
+
` !! opencodex: Codex ${binary} is older than ${CODEX_AUTH_STATUS_MIN_VERSION}, so OpenCodex refreshes auth.json itself (${configured})`,
|
|
214
|
+
" Update Codex to let Codex own its login.",
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -163,7 +163,7 @@ export function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): v
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/** Live quota-refusal avoidance for an account, including the lane the request belongs to. */
|
|
166
|
-
function codexQuotaAvoidUntil(
|
|
166
|
+
export function codexQuotaAvoidUntil(
|
|
167
167
|
accountId: string,
|
|
168
168
|
quotaScope: CodexQuotaScope | undefined,
|
|
169
169
|
now: number,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When another stored pool account can serve again, for a main-account hard-lock refusal (#7046).
|
|
3
|
+
*
|
|
4
|
+
* A pool request that fell through to the main login and met the main-account quota guard used
|
|
5
|
+
* to be told to come back at main's own reset, which can be days away while a sibling pool
|
|
6
|
+
* account recovers within hours. This module answers one question: the earliest KNOWN instant at
|
|
7
|
+
* which a stored pool account's every temporary hold ends. It changes no routing state, selects
|
|
8
|
+
* nothing, and never lifts the main guard. The client retries at that time, and ordinary selection
|
|
9
|
+
* decides again from fresh state; a reset timestamp is a hint, not proof of fresh quota.
|
|
10
|
+
*
|
|
11
|
+
* Conservative by construction (the policy Jun is asked to confirm in #7046):
|
|
12
|
+
* - An account is a candidate only when it is a selectable stored pool account that is not
|
|
13
|
+
* paused, plan-excluded, quarantined for sign-in, otherwise unusable, or denied for the model.
|
|
14
|
+
* Those are operator or credential states no clock lifts.
|
|
15
|
+
* - Its recovery time is the LATEST of every routing hold with a known end: quota-health cooldown,
|
|
16
|
+
* quota avoidance, transient soft avoidance, pool-refresh backoff, and the credits-off
|
|
17
|
+
* usage-limit reset. An account under a hold whose end is unknown, or under the sliding
|
|
18
|
+
* failure-window guard (whose end moves with every failure), is skipped rather than guessed.
|
|
19
|
+
* - For an account-gated model, only accounts the resolved entitlement roster admits count, and
|
|
20
|
+
* with no resolved roster nothing does.
|
|
21
|
+
* - An account with no recorded hold is skipped: nothing says when it became unavailable, so
|
|
22
|
+
* nothing says when it will be available again.
|
|
23
|
+
* - Main itself is never a candidate; its own reset stays on the refusal.
|
|
24
|
+
*/
|
|
25
|
+
import { isCodexAccountPaused } from "../account-pause";
|
|
26
|
+
import { codexUsageLimitResetAt } from "../account-credit-use";
|
|
27
|
+
import { isSelectableCodexPoolAccount } from "../account-id";
|
|
28
|
+
import { isAccountNeedsReauth } from "../account-runtime-state";
|
|
29
|
+
import { isCodexAccountUsable } from "../account-usability";
|
|
30
|
+
import { getCodexPoolRefreshCooldownUntil } from "../pool-refresh-backoff";
|
|
31
|
+
import { getAccountQuota } from "../quota";
|
|
32
|
+
import type { OcxConfig } from "../../types";
|
|
33
|
+
import { failureWindowSteersNewThreads } from "./failure-window";
|
|
34
|
+
import {
|
|
35
|
+
codexQuotaAvoidUntil, getCodexAccountSoftAvoidUntil, getCodexQuotaHealthSnapshot, type CodexQuotaScope,
|
|
36
|
+
} from "./health-store";
|
|
37
|
+
import { getPoolAccountPlanForSelection, isCodexAccountHeldAtUsageLimit, isCodexAccountPlanExcluded } from "./selection";
|
|
38
|
+
|
|
39
|
+
export function nextCodexPoolRecoveryAt(
|
|
40
|
+
config: OcxConfig,
|
|
41
|
+
now: number,
|
|
42
|
+
quotaScope?: CodexQuotaScope,
|
|
43
|
+
options: {
|
|
44
|
+
deniedAccountIds?: ReadonlySet<string>;
|
|
45
|
+
/** When set, only these accounts may serve the model (account-gated models). */
|
|
46
|
+
eligibleAccountIds?: ReadonlySet<string>;
|
|
47
|
+
} = {},
|
|
48
|
+
): number | undefined {
|
|
49
|
+
let earliest: number | undefined;
|
|
50
|
+
for (const account of config.codexAccounts ?? []) {
|
|
51
|
+
if (!isSelectableCodexPoolAccount(account)) continue;
|
|
52
|
+
const id = account.id;
|
|
53
|
+
if (options.deniedAccountIds?.has(id) || (options.eligibleAccountIds && !options.eligibleAccountIds.has(id))
|
|
54
|
+
|| isCodexAccountPaused(config, id)
|
|
55
|
+
|| isCodexAccountPlanExcluded(config, id) || isAccountNeedsReauth(id)
|
|
56
|
+
|| !isCodexAccountUsable(config, id) || failureWindowSteersNewThreads(config, id, now)) continue;
|
|
57
|
+
const holds: number[] = [];
|
|
58
|
+
const health = getCodexQuotaHealthSnapshot(id, quotaScope, now);
|
|
59
|
+
if (health) {
|
|
60
|
+
if (typeof health.cooldownUntil !== "number" || !Number.isFinite(health.cooldownUntil)) continue;
|
|
61
|
+
holds.push(health.cooldownUntil);
|
|
62
|
+
}
|
|
63
|
+
const avoidUntil = codexQuotaAvoidUntil(id, quotaScope, now);
|
|
64
|
+
if (avoidUntil !== null) holds.push(avoidUntil);
|
|
65
|
+
const softAvoidUntil = getCodexAccountSoftAvoidUntil(id, now);
|
|
66
|
+
if (softAvoidUntil !== null) holds.push(softAvoidUntil);
|
|
67
|
+
const refreshCooldownUntil = getCodexPoolRefreshCooldownUntil(id, now);
|
|
68
|
+
if (refreshCooldownUntil !== null) holds.push(refreshCooldownUntil);
|
|
69
|
+
if (isCodexAccountHeldAtUsageLimit(config, id, now)) {
|
|
70
|
+
const resetAt = codexUsageLimitResetAt(getAccountQuota(id), getPoolAccountPlanForSelection(config, id), now);
|
|
71
|
+
if (resetAt === undefined) continue;
|
|
72
|
+
holds.push(resetAt);
|
|
73
|
+
}
|
|
74
|
+
if (holds.length === 0) continue;
|
|
75
|
+
const recoveryAt = Math.max(...holds);
|
|
76
|
+
if (recoveryAt > now && (earliest === undefined || recoveryAt < earliest)) earliest = recoveryAt;
|
|
77
|
+
}
|
|
78
|
+
return earliest;
|
|
79
|
+
}
|
|
@@ -29,6 +29,12 @@ export interface CodexShimDiagnostic {
|
|
|
29
29
|
summary: string;
|
|
30
30
|
runnable?: boolean;
|
|
31
31
|
active?: boolean | null;
|
|
32
|
+
/**
|
|
33
|
+
* PATH overlay only: the wrapper and its private state are intact and runnable, but this process
|
|
34
|
+
* could not see it first on PATH. Reinstalling cannot change that; the shell must source the
|
|
35
|
+
* environment file. The dashboard's PATH can also differ from the user's shell (#6971).
|
|
36
|
+
*/
|
|
37
|
+
activationPending?: boolean;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */
|
|
@@ -44,6 +44,10 @@ export function overlayActivationHint(configDir = getConfigDir()): string {
|
|
|
44
44
|
function sameId(left: OverlayIdentity | null | undefined, right: OverlayIdentity | null | undefined): boolean {
|
|
45
45
|
return !!left && !!right && left.dev === right.dev && left.ino === right.ino;
|
|
46
46
|
}
|
|
47
|
+
/** APFS device numbers can change after reboot; persisted ownership still requires the same inode. */
|
|
48
|
+
function samePersistedInode(current: OverlayIdentity, recorded: OverlayIdentity | undefined): boolean {
|
|
49
|
+
return !!recorded && current.ino === recorded.ino;
|
|
50
|
+
}
|
|
47
51
|
function lexical(path: string): Stats | null {
|
|
48
52
|
try { return lstatSync(path); }
|
|
49
53
|
catch (error) { if (fileErrorCode(error) === "ENOENT") return null; throw error; }
|
|
@@ -128,7 +132,7 @@ function shellEnvironment(): string {
|
|
|
128
132
|
'unset ocx_shim_dir ocx_shim_rest ocx_shim_path ocx_shim_more ocx_shim_part', ""].join("\n");
|
|
129
133
|
}
|
|
130
134
|
function ownedWrapper(value: Snapshot, state: OverlayState): boolean {
|
|
131
|
-
return
|
|
135
|
+
return samePersistedInode(value.identity, state.wrapperIdentity)
|
|
132
136
|
&& value.content.includes(SHIM_MARKER) && value.content.includes(`exec ${shQuote(state.launcherPath)} "$@"`);
|
|
133
137
|
}
|
|
134
138
|
function requireOverlayIdentities(state: OverlayState): void {
|
|
@@ -137,7 +141,7 @@ function requireOverlayIdentities(state: OverlayState): void {
|
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
function ownedEnvironment(value: Snapshot, state: OverlayState): boolean {
|
|
140
|
-
return
|
|
144
|
+
return samePersistedInode(value.identity, state.envIdentity) && value.content === shellEnvironment();
|
|
141
145
|
}
|
|
142
146
|
function revalidateOverlay(state: OverlayState, saved: Snapshot | null, wrapper: Snapshot | null, env: Snapshot | null): void {
|
|
143
147
|
if (!unchanged(statePath(), saved) || !unchanged(state.wrapperPath, wrapper) || !unchanged(overlayPaths().env, env)) {
|
|
@@ -201,6 +205,7 @@ export function overlayDiagnostic(state: ShimState): CodexShimDiagnostic {
|
|
|
201
205
|
inspection?.revalidate();
|
|
202
206
|
} catch (error) { active = null; runnable = false; integrityMessage = failure(error).message; }
|
|
203
207
|
return { installed: true, healthy: complete && runnable && active === true, runnable, active,
|
|
208
|
+
activationPending: complete && runnable && active !== true,
|
|
204
209
|
summary: `Codex PATH shim: ${complete && runnable ? "ready" : "unhealthy"} at ${state.wrapperPath}; launcher ${state.launcherPath}; PATH ${active === null ? "unverified" : active ? "active" : "inactive"}. ${complete && runnable ? overlayActivationHint() : integrityMessage || "Run ocx codex-shim install to repair the shim."}` };
|
|
205
210
|
}
|
|
206
211
|
|
|
@@ -6,6 +6,7 @@ import { lstatSync, readFileSync } from "node:fs";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import * as z from "zod/v4";
|
|
8
8
|
import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
|
|
9
|
+
import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
|
|
9
10
|
import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
|
|
10
11
|
import type { OcxConfig } from "../types";
|
|
11
12
|
import { anthropicAccountPoolConfigError, anthropicOAuthInstanceConfigError, anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
|
|
@@ -694,7 +695,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
694
695
|
const anthropicError = anthropicOAuthInstanceConfigError(value) ?? anthropicAccountPoolConfigError(value) ?? anthropicSidecarConfigError(value);
|
|
695
696
|
if (anthropicError) return { ok: false, error: anthropicError };
|
|
696
697
|
const boundaryError = blockedModelRedirectsError(value)
|
|
697
|
-
?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
|
|
698
|
+
?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value)
|
|
698
699
|
?? blankHostnameError(value)
|
|
699
700
|
?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null)
|
|
700
701
|
?? claudeSubagentEffortError(value)
|
|
@@ -138,6 +138,19 @@ export function adoptPersistedProviderIntoLiveConfig(
|
|
|
138
138
|
if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** Adopt only the committed next-start preference; preserve unrelated pending live edits. */
|
|
142
|
+
export function adoptPersistedDesktopCompatibility(config: OcxConfig, persisted: OcxConfig["desktopCompatibility"]): void {
|
|
143
|
+
const baseline = liveConfigBaseline.get(config);
|
|
144
|
+
const merged = reconcileConfigValue(baseline?.desktopCompatibility ?? MISSING_CONFIG_VALUE,
|
|
145
|
+
config.desktopCompatibility ?? MISSING_CONFIG_VALUE, persisted ?? MISSING_CONFIG_VALUE);
|
|
146
|
+
if (merged === MISSING_CONFIG_VALUE) delete config.desktopCompatibility;
|
|
147
|
+
else config.desktopCompatibility = merged as OcxConfig["desktopCompatibility"];
|
|
148
|
+
if (baseline) {
|
|
149
|
+
if (persisted === undefined) delete baseline.desktopCompatibility;
|
|
150
|
+
else baseline.desktopCompatibility = structuredClone(persisted);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
/** Test seam only: is this instance armed? */
|
|
142
155
|
export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
|
|
143
156
|
return claudeCodeBaseline.has(config);
|
|
@@ -3,6 +3,7 @@ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
|
|
|
3
3
|
import { chmodSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
|
|
6
|
+
import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
|
|
6
7
|
import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
|
|
7
8
|
import { anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
|
|
8
9
|
import {
|
|
@@ -51,9 +52,12 @@ import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict
|
|
|
51
52
|
|
|
52
53
|
export function hardenExistingSecret(path: string): void {
|
|
53
54
|
if (existsSync(path) && !isPassiveRead()) {
|
|
54
|
-
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
55
55
|
if (process.platform === "win32") {
|
|
56
|
+
// Preserve the ACL helper's metadata observation across reads. Windows chmod does
|
|
57
|
+
// not establish a private DACL and can invalidate that observation before every load.
|
|
56
58
|
hardenSecretPath(path, { required: false });
|
|
59
|
+
} else {
|
|
60
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
}
|
|
@@ -129,6 +133,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx
|
|
|
129
133
|
export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
|
|
130
134
|
setCodexWsReuseAcrossTurns(validated.codexWsReuseAcrossTurns === true);
|
|
131
135
|
if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
|
|
136
|
+
if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically.");
|
|
132
137
|
if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
|
|
133
138
|
const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed);
|
|
134
139
|
if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
|
package/src/config/paths.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl";
|
|
5
5
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
6
6
|
import { isPassiveRead } from "../lib/passive-read";
|
|
7
|
+
import { trackBackgroundWork } from "../lib/background-work";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Expand a leading `~` in user-supplied paths without interpreting shell
|
|
@@ -37,17 +38,23 @@ export function hardenConfigDir(): void {
|
|
|
37
38
|
// have changed the protected directory used by the test-home boundary.
|
|
38
39
|
assertNotRealHomeUnderTest(dir);
|
|
39
40
|
if (!existsSync(dir)) return;
|
|
40
|
-
|
|
41
|
+
// Windows privacy is enforced by NTFS ACLs. chmod only changes DOS attributes there;
|
|
42
|
+
// doing it before the ACL lookup can invalidate its ctime-based successful observation.
|
|
43
|
+
if (!windowsSecretAclApplies()) {
|
|
44
|
+
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
45
|
+
}
|
|
41
46
|
if (windowsSecretAclApplies() && !configDirHardeningFlights.has(dir)) {
|
|
42
47
|
// This is an optional read-path harden. Waiting synchronously here used to stop the Bun
|
|
43
48
|
// event loop (including /healthz) for the full icacls timeout. Required mutation paths keep
|
|
44
49
|
// their own awaited/fail-closed hardening; ordinary config reads only start one soft flight.
|
|
45
|
-
|
|
50
|
+
// Tracked: icacls holds the directory open, and a config write or removal that lands on
|
|
51
|
+
// it before the child exits fails with a sharing violation on Windows.
|
|
52
|
+
const flight = trackBackgroundWork("config-dir-acl-harden", hardenSecretDirAsync(dir, { required: false })
|
|
46
53
|
.then(() => undefined)
|
|
47
54
|
.catch(() => undefined)
|
|
48
55
|
.finally(() => {
|
|
49
56
|
if (configDirHardeningFlights.get(dir) === flight) configDirHardeningFlights.delete(dir);
|
|
50
|
-
});
|
|
57
|
+
}));
|
|
51
58
|
configDirHardeningFlights.set(dir, flight);
|
|
52
59
|
}
|
|
53
60
|
}
|