@bitkyc08/opencodex 2.60.0 → 2.61.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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -3,7 +3,13 @@ import {
|
|
|
3
3
|
tryAcquireNativeMainProfileClaim as tryAcquireLifecycleNativeMainProfileClaim,
|
|
4
4
|
tryClaimNativeMainProfileForTurn as tryClaimLifecycleNativeMainProfileForTurn,
|
|
5
5
|
} from "../server/lifecycle";
|
|
6
|
+
import {
|
|
7
|
+
MAIN_CODEX_ACCOUNT_ID,
|
|
8
|
+
MainAccountTokenRefreshError,
|
|
9
|
+
MainAuthJsonChangedDuringRefreshError,
|
|
10
|
+
} from "./main-account";
|
|
6
11
|
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
|
|
12
|
+
import { NativeProfileError } from "./native-profile-types";
|
|
7
13
|
|
|
8
14
|
export interface NativeMainTurnClaimDeps {
|
|
9
15
|
/** Test seams for the synchronous precheck/claim/postcheck transition. */
|
|
@@ -45,3 +51,80 @@ export function tryAcquireNativeMainProfileClaim(): AdmissionLease | null {
|
|
|
45
51
|
claim.release();
|
|
46
52
|
return null;
|
|
47
53
|
}
|
|
54
|
+
|
|
55
|
+
export interface NativeMainCredentialAdmissionDeps {
|
|
56
|
+
/** Test seam for the synchronous admission precheck. */
|
|
57
|
+
readonly acquireNativeMain?: () => AdmissionLease | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NO_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set();
|
|
61
|
+
const NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set([MAIN_CODEX_ACCOUNT_ID]);
|
|
62
|
+
const RELEASE_NOTHING = () => {};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run credential-backed work inside the native-main lifecycle fence.
|
|
66
|
+
*
|
|
67
|
+
* The lease keeps startup recovery and profile drains from owning the physical
|
|
68
|
+
* credential while the operation reads it. Cross-process ownership of the file
|
|
69
|
+
* itself is already coordinated inside the refresh path's exclusive claim, so
|
|
70
|
+
* this fence deliberately does not take the shared claim: holding it across an
|
|
71
|
+
* operation that may refresh would ask for exclusive ownership against our own
|
|
72
|
+
* shared lock, and holding it across the upstream work that follows would
|
|
73
|
+
* stall an unrelated credential commit behind a network fetch.
|
|
74
|
+
*
|
|
75
|
+
* The fence covers only the credential read. The operation must invoke
|
|
76
|
+
* `releaseMainLease` as soon as the native-main credential settles — on both
|
|
77
|
+
* the success and the credential-error path — and before any upstream model
|
|
78
|
+
* listing, so a profile drain never waits on a network fetch while this turn
|
|
79
|
+
* is still counted. The wrapper releases on settle regardless, so an
|
|
80
|
+
* operation without a fenced credential phase may ignore the callback.
|
|
81
|
+
*
|
|
82
|
+
* When the gate refuses, or the credential cannot be read because another
|
|
83
|
+
* lifecycle owns it, the operation reruns with main excluded so independent
|
|
84
|
+
* Pool work is never suppressed by main's unavailability.
|
|
85
|
+
*/
|
|
86
|
+
export async function withNativeMainCredentialAdmission<T>(
|
|
87
|
+
operation: (
|
|
88
|
+
excludeAccountIds: ReadonlySet<string>,
|
|
89
|
+
releaseMainLease?: () => void,
|
|
90
|
+
) => Promise<T>,
|
|
91
|
+
deps: NativeMainCredentialAdmissionDeps = {},
|
|
92
|
+
): Promise<T> {
|
|
93
|
+
const lease = (deps.acquireNativeMain ?? tryAcquireNativeMainProfileClaim)();
|
|
94
|
+
if (!lease) return operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
|
|
95
|
+
let released = false;
|
|
96
|
+
const releaseMainLease = () => {
|
|
97
|
+
if (released) return;
|
|
98
|
+
released = true;
|
|
99
|
+
lease.release();
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
return await operation(NO_EXCLUDED_ACCOUNT_IDS, releaseMainLease);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// The Pool-only retry never reads the native-main credential; release first
|
|
105
|
+
// so a profile drain is not kept waiting behind Pool network work.
|
|
106
|
+
releaseMainLease();
|
|
107
|
+
if (!isNativeMainCredentialUnavailableError(error)) throw error;
|
|
108
|
+
return await operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
|
|
109
|
+
} finally {
|
|
110
|
+
releaseMainLease();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The credential-ownership failures that make main unavailable for one
|
|
116
|
+
* operation: a foreign exclusive holder or an unsupported claim filesystem
|
|
117
|
+
* (NATIVE_MAIN_CLAIM_BUSY / NATIVE_MAIN_CLAIM_UNAVAILABLE), a writer that moved
|
|
118
|
+
* auth.json mid-refresh, or a grant that no longer refreshes. Like a Pool
|
|
119
|
+
* credential failure, none of them may suppress independent Pool discovery.
|
|
120
|
+
* Any other NativeProfileError — MAIN_REQUESTS_ACTIVE, VAULT_INVALID,
|
|
121
|
+
* INTERNAL_ERROR — is not a credential-ownership failure and propagates.
|
|
122
|
+
*/
|
|
123
|
+
function isNativeMainCredentialUnavailableError(error: unknown): boolean {
|
|
124
|
+
if (error instanceof NativeProfileError) {
|
|
125
|
+
return error.code === "NATIVE_MAIN_CLAIM_BUSY"
|
|
126
|
+
|| error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE";
|
|
127
|
+
}
|
|
128
|
+
return error instanceof MainAuthJsonChangedDuringRefreshError
|
|
129
|
+
|| error instanceof MainAccountTokenRefreshError;
|
|
130
|
+
}
|
|
@@ -350,6 +350,45 @@ export function deleteAccountHealth(accountId: string): void {
|
|
|
350
350
|
upstreamHealth.delete(accountId);
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
export function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean {
|
|
354
|
+
return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Has this account refused a request on quota without serving one since?
|
|
359
|
+
*
|
|
360
|
+
* Thread affinity is a prompt-cache optimization and every rule around it is a preference:
|
|
361
|
+
* `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity`
|
|
362
|
+
* deliberately raises that bar further. A refusal is not a preference, and once the account has
|
|
363
|
+
* told THIS thread it cannot serve, the binding has nothing left to optimize.
|
|
364
|
+
*
|
|
365
|
+
* The distinction matters because the cooldown a 429 writes is deliberately short. A reset
|
|
366
|
+
* announcement is advisory — plan quota routinely frees up before the advertised instant — so
|
|
367
|
+
* {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that
|
|
368
|
+
* announcement describes is not capped, so an account whose burst window is spent looks
|
|
369
|
+
* selectable again long before it is. For an unbound request that is correct: going back to find
|
|
370
|
+
* out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit —
|
|
371
|
+
* the cooldown lapses, the account still scores lowest on the only window this proxy has a
|
|
372
|
+
* reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the
|
|
373
|
+
* identical 429. Cleared affinity does not help: the next request re-derives the same choice.
|
|
374
|
+
* From the Codex side that reads exactly as reported — a new session rotates normally while an
|
|
375
|
+
* existing one is locked to an exhausted account until the proxy is restarted, because a restart
|
|
376
|
+
* is the only thing that drops the binding and the stale health together.
|
|
377
|
+
*
|
|
378
|
+
* `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields}
|
|
379
|
+
* strips it from every recovery write, so it survives exactly until the account actually serves a
|
|
380
|
+
* request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes
|
|
381
|
+
* the account and the first success releases every thread this refused.
|
|
382
|
+
*
|
|
383
|
+
* Scope follows where the refusal was recorded. An account-wide throttle lands in
|
|
384
|
+
* `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native
|
|
385
|
+
* quota group, so a spent Spark window still cannot displace the same thread's Terra binding.
|
|
386
|
+
*/
|
|
387
|
+
export function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean {
|
|
388
|
+
if (carriesQuotaRefusal(getAccountHealth(accountId))) return true;
|
|
389
|
+
return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope));
|
|
390
|
+
}
|
|
391
|
+
|
|
353
392
|
export function listScopedHealthEntries(accountId: string): Array<[CodexQuotaScope, CodexUpstreamHealth]> {
|
|
354
393
|
return [...(quotaScopedHealth.get(accountId) ?? [])];
|
|
355
394
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
dropSpentCredentialFailure,
|
|
23
23
|
getAccountHealth,
|
|
24
24
|
getCodexQuotaHealthSnapshot,
|
|
25
|
+
hasUnrecoveredCodexQuotaRefusal,
|
|
25
26
|
isCodexAccountSoftAvoided,
|
|
26
27
|
isCodexQuotaAvoided,
|
|
27
28
|
isIndependentCodexQuotaScope,
|
|
@@ -276,6 +277,40 @@ export function isCacheAffinityEnabled(config: OcxConfig): boolean {
|
|
|
276
277
|
return config.pool?.cacheAffinity !== false;
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Whether quota may retire shared state while cache affinity is active.
|
|
282
|
+
*
|
|
283
|
+
* A threshold crossing is a hint that an account is getting busy, not evidence it cannot
|
|
284
|
+
* serve — the same bar {@link mayRebindAffinityForQuota} applies to a live binding. Shared
|
|
285
|
+
* state held across a model detour gets that exhaustion boundary for the same reason: the
|
|
286
|
+
* detour is request-scoped, so retiring the binding over a hint pays a cold prefix for
|
|
287
|
+
* nothing. New/unbound selection still reads {@link hasCodexQuotaHeadroom}; only
|
|
288
|
+
* preservation of an existing shared selection or thread binding qualifies here. Like the
|
|
289
|
+
* live-binding rule, the configured threshold plays no role once retention applies: a
|
|
290
|
+
* genuinely exhausted (>=100%) account releases even with threshold switching disabled,
|
|
291
|
+
* while the fallback above keeps a disabled threshold's "never drained on quota alone".
|
|
292
|
+
*/
|
|
293
|
+
export function hasCodexSharedStateQuotaHeadroom(
|
|
294
|
+
config: OcxConfig,
|
|
295
|
+
accountId: string,
|
|
296
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
297
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
298
|
+
now: number = Date.now(),
|
|
299
|
+
): boolean {
|
|
300
|
+
if (
|
|
301
|
+
!isCacheAffinityEnabled(config)
|
|
302
|
+
|| accountPoolStrategyForScope(config, quotaScope) !== "quota"
|
|
303
|
+
) {
|
|
304
|
+
return hasCodexQuotaHeadroom(config, accountId, selectionOptions, now);
|
|
305
|
+
}
|
|
306
|
+
const usage = computeCodexUsageScore(
|
|
307
|
+
getAccountQuota(accountId),
|
|
308
|
+
getPoolAccountPlanForSelection(config, accountId, selectionOptions),
|
|
309
|
+
now,
|
|
310
|
+
);
|
|
311
|
+
return isUnknownUsage(usage) || usage < 100;
|
|
312
|
+
}
|
|
313
|
+
|
|
279
314
|
/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
|
|
280
315
|
export function pickResetFirstCodexAccount(
|
|
281
316
|
config: OcxConfig,
|
|
@@ -726,7 +761,8 @@ export function isHealthySharedCodexSelection(
|
|
|
726
761
|
selectionOptions: CodexAccountUsabilityOptions | undefined,
|
|
727
762
|
): boolean {
|
|
728
763
|
return isCodexAccountSelectable(config, accountId, now, quotaScope, selectionOptions)
|
|
729
|
-
&&
|
|
764
|
+
&& hasCodexSharedStateQuotaHeadroom(config, accountId, quotaScope, selectionOptions, now)
|
|
765
|
+
&& !hasUnrecoveredCodexQuotaRefusal(accountId, quotaScope)
|
|
730
766
|
&& !shouldFailover(config, accountId, now);
|
|
731
767
|
}
|
|
732
768
|
|
package/src/codex/routing.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type CodexUpstreamOutcomeMeta,
|
|
29
29
|
} from "./routing/cooldown-math";
|
|
30
30
|
import {
|
|
31
|
+
carriesQuotaRefusal,
|
|
31
32
|
codexPoolKeyForScope,
|
|
32
33
|
codexQuotaScopeForModel,
|
|
33
34
|
deleteAccountHealth,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
getCodexAccountCooldownUntil,
|
|
39
40
|
getCodexAccountSoftAvoidUntil,
|
|
40
41
|
getCodexQuotaHealthSnapshot,
|
|
42
|
+
hasUnrecoveredCodexQuotaRefusal,
|
|
41
43
|
isCodexAccountSoftAvoided,
|
|
42
44
|
isCodexQuotaAvoided,
|
|
43
45
|
isHealthAccountAdmissible,
|
|
@@ -93,6 +95,7 @@ import {
|
|
|
93
95
|
getEligiblePoolAccounts,
|
|
94
96
|
getPoolAccountPlanForSelection,
|
|
95
97
|
hasCodexQuotaHeadroom,
|
|
98
|
+
hasCodexSharedStateQuotaHeadroom,
|
|
96
99
|
isCodexAccountPlanExcluded,
|
|
97
100
|
isCodexAccountSelectable,
|
|
98
101
|
isHealthySharedCodexSelection,
|
|
@@ -489,45 +492,6 @@ export function resolveCodexAccountForThread(
|
|
|
489
492
|
return resolution.status === "selected" ? resolution.accountId : null;
|
|
490
493
|
}
|
|
491
494
|
|
|
492
|
-
function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean {
|
|
493
|
-
return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* Has this account refused a request on quota without serving one since?
|
|
498
|
-
*
|
|
499
|
-
* Thread affinity is a prompt-cache optimization and every rule around it is a preference:
|
|
500
|
-
* `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity`
|
|
501
|
-
* deliberately raises that bar further. A refusal is not a preference, and once the account has
|
|
502
|
-
* told THIS thread it cannot serve, the binding has nothing left to optimize.
|
|
503
|
-
*
|
|
504
|
-
* The distinction matters because the cooldown a 429 writes is deliberately short. A reset
|
|
505
|
-
* announcement is advisory — plan quota routinely frees up before the advertised instant — so
|
|
506
|
-
* {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that
|
|
507
|
-
* announcement describes is not capped, so an account whose burst window is spent looks
|
|
508
|
-
* selectable again long before it is. For an unbound request that is correct: going back to find
|
|
509
|
-
* out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit —
|
|
510
|
-
* the cooldown lapses, the account still scores lowest on the only window this proxy has a
|
|
511
|
-
* reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the
|
|
512
|
-
* identical 429. Cleared affinity does not help: the next request re-derives the same choice.
|
|
513
|
-
* From the Codex side that reads exactly as reported — a new session rotates normally while an
|
|
514
|
-
* existing one is locked to an exhausted account until the proxy is restarted, because a restart
|
|
515
|
-
* is the only thing that drops the binding and the stale health together.
|
|
516
|
-
*
|
|
517
|
-
* `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields}
|
|
518
|
-
* strips it from every recovery write, so it survives exactly until the account actually serves a
|
|
519
|
-
* request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes
|
|
520
|
-
* the account and the first success releases every thread this refused.
|
|
521
|
-
*
|
|
522
|
-
* Scope follows where the refusal was recorded. An account-wide throttle lands in
|
|
523
|
-
* `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native
|
|
524
|
-
* quota group, so a spent Spark window still cannot displace the same thread's Terra binding.
|
|
525
|
-
*/
|
|
526
|
-
function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean {
|
|
527
|
-
if (carriesQuotaRefusal(getAccountHealth(accountId))) return true;
|
|
528
|
-
return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope));
|
|
529
|
-
}
|
|
530
|
-
|
|
531
495
|
function previewReusableAffinityAccount(
|
|
532
496
|
entry: ThreadAffinityEntry | undefined,
|
|
533
497
|
config: OcxConfig,
|
|
@@ -952,7 +916,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
952
916
|
// the account has already told this thread it cannot serve it.
|
|
953
917
|
const quotaRefused = hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope);
|
|
954
918
|
const healthyForSharedAffinity = selectableForSharedState
|
|
955
|
-
&&
|
|
919
|
+
&& hasCodexSharedStateQuotaHeadroom(config, entry.accountId, quotaScope, sharedSelectionOptions, now)
|
|
956
920
|
&& !quotaRefused
|
|
957
921
|
&& !failoverReady;
|
|
958
922
|
if (
|
|
@@ -1150,7 +1114,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1150
1114
|
sharedSelectionOptions,
|
|
1151
1115
|
);
|
|
1152
1116
|
const activeHealthyForSharedSelection = activeSelectableForSharedState
|
|
1153
|
-
&&
|
|
1117
|
+
&& hasCodexSharedStateQuotaHeadroom(config, active, quotaScope, sharedSelectionOptions, now)
|
|
1154
1118
|
&& !shouldFailover(config, active, now);
|
|
1155
1119
|
if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
|
|
1156
1120
|
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
@@ -4,11 +4,29 @@ import { serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
|
4
4
|
import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
|
|
5
5
|
|
|
6
6
|
const SHIM_MARKER = "opencodex codex autostart shim";
|
|
7
|
-
const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision
|
|
7
|
+
const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 3";
|
|
8
8
|
|
|
9
9
|
const CODEX_SHIM_REENTRY_EXIT_CODE = 126;
|
|
10
10
|
const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart.";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Said once, on stderr, when `ocx ensure` could not bring the proxy up (#5261).
|
|
14
|
+
*
|
|
15
|
+
* The shim used to discard both of ensure's streams and ignore its exit status, so a failed
|
|
16
|
+
* autostart was completely silent: Codex launched against injected routing pointing at a port
|
|
17
|
+
* nothing was listening on, and every request — sign-in included — failed with no mention of
|
|
18
|
+
* opencodex anywhere.
|
|
19
|
+
*
|
|
20
|
+
* Ensure's own streams stay discarded rather than being let through. Ensure prints progress and
|
|
21
|
+
* warnings on exit-zero runs too, and a wrapper that leaked those would put noise in front of
|
|
22
|
+
* every ordinary Codex launch, which is how a diagnostic gets ignored. The exit status is the
|
|
23
|
+
* signal; this line is the whole message.
|
|
24
|
+
*
|
|
25
|
+
* It names `ocx restore` because bringing the proxy back is only half the choice. A user who
|
|
26
|
+
* cannot sign in needs the way out that does not require the proxy at all.
|
|
27
|
+
*/
|
|
28
|
+
export const CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC = "opencodex: proxy autostart failed; launching Codex anyway. Run 'ocx doctor' for details, or 'ocx restore' to hand Codex back to its own account.";
|
|
29
|
+
|
|
12
30
|
const CODEX_INTERNAL_COMMANDS = [
|
|
13
31
|
"app-server",
|
|
14
32
|
"archive",
|
|
@@ -136,7 +154,9 @@ case "$ocx_subcommand" in
|
|
|
136
154
|
;;
|
|
137
155
|
*)
|
|
138
156
|
if [ -z "$OCX_SHIM_BYPASS" ]; then
|
|
139
|
-
${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1
|
|
157
|
+
if ! ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1; then
|
|
158
|
+
printf '%s\\n' ${shQuote(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)} >&2
|
|
159
|
+
fi
|
|
140
160
|
fi
|
|
141
161
|
;;
|
|
142
162
|
esac
|
|
@@ -197,6 +217,7 @@ setlocal\r
|
|
|
197
217
|
${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r
|
|
198
218
|
${windowsBatchSet(BUN_RUNTIME_PATH_ENV, bunPath)}\r
|
|
199
219
|
"%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r
|
|
220
|
+
if errorlevel 1 echo ${CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC} 1>&2\r
|
|
200
221
|
endlocal\r
|
|
201
222
|
:run_codex\r
|
|
202
223
|
"%OCX_REAL_CODEX%" %*\r
|
|
@@ -239,13 +260,18 @@ if (-not $skipEnsure) {
|
|
|
239
260
|
$priorRuntimePath = $env:${BUN_RUNTIME_PATH_ENV}
|
|
240
261
|
$env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)}
|
|
241
262
|
$env:${BUN_RUNTIME_PATH_ENV} = ${psString(bunPath)}
|
|
242
|
-
|
|
263
|
+
$ocxEnsureFailed = $false
|
|
264
|
+
# Caught, not propagated: a throwing ensure used to escape this wrapper and Codex never
|
|
265
|
+
# launched at all, which is a lockout produced by the autostart helper itself (#5261).
|
|
266
|
+
try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null; if ($LASTEXITCODE -ne 0) { $ocxEnsureFailed = $true } }
|
|
267
|
+
catch { $ocxEnsureFailed = $true }
|
|
243
268
|
finally {
|
|
244
269
|
if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue }
|
|
245
270
|
else { $env:${BUN_RUNTIME_SOURCE_ENV} = $priorRuntimeSource }
|
|
246
271
|
if ($null -eq $priorRuntimePath) { Remove-Item Env:\\${BUN_RUNTIME_PATH_ENV} -ErrorAction SilentlyContinue }
|
|
247
272
|
else { $env:${BUN_RUNTIME_PATH_ENV} = $priorRuntimePath }
|
|
248
273
|
}
|
|
274
|
+
if ($ocxEnsureFailed) { [Console]::Error.WriteLine(${psString(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)}) }
|
|
249
275
|
}
|
|
250
276
|
& ${psString(realCodexPath)} @args
|
|
251
277
|
$codexExitCode = $LASTEXITCODE
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getConfigDir } from "../config/paths";
|
|
4
|
+
import {
|
|
5
|
+
TIMELINE_HOURS,
|
|
6
|
+
isTimelineModelId,
|
|
7
|
+
type TimelineAggregation,
|
|
8
|
+
type TimelineGrouping,
|
|
9
|
+
type TimelineMetric,
|
|
10
|
+
} from "../usage/timeline";
|
|
11
|
+
|
|
12
|
+
export interface CompanionSettings {
|
|
13
|
+
menuBarMetric: "requests" | "tokens" | "cost" | "quota" | "none";
|
|
14
|
+
menuBarTemplate: string | null;
|
|
15
|
+
showToday: boolean;
|
|
16
|
+
showChart: boolean;
|
|
17
|
+
showModels: boolean;
|
|
18
|
+
showCost: boolean;
|
|
19
|
+
showAccounts: boolean;
|
|
20
|
+
chartHours: typeof TIMELINE_HOURS[number];
|
|
21
|
+
bucketMinutes: number;
|
|
22
|
+
chartStyle: "line" | "stackedBar";
|
|
23
|
+
tokenMetric: TimelineMetric;
|
|
24
|
+
aggregation: TimelineAggregation;
|
|
25
|
+
chartGrouping: TimelineGrouping;
|
|
26
|
+
models: string[] | null;
|
|
27
|
+
hiddenProviders: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = {
|
|
31
|
+
menuBarMetric: "tokens",
|
|
32
|
+
menuBarTemplate: null,
|
|
33
|
+
showToday: true,
|
|
34
|
+
showChart: true,
|
|
35
|
+
showModels: true,
|
|
36
|
+
showCost: true,
|
|
37
|
+
showAccounts: true,
|
|
38
|
+
chartHours: 24,
|
|
39
|
+
bucketMinutes: 60,
|
|
40
|
+
chartStyle: "line",
|
|
41
|
+
tokenMetric: "total",
|
|
42
|
+
aggregation: "sum",
|
|
43
|
+
chartGrouping: "model",
|
|
44
|
+
models: null,
|
|
45
|
+
hiddenProviders: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const TEMPLATE_FIELDS = new Set(["requests", "totalTokens", "inputTokens", "outputTokens", "costUsd", "quotaPercent"]);
|
|
49
|
+
const MENU_BAR_METRICS = new Set(["requests", "tokens", "cost", "quota", "none"]);
|
|
50
|
+
const CHART_STYLES = new Set(["line", "stackedBar"]);
|
|
51
|
+
const TIMELINE_METRICS = new Set(["total", "input", "output", "cached"]);
|
|
52
|
+
const AGGREGATIONS = new Set(["sum", "average", "max"]);
|
|
53
|
+
const GROUPINGS = new Set(["model", "modelAccount"]);
|
|
54
|
+
const SETTINGS_KEYS = Object.keys(DEFAULT_COMPANION_SETTINGS) as (keyof CompanionSettings)[];
|
|
55
|
+
|
|
56
|
+
export function companionSettingsPath(): string {
|
|
57
|
+
return join(getConfigDir(), "companion.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function invalid(message: string): { error: string } {
|
|
61
|
+
return { error: message };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validModels(value: unknown, key: string): value is string[] | null {
|
|
65
|
+
return value === null
|
|
66
|
+
|| (Array.isArray(value)
|
|
67
|
+
&& value.length <= 100
|
|
68
|
+
&& value.every(isTimelineModelId));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateValue(key: keyof CompanionSettings, value: unknown): string | null {
|
|
72
|
+
if (key === "menuBarMetric") return typeof value === "string" && MENU_BAR_METRICS.has(value) ? null : "menuBarMetric is invalid";
|
|
73
|
+
if (key === "menuBarTemplate") {
|
|
74
|
+
if (value === null) return null;
|
|
75
|
+
if (typeof value !== "string" || value.length > 200) return "menuBarTemplate must be null or at most 200 characters";
|
|
76
|
+
for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
|
|
77
|
+
if (!TEMPLATE_FIELDS.has(match[1]!)) return `menuBarTemplate contains unknown placeholder: ${match[1]}`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (["showToday", "showChart", "showModels", "showCost", "showAccounts"].includes(key)) {
|
|
82
|
+
return typeof value === "boolean" ? null : `${key} must be a boolean`;
|
|
83
|
+
}
|
|
84
|
+
if (key === "chartHours") return TIMELINE_HOURS.includes(value as typeof TIMELINE_HOURS[number]) ? null : "chartHours is invalid";
|
|
85
|
+
if (key === "bucketMinutes") return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1440 ? null : "bucketMinutes must be an integer from 1 through 1440";
|
|
86
|
+
if (key === "chartStyle") return typeof value === "string" && CHART_STYLES.has(value) ? null : "chartStyle is invalid";
|
|
87
|
+
if (key === "tokenMetric") return typeof value === "string" && TIMELINE_METRICS.has(value) ? null : "tokenMetric is invalid";
|
|
88
|
+
if (key === "aggregation") return typeof value === "string" && AGGREGATIONS.has(value) ? null : "aggregation is invalid";
|
|
89
|
+
if (key === "chartGrouping") return typeof value === "string" && GROUPINGS.has(value) ? null : "chartGrouping is invalid";
|
|
90
|
+
if (key === "models") return validModels(value, key) ? null : "models must be null or at most 100 provider/model identifiers";
|
|
91
|
+
if (key === "hiddenProviders") return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/\s/.test(item))
|
|
92
|
+
? null : "hiddenProviders must contain at most 100 provider names";
|
|
93
|
+
return `${key} is unsupported`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function applyCompanionSettingsPatch(
|
|
97
|
+
current: CompanionSettings,
|
|
98
|
+
patch: unknown,
|
|
99
|
+
): CompanionSettings | { error: string } {
|
|
100
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return invalid("settings must be an object");
|
|
101
|
+
const values = patch as Record<string, unknown>;
|
|
102
|
+
for (const key of Object.keys(values)) {
|
|
103
|
+
if (!SETTINGS_KEYS.includes(key as keyof CompanionSettings)) return invalid(`unknown settings key: ${key}`);
|
|
104
|
+
const error = validateValue(key as keyof CompanionSettings, values[key]);
|
|
105
|
+
if (error) return invalid(error);
|
|
106
|
+
}
|
|
107
|
+
return { ...current, ...values } as CompanionSettings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } {
|
|
111
|
+
const path = companionSettingsPath();
|
|
112
|
+
if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null };
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
115
|
+
const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed);
|
|
116
|
+
if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
|
|
117
|
+
return { settings, updatedAt: statSync(path).mtimeMs };
|
|
118
|
+
} catch {
|
|
119
|
+
return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function saveCompanionSettings(settings: CompanionSettings): void {
|
|
124
|
+
const path = companionSettingsPath();
|
|
125
|
+
const dir = getConfigDir();
|
|
126
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
128
|
+
writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
129
|
+
chmodSync(temp, 0o600);
|
|
130
|
+
renameSync(temp, path);
|
|
131
|
+
chmodSync(path, 0o600);
|
|
132
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
chmodSync,
|
|
3
3
|
closeSync,
|
|
4
4
|
fchmodSync,
|
|
5
|
+
fsyncSync,
|
|
5
6
|
fstatSync,
|
|
6
7
|
lstatSync,
|
|
7
8
|
openSync,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
unlinkSync,
|
|
11
12
|
writeFileSync,
|
|
12
13
|
} from "node:fs";
|
|
13
|
-
import { dirname } from "node:path";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
14
15
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
15
16
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
16
17
|
import {
|
|
@@ -162,6 +163,27 @@ function carryHardenAcrossContentWrite(path: string): void {
|
|
|
162
163
|
reattributeHardenedSecretPath(path);
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Commit the directory entry a rename just wrote.
|
|
168
|
+
*
|
|
169
|
+
* Best effort by platform, not by importance: Windows has no directory descriptor to sync and
|
|
170
|
+
* some filesystems refuse the open, and failing a replacement that already happened would be
|
|
171
|
+
* worse than reporting it. The throw that matters is the temp's own `fsync`, which runs before
|
|
172
|
+
* the rename and stops it.
|
|
173
|
+
*/
|
|
174
|
+
function syncParentDirectory(target: string): void {
|
|
175
|
+
if (process.platform === "win32") return;
|
|
176
|
+
let descriptor: number | undefined;
|
|
177
|
+
try {
|
|
178
|
+
descriptor = openSync(dirname(target), "r");
|
|
179
|
+
fsyncSync(descriptor);
|
|
180
|
+
} catch {
|
|
181
|
+
/* the rename already landed; a directory that cannot be synced is not a reason to undo it */
|
|
182
|
+
} finally {
|
|
183
|
+
if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* already closed */ } }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
165
187
|
function writePrivateTempFile(
|
|
166
188
|
path: string,
|
|
167
189
|
content: string,
|
|
@@ -191,6 +213,40 @@ function writePrivateTempFile(
|
|
|
191
213
|
carryHardenAcrossContentWrite(path);
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The same private temp, filled by a writer that streams into the descriptor.
|
|
218
|
+
*
|
|
219
|
+
* For content that must not be held in memory as one string. The identity assertions, the
|
|
220
|
+
* ownership handshake and the hardening are the same; the difference is that the bytes arrive in
|
|
221
|
+
* bounded chunks and the descriptor is flushed before it closes.
|
|
222
|
+
*
|
|
223
|
+
* The `fsync` is not optional here and its failure is not swallowed. A replacement whose
|
|
224
|
+
* REPLACEMENT is not on disk can lose the rows it was supposed to retain, so the throw is what
|
|
225
|
+
* stops the rename from happening at all.
|
|
226
|
+
*/
|
|
227
|
+
function writePrivateTempFileWith(
|
|
228
|
+
path: string,
|
|
229
|
+
write: (descriptor: number) => void,
|
|
230
|
+
timeoutMemoKey: string,
|
|
231
|
+
onCreated: () => void,
|
|
232
|
+
): void {
|
|
233
|
+
const descriptor = openSync(path, "wx", 0o600);
|
|
234
|
+
onCreated();
|
|
235
|
+
try {
|
|
236
|
+
if (windowsHardeningApplies()) {
|
|
237
|
+
hardenSecretPath(path, { required: true, timeoutMemoKey });
|
|
238
|
+
}
|
|
239
|
+
if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
|
|
240
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
241
|
+
write(descriptor);
|
|
242
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
243
|
+
fsyncSync(descriptor);
|
|
244
|
+
} finally {
|
|
245
|
+
closeSync(descriptor);
|
|
246
|
+
}
|
|
247
|
+
carryHardenAcrossContentWrite(path);
|
|
248
|
+
}
|
|
249
|
+
|
|
194
250
|
async function writePrivateTempFileAsync(
|
|
195
251
|
path: string,
|
|
196
252
|
content: string,
|
|
@@ -215,14 +271,14 @@ async function writePrivateTempFileAsync(
|
|
|
215
271
|
carryHardenAcrossContentWrite(path);
|
|
216
272
|
}
|
|
217
273
|
|
|
218
|
-
|
|
274
|
+
function atomicWriteFileToTarget(
|
|
219
275
|
path: string,
|
|
220
|
-
content: string,
|
|
276
|
+
content: string | ((descriptor: number) => void),
|
|
277
|
+
target: string,
|
|
221
278
|
io?: AtomicWriteIO,
|
|
222
279
|
hooks: AtomicWriteHooks = {},
|
|
223
280
|
): void {
|
|
224
281
|
recordOwnedConfigPath(getConfigDir(), path);
|
|
225
|
-
const target = resolveWriteTarget(path);
|
|
226
282
|
assertResolvedTargetAllowed(path, target);
|
|
227
283
|
const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
228
284
|
let hardened = false;
|
|
@@ -246,13 +302,26 @@ export function atomicWriteFile(
|
|
|
246
302
|
};
|
|
247
303
|
try {
|
|
248
304
|
if (io) ownsTemp = true;
|
|
249
|
-
|
|
305
|
+
// A streaming writer bypasses the string form of `write` and nothing else. Every later
|
|
306
|
+
// step -- harden, the pre-rename hooks, the rename and the whole residual-cleanup path,
|
|
307
|
+
// which still scrubs through `effective.write(tmp, "")` -- is shared with the string form.
|
|
308
|
+
if (typeof content === "function") writePrivateTempFileWith(tmp, content, path, () => { ownsTemp = true; });
|
|
309
|
+
else effective.write(tmp, content);
|
|
250
310
|
hooks.afterTempWrite?.(tmp, target);
|
|
251
311
|
effective.harden(tmp);
|
|
252
312
|
hardened = true;
|
|
253
313
|
hooks.beforeRename?.(tmp, target);
|
|
254
314
|
hooks.validateBeforeRename?.(target);
|
|
255
315
|
effective.rename(tmp, target);
|
|
316
|
+
// The rename is only as durable as the directory entry recording it. Fsyncing the temp's
|
|
317
|
+
// CONTENT and then losing the entry in a power cut leaves the old file in place, or the
|
|
318
|
+
// directory in an indeterminate state, while the caller was told the replacement landed.
|
|
319
|
+
//
|
|
320
|
+
// Only the streaming form does this. It is the one that makes a durability claim -- a
|
|
321
|
+
// replacement is not an append, and losing it can lose the rows it was meant to keep -- and
|
|
322
|
+
// adding a directory sync to the string form would charge every config write for a promise
|
|
323
|
+
// its callers have never been given.
|
|
324
|
+
if (typeof content === "function") syncParentDirectory(target);
|
|
256
325
|
forgetEphemeralSecretPath(tmp);
|
|
257
326
|
} catch (cause) {
|
|
258
327
|
if (!ownsTemp) throw cause;
|
|
@@ -287,6 +356,49 @@ export function atomicWriteFile(
|
|
|
287
356
|
}
|
|
288
357
|
}
|
|
289
358
|
|
|
359
|
+
export function atomicWriteFile(
|
|
360
|
+
path: string,
|
|
361
|
+
content: string,
|
|
362
|
+
io?: AtomicWriteIO,
|
|
363
|
+
hooks: AtomicWriteHooks = {},
|
|
364
|
+
): void {
|
|
365
|
+
atomicWriteFileToTarget(path, content, resolveWriteTarget(path), io, hooks);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Atomically replace a file with bytes produced straight into the temporary descriptor.
|
|
370
|
+
*
|
|
371
|
+
* Same publication contract as {@link atomicWriteFile}: an exclusively created private temp, the
|
|
372
|
+
* identity assertions around the write, `hooks.validateBeforeRename` immediately before the
|
|
373
|
+
* rename, the platform-aware replace, and the residual cleanup on any failure. A custom
|
|
374
|
+
* {@link AtomicWriteIO} is not accepted, because the point of this form is that the default
|
|
375
|
+
* writer owns the descriptor.
|
|
376
|
+
*/
|
|
377
|
+
export function atomicWriteFileStreamed(
|
|
378
|
+
path: string,
|
|
379
|
+
write: (descriptor: number) => void,
|
|
380
|
+
hooks: AtomicWriteHooks = {},
|
|
381
|
+
): void {
|
|
382
|
+
atomicWriteFileToTarget(path, write, resolveWriteTarget(path), undefined, hooks);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Atomically replace the named directory entry without resolving a symlink at
|
|
387
|
+
* that entry. This is for files in directories writable by another process:
|
|
388
|
+
* a raced symlink is replaced, never followed to a more privileged target.
|
|
389
|
+
*/
|
|
390
|
+
export function atomicWriteFileNoFollow(
|
|
391
|
+
path: string,
|
|
392
|
+
content: string,
|
|
393
|
+
io?: AtomicWriteIO,
|
|
394
|
+
hooks: AtomicWriteHooks = {},
|
|
395
|
+
): void {
|
|
396
|
+
// Only the final entry is no-follow: the parent still resolves, because an
|
|
397
|
+
// OS alias above the configured root (a home junction, /tmp) is legitimate
|
|
398
|
+
// and Windows cannot exclusive-create a temp through a junction.
|
|
399
|
+
atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), io, hooks);
|
|
400
|
+
}
|
|
401
|
+
|
|
290
402
|
export interface AtomicWriteAsyncIO {
|
|
291
403
|
write: (path: string, content: string) => void | Promise<void>;
|
|
292
404
|
harden: (path: string) => void | Promise<void>;
|