@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,226 @@
1
+ import { existsSync, lstatSync } from "node:fs";
2
+ import { DEFAULT_CATALOG_PATH } from "../codex/paths";
3
+ import {
4
+ disconnectClient,
5
+ revokeConnectedClientKey,
6
+ rotateConnectedClientKey,
7
+ connectClient,
8
+ } from "../client/connect";
9
+ import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state";
10
+ import { readServiceApiTokenState } from "../lib/service-secrets";
11
+ import type { OcxConnectedClientId } from "../types";
12
+ import {
13
+ CliUsageError,
14
+ csv,
15
+ printData,
16
+ readSecretLine,
17
+ rejectArgs,
18
+ runCliAction,
19
+ takeFlag,
20
+ takeOption,
21
+ type RuntimeApiDeps,
22
+ } from "./runtime-api";
23
+
24
+ export const CONNECT_USAGE = `Usage:
25
+ ocx connect <url> [--management-url <url>]
26
+ (--pairing-code-stdin | --admin-token-stdin)
27
+ [--clients codex,claude] [--management-transport direct|relay]
28
+ [--no-sync]
29
+ ocx connect status [--json]
30
+ ocx connect rotate (--pairing-code-stdin | --admin-token-stdin)
31
+ [--json]
32
+ ocx connect revoke --admin-token-stdin [--json]`;
33
+
34
+ export const DISCONNECT_USAGE = `Usage:
35
+ ocx disconnect [--keep-catalog] [--json]`;
36
+
37
+ export type ClientConnectionStatus = {
38
+ state: "disconnected" | "connected" | "invalid" | "mismatched";
39
+ reason?: string;
40
+ serverUrl?: string;
41
+ managementUrl?: string;
42
+ managementTransport?: "direct" | "relay";
43
+ protocolVersion?: number;
44
+ apiKeyId?: string;
45
+ selectedClients?: OcxConnectedClientId[];
46
+ connectedAt?: string;
47
+ catalogSyncedAt?: string;
48
+ catalogAgeSeconds?: number;
49
+ catalog: "present" | "missing" | "unsafe";
50
+ token: "owned" | "missing" | "changed" | "unsafe";
51
+ rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe";
52
+ };
53
+
54
+ export function collectClientConnectionStatus(now = Date.now()): ClientConnectionStatus {
55
+ const state = readClientConnectionState();
56
+ const tokenState = readServiceApiTokenState();
57
+ const rotation = inspectClientRotationRecoveryGate(state).kind;
58
+ let catalog: ClientConnectionStatus["catalog"] = "missing";
59
+ if (existsSync(DEFAULT_CATALOG_PATH)) {
60
+ try {
61
+ const stat = lstatSync(DEFAULT_CATALOG_PATH);
62
+ catalog = !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe";
63
+ } catch {
64
+ catalog = "unsafe";
65
+ }
66
+ }
67
+ if (state.kind !== "connected") {
68
+ return {
69
+ state: state.kind,
70
+ ...(state.kind === "invalid" || state.kind === "mismatched" ? { reason: state.reason } : {}),
71
+ catalog,
72
+ token: tokenState.kind === "absent" ? "missing" : tokenState.kind === "unsafe" ? "unsafe" : "changed",
73
+ rotation,
74
+ };
75
+ }
76
+ const catalogAgeSeconds = state.value.catalogSyncedAt
77
+ ? Math.max(0, Math.floor((now - Date.parse(state.value.catalogSyncedAt)) / 1000))
78
+ : undefined;
79
+ const token = tokenState.kind === "absent"
80
+ ? "missing"
81
+ : tokenState.kind === "unsafe"
82
+ ? "unsafe"
83
+ : tokenState.fingerprint === state.value.tokenFingerprint ? "owned" : "changed";
84
+ return {
85
+ state: "connected",
86
+ serverUrl: state.value.serverUrl,
87
+ managementUrl: state.value.managementUrl,
88
+ managementTransport: state.value.managementTransport,
89
+ protocolVersion: state.value.protocolVersion,
90
+ apiKeyId: state.value.apiKeyId,
91
+ selectedClients: [...state.value.selectedClients],
92
+ connectedAt: state.value.connectedAt,
93
+ ...(state.value.catalogSyncedAt ? { catalogSyncedAt: state.value.catalogSyncedAt } : {}),
94
+ ...(catalogAgeSeconds !== undefined ? { catalogAgeSeconds } : {}),
95
+ catalog,
96
+ token,
97
+ rotation,
98
+ };
99
+ }
100
+
101
+ function parseClients(raw: string | undefined): OcxConnectedClientId[] {
102
+ const values = csv(raw) ?? ["codex", "claude"];
103
+ if (values.length < 1 || values.some(value => value !== "codex" && value !== "claude")) {
104
+ throw new CliUsageError("--clients must contain codex and/or claude", CONNECT_USAGE);
105
+ }
106
+ return values as OcxConnectedClientId[];
107
+ }
108
+
109
+ function statusLines(status: ClientConnectionStatus): string[] {
110
+ if (status.state !== "connected") {
111
+ return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`];
112
+ }
113
+ return [
114
+ "Connection: connected",
115
+ `Hub: ${status.serverUrl}`,
116
+ `Management: ${status.managementUrl} (${status.managementTransport})`,
117
+ `Protocol: ${status.protocolVersion}`,
118
+ `API key id: ${status.apiKeyId}`,
119
+ `Clients: ${status.selectedClients?.join(", ")}`,
120
+ `Token file: ${status.token}`,
121
+ `Key rotation: ${status.rotation}`,
122
+ `Catalog: ${status.catalog}${status.catalogAgeSeconds !== undefined ? ` (${status.catalogAgeSeconds}s old)` : ""}`,
123
+ ];
124
+ }
125
+
126
+ async function runRotate(argv: string[], deps: RuntimeApiDeps): Promise<void> {
127
+ const args = [...argv];
128
+ const wantsJson = takeFlag(args, "--json");
129
+ const pairing = takeFlag(args, "--pairing-code-stdin");
130
+ const admin = takeFlag(args, "--admin-token-stdin");
131
+ if (Number(pairing) + Number(admin) !== 1) {
132
+ throw new CliUsageError("choose exactly one of --pairing-code-stdin or --admin-token-stdin", CONNECT_USAGE);
133
+ }
134
+ rejectArgs(args, CONNECT_USAGE, { redactValues: true });
135
+ const value = new TextEncoder().encode(await readSecretLine(deps, pairing ? "pairing code" : "admin token"));
136
+ const connection = await rotateConnectedClientKey({
137
+ credential: { kind: pairing ? "pairing-grant" : "admin", value },
138
+ }, { fetchImpl: deps.fetchImpl });
139
+ printData({ apiKeyId: connection.apiKeyId, rotation: "committed" }, wantsJson, [
140
+ `Rotated connected API key ${connection.apiKeyId}; the previous key is no longer admitted.`,
141
+ ]);
142
+ }
143
+
144
+ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise<void> {
145
+ const args = [...argv];
146
+ const serverUrl = args.shift();
147
+ if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE);
148
+ const managementUrl = takeOption(args, "--management-url");
149
+ const clients = parseClients(takeOption(args, "--clients"));
150
+ const managementTransport = takeOption(args, "--management-transport") ?? "direct";
151
+ if (managementTransport !== "direct" && managementTransport !== "relay") {
152
+ throw new CliUsageError("--management-transport must be direct or relay", CONNECT_USAGE);
153
+ }
154
+ const pairing = takeFlag(args, "--pairing-code-stdin");
155
+ const admin = takeFlag(args, "--admin-token-stdin");
156
+ const noSync = takeFlag(args, "--no-sync");
157
+ if (Number(pairing) + Number(admin) !== 1) {
158
+ throw new CliUsageError("choose exactly one of --pairing-code-stdin or --admin-token-stdin", CONNECT_USAGE);
159
+ }
160
+ rejectArgs(args, CONNECT_USAGE, { redactValues: true });
161
+ const secret = await readSecretLine(deps, pairing ? "pairing code" : "admin token");
162
+ const value = new TextEncoder().encode(secret);
163
+ const connection = await connectClient({
164
+ serverUrl,
165
+ ...(managementUrl ? { managementUrl } : {}),
166
+ credential: { kind: pairing ? "pairing-grant" : "admin", value },
167
+ selectedClients: clients,
168
+ managementTransport,
169
+ noSync,
170
+ }, { fetchImpl: deps.fetchImpl });
171
+ console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`);
172
+ }
173
+
174
+ async function runRevoke(argv: string[], deps: RuntimeApiDeps): Promise<void> {
175
+ const args = [...argv];
176
+ const wantsJson = takeFlag(args, "--json");
177
+ const admin = takeFlag(args, "--admin-token-stdin");
178
+ if (!admin) throw new CliUsageError("revoke requires --admin-token-stdin", CONNECT_USAGE);
179
+ rejectArgs(args, CONNECT_USAGE, { redactValues: true });
180
+ const value = new TextEncoder().encode(await readSecretLine(deps, "admin token"));
181
+ const result = await revokeConnectedClientKey({ kind: "admin", value }, { fetchImpl: deps.fetchImpl });
182
+ printData(result, wantsJson, [`Revoked connected API key ${result.apiKeyId}. Disconnect this client next.`]);
183
+ }
184
+
185
+ export async function handleConnectCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
186
+ return runCliAction(async () => {
187
+ if (argv[0] === "status") {
188
+ const args = argv.slice(1);
189
+ const wantsJson = takeFlag(args, "--json");
190
+ rejectArgs(args, CONNECT_USAGE, { redactValues: true });
191
+ const status = collectClientConnectionStatus();
192
+ printData(status, wantsJson, statusLines(status));
193
+ return;
194
+ }
195
+ if (argv[0] === "revoke") {
196
+ await runRevoke(argv.slice(1), deps);
197
+ return;
198
+ }
199
+ if (argv[0] === "rotate") {
200
+ await runRotate(argv.slice(1), deps);
201
+ return;
202
+ }
203
+ await runConnect(argv, deps);
204
+ });
205
+ }
206
+
207
+ export async function handleDisconnectCommand(argv: string[]): Promise<number> {
208
+ return runCliAction(async () => {
209
+ const args = [...argv];
210
+ const keepCatalog = takeFlag(args, "--keep-catalog");
211
+ const wantsJson = takeFlag(args, "--json");
212
+ rejectArgs(args, DISCONNECT_USAGE, { redactValues: true });
213
+ const result = await disconnectClient({ keepCatalog });
214
+ const payload = {
215
+ ...result,
216
+ revoke: {
217
+ apiKeyId: result.apiKeyId,
218
+ location: "Integrations → API Keys",
219
+ },
220
+ };
221
+ printData(payload, wantsJson, [
222
+ "Disconnected locally; native Codex state was restored.",
223
+ `The hub key ${result.apiKeyId} is still valid. Revoke it from Integrations → API Keys.`,
224
+ ]);
225
+ });
226
+ }
@@ -23,6 +23,7 @@ import { stripGrokConfig } from "../grok/inject";
23
23
  import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes";
