@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -1,126 +1,29 @@
1
- import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { mkdtempSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../../link/ssh-argv";
6
- import { parseFingerprintLine } from "../../link/fingerprint";
7
- import { awaitFirstAdmission } from "../../link/admission-wait";
8
- import { linkKnownHostsPath, linkStorePath } from "../../link/paths";
9
- import { clearCompensationFailed, compensationPath, markCompensationFailed, readCompensation } from "../../link/compensation";
1
+ import { probe, confirmHost, apply } from "./link-enrollment";
2
+ import { fail, pairedSession, auth, port, isRecord, exactBody, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, issueKey, revokeKeyIdempotent, compensateNewLink, type LinkRouteState } from "./link-route-state";
3
+ export type { LinkRouteState, PendingLinkHost, ConfirmedLinkHost, LinkRouteListener } from "./link-route-state";
4
+ import { assertSshAlias, buildExecArgv, remoteOcxArgv } from "../../link/ssh-argv";
5
+ import { clearCompensationFailed, compensationPath, readCompensation } from "../../link/compensation";
10
6
  import { loadHostCandidates } from "../../link/ssh-config";
11
- import { newLinkId, readLinkStore, writeLinkStore, type LinkStore } from "../../link/store";
12
- import { boundHint, createSshRunner, sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../../link/ssh-runner";
7
+ import { newLinkId, type LinkStore } from "../../link/store";
8
+ import { sshFailureHint, sshRunnerErrorHint } from "../../link/ssh-runner";
13
9
  import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
14
10
  import { isLinkPort } from "../../link/ports";
15
11
  import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
12
+ import { clientLinkStatusIdentity } from "../../client/link-status";
16
13
  import { clientLinkTunnelStatus } from "../../client/link-tunnel";
14
+ import { withConfigMutationLockSync } from "../../config/mutation-lock";
17
15
  import type { ManagementContext } from "./context";
18
16
  import { readManagementJsonBodyOr } from "./body";
19
- import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes";
17
+ import { type IssuedApiKey } from "./oauth-account-routes";
20
18
  import { acceptSystemRestart, resolveListenPort } from "./system-restart";
21
19
 
22
- const PROBE_TTL_MS = 5 * 60_000;
23
- const APPLY_ADMISSION_TIMEOUT_MS = 15_000;
24
20
  const LINK_ID = /^lnk_[0-9a-f]{16}$/;
25
- // `ocx link` and `ocx connect --link` first shipped in 2.66.0; an older remote passes --version
26
- // and then fails at apply with remote_port_failed.
27
- const MIN_REMOTE_OCX = [2, 66, 0] as const;
28
- // A bounded semver shape: the parsed version reaches the confirm-host body and the outdated hint,
29
- // so the tail admits only a pre-release and build of semver identifier characters and must end
30
- // the token. Anything else attached to the version makes the output unrecognized.
31
- const REMOTE_OCX_VERSION = /^opencodex (\d{1,9})\.(\d{1,9})\.(\d{1,9})(-[0-9A-Za-z.-]{1,64})?(\+[0-9A-Za-z.-]{1,64})?(?=\s|$)/;
32
21
  const isLinkPath = (path: string): boolean => path === "/api/link" || path.startsWith("/api/link/");
33
22
 
34
- export interface PendingLinkHost {
35
- alias: string;
36
- fingerprint: string;
37
- keyType: string;
38
- knownHostLine: string;
39
- probedAt: number;
40
- }
41
-
42
- export interface ConfirmedLinkHost extends PendingLinkHost {
43
- ocxVersion: string;
44
- }
45
-
46
- export interface LinkRouteListener {
47
- ensureStarted(): Promise<void>;
48
- status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
49
- close(): Promise<void>;
50
- onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
51
- }
52
-
53
- export interface LinkRouteState {
54
- pendingHosts: Map<string, PendingLinkHost>;
55
- confirmedHosts?: Map<string, ConfirmedLinkHost>;
56
- compensationFailures?: Map<string, { since: string; reason: "compensation_failed" }>;
57
- supervisor: import("../../link/supervisor").LinkSupervisor;
58
- listener: LinkRouteListener;
59
- }
60
-
61
23
  const states = new WeakMap<object, LinkRouteState>();
62
24
  // One process runs at most one join: a join ends by restarting this process as a client.
63
25
  let joinInProgress = false;
64
26
 
65
- /**
66
- * `hint` is one line bounded by `boundHint`: from ssh stderr (`sshFailureHint`), from the ssh
67
- * runner's own failure (`sshRunnerErrorHint`), or the parsed remote version for
68
- * `remote_ocx_outdated`. It is returned, never logged.
69
- */
70
- function fail(code: string, message: string, status: number, hint?: string): Response {
71
- return Response.json({ error: { code, message, ...(hint ? { hint } : {}) } }, { status, headers: { "cache-control": "no-store" } });
72
- }
73
-
74
- function isRecord(value: unknown): value is Record<string, unknown> {
75
- return value !== null && typeof value === "object" && !Array.isArray(value);
76
- }
77
-
78
- function exactBody(body: unknown, keys: readonly string[]): Record<string, unknown> | null {
79
- if (!isRecord(body)) return null;
80
- const actual = Object.keys(body).sort();
81
- const expected = [...keys].sort();
82
- return actual.length === expected.length && actual.every((key, index) => key === expected[index]) ? body : null;
83
- }
84
-
85
- function port(value: unknown): value is number {
86
- return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
87
- }
88
-
89
- /** A GUI session redeemed from an operator-created, one-use pairing grant. */
90
- function pairedSession(ctx: ManagementContext): boolean {
91
- return ctx.principal === "gui-session"
92
- && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
93
- }
94
-
95
- /**
96
- * A paired session, or on a standalone runtime the current loopback-issued session that reached
97
- * the public listener bound to a loopback hostname. The loopback bootstrap mints that session
98
- * without a credential, so this is casual-path protection like POST /api/github/star, not a
99
- * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Durable operations
100
- * that require operator approval, such as joining this machine as a Child, use pairedSession.
101
- */
102
- function dashboardSession(ctx: ManagementContext): boolean {
103
- if (pairedSession(ctx)) return true;
104
- return ctx.principal === "gui-session"
105
- && (ctx.config.runtimeRole ?? "standalone") === "standalone"
106
- && ctx.guiSessionIssuance === "loopback"
107
- && ctx.trustedLoopbackIngress
108
- && ctx.sessionControl?.isCurrent(ctx.req, ctx.config) === true;
109
- }
110
-
111
- function adminLoopback(ctx: ManagementContext): boolean {
112
- return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
113
- }
114
-
115
- function auth(ctx: ManagementContext, kind: "dashboard" | "paired" | "admin" | "either"): Response | null {
116
- if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
117
- const allowed = kind === "dashboard" ? dashboardSession(ctx)
118
- : kind === "paired" ? pairedSession(ctx)
119
- : kind === "admin" ? adminLoopback(ctx)
120
- : dashboardSession(ctx) || adminLoopback(ctx);
121
- return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403);
122
- }
123
-
124
27
  /**
125
28
  * The client runtime a join restarts into binds exactly the configured port, with no fallback.
126
29
  * A standalone that runs elsewhere (`ocx start --port`, or a port fallback) would restart into a
@@ -141,18 +44,6 @@ function joinDenied(ctx: ManagementContext): LinkJoinDenied | null {
141
44
  return null;
142
45
  }
143
46
 
144
- function runnerFor(ctx: ManagementContext): SshRunner {
145
- return ctx.deps.sshRunner ?? createSshRunner();
146
- }
147
-
148
- function readStoreFor(ctx: ManagementContext): LinkStore {
149
- return ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath());
150
- }
151
-
152
- function writeStoreFor(ctx: ManagementContext, store: LinkStore): void {
153
- (ctx.deps.writeLinkStore ?? (value => writeLinkStore(linkStorePath(), value)))(store);
154
- }
155
-
156
47
  function stateFor(ctx: ManagementContext): LinkRouteState | null {
157
48
  const supervisor = ctx.deps.linkSupervisor?.();
158
49
  const listener = ctx.deps.linkListener?.();
@@ -171,246 +62,11 @@ function stateFor(ctx: ManagementContext): LinkRouteState | null {
171
62
  return state;
172
63
  }
173
64
 
174
- function knownHostsFile(ctx: ManagementContext): string {
175
- return ctx.deps.linkKnownHostsPath?.() ?? linkKnownHostsPath();
176
- }
177
-
178
- function putKnownHost(path: string, alias: string, line: string): string {
179
- const before = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
180
- const lines = before.split(/\r?\n/).filter(value => value && !value.startsWith(`${alias} `) && !value.startsWith(`${alias},`));
181
- lines.push(line.trim());
182
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
183
- writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
184
- try { chmodSync(path, 0o600); } catch { /* best effort on Windows */ }
185
- return before;
186
- }
187
-
188
- function restoreKnownHost(path: string, before: string): void {
189
- if (!before) {
190
- try { rmSync(path, { force: true }); } catch { /* best effort compensation */ }
191
- return;
192
- }
193
- writeFileSync(path, before, { mode: 0o600 });
194
- }
195
-
196
- function issueKey(ctx: ManagementContext, name: string): IssuedApiKey {
197
- return (ctx.deps.issueApiKey ?? issueApiKeyInProcess)(ctx.config, name);
198
- }
199
-
200
- function revokeKey(ctx: ManagementContext, id: string): boolean {
201
- return (ctx.deps.revokeApiKey ?? revokeApiKeyInProcess)(ctx.config, id);
202
- }
203
-
204
- /**
205
- * Revocation that a retry can repeat. A key that is no longer in the live config is already
206
- * revoked, which is exactly the state a partially completed cleanup leaves behind; without this
207
- * a retried DELETE would fail forever on the key it removed the first time.
208
- */
209
- function revokeKeyIdempotent(ctx: ManagementContext, id: string): boolean {
210
- let revoked = false;
211
- try { revoked = revokeKey(ctx, id); } catch { revoked = false; }
212
- if (revoked) return true;
213
- return !(ctx.config.apiKeys ?? []).some(entry => entry.id === id);
214
- }
215
-
216
- function compensationFailure(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number], phase: string): Response {
217
- const since = new Date((ctx.deps.now ?? Date.now)()).toISOString();
218
- (state.compensationFailures ??= new Map()).set(record.id, { since, reason: "compensation_failed" });
219
- try { markCompensationFailed(record.id, since, compensationPath()); } catch (error) {
220
- console.warn(`[link] compensation marker persistence failed linkId=${record.id}: ${error instanceof Error ? error.message : String(error)}`);
221
- }
222
- console.warn(`[link] compensation_failed linkId=${record.id} phase=${phase}`);
223
- return fail("compensation_failed", "The link compensation could not be completed.", 500);
224
- }
225
-
226
- async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number]): Promise<Response | null> {
227
- const revoked = revokeKeyIdempotent(ctx, record.apiKeyId);
228
- if (!revoked) {
229
- try { await state.supervisor.stopLink(record.id); } catch { /* retain the record and failure marker */ }
230
- try {
231
- const current = readStoreFor(ctx);
232
- if (!current.links.some(link => link.id === record.id)) {
233
- writeStoreFor(ctx, { ...current, links: [...current.links, record] });
234
- }
235
- } catch {
236
- // The in-memory marker still makes the residual visible when persistence is unavailable.
237
- }
238
- return compensationFailure(ctx, state, record, "revoke");
239
- }
240
- try { await state.supervisor.stopLink(record.id); } catch { return compensationFailure(ctx, state, record, "stop"); }
241
- try {
242
- const current = readStoreFor(ctx);
243
- const next = { ...current, links: current.links.filter(link => link.id !== record.id) };
244
- if (next.links.length !== current.links.length) {
245
- writeStoreFor(ctx, next);
246
- }
247
- clearCompensationFailed(record.id, compensationPath());
248
- state.compensationFailures?.delete(record.id);
249
- if (next.links.length === 0) await state.listener.close();
250
- return null;
251
- } catch {
252
- return compensationFailure(ctx, state, record, "store");
253
- }
254
- }
255
-
256
65
  async function candidates(ctx: ManagementContext): Promise<Response> {
257
66
  const values = ctx.deps.loadLinkCandidates?.() ?? loadHostCandidates();
258
67
  return Response.json({ candidates: values.map(value => ({ alias: value.alias, source: value.source })) });
259
68
  }
