@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
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Classification of upstream fetch rejections that occur before any request
3
+ * bytes can reach the origin: DNS resolution failure and TCP connect refusal.
4
+ *
5
+ * Issue #914: these failures are machine/network-wide, not account-specific —
6
+ * every Codex pool account shares the provider host, so rotating accounts
7
+ * cannot repair them. The transport layer previously mapped every non-timeout
8
+ * rejection to `connect_error`, and at `upstreamFailoverThreshold` that streak
9
+ * soft-avoided a healthy account and cleared thread affinity.
10
+ *
11
+ * Bun collapses DNS failure and TCP refusal into one label class
12
+ * (`ConnectionRefused` / `FailedToOpenSocket`, errno 0); Node's undici emits
13
+ * the classic `ECONNREFUSED` / `ENOTFOUND` / `EAI_AGAIN` / `ENETUNREACH` /
14
+ * `ENETDOWN` / `EHOSTUNREACH` shapes. Both shapes are matched here, on `code`
15
+ * values only, through a bounded cause chain. Message substrings are never
16
+ * trusted (a message-only match is a negative case), and labels that can also
17
+ * appear after the origin saw the credential — `ECONNRESET`, `EPIPE`, TLS
18
+ * errors, unknown shapes — deliberately stay outside the set.
19
+ *
20
+ * Classifier semantics extracted from PR #966 (Yuxin-Qiao) with attribution;
21
+ * sidecar blast radius intentionally not inherited.
22
+ *
23
+ * MUST stay a leaf module: imports nothing from server.ts or adapters.
24
+ */
25
+
26
+ import { UpstreamRetryEvidenceError } from "./upstream-retry";
27
+
28
+ export const PRE_CONNECT_REACHABILITY_CODES = new Set([
29
+ // Bun: DNS failure and TCP refusal share this class.
30
+ "ConnectionRefused",
31
+ "FailedToOpenSocket",
32
+ // Node undici / classic Node shapes.
33
+ "ECONNREFUSED",
34
+ "ENOTFOUND",
35
+ "EAI_AGAIN",
36
+ "ENETUNREACH",
37
+ "ENETDOWN",
38
+ "EHOSTUNREACH",
39
+ ]);
40
+
41
+ /** Upper bound on how far `cause` chains are inspected. */
42
+ export const MAX_REACHABILITY_CAUSE_DEPTH = 3;
43
+
44
+ /**
45
+ * True when the rejection (or a bounded `cause` of it) carries a proven
46
+ * pre-connection reachability code. Never matches message text.
47
+ */
48
+ export function isPreConnectReachabilityError(err: unknown): boolean {
49
+ let current: unknown = err;
50
+ const seen = new Set<unknown>();
51
+ for (let depth = 0; depth < MAX_REACHABILITY_CAUSE_DEPTH; depth++) {
52
+ if (!(current instanceof Error) || seen.has(current)) return false;
53
+ seen.add(current);
54
+ const code = (current as { code?: unknown }).code;
55
+ if (typeof code === "string" && PRE_CONNECT_REACHABILITY_CODES.has(code)) return true;
56
+ current = current.cause;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ export type TransportFailureKind = "timeout" | "connect_neutral" | "connect_error";
62
+
63
+ /**
64
+ * Shared transport rejection classification for Codex pool upstream sends.
65
+ * Timeouts keep their existing identity (account-transient); proven pre-connect
66
+ * reachability failures become account-neutral; everything else (ECONNRESET,
67
+ * EPIPE, TLS, unknown shapes) keeps the existing `connect_error`
68
+ * account-attributed behavior.
69
+ */
70
+ export function classifyTransportFailureKind(err: unknown): TransportFailureKind {
71
+ const evidence = err instanceof UpstreamRetryEvidenceError ? err : undefined;
72
+ const rejection = evidence ? evidence.cause : err;
73
+ if (rejection instanceof Error && rejection.name === "TimeoutError") return "timeout";
74
+ if (isPreConnectReachabilityError(rejection)) {
75
+ // A transient upstream response (5xx) or a credential-visible connection
76
+ // reset before the rejection proves the host and credential path were
77
+ // reached: the failure is account-attributable, never the pre-connection
78
+ // neutral class (issue #914 review).
79
+ if (evidence && (evidence.transientStatuses.length > 0 || evidence.resetSeen)) return "connect_error";
80
+ return "connect_neutral";
81
+ }
82
+ return "connect_error";
83
+ }
84
+
85
+ /** Stable `code` carried by a transport rejection, when there is one. */
86
+ export function transportErrorCode(err: unknown): string | undefined {
87
+ const rejection = err instanceof UpstreamRetryEvidenceError ? err.cause : err;
88
+ if (!(rejection instanceof Error)) return undefined;
89
+ const code = (rejection as { code?: unknown }).code;
90
+ return typeof code === "string" && code !== "" ? code : undefined;
91
+ }
@@ -71,6 +71,107 @@ export async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<
71
71
  });