24
24
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
25
25
  import { isJsonOption, takeFlag } from "./runtime-api";
26
+ import type { ClientConnectionState } from "../client/state";
26
27
 
27
28
  export interface CliDispatchDeps {
28
29
  args: string[];
@@ -59,6 +60,13 @@ const commandRunners: Record<string, CommandRunner> = {
59
60
  return Number(process.exitCode ?? 0);
60
61
  },
61
62
  start: async deps => {
63
+ const { readClientConnectionState } = await import("../client/state");
64
+ const clientState = readClientConnectionState();
65
+ await reconcileClientJournalBeforeLifecycle(clientState);
66
+ if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
67
+ console.error(`Client state is ${clientState.kind}: ${clientState.reason}`);
68
+ return 1;
69
+ }
62
70
  await deps.handleStart();
63
71
  return Number(process.exitCode ?? 0);
64
72
  },
@@ -245,6 +253,15 @@ const commandRunners: Record<string, CommandRunner> = {
245
253
  return 0;
246
254
  },
247
255
  ensure: async deps => {
256
+ const { readClientConnectionState } = await import("../client/state");
257
+ const clientState = readClientConnectionState();
258
+ await reconcileClientJournalBeforeLifecycle(clientState);
259
+ if (clientState.kind !== "disconnected") {
260
+ console.error(clientState.kind === "connected"
261
+ ? "Client mode does not start a local provider proxy; use 'ocx sync'."
262
+ : `Client state is ${clientState.kind}: ${clientState.reason}`);
263
+ return 1;
264
+ }
248
265
  await deps.handleEnsure();
249
266
  return Number(process.exitCode ?? 0);
250
267
  },
