@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * Routing-profile management API (RI-04).
3
3
  *
4
- * - `GET /api/routing-profiles` - normalized profiles with revisions
5
- * - `POST /api/routing-profiles/dry-run` - deterministic dry-run evaluation
4
+ * - `GET /api/routing-profiles` - normalized profiles with revisions
5
+ * - `PUT /api/routing-profiles` - create or replace one validated profile
6
+ * - `DELETE /api/routing-profiles?id=<id>` - remove one profile
7
+ * - `POST /api/routing-profiles/dry-run` - deterministic dry-run evaluation
6
8
  * (never dispatches an upstream request)
7
9
  */
8
10
 
9
- import { listRoutingProfileIds, getRoutingProfile, policyPublicModelId } from "../../routing/profile";
11
+ import {
12
+ getRoutingProfile,
13
+ listRoutingProfileIds,
14
+ normalizeRoutingProfile,
15
+ policyPublicModelId,
16
+ routingProfileIssues,
17
+ } from "../../routing/profile";
10
18
  import { evaluatePolicyProfile, type PolicyCandidateEvidence, type PolicyRequestEvidence } from "../../routing/evaluator";
11
19
  import { candidateCapabilityEvidence } from "../../routing/capability";
12
20
  import { policyCandidateHealthEvidence } from "../../routing/health";
@@ -16,17 +24,20 @@ import { providerCodexAccountMode } from "../../providers/registry";
16
24
  import { getEffectiveActiveCodexAccountId } from "../../codex/routing";
17
25
  import { getAccountSet } from "../../oauth/store";
18
26
  import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
27
+ import { saveConfigPreservingClaudeCode } from "../../config";
28
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
19
29
  import { isPlainRecord } from "./shared";
20
30
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
21
31
  import { jsonResponse } from "../auth-cors";
22
32
  import type { ManagementContext } from "./context";
23
- import type { OcxConfig } from "../../types";
33
+ import type { OcxConfig, OcxRoutingProfileConfig } from "../../types";
24
34
 
25
35
  function profileDto(config: Parameters<typeof getRoutingProfile>[0], id: string): Record<string, unknown> | null {
26
36
  const profile = getRoutingProfile(config, id);
27
37
  if (!profile) return null;
28
38
  return {
29
39
  id,
40
+ alias: profile.alias,
30
41
  model: policyPublicModelId(id, profile),
31
42
  revision: profile.revision,
32
43
  candidates: profile.candidates,
@@ -131,8 +142,112 @@ function assembleCandidateEvidence(
131
142
  }));
132
143
  }
133
144
 