260
69
 
261
- async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
262
- const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
263
- if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
264
- try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
265
- const dir = mkdtempSync(join(tmpdir(), "ocx-link-probe-"));
266
- const tempKnownHosts = join(dir, "known_hosts");
267
- writeFileSync(tempKnownHosts, "", { mode: 0o600 });
268
- const runner = runnerFor(ctx);
269
- try {
270
- const result = await runner.run(buildProbeArgv({ alias: body.alias, tempKnownHostsFile: tempKnownHosts }), { timeoutMs: 30_000 });
271
- if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
272
- const fingerprintResult = await runner.run(buildFingerprintArgv(tempKnownHosts), { timeoutMs: 10_000 });
273
- if (fingerprintResult.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fingerprintResult.stderr));
274
- const parsed = parseFingerprintLine(fingerprintResult.stdout.trim());
275
- const knownHostLine = readFileSync(tempKnownHosts, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
276
- if (!knownHostLine) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
277
- state.pendingHosts.set(body.alias, { alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine, probedAt: (ctx.deps.now ?? Date.now)() });
278
- return Response.json({ alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType });
279
- } catch (error) {
280
- return fail("probe_failed", "SSH host probing failed.", 502, sshRunnerErrorHint(error));
281
- } finally {
282
- try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
283
- }
284
- }
285
-
286
- async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
287
- const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "fingerprint"]);
288
- if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string") return fail("invalid_body", "alias and fingerprint are required.", 400);
289
- const pending = state.pendingHosts.get(body.alias);
290
- const now = (ctx.deps.now ?? Date.now)();
291
- if (!pending || now - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe has expired.", 409);
292
- if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
293
- const path = knownHostsFile(ctx);
294
- const before = putKnownHost(path, pending.alias, pending.knownHostLine);
295
- // Every refusal restores known_hosts and keeps the pending probe, so a retry within its TTL works.
296
- const refuse = (response: Response): Response => { restoreKnownHost(path, before); return response; };
297
- let result: SshRunResult;
298
- try {
299
- result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
300
- } catch (error) {
301
- return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error)));
302
- }
303
- if (result.code === REMOTE_COMMAND_NOT_FOUND) return refuse(fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr)));
304
- if (result.code !== 0) return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshFailureHint(result.stderr)));
305
- const version = parseRemoteOcxVersion(result.stdout);
306
- if (!version) return refuse(fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502));
307
- if (!meetsRemoteOcxFloor(version.parts)) {
308
- return refuse(fail("remote_ocx_outdated", `The remote OpenCodex is older than ${MIN_REMOTE_OCX.join(".")}.`, 409, boundHint(`opencodex ${version.version}`)));
309
- }
310
- const confirmed = { ...pending, ocxVersion: version.version };
311
- state.confirmedHosts ??= new Map();
312
- state.confirmedHosts.set(pending.alias, confirmed);
313
- state.pendingHosts.delete(pending.alias);
314
- return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion });
315
- }
316
-
317
- /**
318
- * `ocx --version` prints `opencodex <version>` first; a usage banner, an unbounded version tail or
319
- * other output is refused.
320
- */
321
- function parseRemoteOcxVersion(stdout: string): { version: string; parts: [number, number, number] } | null {
322
- const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? "");
323
- if (!match) return null;
324
- return { version: `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}${match[5] ?? ""}`, parts: [Number(match[1]), Number(match[2]), Number(match[3])] };
325
- }
326
-
327
- function meetsRemoteOcxFloor(parts: readonly [number, number, number]): boolean {
328
- for (let index = 0; index < MIN_REMOTE_OCX.length; index += 1) {
329
- if (parts[index] !== MIN_REMOTE_OCX[index]) return parts[index]! > MIN_REMOTE_OCX[index]!;
330
- }
331
- return true;
332
- }
333
-
334
- function parsePortOutput(stdout: string): number | null {
335
- try {
336
- const value: unknown = JSON.parse(stdout.trim());
337
- if (!isRecord(value) || Object.keys(value).length !== 1 || !port(value.port)) return null;
338
- return value.port;
339
- } catch { return null; }
340
- }
341
-
342
- async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
343
- const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
344
- if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
345
- const confirmed = state.confirmedHosts?.get(body.alias);
346
- if (!confirmed) return fail("host_not_confirmed", "Confirm the SSH host before applying the link.", 409);
347
- const knownHosts = knownHostsFile(ctx);
348
- const runner = runnerFor(ctx);
349
- let remotePort: number;
350
- let portHint: string | undefined;
351
- try {
352
- const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["link", "port"]), knownHostsFile: knownHosts }), { timeoutMs: 30_000 });
353
- if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
354
- remotePort = result.code === 0 ? parsePortOutput(result.stdout) ?? 0 : 0;
355
- if (result.code !== 0) portHint = sshFailureHint(result.stderr);
356
- } catch (error) { remotePort = 0; portHint = sshRunnerErrorHint(error); }
357
- if (!port(remotePort)) return fail("remote_port_failed", "The remote link port could not be determined.", 502, portHint);
358
- let issued: IssuedApiKey;
359
- try { issued = issueKey(ctx, `link:${body.alias}`); }
360
- catch { return fail("key_issue_failed", "The link key could not be issued.", 503); }
361
- const id = newLinkId();
362
- const record = { id, alias: body.alias, direction: "hub-initiated" as const, hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
363
- let store: LinkStore;
364
- try {
365
- const current = readStoreFor(ctx);
366
- if (current.links.some(link => link.alias === body.alias && link.direction === "hub-initiated")) {
367
- const compensation = await compensateNewLink(ctx, state, record);
368
- if (compensation) return compensation;
369
- return fail("link_exists", "A link for this alias already exists.", 409);
370
- }
371
- store = { ...current, links: [...current.links, record] };
372
- writeStoreFor(ctx, store);
373
- await state.listener.ensureStarted();
374
- if (state.listener.status().state !== "listening") {
375
- const compensation = await compensateNewLink(ctx, state, record);
376
- return compensation ?? fail("listener_unavailable", "The link listener is unavailable.", 503);
377
- }
378
- await state.supervisor.ensureStarted();
379
- await state.supervisor.reload();
380
- } catch {
381
- const compensation = await compensateNewLink(ctx, state, record);
382
- if (compensation) return compensation;
383
- return fail("link_apply_failed", "The link could not be started.", 503);
384
- }
385
- const admission = awaitFirstAdmission(issued.id, APPLY_ADMISSION_TIMEOUT_MS, state.listener.onAuthenticatedCatalog);
386
- const input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
387
- try {
388
- const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id]), knownHostsFile: knownHosts }), { stdin: input, timeoutMs: APPLY_ADMISSION_TIMEOUT_MS });
389
- if (result.code !== 0) {
390
- void admission.catch(() => {});
391
- const compensation = await compensateNewLink(ctx, state, record);
392
- if (compensation) return compensation;
393
- const hint = sshFailureHint(result.stderr);
394
- return result.code === REMOTE_COMMAND_NOT_FOUND
395
- ? fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, hint)
396
- : fail("remote_connect_failed", "The remote link connection failed.", 502, hint);
397
- }
398
- try {
399
- await admission;
400
- } catch {
401
- const compensation = await compensateNewLink(ctx, state, record);
402
- return compensation ?? fail("admission_timeout", "The remote link did not authenticate a catalog request in time.", 502);
403
- }
404
- return Response.json({ linkId: id }, { status: 202 });
405
- } catch (error) {
406
- void admission.catch(() => {});
407
- const compensation = await compensateNewLink(ctx, state, record);
408
- return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502, sshRunnerErrorHint(error));
409
- } finally {
410
- input.fill(0);
411
- }
412
- }
413
-
414
70
  async function issue(ctx: ManagementContext): Promise<Response> {
415
71
  const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "tunnelPort"]);
