@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -2,14 +2,14 @@ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:
2
2
  import { mkdtempSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv } from "../../link/ssh-argv";
5
+ import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../../link/ssh-argv";
6
6
  import { parseFingerprintLine } from "../../link/fingerprint";
7
7
  import { awaitFirstAdmission } from "../../link/admission-wait";
8
8
  import { linkKnownHostsPath, linkStorePath } from "../../link/paths";
9
9
  import { clearCompensationFailed, compensationPath, markCompensationFailed, readCompensation } from "../../link/compensation";
10
10
  import { loadHostCandidates } from "../../link/ssh-config";
11
11
  import { newLinkId, readLinkStore, writeLinkStore, type LinkStore } from "../../link/store";
12
- import { createSshRunner, type SshRunner } from "../../link/ssh-runner";
12
+ import { boundHint, createSshRunner, sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../../link/ssh-runner";
13
13
  import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
14
14
  import { isLinkPort } from "../../link/ports";
15
15
  import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
@@ -17,11 +17,18 @@ import { clientLinkTunnelStatus } from "../../client/link-tunnel";
17
17
  import type { ManagementContext } from "./context";
18
18
  import { readManagementJsonBodyOr } from "./body";
19
19
  import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes";
20
- import { acceptSystemRestart } from "./system-restart";
20
+ import { acceptSystemRestart, resolveListenPort } from "./system-restart";
21
21
 
22
22
  const PROBE_TTL_MS = 5 * 60_000;
23
23
  const APPLY_ADMISSION_TIMEOUT_MS = 15_000;
24
24
  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|$)/;
25
32
  const isLinkPath = (path: string): boolean => path === "/api/link" || path.startsWith("/api/link/");
26
33
 