145
+ function storedProfile(
146
+ id: string,
147
+ raw: OcxRoutingProfileConfig,
148
+ ): OcxRoutingProfileConfig {
149
+ const normalized = normalizeRoutingProfile(id, raw);
150
+ const {
151
+ id: _id,
152
+ revision: _revision,
153
+ alias,
154
+ ...profile
155
+ } = normalized;
156
+ return alias === null ? profile : { ...profile, alias };
157
+ }
158
+
159
+ /**
160
+ * Rewrite config references from one public model id to another (alias change
161
+ * on update). Mirrors the /api/combos migration: model-valued config that
162
+ * still names the old alias must follow it, or requests fall through to
163
+ * ordinary routing and send the obsolete alias upstream.
164
+ */
165
+ /**
166
+ * Detect a modelMap key collision that the alias migration would silently
167
+ * resolve by dropping one mapping: the map already contains the new public
168
+ * model as a key with a different target than the old-alias key's target.
169
+ */
170
+ function modelMapMigrationCollision(
171
+ config: OcxConfig,
172
+ oldPublicModel: string,
173
+ newPublicModel: string,
174
+ ): string | null {
175
+ const map = config.claudeCode?.modelMap;
176
+ if (!map) return null;
177
+ if (oldPublicModel === newPublicModel) return null;
178
+ const oldTarget = map[oldPublicModel];
179
+ if (oldTarget === undefined) return null;
180
+ const newTarget = map[newPublicModel];
181
+ if (newTarget === undefined) return null;
182
+ if (oldTarget === newTarget) return null;
183
+ return `modelMap already maps \"${newPublicModel}\" to \"${newTarget}\"; renaming \"${oldPublicModel}\" (→ \"${newTarget}\") would drop one mapping. Resolve the conflict and retry.`;
184
+ }
185
+
186
+ /**
187
+ * Rewrite config references from one public model id to another (alias change
188
+ * on update). Mirrors the /api/combos migration: model-valued config that
189
+ * still names the old alias must follow it, or requests fall through to
190
+ * ordinary routing and send the obsolete alias upstream.
191
+ */
192
+ function migrateProfileModelReferences(
193
+ config: OcxConfig,
194
+ oldPublicModel: string,
195
+ newPublicModel: string,
196
+ ): boolean {
197
+ if (oldPublicModel === newPublicModel) return false;
198
+ const migrateReference = (model: string): string => (
199
+ model === oldPublicModel ? newPublicModel : model
200
+ );
201
+ let shouldSyncClaudeAgentDefs = false;
202
+ const migrateAgentReference = (model: string): string => {
203
+ const migrated = migrateReference(model);
204
+ if (migrated !== model) shouldSyncClaudeAgentDefs = true;
205
+ return migrated;
206
+ };
207
+ const migrateReferences = (models: string[]): string[] => [
208
+ ...new Set(models.map(migrateReference)),
209
+ ];
210
+ if (config.disabledModels) {
211
+ config.disabledModels = migrateReferences(config.disabledModels);
212
+ }
213
+ if (config.subagentModels) {
214
+ config.subagentModels = [...new Set(config.subagentModels.map(migrateAgentReference))];
215
+ }
216
+ if (config.subagentModelFallback) {
217
+ config.subagentModelFallback = [...new Set(config.subagentModelFallback.map(migrateAgentReference))];
218
+ }
219
+ if (config.injectionModel && config.injectionModel === oldPublicModel) {
220
+ config.injectionModel = newPublicModel;
221
+ }
222
+ if (config.shadowCallIntercept?.model && config.shadowCallIntercept.model === oldPublicModel) {
223
+ config.shadowCallIntercept = { ...config.shadowCallIntercept, model: newPublicModel };
224
+ }
225
+ if (config.claudeCode) {
226
+ const claudeCode = { ...config.claudeCode };
227
+ for (const field of ["model", "smallFastModel"] as const) {
228
+ if (claudeCode[field]) claudeCode[field] = migrateAgentReference(claudeCode[field]);
229
+ }
230
+ if (claudeCode.tierModels) {
231
+ claudeCode.tierModels = Object.fromEntries(
232
+ Object.entries(claudeCode.tierModels).map(([tier, model]) => [tier, migrateAgentReference(model)]),
233
+ );
234
+ }
235
+ if (claudeCode.modelMap) {
236
+ // Keys are the inbound ids matched for reroute (src/claude/inbound.ts);
237
+ // an old-alias key must follow the rename or that request stops intercepting.
238
+ claudeCode.modelMap = Object.fromEntries(
239
+ Object.entries(claudeCode.modelMap).map(([source, model]) => [
240
+ migrateAgentReference(source),
241
+ migrateAgentReference(model),
242
+ ]),
243
+ );
244
+ }
245
+ config.claudeCode = claudeCode;
246
+ }
247
+ return shouldSyncClaudeAgentDefs;
248
+ }
134
249
  export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promise<Response | null> {
135
- const { req, url, config } = ctx;
250
+ const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
136
251
 
137
252
  if (url.pathname === "/api/routing-profiles" && req.method === "GET") {
138
253
  const profiles = listRoutingProfileIds(config).map(id => profileDto(config, id)).filter(
@@ -141,6 +256,120 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
141
256
  return jsonResponse({ profiles }, 200, req, config);
142
257
  }
143
258
 
259
+ if (url.pathname === "/api/routing-profiles" && req.method === "PUT") {
260
+ let rawBody: unknown;
261
+ try {
262
+ rawBody = await readManagementJsonBody(req);
263
+ } catch (error) {
264
+ rethrowManagementBodyTooLarge(error);
265
+ return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
266
+ }
267
+ if (!isPlainRecord(rawBody)) {
268
+ return jsonResponse({ error: "request body must be an object" }, 400, req, config);
269
+ }
270
+ const body = rawBody as Record<string, unknown>;
271
+ const id = typeof body.id === "string" ? body.id.trim() : "";
272
+ if (!id) {
273
+ return jsonResponse({ error: { code: "missing_profile_id", message: "id is required" } }, 400, req, config);
274
+ }
275
+ const mode = body.mode === "create" || body.mode === "update" ? body.mode : null;
276
+ if (!mode) {
277
+ return jsonResponse({ error: { code: "invalid_profile_mode", message: "mode must be create or update" } }, 400, req, config);
278
+ }
279
+ const exists = Object.hasOwn(config.routingProfiles ?? {}, id);
280
+ if (mode === "create" && exists) {
281
+ return jsonResponse({ error: { code: "profile_exists", message: `routing profile already exists: ${id}` } }, 409, req, config);
282
+ }
283
+ if (mode === "update" && !exists) {
284
+ return jsonResponse({ error: { code: "unknown_profile", message: `unknown routing profile: ${id}` } }, 404, req, config);
285
+ }
286
+ if (mode === "update") {
287
+ const expectedRevision = typeof body.expectedRevision === "string" && body.expectedRevision.trim()
288
+ ? body.expectedRevision.trim()
289
+ : undefined;
290
+ if (expectedRevision) {
291
+ const current = getRoutingProfile(config, id);
292
+ if (current && current.revision !== expectedRevision) {
293
+ return jsonResponse({
294
+ error: {
295
+ code: "profile_revision_conflict",
296
+ message: `routing profile ${id} changed since it was loaded; reload and retry`,
297
+ currentRevision: current.revision,
298
+ },
299
+ }, 409, req, config);
300
+ }
301
+ }
302
+ }
303
+ const issues = routingProfileIssues(id, body.profile, config, { excludeProfileId: id });
304
+ if (issues.length > 0) {
305
+ return jsonResponse({
306
+ error: {
307
+ code: "invalid_profile",
308
+ message: issues[0]!.message,
309
+ issues,
310
+ },
311
+ }, 400, req, config);
312
+ }
313
+
314
+ const previousProfile = mode === "update" ? getRoutingProfile(config, id) : undefined;
315
+ if (mode === "update" && previousProfile) {
316
+ const oldPublicModel = policyPublicModelId(id, previousProfile);
317
+ const newProfile = normalizeRoutingProfile(id, body.profile as OcxRoutingProfileConfig);
318
+ const newPublicModel = policyPublicModelId(id, newProfile);
319
+ const collision = modelMapMigrationCollision(config, oldPublicModel, newPublicModel);
320
+ if (collision) {
321
+ return jsonResponse({
322
+ error: { code: "alias_reference_conflict", message: collision },
323
+ }, 409, req, config);
324
+ }
325
+ }
326
+ const nextProfiles = { ...(config.routingProfiles ?? {}) };
327
+ nextProfiles[id] = storedProfile(id, body.profile as OcxRoutingProfileConfig);
328
+ config.routingProfiles = nextProfiles;
329
+ // An alias change on update renames the public model id; rewrite config
330
+ // references (disabledModels, subagentModels, injectionModel,
331
+ // shadowCallIntercept, claudeCode) so they follow the new alias.
332
+ let shouldSyncClaudeAgentDefs = false;
333
+ if (previousProfile) {
334
+ const oldPublicModel = policyPublicModelId(id, previousProfile);
335
+ const newPublicModel = policyPublicModelId(id, getRoutingProfile(config, id)!);
336
+ shouldSyncClaudeAgentDefs = migrateProfileModelReferences(config, oldPublicModel, newPublicModel);
337
+ }
338
+ const save = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
339
+ save(config);
340
+ reconcileLiveStateStores();
341
+ const catalogRefresh = await convergeCodexCatalog();
342
+ if (shouldSyncClaudeAgentDefs) await syncClaudeAgentDefsBestEffort();
343
+ const profile = profileDto(config, id)!;
344
+ return jsonResponse({
345
+ success: true,
346
+ id,
347
+ model: profile.model,
348
+ profile,
349
+ catalogRefresh,
350
+ }, 200, req, config);
351
+ }
352
+
353
+ if (url.pathname === "/api/routing-profiles" && req.method === "DELETE") {
354
+ const id = url.searchParams.get("id")?.trim();
355
+ if (!id) {
356
+ return jsonResponse({ error: "id query param is required" }, 400, req, config);
357
+ }
358
+ if (!Object.hasOwn(config.routingProfiles ?? {}, id)) {
359
+ return jsonResponse({ error: "unknown routing profile" }, 404, req, config);
360
+ }
361
+
362
+ const nextProfiles = { ...(config.routingProfiles ?? {}) };
363
+ delete nextProfiles[id];
364
+ if (Object.keys(nextProfiles).length > 0) config.routingProfiles = nextProfiles;
365
+ else delete config.routingProfiles;
366
+ const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
367
+ saveConfigPreservingClaudeCodeSafe(config);
368
+ reconcileLiveStateStores();
369
+ const catalogRefresh = await convergeCodexCatalog();
370
+ return jsonResponse({ success: true, id, catalogRefresh }, 200, req, config);
371
+ }
372
+
144
373
  if (url.pathname === "/api/routing-profiles/dry-run" && req.method === "POST") {
145
374
  let rawBody: unknown;
146
375
  try { rawBody = await readManagementJsonBody(req); } catch (error) {
@@ -17,8 +17,8 @@
17
17
  * - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
18
18
  * markRecycling — after drain the listen socket is already closed, so a latch
19
19
  * reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
20
- * can restore Codex/Grok fences (ensure/tray daemons set the marker without a
21
- * real supervisor). Log only a stable errno code — never the raw message
20
+ * can restore Codex/Grok fences when a stale service marker has no viable
21
+ * supervisor. Log only a stable errno code — never the raw message
22
22
  * (paths in ENOENT often include the OS username).
23
23
  */
24
24
  import { spawn } from "node:child_process";
@@ -35,13 +35,13 @@ import {
35
35
  import { isServiceViable } from "../../service";
36
36
  import { readRuntimePort } from "../../config";
37
37
  import { withProcessRuntimeProvenance } from "../../lib/bun-runtime";
38
+ import {
39
+ MEMORY_DRAIN_RESTART_MS,
40
+ REPLACEMENT_READY_TIMEOUT_MS,
41
+ } from "../../lib/system-restart-contract";
38
42
  import { findLiveProxy } from "../proxy-liveness";
39
43
 
40
- /** Fixed v1 drain window for the memory-card action (not config-driven). */
41
- export const MEMORY_DRAIN_RESTART_MS = 60_000;
42
- // Ordinary pinned-port start can spend 60s reclaiming a Windows ghost listener
43
- // and another 5s settling it. Keep one polling/scheduler margin beyond that.
44
- export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
44
+ export { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../../lib/system-restart-contract";
45
45
  export const DEADLINE_LISTENER_STOP_TIMEOUT_MS = 5_000;
46
46
  const REPLACEMENT_READY_POLL_MS = 150;
47
47
 
@@ -223,11 +223,13 @@ function spawnDetachedStart(
223
223
  return new Promise<void>((resolve, reject) => {
224
224
  let child: ReturnType<typeof spawn>;
225
225
  try {
226
+ const env: NodeJS.ProcessEnv = { ...process.env };
227
+ delete env.OCX_SERVICE;
226
228
  child = spawn(process.execPath, args, {
227
229
  detached: true,
228
230
  stdio: "ignore",
229
231
  windowsHide: true,
230
- env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }),
232
+ env: withProcessRuntimeProvenance(env),
231
233
  });
232
234
  } catch (err) {
233
235
  reject(err);
@@ -411,8 +413,8 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
411
413
  `⚠️ Drain-and-restart spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
412
414
  );
413
415
  // Listen socket is already stopped; do not markRecycling — no child to inherit fences.
414
- // ensure/tray children inherit OCX_SERVICE=1 without an installed service; clear it so
415
- // syncCleanup can restore Codex/Grok fences instead of leaving clients pointed at a dead port.
416
+ // No replacement inherited the routing. Clear a stale service marker so
417
+ // this unsupervised parent restores clients after the failed handoff.
416
418
  delete process.env.OCX_SERVICE;
417
419
  exitProcess(1);
418
420
  return;
@@ -27,6 +27,10 @@ import { getActiveTurnCount, isDraining } from "../lifecycle";
27
27
  import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
28
28
  import { responseStateMetrics } from "../../responses/state";
29
29
  import { appOwnedBytesSnapshot } from "../../lib/app-owned-memory";
30
+ import {
31
+ SYSTEM_RESTART_EXPECTED_PID_HEADER,
32
+ parseExpectedSystemRestartPid,
33
+ } from "../../lib/system-restart-contract";
30
34
  import { jsonResponse } from "../auth-cors";
31
35
  import { getInspectionCounters } from "../relay";
32
36
  import type { ManagementContext } from "./context";
@@ -104,6 +108,22 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
104
108
  }
105
109
 
106
110
  if (url.pathname === "/api/system/restart" && req.method === "POST") {
111
+ const expectedPid = parseExpectedSystemRestartPid(
112
+ req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER),
113
+ );
114
+ if (expectedPid.kind === "invalid") {
115
+ return jsonResponse({
116
+ success: false,
117
+ error: "Invalid restart target identity.",
118
+ }, 400, req, config);
119
+ }
120
+ if (expectedPid.kind === "present" && expectedPid.pid !== process.pid) {
121
+ return jsonResponse({
122
+ success: false,
123
+ error: "Restart target identity changed.",
124
+ }, 409, req, config);
125
+ }
126
+
107
127
  // Longer informed drain than /api/stop; does not tear down Codex/Grok injection.
108
128
  const result = acceptSystemRestart();
109
129
  return jsonResponse({
@@ -13,6 +13,14 @@ import {
13
13
  } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import { adminApiTokenFilePath } from "../lib/admin-secrets";
16
+ import {
17
+ SYSTEM_RESTART_CAPABILITY_HEADER,
18
+ SYSTEM_RESTART_EXPECTED_PID_HEADER,
19
+ SYSTEM_RESTART_NONCE_HEADER,
20
+ SYSTEM_RESTART_PATH,
21
+ parseExpectedSystemRestartPid,
22
+ verifySystemRestartCapability,
23
+ } from "../lib/system-restart-contract";
16
24
  import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
17
25
  import type { OcxConfig } from "../types";
18
26
  import {
@@ -245,20 +253,58 @@ export function issueGuiSession(
245
253
  * minted for a browser, and it only authorizes a mutation after the origin and the
246
254
  * per-session CSRF token match. Consent-bearing routes must key off this value
247
255
  * rather than off request headers, which the token holder can forge freely.
256
+ * `system-restart-capability` is a process-scoped HMAC accepted only for the exact
257
+ * restart route and bound to the current process PID and listening port.
248
258
  */
249
- export type ManagementPrincipal = "admin-token" | "gui-session";
259
+ export type ManagementPrincipal = "admin-token" | "gui-session" | "system-restart-capability";
260
+
261
+ export interface LocalManagementAuthContext {
262
+ attestationSecret: string;
263
+ pid: number;
264
+ port: number;
265
+ }
266
+
267
+ function hasSystemRestartCapability(
268
+ req: Request,
269
+ local: LocalManagementAuthContext | undefined,
270
+ ): boolean {
271
+ if (!local || req.method !== "POST") return false;
272
+ let path: string;
273
+ try {
274
+ path = new URL(req.url).pathname;
275
+ } catch {
276
+ return false;
277
+ }
278
+ if (path !== SYSTEM_RESTART_PATH) return false;
279
+ const expectedPid = parseExpectedSystemRestartPid(
280
+ req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER),
281
+ );
282
+ if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false;
283
+ return verifySystemRestartCapability(
284
+ local.attestationSecret,
285
+ req.headers.get(SYSTEM_RESTART_NONCE_HEADER),
286
+ req.method,
287
+ path,
288
+ local.pid,
289
+ local.port,
290
+ req.headers.get(SYSTEM_RESTART_CAPABILITY_HEADER),
291
+ );
292
+ }
250
293
 
251
294
  /**
252
295
  * The principal for a request that already passed `requireManagementAuth`. Kept as a
253
296
  * separate resolution (rather than a changed return type) so every existing caller
254
- * keeps its `Response | null` contract; the value is derived from the same session
255
- * table and the same CSRF comparison the gate uses, so the two cannot disagree.
297
+ * keeps its `Response | null` contract. Browser and admin principals are derived
298
+ * from the same session table and CSRF comparison the gate uses; the restart
299
+ * principal is derived from the same process-scoped capability check.
256
300
  */
257
301
  export function managementPrincipal(
258
302
  req: Request,
259
303
  state: ManagementAuthState,
260
304
  config?: OcxConfig,
305
+ local?: LocalManagementAuthContext,
261
306
  ): ManagementPrincipal | null {
307
+ if (hasSystemRestartCapability(req, local)) return "system-restart-capability";
262
308
  if (!state.available) return null;
263
309
  const actual = req.headers.get("x-opencodex-api-key")?.trim()
264
310
  || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
@@ -273,7 +319,9 @@ export function requireManagementAuth(
273
319
  req: Request,
274
320
  state: ManagementAuthState,
275
321
  config?: OcxConfig,
322
+ local?: LocalManagementAuthContext,
276
323
  ): Response | null {
324
+ if (hasSystemRestartCapability(req, local)) return null;
277
325
  if (!state.available) {
278
326
  return Response.json({
279
327
  error: "management API unavailable",
@@ -57,6 +57,16 @@ export type FindAvailablePortOptions = {
57
57
  * update restart cannot hop to a random ephemeral listener (PR #152 gap).
58
58
  */
59
59
  allowEphemeralFallback?: boolean;
60
+ /**
61
+ * A port this selection must never return, even when it is free (#1102).
62
+ *
63
+ * The unauthenticated loopback listener binds a fixed port from config. If the public
64
+ * listener took that port first — via an explicit `--port`, a `config.port` of 0, or the
65
+ * ephemeral fallback happening to land on it — the loopback bind would then fail with
66
+ * EADDRINUSE, and the startup transaction would roll back a public listener that had
67
+ * nothing wrong with it. Excluding the port here fails the right thing at the right time.
68
+ */
69
+ reservedPort?: number;
60
70
  };
61
71
 
62
72
  export class PortUnavailableError extends Error {
@@ -75,6 +85,12 @@ export async function findAvailablePort(
75
85
  ): Promise<number> {
76
86
  const preferRetryMs = opts.preferRetryMs ?? 0;
77
87
  const allowEphemeral = opts.allowEphemeralFallback !== false;
88
+ const reserved = opts.reservedPort;
89
+ // An explicit preference for the reserved port is a configuration mistake, not a busy
90
+ // socket: retrying or hopping would hide it. Refuse before probing anything.
91
+ if (reserved !== undefined && preferredPort === reserved) {
92
+ throw new PortUnavailableError(preferredPort, hostname);
93
+ }
78
94
  // Port 0 asks the OS to select an ephemeral port. Resolve it to that concrete
79
95
  // port here so callers never persist or advertise an unusable `:0` endpoint.
80
96
  if (preferredPort > 0 && preferRetryMs > 0) {
@@ -92,7 +108,31 @@ export async function findAvailablePort(
92
108
  throw new PortUnavailableError(preferredPort, hostname);
93
109
  }
94
110
 
95
- return await new Promise((resolve, reject) => {
111
+ // Bounded, not recursive. The OS can hand back the reserved port, and a redraw practically
112
+ // always differs — but "practically always" is not a termination argument, and an unbounded
113
+ // async recursion has no way to stop if the assumption is ever wrong.
114
+ for (let attempt = 0; attempt < EPHEMERAL_REDRAW_LIMIT; attempt += 1) {
115
+ const port = await allocateEphemeralPort(hostname);
116
+ if (port !== reserved) return port;
117
+ }
118
+ throw new Error("failed to allocate an available port");
119
+ }
120
+
121
+ /** How many times an ephemeral draw may come back reserved before we give up. */
122
+ const EPHEMERAL_REDRAW_LIMIT = 8;
123
+
124
+ /** Test seam: replace the OS ephemeral allocator so the redraw path is reachable. */
125
+ let ephemeralAllocator: ((hostname: string) => Promise<number>) | null = null;
126
+
127
+ export function setEphemeralPortAllocatorForTests(
128
+ allocator: ((hostname: string) => Promise<number>) | null,
129
+ ): void {
130
+ ephemeralAllocator = allocator;
131
+ }
132
+
133
+ async function allocateEphemeralPort(hostname: string): Promise<number> {
134
+ if (ephemeralAllocator) return ephemeralAllocator(hostname);
135
+ return await new Promise<number>((resolve, reject) => {
96
136
  const server = createServer();
97
137
  server.once("error", reject);
98
138
  server.once("listening", () => {