@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.
- package/README.md +8 -1
- package/bin/ocx.mjs +13 -14
- package/gui/dist/assets/index-Czw-jpTU.css +1 -0
- package/gui/dist/assets/index-cmds12BG.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +49 -6
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/inbound.ts +26 -9
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/claude.ts +3 -3
- package/src/cli/doctor.ts +23 -18
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +88 -19
- package/src/cli/interactive-confirm.ts +129 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +55 -16
- package/src/cli/status.ts +47 -13
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/parsing.ts +16 -1
- package/src/codex/catalog/provider-fetch.ts +48 -12
- package/src/codex/catalog/sync.ts +18 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +15 -0
- package/src/codex/routing.ts +695 -106
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +566 -25
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +122 -4
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +164 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +283 -72
- package/src/oauth/key-providers.ts +17 -6
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +322 -10
- package/src/oauth/kiro.ts +423 -43
- package/src/oauth/login-cli.ts +32 -5
- package/src/oauth/store.ts +54 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +11 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +14 -4
- package/src/server/auth-cors.ts +110 -22
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/management/agent-settings-routes.ts +28 -4
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +5 -2
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +35 -7
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +2 -2
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +583 -152
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +49 -2
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +366 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +242 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/tray/windows.ts +2 -0
- package/src/types.ts +96 -1
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +103 -12
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/log.ts +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
- 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
|
|
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)
|
|
330
|
-
// OpenAI-style
|
|
331
|
-
|
|
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
|
-
*
|
|
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
|
|
10
|
-
* account identifiers. `external` and `arrayBuffers` keep Windows
|
|
11
|
-
* honest when RSS/working-set counters under-report committed
|
|
12
|
-
* `jscHeap` (bun:jsc heapStats) is useful context, but on Bun 1.3.14
|
|
13
|
-
* standalone leak discriminator. `responseState` attributes growth
|
|
14
|
-
* is the proxy's previous_response_id continuation store, so a
|
|
15
|
-
* responseState.totalBytes under rising observed memory points at
|
|
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 {
|
|
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 (!
|
|
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)
|
|
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)
|
|
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
|
}
|