27
34
  export interface PendingLinkHost {
@@ -55,8 +62,13 @@ const states = new WeakMap<object, LinkRouteState>();
55
62
  // One process runs at most one join: a join ends by restarting this process as a client.
56
63
  let joinInProgress = false;
57
64
 
58
- function fail(code: string, message: string, status: number): Response {
59
- return Response.json({ error: { code, message } }, { status, headers: { "cache-control": "no-store" } });
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" } });
60
72
  }
61
73
 
62
74
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -74,23 +86,54 @@ function port(value: unknown): value is number {
74
86
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
75
87
  }
76
88
 
77
- function dashboardSession(ctx: ManagementContext): boolean {
89
+ /** A paired GUI session. Only hub runtimes issue these. */
90
+ function pairedSession(ctx: ManagementContext): boolean {
78
91
  return ctx.principal === "gui-session"
79
92
  && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
80
93
  }
81
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. Join admits this
100
+ * session too: turning a Child on from its own dashboard is the point of the route, and the
101
+ * dashboard warns first that the restart briefly interrupts running Codex turns.
102
+ */
103
+ function dashboardSession(ctx: ManagementContext): boolean {
104
+ if (pairedSession(ctx)) return true;
105
+ return ctx.principal === "gui-session"
106
+ && (ctx.config.runtimeRole ?? "standalone") === "standalone"
107
+ && ctx.guiSessionIssuance === "loopback"
108
+ && ctx.trustedLoopbackIngress
109
+ && ctx.sessionControl?.isCurrent(ctx.req, ctx.config) === true;
110
+ }
111
+
82
112
  function adminLoopback(ctx: ManagementContext): boolean {
83
113
  return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
84
114
  }
85
115
 
86
116
  function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null {
87
117
  if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
88
- const dashboard = dashboardSession(ctx);
89
- const admin = adminLoopback(ctx);
90
- if ((kind === "dashboard" && !dashboard) || (kind === "admin" && !admin) || (kind === "either" && !dashboard && !admin)) {
91
- return fail("forbidden", "The required link authorization was not present.", 403);
92
- }
93
- return null;
118
+ const allowed = kind === "dashboard" ? dashboardSession(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
+ /**
125
+ * The client runtime a join restarts into binds exactly the configured port, with no fallback.
126
+ * A standalone that runs elsewhere (`ocx start --port`, or a port fallback) would restart into a
127
+ * proxy that cannot bind where Codex is routed, so it may not join.
128
+ */
129
+ function joinPortMatches(ctx: ManagementContext): boolean {
130
+ const live = (ctx.deps.liveListenPort ?? resolveListenPort)();
131
+ return live !== undefined && live === ctx.config.port;
132
+ }
133
+
134
+ /** Whether `POST /api/link/join` would pass its admission, role and port gates for this caller. */
135
+ function joinAvailable(ctx: ManagementContext): boolean {
136
+ return dashboardSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx);
94
137
  }
95
138
 
96
139
  function runnerFor(ctx: ManagementContext): SshRunner {
@@ -220,16 +263,16 @@ async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Res
220
263
  const runner = runnerFor(ctx);
221
264
  try {
222
265
  const result = await runner.run(buildProbeArgv({ alias: body.alias, tempKnownHostsFile: tempKnownHosts }), { timeoutMs: 30_000 });
223
- if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502);
266
+ if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
224
267
  const fingerprintResult = await runner.run(buildFingerprintArgv(tempKnownHosts), { timeoutMs: 10_000 });
225
- if (fingerprintResult.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502);
268
+ if (fingerprintResult.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fingerprintResult.stderr));
226
269
  const parsed = parseFingerprintLine(fingerprintResult.stdout.trim());
227
270
  const knownHostLine = readFileSync(tempKnownHosts, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
228
271
  if (!knownHostLine) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
229
272
  state.pendingHosts.set(body.alias, { alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine, probedAt: (ctx.deps.now ?? Date.now)() });
230
273
  return Response.json({ alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType });
231
- } catch {
232
- return fail("probe_failed", "SSH host probing failed.", 502);
274
+ } catch (error) {
275
+ return fail("probe_failed", "SSH host probing failed.", 502, sshRunnerErrorHint(error));
233
276
  } finally {
234
277
  try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
235
278
  }
@@ -244,18 +287,43 @@ async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promi
244
287
  if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
245
288
  const path = knownHostsFile(ctx);
246
289
  const before = putKnownHost(path, pending.alias, pending.knownHostLine);
290
+ // Every refusal restores known_hosts and keeps the pending probe, so a retry within its TTL works.
291
+ const refuse = (response: Response): Response => { restoreKnownHost(path, before); return response; };
292
+ let result: SshRunResult;
247
293
  try {
248
- const result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: ["ocx", "--version"], knownHostsFile: path }), { timeoutMs: 30_000 });
249
- if (result.code !== 0 || !result.stdout.trim()) throw new Error("version probe failed");
250
- const confirmed = { ...pending, ocxVersion: result.stdout.trim().split(/\r?\n/)[0]! };
251
- state.confirmedHosts ??= new Map();
252
- state.confirmedHosts.set(pending.alias, confirmed);
253
- state.pendingHosts.delete(pending.alias);
254
- return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion });
255
- } catch {
256
- restoreKnownHost(path, before);
257
- return fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502);
294
+ result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
295
+ } catch (error) {
296
+ return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error)));
258
297
  }
298
+ 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)));
299
+ if (result.code !== 0) return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshFailureHint(result.stderr)));
300
+ const version = parseRemoteOcxVersion(result.stdout);
301
+ if (!version) return refuse(fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502));
302
+ if (!meetsRemoteOcxFloor(version.parts)) {
303
+ return refuse(fail("remote_ocx_outdated", `The remote OpenCodex is older than ${MIN_REMOTE_OCX.join(".")}.`, 409, boundHint(`opencodex ${version.version}`)));
304
+ }
305
+ const confirmed = { ...pending, ocxVersion: version.version };
306
+ state.confirmedHosts ??= new Map();
307
+ state.confirmedHosts.set(pending.alias, confirmed);
308
+ state.pendingHosts.delete(pending.alias);
309
+ return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion });
310
+ }
311
+
312
+ /**
313
+ * `ocx --version` prints `opencodex <version>` first; a usage banner, an unbounded version tail or
314
+ * other output is refused.
315
+ */
316
+ function parseRemoteOcxVersion(stdout: string): { version: string; parts: [number, number, number] } | null {
317
+ const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? "");
318
+ if (!match) return null;
319
+ return { version: `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}${match[5] ?? ""}`, parts: [Number(match[1]), Number(match[2]), Number(match[3])] };
320
+ }
321
+
322
+ function meetsRemoteOcxFloor(parts: readonly [number, number, number]): boolean {
323
+ for (let index = 0; index < MIN_REMOTE_OCX.length; index += 1) {
324
+ if (parts[index] !== MIN_REMOTE_OCX[index]) return parts[index]! > MIN_REMOTE_OCX[index]!;
325
+ }
326
+ return true;
259
327
  }
