@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.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
@@ -15,14 +15,21 @@ import {
15
15
  connectClient,
16
16
  } from "../client/connect";
17
17
  import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state";
18
+ import { readClientLinkState } from "../client/link-state";
19
+ import { teardownClientLink } from "../client/link-teardown";
20
+ import { reapOrphanTunnel } from "../client/link-tunnel";
18
21
  import { readServiceApiTokenState } from "../lib/service-secrets";
19
22
  import type { ClientLifecycleLockDeps } from "../client/lifecycle-lock";
23
+ import { linkKnownHostsPath } from "../link/paths";
24
+ import { createSshRunner, type SshRunner } from "../link/ssh-runner";
25
+ import type { OrphanTunnelResult } from "../client/link-tunnel";
20
26
  import { inspectRemoteDesktopStore } from "../claude/desktop-remote-store";
21
27
  import type { OcxConnectedClientId } from "../types";
22
28
  import {
23
29
  CliUsageError,
24
30
  csv,
25
31
  printData,
32
+ readSecretBytes,
26
33
  readSecretLine,
27
34
  rejectArgs,
28
35
  runCliAction,
@@ -37,6 +44,12 @@ import {
37
44
  export interface ClientCommandDeps extends RuntimeApiDeps {
38
45
  lifecycleLockDeps?: ClientLifecycleLockDeps;
39
46
  catalogProbeDeps?: ClientCatalogProbeDeps;
47
+ linkTeardownDeps?: {
48
+ runner?: Pick<SshRunner, "run">;
49
+ reapOrphanTunnel?: () => Promise<OrphanTunnelResult>;
50
+ knownHostsFile?: string;
51
+ timeoutMs?: number;
52
+ };
40
53
  }
41
54
 
42
55
  export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps {
@@ -54,6 +67,8 @@ export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps {
54
67
  }
55
68
 
56
69
  export const CONNECT_USAGE = `Usage:
70
+ ocx connect --link --key-stdin --tunnel-port <port> --link-id <id>
71
+ [--clients codex,claude] [--catalog-timeout <seconds>] [--no-sync]
57
72
  ocx connect <url> [--management-url <url>]
58
73
  (--pairing-code-stdin | --admin-token-stdin)
59
74
  [--clients codex,claude] [--management-transport direct|relay]
@@ -72,6 +87,8 @@ export type ClientConnectionStatus = {
72
87
  serverUrl?: string;
73
88
  managementUrl?: string;
74
89
  managementTransport?: "direct" | "relay";
90
+ transport?: "hub" | "link";
91
+ link?: { tunnelPort: number; linkId: string };
75
92
  protocolVersion?: number;
76
93
  apiKeyId?: string;
77
94
  selectedClients?: OcxConnectedClientId[];
@@ -199,6 +216,8 @@ export function collectClientConnectionStatus(
199
216
  serverUrl: state.value.serverUrl,
200
217
  managementUrl: state.value.managementUrl,
201
218
  managementTransport: state.value.managementTransport,
219
+ ...(state.value.transport ? { transport: state.value.transport } : {}),
220
+ ...(state.value.link ? { link: { ...state.value.link } } : {}),
202
221
  protocolVersion: state.value.protocolVersion,
203
222
  apiKeyId: state.value.apiKeyId,
204
223
  selectedClients: [...state.value.selectedClients],
@@ -221,6 +240,27 @@ function parseClients(raw: string | undefined): OcxConnectedClientId[] {
221
240
  return values as OcxConnectedClientId[];
222
241
  }
223
242
 
243
+ async function readLinkCredential(deps: ClientCommandDeps): Promise<{ apiKeyId: string; key: string }> {
244
+ const raw = await readSecretBytes(deps, "link credential");
245
+ try {
246
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
247
+ const parsed: unknown = JSON.parse(text);
248
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("shape");
249
+ const record = parsed as Record<string, unknown>;
250
+ if (Object.keys(record).some(key => key !== "apiKeyId" && key !== "key")
251
+ || typeof record.apiKeyId !== "string" || !record.apiKeyId.trim()
252
+ || record.apiKeyId.length > 256 || typeof record.key !== "string"
253
+ || !/^ocx_data_[0-9a-f]{40}$/.test(record.key)) {
254
+ throw new Error("shape");
255
+ }
256
+ return { apiKeyId: record.apiKeyId, key: record.key };
257
+ } catch {
258
+ throw new CliUsageError("invalid link credential", CONNECT_USAGE);
259
+ } finally {
260
+ raw.fill(0);
261
+ }
262
+ }
263
+
224
264
  /** Reads as a verdict, not a field dump: "ready" is the only word that means the client works. */
225
265
  function readinessLine(status: ClientConnectionStatus): string {
226
266
  const label = status.readiness === "ready"
@@ -290,6 +330,8 @@ function statusLines(status: ClientConnectionStatus): string[] {
290
330
  // Second line on purpose. The whole of #4207 is that a reader stopped at "connected" and
291
331
  // believed the client was usable, so the local verdict has to arrive before the hub detail.
292
332
  readinessLine(status),
333
+ `Transport: ${status.transport ?? "hub"}`,
334
+ ...(status.link ? [`Link: ${status.link.linkId} via tunnel port ${status.link.tunnelPort}`] : []),
293
335
  `Hub: ${status.serverUrl}`,
294
336
  `Management: ${status.managementUrl} (${status.managementTransport})`,
295
337
  `Protocol: ${status.protocolVersion}`,
@@ -323,7 +365,7 @@ async function runRotate(argv: string[], deps: ClientCommandDeps): Promise<void>
323
365
  ]);
324
366
  }
325
367
 
326
- async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void> {
368
+ async function runHubConnect(argv: string[], deps: ClientCommandDeps): Promise<void> {
327
369
  const args = [...argv];
328
370
  const serverUrl = args.shift();
329
371
  if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE);
@@ -375,6 +417,64 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void
375
417
  if (report.failure) throw new Error(report.failure);
376
418
  }
377
419
 
420
+ async function runLinkConnect(argv: string[], deps: ClientCommandDeps): Promise<void> {
421
+ const args = [...argv];
422
+ const keyStdin = takeFlag(args, "--key-stdin");
423
+ const rawPort = takeIntegerOption(args, "--tunnel-port", { min: 1024 });
424
+ const linkId = takeOption(args, "--link-id");
425
+ const managementUrl = takeOption(args, "--management-url");
426
+ const managementTransport = takeOption(args, "--management-transport");
427
+ const pairing = takeFlag(args, "--pairing-code-stdin");
428
+ const admin = takeFlag(args, "--admin-token-stdin");
429
+ const clients = parseClients(takeOption(args, "--clients"));
430
+ const catalogTimeoutSeconds = takeIntegerOption(args, "--catalog-timeout", { min: 1 });
431
+ if (catalogTimeoutSeconds !== undefined && catalogTimeoutSeconds > 120) {
432
+ throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE);
433
+ }
434
+ const noSync = takeFlag(args, "--no-sync");
435
+ if (!keyStdin || rawPort === undefined || !linkId) {
436
+ throw new CliUsageError("link connect requires --key-stdin, --tunnel-port, and --link-id", CONNECT_USAGE);
437
+ }
438
+ if (managementUrl || managementTransport !== undefined || pairing || admin || args.some(value => !value.startsWith("--"))) {
439
+ throw new CliUsageError("link connect accepts no hub URL, management URL, management transport, pairing code, or admin token", CONNECT_USAGE);
440
+ }
441
+ if (rawPort > 65535 || !/^lnk_[0-9a-f]{16}$/.test(linkId)) {
442
+ throw new CliUsageError("invalid link transport", CONNECT_USAGE);
443
+ }
444
+ rejectArgs(args, CONNECT_USAGE, { redactValues: true });
445
+ const credential = await readLinkCredential(deps);
446
+ const connection = await connectClient({
447
+ serverUrl: `http://127.0.0.1:${rawPort}`,
448
+ managementUrl: `http://127.0.0.1:${rawPort}`,
449
+ credential: { kind: "link", ...credential },
450
+ selectedClients: clients,
451
+ managementTransport: "direct",
452
+ transport: "link",
453
+ link: { tunnelPort: rawPort, linkId },
454
+ noSync,
455
+ ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }),
456
+ }, {
457
+ fetchImpl: deps.fetchImpl,
458
+ lifecycleLockDeps: deps.lifecycleLockDeps,
459
+ catalogCompatibility: catalogObserver(deps.catalogProbeDeps),
460
+ }).catch((error: unknown) => {
461
+ throw terminalSafeError(error);
462
+ });
463
+ const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {});
464
+ const report = connectCompletionReport(connection, clients, readiness);
465
+ for (const line of report.lines) console.log(line);
466
+ if (report.failure) throw new Error(report.failure);
467
+ }
468
+
469
+ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void> {
470
+ const args = [...argv];
471
+ if (takeFlag(args, "--link")) {
472
+ await runLinkConnect(args, deps);
473
+ return;
474
+ }
475
+ await runHubConnect(argv, deps);
476
+ }
477
+
378
478
  async function runRevoke(argv: string[], deps: ClientCommandDeps): Promise<void> {
379
479
  const args = [...argv];
380
480
  const wantsJson = takeFlag(args, "--json");
@@ -408,15 +508,33 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe
408
508
  });
