comisai 1.0.34 → 1.0.36

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 (284) hide show
  1. package/node_modules/@comis/agent/dist/background/auto-background-middleware.d.ts +11 -1
  2. package/node_modules/@comis/agent/dist/background/auto-background-middleware.js +21 -4
  3. package/node_modules/@comis/agent/dist/background/background-task-manager.d.ts +2 -2
  4. package/node_modules/@comis/agent/dist/background/background-task-manager.js +61 -20
  5. package/node_modules/@comis/agent/dist/background/background-task-persistence.js +10 -3
  6. package/node_modules/@comis/agent/dist/background/background-task-types.d.ts +10 -3
  7. package/node_modules/@comis/agent/dist/background/background-task-types.js +1 -1
  8. package/node_modules/@comis/agent/dist/background/completion-formatter.d.ts +39 -0
  9. package/node_modules/@comis/agent/dist/background/completion-formatter.js +77 -0
  10. package/node_modules/@comis/agent/dist/background/completion-runner.d.ts +53 -0
  11. package/node_modules/@comis/agent/dist/background/completion-runner.js +151 -0
  12. package/node_modules/@comis/agent/dist/background/index.d.ts +4 -0
  13. package/node_modules/@comis/agent/dist/background/index.js +2 -0
  14. package/node_modules/@comis/agent/dist/bridge/bridge-metrics.d.ts +17 -2
  15. package/node_modules/@comis/agent/dist/bridge/bridge-metrics.js +14 -2
  16. package/node_modules/@comis/agent/dist/bridge/pi-event-bridge.d.ts +23 -23
  17. package/node_modules/@comis/agent/dist/bridge/pi-event-bridge.js +72 -60
  18. package/node_modules/@comis/agent/dist/bridge/thinking-block-hash-invariant.d.ts +6 -7
  19. package/node_modules/@comis/agent/dist/bridge/thinking-block-hash-invariant.js +24 -25
  20. package/node_modules/@comis/agent/dist/budget/cost-tracker.d.ts +1 -1
  21. package/node_modules/@comis/agent/dist/context-engine/constants.d.ts +5 -5
  22. package/node_modules/@comis/agent/dist/context-engine/constants.js +12 -12
  23. package/node_modules/@comis/agent/dist/context-engine/context-engine.js +13 -4
  24. package/node_modules/@comis/agent/dist/context-engine/dag-annotator.d.ts +1 -2
  25. package/node_modules/@comis/agent/dist/context-engine/dag-annotator.js +1 -2
  26. package/node_modules/@comis/agent/dist/context-engine/llm-compaction.js +20 -16
  27. package/node_modules/@comis/agent/dist/context-engine/rehydration.js +6 -6
  28. package/node_modules/@comis/agent/dist/context-engine/signature-replay-scrubber.d.ts +12 -12
  29. package/node_modules/@comis/agent/dist/context-engine/signature-replay-scrubber.js +36 -22
  30. package/node_modules/@comis/agent/dist/context-engine/types-core.d.ts +15 -0
  31. package/node_modules/@comis/agent/dist/executor/cache-break-detection.d.ts +6 -6
  32. package/node_modules/@comis/agent/dist/executor/cache-break-detection.js +8 -8
  33. package/node_modules/@comis/agent/dist/executor/executor-context-engine-setup.d.ts +16 -0
  34. package/node_modules/@comis/agent/dist/executor/executor-context-engine-setup.js +46 -5
  35. package/node_modules/@comis/agent/dist/executor/executor-post-execution.d.ts +30 -0
  36. package/node_modules/@comis/agent/dist/executor/executor-post-execution.js +17 -1
  37. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.js +1 -1
  38. package/node_modules/@comis/agent/dist/executor/executor-response-filter.d.ts +7 -6
  39. package/node_modules/@comis/agent/dist/executor/executor-response-filter.js +9 -42
  40. package/node_modules/@comis/agent/dist/executor/executor-tool-assembly.js +2 -3
  41. package/node_modules/@comis/agent/dist/executor/gemini-cache-injector.d.ts +2 -2
  42. package/node_modules/@comis/agent/dist/executor/gemini-cache-injector.js +4 -4
  43. package/node_modules/@comis/agent/dist/executor/phase-filter.d.ts +2 -2
  44. package/node_modules/@comis/agent/dist/executor/phase-filter.js +5 -7
  45. package/node_modules/@comis/agent/dist/executor/pi-executor.d.ts +13 -0
  46. package/node_modules/@comis/agent/dist/executor/pi-executor.js +71 -6
  47. package/node_modules/@comis/agent/dist/executor/post-batch-continuation.js +7 -7
  48. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.d.ts +1 -1
  49. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.js +1 -1
  50. package/node_modules/@comis/agent/dist/executor/tool-deferral.d.ts +2 -2
  51. package/node_modules/@comis/agent/dist/executor/tool-deferral.js +7 -7
  52. package/node_modules/@comis/agent/dist/index.d.ts +17 -0
  53. package/node_modules/@comis/agent/dist/index.js +32 -11
  54. package/node_modules/@comis/agent/dist/model/auth-provider.d.ts +25 -2
  55. package/node_modules/@comis/agent/dist/model/auth-provider.js +6 -0
  56. package/node_modules/@comis/agent/dist/model/compaction-model-resolver.d.ts +3 -3
  57. package/node_modules/@comis/agent/dist/model/compaction-model-resolver.js +3 -3
  58. package/node_modules/@comis/agent/dist/model/oauth-credential-store-file.d.ts +37 -0
  59. package/node_modules/@comis/agent/dist/model/oauth-credential-store-file.js +279 -0
  60. package/node_modules/@comis/agent/dist/model/oauth-credential-store-selector.d.ts +49 -0
  61. package/node_modules/@comis/agent/dist/model/oauth-credential-store-selector.js +50 -0
  62. package/node_modules/@comis/agent/dist/model/oauth-device-code.d.ts +57 -0
  63. package/node_modules/@comis/agent/dist/model/oauth-device-code.js +302 -0
  64. package/node_modules/@comis/agent/dist/model/oauth-env.d.ts +33 -0
  65. package/node_modules/@comis/agent/dist/model/oauth-env.js +38 -0
  66. package/node_modules/@comis/agent/dist/model/oauth-errors.d.ts +41 -0
  67. package/node_modules/@comis/agent/dist/model/oauth-errors.js +88 -0
  68. package/node_modules/@comis/agent/dist/model/oauth-identity.d.ts +53 -0
  69. package/node_modules/@comis/agent/dist/model/oauth-identity.js +141 -0
  70. package/node_modules/@comis/agent/dist/model/oauth-login-runner.d.ts +99 -0
  71. package/node_modules/@comis/agent/dist/model/oauth-login-runner.js +374 -0
  72. package/node_modules/@comis/agent/dist/model/oauth-tls-preflight.d.ts +58 -0
  73. package/node_modules/@comis/agent/dist/model/oauth-tls-preflight.js +82 -0
  74. package/node_modules/@comis/agent/dist/model/oauth-token-manager.d.ts +86 -16
  75. package/node_modules/@comis/agent/dist/model/oauth-token-manager.js +961 -66
  76. package/node_modules/@comis/agent/dist/model/operation-model-defaults.d.ts +9 -4
  77. package/node_modules/@comis/agent/dist/model/operation-model-defaults.js +36 -9
  78. package/node_modules/@comis/agent/dist/model/resolve-provider-api-key.d.ts +48 -0
  79. package/node_modules/@comis/agent/dist/model/resolve-provider-api-key.js +66 -0
  80. package/node_modules/@comis/agent/dist/provider/capabilities.d.ts +5 -5
  81. package/node_modules/@comis/agent/dist/provider/capabilities.js +10 -23
  82. package/node_modules/@comis/agent/dist/safety/tool-output-safety.js +3 -3
  83. package/node_modules/@comis/agent/dist/session/comis-session-manager.d.ts +1 -1
  84. package/node_modules/@comis/agent/dist/session/comis-session-manager.js +1 -1
  85. package/node_modules/@comis/agent/dist/spawn/narrative-caster.d.ts +10 -0
  86. package/node_modules/@comis/agent/dist/spawn/narrative-caster.js +5 -1
  87. package/node_modules/@comis/agent/package.json +1 -1
  88. package/node_modules/@comis/channels/dist/email/email-adapter.js +6 -6
  89. package/node_modules/@comis/channels/dist/email/imap-lifecycle.js +7 -7
  90. package/node_modules/@comis/channels/dist/shared/deliver-to-channel.js +12 -10
  91. package/node_modules/@comis/channels/dist/telegram/telegram-adapter.js +1 -1
  92. package/node_modules/@comis/channels/package.json +1 -1
  93. package/node_modules/@comis/cli/dist/cli.js +2 -0
  94. package/node_modules/@comis/cli/dist/commands/agent.d.ts +3 -3
  95. package/node_modules/@comis/cli/dist/commands/agent.js +46 -3
  96. package/node_modules/@comis/cli/dist/commands/auth.d.ts +37 -0
  97. package/node_modules/@comis/cli/dist/commands/auth.js +433 -0
  98. package/node_modules/@comis/cli/dist/commands/doctor.d.ts +4 -1
  99. package/node_modules/@comis/cli/dist/commands/doctor.js +20 -5
  100. package/node_modules/@comis/cli/dist/doctor/checks/oauth-health.d.ts +39 -0
  101. package/node_modules/@comis/cli/dist/doctor/checks/oauth-health.js +399 -0
  102. package/node_modules/@comis/cli/dist/doctor/types.d.ts +19 -0
  103. package/node_modules/@comis/cli/dist/index.d.ts +1 -0
  104. package/node_modules/@comis/cli/dist/index.js +10 -4
  105. package/node_modules/@comis/cli/dist/output/relative-time.d.ts +23 -0
  106. package/node_modules/@comis/cli/dist/output/relative-time.js +36 -0
  107. package/node_modules/@comis/cli/dist/wizard/non-interactive.js +17 -8
  108. package/node_modules/@comis/cli/dist/wizard/steps/03-provider.js +2 -1
  109. package/node_modules/@comis/cli/dist/wizard/steps/04-credentials.js +223 -34
  110. package/node_modules/@comis/cli/dist/wizard/steps/10-write-config.js +14 -0
  111. package/node_modules/@comis/cli/dist/wizard/steps/11-daemon-start.js +3 -3
  112. package/node_modules/@comis/cli/dist/wizard/types.d.ts +7 -0
  113. package/node_modules/@comis/cli/package.json +1 -1
  114. package/node_modules/@comis/core/dist/bootstrap.d.ts +1 -1
  115. package/node_modules/@comis/core/dist/config/env-substitution.d.ts +66 -0
  116. package/node_modules/@comis/core/dist/config/env-substitution.js +115 -0
  117. package/node_modules/@comis/core/dist/config/index.d.ts +3 -1
  118. package/node_modules/@comis/core/dist/config/index.js +2 -1
  119. package/node_modules/@comis/core/dist/config/loader.js +61 -0
  120. package/node_modules/@comis/core/dist/config/managed-sections.d.ts +3 -3
  121. package/node_modules/@comis/core/dist/config/managed-sections.js +10 -5
  122. package/node_modules/@comis/core/dist/config/schema-agent.d.ts +4 -0
  123. package/node_modules/@comis/core/dist/config/schema-agent.js +16 -1
  124. package/node_modules/@comis/core/dist/config/schema-background-tasks.d.ts +7 -0
  125. package/node_modules/@comis/core/dist/config/schema-background-tasks.js +7 -0
  126. package/node_modules/@comis/core/dist/config/schema-delivery.d.ts +2 -0
  127. package/node_modules/@comis/core/dist/config/schema-delivery.js +2 -0
  128. package/node_modules/@comis/core/dist/config/schema-gemini-cache.d.ts +0 -2
  129. package/node_modules/@comis/core/dist/config/schema-gemini-cache.js +0 -2
  130. package/node_modules/@comis/core/dist/config/schema-oauth.d.ts +23 -0
  131. package/node_modules/@comis/core/dist/config/schema-oauth.js +19 -0
  132. package/node_modules/@comis/core/dist/config/schema-skills.d.ts +6 -8
  133. package/node_modules/@comis/core/dist/config/schema-skills.js +3 -4
  134. package/node_modules/@comis/core/dist/config/schema.d.ts +10 -0
  135. package/node_modules/@comis/core/dist/config/schema.js +3 -0
  136. package/node_modules/@comis/core/dist/domain/background-task-origin.d.ts +39 -0
  137. package/node_modules/@comis/core/dist/domain/background-task-origin.js +39 -0
  138. package/node_modules/@comis/core/dist/event-bus/events-infra.d.ts +71 -2
  139. package/node_modules/@comis/core/dist/exports/config.d.ts +2 -2
  140. package/node_modules/@comis/core/dist/exports/config.js +1 -1
  141. package/node_modules/@comis/core/dist/exports/domain.d.ts +2 -0
  142. package/node_modules/@comis/core/dist/exports/domain.js +1 -0
  143. package/node_modules/@comis/core/dist/exports/ports.d.ts +2 -2
  144. package/node_modules/@comis/core/dist/exports/ports.js +1 -1
  145. package/node_modules/@comis/core/dist/ports/delivery-queue.d.ts +23 -0
  146. package/node_modules/@comis/core/dist/ports/delivery-queue.js +2 -0
  147. package/node_modules/@comis/core/dist/ports/index.d.ts +2 -0
  148. package/node_modules/@comis/core/dist/ports/index.js +1 -0
  149. package/node_modules/@comis/core/dist/ports/oauth-credential-store.d.ts +64 -0
  150. package/node_modules/@comis/core/dist/ports/oauth-credential-store.js +37 -0
  151. package/node_modules/@comis/core/dist/tool-metadata.d.ts +20 -0
  152. package/node_modules/@comis/core/package.json +1 -1
  153. package/node_modules/@comis/daemon/dist/daemon-types.d.ts +23 -3
  154. package/node_modules/@comis/daemon/dist/daemon.js +82 -19
  155. package/node_modules/@comis/daemon/dist/index.d.ts +2 -0
  156. package/node_modules/@comis/daemon/dist/index.js +5 -0
  157. package/node_modules/@comis/daemon/dist/observability/channel-health-logger.js +3 -3
  158. package/node_modules/@comis/daemon/dist/observability/delivery-queue-logger.js +1 -1
  159. package/node_modules/@comis/daemon/dist/rpc/agent-handlers.d.ts +22 -1
  160. package/node_modules/@comis/daemon/dist/rpc/agent-handlers.js +84 -21
  161. package/node_modules/@comis/daemon/dist/rpc/agent-inline-workspace.js +2 -2
  162. package/node_modules/@comis/daemon/dist/rpc/config-handlers.d.ts +9 -1
  163. package/node_modules/@comis/daemon/dist/rpc/config-handlers.js +104 -23
  164. package/node_modules/@comis/daemon/dist/rpc/credential-resolver.d.ts +30 -1
  165. package/node_modules/@comis/daemon/dist/rpc/credential-resolver.js +74 -11
  166. package/node_modules/@comis/daemon/dist/rpc/mcp-handlers.d.ts +8 -0
  167. package/node_modules/@comis/daemon/dist/rpc/mcp-handlers.js +22 -8
  168. package/node_modules/@comis/daemon/dist/rpc/provider-handlers.js +9 -12
  169. package/node_modules/@comis/daemon/dist/rpc/rpc-dispatch.d.ts +1 -0
  170. package/node_modules/@comis/daemon/dist/rpc/rpc-dispatch.js +27 -2
  171. package/node_modules/@comis/daemon/dist/setup-docker-restart-warn.js +0 -1
  172. package/node_modules/@comis/daemon/dist/wiring/index.d.ts +2 -0
  173. package/node_modules/@comis/daemon/dist/wiring/index.js +1 -0
  174. package/node_modules/@comis/daemon/dist/wiring/oauth-preflight.d.ts +21 -0
  175. package/node_modules/@comis/daemon/dist/wiring/oauth-preflight.js +134 -0
  176. package/node_modules/@comis/daemon/dist/wiring/setup-agents.d.ts +46 -1
  177. package/node_modules/@comis/daemon/dist/wiring/setup-agents.js +127 -3
  178. package/node_modules/@comis/daemon/dist/wiring/setup-background-completion-runner.d.ts +39 -0
  179. package/node_modules/@comis/daemon/dist/wiring/setup-background-completion-runner.js +32 -0
  180. package/node_modules/@comis/daemon/dist/wiring/setup-background-tasks.d.ts +10 -3
  181. package/node_modules/@comis/daemon/dist/wiring/setup-background-tasks.js +11 -5
  182. package/node_modules/@comis/daemon/dist/wiring/setup-channels.js +20 -1
  183. package/node_modules/@comis/daemon/dist/wiring/setup-cross-session.js +1 -1
  184. package/node_modules/@comis/daemon/dist/wiring/setup-delivery.d.ts +14 -5
  185. package/node_modules/@comis/daemon/dist/wiring/setup-delivery.js +52 -19
  186. package/node_modules/@comis/daemon/dist/wiring/setup-schedulers.js +4 -0
  187. package/node_modules/@comis/daemon/package.json +1 -1
  188. package/node_modules/@comis/gateway/dist/index.d.ts +2 -0
  189. package/node_modules/@comis/gateway/dist/index.js +2 -0
  190. package/node_modules/@comis/gateway/dist/oauth/oauth-callback-route.d.ts +66 -0
  191. package/node_modules/@comis/gateway/dist/oauth/oauth-callback-route.js +212 -0
  192. package/node_modules/@comis/gateway/dist/server/hono-server.d.ts +14 -0
  193. package/node_modules/@comis/gateway/dist/server/hono-server.js +10 -0
  194. package/node_modules/@comis/gateway/package.json +1 -1
  195. package/node_modules/@comis/infra/dist/logging/log-fields.d.ts +23 -0
  196. package/node_modules/@comis/infra/package.json +1 -1
  197. package/node_modules/@comis/memory/dist/compaction.d.ts +3 -5
  198. package/node_modules/@comis/memory/dist/compaction.js +2 -3
  199. package/node_modules/@comis/memory/dist/delivery-queue-adapter.d.ts +2 -2
  200. package/node_modules/@comis/memory/dist/delivery-queue-adapter.js +49 -1
  201. package/node_modules/@comis/memory/dist/index.d.ts +2 -0
  202. package/node_modules/@comis/memory/dist/index.js +3 -0
  203. package/node_modules/@comis/memory/dist/memory-api.d.ts +1 -1
  204. package/node_modules/@comis/memory/dist/memory-api.js +1 -1
  205. package/node_modules/@comis/memory/dist/oauth-profile-schema.d.ts +17 -0
  206. package/node_modules/@comis/memory/dist/oauth-profile-schema.js +33 -0
  207. package/node_modules/@comis/memory/dist/oauth-profile-store-encrypted.d.ts +27 -0
  208. package/node_modules/@comis/memory/dist/oauth-profile-store-encrypted.js +144 -0
  209. package/node_modules/@comis/memory/dist/session-store.d.ts +1 -1
  210. package/node_modules/@comis/memory/dist/session-store.js +1 -1
  211. package/node_modules/@comis/memory/dist/sqlite-secret-store.d.ts +29 -3
  212. package/node_modules/@comis/memory/dist/sqlite-secret-store.js +11 -3
  213. package/node_modules/@comis/memory/package.json +1 -1
  214. package/node_modules/@comis/scheduler/dist/execution/execution-lock.d.ts +13 -0
  215. package/node_modules/@comis/scheduler/dist/execution/execution-lock.js +1 -1
  216. package/node_modules/@comis/scheduler/dist/execution/index.d.ts +2 -0
  217. package/node_modules/@comis/scheduler/dist/execution/index.js +2 -0
  218. package/node_modules/@comis/scheduler/dist/heartbeat/agent-heartbeat-source.js +1 -1
  219. package/node_modules/@comis/scheduler/dist/index.d.ts +2 -0
  220. package/node_modules/@comis/scheduler/dist/index.js +2 -0
  221. package/node_modules/@comis/scheduler/package.json +1 -1
  222. package/node_modules/@comis/shared/package.json +1 -1
  223. package/node_modules/@comis/skills/dist/bridge/schema-validator.d.ts +38 -0
  224. package/node_modules/@comis/skills/dist/bridge/schema-validator.js +169 -0
  225. package/node_modules/@comis/skills/dist/bridge/tool-metadata-enforcement.js +12 -0
  226. package/node_modules/@comis/skills/dist/bridge/tool-metadata-registry.js +130 -0
  227. package/node_modules/@comis/skills/dist/builtin/exec-diagnostics.d.ts +32 -0
  228. package/node_modules/@comis/skills/dist/builtin/exec-diagnostics.js +127 -0
  229. package/node_modules/@comis/skills/dist/builtin/exec-security.js +38 -0
  230. package/node_modules/@comis/skills/dist/builtin/exec-tool.js +9 -0
  231. package/node_modules/@comis/skills/dist/builtin/file-tools/grep-tool.js +6 -6
  232. package/node_modules/@comis/skills/dist/builtin/platform/agents-manage-tool.d.ts +5 -4
  233. package/node_modules/@comis/skills/dist/builtin/platform/agents-manage-tool.js +38 -27
  234. package/node_modules/@comis/skills/dist/builtin/platform/background-tasks-tool.d.ts +4 -1
  235. package/node_modules/@comis/skills/dist/builtin/platform/background-tasks-tool.js +3 -3
  236. package/node_modules/@comis/skills/dist/builtin/platform/cron-tool.js +1 -1
  237. package/node_modules/@comis/skills/dist/builtin/platform/gateway-tool.js +6 -6
  238. package/node_modules/@comis/skills/dist/builtin/platform/mcp-manage-tool.d.ts +1 -1
  239. package/node_modules/@comis/skills/dist/builtin/platform/mcp-manage-tool.js +9 -9
  240. package/node_modules/@comis/skills/dist/builtin/sandbox/bwrap-provider.d.ts +11 -0
  241. package/node_modules/@comis/skills/dist/builtin/sandbox/bwrap-provider.js +114 -1
  242. package/node_modules/@comis/skills/dist/builtin/sandbox/detect-provider.js +40 -15
  243. package/node_modules/@comis/skills/dist/media/ssrf-fetcher.d.ts +7 -0
  244. package/node_modules/@comis/skills/dist/media/ssrf-fetcher.js +9 -2
  245. package/node_modules/@comis/skills/package.json +1 -1
  246. package/node_modules/@comis/web/dist/assets/{agent-detail-71BSbSfD.js → agent-detail-q8t1NB7w.js} +1 -1
  247. package/node_modules/@comis/web/dist/assets/{agent-editor-CTSDZhwT.js → agent-editor-B46io5gv.js} +1 -1
  248. package/node_modules/@comis/web/dist/assets/{agent-list-BEhni2ea.js → agent-list-DQ6g2Rcx.js} +1 -1
  249. package/node_modules/@comis/web/dist/assets/{billing-view-DVP1IvVs.js → billing-view-IWPR8LgF.js} +1 -1
  250. package/node_modules/@comis/web/dist/assets/{channel-detail-N_YK74xC.js → channel-detail-DlNNZuuC.js} +1 -1
  251. package/node_modules/@comis/web/dist/assets/{channel-list-DRk6ZJaF.js → channel-list-DhGwxiMc.js} +1 -1
  252. package/node_modules/@comis/web/dist/assets/{chat-console-Dm-GtSf9.js → chat-console-Nv6fM3Rc.js} +1 -1
  253. package/node_modules/@comis/web/dist/assets/{config-editor-CIferYX6.js → config-editor-BYKuJF76.js} +1 -1
  254. package/node_modules/@comis/web/dist/assets/{context-dag-browser-CL84rXXM.js → context-dag-browser-ClNEtzYE.js} +1 -1
  255. package/node_modules/@comis/web/dist/assets/{context-engine-B1HOTEZv.js → context-engine-BZJ6HChd.js} +1 -1
  256. package/node_modules/@comis/web/dist/assets/{delivery-view-Y6JKYVFw.js → delivery-view-Cb7I3vGu.js} +1 -1
  257. package/node_modules/@comis/web/dist/assets/{diagnostics-view-DWV1UQjz.js → diagnostics-view-9u9Lyu5a.js} +1 -1
  258. package/node_modules/@comis/web/dist/assets/{ic-chat-message-DfSERzzg.js → ic-chat-message-BFt3cVpx.js} +1 -1
  259. package/node_modules/@comis/web/dist/assets/{ic-connection-dot-CXyhlJup.js → ic-connection-dot-y77LZ3Gu.js} +1 -1
  260. package/node_modules/@comis/web/dist/assets/{ic-tool-call-DNmwTjek.js → ic-tool-call-qt6w1NQl.js} +1 -1
  261. package/node_modules/@comis/web/dist/assets/{index-CBr0Tm9_.js → index-8Tg9oc-C.js} +2 -2
  262. package/node_modules/@comis/web/dist/assets/{mcp-management-BaH2-vox.js → mcp-management-69dtH_kY.js} +2 -2
  263. package/node_modules/@comis/web/dist/assets/{media-config-CZLshJoN.js → media-config-BdjLj5c1.js} +1 -1
  264. package/node_modules/@comis/web/dist/assets/{media-test-C9NUWgo_.js → media-test-DuPqrixi.js} +1 -1
  265. package/node_modules/@comis/web/dist/assets/{memory-inspector-D_fmTcRN.js → memory-inspector-B-Pepbq-.js} +1 -1
  266. package/node_modules/@comis/web/dist/assets/{message-center-BBFlNCZn.js → message-center-B7l0yNYY.js} +1 -1
  267. package/node_modules/@comis/web/dist/assets/{models-BytGLm99.js → models-JHFHuv5S.js} +1 -1
  268. package/node_modules/@comis/web/dist/assets/{observe-view-VXtHqaqq.js → observe-view-r8mqhy4O.js} +1 -1
  269. package/node_modules/@comis/web/dist/assets/{pipeline-builder-CfXczlfJ.js → pipeline-builder-XjkiZRcR.js} +1 -1
  270. package/node_modules/@comis/web/dist/assets/{pipeline-history-CPmXFnbe.js → pipeline-history-CZqJv_Hj.js} +1 -1
  271. package/node_modules/@comis/web/dist/assets/{pipeline-history-detail-DcueTMs9.js → pipeline-history-detail-BEFGMoDy.js} +1 -1
  272. package/node_modules/@comis/web/dist/assets/{pipeline-list-B-xG5WZh.js → pipeline-list-B6q5LvO1.js} +1 -1
  273. package/node_modules/@comis/web/dist/assets/{pipeline-monitor-pnIOYaSY.js → pipeline-monitor-BNomXjVL.js} +1 -1
  274. package/node_modules/@comis/web/dist/assets/{scheduler-BtUIFHhA.js → scheduler-BJEjcGKA.js} +1 -1
  275. package/node_modules/@comis/web/dist/assets/{security-C8mWRq2y.js → security-2G1jhBfV.js} +1 -1
  276. package/node_modules/@comis/web/dist/assets/{session-detail-DgdkO5ka.js → session-detail-DmVPzFBR.js} +1 -1
  277. package/node_modules/@comis/web/dist/assets/{session-list-DcylcfTn.js → session-list-CsqMQoHs.js} +1 -1
  278. package/node_modules/@comis/web/dist/assets/{setup-wizard-BP5yjsuL.js → setup-wizard-CAdM-gSP.js} +1 -1
  279. package/node_modules/@comis/web/dist/assets/{skills-DXt1bX8Z.js → skills-2ODqKaWr.js} +1 -1
  280. package/node_modules/@comis/web/dist/assets/{subagents-C7YbUHXY.js → subagents-BFlwfTbD.js} +1 -1
  281. package/node_modules/@comis/web/dist/assets/{workspace-manager-DP6pW4wa.js → workspace-manager--CbOx_dI.js} +1 -1
  282. package/node_modules/@comis/web/dist/index.html +1 -1
  283. package/node_modules/@comis/web/package.json +1 -1
  284. package/package.json +17 -16
