@bitkyc08/opencodex 2.59.0 → 2.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/request-builder.ts +7 -1
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +3 -1
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +50 -13
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +29 -5
- package/src/adapters/google-http.ts +49 -10
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +37 -6
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +5 -1
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +15 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/bridge/sse.ts +3 -151
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/dispatch.ts +3 -3
- package/src/cli/doctor.ts +28 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +7 -2
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +2 -2
- package/src/client/runtime.ts +26 -2
- package/src/codex/account-store.ts +65 -0
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/retained-sync.ts +22 -26
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +20 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +29 -10
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing.ts +7 -1
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +18 -0
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/config/schema/leaf-validators.ts +12 -0
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +249 -169
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +50 -2
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +66 -18
- package/src/providers/model-rename-migration.ts +55 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +35 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +9 -0
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +5 -2
- package/src/server/claude-messages.ts +6 -3
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +16 -13
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +119 -10
- package/src/server/request-log.ts +55 -2
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/compact.ts +32 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/passthrough-delivery.ts +31 -14
- package/src/server/responses/passthrough-dispatch.ts +36 -9
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +67 -17
- package/src/server/responses/request-send-budget.ts +5 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +2 -2
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/config.ts +11 -3
- package/src/types/provider.ts +18 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +14 -0
- package/src/types.ts +1 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
|
@@ -24,11 +24,10 @@
|
|
|
24
24
|
* released. Minting a new root id mints no new budget because the identity and pool scopes
|
|
25
25
|
* still hold the spend.
|
|
26
26
|
*
|
|
27
|
-
* SUPPORTED TOPOLOGY:
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* needs a
|
|
31
|
-
* than implied.
|
|
27
|
+
* SUPPORTED TOPOLOGY: one live writer owns one OPENCODEX_HOME journal. Every server and
|
|
28
|
+
* direct shared-ledger caller must hold the state-directory SQLite lease before replay,
|
|
29
|
+
* append or compaction. Independent homes remain independent; multi-host shared storage
|
|
30
|
+
* still needs a distributed transaction boundary and is outside this local lease.
|
|
32
31
|
*
|
|
33
32
|
* Five properties this file owes its callers. Each one was absent in the first draft, and a
|
|
34
33
|
* budget that can be bypassed is worse than no budget because it looks like protection:
|
|
@@ -54,7 +53,7 @@
|
|
|
54
53
|
* that are re-applied to an EXISTING file rather than trusted from its creation.
|
|
55
54
|
*/
|
|
56
55
|
|
|
57
|
-
import { appendFileSync, chmodSync,
|
|
56
|
+
import { appendFileSync, chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
58
57
|
import { createHash, randomBytes } from "node:crypto";
|
|
59
58
|
import { dirname, join } from "node:path";
|
|
60
59
|
// Definition-site import, not the ../config barrel -- same reasoning as
|
|
@@ -66,6 +65,12 @@ import type { OcxSpendConfig, OcxSpendScopeConfig } from "../types/config";
|
|
|
66
65
|
import { assertNotRealHomeUnderTest } from "./test-home-guard";
|
|
67
66
|
// Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
|
|
68
67
|
import { hardenSecretPath } from "./windows-secret-acl";
|
|
68
|
+
import { assertSpendLedgerOwnerHeld, assertStorageOwned, bindSpendLedgerOwnerHome, mintSpendLedgerStorage, onSpendLedgerOwnerReleased, resetSpendLedgerOwnerBindingForTest, SpendLedgerOwnerError, spendLedgerOwnerSnapshot, spendLedgerStoragePath, type SpendLedgerStorage } from "./spend-ledger-owner";
|
|
69
|
+
|
|
70
|
+
// The singleton belongs to the state directory it was built for. Releasing ownership hands that
|
|
71
|
+
// directory to whoever comes next, so the in-memory copy goes with it and the next construction
|
|
72
|
+
// replays the journal.
|
|
73
|
+
onSpendLedgerOwnerReleased(() => { sharedLedger = undefined; });
|
|
69
74
|
|
|
70
75
|
export const SPEND_LEDGER_JOURNAL_FILENAME = "spend-ledger.jsonl";
|
|
71
76
|
/**
|
|
@@ -391,7 +396,70 @@ function hardenLedgerFile(path: string, options: { readonly force?: boolean } =
|
|
|
391
396
|
} catch { /* best-effort: a non-owner cannot chmod */ }
|
|
392
397
|
}
|
|
393
398
|
|
|
394
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Fault injection for the journal's own filesystem steps. Internal test contract, not config.
|
|
401
|
+
*
|
|
402
|
+
* The compaction cleanup only runs when a step after the exclusive create fails, and there is no
|
|
403
|
+
* portable way to make a validate, harden or rename fail on demand. Without a seam the cleanup
|
|
404
|
+
* would ship asserted by reading alone, which is how a failure path stays broken.
|
|
405
|
+
*/
|
|
406
|
+
export type SpendJournalFaultStep = "stat" | "create" | "write" | "validate" | "harden" | "rename";
|
|
407
|
+
|
|
408
|
+
let journalFaultForTests: ((step: SpendJournalFaultStep, temp: string) => void) | undefined;
|
|
409
|
+
|
|
410
|
+
export function setSpendJournalFaultForTests(
|
|
411
|
+
fault: ((step: SpendJournalFaultStep, temp: string) => void) | undefined,
|
|
412
|
+
): void {
|
|
413
|
+
journalFaultForTests = fault;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Does a directory entry exist here, whatever it points at?
|
|
418
|
+
*
|
|
419
|
+
* `existsSync` follows the link, so a symlink whose target is absent reads as "no file" and an
|
|
420
|
+
* append then creates that target somewhere else entirely. The entry itself is what decides
|
|
421
|
+
* whether the safety check runs.
|
|
422
|
+
*/
|
|
423
|
+
function ledgerEntryExists(path: string): boolean {
|
|
424
|
+
try {
|
|
425
|
+
journalFaultForTests?.("stat", path);
|
|
426
|
+
lstatSync(path);
|
|
427
|
+
return true;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
// Only a genuinely absent entry is absent. Treating every failure as "no file" meant a
|
|
430
|
+
// permission denial or an I/O error skipped assertSafeLedgerFile entirely and let the
|
|
431
|
+
// append proceed against whatever is actually there, which is the case that check exists
|
|
432
|
+
// for. An entry we cannot inspect is a refusal, not an empty slot.
|
|
433
|
+
//
|
|
434
|
+
// Raised as the module's typed error rather than the raw fs error: the path and errno of a
|
|
435
|
+
// state file are not something a client should be handed.
|
|
436
|
+
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") return false;
|
|
437
|
+
throw new SpendLedgerOwnerError(
|
|
438
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
439
|
+
"Spend-ledger storage could not be inspected safely.",
|
|
440
|
+
{ cause: error },
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function assertSafeLedgerFile(path: string): void {
|
|
446
|
+
const stat = lstatSync(path);
|
|
447
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1
|
|
448
|
+
|| (process.platform !== "win32" && stat.uid !== process.getuid!())) {
|
|
449
|
+
throw new SpendLedgerOwnerError(
|
|
450
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
451
|
+
"Spend-ledger storage could not be opened safely.",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The production journal. Its location comes from the owned state directory and every touch
|
|
458
|
+
* proves that ownership, so there is no entrypoint here that writes a caller-chosen path.
|
|
459
|
+
*/
|
|
460
|
+
export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJournal {
|
|
461
|
+
assertStorageOwned(storage);
|
|
462
|
+
const path = spendLedgerStoragePath(storage);
|
|
395
463
|
const ensureDir = (): string => {
|
|
396
464
|
const dir = dirname(path);
|
|
397
465
|
// The guard runs before any mutation so a rejected write leaves nothing behind.
|
|
@@ -401,26 +469,66 @@ export function createFileSpendJournal(path: string): SpendJournal {
|
|
|
401
469
|
};
|
|
402
470
|
return {
|
|
403
471
|
read(): string[] {
|
|
404
|
-
|
|
472
|
+
assertStorageOwned(storage);
|
|
473
|
+
if (!ledgerEntryExists(path)) return [];
|
|
474
|
+
assertSafeLedgerFile(path);
|
|
405
475
|
// Replay is once per process and is the moment a journal inherited from an older build
|
|
406
476
|
// or a restored backup first passes through here.
|
|
407
477
|
hardenLedgerFile(path, { force: true });
|
|
408
478
|
return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0);
|
|
409
479
|
},
|
|
410
480
|
append(line: string): void {
|
|
481
|
+
assertStorageOwned(storage);
|
|
411
482
|
ensureDir();
|
|
412
|
-
const created = !
|
|
483
|
+
const created = !ledgerEntryExists(path);
|
|
484
|
+
if (!created) assertSafeLedgerFile(path);
|
|
413
485
|
appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 });
|
|
486
|
+
assertSafeLedgerFile(path);
|
|
414
487
|
hardenLedgerFile(path, { force: created });
|
|
415
488
|
},
|
|
416
489
|
rewrite(lines: string[]): void {
|
|
490
|
+
assertStorageOwned(storage);
|
|
417
491
|
ensureDir();
|
|
418
492
|
// Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction
|
|
419
493
|
// leaves either the old journal or the new one, never a half-written ledger.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
494
|
+
if (ledgerEntryExists(path)) assertSafeLedgerFile(path);
|
|
495
|
+
const temp = `${path}.compact-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
496
|
+
// The creation is INSIDE the cleanup, not before it. The name carries random bytes, so a
|
|
497
|
+
// failure anywhere after the entry exists used to leave a uniquely named file and the next
|
|
498
|
+
// attempt made another: repeated failures accumulated instead of overwriting one fixed
|
|
499
|
+
// name. Only an entry this call created is removed, so a name that turned out to belong to
|
|
500
|
+
// something else is left alone, and the original journal and the primary error survive.
|
|
501
|
+
let fd: number | undefined;
|
|
502
|
+
let created = false;
|
|
503
|
+
let renamed = false;
|
|
504
|
+
try {
|
|
505
|
+
// Exclusive create FIRST, so "this entry is ours" is a fact rather than a guess about
|
|
506
|
+
// which error a combined write threw. EEXIST leaves created false and the name is left
|
|
507
|
+
// alone; every failure after this point is cleaned because the entry is provably ours,
|
|
508
|
+
// including a write that stopped partway through.
|
|
509
|
+
journalFaultForTests?.("create", temp);
|
|
510
|
+
fd = openSync(temp, "wx", 0o600);
|
|
511
|
+
created = true;
|
|
512
|
+
journalFaultForTests?.("write", temp);
|
|
513
|
+
writeFileSync(fd, lines.map((line) => line + "\n").join(""), { encoding: "utf8" });
|
|
514
|
+
closeSync(fd);
|
|
515
|
+
fd = undefined;
|
|
516
|
+
journalFaultForTests?.("validate", temp);
|
|
517
|
+
assertSafeLedgerFile(temp);
|
|
518
|
+
journalFaultForTests?.("harden", temp);
|
|
519
|
+
hardenLedgerFile(temp, { force: true });
|
|
520
|
+
journalFaultForTests?.("rename", temp);
|
|
521
|
+
renameSync(temp, path);
|
|
522
|
+
renamed = true;
|
|
523
|
+
} finally {
|
|
524
|
+
if (fd !== undefined) {
|
|
525
|
+
try { closeSync(fd); } catch { /* the compaction failure is the one to report */ }
|
|
526
|
+
}
|
|
527
|
+
if (created && !renamed) {
|
|
528
|
+
try { unlinkSync(temp); } catch { /* same */ }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
assertSafeLedgerFile(path);
|
|
424
532
|
hardenLedgerFile(path, { force: true });
|
|
425
533
|
},
|
|
426
534
|
};
|
|
@@ -433,17 +541,26 @@ export function createFileSpendJournal(path: string): SpendJournal {
|
|
|
433
541
|
* recorded spend, which would hand every scope a fresh allowance -- so it is a file, not a
|
|
434
542
|
* per-process value.
|
|
435
543
|
*/
|
|
436
|
-
export function loadOrCreateSpendLedgerSalt(
|
|
437
|
-
|
|
544
|
+
export function loadOrCreateSpendLedgerSalt(storage: SpendLedgerStorage): string {
|
|
545
|
+
assertStorageOwned(storage);
|
|
546
|
+
const path = spendLedgerStoragePath(storage);
|
|
547
|
+
if (ledgerEntryExists(path)) {
|
|
548
|
+
assertSafeLedgerFile(path);
|
|
438
549
|
hardenLedgerFile(path, { force: true });
|
|
439
550
|
const existing = readFileSync(path, "utf8").trim();
|
|
440
551
|
if (/^[0-9a-f]{32,}$/.test(existing)) return existing;
|
|
552
|
+
throw new SpendLedgerOwnerError(
|
|
553
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
554
|
+
"Spend-ledger storage could not be opened safely.",
|
|
555
|
+
);
|
|
441
556
|
}
|
|
442
557
|
const dir = dirname(path);
|
|
558
|
+
assertStorageOwned(storage);
|
|
443
559
|
assertNotRealHomeUnderTest(dir);
|
|
444
560
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
445
561
|
const salt = randomBytes(32).toString("hex");
|
|
446
|
-
writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600 });
|
|
562
|
+
writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
563
|
+
assertSafeLedgerFile(path);
|
|
447
564
|
hardenLedgerFile(path, { force: true });
|
|
448
565
|
return salt;
|
|
449
566
|
}
|
|
@@ -529,6 +646,12 @@ export function createSpendReservationLedger(options: {
|
|
|
529
646
|
* no file anyone could correlate.
|
|
530
647
|
*/
|
|
531
648
|
readonly salt?: string;
|
|
649
|
+
/**
|
|
650
|
+
* Shared production ledgers prove their exact ownership before reading or changing
|
|
651
|
+
* accounting. Identity, not just the directory name: a handle kept across a release and a
|
|
652
|
+
* reacquire describes a journal another writer may have changed in between.
|
|
653
|
+
*/
|
|
654
|
+
readonly assertOwnedAccounting?: () => void;
|
|
532
655
|
} = {}): SpendReservationLedger {
|
|
533
656
|
// Mutable because the ceilings are operator configuration, and configuration is reloadable.
|
|
534
657
|
// The three bounds below are read through functions for the same reason: a value captured
|
|
@@ -538,6 +661,7 @@ export function createSpendReservationLedger(options: {
|
|
|
538
661
|
const journal = options.journal;
|
|
539
662
|
const now = options.now ?? (() => Date.now());
|
|
540
663
|
const salt = options.salt ?? "";
|
|
664
|
+
const assertOwnedAccounting = options.assertOwnedAccounting;
|
|
541
665
|
const maxTrackedScopes = (): number => policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
|
|
542
666
|
const maxTrackedSends = (): number => policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
|
|
543
667
|
const compactAfterRecords = (): number => policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
|
|
@@ -592,7 +716,8 @@ export function createSpendReservationLedger(options: {
|
|
|
592
716
|
journal.append(JSON.stringify(record));
|
|
593
717
|
recordsOnDisk += 1;
|
|
594
718
|
return true;
|
|
595
|
-
} catch {
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error instanceof SpendLedgerOwnerError) throw error;
|
|
596
721
|
// In-memory state still bounds this process; the counter is how a caller learns the
|
|
597
722
|
// restart guarantee degraded instead of discovering it after the fact.
|
|
598
723
|
persistFailures += 1;
|
|
@@ -819,7 +944,8 @@ export function createSpendReservationLedger(options: {
|
|
|
819
944
|
try {
|
|
820
945
|
rewrite.call(journal, [JSON.stringify(checkpoint)]);
|
|
821
946
|
recordsOnDisk = 1;
|
|
822
|
-
} catch {
|
|
947
|
+
} catch (error) {
|
|
948
|
+
if (error instanceof SpendLedgerOwnerError) throw error;
|
|
823
949
|
// Compaction is maintenance, not accounting: a failed rewrite leaves the previous
|
|
824
950
|
// journal intact and every figure in it still replayable.
|
|
825
951
|
persistFailures += 1;
|
|
@@ -844,12 +970,15 @@ export function createSpendReservationLedger(options: {
|
|
|
844
970
|
};
|
|
845
971
|
|
|
846
972
|
return {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
get
|
|
850
|
-
get
|
|
973
|
+
// Every figure this ledger reports describes a journal it must still own. Reporting one
|
|
974
|
+
// after ownership ended is the same error as writing then, with a quieter symptom.
|
|
975
|
+
get persistFailures() { assertOwnedAccounting?.(); return persistFailures; },
|
|
976
|
+
get corruptRecords() { assertOwnedAccounting?.(); return corruptRecords; },
|
|
977
|
+
get degraded() { assertOwnedAccounting?.(); return persistFailures > 0 || corruptRecords > 0; },
|
|
978
|
+
get policy() { assertOwnedAccounting?.(); return policy; },
|
|
851
979
|
|
|
852
980
|
reserve(request: SpendReservationRequest): SpendReservationDecision {
|
|
981
|
+
assertOwnedAccounting?.();
|
|
853
982
|
const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
|
|
854
983
|
const at = request.at ?? now();
|
|
855
984
|
const send = aliasFor("send", request.sendId);
|
|
@@ -904,6 +1033,7 @@ export function createSpendReservationLedger(options: {
|
|
|
904
1033
|
},
|
|
905
1034
|
|
|
906
1035
|
markDispatched(sendId: string): boolean {
|
|
1036
|
+
assertOwnedAccounting?.();
|
|
907
1037
|
const send = aliasFor("send", sendId);
|
|
908
1038
|
const reservation = reservations.get(send);
|
|
909
1039
|
if (!reservation || reservation.status !== "open") return false;
|
|
@@ -914,6 +1044,7 @@ export function createSpendReservationLedger(options: {
|
|
|
914
1044
|
},
|
|
915
1045
|
|
|
916
1046
|
abandon(sendId: string): boolean {
|
|
1047
|
+
assertOwnedAccounting?.();
|
|
917
1048
|
const send = aliasFor("send", sendId);
|
|
918
1049
|
const reservation = reservations.get(send);
|
|
919
1050
|
// Only an UNDISPATCHED reservation may be released for free. Once bytes have left for
|
|
@@ -926,6 +1057,7 @@ export function createSpendReservationLedger(options: {
|
|
|
926
1057
|
},
|
|
927
1058
|
|
|
928
1059
|
settle(sendId: string, usage: SpendUsage): boolean {
|
|
1060
|
+
assertOwnedAccounting?.();
|
|
929
1061
|
const send = aliasFor("send", sendId);
|
|
930
1062
|
const reservation = reservations.get(send);
|
|
931
1063
|
if (!reservation || !isLive(reservation.status)) return false;
|
|
@@ -937,6 +1069,7 @@ export function createSpendReservationLedger(options: {
|
|
|
937
1069
|
},
|
|
938
1070
|
|
|
939
1071
|
markLost(sendId: string): boolean {
|
|
1072
|
+
assertOwnedAccounting?.();
|
|
940
1073
|
const send = aliasFor("send", sendId);
|
|
941
1074
|
const reservation = reservations.get(send);
|
|
942
1075
|
if (!reservation || !isLive(reservation.status)) return false;
|
|
@@ -947,10 +1080,14 @@ export function createSpendReservationLedger(options: {
|
|
|
947
1080
|
},
|
|
948
1081
|
|
|
949
1082
|
knows(sendId: string): boolean {
|
|
1083
|
+
assertOwnedAccounting?.();
|
|
950
1084
|
return reservations.has(aliasFor("send", sendId));
|
|
951
1085
|
},
|
|
952
1086
|
|
|
953
1087
|
snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined {
|
|
1088
|
+
// Reading accounting from a handle whose ownership has ended is as wrong as writing it:
|
|
1089
|
+
// the figures describe a journal this process no longer owns.
|
|
1090
|
+
assertOwnedAccounting?.();
|
|
954
1091
|
const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
|
|
955
1092
|
if (!state) return undefined;
|
|
956
1093
|
return {
|
|
@@ -962,11 +1099,13 @@ export function createSpendReservationLedger(options: {
|
|
|
962
1099
|
},
|
|
963
1100
|
|
|
964
1101
|
exhausted(scope: SpendScope, scopeId: string): boolean {
|
|
1102
|
+
assertOwnedAccounting?.();
|
|
965
1103
|
const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
|
|
966
1104
|
return state !== undefined && isExhausted(scope, state);
|
|
967
1105
|
},
|
|
968
1106
|
|
|
969
1107
|
prune(at: number = now()): void {
|
|
1108
|
+
assertOwnedAccounting?.();
|
|
970
1109
|
// Removal requires BOTH inactive and not exhausted inside the window. An
|
|
971
1110
|
// exhausted-but-idle scope that was dropped would be recreated fresh under the
|
|
972
1111
|
// same id -- the exact laundering the ceiling exists to stop.
|
|
@@ -975,6 +1114,7 @@ export function createSpendReservationLedger(options: {
|
|
|
975
1114
|
},
|
|
976
1115
|
|
|
977
1116
|
reconfigure(next: SpendReservationPolicy): void {
|
|
1117
|
+
assertOwnedAccounting?.();
|
|
978
1118
|
policy = next;
|
|
979
1119
|
},
|
|
980
1120
|
};
|
|
@@ -1036,6 +1176,12 @@ export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined): SpendR
|
|
|
1036
1176
|
* configures no ceiling must not open a journal merely because the server started.
|
|
1037
1177
|
*/
|
|
1038
1178
|
export function configureSharedSpendLedger(policy: SpendReservationPolicy): void {
|
|
1179
|
+
// Recording a policy value touches no journal, so it needs no ownership. Changing a ledger
|
|
1180
|
+
// that already exists does, because that ledger is a live view of an owned directory.
|
|
1181
|
+
if (sharedLedger) {
|
|
1182
|
+
assertSpendLedgerOwnerHeld();
|
|
1183
|
+
bindSpendLedgerOwnerHome();
|
|
1184
|
+
}
|
|
1039
1185
|
sharedPolicy = policy;
|
|
1040
1186
|
sharedLedger?.reconfigure(policy);
|
|
1041
1187
|
}
|
|
@@ -1043,22 +1189,67 @@ export function configureSharedSpendLedger(policy: SpendReservationPolicy): void
|
|
|
1043
1189
|
/**
|
|
1044
1190
|
* Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
|
|
1045
1191
|
* importing the module -- or running a request path that never reserves -- touches no
|
|
1046
|
-
* disk.
|
|
1192
|
+
* disk. One directory at a time, not one directory for the life of the process: the singleton
|
|
1193
|
+
* is discarded when its ownership ends, so a later directory replays its own journal rather
|
|
1194
|
+
* than inheriting figures from the previous one.
|
|
1047
1195
|
*/
|
|
1048
1196
|
export function sharedSpendLedger(): SpendReservationLedger {
|
|
1197
|
+
assertSpendLedgerOwnerHeld();
|
|
1198
|
+
bindSpendLedgerOwnerHome();
|
|
1049
1199
|
if (!sharedLedger) {
|
|
1050
|
-
|
|
1200
|
+
// Minted by the owner module from the directory it actually owns, and carrying the exact
|
|
1201
|
+
// ownership they were minted under. Nothing here chooses a path or supplies its own guard.
|
|
1202
|
+
const journalStorage = mintSpendLedgerStorage(SPEND_LEDGER_JOURNAL_FILENAME);
|
|
1203
|
+
const saltStorage = mintSpendLedgerStorage(SPEND_LEDGER_SALT_FILENAME);
|
|
1204
|
+
const journalPath = spendLedgerStoragePath(journalStorage);
|
|
1205
|
+
const saltPath = spendLedgerStoragePath(saltStorage);
|
|
1206
|
+
const assertOwnedAccounting = (): void => {
|
|
1207
|
+
assertStorageOwned(journalStorage);
|
|
1208
|
+
if (ledgerEntryExists(journalPath)) assertSafeLedgerFile(journalPath);
|
|
1209
|
+
if (ledgerEntryExists(saltPath)) assertSafeLedgerFile(saltPath);
|
|
1210
|
+
};
|
|
1051
1211
|
sharedLedger = createSpendReservationLedger({
|
|
1052
|
-
journal:
|
|
1053
|
-
salt: loadOrCreateSpendLedgerSalt(
|
|
1212
|
+
journal: createOwnedFileSpendJournal(journalStorage),
|
|
1213
|
+
salt: loadOrCreateSpendLedgerSalt(saltStorage),
|
|
1054
1214
|
policy: sharedPolicy,
|
|
1215
|
+
assertOwnedAccounting,
|
|
1055
1216
|
});
|
|
1056
1217
|
}
|
|
1057
1218
|
return sharedLedger;
|
|
1058
1219
|
}
|
|
1059
1220
|
|
|
1060
|
-
|
|
1221
|
+
const MAX_DIAGNOSTIC_ERROR_COUNT = 1_000_000;
|
|
1222
|
+
|
|
1223
|
+
/** Scalar-only and side-effect-free: reading diagnostics never constructs or replays. */
|
|
1224
|
+
export function spendLedgerDiagnosticsSnapshot(): {
|
|
1225
|
+
readonly ownership: "held" | "unheld";
|
|
1226
|
+
readonly initialized: boolean;
|
|
1227
|
+
readonly configured: boolean;
|
|
1228
|
+
readonly degraded: boolean;
|
|
1229
|
+
readonly persistFailures: number;
|
|
1230
|
+
readonly corruptRecords: number;
|
|
1231
|
+
} {
|
|
1232
|
+
const ledger = sharedLedger;
|
|
1233
|
+
const bounded = (value: number): number => Math.min(MAX_DIAGNOSTIC_ERROR_COUNT, Math.max(0, value));
|
|
1234
|
+
return {
|
|
1235
|
+
...spendLedgerOwnerSnapshot(),
|
|
1236
|
+
initialized: ledger !== undefined,
|
|
1237
|
+
configured: spendCeilingsConfigured(sharedPolicy),
|
|
1238
|
+
degraded: ledger?.degraded ?? false,
|
|
1239
|
+
persistFailures: bounded(ledger?.persistFailures ?? 0),
|
|
1240
|
+
corruptRecords: bounded(ledger?.corruptRecords ?? 0),
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Test seam for discarding the singleton outright.
|
|
1246
|
+
*
|
|
1247
|
+
* Production discards it too, but only with the ownership it belongs to, which is what
|
|
1248
|
+
* `onSpendLedgerOwnerReleased` does. Neither path resets a spent budget: the journal is the
|
|
1249
|
+
* durable record and the next construction replays it.
|
|
1250
|
+
*/
|
|
1061
1251
|
export function resetSharedSpendLedgerForTest(): void {
|
|
1062
1252
|
sharedLedger = undefined;
|
|
1063
1253
|
sharedPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
|
|
1254
|
+
resetSpendLedgerOwnerBindingForTest();
|
|
1064
1255
|
}
|
|
@@ -16,7 +16,7 @@ import { decodeWindowsTextBytes } from "./windows-text";
|
|
|
16
16
|
const INTERNET_SETTINGS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
|
|
17
17
|
|
|
18
18
|
export type WindowsSystemProxyResult =
|
|
19
|
-
| { kind: "proxy";
|
|
19
|
+
| { kind: "proxy"; httpUrl?: string; httpsUrl?: string }
|
|
20
20
|
| { kind: "disabled" }
|
|
21
21
|
| { kind: "socks-only" }
|
|
22
22
|
| { kind: "unsupported" }
|
|
@@ -63,35 +63,40 @@ export function readWindowsProxyRegistry(): WindowsProxyRegistryValues | null {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* `ProxyServer` is either a bare `host:port` (applies to every scheme) or a semicolon list of
|
|
66
|
-
* `scheme=host:port` entries.
|
|
67
|
-
* mirrored into
|
|
66
|
+
* `scheme=host:port` entries. Bare values apply to both HTTP and HTTPS destinations; per-scheme
|
|
67
|
+
* values retain their WinINET scope. A SOCKS-only value cannot be mirrored into HTTP(S)_PROXY.
|
|
68
68
|
*/
|
|
69
|
-
export function parseWindowsProxyServer(value: string): { kind: "proxy"
|
|
69
|
+
export function parseWindowsProxyServer(value: string): Extract<WindowsSystemProxyResult, { kind: "proxy" | "socks-only" | "disabled" }> {
|
|
70
70
|
const trimmed = value.trim();
|
|
71
71
|
if (!trimmed) return { kind: "disabled" };
|
|
72
|
-
if (!trimmed.includes("="))
|
|
72
|
+
if (!trimmed.includes("=")) {
|
|
73
|
+
const url = normalize(trimmed);
|
|
74
|
+
return url ? { kind: "proxy", httpUrl: url, httpsUrl: url } : { kind: "disabled" };
|
|
75
|
+
}
|
|
73
76
|
const entries = new Map<string, string>();
|
|
74
77
|
for (const part of trimmed.split(";")) {
|
|
75
78
|
const eq = part.indexOf("=");
|
|
76
79
|
if (eq <= 0) continue;
|
|
77
80
|
entries.set(part.slice(0, eq).trim().toLowerCase(), part.slice(eq + 1).trim());
|
|
78
81
|
}
|
|
79
|
-
const
|
|
80
|
-
|
|
82
|
+
const httpUrl = normalize(entries.get("http") ?? "");
|
|
83
|
+
const httpsUrl = normalize(entries.get("https") ?? "");
|
|
84
|
+
if (httpUrl || httpsUrl) return { kind: "proxy", ...(httpUrl && { httpUrl }), ...(httpsUrl && { httpsUrl }) };
|
|
81
85
|
if (entries.has("socks")) return { kind: "socks-only" };
|
|
82
86
|
return { kind: "disabled" };
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
function normalize(hostPort: string):
|
|
89
|
+
function normalize(hostPort: string): string | undefined {
|
|
90
|
+
if (!hostPort) return undefined;
|
|
86
91
|
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(hostPort) ? hostPort : `http://${hostPort}`;
|
|
87
92
|
try {
|
|
88
93
|
const url = new URL(withScheme);
|
|
89
|
-
if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return
|
|
94
|
+
if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return undefined;
|
|
90
95
|
// Keep userinfo: a credentialed proxy is valid in HTTP_PROXY. Only the log strips it.
|
|
91
96
|
const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ""}@` : "";
|
|
92
|
-
return
|
|
97
|
+
return `${url.protocol}//${auth}${url.host}`;
|
|
93
98
|
} catch {
|
|
94
|
-
return
|
|
99
|
+
return undefined;
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
|
|
@@ -252,9 +252,10 @@ export abstract class OAuthCallbackFlow {
|
|
|
252
252
|
if (!parsed.code) return null;
|
|
253
253
|
// Kind-aware state enforcement: url/query-shaped input is an authorization
|
|
254
254
|
// RESPONSE and must carry a matching state — missing state is rejected, not
|
|
255
|
-
// downgraded to raw.
|
|
256
|
-
//
|
|
257
|
-
|
|
255
|
+
// downgraded to raw. A raw paste with an explicit code#state suffix is
|
|
256
|
+
// state-bearing too. Only a syntactically raw code WITHOUT a state suffix
|
|
257
|
+
// (same PKCE session) is exempt, so the CLI/GUI paste fallback still works.
|
|
258
|
+
if ((parsed.kind !== "raw" || parsed.state !== undefined) && expectedState && parsed.state !== expectedState) return null;
|
|
258
259
|
return { code: parsed.code, state: parsed.state ?? expectedState };
|
|
259
260
|
})
|
|
260
261
|
.catch((): CallbackResult | null => null),
|
|
@@ -234,6 +234,7 @@ function stableGenericRoster(providerName: string): string[] {
|
|
|
234
234
|
* quota-less provider off its active account on the very first request.
|
|
235
235
|
*/
|
|
236
236
|
function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
|
|
237
|
+
if (threshold <= 0) return false;
|
|
237
238
|
const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
|
|
238
239
|
if (headroom === null) return false;
|
|
239
240
|
return 100 - headroom >= threshold;
|
package/src/oauth/health.ts
CHANGED
|
@@ -202,6 +202,7 @@ export function projectStoredOAuthAccountHealth(
|
|
|
202
202
|
export function projectCodexAccountHealth(input: {
|
|
203
203
|
accountId: string;
|
|
204
204
|
needsReauth: boolean;
|
|
205
|
+
reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
|
|
205
206
|
now?: number;
|
|
206
207
|
}): OAuthAccountHealth {
|
|
207
208
|
// One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
|
|
@@ -230,6 +231,16 @@ export function projectCodexAccountHealth(input: {
|
|
|
230
231
|
|
|
231
232
|
const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure;
|
|
232
233
|
|
|
234
|
+
// A stored verification failure carries its own cause. When the caller did not name a
|
|
235
|
+
// reason, surface the persisted one instead of flattening every verdict into
|
|
236
|
+
// `refresh_failed`.
|
|
237
|
+
const storedVerificationFailed = validationAuthFailed || terminalGrantFailure;
|
|
238
|
+
const storedFailureReason: "unauthorized" | "forbidden" | undefined = !storedVerificationFailed
|
|
239
|
+
? undefined
|
|
240
|
+
: record?.lastCodexValidationError === "http_status:403" ? "forbidden"
|
|
241
|
+
: record?.lastCodexValidationError === "http_status:401" ? "unauthorized"
|
|
242
|
+
: undefined;
|
|
243
|
+
|
|
233
244
|
// Deferred validation is only worth reporting while the credential itself is still viable. A
|
|
234
245
|
// revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first.
|
|
235
246
|
if (!needsReauth && record?.codexValidationPending) {
|
|
@@ -239,7 +250,7 @@ export function projectCodexAccountHealth(input: {
|
|
|
239
250
|
const snap = getCodexAccountHealthSnapshot(input.accountId, now);
|
|
240
251
|
return projectOAuthAccountHealth({
|
|
241
252
|
needsReauth,
|
|
242
|
-
reauthReason: needsReauth ? "refresh_failed" : undefined,
|
|
253
|
+
reauthReason: needsReauth ? (input.reauthReason ?? storedFailureReason ?? "refresh_failed") : undefined,
|
|
243
254
|
cooldownUntilMs: snap?.cooldownUntil,
|
|
244
255
|
cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
|
|
245
256
|
now,
|