@bitkyc08/opencodex 2.63.0 → 2.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-CpuDF3ci.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-B4uEIa1O.js} +1 -1
  10. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  11. package/gui/dist/assets/index-SggB6t3z.js +86 -0
  12. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
package/src/types/wire.ts CHANGED
@@ -70,6 +70,67 @@ export function captureWireAdapterHardPins(providerName: string): Readonly<Recor
70
70
  return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"])));
71
71
  }
72
72
 
73
+ interface WirePinPrefixRule {
74
+ /** The registry endpoint the rule describes; another destination under the same name is not covered. */
75
+ readonly endpoint: string;
76
+ readonly prefixes: Readonly<Record<string, string>>;
77
+ }
78
+
79
+ /**
80
+ * Provider-local model-id prefixes whose upstream accepts only one wire, bound to the endpoint that
81
+ * behaves that way. Command Code's Provider API serves `claude-*` ids only on `/provider/v1/messages`
82
+ * (live `supported_endpoints` on 2026-09-23 list `/messages` alone; `/chat/completions` answers 400
83
+ * "must be called via /provider/v1/messages"). A prefix covers Claude ids Command Code adds later.
84
+ */
85
+ const WIRE_ADAPTER_PIN_PREFIXES: Readonly<Record<string, WirePinPrefixRule>> = Object.freeze({
86
+ commandcode: Object.freeze({
87
+ endpoint: "https://api.commandcode.ai/provider/v1",
88
+ prefixes: Object.freeze({ "claude-": "anthropic" }),
89
+ }),
90
+ });
91
+
92
+ /** Just enough of a provider config to tell whether it still points at the pinned endpoint. */
93
+ export interface WirePinProvider {
94
+ readonly baseUrl?: unknown;
95
+ }
96
+
97
+ function normalizedEndpoint(value: unknown): string | undefined {
98
+ if (typeof value !== "string") return undefined;
99
+ try {
100
+ const url = new URL(value.trim());
101
+ return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase();
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ function prefixPinsFor(providerName: string, provider: WirePinProvider | undefined): Readonly<Record<string, string>> | undefined {
108
+ if (!provider || !Object.hasOwn(WIRE_ADAPTER_PIN_PREFIXES, providerName)) return undefined;
109
+ const rule = WIRE_ADAPTER_PIN_PREFIXES[providerName]!;
110
+ return normalizedEndpoint(provider.baseUrl) === rule.endpoint ? rule.prefixes : undefined;
111
+ }
112
+
113
+ /**
114
+ * Detached provider-local prefix pins for pure wire-policy resolution. Empty unless the provider
115
+ * still points at the endpoint the rule describes.
116
+ */
117
+ export function captureWireAdapterHardPinPrefixes(
118
+ providerName: string,
119
+ provider: WirePinProvider | undefined,
120
+ ): Readonly<Record<string, string>> {
121
+ return Object.freeze({ ...(prefixPinsFor(providerName, provider) ?? {}) });
122
+ }
123
+
124
+ function prefixedWireAdapter(providerName: string, modelId: string, provider: WirePinProvider | undefined): string | undefined {
125
+ const prefixes = prefixPinsFor(providerName, provider);
126
+ if (!prefixes) return undefined;
127
+ const folded = modelId.toLowerCase();
128
+ for (const [prefix, adapter] of Object.entries(prefixes)) {
129
+ if (folded.startsWith(prefix)) return adapter;
130
+ }
131
+ return undefined;
132
+ }
133
+
73
134
  /**
74
135
  * True when the upstream speaks exactly one wire for this model, so a configured
75
136
  * override must not apply.
@@ -78,11 +139,14 @@ export function captureWireAdapterHardPins(providerName: string): Readonly<Recor
78
139
  * more than once per request, and a check phrased as "pin differs from the current
79
140
  * adapter" would pass on the first pass and then let the override win on the second.
80
141
  */
81
- export function isWirePinnedModel(providerName: string, modelId: string): boolean {
82
- return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false;
142
+ export function isWirePinnedModel(providerName: string, modelId: string, provider?: WirePinProvider): boolean {
143
+ return (anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false)
144
+ || prefixedWireAdapter(providerName, modelId, provider) !== undefined;
83
145
  }
84
146
 
85
147
  /** The wire a pinned model must use, or undefined when the model is not pinned. */
86
- export function pinnedWireAdapter(providerName: string, modelId: string): string | undefined {
87
- return isWirePinnedModel(providerName, modelId) ? "anthropic" : undefined;
148
+ export function pinnedWireAdapter(providerName: string, modelId: string, provider?: WirePinProvider): string | undefined {
149
+ return anthropicWireModelsForProvider(providerName)?.has(modelId)
150
+ ? "anthropic"
151
+ : prefixedWireAdapter(providerName, modelId, provider);
88
152
  }
package/src/types.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  OPENAI_PROVIDER_TIER_VERSION,
29
29
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
30
30
  captureWireAdapterHardPins,
31
+ captureWireAdapterHardPinPrefixes,
31
32
  isWirePinnedModel,
32
33
  pinnedWireAdapter,
33
34
  } from "./types/wire";
package/src/update/job.ts CHANGED
@@ -24,7 +24,13 @@ import {
24
24
  import { stopWinswService } from "../lib/winsw";
25
25
  import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } from "../server/port-reclaim";
26
26
  import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop";
27
- import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness";
27
+ import {
28
+ isHealthzVersion,
29
+ isOpencodexHealthz,
30
+ probeHostname,
31
+ proxyIdentityAt,
32
+ type HealthzIdentity,
33
+ } from "../server/proxy-liveness";
28
34
  import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service";
29
35
  import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership";
30
36
  import {
@@ -45,6 +51,7 @@ import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs";
45
51
  import { isNewer } from "./notify";
46
52
  import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
47
53
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
54
+ import { GUI_UPDATE_FAILURE_NEXT_STEP } from "./update-failure-guidance.mjs";
48
55
  import {
49
56
  npmCachePreflightFailureMessage,
50
57
  runNpmCachePreflight,
@@ -268,19 +275,6 @@ function ensureJobDir(): void {
268
275
  * TYPE and size — enough to tell a reader what class of failure occurred — and never its text,
269
276
  * which is where the paths and account names live.
270
277
  */
271
- /**
272
- * A version string we are willing to repeat in a persisted field.
273
- *
274
- * Semver plus an optional prerelease/build tail, capped in length. Anything else is dropped
275
- * rather than logged: `/healthz` is answered by whatever holds the port, so its `version` is
276
- * external input on the same footing as an error message.
277
- */
278
- function isVersionLike(value: unknown): value is string {
279
- return typeof value === "string"
280
- && value.length <= 64
281
- && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
282
- }
283
-
284
278
  function withheldSummary(error: unknown): string {
285
279
  // `error.name` is writable, so it is external text like the message. A fixed classification
286
280
  // is the only part of an unknown error we can state without repeating something we were
@@ -1589,7 +1583,7 @@ async function defaultProbeProxyIdentity(
1589
1583
  // `/healthz` is answered by whatever is listening on that port, so a hostile or confused
1590
1584
  // responder can return any string here — and the restart-evidence reasons below
1591
1585
  // interpolate it into a persisted field. A version is a version or it is nothing.
1592
- ...(isVersionLike(body?.version) ? { version: body.version } : {}),
1586
+ ...(isHealthzVersion(body?.version) ? { version: body.version } : {}),
1593
1587
  };
1594
1588
  } catch {
1595
1589
  return null;
@@ -1949,7 +1943,7 @@ export async function runGuiUpdateWorker(
1949
1943
  status: "failed",
1950
1944
  exitCode: result.status,
1951
1945
  signal: result.signal,
1952
- error: `update command failed (${result.status ?? "?"})`,
1946
+ error: `update command failed (${result.status ?? "?"}). ${GUI_UPDATE_FAILURE_NEXT_STEP}`,
1953
1947
  });
1954
1948
  return;
1955
1949
  }
@@ -12,21 +12,17 @@ function escapeCmdCommand(command) {
12
12
  return command.replace(CMD_META, "^$1");
13
13
  }
14
14
 
15
- /**
16
- * Whether a PATH entry *is* the current directory. The hijack this guards against is
17
- * cmd.exe resolving a bare `npm` out of the directory opencodex was launched from, so
18
- * only that exact directory has to be skipped — every candidate we hand to spawn is an
19
- * absolute path, which is what actually defeats the implicit cwd-first search.
20
- *
21
- * Deliberately not a subtree test: npm's default Windows global prefix is
22
- * `%AppData%\npm` (`C:\Users\x\AppData\Roaming\npm`), so excluding everything under the
23
- * cwd would fail closed for anyone whose shell sits in their home directory — a normal
24
- * setup, not the untrusted-project case this hardening is for.
25
- */
26
- function isCurrentDirectory(cwd, entry) {
27
- const left = win32.resolve(entry);
28
- const right = win32.resolve(cwd);
29
- return left.toLowerCase() === right.toLowerCase();
15
+ function isInside(root, candidate) {
16
+ const relative = win32.relative(win32.resolve(root), win32.resolve(candidate));
17
+ return relative === "" || (
18
+ relative !== ".."
19
+ && !relative.startsWith(`..${win32.sep}`)
20
+ && !win32.isAbsolute(relative)
21
+ );
22
+ }
23
+
24
+ function isSamePath(left, right) {
25
+ return win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase();
30
26
  }
31
27
 
32
28
  function cleanPathEntry(entry) {
@@ -43,6 +39,10 @@ export function resolveNpmCommand(
43
39
  if (platform !== "win32") return "npm";
44
40
  const exists = deps.exists ?? existsSync;
45
41
  const cwd = deps.cwd ?? process.cwd();
42
+ const trustedRoots = [env.APPDATA, env.LOCALAPPDATA, env.ProgramFiles, env["ProgramFiles(x86)"],
43
+ env.USERPROFILE && win32.join(env.USERPROFILE, "scoop", "shims")]
44
+ .filter(root => typeof root === "string" && win32.isAbsolute(root));
45
+ const trustedEntry = entry => trustedRoots.some(root => isInside(root, entry) && !isInside(root, cwd));
46
46
  const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
47
47
  .split(";")
48
48
  .filter(Boolean);
@@ -53,7 +53,8 @@ export function resolveNpmCommand(
53
53
 
54
54
  for (const entry of pathEntries) {
55
55
  if (!win32.isAbsolute(entry)) continue;
56
- if (isCurrentDirectory(cwd, entry)) continue;
56
+ if (isSamePath(entry, cwd)) continue;
57
+ if (isInside(cwd, entry) && !trustedEntry(entry)) continue;
57
58
  for (const extension of extensions) {
58
59
  const candidate = win32.join(entry, `npm${extension.toLowerCase()}`);
59
60
  if (exists(candidate)) return win32.resolve(candidate);
@@ -5,6 +5,27 @@ export function bootRestoreProbe(
5
5
  packageDir: string,
6
6
  deps?: { rename?: (from: string, to: string) => void },
7
7
  ): { action: "none" | "reaped" | "restored" | "failed"; count?: number; from?: string; error?: string };
8
+ export type UpdateFsDeps = {
9
+ rename?: (from: string, to: string) => void;
10
+ mkdir?: (path: string) => void;
11
+ rm?: (path: string, options: { recursive: true; force: true }) => void;
12
+ now?: () => number;
13
+ };
14
+ export const UPDATE_OWNER_MARKER: string;
15
+ export const STALE_STAGE_MIN_AGE_MS: number;
16
+ export function removeOwnedStage(stageRoot: string, deps?: UpdateFsDeps): { removed: boolean; code?: string };
17
+ export function sweepUpdateLeftovers(args: {
18
+ packageDir: string;
19
+ pkgName: string;
20
+ log?: (line: string) => void;
21
+ deps?: UpdateFsDeps;
22
+ }): {
23
+ removed: string[];
24
+ inUse: Array<{ path: string; code: string }>;
25
+ recent: string[];
26
+ notOwned: string[];
27
+ };
28
+ export function launcherUsableAfterNpmUpdate(tx: { ok: boolean; phase: string; rolledBack?: boolean } | null | undefined): boolean;
8
29
  export function transactionalNpmUpdate(args: {
9
30
  packageDir: string;
10
31
  pkgName: string;
@@ -12,7 +33,7 @@ export function transactionalNpmUpdate(args: {
12
33
  tag: string;
13
34
  runNpm: (args: string[]) => { status: number | null };
14
35
  log?: (line: string) => void;
15
- deps?: { rename?: (from: string, to: string) => void };
36
+ deps?: UpdateFsDeps;
16
37
  }): {
17
38
  ok: boolean;
18
39
  phase: "stage" | "verify" | "swap-backup" | "swap-live" | "post-verify" | "double-fault" | "done";
@@ -15,9 +15,15 @@
15
15
  * <scopeDir>/.ocx-staging-<ts>/ npm --prefix root (contains node_modules/...)
16
16
  * <scopeDir>/.ocx-backup-<ts>/opencodex previous live tree during/after the swap
17
17
  * <scopeDir>/.ocx-recovery.json double-fault marker with a one-line restore
18
+ *
19
+ * A staging directory is created exclusively and immediately carries an ownership marker
20
+ * (`.ocx-update-owner.json`). Only a marked staging directory is ever swept by a later
21
+ * update; anything else next to the package — an unmarked stage from an older updater, npm's
22
+ * own `.<name>-<random>` rename-aside, a link — is reported and left alone (#5624).
18
23
  */
19
24
  import { spawnSync } from "node:child_process";
20
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
25
+ import { randomBytes } from "node:crypto";
26
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
21
27
  import { basename, dirname, join } from "node:path";
22
28
 
23
29
  /**
@@ -160,6 +166,146 @@ function stampedName(prefix) {
160
166
  return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-");
161
167
  }
162
168
 
169
+ export const UPDATE_OWNER_MARKER = ".ocx-update-owner.json";
170
+ const STAGE_PREFIX = ".ocx-staging-";
171
+ /**
172
+ * A marked stage younger than this is left alone by the sweep. npm's staging install is bounded
173
+ * at three minutes by the launcher; the floor is far above it so an update running from another
174
+ * home against the same global prefix never loses its in-flight stage.
175
+ */
176
+ export const STALE_STAGE_MIN_AGE_MS = 30 * 60 * 1000;
177
+ const RETRYABLE_REMOVE_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOTEMPTY"]);
178
+
179
+ /** Create a fresh staging directory that did not exist before, and mark it as ours. */
180
+ function createOwnedStage(scopeDir, pkgName, deps = {}) {
181
+ const mkdir = deps.mkdir ?? mkdirSync;
182
+ const stageRoot = join(scopeDir, stampedName(".ocx-staging") + "-" + randomBytes(4).toString("hex"));
183
+ // Not recursive: an existing directory of the same name must fail, never be reused.
184
+ mkdir(stageRoot);
185
+ try {
186
+ writeFileSync(join(stageRoot, UPDATE_OWNER_MARKER), JSON.stringify({
187
+ schema: 1,
188
+ kind: "staging",
189
+ pkgName,
190
+ pid: process.pid,
191
+ createdAt: (deps.now ?? Date.now)(),
192
+ }), { flag: "wx" });
193
+ } catch (error) {
194
+ // Still empty and created by this call: remove it rather than leave an unmarked stage.
195
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* reported by the caller */ }
196
+ throw error;
197
+ }
198
+ return stageRoot;
199
+ }
200
+
201
+ /** The marker of a real (non-link) staging directory this updater created, or null. */
202
+ function readOwnedStageMarker(dir, pkgName) {
203
+ try {
204
+ const dirStat = lstatSync(dir);
205
+ // A symlink or junction is never ours to delete: removing through it reaches its target.
206
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return null;
207
+ const markerPath = join(dir, UPDATE_OWNER_MARKER);
208
+ if (!lstatSync(markerPath).isFile()) return null;
209
+ const marker = JSON.parse(readFileSync(markerPath, "utf8"));
210
+ if (marker?.schema !== 1 || marker.kind !== "staging" || marker.pkgName !== pkgName) return null;
211
+ if (typeof marker.createdAt !== "number" || !Number.isFinite(marker.createdAt)) return null;
212
+ return marker;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function removeWithRetry(rm, target, attempts = 3) {
219
+ for (let attempt = 0; ; attempt += 1) {
220
+ try {
221
+ rm(target, { recursive: true, force: true });
222
+ return null;
223
+ } catch (error) {
224
+ const code = typeof error?.code === "string" ? error.code : "EUNKNOWN";
225
+ if (!RETRYABLE_REMOVE_CODES.has(code) || attempt >= attempts - 1) return code;
226
+ const until = Date.now() + 100 * (attempt + 1);
227
+ while (Date.now() < until) { /* brief synchronous backoff, as renameWithRetry */ }
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Remove an owned staging directory. The marker goes last and only once everything else is
234
+ * gone, so a tree held open by a running executable (the reported locked `bunx.exe`) stays
235
+ * provably ours and the next update can finish the job.
236
+ */
237
+ export function removeOwnedStage(stageRoot, deps = {}) {
238
+ const rm = deps.rm ?? rmSync;
239
+ let names;
240
+ try {
241
+ names = readdirSync(stageRoot);
242
+ } catch (error) {
243
+ return error?.code === "ENOENT" ? { removed: true } : { removed: false, code: error?.code ?? "EUNKNOWN" };
244
+ }
245
+ let failure = null;
246
+ for (const name of names) {
247
+ if (name === UPDATE_OWNER_MARKER) continue;
248
+ const code = removeWithRetry(rm, join(stageRoot, name));
249
+ if (code && !failure) failure = code;
250
+ }
251
+ if (failure) return { removed: false, code: failure };
252
+ const markerCode = removeWithRetry(rm, join(stageRoot, UPDATE_OWNER_MARKER));
253
+ if (markerCode) return { removed: false, code: markerCode };
254
+ const dirCode = removeWithRetry(rm, stageRoot);
255
+ return dirCode ? { removed: false, code: dirCode } : { removed: true };
256
+ }
257
+
258
+ /**
259
+ * Clear what earlier update attempts left next to the package, without ever deleting anything
260
+ * this updater cannot prove it created. Never throws and never fails the update: every stage is
261
+ * a fresh, uniquely named directory, so a leftover can only cost disk space, not block staging.
262
+ */
263
+ export function sweepUpdateLeftovers({ packageDir, pkgName, log = () => {}, deps = {} }) {
264
+ const scopeDir = dirname(packageDir);
265
+ const now = (deps.now ?? Date.now)();
266
+ const result = { removed: [], inUse: [], recent: [], notOwned: [] };
267
+ let names = [];
268
+ try {
269
+ names = readdirSync(scopeDir);
270
+ } catch {
271
+ return result;
272
+ }
273
+ // npm renames a package it is replacing to ".<name>-<random>" during a direct global install.
274
+ const renameAsidePrefix = "." + basename(packageDir) + "-";
275
+ for (const name of names.sort()) {
276
+ const full = join(scopeDir, name);
277
+ if (name.startsWith(STAGE_PREFIX)) {
278
+ const marker = readOwnedStageMarker(full, pkgName);
279
+ if (!marker) {
280
+ result.notOwned.push(full);
281
+ } else if (now - marker.createdAt < STALE_STAGE_MIN_AGE_MS) {
282
+ result.recent.push(full);
283
+ } else {
284
+ const removal = removeOwnedStage(full, deps);
285
+ if (removal.removed) result.removed.push(full);
286
+ else result.inUse.push({ path: full, code: removal.code });
287
+ }
288
+ } else if (name.startsWith(renameAsidePrefix)) {
289
+ result.notOwned.push(full);
290
+ }
291
+ }
292
+ // Names only: the full path under a user-scoped npm prefix carries the account name, and
293
+ // this logger is the launcher's console.
294
+ for (const path of result.removed) log("Removed a staging directory left by an earlier update: " + basename(path));
295
+ for (const entry of result.inUse) {
296
+ log("Left an earlier update's staging directory in place (" + entry.code + "; a file inside is still in use); the next update retries it: " + basename(entry.path));
297
+ }
298
+ for (const path of result.notOwned) {
299
+ log("Not removing " + basename(path) + " next to the package: this updater did not create it. Delete it by hand once no OpenCodex process is running from it.");
300
+ }
301
+ return result;
302
+ }
303
+
304
+ /** Whether the launcher that ran this npm update can still drive service/tray recovery. */
305
+ export function launcherUsableAfterNpmUpdate(tx) {
306
+ return Boolean(tx?.ok || tx?.rolledBack === true || ["stage", "verify", "swap-backup"].includes(tx?.phase));
307
+ }
308
+
163
309
  /** Largest regular file under a directory tree (bounded depth) — locates the Bun binary. */
164
310
  function findLargestFile(root, depth = 3) {
165
311
  let best;
@@ -247,29 +393,37 @@ export function transactionalNpmUpdate({
247
393
  }) {
248
394
  const rename = deps.rename ?? renameSync;
249
395
  const scopeDir = dirname(packageDir);
250
- const stageRoot = join(scopeDir, stampedName(".ocx-staging"));
396
+ // Leftovers from earlier attempts are cleared or stepped around first; this never fails.
397
+ try { sweepUpdateLeftovers({ packageDir, pkgName, log, deps }); } catch { /* report-only */ }
398
+ let stageRoot;
251
399
  // GLOBAL-style staging (-g --prefix): npm nests the package's dependencies INSIDE the
252
400
  // package dir, exactly like the live global tree this stage will replace. A local-style
253
401
  // install would hoist bun/zod to stageRoot/node_modules — siblings that the swap would
254
402
  // leave behind, shipping a dependency-less live tree (release-audit blocker).
255
403
  // Layout: <stageRoot>/lib/node_modules/<pkg> on POSIX, <stageRoot>/node_modules/<pkg>
256
404
  // on Windows.
257
- const stagedCandidates = [
258
- join(stageRoot, "lib", "node_modules", ...pkgName.split("/")),
259
- join(stageRoot, "node_modules", ...pkgName.split("/")),
260
- ];
261
405
 
262
406
  // D1: stage to the side. --prefix keeps npm entirely inside stageRoot; the live tree
263
407
  // and the npm bin shims are untouched until the swap. A failure HERE (mkdir EACCES,
264
408
  // ENOSPC) must NOT fall back to the destructive legacy install (review High 4): the
265
409
  // caller sees a normal phase failure with live untouched.
266
410
  try {
267
- mkdirSync(stageRoot, { recursive: true });
411
+ stageRoot = createOwnedStage(scopeDir, pkgName, deps);
268
412
  } catch (error) {
269
413
  return { ok: false, phase: "stage", error: "could not create staging directory: " + (error?.message ?? String(error)) };
270
414
  }
415
+ const stagedCandidatesIn = root => [
416
+ join(root, "lib", "node_modules", ...pkgName.split("/")),
417
+ join(root, "node_modules", ...pkgName.split("/")),
418
+ ];
419
+ const discardStage = () => {
420
+ const removal = removeOwnedStage(stageRoot, deps);
421
+ if (!removal.removed) {
422
+ log("Left this update's staging directory in place (" + removal.code + "; a file inside is still in use); the next update removes it: " + basename(stageRoot));
423
+ }
424
+ };
271
425
  const spec = pkgName + "@" + (targetVersion || tag);
272
- log("Staging " + spec + " into " + stageRoot);
426
+ log("Staging " + spec + " into " + basename(stageRoot) + " next to the package");
273
427
  // npm 12 blocks lifecycle scripts by default. Bun's postinstall copies the selected
274
428
  // @oven/bun-* executable into bun/bin, so a successful npm exit without this narrow
275
429
  // approval leaves the staged tree intentionally incomplete. Allow only the package
@@ -279,19 +433,19 @@ export function transactionalNpmUpdate({
279
433
  "--allow-scripts=bun", "--no-audit", "--no-fund", spec,
280
434
  ]);
281
435
  if (install.status !== 0) {
282
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
436
+ discardStage();
283
437
  return { ok: false, phase: "stage", error: "npm staging install failed (" + (install.status ?? "?") + ")" };
284
438
  }
285
- const stagedPackage = stagedCandidates.find(dir => existsSync(join(dir, "package.json")));
439
+ const stagedPackage = stagedCandidatesIn(stageRoot).find(dir => existsSync(join(dir, "package.json")));
286
440
  if (!stagedPackage) {
287
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
441
+ discardStage();
288
442
  return { ok: false, phase: "verify", error: "staged package directory not found under " + stageRoot };
289
443
  }
290
444
 
291
445
  // D2: verify INSIDE the stage. Live is still untouched on any failure here.
292
446
  const staged = verifyInstallTree(stagedPackage, targetVersion || undefined);
293
447
  if (!staged.ok) {
294
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
448
+ discardStage();
295
449
  return { ok: false, phase: "verify", error: "staged tree failed verification: " + staged.failures.join("; ") };
296
450
  }
297
451
 
@@ -301,13 +455,13 @@ export function transactionalNpmUpdate({
301
455
  try {
302
456
  mkdirSync(backupRoot, { recursive: true });
303
457
  } catch (error) {
304
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
458
+ discardStage();
305
459
  return { ok: false, phase: "swap-backup", error: "could not create backup directory: " + (error?.message ?? String(error)) };
306
460
  }
307
461
  try {
308
462
  renameWithRetry(rename, packageDir, backupPackage);
309
463
  } catch (error) {
310
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
464
+ discardStage();
311
465
  try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
312
466
  return { ok: false, phase: "swap-backup", error: "could not move live tree aside: " + (error?.message ?? String(error)) };
313
467
  }
@@ -317,7 +471,7 @@ export function transactionalNpmUpdate({
317
471
  // Rollback: reverse the first rename. Double fault leaves the recovery marker.
318
472
  try {
319
473
  renameWithRetry(rename, backupPackage, packageDir);
320
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
474
+ discardStage();
321
475
  try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
322
476
  return { ok: false, phase: "swap-live", rolledBack: true, error: "could not place staged tree: " + (error?.message ?? String(error)) };
323
477
  } catch (rollbackError) {
@@ -335,9 +489,12 @@ export function transactionalNpmUpdate({
335
489
  const liveCheck = verifyInstallTree(packageDir, targetVersion || undefined);
336
490
  if (!liveCheck.ok) {
337
491
  try {
338
- rmSync(packageDir, { recursive: true, force: true });
339
- rename(backupPackage, packageDir);
340
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
492
+ // Move the rejected tree into our own stage instead of deleting it in place: a file held
493
+ // open there must not turn a clean rollback into a double fault. The stage's marker keeps
494
+ // it sweepable by the next update.
495
+ renameWithRetry(rename, packageDir, join(stageRoot, "rejected"));
496
+ renameWithRetry(rename, backupPackage, packageDir);
497
+ discardStage();
341
498
  try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
342
499
  return { ok: false, phase: "post-verify", rolledBack: true, error: "live tree failed post-swap verification: " + liveCheck.failures.join("; ") };
343
500
  } catch (rollbackError) {
@@ -355,6 +512,6 @@ export function transactionalNpmUpdate({
355
512
  // Success: stage scaffolding is disposable now; the backup stays until the next
356
513
  // healthy boot reaps it (bootRestoreProbe) — the process that spawned this update may
357
514
  // still hold the old cwd.
358
- try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
515
+ discardStage();
359
516
  return { ok: true, phase: "done", backup: backupPackage };
360
517
  }
@@ -0,0 +1,8 @@
1
+ export function npmUpdateFailureGuidance(failure: {
2
+ phase?: string;
3
+ rolledBack?: boolean;
4
+ pkgName: string;
5
+ version?: string;
6
+ tag?: string;
7
+ }): { previousVersionKept: boolean; lines: string[] };
8
+ export const GUI_UPDATE_FAILURE_NEXT_STEP: string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What to tell the operator after a failed npm self-update (#5624).
3
+ *
4
+ * The transactional updater (`transactional-install.mjs`) either leaves the live package
5
+ * untouched, rolls it back, or — only on a double fault — leaves it moved aside with a recovery
6
+ * marker. Each of those needs a different next step, and the one command a user most often
7
+ * reaches for after a failed update, a bare `npm install -g` under a live proxy, is the one that
8
+ * fences the proxy (#5496). So the guidance stops the proxy first.
9
+ *
10
+ * Plain ESM with no Bun APIs: the Node launcher (`bin/ocx.mjs`) imports it.
11
+ */
12
+
13
+ /** Phases in which the live package was never touched. */
14
+ const UNTOUCHED_PHASES = new Set(["stage", "verify", "swap-backup"]);
15
+
16
+ /**
17
+ * @param {{ phase?: string; rolledBack?: boolean; pkgName: string; version?: string; tag?: string }} failure
18
+ * @returns {{ previousVersionKept: boolean; lines: string[] }}
19
+ */
20
+ export function npmUpdateFailureGuidance({ phase, rolledBack, pkgName, version, tag }) {
21
+ const reinstall = "npm install -g --allow-scripts=bun " + pkgName + "@" + (version || tag || "latest");
22
+ const kept = UNTOUCHED_PHASES.has(phase ?? "") || rolledBack === true;
23
+ if (!kept) {
24
+ return {
25
+ previousVersionKept: false,
26
+ lines: [
27
+ "The previous version was moved aside and could not be put back automatically.",
28
+ "Next: run the \"restore\" command recorded in .ocx-recovery.json next to the package, or reinstall with '" + reinstall + "'.",
29
+ ],
30
+ };
31
+ }
32
+ return {
33
+ previousVersionKept: true,
34
+ lines: [
35
+ "The previous version is still installed.",
36
+ "Next: run 'ocx update' again. If it fails the same way, run 'ocx stop', then '" + reinstall
37
+ + "', then 'ocx service restart' ('ocx start' when no background service is installed).",
38
+ ],
39
+ };
40
+ }
41
+
42
+ /**
43
+ * The dashboard job only sees the launcher's exit status; its output is withheld because it can
44
+ * carry local paths. Point at the terminal, where the launcher prints the phase-specific step.
45
+ */
46
+ export const GUI_UPDATE_FAILURE_NEXT_STEP =
47
+ "Run 'ocx update' in a terminal to see the reason and the exact recovery step.";