@bitkyc08/opencodex 2.39.0 → 2.40.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 +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { OcxConfig } from "../types";
|
|
3
|
+
import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability";
|
|
4
|
+
import {
|
|
5
|
+
isAllowedManagementOrigin,
|
|
6
|
+
isApiAuthRequired,
|
|
7
|
+
isLoopbackHostname,
|
|
8
|
+
managementRequestOrigin,
|
|
9
|
+
parseHttpHost,
|
|
10
|
+
} from "./auth-cors";
|
|
11
|
+
|
|
12
|
+
export type GuiSessionIssuance =
|
|
13
|
+
| "loopback"
|
|
14
|
+
| "tailscale-identity"
|
|
15
|
+
| "pairing";
|
|
16
|
+
|
|
17
|
+
export interface GuiSessionRecord {
|
|
18
|
+
serverOrigin: string;
|
|
19
|
+
browserOrigin: string;
|
|
20
|
+
csrfToken: string;
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
issuance: GuiSessionIssuance;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GuiSessionBootstrap extends GuiSessionRecord {
|
|
26
|
+
token: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GuiPairingGrantRecord {
|
|
30
|
+
serverOrigin: string;
|
|
31
|
+
browserOrigin: string;
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
failedAttempts?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PairingAttemptContext {
|
|
37
|
+
ingress: "public" | "hub-management";
|
|
38
|
+
peerAddress: string | null;
|
|
39
|
+
tailscaleUser: string | null;
|
|
40
|
+
browserOrigin: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type PairingAttemptResult =
|
|
44
|
+
| { allowed: true }
|
|
45
|
+
| { allowed: false; retryAfterSeconds: number; reason: "grant" | "source" | "capacity" };
|
|
46
|
+
type PairingAttemptRefusal = Extract<PairingAttemptResult, { allowed: false }>;
|
|
47
|
+
|
|
48
|
+
export interface GuiSessionState {
|
|
49
|
+
sessions: Map<string, GuiSessionRecord>;
|
|
50
|
+
pairingGrants: Map<string, GuiPairingGrantRecord>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GuiSessionRequestContext {
|
|
54
|
+
trustedTailscaleIngress: boolean;
|
|
55
|
+
now?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type GuiSessionAdmission =
|
|
59
|
+
| { ok: true; principal: "gui-session"; session: GuiSessionRecord }
|
|
60
|
+
| { ok: false; reason: "missing" | "expired" | "server-origin" | "browser-origin" | "csrf" };
|
|
61
|
+
|
|
62
|
+
export const LOOPBACK_GUI_SESSION_TTL_MS = 5 * 60_000;
|
|
63
|
+
export const REMOTE_GUI_SESSION_TTL_MS = 12 * 60 * 60_000;
|
|
64
|
+
export const GUI_PAIRING_GRANT_TTL_MS = 5 * 60_000;
|
|
65
|
+
export const GUI_SESSION_LIMIT = 128;
|
|
66
|
+
export const GUI_PAIRING_GRANT_LIMIT = 128;
|
|
67
|
+
export const GUI_PAIRING_GRANT_RATE_LIMIT = 8;
|
|
68
|
+
export const GUI_PAIRING_GRANT_RATE_WINDOW_MS = 60_000;
|
|
69
|
+
|
|
70
|
+
const pairingGrantCreations = new WeakMap<GuiSessionState, number[]>();
|
|
71
|
+
const pairingSourceAttempts = new WeakMap<GuiSessionState, Map<string, { failures: number; windowStartedAt: number }>>();
|
|
72
|
+
const PAIRING_SOURCE_WINDOW_MS = 10 * 60_000;
|
|
73
|
+
const PAIRING_SOURCE_FAILURE_LIMIT = 10;
|
|
74
|
+
const PAIRING_SOURCE_LIMIT = 1_024;
|
|
75
|
+
const PAIRING_GRANT_FAILURE_LIMIT = 5;
|
|
76
|
+
|
|
77
|
+
export class GuiPairingGrantRateLimitError extends Error {
|
|
78
|
+
constructor() {
|
|
79
|
+
super("GUI pairing grant rate limit exceeded");
|
|
80
|
+
this.name = "GuiPairingGrantRateLimitError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function equalSecret(actual: string, expected: string): boolean {
|
|
85
|
+
const encoder = new TextEncoder();
|
|
86
|
+
const left = encoder.encode(actual);
|
|
87
|
+
const right = encoder.encode(expected);
|
|
88
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function canonicalHttpOrigin(value: unknown): string | null {
|
|
92
|
+
if (typeof value !== "string") return null;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = new URL(value);
|
|
95
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
96
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
|
|
97
|
+
return parsed.origin;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isRemoteGuiBrowserOriginAllowed(browserOrigin: string, config: OcxConfig): boolean {
|
|
104
|
+
const canonical = canonicalGuiBrowserOrigin(browserOrigin);
|
|
105
|
+
if (!canonical || canonical !== browserOrigin) return false;
|
|
106
|
+
const publicOrigin = canonicalHttpOrigin(config.hub?.managementPublicOrigin);
|
|
107
|
+
if (publicOrigin === canonical) return true;
|
|
108
|
+
return (config.corsAllowOrigins ?? []).some(value => canonicalGuiBrowserOrigin(value) === canonical);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pruneExpired(state: GuiSessionState, now: number): void {
|
|
112
|
+
for (const [token, session] of state.sessions) {
|
|
113
|
+
if (session.expiresAt <= now) state.sessions.delete(token);
|
|
114
|
+
}
|
|
115
|
+
for (const [digest, grant] of state.pairingGrants) {
|
|
116
|
+
if (grant.expiresAt <= now) state.pairingGrants.delete(digest);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function evictOldestSession(state: GuiSessionState): void {
|
|
121
|
+
while (state.sessions.size >= GUI_SESSION_LIMIT) {
|
|
122
|
+
const oldest = state.sessions.keys().next().value as string | undefined;
|
|
123
|
+
if (!oldest) return;
|
|
124
|
+
state.sessions.delete(oldest);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mintSession(
|
|
129
|
+
serverOrigin: string,
|
|
130
|
+
browserOrigin: string,
|
|
131
|
+
issuance: GuiSessionIssuance,
|
|
132
|
+
state: GuiSessionState,
|
|
133
|
+
now: number,
|
|
134
|
+
): GuiSessionBootstrap {
|
|
135
|
+
pruneExpired(state, now);
|
|
136
|
+
evictOldestSession(state);
|
|
137
|
+
let token: string;
|
|
138
|
+
do {
|
|
139
|
+
token = `ocx_session_${randomBytes(32).toString("base64url")}`;
|
|
140
|
+
} while (state.sessions.has(token));
|
|
141
|
+
const session: GuiSessionRecord = {
|
|
142
|
+
serverOrigin,
|
|
143
|
+
browserOrigin,
|
|
144
|
+
csrfToken: randomBytes(32).toString("base64url"),
|
|
145
|
+
expiresAt: now + (issuance === "loopback" ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS),
|
|
146
|
+
issuance,
|
|
147
|
+
};
|
|
148
|
+
state.sessions.set(token, session);
|
|
149
|
+
return {
|
|
150
|
+
token,
|
|
151
|
+
serverOrigin: session.serverOrigin,
|
|
152
|
+
browserOrigin: session.browserOrigin,
|
|
153
|
+
csrfToken: session.csrfToken,
|
|
154
|
+
issuance: session.issuance,
|
|
155
|
+
get expiresAt() { return session.expiresAt; },
|
|
156
|
+
set expiresAt(value) { session.expiresAt = value; },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function tailscaleLoginAllowed(req: Request, config: OcxConfig): boolean {
|
|
161
|
+
const login = req.headers.get("Tailscale-User-Login");
|
|
162
|
+
if (!login) return false;
|
|
163
|
+
return (config.remoteGui?.allowedTailscaleUsers ?? []).some(user => user === login);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function issueGuiSession(
|
|
167
|
+
req: Request,
|
|
168
|
+
config: OcxConfig,
|
|
169
|
+
state: GuiSessionState,
|
|
170
|
+
context: GuiSessionRequestContext = { trustedTailscaleIngress: false },
|
|
171
|
+
): GuiSessionBootstrap | null {
|
|
172
|
+
if (req.method !== "GET") return null;
|
|
173
|
+
const host = parseHttpHost(req.headers.get("Host"));
|
|
174
|
+
if (!host) return null;
|
|
175
|
+
const now = context.now ?? Date.now();
|
|
176
|
+
|
|
177
|
+
if (!isApiAuthRequired(config)) {
|
|
178
|
+
if (!isLoopbackHostname(host.hostname) || !isAllowedManagementOrigin(req, config)) return null;
|
|
179
|
+
const origin = managementRequestOrigin(req, config);
|
|
180
|
+
return origin ? mintSession(origin, origin, "loopback", state, now) : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (
|
|
184
|
+
config.runtimeRole !== "hub"
|
|
185
|
+
|| !context.trustedTailscaleIngress
|
|
186
|
+
|| !tailscaleLoginAllowed(req, config)
|
|
187
|
+
|| !isAllowedManagementOrigin(req, config)
|
|
188
|
+
) return null;
|
|
189
|
+
const serverOrigin = managementRequestOrigin(req, config);
|
|
190
|
+
if (!serverOrigin || new URL(serverOrigin).protocol !== "https:") return null;
|
|
191
|
+
const browserOrigin = canonicalGuiBrowserOrigin(req.headers.get("Origin") ?? serverOrigin);
|
|
192
|
+
if (!browserOrigin || !isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null;
|
|
193
|
+
return mintSession(serverOrigin, browserOrigin, "tailscale-identity", state, now);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pairingGrantDigest(grant: string): string {
|
|
197
|
+
return createHash("sha256").update(grant).digest("base64url");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pairingSourceKey(context: PairingAttemptContext): string {
|
|
201
|
+
const identity = context.ingress === "hub-management" && context.tailscaleUser
|
|
202
|
+
? `tailscale:${context.tailscaleUser}`
|
|
203
|
+
: context.peerAddress
|
|
204
|
+
? `peer:${context.peerAddress}`
|
|
205
|
+
: "anonymous";
|
|
206
|
+
return createHash("sha256").update(identity).digest("base64url");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function recordSourceFailure(
|
|
210
|
+
state: GuiSessionState,
|
|
211
|
+
context: PairingAttemptContext,
|
|
212
|
+
now: number,
|
|
213
|
+
): PairingAttemptResult {
|
|
214
|
+
let attempts = pairingSourceAttempts.get(state);
|
|
215
|
+
if (!attempts) {
|
|
216
|
+
attempts = new Map();
|
|
217
|
+
pairingSourceAttempts.set(state, attempts);
|
|
218
|
+
}
|
|
219
|
+
for (const [key, record] of attempts) {
|
|
220
|
+
if (record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS <= now) attempts.delete(key);
|
|
221
|
+
}
|
|
222
|
+
const key = pairingSourceKey(context);
|
|
223
|
+
let record = attempts.get(key);
|
|
224
|
+
if (!record) {
|
|
225
|
+
if (attempts.size >= PAIRING_SOURCE_LIMIT) {
|
|
226
|
+
return { allowed: false, retryAfterSeconds: 1, reason: "capacity" };
|
|
227
|
+
}
|
|
228
|
+
record = { failures: 0, windowStartedAt: now };
|
|
229
|
+
attempts.set(key, record);
|
|
230
|
+
}
|
|
231
|
+
record.failures += 1;
|
|
232
|
+
if (record.failures < PAIRING_SOURCE_FAILURE_LIMIT) return { allowed: true };
|
|
233
|
+
const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now);
|
|
234
|
+
return { allowed: false, retryAfterSeconds: Math.ceil(remaining / 1000), reason: "source" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function findPairingGrant(
|
|
238
|
+
grant: string,
|
|
239
|
+
state: GuiSessionState,
|
|
240
|
+
): [string, GuiPairingGrantRecord] | null {
|
|
241
|
+
const digest = pairingGrantDigest(grant);
|
|
242
|
+
for (const [candidate, record] of state.pairingGrants) {
|
|
243
|
+
if (equalSecret(candidate, digest)) return [candidate, record];
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function consumeGrantRateSlot(state: GuiSessionState, now: number): void {
|
|
249
|
+
const recent = (pairingGrantCreations.get(state) ?? [])
|
|
250
|
+
.filter(createdAt => createdAt > now - GUI_PAIRING_GRANT_RATE_WINDOW_MS);
|
|
251
|
+
if (recent.length >= GUI_PAIRING_GRANT_RATE_LIMIT) throw new GuiPairingGrantRateLimitError();
|
|
252
|
+
recent.push(now);
|
|
253
|
+
pairingGrantCreations.set(state, recent);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createGuiPairingGrant(
|
|
257
|
+
browserOrigin: string,
|
|
258
|
+
config: OcxConfig,
|
|
259
|
+
state: GuiSessionState,
|
|
260
|
+
now = Date.now(),
|
|
261
|
+
): { grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number } {
|
|
262
|
+
const canonicalBrowserOrigin = canonicalGuiBrowserOrigin(browserOrigin);
|
|
263
|
+
const serverOrigin = canonicalHttpOrigin(config.hub?.managementPublicOrigin);
|
|
264
|
+
if (
|
|
265
|
+
config.runtimeRole !== "hub"
|
|
266
|
+
|| !canonicalBrowserOrigin
|
|
267
|
+
|| canonicalBrowserOrigin !== browserOrigin
|
|
268
|
+
|| !serverOrigin
|
|
269
|
+
|| !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config)
|
|
270
|
+
) throw new TypeError("remote GUI origin is not allowed");
|
|
271
|
+
pruneExpired(state, now);
|
|
272
|
+
consumeGrantRateSlot(state, now);
|
|
273
|
+
if (state.pairingGrants.size >= GUI_PAIRING_GRANT_LIMIT) throw new GuiPairingGrantRateLimitError();
|
|
274
|
+
let grant: string;
|
|
275
|
+
let digest: string;
|
|
276
|
+
do {
|
|
277
|
+
grant = `ocx_pair_${randomBytes(32).toString("base64url")}`;
|
|
278
|
+
digest = pairingGrantDigest(grant);
|
|
279
|
+
} while (state.pairingGrants.has(digest));
|
|
280
|
+
const expiresAt = now + GUI_PAIRING_GRANT_TTL_MS;
|
|
281
|
+
state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt });
|
|
282
|
+
return { grant, browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function strictPairingGrantBody(body: unknown): string | null {
|
|
286
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
287
|
+
const record = body as Record<string, unknown>;
|
|
288
|
+
if (Object.keys(record).length !== 1 || typeof record.grant !== "string") return null;
|
|
289
|
+
return /^ocx_pair_[A-Za-z0-9_-]{43}$/.test(record.grant) ? record.grant : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hasAlternateCredential(req: Request): boolean {
|
|
293
|
+
return req.headers.has("authorization")
|
|
294
|
+
|| req.headers.has("x-opencodex-api-key")
|
|
295
|
+
|| req.headers.has("x-api-key");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function consumeGuiPairingGrant(
|
|
299
|
+
req: Request,
|
|
300
|
+
body: unknown,
|
|
301
|
+
config: OcxConfig,
|
|
302
|
+
state: GuiSessionState,
|
|
303
|
+
now?: number,
|
|
304
|
+
): GuiSessionBootstrap | null;
|
|
305
|
+
export function consumeGuiPairingGrant(
|
|
306
|
+
req: Request,
|
|
307
|
+
body: unknown,
|
|
308
|
+
config: OcxConfig,
|
|
309
|
+
state: GuiSessionState,
|
|
310
|
+
now: number,
|
|
311
|
+
attemptContext: PairingAttemptContext,
|
|
312
|
+
): GuiSessionBootstrap | PairingAttemptRefusal | null;
|
|
313
|
+
export function consumeGuiPairingGrant(
|
|
314
|
+
req: Request,
|
|
315
|
+
body: unknown,
|
|
316
|
+
config: OcxConfig,
|
|
317
|
+
state: GuiSessionState,
|
|
318
|
+
now = Date.now(),
|
|
319
|
+
attemptContext?: PairingAttemptContext,
|
|
320
|
+
): GuiSessionBootstrap | PairingAttemptRefusal | null {
|
|
321
|
+
if (req.method !== "POST" || hasAlternateCredential(req) || config.runtimeRole !== "hub") return null;
|
|
322
|
+
// Scheme check FIRST, before the grant is parsed or looked up.
|
|
323
|
+
//
|
|
324
|
+
// A grant is single-use, so consuming one and then refusing to mint would burn the
|
|
325
|
+
// operator's code on a request that was never going to succeed — an unauthenticated
|
|
326
|
+
// caller could strip TLS termination and spend every code the operator prints. Refusing
|
|
327
|
+
// here leaves the grant intact for a later request over a scheme that can carry it.
|
|
328
|
+
//
|
|
329
|
+
// There is no opt-in for plaintext. An earlier revision allowed non-loopback HTTP when
|
|
330
|
+
// `remoteGui.allowInsecureHttp` was true; a reusable grant on plaintext HTTP is readable
|
|
331
|
+
// by anything on the path and the session it mints is reusable, so the flag recorded a
|
|
332
|
+
// risk the operator could not bound rather than controlling one.
|
|
333
|
+
const destination = managementRequestOrigin(req, config);
|
|
334
|
+
if (!destination || !isPairingTransportPermitted(destination)) return null;
|
|
335
|
+
const grant = strictPairingGrantBody(body);
|
|
336
|
+
const browserOrigin = canonicalGuiBrowserOrigin(req.headers.get("Origin"));
|
|
337
|
+
if (!grant || !browserOrigin) return null;
|
|
338
|
+
const context = attemptContext ?? {
|
|
339
|
+
ingress: "public",
|
|
340
|
+
peerAddress: null,
|
|
341
|
+
tailscaleUser: null,
|
|
342
|
+
browserOrigin,
|
|
343
|
+
};
|
|
344
|
+
const sourceRecord = attemptContext
|
|
345
|
+
? pairingSourceAttempts.get(state)?.get(pairingSourceKey(context))
|
|
346
|
+
: undefined;
|
|
347
|
+
if (sourceRecord && sourceRecord.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now
|
|
348
|
+
&& sourceRecord.failures >= PAIRING_SOURCE_FAILURE_LIMIT) {
|
|
349
|
+
return {
|
|
350
|
+
allowed: false,
|
|
351
|
+
retryAfterSeconds: Math.max(1, Math.ceil((sourceRecord.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now) / 1000)),
|
|
352
|
+
reason: "source",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const found = findPairingGrant(grant, state);
|
|
356
|
+
if (!found) {
|
|
357
|
+
const source = recordSourceFailure(state, context, now);
|
|
358
|
+
return attemptContext && !source.allowed ? source : null;
|
|
359
|
+
}
|
|
360
|
+
const [digest, record] = found;
|
|
361
|
+
if (record.expiresAt <= now) {
|
|
362
|
+
state.pairingGrants.delete(digest);
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
if (browserOrigin !== record.browserOrigin) {
|
|
366
|
+
record.failedAttempts = (record.failedAttempts ?? 0) + 1;
|
|
367
|
+
const source = recordSourceFailure(state, context, now);
|
|
368
|
+
if (record.failedAttempts >= PAIRING_GRANT_FAILURE_LIMIT) {
|
|
369
|
+
state.pairingGrants.delete(digest);
|
|
370
|
+
return attemptContext ? { allowed: false, retryAfterSeconds: 1, reason: "grant" } : null;
|
|
371
|
+
}
|
|
372
|
+
return attemptContext && !source.allowed ? source : null;
|
|
373
|
+
}
|
|
374
|
+
const serverOrigin = managementRequestOrigin(req, config);
|
|
375
|
+
if (serverOrigin !== record.serverOrigin) return null;
|
|
376
|
+
// Re-checked against the grant's own recorded origin rather than only the request's:
|
|
377
|
+
// the two are compared just above, but this keeps the transport rule true of the value
|
|
378
|
+
// the session is actually minted from.
|
|
379
|
+
if (!isPairingTransportPermitted(record.serverOrigin)) return null;
|
|
380
|
+
state.pairingGrants.delete(digest);
|
|
381
|
+
return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A pairing grant may cross loopback or authenticated HTTPS, and nothing else.
|
|
386
|
+
*
|
|
387
|
+
* Loopback plaintext is admissible because the bytes never leave the machine. Non-loopback
|
|
388
|
+
* plaintext is not, and no configuration re-opens it.
|
|
389
|
+
*/
|
|
390
|
+
function isPairingTransportPermitted(origin: string): boolean {
|
|
391
|
+
let url: URL;
|
|
392
|
+
try {
|
|
393
|
+
url = new URL(origin);
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
if (url.protocol === "https:") return true;
|
|
398
|
+
return url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function requestCredential(req: Request): string | null {
|
|
402
|
+
return req.headers.get("x-opencodex-api-key")?.trim()
|
|
403
|
+
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
|
|
404
|
+
|| null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function findSession(
|
|
408
|
+
credential: string,
|
|
409
|
+
state: GuiSessionState,
|
|
410
|
+
): [string, GuiSessionRecord] | null {
|
|
411
|
+
for (const [token, session] of state.sessions) {
|
|
412
|
+
if (equalSecret(credential, token)) return [token, session];
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function authorizeGuiSessionRequest(
|
|
418
|
+
req: Request,
|
|
419
|
+
config: OcxConfig,
|
|
420
|
+
state: GuiSessionState,
|
|
421
|
+
now = Date.now(),
|
|
422
|
+
): GuiSessionAdmission {
|
|
423
|
+
const credential = requestCredential(req);
|
|
424
|
+
if (!credential) return { ok: false, reason: "missing" };
|
|
425
|
+
const found = findSession(credential, state);
|
|
426
|
+
if (!found) return { ok: false, reason: "missing" };
|
|
427
|
+
const [token, session] = found;
|
|
428
|
+
if (session.expiresAt <= now) {
|
|
429
|
+
state.sessions.delete(token);
|
|
430
|
+
return { ok: false, reason: "expired" };
|
|
431
|
+
}
|
|
432
|
+
if (managementRequestOrigin(req, config) !== session.serverOrigin) {
|
|
433
|
+
return { ok: false, reason: "server-origin" };
|
|
434
|
+
}
|
|
435
|
+
const claimedBrowserOrigin = req.headers.get("x-opencodex-gui-origin");
|
|
436
|
+
const browserOrigin = req.headers.get("Origin");
|
|
437
|
+
const safeMethod = req.method === "GET" || req.method === "HEAD";
|
|
438
|
+
if (
|
|
439
|
+
claimedBrowserOrigin !== session.browserOrigin
|
|
440
|
+
|| (browserOrigin !== null && browserOrigin !== session.browserOrigin)
|
|
441
|
+
|| (!safeMethod && browserOrigin !== session.browserOrigin)
|
|
442
|
+
) return { ok: false, reason: "browser-origin" };
|
|
443
|
+
if (!safeMethod) {
|
|
444
|
+
const csrf = req.headers.get("x-opencodex-csrf-token")?.trim();
|
|
445
|
+
if (!csrf || !equalSecret(csrf, session.csrfToken)) return { ok: false, reason: "csrf" };
|
|
446
|
+
}
|
|
447
|
+
if (session.issuance !== "loopback") session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS;
|
|
448
|
+
return { ok: true, principal: "gui-session", session };
|
|
449
|
+
}
|
package/src/server/gui-static.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { browserSecurityHeaders } from "./auth-cors";
|
|
4
|
-
import type { GuiSessionBootstrap } from "./
|
|
4
|
+
import type { GuiSessionBootstrap } from "./gui-session";
|
|
5
5
|
|
|
6
6
|
/** opencodex version, read from the packaged package.json (same source as the server bootstrap). */
|
|
7
7
|
const VERSION = (() => {
|
|
@@ -70,10 +70,26 @@ function sessionBootstrapMeta(session: GuiSessionBootstrap): string {
|
|
|
70
70
|
return [
|
|
71
71
|
`<meta name="opencodex-session-token" content="${escapeHtmlAttribute(session.token)}">`,
|
|
72
72
|
`<meta name="opencodex-session-csrf" content="${escapeHtmlAttribute(session.csrfToken)}">`,
|
|
73
|
-
`<meta name="opencodex-session-origin" content="${escapeHtmlAttribute(session.
|
|
73
|
+
`<meta name="opencodex-session-origin" content="${escapeHtmlAttribute(session.browserOrigin)}">`,
|
|
74
|
+
`<meta name="opencodex-session-server-origin" content="${escapeHtmlAttribute(session.serverOrigin)}">`,
|
|
74
75
|
].join("");
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Runtime role, emitted on every served document.
|
|
80
|
+
*
|
|
81
|
+
* Separate from the session block on purpose: the session exists only once a GUI session
|
|
82
|
+
* has been issued, but the role has to be known on the very first paint of a plain
|
|
83
|
+
* standalone install — which never issues one. Without it the GUI has to ASK, and asking
|
|
84
|
+
* means a request to a remote-hub endpoint from a user who never enabled remote hub.
|
|
85
|
+
*
|
|
86
|
+
* Non-secret: it names which topology this proxy is running, which the operator configured
|
|
87
|
+
* and which the dashboard already reflects everywhere else.
|
|
88
|
+
*/
|
|
89
|
+
function runtimeRoleMeta(runtimeRole: string): string {
|
|
90
|
+
return `<meta name="opencodex-runtime-role" content="${escapeHtmlAttribute(runtimeRole)}">`;
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
function htmlDocumentResponse(html: string): Response {
|
|
78
94
|
return new Response(html, {
|
|
79
95
|
headers: {
|
|
@@ -85,10 +101,10 @@ function htmlDocumentResponse(html: string): Response {
|
|
|
85
101
|
});
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
function htmlResponse(path: string, session?: GuiSessionBootstrap): Response {
|
|
104
|
+
function htmlResponse(path: string, session?: GuiSessionBootstrap, runtimeRole?: string): Response {
|
|
89
105
|
let html = readFileSync(path, "utf8");
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
const bootstrap = `${runtimeRole ? runtimeRoleMeta(runtimeRole) : ""}${session ? sessionBootstrapMeta(session) : ""}`;
|
|
107
|
+
if (bootstrap) {
|
|
92
108
|
html = html.includes("</head>") ? html.replace("</head>", `${bootstrap}</head>`) : `${bootstrap}${html}`;
|
|
93
109
|
}
|
|
94
110
|
return htmlDocumentResponse(html);
|
|
@@ -109,6 +125,7 @@ export function serveGuiFile(
|
|
|
109
125
|
pathname: string,
|
|
110
126
|
guiDist = findGuiDist(),
|
|
111
127
|
session?: GuiSessionBootstrap,
|
|
128
|
+
runtimeRole?: string,
|
|
112
129
|
): Response | null {
|
|
113
130
|
if (!guiDist) return null;
|
|
114
131
|
const filePath = resolveGuiFilePath(guiDist, pathname);
|
|
@@ -118,7 +135,7 @@ export function serveGuiFile(
|
|
|
118
135
|
if (!extname(pathname)) {
|
|
119
136
|
const indexPath = join(guiDist, "index.html");
|
|
120
137
|
if (isFile(indexPath)) {
|
|
121
|
-
return htmlResponse(indexPath, session);
|
|
138
|
+
return htmlResponse(indexPath, session, runtimeRole);
|
|
122
139
|
}
|
|
123
140
|
}
|
|
124
141
|
return null;
|
|
@@ -126,7 +143,7 @@ export function serveGuiFile(
|
|
|
126
143
|
|
|
127
144
|
const ext = extname(filePath);
|
|
128
145
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
129
|
-
if (ext === ".html") return htmlResponse(filePath, session);
|
|
146
|
+
if (ext === ".html") return htmlResponse(filePath, session, runtimeRole);
|
|
130
147
|
// Snapshot bytes before returning the response. Bun.file is lazy: if gui/dist is replaced
|
|
131
148
|
// after Bun frames the response but before the stream finishes, its Content-Length can
|
|
132
149
|
// describe the old file while the body comes from the new one (#2792).
|