@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -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/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import type { CatalogModel } from "../../codex/catalog";
4
4
  import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
5
+ import { providerModelsListFromProbeResponse } from "../../codex/catalog/provider-fetch";
5
6
  import {
6
7
  DEFAULT_SUBAGENT_MODELS,
7
8
  codexAutoStartEnabled,
@@ -23,12 +24,14 @@ import {
23
24
  } from "../../oauth";
24
25
  import { removeCredential } from "../../oauth/store";
25
26
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
27
+ import { providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
26
28
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
27
29
  import { deriveProviderPresets } from "../../providers/derive";
28
30
  import { providerCodexAccountMode } from "../../providers/registry";
29
31
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
30
32
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
33
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
34
+ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
32
35
  import { clearThreadAccountMap } from "../../codex/routing";
33
36
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
37
  import { getProviderDiscoveryStatus } from "../../codex/model-cache";
@@ -76,6 +79,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
76
79
  liveModels: p.liveModels !== false,
77
80
  models: p.models ?? [],
78
81
  authMode: p.authMode,
82
+ apiKeyTransport: p.apiKeyTransport,
79
83
  disabled: p.disabled === true,
80
84
  codexAccountMode: providerCodexAccountMode(name, p),
81
85
  discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name),
@@ -98,6 +102,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
98
102
  if (!isValidProviderName(name)) {
99
103
  return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
100
104
  }
105
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
106
+ if (namespaceCollision) {
107
+ return jsonResponse({ error: namespaceCollision }, 409);
108
+ }
101
109
  // Hostname destinations additionally get a DNS-resolved SSRF check at write time —
102
110
  // the sync check above only classifies literal IPs (review finding, PR #96).
103
111
  // Canonical openai still runs the resolver: only Clash fake-IP (198.18.0.0/15)
@@ -213,6 +221,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
213
221
  return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
214
222
  }
215
223
  }
