@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.
Files changed (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. 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
- return envelope.sig || envelope.red || envelope.txt ? envelope : null;
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
- forgetHardenedSecretPath(path);
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")) forgetHardenedSecretPath(path);
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
- unlink(publishTempPath);
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 { unlink(tempPath); } catch { /* best effort */ }
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
- unlink(path);
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 {
@@ -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(id: string, expected?: StoredResponseState): void {
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) deleteOwnedSpills(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
- replaceWithSpillFailure(id, expected);
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) replaceMapEntry(id, resident);
338
- else replaceMapEntry(id, tombstone(id, rec.createdAt));
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
- recoverStaleResponseStateTemps(dirname(path));
577
+ resolvedDir = dirname(resolveWriteTarget(path));
459
578
  } catch {
460
- /* best-effort cleanup only; snapshot loading must remain independent */
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
- const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
465
- if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
466
- for (const entry of raw.states) {
467
- if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
468
- loadSnapshotEntry(entry[0], entry[1]);
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
- const size = JSON.stringify(persistEntry).length;
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" ? "spill_missing" : "spill_corrupt",
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();