@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.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- 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
|
+
}
|