@iislee/opencodex 2.11.0 → 2.31.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 (675) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +114 -19
  3. package/bin/ocx.mjs +164 -36
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-BF38heuV.js +104 -0
  6. package/gui/dist/assets/index-DMiI18Kv.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +19 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +376 -52
  44. package/src/adapters/base.ts +54 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +601 -0
  48. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  49. package/src/adapters/cursor/cursor-errors.ts +129 -6
  50. package/src/adapters/cursor/discovery.ts +61 -4
  51. package/src/adapters/cursor/effort-map.ts +27 -3
  52. package/src/adapters/cursor/framing.ts +39 -0
  53. package/src/adapters/cursor/h2-pool.ts +123 -0
  54. package/src/adapters/cursor/http1-bidi.ts +361 -0
  55. package/src/adapters/cursor/images.ts +704 -0
  56. package/src/adapters/cursor/live-models.ts +174 -58
  57. package/src/adapters/cursor/live-transport.ts +609 -170
  58. package/src/adapters/cursor/native-exec-common.ts +23 -2
  59. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  60. package/src/adapters/cursor/native-exec-network.ts +1 -1
  61. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  62. package/src/adapters/cursor/native-exec.ts +101 -14
  63. package/src/adapters/cursor/protobuf-events.ts +829 -11
  64. package/src/adapters/cursor/protobuf-request.ts +383 -65
  65. package/src/adapters/cursor/request-builder.ts +237 -31
  66. package/src/adapters/cursor/tool-definitions.ts +242 -12
  67. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  68. package/src/adapters/cursor/transport.ts +22 -0
  69. package/src/adapters/cursor/types.ts +28 -1
  70. package/src/adapters/cursor.ts +133 -7
  71. package/src/adapters/google-antigravity-replay.ts +558 -34
  72. package/src/adapters/google-antigravity-wire.ts +43 -10
  73. package/src/adapters/google-http.ts +38 -10
  74. package/src/adapters/google-tool-schema.ts +80 -15
  75. package/src/adapters/google-truncation.ts +11 -0
  76. package/src/adapters/google.ts +618 -74
  77. package/src/adapters/identity.ts +39 -6
  78. package/src/adapters/kiro-errors.ts +11 -0
  79. package/src/adapters/kiro-events.ts +19 -1
  80. package/src/adapters/kiro-thinking.ts +18 -2
  81. package/src/adapters/kiro-tools.ts +10 -1
  82. package/src/adapters/kiro.ts +92 -54
  83. package/src/adapters/mimo-free.ts +17 -0
  84. package/src/adapters/openai-chat-url.ts +11 -0
  85. package/src/adapters/openai-chat.ts +1311 -336
  86. package/src/adapters/openai-responses-url.ts +16 -0
  87. package/src/adapters/openai-responses.ts +830 -56
  88. package/src/adapters/registry.ts +175 -0
  89. package/src/adapters/responses-tool-schema.ts +67 -0
  90. package/src/adapters/tool-call-id.ts +119 -0
  91. package/src/adapters/tool-catalog-nudge.ts +104 -21
  92. package/src/adapters/xai-web-search.ts +185 -0
  93. package/src/bridge.ts +428 -61
  94. package/src/chat/inbound.ts +43 -19
  95. package/src/chat/outbound.ts +82 -26
  96. package/src/claude/agents-inject.ts +32 -9
  97. package/src/claude/context-windows.ts +21 -5
  98. package/src/claude/desktop-3p.ts +243 -9
  99. package/src/claude/gateway-cache.ts +41 -4
  100. package/src/claude/inbound.ts +72 -3
  101. package/src/claude/model-info.ts +38 -15
  102. package/src/claude/outbound.ts +70 -16
  103. package/src/cli/account-api.ts +35 -1
  104. package/src/cli/account-auth.ts +33 -6
  105. package/src/cli/account-catalog-refresh.ts +14 -0
  106. package/src/cli/account-extended.ts +389 -2
  107. package/src/cli/account-main.ts +317 -0
  108. package/src/cli/account.ts +30 -6
  109. package/src/cli/agent.ts +45 -1
  110. package/src/cli/claude-agent-startup-sync.ts +73 -0
  111. package/src/cli/claude-desktop.ts +29 -4
  112. package/src/cli/claude.ts +103 -34
  113. package/src/cli/codex-log-guard-doctor.ts +103 -0
  114. package/src/cli/codex-shim-autorestore.ts +2 -0
  115. package/src/cli/codex-shim-readiness.ts +76 -0
  116. package/src/cli/combo.ts +8 -0
  117. package/src/cli/config-command.ts +74 -10
  118. package/src/cli/dispatch.ts +593 -0
  119. package/src/cli/doctor.ts +315 -43
  120. package/src/cli/ensure-desired-integrations.ts +152 -0
  121. package/src/cli/export-command.ts +46 -20
  122. package/src/cli/help.ts +20 -271
  123. package/src/cli/index.ts +378 -519
  124. package/src/cli/init.ts +4 -17
  125. package/src/cli/integrations.ts +120 -2
  126. package/src/cli/lab.ts +607 -0
  127. package/src/cli/launcher-context.ts +77 -0
  128. package/src/cli/minimax.ts +497 -0
  129. package/src/cli/models-runtime.ts +35 -2
  130. package/src/cli/models.ts +100 -14
  131. package/src/cli/observe.ts +92 -3
  132. package/src/cli/opencode.ts +4 -2
  133. package/src/cli/provider-runtime.ts +18 -1
  134. package/src/cli/provider.ts +24 -3
  135. package/src/cli/ready.ts +301 -0
  136. package/src/cli/registry.ts +437 -0
  137. package/src/cli/root.ts +86 -0
  138. package/src/cli/route-policy.ts +92 -0
  139. package/src/cli/runtime-api.ts +6 -3
  140. package/src/cli/star-prompt.ts +71 -15
  141. package/src/cli/status.ts +10 -3
  142. package/src/cli/system-restart-client.ts +146 -0
  143. package/src/cli/tray-proxy.ts +153 -6
  144. package/src/cli/v2.ts +105 -10
  145. package/src/cli.ts +1 -1
  146. package/src/clients/config-export.ts +1358 -21
  147. package/src/codex/account-label.ts +14 -1
  148. package/src/codex/account-lifecycle.ts +130 -13
  149. package/src/codex/account-namespaces.ts +49 -3
  150. package/src/codex/account-priority.ts +83 -0
  151. package/src/codex/account-store.ts +29 -2
  152. package/src/codex/account-usability.ts +25 -2
  153. package/src/codex/admission.ts +256 -0
  154. package/src/codex/affinity-debug.ts +162 -0
  155. package/src/codex/app-server-processes.ts +493 -106
  156. package/src/codex/app-server-restart-service.ts +232 -0
  157. package/src/codex/auth-api.ts +849 -242
  158. package/src/codex/auth-collision.ts +5 -3
  159. package/src/codex/auth-context.ts +345 -32
  160. package/src/codex/autostart-health.ts +8 -1
  161. package/src/codex/catalog/account-models.ts +67 -0
  162. package/src/codex/catalog/aggregation.ts +68 -10
  163. package/src/codex/catalog/bundled.ts +331 -33
  164. package/src/codex/catalog/effort.ts +121 -30
  165. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  166. package/src/codex/catalog/kinds.ts +2 -0
  167. package/src/codex/catalog/metadata.ts +529 -45
  168. package/src/codex/catalog/native-models.ts +72 -0
  169. package/src/codex/catalog/parsing.ts +224 -30
  170. package/src/codex/catalog/provider-fetch.ts +1460 -134
  171. package/src/codex/catalog/sync.ts +1449 -186
  172. package/src/codex/catalog-admission.ts +199 -0
  173. package/src/codex/catalog-refresh-status.ts +105 -0
  174. package/src/codex/catalog-write-serialization.ts +242 -0
  175. package/src/codex/catalog.ts +6 -3
  176. package/src/codex/codex-write-lock.ts +384 -0
  177. package/src/codex/convergence-types.ts +614 -0
  178. package/src/codex/convergence.ts +651 -0
  179. package/src/codex/coordinator-doctor.ts +332 -0
  180. package/src/codex/custom-model-catalog-migration.ts +176 -0
  181. package/src/codex/desired-state.ts +230 -0
  182. package/src/codex/features.ts +636 -39
  183. package/src/codex/generation.ts +202 -0
  184. package/src/codex/history-job.ts +407 -0
  185. package/src/codex/history-lock.ts +242 -0
  186. package/src/codex/history-migration-guardian.ts +26 -20
  187. package/src/codex/history-provider.ts +231 -28
  188. package/src/codex/history-transition.ts +105 -0
  189. package/src/codex/history-worker.ts +220 -0
  190. package/src/codex/inject-coordination.ts +290 -0
  191. package/src/codex/inject.ts +1073 -152
  192. package/src/codex/injected-marker.ts +37 -3
  193. package/src/codex/integration-record.ts +266 -0
  194. package/src/codex/internal/catalog-writer.ts +203 -0
  195. package/src/codex/internal/history-writer.ts +80 -0
  196. package/src/codex/journal.ts +66 -4
  197. package/src/codex/log-guard/inspect.ts +506 -0
  198. package/src/codex/log-guard/lock.ts +150 -0
  199. package/src/codex/log-guard/maintenance.ts +403 -0
  200. package/src/codex/log-guard/path-safety.ts +88 -0
  201. package/src/codex/log-guard/policy.ts +44 -0
  202. package/src/codex/log-guard/processes.ts +205 -0
  203. package/src/codex/log-guard/protection.ts +489 -0
  204. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  205. package/src/codex/main-account-cache.ts +24 -0
  206. package/src/codex/main-account.ts +29 -1
  207. package/src/codex/management-convergence.ts +167 -0
  208. package/src/codex/model-cache.ts +56 -10
  209. package/src/codex/model-entitlements.ts +353 -0
  210. package/src/codex/native-main-admission.ts +47 -0
  211. package/src/codex/native-main-auth-temp.ts +187 -0
  212. package/src/codex/native-main-claim.ts +178 -0
  213. package/src/codex/native-main-lock-file.ts +162 -0
  214. package/src/codex/native-main-owner.ts +329 -0
  215. package/src/codex/native-profile-api.ts +247 -0
  216. package/src/codex/native-profile-manager.ts +1531 -0
  217. package/src/codex/native-profile-processes.ts +121 -0
  218. package/src/codex/native-profile-recovery.ts +99 -0
  219. package/src/codex/native-profile-stage-store.ts +387 -0
  220. package/src/codex/native-profile-startup.ts +492 -0
  221. package/src/codex/native-profile-store.ts +855 -0
  222. package/src/codex/native-profile-types.ts +120 -0
  223. package/src/codex/native-residue.ts +682 -0
  224. package/src/codex/paths.ts +80 -1
  225. package/src/codex/plan-from-token.ts +140 -0
  226. package/src/codex/plan.ts +40 -0
  227. package/src/codex/plugins-doctor.ts +1 -1
  228. package/src/codex/pool-rotation.ts +74 -4
  229. package/src/codex/project-config-warnings.ts +20 -6
  230. package/src/codex/prompt-journal.ts +352 -0
  231. package/src/codex/prompt-layers.ts +967 -0
  232. package/src/codex/prompt-lock.ts +143 -0
  233. package/src/codex/quota-rejection.ts +298 -0
  234. package/src/codex/quota.ts +175 -13
  235. package/src/codex/refresh.ts +11 -2
  236. package/src/codex/reset-credit-recovery.ts +1044 -0
  237. package/src/codex/routing.ts +505 -94
  238. package/src/codex/runtime.ts +159 -38
  239. package/src/codex/shim.ts +1009 -28
  240. package/src/codex/subagent-model-fallback.ts +350 -35
  241. package/src/codex/sync.ts +191 -2
  242. package/src/codex/transition-state.ts +612 -0
  243. package/src/codex/upstream-host-health.ts +368 -0
  244. package/src/codex/user-identity.ts +557 -0
  245. package/src/codex/warmup.ts +187 -81
  246. package/src/codex/write-coordination.ts +114 -0
  247. package/src/combos/failover.ts +20 -0
  248. package/src/combos/index.ts +4 -0
  249. package/src/combos/request.ts +32 -0
  250. package/src/combos/types.ts +81 -9
  251. package/src/config/provider-name.ts +24 -0
  252. package/src/config.ts +1762 -140
  253. package/src/generated/compatibility-version.json +3164 -0
  254. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  255. package/src/grok/inject.ts +16 -5
  256. package/src/grok/inspect.ts +45 -0
  257. package/src/grok/sync.ts +2 -2
  258. package/src/images/loop.ts +152 -29
  259. package/src/images/plan.ts +23 -13
  260. package/src/integrations/config-io.ts +269 -0
  261. package/src/integrations/journal.ts +315 -0
  262. package/src/integrations/merge.ts +135 -0
  263. package/src/integrations/mutation-flight.ts +71 -0
  264. package/src/integrations/native/ownership-preflight.ts +202 -0
  265. package/src/integrations/omp-yaml-source.ts +358 -0
  266. package/src/integrations/owned-refresh.ts +74 -0
  267. package/src/integrations/ownership.ts +111 -0
  268. package/src/integrations/registry.ts +159 -0
  269. package/src/integrations/serialize.ts +314 -0
  270. package/src/integrations/state.ts +361 -0
  271. package/src/integrations/store.ts +103 -0
  272. package/src/integrations/writer-lock.ts +98 -0
  273. package/src/integrations/writer.ts +691 -0
  274. package/src/lab/artifacts/sanitize.ts +586 -0
  275. package/src/lab/artifacts/secure-fs.ts +475 -0
  276. package/src/lab/artifacts/store.ts +310 -0
  277. package/src/lab/automation/budgets.ts +78 -0
  278. package/src/lab/automation/config-persistence.ts +256 -0
  279. package/src/lab/automation/constants.ts +39 -0
  280. package/src/lab/automation/cooldown.ts +103 -0
  281. package/src/lab/automation/dispatch.ts +211 -0
  282. package/src/lab/automation/index.ts +13 -0
  283. package/src/lab/automation/orchestrator.ts +499 -0
  284. package/src/lab/automation/persistence.ts +512 -0
  285. package/src/lab/automation/planner.ts +371 -0
  286. package/src/lab/automation/policy.ts +136 -0
  287. package/src/lab/automation/queue.ts +191 -0
  288. package/src/lab/automation/recovery.ts +24 -0
  289. package/src/lab/automation/route-context.ts +21 -0
  290. package/src/lab/automation/run-key.ts +44 -0
  291. package/src/lab/automation/runs-query.ts +34 -0
  292. package/src/lab/automation/types.ts +160 -0
  293. package/src/lab/conformance/assertion.ts +325 -0
  294. package/src/lab/conformance/digest.ts +22 -0
  295. package/src/lab/conformance/executor.ts +741 -0
  296. package/src/lab/conformance/fixture-provider.ts +27 -0
  297. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  298. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  299. package/src/lab/conformance/harness-budget.ts +47 -0
  300. package/src/lab/conformance/index.ts +5 -0
  301. package/src/lab/conformance/jcs.ts +64 -0
  302. package/src/lab/conformance/json-pointer.ts +39 -0
  303. package/src/lab/conformance/manifest.ts +180 -0
  304. package/src/lab/conformance/mcp-stub.ts +179 -0
  305. package/src/lab/conformance/negative-controls.ts +164 -0
  306. package/src/lab/conformance/observation.ts +355 -0
  307. package/src/lab/conformance/runner.ts +68 -0
  308. package/src/lab/conformance/sse-normalize.ts +59 -0
  309. package/src/lab/conformance/suite-manifest.ts +78 -0
  310. package/src/lab/conformance/types.ts +214 -0
  311. package/src/lab/constants.ts +126 -0
  312. package/src/lab/digest.ts +64 -0
  313. package/src/lab/events/errors.ts +9 -0
  314. package/src/lab/events/limits.ts +117 -0
  315. package/src/lab/events/types.ts +229 -0
  316. package/src/lab/events/validate.ts +781 -0
  317. package/src/lab/fabric/constants.ts +40 -0
  318. package/src/lab/fabric/executor.ts +492 -0
  319. package/src/lab/fabric/index.ts +80 -0
  320. package/src/lab/fabric/manifest.ts +222 -0
  321. package/src/lab/fabric/observe.ts +489 -0
  322. package/src/lab/fabric/patch.ts +79 -0
  323. package/src/lab/fabric/producer-child.ts +139 -0
  324. package/src/lab/fabric/producer-isolate.ts +276 -0
  325. package/src/lab/fabric/producer-protocol.ts +61 -0
  326. package/src/lab/fabric/scratch.ts +439 -0
  327. package/src/lab/fabric/subject.ts +106 -0
  328. package/src/lab/fabric/types.ts +134 -0
  329. package/src/lab/fabric/verifier.ts +98 -0
  330. package/src/lab/index.ts +54 -0
  331. package/src/lab/ledger/artifact-refs.ts +127 -0
  332. package/src/lab/ledger/invalidation.ts +136 -0
  333. package/src/lab/ledger/purge.ts +310 -0
  334. package/src/lab/ledger/store.ts +532 -0
  335. package/src/lab/live/credential-lease.ts +53 -0
  336. package/src/lab/live/destination.ts +155 -0
  337. package/src/lab/live/executor.ts +336 -0
  338. package/src/lab/live/inert-tools.ts +56 -0
  339. package/src/lab/live/manifest.ts +85 -0
  340. package/src/lab/live/mcp-loopback.ts +57 -0
  341. package/src/lab/live/runner.ts +19 -0
  342. package/src/lab/live/sandbox.ts +61 -0
  343. package/src/lab/live/suite-manifest.ts +41 -0
  344. package/src/lab/live/transport.ts +118 -0
  345. package/src/lab/live/types.ts +197 -0
  346. package/src/lab/observe/from-conformance.ts +301 -0
  347. package/src/lab/observe/from-live.ts +117 -0
  348. package/src/lab/paths.ts +153 -0
  349. package/src/lab/projection/rebuild.ts +495 -0
  350. package/src/lab/projection/schema.ts +135 -0
  351. package/src/lab/projection/verdicts.ts +474 -0
  352. package/src/lab/projection/verification.ts +412 -0
  353. package/src/lab/public/bundle.ts +217 -0
  354. package/src/lab/public/community-authority.ts +175 -0
  355. package/src/lab/public/community-files.ts +29 -0
  356. package/src/lab/public/community.ts +479 -0
  357. package/src/lab/public/file-safety.ts +155 -0
  358. package/src/lab/public/ids.ts +26 -0
  359. package/src/lab/public/index.ts +16 -0
  360. package/src/lab/public/mutation-lock.ts +424 -0
  361. package/src/lab/public/operator.ts +353 -0
  362. package/src/lab/public/origin-purge.ts +79 -0
  363. package/src/lab/public/origin.ts +203 -0
  364. package/src/lab/public/privacy.ts +143 -0
  365. package/src/lab/public/private-file.ts +261 -0
  366. package/src/lab/public/project.ts +124 -0
  367. package/src/lab/public/purge-test-fault.ts +21 -0
  368. package/src/lab/public/purge.ts +223 -0
  369. package/src/lab/public/registry.ts +44 -0
  370. package/src/lab/public/revocation.ts +252 -0
  371. package/src/lab/public/signature.ts +243 -0
  372. package/src/lab/public/storage.ts +105 -0
  373. package/src/lab/public/strict-json.ts +206 -0
  374. package/src/lab/public/time.ts +26 -0
  375. package/src/lab/public/types.ts +172 -0
  376. package/src/lab/public/validate.ts +391 -0
  377. package/src/lab/query/catalog.ts +101 -0
  378. package/src/lab/query/connection.ts +107 -0
  379. package/src/lab/query/constants.ts +4 -0
  380. package/src/lab/query/cursor.ts +132 -0
  381. package/src/lab/query/dto-map.ts +277 -0
  382. package/src/lab/query/errors.ts +22 -0
  383. package/src/lab/query/freshness.ts +53 -0
  384. package/src/lab/query/index.ts +45 -0
  385. package/src/lab/query/latest-observation.ts +59 -0
  386. package/src/lab/query/passive-production.ts +159 -0
  387. package/src/lab/query/queries.ts +444 -0
  388. package/src/lab/query/types.ts +266 -0
  389. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  390. package/src/lab/subject/installation-salt.ts +112 -0
  391. package/src/lab/subject/protocol-subject.ts +80 -0
  392. package/src/lab/subject/route-subject.ts +74 -0
  393. package/src/lib/app-owned-memory-stores.ts +22 -0
  394. package/src/lib/bounded-body.ts +153 -9
  395. package/src/lib/bun-runtime.ts +125 -12
  396. package/src/lib/bun-stream-caps.ts +13 -9
  397. package/src/lib/codex-restart-contract.ts +120 -0
  398. package/src/lib/config-ownership.ts +6 -2
  399. package/src/lib/destination-policy.ts +65 -1
  400. package/src/lib/errors.ts +44 -2
  401. package/src/lib/fabric-task-execution-authority.ts +7 -0
  402. package/src/lib/fabric-task-host.ts +29 -0
  403. package/src/lib/lab-activation.ts +223 -0
  404. package/src/lib/lab-live-execution-authority.ts +13 -0
  405. package/src/lib/lab-live-host.ts +30 -0
  406. package/src/lib/lab-live-pinned-sender.ts +56 -0
  407. package/src/lib/lab-live-route-production.ts +130 -0
  408. package/src/lib/lab-passive-linker-registration.ts +26 -0
  409. package/src/lib/local-management-attestation.ts +51 -0
  410. package/src/lib/local-management-capability.ts +100 -0
  411. package/src/lib/local-provider-reload-contract.ts +100 -0
  412. package/src/lib/optional-shutdown-hooks.ts +57 -0
  413. package/src/lib/pinned-http.ts +145 -26
  414. package/src/lib/process-control.ts +4 -1
  415. package/src/lib/provider-outbound.ts +49 -9
  416. package/src/lib/redact.ts +419 -3
  417. package/src/lib/self-launch-argv.ts +15 -0
  418. package/src/lib/server-resource-ownership.ts +71 -0
  419. package/src/lib/shadow-call.ts +35 -4
  420. package/src/lib/sse-decoder.ts +41 -0
  421. package/src/lib/state-store-registrations.ts +10 -2
  422. package/src/lib/system-restart-contract.ts +73 -0
  423. package/src/lib/token-estimate.ts +19 -2
  424. package/src/lib/tool-argument-integers.ts +202 -0
  425. package/src/lib/translator-budget.ts +44 -0
  426. package/src/lib/upstream-http-version.ts +57 -0
  427. package/src/lib/upstream-reachability.ts +95 -0
  428. package/src/lib/upstream-retry.ts +156 -3
  429. package/src/lib/windows-atomic-replace.ts +155 -0
  430. package/src/lib/windows-elevation.ts +70 -2
  431. package/src/lib/windows-secret-acl.ts +409 -69
  432. package/src/lib/windows-service-wrappers.ts +72 -0
  433. package/src/lib/windows-text.ts +106 -0
  434. package/src/lib/windows-user-principal.ts +341 -0
  435. package/src/lib/winsw.ts +33 -5
  436. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  437. package/src/oauth/account-import/index.ts +15 -0
  438. package/src/oauth/account-import/parser.ts +83 -0
  439. package/src/oauth/account-import/registry.ts +18 -0
  440. package/src/oauth/account-import/service.ts +75 -0
  441. package/src/oauth/account-import/types.ts +91 -0
  442. package/src/oauth/anthropic.ts +12 -1
  443. package/src/oauth/callback-server.ts +8 -2
  444. package/src/oauth/chatgpt.ts +12 -1
  445. package/src/oauth/command-code.ts +239 -0
  446. package/src/oauth/cursor.ts +46 -5
  447. package/src/oauth/google-antigravity.ts +35 -3
  448. package/src/oauth/health.ts +20 -12
  449. package/src/oauth/index.ts +398 -66
  450. package/src/oauth/key-providers.ts +16 -0
  451. package/src/oauth/kimi.ts +16 -2
  452. package/src/oauth/kiro.ts +50 -6
  453. package/src/oauth/local-token-detect.ts +11 -2
  454. package/src/oauth/log.ts +3 -1
  455. package/src/oauth/login-cli.ts +88 -28
  456. package/src/oauth/nous.ts +798 -0
  457. package/src/oauth/store.ts +119 -21
  458. package/src/oauth/token-guardian.ts +9 -3
  459. package/src/pi/models.ts +2 -2
  460. package/src/providers/alibaba-region-migration.ts +1 -1
  461. package/src/providers/antigravity-models.ts +521 -31
  462. package/src/providers/base-url-choices.ts +10 -0
  463. package/src/providers/codex-capacity.ts +292 -0
  464. package/src/providers/command-code-efforts.ts +144 -0
  465. package/src/providers/context-cap.ts +22 -5
  466. package/src/providers/cursor-pool.ts +72 -0
  467. package/src/providers/derive.ts +253 -6
  468. package/src/providers/fastwire.ts +501 -0
  469. package/src/providers/free-directory.ts +10 -7
  470. package/src/providers/google-vertex-location.ts +14 -0
  471. package/src/providers/key-failover.ts +71 -3
  472. package/src/providers/label.ts +1 -1
  473. package/src/providers/model-discovery-limits.ts +16 -0
  474. package/src/providers/model-discovery.ts +115 -22
  475. package/src/providers/model-rename-migration.ts +255 -0
  476. package/src/providers/model-rename-startup.ts +28 -0
  477. package/src/providers/openai-sidecar.ts +72 -4
  478. package/src/providers/openai-tier-startup.ts +31 -2
  479. package/src/providers/openai-tiers.ts +119 -4
  480. package/src/providers/openai-virtual-models.ts +1 -0
  481. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  482. package/src/providers/provider-id-rewrite.ts +29 -0
  483. package/src/providers/quota.ts +1319 -38
  484. package/src/providers/registry.ts +1429 -111
  485. package/src/providers/request-pacing.ts +310 -0
  486. package/src/providers/service-tier.ts +277 -0
  487. package/src/providers/slug-codec.ts +42 -6
  488. package/src/providers/static-model-discovery.ts +86 -0
  489. package/src/providers/xai-responses-opt-in.ts +15 -0
  490. package/src/providers/xai-transport.ts +11 -4
  491. package/src/reasoning-effort.ts +49 -1
  492. package/src/responses/compaction.ts +26 -1
  493. package/src/responses/custom-tool-compat.ts +266 -0
  494. package/src/responses/hosted-tool-policy.ts +9 -0
  495. package/src/responses/namespace-tool-compat.ts +355 -0
  496. package/src/responses/parser.ts +220 -38
  497. package/src/responses/provider-continuation.ts +98 -0
  498. package/src/responses/provider-opaque-metadata.ts +73 -0
  499. package/src/responses/reasoning-envelope.ts +9 -1
  500. package/src/responses/reasoning-replay-cache.ts +426 -0
  501. package/src/responses/schema.ts +7 -1
  502. package/src/responses/spill-store.ts +75 -10
  503. package/src/responses/state.ts +565 -27
  504. package/src/responses/thought-signature-replay.ts +347 -0
  505. package/src/responses/tool-search-compat.ts +301 -0
  506. package/src/responses/truncated-stop-reason.ts +60 -0
  507. package/src/router.ts +366 -30
  508. package/src/routing/analytics.ts +378 -0
  509. package/src/routing/capability.ts +244 -0
  510. package/src/routing/compatibility/assemble.ts +73 -0
  511. package/src/routing/compatibility/behavior.ts +278 -0
  512. package/src/routing/compatibility/catalog.ts +99 -0
  513. package/src/routing/compatibility/endpoint.ts +52 -0
  514. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  515. package/src/routing/compatibility/policy.ts +181 -0
  516. package/src/routing/compatibility/provider-slot.ts +56 -0
  517. package/src/routing/compatibility/reader.ts +110 -0
  518. package/src/routing/compatibility/subject.ts +191 -0
  519. package/src/routing/compatibility/types.ts +64 -0
  520. package/src/routing/compatibility/version.ts +104 -0
  521. package/src/routing/cost.ts +77 -0
  522. package/src/routing/evaluator.ts +495 -0
  523. package/src/routing/health.ts +412 -0
  524. package/src/routing/history/cursor.ts +43 -0
  525. package/src/routing/history/indexer.ts +605 -0
  526. package/src/routing/history/schema.ts +72 -0
  527. package/src/routing/profile-namespace.ts +15 -0
  528. package/src/routing/profile.ts +547 -0
  529. package/src/routing/quota.ts +145 -0
  530. package/src/routing/request-evidence.ts +45 -0
  531. package/src/routing/trace.ts +776 -0
  532. package/src/server/adapter-resolve.ts +2 -29
  533. package/src/server/auth-cors.ts +267 -46
  534. package/src/server/background-lifecycle.ts +182 -0
  535. package/src/server/chat-completions.ts +130 -56
  536. package/src/server/chat-native-sse.ts +331 -0
  537. package/src/server/chat-native.ts +426 -0
  538. package/src/server/claude-messages.ts +159 -43
  539. package/src/server/direct-local-http.ts +347 -0
  540. package/src/server/effort-policy.ts +18 -0
  541. package/src/server/github-copilot-responses-repair.ts +338 -0
  542. package/src/server/gui-static.ts +39 -10
  543. package/src/server/images.ts +94 -12
  544. package/src/server/index.ts +865 -181
  545. package/src/server/lifecycle.ts +284 -13
  546. package/src/server/live.ts +136 -17
  547. package/src/server/local-management-read-client.ts +90 -0
  548. package/src/server/local-provider-reload-client.ts +137 -0
  549. package/src/server/management/agent-settings-routes.ts +398 -103
  550. package/src/server/management/api-key-usage.ts +31 -5
  551. package/src/server/management/body.ts +6 -0
  552. package/src/server/management/combo-routes.ts +62 -24
  553. package/src/server/management/config-routes.ts +464 -51
  554. package/src/server/management/context.ts +80 -2
  555. package/src/server/management/integration-routes.ts +498 -0
  556. package/src/server/management/lab-automation-routes.ts +206 -0
  557. package/src/server/management/lab-routes.ts +563 -0
  558. package/src/server/management/logs-usage-routes.ts +101 -32
  559. package/src/server/management/model-routes.ts +189 -131
  560. package/src/server/management/model-rows.ts +163 -0
  561. package/src/server/management/native-integration-routes.ts +769 -0
  562. package/src/server/management/oauth-account-routes.ts +80 -4
  563. package/src/server/management/provider-capability-config.ts +48 -0
  564. package/src/server/management/provider-routes.ts +764 -157
  565. package/src/server/management/request-history-routes.ts +191 -0
  566. package/src/server/management/routing-analytics-routes.ts +74 -0
  567. package/src/server/management/routing-profile-routes.ts +380 -0
  568. package/src/server/management/shared.ts +27 -11
  569. package/src/server/management/sidebar-routes.ts +47 -31
  570. package/src/server/management/storage-log-guard-routes.ts +186 -0
  571. package/src/server/management/sync-response.ts +69 -0
  572. package/src/server/management/system-restart.ts +288 -32
  573. package/src/server/management/system-routes.ts +77 -0
  574. package/src/server/management/usage-summary-cache.ts +9 -1
  575. package/src/server/management/vision-sidecar-options.ts +167 -0
  576. package/src/server/management/web-search-sidecar-options.ts +120 -0
  577. package/src/server/management-api.ts +115 -14
  578. package/src/server/management-auth.ts +220 -5
  579. package/src/server/passive-route-linker.ts +66 -0
  580. package/src/server/ports.ts +41 -1
  581. package/src/server/proxy-liveness.ts +132 -5
  582. package/src/server/readiness.ts +99 -0
  583. package/src/server/relay-eager.ts +82 -42
  584. package/src/server/relay.ts +236 -76
  585. package/src/server/request-decompress.ts +113 -6
  586. package/src/server/request-log.ts +235 -22
  587. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  588. package/src/server/responses/agent-task-recovery.ts +465 -0
  589. package/src/server/responses/collaboration.ts +204 -35
  590. package/src/server/responses/compact.ts +442 -55
  591. package/src/server/responses/core.ts +2872 -331
  592. package/src/server/responses/empty-completion-guard.ts +276 -0
  593. package/src/server/responses/encrypted-payload.ts +62 -39
  594. package/src/server/responses/fetch-helpers.ts +79 -4
  595. package/src/server/responses/input-admission.ts +185 -0
  596. package/src/server/responses/pacing-overload.ts +13 -0
  597. package/src/server/responses/policy-fallback.ts +178 -0
  598. package/src/server/responses/responses-field-backfill.ts +251 -0
  599. package/src/server/responses/terminal-guard.ts +26 -5
  600. package/src/server/responses/upstream-error.ts +5 -0
  601. package/src/server/responses/ws-upstream.ts +308 -0
  602. package/src/server/responses-custom-tool-repair.ts +282 -0
  603. package/src/server/responses-item-id-repair.ts +54 -6
  604. package/src/server/responses-json-events.ts +90 -0
  605. package/src/server/responses-model-rewrite.ts +29 -0
  606. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  607. package/src/server/responses-snapshot-repair.ts +621 -0
  608. package/src/server/responses-terminal-repair.ts +342 -0
  609. package/src/server/responses-tool-search-repair.ts +267 -0
  610. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  611. package/src/server/responses.ts +18 -2
  612. package/src/server/search.ts +78 -13
  613. package/src/server/sse-frame-buffer.ts +292 -0
  614. package/src/server/sse-payload-rewrite.ts +110 -22
  615. package/src/server/startup-action-control.ts +8 -1
  616. package/src/server/startup-health-cache.ts +19 -1
  617. package/src/server/system-env.ts +80 -9
  618. package/src/server/ws-bridge.ts +39 -38
  619. package/src/service-manager-probe.ts +892 -0
  620. package/src/service.ts +1111 -90
  621. package/src/sidecar/auth.ts +92 -0
  622. package/src/sidecar/candidates.ts +83 -0
  623. package/src/storage/cleanup.ts +2 -2
  624. package/src/storage/scanner.ts +1 -1
  625. package/src/storage/worker-lifecycle.ts +14 -14
  626. package/src/tray/windows-tray.ps1 +83 -9
  627. package/src/tray/windows.ts +43 -16
  628. package/src/types/accounts.ts +37 -0
  629. package/src/types/config.ts +845 -0
  630. package/src/types/provider.ts +545 -0
  631. package/src/types/request.ts +384 -0
  632. package/src/types/tools.ts +131 -0
  633. package/src/types/wire.ts +80 -0
  634. package/src/types.ts +104 -1236
  635. package/src/update/index.ts +32 -19
  636. package/src/update/job.ts +442 -67
  637. package/src/update/notify.ts +12 -6
  638. package/src/update/npm-cache-preflight.d.mts +47 -0
  639. package/src/update/npm-cache-preflight.mjs +201 -0
  640. package/src/update/transactional-install.d.mts +22 -0
  641. package/src/update/transactional-install.mjs +259 -0
  642. package/src/usage/cost.ts +0 -0
  643. package/src/usage/expected-prices.ts +268 -16
  644. package/src/usage/log.ts +606 -41
  645. package/src/usage/summary.ts +177 -9
  646. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  647. package/src/usage/user-cost-overlays.ts +314 -0
  648. package/src/vision/anthropic-describe.ts +10 -6
  649. package/src/vision/backends.ts +97 -0
  650. package/src/vision/describe.ts +9 -3
  651. package/src/vision/eligibility.ts +250 -0
  652. package/src/vision/index.ts +238 -24
  653. package/src/vision/reasoning.ts +55 -0
  654. package/src/vision/routed-describe.ts +175 -0
  655. package/src/vision/timeout-bounds.ts +9 -0
  656. package/src/web-search/anthropic-executor.ts +13 -7
  657. package/src/web-search/backends.ts +108 -0
  658. package/src/web-search/exa-executor.ts +88 -0
  659. package/src/web-search/executor.ts +11 -3
  660. package/src/web-search/gemini-executor.ts +141 -0
  661. package/src/web-search/index.ts +150 -15
  662. package/src/web-search/loop.ts +279 -50
  663. package/src/web-search/parse.ts +125 -30
  664. package/src/web-search/sources.ts +60 -0
  665. package/src/web-search/xai-executor.ts +219 -0
  666. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  667. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  668. package/gui/dist/provider-icons/antigravity.svg +0 -1
  669. package/gui/dist/provider-icons/claude.svg +0 -1
  670. package/gui/dist/provider-icons/copilot.svg +0 -1
  671. package/gui/dist/provider-icons/cursor.svg +0 -2
  672. package/gui/dist/provider-icons/gemini.svg +0 -1
  673. package/gui/dist/provider-icons/grok-color.svg +0 -1
  674. package/gui/dist/provider-icons/kiro.svg +0 -14
  675. package/src/cli/internal-dispatch.ts +0 -20