@@ -317,6 +334,31 @@ const commandRunners: Record<string, CommandRunner> = {
317
334
  // Separate flag on purpose: --restart-codex promises app-server-only scope,
318
335
  // and quitting the desktop app ends live conversations.
319
336
  const restartDesktopApp = syncArgs.includes("--restart-desktop-app");
337
+ const { readClientConnectionState } = await import("../client/state");
338
+ const clientState = readClientConnectionState();
339
+ if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
340
+ console.error(`Client state is ${clientState.kind}: ${clientState.reason}`);
341
+ return 1;
342
+ }
343
+ if (clientState.kind === "connected") {
344
+ try {
345
+ const { syncConnectedClient } = await import("../client/connect");
346
+ const result = await syncConnectedClient({ restartCodex });
347
+ console.log(result.stale
348
+ ? "Hub unavailable; retained and applied the last-known-good remote catalog (stale)."
349
+ : "Remote hub catalog synchronized.");
350
+ await handleConnectedSyncCatalogWrite(result, restartCodex, restartDesktopApp);
351
+ // `process.exitCode` rather than a literal 0, for the same reason every other
352
+ // runner does it (tests/cli-transport-honesty.test.ts): the catalog-write helper
353
+ // drives app-server restarts, and one of those recording a failure must not be
354
+ // erased by the value this runner returns. It reads 0 on the ordinary path. Node
355
+ // types it as `number | string`; only a numeric code means anything here.
356
+ return typeof process.exitCode === "number" ? process.exitCode : 0;
357
+ } catch (error) {
358
+ console.error(`Connected sync failed without local fallback: ${error instanceof Error ? error.message : String(error)}`);
359
+ return 1;
360
+ }
361
+ }
320
362
  const live = await deps.findLiveProxy();