416
72
  if (!body || typeof body.alias !== "string" || !isLinkPort(body.tunnelPort)) return fail("invalid_body", "alias and tunnelPort are required.", 400);
@@ -430,13 +86,18 @@ async function issue(ctx: ManagementContext): Promise<Response> {
430
86
  const record = { id, alias: body.alias, direction: "client-initiated" as const, hostKeyFingerprint: null, tunnelPort: body.tunnelPort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
431
87
  let failureCode = "link_issue_failed";
432
88
  try {
433
- const current = readStoreFor(ctx);
434
- if (current.links.some(link => link.id === id)) throw new Error("link id collision");
435
- if (current.links.some(link => link.alias === body.alias && link.direction === "client-initiated")) {
89
+ // Same atomic duplicate-check+append as the hub-initiated branch.
90
+ const writeOutcome = withConfigMutationLockSync((): "dup" | "written" => {
91
+ const current = readStoreFor(ctx);
92
+ if (current.links.some(link => link.id === id)) throw new Error("link id collision");
93
+ if (current.links.some(link => link.alias === body.alias && link.direction === "client-initiated")) return "dup";
94
+ writeStoreFor(ctx, { ...current, links: [...current.links, record] });
95
+ return "written";
96
+ });
97
+ if (writeOutcome === "dup") {
436
98
  const compensation = await compensateNewLink(ctx, state, record);
437
99
  return compensation ?? fail("link_exists", "A link for this alias already exists.", 409);
438
100
  }
439
- writeStoreFor(ctx, { ...current, links: [...current.links, record] });
440
101
  await state.listener.ensureStarted();
441
102
  if (state.listener.status().state !== "listening") {
442
103
  failureCode = "listener_unavailable";
@@ -506,6 +167,30 @@ async function handleJoin(ctx: ManagementContext, state: LinkRouteState): Promis
506
167
  }
507
168
  }
508
169
 
170
+ /**
171
+ * Restart a Home-initiated link's tunnel now: the dashboard's Retry and `ocx link reconnect`.
172
+ * The supervisor stops the current ssh child, if any, forgets its backoff or failed state and
173
+ * spawns a fresh attempt; status then reports it like any other attempt.
174
+ */
175
+ async function reconnect(ctx: ManagementContext, state: LinkRouteState, id: string): Promise<Response> {
176
+ if (!LINK_ID.test(id)) return fail("invalid_link_id", "The link id is invalid.", 400);
177
+ const record = readStoreFor(ctx).links.find(link => link.id === id);
178
+ if (!record) return fail("link_not_found", "The link was not found.", 404);
179
+ if (record.direction !== "hub-initiated") {
180
+ return fail("link_not_home_initiated", "Only a Home-initiated link has a tunnel this computer can restart.", 409);
181
+ }
182
+ try {
183
+ // A reverse forward may only target a port the link listener owns, as when a link is applied.
184
+ await state.listener.ensureStarted();
185
+ if (state.listener.status().state !== "listening") return fail("listener_unavailable", "The link listener is unavailable.", 503);
186
+ await state.supervisor.ensureStarted();
187
+ if (!await state.supervisor.reconnect(id)) return fail("link_unavailable", "The link tunnel could not be restarted.", 503);
188
+ } catch {
189
+ return fail("link_unavailable", "The link tunnel could not be restarted.", 503);
190
+ }
191
+ return Response.json({ linkId: id });
192
+ }
193
+
509
194
  async function remove(ctx: ManagementContext, state: LinkRouteState, id: string): Promise<Response> {
510
195
  if (!LINK_ID.test(id)) return fail("invalid_link_id", "The link id is invalid.", 400);
511
196
  const current = readStoreFor(ctx);
@@ -514,38 +199,46 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string)
514
199
  const body = await readManagementJsonBodyOr(ctx.req, {});
515
200
  if (!isRecord(body) || Object.keys(body).some(key => key !== "force") || (body.force !== undefined && typeof body.force !== "boolean")) return fail("invalid_body", "force must be a boolean.", 400);
516
201
  const force = body.force === true;
517
- await state.supervisor.stopLink(id);
518
- const restartTunnel = async (): Promise<void> => {
519
- try {
520
- await state.supervisor.ensureStarted();
521
- await state.supervisor.reload();
522
- } catch (error) {
523
- console.warn(`[link] tunnel restart failed linkId=${id}: ${error instanceof Error ? error.message : String(error)}`);
524
- }
525
- };
526
- if (!force && record.direction === "hub-initiated") {
527
- try {
528
- const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"]), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 });
529
- if (result.code !== 0) {
202
+ const release = state.supervisor.holdLink?.(id);
203
+ try {
204
+ await state.supervisor.stopLink(id);
205
+ const restartTunnel = async (): Promise<void> => {
206
+ release?.();
207
+ try {
208
+ await state.supervisor.ensureStarted();
209
+ await state.supervisor.reload();
210
+ } catch (error) {
211
+ console.warn(`[link] tunnel restart failed linkId=${id}: ${error instanceof Error ? error.message : String(error)}`);
212
+ }
213
+ };
214
+ if (!force && record.direction === "hub-initiated") {
215
+ try {
216
+ const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"]), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 });
217
+ if (result.code !== 0) {
218
+ await restartTunnel();
219
+ return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr));
220
+ }
221
+ } catch (error) {
530
222
  await restartTunnel();
531
- return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr));
223
+ return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error));
532
224
  }
533
- } catch (error) {
534
- await restartTunnel();
535
- return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error));
536
225
  }