@@ -0,0 +1,798 @@
1
+ /**
2
+ * Nous Portal OAuth flow (device authorization grant, RFC 8628).
3
+ *
4
+ * Nous Research's unified subscription gateway — the same backend Hermes Agent
5
+ * uses. The Portal is a single account surface for both the paid subscription
6
+ * (billed against the account) and a set of free models (the `:free` slugs such
7
+ * as `tencent/hy3:free`, `inclusionai/ling-3.0-flash:free`).
8
+ *
9
+ * Verified against Hermes `hermes_cli/auth.py` (2026-08):
10
+ * - device endpoint: POST {portal}/api/oauth/device/code
11
+ * - token endpoint: POST {portal}/api/oauth/token
12
+ * - the access token returned by the token endpoint IS the per-request
13
+ * inference JWT (scope `inference:invoke`) and is used directly as
14
+ * `Authorization: *** against the OpenAI-compatible inference API at
15
+ * https://inference-api.nousresearch.com/v1.
16
+ * - refresh sends the refresh token in the `x-nous-refresh-token` HEADER (not
17
+ * the body): `POST /api/oauth/token` with `grant_type=refresh_token` +
18
+ * `client_id`, header `x-nous-refresh-token: <rt>`.
19
+ * - Nous refresh tokens are SINGLE-USE: every successful refresh rotates the
20
+ * token, and reuse (e.g. two processes refreshing concurrently) is treated as
21
+ * token theft and revokes the whole session (`refresh_token_reused`).
22
+ * OpenCodex's refresh path persists the rotated token immediately
23
+ * (`mergeAccountCredential`), which is exactly the discipline the Portal
24
+ * expects; proactive background refresh must stay off for this provider.
25
+ *
26
+ * Single-use refresh is made failure-atomic (review blocker #2): a durable
27
+ * refresh-intent file is written BEFORE the refresh request and only removed
28
+ * after the rotated token is obtained. If we ever receive a server response
29
+ * but fail to persist the rotated token, the intent is marked "uncertain" and
30
+ * the next refresh refuses to replay the (possibly consumed) token, forcing a
31
+ * clean re-authentication instead of a silent session-revoking replay. After
32
+ * dispatch, ANY non-2xx outcome (429, unknown/custom 4xx, 5xx, gateway errors)
33
+ * retains the durable intent: no HTTP status class proves the single-use token
34
+ * was not consumed, so the submitted token is never automatically replayed.
35
+ */
36
+ import { createHash } from "node:crypto";
37
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import type { OAuthController, OAuthCredentials } from "./types";
40
+ import { getAuthStorePath } from "./store";
41
+ import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
42
+ import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
43
+
44
+ export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com";
45
+ export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1";
46
+ export const NOUS_OAUTH_CLIENT_ID = "hermes-cli";
47
+ export const NOUS_OAUTH_SCOPE = "inference:invoke";
48
+
49
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
50
+ const MAX_POLL_INTERVAL_MS = 30_000;
51
+ const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
52
+ // Fallback lifetime for an inference access token when neither the JWT `exp`
53
+ // claim nor `expires_in` is present. Kept distinct from the device-flow window:
54
+ // the two are unrelated durations.
55
+ const DEFAULT_ACCESS_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
56
+ // Upper bound on a plausible inference-JWT lifetime; guards against a bad `exp`
57
+ // unit (ms instead of s) or a badly skewed clock.
58
+ const MAX_PLAUSIBLE_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
59
+ const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
60
+ const OAUTH_EXPIRY_SKEW_MS = 2 * 60 * 1000;
61
+
62
+ interface NousDeviceAuthorizationResponse {
63
+ device_code?: unknown;
64
+ user_code?: unknown;
65
+ verification_uri?: unknown;
66
+ verification_uri_complete?: unknown;
67
+ expires_in?: unknown;
68
+ interval?: unknown;
69
+ }
70
+
71
+ interface NousTokenResponse {
72
+ access_token?: unknown;
73
+ refresh_token?: unknown;
74
+ expires_in?: unknown;
75
+ token_type?: unknown;
76
+ scope?: unknown;
77
+ inference_base_url?: unknown;
78
+ error?: unknown;
79
+ error_description?: unknown;
80
+ interval?: unknown;
81
+ }
82
+
83
+ interface NousJwtPayload {
84
+ sub?: unknown;
85
+ email?: unknown;
86
+ exp?: unknown;
87
+ scope?: unknown;
88
+ [key: string]: unknown;
89
+ }
90
+
91
+ async function readOAuthBytes(response: Response, signal: AbortSignal): Promise<Uint8Array> {
92
+ const { bytes, oversized } = await readBoundedResponseBytes(response, {
93
+ maxBytes: BOUNDED_BODY_MAX_BYTES,
94
+ signal,
95
+ });
96
+ if (oversized) {
97
+ throw new NousTokenError(
98
+ response.status,
99
+ "response_too_large",
100
+ `Nous Portal OAuth response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
101
+ );
102
+ }
103
+ return bytes;
104
+ }
105
+
106
+ function parseOAuthJson(bytes: Uint8Array): unknown {
107
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
108
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
109
+ ? parsed as Record<string, unknown>
110
+ : {};
111
+ }
112
+
113
+ async function readOAuthJson(response: Response, signal: AbortSignal): Promise<unknown> {
114
+ return parseOAuthJson(await readOAuthBytes(response, signal));
115
+ }
116
+
117
+ async function readOAuthJsonOrEmpty(response: Response, signal: AbortSignal): Promise<unknown> {
118
+ const bytes = await readOAuthBytes(response, signal);
119
+ try {
120
+ return parseOAuthJson(bytes);
121
+ } catch {
122
+ // Preserve the pre-PR behavior for empty/HTML/malformed JSON only. Body
123
+ // read failures, timeouts, caller cancellation, and size-limit errors have
124
+ // already escaped readOAuthBytes and must retain their real identity.
125
+ return {};
126
+ }
127
+ }
128
+
129
+ // ── Durable refresh-intent (review blocker #2) ──────────────────────────────
130
+ // A refresh-intent file records that we submitted `refreshToken` to the Portal
131
+ // and whether we are certain the rotated token was persisted. It lives next to
132
+ // the auth store (same config dir) and is keyed by a hash of the refresh
133
+ // token, so it never contains the token in cleartext.
134
+ //
135
+ // The mechanism reuses the repository's hardened config IO (atomicWriteFile +
136
+ // hardenConfigDir from ../config and ./store) and is FAIL-CLOSED: if we cannot
137
+ // durably create or read the intent, we refuse the refresh rather than silently
138
+ // disable the guard. An unreadable/corrupt intent is treated as "uncertain"
139
+ // (replay refused), never as "absent".
140
+ //
141
+ // States:
142
+ // - "submitted": we sent this token and a server response was received (so it
143
+ // may have been consumed). We leave it set after a successful rotation until
144
+ // the store confirms persistence of the rotated token; if we crash before
145
+ // that, a later replay of the same token is refused.
146
+ // - "uncertain": the dispatch may have reached the server (we saw an error
147
+ // after sending, or failed to parse/persist the rotated token). Replay is
148
+ // refused.
149
+
150
+ type RefreshIntentStatus = "submitted" | "uncertain";
151
+
152
+ interface RefreshIntent {
153
+ status: RefreshIntentStatus;
154
+ updatedAt: number;
155
+ }
156
+
157
+ export class RefreshIntentIOError extends Error {
158
+ constructor(message: string, cause?: unknown) {
159
+ super(message, cause ? { cause } : undefined);
160
+ this.name = "RefreshIntentIOError";
161
+ }
162
+ }
163
+
164
+ function refreshIntentDir(): string {
165
+ return join(getAuthStorePath(), "..", ".nous-refresh-intent");
166
+ }
167
+
168
+ function refreshIntentPath(refreshToken: string): string {
169
+ const hash = createHash("sha256").update(refreshToken).digest("hex");
170
+ return join(refreshIntentDir(), `${hash}.json`);
171
+ }
172
+
173
+ /**
174
+ * Validate a persisted refresh-intent payload BEFORE trusting it. A syntactically
175
+ * valid JSON blob is not enough: `{}` or a wrong-shaped object must not silently
176
+ * produce `status === undefined` (which would bypass the replay guard). We only
177
+ * accept an object whose `status` is exactly one supported state and whose
178
+ * `updatedAt` is a finite number. Anything else is treated as `uncertain` —
179
+ * never as absent — so a corrupt intent can never make a possibly-consumed
180
+ * token replayable.
181
+ */
182
+ function parseRefreshIntent(raw: string): RefreshIntent {
183
+ let value: unknown;
184
+ try {
185
+ value = JSON.parse(raw);
186
+ } catch {
187
+ return { status: "uncertain", updatedAt: Date.now() };
188
+ }
189
+ if (typeof value !== "object" || value === null) {
190
+ return { status: "uncertain", updatedAt: Date.now() };
191
+ }
192
+ const candidate = value as Record<string, unknown>;
193
+ const status = candidate.status;
194
+ if (status !== "submitted" && status !== "uncertain") {
195
+ return { status: "uncertain", updatedAt: Date.now() };
196
+ }
197
+ if (typeof candidate.updatedAt !== "number" || !Number.isFinite(candidate.updatedAt)) {
198
+ return { status: "uncertain", updatedAt: Date.now() };
199
+ }
200
+ return { status, updatedAt: candidate.updatedAt };
201
+ }
202
+
203
+ function readRefreshIntent(refreshToken: string): RefreshIntent | undefined {
204
+ const path = refreshIntentPath(refreshToken);
205
+ try {
206
+ // Mirror the repository's hardened read: chmod/ACL-harden the secret path
207
+ // before reading, and treat any read/parse error as uncertain (fail-closed)
208
+ // rather than silently absent.
209
+ hardenExistingSecret(path);
210
+ return parseRefreshIntent(readFileSync(path, "utf8"));
211
+ } catch (error) {
212
+ // ENOENT means we never recorded an intent for this token -> safe to proceed.
213
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") {
214
+ return undefined;
215
+ }
216
+ // Any other failure (corrupt JSON, permission, ACL) => uncertain: never
217
+ // assume the token is replayable.
218
+ return { status: "uncertain", updatedAt: Date.now() };
219
+ }
220
+ }
221
+
222
+ function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void {
223
+ const dir = refreshIntentDir();
224
+ // Hardened, owner-only directory + atomic (temp+rename) write. Throws on
225
+ // failure so the caller can fail closed instead of refreshing blind.
226
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
227
+ // mkdirSync only applies the mode at creation; re-apply owner-only on an
228
+ // existing directory so a permissive pre-existing dir is corrected. Fail
229
+ // closed: if the directory cannot be hardened to owner-only, do not write
230
+ // refresh-intent data into a directory a local attacker may observe.
231
+ chmodSync(dir, 0o700);
232
+ hardenConfigDir();
233
+ const path = refreshIntentPath(refreshToken);
234
+ try {
235
+ atomicWriteFile(path, JSON.stringify({ status, updatedAt: Date.now() } satisfies RefreshIntent));
236
+ } catch (error) {
237
+ throw new RefreshIntentIOError(`Failed to durably record Nous refresh intent (${status}); refusing refresh to avoid replaying a possibly-consumed token`, error);
238
+ }
239
+ }
240
+
241
+ function clearRefreshIntent(refreshToken: string): void {
242
+ try {
243
+ rmSync(refreshIntentPath(refreshToken), { force: true });
244
+ } catch (error) {
245
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return;
246
+ // A non-ENOENT failure to clear is concerning but non-fatal for the caller;
247
+ // the next replay guard still keys off the (now possibly stale) file.
248
+ throw new RefreshIntentIOError("Failed to clear Nous refresh intent", error);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * True when replaying this refresh token is unsafe: we previously submitted it
254
+ * and either got a response (so it may have been consumed) or failed to confirm
255
+ * the rotation persisted. In that uncertain state we must never blindly replay
256
+ * it — a replay could trigger `refresh_token_reused` and revoke the session.
257
+ * The caller should force a clean re-authentication instead.
258
+ */
259
+ export function nousRefreshIntentBlocksReplay(refreshToken: string): boolean {
260
+ return readRefreshIntent(refreshToken)?.status !== undefined;
261
+ }
262
+
263
+ // ── Base URL hardening (review blocker #1, also flagged by multiple reviewers) ─
264
+
265
+ /**
266
+ * Normalize and hard-validate the Nous Portal OAuth base URL.
267
+ *
268
+ * Security: the portal accepts the bearer-equivalent single-use refresh token
269
+ * in the `x-nous-refresh-token` header and returns the per-request inference
270
+ * JWT as the access token. Sending either over cleartext (or to a
271
+ * credential/query/fragment-laden URL) leaks credentials to a network
272
+ * attacker. Validate the *complete* URL up front and throw before any
273
+ * `fetch` is dispatched — both the device-grant and the refresh path call
274
+ * this from inside their `fetch` arguments, so a thrown error guarantees the
275
+ * network call never runs.
276
+ *
277
+ * Mirrors the https-only default in Hermes `hermes_cli/auth.py`
278
+ * (`DEFAULT_NOUS_PORTAL_URL`). Note: unlike Hermes, this function does not pin
279
+ * the host to an allowlist; any HTTPS origin passes.
280
+ */
281
+ function resolvePortalBaseUrl(): string {
282
+ const raw = (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).trim();
283
+ let url: URL;
284
+ try {
285
+ url = new URL(raw);
286
+ } catch {
287
+ // Do not echo the raw value: it may contain embedded credentials. Identify
288
+ // the configuration problem without reflecting secret-bearing input.
289
+ throw new NousTokenError(undefined, undefined, "Nous Portal base URL is not a valid URL");
290
+ }
291
+ if (url.protocol !== "https:") {
292
+ throw new NousTokenError(undefined, undefined, `Nous Portal base URL must use HTTPS (got ${url.protocol})`);
293
+ }
294
+ if (url.username || url.password) {
295
+ throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain embedded credentials");
296
+ }
297
+ if (url.search) {
298
+ throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a query string");
299
+ }
300
+ if (url.hash) {
301
+ throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a fragment");
302
+ }
303
+ // Origin only — no path/query/fragment — so callers cannot smuggle a
304
+ // non-canonical endpoint through the override.
305
+ return url.origin;
306
+ }
307
+
308
+ function decodeJwtPayload(token: string): NousJwtPayload | undefined {
309
+ const parts = token.split(".");
310
+ const payload = parts[1];
311
+ if (parts.length !== 3 || !payload) return undefined;
312
+ try {
313
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as NousJwtPayload;
314
+ } catch {
315
+ return undefined;
316
+ }
317
+ }
318
+
319
+ function nonEmptyString(value: unknown): string | undefined {
320
+ return typeof value === "string" && value.length > 0 ? value : undefined;
321
+ }
322
+
323
+ /**
324
+ * Best-effort multiauth identity from the Nous inference JWT claims. The Portal
325
+ * mints these tokens per login; `sub` is the stable subject and `email` is
326
+ * lowercased when present. Opaque (non-JWT) tokens carry no identity and are
327
+ * rejected downstream by the `inference:invoke` scope gate (parseTokenPayload),
328
+ * so a usable access token is always a decodable JWT.
329
+ */
330
+ export function identityFromNousTokens(accessToken: string): { accountId?: string; email?: string } {
331
+ const payload = decodeJwtPayload(accessToken);
332
+ if (!payload) return {};
333
+ const accountId = nonEmptyString(payload.sub);
334
+ const email = nonEmptyString(payload.email)?.toLowerCase();
335
+ return {
336
+ ...(accountId ? { accountId } : {}),
337
+ ...(email ? { email } : {}),
338
+ };
339
+ }
340
+
341
+ /** JWT `exp` (epoch seconds) → expiry ms, when present and sane. */
342
+ function jwtExpiryMs(payload: NousJwtPayload | undefined): number | undefined {
343
+ const exp = payload?.exp;
344
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined;
345
+ const expMs = exp * 1000;
346
+ // Ignore an implausible claim (past, or absurdly far in the future) and let
347
+ // the caller fall back to `expires_in`. A too-large `exp` (e.g. ms instead of
348
+ // seconds, or clock skew) would otherwise pin the credential as never
349
+ // expiring, and a too-small one would force an immediate refresh that burns a
350
+ // single-use token.
351
+ if (expMs <= Date.now() || expMs > Date.now() + MAX_PLAUSIBLE_TOKEN_LIFETIME_MS) return undefined;
352
+ return expMs;
353
+ }
354
+
355
+ /** Does the inference JWT grant the required `inference:invoke` scope? */
356
+ function jwtGrantsInference(payload: NousJwtPayload | undefined): boolean {
357
+ const scope = nonEmptyString(payload?.scope);
358
+ if (!scope) return false;
359
+ // Scope is a space-separated list per RFC 6749.
360
+ return scope.split(/\s+/).includes(NOUS_OAUTH_SCOPE);
361
+ }
362
+
363
+ export class NousTokenError extends Error {
364
+ /** When true, the token cannot be saved/used and the account needs re-auth. */
365
+ public readonly terminal: boolean;
366
+ /**
367
+ * When set, the (already rotated) refresh token to persist before re-auth, so
368
+ * the caller can drive a clean re-authentication without discarding the
369
+ * rotation the server already performed (review #5). Only the refresh token
370
+ * is retained (never the access token), and the property is non-enumerable so
371
+ * it is not leaked by structured logging/serialization (review: credentials
372
+ * must not be enumerable Error properties).
373
+ */
374
+ private readonly rotatedRefresh?: string;
375
+
376
+ constructor(
377
+ status: number | undefined,
378
+ public readonly oauthError: string | undefined,
379
+ message: string,
380
+ options?: { cause?: unknown; terminal?: boolean; credentials?: OAuthCredentials },
381
+ ) {
382
+ super(message, options);
383
+ this.name = "NousTokenError";
384
+ this.terminal = options?.terminal ?? false;
385
+ this.rotatedRefresh = options?.credentials?.refresh;
386
+ // Non-enumerable so JSON.stringify / util.inspect / logging sinks do not
387
+ // surface a live credential. Read it via getRotatedRefresh().
388
+ Object.defineProperty(this, "rotatedRefresh", { enumerable: false, configurable: true });
389
+ }
390
+
391
+ /** The rotated refresh token to persist before re-auth, if any. */
392
+ getRotatedRefresh(): string | undefined {
393
+ return this.rotatedRefresh;
394
+ }
395
+ }
396
+
397
+ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
398
+ const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
399
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
400
+ }
401
+
402
+ /**
403
+ * Sleep for `ms`, resolving on timer completion. The abort listener is removed
404
+ * on both resolve and abort so we do not accumulate listeners across polling
405
+ * iterations (review point #9).
406
+ */
407
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
408
+ return new Promise((resolve, reject) => {
409
+ if (signal?.aborted) return reject(new Error("Login cancelled"));
410
+ const onAbort = () => {
411
+ clearTimeout(t);
412
+ cleanup();
413
+ reject(new Error("Login cancelled"));
414
+ };
415
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
416
+ const t = setTimeout(() => {
417
+ cleanup();
418
+ resolve();
419
+ }, ms);
420
+ signal?.addEventListener("abort", onAbort, { once: true });
421
+ });
422
+ }
423
+
424
+ /**
425
+ * Read an error payload from a failed token response. `payload` is the already
426
+ * parsed JSON (so callers do not re-read a consumed body — review point #8).
427
+ */
428
+ function tokenErrorFromPayload(status: number, payload: unknown): NousTokenError {
429
+ const body = (payload ?? {}) as { error?: unknown; error_description?: unknown };
430
+ const oauthError = typeof body.error === "string" ? body.error : undefined;
431
+ const detail = typeof body.error_description === "string" ? body.error_description : "";
432
+ const suffix = detail ? `: ${detail}` : oauthError ? `: ${oauthError}` : "";
433
+ const terminal = oauthError === "invalid_token" || oauthError === "invalid_grant" || oauthError === "revoked" || oauthError === "revoked_token";
434
+ return new NousTokenError(status, oauthError, `Nous Portal token request failed: ${status}${suffix}`, { terminal });
435
+ }
436
+
437
+ /**
438
+ * Build credentials from a token endpoint response.
439
+ *
440
+ * Nous refresh tokens are SINGLE-USE and rotated on every successful refresh
441
+ * (see module docstring, matching Hermes `hermes_cli/auth.py`). A response
442
+ * that omits `refresh_token`, or returns a replacement equal to the token we
443
+ * just submitted, leaves us holding a consumed credential: the next refresh
444
+ * would replay it and the Portal treats reuse as token theft
445
+ * (`refresh_token_reused`), revoking the whole session. Reject both cases
446
+ * rather than silently falling back to the submitted token.
447
+ *
448
+ * The returned access token must also grant the `inference:invoke` scope; if it
449
+ * does not, the credential is unusable for inference and we raise a terminal
450
+ * error — but we still surface the (already rotated) refresh token in the
451
+ * error so the caller can persist it and drive a clean re-authentication
452
+ * without discarding the rotation the server already performed (review #5).
453
+ *
454
+ * @param submittedRefreshToken the refresh token sent in the request; used only
455
+ * to detect a no-rotation / consumed-token response, never as a fallback.
456
+ */
457
+ function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: string): OAuthCredentials {
458
+ const access = nonEmptyString(payload.access_token);
459
+ if (!access) {
460
+ // A response without a usable access token cannot be used for inference;
461
+ // classify it as terminal so the coordinator forces re-authentication.
462
+ throw new NousTokenError(
463
+ undefined,
464
+ "invalid_token",
465
+ "Nous Portal token response did not include an access token",
466
+ { terminal: true },
467
+ );
468
+ }
469
+ const refresh = nonEmptyString(payload.refresh_token);
470
+ if (!refresh) {
471
+ if (submittedRefreshToken) {
472
+ // Rotation path: the server consumed RT-A but returned no replacement, so
473
+ // reusing RT-A would trigger refresh_token_reused and revoke the session.
474
+ throw new NousTokenError(
475
+ undefined,
476
+ "refresh_token_reused",
477
+ "Nous Portal did not return a replacement refresh token; refusing to reuse the consumed one (would trigger refresh_token_reused and revoke the session)",
478
+ { terminal: true },
479
+ );
480
+ }
481
+ // Device-login path: no refresh token was submitted; a missing refresh_token
482
+ // in the initial token response is an unusable response, not a reuse.
483
+ throw new NousTokenError(
484
+ undefined,
485
+ "invalid_token",
486
+ "Nous Portal token response did not include a refresh token",
487
+ { terminal: true },
488
+ );
489
+ }
490
+ if (submittedRefreshToken && refresh === submittedRefreshToken) {
491
+ throw new NousTokenError(
492
+ undefined,
493
+ "refresh_token_reused",
494
+ "Nous Portal returned the same refresh token we submitted; refusing to reuse it (single-use rotation expected, session may be compromised)",
495
+ { terminal: true },
496
+ );
497
+ }
498
+
499
+ const jwtPayload = decodeJwtPayload(access);
500
+ const expMs = jwtExpiryMs(jwtPayload);
501
+ const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : undefined;
502
+ // Prefer the JWT `exp` claim when present (it is the authoritative inference
503
+ // JWT lifetime), else fall back to `expires_in`.
504
+ const rawExpires = expMs
505
+ ?? (expiresInMs !== undefined ? Date.now() + expiresInMs : Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS);
506
+ const expires = Math.max(0, rawExpires - OAUTH_EXPIRY_SKEW_MS);
507
+
508
+ const creds: OAuthCredentials = {
509
+ access,
510
+ refresh,
511
+ expires,
512
+ ...identityFromNousTokens(access),
513
+ };
514
+
515
+ if (!jwtGrantsInference(jwtPayload)) {
516
+ // Unusable for inference, but the server already rotated the refresh token:
517
+ // surface it so the caller persists it and forces a re-auth rather than
518
+ // discarding a valid rotation.
519
+ throw new NousTokenError(
520
+ undefined,
521
+ "insufficient_scope",
522
+ "Nous Portal access token does not grant the required inference:invoke scope",
523
+ { terminal: true, credentials: creds },
524
+ );
525
+ }
526
+
527
+ return creds;
528
+ }
529
+
530
+ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
531
+ userCode: string;
532
+ deviceCode: string;
533
+ verificationUriComplete: string;
534
+ expiresInMs: number;
535
+ intervalMs: number;
536
+ }> {
537
+ const effectiveSignal = requestSignal(signal);
538
+ const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/device/code`, {
539
+ method: "POST",
540
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
541
+ body: new URLSearchParams({
542
+ client_id: NOUS_OAUTH_CLIENT_ID,
543
+ scope: NOUS_OAUTH_SCOPE,
544
+ }),
545
+ redirect: "error",
546
+ signal: effectiveSignal,
547
+ });
548
+ if (!response.ok) {
549
+ throw tokenErrorFromPayload(response.status, await readOAuthJsonOrEmpty(response, effectiveSignal));
550
+ }
551
+ // A successful HTTP response may still carry an empty/HTML/non-JSON body.
552
+ // Fall back to an empty object so the required-field check below produces the
553
+ // clear "missing required fields" validation error instead of leaking a raw
554
+ // JSON parser exception.
555
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousDeviceAuthorizationResponse;
556
+ const userCode = nonEmptyString(payload.user_code);
557
+ const deviceCode = nonEmptyString(payload.device_code);
558
+ const verificationUri = nonEmptyString(payload.verification_uri_complete) ?? nonEmptyString(payload.verification_uri);
559
+ if (!userCode || !deviceCode || !verificationUri) {
560
+ throw new Error("Nous Portal device authorization response missing required fields");
561
+ }
562
+ return {
563
+ userCode,
564
+ deviceCode,
565
+ verificationUriComplete: verificationUri,
566
+ expiresInMs: typeof payload.expires_in === "number" && payload.expires_in > 0
567
+ ? payload.expires_in * 1000
568
+ : DEFAULT_DEVICE_FLOW_TTL_MS,
569
+ intervalMs: typeof payload.interval === "number" && payload.interval > 0
570
+ ? payload.interval * 1000
571
+ : DEFAULT_POLL_INTERVAL_MS,
572
+ };
573
+ }
574
+
575
+ async function pollForToken(
576
+ deviceCode: string,
577
+ intervalMs: number,
578
+ expiresInMs: number,
579
+ signal?: AbortSignal,
580
+ ): Promise<OAuthCredentials> {
581
+ const deadline = Date.now() + expiresInMs;
582
+ // Deadline-aware retry wait: never sleep past the device-flow deadline, so a
583
+ // late retry cannot delay the expiration report or accept a stale response.
584
+ const sleepUntilDeadline = async (ms: number) => {
585
+ const remainingMs = deadline - Date.now();
586
+ if (remainingMs <= 0) return false;
587
+ await sleep(Math.min(ms, remainingMs), signal);
588
+ return true;
589
+ };
590
+ let waitMs = Math.max(1000, intervalMs);
591
+ while (Date.now() < deadline) {
592
+ if (signal?.aborted) throw new Error("Login cancelled");
593
+ let response: Response;
594
+ const effectiveSignal = requestSignal(signal);
595
+ try {
596
+ response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
597
+ method: "POST",
598
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
599
+ body: new URLSearchParams({
600
+ client_id: NOUS_OAUTH_CLIENT_ID,
601
+ device_code: deviceCode,
602
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
603
+ }),
604
+ redirect: "error",
605
+ signal: effectiveSignal,
606
+ });
607
+ } catch (netErr) {
608
+ // Genuine cancellation must abort immediately. Any other transport-level
609
+ // failure (timeout, DNS, dropped connection, proxy reset) must not destroy
610
+ // a device session that is still within its deadline: retry with the
611
+ // current wait interval. The device-code grant is idempotent for the
612
+ // pending case, so a retried poll is safe (unlike the refresh path).
613
+ if (signal?.aborted) throw new Error("Login cancelled");
614
+ if (await sleepUntilDeadline(waitMs)) continue;
615
+ break;
616
+ }
617
+ // Parse under the same deadline that covered the request headers. Keep the
618
+ // read outside the fetch retry catch: a bounded-reader error or caller
619
+ // cancellation is an observed response failure, not a safe poll retry.
620
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousTokenResponse;
621
+ // Parse once and pass the payload through to the failure path (review #8),
622
+ // so we never try to re-read a body that has already been consumed.
623
+ // Normalize a successful-but-non-object body (for example valid JSON
624
+ // `null`) to an empty object so the required-field validation below
625
+ // produces a terminal NousTokenError instead of a raw TypeError.
626
+ if (Date.now() >= deadline) break;
627
+ if (response.ok) return parseTokenPayload(payload, "");
628
+ const error = payload.error;
629
+ if (error === "authorization_pending") {
630
+ if (!(await sleepUntilDeadline(waitMs))) break;
631
+ continue;
632
+ }
633
+ if (error === "slow_down") {
634
+ waitMs = Math.min(MAX_POLL_INTERVAL_MS, waitMs + 5000);
635
+ const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined;
636
+ if (retryAfter && retryAfter > waitMs) waitMs = Math.min(MAX_POLL_INTERVAL_MS, retryAfter);
637
+ if (!(await sleepUntilDeadline(waitMs))) break;
638
+ continue;
639
+ }
640
+ if (error === "expired_token") {
641
+ throw new NousTokenError(response.status, "expired_token", "Nous Portal device authorization expired", { terminal: true });
642
+ }
643
+ if (error === "access_denied") {
644
+ throw new NousTokenError(response.status, "access_denied", "Nous Portal device authorization denied", { terminal: true });
645
+ }
646
+ // Unknown OAuth error: report it from the parsed payload, not by
647
+ // re-reading the (already consumed) response body.
648
+ if (error) {
649
+ const detail = typeof payload.error_description === "string" ? `: ${payload.error_description}` : "";
650
+ throw new NousTokenError(response.status, String(error), `Nous Portal device authorization failed (${error})${detail}`);
651
+ }
652
+ throw tokenErrorFromPayload(response.status, payload);
653
+ }
654
+ throw new NousTokenError(undefined, "expired_token", "Nous Portal device flow timed out");
655
+ }
656
+
657
+ export async function loginNous(ctrl: OAuthController): Promise<OAuthCredentials> {
658
+ const device = await requestDeviceAuthorization(ctrl.signal);
659
+ ctrl.onAuth?.({
660
+ url: device.verificationUriComplete,
661
+ instructions: `Sign in to Nous Portal and enter the code: ${device.userCode}`,
662
+ deviceCode: device.userCode,
663
+ });
664
+ return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, ctrl.signal);
665
+ }
666
+
667
+ /**
668
+ * Refresh a Nous Portal session. The refresh token travels in the
669
+ * `x-nous-refresh-token` header; the server rotates it on every successful
670
+ * refresh, and the rotated token is what the caller persists.
671
+ *
672
+ * Failure-atomicity contract (review blocker #2): a durable refresh-intent is
673
+ * recorded before the request and cleared only after the rotated token is
674
+ * obtained. After dispatch, every non-2xx response (including 429 and unknown
675
+ * 4xx) leaves the intent "uncertain" — a client-class status does not prove the
676
+ * single-use token was not consumed, so the old token stays blocked and a later
677
+ * refresh refuses to replay it, forcing re-auth instead of a session-revoking
678
+ * replay. The intent is cleared only after the rotated credential is durably
679
+ * persisted by the store.
680
+ */
681
+ export async function refreshNousToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
682
+ // Validate the OAuth base URL first (independent of the token): a malformed
683
+ // or non-HTTPS override must fail before we record any refresh intent, so a
684
+ // bad URL never leaves a "submitted" intent behind (which would otherwise
685
+ // make the next call refuse to replay the token).
686
+ const baseUrl = resolvePortalBaseUrl();
687
+ // Never blindly replay a token whose outcome we could not confirm earlier:
688
+ // a prior submission got a server response (so it may have been consumed) or
689
+ // failed to persist its rotation. Replaying it could trigger
690
+ // `refresh_token_reused` and revoke the session.
691
+ if (nousRefreshIntentBlocksReplay(refreshToken)) {
692
+ throw new NousTokenError(
693
+ undefined,
694
+ "refresh_token_reused",
695
+ "Refusing to replay a refresh token with an unconfirmed prior outcome (previous rotation may not have persisted)",
696
+ { terminal: true },
697
+ );
698
+ }
699
+ // Record that we are about to submit this token. It stays "submitted" after a
700
+ // successful rotation until the store confirms persistence of the rotated
701
+ // token (via clearNousRefreshIntent) — so a crash before persistence leaves
702
+ // the old token refused on replay instead of silently reused.
703
+ // Fail-closed: if we cannot durably record the intent, refuse the refresh.
704
+ try {
705
+ writeRefreshIntent(refreshToken, "submitted");
706
+ } catch (ioErr) {
707
+ // The write happens BEFORE dispatch, so the credential has not been
708
+ // rejected or consumed. Fail closed (abort the refresh) but surface a
709
+ // NON-terminal operational error: the coordinator must not mark the account
710
+ // needsReauth merely because local persistence infrastructure is broken.
711
+ throw new RefreshIntentIOError(
712
+ "Refusing refresh: could not durably record the refresh-intent guard (fail-closed)",
713
+ ioErr,
714
+ );
715
+ }
716
+
717
+ let response: Response;
718
+ const effectiveSignal = requestSignal(signal);
719
+ try {
720
+ response = await fetch(`${baseUrl}/api/oauth/token`, {
721
+ method: "POST",
722
+ headers: {
723
+ Accept: "application/json",
724
+ "Content-Type": "application/x-www-form-urlencoded",
725
+ "x-nous-refresh-token": refreshToken,
726
+ },
727
+ body: new URLSearchParams({
728
+ grant_type: "refresh_token",
729
+ client_id: NOUS_OAUTH_CLIENT_ID,
730
+ }),
731
+ redirect: "error",
732
+ signal: effectiveSignal,
733
+ });
734
+ } catch (netErr) {
735
+ // The request may have reached the server and rotated the token even on a
736
+ // timeout/abort/connection error — we cannot prove it did NOT. Mark the
737
+ // intent uncertain so the submitted token is never replayed; the next
738
+ // refresh will force a clean re-auth instead of risking reuse.
739
+ try {
740
+ writeRefreshIntent(refreshToken, "uncertain");
741
+ } catch {
742
+ // If we cannot even mark uncertain, the worst case is a later blind
743
+ // replay; prefer surfacing the original network error so it is retried
744
+ // through the normal path, which will re-encounter the guard if the file
745
+ // later becomes readable.
746
+ }
747
+ throw netErr;
748
+ }
749
+
750
+ if (!response.ok) {
751
+ const status = response.status;
752
+ // The request reached the Portal's token endpoint. A non-2xx response does
753
+ // NOT establish that the single-use refresh token was not consumed: 429
754
+ // rate limits, unknown/custom 4xx, and gateway-generated client-class
755
+ // errors can all be returned AFTER the remote side processed (and consumed)
756
+ // the token. Only a response whose documented OAuth semantics prove
757
+ // non-consumption would be safe to retry, and no such Nous contract is
758
+ // documented (invalid_grant/refresh_token_reused are explicitly terminal).
759
+ // Fail closed: retain the durable intent as uncertain so the submitted
760
+ // token is never automatically replayed.
761
+ try {
762
+ writeRefreshIntent(refreshToken, "uncertain");
763
+ } catch {
764
+ // The pre-dispatch "submitted" intent is still on disk, which also
765
+ // blocks replay; surface the original HTTP error below.
766
+ }
767
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal);
768
+ throw tokenErrorFromPayload(status, payload);
769
+ }
770
+
771
+ // The server responded 200 — the submitted token may now be consumed. If we
772
+ // fail to parse the rotated token, mark the intent uncertain so we never
773
+ // replay it. On success we deliberately LEAVE the intent as "submitted"
774
+ // (the store clears it once the rotated token is persisted).
775
+ try {
776
+ const creds = parseTokenPayload(
777
+ (await readOAuthJson(response, effectiveSignal)) as NousTokenResponse,
778
+ refreshToken,
779
+ );
780
+ return creds;
781
+ } catch (e) {
782
+ try {
783
+ writeRefreshIntent(refreshToken, "uncertain");
784
+ } catch {
785
+ // already throwing the parse error below; do not mask it
786
+ }
787
+ throw e;
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Clear the durable refresh-intent for a token. The account store calls this
793
+ * after `mergeAccountCredential` persists the rotated token, closing the
794
+ * uncertain-outcome window opened by `refreshNousToken`.
795
+ */
796
+ export function clearNousRefreshIntent(refreshToken: string): void {
797
+ clearRefreshIntent(refreshToken);
798
+ }