@@ -2,9 +2,24 @@
2
2
  /**
3
3
  * OAuth Token Manager: Wraps pi-ai's OAuth subsystem for Comis patterns.
4
4
  *
5
- * Provides automatic token refresh via pi-ai's getOAuthApiKey(), credential
6
- * storage via in-memory cache (bootstrapped from SecretManager), and
7
- * observability via TypedEventBus auth:token_rotated events.
5
+ * Architecture:
6
+ * - Reads + writes credentials through OAuthCredentialStorePort (no in-memory map
7
+ * as source of truth; refreshed credentials persist to disk and survive restart).
8
+ * - Per-profile-ID file lock via withExecutionLock from @comis/scheduler — concurrent
9
+ * refresh attempts from multiple processes serialize; different profiles refresh
10
+ * in parallel.
11
+ * - 30s timeout wrapper around pi-ai's getOAuthApiKey to prevent indefinite hang
12
+ * when auth.openai.com is unreachable.
13
+ * - Real-refresh detection via newCredentials.refresh !== profile.refresh — the
14
+ * original !!newCredentials check was a no-op since pi-ai always returns truthy
15
+ * newCredentials.
16
+ * - Log events with submodule: "oauth-token-manager".
17
+ * - Event-bus events: auth:token_rotated (extended with profileId),
18
+ * auth:profile_bootstrapped, auth:refresh_failed.
19
+ * - Env-var bootstrap: empty store + valid OAUTH_<PROVIDER> env writes profile
20
+ * to store, decodes JWT identity, emits auth:profile_bootstrapped.
21
+ * - Env-var conflict: stored profile + different env-var refresh → WARN once
22
+ * per (provider, process) with hint=env-override-ignored.
8
23
  *
9
24
  * Supported OAuth providers (via pi-ai built-in):
10
25
  * - Anthropic (Claude Pro/Max)
@@ -15,11 +30,32 @@
15
30
  *
16
31
  * @module
17
32
  */