321
363
  const synced = await syncModelsToCodex(
322
364
  live?.port,
@@ -372,6 +414,14 @@ const commandRunners: Record<string, CommandRunner> = {
372
414
  const { cmdV2 } = await import("./v2");
373
415
  return await cmdV2(deps.args.slice(1), {}, async () => (await deps.findLiveProxy())?.port);
374
416
  },
417
+ connect: async deps => {
418
+ const { handleConnectCommand } = await import("./connect");
419
+ return await handleConnectCommand(deps.args.slice(1));
420
+ },
421
+ disconnect: async deps => {
422
+ const { handleDisconnectCommand } = await import("./connect");
423
+ return await handleDisconnectCommand(deps.args.slice(1));
424
+ },
375
425
  "sync-cache": async deps => {
376
426
  const cacheArgs = deps.args.slice(1);
377
427
  const restartCodex = cacheArgs.includes("--restart-codex");
@@ -451,27 +501,34 @@ const commandRunners: Record<string, CommandRunner> = {
451
501
  return ok ? 0 : 1;
452
502
  },
453
503
  gui: async deps => {
454
- const config = deps.loadConfig();
455
- // Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port
456
- // proxy and waits until the spawned one actually answers before opening the browser.
457
- let live = await deps.findLiveProxy();
458
- if (!live) {
459
- console.log("Proxy not running. Starting...");
460
- deps.spawnDetached(deps.startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined));
461
- live = await deps.waitForProxy();
462
- if (!live) {
463
- console.error("❌ Proxy did not become healthy after starting. Not opening the GUI.");
464
- return 1;
465
- }
466
- }
467
- // Open the host the proxy actually binds — `localhost` only answers for
468
- // loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
469
- const guiHost = deps.probeHostname(live?.hostname ?? config.hostname);
470
- const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
471
- console.log(`Opening ${guiUrl}`);
472
- const { openUrl } = await import("../lib/open-url");
473
- openUrl(guiUrl);
474
- return 0;
504
+ const { runGuiCommand } = await import("./gui");
505
+ return runGuiCommand(deps.args.slice(1), {
506
+ loadConfig: deps.loadConfig,
507
+ findLiveProxy: deps.findLiveProxy,
508
+ openDefaultGui: async () => {
509
+ const config = deps.loadConfig();
510
+ // Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port
511
+ // proxy and waits until the spawned one actually answers before opening the browser.
512
+ let live = await deps.findLiveProxy();
513
+ if (!live) {
514
+ console.log("Proxy not running. Starting...");
515
+ deps.spawnDetached(deps.startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined));
516
+ live = await deps.waitForProxy();
517
+ if (!live) {
518
+ console.error("❌ Proxy did not become healthy after starting. Not opening the GUI.");
519
+ return 1;
520
+ }
521
+ }
522
+ // Open the host the proxy actually binds — `localhost` only answers for
523
+ // loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
524
+ const guiHost = deps.probeHostname(live?.hostname ?? config.hostname);
525
+ const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
526
+ console.log(`Opening ${guiUrl}`);
527
+ const { openUrl } = await import("../lib/open-url");
528
+ openUrl(guiUrl);
529
+ return 0;
530
+ },
531
+ });
475
532
  },