224
+ if (Object.hasOwn(rawBody, "apiKeyTransport")) {
225
+ const transport = rawBody.apiKeyTransport;
226
+ if (transport === "x-api-key" || transport === "bearer") {
227
+ next.apiKeyTransport = transport;
228
+ touched = true;
229
+ } else if (transport === "") {
230
+ delete next.apiKeyTransport;
231
+ touched = true;
232
+ } else {
233
+ return jsonResponse({ error: "apiKeyTransport must be x-api-key, bearer, or empty to clear" }, 400);
234
+ }
235
+ }
216
236
  if (Object.hasOwn(rawBody, "note")) {
217
237
  if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
218
238
  const note = rawBody.note.trim();
@@ -321,17 +341,25 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
321
341
  const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
322
342
  const started = Date.now();
323
343
  try {
324
- const res = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
344
+ const res = await providerOutboundGet(name, prov, modelsUrl, {
345
+ headers,
346
+ signal: AbortSignal.timeout(8000),
347
+ });
325
348
  const latencyMs = Date.now() - started;
349
+ const redirectError = await providerRedirectError(res, modelsUrl);
350
+ if (redirectError) {
351
+ return jsonResponse({
352
+ ok: false,
353
+ latencyMs,
354
+ error: redirectError,
355
+ });
356
+ }
326
357
  if (!res.ok) {
327
358
  return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
328
359
  }
329
- const json = await res.json().catch(() => null) as { data?: unknown; models?: unknown } | null;
330
- // OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape
331
- // buildModelsRequest can produce) returns { models: [...] }.
332
- const list = json && typeof json === "object" && !Array.isArray(json)
333
- ? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined)
334
- : undefined;
360
+ const json = await res.json().catch(() => null);
361
+ // OpenAI-style { data }, Google { models }, and Together-style top-level arrays (#617).
362
+ const list = providerModelsListFromProbeResponse(json);
335
363
  if (!Array.isArray(list)) {
336
364
  return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" });
337
365
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Dashboard memory-card drain-and-restart (#563).
3
+ *
4
+ * Longer than POST /api/stop's short drain: waits up to 60s for active turns,
5
+ * then respawns. Never runs restoreNativeCodex / stripGrokConfig — this is a
6
+ * recycle to reclaim RSS, not a teardown.
7
+ *
8
+ * Respawn policy (matches real supervisor configs in src/service.ts):
9
+ * - Supervised child (`OCX_SERVICE=1` + service installed): exit(1) so
10
+ * failure-only supervisors (systemd Restart=on-failure, WinSW onfailure,
11
+ * Task Scheduler ERRORLEVEL loop) bring the proxy back.
12
+ * - Otherwise: detached `ocx start --port <live>` (bypasses ensure's
13
+ * codexAutoStart gate), mark recycle so exit cleanup keeps injection, exit(0).
14
+ * - If detached spawn fails (sync throw or pre-start `error`): exit(1) without
15
+ * markRecycling — after drain the listen socket is already closed, so a latch
16
+ * reset cannot recover serving. Clear inherited `OCX_SERVICE` so exit cleanup
17
+ * can restore Codex/Grok fences (ensure/tray daemons set the marker without a
18
+ * real supervisor). Log only a stable errno code — never the raw message
19
+ * (paths in ENOENT often include the OS username).
20
+ */
21
+ import { spawn } from "node:child_process";
22
+ import {
23
+ drainAndShutdown,
24
+ getActiveTurnCount,
25
+ getServerListenPort,
26
+ isDraining,
27
+ markRecyclingForExit,
28
+ setDraining,
29
+ } from "../lifecycle";
30
+ import { isServiceInstalled } from "../../service";
31
+ import { readRuntimePort } from "../../config";
32
+
33
+ /** Fixed v1 drain window for the memory-card action (not config-driven). */
34
+ export const MEMORY_DRAIN_RESTART_MS = 60_000;
35
+
36
+ export interface SystemRestartIo {
37
+ drainAndShutdown?: typeof drainAndShutdown;
38
+ isServiceInstalled?: () => boolean;
39
+ isSupervisedServiceChild?: () => boolean;
40
+ /** Must resolve only after the replacement process has actually started. */
41
+ spawnStart?: (port?: number) => void | Promise<void>;
42
+ markRecycling?: () => void;
43
+ exitProcess?: (code: number) => void;
44
+ schedule?: (fn: () => void | Promise<void>, ms: number) => void;
45
+ isDraining?: () => boolean;
46
+ setDraining?: (value: boolean) => void;
47
+ getActiveTurnCount?: () => number;
48
+ listenPort?: () => number | undefined;
49
+ }
50
+
51
+ let restartIo: SystemRestartIo = {};
52
+ /** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
53
+ let restartAccepted = false;
54
+
55
+ /** Test seam — reset between tests. */
56
+ export function setSystemRestartIoForTests(io: SystemRestartIo = {}): void {
57
+ restartIo = io;
58
+ restartAccepted = false;
59
+ }
60
+
61
+ function resolveListenPort(): number | undefined {
62
+ const live = getServerListenPort();
63
+ if (live) return live;
64
+ const runtime = readRuntimePort(process.pid);
65
+ if (runtime && runtime.port > 0) return runtime.port;
66
+ return undefined;
67
+ }
68
+
69
+ function isSupervisedServiceChild(): boolean {
70
+ return process.env.OCX_SERVICE === "1" && isServiceInstalled();
71
+ }
72
+
73
+ /** Stable, path-free spawn failure label for logs (never interpolate err.message). */
74
+ function spawnFailureCode(err: unknown): string {
75
+ if (err && typeof err === "object" && "code" in err) {
76
+ const code = (err as NodeJS.ErrnoException).code;
77
+ if (typeof code === "string" && code.length > 0 && code.length <= 64) return code;
78
+ }
79
+ return "spawn_failed";
80
+ }
81
+
82
+ function spawnDetachedStart(port?: number): Promise<void> {
83
+ const args = [process.argv[1], "start"];
84
+ if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
85
+ args.push("--port", String(Math.trunc(port)));
86
+ }
87
+ return new Promise<void>((resolve, reject) => {
88
+ let child: ReturnType<typeof spawn>;
89
+ try {
90
+ child = spawn(process.execPath, args, {
91
+ detached: true,
92
+ stdio: "ignore",
93
+ windowsHide: true,
94
+ env: { ...process.env, OCX_SERVICE: "1" },
95
+ });
96
+ } catch (err) {
97
+ reject(err);
98
+ return;
99
+ }
100
+ let settled = false;
101
+ const finish = (fn: () => void) => {
102
+ if (settled) return;
103
+ settled = true;
104
+ fn();
105
+ };
106
+ child.once("error", (err) => {
107
+ finish(() => reject(err));
108
+ });
109
+ child.once("spawn", () => {
110
+ finish(() => {
111
+ child.unref();
112
+ resolve();
113
+ });
114
+ });
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Accept a drain-and-restart request. Returns immediately; the drain +
120
+ * respawn runs on a short timer so the HTTP response can flush first.
121
+ * Idempotent while already draining: returns the accepted shape again.
122
+ */
123
+ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
124
+ accepted: true;
125
+ alreadyDraining: boolean;
126
+ activeTurnCount: number;
127
+ drainTimeoutMs: number;
128
+ } {
129
+ const alreadyDraining = restartAccepted || (io.isDraining ?? isDraining)();
130
+ const activeTurnCount = (io.getActiveTurnCount ?? getActiveTurnCount)();
131
+ const schedule = io.schedule ?? ((fn, ms) => { setTimeout(() => { void fn(); }, ms); });
132
+
133
+ if (!alreadyDraining) {
134
+ restartAccepted = true;
135
+ // Reject new data-plane traffic immediately (503), before the 200ms response-flush delay.
136
+ (io.setDraining ?? setDraining)(true);
137
+ schedule(async () => {
138
+ const drain = io.drainAndShutdown ?? drainAndShutdown;
139
+ await drain(undefined, MEMORY_DRAIN_RESTART_MS);
140
+ const supervised = (io.isSupervisedServiceChild ?? isSupervisedServiceChild)();
141
+ if (supervised) {
142
+ // Failure-only supervisors ignore exit(0); intentional non-zero triggers respawn.
143
+ (io.exitProcess ?? ((code: number) => { process.exit(code); }))(1);
144
+ return;
145
+ }
146
+ const port = (io.listenPort ?? resolveListenPort)();
147
+ const exitProcess = io.exitProcess ?? ((code: number) => { process.exit(code); });
148
+ try {
149
+ await (io.spawnStart ?? spawnDetachedStart)(port);
150
+ } catch (err) {
151
+ console.warn(
152
+ `⚠️ Drain-and-restart spawn failed (${spawnFailureCode(err)}); exiting without replacement`,
153
+ );
154
+ // Listen socket is already stopped; do not markRecycling — no child to inherit fences.
155
+ // ensure/tray children inherit OCX_SERVICE=1 without an installed service; clear it so
156
+ // syncCleanup can restore Codex/Grok fences instead of leaving clients pointed at a dead port.
157
+ delete process.env.OCX_SERVICE;
158
+ exitProcess(1);
159
+ return;
160
+ }
161
+ (io.markRecycling ?? markRecyclingForExit)();
162
+ exitProcess(0);
163
+ }, 200);
164
+ }
165
+
166
+ return {
167
+ accepted: true,
168
+ alreadyDraining,
169
+ activeTurnCount,
170
+ drainTimeoutMs: MEMORY_DRAIN_RESTART_MS,
171
+ };
172
+ }
@@ -1,25 +1,31 @@
1
1
  /**
2
- * /api/system/* — service-process runtime/memory introspection (#314 WP3).
2
+ * /api/system/* — service-process runtime/memory introspection (#314 WP3)
3
+ * and the memory-card drain-and-restart action (#563).
3
4
  *
4
5
  * Rides the standard management gate: every /api/* request already passed
5
- * requireApiAuth("management") + the origin check before dispatch, so these
6
+ * the independent management-auth gate + the origin check before dispatch, so these
6
7
  * routes add no auth of their own. NEVER expose this data on the
7
8
  * unauthenticated /healthz surface.
8
9
  *
9
- * The payload is scalar-only (numbers, enum strings): no paths, no tokens, no
10
- * account identifiers. `external` and `arrayBuffers` keep Windows diagnostics
11
- * honest when RSS/working-set counters under-report committed retention.
12
- * `jscHeap` (bun:jsc heapStats) is useful context, but on Bun 1.3.14 it is not a
13
- * standalone leak discriminator. `responseState` attributes growth further: it
14
- * is the proxy's previous_response_id continuation store, so a growing
15
- * responseState.totalBytes under rising observed memory points at conversation
16
- * retention rather than the runtime allocator.
10
+ * The payload is scalar-only (numbers, enum strings, booleans): no paths, no
11
+ * tokens, no account identifiers. `external` and `arrayBuffers` keep Windows
12
+ * diagnostics honest when RSS/working-set counters under-report committed
13
+ * retention. `jscHeap` (bun:jsc heapStats) is useful context, but on Bun 1.3.14
14
+ * it is not a standalone leak discriminator. `responseState` attributes growth
15
+ * further: it is the proxy's previous_response_id continuation store, so a
16
+ * growing responseState.totalBytes under rising observed memory points at
17
+ * conversation retention rather than the runtime allocator.
18
+ *
19
+ * `activeTurnCount` / `isDraining` are scalar lifecycle counters for the
20
+ * dashboard drain-and-restart confirm UX — never request bodies or IDs.
17
21
  */
18
22
  import { decideEagerRelay } from "../../lib/bun-stream-caps";
23
+ import { getActiveTurnCount, isDraining } from "../lifecycle";
19
24
  import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
20
25
  import { responseStateMetrics } from "../../responses/state";
21
26
  import { jsonResponse } from "../auth-cors";
22
27
  import type { ManagementContext } from "./context";
28
+ import { acceptSystemRestart } from "./system-restart";
23
29
 
24
30
  const ENDPOINT_SAMPLE_LIMIT = 60;
25
31
 
@@ -76,7 +82,24 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
76
82
  streamMode,
77
83
  eagerRelay: process.platform === "win32" ? decideEagerRelay(streamMode) : null,
78
84
  watchdog,
85
+ activeTurnCount: getActiveTurnCount(),
86
+ isDraining: isDraining(),
79
87
  });
80
88
  }
89
+
90
+ if (url.pathname === "/api/system/restart" && req.method === "POST") {
91
+ // Longer informed drain than /api/stop; does not tear down Codex/Grok injection.
92
+ const result = acceptSystemRestart();
93
+ return jsonResponse({
94
+ success: true,
95
+ message: result.alreadyDraining
96
+ ? "Drain already in progress."
97
+ : "Draining in-flight requests, then restarting.",
98
+ activeTurnCount: result.activeTurnCount,
99
+ drainTimeoutMs: result.drainTimeoutMs,
100
+ alreadyDraining: result.alreadyDraining,
101
+ }, 202, req, config);
102
+ }
103
+
81
104
  return null;
82
105
  }
