@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.
Files changed (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. 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
+ }
@@ -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 "./management-auth";
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.origin)}">`,
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
- if (session) {
91
- const bootstrap = sessionBootstrapMeta(session);
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).