@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -14,16 +14,20 @@
14
14
  * it is not a standalone leak discriminator. `responseState` attributes growth
15
15
  * further: it is the proxy's previous_response_id continuation store, so a
16
16
  * growing responseState.totalBytes under rising observed memory points at
17
- * conversation retention rather than the runtime allocator.
17
+ * conversation retention rather than the runtime allocator. Spill counts,
18
+ * payload-byte totals, tombstones, and failure counters remain finite scalars;
19
+ * response ids, filenames, digests, paths, and payload content never leave the owner.
18
20
  *
19
21
  * `activeTurnCount` / `isDraining` are scalar lifecycle counters for the
20
22
  * dashboard drain-and-restart confirm UX — never request bodies or IDs.
21
23
  */
22
- import { decideEagerRelay } from "../../lib/bun-stream-caps";
24
+ import { selectEagerPath } from "../../lib/bun-stream-caps";
23
25
  import { getActiveTurnCount, isDraining } from "../lifecycle";
24
26
  import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
25
27
  import { responseStateMetrics } from "../../responses/state";
28
+ import { appOwnedBytesSnapshot } from "../../lib/app-owned-memory";
26
29
  import { jsonResponse } from "../auth-cors";
30
+ import { getInspectionCounters } from "../relay";
27
31
  import type { ManagementContext } from "./context";
28
32
  import { acceptSystemRestart } from "./system-restart";
29
33
 
@@ -64,6 +68,12 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
64
68
  })()
65
69
  : null;
66
70
  const streamMode = config.streamMode ?? "auto";
