@oar-sh/oar 0.9.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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,593 @@
1
+ 'use strict';
2
+ import { spawn } from 'child_process';
3
+
4
+ // The mechanical spawn plumbing — escape stripping, secret scrubbing, the
5
+ // process-group kill, the run-to-completion wrapper — is shared with the Claude
6
+ // auth service and the CLI install service. One implementation, so the two auth
7
+ // paths cannot drift apart in what they scrub or how hard they kill.
8
+ import {
9
+ CLI_SPAWN_DISABLED_ERROR,
10
+ MAX_CAPTURED_OUTPUT_CHARS,
11
+ killTree as killProcessTree,
12
+ runToCompletion as runProcessToCompletion,
13
+ scrubSecrets,
14
+ stripTerminalEscapes,
15
+ tailOf,
16
+ } from './cli-process-runner.mjs';
17
+ import { createGrokBillingUsageFetcher, readGrokCliAuthKey } from './grok-billing-usage.mjs';
18
+
19
+ /**
20
+ * Grok CLI auth (status / login / logout) driven from the relay.
21
+ *
22
+ * Probed live against Grok Build 1.0.13 (2026-08-31), and that probe is why
23
+ * this file is so much shorter than claude-auth-service.mjs: `grok login
24
+ * --device-auth` needs no pseudo-TTY (plain piped stdio, stdin ignorable) and
25
+ * the user never pastes anything back — the code rides inside the URL, the CLI
26
+ * polls x.ai itself and exits 0 once the browser authorises. So there is no
27
+ * `exchanging` state, no `submitCode()`, and no `script -qec` harness here.
28
+ *
29
+ * idle -> starting -> awaiting_authorization -> success
30
+ * |
31
+ * +-- cancel/timeout, error (non-zero exit, dead
32
+ * process) --> session released
33
+ *
34
+ * `success` and `error` are terminal *display* states, exactly as in the Claude
35
+ * service: the session is already released (a new `startLogin()` may begin,
36
+ * `logout()` is allowed again) but the outcome stays readable until the next
37
+ * start or `cancel()` resets it to idle.
38
+ *
39
+ * Status has no command to run. There is no `grok auth status`, and `grok
40
+ * models` prints "You are not authenticated." while still exiting 0, so the exit
41
+ * code carries nothing. The authoritative signal is `~/.grok/auth.json`, which
42
+ * grok-billing-usage.mjs already reads for the usage card — reused here rather
43
+ * than reimplemented, so both surfaces agree on what "logged in" means.
44
+ *
45
+ * No Grok secret transits the relay: the device `user_code` is public by design
46
+ * (it is printed on the terminal and typed into a browser), and the token is
47
+ * written by the CLI straight into `~/.grok/auth.json` (0600). The bearer key
48
+ * read for the billing label never leaves this module.
49
+ */
50
+
51
+ export const GROK_AUTH_LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
52
+ export const GROK_AUTH_STATUS_TTL_MS = 5_000;
53
+ const GROK_AUTH_LOGOUT_TIMEOUT_MS = 60_000;
54
+ const KILL_ESCALATION_MS = 2_000;
55
+
56
+ // The device-authorisation endpoint, pinned to the host the CLI actually
57
+ // prints. Unlike Claude's authorize URL this one has never moved, and pinning
58
+ // it keeps a stray link in some future banner from being offered as the sign-in
59
+ // target.
60
+ const DEVICE_URL_PATTERN = /https:\/\/accounts\.x\.ai\/oauth2\/device\S*/;
61
+ // `user_code=D7SV-M4TR` inside that URL — the authoritative copy of the code.
62
+ // `%` is in the class so a percent-encoded separator survives to decodeURIComponent().
63
+ const DEVICE_URL_CODE_PATTERN = /[?&]user_code=([A-Za-z0-9][A-Za-z0-9%-]*)/;
64
+ // The same code printed on its own line under "Confirm this code in your
65
+ // browser:", used only when a future release stops putting it in the URL.
66
+ const DEVICE_CODE_PATTERN = /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/;
67
+
68
+ const LOGIN_STATES = Object.freeze({
69
+ IDLE: 'idle',
70
+ STARTING: 'starting',
71
+ AWAITING_AUTHORIZATION: 'awaiting_authorization',
72
+ SUCCESS: 'success',
73
+ ERROR: 'error',
74
+ });
75
+
76
+ export const GROK_AUTH_LOGIN_STATES = LOGIN_STATES;
77
+
78
+ function normalizeText(value) {
79
+ return String(value == null ? '' : value).trim();
80
+ }
81
+
82
+ function toIso(value) {
83
+ const parsed = Date.parse(normalizeText(value));
84
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
85
+ }
86
+
87
+ /**
88
+ * Escape-stripped output truncated to its last line break.
89
+ *
90
+ * Both scrapes run against a growing buffer of arbitrary stdout chunks, and the
91
+ * first hit wins for the rest of the session — so a chunk boundary landing
92
+ * mid-URL would otherwise latch a truncated link the user cannot open and the
93
+ * state machine never revises. Only completed lines are ever scanned; the URL
94
+ * and the code each sit alone on one, so nothing is lost by waiting for the
95
+ * newline that follows them.
96
+ */
97
+ function completedLines(rawOutput) {
98
+ const visible = stripTerminalEscapes(rawOutput);
99
+ const lastBreak = visible.lastIndexOf('\n');
100
+ return lastBreak < 0 ? '' : visible.slice(0, lastBreak + 1);
101
+ }
102
+
103
+ /**
104
+ * Pulls the device-authorisation URL out of raw CLI output.
105
+ *
106
+ * Simpler than the Claude equivalent because there is no PTY: the CLI prints
107
+ * the URL on one unwrapped line with no OSC-8 hyperlink wrapper, so escape
108
+ * stripping (for the one `\x1b[90m…\x1b[0m` warning line) plus a single match is
109
+ * the whole job — no hard-wrap rejoining. Returns '' rather than throwing when
110
+ * the banner changes shape; the panel then degrades to the raw output tail
111
+ * (plan §9, risk row 2).
112
+ */
113
+ export function extractDeviceAuthUrl(rawOutput) {
114
+ const match = completedLines(rawOutput).match(DEVICE_URL_PATTERN);
115
+ return match ? match[0] : '';
116
+ }
117
+
118
+ /**
119
+ * The `XXXX-XXXX` device code, preferred from the URL's `user_code` parameter
120
+ * (that is the copy the browser will compare against) and falling back to the
121
+ * bare line the CLI prints below it.
122
+ */
123
+ export function extractDeviceCode(rawOutput) {
124
+ const visible = completedLines(rawOutput);
125
+ const url = visible.match(DEVICE_URL_PATTERN);
126
+ const fromUrl = url && url[0].match(DEVICE_URL_CODE_PATTERN);
127
+ if (fromUrl) {
128
+ try {
129
+ return decodeURIComponent(fromUrl[1]);
130
+ } catch {
131
+ return fromUrl[1];
132
+ }
133
+ }
134
+ const bare = visible.match(DEVICE_CODE_PATTERN);
135
+ return bare ? bare[1] : '';
136
+ }
137
+
138
+ /**
139
+ * Same override chain the Grok session worker (grok-session-worker.mjs:48) and
140
+ * the model-discovery probe (server-runtime.mjs:1110) use, so auth and turn
141
+ * execution can never end up addressing two different binaries.
142
+ */
143
+ export function resolveGrokAuthBinary(env = process.env) {
144
+ return normalizeText(env?.GROK_CLI_COMMAND) || normalizeText(env?.GROK_COMMAND) || 'grok';
145
+ }
146
+
147
+ export function createGrokAuthService({
148
+ env = process.env,
149
+ platform = process.platform,
150
+ spawnImpl = spawn,
151
+ processKillImpl = process.kill.bind(process),
152
+ setTimeoutImpl = setTimeout,
153
+ clearTimeoutImpl = clearTimeout,
154
+ now = () => Date.now(),
155
+ logger = console,
156
+ readAuthKeyImpl = readGrokCliAuthKey,
157
+ fetchBillingImpl = createGrokBillingUsageFetcher(),
158
+ loginTimeoutMs = GROK_AUTH_LOGIN_TIMEOUT_MS,
159
+ statusTtlMs = GROK_AUTH_STATUS_TTL_MS,
160
+ logoutTimeoutMs = GROK_AUTH_LOGOUT_TIMEOUT_MS,
161
+ } = {}) {
162
+ const listeners = new Set();
163
+ const successHooks = new Set();
164
+ const login = {
165
+ state: LOGIN_STATES.IDLE,
166
+ authUrl: '',
167
+ userCode: '',
168
+ error: '',
169
+ startedAt: null,
170
+ };
171
+ let session = null;
172
+ // Bumped by every startLogin/cancel so a slow post-success continuation can
173
+ // tell whether it still owns the visible login state.
174
+ let loginGeneration = 0;
175
+ let statusCache = null;
176
+ let statusInFlight = null;
177
+ // Serialises status reads: a forced read never joins an older in-flight one
178
+ // (it could predate the login/logout that prompted the force), but it waits
179
+ // for it rather than racing a second billing fetch.
180
+ let statusChain = Promise.resolve();
181
+
182
+ const log = (message) => {
183
+ try { logger?.log?.(`[grok-auth] ${message}`); } catch {}
184
+ };
185
+
186
+ function loginSnapshot() {
187
+ return {
188
+ state: login.state,
189
+ authUrl: login.authUrl || null,
190
+ userCode: login.userCode || null,
191
+ error: login.error || null,
192
+ startedAt: login.startedAt || null,
193
+ active: Boolean(session),
194
+ };
195
+ }
196
+
197
+ function emitState() {
198
+ const snapshot = loginSnapshot();
199
+ for (const listener of [...listeners]) {
200
+ try { listener(snapshot); } catch (error) { log(`listener failed: ${error?.message || error}`); }
201
+ }
202
+ }
203
+
204
+ function setLoginState(nextState, { authUrl, userCode, error } = {}) {
205
+ login.state = nextState;
206
+ if (authUrl !== undefined) login.authUrl = authUrl || '';
207
+ if (userCode !== undefined) login.userCode = userCode || '';
208
+ if (error !== undefined) login.error = error || '';
209
+ emitState();
210
+ }
211
+
212
+ function cliSpawnsDisabled() {
213
+ // Same kill switch (and same truthiness rule) as
214
+ // session-worker-launch-service.mjs and the Claude auth service: a relay
215
+ // started with it must never run a real CLI — including `grok logout`, which
216
+ // would sign the developer's own host out.
217
+ if (!String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim()) return false;
218
+ // Narrow escape hatch for the e2e harness, which keeps the kill switch on
219
+ // (no real workers) but still drives the whole auth flow. Both halves are
220
+ // required: the opt-in flag AND an explicit binary override, so "spawns
221
+ // disabled" can never end up running the host's real `grok`.
222
+ return !(
223
+ String(env?.COPILOT_WEB_RELAY_GROK_AUTH_ALLOW_STUB_SPAWN || '').trim()
224
+ && (String(env?.GROK_CLI_COMMAND || '').trim() || String(env?.GROK_COMMAND || '').trim())
225
+ );
226
+ }
227
+
228
+ /**
229
+ * The billing proxy is the only outbound network call in this module, and it
230
+ * is purely cosmetic (the plan label). The kill switch that blocks CLI spawns
231
+ * blocks it too — including through the stub escape hatch, which exists so a
232
+ * test relay can drive the flow *without* reaching x.ai.
233
+ */
234
+ function outboundProbesDisabled() {
235
+ return Boolean(String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim());
236
+ }
237
+
238
+ /**
239
+ * The single spawn site for every auth subcommand. No PTY anywhere: probed
240
+ * live, `login --device-auth` runs happily on plain pipes with stdin closed,
241
+ * and `logout` is non-interactive.
242
+ */
243
+ function spawnGrokProcess(args) {
244
+ if (cliSpawnsDisabled()) throw new Error(CLI_SPAWN_DISABLED_ERROR);
245
+ const bin = resolveGrokAuthBinary(env);
246
+ return spawnImpl(bin, args, {
247
+ // stdin is never written to, so it is closed outright rather than left as
248
+ // a pipe nothing ever drains.
249
+ stdio: ['ignore', 'pipe', 'pipe'],
250
+ // A plain copy of the relay environment: HOME (and any GROK_* overrides)
251
+ // come along, so login/logout address the same `~/.grok` root the workers
252
+ // and the usage card read.
253
+ env: { ...env },
254
+ windowsHide: true,
255
+ // POSIX only: its own process group, so cancel()/dispose() can signal the
256
+ // polling CLI and anything it spawned in one shot. Windows has no
257
+ // equivalent here, so killTree() falls back to the child handle.
258
+ ...(platform === 'win32' ? {} : { detached: true }),
259
+ });
260
+ }
261
+
262
+ const killTree = (child, signal) => killProcessTree(child, signal, { platform, processKillImpl });
263
+
264
+ /** One-shot subcommands (`logout`); the login session drives its own child. */
265
+ function runGrokCommand(args, { timeoutMs } = {}) {
266
+ return runProcessToCompletion(() => spawnGrokProcess(args), {
267
+ timeoutMs,
268
+ setTimeoutImpl,
269
+ clearTimeoutImpl,
270
+ killChild: killTree,
271
+ });
272
+ }
273
+
274
+ /**
275
+ * `ok` reports that the account state was *determined*, not that anyone is
276
+ * signed in — reading the auth store cannot fail in a way this module could
277
+ * report, so the only `ok:false` payload in the system is the route module's
278
+ * pre-first-read placeholder.
279
+ */
280
+ function loggedOutStatus() {
281
+ return {
282
+ ok: true,
283
+ loggedIn: false,
284
+ expiresAt: null,
285
+ expired: false,
286
+ plan: null,
287
+ usagePercent: null,
288
+ periodType: null,
289
+ periodEnd: null,
290
+ error: null,
291
+ checkedAt: new Date(now()).toISOString(),
292
+ };
293
+ }
294
+
295
+ async function fetchStatus() {
296
+ // readGrokCliAuthKey() never throws and cannot tell "no file" from
297
+ // "unreadable file" apart — both mean the relay has no usable Grok login,
298
+ // which is exactly what the account row needs to say.
299
+ let auth = null;
300
+ try {
301
+ auth = readAuthKeyImpl();
302
+ } catch (error) {
303
+ log(`auth store read failed: ${error?.message || error}`);
304
+ auth = null;
305
+ }
306
+ if (!auth?.key) return loggedOutStatus();
307
+
308
+ const expiresAt = toIso(auth.expiresAt);
309
+ // The CLI rotates the key every few hours and refreshes it in place, so an
310
+ // elapsed expiry is a "may need to sign in again" hint, not a logged-out
311
+ // verdict — the row still reads as signed in.
312
+ const expired = Boolean(expiresAt && Date.parse(expiresAt) <= now());
313
+
314
+ let billing = null;
315
+ if (!outboundProbesDisabled() && typeof fetchBillingImpl === 'function') {
316
+ try {
317
+ billing = await fetchBillingImpl();
318
+ } catch (error) {
319
+ // The fetcher already degrades to null; this only catches a broken seam.
320
+ log(`billing probe failed: ${error?.message || error}`);
321
+ billing = null;
322
+ }
323
+ }
324
+ const products = Array.isArray(billing?.products) ? billing.products : [];
325
+ return {
326
+ ok: true,
327
+ loggedIn: true,
328
+ expiresAt,
329
+ expired,
330
+ // The proxy payload names the *product* ("GrokBuild"), not the
331
+ // subscription tier — nothing the relay can read exposes the tier — so the
332
+ // label is offered only when there is exactly one product to name, and the
333
+ // UI composes the rest of the row from usagePercent/periodType.
334
+ plan: products.length === 1 ? (products[0]?.product || null) : null,
335
+ usagePercent: Number.isFinite(billing?.usagePercent) ? billing.usagePercent : null,
336
+ periodType: billing?.periodType || null,
337
+ periodEnd: billing?.periodEnd || null,
338
+ error: null,
339
+ checkedAt: new Date(now()).toISOString(),
340
+ };
341
+ }
342
+
343
+ async function getStatus({ force = false } = {}) {
344
+ if (!force && statusCache && !statusCache.stale && (now() - statusCache.at) < statusTtlMs) {
345
+ return statusCache.value;
346
+ }
347
+ // A non-forced caller is happy with whatever read is already running; a
348
+ // forced one is not (that read may have started before the login or logout
349
+ // it is meant to observe), so it queues its own behind it.
350
+ if (statusInFlight && !force) return statusInFlight;
351
+ const pending = statusChain
352
+ .then(() => fetchStatus())
353
+ .then((value) => {
354
+ statusCache = { at: now(), value, stale: false };
355
+ return value;
356
+ });
357
+ statusChain = pending.then(() => {}, () => {});
358
+ statusInFlight = pending;
359
+ pending.then(
360
+ () => { if (statusInFlight === pending) statusInFlight = null; },
361
+ () => { if (statusInFlight === pending) statusInFlight = null; },
362
+ );
363
+ return pending;
364
+ }
365
+
366
+ /** Last status read, without ever touching the auth store or the network. */
367
+ function getCachedStatus() {
368
+ return statusCache ? statusCache.value : null;
369
+ }
370
+
371
+ /**
372
+ * Keeps the last-known value readable (broadcasts render it) while forcing the
373
+ * next `getStatus()` to re-read the auth store.
374
+ */
375
+ function invalidateStatusCache() {
376
+ if (statusCache) statusCache.stale = true;
377
+ }
378
+
379
+ /**
380
+ * Drops the session's timers and, when it is still the current one, the
381
+ * module-level handle. A late `close` from an already-settled session must
382
+ * never tear down a newer session started in the meantime.
383
+ */
384
+ function releaseSession(owner = null) {
385
+ const current = owner || session;
386
+ if (!current) return;
387
+ if (current.timeoutTimer) clearTimeoutImpl(current.timeoutTimer);
388
+ current.timeoutTimer = null;
389
+ if (session === current) session = null;
390
+ }
391
+
392
+ function terminateSession(current, { signal = 'SIGTERM' } = {}) {
393
+ const child = current?.child;
394
+ if (!child) return;
395
+ killTree(child, signal);
396
+ // Deliberately not stored on the session: releaseSession() runs immediately
397
+ // after most terminate calls, so a session-owned timer would be cleared
398
+ // before it could escalate and a SIGTERM-ignoring CLI would live forever.
399
+ // Unref'd and closed over the child, so it is a no-op once the child exits.
400
+ const escalation = setTimeoutImpl(() => killTree(child, 'SIGKILL'), KILL_ESCALATION_MS);
401
+ escalation?.unref?.();
402
+ }
403
+
404
+ function failSession(current, message) {
405
+ if (!current || current.settled) return;
406
+ current.settled = true;
407
+ terminateSession(current);
408
+ const scrubbed = scrubSecrets(message);
409
+ releaseSession(current);
410
+ invalidateStatusCache();
411
+ setLoginState(LOGIN_STATES.ERROR, { error: scrubbed || 'Grok login failed' });
412
+ }
413
+
414
+ async function succeedSession(current) {
415
+ if (!current || current.settled) return;
416
+ current.settled = true;
417
+ releaseSession(current);
418
+ invalidateStatusCache();
419
+ // Success is published before the confirming status read: that read hits the
420
+ // billing proxy (up to its own timeout), and the UI must not sit on
421
+ // "awaiting authorization" while it runs.
422
+ const generation = loginGeneration;
423
+ setLoginState(LOGIN_STATES.SUCCESS, { error: '' });
424
+ try {
425
+ await getStatus({ force: true });
426
+ } catch (error) {
427
+ log(`status refresh after login failed: ${error?.message || error}`);
428
+ }
429
+ if (generation !== loginGeneration) {
430
+ // A new login (or a cancel) took over while the status read ran; its own
431
+ // outcome owns the state and the hooks now.
432
+ log('skipping post-login refresh broadcast: a newer login session took over');
433
+ return;
434
+ }
435
+ // Re-broadcast the unchanged login state so subscribers pick up the fresh
436
+ // status that just landed in the cache.
437
+ emitState();
438
+ for (const hook of [...successHooks]) {
439
+ try { await hook(); } catch (error) { log(`post-login hook failed: ${error?.message || error}`); }
440
+ }
441
+ }
442
+
443
+ function handleLoginOutput(current, chunk) {
444
+ const text = chunk.toString();
445
+ current.rawOutput = `${current.rawOutput}${text}`.slice(-MAX_CAPTURED_OUTPUT_CHARS);
446
+ // Nothing secret is ever printed by this flow — the device code is public by
447
+ // design and the token goes straight to disk — so the whole transcript is
448
+ // loggable, unlike the Claude login's post-exchange output.
449
+ const visible = stripTerminalEscapes(text).trim();
450
+ if (visible) log(`login: ${visible.replace(/\s+/g, ' ').slice(0, 300)}`);
451
+ if (current.settled) return;
452
+ if (login.authUrl && login.userCode) return;
453
+ const authUrl = login.authUrl || extractDeviceAuthUrl(current.rawOutput);
454
+ if (!authUrl) return;
455
+ const userCode = login.userCode || extractDeviceCode(current.rawOutput);
456
+ if (authUrl === login.authUrl && userCode === login.userCode) return;
457
+ setLoginState(LOGIN_STATES.AWAITING_AUTHORIZATION, { authUrl, userCode, error: '' });
458
+ }
459
+
460
+ function startLogin() {
461
+ if (session) {
462
+ // Idempotent: a second Sign in press (or a second browser tab) joins the
463
+ // session already in flight rather than requesting a second device code
464
+ // and leaving the user two codes to choose between.
465
+ return { ok: true, login: loginSnapshot(), reused: true };
466
+ }
467
+ const startedAt = new Date(now()).toISOString();
468
+ let child = null;
469
+ try {
470
+ child = spawnGrokProcess(['login', '--device-auth']);
471
+ } catch (error) {
472
+ loginGeneration += 1;
473
+ setLoginState(LOGIN_STATES.ERROR, {
474
+ authUrl: '',
475
+ userCode: '',
476
+ error: scrubSecrets(error?.message || String(error)),
477
+ });
478
+ return { ok: false, error: login.error, login: loginSnapshot() };
479
+ }
480
+ const current = {
481
+ child,
482
+ rawOutput: '',
483
+ settled: false,
484
+ timeoutTimer: null,
485
+ };
486
+ session = current;
487
+ loginGeneration += 1;
488
+ login.startedAt = startedAt;
489
+ setLoginState(LOGIN_STATES.STARTING, { authUrl: '', userCode: '', error: '' });
490
+
491
+ current.timeoutTimer = setTimeoutImpl(() => {
492
+ failSession(current, `Grok login timed out after ${Math.round(loginTimeoutMs / 1000)}s`);
493
+ }, loginTimeoutMs);
494
+ current.timeoutTimer?.unref?.();
495
+
496
+ child.stdout?.on?.('data', (chunk) => handleLoginOutput(current, chunk));
497
+ child.stderr?.on?.('data', (chunk) => handleLoginOutput(current, chunk));
498
+ child.on('error', (error) => failSession(current, error?.message || String(error)));
499
+ child.on('close', (code) => {
500
+ if (current.settled) {
501
+ releaseSession(current);
502
+ return;
503
+ }
504
+ // The CLI polls x.ai itself and exits 0 only once the browser has
505
+ // authorised, so the exit code *is* the outcome — there is no submitted
506
+ // code to cross-check the way the Claude flow has to.
507
+ if (code === 0) {
508
+ void succeedSession(current);
509
+ return;
510
+ }
511
+ const tail = tailOf(stripTerminalEscapes(current.rawOutput));
512
+ failSession(current, tail
513
+ ? `Grok login exited with code ${code}: ${tail}`
514
+ : `Grok login exited with code ${code}`);
515
+ });
516
+ return { ok: true, login: loginSnapshot(), reused: false };
517
+ }
518
+
519
+ function cancel() {
520
+ const current = session;
521
+ loginGeneration += 1;
522
+ if (current) {
523
+ current.settled = true;
524
+ terminateSession(current);
525
+ releaseSession(current);
526
+ }
527
+ // Also the "dismiss the terminal state" path for the UI. Aborting mid-poll
528
+ // is safe: the probe confirmed `~/.grok/auth.json` is still absent after a
529
+ // SIGINT, so a cancelled login leaves no half-written credential behind.
530
+ login.startedAt = null;
531
+ setLoginState(LOGIN_STATES.IDLE, { authUrl: '', userCode: '', error: '' });
532
+ return { ok: true, login: loginSnapshot() };
533
+ }
534
+
535
+ async function logout() {
536
+ if (session) {
537
+ return { ok: false, statusCode: 409, error: 'A Grok login is in progress; cancel it first' };
538
+ }
539
+ const result = await runGrokCommand(['logout'], { timeoutMs: logoutTimeoutMs });
540
+ const visible = stripTerminalEscapes(result.output);
541
+ invalidateStatusCache();
542
+ const status = await getStatus({ force: true });
543
+ if (!result.ok) {
544
+ return {
545
+ ok: false,
546
+ statusCode: 500,
547
+ error: scrubSecrets(result.error || tailOf(visible) || `grok logout exited with code ${result.code}`),
548
+ status,
549
+ };
550
+ }
551
+ login.startedAt = null;
552
+ // The status cache is fresh at this point, so the IDLE broadcast subscribers
553
+ // build carries the post-logout account state without a second read.
554
+ setLoginState(LOGIN_STATES.IDLE, { authUrl: '', userCode: '', error: '' });
555
+ return { ok: true, status };
556
+ }
557
+
558
+ function subscribe(listener) {
559
+ if (typeof listener !== 'function') return () => {};
560
+ listeners.add(listener);
561
+ return () => listeners.delete(listener);
562
+ }
563
+
564
+ function onLoginSuccess(hook) {
565
+ if (typeof hook !== 'function') return () => {};
566
+ successHooks.add(hook);
567
+ return () => successHooks.delete(hook);
568
+ }
569
+
570
+ function dispose() {
571
+ const current = session;
572
+ if (current) {
573
+ current.settled = true;
574
+ loginGeneration += 1;
575
+ terminateSession(current);
576
+ releaseSession(current);
577
+ }
578
+ listeners.clear();
579
+ successHooks.clear();
580
+ }
581
+
582
+ return {
583
+ getStatus,
584
+ getCachedStatus,
585
+ getLoginState: loginSnapshot,
586
+ startLogin,
587
+ cancel,
588
+ logout,
589
+ subscribe,
590
+ onLoginSuccess,
591
+ dispose,
592
+ };
593
+ }