18
- import { ok, err, fromPromise } from "@comis/shared";
33
+ import { ok, err, fromPromise, } from "@comis/shared";
34
+ import { safePath, } from "@comis/core";
35
+ import { withExecutionLock } from "@comis/scheduler";
19
36
  import { getOAuthProvider, getOAuthApiKey, getOAuthProviders, } from "@mariozechner/pi-ai/oauth";
37
+ import { watch } from "chokidar";
38
+ import { resolveCodexAuthIdentity, redactEmailForLog, } from "./oauth-identity.js";
39
+ import { rewriteOAuthError } from "./oauth-errors.js";
20
40
  // ---------------------------------------------------------------------------
21
41
  // Internal helpers
22
42
  // ---------------------------------------------------------------------------
43
+ const LOCK_OPTIONS = {
44
+ staleMs: 30_000,
45
+ updateMs: 5_000,
46
+ // Retries enable two concurrent getApiKey() callers (different manager
47
+ // instances, same process or cross-process) to wait for a sibling refresh
48
+ // to complete instead of immediately failing with err("locked").
49
+ // proper-lockfile's incremental-backoff retry supports the concurrent-
50
+ // refresh contract: two parallel calls → exactly 1 refresh request → both
51
+ // return the SAME access token. The retry budget of 5 with 50ms..1s backoff
52
+ // fits well within the 30s REFRESH_TIMEOUT_MS (worst case: ~5s waiting for
53
+ // the holder to finish a slow refresh).
54
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 1_000, factor: 2 },
55
+ };
56
+ const LOCKS_SUBDIR = ".locks";
57
+ const REFRESH_TIMEOUT_MS = 30_000;
58
+ const SCHEMA_VERSION = 1;
23
59
  /**
24
60
  * Convert a provider ID to an uppercase SecretManager key.
25
61
  * "github-copilot" -> "OAUTH_GITHUB_COPILOT" (with default prefix).
@@ -28,58 +64,753 @@ function toSecretKey(providerId, prefix) {
28
64
  const upper = providerId.toUpperCase().replace(/-/g, "_");
29
65
  return `${prefix}${upper}`;
30
66
  }
67
+ /**
68
+ * Sanitize a profile-ID for safe inclusion in a lock-file path.
69
+ * One-way transformation — the canonical profile-ID stored in the credential
70
+ * store keeps its original form. Mappings: ":" → "__", "@" → "_at_".
71
+ */
72
+ function sanitizeProfileIdForLockPath(profileId) {
73
+ return profileId.replace(/:/g, "__").replace(/@/g, "_at_");
74
+ }
75
+ function lockSentinelPath(dataDir, profileId) {
76
+ // Sentinel name is "auth-refresh__<sanitized>.lock" — distinct from the
77
+ // file adapter's "auth-profile__<sanitized>.lock". This separation
78
+ // is intentional: the manager's lock guards the "refresh transaction"
79
+ // (don't make two concurrent pi-ai requests for the same profile), while
80
+ // the adapter's lock guards the "file-write transaction" (don't race two
81
+ // load-mutate-atomic-write sequences). Both protect the same profile but
82
+ // at different layers, so they MUST use different sentinel paths or
83
+ // credentialStore.set() inside refreshUnderLock would self-deadlock under
84
+ // proper-lockfile's default retries: 0 (discovered when wiring the file
85
+ // adapter through the manager end-to-end for the first time).
86
+ return safePath(dataDir, LOCKS_SUBDIR, "auth-refresh__" + sanitizeProfileIdForLockPath(profileId) + ".lock");
87
+ }
88
+ /**
89
+ * Parse env-var-stored OAuth credentials. Returns undefined on JSON failure
90
+ * or missing required fields (refresh + access).
91
+ */
92
+ function parseEnvCredentials(raw) {
93
+ if (!raw)
94
+ return undefined;
95
+ try {
96
+ const parsed = JSON.parse(raw);
97
+ if (parsed &&
98
+ typeof parsed === "object" &&
99
+ typeof parsed.refresh === "string" &&
100
+ typeof parsed.access === "string") {
101
+ return parsed;
102
+ }
103
+ return undefined;
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ /**
110
+ * Race a promise against a setTimeout-based timeout. setTimeout (rather than
111
+ * AbortSignal.timeout) is used so vi.useFakeTimers() in tests can advance the
112
+ * timer deterministically. Returns "timeout" on timeout, else the resolved value.
113
+ */
114
+ async function withTimeout(promise, timeoutMs) {
115
+ let timer;
116
+ const timeoutPromise = new Promise((resolve) => {
117
+ timer = setTimeout(() => resolve({ ok: false, reason: "timeout" }), timeoutMs);
118
+ });
119
+ try {
120
+ const winner = await Promise.race([
121
+ promise.then((value) => ({ ok: true, value })),
122
+ timeoutPromise,
123
+ ]);
124
+ return winner;
125
+ }
126
+ finally {
127
+ if (timer !== undefined)
128
+ clearTimeout(timer);
129
+ }
130
+ }
131
+ /**
132
+ * Refresh OpenAI Codex OAuth tokens by calling auth.openai.com directly
133
+ * (bypassing pi-ai's getOAuthApiKey wrapper). On HTTP error, parses the
134
+ * response body so refresh_token_reused / invalid_grant can be classified
135
+ * by `rewriteOAuthError`.
136
+ *
137
+ * Used ONLY when providerId === "openai-codex"; other providers continue to
138
+ * use pi-ai's wrapper (which works correctly for them — they don't need the
139
+ * wire-body classification).
140
+ *
141
+ * Never throws — wraps network errors in {ok:false} per AGENTS.md §2.1.
142
+ */
143
+ async function refreshOpenAICodexTokenLocal(profile) {
144
+ const tokenUrl = "https://auth.openai.com/oauth/token";
145
+ const body = new URLSearchParams({
146
+ grant_type: "refresh_token",
147
+ refresh_token: profile.refresh,
148
+ // Public OpenAI Codex client_id (per pi-ai source). NOT a comis secret.
149
+ client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
150
+ });
151
+ let response;
152
+ try {
153
+ response = await fetch(tokenUrl, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
156
+ body,
157
+ });
158
+ }
159
+ catch (e) {
160
+ // Network error / DNS / TLS — surface as a synthesized 0-status failure
161
+ // so the classifier maps it to the default "callback_timeout" path.
162
+ return {
163
+ ok: false,
164
+ error: {
165
+ error: "network_error",
166
+ errorDescription: e instanceof Error ? e.message : String(e),
167
+ status: 0,
168
+ },
169
+ };
170
+ }
171
+ if (!response.ok) {
172
+ let text = "";
173
+ try {
174
+ text = await response.text();
175
+ }
176
+ catch {
177
+ // Defense-in-depth: body read failed → empty string.
178
+ }
179
+ let parsed = {};
180
+ try {
181
+ parsed = JSON.parse(text);
182
+ }
183
+ catch {
184
+ // Malformed body → empty parse, classifier falls back to default case.
185
+ }
186
+ return {
187
+ ok: false,
188
+ error: {
189
+ error: parsed.error ?? "unknown_error",
190
+ errorDescription: parsed.error_description,
191
+ status: response.status,
192
+ },
193
+ };
194
+ }
195
+ const json = (await response.json());
196
+ // Recover accountId via pi-ai's exported provider helper. The provider
197
+ // object may NOT expose `getAccountId` (the openai-codex provider in
198
+ // pi-ai 0.71 does not), so the optional chain falls through to undefined
199
+ // — `mergeRefreshedCredentials` handles missing accountId.
200
+ const provider = getOAuthProvider("openai-codex");
201
+ let accountId;
202
+ try {
203
+ const extracted = provider?.getAccountId?.(json.access_token);
204
+ if (typeof extracted === "string" && extracted.length > 0) {
205
+ accountId = extracted;
206
+ }
207
+ }
208
+ catch {
209
+ // Defensive — getAccountId may throw on malformed JWT; leave undefined.
210
+ }
211
+ return {
212
+ ok: true,
213
+ value: {
214
+ access: json.access_token,
215
+ refresh: json.refresh_token,
216
+ expires: Date.now() + json.expires_in * 1000,
217
+ accountId,
218
+ },
219
+ };
220
+ }
221
+ /**
222
+ * Map a pi-ai OAuthCredentials object into a refreshed OAuthProfile (preserves
223
+ * existing identity metadata; only access/refresh/expires/accountId change).
224
+ */
225
+ function mergeRefreshedCredentials(existing, refreshed) {
226
+ const accountIdRaw = refreshed.accountId;
227
+ const accountId = typeof accountIdRaw === "string" && accountIdRaw.length > 0
228
+ ? accountIdRaw
229
+ : existing.accountId;
230
+ return {
231
+ ...existing,
232
+ access: refreshed.access,
233
+ refresh: refreshed.refresh,
234
+ expires: refreshed.expires,
235
+ accountId,
236
+ version: SCHEMA_VERSION,
237
+ };
238
+ }
239
+ /**
240
+ * Build a fresh OAuthProfile from an env-var seed (bootstrap path).
241
+ * The identity (and therefore the canonical profileId) is derived by
242
+ * decoding the access-token JWT. Falls back to "<provider>:env-bootstrap"
243
+ * when the JWT cannot be decoded.
244
+ */
245
+ function buildBootstrapProfile(provider, seed) {
246
+ const identityResult = resolveCodexAuthIdentity({ accessToken: seed.access });
247
+ const email = identityResult.email;
248
+ const profileNameLike = identityResult.profileName;
249
+ // Canonical identity for profileId: prefer email, then profileName fallback,
250
+ // else "env-bootstrap" sentinel.
251
+ let identityKey;
252
+ if (email && email.length > 0) {
253
+ identityKey = email;
254
+ }
255
+ else if (profileNameLike && profileNameLike.length > 0) {
256
+ identityKey = profileNameLike;
257
+ }
258
+ else {
259
+ identityKey = "env-bootstrap";
260
+ }
261
+ const profileId = `${provider}:${identityKey}`;
262
+ const accountIdRaw = seed.accountId;
263
+ const accountId = typeof accountIdRaw === "string" && accountIdRaw.length > 0 ? accountIdRaw : undefined;
264
+ const profile = {
265
+ provider,
266
+ profileId,
267
+ access: seed.access,
268
+ refresh: seed.refresh,
269
+ expires: seed.expires,
270
+ accountId,
271
+ email,
272
+ version: SCHEMA_VERSION,
273
+ };
274
+ // Identity for the bootstrapped event payload — semi-redacted email when
275
+ // present, else the profileName (id-<base64> form is already non-PII).
276
+ const identity = email ? (redactEmailForLog(email) ?? identityKey) : identityKey;
277
+ return { profileId, profile, identity };
278
+ }
31
279
  // ---------------------------------------------------------------------------
32
280
  // Factory
33
281
  // ---------------------------------------------------------------------------
34
282
  /**
35
283
  * Create an OAuth token manager wrapping pi-ai's OAuth subsystem.
36
284
  *
37
- * Credentials are bootstrapped from SecretManager (read-only) on first
38
- * access and cached in-memory. Refreshed credentials are stored in the
39
- * cache (not written back to SecretManager, which is immutable).
285
+ * Architecture:
286
+ * 1. Resolve candidate profileId (env-var seed JWT > stored list > env-bootstrap sentinel).
287
+ * 2. Acquire per-profile lock via withExecutionLock.
288
+ * 3. Inside lock: TOCTOU re-read profile, run pi-ai with 30s timeout.
289
+ * 4. Detect real refresh by comparing newCredentials.refresh !== profile.refresh.
290
+ * 5. If refreshed, persist via credentialStore.set, then emit auth:token_rotated.
291
+ * 6. Release lock.
40
292
  *
41
- * @param deps - SecretManager, EventBus, and optional key prefix
293
+ * @param deps - SecretManager, EventBus, CredentialStore, Logger, dataDir, optional keyPrefix
42
294
  */
43
295
  export function createOAuthTokenManager(deps) {
44
- const { secretManager, eventBus, keyPrefix = "OAUTH_" } = deps;
45
- // In-memory credential cache. Bootstrapped from SecretManager on first
46
- // access per provider, then updated in-place on token refresh.
47
- const credentialCache = new Map();
296
+ const { secretManager, eventBus, credentialStore, logger, dataDir, keyPrefix = "OAUTH_", } = deps;
297
+ // Hot-path read cache (Discretion item invalidated on persisted writes).
298
+ // Keyed by canonical profileId.
299
+ const cache = new Map();
300
+ // De-dup sets — fire WARN/INFO once per (provider, process).
301
+ const bootstrappedProviders = new Set();
302
+ const warnedConflictProviders = new Set();
303
+ // Per-instance lastGood map. Records the most-recently-resolved profileId
304
+ // per provider for this agent's OAuthTokenManager. Resets on daemon restart
305
+ // (in-memory only). Updated inside the per-profile-ID lock on every
306
+ // successful resolve (refresh OR cached-hit).
307
+ const lastGood = new Map();
308
+ // Logger de-dup: fire INFO once per (provider, configured-profile, process)
309
+ // when the configured profile is first used. Mirrors bootstrappedProviders
310
+ // pattern.
311
+ const loggedConfiguredProviders = new Set();
312
+ // -------------------------------------------------------------------------
313
+ // chokidar watcher on auth-profiles.json (file adapter only). chokidar's
314
+ // atomic: 100 coalesces the tmp+rename atomic-write sequence into a single
315
+ // change event; raw fs.watch detaches across the rename on Linux ext4
316
+ // (inode tracking).
317
+ // -------------------------------------------------------------------------
318
+ const { watchPath } = deps;
319
+ let watcher;
320
+ let debounceTimer = null;
321
+ // Snapshot of profileIds the manager has seen — used to diff against
322
+ // store.list() after a watcher fire to emit auth:profile_added for new
323
+ // profiles only.
324
+ const seenProfileIds = new Set();
325
+ async function emitProfileAddedEventsAfterReload() {
326
+ const listResult = await credentialStore.list();
327
+ if (!listResult.ok)
328
+ return;
329
+ const before = new Set(seenProfileIds);
330
+ seenProfileIds.clear();
331
+ for (const profile of listResult.value) {
332
+ seenProfileIds.add(profile.profileId);
333
+ if (!before.has(profile.profileId)) {
334
+ // source: "external" always (drop discriminator).
335
+ eventBus.emit("auth:profile_added", {
336
+ provider: profile.provider,
337
+ profileId: profile.profileId,
338
+ identity: profile.email
339
+ ? (redactEmailForLog(profile.email) ?? profile.profileId)
340
+ : profile.profileId,
341
+ source: "external",
342
+ timestamp: Date.now(),
343
+ });
344
+ }
345
+ }
346
+ }
347
+ function scheduleCacheInvalidation() {
348
+ if (debounceTimer)
349
+ clearTimeout(debounceTimer);
350
+ debounceTimer = setTimeout(() => {
351
+ debounceTimer = null;
352
+ // Invalidate the ENTIRE hot cache (file is rewritten as a whole;
353
+ // per-profile diffing is YAGNI for a sub-1MB JSON).
354
+ cache.clear();
355
+ logger.debug({
356
+ filePath: watchPath,
357
+ debouncedMs: 100,
358
+ submodule: "oauth-store-watcher",
359
+ }, "OAuth store change detected; cache invalidated");
360
+ // Emit auth:profile_added for newly-discovered profiles.
361
+ // Best-effort — failure is logged inside the helper but not surfaced
362
+ // (the next getApiKey call will repopulate cache from store anyway).
363
+ void emitProfileAddedEventsAfterReload().catch((emitErr) => {
364
+ logger.warn({
365
+ submodule: "oauth-store-watcher",
366
+ hint: "profile_added_emit_failed",
367
+ errorKind: "event_emit",
368
+ err: emitErr,
369
+ }, "Failed to emit auth:profile_added after watcher fire");
370
+ });
371
+ }, 100);
372
+ }
373
+ if (watchPath) {
374
+ watcher = watch(watchPath, {
375
+ persistent: false, // do not keep the daemon alive on the watcher alone
376
+ ignoreInitial: true, // no event for the file's existence at startup
377
+ atomic: 100, // coalesce tmp+rename into one change event
378
+ awaitWriteFinish: false,
379
+ });
380
+ // Subscribe to change AND unlink AND add — unlink handles the logout path
381
+ // (file deleted), change handles modify after atomic-rename swap, add
382
+ // handles the first-write case after the file didn't exist at startup.
383
+ watcher.on("change", scheduleCacheInvalidation);
384
+ watcher.on("unlink", scheduleCacheInvalidation);
385
+ watcher.on("add", scheduleCacheInvalidation);
386
+ watcher.on("error", (watchErr) => {
387
+ logger.warn({
388
+ submodule: "oauth-store-watcher",
389
+ hint: "watcher_failed",
390
+ errorKind: "fs_watch",
391
+ err: watchErr,
392
+ }, "OAuth profile watcher errored");
393
+ });
394
+ logger.debug({ submodule: "oauth-store-watcher", filePath: watchPath }, "OAuth profile watcher registered");
395
+ }
48
396
  /**
49
- * Resolve credentials for a provider. Checks in-memory cache first,
50
- * then falls back to SecretManager (JSON-serialized OAuthCredentials).
397
+ * Resolve the working profile for a provider:
398
+ * - Look up via list({ provider }) first (canonical stored profile).
399
+ * - Else use env-var seed if present (bootstrap path).
400
+ * - Else fallback to "<provider>:env-bootstrap" sentinel for the get() call.
401
+ *
402
+ * Returns the candidate profileId plus optional env seed for downstream
403
+ * bootstrap/conflict detection.
51
404
  */
52
- function resolveCredentials(providerId) {
53
- // Check in-memory cache first (may have refreshed credentials)
54
- const cached = credentialCache.get(providerId);
55
- if (cached)
56
- return cached;
57
- // Bootstrap from SecretManager
58
- const secretKey = toSecretKey(providerId, keyPrefix);
59
- const raw = secretManager.get(secretKey);
60
- if (!raw)
61
- return undefined;
62
- try {
63
- const parsed = JSON.parse(raw);
64
- credentialCache.set(providerId, parsed);
65
- return parsed;
405
+ async function resolveCandidateProfileId(providerId) {
406
+ const envRaw = secretManager.get(toSecretKey(providerId, keyPrefix));
407
+ const envSeed = parseEnvCredentials(envRaw);
408
+ // Prefer existing stored profile (list discovery).
409
+ const listResult = await credentialStore.list({ provider: providerId });
410
+ if (listResult.ok && listResult.value.length > 0) {
411
+ const first = listResult.value[0];
412
+ if (first) {
413
+ return { profileId: first.profileId, envSeed };
414
+ }
66
415
  }
67
- catch {
68
- return undefined;
416
+ // No stored profile — derive candidate from env seed JWT (if valid).
417
+ if (envSeed) {
418
+ const { profileId: candidate } = buildBootstrapProfile(providerId, envSeed);
419
+ return { profileId: candidate, envSeed };
69
420
  }
421
+ // Final fallback — sentinel profileId for the get() call. If get returns
422
+ // a profile, its actual profileId (from the returned object) supersedes.
423
+ return { profileId: `${providerId}:env-bootstrap`, envSeed };
70
424
  }
71
- return {
72
- async getApiKey(providerId) {
73
- // 1. Resolve stored credentials
74
- const credentials = resolveCredentials(providerId);
75
- if (!credentials) {
76
- return err({
77
- code: "NO_CREDENTIALS",
78
- message: `No OAuth credentials stored for provider "${providerId}"`,
79
- providerId,
80
- });
425
+ /**
426
+ * Detect env-var override drift after a profile is loaded from the store.
427
+ * When the env var refresh-token differs from the stored refresh-token,
428
+ * WARN once per (provider, process) with operator hint.
429
+ */
430
+ function maybeWarnEnvConflict(providerId, storedProfile, envSeed) {
431
+ if (!envSeed)
432
+ return;
433
+ if (envSeed.refresh === storedProfile.refresh)
434
+ return;
435
+ if (warnedConflictProviders.has(providerId))
436
+ return;
437
+ warnedConflictProviders.add(providerId);
438
+ logger.warn({
439
+ provider: providerId,
440
+ profileId: storedProfile.profileId,
441
+ submodule: "oauth-token-manager",
442
+ hint: "env-override-ignored",
443
+ errorKind: "config_drift",
444
+ }, "OAuth env var refresh token differs from stored profile; stored profile is canonical");
445
+ }
446
+ /**
447
+ * Bootstrap an env-var seed into the credential store on first access.
448
+ * Writes the new profile, emits auth:profile_bootstrapped (once per provider),
449
+ * and returns the persisted profile. Bootstrap is performed BEFORE acquiring
450
+ * the per-profile refresh lock — the bootstrap-write is idempotent (set is
451
+ * UPSERT) and runs at most once per (provider, process) due to the de-dup
452
+ * tracker.
453
+ */
454
+ async function bootstrapFromEnv(providerId, envSeed) {
455
+ const { profileId, profile, identity } = buildBootstrapProfile(providerId, envSeed);
456
+ const writeResult = await credentialStore.set(profileId, profile);
457
+ if (!writeResult.ok) {
458
+ logger.warn({
459
+ provider: providerId,
460
+ profileId,
461
+ submodule: "oauth-token-manager",
462
+ hint: "store_write_failed",
463
+ errorKind: "store_failed",
464
+ err: writeResult.error,
465
+ }, "OAuth bootstrap failed: credentialStore.set rejected");
466
+ return err({
467
+ code: "STORE_FAILED",
468
+ message: `Failed to bootstrap OAuth profile for "${providerId}": ${writeResult.error.message}`,
469
+ providerId,
470
+ });
471
+ }
472
+ cache.set(profileId, profile);
473
+ // De-dup the bootstrap event per (provider, process).
474
+ if (!bootstrappedProviders.has(providerId)) {
475
+ bootstrappedProviders.add(providerId);
476
+ logger.info({
477
+ provider: providerId,
478
+ profileId,
479
+ submodule: "oauth-token-manager",
480
+ identity,
481
+ }, "Profile bootstrapped from env");
482
+ eventBus.emit("auth:profile_bootstrapped", {
483
+ provider: providerId,
484
+ profileId,
485
+ identity,
486
+ timestamp: Date.now(),
487
+ });
488
+ }
489
+ return ok(profile);
490
+ }
491
+ /**
492
+ * Run the lock-protected refresh body. Re-reads profile inside the lock
493
+ * (TOCTOU safety), calls pi-ai with a 30s timeout, detects real refresh
494
+ * by comparing refresh-token field, persists if rotated, emits events.
495
+ *
496
+ * Returns the API key on success, or an OAuthError on failure.
497
+ */
498
+ async function refreshUnderLock(providerId, initialProfile) {
499
+ const lockPath = lockSentinelPath(dataDir, initialProfile.profileId);
500
+ const lockStart = Date.now();
501
+ const lockResult = await withExecutionLock(lockPath, async () => {
502
+ const acquireMs = Date.now() - lockStart;
503
+ logger.debug({
504
+ provider: providerId,
505
+ profileId: initialProfile.profileId,
506
+ submodule: "oauth-token-manager",
507
+ durationMs: acquireMs,
508
+ }, "Lock acquired");
509
+ const heldStart = Date.now();
510
+ try {
511
+ // TOCTOU re-read inside lock to avoid acting on stale cache.
512
+ const reread = await credentialStore.get(initialProfile.profileId);
513
+ if (!reread.ok) {
514
+ return err({
515
+ code: "STORE_FAILED",
516
+ message: `credentialStore.get failed inside lock: ${reread.error.message}`,
517
+ providerId,
518
+ });
519
+ }
520
+ const profile = reread.value ?? initialProfile;
521
+ // Pi-ai requires a Record<providerId, OAuthCredentials> shape.
522
+ const credsRecord = {
523
+ [providerId]: {
524
+ access: profile.access,
525
+ refresh: profile.refresh,
526
+ expires: profile.expires,
527
+ },
528
+ };
529
+ // Bypass pi-ai for openai-codex so we can parse the wire response
530
+ // body for clean error classification (refresh_token_reused
531
+ // detection). Other providers continue to use pi-ai (which works
532
+ // correctly when the body is not needed).
533
+ const isCodex = providerId === "openai-codex";
534
+ // Both branches end with `apiKeyResult` populated to the pi-ai
535
+ // success-shape. This let-binding mirrors pi-ai's untyped result
536
+ // — `any` is the one acceptable use in this file (pi-ai's
537
+ // getOAuthApiKey return type is genuinely untyped at the npm
538
+ // boundary; the bypass synthesizes the same shape).
539
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pi-ai surface is untyped
540
+ let apiKeyResult;
541
+ if (isCodex) {
542
+ // Skip the wire if the persisted access is still valid.
543
+ // pi-ai's getOAuthApiKey performs this check internally for
544
+ // non-codex providers; the codex bypass must mirror it or every
545
+ // getApiKey() call would re-hit the token endpoint and break the
546
+ // "restart-survives-refresh" contract. 60s buffer keeps callers
547
+ // from racing the actual expiry.
548
+ const REFRESH_EXPIRY_BUFFER_MS = 60_000;
549
+ if (typeof profile.expires === "number"
550
+ && profile.expires > Date.now() + REFRESH_EXPIRY_BUFFER_MS) {
551
+ cache.set(profile.profileId, profile);
552
+ // Mirror the post-refresh lastGood update so subsequent calls
553
+ // short-circuit at tier (b) instead of re-hitting tier (c) list().
554
+ lastGood.set(providerId, profile.profileId);
555
+ logger.debug({
556
+ provider: providerId,
557
+ profileId: profile.profileId,
558
+ submodule: "oauth-token-manager",
559
+ remainingMs: profile.expires - Date.now(),
560
+ }, "OAuth token still valid — skipping refresh");
561
+ return ok(profile.access);
562
+ }
563
+ // Bypass never throws (it returns a tagged outcome on every path),
564
+ // so feed the bare promise into `withTimeout` — no fromPromise wrap.
565
+ const raceResult = await withTimeout(refreshOpenAICodexTokenLocal(profile), REFRESH_TIMEOUT_MS);
566
+ if (!raceResult.ok) {
567
+ // timeout — bypass branch
568
+ logger.warn({
569
+ provider: providerId,
570
+ profileId: profile.profileId,
571
+ submodule: "oauth-token-manager",
572
+ hint: "auth_endpoint_unreachable",
573
+ errorKind: "timeout",
574
+ }, "OAuth refresh timed out after 30s");
575
+ eventBus.emit("auth:refresh_failed", {
576
+ provider: providerId,
577
+ profileId: profile.profileId,
578
+ errorKind: "timeout",
579
+ hint: "auth_endpoint_unreachable",
580
+ timestamp: Date.now(),
581
+ });
582
+ return err({
583
+ code: "REFRESH_FAILED",
584
+ message: `OAuth refresh timed out for provider "${providerId}" after ${REFRESH_TIMEOUT_MS}ms`,
585
+ providerId,
586
+ errorKind: "timeout",
587
+ profileId: profile.profileId,
588
+ hint: "auth_endpoint_unreachable",
589
+ });
590
+ }
591
+ const localResult = raceResult.value;
592
+ if (!localResult.ok) {
593
+ // Concatenate both wire fields so the catalogue's substring
594
+ // matchers (rewriteOAuthError) can detect specific patterns
595
+ // regardless of which field carries them. Tests confirm:
596
+ // refresh_token_reused → typically in error_description
597
+ // invalid_grant generic → in error code
598
+ // unsupported_country_region_territory → in error code
599
+ const classifyMessage = `${localResult.error.error} ${localResult.error.errorDescription ?? ""}`.trim();
600
+ const classifyInput = new Error(classifyMessage);
601
+ const rewritten = rewriteOAuthError(classifyInput);
602
+ logger.warn({
603
+ provider: providerId,
604
+ profileId: profile.profileId,
605
+ submodule: "oauth-token-manager",
606
+ hint: rewritten.hint,
607
+ errorKind: rewritten.errorKind,
608
+ err: classifyInput,
609
+ status: localResult.error.status,
610
+ }, "OAuth refresh failed");
611
+ eventBus.emit("auth:refresh_failed", {
612
+ provider: providerId,
613
+ profileId: profile.profileId,
614
+ errorKind: rewritten.errorKind,
615
+ hint: rewritten.hint,
616
+ timestamp: Date.now(),
617
+ });
618
+ return err({
619
+ code: "REFRESH_FAILED",
620
+ message: rewritten.userMessage,
621
+ providerId,
622
+ errorKind: rewritten.errorKind,
623
+ profileId: profile.profileId,
624
+ hint: rewritten.hint,
625
+ });
626
+ }
627
+ // Synthesize the apiKeyResult shape pi-ai would return on success.
628
+ // Downstream code reads `apiKeyResult.value.apiKey` and
629
+ // `apiKeyResult.value.newCredentials.refresh`.
630
+ const synth = {
631
+ access: localResult.value.access,
632
+ refresh: localResult.value.refresh,
633
+ expires: localResult.value.expires,
634
+ ...(localResult.value.accountId !== undefined
635
+ ? { accountId: localResult.value.accountId }
636
+ : {}),
637
+ };
638
+ apiKeyResult = {
639
+ ok: true,
640
+ value: { apiKey: synth.access, newCredentials: synth },
641
+ };
642
+ }
643
+ else {
644
+ // Non-Codex: original pi-ai path UNCHANGED.
645
+ // 30s timeout wrapper — pi-ai has no built-in timeout.
646
+ const piAiCall = fromPromise(getOAuthApiKey(providerId, credsRecord));
647
+ const raceResult = await withTimeout(piAiCall, REFRESH_TIMEOUT_MS);
648
+ if (!raceResult.ok) {
649
+ // timeout
650
+ logger.warn({
651
+ provider: providerId,
652
+ profileId: profile.profileId,
653
+ submodule: "oauth-token-manager",
654
+ hint: "auth_endpoint_unreachable",
655
+ errorKind: "timeout",
656
+ }, "OAuth refresh timed out after 30s");
657
+ eventBus.emit("auth:refresh_failed", {
658
+ provider: providerId,
659
+ profileId: profile.profileId,
660
+ errorKind: "timeout",
661
+ hint: "auth_endpoint_unreachable",
662
+ timestamp: Date.now(),
663
+ });
664
+ return err({
665
+ code: "REFRESH_FAILED",
666
+ message: `OAuth refresh timed out for provider "${providerId}" after ${REFRESH_TIMEOUT_MS}ms`,
667
+ providerId,
668
+ errorKind: "timeout",
669
+ profileId: profile.profileId,
670
+ hint: "auth_endpoint_unreachable",
671
+ });
672
+ }
673
+ apiKeyResult = raceResult.value;
674
+ if (!apiKeyResult.ok) {
675
+ // Classify via the shared catalogue — generic invalid_grant +
676
+ // unsupported_region matchers still apply to non-Codex providers
677
+ // when the original error message contains the substring.
678
+ const rewritten = rewriteOAuthError(apiKeyResult.error);
679
+ logger.warn({
680
+ provider: providerId,
681
+ profileId: profile.profileId,
682
+ submodule: "oauth-token-manager",
683
+ hint: rewritten.hint,
684
+ errorKind: rewritten.errorKind,
685
+ err: apiKeyResult.error,
686
+ }, "OAuth refresh failed");
687
+ eventBus.emit("auth:refresh_failed", {
688
+ provider: providerId,
689
+ profileId: profile.profileId,
690
+ errorKind: rewritten.errorKind,
691
+ hint: rewritten.hint,
692
+ timestamp: Date.now(),
693
+ });
694
+ return err({
695
+ code: "REFRESH_FAILED",
696
+ message: rewritten.userMessage,
697
+ providerId,
698
+ errorKind: rewritten.errorKind,
699
+ profileId: profile.profileId,
700
+ hint: rewritten.hint,
701
+ });
702
+ }
703
+ }
704
+ const oauthResult = apiKeyResult.value;
705
+ // pi-ai returns null when no credentials.
706
+ if (!oauthResult) {
707
+ return err({
708
+ code: "NO_CREDENTIALS",
709
+ message: `getOAuthApiKey returned null for provider "${providerId}"`,
710
+ providerId,
711
+ });
712
+ }
713
+ // Real refresh detection compares the refresh-token
714
+ // value, not the always-truthy newCredentials marker.
715
+ const refreshed = oauthResult.newCredentials.refresh !== profile.refresh;
716
+ if (refreshed) {
717
+ const newProfile = mergeRefreshedCredentials(profile, oauthResult.newCredentials);
718
+ const writeResult = await credentialStore.set(profile.profileId, newProfile);
719
+ if (!writeResult.ok) {
720
+ logger.warn({
721
+ provider: providerId,
722
+ profileId: profile.profileId,
723
+ submodule: "oauth-token-manager",
724
+ hint: "store_write_failed",
725
+ errorKind: "store_failed",
726
+ err: writeResult.error,
727
+ }, "OAuth refresh persisted-write failed");
728
+ return err({
729
+ code: "STORE_FAILED",
730
+ message: `Failed to persist refreshed OAuth credentials for "${providerId}": ${writeResult.error.message}`,
731
+ providerId,
732
+ });
733
+ }
734
+ cache.set(profile.profileId, newProfile);
735
+ // pi-ai's OAuthCredentials.expires is already milliseconds since epoch
736
+ // — use directly, do NOT multiply by 1000.
737
+ eventBus.emit("auth:token_rotated", {
738
+ provider: providerId,
739
+ profileName: toSecretKey(providerId, keyPrefix),
740
+ profileId: profile.profileId,
741
+ expiresAtMs: newProfile.expires,
742
+ timestamp: Date.now(),
743
+ });
744
+ }
745
+ else {
746
+ // Cache the unrotated profile for the next read (no DB roundtrip needed).
747
+ cache.set(profile.profileId, profile);
748
+ }
749
+ const completeStart = Date.now();
750
+ logger.info({
751
+ provider: providerId,
752
+ profileId: profile.profileId,
753
+ submodule: "oauth-token-manager",
754
+ durationMs: completeStart - heldStart,
755
+ refreshed,
756
+ }, "OAuth refresh complete");
757
+ // lastGood update inside the lock-held window. Updated on every
758
+ // successful resolve (refresh OR cached-hit) so the tier-(b) lookup
759
+ // in subsequent getApiKey calls (no agent-level config)
760
+ // short-circuits to the just-resolved profile.
761
+ const previousLastGood = lastGood.get(providerId);
762
+ lastGood.set(providerId, profile.profileId);
763
+ if (previousLastGood !== profile.profileId) {
764
+ logger.debug({
765
+ provider: providerId,
766
+ profileId: profile.profileId,
767
+ previous: previousLastGood ?? null,
768
+ submodule: "oauth-resolver",
769
+ }, "lastGood updated");
770
+ }
771
+ return ok(oauthResult.apiKey);
772
+ }
773
+ finally {
774
+ logger.debug({
775
+ provider: providerId,
776
+ profileId: initialProfile.profileId,
777
+ submodule: "oauth-token-manager",
778
+ heldMs: Date.now() - heldStart,
779
+ }, "Lock released");
81
780
  }
82
- // 2. Check if pi-ai knows this provider
781
+ }, LOCK_OPTIONS);
782
+ if (!lockResult.ok) {
783
+ const lockKind = lockResult.error;
784
+ const hint = lockKind === "locked" ? "lock_contention" : "lock_error";
785
+ const errorKind = lockKind === "locked" ? "lock_contention" : "lock_error";
786
+ logger.warn({
787
+ provider: providerId,
788
+ profileId: initialProfile.profileId,
789
+ submodule: "oauth-token-manager",
790
+ retries: 0,
791
+ hint,
792
+ errorKind,
793
+ }, lockKind === "locked"
794
+ ? "OAuth refresh lock contention"
795
+ : "OAuth refresh lock error");
796
+ eventBus.emit("auth:refresh_failed", {
797
+ provider: providerId,
798
+ profileId: initialProfile.profileId,
799
+ errorKind,
800
+ hint,
801
+ timestamp: Date.now(),
802
+ });
803
+ return err({
804
+ code: "REFRESH_FAILED",
805
+ message: `OAuth refresh lock ${lockKind} for provider "${providerId}"`,
806
+ providerId,
807
+ });
808
+ }
809
+ return lockResult.value;
810
+ }
811
+ return {
812
+ async getApiKey(providerId, agentContext) {
813
+ // Provider validation first (cheap check; avoids store I/O on bad input).
83
814
  const provider = getOAuthProvider(providerId);
84
815
  if (!provider) {
85
816
  return err({
@@ -88,51 +819,215 @@ export function createOAuthTokenManager(deps) {
88
819
  providerId,
89
820
  });
90
821
  }
91
- // 3. Call getOAuthApiKey (auto-refreshes if expired)
92
- const credsRecord = { [providerId]: credentials };
93
- const apiKeyResult = await fromPromise(getOAuthApiKey(providerId, credsRecord));
94
- if (!apiKeyResult.ok) {
822
+ // Resolve oauthProfiles fresh on every call (no caching). Dual-surface:
823
+ // prefer the explicit agentContext argument; fall back to the deps
824
+ // getter for callers without an agent context (e.g., env-var bootstrap
825
+ // path, tests).
826
+ const oauthProfiles = agentContext?.oauthProfiles ?? deps.getAgentOauthProfiles?.();
827
+ const configured = oauthProfiles?.[providerId];
828
+ // Tier (a) — Agent-config-named profile. Hard-fail on missing; this is
829
+ // the security keystone — never silently fall through to a different
830
+ // account when the configured one is gone.
831
+ if (configured !== undefined) {
832
+ const hasResult = await credentialStore.has(configured);
833
+ if (!hasResult.ok) {
834
+ return err({
835
+ code: "STORE_FAILED",
836
+ message: `credentialStore.has failed for "${configured}": ${hasResult.error.message}`,
837
+ providerId,
838
+ });
839
+ }
840
+ if (!hasResult.value) {
841
+ logger.warn({
842
+ provider: providerId,
843
+ configuredProfileId: configured,
844
+ hint: "configured-profile-missing",
845
+ errorKind: "profile_not_found",
846
+ submodule: "oauth-resolver",
847
+ }, "Configured OAuth profile not found in store");
848
+ return err({
849
+ code: "PROFILE_NOT_FOUND",
850
+ message: `OAuth profile "${configured}" configured for agent but not found in store. Run "comis auth list" to see available profiles.`,
851
+ providerId,
852
+ });
853
+ }
854
+ // Once-per-(provider, configured-profile, process) INFO log when the
855
+ // configured profile is first used.
856
+ const dedupKey = `${providerId}::${configured}`;
857
+ if (!loggedConfiguredProviders.has(dedupKey)) {
858
+ loggedConfiguredProviders.add(dedupKey);
859
+ logger.info({
860
+ provider: providerId,
861
+ profileId: configured,
862
+ submodule: "oauth-resolver",
863
+ }, "OAuth profile resolved via agent config");
864
+ }
865
+ logger.debug({
866
+ provider: providerId,
867
+ source: "agent-config",
868
+ profileId: configured,
869
+ submodule: "oauth-resolver",
870
+ }, "Resolved OAuth profile via chain");
871
+ // Read the full profile to feed into refreshUnderLock.
872
+ const getResult = await credentialStore.get(configured);
873
+ if (!getResult.ok) {
874
+ return err({
875
+ code: "STORE_FAILED",
876
+ message: `credentialStore.get failed for "${configured}": ${getResult.error.message}`,
877
+ providerId,
878
+ });
879
+ }
880
+ if (!getResult.value) {
881
+ // Race: profile existed at .has() but vanished by .get(). Treat as
882
+ // PROFILE_NOT_FOUND with a retry hint.
883
+ return err({
884
+ code: "PROFILE_NOT_FOUND",
885
+ message: `OAuth profile "${configured}" disappeared from store between has() and get(). Retry the operation.`,
886
+ providerId,
887
+ });
888
+ }
889
+ return refreshUnderLock(providerId, getResult.value);
890
+ }
891
+ // Tier (b) — lastGood (in-process Map; only consulted when no agent-level
892
+ // config). Stale entries (profile deleted post-lastGood-set) cause
893
+ // fall-through to tier (c).
894
+ const lg = lastGood.get(providerId);
895
+ if (lg !== undefined) {
896
+ const hasResult = await credentialStore.has(lg);
897
+ if (hasResult.ok && hasResult.value) {
898
+ const getResult = await credentialStore.get(lg);
899
+ if (getResult.ok && getResult.value) {
900
+ logger.debug({
901
+ provider: providerId,
902
+ source: "lastGood",
903
+ profileId: lg,
904
+ submodule: "oauth-resolver",
905
+ }, "Resolved OAuth profile via chain");
906
+ return refreshUnderLock(providerId, getResult.value);
907
+ }
908
+ }
909
+ // Stale lastGood (profile deleted, has() failed, or get() returned
910
+ // nothing) → fall through to tier (c).
911
+ }
912
+ // Tier (c) — first available from list({provider}). Returns early when
913
+ // the store has at least one profile for this provider.
914
+ const tierCList = await credentialStore.list({ provider: providerId });
915
+ if (!tierCList.ok) {
95
916
  return err({
96
- code: "REFRESH_FAILED",
97
- message: apiKeyResult.error.message,
917
+ code: "STORE_FAILED",
918
+ message: `credentialStore.list failed for provider "${providerId}": ${tierCList.error.message}`,
98
919
  providerId,
99
920
  });
100
921
  }
101
- const oauthResult = apiKeyResult.value;
102
- // 4. getOAuthApiKey returns null if no credentials
103
- if (!oauthResult) {
922
+ if (tierCList.value.length > 0) {
923
+ const firstProfile = tierCList.value[0];
924
+ if (firstProfile) {
925
+ // Conflict detection on the picked profile — env var may diverge
926
+ // from stored refresh (R7c silent vs WARN path).
927
+ const envRawForC = secretManager.get(toSecretKey(providerId, keyPrefix));
928
+ const envSeedForC = parseEnvCredentials(envRawForC);
929
+ maybeWarnEnvConflict(providerId, firstProfile, envSeedForC);
930
+ logger.debug({
931
+ provider: providerId,
932
+ source: "first",
933
+ profileId: firstProfile.profileId,
934
+ submodule: "oauth-resolver",
935
+ }, "Resolved OAuth profile via chain");
936
+ return refreshUnderLock(providerId, firstProfile);
937
+ }
938
+ }
939
+ // Env-bootstrap fallback: tiers (a)/(b)/(c) all came up empty.
940
+ // Discover candidate profileId + env-var seed from the legacy resolver.
941
+ const { profileId: candidateProfileId, envSeed } = await resolveCandidateProfileId(providerId);
942
+ logger.debug({
943
+ provider: providerId,
944
+ profileId: candidateProfileId,
945
+ source: "env-bootstrap",
946
+ submodule: "oauth-resolver",
947
+ }, "Resolved OAuth profile via env-bootstrap fallback");
948
+ // Try to load the existing stored profile (tier (c) covered list-based
949
+ // discovery; this catches the case where candidateProfileId resolves to
950
+ // a sentinel that is still recoverable via direct get()).
951
+ const storeRead = await credentialStore.get(candidateProfileId);
952
+ if (!storeRead.ok) {
104
953
  return err({
105
- code: "NO_CREDENTIALS",
106
- message: `getOAuthApiKey returned null for provider "${providerId}"`,
954
+ code: "STORE_FAILED",
955
+ message: `credentialStore.get failed for "${candidateProfileId}": ${storeRead.error.message}`,
107
956
  providerId,
108
957
  });
109
958
  }
110
- // 5. If credentials were refreshed, cache updated creds and emit event
111
- if (oauthResult.newCredentials) {
112
- credentialCache.set(providerId, oauthResult.newCredentials);
113
- const secretKey = toSecretKey(providerId, keyPrefix);
114
- eventBus.emit("auth:token_rotated", {
959
+ let profile = storeRead.value;
960
+ if (profile) {
961
+ const expiresAt = typeof profile.expires === "number" ? profile.expires : undefined;
962
+ const secsUntilExpiry = expiresAt !== undefined ? Math.floor((expiresAt - Date.now()) / 1000) : undefined;
963
+ logger.debug({
115
964
  provider: providerId,
116
- profileName: secretKey,
117
- expiresAtMs: oauthResult.newCredentials.expires * 1000,
118
- timestamp: Date.now(),
119
- });
965
+ profileId: profile.profileId,
966
+ submodule: "oauth-token-manager",
967
+ expiresAt,
968
+ secsUntilExpiry,
969
+ }, "Profile loaded from store");
970
+ // Conflict detection (R7c) — env var diverges from stored refresh.
971
+ maybeWarnEnvConflict(providerId, profile, envSeed);
972
+ }
973
+ else {
974
+ // Store empty for this profileId. Try in-memory cache first — it
975
+ // covers (a) the rotated-profile-after-prior-refresh path where the
976
+ // store mock isn't updated mid-test, and (b) the storeCredentials()
977
+ // path used by login flows + back-compat tests.
978
+ const cached = cache.get(candidateProfileId);
979
+ const cachedByProvider = cached ?? Array.from(cache.values()).find((p) => p.provider === providerId);
980
+ if (cachedByProvider) {
981
+ profile = cachedByProvider;
982
+ // Conflict detection on the cache-loaded profile too — env var may
983
+ // have changed since storeCredentials() was called.
984
+ maybeWarnEnvConflict(providerId, profile, envSeed);
985
+ }
986
+ else if (envSeed) {
987
+ // Bootstrap from env-var seed when available.
988
+ const bootstrapResult = await bootstrapFromEnv(providerId, envSeed);
989
+ if (!bootstrapResult.ok)
990
+ return bootstrapResult;
991
+ profile = bootstrapResult.value;
992
+ }
993
+ else {
994
+ return err({
995
+ code: "NO_CREDENTIALS",
996
+ message: `No OAuth credentials stored or seeded for provider "${providerId}"`,
997
+ providerId,
998
+ });
999
+ }
120
1000
  }
121
- return ok(oauthResult.apiKey);
1001
+ // Acquire per-profile lock and run the refresh body.
1002
+ return refreshUnderLock(providerId, profile);
122
1003
  },
123
1004
  hasCredentials(providerId) {
124
- // Check in-memory cache first
125
- if (credentialCache.has(providerId))
1005
+ // Cache-only synchronous check — sufficient for "has any candidate".
1006
+ // Async store/list checks live in getApiKey.
1007
+ const cached = Array.from(cache.values()).some((p) => p.provider === providerId);
1008
+ if (cached)
126
1009
  return true;
127
- // Fall back to SecretManager
128
1010
  const secretKey = toSecretKey(providerId, keyPrefix);
129
1011
  return secretManager.has(secretKey);
130
1012
  },
131
1013
  storeCredentials(providerId, creds) {
132
- credentialCache.set(providerId, creds);
1014
+ // Best-effort cache-only store; persistent storage uses bootstrapFromEnv
1015
+ // or the lock-protected refresh path. Used by tests + future login flows.
1016
+ const { profileId, profile } = buildBootstrapProfile(providerId, creds);
1017
+ cache.set(profileId, profile);
133
1018
  },
134
1019
  getSupportedProviders() {
135
1020
  return getOAuthProviders().map((p) => p.id);
136
1021
  },
1022
+ async dispose() {
1023
+ if (debounceTimer) {
1024
+ clearTimeout(debounceTimer);
1025
+ debounceTimer = null;
1026
+ }
1027
+ if (watcher) {
1028
+ await watcher.close();
1029
+ watcher = undefined;
1030
+ }
1031
+ },
137
1032
  };
138
1033
  }