260
328
 
261
329
  function parsePortOutput(stdout: string): number | null {
@@ -274,11 +342,14 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
274
342
  const knownHosts = knownHostsFile(ctx);
275
343
  const runner = runnerFor(ctx);
276
344
  let remotePort: number;
345
+ let portHint: string | undefined;
277
346
  try {
278
- const result = await runner.run(buildExecArgv({ alias: body.alias, argv: ["ocx", "link", "port"], knownHostsFile: knownHosts }), { timeoutMs: 30_000 });
347
+ const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["link", "port"]), knownHostsFile: knownHosts }), { timeoutMs: 30_000 });
348
+ if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
279
349
  remotePort = result.code === 0 ? parsePortOutput(result.stdout) ?? 0 : 0;
280
- } catch { remotePort = 0; }
281
- if (!port(remotePort)) return fail("remote_port_failed", "The remote link port could not be determined.", 502);
350
+ if (result.code !== 0) portHint = sshFailureHint(result.stderr);
351
+ } catch (error) { remotePort = 0; portHint = sshRunnerErrorHint(error); }
352
+ if (!port(remotePort)) return fail("remote_port_failed", "The remote link port could not be determined.", 502, portHint);
282
353
  let issued: IssuedApiKey;
283
354
  try { issued = issueKey(ctx, `link:${body.alias}`); }
284
355
  catch { return fail("key_issue_failed", "The link key could not be issued.", 503); }
@@ -309,11 +380,15 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
309
380
  const admission = awaitFirstAdmission(issued.id, APPLY_ADMISSION_TIMEOUT_MS, state.listener.onAuthenticatedCatalog);
310
381
  const input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
311
382
  try {
312
- const result = await runner.run(buildExecArgv({ alias: body.alias, argv: ["ocx", "connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id], knownHostsFile: knownHosts }), { stdin: input, timeoutMs: APPLY_ADMISSION_TIMEOUT_MS });
383
+ 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 });
313
384
  if (result.code !== 0) {
314
385
  void admission.catch(() => {});
315
386
  const compensation = await compensateNewLink(ctx, state, record);
316
- return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502);
387
+ if (compensation) return compensation;
388
+ const hint = sshFailureHint(result.stderr);
389
+ return result.code === REMOTE_COMMAND_NOT_FOUND
390
+ ? fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, hint)
391
+ : fail("remote_connect_failed", "The remote link connection failed.", 502, hint);
317
392
  }
318
393
  try {
319
394
  await admission;
@@ -322,10 +397,10 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
322
397
  return compensation ?? fail("admission_timeout", "The remote link did not authenticate a catalog request in time.", 502);
323
398
  }
324
399
  return Response.json({ linkId: id }, { status: 202 });
325
- } catch {
400
+ } catch (error) {
326
401
  void admission.catch(() => {});
327
402
  const compensation = await compensateNewLink(ctx, state, record);
328
- return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502);
403
+ return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502, sshRunnerErrorHint(error));
329
404
  } finally {
330
405
  input.fill(0);
331
406
  }
@@ -362,6 +437,7 @@ async function issue(ctx: ManagementContext): Promise<Response> {
362
437
  failureCode = "listener_unavailable";
363
438
  throw new Error("link listener unavailable");
364
439
  }
440
+ await state.supervisor.ensureStarted();
365
441
  const boundStore = readStoreFor(ctx);