476
533
  service: async deps => {
477
534
  process.exitCode = 0;
@@ -779,6 +836,34 @@ export const DISPATCH_ALIASES: ReadonlyMap<string, string> = aliasTargets;
779
836
 
780
837
  /** Resolve the runner key for a command, following registry aliases to the
781
838
  * canonical runner. Returns undefined when the command is unknown. */
839
+ /** What `handleStart` does about a live proxy it found before binding. */
840
+ export type StartOwnerDecision = "refuse" | "service-stay-out" | "sibling";
841
+
842
+ /**
843
+ * Pure decision for `handleStart` when the pre-bind probe found a live proxy.
844
+ *
845
+ * The #3106 guard exists so a bare `start` cannot shadow a healthy configured-port
846
+ * proxy with an ephemeral-port copy. An interactive `--port X` naming a DIFFERENT
847
+ * port than the live proxy's is an explicit sibling request, not that shadow — and
848
+ * refusing it also broke every spawned-launcher test on a machine running a real
849
+ * proxy, because the probe reaches the machine-global port across sandbox homes.
850
+ * The service wrapper always passes the configured port and keeps its exact
851
+ * stay-out-of-the-way semantics: it never takes the sibling path.
852
+ */
853
+ export function decideStartWithLiveOwner(input: {
854
+ livePort: number;
855
+ requestedPort: number | undefined;
856
+ ocxService: string | undefined;
857
+ }): StartOwnerDecision {
858
+ const sibling = input.requestedPort !== undefined
859
+ && input.requestedPort !== input.livePort
860
+ // Only the exact "1" sentinel is service context — the same check syncCleanup
861
+ // uses — so an env value like "0" or "false" cannot reach the stay-out path.
862
+ && input.ocxService !== "1";
863
+ if (sibling) return "sibling";
864
+ return input.ocxService === "1" ? "service-stay-out" : "refuse";
865
+ }
866
+
782
867
  export function resolveDispatchCommand(command: string | undefined): string | undefined {
783
868
  if (command === undefined) return undefined;
784
869
  if (Object.prototype.hasOwnProperty.call(commandRunners, command)) return command;
@@ -847,3 +932,23 @@ async function handleDesktopAppRestart(log: Pick<Console, "log" | "error">): Pro
847
932
  }
848
933
  }
849
934
  }
