@bitkyc08/opencodex 2.65.0 → 2.66.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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-DyHlFeNB.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-CI8cVKWu.js} +1 -1
  5. package/gui/dist/assets/index-BiwnVAUm.js +86 -0
  6. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-_WyYzbMt.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -3,7 +3,7 @@ import { MAX_HUB_USAGE_BYTES, parseHubUsage } from "../remote/hub-usage";
3
3
  import { parseRange, parseUsageSurface, USAGE_RANGES, USAGE_SURFACES } from "../usage/summary";
4
4
  import { parseUsageTimeWindow } from "../usage/time-range";
5
5
  import { getFilteredUsageAggregate } from "./management/usage-aggregate-cache";
6
- import { isAllowedRequestOrigin, resolveDataPlaneAdmissionSecret, withCors, type RequestPolicyView } from "./auth-cors";
6
+ import { isAllowedRequestOrigin, resolveDataPlaneAdmissionSecret, withCors, type DataPlaneAdmissionOptions, type RequestPolicyView } from "./auth-cors";
7
7
 
8
8
  /** Read only the ledger rows belonging to an explicitly authenticated configured key. */
9
9
  export async function handleHubUsage(req: Request, config: OcxConfig, policy: RequestPolicyView): Promise<Response> {
@@ -12,7 +12,8 @@ export async function handleHubUsage(req: Request, config: OcxConfig, policy: Re
12
12
  }), req, policy);
13
13
  const error = (code: string, message: string, status: number) => reply({ error: { code, message } }, status);
14
14
  const token = req.headers.get("x-opencodex-api-key") ?? "";
15
- const admission = resolveDataPlaneAdmissionSecret(token, config);
15
+ const options: DataPlaneAdmissionOptions = { linkIngress: policy.linkIngress?.allowedKeyIds };
16
+ const admission = resolveDataPlaneAdmissionSecret(token, config, "dedicated", options);
16
17
  if (admission?.kind !== "configured") return error("hub_usage_unauthorized", "A configured client data key is required", 401);
17
18
  if (!isAllowedRequestOrigin(req, policy)) return error("origin_rejected", "Cross-origin request blocked", 403);
18
19
  if (config.runtimeRole !== "hub") return error("hub_usage_not_a_hub", "Usage is served here only by a hub", 404);