366
442
  if (!port(boundStore.listenerPort)) throw new Error("link listener did not bind");
367
443
  return Response.json({ linkId: id, apiKeyId: issued.id, key: issued.key, listenerPort: boundStore.listenerPort });
@@ -383,11 +459,13 @@ type ManagementApiDepsWithJoinOverrides = ManagementContext["deps"] & LinkJoinRo
383
459
 
384
460
  function joinFailure(error: unknown): Response {
385
461
  const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "";
462
+ const hint = error && typeof error === "object" && "hint" in error && typeof error.hint === "string" ? error.hint : undefined;
386
463
  switch (code) {
387
464
  case "host_not_confirmed": return fail("host_not_confirmed", "Confirm the SSH host before joining the link.", 409);
388
465
  case "host_confirmation_expired": return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409);
389
466
  case "join_port_failed": return fail("join_port_failed", "No local port is available for the link tunnel.", 503);
390
- case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502);
467
+ case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502, hint);
468
+ case "remote_ocx_missing": return fail("remote_ocx_missing", "ocx was not found on the home.", 502, hint);
391
469
  case "join_tunnel_failed": return fail("join_tunnel_failed", "The SSH tunnel to the home did not become ready.", 502);
392
470
  case "admission_failed": return fail("admission_failed", "The home refused the issued link key.", 502);
393
471
  case "join_rollback_failed": {
@@ -442,14 +520,14 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string)
442
520
  };
443
521
  if (!force && record.direction === "hub-initiated") {
444
522
  try {
445
- const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: ["ocx", "disconnect"], knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 });
523
+ const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"]), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 });
446
524
  if (result.code !== 0) {
447
525
  await restartTunnel();
448
- return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502);
526
+ return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr));
449
527
  }
450
- } catch {
528
+ } catch (error) {
451
529
  await restartTunnel();
452
- return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502);
530
+ return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error));
453
531
  }
454
532
  }
455
533
  if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
@@ -471,9 +549,12 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
471
549
  if (!isLinkPath(path)) return null;
472
550
  if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
473
551
  if (url.pathname === "/api/link/join" && req.method === "POST") {
552
+ // The same dashboard admission as the Home side. Every refusal below runs before link state
553
+ // is read and before any SSH.
474
554
  const denied = auth(ctx, "dashboard");
475
555
  if (denied) return denied;
476
556
  if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409);
557
+ if (!joinPortMatches(ctx)) return fail("join_port_mismatch", "OpenCodex is not running on its configured port, so it cannot restart as a Child.", 409);
477
558
  const state = suppliedState ?? stateFor(ctx);
478
559
  if (!state) return fail("link_unavailable", "The link lifecycle is unavailable.", 503);
479
560
  return handleJoin(ctx, state);
@@ -497,7 +578,10 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
497
578
  const failure = state.compensationFailures?.get(store.links[0].id);
498
579
  if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
499
580
  }
500
- return Response.json(dto, { headers: { "cache-control": "no-store" } });
581
+ // A dashboard session also learns whether it may join as a Child. The admin-token answer stays
582
+ // the exact K16 document that `ocx link status` validates key by key.
583
+ const body: LinkStatusDto & { joinAvailable?: boolean } = ctx.principal === "gui-session" ? { ...dto, joinAvailable: joinAvailable(ctx) } : dto;
584
+ return Response.json(body, { headers: { "cache-control": "no-store" } });
501
585
  }
502
586
  if (url.pathname === "/api/link/candidates" && req.method === "GET") {
503
587
  const denied = auth(ctx, "dashboard");
@@ -67,7 +67,7 @@ import {
67
67
  } from "../../lib/debug-settings";
68
68
  import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
69
69
  import { drainAndShutdown } from "../lifecycle";
70
- import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log";
70
+ import { getRequestLogEntries, queryRequestLogs, type RequestLogEntry } from "../request-log";
71
71
  import { decodeRequestLogCursor, selectRequestLogPoll } from "../request-log-cursor";
72
72
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
73
73
  import { userCostOverlayVersion } from "../../usage/user-cost-overlays";
@@ -120,11 +120,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
120
120
  return jsonResponse({ error: { code: "invalid_cursor", message: "invalid cursor" } }, 400);
121
121
  }
