@bitkyc08/opencodex 2.56.0 → 2.58.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
- package/src/adapters/command-code.ts +12 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +19 -1
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +55 -5
- package/src/adapters/google-http.ts +38 -13
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +62 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/physical-send.ts +50 -0
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +8 -2
- package/src/bridge/sse.ts +20 -2
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +32 -4
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/config-command.ts +35 -18
- package/src/cli/dispatch.ts +17 -4
- package/src/cli/index.ts +92 -7
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +74 -5
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +199 -15
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +33 -4
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +231 -32
- package/src/codex/inject.ts +12 -16
- package/src/codex/loopback-target.ts +9 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/atomic-write.ts +83 -8
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +272 -180
- package/src/images/loop.ts +1 -1
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/derive.ts +6 -0
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +35 -7
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +59 -28
- package/src/providers/registry/model-seeds.ts +71 -17
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +54 -3
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +27 -5
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +129 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/fetch-helpers.ts +4 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +30 -6
- package/src/server/responses/passthrough-dispatch.ts +61 -11
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +173 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses/ws-upstream.ts +21 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +13 -1
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +8 -1
- package/src/types/provider.ts +13 -0
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
package/src/images/loop.ts
CHANGED
|
@@ -615,7 +615,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
615
615
|
signal: headerDeadline.signal,
|
|
616
616
|
}, retryRecovery));
|
|
617
617
|
},
|
|
618
|
-
{ abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
|
|
618
|
+
{ replaySafe: true, abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
|
|
619
619
|
);
|
|
620
620
|
}
|
|
621
621
|
} finally {
|
|
@@ -9,28 +9,80 @@ export interface BoundedSubprocessExit {
|
|
|
9
9
|
timedOut: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
export type SubprocessDeadlineScheduler = (
|
|
13
|
+
callback: () => void,
|
|
14
|
+
milliseconds: number,
|
|
15
|
+
) => () => void;
|
|
16
|
+
|
|
17
|
+
const scheduleDeadline: SubprocessDeadlineScheduler = (callback, milliseconds) => {
|
|
18
|
+
const timer = setTimeout(callback, milliseconds);
|
|
19
|
+
return () => clearTimeout(timer);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compatibility allowance used by the ACL runner's outer watchdog.
|
|
24
|
+
*
|
|
25
|
+
* `kill()` only REQUESTS termination. It returns before the kernel has torn the process down, and
|
|
26
|
+
* every handle that process holds stays held until it does. On Windows that is not a detail: file
|
|
27
|
+
* locking is mandatory, so a directory an abandoned `icacls.exe` still has open cannot be removed
|
|
28
|
+
* by anyone, and the removal fails with EPERM rather than waiting.
|
|
29
|
+
*/
|
|
30
|
+
export const SUBPROCESS_KILL_GRACE_MS = 2_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wait for a child until the deadline. At the deadline, kill it AND wait for it to actually die.
|
|
34
|
+
*
|
|
35
|
+
* This used to kill, `unref`, and resolve in the same tick, which made every caller's "I waited
|
|
36
|
+
* for my child" guarantee false precisely when it mattered. The ACL runner now waits here until
|
|
37
|
+
* actual exit; if its separate caller-facing belt fires first, that layer registers the target so
|
|
38
|
+
* removal can wait for the reap without making ordinary startup or shutdown unbounded.
|
|
39
|
+
*
|
|
40
|
+
* That cost three failed fixes. #4789 blamed the removal retry budget and asked for more than
|
|
41
|
+
* 2.5s; #4796 gave it a 15s exponential schedule; a later change awaited the hardening flight from
|
|
42
|
+
* the test hook. Windows shard 1/6 failed identically through all three, because none of them
|
|
43
|
+
* addressed a live process holding the handle -- run 35108652486 burned the full 15s budget and
|
|
44
|
+
* still threw `EPERM ... rm ocx-management-auth-fDchUb`, with two
|
|
45
|
+
* `ACL hardening timed out (ETIMEDOUT) - transient icacls stall` lines logged beside it.
|
|
46
|
+
*
|
|
47
|
+
* The old grace still abandoned a live child after two seconds. That recreated the same false
|
|
48
|
+
* ownership contract on a slower clock: the ACL flight settled, cleanup removed the directory,
|
|
49
|
+
* and Windows returned EPERM because the child still held it. A handle-bearing caller therefore
|
|
50
|
+
* has no second deadline after kill. The child's actual exit is the only release signal.
|
|
51
|
+
*
|
|
52
|
+
* Pass `0` to opt out for a child that holds no path anyone will remove. The numeric form is kept
|
|
53
|
+
* for compatibility with the existing callers; any positive value means that reaping is required.
|
|
54
|
+
* The injected scheduler is a test seam so deadline and exit ordering can be proved without sleep.
|
|
55
|
+
*/
|
|
13
56
|
export function waitForSubprocessExit(
|
|
14
57
|
proc: KillableSubprocess,
|
|
15
58
|
timeoutMs: number,
|
|
59
|
+
reapAfterKill: number = SUBPROCESS_KILL_GRACE_MS,
|
|
60
|
+
schedule: SubprocessDeadlineScheduler = scheduleDeadline,
|
|
16
61
|
): Promise<BoundedSubprocessExit> {
|
|
17
62
|
return new Promise(resolve => {
|
|
18
63
|
let settled = false;
|
|
19
|
-
let
|
|
64
|
+
let deadlineFired = false;
|
|
65
|
+
let cancelDeadline: (() => void) | undefined;
|
|
20
66
|
const finish = (result: BoundedSubprocessExit): void => {
|
|
21
67
|
if (settled) return;
|
|
22
68
|
settled = true;
|
|
23
|
-
|
|
69
|
+
cancelDeadline?.();
|
|
24
70
|
resolve(result);
|
|
25
71
|
};
|
|
26
|
-
|
|
72
|
+
const reaped = proc.exited.then(
|
|
73
|
+
exitCode => finish(deadlineFired
|
|
74
|
+
? { exitCode: null, timedOut: true }
|
|
75
|
+
: { exitCode, timedOut: false }),
|
|
76
|
+
() => finish({ exitCode: null, timedOut: deadlineFired }),
|
|
77
|
+
);
|
|
78
|
+
cancelDeadline = schedule(() => {
|
|
79
|
+
deadlineFired = true;
|
|
27
80
|
try { proc.kill(); } catch { /* already exited */ }
|
|
28
|
-
|
|
29
|
-
|
|
81
|
+
if (reapAfterKill <= 0) {
|
|
82
|
+
try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
|
|
83
|
+
finish({ exitCode: null, timedOut: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
30
86
|
}, Math.max(1, timeoutMs));
|
|
31
|
-
void proc.exited.then(
|
|
32
|
-
exitCode => finish({ exitCode, timedOut: false }),
|
|
33
|
-
() => finish({ exitCode: null, timedOut: false }),
|
|
34
|
-
);
|
|
35
87
|
});
|
|
36
88
|
}
|
package/src/lib/errors.ts
CHANGED
|
@@ -7,6 +7,15 @@ export interface OcxErrorPayload {
|
|
|
7
7
|
export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION =
|
|
8
8
|
"Encrypted function output content could not be decrypted or decoded.";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The error identity for a send this proxy declined to make (#4708).
|
|
12
|
+
*
|
|
13
|
+
* Declared here rather than only on the error class because the classifier is what decides
|
|
14
|
+
* whether the identity survives serialization, and every dispatch path has to name the same
|
|
15
|
+
* string for a client to be able to tell this apart from a provider rate limit.
|
|
16
|
+
*/
|
|
17
|
+
export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted";
|
|
18
|
+
|
|
10
19
|
/** Canonical human-readable message paths used by Responses upstream failures. */
|
|
11
20
|
export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined {
|
|
12
21
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
@@ -253,6 +262,14 @@ export function classifyError(status: number, type: string, message: string): Oc
|
|
|
253
262
|
) {
|
|
254
263
|
return { message, type: "insufficient_quota", code: "insufficient_quota" };
|
|
255
264
|
}
|
|
265
|
+
// A refusal this proxy made itself, kept apart from the provider rate limits below. The HTTP
|
|
266
|
+
// semantics are identical -- 429, do not send this again now -- but the code is the only thing
|
|
267
|
+
// that tells an operator reading a log whether the provider throttled the request or whether
|
|
268
|
+
// this process declined to send it. Folding it into the generic rate-limit code sent them to
|
|
269
|
+
// the provider's dashboard to explain a decision that was never made there.
|
|
270
|
+
if (type === SEND_BUDGET_EXHAUSTED_CODE) {
|
|
271
|
+
return { message, type: "rate_limit_error", code: SEND_BUDGET_EXHAUSTED_CODE };
|
|
272
|
+
}
|
|
256
273
|
if (
|
|
257
274
|
status === 429 ||
|
|
258
275
|
text.includes("rate limit") ||
|
|
@@ -56,6 +56,7 @@ export type BudgetDenial =
|
|
|
56
56
|
| "final-recovery-spent"
|
|
57
57
|
| "alternate-target-exhausted"
|
|
58
58
|
| "target-transition-exhausted"
|
|
59
|
+
| "spend-exhausted"
|
|
59
60
|
| "not-replay-safe";
|
|
60
61
|
|
|
61
62
|
export interface DispatchIntent {
|
|
@@ -94,12 +95,45 @@ export interface SingleUseDispatchPermit {
|
|
|
94
95
|
* once an external send reporter already settled it.
|
|
95
96
|
*/
|
|
96
97
|
release(): void;
|
|
98
|
+
/**
|
|
99
|
+
* Take over an externally counted booking, because the layer holding this permit is the one
|
|
100
|
+
* that physically sends.
|
|
101
|
+
*
|
|
102
|
+
* `countedExternally` promises that a retry helper will name this send through
|
|
103
|
+
* `onSendsConsumed`. An adapter that owns its own dispatch ladder -- Kiro's reset loop,
|
|
104
|
+
* Cursor's transport loop -- reserves per physical send instead, so no reporter ever arrives
|
|
105
|
+
* and the pending booking would sit there until it silently swallowed an unrelated later
|
|
106
|
+
* report. Confirming through this method settles the permit AND closes the booking, so the
|
|
107
|
+
* send stays charged exactly once (#4709). Returns false once the permit is settled, which is
|
|
108
|
+
* what keeps one permit from admitting two sends.
|
|
109
|
+
*/
|
|
110
|
+
assumeCharge(): boolean;
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
export type DispatchDecision =
|
|
100
114
|
| { allowed: true; permit: SingleUseDispatchPermit }
|
|
101
115
|
| { allowed: false; reason: BudgetDenial };
|
|
102
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Notified when this request's physical-send count moves.
|
|
119
|
+
*
|
|
120
|
+
* `spent` is the only number here that counts SENDS rather than intentions: a reservation
|
|
121
|
+
* increments it, a refund decrements it, and an externally reported send settles against a
|
|
122
|
+
* booking that was already counted. Anything that books one entry per increment therefore
|
|
123
|
+
* books exactly one entry per physical send -- which is what lets the durable spend ledger
|
|
124
|
+
* have a production caller without every dispatch site in the tree remembering to call it.
|
|
125
|
+
*
|
|
126
|
+
* `charge` may refuse, and a refusal denies the dispatch. That is deliberate: the ledger is
|
|
127
|
+
* the only bound here that survives a restart, so a limit it enforces has to be able to stop a
|
|
128
|
+
* send rather than merely describe one.
|
|
129
|
+
*/
|
|
130
|
+
export interface RequestSendObserver {
|
|
131
|
+
/** Book one physical send. False refuses the dispatch before the budget charges it. */
|
|
132
|
+
charge(): boolean;
|
|
133
|
+
/** Give back a booking whose send never happened. */
|
|
134
|
+
refund(): void;
|
|
135
|
+
}
|
|
136
|
+
|
|
103
137
|
/**
|
|
104
138
|
* Carried on HandleResponsesOptions so a combo child, a rebuild and an alternate-account leg
|
|
105
139
|
* all decrement the same holder. `used` is the existing #4605 counter and still counts every
|
|
@@ -134,33 +168,57 @@ const RESERVE_FUNDED_CLASSES: ReadonlySet<SendClass> = new Set<SendClass>([
|
|
|
134
168
|
|
|
135
169
|
let logicalRequestSeq = 0;
|
|
136
170
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
171
|
+
/**
|
|
172
|
+
* One request's physical-send ledger, held apart from the budget object so a derived policy
|
|
173
|
+
* scope can share the exact same one.
|
|
174
|
+
*
|
|
175
|
+
* `spent` and `pendingExternalSends` belong together: a pending booking is a send that is
|
|
176
|
+
* already counted in `spent` and awaiting its reporter, so a scope that shared one without the
|
|
177
|
+
* other would either charge that send twice or never charge it at all.
|
|
178
|
+
*
|
|
179
|
+
* The durable-spend observer belongs here for the same reason. It books one entry per physical
|
|
180
|
+
* send by watching this counter move, so a derived scope that spent the counter without
|
|
181
|
+
* carrying the observer would move it without booking, and a combo child's sends would go
|
|
182
|
+
* missing from the ledger (#4707).
|
|
183
|
+
*/
|
|
184
|
+
interface SharedSendLedger {
|
|
185
|
+
spent: number;
|
|
186
|
+
pendingExternalSends: number;
|
|
187
|
+
readonly observer?: RequestSendObserver;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>();
|
|
191
|
+
|
|
192
|
+
function createRequestExecutionBudgetWithLedger(
|
|
193
|
+
policy: RequestExecutionBudgetPolicy,
|
|
194
|
+
logicalRequestId: string | undefined,
|
|
195
|
+
counter: SharedSendLedger,
|
|
140
196
|
): RequestExecutionBudget {
|
|
141
|
-
|
|
142
|
-
// Reservations whose physical send is reported by a retry helper rather than by the permit.
|
|
143
|
-
// They are already charged; the reporter's first send settles one instead of charging again.
|
|
144
|
-
let pendingExternalSends = 0;
|
|
197
|
+
const observer = counter.observer;
|
|
145
198
|
let reserveSpent = false;
|
|
146
199
|
let alternateTargetSends = 0;
|
|
147
200
|
let targetTransitions = 0;
|
|
148
201
|
let lastTargetKey: string | undefined;
|
|
149
202
|
|
|
150
203
|
const budget: RequestExecutionBudget = {
|
|
151
|
-
get used(): number { return spent; },
|
|
204
|
+
get used(): number { return counter.spent; },
|
|
152
205
|
set used(next: number) {
|
|
153
206
|
// The retry helpers report their real send count by assigning through this field. A
|
|
154
207
|
// reservation taken with `countedExternally` has already booked one of those sends, so
|
|
155
208
|
// the report settles the pending booking first and only the surplus is charged.
|
|
156
|
-
const delta = next - spent;
|
|
209
|
+
const delta = next - counter.spent;
|
|
157
210
|
if (delta <= 0) {
|
|
158
|
-
spent = Math.max(0, next);
|
|
211
|
+
counter.spent = Math.max(0, next);
|
|
159
212
|
return;
|
|
160
213
|
}
|
|
161
|
-
const settled = Math.min(delta, pendingExternalSends);
|
|
162
|
-
pendingExternalSends -= settled;
|
|
163
|
-
|
|
214
|
+
const settled = Math.min(delta, counter.pendingExternalSends);
|
|
215
|
+
counter.pendingExternalSends -= settled;
|
|
216
|
+
const charged = delta - settled;
|
|
217
|
+
counter.spent += charged;
|
|
218
|
+
// These sends have already left. The ledger records them even past a ceiling it would
|
|
219
|
+
// have refused, because refusing after the fact only hides spend that was really
|
|
220
|
+
// incurred -- the refusal has to happen at the reservation below, or not at all.
|
|
221
|
+
for (let index = 0; index < charged; index += 1) observer?.charge();
|
|
164
222
|
},
|
|
165
223
|
logicalRequestId: logicalRequestId ?? `lr-${Date.now().toString(36)}-${(logicalRequestSeq += 1).toString(36)}`,
|
|
166
224
|
policyVersion: REQUEST_BUDGET_POLICY_VERSION,
|
|
@@ -171,11 +229,11 @@ export function createRequestExecutionBudget(
|
|
|
171
229
|
get lastTargetKey() { return lastTargetKey; },
|
|
172
230
|
remainingBaseSends(cap: number): number {
|
|
173
231
|
const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0;
|
|
174
|
-
return Math.max(0, Math.min(capped, policy.baseSendAllowance - spent));
|
|
232
|
+
return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent));
|
|
175
233
|
},
|
|
176
234
|
reserveDispatch(intent: DispatchIntent): DispatchDecision {
|
|
177
235
|
if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" };
|
|
178
|
-
if (spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
|
|
236
|
+
if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
|
|
179
237
|
|
|
180
238
|
const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey;
|
|
181
239
|
const isAlternateTarget = changesTarget || intent.sendClass === "account-failover"
|
|
@@ -190,7 +248,7 @@ export function createRequestExecutionBudget(
|
|
|
190
248
|
// The base allowance is spent first. Only once it is gone does a recovery class reach
|
|
191
249
|
// for the single shared reserve -- an account move and a validated rebuild cannot each
|
|
192
250
|
// take one.
|
|
193
|
-
const drawsReserve = policy.baseSendAllowance - spent <= 0;
|
|
251
|
+
const drawsReserve = policy.baseSendAllowance - counter.spent <= 0;
|
|
194
252
|
if (drawsReserve) {
|
|
195
253
|
if (!RESERVE_FUNDED_CLASSES.has(intent.sendClass)) {
|
|
196
254
|
return { allowed: false, reason: "base-allowance-exhausted" };
|
|
@@ -200,13 +258,18 @@ export function createRequestExecutionBudget(
|
|
|
200
258
|
}
|
|
201
259
|
}
|
|
202
260
|
|
|
261
|
+
// Consulted last, because it is the only bound here that WRITES. A ledger entry booked
|
|
262
|
+
// for a dispatch a cheaper check above would have refused is spend this request never
|
|
263
|
+
// makes, and it would hold those tokens against the scope until retention expired.
|
|
264
|
+
if (observer && !observer.charge()) return { allowed: false, reason: "spend-exhausted" };
|
|
265
|
+
|
|
203
266
|
// THE RESERVATION IS THE CHARGE. Deciding here and charging in `use()` left a window in
|
|
204
267
|
// which two legs read the same remainder, both received a permit, and both dispatched:
|
|
205
268
|
// one remaining send admitted two physical sends, which is the per-request multiplication
|
|
206
269
|
// this budget exists to stop. Everything is booked now; `release()` is the way back.
|
|
207
270
|
const previousTargetKey = lastTargetKey;
|
|
208
|
-
spent += 1;
|
|
209
|
-
if (intent.countedExternally === true) pendingExternalSends += 1;
|
|
271
|
+
counter.spent += 1;
|
|
272
|
+
if (intent.countedExternally === true) counter.pendingExternalSends += 1;
|
|
210
273
|
if (drawsReserve) reserveSpent = true;
|
|
211
274
|
if (isAlternateTarget) alternateTargetSends += 1;
|
|
212
275
|
if (changesTarget) targetTransitions += 1;
|
|
@@ -222,16 +285,28 @@ export function createRequestExecutionBudget(
|
|
|
222
285
|
settled = "used";
|
|
223
286
|
return true;
|
|
224
287
|
},
|
|
288
|
+
assumeCharge(): boolean {
|
|
289
|
+
if (settled !== "open") return false;
|
|
290
|
+
settled = "used";
|
|
291
|
+
// The booking this reservation made for an external reporter is now owned by the
|
|
292
|
+
// caller. Leaving it pending is not harmless: the next `used` report of this request
|
|
293
|
+
// would settle against it and one real send would go uncharged.
|
|
294
|
+
if (intent.countedExternally === true && counter.pendingExternalSends > 0) {
|
|
295
|
+
counter.pendingExternalSends -= 1;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
},
|
|
225
299
|
release(): void {
|
|
226
300
|
if (settled !== "open") return;
|
|
227
301
|
settled = "released";
|
|
228
302
|
// An externally counted reservation the reporter already settled paid for a send
|
|
229
303
|
// that physically happened. Refunding it would hand the request a free send back.
|
|
230
304
|
if (intent.countedExternally === true) {
|
|
231
|
-
if (pendingExternalSends === 0) return;
|
|
232
|
-
pendingExternalSends -= 1;
|
|
305
|
+
if (counter.pendingExternalSends === 0) return;
|
|
306
|
+
counter.pendingExternalSends -= 1;
|
|
233
307
|
}
|
|
234
|
-
spent -= 1;
|
|
308
|
+
counter.spent -= 1;
|
|
309
|
+
observer?.refund();
|
|
235
310
|
if (drawsReserve) reserveSpent = false;
|
|
236
311
|
if (isAlternateTarget) alternateTargetSends -= 1;
|
|
237
312
|
if (changesTarget) targetTransitions -= 1;
|
|
@@ -241,9 +316,60 @@ export function createRequestExecutionBudget(
|
|
|
241
316
|
};
|
|
242
317
|
},
|
|
243
318
|
};
|
|
319
|
+
sharedSendLedgers.set(budget, counter);
|
|
244
320
|
return budget;
|
|
245
321
|
}
|
|
246
322
|
|
|
323
|
+
export function createRequestExecutionBudget(
|
|
324
|
+
policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY,
|
|
325
|
+
logicalRequestId?: string,
|
|
326
|
+
observer?: RequestSendObserver,
|
|
327
|
+
): RequestExecutionBudget {
|
|
328
|
+
return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
|
|
329
|
+
spent: 0,
|
|
330
|
+
pendingExternalSends: 0,
|
|
331
|
+
...(observer ? { observer } : {}),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A budget that applies its own policy and keeps its own recovery ledgers while spending the
|
|
337
|
+
* parent's exact physical-send ledger.
|
|
338
|
+
*
|
|
339
|
+
* Aliasing the public `used` property was not enough, and that is the whole defect. The factory
|
|
340
|
+
* reads its own private counter back in `remainingBaseSends`, in the total check, and in the
|
|
341
|
+
* reserve test, so an aliased scope answered every admission question from a counter that only
|
|
342
|
+
* ever saw its own reservations. A combo's per-target holdback is computed from
|
|
343
|
+
* `maxTotalModelSends` and is therefore unenforceable unless the scope actually observes what
|
|
344
|
+
* the request has already spent.
|
|
345
|
+
*/
|
|
346
|
+
export function deriveRequestExecutionBudget(
|
|
347
|
+
parent: RequestExecutionBudget,
|
|
348
|
+
policy: RequestExecutionBudgetPolicy,
|
|
349
|
+
): RequestExecutionBudget {
|
|
350
|
+
return createRequestExecutionBudgetWithLedger(policy, parent.logicalRequestId, ledgerFor(parent));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A budget that did not come from this factory still honors the public `used` contract, so
|
|
355
|
+
* bridge onto it rather than failing the request. `isRequestExecutionBudget` is a shape test,
|
|
356
|
+
* so a stub can reach here; turning that into a thrown error would convert a routing request
|
|
357
|
+
* into a 500 to report a condition production never produces. Only a factory-backed parent can
|
|
358
|
+
* share pending external bookings and a durable-spend observer, which are private by
|
|
359
|
+
* construction; a bridged scope keeps the parent's spend accurate and books nothing of its own.
|
|
360
|
+
*/
|
|
361
|
+
function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
|
|
362
|
+
const existing = sharedSendLedgers.get(parent);
|
|
363
|
+
if (existing) return existing;
|
|
364
|
+
let pendingExternalSends = 0;
|
|
365
|
+
return {
|
|
366
|
+
get spent(): number { return parent.used; },
|
|
367
|
+
set spent(next: number) { parent.used = next; },
|
|
368
|
+
get pendingExternalSends(): number { return pendingExternalSends; },
|
|
369
|
+
set pendingExternalSends(next: number) { pendingExternalSends = next; },
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
247
373
|
export function isRequestExecutionBudget(
|
|
248
374
|
value: TransientSendBudget | undefined,
|
|
249
375
|
): value is RequestExecutionBudget {
|
|
@@ -669,6 +669,24 @@ export function createSpendReservationLedger(options: {
|
|
|
669
669
|
case "checkpoint": applyCheckpoint(record); break;
|
|
670
670
|
}
|
|
671
671
|
}
|
|
672
|
+
// A reservation that survived replay has no owner left. The process that made it is gone,
|
|
673
|
+
// so nothing in this one can ever settle it, and leaving it live means the send stays
|
|
674
|
+
// pending forever against a scope that can never resolve it. Deleting the entry is not the
|
|
675
|
+
// alternative either: that would hand the same send id a second reservation.
|
|
676
|
+
//
|
|
677
|
+
// Both live states resolve to UNRESOLVED, including an undispatched one. The tempting
|
|
678
|
+
// distinction -- open never reached the wire, so give its tokens back -- assumes the
|
|
679
|
+
// journal is complete up to the crash, and the torn-tail handling above says it is not: a
|
|
680
|
+
// send can dispatch and die before its dispatch record lands. Abandoning that reservation
|
|
681
|
+
// returns tokens for a send that may have been billed, and worse, it RESETS a ceiling that
|
|
682
|
+
// had already fired. An exhausted scope staying exhausted across a restart is the whole
|
|
683
|
+
// reason this store is on disk.
|
|
684
|
+
const reconciledAt = now();
|
|
685
|
+
for (const [send, reservation] of reservations) {
|
|
686
|
+
if (!isLive(reservation.status)) continue;
|
|
687
|
+
applyResolve(send, "lost", 0, reconciledAt);
|
|
688
|
+
append({ v: 1, kind: "lost", send, at: reconciledAt });
|
|
689
|
+
}
|
|
672
690
|
}
|
|
673
691
|
|
|
674
692
|
/**
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "../combos/failover";
|
|
22
22
|
import { reconcileComboWarningMemos } from "../combos/request";
|
|
23
23
|
import { reconcileComboRotationState } from "../combos/resolve";
|
|
24
|
-
import { reconcileComboRecall } from "../server/responses/combo-session-recall";
|
|
24
|
+
import { reconcileComboRecall, sweepExpiredComboRecall } from "../server/responses/combo-session-recall";
|
|
25
25
|
import { listLiveComboTargetKeys } from "../combos/types";
|
|
26
26
|
import {
|
|
27
27
|
listLiveConfigOwnershipRoots,
|
|
@@ -112,7 +112,11 @@ export const STATE_STORE_REGISTRATIONS = [
|
|
|
112
112
|
{ name: "model-cache-history", reconcileGeneration: reconcileModelCacheGeneration },
|
|
113
113
|
{ name: "pool-rotation", reconcileGeneration: reconcilePoolRotationState },
|
|
114
114
|
{ name: "combo-rotation", reconcileGeneration: reconcileComboRotationState },
|
|
115
|
-
{
|
|
115
|
+
{
|
|
116
|
+
name: "combo-session-recall",
|
|
117
|
+
sweepExpired: sweepExpiredComboRecall,
|
|
118
|
+
reconcileGeneration: reconcileComboRecall,
|
|
119
|
+
},
|
|
116
120
|
{ name: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff },
|
|
117
121
|
{ name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState },
|
|
118
122
|
{ name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState },
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* how this incident happened.
|
|
21
21
|
*/
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
23
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
24
24
|
import { realpathSync } from "node:fs";
|
|
25
25
|
|
|
26
26
|
const GUARD_ENV = "OCX_TEST_HOME_GUARD";
|
|
@@ -152,3 +152,87 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
|
|
|
152
152
|
+ "Point CODEX_HOME at a temp directory for this test before writing native auth.json.",
|
|
153
153
|
);
|
|
154
154
|
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The trees a removal must never reach, and the reason each one is named.
|
|
158
|
+
*
|
|
159
|
+
* The writer guard above cannot help here. `rmSync` is plain `node:fs`: it calls no writer of
|
|
160
|
+
* ours, so no assertion of ours runs, and by the time anything could observe the damage the
|
|
161
|
+
* directory is already gone. On 2026-09-15 that is exactly what happened — a test resolved the
|
|
162
|
+
* process-global config directory and removed it, taking every OAuth login, the Codex account
|
|
163
|
+
* store, the service tokens and a 372MB usage ledger with it.
|
|
164
|
+
*/
|
|
165
|
+
const PROTECTED_TREES: ReadonlyArray<{ path: string; lexical: string; label: string }> = [
|
|
166
|
+
{ path: PROTECTED_HOME, lexical: resolve(join(REAL_HOME, ".opencodex")), label: "the real OpenCodex home" },
|
|
167
|
+
{ path: PROTECTED_CODEX_HOME, lexical: resolve(join(REAL_HOME, ".codex")), label: "the real Codex home" },
|
|
168
|
+
{
|
|
169
|
+
path: PROTECTED_LAUNCH_AGENTS,
|
|
170
|
+
lexical: resolve(join(REAL_HOME, "Library", "LaunchAgents")),
|
|
171
|
+
label: "the real LaunchAgents directory",
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
const PROTECTED_REAL_HOME = canonicalize(REAL_HOME);
|
|
175
|
+
const LEXICAL_REAL_HOME = resolve(REAL_HOME);
|
|
176
|
+
|
|
177
|
+
/** Canonical paths whose removal is refused. Exported so the guard's tests cannot drift off them. */
|
|
178
|
+
export function protectedRemovalTreesForTests(): readonly string[] {
|
|
179
|
+
return [PROTECTED_REAL_HOME, ...PROTECTED_TREES.map(tree => tree.path)];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether `child` sits strictly below `parent`, both already canonicalized. */
|
|
183
|
+
function isInside(parent: string, child: string): boolean {
|
|
184
|
+
const rel = relative(parent, child);
|
|
185
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Why removing `target` is refused, or `null` when it is not a protected location.
|
|
190
|
+
*
|
|
191
|
+
* Three relations are refused, not one. Equality alone would still permit
|
|
192
|
+
* `rmSync(getConfigPath())` against a live `config.json`, and it would permit
|
|
193
|
+
* `rmSync(homedir())`, which takes the protected tree with it. So a target is refused when it
|
|
194
|
+
* IS a protected tree, when it sits INSIDE one, or when it is an ANCESTOR of one.
|
|
195
|
+
*
|
|
196
|
+
* Canonicalization is what makes a symlink useless as a bypass: a temp path that merely points
|
|
197
|
+
* at the real home resolves to the real home before any comparison happens.
|
|
198
|
+
*/
|
|
199
|
+
export function protectedRemovalReason(target: string): string | null {
|
|
200
|
+
// Both spellings are judged, not just the canonical one. Canonicalization is what defeats a
|
|
201
|
+
// symlink alias, but it also resolves the target away: if `~/.opencodex` is itself a link,
|
|
202
|
+
// the literal path a caller passed is the thing that gets unlinked, and only the lexical
|
|
203
|
+
// form still names it. Upstream Codex makes the same distinction in its writable-root
|
|
204
|
+
// handling, keeping logical and resolved forms side by side rather than collapsing to one.
|
|
205
|
+
for (const candidate of [canonicalize(target), resolve(target)]) {
|
|
206
|
+
if (candidate === PROTECTED_REAL_HOME || candidate === LEXICAL_REAL_HOME) {
|
|
207
|
+
return `the real home directory (${PROTECTED_REAL_HOME})`;
|
|
208
|
+
}
|
|
209
|
+
for (const tree of PROTECTED_TREES) {
|
|
210
|
+
for (const protectedPath of [tree.path, tree.lexical]) {
|
|
211
|
+
if (candidate === protectedPath) return `${tree.label} (${protectedPath})`;
|
|
212
|
+
if (isInside(protectedPath, candidate)) return `a path inside ${tree.label} (${protectedPath})`;
|
|
213
|
+
if (isInside(candidate, protectedPath)) return `an ancestor of ${tree.label} (${protectedPath})`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Throw before a removal that would reach a protected tree.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately NOT gated on {@link isTestHomeGuardArmed}. Arming happens in `tests/preload.ts`,
|
|
224
|
+
* which Bun loads from the `bunfig.toml` it finds in the CURRENT WORKING DIRECTORY — so a run
|
|
225
|
+
* started outside the repository arms nothing, leaves OPENCODEX_HOME unset, and resolves the
|
|
226
|
+
* developer's real home. That unarmed run is precisely the one that caused the incident, so the
|
|
227
|
+
* refusal has to hold without it. Nothing in production calls this; the callers are test
|
|
228
|
+
* helpers, where the only cost of an unconditional check is a path comparison.
|
|
229
|
+
*/
|
|
230
|
+
export function assertRemovalOutsideProtectedTrees(target: string): void {
|
|
231
|
+
const reason = protectedRemovalReason(target);
|
|
232
|
+
if (reason === null) return;
|
|
233
|
+
throw new Error(
|
|
234
|
+
`refusing to remove ${reason} from a test process: "${target}" resolves there. `
|
|
235
|
+
+ "Create the directory this test owns with createTempHome() from tests/helpers/temp-home "
|
|
236
|
+
+ "and remove that handle instead (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).",
|
|
237
|
+
);
|
|
238
|
+
}
|