@@ -53,7 +53,7 @@ import { drainAndShutdown } from "./lifecycle";
53
53
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
54
54
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
55
55
  import type { PersistedUsageAttempt } from "../usage/log";
56
- import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
56
+ import { isAllowedManagementOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
57
57
  import { applySystemEnvToggle } from "./system-env";
58
58
 
59
59
  import type { ManagementApiDeps } from "./management/context";
@@ -79,7 +79,7 @@ export const VERSION = (() => {
79
79
  })();
80
80
 
81
81
  export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
82
- if (!isAllowedRequestOrigin(req, config)) {
82
+ if (!isAllowedManagementOrigin(req, config)) {
83
83
  return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
84
84
  }
85
85
  // Management bodies are small JSON (provider names, key ids, settings). Reject oversized
@@ -0,0 +1,216 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fsyncSync,
6
+ linkSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { adminApiTokenFilePath } from "../lib/admin-secrets";
16
+ import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
17
+ import type { OcxConfig } from "../types";
18
+ import {
19
+ isAllowedManagementOrigin,
20
+ isApiAuthRequired,
21
+ isDataPlaneAdmissionSecret,
22
+ isLoopbackHostname,
23
+ managementRequestOrigin,
24
+ parseHttpHost,
25
+ } from "./auth-cors";
26
+
27
+ const GUI_SESSION_TTL_MS = 5 * 60_000;
28
+ const GUI_SESSION_LIMIT = 128;
29
+
30
+ interface GuiSessionRecord {
31
+ csrfToken: string;
32
+ origin: string;
33
+ expiresAt: number;
34
+ }
35
+
36
+ export interface GuiSessionBootstrap extends GuiSessionRecord {
37
+ token: string;
38
+ }
39
+
40
+ export type ManagementAuthState =
41
+ | {
42
+ available: true;
43
+ token: string;
44
+ source: "environment" | "file";
45
+ sessions: Map<string, GuiSessionRecord>;
46
+ }
47
+ | { available: false; reason: string };
48
+
49
+ function fail(reason: string): ManagementAuthState {
50
+ return { available: false, reason };
51
+ }
52
+
53
+ function assertSafeDirectory(path: string): void {
54
+ mkdirSync(path, { recursive: true, mode: 0o700 });
55
+ const stat = lstatSync(path);
56
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("management token directory is not a regular directory");
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");
60
+ }
61
+
62
+ function readExistingToken(path: string): string {
63
+ const stat = lstatSync(path);
64
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 512) {
65
+ throw new Error("management token path is not a regular secret file");
66
+ }
67
+ 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");
70
+ const token = readFileSync(path, "utf8").trim();
71
+ if (!/^ocx_admin_[A-Za-z0-9_-]{43}$/.test(token)) throw new Error("management token file is invalid");
72
+ return token;
73
+ }
74
+
75
+ function removeBestEffort(path: string): void {
76
+ try { unlinkSync(path); } catch { /* fail-closed state is preserved by the caller */ }
77
+ }
78
+
79
+ function createTokenFile(path: string): string {
80
+ const directory = dirname(path);
81
+ const token = `ocx_admin_${randomBytes(32).toString("base64url")}`;
82
+ const temporary = join(directory, `.${randomUUID()}.admin-token.tmp`);
83
+ let linked = false;
84
+ let fd: number | null = null;
85
+ try {
86
+ fd = openSync(temporary, "wx", 0o600);
87
+ writeFileSync(fd, `${token}\n`, "utf8");
88
+ fsyncSync(fd);
89
+ closeSync(fd);
90
+ fd = null;
91
+ 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");
94
+ try {
95
+ linkSync(temporary, path);
96
+ linked = true;
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return readExistingToken(path);
99
+ throw error;
100
+ }
101
+ const finalHardened = hardenSecretPath(path, { required: true });
102
+ if (!finalHardened.ok) throw new Error("management token file ACL hardening did not complete");
103
+ return token;
104
+ } catch (error) {
105
+ if (linked) removeBestEffort(path);
106
+ throw error;
107
+ } finally {
108
+ if (fd !== null) {
109
+ try { closeSync(fd); } catch { /* best effort */ }
110
+ }
111
+ removeBestEffort(temporary);
112
+ }
113
+ }
114
+
115
+ function ready(token: string, source: "environment" | "file", config: OcxConfig): ManagementAuthState {
116
+ if (isDataPlaneAdmissionSecret(token, config)) {
117
+ return fail("management credential conflicts with a data-plane credential");
118
+ }
119
+ return { available: true, token, source, sessions: new Map() };
120
+ }
121
+
122
+ export function initializeManagementAuthState(config: OcxConfig): ManagementAuthState {
123
+ const environmentToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim();
124
+ if (environmentToken) {
125
+ return ready(environmentToken, "environment", config);
126
+ }
127
+ try {
128
+ const path = adminApiTokenFilePath();
129
+ assertSafeDirectory(dirname(path));
130
+ let token: string;
131
+ try {
132
+ token = readExistingToken(path);
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
135
+ token = createTokenFile(path);
136
+ }
137
+ return ready(token, "file", config);
138
+ } catch (error) {
139
+ return fail(error instanceof Error ? error.message : "management token initialization failed");
140
+ }
141
+ }
142
+
143
+ function equalSecret(actual: string, expected: string): boolean {
144
+ const encoder = new TextEncoder();
145
+ const left = encoder.encode(actual);
146
+ const right = encoder.encode(expected);
147
+ return left.length === right.length && timingSafeEqual(left, right);
148
+ }
149
+
150
+ function removeExpiredSessions(state: Extract<ManagementAuthState, { available: true }>, now = Date.now()): void {
151
+ for (const [token, session] of state.sessions) {
152
+ if (session.expiresAt <= now) state.sessions.delete(token);
153
+ }
154
+ }
155
+
156
+ function randomSessionSecret(prefix: "ocx_session_"): string {
157
+ return `${prefix}${randomBytes(32).toString("base64url")}`;
158
+ }
159
+
160
+ export function issueGuiSession(
161
+ req: Request,
162
+ config: OcxConfig,
163
+ state: ManagementAuthState,
164
+ ): GuiSessionBootstrap | null {
165
+ if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null;
166
+ const host = parseHttpHost(req.headers.get("Host"));
167
+ if (!host || !isLoopbackHostname(host.hostname)) return null;
168
+ const origin = managementRequestOrigin(req, config);
169
+ if (!origin) return null;
170
+ const now = Date.now();
171
+ removeExpiredSessions(state, now);
172
+ while (state.sessions.size >= GUI_SESSION_LIMIT) {
173
+ const oldest = state.sessions.keys().next().value as string | undefined;
174
+ if (!oldest) break;
175
+ state.sessions.delete(oldest);
176
+ }
177
+ const token = randomSessionSecret("ocx_session_");
178
+ const session: GuiSessionRecord = {
179
+ csrfToken: randomBytes(32).toString("base64url"),
180
+ origin,
181
+ expiresAt: now + GUI_SESSION_TTL_MS,
182
+ };
183
+ state.sessions.set(token, session);
184
+ return { token, ...session };
185
+ }
186
+
187
+ export function requireManagementAuth(
188
+ req: Request,
189
+ state: ManagementAuthState,
190
+ config?: OcxConfig,
191
+ ): Response | null {
192
+ if (!state.available) {
193
+ return Response.json({ error: "management API unavailable" }, { status: 503 });
194
+ }
195
+ const actual = req.headers.get("x-opencodex-api-key")?.trim()
196
+ || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
197
+ if (actual && equalSecret(actual, state.token)) return null;
198
+ if (actual && config) {
199
+ removeExpiredSessions(state);
200
+ const session = state.sessions.get(actual);
201
+ if (session) {
202
+ const requestOrigin = managementRequestOrigin(req, config);
203
+ const claimedOrigin = req.headers.get("x-opencodex-gui-origin");
204
+ const browserOrigin = req.headers.get("Origin");
205
+ const sameOrigin = requestOrigin === session.origin
206
+ && claimedOrigin === session.origin
207
+ && (!browserOrigin || browserOrigin === session.origin);
208
+ const safeMethod = req.method === "GET" || req.method === "HEAD";
209
+ const csrf = req.headers.get("x-opencodex-csrf-token")?.trim();
210
+ if (sameOrigin && (safeMethod || (browserOrigin === session.origin && !!csrf && equalSecret(csrf, session.csrfToken)))) {
211
+ return null;
212
+ }
213
+ }
214
+ }
215
+ return Response.json({ error: "opencodex admin token required" }, { status: 401 });
216
+ }
@@ -37,6 +37,8 @@ export interface LiveProxy {
37
37
  port: number;
38
38
  /** Raw bind hostname the probe succeeded against; compose URLs via `probeHostname`. */
39
39
  hostname?: string;
40
+ /** Whether the successful probe used runtime-port metadata or the configured listen port. */
41
+ source: "runtime" | "config";
40
42
  }
41
43
 
42
44
  /**
@@ -118,7 +120,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
118
120
  // healthz confirmed the pid itself → trusted; a pidless legacy body did not,
119
121
  // so the cheap pid must pass full identity verification before it is returned.
120
122
  const trusted = identity.pid === pid ? pid : killablePid(pid);
121
- return { pid: trusted, port: runtime.port, hostname: runtime.hostname };
123
+ return { pid: trusted, port: runtime.port, hostname: runtime.hostname, source: "runtime" };
122
124
  }
123
125
  }
124
126
  }
@@ -133,12 +135,21 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
133
135
  // Only the healthz-reported pid is authoritative here. The record's pid may be stale
134
136
  // (its process dead, the port reused by a pidless legacy proxy) — synthesizing it
135
137
  // would hand destructive callers (stopProxy → kill fallback) a reusable pid.
136
- if (identity) return { pid: identity.pid ?? null, port: record.port, hostname: record.hostname };
138
+ if (identity) {
139
+ return { pid: identity.pid ?? null, port: record.port, hostname: record.hostname, source: "runtime" };
140
+ }
137
141
  }
138
142
 
139
143
  const config = configFn();
140
144
  const port = config.port ?? 10100;
141
145
  const identity = await proxyIdentityAt(port, { hostname: config.hostname }, io);
142
- if (identity) return { pid: identity.pid ?? killablePid(pid), port, hostname: config.hostname };
146
+ if (identity) {
147
+ return {
148
+ pid: identity.pid ?? killablePid(pid),
149
+ port,
150
+ hostname: config.hostname,
151
+ source: "config",
152
+ };
153
+ }
143
154
  return null;
144
155
  }