122
122
  const all = getRequestLogEntries();
123
- const total = filteredRequestLogCount(all, url.searchParams);
123
+ const queried = queryRequestLogs(all, url.searchParams);
124
+ const total = queried.total;
124
125
  // Not point-free: requestLogDto takes an options object second, and Array.map would pass the
125
126
  // element INDEX into it. An explicit arrow keeps the default (decode rate included) and is
126
127
  // what /api/logs wants; /api/request-history opts out at its own call sites.
127
- const logs = filterRequestLogs(all, url.searchParams).map(entry => requestLogDto(entry));
128
+ const logs = queried.logs.map(entry => requestLogDto(entry));
128
129
  const poll = selectRequestLogPoll(logs, url.searchParams, cursor);
129
130
  return jsonResponse({
130
131
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -347,45 +348,11 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
347
348
  }
348
349
  return jsonResponse(requestedSummary);
349
350
  } catch {
350
- return jsonResponse({
351
- range,
352
- surface,
353
- since: window?.since ?? null,
354
- ...(window ? { customWindow: true, until: window.until } : {}),
355
- generatedAt: now,
356
- summary: {
357
- requests: 0,
358
- attemptCount: 0,
359
- measuredRequests: 0,
360
- reportedRequests: 0,
361
- unreportedRequests: 0,
362
- unsupportedRequests: 0,
363
- estimatedRequests: 0,
364
- inputTokens: 0,
365
- outputTokens: 0,
366
- cachedInputTokens: 0,
367
- cacheReadInputTokens: 0,
368
- cacheCreationInputTokens: 0,
369
- reasoningOutputTokens: 0,
370
- totalTokens: 0,
371
- coverageRatio: 0,
372
- estimatedCostUsd: 0,
373
- pricedRequests: 0,
374
- unpricedRequests: 0,
375
- unmeteredRequests: 0,
376
- },
377
- days: [],
378
- models: [],
379
- providers: [],
380
- accounts: [],
381
- historyTruncated: false,
382
- truncatedPrefixBytes: 0,
383
- entriesTruncated: false,
384
- entriesDropped: 0,
385
- snapshotWindowStart: null,
386
- snapshotWindowEnd: null,
387
- error: "read_failed",
388
- });
351
+ // A missing ledger is handled as an empty installation by the reader. Reaching this catch
352
+ // therefore means the report is unavailable, not that usage was measured as zero. Keep the
353
+ // transport status aligned with the JEV projection above so every dashboard consumer can
354
+ // retain its last valid snapshot instead of caching fabricated zero totals.
355
+ return jsonResponse({ error: "read_failed" }, 500);
389
356
  }
390
357
  }
391
358
 
@@ -241,6 +241,7 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
241
241
  ...(row.native ? { native: true } : {}),
242
242
  ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}),
243
243
  ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
244
+ ...(row.maxOutputTokens !== undefined ? { maxTokens: row.maxOutputTokens } : {}),
244
245
  ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
245
246
  ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}),
246
247
  ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}),
@@ -24,6 +24,7 @@ import {
24
24
  submitManualLoginCode,
25
25
  } from "../../oauth";
26
26
  import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
27
+ import { cancelKiroDeviceLogin, kiroDeviceConfigBaseline, startKiroDeviceLogin, statusKiroDeviceLogin, type KiroDeviceMethod } from "../../oauth/kiro-device-login";
27
28
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
28
29
  import { emailMaskingEnabled } from "../../lib/privacy";
29
30
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
@@ -186,8 +187,12 @@ export function revokeApiKeyInProcess(config: OcxConfig, id: string): boolean {
186
187
  return true;
187
188
  }
188
189
 