71
+ /**
72
+ * No request-specific rewrite context exists on this route, so report the
73
+ * effective no-client-rewrite baseline. Individual rewrite requests still
74
+ * stay on tee even when this baseline says eager.
75
+ */
76
+ const eagerRelay = selectEagerPath(process.platform, false, streamMode);
67
77
  return jsonResponse({
68
78
  pid: process.pid,
69
79
  bunVersion: Bun.version,
@@ -79,8 +89,10 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
79
89
  observedMetric: observed.observedMetric,
80
90
  jscHeap,
81
91
  responseState: responseStateMetrics(),
92
+ appOwnedBytes: appOwnedBytesSnapshot(),
93
+ inspectionCounters: getInspectionCounters(),
82
94
  streamMode,
83
- eagerRelay: process.platform === "win32" ? decideEagerRelay(streamMode) : null,
95
+ eagerRelay,
84
96
  watchdog,
85
97
  activeTurnCount: getActiveTurnCount(),
86
98
  isDraining: isDraining(),
@@ -0,0 +1,86 @@
1
+ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../../lib/app-owned-memory";
2
+ import type { UsageSummary } from "../../usage/summary";
3
+
4
+ export type CachedUsageSummary = UsageSummary & {
5
+ historyTruncated: boolean;
6
+ truncatedPrefixBytes: number;
7
+ entriesTruncated: boolean;
8
+ entriesDropped: number;
9
+ };
10
+
11
+ interface UsageSummaryCacheEntry {
12
+ revisionKey: string;
13
+ expiresAt: number;
14
+ summary: CachedUsageSummary;
15
+ revisionReadAt: number;
16
+ sizeBytes: number;
17
+ }
18
+
19
+ const usageSummaryCache = new Map<string, UsageSummaryCacheEntry>();
20
+ let usageSummaryCacheBytes = 0;
21
+ let oldestUsageSummaryKey: string | undefined;
22
+ let oldestUsageSummaryAt: number | null = null;
23
+
24
+ function recomputeOldestUsageSummary(): void {
25
+ oldestUsageSummaryKey = undefined;
26
+ oldestUsageSummaryAt = null;
27
+ for (const [key, entry] of usageSummaryCache) {
28
+ const readAt = entry.revisionReadAt;
29
+ if (oldestUsageSummaryAt !== null && readAt >= oldestUsageSummaryAt) continue;
30
+ oldestUsageSummaryKey = key;
31
+ oldestUsageSummaryAt = readAt;
32
+ }
33
+ }
34
+
35
+ function deleteUsageSummaryCacheEntry(key: string): number {
36
+ const entry = usageSummaryCache.get(key);
37
+ if (!entry) return 0;
38
+ usageSummaryCache.delete(key);
39
+ usageSummaryCacheBytes = Math.max(0, usageSummaryCacheBytes - entry.sizeBytes);
40
+ if (oldestUsageSummaryKey === key) recomputeOldestUsageSummary();
41
+ return entry.sizeBytes;
42
+ }
43
+
44
+ export function getUsageSummaryCacheEntry(key: string): UsageSummaryCacheEntry | undefined {
45
+ return usageSummaryCache.get(key);
46
+ }
47
+
48
+ export function discardUsageSummaryCacheEntry(key: string): number {
49
+ return deleteUsageSummaryCacheEntry(key);
50
+ }
51
+
52
+ export function setUsageSummaryCacheEntry(
53
+ key: string,
54
+ entry: Omit<UsageSummaryCacheEntry, "sizeBytes">,
55
+ ): void {
56
+ deleteUsageSummaryCacheEntry(key);
57
+ const sizeBytes = Buffer.byteLength(JSON.stringify({ key, ...entry }), "utf8");
58
+ usageSummaryCache.set(key, { ...entry, sizeBytes });
59
+ usageSummaryCacheBytes += sizeBytes;
60
+ if (oldestUsageSummaryAt === null || entry.revisionReadAt < oldestUsageSummaryAt) {
61
+ oldestUsageSummaryKey = key;
62
+ oldestUsageSummaryAt = entry.revisionReadAt;
63
+ }
64
+ enforceAppOwnedMemoryBudget();
65
+ }
66
+
67
+ export function usageSummaryRetainedStoreSnapshot(): RetainedStoreSnapshot {
68
+ return {
69
+ count: usageSummaryCache.size,
70
+ bytes: usageSummaryCacheBytes,
71
+ evictableBytes: usageSummaryCacheBytes,
72
+ pinnedBytes: 0,
73
+ oldestAt: oldestUsageSummaryAt,
74
+ };
75
+ }
76
+
77
+ export function evictOldestUsageSummaryForBudget(): number {
78
+ return oldestUsageSummaryKey === undefined ? 0 : deleteUsageSummaryCacheEntry(oldestUsageSummaryKey);
79
+ }
80
+
81
+ export function resetUsageSummaryCacheForTests(): void {
82
+ usageSummaryCache.clear();
83
+ usageSummaryCacheBytes = 0;
84
+ oldestUsageSummaryKey = undefined;
85
+ oldestUsageSummaryAt = null;
86
+ }
@@ -21,7 +21,7 @@ import {
21
21
  submitManualLoginCode,
22
22
  upsertOAuthProvider,
23
23
  } from "../oauth";
24
- import { removeCredential } from "../oauth/store";
24
+ import { OAuthMutationBusyError, removeCredential } from "../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../lib/destination-policy";
26
26
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../oauth/key-providers";
27
27
  import { deriveProviderPresets } from "../providers/derive";
@@ -69,6 +69,8 @@ import { handleSidebarRoutes } from "./management/sidebar-routes";
69
69
  import type { ManagementContext } from "./management/context";
70
70
  export type { ManagementApiDeps } from "./management/context";
71
71
  import { fetchAllModels } from "./management/shared";
72
+ import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
73
+ import { managementBodyTooLargeResponse } from "./management/body";
72
74
 
73
75
  // installed npm version instead of a stale hardcode.
74
76
  export const VERSION = (() => {
@@ -123,8 +125,9 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
123
125
  } catch { /* best-effort */ }
124
126
  }
125
127
  const ctx: ManagementContext = { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort };
126
- const routed =
127
- (await handleConfigRoutes(ctx))
128
+ let routed: Response | null;
129
+ try {
130
+ routed = (await handleConfigRoutes(ctx))
128
131
  ?? (await handleLogsUsageRoutes(ctx))
129
132
  ?? (await handleProviderRoutes(ctx))
130
133
  ?? (await handleModelRoutes(ctx))
@@ -132,7 +135,22 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
132
135
  ?? (await handleOauthAccountRoutes(ctx))
133
136
  ?? (await handleComboRoutes(ctx))
134
137
  ?? (await handleSystemRoutes(ctx))
135
- ?? (await handleSidebarRoutes(ctx));
138
+ ?? (await handleSidebarRoutes(ctx));
139
+ } catch (error) {
140
+ const tooLarge = managementBodyTooLargeResponse(error, req, config);
141
+ if (tooLarge) return tooLarge;
142
+ if (error instanceof OAuthMutationBusyError) {
143
+ return new Response(JSON.stringify({ error: { type: "server_error", code: "oauth_mutation_busy", message: error.message } }), {
144
+ status: 503,
145
+ headers: { "content-type": "application/json", "Retry-After": "1" },
146
+ });
147
+ }
148
+ if (!(error instanceof CatalogGatherBusyError)) throw error;
149
+ return new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
150
+ status: 503,
151
+ headers: { "content-type": "application/json", "Retry-After": "1" },
152
+ });
153
+ }
136
154
  if (routed) return routed;
