@bitkyc08/opencodex 2.10.0 → 2.10.1
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 +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
|
@@ -24,6 +24,12 @@ export interface ReasoningEnvelope {
|
|
|
24
24
|
* so replay needs it even though the visible summary was suppressed.
|
|
25
25
|
*/
|
|
26
26
|
txt?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Kiro `reasoningContentEvent.redactedContent`: a KMS-encrypted reasoning blob that is opaque to
|
|
29
|
+
* the proxy. Kiro's own CLI replays it on the matching `assistantResponseMessage` to preserve
|
|
30
|
+
* model reasoning across turns, so it round-trips here the same way a signature does.
|
|
31
|
+
*/
|
|
32
|
+
krc?: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string {
|
|
@@ -45,7 +51,9 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve
|
|
|
45
51
|
}
|
|
46
52
|
const txt = (parsed as { txt?: unknown }).txt;
|
|
47
53
|
if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
|
|
48
|
-
|
|
54
|
+
const krc = (parsed as { krc?: unknown }).krc;
|
|
55
|
+
if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
|
|
56
|
+
return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null;
|
|
49
57
|
} catch {
|
|
50
58
|
return null;
|
|
51
59
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process fallback store pairing raw reasoning text with the tool call it
|
|
3
|
+
* preceded (issue #950).
|
|
4
|
+
*
|
|
5
|
+
* DeepSeek thinking mode requires the assistant's original `reasoning_content`
|
|
6
|
+
* to be replayed on every continuation of a tool-call turn. The bridge records
|
|
7
|
+
* the raw reasoning here when it closes a reasoning block and a tool call
|
|
8
|
+
* follows; the openai-chat adapter re-attaches it when a `tool_calls`
|
|
9
|
+
* assistant message is about to serialize without thinking parts (compacted
|
|
10
|
+
* history, lost assistant turn, orphan-repaired tool results).
|
|
11
|
+
*
|
|
12
|
+
* Entries are scoped by an optional conversation identity in addition to the
|
|
13
|
+
* call id: provider-generated ids like `call_1` are not globally unique, so a
|
|
14
|
+
* process-wide key would let one conversation's reasoning bleed into another
|
|
15
|
+
* when ids collide (CodeRabbit P1 on #971).
|
|
16
|
+
*
|
|
17
|
+
* Privacy: entries hold reasoning text in memory only — never logged,
|
|
18
|
+
* serialized, or exported. Bounded by entry count, total bytes, and TTL, so a
|
|
19
|
+
* long-lived proxy cannot grow without limit.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MAX_ENTRIES = 64;
|
|
23
|
+
const MAX_TOTAL_BYTES = 256 * 1024;
|
|
24
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
interface CacheEntry {
|
|
27
|
+
text: string;
|
|
28
|
+
bytes: number;
|
|
29
|
+
at: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const entries = new Map<string, CacheEntry>();
|
|
33
|
+
let totalBytes = 0;
|
|
34
|
+
let clockForTests: (() => number) | null = null;
|
|
35
|
+
|
|
36
|
+
const now = (): number => clockForTests?.() ?? Date.now();
|
|
37
|
+
const keyFor = (callId: string, scope: string | undefined): string =>
|
|
38
|
+
`${scope ?? "global"}\u0000${callId}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Record the raw reasoning text that preceded the given tool call.
|
|
42
|
+
*
|
|
43
|
+
* Expired entries are swept on insert so the TTL bound holds even when a call
|
|
44
|
+
* id is never read again.
|
|
45
|
+
*/
|
|
46
|
+
export function rememberReasoningForCall(callId: string, text: string, scope?: string): void {
|
|
47
|
+
if (!callId || typeof text !== "string" || text.length === 0) return;
|
|
48
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
49
|
+
// A single entry larger than the whole budget would immediately evict itself.
|
|
50
|
+
if (bytes > MAX_TOTAL_BYTES) return;
|
|
51
|
+
const at = now();
|
|
52
|
+
// Delete every due entry first so expired reasoning cannot linger until a
|
|
53
|
+
// later peek or capacity eviction.
|
|
54
|
+
for (const [key, entry] of entries) {
|
|
55
|
+
if (at - entry.at >= TTL_MS) {
|
|
56
|
+
entries.delete(key);
|
|
57
|
+
totalBytes -= entry.bytes;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const key = keyFor(callId, scope);
|
|
61
|
+
const previous = entries.get(key);
|
|
62
|
+
if (previous) totalBytes -= previous.bytes;
|
|
63
|
+
entries.set(key, { text, bytes, at });
|
|
64
|
+
totalBytes += bytes;
|
|
65
|
+
// Evict oldest-first until both caps hold (never evict the entry just written
|
|
66
|
+
// while it is the only one — the loop guards on size > 1).
|
|
67
|
+
while ((totalBytes > MAX_TOTAL_BYTES || entries.size > MAX_ENTRIES) && entries.size > 1) {
|
|
68
|
+
let oldestKey: string | undefined;
|
|
69
|
+
let oldestAt = Infinity;
|
|
70
|
+
for (const [candidateKey, entry] of entries) {
|
|
71
|
+
if (entry.at < oldestAt) {
|
|
72
|
+
oldestAt = entry.at;
|
|
73
|
+
oldestKey = candidateKey;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (oldestKey === undefined) break;
|
|
77
|
+
const evicted = entries.get(oldestKey)!;
|
|
78
|
+
totalBytes -= evicted.bytes;
|
|
79
|
+
entries.delete(oldestKey);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the recorded reasoning for a call id without removing it: retries after
|
|
85
|
+
* a failed continuation reuse the same fallback.
|
|
86
|
+
*/
|
|
87
|
+
export function peekReasoningForCall(callId: string, scope?: string): string | undefined {
|
|
88
|
+
if (!callId) return undefined;
|
|
89
|
+
const key = keyFor(callId, scope);
|
|
90
|
+
const entry = entries.get(key);
|
|
91
|
+
if (!entry) return undefined;
|
|
92
|
+
if (now() - entry.at >= TTL_MS) {
|
|
93
|
+
entries.delete(key);
|
|
94
|
+
totalBytes -= entry.bytes;
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return entry.text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Test-only: reset the cache and optionally pin the clock. */
|
|
101
|
+
export function clearReasoningReplayCacheForTests(clock?: (() => number) | null): void {
|
|
102
|
+
entries.clear();
|
|
103
|
+
totalBytes = 0;
|
|
104
|
+
clockForTests = clock ?? null;
|
|
105
|
+
}
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { createHash, randomBytes } from "node:crypto";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { getConfigDir } from "../config";
|
|
20
|
-
import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
20
|
+
import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
21
21
|
import type { OcxProviderContinuationState } from "../types";
|
|
22
22
|
|
|
23
23
|
export const RESPONSE_SPILL_VERSION = 1;
|
|
@@ -45,9 +45,29 @@ export interface ResponseSpillRef {
|
|
|
45
45
|
payloadBytes: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Hard ceiling for one spill payload, enforced BOTH at direct-spill admission
|
|
50
|
+
* (state.ts refuses to durably retain a larger candidate) and at replay read
|
|
51
|
+
* (below). 256 MiB keeps the replay transient under the process-wide
|
|
52
|
+
* APP_OWNED_WORST_CASE_PINNED_BYTES ceiling (512 MiB); without an admission
|
|
53
|
+
* ceiling the read ceiling would strand write-only spills on disk.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_RESPONSE_SPILL_PAYLOAD_BYTES = 256 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
let spillPayloadCapOverride: number | null = null;
|
|
58
|
+
|
|
59
|
+
/** Test-only: lower/restore the single-spill payload ceiling (null restores). */
|
|
60
|
+
export function setResponseSpillPayloadCapForTests(bytes: number | null): void {
|
|
61
|
+
spillPayloadCapOverride = bytes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function responseSpillPayloadCap(): number {
|
|
65
|
+
return spillPayloadCapOverride ?? MAX_RESPONSE_SPILL_PAYLOAD_BYTES;
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
export type ResponseSpillReadResult =
|
|
49
69
|
| { ok: true; payload: ResponseSpillPayload }
|
|
50
|
-
| { ok: false; reason: "missing" | "corrupt" };
|
|
70
|
+
| { ok: false; reason: "missing" | "corrupt" | "too_large" };
|
|
51
71
|
|
|
52
72
|
export interface ResponseSpillCleanupResult {
|
|
53
73
|
scanned: number;
|
|
@@ -176,17 +196,28 @@ function closeFile(fd: number): void {
|
|
|
176
196
|
record("close");
|
|
177
197
|
}
|
|
178
198
|
|
|
179
|
-
function unlink(path: string): void {
|
|
199
|
+
function unlink(path: string, ephemeral = false): void {
|
|
180
200
|
try {
|
|
181
201
|
if (spillIoForTest?.unlink) spillIoForTest.unlink(path);
|
|
182
202
|
else unlinkSync(path);
|
|
183
|
-
|
|
203
|
+
// Ephemeral release only for publish temps; stable spill files keep their
|
|
204
|
+
// destination-keyed timeout memos (anti-restall) and drop just the
|
|
205
|
+
// success memo for the now-deleted file.
|
|
206
|
+
if (ephemeral) forgetEphemeralSecretPath(path);
|
|
207
|
+
else forgetHardenedSecretPath(path);
|
|
184
208
|
} catch (error) {
|
|
185
|
-
if (isErrno(error, "ENOENT"))
|
|
209
|
+
if (isErrno(error, "ENOENT")) {
|
|
210
|
+
if (ephemeral) forgetEphemeralSecretPath(path);
|
|
211
|
+
else forgetHardenedSecretPath(path);
|
|
212
|
+
}
|
|
186
213
|
throw error;
|
|
187
214
|
}
|
|
188
215
|
}
|
|
189
216
|
|
|
217
|
+
function unlinkEphemeral(path: string): void {
|
|
218
|
+
unlink(path, true);
|
|
219
|
+
}
|
|
220
|
+
|
|
190
221
|
function publishNoReplace(tempPath: string, destinationPath: string): void {
|
|
191
222
|
try {
|
|
192
223
|
if (spillIoForTest?.link) spillIoForTest.link(tempPath, destinationPath);
|
|
@@ -284,7 +315,7 @@ export function writeResponseSpillDurably(
|
|
|
284
315
|
try {
|
|
285
316
|
publishNoReplace(publishTempPath, destinationPath);
|
|
286
317
|
fsyncDirectoryBestEffort(dir);
|
|
287
|
-
|
|
318
|
+
unlinkEphemeral(publishTempPath);
|
|
288
319
|
tempPath = null;
|
|
289
320
|
return { version: 1, fileName, digest, payloadBytes: bytes.byteLength };
|
|
290
321
|
} catch (error) {
|
|
@@ -298,7 +329,7 @@ export function writeResponseSpillDurably(
|
|
|
298
329
|
try { closeSync(fd); } catch { /* best effort */ }
|
|
299
330
|
}
|
|
300
331
|
if (tempPath) {
|
|
301
|
-
try {
|
|
332
|
+
try { unlinkEphemeral(tempPath); } catch { /* best effort */ }
|
|
302
333
|
}
|
|
303
334
|
throw new Error("Response spill write failed");
|
|
304
335
|
}
|
|
@@ -306,6 +337,9 @@ export function writeResponseSpillDurably(
|
|
|
306
337
|
|
|
307
338
|
export function readResponseSpill(responseId: string, ref: ResponseSpillRef): ResponseSpillReadResult {
|
|
308
339
|
if (!validSpillRef(ref)) return { ok: false, reason: "corrupt" };
|
|
340
|
+
// Refuse before any read/parse: an oversized declared payload would otherwise
|
|
341
|
+
// materialize an unbounded transient (readFileSync + utf8 + JSON.parse).
|
|
342
|
+
if (ref.payloadBytes > responseSpillPayloadCap()) return { ok: false, reason: "too_large" };
|
|
309
343
|
const match = OWNED_SPILL_NAME.exec(ref.fileName);
|
|
310
344
|
if (!match
|
|
311
345
|
|| match[2] !== sha256(responseId).slice(0, 12)
|
|
@@ -376,7 +410,10 @@ export function recoverOrphanedResponseSpills(
|
|
|
376
410
|
try { stat = lstatSync(path); } catch { continue; }
|
|
377
411
|
if (!stat.isFile() || stat.isSymbolicLink() || Date.now() - stat.mtimeMs < graceMs) continue;
|
|
378
412
|
try {
|
|
379
|
-
|
|
413
|
+
// Orphaned publish temps get the full ephemeral release; stable
|
|
414
|
+
// orphaned spills keep destination-keyed timeout memos.
|
|
415
|
+
if (isOwnedTemp) unlinkEphemeral(path);
|
|
416
|
+
else unlink(path);
|
|
380
417
|
result.removed += 1;
|
|
381
418
|
result.bytesRemoved += stat.size;
|
|
382
419
|
} catch {
|
package/src/responses/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { atomicWriteFileAsync, getConfigDir } from "../config";
|
|
3
|
+
import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
|
|
4
4
|
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
|
|
5
5
|
import type { OcxProviderContinuationState } from "../types";
|
|
6
6
|
import {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readResponseSpill,
|
|
10
10
|
recoverOrphanedResponseSpills,
|
|
11
11
|
responseSpillDirectory,
|
|
12
|
+
responseSpillPayloadCap,
|
|
12
13
|
type ResponseSpillRef,
|
|
13
14
|
writeResponseSpillDurably,
|
|
14
15
|
} from "./spill-store";
|
|
@@ -23,6 +24,10 @@ export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
|
23
24
|
/** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
|
|
24
25
|
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
26
|
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
|
|
27
|
+
/** Refuse-to-parse ceiling for an existing snapshot file (above the 24 MiB write
|
|
28
|
+
* bound, so anything we wrote ourselves always loads; guards against externally
|
|
29
|
+
* planted or pre-cap unbounded files being parsed whole). */
|
|
30
|
+
const SNAPSHOT_FILE_MAX_BYTES = 32 * 1024 * 1024;
|
|
26
31
|
const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
|
|
27
32
|
const STALE_TEMP_MAX_ENTRIES = 4_096;
|
|
28
33
|
const STALE_TEMP_MAX_CLEANUPS = 512;
|
|
@@ -56,7 +61,7 @@ type ResidentInput = Omit<ResidentResponseState, "kind" | "sizeBytes">;
|
|
|
56
61
|
|
|
57
62
|
export type PreviousResponseReplayFailure = {
|
|
58
63
|
code: "previous_response_not_found";
|
|
59
|
-
reason: "spill_missing" | "spill_corrupt" | "spill_failed";
|
|
64
|
+
reason: "spill_missing" | "spill_corrupt" | "spill_failed" | "spill_too_large";
|
|
60
65
|
};
|
|
61
66
|
|
|
62
67
|
const states = new Map<string, StoredResponseState>();
|
|
@@ -67,6 +72,19 @@ let oldestResidentAt: number | null = null;
|
|
|
67
72
|
let byteCapOverride: number | null = null;
|
|
68
73
|
let stateRevision = 0;
|
|
69
74
|
const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
|
|
75
|
+
/**
|
|
76
|
+
* Admission-boundary observability (test-visible). directSpills: oversized
|
|
77
|
+
* candidates routed straight to durable spill without a resident stay or
|
|
78
|
+
* unrelated demotion. oversizedDrops: candidates above the single-spill
|
|
79
|
+
* payload ceiling, tombstoned instead of retained. snapshotOversizedRefusals:
|
|
80
|
+
* snapshot files refused before parse.
|
|
81
|
+
*/
|
|
82
|
+
const admissionCounters = { directSpills: 0, oversizedDrops: 0, snapshotOversizedRefusals: 0 };
|
|
83
|
+
|
|
84
|
+
/** Test-only: admission-boundary counters (proves the new paths fire). */
|
|
85
|
+
export function responseAdmissionCountersForTests(): Readonly<typeof admissionCounters> {
|
|
86
|
+
return admissionCounters;
|
|
87
|
+
}
|
|
70
88
|
// Superseded spill generations awaiting a durable snapshot before unlink
|
|
71
89
|
// (review C1-1: unlinking at swap time races a crash against the debounced
|
|
72
90
|
// snapshot — the reloaded OLD stub would point at a deleted file).
|
|
@@ -177,12 +195,28 @@ function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void
|
|
|
177
195
|
if (options.deleteSpill !== false) deleteOwnedSpills(existing);
|
|
178
196
|
}
|
|
179
197
|
|
|
180
|
-
function replaceWithSpillFailure(
|
|
198
|
+
function replaceWithSpillFailure(
|
|
199
|
+
id: string,
|
|
200
|
+
expected?: StoredResponseState,
|
|
201
|
+
options: { deferSpillUnlink?: boolean } = {},
|
|
202
|
+
): void {
|
|
181
203
|
const existing = states.get(id);
|
|
182
204
|
if (expected && existing !== expected) return;
|
|
183
205
|
const failed = tombstone(id, expected?.createdAt ?? existing?.createdAt ?? now());
|
|
184
206
|
if (replaceMapEntry(id, failed, expected)) {
|
|
185
|
-
if (existing)
|
|
207
|
+
if (existing) {
|
|
208
|
+
if (options.deferSpillUnlink && existing.kind === "spill") {
|
|
209
|
+
// Crash consistency (same rule as replaceSpillEntryAtomically): the old
|
|
210
|
+
// durable snapshot still references this generation until the tombstone
|
|
211
|
+
// itself is durable — queue the unlink for the next stable persist.
|
|
212
|
+
pendingSpillUnlinks.push(existing.spill);
|
|
213
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
214
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
deleteOwnedSpills(existing);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
186
220
|
}
|
|
187
221
|
}
|
|
188
222
|
|
|
@@ -236,7 +270,9 @@ function replaceSpillEntryAtomically(
|
|
|
236
270
|
}
|
|
237
271
|
} catch {
|
|
238
272
|
spillCounters.writeFailures += 1;
|
|
239
|
-
|
|
273
|
+
// deferSpillUnlink: the durable snapshot may still reference the old
|
|
274
|
+
// generation; deleting it now would strand the old stub after a crash.
|
|
275
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
240
276
|
}
|
|
241
277
|
}
|
|
242
278
|
|
|
@@ -251,6 +287,11 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
|
|
|
251
287
|
pruneResponses();
|
|
252
288
|
return;
|
|
253
289
|
}
|
|
290
|
+
if (candidate.sizeBytes > byteCap()) {
|
|
291
|
+
admitOversizedCandidate(id, candidate, expected);
|
|
292
|
+
pruneResponses();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
254
295
|
if (expected?.kind === "spill") {
|
|
255
296
|
replaceSpillEntryAtomically(id, expected, candidate);
|
|
256
297
|
pruneResponses();
|
|
@@ -260,6 +301,68 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
|
|
|
260
301
|
pruneResponses();
|
|
261
302
|
}
|
|
262
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Admission boundary for candidates that can never fit as resident (larger
|
|
306
|
+
* than the whole resident-map cap). Writes them DIRECTLY to durable spill and
|
|
307
|
+
* installs only the stub — the oversized candidate never becomes resident and
|
|
308
|
+
* no unrelated resident is demoted to make room for it. Candidates above the
|
|
309
|
+
* single-spill payload ceiling are tombstoned instead: retaining a spill the
|
|
310
|
+
* replay ceiling would refuse to read is write-only waste.
|
|
311
|
+
*/
|
|
312
|
+
function admitOversizedCandidate(
|
|
313
|
+
id: string,
|
|
314
|
+
candidate: ResidentResponseState,
|
|
315
|
+
expected?: StoredResponseState,
|
|
316
|
+
): void {
|
|
317
|
+
if (candidate.sizeBytes > responseSpillPayloadCap()) {
|
|
318
|
+
admissionCounters.oversizedDrops += 1;
|
|
319
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const ref = writeResponseSpillDurably(id, {
|
|
324
|
+
createdAt: candidate.createdAt,
|
|
325
|
+
items: candidate.items,
|
|
326
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
327
|
+
});
|
|
328
|
+
// Enforce the ceiling against the REAL envelope: the spill payload adds
|
|
329
|
+
// the {version, responseId, ...} wrapper, so a candidate within the
|
|
330
|
+
// wrapper's size of the cap would otherwise be retained unreadably.
|
|
331
|
+
if (ref.payloadBytes > responseSpillPayloadCap()) {
|
|
332
|
+
deleteResponseSpill(ref);
|
|
333
|
+
admissionCounters.oversizedDrops += 1;
|
|
334
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
338
|
+
kind: "spill",
|
|
339
|
+
createdAt: candidate.createdAt,
|
|
340
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
341
|
+
spill: ref,
|
|
342
|
+
};
|
|
343
|
+
const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
|
|
344
|
+
if (!replaceMapEntry(id, next, expected)) {
|
|
345
|
+
deleteResponseSpill(ref);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
spillCounters.writes += 1;
|
|
349
|
+
admissionCounters.directSpills += 1;
|
|
350
|
+
noteStubSwapForTest();
|
|
351
|
+
if (expected?.kind === "spill") {
|
|
352
|
+
// Same deferred-unlink rule as replaceSpillEntryAtomically: the new stub
|
|
353
|
+
// is durable only after the debounced snapshot, so the old generation
|
|
354
|
+
// stays until a stable persist drains the queue.
|
|
355
|
+
pendingSpillUnlinks.push(expected.spill);
|
|
356
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
357
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
spillCounters.writeFailures += 1;
|
|
362
|
+
replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
263
366
|
// Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
|
|
264
367
|
// newly appended input suffix without adding an unknown field that native passthrough could send
|
|
265
368
|
// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly once.
|
|
@@ -334,8 +437,18 @@ function loadSnapshotEntry(id: string, value: unknown): void {
|
|
|
334
437
|
items: rec.items,
|
|
335
438
|
...(providers ? { providers } : {}),
|
|
336
439
|
});
|
|
337
|
-
if (resident)
|
|
338
|
-
|
|
440
|
+
if (!resident) {
|
|
441
|
+
replaceMapEntry(id, tombstone(id, rec.createdAt));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Same admission boundary as live writes: an oversized snapshot row goes
|
|
445
|
+
// straight to spill (or tombstone above the payload ceiling) instead of
|
|
446
|
+
// entering the resident map and demoting unrelated rows on the first prune.
|
|
447
|
+
if (resident.sizeBytes > byteCap()) {
|
|
448
|
+
admitOversizedCandidate(id, resident, undefined);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
replaceMapEntry(id, resident);
|
|
339
452
|
}
|
|
340
453
|
|
|
341
454
|
export interface ResponseStateTempRecoveryResult {
|
|
@@ -454,18 +567,43 @@ function ensureLoaded(): void {
|
|
|
454
567
|
if (loaded) return;
|
|
455
568
|
loaded = true;
|
|
456
569
|
const path = snapshotPath();
|
|
570
|
+
// Atomic writes place their temp beside the RESOLVED target, so a symlinked
|
|
571
|
+
// snapshot (dotfiles-managed config dir) strands temps in the link's real
|
|
572
|
+
// directory where a scan of the literal config dir would never see them.
|
|
573
|
+
// Both locations are swept; they collapse to one when nothing is symlinked.
|
|
574
|
+
// resolveWriteTarget refuses a dangling link; snapshot loading stays independent.
|
|
575
|
+
let resolvedDir = dirname(path);
|
|
457
576
|
try {
|
|
458
|
-
|
|
577
|
+
resolvedDir = dirname(resolveWriteTarget(path));
|
|
459
578
|
} catch {
|
|
460
|
-
/*
|
|
579
|
+
/* unresolvable link: sweep the literal dir only */
|
|
580
|
+
}
|
|
581
|
+
for (const dir of new Set([dirname(path), resolvedDir])) {
|
|
582
|
+
try {
|
|
583
|
+
recoverStaleResponseStateTemps(dir);
|
|
584
|
+
} catch {
|
|
585
|
+
/* best-effort cleanup only; snapshot loading must remain independent */
|
|
586
|
+
}
|
|
461
587
|
}
|
|
462
588
|
try {
|
|
463
589
|
if (existsSync(path)) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
590
|
+
// Bound the read BEFORE parse: the 24 MiB write cap constrains snapshots
|
|
591
|
+
// this process wrote, not a pre-existing oversized file. statSync follows
|
|
592
|
+
// symlinks deliberately — readFileSync below follows them too, so the
|
|
593
|
+
// size gate must measure the same target the read would.
|
|
594
|
+
const stat = statSync(path);
|
|
595
|
+
if (!stat.isFile()) {
|
|
596
|
+
// Symlink to a FIFO/device (e.g. /dev/zero): reading would block or
|
|
597
|
+
// return unbounded input. Only regular files are ever parsed.
|
|
598
|
+
} else if (stat.size > SNAPSHOT_FILE_MAX_BYTES) {
|
|
599
|
+
admissionCounters.snapshotOversizedRefusals += 1;
|
|
600
|
+
} else {
|
|
601
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
|
|
602
|
+
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
|
|
603
|
+
for (const entry of raw.states) {
|
|
604
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
|
|
605
|
+
loadSnapshotEntry(entry[0], entry[1]);
|
|
606
|
+
}
|
|
469
607
|
}
|
|
470
608
|
}
|
|
471
609
|
}
|
|
@@ -504,7 +642,9 @@ async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome>
|
|
|
504
642
|
persistable = smallState;
|
|
505
643
|
}
|
|
506
644
|
const persistEntry: [string, unknown] = [id, persistable];
|
|
507
|
-
|
|
645
|
+
// UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip
|
|
646
|
+
// past both snapshot caps at up to 2x the intended size.
|
|
647
|
+
const size = Buffer.byteLength(JSON.stringify(persistEntry), "utf8");
|
|
508
648
|
if (state.kind === "resident" && size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
|
|
509
649
|
if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
|
|
510
650
|
total += size;
|
|
@@ -676,7 +816,11 @@ function materializeEntry(
|
|
|
676
816
|
spillCounters.readFailures += 1;
|
|
677
817
|
const failure: PreviousResponseReplayFailure = {
|
|
678
818
|
code: "previous_response_not_found",
|
|
679
|
-
reason: result.reason === "missing"
|
|
819
|
+
reason: result.reason === "missing"
|
|
820
|
+
? "spill_missing"
|
|
821
|
+
: result.reason === "too_large"
|
|
822
|
+
? "spill_too_large"
|
|
823
|
+
: "spill_corrupt",
|
|
680
824
|
};
|
|
681
825
|
replaceWithSpillFailure(id, entry);
|
|
682
826
|
schedulePersist();
|