72
72
  }
73
73
 
74
+ /**
75
+ * Best-effort, bounded cancellation of a response body before a retry backoff.
76
+ *
77
+ * The 429 paths release the unread body before waiting so sockets do not accumulate under a
78
+ * rate-limit storm, but a never-settling `cancel()` promise must not be able to block the
79
+ * abort-aware backoff (client cancel, `maxIntervalMs`, or the cumulative header deadline).
80
+ * Cancellation is started and its rejection observed; the await is bounded by `timeoutMs`
81
+ * and the abort signal. This mirrors the rotation-path guarantee (release is initiated, not
82
+ * awaited forever) while preserving the resource-release intent of the same-target paths.
83
+ */
84
+ export async function releaseResponseBodyBestEffort(
85
+ body: ReadableStream<Uint8Array> | null,
86
+ signal: AbortSignal | undefined,
87
+ timeoutMs = 1_000,
88
+ ): Promise<void> {
89
+ if (!body) return;
90
+ if (signal?.aborted) {
91
+ void body.cancel().catch(() => {});
92
+ return;
93
+ }
94
+ const cancel = body.cancel().catch(() => {});
95
+ if (!signal) {
96
+ await Promise.race([cancel, new Promise<void>(resolve => setTimeout(resolve, timeoutMs))]);
97
+ return;
98
+ }
99
+ await new Promise<void>(resolve => {
100
+ let timer: ReturnType<typeof setTimeout>;
101
+ /**
102
+ * Abort hook: clear the bounded-body release timer and settle the promise so a
103
+ * never-settling cancel() can never block the abort-aware backoff.
104
+ */
105
+ const onAbort = () => {
106
+ clearTimeout(timer);
107
+ resolve();
108
+ };
109
+ timer = setTimeout(() => {
110
+ signal.removeEventListener("abort", onAbort);
111
+ resolve();
112
+ }, timeoutMs);
113
+ signal.addEventListener("abort", onAbort, { once: true });
114
+ void cancel.then(() => {
115
+ clearTimeout(timer);
116
+ signal.removeEventListener("abort", onAbort);
117
+ resolve();
118
+ });
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Abort-aware sleep that yields an adapter `heartbeat` at least every `heartbeatIntervalMs`.
124
+ * The Responses bridge treats a returned iterator event as upstream liveness and aborts turns
125
+ * that stay silent past the stall budget (default 300s), while a retryOn429 wait may legally
126
+ * reach 600s — so deliberate waits must keep the watchdog fed or a long backoff is killed
127
+ * mid-turn. The final chunk always yields once, which doubles as the post-wait liveness beat.
128
+ */
129
+ export async function* sleepWithHeartbeats(
130
+ ms: number,
131
+ signal?: AbortSignal,
132
+ heartbeatIntervalMs = 10_000,
133
+ ): AsyncGenerator<{ type: "heartbeat" }> {
134
+ if (ms <= 0) return;
135
+ // Guard against a non-positive interval: a zero/negative step would spin the loop forever
136
+ // while sleepWithAbort early-returns without ever observing the abort signal. NaN must be
137
+ // normalized too: Math.max(1, NaN) is NaN, which would abort the wait after one beat.
138
+ const stepMs = Number.isNaN(heartbeatIntervalMs) ? 1 : Math.max(1, heartbeatIntervalMs);
139
+ let remaining = ms;
140
+ while (remaining > 0) {
141
+ const chunk = Math.min(remaining, stepMs);
142
+ await sleepWithAbort(chunk, signal);
143
+ remaining -= chunk;
144
+ yield { type: "heartbeat" };
145
+ }
146
+ }
147
+
148
+ export interface SameTarget429WaitOptions {
149
+ body: ReadableStream<Uint8Array> | null;
150
+ signal?: AbortSignal;
151
+ delayMs: number;
152
+ /**
153
+ * When set, the wait yields adapter heartbeats so bridge stall watchdogs stay fed.
154
+ * Omit for pre-stream recovery paths that have no stall watchdog.
155
+ */
156
+ heartbeatIntervalMs?: number;
157
+ }
158
+
159
+ /**
160
+ * Shared pre-replay prep for opt-in same-target 429 waits:
161
+ * release the unread 429 body, then sleep (optionally with heartbeats).
162
+ * Callers still own attempt budgeting, abort re-checks, and the replay itself.
163
+ */
164
+ export async function* prepareSameTarget429Wait(
165
+ options: SameTarget429WaitOptions,
166
+ ): AsyncGenerator<{ type: "heartbeat" }> {
167
+ await releaseResponseBodyBestEffort(options.body, options.signal);
168
+ if (options.heartbeatIntervalMs === undefined) {
169
+ await sleepWithAbort(options.delayMs, options.signal);
170
+ return;
171
+ }
172
+ yield* sleepWithHeartbeats(options.delayMs, options.signal, options.heartbeatIntervalMs);
173
+ }
174
+
74
175
  export function isConnectionResetError(err: unknown): boolean {
75
176
  if (!(err instanceof Error)) return false;
76
177
  // Aborts and timeouts are caller decisions / honest failures — never retryable.
@@ -148,6 +249,39 @@ export interface TransientRetryOptions extends ResetRetryOptions {
148
249
  export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
149
250
  type ReplayableFetch = (recovery?: UpstreamSendRecovery) => Promise<Response>;
150
251
 
252
+ /**
253
+ * Rejection thrown by the upstream retry helpers when the terminal attempt
254
+ * rejects after earlier attempts already produced credential-visible evidence:
255
+ * transient 5xx responses, or a connection reset after the request was read.
256
+ *
257
+ * That evidence proves the host and credential path were reached, so the
258
+ * failure must stay account-attributed even though the terminal promise looks
259
+ * like a transport rejection (issue #914 review: mixed 5xx/reset -> rejection
260
+ * must not be downgraded to the account-neutral pre-connection class). The
261
+ * original rejection is preserved as `cause` so its code and message stay
262
+ * inspectable. Extracted from PR #966 (Yuxin-Qiao) with attribution.
263
+ */
264
+ export class UpstreamRetryEvidenceError extends Error {
265
+ constructor(
266
+ public readonly transientStatuses: readonly number[],
267
+ cause: unknown,
268
+ /** True when a connection-reset retry already reached the origin. */
269
+ public readonly resetSeen = false,
270
+ ) {
271
+ const detail = cause instanceof Error ? cause.message : String(cause);
272
+ const kinds: string[] = [];
273
+ if (transientStatuses.length > 0) kinds.push("transient 5xx response(s)");
274
+ if (resetSeen) kinds.push("a credential-visible connection reset");
275
+ super(
276
+ kinds.length > 0
277
+ ? `upstream fetch failed after ${kinds.join(" and ")}: ${detail}`
278
+ : `upstream fetch failed: ${detail}`,
279
+ { cause },
280
+ );
281
+ this.name = "UpstreamRetryEvidenceError";
282
+ }
283
+ }
284
+
151
285
  /**
152
286
  * Opt out of Bun's keep-alive pool after a connection-reset retry.
153
287
  *
@@ -181,12 +315,22 @@ export async function fetchWithResetRetry(
181
315
  ): Promise<Response> {
182
316
  const attempts = Math.max(1, opts.attempts ?? RESET_RETRY_MAX_ATTEMPTS);
183
317
  let lastError: unknown;
318
+ let sawReset = false;
184
319
  for (let attempt = 0; attempt < attempts; attempt++) {
185
320
  if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
186
321
  try {
187
322
  return await doFetch(attempt === 0 ? firstRecovery : "connection-reset");
188
323
  } catch (err) {
189
- if (opts.abortSignal?.aborted || !isConnectionResetError(err) || attempt === attempts - 1) throw err;
324
+ if (opts.abortSignal?.aborted) throw err;
325
+ if (!isConnectionResetError(err)) {
326
+ // A reset that already reached the origin is credential-visible
327
+ // evidence: keep it attached so the terminal rejection cannot be
328
+ // downgraded to the pre-connection neutral class (#914 review).
329
+ if (sawReset) throw new UpstreamRetryEvidenceError([], err, true);
330
+ throw err;
331
+ }
332
+ if (attempt === attempts - 1) throw err;
333
+ sawReset = true;
190
334
  lastError = err;
191
335
  console.warn(
192
336
  `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
@@ -216,6 +360,7 @@ export async function fetchWithTransientRetry(
216
360
  ): Promise<Response> {
217
361
  const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
218
362
  const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS;
363
+ const transientStatuses: number[] = [];
219
364
  let attemptStart = Date.now();
220
365
  let res = await fetchWithResetRetry(doFetch, opts);
221
366
  for (let attempt = 0; attempt < attempts - 1; attempt++) {
@@ -233,7 +378,14 @@ export async function fetchWithTransientRetry(
233
378
  cancelResponseBodyBestEffort(res);
234
379
  await sleepWithAbort(delay, opts.abortSignal);
235
380
  attemptStart = Date.now();
236
- res = await fetchWithResetRetry(doFetch, opts, "transient-5xx");
381
+ transientStatuses.push(res.status);
382
+ try {
383
+ res = await fetchWithResetRetry(doFetch, opts, "transient-5xx");
384
+ } catch (err) {
385
+ // Keep the prior 5xx evidence attached: the origin already responded, so
386
+ // this rejection is not pre-connection and must not classify as neutral.
387
+ throw new UpstreamRetryEvidenceError(transientStatuses, err);
388
+ }
237
389
  }
238
390
  return res;
239
391
  }
@@ -29,14 +29,175 @@
29
29
  * hardenSecretDir — same contract for directories.
30
30
  */
31
31
 
32
- import { existsSync } from "node:fs";
32
+ import { existsSync, statSync } from "node:fs";
33
33
  import { env, platform } from "node:process";
34
34
 
35
- const hardenedDirectories = new Set<string>();
36
- const hardenedPaths = new Set<string>();
35
+ const hardenedDirectories = new Map<string, HardenedIdentity>();
36
+ const hardenedPaths = new Map<string, HardenedIdentity>();
37
37
  /** Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. */
38
38
  const timedOutPaths = new Set<string>();
39
39
 
40
+ /**
41
+ * The memo value: `object:freshness` for a file a harden was actually attributed
42
+ * to.
43
+ *
44
+ * There is deliberately no null member. An observation that cannot be read is
45
+ * not stored at all — the entry is deleted — because a "recorded as unverifiable"
46
+ * value was dead code the moment attribution became a before/after comparison,
47
+ * and a branch nothing can reach is a branch no test can defend.
48
+ */
49
+ type HardenedIdentity = string;
50
+
51
+ /**
52
+ * What a stat can tell us about WHICH OBJECT is at a path.
53
+ *
54
+ * Two fields, deliberately separated, because conflating them shipped a bug:
55
+ *
56
+ * - `object` — `dev:ino`. Answers "is this the same file". Survives an ACL or
57
+ * permission change, which is exactly what we need across an icacls call.
58
+ * - `freshness` — `ctimeNs`. Answers "has this file's metadata moved since". It
59
+ * distinguishes an unlink/recreate that ext4 gave the same inode back for, and
60
+ * it MOVES when permissions change.
61
+ *
62
+ * The first version used `dev:ino:ctimeNs` for both jobs. Since chmod bumps ctime
63
+ * — probed, `{ctimeChangedByChmod: true}` — and icacls is a permission change, the
64
+ * before/after comparison would have rejected its own successful harden and failed
65
+ * closed on every first harden on Windows. Requiring the identity to be unchanged
66
+ * across an operation whose entire purpose is to change it is not a strict check;
67
+ * it is a broken one.
68
+ */
69
+ interface PathObservation {
70
+ readonly object: string;
71
+ readonly freshness: string;
72
+ }
73
+
74
+ /**
75
+ * Observe which object is at a path, and how fresh it is.
76
+ *
77
+ * `dev:ino` alone is not enough to detect a replacement, which a Linux CI run
78
+ * proved: ext4 reuses the inode of an unlinked file immediately — 100 of 100
79
+ * unlink/recreate cycles produced the SAME `ino`, while macOS reused none in 200
80
+ * and happily reported the earlier fix working. That is why `freshness` exists;
81
+ * `ctimeNs` differed in 100 of 100 of those same cycles.
82
+ *
83
+ * `bigint: true` is used because `ctimeNs` exists only in that variant.
84
+ *
85
+ * Test seam: `setStatForTests` replaces this reader so a test can vary `dev`,
86
+ * `ino`, and `ctimeNs` independently. Mirroring the implementation's string
87
+ * format in test setup proves nothing about which components production uses —
88
+ * an audit removed `dev` and all forty tests still passed.
89
+ *
90
+ * UNVERIFIED: the plain `ino` is reported to be 0 on NTFS while the bigint form
91
+ * carries the file index, and the zero-ino guard exists for that case. Neither
92
+ * Darwin nor Linux CI can confirm it and no pinned-Bun Windows probe has run.
93
+ * It is defensive code, not a demonstrated platform fact.
94
+ */
95
+ type StatReader = (path: string) => { dev: bigint; ino: bigint; ctimeNs: bigint };
96
+
97
+ const defaultStatReader: StatReader = path => {
98
+ const s = statSync(path, { bigint: true });
99
+ return { dev: s.dev, ino: s.ino, ctimeNs: s.ctimeNs };
100
+ };
101
+
102
+ let statReader: StatReader = defaultStatReader;
103
+
104
+ /** Test seam: drive dev / ino / ctime independently. */
105
+ export function setStatForTests(reader: StatReader | null): void {
106
+ statReader = reader ?? defaultStatReader;
107
+ }
108
+
109
+ function observe(targetPath: string): PathObservation | null {
110
+ try {
111
+ const s = statReader(targetPath);
112
+ if (s.ino === 0n) return null;
113
+ return { object: `${s.dev}:${s.ino}`, freshness: `${s.ctimeNs}` };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function memoValue(seen: PathObservation): HardenedIdentity {
120
+ return `${seen.object}:${seen.freshness}`;
121
+ }
122
+
123
+ /**
124
+ * True only when this exact FILE was hardened, not merely this pathname.
125
+ *
126
+ * The memo used to be a `Set<string>` of paths. A stable destination — such as
127
+ * the coordinator database `hardenStableLockFile` hardens — can be unlinked and
128
+ * recreated at the same name, and the replacement inherited the previous file's
129
+ * hardening while never having been through icacls. Ephemeral temps escaped this
130
+ * only because atomic writers call `forgetEphemeralSecretPath` once the temp is
131
+ * gone; nothing does that for a stable path.
132
+ */
133
+ function memoSatisfied(cache: Map<string, HardenedIdentity>, targetPath: string): boolean {
134
+ const remembered = cache.get(targetPath);
135
+ if (remembered === undefined) return false;
136
+ const current = observe(targetPath);
137
+ // Unreadable now is not "unchanged": re-harden rather than trust a value we
138
+ // cannot confirm still describes what is there.
139
+ //
140
+ // A miss RETIRES the entry rather than leaving it. Keeping it left the cache in
141
+ // a state nothing could justify: after a mismatch and a failed re-harden, the
142
+ // stale value survived, so restoring the old identity would satisfy it again
143
+ // without any ACL work. That needs exact-identity ABA to bite — outside the
144
+ // proof bound this unit claims — but "the consequence is out of scope" is not a
145
+ // reason to keep an entry we have just proven does not describe what is there.
146
+ if (current === null || memoValue(current) !== remembered) {
147
+ cache.delete(targetPath);
148
+ return false;
149
+ }
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Record a harden ONLY if the file we hardened is still the file at that path.
155
+ *
156
+ * Reading identity after the ACL sequence returns answers "what is there now",
157
+ * which is not the same question as "what did icacls operate on". A replacement
158
+ * landing mid-sequence — probed by swapping the file during the final
159
+ * `/remove:g` — made the memo remember the REPLACEMENT as hardened, so the next
160
+ * acquisition skipped ACL work on a file that had never seen it:
161
+ *
162
+ * {identityChangedDuringHarden: true, callsForOriginal: 3, totalCalls: 3,
163
+ * replacementWasHardened: false}
164
+ *
165
+ * So the OBJECT is captured before the sequence and compared after it. Only the
166
+ * object — `dev:ino` — because icacls changes permissions, and `ctimeNs` moves
167
+ * when permissions change (probed: `{ctimeChangedByChmod: true}`). Comparing the
168
+ * full identity across the call would have rejected every successful harden and
169
+ * failed closed on the first harden on Windows: the check would have been
170
+ * demanding that an operation not do the thing it exists to do.
171
+ *
172
+ * The memo then stores the object plus the freshness read AFTER hardening, which
173
+ * is the state a later lookup should match.
174
+ *
175
+ * A changed object, or an unreadable observation at either end, means we cannot
176
+ * say what was hardened: the memo is cleared rather than written, and required
177
+ * callers fail closed. An optional caller soft-fails, as it does for every other
178
+ * unproven ACL.
179
+ *
180
+ * Returns true when the harden may be reported successful.
181
+ */
182
+ function recordHarden(
183
+ cache: Map<string, HardenedIdentity>,
184
+ targetPath: string,
185
+ before: PathObservation | null,
186
+ ): boolean {
187
+ const after = observe(targetPath);
188
+ if (before === null || after === null || before.object !== after.object) {
189
+ // Never leave a memo behind for a file we cannot vouch for, including one
190
+ // written by an earlier successful harden of a now-replaced file.
191
+ cache.delete(targetPath);
192
+ return false;
193
+ }
194
+ cache.set(targetPath, memoValue(after));
195
+ return true;
196
+ }
197
+
198
+ const SUBSTITUTED_DIAGNOSTIC =
199
+ "ACL hardening could not be attributed — the file at this path changed during hardening";
200
+
40
201
  export interface HardenResult {
41
202
  ok: boolean;
42
203
  diagnostics?: string;
@@ -173,11 +334,37 @@ export function forgetHardenedSecretPath(targetPath: string): void {
173
334
  hardenedPaths.delete(targetPath);
174
335
  }
175
336
 
337
+ /**
338
+ * Ephemeral-path lifecycle release: clears the success memo AND any timeout
339
+ * memo keyed by THIS TEMP path in both namespaces. Call only after the temp is
340
+ * proven absent (successful rename, successful unlink, ENOENT, or an explicit
341
+ * non-existence check). Never pass a stable destination: destination-keyed
342
+ * timeout memos are intentional anti-restall state and are not touched here.
343
+ */
344
+ export function forgetEphemeralSecretPath(tempPath: string): void {
345
+ hardenedPaths.delete(tempPath);
346
+ timedOutPaths.delete(`required:${tempPath}`);
347
+ timedOutPaths.delete(`optional:${tempPath}`);
348
+ }
349
+
350
+ /** Test seam: timeout memo sets return to baseline after ephemeral cleanup. */
351
+ export function timedOutSecretPathCountForTests(): number {
352
+ return timedOutPaths.size;
353
+ }
354
+
176
355
  /** Test seam for proving ephemeral success memos do not grow across replacements. */
177
356
  export function hardenedSecretPathCountForTests(): number {
178
357
  return hardenedPaths.size;
179
358
  }
180
359
 
360
+ /**
361
+ * Directory counterpart. It had no seam, and that absence hid a real gap: a
362
+ * directory-only pathname memo passed every file-based test in this suite.
363
+ */
364
+ export function hardenedSecretDirCountForTests(): number {
365
+ return hardenedDirectories.size;
366
+ }
367
+
181
368
  function effectivePlatform(): string {
182
369
  return platformOverride ?? platform;
183
370
  }
@@ -393,11 +580,13 @@ function hardenEntry(
393
580
  targetPath: string,
394
581
  directory: boolean,
395
582
  opts: HardenOptions,
396
- cache: Set<string>,
583
+ cache: Map<string, HardenedIdentity>,
397
584
  ): HardenResult {
398
- if (!existsSync(targetPath)) return { ok: true };
585
+ // Observed absence retires the memo. Leaving it would let a later file at this
586
+ // path satisfy the cache if the filesystem ever hands back a matching identity.
587
+ if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
399
588
  if (effectivePlatform() !== "win32") return { ok: true };
400
- if (cache.has(targetPath)) return { ok: true };
589
+ if (memoSatisfied(cache, targetPath)) return { ok: true };
401
590
  const memoKey = timeoutMemoKey(targetPath, opts);
402
591
  if (timedOutPaths.has(memoKey)) {
403
592
  const diagnostics = "ACL hardening skipped — previous attempt timed out";
@@ -410,10 +599,17 @@ function hardenEntry(
410
599
  for (let attempt = 0; attempt < 2; attempt++) {
411
600
  if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains
412
601
  try {
602
+ // Captured BEFORE the sequence: this is the file we are about to harden.
603
+ const before = observe(targetPath);
413
604
  runIcacls(targetPath, directory, deadline);
414
- cache.add(targetPath);
605
+ if (!recordHarden(cache, targetPath, before)) {
606
+ if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
607
+ return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
608
+ }
415
609
  return { ok: true };
416
610
  } catch (err) {
611
+ // A substitution is not a transient icacls stall; do not spend the retry on it.
612
+ if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err;
417
613
  lastErr = err;
418
614
  if (!isTimeoutError(err)) break; // real failures do not retry
419
615
  }
@@ -437,11 +633,11 @@ async function hardenEntryAsync(
437
633
  targetPath: string,
438
634
  directory: boolean,
439
635
  opts: HardenOptions,
440
- cache: Set<string>,
636
+ cache: Map<string, HardenedIdentity>,
441
637
  ): Promise<HardenResult> {
442
- if (!existsSync(targetPath)) return { ok: true };
638
+ if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
443
639
  if (effectivePlatform() !== "win32") return { ok: true };
444
- if (cache.has(targetPath)) return { ok: true };
640
+ if (memoSatisfied(cache, targetPath)) return { ok: true };
445
641
  const memoKey = timeoutMemoKey(targetPath, opts);
446
642
  if (timedOutPaths.has(memoKey)) {
447
643
  const diagnostics = "ACL hardening skipped — previous attempt timed out";
@@ -454,10 +650,15 @@ async function hardenEntryAsync(
454
650
  for (let attempt = 0; attempt < 2; attempt++) {
455
651
  if (attempt > 0 && deadline - nowFn() <= 0) break;
456
652
  try {
653
+ const before = observe(targetPath);
457
654
  await runIcaclsAsync(targetPath, directory, deadline);
458
- cache.add(targetPath);
655
+ if (!recordHarden(cache, targetPath, before)) {
656
+ if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
657
+ return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
658
+ }
459
659
  return { ok: true };
460
660
  } catch (err) {
661
+ if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err;
461
662
  lastErr = err;
462
663
  if (!isTimeoutError(err)) break;
463
664
  }
package/src/lib/winsw.ts CHANGED
@@ -20,7 +20,8 @@ import { homedir } from "node:os";
20
20
  import { join, resolve } from "node:path";
21
21
  import { expandUserPath, getConfigDir, loadConfig } from "../config";
22
22
  import { recordOwnedConfigPath } from "./config-ownership";
23
- import { durableBunPath } from "./bun-runtime";
23
+ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime";
24
+ import type { BunRuntimeSource } from "./bun-runtime";
24
25
  import { serviceApiTokenFilePath } from "./service-secrets";
25
26
 
26
27
  export const WINSW_VERSION = "2.12.0";
@@ -64,6 +65,8 @@ function currentCodexHomeAbsolute(): string {
64
65
 
65
66
  export interface WinswEntry {
66
67
  bun: string;
68
+ /** Provenance of `bun`, resolved together with it so the two can never disagree. */
69
+ bunRuntimeSource: BunRuntimeSource;
67
70
  cli: string;
68
71
  }
69
72
 
@@ -95,6 +98,8 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
95
98
  const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim();
96
99
  const envLines = [
97
100
  ` <env name="OCX_SERVICE" value="1"/>`,
101
+ ` <env name="${BUN_RUNTIME_SOURCE_ENV}" value="${xmlEscape(entry.bunRuntimeSource)}"/>`,
102
+ ` <env name="${BUN_RUNTIME_PATH_ENV}" value="${xmlEscape(entry.bun)}"/>`,
98
103
  ` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
99
104
  ` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
100
105
  env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
@@ -363,7 +368,7 @@ export function winswStatusSummary(): string {
363
368
  if (status === "nonexistent") {
364
369
  // A stale SCM service can outlive a deleted exe; surface the repair path.
365
370
  return existsSync(winswXmlPath()) && !existsSync(winswExePath())
366
- ? "native assets present but WinSW binary missing — run 'ocx service install --native' to repair"
371
+ ? "native assets present but WinSW binary missing — run 'ocx service repair'"
367
372
  : "";
368
373
  }
369
374
  return `native (WinSW ${WINSW_VERSION}): ${status}`;
@@ -371,5 +376,6 @@ export function winswStatusSummary(): string {
371
376
 
372
377
  /** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */
373
378
  export function defaultWinswEntry(cliDir: string): WinswEntry {
374
- return { bun: durableBunPath(), cli: join(cliDir, "cli", "index.ts") };
379
+ const runtime = durableBunRuntime();
380
+ return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") };
375
381
  }