137
155
 
138
156
  if (url.pathname === "/api/stop" && req.method === "POST") {
@@ -167,7 +185,23 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
167
185
 
168
186
  if (url.pathname.startsWith("/api/codex-auth/")) {
169
187
  const { handleCodexAuthAPI } = await import("../codex/auth-api");
170
- return handleCodexAuthAPI(req, url, config);
188
+ const { ConfigMutationLockError } = await import("../config");
189
+ const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
190
+ try {
191
+ return await handleCodexAuthAPI(req, url, config);
192
+ } catch (error) {
193
+ // Credential writers remap ConfigMutationLockError to CodexCredentialRefreshLockTimeoutError;
194
+ // treat both as the same retryable busy response.
195
+ if (error instanceof ConfigMutationLockError || error instanceof CodexCredentialRefreshLockTimeoutError) {
196
+ return jsonResponse(
197
+ { error: "Configuration is busy; retry shortly", code: "CONFIG_MUTATION_LOCK_UNAVAILABLE" },
198
+ 503,
199
+ req,
200
+ config,
201
+ );
202
+ }
203
+ throw error;
204
+ }
171
205
  }
172
206
 
173
207
  return null;
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import { adminApiTokenFilePath } from "../lib/admin-secrets";
16
- import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
16
+ import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
17
17
  import type { OcxConfig } from "../types";
18
18
  import {
19
19
  isAllowedManagementOrigin,
@@ -55,8 +55,19 @@ function assertSafeDirectory(path: string): void {
55
55
  const stat = lstatSync(path);
56
56
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("management token directory is not a regular directory");
57
57
  chmodSync(path, 0o700);
58
- const hardened = hardenSecretDir(path, { required: true });
59
- if (!hardened.ok) throw new Error("management token directory ACL hardening did not complete");
58
+ let hardened: { ok: boolean };
59
+ try {
60
+ hardened = hardenSecretDir(path, { required: true });
61
+ } catch {
62
+ // required:true hardening now fails closed on genuine ACL timeouts too;
63
+ // keep the actionable guidance in the surfaced reason.
64
+ hardened = { ok: false };
65
+ }
66
+ if (!hardened.ok) {
67
+ throw new Error(
68
+ "management token directory ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
69
+ );
70
+ }
60
71
  }
61
72
 
62
73
  function readExistingToken(path: string): string {
@@ -65,15 +76,33 @@ function readExistingToken(path: string): string {
65
76
  throw new Error("management token path is not a regular secret file");
66
77
  }
67
78
  chmodSync(path, 0o600);
68
- const hardened = hardenSecretPath(path, { required: true });
69
- if (!hardened.ok) throw new Error("management token file ACL hardening did not complete");
79
+ let hardened: { ok: boolean };
80
+ try {
81
+ hardened = hardenSecretPath(path, { required: true });
82
+ } catch {
83
+ hardened = { ok: false };
84
+ }
85
+ if (!hardened.ok) {
86
+ throw new Error(
87
+ "management token file ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
88
+ );
89
+ }
70
90
  const token = readFileSync(path, "utf8").trim();
71
91
  if (!/^ocx_admin_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("management token file is invalid");
72
92
  return token;
73
93
  }
74
94
 
75
- function removeBestEffort(path: string): void {
76
- try { unlinkSync(path); } catch { /* fail-closed state is preserved by the caller */ }
95
+ export function removeManagementTokenPathBestEffort(
96
+ path: string,
97
+ remove: (path: string) => void = unlinkSync,
98
+ ): void {
99
+ try {
100
+ remove(path);
101
+ forgetHardenedSecretPath(path);
102
+ } catch (error) {
103
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") forgetHardenedSecretPath(path);
104
+ /* other failures retain fail-closed state for the caller */
105
+ }
77
106
  }
78
107
 
79
108
  function createTokenFile(path: string): string {
@@ -89,8 +118,17 @@ function createTokenFile(path: string): string {
89
118
  closeSync(fd);
90
119
  fd = null;
91
120
  chmodSync(temporary, 0o600);
92
- const temporaryHardened = hardenSecretPath(temporary, { required: true });
93
- if (!temporaryHardened.ok) throw new Error("management token temporary ACL hardening did not complete");
121
+ let temporaryHardened: { ok: boolean };
122
+ try {
123
+ temporaryHardened = hardenSecretPath(temporary, { required: true });
124
+ } catch {
125
+ temporaryHardened = { ok: false };
126
+ }
127
+ if (!temporaryHardened.ok) {
128
+ throw new Error(
129
+ "management token temporary ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
130
+ );
131
+ }
94
132
  try {
95
133
  linkSync(temporary, path);
96
134
  linked = true;
@@ -98,17 +136,26 @@ function createTokenFile(path: string): string {
98
136
  if ((error as NodeJS.ErrnoException).code === "EEXIST") return readExistingToken(path);
99
137
  throw error;
100
138
  }
101
- const finalHardened = hardenSecretPath(path, { required: true });
102
- if (!finalHardened.ok) throw new Error("management token file ACL hardening did not complete");
139
+ let finalHardened: { ok: boolean };
140
+ try {
141
+ finalHardened = hardenSecretPath(path, { required: true });
142
+ } catch {
143
+ finalHardened = { ok: false };
144
+ }
145
+ if (!finalHardened.ok) {
146
+ throw new Error(
147
+ "management token file ACL hardening did not complete; set OPENCODEX_ADMIN_AUTH_TOKEN to use an environment token instead of a file-backed token",
148
+ );
149
+ }
103
150
  return token;
104
151
  } catch (error) {
105
- if (linked) removeBestEffort(path);
152
+ if (linked) removeManagementTokenPathBestEffort(path);
106
153
  throw error;
107
154
  } finally {
108
155
  if (fd !== null) {
109
156
  try { closeSync(fd); } catch { /* best effort */ }
110
157
  }
111
- removeBestEffort(temporary);
158
+ removeManagementTokenPathBestEffort(temporary);
112
159
  }
113
160
  }
114
161
 
@@ -190,7 +237,11 @@ export function requireManagementAuth(
190
237
  config?: OcxConfig,
191
238
  ): Response | null {
192
239
  if (!state.available) {
193
- return Response.json({ error: "management API unavailable" }, { status: 503 });
240
+ return Response.json({
241
+ error: "management API unavailable",
242
+ reason: state.reason,
243
+ hint: "Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening",
244
+ }, { status: 503 });
194
245
  }
195
246
  const actual = req.headers.get("x-opencodex-api-key")?.trim()
196
247
  || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
@@ -2,8 +2,11 @@
2
2
  * Reclaim a listen port after stop/update so restart can stay on the configured
3
3
  * port instead of hopping to an ephemeral one (Windows CLOSE_WAIT / leftover ocx).
4
4
  *
5
- * Killing is never the default: a process may be killed only when the caller
6
- * supplies a non-empty explicit PID allowlist for a process it just stopped.
5
+ * Killing is never the default. A process may be killed only when the caller
6
+ * sets `killOcxHolders` and either supplies a non-empty `onlyKillPids` allowlist
7
+ * (trusted teardown PIDs, including allowlisted holders that fail ocx revalidate)
8
+ * or enables `killAllOcxOnPort` for revalidated ocx listeners. Unknown foreign
9
+ * (non-ocx, non-allowlisted) processes are never killed.
7
10
  */
8
11
  import { execFileSync } from "node:child_process";
9
12
  import { verifyPidIdentity } from "../config";
@@ -19,13 +22,24 @@ export type ReclaimListenPortOptions = WaitForPortOptions & {
19
22
  /**
20
23
  * When true AND `onlyKillPids` is a non-empty allowlist, those PIDs may be
21
24
  * killed after revalidation. Default false — never kill without an allowlist.
25
+ * When {@link killAllOcxOnPort} is also true, any ocx listener on this port
26
+ * may be killed even if it is not in `onlyKillPids`.
22
27
  */
23
28
  killOcxHolders?: boolean;
24
29
  /**
25
30
  * Explicit PIDs the caller just stopped / hard-killed. An omitted or empty
26
- * list means no process may be killed.
31
+ * list means no process may be killed — unless {@link killAllOcxOnPort} is set.
27
32
  */
28
33
  onlyKillPids?: number[];
34
+ /**
35
+ * When true with `killOcxHolders`, every live ocx listener on this port may be
36
+ * killed (re-checked each scan). Used by post-update restart so a Windows
37
+ * service wrapper that respawns a *new* bun PID mid-reclaim cannot stay
38
+ * protected just because it was absent from the pre-wait allowlist snapshot.
39
+ * Never kills foreign (non-ocx) processes — only allowlisted teardown PIDs
40
+ * and revalidated ocx listeners.
41
+ */
42
+ killAllOcxOnPort?: boolean;
29
43
  /**
30
44
  * On Windows, force-delete IPv4 TCP rows for this local port via SetTcpEntry.
31
45
  * Default true on win32. Never kills foreign processes, never runs while a
@@ -157,9 +171,9 @@ export function listListenPids(port: number): number[] {
157
171
 
158
172
  /**
159
173
  * Wait until `port` can bind.
160
- * Never kills a process unless `killOcxHolders === true` and `onlyKillPids` is a
161
- * non-empty allowlist of PIDs the caller itself just stopped — then revalidates
162
- * immediately before each kill.
174
+ * Never kills a process unless `killOcxHolders === true` and either
175
+ * `onlyKillPids` is a non-empty allowlist or `killAllOcxOnPort` is set — then
176
+ * revalidates immediately before each kill.
163
177
  * Never kills foreign processes. Never drops TCP rows while a live foreign or
164
178
  * protected ocx listener owns the port, or when the listener scan failed.
165
179
  */
@@ -174,7 +188,9 @@ export async function reclaimListenPort(
174
188
  const allowedKillPids = new Set(
175
189
  (opts.onlyKillPids ?? []).filter(pid => Number.isSafeInteger(pid) && pid > 0),
176
190
  );
177
- const mayKill = opts.killOcxHolders === true && allowedKillPids.size > 0;
191
+ const killAllOcx = opts.killAllOcxOnPort === true;
192
+ const mayKill = opts.killOcxHolders === true
193
+ && (allowedKillPids.size > 0 || killAllOcx);
178
194
  const dropTcpRows = opts.dropTcpRows ?? process.platform === "win32";
179
195
  const listFn = opts.listListenPidsFn ?? scanListenPids;
180
196
  const isAliveFn = opts.isAliveFn ?? isProcessAlive;
@@ -207,20 +223,44 @@ export async function reclaimListenPort(
207
223
 
208
224
  for (const pid of scan.pids) {
209
225
  if (pid === process.pid) continue;
210
- if (!isAliveFn(pid)) continue; // Windows may still list a dead owner briefly
226
+ if (!isAliveFn(pid)) {
227
+ // Clear "already tried" so a later respawn that reuses this PID slot
228
+ // is not skipped (Windows service :loop / npm rename respawns).
229
+ killed.delete(pid);
230
+ continue; // Windows may still list a dead owner briefly
231
+ }
211
232
  const isOcx = verifyOcxFn(pid) === pid;
233
+ const allowlisted = allowedKillPids.has(pid);
234
+ // Pre-update PIDs can fail verify while still LISTENing (dead owner still
235
+ // listed, or cmdline probe raced). Allowlisted teardown PIDs may be killed;
236
+ // unknown foreign claimants must remain fail-closed.
212
237
  if (!isOcx) {
238
+ if (mayKill && allowlisted) {
239
+ if (!killed.has(pid)) {
240
+ try {
241
+ killFn(pid);
242
+ killed.add(pid);
243
+ } catch {
244
+ // Kill failed: never SetTcpEntry while the process may still own the port.
245
+ protectedOcxListener = true;
246
+ }
247
+ }
248
+ if (!isAliveFn(pid)) killed.delete(pid);
249
+ else protectedOcxListener = true;
250
+ continue;
251
+ }
213
252
  foreignLive = true;
214
253
  continue;
215
254
  }
216
- if (!mayKill || !allowedKillPids.has(pid)) {
255
+ const mayKillThis = allowlisted || killAllOcx;
256
+ if (!mayKill || !mayKillThis) {
217
257
  // Healthy / intentional ocx proxy — never steal its port.
218
258
  protectedOcxListener = true;
219
259
  continue;
220
260
  }
221
261
  if (!killed.has(pid)) {
222
262
  // Revalidate immediately before termination.
223
- if (isAliveFn(pid) && verifyOcxFn(pid) === pid && allowedKillPids.has(pid)) {
263
+ if (isAliveFn(pid) && verifyOcxFn(pid) === pid && mayKillThis) {
224
264
  try {
225
265
  killFn(pid);
226
266
  killed.add(pid);
@@ -233,8 +273,14 @@ export async function reclaimListenPort(
233
273
  protectedOcxListener = true;
234
274
  }
235
275
  }
236
- // Only reset TCP rows after confirmed process death.
237
- if (isAliveFn(pid)) protectedOcxListener = true;
276
+ // Respawning supervisors (Windows service :loop) mint a new PID after each
277
+ // kill clear the per-PID "already tried" bit once the process is gone so a
278
+ // later child with a reused slot is not skipped, and keep reclaiming while live.
279
+ if (!isAliveFn(pid)) {
280
+ killed.delete(pid);
281
+ } else {
282
+ protectedOcxListener = true;
283
+ }
238
284
  }
239
285
 
240
286
  if (foreignLive || protectedOcxListener) {
@@ -16,6 +16,8 @@ export function isAddrInUse(err: unknown): boolean {
16
16
  export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
17
17
  return await new Promise(resolve => {
18
18
  const server = createServer();
19
+ // Fail closed: EACCES / EADDRNOTAVAIL / EPERM / unknown listen errors mean the
20
+ // requested bind is not available. Only the listening event reports free.
19
21
  server.once("error", () => resolve(false));
20
22
  server.once("listening", () => {
21
23
  server.close(() => resolve(true));
@@ -30,8 +30,29 @@ export interface LivenessIo {
30
30
  readRuntimeFn?: (pid?: number) => { pid?: number; port: number; hostname?: string } | null;
31
31
  configFn?: () => { port?: number; hostname?: string };
32
32
  timeoutMs?: number;
33
+ /**
34
+ * How many times to retry a probe that failed with a transport error (timeout /
35
+ * connection refused). Definitive answers (non-OK HTTP, foreign /healthz body, pid
36
+ * mismatch) do not retry. Default 1 = no retry. Stop paths should pass 2–3 (#764).
37
+ */
38
+ attempts?: number;
39
+ sleepFn?: (ms: number) => Promise<void>;
40
+ /**
41
+ * Absolute wall-clock deadline for discovery. When set, each probe attempt aborts
42
+ * once the remaining budget cannot cover another fetch — so multi-candidate
43
+ * `findLiveProxy` under `SERVICE_STOP_LIVENESS` cannot overrun the stop-path
44
+ * verification window (#764 / CodeRabbit).
45
+ */
46
+ deadlineAt?: number;
47
+ nowFn?: () => number;
33
48
  }
34
49
 
50
+ /** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */
51
+ export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
52
+ timeoutMs: 1500,
53
+ attempts: 3,
54
+ };
55
+
35
56
  export interface LiveProxy {
36
57
  pid: number | null;
37
58
  port: number;
@@ -72,19 +93,37 @@ export async function proxyIdentityAt(
72
93
  io: LivenessIo = {},
73
94
  ): Promise<{ pid: number | null } | null> {
74
95
  const fetchFn = io.fetchFn ?? fetch;
75
- try {
76
- const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
77
- signal: AbortSignal.timeout(io.timeoutMs ?? 750),
78
- });
79
- if (!res.ok) return null;
80
- const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
81
- if (!isOpencodexHealthz(body)) return null;
82
- const pid = typeof body?.pid === "number" ? body.pid : null;
83
- if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
84
- return { pid };
85
- } catch {
86
- return null;
96
+ const sleepFn = io.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
97
+ const nowFn = io.nowFn ?? Date.now;
98
+ const baseTimeoutMs = io.timeoutMs ?? 750;
99
+ const requestedAttempts = Math.trunc(io.attempts ?? 1);
100
+ const attempts = Number.isNaN(requestedAttempts)
101
+ ? 1
102
+ : Math.max(1, Math.min(requestedAttempts, 5));
103
+
104
+ for (let attempt = 1; attempt <= attempts; attempt++) {
105
+ const remainingMs = io.deadlineAt === undefined ? baseTimeoutMs : io.deadlineAt - nowFn();
106
+ if (remainingMs <= 0) return null;
107
+ const timeoutMs = Math.min(baseTimeoutMs, remainingMs);
108
+ try {
109
+ const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
110
+ signal: AbortSignal.timeout(timeoutMs),
111
+ });
112
+ if (!res.ok) return null;
113
+ const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
114
+ if (!isOpencodexHealthz(body)) return null;
115
+ const pid = typeof body?.pid === "number" ? body.pid : null;
116
+ if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
117
+ return { pid };
118
+ } catch {
119
+ // Transport failure (timeout / refused) — retry while budget remains; a proxy that
120
+ // has only just begun listening can miss a single short probe (#764).
121
+ if (attempt >= attempts) return null;
122
+ if (io.deadlineAt !== undefined && io.deadlineAt - nowFn() <= 0) return null;
123
+ await sleepFn(100);
124
+ }
87
125
  }
126
+ return null;
88
127
  }
89
128
 
90
129
  /**
@@ -109,6 +148,13 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
109
148
  return verified === candidate ? verified : null;
110
149
  };
111
150
 
151
+ const verifiedReportedPid = (reported: number | null): number | null => {
152
+ if (reported === null) return null;
153
+ if (!Number.isSafeInteger(reported) || reported <= 0) return null;
154
+ const verified = verifyPidFn(reported);
155
+ return verified === reported ? verified : null;
156
+ };
157
+
112
158
  const pid = readPidFn();
113
159
  let probedPort: number | null = null;
114
160
  if (pid) {
@@ -136,7 +182,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
136
182
  // (its process dead, the port reused by a pidless legacy proxy) — synthesizing it
137
183
  // would hand destructive callers (stopProxy → kill fallback) a reusable pid.
138
184
  if (identity) {
139
- return { pid: identity.pid ?? null, port: record.port, hostname: record.hostname, source: "runtime" };
185
+ return { pid: verifiedReportedPid(identity.pid), port: record.port, hostname: record.hostname, source: "runtime" };
140
186
  }
141
187
  }
142
188
 
@@ -145,7 +191,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
145
191
  const identity = await proxyIdentityAt(port, { hostname: config.hostname }, io);
146
192
  if (identity) {
147
193
  return {
148
- pid: identity.pid ?? killablePid(pid),
194
+ pid: verifiedReportedPid(identity.pid) ?? killablePid(pid),
149
195
  port,
150
196
  hostname: config.hostname,
151
197
  source: "config",
@@ -24,11 +24,15 @@
24
24
  * up to the drain window.
25
25
  */
26
26
 
27
+ import { buildFailedTailPayload } from "./relay";
28
+
27
29
  export type EagerRelayHooks = {
28
30
  /** Feed one upstream chunk through SSE inspection (createSseInspector.feed). */
29
31
  inspectChunk: (chunk: Uint8Array) => void;
30
32
  /** Flush inspection at upstream end (createSseInspector.finish). */
31
33
  finishInspection: () => void;
34
+ /** Drop inspector-owned frame/item state during producer teardown. */
35
+ disposeInspection?: () => void;
32
36
  /** True once inspection has reported a protocol terminal (inspector.reported). */
33
37
  sawTerminal: () => boolean;
34
38
  /** Record a synthetic terminal (caller decides incomplete vs failed-502). */
@@ -151,25 +155,37 @@ export function relaySseEagerBounded(
151
155
  await paused();
152
156
  }
153
157
  }
154
- } catch {
158
+ } catch (err) {
155
159
  // Upstream read failure. Distinguish genuine mid-stream reset from
156
160
  // abort-driven teardown (shutdown/cancel-expiry) — audit M3.
157
161
  if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
158
- syntheticKind = "failed";
159
- try { controllerRef?.error(new Error("upstream stream failed")); } catch { /* torn down */ }
162
+ // Serializing `err` can run user-defined accessors (Error.message
163
+ // getters, toString) that re-entrantly cancel the client or abort the
164
+ // upstream. Build the tail FIRST, then re-check eligibility before
165
+ // committing to the synthetic terminal (adversarial review blocker).
166
+ const tail = new TextEncoder().encode(
167
+ `\n\nevent: response.failed\ndata: ${buildFailedTailPayload(err)}\n\ndata: [DONE]\n\n`,
168
+ );
169
+ if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
170
+ syntheticKind = "failed";
171
+ queuedBytes += tail.byteLength;
172
+ try { controllerRef?.enqueue(tail); } catch { /* client already torn down */ }
173
+ try { controllerRef?.close(); } catch { /* client already torn down */ }
174
+ }
160
175
  }
161
176
  } finally {
162
177
  if (syntheticKind) hooks.onSynthetic(syntheticKind);
163
178
  if (cancelled && !hooks.sawTerminal()) {
164
179
  hooks.onClientCancel();
165
180
  }
166
- if (cancelled || upstream.signal.aborted) {
181
+ if (cancelled || upstream.signal.aborted || syntheticKind === "failed") {
167
182
  upstream.abort();
168
183
  reader.cancel().catch(() => {});
169
184
  }
170
185
  if (!cancelled) {
171
186
  try { controllerRef?.close(); } catch { /* already closed/errored */ }
172
187
  }
188
+ try { hooks.disposeInspection?.(); } catch { /* inspection teardown must not block lifecycle cleanup */ }
173
189
  fireDone();
174
190
  }
175
191
  };