@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/bin/ocx.mjs +47 -22
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -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/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- 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 +64 -7
- 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/alias.ts +94 -14
- package/src/claude/inbound.ts +26 -9
- package/src/claude/outbound.ts +6 -3
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +35 -10
- package/src/cli/doctor.ts +71 -19
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +93 -19
- package/src/cli/interactive-confirm.ts +133 -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 +79 -18
- package/src/cli/status.ts +47 -13
- package/src/cli/v2.ts +10 -1
- 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-store.ts +2 -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/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +42 -2
- package/src/codex/catalog/provider-fetch.ts +264 -70
- package/src/codex/catalog/sync.ts +45 -8
- package/src/codex/catalog.ts +2 -2
- package/src/codex/features.ts +524 -5
- 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 +92 -2
- package/src/codex/routing.ts +695 -106
- package/src/codex/runtime.ts +10 -1
- 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 +574 -25
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- 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/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +132 -7
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +167 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- 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 +310 -75
- package/src/oauth/key-providers.ts +38 -8
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +373 -12
- package/src/oauth/kiro.ts +424 -43
- package/src/oauth/login-cli.ts +33 -6
- package/src/oauth/store.ts +56 -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 +8 -2
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +125 -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 +19 -7
- package/src/server/auth-cors.ts +114 -24
- 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/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +106 -8
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +22 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +73 -10
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +5 -3
- 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 +614 -172
- package/src/server/responses/upstream-error.ts +48 -0
- 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/startup-action-control.ts +30 -14
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +284 -19
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +387 -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 +253 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +34 -4
- package/src/types.ts +107 -1
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +111 -16
- 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/expected-prices.ts +6 -5
- package/src/usage/log.ts +12 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-CjKFJHSC.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -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";
|
|
@@ -65,6 +65,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
|
|
|
65
65
|
import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
|
|
66
66
|
import { handleComboRoutes } from "./management/combo-routes";
|
|
67
67
|
import { handleSystemRoutes } from "./management/system-routes";
|
|
68
|
+
import { handleSidebarRoutes } from "./management/sidebar-routes";
|
|
68
69
|
import type { ManagementContext } from "./management/context";
|
|
69
70
|
export type { ManagementApiDeps } from "./management/context";
|
|
70
71
|
import { fetchAllModels } from "./management/shared";
|
|
@@ -79,7 +80,7 @@ export const VERSION = (() => {
|
|
|
79
80
|
})();
|
|
80
81
|
|
|
81
82
|
export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
|
|
82
|
-
if (!
|
|
83
|
+
if (!isAllowedManagementOrigin(req, config)) {
|
|
83
84
|
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
|
|
84
85
|
}
|
|
85
86
|
// Management bodies are small JSON (provider names, key ids, settings). Reject oversized
|
|
@@ -130,7 +131,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
130
131
|
?? (await handleAgentSettingsRoutes(ctx))
|
|
131
132
|
?? (await handleOauthAccountRoutes(ctx))
|
|
132
133
|
?? (await handleComboRoutes(ctx))
|
|
133
|
-
?? (await handleSystemRoutes(ctx))
|
|
134
|
+
?? (await handleSystemRoutes(ctx))
|
|
135
|
+
?? (await handleSidebarRoutes(ctx));
|
|
134
136
|
if (routed) return routed;
|
|
135
137
|
|
|
136
138
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
@@ -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
|
}
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
isCodexAuthContextUsable,
|
|
52
52
|
resolveCodexAuthContext,
|
|
53
53
|
codexProbeLeaseId,
|
|
54
|
+
codexProbeQuotaScope,
|
|
54
55
|
type CodexAuthContext,
|
|
55
56
|
} from "../../codex/auth-context";
|
|
56
57
|
import {
|
|
@@ -217,7 +218,7 @@ export async function handleResponsesCompact(
|
|
|
217
218
|
const headers = new Headers({ "content-type": "application/json" });
|
|
218
219
|
try {
|
|
219
220
|
if (route.codexAccountMode) {
|
|
220
|
-
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
|
|
221
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: selectedModelId });
|
|
221
222
|
const selected = headersForCodexAuthContext(req.headers, authCtx);
|
|
222
223
|
compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
223
224
|
for (const name of FORWARD_HEADERS) {
|
|
@@ -255,12 +256,17 @@ export async function handleResponsesCompact(
|
|
|
255
256
|
const compactUrl = `${base}/responses/compact`;
|
|
256
257
|
const compactThreadId = req.headers.get("x-codex-parent-thread-id");
|
|
257
258
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
258
|
-
const recordCompactPoolOutcome = (
|
|
259
|
+
const recordCompactPoolOutcome = (
|
|
260
|
+
outcome: CodexUpstreamOutcome,
|
|
261
|
+
meta: { retryAfter?: string | null; resetAt?: unknown | unknown[] } = {},
|
|
262
|
+
) => {
|
|
259
263
|
if (!usesCodexForwardPoolAuth(authCtx, route.provider)) return;
|
|
260
264
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
261
265
|
...meta,
|
|
262
266
|
threadId: compactThreadId,
|
|
267
|
+
modelId: selectedModelId,
|
|
263
268
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
269
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
264
270
|
});
|
|
265
271
|
};
|
|
266
272
|
let upstream: Response;
|
|
@@ -283,28 +289,30 @@ export async function handleResponsesCompact(
|
|
|
283
289
|
{ abortSignal: req.signal, label: safeHostLabel(compactUrl) },
|
|
284
290
|
);
|
|
285
291
|
} catch (err) {
|
|
286
|
-
if (req.signal.aborted)
|
|
292
|
+
if (req.signal.aborted) {
|
|
293
|
+
recordCompactPoolOutcome(499);
|
|
294
|
+
return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
295
|
+
}
|
|
287
296
|
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
288
297
|
recordCompactPoolOutcome(outcome);
|
|
289
298
|
return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
|
|
290
299
|
}
|
|
291
300
|
const retryAfter = upstream.headers.get("retry-after");
|
|
301
|
+
const resetAt = [
|
|
302
|
+
upstream.headers.get("x-codex-primary-reset-at"),
|
|
303
|
+
upstream.headers.get("x-codex-secondary-reset-at"),
|
|
304
|
+
upstream.headers.get("x-codex-tertiary-reset-at"),
|
|
305
|
+
].filter(Boolean);
|
|
292
306
|
const buffered = await bufferCompactResponse(upstream, req.signal);
|
|
293
307
|
// Record pool health only after the body is fully delivered (or definitively failed).
|
|
294
308
|
// A premature 200 would clear soft-avoid while the client still sees a buffer 502.
|
|
295
309
|
if (buffered.status === 499) {
|
|
310
|
+
recordCompactPoolOutcome(499);
|
|
296
311
|
return buffered;
|
|
297
312
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// reset on a small JSON payload) is a local proxy issue, not account flakiness.
|
|
302
|
-
// Record the upstream status so a deterministic payload-size limit does not
|
|
303
|
-
// soft-avoid a healthy account and rotate a thread for 30s.
|
|
304
|
-
recordCompactPoolOutcome(upstream.status, { retryAfter });
|
|
305
|
-
} else {
|
|
306
|
-
recordCompactPoolOutcome(upstream.status, { retryAfter });
|
|
307
|
-
}
|
|
313
|
+
// Always record the real upstream status: a local buffering failure after a
|
|
314
|
+
// 200 upstream response must not soft-avoid a healthy account or rotate a thread.
|
|
315
|
+
recordCompactPoolOutcome(upstream.status, { retryAfter, resetAt });
|
|
308
316
|
return buffered;
|
|
309
317
|
}
|
|
310
318
|
|