@@ -38,7 +39,7 @@ export async function handleHubUsage(req: Request, config: OcxConfig, policy: Re
38
39
  const aggregate = await getFilteredUsageAggregate({
39
40
  apiKeyId: keyId, provider: query.get("provider"), model: query.get("model"),
40
41
  }, window);
41
- const current = resolveDataPlaneAdmissionSecret(token, config);
42
+ const current = resolveDataPlaneAdmissionSecret(token, config, "dedicated", options);
42
43
  if (current?.kind !== "configured" || current.keyId !== keyId) {
43
44
  return error("hub_usage_unauthorized", "The client key changed during the read", 401);
44
45
  }
@@ -0,0 +1,195 @@
1
+ import type { Server } from "bun";
2
+ import {
3
+ emptyLinkStore,
4
+ readLinkStore,
5
+ type LinkStore,
6
+ writeLinkStore,
7
+ } from "../../link/store";
8
+ import { linkStorePath } from "../../link/paths";
9
+ import { linkRouteAllowed } from "../../link/routes";
10
+
11
+ export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid";
12
+
13
+ export interface LinkListenerStartContext<T> {
14
+ dispatch: (req: Request, server: Server<T>) => Promise<Response>;
15
+ maxRequestBodySize: number;
16
+ }
17
+
18
+ export interface LinkListenerDeps {
19
+ storePath?: string;
20
+ readStore?: (path: string) => LinkStore;
21
+ writeStore?: (path: string, store: LinkStore) => void;
22
+ serve?: (options: Parameters<typeof Bun.serve>[0]) => Server<unknown>;
23
+ warn?: (message: string) => void;
24
+ }
25
+
26
+ export type LinkListenerStatus = {
27
+ state: "off" | "listening" | "failed";
28
+ port: number | null;
29
+ reason: string | null;
30
+ };
31
+
32
+ export interface LinkListenerLifecycle<T> {
33
+ ownsListener(server: Server<T>): boolean;
34
+ start(ctx: LinkListenerStartContext<T>): void;
35
+ ensureStarted(): Promise<void>;
36
+ linkAdmissionKeyIds(): ReadonlySet<string>;
37
+ onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
38
+ notifyAuthenticatedCatalog(apiKeyId: string): void;
39
+ status(): LinkListenerStatus;
40
+ close(): Promise<void>;
41
+ stop(): Promise<void>;
42
+ }
43
+
44
+ export { linkRouteAllowed } from "../../link/routes";
45
+
46
+ function closeWithoutAwait<T>(server: Server<T>): void {
47
+ try { void server.stop(true).catch(() => {}); } catch { /* preserve the bind failure */ }
48
+ }
49
+
50
+ export function createLinkListenerLifecycle<T>(deps: LinkListenerDeps = {}): LinkListenerLifecycle<T> {
51
+ const storePath = deps.storePath ?? linkStorePath();
52
+ const readStore = deps.readStore ?? readLinkStore;
53
+ const writeStore = deps.writeStore ?? writeLinkStore;
54
+ const serve = deps.serve ?? (options => Bun.serve(options));
55
+ const warn = deps.warn ?? ((message: string) => console.warn(message));
56
+ let listener: Server<T> | null = null;
57
+ let startContext: LinkListenerStartContext<T> | undefined;
58
+ let ensureFlight: Promise<void> | undefined;
59
+ let closeFlight: Promise<void> | undefined;
60
+ let stopped = false;
61
+ const authenticatedCatalogListeners = new Set<(apiKeyId: string) => void>();
62
+ let lifecycleStatus: LinkListenerStatus = { state: "off", port: null, reason: null };
63
+
64
+ const setStatus = (state: LinkListenerStatus["state"], port: number | null, reason: string | null): void => {
65
+ lifecycleStatus = { state, port, reason };
66
+ };
67
+
68
+ const reportFailure = (operation: string, error: unknown, reason: string): void => {
69
+ setStatus("failed", null, reason);
70
+ warn(`⚠ hub-link listener ${operation} failed: ${error instanceof Error ? error.message : String(error)}`);
71
+ };
72
+
73
+ const readStoreForAdmission = (): LinkStore => {
74
+ try { return readStore(storePath); } catch { return emptyLinkStore(); }
75
+ };
76
+
77
+ const bindIfNeeded = (): void => {
78
+ if (stopped || listener || !startContext) return;
79
+ let store: LinkStore;
80
+ try {
81
+ store = readStore(storePath);
82
+ } catch (error) {
83
+ reportFailure("store read", error, "bind");
84
+ return;
85
+ }
86
+ if (store.links.length === 0) {
87
+ setStatus("off", null, null);
88
+ return;
89
+ }
90
+ const requestedPort = store.listenerPort ?? 0;
91
+ let bound: Server<unknown>;
92
+ try {
93
+ bound = serve({
94
+ hostname: "127.0.0.1",
95
+ port: requestedPort,
96
+ maxRequestBodySize: startContext.maxRequestBodySize,
97
+ fetch: (req: Request, server: Server<unknown>) => startContext!.dispatch(req, server as Server<T>),
98
+ } as Parameters<typeof Bun.serve>[0]);
99
+ } catch (error) {
100
+ reportFailure("bind", error, "bind");
101
+ return;
102
+ }
103
+ if (stopped) {
104
+ closeWithoutAwait(bound);
105
+ return;
106
+ }
107
+ if (store.listenerPort === null) {
108
+ const port = bound.port;
109
+ if (!port || port === 0) {
110
+ closeWithoutAwait(bound);
111
+ reportFailure("bind", new Error("Bun did not report a concrete listener port"), "bind");
112
+ return;
113
+ }
114
+ try {
115
+ const current = readStore(storePath);
116
+ if (current.listenerPort !== null && current.listenerPort !== port) {
117
+ // Another writer fixed a different port while this bind ran. Tunnels target the stored
118
+ // port, so serving on this one would strand them: give it up and let the next
119
+ // ensureStarted() bind the stored port.
120
+ closeWithoutAwait(bound);
121
+ reportFailure("listenerPort persistence", new Error(`stored port ${current.listenerPort} differs from bound port ${port}`), "persist");
122
+ return;
123
+ }
124
+ writeStore(storePath, { ...current, listenerPort: port });
125
+ } catch (error) {
126
+ closeWithoutAwait(bound);
127
+ reportFailure("listenerPort persistence", error, "persist");
128
+ return;
129
+ }
130
+ }
131
+ listener = bound as Server<T>;
132
+ setStatus("listening", bound.port ?? (requestedPort > 0 ? requestedPort : null), null);
133
+ };
134
+
135
+ const ensureStarted = (): Promise<void> => {
136
+ if (stopped) return Promise.resolve();
137
+ if (ensureFlight) return ensureFlight;
138
+ const flight = (async () => {
139
+ if (closeFlight) await closeFlight;
140
+ if (stopped) return;
141
+ bindIfNeeded();
142
+ })();
143
+ ensureFlight = flight.finally(() => {
144
+ if (ensureFlight === sharedFlight) ensureFlight = undefined;
145
+ });
146
+ const sharedFlight = ensureFlight;
147
+ return sharedFlight;
148
+ };
149
+
150
+ const close = (): Promise<void> => {
151
+ if (closeFlight) return closeFlight;
152
+ const flight = (async () => {
153
+ if (ensureFlight) await ensureFlight;
154
+ const current = listener;
155
+ listener = null;
156
+ if (current) await current.stop(true);
157
+ // Closing is the caller saying "no links now": an earlier bind or persist failure no
158
+ // longer describes anything, so the status reads off either way.
159
+ setStatus("off", null, null);
160
+ })();
161
+ closeFlight = flight.finally(() => {
162
+ closeFlight = undefined;
163
+ });
164
+ return closeFlight;
165
+ };
166
+
167
+ return {
168
+ ownsListener: server => listener !== null && listener === server,
169
+ start(ctx) {
170
+ startContext = ctx;
171
+ bindIfNeeded();
172
+ },
173
+ ensureStarted,
174
+ linkAdmissionKeyIds() {
175
+ return new Set(readStoreForAdmission().links.map(link => link.apiKeyId));
176
+ },
177
+ onAuthenticatedCatalog(listener) {
178
+ authenticatedCatalogListeners.add(listener);
179
+ return () => { authenticatedCatalogListeners.delete(listener); };
180
+ },
181
+ notifyAuthenticatedCatalog(apiKeyId) {
182
+ for (const listener of authenticatedCatalogListeners) listener(apiKeyId);
183
+ },
184
+ status() {
185
+ return { ...lifecycleStatus };
186
+ },
187
+ close,
188
+ async stop() {
189
+ stopped = true;
190
+ await close();
191
+ startContext = undefined;
192
+ setStatus("off", null, null);
193
+ },
194
+ };
195
+ }
@@ -0,0 +1,115 @@
1
+ import type { Server } from "bun";
2
+ import type { OcxConfig } from "../../types";
3
+ import {
4
+ createClaudeInterceptLifecycle,
5
+ type ClaudeInterceptLifecycle,
6
+ } from "./claude-intercept-lifecycle";
7
+ import {
8
+ createLinkListenerLifecycle,
9
+ linkRouteAllowed,
10
+ type LinkListenerDeps,
11
+ type LinkListenerLifecycle,
12
+ type LinkListenerStatus,
13
+ } from "./link-listener";
14
+ import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
15
+ import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
16
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
17
+ export { LINK_INGRESS_HOSTNAME } from "./link-listener";
18
+ import type { ServerIngress } from "./serve-options";
19
+
20
+ export interface OptionalListenerStartContext<T> {
21
+ config: OcxConfig;
22
+ publicPort: number;
23
+ requestedPort?: number;
24
+ maxRequestBodySize: number;
25
+ dispatch: (req: Request, server: Server<T>) => Promise<Response>;
26
+ }
27
+
28
+ export interface OptionalListenerSet<T> {
29
+ ingressOf(server: Server<T>): ServerIngress | undefined;
30
+ linkRouteAllowed(url: URL, req: Request): boolean;
31
+ linkAdmissionKeyIds(): ReadonlySet<string>;
32
+ onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
33
+ notifyAuthenticatedCatalog(apiKeyId: string): void;
34
+ status(): LinkListenerStatus;
35
+ linkStatus(): LinkListenerStatus;
36
+ linkSupervisor(): LinkSupervisor;
37
+ start(ctx: OptionalListenerStartContext<T>): void;
38
+ ensureStarted(): Promise<void>;
39
+ close(): Promise<void>;
40
+ registerSupervisorStop(stop: () => Promise<void>): () => void;
41
+ stop(): Promise<void>;
42
+ }
43
+
44
+ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): OptionalListenerSet<T> {
45
+ const claudeIntercept: ClaudeInterceptLifecycle<T> = createClaudeInterceptLifecycle<T>();
46
+ const linkListener: LinkListenerLifecycle<T> = createLinkListenerLifecycle<T>(linkDeps);
47
+ let activeConfig: OcxConfig | undefined;
48
+ const supervisor = createLinkSupervisor({
49
+ apiKeys: () => activeConfig?.apiKeys ?? [],
50
+ revokeApiKey: id => {
51
+ if (!activeConfig) return false;
52
+ const before = activeConfig.apiKeys ?? [];
53
+ if (!before.some(key => key.id === id)) return false;
54
+ activeConfig.apiKeys = before.filter(key => key.id !== id);
55
+ try {
56
+ saveConfigPreservingClaudeCode(activeConfig);
57
+ reconcileLiveStateStores();
58
+ } catch (error) {
59
+ activeConfig.apiKeys = before;
60
+ throw error;
61
+ }
62
+ return true;
63
+ },
64
+ });
65
+ let unregisterSupervisorAdmission: (() => void) | undefined;
66
+ let supervisorStop: (() => Promise<void>) | undefined;
67
+
68
+ return {
69
+ ingressOf(server) {
70
+ if (linkListener.ownsListener(server)) return "hub-link";
71
+ if (claudeIntercept.ownsListener(server)) return "claude-intercept";
72
+ return undefined;
73
+ },
74
+ linkRouteAllowed,
75
+ linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
76
+ onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
77
+ notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
78
+ status: () => linkListener.status(),
79
+ linkStatus: () => linkListener.status(),
80
+ linkSupervisor: () => supervisor,
81
+ start(ctx) {
82
+ activeConfig = ctx.config;
83
+ linkListener.start({ dispatch: ctx.dispatch, maxRequestBodySize: ctx.maxRequestBodySize });
84
+ unregisterSupervisorAdmission ??= linkListener.onAuthenticatedCatalog(apiKeyId => supervisor.notifyAuthenticatedRequest?.(apiKeyId));
85
+ supervisor.start();
86
+ supervisorStop = () => supervisor.stop();
87
+ claudeIntercept.start({
88
+ config: ctx.config,
89
+ publicPort: ctx.publicPort,
90
+ requestedPort: ctx.requestedPort,
91
+ maxRequestBodySize: ctx.maxRequestBodySize,
92
+ dispatch: ctx.dispatch,
93
+ });
94
+ },
95
+ ensureStarted: () => linkListener.ensureStarted(),
96
+ close: () => linkListener.close(),
97
+ registerSupervisorStop(stop) {
98
+ supervisorStop = stop;
99
+ return () => {
100
+ if (supervisorStop === stop) supervisorStop = undefined;
101
+ };
102
+ },
103
+ async stop() {
104
+ let failure: unknown;
105
+ if (supervisorStop) {
106
+ try { await supervisorStop(); } catch (error) { failure = error; }
107
+ }
108
+ try { await linkListener.stop(); } catch (error) { failure ??= error; }
109
+ try { await claudeIntercept.stop(); } catch (error) { failure ??= error; }
110
+ unregisterSupervisorAdmission?.();
111
+ unregisterSupervisorAdmission = undefined;
112
+ if (failure) throw failure;
113
+ },
114
+ };
115
+ }
@@ -93,6 +93,7 @@ import {
93
93
  isAllowedRequestOrigin,
94
94
  isAllowedManagementOrigin,
95
95
  isApiAuthRequired,
96
+ isLoopbackHostname,
96
97
  jsonResponse,
97
98
  admissionFields,
98
99
  resolveApiAuth,
@@ -101,6 +102,7 @@ import {
101
102
  withCors,
102
103
  withManagementCors,
103
104
  } from "../auth-cors";
105
+ import { managementSessionIssuance } from "../management-auth";
104
106
  import { resolveAdmissionModelScope, routeAllowedByScope } from "../admission-model-scope";
105
107
  import {
106
108
  disableResponsesRequestTimeout,
@@ -199,7 +201,12 @@ import { readyProtocolMetadata } from "../../remote/protocol";
199
201
  import { modelCapabilityFields } from "../models-capabilities";
200
202
  import { createWebsocketHandler } from "./websocket-handler";
201
203
 
202
- export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept";
204
+ export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link";
205
+
206
+ export function trustedLoopbackForIngress(ingress: ServerIngress, hostname: string): boolean {
207
+ return ingress === "unauthenticated-loopback"
208
+ || (ingress === "public" && isLoopbackHostname(hostname));
209
+ }
203
210
 
204
211
  /**
205
212
  * Routes the Claude intercept TLS listener may reach. Everything else on that socket is relayed
@@ -219,6 +226,9 @@ export interface ServeOptionsContext {
219
226
  ingressForServer: (requestServer: Server<WsData>) => ServerIngress;
220
227
  loopbackRouteAllowed: (url: URL, req: Request) => boolean;
221
228
  managementIngressRouteAllowed: (url: URL, req: Request) => boolean;
229
+ linkRouteAllowed: (url: URL, req: Request) => boolean;
230
+ onAuthenticatedCatalog: (apiKeyId: string) => void;
231
+ linkPolicy: () => RequestPolicyView;
222
232
  packageTreeChangedResponse: (
223
233
  req: Request,
224
234
  policy: RequestPolicyView,
@@ -261,6 +271,8 @@ export function createServeOptions(ctx: ServeOptionsContext) {
261
271
  ingressForServer,
262
272
  loopbackRouteAllowed,
263
273
  managementIngressRouteAllowed,
274
+ linkRouteAllowed, onAuthenticatedCatalog,
275
+ linkPolicy,
264
276
  packageTreeChangedResponse,
265
277
  serverBusyResponse,
266
278
  runAdmittedHttpTurn,
@@ -293,6 +305,8 @@ export function createServeOptions(ctx: ServeOptionsContext) {
293
305
  maxRequestBodySize: inboundBodyLimitBytes,
294
306
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
295
307
  const ingress = ingressForServer(requestServer);
308
+ const requestUrl = codexCompatibleUrl(req.url);
309
+ const linkPolicyView = ingress === "hub-link" ? linkPolicy() : undefined;
296
310
  // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
297
311
  // else. Rejecting here, before any handler runs, is what keeps the surface from growing
298
312
  // silently when a route is added below.
@@ -303,6 +317,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
303
317
  loopbackPolicy(),
304
318
  );
305
319
  }
320
+ if (ingress === "hub-link" && !linkRouteAllowed(requestUrl, req)) {
321
+ return withCors(
322
+ formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
323
+ req,
324
+ linkPolicyView!,
325
+ );
326
+ }
306
327
  // Tailscale Serve terminates only on this separately bound loopback socket. Reject before
307
328
  // dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run.
308
329
  if (ingress === "hub-management" && !managementIngressRouteAllowed(codexCompatibleUrl(req.url), req)) {
@@ -327,10 +348,20 @@ export function createServeOptions(ctx: ServeOptionsContext) {
327
348
  // The Claude intercept listener is loopback by construction (the CONNECT proxy binds
328
349
  // 127.0.0.1 and the request was rewritten onto a loopback origin), and its callers carry
329
350
  // Anthropic credentials, not opencodex admission tokens — so it takes the loopback view too.
330
- const policy: RequestPolicyView = ingress === "unauthenticated-loopback" || ingress === "claude-intercept"
331
- ? loopbackPolicy()
332
- : config;
333
- const url = codexCompatibleUrl(req.url);
351
+ const policy: RequestPolicyView = ingress === "hub-link"
352
+ ? linkPolicyView!
353
+ : ingress === "unauthenticated-loopback" || ingress === "claude-intercept"
354
+ ? loopbackPolicy()
355
+ : config;
356
+ if (ingress === "hub-link") {
357
+ const admission = resolveApiAuth(req, policy);
358
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
359
+ if (requestUrl.pathname === "/v1/catalog" && requestUrl.search === ""
360
+ && (req.method === "GET" || req.method === "HEAD")
361
+ && admission.kind === "configured") onAuthenticatedCatalog(admission.keyId);
362
+ }
363
+ const url = requestUrl;
364
+ const admissionOptions = { linkIngress: policy.linkIngress?.allowedKeyIds };
334
365
  markActivity(`${req.method} ${url.pathname}`);
335
366
 
336
367
  // Readiness is exact-GET on the literal /readyz path. Compare the DECODED
@@ -660,7 +691,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
660
691
  }), req, config);
661
692
  }
662
693
  }
663
- const mgmtResponse = await handleManagementAPI(req, url, config, requestManagementApiDeps, principal, managementSessionControl);
694
+ const mgmtResponse = await handleManagementAPI(req, url, config, requestManagementApiDeps, principal, managementSessionControl, {
695
+ trustedLoopback: trustedLoopbackForIngress(ingress, config.hostname ?? "127.0.0.1"),
696
+ guiSessionIssuance: managementSessionIssuance(req, managementAuth),
697
+ });
664
698
  if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
665
699
  return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
666
700
  }
@@ -1545,7 +1579,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1545
1579
  if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
1546
1580
  disableResponsesRequestTimeout(req, requestServer);
1547
1581
  if (isDraining()) return drainingResponse(req, policy);
1548
- const admission = resolveAudioAdmission(req.headers, config);
1582
+ const admission = resolveAudioAdmission(req.headers, config, admissionOptions);
1549
1583
  if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1550
1584
  if (!isAllowedRequestOrigin(req, policy)) {
1551
1585
  return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin audio request blocked"), req, policy);
@@ -1576,7 +1610,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1576
1610
  if (isDraining()) {
1577
1611
  return drainingResponse(req, policy);
1578
1612
  }
1579
- const audioClient = resolveAudioClient(req, config);
1613
+ const audioClient = resolveAudioClient(req, config, false, admissionOptions);
1580
1614
  if (audioClient instanceof Response) return withCors(audioClient, req, policy);
1581
1615
  const admission = audioClient?.admission ?? resolveApiAuth(req, policy);
1582
1616
  if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
@@ -1620,7 +1654,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1620
1654
  if (isDraining()) {
1621
1655
  return drainingResponse(req, policy);
1622
1656
  }
1623
- const audioClient = resolveAudioClient(req, config, dictationSocket);
1657
+ const audioClient = resolveAudioClient(req, config, dictationSocket, admissionOptions);
1624
1658
  if (audioClient instanceof Response) return withCors(audioClient, req, policy);
1625
1659
  if (!audioClient && liveSidebandTarget && "callId" in liveSidebandTarget
1626
1660
  && liveSidebandTarget.callId.startsWith(EXTERNAL_CALL_PREFIX)) {
@@ -1868,7 +1902,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1868
1902
  undefined,
1869
1903
  guiSessionCandidate ?? undefined,
1870
1904
  config.runtimeRole ?? "standalone",
1871
- isApiAuthRequired(config),
1905
+ isApiAuthRequired(policy),
1872
1906
  );
1873
1907
  if (guiFile) return guiFile;
1874
1908
  if (url.pathname === "/" && req.method === "GET") {
@@ -196,7 +196,7 @@ import {
196
196
  } from "../lib/local-management-attestation";
197
197
  import { createReadinessGate, type ReadinessGate } from "./readiness";
198
198
  import { createServeOptions, type ServerIngress } from "./index/serve-options";
199
- import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle";
199
+ import { createOptionalListenerSet, LINK_INGRESS_HOSTNAME } from "./index/optional-listeners";
200
200
  import { createPackageTreeIntegrityGuardForServer } from "./index/package-tree-guard";
201
201
  import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings";
202
202
  import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle";
@@ -623,15 +623,17 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
623
623
  let server: Server<WsData>;
624
624
  let loopbackServer: Server<WsData> | null = null;
625
625
  let managementIngressServer: Server<WsData> | null = null;
626
- const claudeIntercept = createClaudeInterceptLifecycle<WsData>();
626
+ const optionalListeners = createOptionalListenerSet<WsData>();
627
627
  const inboundBodyLimitBytes = resolveInboundBodyLimitWithWarning(config);
628
628
 
629
629
  function ingressForServer(requestServer: Server<WsData>): ServerIngress {
630
+ const optionalIngress = optionalListeners.ingressOf(requestServer);
631
+ if (optionalIngress !== undefined) return optionalIngress;
630
632
  if (requestServer === loopbackServer) return "unauthenticated-loopback";
631
633
  if (requestServer === managementIngressServer) return "hub-management";
632
- if (claudeIntercept.ownsListener(requestServer)) return "claude-intercept";
633
634
  return "public";
634
635
  }
636
+ const linkPolicy = (): RequestPolicyView => requestPolicyView(config, LINK_INGRESS_HOSTNAME, { allowedKeyIds: optionalListeners.linkAdmissionKeyIds() });
635
637
  let backgroundLifecycle: ReturnType<typeof acquireServerBackgroundLifecycle> | null = null;
636
638
  let unregisterQuotaAutoRefresh: (() => void) | null = null;
637
639
  let remoteWorkspaceStopping = false;
@@ -639,7 +641,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
639
641
  const managementApiDeps: ManagementApiDeps = {
640
642
  ...deps.managementApi,
641
643
  remoteWorkspaceStopping: () => remoteWorkspaceStopping,
642
- onRemoteWorkspaceShutdown: shutdown => { remoteWorkspaceShutdown = shutdown; },
644
+ onRemoteWorkspaceShutdown: shutdown => { remoteWorkspaceShutdown = shutdown; }, linkSupervisor: () => optionalListeners.linkSupervisor(), linkListener: () => optionalListeners,
643
645
  };
644
646
  let workspaceRuntimeFlight: Promise<typeof import("../remote-control/workspace-runtime")> | undefined;
645
647
  const loadRemoteWorkspaceRuntime = () => {
@@ -666,6 +668,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
666
668
  ingressForServer,
667
669
  loopbackRouteAllowed,
668
670
  managementIngressRouteAllowed,
671
+ linkRouteAllowed: optionalListeners.linkRouteAllowed, linkPolicy, onAuthenticatedCatalog: optionalListeners.notifyAuthenticatedCatalog,
669
672
  packageTreeChangedResponse,
670
673
  serverBusyResponse,
671
674
  runAdmittedHttpTurn,
@@ -731,10 +734,8 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
731
734
  throw new AuxiliaryListenerBindError("hub.managementIngress", managementIngressPort, "127.0.0.1", error);
732
735
  }
733
736
  }
734
- claudeIntercept.start({
735
- config, publicPort: server.port ?? listenPort, requestedPort: listenPort, maxRequestBodySize: inboundBodyLimitBytes,
736
- dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer),
737
- });
737
+ optionalListeners.start({ config, publicPort: server.port ?? listenPort, requestedPort: listenPort,
738
+ maxRequestBodySize: inboundBodyLimitBytes, dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer) });
738
739
  } catch (error) {
739
740
  unregisterQuotaAutoRefresh?.();
740
741
  userCostOverlayReconciler?.stop();
@@ -772,7 +773,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
772
773
  ...(managementIngressRef
773
774
  ? [() => managementIngressRef.stop(closeActiveConnections)]
774
775
  : []),
775
- () => claudeIntercept.stop(),
776
+ () => optionalListeners.stop(),
776
777
  async () => { await remoteWorkspaceShutdown?.(); },
777
778
  async () => {
778
779
  try {
@@ -0,0 +1,51 @@
1
+ import type { OcxUsage } from "../../types";
2
+ import type { PersistedUsageAttempt } from "../../usage/log";
3
+ import {
4
+ beginRequestAttempt,
5
+ finishRequestAttempt,
6
+ sealRequestAttemptIdentity,
7
+ type RequestLogContext,
8
+ } from "../request-log";
9
+
10
+ export interface InferenceAttemptTarget {
11
+ provider: string;
12
+ model: string;
13
+ adapter: string;
14
+ }
15
+
16
+ export interface InferenceAttempt {
17
+ readonly attempt: PersistedUsageAttempt;
18
+ /** Epoch ms the attempt became active; the duration `finish` records is measured from it. */
19
+ readonly startedAt: number;
20
+ /** Stamp the attempt's provider/adapter identity and, before its first send, its account. */
21
+ seal(accountLabel?: string): void;
22
+ /** Close the attempt row with its status and duration; `usage` overrides the attempt's own. */
23
+ finish(status: number, usage?: OcxUsage): PersistedUsageAttempt;
24
+ }
25
+
26
+ /**
27
+ * Open one physical attempt on `logCtx`: the next ordinal, the active attempt and its start
28
+ * time, and the attempt appended to the request's list, in that order. The attempt is the row
29
+ * the final request log finishes; `finish` exists for owners that close it themselves.
30
+ */
31
+ export function beginInferenceAttempt(
32
+ logCtx: RequestLogContext,
33
+ target: InferenceAttemptTarget,
34
+ ): InferenceAttempt {
35
+ const attempt = beginRequestAttempt(
36
+ (logCtx.attempts?.length ?? 0) + 1,
37
+ target.provider,
38
+ target.model,
39
+ target.adapter,
40
+ );
41
+ logCtx.activeAttempt = attempt;
42
+ const startedAt = Date.now();
43
+ logCtx.activeAttemptStartedAt = startedAt;
44
+ (logCtx.attempts ??= []).push(attempt);
45
+ return {
46
+ attempt,
47
+ startedAt,
48
+ seal: accountLabel => sealRequestAttemptIdentity(attempt, target.provider, target.adapter, accountLabel),
49
+ finish: (status, usage) => finishRequestAttempt(attempt, status, Date.now() - startedAt, usage),
50
+ };
51
+ }