537
- }
538
- if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
539
- let next: LinkStore;
540
- try {
541
- const latest = readStoreFor(ctx);
542
- next = { ...latest, links: latest.links.filter(link => link.id !== id) };
543
- if (next.links.length !== latest.links.length) writeStoreFor(ctx, next);
544
- clearCompensationFailed(id, compensationPath());
545
- } catch { return fail("link_remove_failed", "The link record could not be removed.", 503); }
546
- state.compensationFailures?.delete(id);
547
- if (next.links.length === 0) await state.listener.close();
548
- return Response.json({ linkId: id });
226
+ if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
227
+ let next: LinkStore;
228
+ try {
229
+ next = withConfigMutationLockSync(() => {
230
+ const latest = readStoreFor(ctx);
231
+ const filtered = { ...latest, links: latest.links.filter(link => link.id !== id) };
232
+ if (filtered.links.length !== latest.links.length) writeStoreFor(ctx, filtered);
233
+ return filtered;
234
+ });
235
+ clearCompensationFailed(id, compensationPath());
236
+ } catch { return fail("link_remove_failed", "The link record could not be removed.", 503); }
237
+ state.compensationFailures?.delete(id);
238
+ await state.supervisor.reload();
239
+ if (next.links.length === 0) await state.listener.close();
240
+ return Response.json({ linkId: id });
241
+ } finally { release?.(); }
549
242
  }