409
509
  }
410
510
 
411
- export async function handleDisconnectCommand(argv: string[], deps: Pick<ClientCommandDeps, "lifecycleLockDeps"> = {}): Promise<number> {
511
+ export async function handleDisconnectCommand(
512
+ argv: string[],
513
+ deps: Pick<ClientCommandDeps, "lifecycleLockDeps" | "linkTeardownDeps"> = {},
514
+ ): Promise<number> {
412
515
  return runCliAction(async () => {
413
516
  const args = [...argv];
414
517
  const keepCatalog = takeFlag(args, "--keep-catalog");
415
518
  const wantsJson = takeFlag(args, "--json");
416
519
  rejectArgs(args, DISCONNECT_USAGE, { redactValues: true });
520
+ const teardown = await teardownClientLink({
521
+ readSidecar: readClientLinkState,
522
+ connectedLinkId: () => {
523
+ const state = readClientConnectionState();
524
+ return state.kind === "connected" && state.value.transport === "link"
525
+ ? state.value.link?.linkId ?? null
526
+ : null;
527
+ },
528
+ reapOrphanTunnel: deps.linkTeardownDeps?.reapOrphanTunnel ?? (() => reapOrphanTunnel()),
529
+ runner: deps.linkTeardownDeps?.runner ?? createSshRunner(),
530
+ knownHostsFile: deps.linkTeardownDeps?.knownHostsFile ?? linkKnownHostsPath(),
531
+ timeoutMs: deps.linkTeardownDeps?.timeoutMs,
532
+ });
417
533
  const result = await disconnectClient({ keepCatalog }, deps);
418
534
  const payload = {
419
535
  ...result,
536
+ homeRevoke: teardown.homeRevoke,
537
+ tunnel: teardown.tunnel,
420
538
  revoke: {
421
539
  apiKeyId: result.apiKeyId,
422
540
  location: "Integrations → API Keys",
@@ -427,7 +545,13 @@ export async function handleDisconnectCommand(argv: string[], deps: Pick<ClientC
427
545
  ...(result.desktopRestoration === "standard_fallback"
428
546
  ? ["Previous Desktop settings were not recorded; the managed profile was switched to standard mode."] : []),
429
547
  ...(result.restartRequired ? ["Fully quit and reopen Claude Desktop; local cleanup cannot revoke an in-memory credential."] : []),
430
- `The hub key ${result.apiKeyId} is still valid. Revoke it from Integrations → API Keys.`,
548
+ ...(teardown.homeRevoke === "failed" && teardown.linkId
549
+ ? [`Home revoke failed; run ocx link revoke --link-id ${teardown.linkId} on the home.`] : []),
550
+ ...(teardown.tunnel?.tunnel === "unresolved"
551
+ ? [`A link tunnel (pid ${teardown.tunnel.pid}) may still be running; stop it if it is.`] : []),
552
+ teardown.homeRevoke === "revoked"
553
+ ? `The home revoked link ${teardown.linkId} and its key ${result.apiKeyId}.`
554
+ : `The hub key ${result.apiKeyId} is still valid. Revoke it from Integrations → API Keys.`,
431
555
  ]);
432
556
  });
433
557
  }
@@ -539,6 +539,10 @@ const commandRunners: Record<string, CommandRunner> = {
539
539
  const { handleConnectCommand } = await import("./connect");
540
540
  return await handleConnectCommand(deps.args.slice(1));
541
541
  },
542
+ link: async deps => {
543
+ const { runLinkCommand } = await import("./link");
544
+ return await runLinkCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy });
545
+ },
542
546
  "remote-workspace": async deps => {
543
547
  const { runRemoteWorkspaceCommand } = await import("./remote-workspace");
544
548
  return await runRemoteWorkspaceCommand(deps.args.slice(1));
@@ -892,6 +896,10 @@ const commandRunners: Record<string, CommandRunner> = {
892
896
  const { handleAccessCommand } = await import("./access");
893
897
  return await handleAccessCommand(["key", ...deps.args.slice(1)]);
894
898
  },
899
+ api: async deps => {
900
+ const { handleApiCommand } = await import("./api-protocols");
901
+ return await handleApiCommand(deps.args.slice(1));
902
+ },
895
903
  export: async deps => {
896
904
  const { handleExportCommand } = await import("./export-command");
897
905
  return await handleExportCommand(deps.args.slice(1));
package/src/cli/doctor.ts CHANGED
@@ -42,7 +42,9 @@ import {
42
42
  } from "../codex/coordinator-doctor";
43
43
  import {
44
44
  inspectAbandonedResponseStateTemps,
45
+ inspectResponseSpillStorage,
45
46
  reclaimAbandonedResponseStateTemps,
47
+ type ResponseSpillDirInspection,
46
48
  type ResponseStateTempRecoveryResult,
47
49
  } from "../responses/state";
48
50
  import {
@@ -861,6 +863,34 @@ export function formatResponseTempLines(
861
863
  return lines;
862
864
  }
863
865
 
866
+ /**
867
+ * Render the response-state spill section (testable without console capture).
868
+ *
869
+ * Always dry-run: doctor reports what the disk looks like, it never unlinks
870
+ * spill files. "Owned" counts union the live store and the persisted snapshot,
871
+ * so a file a restart would re-own is never reported as garbage.
872
+ */
873
+ export function formatResponseSpillLines(result: ResponseSpillDirInspection): string[] {
874
+ // A truncated scan saw only a prefix of the directory, so zero orphans there is
875
+ // not a clean bill of health: the unscanned tail may still hold them.
876
+ if (result.orphanFiles === 0 && result.truncated) {
877
+ return [
878
+ ` !! No orphaned response-state spill files in the first ${result.scanned} entries (${result.files} file(s), ${mb(result.bytes)} scanned).`,
879
+ " Scan stopped at its entry budget; the rest of the directory was not checked.",
880
+ ];
881
+ }
882
+ if (result.orphanFiles === 0) {
883
+ return [` ok No orphaned response-state spill files (${result.files} file(s), ${mb(result.bytes)} on disk).`];
884
+ }
885
+ const lines = [
886
+ ` !! ${result.orphanFiles} unreferenced response-state spill file(s), ${mb(result.orphanBytes)} reclaimable.`,
887
+ ` ${result.ownedFiles} file(s), ${mb(result.ownedBytes)} still owned by the store or the persisted snapshot.`,
888
+ " The running proxy reclaims orphans on its periodic sweep; do not delete spill files manually.",
889
+ ];
890
+ if (result.truncated) lines.push(" Scan stopped at its entry budget; the real total is higher.");
891
+ return lines;
892
+ }
893
+
864
894
  export function formatCoordinatorDoctorLines(diagnostic: CodexCoordinatorDiagnostic): string[] {
865
895
  const pathLine = diagnostic.path ? [` path: ${diagnostic.path}`] : [];
866
896
  const evidenceLines = "evidence" in diagnostic && diagnostic.evidence
@@ -1299,6 +1329,9 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1299
1329
  reclaimTemps,
1300
1330
  )) console.log(line);
1301
1331
 
1332
+ console.log("\nResponse-state spill files");
1333
+ for (const line of formatResponseSpillLines(inspectResponseSpillStorage())) console.log(line);
1334
+
1302
1335
  const orcaHome = collectOrcaCodexHomeDiagnostic();
1303
1336
  console.log("\nCodex app home targeting");
1304
1337
  console.log(` ${orcaHome.mismatch ? "!! " : "ok "} Effective Codex home: ${orcaHome.effectiveCodexHome}`);
package/src/cli/help.ts CHANGED
@@ -57,6 +57,7 @@ Usage:
57
57
  Open the dashboard or create a single-use remote pairing grant
58
58
  ocx hub invite [--json] Print a ready-to-run \`ocx connect\` line for one more machine
59
59
  (hub only; see \`ocx help hub\` for the one-port topology)
60
+ ocx link <sub> Machine links over SSH (port|issue|revoke|status)
60
61
  ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
61
62
  ocx restart Stop and restart the proxy
62
63
  ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|keep-native-v1|threads|mode-hint)
@@ -81,6 +82,7 @@ Usage:
81
82
  ocx memory [--json] Alias of ocx observe memory
82
83
  ocx api-key <sub> Alias of ocx access key
83
84
  ocx access <sub> External API keys and endpoint information
85
+ ocx api <sub> Protocol paths: vocabulary, request-path preview, and policy
84
86
  ocx export --client <id> Print a client config wired to the running proxy (15 clients)
85
87
  ocx integration client <sub> Enable, disable, inspect or roll back a client integration
86
88
  ocx grok <sub> Grok Build model selection and apply
@@ -0,0 +1,288 @@
1
+ import { assertSshAlias } from "../link/ssh-argv";
2
+ import { linkStorePath } from "../link/paths";
3
+ import { isLinkPort } from "../link/ports";
4
+ import { readLinkStore, type LinkDirection, type LinkStore } from "../link/store";
5
+ import { findAvailablePort } from "../server/ports";
6
+ import {
7
+ CliUsageError,
8
+ RuntimeApiError,
9
+ rejectArgs,
10
+ runCliAction,
11
+ runtimeBaseUrl,
12
+ runtimeRequest,
13
+ takeIntegerOption,
14
+ takeJsonFlag,
15
+ takeOption,
16
+ type RuntimeApiDeps,
17
+ } from "./runtime-api";
18
+
19
+ export const LINK_USAGE = `Usage:
20
+ ocx link port [--json]
21
+ ocx link issue --alias <alias> --tunnel-port <port> [--json]
22
+ ocx link status [--json]
23
+ ocx link revoke --link-id <id> [--json]`;
24
+
25
+ type LinkState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
26
+ type ListenerState = "off" | "listening" | "failed";
27
+
28
+ export interface LinkStatusPayload {
29
+ role: "standalone" | "home" | "child";
30
+ listener: { state: ListenerState; port: number | null };
31
+ links: Array<{
32
+ id: string;
33
+ alias: string;
34
+ direction: LinkDirection;
35
+ state: LinkState;
36
+ since: string;
37
+ reason: string | null;
38
+ tunnelPort: number;
39
+ }>;
40
+ child: null | {
41
+ alias: string;
42
+ state: LinkState;
43
+ since: string;
44
+ reason: string | null;
45
+ };
46
+ }
47
+
48
+ export interface LinkIssuePayload {
49
+ linkId: string;
50
+ apiKeyId: string;
51
+ key: string;
52
+ listenerPort: number;
53
+ }
54
+
55
+ export interface LinkCliDeps extends RuntimeApiDeps {
56
+ choosePort?: () => Promise<number>;
57
+ readStore?: () => LinkStore;
58
+ readAdminToken?: () => string | null;
59
+ }
60
+
61
+ const LINK_ID = /^lnk_[0-9a-f]{16}$/;
62
+ const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
63
+ const DATA_KEY = /^ocx_data_[0-9a-f]{40}$/;
64
+
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return value !== null && typeof value === "object" && !Array.isArray(value);
67
+ }
68
+
69
+ function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
70
+ const expected = new Set(keys);
71
+ const actual = Object.keys(value);
72
+ if (actual.length !== expected.size || actual.some(key => !expected.has(key))) {
73
+ throw new Error(`invalid link API response: ${label} fields`);
74
+ }
75
+ }
76
+
77
+ function validListenerPort(value: unknown): value is number {
78
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
79
+ }
80
+
81
+ function validString(value: unknown): value is string {
82
+ return typeof value === "string" && value.length > 0;
83
+ }
84
+
85
+ function validNullableString(value: unknown): value is string | null {
86
+ return value === null || typeof value === "string";
87
+ }
88
+
89
+ function validateStatus(value: unknown): LinkStatusPayload {
90
+ if (!isRecord(value)) throw new Error("invalid link API response: status object");
91
+ assertExactKeys(value, ["role", "listener", "links", "child"], "status");
92
+ if (value.role !== "standalone" && value.role !== "home" && value.role !== "child") {
93
+ throw new Error("invalid link API response: status role");
94
+ }
95
+ if (!isRecord(value.listener)) throw new Error("invalid link API response: listener");
96
+ assertExactKeys(value.listener, ["state", "port"], "listener");
97
+ if (value.listener.state !== "off" && value.listener.state !== "listening" && value.listener.state !== "failed") {
98
+ throw new Error("invalid link API response: listener state");
99
+ }
100
+ if (value.listener.port !== null && !validListenerPort(value.listener.port)) {
101
+ throw new Error("invalid link API response: listener port");
102
+ }
103
+ if (!Array.isArray(value.links)) throw new Error("invalid link API response: links");
104
+ const links = value.links.map((candidate, index) => {
105
+ if (!isRecord(candidate)) throw new Error(`invalid link API response: link ${index}`);
106
+ assertExactKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], `link ${index}`);
107
+ if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id)
108
+ || !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated")
109
+ || (candidate.state !== "connecting" && candidate.state !== "connected" && candidate.state !== "reconnecting" && candidate.state !== "failed" && candidate.state !== "idle")
110
+ || !validString(candidate.since) || !validNullableString(candidate.reason) || !isLinkPort(candidate.tunnelPort)) {
111
+ throw new Error(`invalid link API response: link ${index} fields`);
112
+ }
113
+ return {
114
+ id: candidate.id,
115
+ alias: candidate.alias,
116
+ direction: candidate.direction,
117
+ state: candidate.state,
118
+ since: candidate.since,
119
+ reason: candidate.reason,
120
+ tunnelPort: candidate.tunnelPort,
121
+ } as LinkStatusPayload["links"][number];
122
+ });
123
+ let child: LinkStatusPayload["child"] = null;
124
+ if (value.child !== null) {
125
+ if (!isRecord(value.child)) throw new Error("invalid link API response: child");
126
+ assertExactKeys(value.child, ["alias", "state", "since", "reason"], "child");
127
+ if (!validString(value.child.alias)
128
+ || (value.child.state !== "connecting" && value.child.state !== "connected" && value.child.state !== "reconnecting" && value.child.state !== "failed" && value.child.state !== "idle")
129
+ || !validString(value.child.since) || !validNullableString(value.child.reason)) {
130
+ throw new Error("invalid link API response: child fields");
131
+ }
132
+ child = {
133
+ alias: value.child.alias,
134
+ state: value.child.state,
135
+ since: value.child.since,
136
+ reason: value.child.reason,
137
+ };
138
+ }
139
+ return {
140
+ role: value.role,
141
+ listener: { state: value.listener.state, port: value.listener.port },
142
+ links,
143
+ child,
144
+ };
145
+ }
146
+
147
+ function validateIssue(value: unknown): LinkIssuePayload {
148
+ if (!isRecord(value)) throw new Error("invalid link API response: issue object");
149
+ assertExactKeys(value, ["linkId", "apiKeyId", "key", "listenerPort"], "issue");
150
+ if (typeof value.linkId !== "string" || !LINK_ID.test(value.linkId)
151
+ || typeof value.apiKeyId !== "string" || !API_KEY_ID.test(value.apiKeyId)
152
+ || typeof value.key !== "string" || !DATA_KEY.test(value.key)
153
+ || !validListenerPort(value.listenerPort)) {
154
+ throw new Error("invalid link API response: issue fields");
155
+ }
156
+ return {
157
+ linkId: value.linkId,
158
+ apiKeyId: value.apiKeyId,
159
+ key: value.key,
160
+ listenerPort: value.listenerPort,
161
+ };
162
+ }
163
+
164
+ function validateRevoke(value: unknown, expectedLinkId: string): void {
165
+ if (value === null) return;
166
+ if (!isRecord(value)) throw new Error("invalid link API response: revoke object");
167
+ assertExactKeys(value, ["linkId"], "revoke");
168
+ if (value.linkId !== expectedLinkId) throw new Error("invalid link API response: revoke link id");
169
+ }
170
+
171
+ function localStatus(store: LinkStore): LinkStatusPayload {
172
+ const hasLinks = store.links.length > 0;
173
+ return {
174
+ role: hasLinks ? "home" : "standalone",
175
+ listener: {
176
+ state: hasLinks && store.listenerPort !== null ? "listening" : "off",
177
+ port: hasLinks ? store.listenerPort : null,
178
+ },
179
+ links: store.links.map(link => ({
180
+ id: link.id,
181
+ alias: link.alias,
182
+ direction: link.direction,
183
+ state: "idle",
184
+ since: link.createdAt,
185
+ reason: null,
186
+ tunnelPort: link.tunnelPort,
187
+ })),
188
+ child: null,
189
+ };
190
+ }
191
+
192
+ async function linkRequest<T>(path: string, init: RequestInit, deps: LinkCliDeps): Promise<T> {
193
+ const hasTokenOverride = deps.readAdminToken !== undefined;
194
+ const token = deps.readAdminToken?.() ?? null;
195
+ const headers = new Headers(init.headers);
196
+ if (hasTokenOverride) headers.set("x-opencodex-api-key", token ?? "");
197
+ const requestInit: RequestInit = { ...init, headers };
198
+ try {
199
+ return await runtimeRequest<T>(path, requestInit, deps);
200
+ } catch (error) {
201
+ if (error instanceof RuntimeApiError) {
202
+ // Never echo an API error body: issue responses contain a one-time data key. Only the error
203
+ // code survives, because callers branch on it and a code is never secret.
204
+ throw new RuntimeApiError(`Link management request failed (${error.status})`, error.status, errorCodeBody(error.body));
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function errorCodeBody(body: unknown): { error: { code: string } } | null {
211
+ if (!isRecord(body) || !isRecord(body.error)) return null;
212
+ const code = body.error.code;
213
+ return typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? { error: { code } } : null;
214
+ }
215
+
216
+ async function runPort(args: string[], deps: LinkCliDeps): Promise<void> {
217
+ takeJsonFlag(args);
218
+ rejectArgs(args, LINK_USAGE);
219
+ const port = await (deps.choosePort ?? (() => findAvailablePort(0, "127.0.0.1")))();
220
+ if (!isLinkPort(port)) throw new Error("port allocator returned an invalid link port");
221
+ console.log(JSON.stringify({ port }));
222
+ }
223
+
224
+ async function runIssue(args: string[], deps: LinkCliDeps): Promise<void> {
225
+ takeJsonFlag(args);
226
+ const alias = takeOption(args, "--alias");
227
+ const tunnelPort = takeIntegerOption(args, "--tunnel-port", { min: 1024 });
228
+ if (!alias || tunnelPort === undefined || !isLinkPort(tunnelPort)) {
229
+ throw new CliUsageError("issue requires --alias and --tunnel-port", LINK_USAGE);
230
+ }
231
+ try { assertSshAlias(alias); }
232
+ catch { throw new CliUsageError("--alias must be a valid SSH host alias", LINK_USAGE); }
233
+ rejectArgs(args, LINK_USAGE);
234
+ const result = validateIssue(await linkRequest<unknown>("/api/link/issue", {
235
+ method: "POST",
236
+ headers: { "content-type": "application/json" },
237
+ body: JSON.stringify({ alias, tunnelPort }),
238
+ }, deps));
239
+ console.log(JSON.stringify(result));
240
+ }
241
+
242
+ async function runStatus(args: string[], deps: LinkCliDeps): Promise<void> {
243
+ takeJsonFlag(args);
244
+ rejectArgs(args, LINK_USAGE);
245
+ let baseUrl: string;
246
+ try {
247
+ baseUrl = await runtimeBaseUrl(deps);
248
+ } catch (error) {
249
+ if (!deps.baseUrl && error instanceof RuntimeApiError && error.status === 503) {
250
+ const store = (deps.readStore ?? (() => readLinkStore(linkStorePath())))();
251
+ console.log(JSON.stringify(localStatus(store)));
252
+ return;
253
+ }
254
+ throw error;
255
+ }
256
+ const result = validateStatus(await linkRequest<unknown>("/api/link/status", {}, { ...deps, baseUrl }));
257
+ console.log(JSON.stringify(result));
258
+ }
259
+
260
+ async function runRevoke(args: string[], deps: LinkCliDeps): Promise<void> {
261
+ takeJsonFlag(args);
262
+ const linkId = takeOption(args, "--link-id");
263
+ if (!linkId || !LINK_ID.test(linkId)) throw new CliUsageError("revoke requires a valid --link-id", LINK_USAGE);
264
+ rejectArgs(args, LINK_USAGE);
265
+ try {
266
+ const response = await linkRequest<unknown>(`/api/link/${encodeURIComponent(linkId)}`, { method: "DELETE" }, deps);
267
+ validateRevoke(response, linkId);
268
+ } catch (error) {
269
+ // Revoke is idempotent: a link the Home no longer has is already revoked, and a Child retrying
270
+ // a join rollback depends on that answer being success. A 404 without this code comes from a
271
+ // listener that does not serve the management API and stays a failure.
272
+ const code = error instanceof RuntimeApiError && error.status === 404 ? errorCodeBody(error.body)?.error.code : undefined;
273
+ if (code !== "link_not_found") throw error;
274
+ }
275
+ console.log(JSON.stringify({ linkId }));
276
+ }
277
+
278
+ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}): Promise<number> {
279
+ return runCliAction(async () => {
280
+ const args = [...rawArgs];
281
+ const command = args.shift();
282
+ if (command === "port") await runPort(args, deps);
283
+ else if (command === "issue") await runIssue(args, deps);
284
+ else if (command === "status") await runStatus(args, deps);
285
+ else if (command === "revoke") await runRevoke(args, deps);
286
+ else throw new CliUsageError("link requires one of: port, issue, status, revoke", LINK_USAGE);
287
+ });
288
+ }
@@ -130,6 +130,18 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
130
130
  "Credentials are accepted only through stdin; argv and environment credential forms are not supported.",
131
131
  ],
132
132
  },
133
+ {
134
+ name: "link",
135
+ usage: "ocx link <port|issue|status|revoke>",
136
+ summary: "Allocate and manage a loopback remote home link.",
137
+ details: [
138
+ "Port: ocx link port [--json]",
139
+ "Issue: ocx link issue --alias <alias> --tunnel-port <port> [--json]",
140
+ "Status: ocx link status [--json]",
141
+ "Revoke: ocx link revoke --link-id <id> [--json]",
142
+ "Issue, status, and revoke use the running proxy's loopback management API and admin token.",
143
+ ],
144
+ },
133
145
  {
134
146
  name: "remote-workspace",
135
147
  usage: "ocx remote-workspace <pair|agent|status>",
@@ -391,6 +403,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
391
403
  ],
392
404
  },
393
405
  { name: "api-key", usage: "ocx api-key <list|create|rotate|remove> ...", summary: "Alias of ocx access key." },
406
+ {
407
+ name: "api",
408
+ usage: "ocx api <protocols|explain|policy> ...",
409
+ summary: "Inspect protocol paths, preview a request path, and read or change the protocol policy.",
410
+ details: [
411
+ "protocols [--provider <name>] Contract version, API surfaces, protocol settings and feature vocabulary.",
412
+ "explain --model <id> --inbound <responses|chat|messages> [--feature <key>]... Preview the request path; sends nothing upstream.",
413
+ "policy Read the protocol policy; with --messages, --unrepresentable or --rollout <switch>=<on|off> it changes config.",
414
+ "Every rollout switch defaults off. `ocx api policy` writes only when a setting flag is given.",
415
+ ],
416
+ },
394
417
  {
395
418
  name: "export",
396
419
  usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside|raycast|omo|cline> [--json] [--out <path>] [--force]",