190
+ function canStartManagementOAuth(provider: string, principal: ManagementContext["principal"]): boolean {
191
+ return provider !== "meta-muse" || principal === "gui-session";
192
+ }
193
+
189
194
  function metaMuseConsentRequired(provider: string, principal: ManagementContext["principal"]): Response | null {
190
- if (provider !== "meta-muse" || principal === "gui-session") return null;
195
+ if (canStartManagementOAuth(provider, principal)) return null;
191
196
  return jsonResponse({
192
197
  error: "Meta Muse login requires acknowledgement in the OpenCodex dashboard.",
193
198
  code: "oauth_consent_required",
@@ -204,7 +209,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
204
209
 
205
210
  // Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
206
211
  if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
207
- return jsonResponse({ providers: listOAuthProviders() });
212
+ // Discovery reflects this principal's admission; hiding a button is not the
213
+ // consent boundary, which remains independently enforced on both POST routes.
214
+ return jsonResponse({ providers: listOAuthProviders().filter(provider => canStartManagementOAuth(provider, principal)) });
208
215
  }
209
216
 
210
217
  // API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker.
@@ -216,7 +223,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
216
223
  // the provider's loopback callback server (inside this process) captures the redirect in the
217
224
  // background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
218
225
  if (url.pathname === "/api/oauth/login" && req.method === "POST") {
219
- const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown };
226
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown; method?: unknown };
220
227
  const provider = (body.provider ?? "").trim().toLowerCase();
221
228
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
222
229
  // Muse may import a local Keychain credential or start a device grant; add-account
@@ -229,6 +236,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
229
236
  if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409);
230
237
  const accountId = body.accountId?.trim();
231
238
  const reauth = body.reauth === true || Boolean(accountId);
239
+ if (provider === "kiro") {
240
+ if (reauth && !accountId) return jsonResponse({ error: "Kiro reauth requires an accountId" }, 400);
241
+ if (body.method !== undefined) {
242
+ if (body.method !== "builder-id" && body.method !== "google" && body.method !== "github") {
243
+ return jsonResponse({ error: "invalid Kiro device method" }, 400);
244
+ }
245
+ if (reauth) return jsonResponse({ error: "native_login_is_add_only" }, 400);
246
+ try {
247
+ return jsonResponse(await startKiroDeviceLogin(body.method as KiroDeviceMethod, principal ?? "admin-token", readConfigDiagnostics().config));
248
+ } catch {
249
+ return jsonResponse({ error: "Kiro device login could not start" }, 409);
250
+ }
251
+ }
252
+ }
232
253
  try {
233
254
  if (accountId) {
234
255
  const { getAccountSet } = await import("../../oauth/store");
@@ -281,9 +302,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
281
302
  // Cancel an in-progress browser/device OAuth login (GUI "Cancel" / modal close). Guarded by
282
303
  // the same public predicate as /api/oauth/login — only publicly startable flows are cancellable.
283
304
  if (url.pathname === "/api/oauth/login/cancel" && req.method === "POST") {
284
- const body = await readManagementJsonBodyOr(req, {}) as { provider?: string };
305
+ const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; flowId?: unknown };
285
306
  const provider = (body.provider ?? "").trim().toLowerCase();
286
307
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
308
+ if (provider === "kiro" && body.flowId !== undefined) {
309
+ if (typeof body.flowId !== "string") return jsonResponse({ error: "unknown login flow" }, 404);
310
+ const result = cancelKiroDeviceLogin(body.flowId, principal ?? "admin-token");
311
+ return result ? jsonResponse(result) : jsonResponse({ error: "unknown login flow" }, 404);
312
+ }
287
313
  const { cancelLoginFlow } = await import("../../oauth");
288
314
  const cancelled = cancelLoginFlow(provider);
289
315
  return jsonResponse({ ok: true, cancelled });
@@ -309,6 +335,17 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
309
335
  if (url.pathname === "/api/oauth/status" && req.method === "GET") {
310
336
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
311
337
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
338
+ if (provider === "kiro" && url.searchParams.has("flowId")) {
339
+ const flowId = url.searchParams.get("flowId") ?? "";
340
+ const baseline = kiroDeviceConfigBaseline(flowId, principal ?? "admin-token");
341
+ const status = await statusKiroDeviceLogin(flowId, principal ?? "admin-token");
342
+ if (!status) return jsonResponse({ error: "unknown login flow" }, 404);
343
+ if (status.state === "done") {
344
+ reconcileLiveConfigFromDisk(config, baseline ?? structuredClone(config));
345
+ reconcileLiveStateStores();
346
+ }
347
+ return jsonResponse(status);
348
+ }
312
349
  // Resolved here, at the request boundary that already holds the config, and passed down.
313
350
  // getLoginStatus stays free of config I/O. This route does not re-mask afterwards: it
314
351
  // consumes the already-projected status rather than redacting a second time.
@@ -347,6 +384,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
347
384
  const quotaMode = providerOAuthAccountQuotaMode(provider);
348
385
  const quotaProvider = config.providers[provider];
349
386
  const { getAccountSet } = await import("../../oauth/store");
387
+ const { kiroAutoSelection } = await import("../../oauth/generic-account-failover");
350
388
  const {
351
389
  oauthAccountHealthFields,
352
390
  projectOAuthAccountHealth,
@@ -365,7 +403,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
365
403
  needsReauth: summary.needsReauth === true,
366
404
  reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
367
405
  });
368
- return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode };
406
+ return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
407
+ ...(provider === "kiro" && full ? kiroAutoSelection(full) : {}) };
369
408
  }),