550
243
 
551
244
  export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: LinkRouteState): Promise<Response | null> {
@@ -574,6 +267,7 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
574
267
  const dto: LinkStatusDto = projectLinkStatus(
575
268
  store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
576
269
  ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
270
+ ctx.config.runtimeRole === "client" && ctx.config.client?.link ? clientLinkStatusIdentity(ctx.config.client.link.linkId) : null,
577
271
  );
578
272
  for (const link of dto.links) {
579
273
  const failure = state.compensationFailures?.get(link.id);
@@ -596,17 +290,17 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
596
290
  return candidates(ctx);
597
291
  }
598
292
  if (url.pathname === "/api/link/probe" && req.method === "POST") {
599
- const denied = auth(ctx, "dashboard");
293
+ const denied = auth(ctx, "either");
600
294
  if (denied) return denied;
601
295
  return probe(ctx, state);
602
296
  }
603
297
  if (url.pathname === "/api/link/confirm-host" && req.method === "POST") {
604
- const denied = auth(ctx, "dashboard");
298
+ const denied = auth(ctx, "either");
605
299
  if (denied) return denied;
606
300
  return confirmHost(ctx, state);
607
301
  }
608
302
  if (url.pathname === "/api/link/apply" && req.method === "POST") {
609
- const denied = auth(ctx, "dashboard");
303
+ const denied = auth(ctx, "either");
610
304
  if (denied) return denied;
611
305
  return apply(ctx, state);
612
306
  }
@@ -615,6 +309,14 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
615
309
  if (denied) return denied;
616
310
  return issue(ctx);
617
311
  }