935
+
936
+ async function handleConnectedSyncCatalogWrite(
937
+ result: { catalogWritten: boolean; cacheSynced: boolean },
938
+ restartCodex: boolean,
939
+ restartDesktopApp: boolean,
940
+ ): Promise<void> {
941
+ if (!result.catalogWritten && !result.cacheSynced) return;
942
+ afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
943
+ if (restartDesktopApp) await handleDesktopAppRestart(console);
944
+ }
945
+
946
+ async function reconcileClientJournalBeforeLifecycle(
947
+ state: ClientConnectionState,
948
+ ): Promise<void> {
949
+ if (state.kind === "disconnected") return;
950
+ const { reconcileJournal } = await import("../codex/journal");
951
+ reconcileJournal(state.kind === "connected"
952
+ ? { activeClientApiKeyId: state.value.apiKeyId }
953
+ : undefined);
954
+ }
@@ -0,0 +1,170 @@
1
+ import { readRuntimePort, type RuntimePortState } from "../config/process-state";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import {
4
+ LOCAL_ATTESTATION_CHALLENGE_HEADER,
5
+ LOCAL_ATTESTATION_PROOF_HEADER,
6
+ createLocalAttestationChallenge,
7
+ verifyLocalAttestationProof,
8
+ } from "../lib/local-management-attestation";
9
+ import {
10
+ GUI_PAIR_BROWSER_ORIGIN_HEADER,
11
+ GUI_PAIR_CAPABILITY_HEADER,
12
+ GUI_PAIR_CAPABILITY_TTL_MS,
13
+ GUI_PAIR_CAPABILITY_VERSION,
14
+ GUI_PAIR_EXPECTED_PID_HEADER,
15
+ GUI_PAIR_EXPIRES_AT_HEADER,
16
+ GUI_PAIR_METHOD,
17
+ GUI_PAIR_NONCE_HEADER,
18
+ GUI_PAIR_PATH,
19
+ canonicalGuiBrowserOrigin,
20
+ createGuiPairCapability,
21
+ } from "../lib/gui-pair-capability";
22
+ import { directLocalHttpFetch } from "../server/direct-local-http";
23
+ import {
24
+ isOpencodexHealthz,
25
+ probeHostname,
26
+ type HealthzIdentity,
27
+ type LiveProxy,
28
+ } from "../server/proxy-liveness";
29
+
30
+ export type GuiPairRequestResult =
31
+ | { kind: "created"; grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number }
32
+ | { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" };
33
+
34
+ export interface GuiPairClientDeps {
35
+ fetchImpl?: typeof fetch;
36
+ readRuntime?: (pid: number) => RuntimePortState | null;
37
+ createChallenge?: () => string;
38
+ now?: () => number;
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ const GUI_PAIR_REQUEST_TIMEOUT_MS = 10_000;
43
+
44
+ function sameRuntime(left: RuntimePortState, right: RuntimePortState | null): boolean {
45
+ const leftSecret = Buffer.from(left.attestationSecret ?? "");
46
+ const rightSecret = Buffer.from(right?.attestationSecret ?? "");
47
+ return !!right?.attestationSecret
48
+ && right.pid === left.pid
49
+ && right.port === left.port
50
+ && right.hostname === left.hostname
51
+ && leftSecret.length === rightSecret.length
52
+ && timingSafeEqual(leftSecret, rightSecret);
53
+ }
54
+
55
+ function canonicalHttpOrigin(value: unknown): string | null {
56
+ if (typeof value !== "string") return null;
57
+ try {
58
+ const parsed = new URL(value);
59
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
60
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
61
+ return parsed.origin;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function parseCreatedResult(value: unknown, browserOrigin: string): GuiPairRequestResult | null {
68
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
69
+ const record = value as Record<string, unknown>;
70
+ if (
71
+ typeof record.grant !== "string"
72
+ || !/^ocx_pair_[A-Za-z0-9_-]{43}$/.test(record.grant)
73
+ || canonicalGuiBrowserOrigin(record.browserOrigin) !== browserOrigin
74
+ || typeof record.expiresAt !== "number"
75
+ || !Number.isSafeInteger(record.expiresAt)
76
+ ) return null;
77
+ const serverOrigin = canonicalHttpOrigin(record.serverOrigin);
78
+ if (!serverOrigin) return null;
79
+ return {
80
+ kind: "created",
81
+ grant: record.grant,
82
+ browserOrigin,
83
+ serverOrigin,
84
+ expiresAt: record.expiresAt,
85
+ };
86
+ }
87
+
88
+ export async function requestBoundGuiPairingGrant(
89
+ target: LiveProxy,
90
+ browserOrigin: string,
91
+ deps: GuiPairClientDeps = {},
92
+ ): Promise<GuiPairRequestResult> {
93
+ if (target.source !== "runtime" || target.pid === null || target.pid <= 0) {
94
+ return { kind: "unavailable", reason: "unattested-target" };
95
+ }
96
+ const canonicalOrigin = canonicalGuiBrowserOrigin(browserOrigin);
97
+ if (!canonicalOrigin || canonicalOrigin !== browserOrigin) {
98
+ return { kind: "unavailable", reason: "capability" };
99
+ }
100
+ const readRuntime = deps.readRuntime ?? readRuntimePort;
101
+ const runtime = readRuntime(target.pid);
102
+ if (!runtime?.attestationSecret || runtime.pid !== target.pid || runtime.port !== target.port) {
103
+ return { kind: "unavailable", reason: "runtime-mismatch" };
104
+ }
105
+ const fetchImpl = deps.fetchImpl ?? directLocalHttpFetch;
106
+ const timeoutMs = deps.timeoutMs ?? GUI_PAIR_REQUEST_TIMEOUT_MS;
107
+ const challenge = (deps.createChallenge ?? createLocalAttestationChallenge)();
108
+ const baseUrl = `http://${probeHostname(target.hostname)}:${target.port}`;
109
+ let proofResponse: Response;
110
+ try {
111
+ proofResponse = await fetchImpl(`${baseUrl}/healthz`, {
112
+ headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge },
113
+ signal: AbortSignal.timeout(timeoutMs),
114
+ });
115
+ } catch {
116
+ return { kind: "unavailable", reason: "transport" };
117
+ }
118
+ const body = await proofResponse.json().catch(() => null) as HealthzIdentity | null;
119
+ if (
120
+ !proofResponse.ok
121
+ || !isOpencodexHealthz(body)
122
+ || body?.pid !== target.pid
123
+ || body?.port !== target.port
124
+ || !verifyLocalAttestationProof(
125
+ runtime.attestationSecret,
126
+ challenge,
127
+ target.pid,
128
+ target.port,
129
+ proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER),
130
+ )
131
+ ) return { kind: "unavailable", reason: "attestation" };
132
+ if (body.guiPairCapability !== GUI_PAIR_CAPABILITY_VERSION) {
133
+ return { kind: "unavailable", reason: "capability" };
134
+ }
135
+ if (!sameRuntime(runtime, readRuntime(target.pid))) {
136
+ return { kind: "unavailable", reason: "runtime-mismatch" };
137
+ }
138
+ const expiresAt = (deps.now ?? Date.now)() + GUI_PAIR_CAPABILITY_TTL_MS;
139
+ const capability = createGuiPairCapability(
140
+ runtime.attestationSecret,
141
+ challenge,
142
+ GUI_PAIR_METHOD,
143
+ GUI_PAIR_PATH,
144
+ browserOrigin,
145
+ target.pid,
146
+ target.port,
147
+ expiresAt,
148
+ );
149
+ if (!capability) return { kind: "unavailable", reason: "capability" };
150
+ let response: Response;
151
+ try {
152
+ response = await fetchImpl(`${baseUrl}${GUI_PAIR_PATH}`, {
153
+ method: GUI_PAIR_METHOD,
154
+ headers: {
155
+ "Content-Length": "0",
156
+ [GUI_PAIR_EXPECTED_PID_HEADER]: String(target.pid),
157
+ [GUI_PAIR_NONCE_HEADER]: challenge,
158
+ [GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt),
159
+ [GUI_PAIR_BROWSER_ORIGIN_HEADER]: browserOrigin,
160
+ [GUI_PAIR_CAPABILITY_HEADER]: capability,
161
+ },
162
+ signal: AbortSignal.timeout(timeoutMs),
163
+ });
164
+ } catch {
165
+ return { kind: "unavailable", reason: "transport" };
166
+ }
167
+ if (!response.ok) return { kind: "unavailable", reason: "rejected" };
168
+ const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin);
169
+ return result ?? { kind: "unavailable", reason: "rejected" };
170
+ }
package/src/cli/gui.ts ADDED
@@ -0,0 +1,87 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability";
3
+ import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness";
4
+ import {
5
+ requestBoundGuiPairingGrant,
6
+ type GuiPairClientDeps,
7
+ type GuiPairRequestResult,
8
+ } from "./gui-pair-client";
9
+ import type { RuntimeApiDeps } from "./runtime-api";
10
+
11
+ const GUI_USAGE = "ocx gui [pair --origin <browser-origin> [--json]]";
12
+ const PAIRING_WARNING = "Pairing grants are secret, single-use, and expire quickly. Do not save them.";
13
+
14
+ export interface GuiCommandDeps extends RuntimeApiDeps {
15
+ openDefaultGui: () => Promise<number>;
16
+ loadConfig: () => OcxConfig;
17
+ findLiveProxy?: () => Promise<LiveProxy | null>;
18
+ requestPairingGrant?: (
19
+ target: LiveProxy,
20
+ browserOrigin: string,
21
+ deps?: GuiPairClientDeps,
22
+ ) => Promise<GuiPairRequestResult>;
23
+ }
24
+
25
+ function allowedPairingOrigin(origin: string, config: OcxConfig): boolean {
26
+ if (config.runtimeRole !== "hub") return false;
27
+ if (canonicalGuiBrowserOrigin(config.hub?.managementPublicOrigin) === origin) return true;
28
+ return (config.corsAllowOrigins ?? []).some(value => canonicalGuiBrowserOrigin(value) === origin);
29
+ }
30
+
31
+ function parsePairArgs(args: string[]): { origin: string; json: boolean } | null {
32
+ let origin: string | undefined;
33
+ let json = false;
34
+ for (let index = 0; index < args.length; index++) {
35
+ const arg = args[index]!;
36
+ if (arg === "--json" && !json) {
37
+ json = true;
38
+ continue;
39
+ }
40
+ if (arg === "--origin" && origin === undefined) {
41
+ const value = args[++index];
42
+ if (!value || value.startsWith("--")) return null;
43
+ origin = value;
44
+ continue;
45
+ }
46
+ return null;
47
+ }
48
+ return origin ? { origin, json } : null;
49
+ }
50
+
51
+ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promise<number> {
52
+ if (args.length === 0) return deps.openDefaultGui();
53
+ if (args[0] !== "pair") {
54
+ console.error(`Usage: ${GUI_USAGE}`);
55
+ return 1;
56
+ }
57
+ const parsed = parsePairArgs(args.slice(1));
58
+ const canonicalOrigin = parsed ? canonicalGuiBrowserOrigin(parsed.origin) : null;
59
+ if (!parsed || !canonicalOrigin || canonicalOrigin !== parsed.origin) {
60
+ console.error(`Usage: ${GUI_USAGE}`);
61
+ return 1;
62
+ }
63
+ const config = deps.loadConfig();
64
+ if (!allowedPairingOrigin(canonicalOrigin, config)) {
65
+ console.error("The pairing origin is not enabled by hub.managementPublicOrigin or corsAllowOrigins.");
66
+ return 1;
67
+ }
68
+ const target = await (deps.findLiveProxy ?? findLiveProxy)();
69
+ if (!target) {
70
+ console.error("No running attested OpenCodex proxy is available for GUI pairing.");
71
+ return 1;
72
+ }
73
+ const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, canonicalOrigin, {
74
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
75
+ });
76
+ if (result.kind !== "created") {
77
+ console.error(`GUI pairing failed (${result.reason}).`);
78
+ return 1;
79
+ }
80
+ if (parsed.json) {
81
+ console.log(JSON.stringify({ ...result, warning: PAIRING_WARNING }));
82
+ } else {
83
+ console.log(result.grant);
84
+ console.error(PAIRING_WARNING);
85
+ }
86
+ return 0;
87
+ }