@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { KiroOAuthMetadata } from "./oauth/types";
2
+
1
3
  export interface OcxParsedRequest {
2
4
  modelId: string;
3
5
  previousResponseId?: string;
@@ -23,6 +25,8 @@ export interface OcxParsedRequest {
23
25
  * derived from the parent thread id.
24
26
  */
25
27
  _cursorIsolateConversation?: boolean;
28
+ /** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */
29
+ _kiroAuthContext?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
26
30
  /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
27
31
  _providerContinuation?: OcxProviderContinuationState;
28
32
  /**
@@ -31,6 +35,8 @@ export interface OcxParsedRequest {
31
35
  * executes searches via the gpt-5.4-mini sidecar (see src/web-search). Absent when not requested.
32
36
  */
33
37
  _webSearch?: Record<string, unknown>;
38
+ /** Hosted image_generation tool config stashed for the image bridge sidecar (see src/images). */
39
+ _imageGeneration?: { toolNames: Set<string>; originalTool?: Record<string, unknown> };
34
40
  /**
35
41
  * True when Codex requested structured output (`text.format` = json_schema/json_object). The
36
42
  * web-search tool_result is then rendered as compact JSON instead of markdown prose, so its
@@ -152,6 +158,10 @@ export interface OcxTool {
152
158
  loadedFromToolSearch?: boolean;
153
159
  /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */
154
160
  webSearch?: boolean;
161
+ /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */
162
+ imageGeneration?: boolean;
163
+ /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */
164
+ videoGeneration?: boolean;
155
165
  }
156
166
 
157
167
  /**
@@ -432,6 +442,11 @@ export interface OcxClaudeCodeConfig {
432
442
  * free. Only ocx-*.md files are owned/pruned. Default: enabled.
433
443
  */
434
444
  injectAgents?: boolean;
445
+ /**
446
+ * Optional Claude Code effort pinned in every generated ocx-* subagent
447
+ * definition. Unset inherits the parent session effort.
448
+ */
449
+ subagentEffort?: "low" | "medium" | "high" | "xhigh" | "max";
435
450
  /** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */
436
451
  webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string };
437
452
  /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */
@@ -459,6 +474,25 @@ export interface OcxClaudeDesktopProfile {
459
474
  appliedAt?: string;
460
475
  }
461
476
 
477
+ /**
478
+ * Opt-in archived-session auto-cleanup policy (issue #42 Phase 3).
479
+ * Persisted under `OcxConfig.storageCleanupPolicy`. Default `enabled: false`.
480
+ */
481
+ export interface StorageCleanupPolicy {
482
+ /** When false/unset, the engine never mutates. Default false. */
483
+ enabled: boolean;
484
+ /** Run when archived session bytes exceed this threshold. */
485
+ trigger: { archivedBytesOver: number };
486
+ /** Either shrink archives toward a byte floor, or remove the oldest N%. */
487
+ target: { reduceToBytes?: number } | { removeOldestPercent?: number };
488
+ schedule: "startup" | "daily" | "weekly" | "manual";
489
+ /** Default quarantine. Permanent only when explicitly set. */
490
+ mode: "quarantine" | "permanent";
491
+ lastRun?: { at: number; freedBytes: number; removed: number };
492
+ /** Epoch ms when the next scheduled evaluation is due. */
493
+ nextRun?: number;
494
+ }
495
+
462
496
  /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */
463
497
  export interface OcxCustomModel {
464
498
  /** 고유 ID (crypto.randomUUID()) */
@@ -501,12 +535,28 @@ export interface OcxConfig {
501
535
  */
502
536
  subagentModelFallbackPollMs?: number;
503
537
  injectionModel?: string;
538
+ /**
539
+ * Opt in to synchronizing the selected injection model into Codex's native
540
+ * sub-agent defaults. Only meaningful while `injectionModel` is set.
541
+ */
542
+ syncCodexSubagentDefaults?: boolean;
504
543
  /**
505
544
  * Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls
506
545
  * (`reasoning_effort` argument). Only meaningful while `injectionModel` is set; validated against
507
546
  * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary.
508
547
  */
509
548
  injectionEffort?: string;
549
+ /**
550
+ * Explicit sideband websocket base for realtime/live joins, mirroring upstream's
551
+ * `experimental_realtime_ws_base_url`. The value is a ROOT (or a recognized
552
+ * `/realtime`, `/realtime/calls/<id>`, `/live/<id>` endpoint form, which is
553
+ * stripped back to the root); `/v1` is appended during normalization. Intended
554
+ * for local development against a fake realtime server — plaintext `http`/`ws`
555
+ * is accepted only for loopback hosts, and URL userinfo is rejected; both
556
+ * failures close to the canonical `https://api.openai.com/v1`. Configured by
557
+ * editing this file; there is deliberately no management-API or GUI surface.
558
+ */
559
+ experimentalRealtimeWsBaseUrl?: string;
510
560
  /**
511
561
  * Model ids the user has EXCLUDED from the Grok Build managed block. Absent or empty
512
562
  * means "everything visible", which is the historical behaviour — so an existing
@@ -613,6 +663,12 @@ export interface OcxConfig {
613
663
  shutdownTimeoutMs?: number;
614
664
  /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
615
665
  websockets?: boolean;
666
+ /**
667
+ * Opt-in auto-cleanup policy for archived Codex sessions (issue #42 Phase 3).
668
+ * Default OFF (`enabled` false / unset). Never enabled implicitly.
669
+ * See `src/storage/policy.ts`.
670
+ */
671
+ storageCleanupPolicy?: StorageCleanupPolicy;
616
672
  /** Generated API keys for external access to the proxy's /v1/responses endpoint. */
617
673
  apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>;
618
674
  /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
@@ -640,12 +696,38 @@ export interface OcxConfig {
640
696
  search?: OcxSearchConfig;
641
697
  /** Codex multi-account pool. */
642
698
  codexAccounts?: CodexAccount[];
699
+ /** Account ids administratively excluded from future pool selection until resumed. */
700
+ pausedCodexAccountIds?: string[];
701
+ /**
702
+ * Public model-selector namespaces bound to one Codex account. Values are stored account ids;
703
+ * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases
704
+ * are intentionally separate from these selectors.
705
+ */
706
+ codexAccountNamespaces?: Record<string, string>;
643
707
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
644
708
  activeCodexAccountId?: string;
645
709
  /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */
646
710
  autoSwitchThreshold?: number;
711
+ /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */
712
+ accountPoolStrategy?: OcxAccountPoolRotationStrategy;
713
+ /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
714
+ accountPoolStickyLimit?: number;
647
715
  /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */
648
716
  upstreamFailoverThreshold?: number;
717
+ /**
718
+ * Opt-in Anthropic OAuth account pool (#294). Default OFF.
719
+ * Failover on 429 + sticky affinity; new sessions may pick lowest known 5h usage.
720
+ * Experimental — see docs and GUI warning before enabling.
721
+ */
722
+ anthropicAccountPool?: {
723
+ enabled?: boolean;
724
+ /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */
725
+ autoSwitchThreshold?: number;
726
+ /** New-session rotation strategy. Default quota (today's behaviour). */
727
+ strategy?: OcxAccountPoolRotationStrategy;
728
+ /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
729
+ stickyLimit?: number;
730
+ };
649
731
  /** Virtual `combo/<id>` models spanning concrete provider/model targets (issue #133). */
650
732
  combos?: Record<string, OcxComboConfig>;
651
733
  /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */
@@ -654,6 +736,8 @@ export interface OcxConfig {
654
736
  corsAllowOrigins?: string[];
655
737
  }
656
738
 
739
+ export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first";
740
+
657
741
  export type OcxComboStrategy = "failover" | "round-robin";
658
742
  export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
659
743
 
@@ -714,8 +798,24 @@ export interface OcxTokenGuardianConfig {
714
798
  export interface OcxImagesConfig {
715
799
  /** Optional custom API-key provider for /v1/images relays. Built-in OpenAI tiers remain automatic. */
716
800
  provider?: string;
717
- /** Upstream timeout (ms) for one /v1/images relay. Default 300000 generation is slow. */
801
+ /** Upstream timeout (ms) for one image generation/edit call (bridge xAI + /v1/images relay). Default 60000 for the bridge; relay may use a higher default (300000). */
718
802
  timeoutMs?: number;
803
+ /** Master switch for the image bridge. Default false — set true to enable paid xAI Grok Imagine generation. */
804
+ bridgeEnabled?: boolean;
805
+ /** xAI image model id. Default "grok-imagine-image-quality" (see DEFAULT_MODEL in images/plan.ts). */
806
+ bridgeModel?: string;
807
+ /** Max image-generation loop iterations before forced-final. Default 3; clamped to [0, 10]. */
808
+ maxRounds?: number;
809
+ /** Max files retained under artifacts/. Oldest deleted when exceeded. Default 200. */
810
+ artifactsKeepCount?: number;
811
+ /** Master switch for the video bridge. Default false — must be explicitly opted in. */
812
+ videoBridgeEnabled?: boolean;
813
+ /** Model for xAI video generation. Default "grok-imagine-video". */
814
+ videoBridgeModel?: string;
815
+ /** Max video-gen rounds before forced-final. Default 2 (video is slower than image). */
816
+ videoMaxRounds?: number;
817
+ /** Per-video generation timeout (ms) including polling. Default 300000 (5 min). */
818
+ videoTimeoutMs?: number;
719
819
  }
720
820
 
721
821
  export interface OcxSearchConfig {
@@ -817,6 +917,12 @@ export interface OcxProviderConfig {
817
917
  */
818
918
  codexAccountMode?: CodexAccountMode;
819
919
  apiKey?: string;
920
+ /**
921
+ * Key-auth header style for Anthropic-compatible providers.
922
+ * Defaults to the native Anthropic `x-api-key`; gateways may require
923
+ * `Authorization: Bearer <key>` instead.
924
+ */
925
+ apiKeyTransport?: "x-api-key" | "bearer";
820
926
  /**
821
927
  * Multi-key pool (API-key twin of OAuth multiauth). `apiKey` always mirrors the ACTIVE
822
928
  * entry so routing stays single-key; managed via /api/providers/keys. A legacy bare
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cached "is an update available?" answer for the GUI sidebar badge.
3
+ *
4
+ * `/api/update/check` spawns `npm view` on every call (~1s, network-bound), so a
5
+ * sidebar that polls it would spawn a process per tick on every page of the GUI.
6
+ * The badge instead READS the 20h version cache the CLI update prompt already
7
+ * maintains (`~/.opencodex/version.json`).
8
+ *
9
+ * This is deliberately read-only: it must never trigger a registry refresh. The GUI
10
+ * polls it, so a refresh-on-read would let repeated polls launch repeated `npm view`
11
+ * helpers with no coalescing. Cache warming stays with `ocx start`
12
+ * (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit
13
+ * `/api/update/check` the user reaches by clicking the sidebar update button.
14
+ */
15
+ import { currentVersion, defaultUpdateTag, detectInstall, type Channel } from "./index";
16
+ import { isNewer, isSourceBuildVersion, readVersionCache } from "./notify";
17
+
18
+ export interface UpdateBadge {
19
+ /** True only when a newer version exists on the current channel. */
20
+ updateAvailable: boolean;
21
+ currentVersion: string;
22
+ latestVersion: string | null;
23
+ channel: Channel;
24
+ /** False for source checkouts, where the GUI cannot offer a one-click update. */
25
+ canUpdate: boolean;
26
+ /** True when no cached registry answer exists yet, so "no update" is unproven. */
27
+ unknown: boolean;
28
+ }
29
+
30
+ export interface UpdateBadgeDeps {
31
+ currentVersion: () => string;
32
+ detectInstall: () => ReturnType<typeof detectInstall>;
33
+ readCache: (channel: Channel) => ReturnType<typeof readVersionCache>;
34
+ }
35
+
36
+ const defaultDeps: UpdateBadgeDeps = {
37
+ currentVersion,
38
+ detectInstall,
39
+ readCache: readVersionCache,
40
+ };
41
+
42
+ /**
43
+ * Read-only badge state. Source checkouts and unknown versions report no update
44
+ * rather than a dead badge the user cannot act on.
45
+ */
46
+ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadge {
47
+ const current = deps.currentVersion();
48
+ const installer = deps.detectInstall();
49
+ const channel = defaultUpdateTag(current);
50
+ const base: UpdateBadge = {
51
+ updateAvailable: false,
52
+ currentVersion: current,
53
+ latestVersion: null,
54
+ channel,
55
+ canUpdate: installer !== "source",
56
+ unknown: true,
57
+ };
58
+ // A source checkout has nothing to compare against, so "unknown" is not useful there.
59
+ if (installer === "source" || current === "?" || isSourceBuildVersion(current)) {
60
+ return { ...base, canUpdate: false, unknown: false };
61
+ }
62
+
63
+ const cache = deps.readCache(channel);
64
+ if (!cache) return base;
65
+
66
+ return {
67
+ ...base,
68
+ latestVersion: cache.latest_version,
69
+ updateAvailable: isNewer(cache.latest_version, current, channel),
70
+ unknown: false,
71
+ };
72
+ }
@@ -3,6 +3,7 @@ import { readFileSync, readdirSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
+ import { npmInvocation } from "./npm-invocation.mjs";
6
7
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
7
8
 
8
9
  /**
@@ -50,19 +51,24 @@ export function updateTag(current: string): Channel {
50
51
  return defaultUpdateTag(current);
51
52
  }
52
53
 
53
- /**
54
- * npm is `npm.cmd` on Windows, and Node/Bun refuse shell-less .cmd spawns
55
- * (CVE-2024-27980 hardening) route Windows npm invocations through the shell.
56
- */
57
- function npmSpawnTarget(bin: string): { bin: string; shell: boolean } {
58
- if (process.platform !== "win32" || bin !== "npm") return { bin, shell: false };
59
- return { bin: "npm.cmd", shell: true };
54
+ function npmSpawnTarget(args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
55
+ const invocation = npmInvocation(args);
56
+ if (!invocation) return null;
57
+ return { bin: invocation.file, args: invocation.args, options: invocation.options };
58
+ }
59
+
60
+ function updateSpawnTarget(bin: string, args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
61
+ if (bin === "npm") return npmSpawnTarget(args);
62
+ if (process.platform === "win32" && bin === "bun") {
63
+ return { bin: process.execPath, args: [...args], options: {} };
64
+ }
65
+ return { bin, args: [...args], options: {} };
60
66
  }
61
67
 
62
68
  /**
63
69
  * The GUI update worker sets OCX_SERVICE=1 and has stdio ignored — inheriting that for
64
- * `npm.cmd` (shell:true) opens stacked visible consoles on Windows. Pipe instead and
65
- * relay bounded output after the child exits. (Ported from PR #167.)
70
+ * Background package-manager children can open stacked visible consoles on Windows.
71
+ * Pipe instead and relay bounded output after the child exits. (Ported from PR #167.)
66
72
  */
67
73
  function updateChildStdio(): "inherit" | "pipe" {
68
74
  if (process.env.OCX_SERVICE === "1") return "pipe";
@@ -79,8 +85,14 @@ function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null
79
85
 
80
86
  /** Latest published version from the registry (best-effort; null if npm isn't available). */
81
87
  export function latestVersion(tag: string): string | null {
82
- const npm = npmSpawnTarget("npm");
83
- const r = spawnSync(npm.bin, ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell });
88
+ const npm = npmSpawnTarget(["view", `${PKG}@${tag}`, "version"]);
89
+ if (!npm) return null;
90
+ const r = spawnSync(npm.bin, npm.args, {
91
+ encoding: "utf8",
92
+ timeout: 12000,
93
+ windowsHide: true,
94
+ ...npm.options,
95
+ });
84
96
  return r.status === 0 ? (r.stdout.trim() || null) : null;
85
97
  }
86
98
 
@@ -116,11 +128,12 @@ export function checkUpdatePackageIntegrity(
116
128
  spawn: typeof spawnSync = spawnSync,
117
129
  ): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } {
118
130
  if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" };
119
- const npm = npmSpawnTarget("npm");
131
+ const npm = npmSpawnTarget(["view", `${PKG}@${version}`, "dist.integrity"]);
132
+ if (!npm) return { ok: "skipped", reason: "npm executable was not found on a trusted PATH entry" };
120
133
  const r = spawn(
121
134
  npm.bin,
122
- ["view", `${PKG}@${version}`, "dist.integrity"],
123
- { encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell },
135
+ npm.args,
136
+ { encoding: "utf8", timeout: 12000, windowsHide: true, ...npm.options },
124
137
  );
125
138
  // status !== 0 covers nonzero exits AND timeouts (status === null).
126
139
  if (r.status !== 0) return { ok: "skipped", reason: `registry integrity query failed (status ${r.status ?? "timeout"})` };
@@ -164,6 +177,13 @@ export async function runUpdate(): Promise<void> {
164
177
  console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`);
165
178
  }
166
179
 
180
+ const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
181
+ const target = updateSpawnTarget(bin, cmdArgs);
182
+ if (!target) {
183
+ console.error("⚠️ Could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy.");
184
+ process.exit(1);
185
+ }
186
+
167
187
  // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
168
188
  // unloads it permanently, so a successful update must reinstall/restart it afterwards.
169
189
  let serviceWasInstalled = false;
@@ -240,17 +260,15 @@ export async function runUpdate(): Promise<void> {
240
260
  }
241
261
  }
242
262
 
243
- const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
244
263
  console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
245
264
 
246
- const target = npmSpawnTarget(bin);
247
265
  const installStdio = updateChildStdio();
248
- const r = spawnSync(target.bin, cmdArgs, {
266
+ const r = spawnSync(target.bin, target.args, {
249
267
  stdio: installStdio,
250
268
  encoding: installStdio === "pipe" ? "utf8" : undefined,
251
269
  timeout: 180000,
252
270
  windowsHide: true,
253
- shell: target.shell,
271
+ ...target.options,
254
272
  });
255
273
  if (installStdio === "pipe") logSpawnOutput("", r);
256
274
  if (r.status === 0) {
package/src/update/job.ts CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { atomicWriteFile, getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
- import { killProxy } from "../lib/process-control";
6
+ import { isProcessAlive, killProxy } from "../lib/process-control";
7
7
  import { reclaimListenPort } from "../server/port-reclaim";
8
8
  import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness";
9
9
  import { isServiceInstalled } from "../service";
@@ -26,8 +26,10 @@ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/lates
26
26
  const UPDATE_JOB_FILENAME = "update-job.json";
27
27
  const UPDATE_TIMEOUT_MS = 180_000;
28
28
  const RESTART_TIMEOUT_MS = 60_000;
29
- const RESTART_HEALTH_TIMEOUT_MS = 15_000;
29
+ const RESTART_HEALTH_TIMEOUT_MS = 30_000;
30
30
  const RESTART_STABILITY_WINDOW_MS = 15_000;
31
+ /** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */
32
+ export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000;
31
33
  /** How long update restart waits for the captured port to become bindable after stop. */
32
34
  export const RESTART_PORT_RECLAIM_MS = 30_000;
33
35
 
@@ -77,6 +79,19 @@ export interface UpdateCheckDeps {
77
79
  latestVersion: (tag: Channel) => string | null;
78
80
  }
79
81
 
82
+ interface UpdateWorkerProcess {
83
+ pid?: number;
84
+ unref(): void;
85
+ once(event: "error", listener: (error: Error) => void): unknown;
86
+ }
87
+
88
+ export interface StartUpdateJobDeps {
89
+ checkForUpdateFn: (channel: Channel) => UpdateCheckResult;
90
+ spawnWorkerFn: (jobId: string, channel: Channel, restart: boolean) => UpdateWorkerProcess;
91
+ isProcessAliveFn: (pid: number) => boolean;
92
+ nowMs: () => number;
93
+ }
94
+
80
95
  const defaultCheckDeps: UpdateCheckDeps = {
81
96
  currentVersion,
82
97
  detectInstall,
@@ -155,7 +170,9 @@ export function updateExecutionCommand(
155
170
  return { bin, args, display: formatCommand(bin, args) };
156
171
  }
157
172
  if (installer === "bun") {
158
- const { bin, args } = updateCommand(installer, channel, resolvedVersion);
173
+ const command = updateCommand(installer, channel, resolvedVersion);
174
+ const bin = process.platform === "win32" ? process.execPath : command.bin;
175
+ const { args } = command;
159
176
  return { bin, args, display: updateCommandStr(installer, channel, resolvedVersion) };
160
177
  }
161
178
  return { bin: "sh", args: ["-lc", manualSourceCommand()], display: manualSourceCommand() };
@@ -225,19 +242,77 @@ function newJobId(): string {
225
242
  return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
226
243
  }
227
244
 
228
- export function startUpdateJob(channel: Channel, restart: boolean): UpdateJobState {
245
+ /**
246
+ * [Decision Log]
247
+ * - Purpose: recover dashboard updates after a detached worker dies without unlocking concurrent live updates.
248
+ * - Existing constraints: legacy records have no PID, while a healthy update may legitimately run for minutes.
249
+ * - Alternatives considered: clear every active record by age, or require operators to delete the file manually.
250
+ * - Chosen approach: trust PID liveness first and use a conservative age limit only for legacy no-PID records.
251
+ * - Why: age-only recovery can start two installers, while never recovering leaves the dashboard permanently blocked.
252
+ * - Impact: live PID records remain locked regardless of age; dead PIDs recover immediately; legacy records recover after ten minutes.
253
+ */
254
+ export function staleActiveUpdateJobReason(
255
+ job: Pick<UpdateJobState, "status" | "pid" | "updatedAt">,
256
+ now = Date.now(),
257
+ isAlive: (pid: number) => boolean = isProcessAlive,
258
+ ): string | null {
259
+ if (job.status !== "running" && job.status !== "restarting") return null;
260
+ if (typeof job.pid === "number" && Number.isSafeInteger(job.pid) && job.pid > 0) {
261
+ return isAlive(job.pid) ? null : `update worker PID ${job.pid} is no longer running`;
262
+ }
263
+ const updatedAt = Date.parse(job.updatedAt);
264
+ if (Number.isFinite(updatedAt) && now - updatedAt >= UPDATE_JOB_LEGACY_STALE_MS) {
265
+ return "legacy active update record has no worker PID and exceeded the stale window";
266
+ }
267
+ return null;
268
+ }
269
+
270
+ const defaultStartUpdateJobDeps: StartUpdateJobDeps = {
271
+ checkForUpdateFn: channel => checkForUpdate(channel),
272
+ spawnWorkerFn: (jobId, channel, restart) => spawn(
273
+ process.execPath,
274
+ [process.argv[1], "__gui-update-worker", jobId, channel, restart ? "restart" : "no-restart"],
275
+ {
276
+ detached: true,
277
+ stdio: "ignore",
278
+ windowsHide: true,
279
+ env: { ...process.env, OCX_SERVICE: "1" },
280
+ },
281
+ ),
282
+ isProcessAliveFn: isProcessAlive,
283
+ nowMs: Date.now,
284
+ };
285
+
286
+ export function startUpdateJob(
287
+ channel: Channel,
288
+ restart: boolean,
289
+ deps: Partial<StartUpdateJobDeps> = {},
290
+ ): UpdateJobState {
291
+ const resolvedDeps = { ...defaultStartUpdateJobDeps, ...deps };
229
292
  const running = readUpdateJob();
230
293
  if (running?.status === "running" || running?.status === "restarting") {
231
- throw new UpdateJobError("An update job is already running", 409, "update_already_running");
294
+ const staleReason = staleActiveUpdateJobReason(
295
+ running,
296
+ resolvedDeps.nowMs(),
297
+ resolvedDeps.isProcessAliveFn,
298
+ );
299
+ if (!staleReason) {
300
+ throw new UpdateJobError("An update job is already running", 409, "update_already_running");
301
+ }
302
+ updateJob(
303
+ running,
304
+ { status: "failed", error: `Recovered stale update job: ${staleReason}.`, exitCode: null },
305
+ `Recovered stale update job: ${staleReason}.`,
306
+ );
232
307
  }
233
308
 
234
- const check = checkForUpdate(channel);
309
+ const check = resolvedDeps.checkForUpdateFn(channel);
235
310
  if (!check.canUpdate) {
236
311
  throw new UpdateJobError(check.reason ?? "No update is available", 409, check.reason ?? "update_unavailable");
237
312
  }
238
313
 
239
314
  const id = newJobId();
240
- const now = new Date().toISOString();
315
+ const now = new Date(resolvedDeps.nowMs()).toISOString();
241
316
  const job: UpdateJobState = {
242
317
  id,
243
318
  status: "running",
@@ -254,14 +329,30 @@ export function startUpdateJob(channel: Channel, restart: boolean): UpdateJobSta
254
329
  };
255
330
  writeJob(job);
256
331
 
257
- const child = spawn(process.execPath, [process.argv[1], "__gui-update-worker", id, channel, restart ? "restart" : "no-restart"], {
258
- detached: true,
259
- stdio: "ignore",
260
- windowsHide: true,
261
- env: { ...process.env, OCX_SERVICE: "1" },
332
+ let child: UpdateWorkerProcess;
333
+ try {
334
+ child = resolvedDeps.spawnWorkerFn(id, channel, restart);
335
+ } catch (error) {
336
+ const message = error instanceof Error ? error.message : String(error);
337
+ updateJob(job, { status: "failed", error: `Could not start update worker: ${message}` }, "Update worker failed to start.");
338
+ throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
339
+ }
340
+ if (typeof child.pid !== "number" || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
341
+ updateJob(job, { status: "failed", error: "Could not start update worker: no worker PID was returned." }, "Update worker failed to start.");
342
+ throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
343
+ }
344
+ const startedJob = updateJob(job, { pid: child.pid }, `Update worker started as PID ${child.pid}.`);
345
+ child.once("error", error => {
346
+ const current = readUpdateJob(id);
347
+ if (!current || current.pid !== child.pid || (current.status !== "running" && current.status !== "restarting")) return;
348
+ updateJob(
349
+ current,
350
+ { status: "failed", error: `Update worker failed to start: ${error.message}` },
351
+ "Update worker emitted a startup error.",
352
+ );
262
353
  });
263
354
  child.unref();
264
- return { ...job, pid: child.pid };
355
+ return startedJob;
265
356
  }
266
357
 
267
358
  function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } {
@@ -442,7 +533,10 @@ async function awaitRestartedProxyHealthy(
442
533
  const hostname = captured.hostname;
443
534
  const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS;
444
535
 
445
- while (now() < startDeadline) {
536
+ while (true) {
537
+ // Always make one identity-aware probe at or after the boundary. A replacement
538
+ // becoming healthy on the final tick must not be mistaken for a timeout.
539
+ const finalProbe = now() >= startDeadline;
446
540
  if (await probe(port, hostname)) {
447
541
  updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
448
542
  const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
@@ -456,7 +550,8 @@ async function awaitRestartedProxyHealthy(
456
550
  updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`);
457
551
  return { ok: true };
458
552
  }
459
- await sleep(250);
553
+ if (finalProbe) break;
554
+ await sleep(Math.min(250, Math.max(0, startDeadline - now())));
460
555
  }
461
556
 
462
557
  return { ok: false, reason: "timeout" };
@@ -479,7 +574,7 @@ async function confirmRestartedProxy(
479
574
  - 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다.
480
575
  - 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다.
481
576
  - 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다.
482
- - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 성공 판정이 최대 30초 늦어질 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
577
+ - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 설정상 성공 판정 창이 30초 도착 + 15초 안정성으로 늘어나고 경계 probe 지연이 추가될 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
483
578
  */
484
579
  const result = await awaitRestartedProxyHealthy(job, captured, io);
485
580
  if (result.ok) return true;
@@ -0,0 +1,23 @@
1
+ export interface NpmInvocationDeps {
2
+ cwd?: string;
3
+ exists?: (path: string) => boolean;
4
+ }
5
+
6
+ export interface NpmInvocation {
7
+ file: string;
8
+ args: string[];
9
+ options: { windowsVerbatimArguments?: boolean };
10
+ }
11
+
12
+ export declare function resolveNpmCommand(
13
+ platform?: NodeJS.Platform,
14
+ env?: Record<string, string | undefined>,
15
+ deps?: NpmInvocationDeps,
16
+ ): string | null;
17
+
18
+ export declare function npmInvocation(
19
+ args: readonly string[],
20
+ platform?: NodeJS.Platform,
21
+ env?: Record<string, string | undefined>,
22
+ deps?: NpmInvocationDeps,
23
+ ): NpmInvocation | null;