312
+ const reconnectPath = req.method === "POST" ? /^\/api\/link\/([^/]+)\/reconnect$/.exec(path) : null;
313
+ if (reconnectPath) {
314
+ const denied = auth(ctx, "either");
315
+ if (denied) return denied;
316
+ let id: string;
317
+ try { id = decodeURIComponent(reconnectPath[1]!); } catch { return fail("invalid_link_id", "The link id is invalid.", 400); }
318
+ return reconnect(ctx, state, id);
319
+ }
618
320
  if (req.method === "DELETE" && path.startsWith("/api/link/")) {
619
321
  const denied = auth(ctx, "either");
620
322
  if (denied) return denied;
@@ -11,6 +11,7 @@
11
11
  import type { CatalogModel } from "../../codex/catalog";
12
12
  import { antigravityFamilyDisabled } from "../../providers/antigravity-effort-families";
13
13
  import { observeModelCacheRevision } from "../../codex/model-cache";
14
+ import { isPassiveRead } from "../../lib/passive-read";
14
15
  import {
15
16
  captureExportConfigAdmission,
16
17
  detachedConfigSnapshot,
@@ -306,8 +307,9 @@ export async function listManagementModelRows(
306
307
  * that brings its own roster still sees exactly what a writer would, and the two cannot
307
308
  * disagree about the roster for any reason except the roster itself.
308
309
  */
310
+ // A passive read (#6959) also skips the entitlement refresh, which reaches the Codex accounts.
309
311
  const models = options.models === undefined
310
- ? (await Promise.all([
312
+ ? isPassiveRead() ? [] : (await Promise.all([
311
313
  fetchAllModels(config, options.providerContentRevisions, projection => { config = projection; }),
312
314
  ensureCodexEntitlementFreshness(config, {
313
315
  waitMs: options.entitlementWaitMs ?? 3_000,
@@ -50,6 +50,7 @@ const RETRY_POLICY_VALIDATORS = {
50
50
  */
51
51
  export const PROVIDER_COMPAT_CARRY_FIELDS = [
52
52
  "preserveReasoningContentModels",
53
+ "preserveResponsesReasoningContent",
53
54
  "requiresReasoningPlaceholderModels",
54
55
  "foldDeveloperRoleToSystem",
55
56
  "reasoningWireFormat",
@@ -160,6 +161,8 @@ export function providerCompatFieldConfigError(provider: Record<string, unknown>
160
161
  if (fold !== undefined && typeof fold !== "boolean") return "foldDeveloperRoleToSystem must be a boolean";
161
162
  const hideRawReasoning = provider.hideRawReasoning;
162
163
  if (hideRawReasoning !== undefined && typeof hideRawReasoning !== "boolean") return "hideRawReasoning must be a boolean";
164
+ const preserveResponses = provider.preserveResponsesReasoningContent;
165
+ if (preserveResponses !== undefined && typeof preserveResponses !== "boolean") return "preserveResponsesReasoningContent must be a boolean";
163
166
  const wire = provider.reasoningWireFormat;
164
167
  if (wire !== undefined && !PROVIDER_REASONING_WIRE_FORMATS.includes(wire as never)) {
165
168
  return `reasoningWireFormat must be one of: ${PROVIDER_REASONING_WIRE_FORMATS.join(", ")}`;
@@ -204,6 +207,13 @@ export function applyProviderCompatPatchFields(
204
207
  else return { error: "hideRawReasoning must be a boolean" };
205
208
  touched = true;
206
209
  }
210
+ if (Object.hasOwn(rawBody, "preserveResponsesReasoningContent")) {
211
+ const value = rawBody.preserveResponsesReasoningContent;
212
+ if (value === null) delete next.preserveResponsesReasoningContent;
213
+ else if (typeof value === "boolean") next.preserveResponsesReasoningContent = value;
214
+ else return { error: "preserveResponsesReasoningContent must be a boolean" };
215
+ touched = true;
216
+ }
207
217
  if (Object.hasOwn(rawBody, "reasoningWireFormat")) {
208
218
  const value = rawBody.reasoningWireFormat;
209
219
  if (value === null) {
@@ -917,6 +917,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
917
917
  // Presence only (#959 review): header names and values never leave the process.
918
918
  hasHeaders: !!p.headers && Object.keys(p.headers).length > 0,
919
919
  allowPrivateNetwork: p.allowPrivateNetwork === true,
920
+ preserveResponsesReasoningContent: p.preserveResponsesReasoningContent,
920
921
  liveModels: p.liveModels !== false,
921
922
  requestPacing: p.requestPacing,
922
923
  models: p.models ?? [],