@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,288 @@
1
+ import { stripMachineAuthHeaders } from "./machine-auth";
2
+
3
+ export interface HubRelayTarget {
4
+ managementUrl: string;
5
+ browserOrigin: string;
6
+ }
7
+
8
+ export const HUB_RELAY_REQUEST_BODY_MAX_BYTES = 4 * 1024 * 1024;
9
+ export const HUB_RELAY_RESPONSE_BODY_MAX_BYTES = 16 * 1024 * 1024;
10
+ export const HUB_RELAY_DEFAULT_TIMEOUT_MS = 15_000;
11
+ export const HUB_RELAY_HEADER_MAX_BYTES = 64 * 1024;
12
+
13
+ const ALLOWED_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
14
+ const REQUEST_HEADERS = new Set([
15
+ "accept",
16
+ "accept-language",
17
+ "cache-control",
18
+ "content-type",
19
+ "if-match",
20
+ "if-modified-since",
21
+ "if-none-match",
22
+ "if-unmodified-since",
23
+ "origin",
24
+ "x-opencodex-api-key",
25
+ "x-opencodex-csrf-token",
26
+ "x-opencodex-gui-origin",
27
+ ]);
28
+ const RESPONSE_HEADERS = new Set([
29
+ "cache-control",
30
+ "content-language",
31
+ "content-type",
32
+ "etag",
33
+ "expires",
34
+ "last-modified",
35
+ "pragma",
36
+ "retry-after",
37
+ "vary",
38
+ ]);
39
+ const HOP_BY_HOP_HEADERS = new Set([
40
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
41
+ "te", "trailer", "transfer-encoding", "upgrade",
42
+ ]);
43
+
44
+ export type HubRelayRawHeaderValidation =
45
+ | { ok: true; connectionNamed: Set<string> }
46
+ | { ok: false; reason: "smuggling" | "invalid" };
47
+
48
+ export function validateHubRelayRequestHeaders(raw: readonly (readonly [string, string])[]): HubRelayRawHeaderValidation {
49
+ let contentLengths = 0;
50
+ let transferEncoding = false;
51
+ const connectionNamed = new Set<string>();
52
+ for (const [rawName, value] of raw) {
53
+ const name = rawName.trim().toLowerCase();
54
+ if (!name || /[\r\n]/.test(rawName) || /[\r\n]/.test(value)) return { ok: false, reason: "invalid" };
55
+ if (name === "content-length") {
56
+ contentLengths += 1;
57
+ if (!/^\d+$/.test(value.trim())) return { ok: false, reason: "smuggling" };
58
+ }
59
+ if (name === "transfer-encoding") transferEncoding = true;
60
+ if (name === "upgrade") return { ok: false, reason: "smuggling" };
61
+ if (name === "connection") {
62
+ for (const token of value.split(",")) {
63
+ const normalized = token.trim().toLowerCase();
64
+ if (normalized) connectionNamed.add(normalized);
65
+ }
66
+ }
67
+ }
68
+ if (transferEncoding || contentLengths > 1) return { ok: false, reason: "smuggling" };
69
+ return { ok: true, connectionNamed };
70
+ }
71
+
72
+ function relayError(status: number, error: string): Response {
73
+ return Response.json({ error }, { status });
74
+ }
75
+
76
+ function canonicalOrigin(value: string): string | null {
77
+ try {
78
+ const url = new URL(value);
79
+ if ((url.protocol !== "http:" && url.protocol !== "https:")
80
+ || url.username || url.password || url.pathname !== "/" || url.search || url.hash) return null;
81
+ return url.origin;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function relayDestination(suffix: string, target: HubRelayTarget, method: string): URL | null {
88
+ const origin = canonicalOrigin(target.managementUrl);
89
+ const browserOrigin = canonicalOrigin(target.browserOrigin);
90
+ if (!origin || !browserOrigin || !ALLOWED_METHODS.has(method)) return null;
91
+ if (!suffix.startsWith("/") || suffix.startsWith("//") || suffix.includes("\\") || suffix.includes("#")) return null;
92
+ if (/%(?:2f|5c)/i.test(suffix) || /%(?:2e)(?:%2e|\.)?/i.test(suffix)) return null;
93
+ const rawPath = suffix.split("?", 1)[0]!;
94
+ for (const segment of rawPath.split("/")) {
95
+ let decoded: string;
96
+ try { decoded = decodeURIComponent(segment); } catch { return null; }
97
+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) return null;
98
+ }
99
+ if (rawPath === "/opencodex-session") {
100
+ if (suffix !== rawPath || (method !== "GET" && method !== "POST")) return null;
101
+ } else if (!rawPath.startsWith("/api/")) {
102
+ return null;
103
+ }
104
+ let destination: URL;
105
+ try { destination = new URL(suffix, `${origin}/`); } catch { return null; }
106
+ if (destination.origin !== origin || destination.username || destination.password || destination.hash) return null;
107
+ if (destination.pathname !== rawPath) return null;
108
+ return destination;
109
+ }
110
+
111
+ async function boundedBody(
112
+ stream: ReadableStream<Uint8Array> | null,
113
+ declared: string | null,
114
+ limit: number,
115
+ ): Promise<Uint8Array<ArrayBuffer> | null> {
116
+ if (!stream) return null;
117
+ const contentLength = declared === null ? null : Number(declared);
118
+ if (contentLength !== null && (!Number.isSafeInteger(contentLength) || contentLength < 0 || contentLength > limit)) {
119
+ throw new RangeError("body_too_large");
120
+ }
121
+ const reader = stream.getReader();
122
+ const chunks: Uint8Array<ArrayBufferLike>[] = [];
123
+ let length = 0;
124
+ try {
125
+ while (true) {
126
+ const next = await reader.read();
127
+ if (next.done) break;
128
+ length += next.value.byteLength;
129
+ if (length > limit) throw new RangeError("body_too_large");
130
+ chunks.push(next.value);
131
+ }
132
+ } finally {
133
+ reader.releaseLock();
134
+ }
135
+ // BodyInit requires an ArrayBuffer-backed view, not a SharedArrayBuffer-capable view.
136
+ const body: Uint8Array<ArrayBuffer> = new Uint8Array(new ArrayBuffer(length));
137
+ let offset = 0;
138
+ for (const chunk of chunks) {
139
+ body.set(chunk, offset);
140
+ offset += chunk.byteLength;
141
+ }
142
+ return body;
143
+ }
144
+
145
+ function filteredHeaders(source: Headers, allowlist: Set<string>, omitted: ReadonlySet<string> = new Set()): Headers {
146
+ const headers = new Headers();
147
+ for (const [name, value] of source) {
148
+ const normalized = name.toLowerCase();
149
+ if (allowlist.has(normalized) && !HOP_BY_HOP_HEADERS.has(normalized) && !omitted.has(normalized)) headers.append(name, value);
150
+ }
151
+ return headers;
152
+ }
153
+
154
+ function headersWithinLimit(headers: Headers): boolean {
155
+ let bytes = 0;
156
+ for (const [name, value] of headers) {
157
+ bytes += name.length + value.length + 4;
158
+ if (bytes > HUB_RELAY_HEADER_MAX_BYTES) return false;
159
+ }
160
+ return true;
161
+ }
162
+
163
+ function boundedRelayResponseStream(
164
+ body: ReadableStream<Uint8Array>,
165
+ limit: number,
166
+ signal: AbortSignal,
167
+ ): ReadableStream<Uint8Array> {
168
+ const reader = body.getReader();
169
+ let bytes = 0;
170
+ let finished = false;
171
+ const finish = () => {
172
+ if (finished) return;
173
+ finished = true;
174
+ signal.removeEventListener("abort", onAbort);
175
+ try { reader.releaseLock(); } catch { /* a pending read may still own it */ }
176
+ };
177
+ const onAbort = () => {
178
+ if (finished) return;
179
+ try { void reader.cancel(signal.reason).catch(() => undefined).finally(finish); }
180
+ catch { finish(); }
181
+ };
182
+ signal.addEventListener("abort", onAbort, { once: true });
183
+ if (signal.aborted) onAbort();
184
+ return new ReadableStream<Uint8Array>({
185
+ async pull(controller) {
186
+ try {
187
+ const next = await reader.read();
188
+ if (next.done) {
189
+ finish();
190
+ controller.close();
191
+ return;
192
+ }
193
+ bytes += next.value.byteLength;
194
+ if (bytes > limit) {
195
+ try { await reader.cancel(new RangeError("hub relay response body too large")); } catch { /* best effort */ }
196
+ finish();
197
+ controller.error(new RangeError("hub relay response body too large"));
198
+ return;
199
+ }
200
+ controller.enqueue(next.value);
201
+ } catch (error) {
202
+ finish();
203
+ controller.error(error);
204
+ }
205
+ },
206
+ async cancel(reason) {
207
+ try { await reader.cancel(reason); } catch { /* best effort */ }
208
+ finish();
209
+ },
210
+ });
211
+ }
212
+
213
+ export async function relayHubManagementRequest(
214
+ req: Request,
215
+ suffix: string,
216
+ target: HubRelayTarget,
217
+ deps: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
218
+ ): Promise<Response> {
219
+ const method = req.method.toUpperCase();
220
+ const destination = relayDestination(suffix, target, method);
221
+ if (!destination) return relayError(404, "hub relay path refused");
222
+ const requestHeaderValidation = validateHubRelayRequestHeaders([...req.headers]);
223
+ if (!requestHeaderValidation.ok) return relayError(400, "hub relay request headers refused");
224
+
225
+ let body: Uint8Array<ArrayBuffer> | null;
226
+ try {
227
+ body = method === "GET" || method === "HEAD"
228
+ ? null
229
+ : await boundedBody(req.body, req.headers.get("content-length"), HUB_RELAY_REQUEST_BODY_MAX_BYTES);
230
+ } catch {
231
+ return relayError(413, "hub relay request body too large");
232
+ }
233
+
234
+ const stripped = stripMachineAuthHeaders(req.headers);
235
+ const headers = filteredHeaders(stripped, REQUEST_HEADERS, requestHeaderValidation.connectionNamed);
236
+ if (!headersWithinLimit(headers)) return relayError(431, "hub relay request headers too large");
237
+ const browserOrigin = canonicalOrigin(target.browserOrigin);
238
+ const mutation = method !== "GET" && method !== "HEAD";
239
+ const suppliedOrigin = headers.get("origin");
240
+ if (!browserOrigin || (mutation ? suppliedOrigin !== browserOrigin : suppliedOrigin !== null && suppliedOrigin !== browserOrigin)) {
241
+ return relayError(403, "hub relay browser origin refused");
242
+ }
243
+
244
+ const timeoutMs = typeof deps.timeoutMs === "number" && Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0
245
+ ? Math.min(Math.floor(deps.timeoutMs), 120_000)
246
+ : HUB_RELAY_DEFAULT_TIMEOUT_MS;
247
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
248
+ const signal = req.signal
249
+ ? AbortSignal.any([req.signal, timeoutSignal])
250
+ : timeoutSignal;
251
+ let upstream: Response;
252
+ try {
253
+ upstream = await (deps.fetchImpl ?? fetch)(destination, {
254
+ method,
255
+ headers,
256
+ ...(body ? { body } : {}),
257
+ redirect: "manual",
258
+ signal,
259
+ });
260
+ } catch {
261
+ return relayError(502, "hub relay unavailable");
262
+ }
263
+ if (upstream.status >= 300 && upstream.status < 400) {
264
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
265
+ return relayError(502, "hub relay redirect refused");
266
+ }
267
+
268
+ const responseConnectionNamed = new Set((upstream.headers.get("connection") ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
269
+ const responseHeaders = filteredHeaders(upstream.headers, RESPONSE_HEADERS, responseConnectionNamed);
270
+ if (!headersWithinLimit(responseHeaders)) {
271
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
272
+ return relayError(502, "hub relay response headers too large");
273
+ }
274
+ const declaredResponseLength = upstream.headers.get("content-length");
275
+ if (declaredResponseLength !== null && (!/^\d+$/.test(declaredResponseLength)
276
+ || Number(declaredResponseLength) > HUB_RELAY_RESPONSE_BODY_MAX_BYTES)) {
277
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
278
+ return relayError(502, "hub relay response body too large");
279
+ }
280
+ const responseBody = method === "HEAD" || !upstream.body
281
+ ? null
282
+ : boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, signal);
283
+ return new Response(responseBody, {
284
+ status: upstream.status,
285
+ statusText: upstream.statusText,
286
+ headers: responseHeaders,
287
+ });
288
+ }
@@ -0,0 +1,139 @@
1
+ import { journalOwner } from "../codex/journal";
2
+ import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../codex/shim";
3
+ import { readManagementJsonBody } from "../server/management/body";
4
+ import type { OcxClientConnectionConfig } from "../types";
5
+ import { disconnectClient, syncConnectedClient } from "./connect";
6
+
7
+ export type HubReachability = "unknown" | "online" | "offline" | "unauthorized";
8
+
9
+ export interface MachineStatusV1 {
10
+ mode: "client";
11
+ connected: true;
12
+ machineBase: string;
13
+ sharedBase: string;
14
+ sharedServerOrigin: string;
15
+ managementTransport: "direct" | "relay";
16
+ apiKeyId: string;
17
+ protocolVersion: 1;
18
+ connectedAt: string;
19
+ catalogSyncedAt?: string;
20
+ hubReachability: HubReachability;
21
+ }
22
+
23
+ export interface MachineApiDeps {
24
+ sync: typeof syncConnectedClient;
25
+ disconnect: typeof disconnectClient;
26
+ scheduleStandaloneRecycle: () => void;
27
+ hubReachability?: () => HubReachability;
28
+ setHubReachability?: (value: HubReachability) => void;
29
+ }
30
+
31
+ const defaultDeps: MachineApiDeps = {
32
+ sync: syncConnectedClient,
33
+ disconnect: disconnectClient,
34
+ scheduleStandaloneRecycle: () => {},
35
+ };
36
+
37
+ function strictObject(value: unknown, allowed: readonly string[]): Record<string, unknown> | null {
38
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
39
+ const record = value as Record<string, unknown>;
40
+ return Object.keys(record).every(key => allowed.includes(key)) ? record : null;
41
+ }
42
+
43
+ async function jsonBody(req: Request): Promise<unknown | Response> {
44
+ try {
45
+ return await readManagementJsonBody(req);
46
+ } catch {
47
+ return Response.json({ error: "invalid JSON body" }, { status: 400 });
48
+ }
49
+ }
50
+
51
+ function statusPayload(req: Request, state: OcxClientConnectionConfig, deps: MachineApiDeps): MachineStatusV1 {
52
+ const machineBase = new URL(req.url).origin;
53
+ return {
54
+ mode: "client",
55
+ connected: true,
56
+ machineBase,
57
+ sharedBase: state.managementTransport === "relay"
58
+ ? `${machineBase}/api/machine/hub-relay`
59
+ : state.managementUrl,
60
+ sharedServerOrigin: state.managementUrl,
61
+ managementTransport: state.managementTransport,
62
+ apiKeyId: state.apiKeyId,
63
+ protocolVersion: state.protocolVersion,
64
+ connectedAt: state.connectedAt,
65
+ ...(state.catalogSyncedAt ? { catalogSyncedAt: state.catalogSyncedAt } : {}),
66
+ hubReachability: deps.hubReachability?.() ?? "unknown",
67
+ };
68
+ }
69
+
70
+ export async function handleMachineApi(
71
+ req: Request,
72
+ url: URL,
73
+ state: OcxClientConnectionConfig,
74
+ injected: MachineApiDeps = defaultDeps,
75
+ ): Promise<Response | null> {
76
+ const deps = { ...defaultDeps, ...injected };
77
+ if (url.pathname === "/api/machine/status" && req.method === "GET") {
78
+ return Response.json(statusPayload(req, state, deps), { headers: { "Cache-Control": "no-store" } });
79
+ }
80
+ if (url.pathname === "/api/machine/clients" && req.method === "GET") {
81
+ return Response.json({
82
+ selectedClients: [...state.selectedClients],
83
+ journalOwner: journalOwner(),
84
+ shim: diagnoseCodexShim(),
85
+ }, { headers: { "Cache-Control": "no-store" } });
86
+ }
87
+ if (url.pathname === "/api/machine/sync" && req.method === "POST") {
88
+ const body = await jsonBody(req);
89
+ if (body instanceof Response) return body;
90
+ const input = strictObject(body, ["restartCodex"]);
91
+ if (!input || (input.restartCodex !== undefined && typeof input.restartCodex !== "boolean")) {
92
+ return Response.json({ error: "invalid sync request" }, { status: 400 });
93
+ }
94
+ try {
95
+ const result = await deps.sync(
96
+ input.restartCodex === undefined ? {} : { restartCodex: input.restartCodex },
97
+ );
98
+ deps.setHubReachability?.("online");
99
+ return Response.json({ success: true, ...result });
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : "client sync failed";
102
+ deps.setHubReachability?.(/unauthor/i.test(message) ? "unauthorized" : "offline");
103
+ return Response.json({ success: false, error: message }, { status: 502 });
104
+ }
105
+ }
106
+ if (url.pathname === "/api/machine/shim" && req.method === "GET") {
107
+ return Response.json(diagnoseCodexShim(), { headers: { "Cache-Control": "no-store" } });
108
+ }
109
+ if (url.pathname === "/api/machine/shim" && req.method === "POST") {
110
+ const body = await jsonBody(req);
111
+ if (body instanceof Response) return body;
112
+ const input = strictObject(body, ["action"]);
113
+ if (!input || (input.action !== "install" && input.action !== "repair" && input.action !== "uninstall")) {
114
+ return Response.json({ error: "action must be install, repair, or uninstall" }, { status: 400 });
115
+ }
116
+ try {
117
+ const result = input.action === "uninstall" ? uninstallCodexShim() : installCodexShim();
118
+ return Response.json({ success: true, action: input.action, result, shim: diagnoseCodexShim() });
119
+ } catch (error) {
120
+ return Response.json({ success: false, error: error instanceof Error ? error.message : "shim action failed" }, { status: 409 });
121
+ }
122
+ }
123
+ if (url.pathname === "/api/machine/disconnect" && req.method === "POST") {
124
+ const body = await jsonBody(req);
125
+ if (body instanceof Response) return body;
126
+ const input = strictObject(body, ["keepCatalog"]);
127
+ if (!input || (input.keepCatalog !== undefined && typeof input.keepCatalog !== "boolean")) {
128
+ return Response.json({ error: "invalid disconnect request" }, { status: 400 });
129
+ }
130
+ try {
131
+ const result = await deps.disconnect(input.keepCatalog === undefined ? {} : { keepCatalog: input.keepCatalog });
132
+ deps.scheduleStandaloneRecycle();
133
+ return Response.json({ success: true, ...result }, { status: 202 });
134
+ } catch (error) {
135
+ return Response.json({ success: false, error: error instanceof Error ? error.message : "disconnect failed" }, { status: 409 });
136
+ }
137
+ }
138
+ return null;
139
+ }
@@ -0,0 +1,54 @@
1
+ import type { OcxConfig } from "../types";
2
+ import {
3
+ managementPrincipal,
4
+ requireManagementAuth,
5
+ type ManagementAuthState,
6
+ } from "../server/management-auth";
7
+
8
+ export const MACHINE_SESSION_HEADER = "x-opencodex-machine-session";
9
+ export const MACHINE_GUI_ORIGIN_HEADER = "x-opencodex-machine-gui-origin";
10
+ export const MACHINE_CSRF_HEADER = "x-opencodex-machine-csrf-token";
11
+
12
+ const MACHINE_AUTH_HEADERS = [
13
+ MACHINE_SESSION_HEADER,
14
+ MACHINE_GUI_ORIGIN_HEADER,
15
+ MACHINE_CSRF_HEADER,
16
+ ] as const;
17
+
18
+ function machinePrincipalRequest(req: Request): Request {
19
+ const headers = new Headers(req.headers);
20
+ const token = headers.get(MACHINE_SESSION_HEADER);
21
+ const browserOrigin = headers.get(MACHINE_GUI_ORIGIN_HEADER);
22
+ const csrf = headers.get(MACHINE_CSRF_HEADER);
23
+ headers.delete("authorization");
24
+ headers.delete("x-api-key");
25
+ headers.delete("x-opencodex-api-key");
26
+ headers.delete("x-opencodex-gui-origin");
27
+ headers.delete("x-opencodex-csrf-token");
28
+ if (token) headers.set("x-opencodex-api-key", token);
29
+ if (browserOrigin) {
30
+ headers.set("x-opencodex-gui-origin", browserOrigin);
31
+ headers.set("Origin", browserOrigin);
32
+ }
33
+ if (csrf) headers.set("x-opencodex-csrf-token", csrf);
34
+ return new Request(req.url, { method: req.method, headers, signal: req.signal });
35
+ }
36
+
37
+ export function requireMachineAuth(
38
+ req: Request,
39
+ state: ManagementAuthState,
40
+ config: OcxConfig,
41
+ ): Response | null {
42
+ const synthetic = machinePrincipalRequest(req);
43
+ const error = requireManagementAuth(synthetic, state, config);
44
+ if (error) return error;
45
+ return managementPrincipal(synthetic, state, config) === "gui-session"
46
+ ? null
47
+ : Response.json({ error: "opencodex machine GUI session required" }, { status: 401 });
48
+ }
49
+
50
+ export function stripMachineAuthHeaders(headers: Headers): Headers {
51
+ const stripped = new Headers(headers);
52
+ for (const name of MACHINE_AUTH_HEADERS) stripped.delete(name);
53
+ return stripped;
54
+ }
@@ -0,0 +1,143 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { Server } from "bun";
3
+ import { loadConfig } from "../config";
4
+ import { browserSecurityHeaders } from "../server/auth-cors";
5
+ import { serveGuiFile, serveSessionBootstrap } from "../server/gui-static";
6
+ import {
7
+ initializeManagementAuthState,
8
+ issueGuiSession,
9
+ managementPrincipal,
10
+ requireManagementAuth,
11
+ type ManagementAuthState,
12
+ } from "../server/management-auth";
13
+ import type { OcxClientConnectionConfig, OcxConfig } from "../types";
14
+ import { disconnectClient, syncConnectedClient } from "./connect";
15
+ import { readClientConnectionState } from "./state";
16
+ import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api";
17
+ import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth";
18
+ import { relayHubManagementRequest } from "./hub-relay";
19
+
20
+ const VERSION = (() => {
21
+ try { return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string; }
22
+ catch { return "0.0.0"; }
23
+ })();
24
+ const GUI_SPA_PATHS = new Set([
25
+ "/dashboard", "/startup", "/providers", "/models", "/subagents",
26
+ "/logs", "/usage", "/storage", "/codex-set", "/integrations",
27
+ ]);
28
+
29
+ export interface MachineListenerDeps {
30
+ state?: OcxClientConnectionConfig;
31
+ managementAuthState?: ManagementAuthState;
32
+ fetchImpl?: typeof fetch;
33
+ machineApi?: Partial<MachineApiDeps>;
34
+ }
35
+
36
+ function json404(req: Request): Response {
37
+ const url = new URL(req.url);
38
+ return Response.json({ error: "not_found", method: req.method, path: url.pathname }, { status: 404 });
39
+ }
40
+
41
+ function machinePolicyConfig(config: OcxConfig): OcxConfig {
42
+ return { ...config, hostname: "127.0.0.1" };
43
+ }
44
+
45
+ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolean): boolean {
46
+ if (req.headers.get("upgrade")) return false;
47
+ const path = url.pathname;
48
+ if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
49
+ if (req.method === "GET" && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
50
+ if (req.method === "POST" && (path === "/api/machine/sync" || path === "/api/machine/shim" || path === "/api/machine/disconnect")) return true;
51
+ if (relayEnabled && path.startsWith("/api/machine/hub-relay/")) return true;
52
+ if (req.method !== "GET" || path.startsWith("/api/") || path.startsWith("/v1/")) return false;
53
+ return GUI_SPA_PATHS.has(path)
54
+ || path.startsWith("/integrations/")
55
+ || /\.(?:css|gif|ico|jpe?g|js|json|map|png|svg|webp|woff2?)$/i.test(path);
56
+ }
57
+
58
+ export function startMachineListener(
59
+ port?: number,
60
+ deps: MachineListenerDeps = {},
61
+ ): Server<unknown> {
62
+ const config = machinePolicyConfig(loadConfig());
63
+ const connection = deps.state ?? (() => {
64
+ const state = readClientConnectionState();
65
+ if (state.kind !== "connected") throw new Error(`machine listener requires connected client state, got ${state.kind}`);
66
+ return state.value;
67
+ })();
68
+ const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
69
+ let hubReachability: HubReachability = "unknown";
70
+ const machineApiDeps: MachineApiDeps = {
71
+ sync: deps.machineApi?.sync ?? syncConnectedClient,
72
+ disconnect: deps.machineApi?.disconnect ?? disconnectClient,
73
+ scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (() => {
74
+ void import("./runtime").then(module => module.scheduleStandaloneRecycle());
75
+ }),
76
+ hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
77
+ setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
78
+ };
79
+ const relayEnabled = connection.managementTransport === "relay";
80
+
81
+ return Bun.serve({
82
+ port: port ?? config.port ?? 10100,
83
+ hostname: "127.0.0.1",
84
+ async fetch(req, server) {
85
+ const url = new URL(req.url);
86
+ if (!machineRouteAllowed(url, req, relayEnabled)) return json404(req);
87
+ if (url.pathname === "/healthz" && req.method === "GET") {
88
+ return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port });
89
+ }
90
+ if (url.pathname === "/readyz" && req.method === "GET") {
91
+ return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 });
92
+ }
93
+ if (url.pathname.startsWith("/api/machine/hub-relay/")) {
94
+ if (!relayEnabled) return json404(req);
95
+ const authError = requireMachineAuth(req, managementAuth, config);
96
+ if (authError) return authError;
97
+ const prefix = "/api/machine/hub-relay";
98
+ const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
99
+ const response = await relayHubManagementRequest(req, suffix, {
100
+ managementUrl: connection.managementUrl,
101
+ browserOrigin: req.headers.get(MACHINE_GUI_ORIGIN_HEADER) ?? req.headers.get("Origin") ?? "",
102
+ }, { fetchImpl: deps.fetchImpl });
103
+ if (response.status === 401) hubReachability = "unauthorized";
104
+ else if (response.status >= 500) hubReachability = "offline";
105
+ else hubReachability = "online";
106
+ return response;
107
+ }
108
+ if (url.pathname.startsWith("/api/machine/")) {
109
+ const authError = requireManagementAuth(req, managementAuth, config);
110
+ if (authError) return authError;
111
+ if (managementPrincipal(req, managementAuth, config) !== "gui-session") {
112
+ return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 });
113
+ }
114
+ return await handleMachineApi(req, url, connection, machineApiDeps) ?? json404(req);
115
+ }
116
+
117
+ const session = (url.pathname === "/" || url.pathname === "/opencodex-session")
118
+ ? issueGuiSession(req, config, managementAuth, { trustedTailscaleIngress: false })
119
+ : null;
120
+ if (url.pathname === "/opencodex-session" && session) return serveSessionBootstrap(session);
121
+ // State the role, exactly as the standalone/hub server does (src/server/index.ts).
122
+ // The GUI decides whether a machine plane exists from this tag alone
123
+ // (gui/src/api-targets.ts `isConnectedRuntime`): without it `discoverApiTargets`
124
+ // returns standalone targets and never queries /api/machine/status, so a connected
125
+ // client renders as a plain install — no hub usage scope, no "this machine" panel,
126
+ // no connected-client list. This listener only ever serves a connected client, so
127
+ // the role is a constant here rather than a config read.
128
+ const gui = serveGuiFile(url.pathname, undefined, session ?? undefined, "client");
129
+ if (gui) return gui;
130
+ if (url.pathname === "/") {
131
+ return Response.json({
132
+ status: "ok",
133
+ service: "opencodex",
134
+ version: VERSION,
135
+ role: "client",
136
+ dashboard: { available: false, reason: "GUI build not found" },
137
+ endpoints: { health: "/healthz", ready: "/readyz", machine: "/api/machine/*" },
138
+ }, { headers: browserSecurityHeaders() });
139
+ }
140
+ return json404(req);
141
+ },
142
+ });
143
+ }