370
409
  };
371
410
  };
@@ -439,14 +478,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
439
478
  // them that kind honours.
440
479
  if (url.pathname === "/api/pool/settings" && (req.method === "GET" || req.method === "PUT" || req.method === "PATCH")) {
441
480
  const {
442
- poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit,
481
+ poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit, parseKiroAccountCap,
443
482
  unifiedPoolSettingsDto,
444
483
  } = await import("../../oauth/pool-settings-capability");
445
484
  const rawBody = req.method === "GET" ? {} : await readManagementJsonBodyOr(req, {});
446
485
  if (req.method !== "GET" && !isPlainRecord(rawBody)) {
447
486
  return jsonResponse({ error: "body must be an object" }, 400);
448
487
  }
449
- const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown };
488
+ const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown; maxConcurrentPerAccount?: unknown };
450
489
  const provider = req.method === "GET"
451
490
  ? (url.searchParams.get("provider") ?? "").trim().toLowerCase()
452
491
  : (typeof fields.provider === "string" ? fields.provider.trim().toLowerCase() : "");
@@ -458,10 +497,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
458
497
  // sticky limit is refused identically whichever pool is addressed.
459
498
  let strategy: string | undefined;
460
499
  if (fields.strategy !== undefined) {
461
- const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy);
500
+ const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy, provider);
462
501
  if (parsed === null) return jsonResponse({ error: kind === "codex"
463
502
  ? "strategy must be one of: quota, round-robin, fill-first, reset-first"
464
- : "strategy must be one of: quota, round-robin, fill-first" }, 400);
503
+ : `strategy must be one of: quota, round-robin, fill-first${provider === "kiro" ? ", least-loaded" : ""}` }, 400);
465
504
  strategy = parsed;
466
505
  }
467
506
  let stickyLimit: number | undefined;
@@ -489,6 +528,12 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
489
528
  if (kind === "codex") return jsonResponse({ error: "enabled is not part of the codex pool contract" }, 400);
490
529
  if (typeof fields.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
491
530
  }
531
+ let accountCap: number | null | undefined;
532
+ if (fields.maxConcurrentPerAccount !== undefined) {
533
+ if (provider !== "kiro" || kind !== "generic") return jsonResponse({ error: "maxConcurrentPerAccount is only supported for Kiro OAuth" }, 400);
534
+ accountCap = fields.maxConcurrentPerAccount === null ? null : parseKiroAccountCap(fields.maxConcurrentPerAccount);
535
+ if (accountCap === null && fields.maxConcurrentPerAccount !== null) return jsonResponse({ error: "maxConcurrentPerAccount must be an integer 1-100 or null" }, 400);
536
+ }
492
537
 
