@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,578 @@
1
+ 'use strict';
2
+ import { spawn } from 'child_process';
3
+
4
+ // The mechanical half of this module now lives in cli-process-runner.mjs, so
5
+ // the CLI install service and the Grok auth service share one implementation
6
+ // of it rather than each growing its own. The four names this module has
7
+ // always exported are re-exported below: every importer and test of this path
8
+ // keeps working, and the state machine underneath is unchanged.
9
+ import {
10
+ CLI_SPAWN_DISABLED_ERROR,
11
+ MAX_CAPTURED_OUTPUT_CHARS,
12
+ joinWrappedLines,
13
+ killTree as killProcessTree,
14
+ runToCompletion as runProcessToCompletion,
15
+ scrubSecrets,
16
+ stripTerminalEscapes,
17
+ tailOf,
18
+ } from './cli-process-runner.mjs';
19
+
20
+ export {
21
+ CLI_SPAWN_DISABLED_ERROR,
22
+ joinWrappedLines,
23
+ scrubSecrets,
24
+ stripTerminalEscapes,
25
+ };
26
+
27
+ /**
28
+ * Claude CLI auth (status / login / logout) driven from the relay.
29
+ *
30
+ * The CLI's `auth login` prints an OAuth authorize URL and then blocks reading
31
+ * the pasted code from a TTY, so it runs under the same `script` pseudo-TTY
32
+ * trick the Copilot worker uses (session-worker-launch-service.mjs:407);
33
+ * `script` forwards our pipe stdin into the PTY, so writing `code + "\n"` to
34
+ * the child's stdin reaches the prompt.
35
+ *
36
+ * One login session at a time, relay-wide:
37
+ *
38
+ * idle -> starting -> awaiting_code -> exchanging -> success
39
+ * | |
40
+ * +-- cancel/timeout, error (rejected code / dead
41
+ * process) --> session released
42
+ *
43
+ * `success` and `error` are terminal *display* states: the session is already
44
+ * released (a new `startLogin()` may begin, `logout()` is allowed again) but the
45
+ * outcome stays readable until the next start or `cancel()` resets it to idle.
46
+ */
47
+
48
+ export const CLAUDE_AUTH_LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
49
+ export const CLAUDE_AUTH_STATUS_TTL_MS = 5_000;
50
+ const CLAUDE_AUTH_STATUS_TIMEOUT_MS = 20_000;
51
+ const CLAUDE_AUTH_LOGOUT_TIMEOUT_MS = 60_000;
52
+ const KILL_ESCALATION_MS = 2_000;
53
+ const MAX_CODE_LENGTH = 4_096;
54
+
55
+ // The authorize URL has moved hosts across CLI releases (claude.com today,
56
+ // claude.ai and console.anthropic.com in older/enterprise builds), so the host
57
+ // is matched as a family rather than pinned to one domain.
58
+ const AUTH_URL_PATTERN = /https:\/\/(?:[A-Za-z0-9-]+\.)*(?:claude\.(?:com|ai)|anthropic\.com)\/\S*/;
59
+ // OSC-8 hyperlink: ESC ] 8 ; ; <target> (BEL | ESC \)
60
+ const OSC8_PATTERN = /\x1b\]8;;(.*?)(?:\x07|\x1b\\)/g;
61
+ const PROMPT_SENTINEL = 'Paste code here';
62
+
63
+ const LOGIN_STATES = Object.freeze({
64
+ IDLE: 'idle',
65
+ STARTING: 'starting',
66
+ AWAITING_CODE: 'awaiting_code',
67
+ EXCHANGING: 'exchanging',
68
+ SUCCESS: 'success',
69
+ ERROR: 'error',
70
+ });
71
+
72
+ export const CLAUDE_AUTH_LOGIN_STATES = LOGIN_STATES;
73
+
74
+ function normalizeText(value) {
75
+ return String(value == null ? '' : value).trim();
76
+ }
77
+
78
+ /**
79
+ * Pulls the OAuth authorize URL out of raw CLI output.
80
+ *
81
+ * The OSC-8 hyperlink *target* is preferred over the visible label: the label is
82
+ * what a terminal would wrap or truncate, the target is the real href. Falls
83
+ * back to matching the escape-stripped text so a CLI release that drops the
84
+ * hyperlink wrapper still parses (risk row 1 of the plan: degrade, never break),
85
+ * rejoining a hard-wrapped URL when the visible match runs into a line break.
86
+ */
87
+ export function extractAuthUrl(rawOutput) {
88
+ const raw = String(rawOutput == null ? '' : rawOutput);
89
+ for (const match of raw.matchAll(OSC8_PATTERN)) {
90
+ const target = normalizeText(match[1]);
91
+ const hit = target.match(AUTH_URL_PATTERN);
92
+ if (hit) return hit[0];
93
+ }
94
+ const visible = stripTerminalEscapes(raw);
95
+ const direct = visible.match(AUTH_URL_PATTERN);
96
+ if (!direct) return '';
97
+ const endedAtLineBreak = visible[direct.index + direct[0].length] === '\n';
98
+ if (!endedAtLineBreak) return direct[0];
99
+ const rejoined = joinWrappedLines(visible).match(AUTH_URL_PATTERN);
100
+ return rejoined && rejoined[0].length > direct[0].length ? rejoined[0] : direct[0];
101
+ }
102
+
103
+ export function hasCodePrompt(rawOutput) {
104
+ return stripTerminalEscapes(rawOutput).includes(PROMPT_SENTINEL);
105
+ }
106
+
107
+ function shellQuote(value) {
108
+ return `'${String(value == null ? '' : value).replace(/'/g, `'\\''`)}'`;
109
+ }
110
+
111
+ function parseAuthStatusJson(stdout) {
112
+ const text = stripTerminalEscapes(stdout).trim();
113
+ if (!text) return null;
114
+ // Tolerate leading noise (npm shim banners etc.) by taking the last JSON
115
+ // object in the output.
116
+ const start = text.indexOf('{');
117
+ const end = text.lastIndexOf('}');
118
+ if (start < 0 || end <= start) return null;
119
+ try {
120
+ return JSON.parse(text.slice(start, end + 1));
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function normalizeStatusPayload(parsed) {
127
+ return {
128
+ loggedIn: parsed?.loggedIn === true,
129
+ authMethod: normalizeText(parsed?.authMethod) || null,
130
+ apiProvider: normalizeText(parsed?.apiProvider) || null,
131
+ email: normalizeText(parsed?.email) || null,
132
+ orgId: normalizeText(parsed?.orgId) || null,
133
+ orgName: normalizeText(parsed?.orgName) || null,
134
+ subscriptionType: normalizeText(parsed?.subscriptionType) || null,
135
+ };
136
+ }
137
+
138
+ export function resolveClaudeAuthBinary(env = process.env) {
139
+ return normalizeText(env?.COPILOT_WEB_RELAY_CLAUDE_AUTH_BIN) || 'claude';
140
+ }
141
+
142
+ export function createClaudeAuthService({
143
+ env = process.env,
144
+ platform = process.platform,
145
+ spawnImpl = spawn,
146
+ processKillImpl = process.kill.bind(process),
147
+ setTimeoutImpl = setTimeout,
148
+ clearTimeoutImpl = clearTimeout,
149
+ now = () => Date.now(),
150
+ logger = console,
151
+ loginTimeoutMs = CLAUDE_AUTH_LOGIN_TIMEOUT_MS,
152
+ statusTtlMs = CLAUDE_AUTH_STATUS_TTL_MS,
153
+ statusTimeoutMs = CLAUDE_AUTH_STATUS_TIMEOUT_MS,
154
+ logoutTimeoutMs = CLAUDE_AUTH_LOGOUT_TIMEOUT_MS,
155
+ } = {}) {
156
+ const listeners = new Set();
157
+ const successHooks = new Set();
158
+ const login = {
159
+ state: LOGIN_STATES.IDLE,
160
+ authUrl: '',
161
+ error: '',
162
+ startedAt: null,
163
+ };
164
+ let session = null;
165
+ // Bumped by every startLogin/cancel so a slow post-success continuation can
166
+ // tell whether it still owns the visible login state.
167
+ let loginGeneration = 0;
168
+ let statusCache = null;
169
+ let statusInFlight = null;
170
+ // Serialises status spawns: a forced read never joins an older in-flight
171
+ // probe (it could predate the login/logout that prompted the force), but it
172
+ // waits for it rather than running two `claude auth status` at once.
173
+ let statusChain = Promise.resolve();
174
+
175
+ const log = (message) => {
176
+ try { logger?.log?.(`[claude-auth] ${message}`); } catch {}
177
+ };
178
+
179
+ function loginSnapshot() {
180
+ return {
181
+ state: login.state,
182
+ authUrl: login.authUrl || null,
183
+ error: login.error || null,
184
+ startedAt: login.startedAt || null,
185
+ active: Boolean(session),
186
+ };
187
+ }
188
+
189
+ function emitState() {
190
+ const snapshot = loginSnapshot();
191
+ for (const listener of [...listeners]) {
192
+ try { listener(snapshot); } catch (error) { log(`listener failed: ${error?.message || error}`); }
193
+ }
194
+ }
195
+
196
+ function setLoginState(nextState, { authUrl, error } = {}) {
197
+ login.state = nextState;
198
+ if (authUrl !== undefined) login.authUrl = authUrl || '';
199
+ if (error !== undefined) login.error = error || '';
200
+ emitState();
201
+ }
202
+
203
+ function buildSpawnEnv() {
204
+ // A plain copy of the relay environment: CLAUDE_CONFIG_DIR (and HOME) come
205
+ // along, so status/login/logout all address the same config root the
206
+ // workers read.
207
+ return { ...env };
208
+ }
209
+
210
+ function cliSpawnsDisabled() {
211
+ // Same kill switch (and same truthiness rule) as
212
+ // session-worker-launch-service.mjs: a relay started with it must never run
213
+ // a real CLI — including `claude auth logout`, which would sign the
214
+ // developer's own host out.
215
+ if (!String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim()) return false;
216
+ // Narrow escape hatch for the e2e harness, which keeps the kill switch on
217
+ // (no real Copilot/Claude workers) but still drives the whole auth flow.
218
+ // Both halves are required: the opt-in flag AND an explicit binary override,
219
+ // so "spawns disabled" can never end up running the host's real `claude`.
220
+ return !(
221
+ String(env?.COPILOT_WEB_RELAY_CLAUDE_AUTH_ALLOW_STUB_SPAWN || '').trim()
222
+ && String(env?.COPILOT_WEB_RELAY_CLAUDE_AUTH_BIN || '').trim()
223
+ );
224
+ }
225
+
226
+ /**
227
+ * The single spawn site for every auth subcommand.
228
+ *
229
+ * POSIX login/logout run under a pseudo-TTY (`script -qec "<cmd>" /dev/null`)
230
+ * with piped stdio, detached so the whole process group can be signalled. On
231
+ * Windows there is no `script`, so the binary is spawned directly (best
232
+ * effort — the auth flow is a POSIX-host feature). `auth status --json` needs
233
+ * no TTY and runs without one.
234
+ */
235
+ function spawnAuthProcess(args, { pty = true, stdin = 'pipe' } = {}) {
236
+ if (cliSpawnsDisabled()) throw new Error(CLI_SPAWN_DISABLED_ERROR);
237
+ const bin = resolveClaudeAuthBinary(env);
238
+ const child = (!pty || platform === 'win32')
239
+ ? spawnImpl(bin, args, {
240
+ stdio: [stdin, 'pipe', 'pipe'],
241
+ env: buildSpawnEnv(),
242
+ windowsHide: true,
243
+ })
244
+ : spawnImpl('script', ['-qec', [bin, ...args].map(shellQuote).join(' '), '/dev/null'], {
245
+ stdio: [stdin, 'pipe', 'pipe'],
246
+ env: buildSpawnEnv(),
247
+ detached: true,
248
+ });
249
+ // stdin errors (EPIPE when the CLI exits mid-write) arrive asynchronously
250
+ // as 'error' events; without a listener Node turns them into an unhandled
251
+ // error that would take the relay down.
252
+ child?.stdin?.on?.('error', (error) => {
253
+ log(`child stdin error: ${error?.message || error}`);
254
+ });
255
+ return child;
256
+ }
257
+
258
+ function killTree(child, signal) {
259
+ killProcessTree(child, signal, { platform, processKillImpl });
260
+ }
261
+
262
+ function runToCompletion(args, { timeoutMs, usePty = true } = {}) {
263
+ return runProcessToCompletion(
264
+ () => spawnAuthProcess(args, { pty: usePty, stdin: usePty ? 'pipe' : 'ignore' }),
265
+ {
266
+ timeoutMs,
267
+ setTimeoutImpl,
268
+ clearTimeoutImpl,
269
+ killChild: killTree,
270
+ maxOutputChars: MAX_CAPTURED_OUTPUT_CHARS,
271
+ },
272
+ );
273
+ }
274
+
275
+ async function fetchStatus() {
276
+ const result = await runToCompletion(['auth', 'status', '--json'], {
277
+ timeoutMs: statusTimeoutMs,
278
+ usePty: false,
279
+ });
280
+ const parsed = parseAuthStatusJson(result.output);
281
+ if (!parsed) {
282
+ return {
283
+ ok: false,
284
+ ...normalizeStatusPayload(null),
285
+ error: result.error
286
+ || tailOf(stripTerminalEscapes(result.output))
287
+ || `claude auth status exited with code ${result.code}`,
288
+ checkedAt: new Date(now()).toISOString(),
289
+ };
290
+ }
291
+ return {
292
+ ok: true,
293
+ ...normalizeStatusPayload(parsed),
294
+ error: null,
295
+ checkedAt: new Date(now()).toISOString(),
296
+ };
297
+ }
298
+
299
+ async function getStatus({ force = false } = {}) {
300
+ if (!force && statusCache && !statusCache.stale && (now() - statusCache.at) < statusTtlMs) {
301
+ return statusCache.value;
302
+ }
303
+ // A non-forced caller is happy with whatever probe is already running; a
304
+ // forced one is not (that probe may have started before the login or logout
305
+ // it is meant to observe), so it queues its own behind it.
306
+ if (statusInFlight && !force) return statusInFlight;
307
+ const pending = statusChain
308
+ .then(() => fetchStatus())
309
+ .then((value) => {
310
+ statusCache = { at: now(), value, stale: false };
311
+ return value;
312
+ });
313
+ statusChain = pending.then(() => {}, () => {});
314
+ statusInFlight = pending;
315
+ pending.then(
316
+ () => { if (statusInFlight === pending) statusInFlight = null; },
317
+ () => { if (statusInFlight === pending) statusInFlight = null; },
318
+ );
319
+ return pending;
320
+ }
321
+
322
+ /** Last status the CLI reported, without ever spawning. */
323
+ function getCachedStatus() {
324
+ return statusCache ? statusCache.value : null;
325
+ }
326
+
327
+ /**
328
+ * Keeps the last-known value readable (broadcasts render it) while forcing
329
+ * the next `getStatus()` to re-read the CLI.
330
+ */
331
+ function invalidateStatusCache() {
332
+ if (statusCache) statusCache.stale = true;
333
+ }
334
+
335
+ /**
336
+ * Drops the session's timers and, when it is still the current one, the
337
+ * module-level handle. A late `close` from an already-settled session must
338
+ * never tear down a newer session started in the meantime.
339
+ */
340
+ function releaseSession(owner = null) {
341
+ const current = owner || session;
342
+ if (!current) return;
343
+ if (current.timeoutTimer) clearTimeoutImpl(current.timeoutTimer);
344
+ current.timeoutTimer = null;
345
+ if (session === current) session = null;
346
+ }
347
+
348
+ function terminateSession(current, { signal = 'SIGTERM' } = {}) {
349
+ const child = current?.child;
350
+ if (!child) return;
351
+ killTree(child, signal);
352
+ // Deliberately not stored on the session: releaseSession() runs immediately
353
+ // after most terminate calls, so a session-owned timer would be cleared
354
+ // before it could escalate and a SIGTERM-ignoring CLI would live forever.
355
+ // Unref'd and closed over the child, so it is a no-op once the child exits.
356
+ const escalation = setTimeoutImpl(() => killTree(child, 'SIGKILL'), KILL_ESCALATION_MS);
357
+ escalation?.unref?.();
358
+ }
359
+
360
+ function failSession(current, message) {
361
+ if (!current || current.settled) return;
362
+ current.settled = true;
363
+ terminateSession(current);
364
+ const scrubbed = scrubSecrets(message, current.submittedCode);
365
+ releaseSession(current);
366
+ invalidateStatusCache();
367
+ setLoginState(LOGIN_STATES.ERROR, { error: scrubbed || 'Claude login failed' });
368
+ }
369
+
370
+ async function succeedSession(current) {
371
+ if (!current || current.settled) return;
372
+ current.settled = true;
373
+ releaseSession(current);
374
+ invalidateStatusCache();
375
+ // Success is published before the confirming status read: that read spawns
376
+ // the CLI again (up to statusTimeoutMs), and the UI must not sit on
377
+ // "exchanging" while it runs.
378
+ const generation = loginGeneration;
379
+ setLoginState(LOGIN_STATES.SUCCESS, { error: '' });
380
+ try {
381
+ await getStatus({ force: true });
382
+ } catch (error) {
383
+ log(`status refresh after login failed: ${error?.message || error}`);
384
+ }
385
+ if (generation !== loginGeneration) {
386
+ // A new login (or a cancel) took over while the status read ran; its own
387
+ // outcome owns the state and the hooks now.
388
+ log('skipping post-login refresh broadcast: a newer login session took over');
389
+ return;
390
+ }
391
+ // Re-broadcast the unchanged login state so subscribers pick up the fresh
392
+ // status that just landed in the cache.
393
+ emitState();
394
+ for (const hook of [...successHooks]) {
395
+ try { await hook(); } catch (error) { log(`post-login hook failed: ${error?.message || error}`); }
396
+ }
397
+ }
398
+
399
+ function handleLoginOutput(current, chunk) {
400
+ const text = chunk.toString();
401
+ current.rawOutput = `${current.rawOutput}${text}`.slice(-MAX_CAPTURED_OUTPUT_CHARS);
402
+ // Once the code has been written to stdin nothing more from this process is
403
+ // logged: the exchange output can echo credential material.
404
+ if (!current.submittedCode) {
405
+ const visible = stripTerminalEscapes(text).trim();
406
+ if (visible) log(`login: ${visible.replace(/\s+/g, ' ').slice(0, 300)}`);
407
+ }
408
+ if (current.settled) return;
409
+ if (login.authUrl) return;
410
+ const url = extractAuthUrl(current.rawOutput);
411
+ if (url) setLoginState(LOGIN_STATES.AWAITING_CODE, { authUrl: url, error: '' });
412
+ }
413
+
414
+ function startLogin() {
415
+ if (session) {
416
+ // Idempotent: a second Relogin press (or a second browser tab) joins the
417
+ // session already in flight rather than racing a new PKCE challenge.
418
+ return { ok: true, login: loginSnapshot(), reused: true };
419
+ }
420
+ const startedAt = new Date(now()).toISOString();
421
+ let child = null;
422
+ try {
423
+ child = spawnAuthProcess(['auth', 'login']);
424
+ } catch (error) {
425
+ loginGeneration += 1;
426
+ setLoginState(LOGIN_STATES.ERROR, {
427
+ authUrl: '',
428
+ error: scrubSecrets(error?.message || String(error)),
429
+ });
430
+ return { ok: false, error: login.error, login: loginSnapshot() };
431
+ }
432
+ const current = {
433
+ child,
434
+ rawOutput: '',
435
+ submittedCode: '',
436
+ settled: false,
437
+ timeoutTimer: null,
438
+ };
439
+ session = current;
440
+ loginGeneration += 1;
441
+ login.startedAt = startedAt;
442
+ setLoginState(LOGIN_STATES.STARTING, { authUrl: '', error: '' });
443
+
444
+ current.timeoutTimer = setTimeoutImpl(() => {
445
+ failSession(current, `Claude login timed out after ${Math.round(loginTimeoutMs / 1000)}s`);
446
+ }, loginTimeoutMs);
447
+ current.timeoutTimer?.unref?.();
448
+
449
+ child.stdout?.on?.('data', (chunk) => handleLoginOutput(current, chunk));
450
+ child.stderr?.on?.('data', (chunk) => handleLoginOutput(current, chunk));
451
+ child.on('error', (error) => failSession(current, error?.message || String(error)));
452
+ child.on('close', (code) => {
453
+ if (current.settled) {
454
+ releaseSession(current);
455
+ return;
456
+ }
457
+ if (code === 0 && current.submittedCode) {
458
+ void succeedSession(current);
459
+ return;
460
+ }
461
+ const tail = tailOf(stripTerminalEscapes(current.rawOutput));
462
+ failSession(current, current.submittedCode
463
+ ? (tail || `Claude login exited with code ${code}`)
464
+ : `Claude login exited before a code was submitted (code ${code})${tail ? `: ${tail}` : ''}`);
465
+ });
466
+ return { ok: true, login: loginSnapshot(), reused: false };
467
+ }
468
+
469
+ function submitCode(code) {
470
+ const current = session;
471
+ if (!current) {
472
+ return { ok: false, statusCode: 409, error: 'No Claude login is in progress', login: loginSnapshot() };
473
+ }
474
+ if (login.state !== LOGIN_STATES.AWAITING_CODE) {
475
+ return {
476
+ ok: false,
477
+ statusCode: 409,
478
+ error: `Claude login is not waiting for a code (state: ${login.state})`,
479
+ login: loginSnapshot(),
480
+ };
481
+ }
482
+ const trimmed = normalizeText(code);
483
+ if (!trimmed) {
484
+ return { ok: false, statusCode: 400, error: 'Missing code', login: loginSnapshot() };
485
+ }
486
+ if (trimmed.length > MAX_CODE_LENGTH || /[\r\n]/.test(trimmed)) {
487
+ return { ok: false, statusCode: 400, error: 'Invalid code', login: loginSnapshot() };
488
+ }
489
+ current.submittedCode = trimmed;
490
+ try {
491
+ current.child.stdin.write(`${trimmed}\n`);
492
+ } catch (error) {
493
+ failSession(current, `Failed to hand the code to the Claude CLI: ${error?.message || error}`);
494
+ return { ok: false, statusCode: 500, error: login.error, login: loginSnapshot() };
495
+ }
496
+ setLoginState(LOGIN_STATES.EXCHANGING, { error: '' });
497
+ return { ok: true, login: loginSnapshot() };
498
+ }
499
+
500
+ function cancel() {
501
+ const current = session;
502
+ loginGeneration += 1;
503
+ if (current) {
504
+ current.settled = true;
505
+ terminateSession(current);
506
+ releaseSession(current);
507
+ }
508
+ // Also the "dismiss the terminal state" path for the UI.
509
+ login.startedAt = null;
510
+ setLoginState(LOGIN_STATES.IDLE, { authUrl: '', error: '' });
511
+ return { ok: true, login: loginSnapshot() };
512
+ }
513
+
514
+ async function logout() {
515
+ if (session) {
516
+ return { ok: false, statusCode: 409, error: 'A Claude login is in progress; cancel it first' };
517
+ }
518
+ // Same PTY harness as login: `auth logout` was never probed live, so treat
519
+ // it as possibly-interactive and capture whatever it prints.
520
+ const result = await runToCompletion(['auth', 'logout'], { timeoutMs: logoutTimeoutMs });
521
+ const visible = stripTerminalEscapes(result.output);
522
+ if (hasCodePrompt(visible) || /\(y\/n\)|\[y\/N\]/i.test(visible)) {
523
+ log(`logout produced an unexpected prompt: ${tailOf(visible)}`);
524
+ }
525
+ invalidateStatusCache();
526
+ const status = await getStatus({ force: true });
527
+ if (!result.ok) {
528
+ return {
529
+ ok: false,
530
+ statusCode: 500,
531
+ error: scrubSecrets(result.error || tailOf(visible) || `claude auth logout exited with code ${result.code}`),
532
+ status,
533
+ };
534
+ }
535
+ login.startedAt = null;
536
+ // The status cache is fresh at this point, so the IDLE broadcast subscribers
537
+ // build carries the post-logout account state without another spawn.
538
+ setLoginState(LOGIN_STATES.IDLE, { authUrl: '', error: '' });
539
+ return { ok: true, status };
540
+ }
541
+
542
+ function subscribe(listener) {
543
+ if (typeof listener !== 'function') return () => {};
544
+ listeners.add(listener);
545
+ return () => listeners.delete(listener);
546
+ }
547
+
548
+ function onLoginSuccess(hook) {
549
+ if (typeof hook !== 'function') return () => {};
550
+ successHooks.add(hook);
551
+ return () => successHooks.delete(hook);
552
+ }
553
+
554
+ function dispose() {
555
+ const current = session;
556
+ if (current) {
557
+ current.settled = true;
558
+ loginGeneration += 1;
559
+ terminateSession(current);
560
+ releaseSession(current);
561
+ }
562
+ listeners.clear();
563
+ successHooks.clear();
564
+ }
565
+
566
+ return {
567
+ getStatus,
568
+ getCachedStatus,
569
+ getLoginState: loginSnapshot,
570
+ startLogin,
571
+ submitCode,
572
+ cancel,
573
+ logout,
574
+ subscribe,
575
+ onLoginSuccess,
576
+ dispose,
577
+ };
578
+ }