493
538
  if (req.method !== "GET") {
494
539
  if (kind === "codex") {
@@ -508,6 +553,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
508
553
  const next = { ...(prov.oauthAccountFailover ?? {}) };
509
554
  if (fields.enabled !== undefined) next.enabled = fields.enabled as boolean;
510
555
  if (strategy !== undefined) next.strategy = strategy as never;
556
+ if (accountCap === null) delete next.maxConcurrentPerAccount;
557
+ else if (accountCap !== undefined) next.maxConcurrentPerAccount = accountCap;
511
558
  if (stickyLimit !== undefined) next.stickyLimit = stickyLimit;
512
559
  if (autoSwitchThreshold !== undefined) next.autoSwitchThreshold = autoSwitchThreshold;
513
560
  if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
@@ -558,12 +605,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
558
605
  strategy?: unknown;
559
606
  stickyLimit?: unknown;
560
607
  quotaWindow?: unknown;
608
+ maxConcurrentPerAccount?: unknown;
561
609
  };
562
610
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
563
611
  if (provider !== "anthropic") {
564
612
  const {
565
613
  poolSettingsCapability, genericPoolSettingsDto, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold,
566
- parseGenericStickyLimit,
614
+ parseGenericStickyLimit, parseKiroAccountCap,
567
615
  } = await import("../../oauth/pool-settings-capability");
568
616
  const prov = config.providers[provider];
569
617
  if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
@@ -580,8 +628,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
580
628
  if (body.strategy !== undefined) {
581
629
  if (body.strategy === null) delete next.strategy;
582
630
  else {
583
- const parsed = parseGenericPoolStrategy(body.strategy);
584
- if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400);
631
+ const parsed = parseGenericPoolStrategy(body.strategy, provider);
632
+ if (parsed === null) return jsonResponse({ error: `strategy must be one of: quota, round-robin, fill-first${provider === "kiro" ? ", least-loaded" : ""}` }, 400);
585
633
  next.strategy = parsed;
586
634
  }
587
635
  }
@@ -601,6 +649,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
601
649
  next.stickyLimit = parsed;
602
650
  }
603
651
  }
652
+ if (body.maxConcurrentPerAccount !== undefined) {
653
+ if (provider !== "kiro") return jsonResponse({ error: "maxConcurrentPerAccount is only supported for Kiro OAuth" }, 400);
654
+ const cap = body.maxConcurrentPerAccount === null ? null : parseKiroAccountCap(body.maxConcurrentPerAccount);
655
+ if (cap === null && body.maxConcurrentPerAccount !== null) return jsonResponse({ error: "maxConcurrentPerAccount must be an integer 1-100 or null" }, 400);
656
+ if (cap === null) delete next.maxConcurrentPerAccount;
657
+ else next.maxConcurrentPerAccount = cap;
658
+ }
604
659
  if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
605
660
  else delete prov.oauthAccountFailover;
606
661
  saveConfigPreservingClaudeCode(config);
@@ -368,11 +368,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
368
368
  { method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true },
369
369
  // server/management/link-routes
370
370
  { method: "GET", path: "/api/link/status", module: "server/management/link-routes", mutates: false },
371
- { method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard pairing surface and are withheld from admin-token and Tailscale identity sessions." } },
372
- { method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive pairing consent flow." } },
373
- { method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the paired dashboard session that saw the fingerprint." } },
374
- { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client." } },
375
- { method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires the paired dashboard session." } },
371
+ { method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard surface: a paired session, or on a standalone runtime the current loopback dashboard session on trusted loopback ingress; admin-token and Tailscale identity sessions are refused." } },
372
+ { method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive dashboard consent flow (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
373
+ { method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the dashboard session that saw the fingerprint (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
374
+ { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client on its configured port, which briefly interrupts running Codex turns. It is the dashboard's turn-on flow with a pre-join notice (paired, or standalone loopback dashboard on trusted loopback ingress); admin-token and Tailscale identity sessions are refused." } },
375
+ { method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires a dashboard session (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
376
376
  { method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
377
377
  { method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true },
378
378
  // server/management/remote-workspace-routes