@phuetz/code-buddy 1.1.0 → 1.3.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/README.md +211 -163
- package/dist/agent/autonomous/agentic-coding-contract.d.ts +12 -12
- package/dist/agent/base-agent.d.ts +4 -0
- package/dist/agent/base-agent.js +6 -0
- package/dist/agent/codebuddy-agent.d.ts +1 -1
- package/dist/agent/codebuddy-agent.js +69 -14
- package/dist/agent/execution/agent-executor.js +122 -12
- package/dist/agent/execution/tool-dependency-graph.js +7 -0
- package/dist/agent/execution/tool-hooks.d.ts +6 -0
- package/dist/agent/execution/tool-hooks.js +13 -5
- package/dist/agent/execution/tool-selection-strategy.d.ts +11 -2
- package/dist/agent/execution/tool-selection-strategy.js +67 -10
- package/dist/agent/extended-thinking.d.ts +8 -0
- package/dist/agent/extended-thinking.js +27 -0
- package/dist/agent/facades/infrastructure-facade.d.ts +9 -2
- package/dist/agent/facades/infrastructure-facade.js +15 -6
- package/dist/agent/hermes-browser-backends.js +151 -11
- package/dist/agent/hermes-claw-migrate.d.ts +59 -0
- package/dist/agent/hermes-claw-migrate.js +427 -26
- package/dist/agent/hermes-parity-manifest.js +78 -71
- package/dist/agent/hermes-runtime-lifecycle.d.ts +67 -0
- package/dist/agent/hermes-runtime-lifecycle.js +468 -0
- package/dist/agent/middleware/index.d.ts +1 -0
- package/dist/agent/middleware/index.js +1 -0
- package/dist/agent/middleware/visual-validation-middleware.d.ts +17 -0
- package/dist/agent/middleware/visual-validation-middleware.js +67 -0
- package/dist/agent/model-benchmark.d.ts +77 -0
- package/dist/agent/model-benchmark.js +309 -0
- package/dist/agent/model-tier.d.ts +14 -0
- package/dist/agent/model-tier.js +71 -0
- package/dist/agent/multi-agent/agents/orchestrator-agent.js +17 -6
- package/dist/agent/multi-agent/multi-agent-system.js +3 -1
- package/dist/agent/multi-agent/session-fleet-bridge.d.ts +2 -0
- package/dist/agent/multi-agent/session-fleet-bridge.js +9 -1
- package/dist/agent/multi-agent/session-tools.js +22 -5
- package/dist/agent/reasoning/mcts.js +20 -6
- package/dist/agent/self-improvement/authored-artifact-gate.d.ts +18 -0
- package/dist/agent/self-improvement/authored-artifact-gate.js +42 -0
- package/dist/agent/self-improvement/authored-tool-runtime.d.ts +27 -0
- package/dist/agent/self-improvement/authored-tool-runtime.js +57 -0
- package/dist/agent/self-improvement/authored-tool-store.d.ts +24 -0
- package/dist/agent/self-improvement/authored-tool-store.js +57 -0
- package/dist/agent/self-improvement/llm-tool-proposer.d.ts +41 -0
- package/dist/agent/self-improvement/llm-tool-proposer.js +136 -0
- package/dist/agent/self-improvement/sandbox-scorer.d.ts +17 -0
- package/dist/agent/self-improvement/sandbox-scorer.js +43 -0
- package/dist/agent/self-improvement/self-knowledge.d.ts +8 -0
- package/dist/agent/self-improvement/self-knowledge.js +24 -0
- package/dist/agent/self-improvement/skill-benchmark.d.ts +9 -0
- package/dist/agent/self-improvement/skill-benchmark.js +22 -0
- package/dist/agent/self-improvement/skill-consolidator.d.ts +71 -0
- package/dist/agent/self-improvement/skill-consolidator.js +137 -0
- package/dist/agent/self-improvement/skill-engine.d.ts +42 -0
- package/dist/agent/self-improvement/skill-engine.js +87 -0
- package/dist/agent/self-improvement/skill-gate.d.ts +19 -0
- package/dist/agent/self-improvement/skill-gate.js +62 -0
- package/dist/agent/self-improvement/skill-mutator.d.ts +74 -0
- package/dist/agent/self-improvement/skill-mutator.js +223 -0
- package/dist/agent/self-improvement/skill-proposer.d.ts +40 -0
- package/dist/agent/self-improvement/skill-proposer.js +82 -0
- package/dist/agent/self-improvement/skill-types.d.ts +41 -0
- package/dist/agent/self-improvement/skill-types.js +13 -0
- package/dist/agent/self-improvement/tool-benchmark.d.ts +10 -0
- package/dist/agent/self-improvement/tool-benchmark.js +37 -0
- package/dist/agent/self-improvement/tool-engine.d.ts +54 -0
- package/dist/agent/self-improvement/tool-engine.js +101 -0
- package/dist/agent/self-improvement/tool-gate.d.ts +20 -0
- package/dist/agent/self-improvement/tool-gate.js +78 -0
- package/dist/agent/self-improvement/tool-proposer.d.ts +31 -0
- package/dist/agent/self-improvement/tool-proposer.js +34 -0
- package/dist/agent/self-improvement/tool-skill-mutator.d.ts +40 -0
- package/dist/agent/self-improvement/tool-skill-mutator.js +79 -0
- package/dist/agent/self-improvement/tool-types.d.ts +48 -0
- package/dist/agent/self-improvement/tool-types.js +9 -0
- package/dist/agent/self-improvement/types.d.ts +3 -1
- package/dist/agent/session-end-flush.d.ts +6 -1
- package/dist/agent/session-end-flush.js +20 -4
- package/dist/agent/streaming/streaming-handler.d.ts +7 -0
- package/dist/agent/streaming/streaming-handler.js +8 -1
- package/dist/agent/tool-executor.js +1 -0
- package/dist/agent/tool-handler.d.ts +5 -0
- package/dist/agent/tool-handler.js +133 -4
- package/dist/browser-automation/browser-operator-executor.js +15 -0
- package/dist/browser-automation/browser-use-runner.d.ts +96 -0
- package/dist/browser-automation/browser-use-runner.js +492 -0
- package/dist/browser-automation/camofox-runner.d.ts +107 -0
- package/dist/browser-automation/camofox-runner.js +287 -0
- package/dist/channels/dingtalk/index.js +3 -0
- package/dist/channels/discord/client.d.ts +9 -0
- package/dist/channels/discord/client.js +12 -0
- package/dist/channels/feishu/index.d.ts +167 -1
- package/dist/channels/feishu/index.js +432 -7
- package/dist/channels/gateway-lifecycle.d.ts +132 -0
- package/dist/channels/gateway-lifecycle.js +219 -0
- package/dist/channels/google-chat/index.d.ts +5 -1
- package/dist/channels/google-chat/index.js +5 -1
- package/dist/channels/imessage/index.d.ts +14 -0
- package/dist/channels/imessage/index.js +64 -17
- package/dist/channels/index.d.ts +4 -0
- package/dist/channels/index.js +4 -0
- package/dist/channels/irc/index.d.ts +73 -3
- package/dist/channels/irc/index.js +446 -11
- package/dist/channels/line/index.js +3 -0
- package/dist/channels/mattermost/index.d.ts +51 -4
- package/dist/channels/mattermost/index.js +303 -20
- package/dist/channels/nextcloud-talk/index.d.ts +89 -8
- package/dist/channels/nextcloud-talk/index.js +367 -16
- package/dist/channels/nostr/index.d.ts +121 -1
- package/dist/channels/nostr/index.js +396 -8
- package/dist/channels/ntfy/index.js +3 -0
- package/dist/channels/qq/index.js +3 -0
- package/dist/channels/slash-parity.d.ts +117 -0
- package/dist/channels/slash-parity.js +185 -0
- package/dist/channels/synology-chat/index.js +3 -0
- package/dist/channels/teams/index.d.ts +3 -0
- package/dist/channels/teams/index.js +3 -0
- package/dist/channels/telegram/client.d.ts +9 -0
- package/dist/channels/telegram/client.js +12 -0
- package/dist/channels/twilio-voice/index.js +3 -0
- package/dist/channels/webchat/index.d.ts +7 -1
- package/dist/channels/webchat/index.js +7 -1
- package/dist/channels/wecom/index.js +3 -0
- package/dist/channels/weixin/index.js +3 -0
- package/dist/channels/zalo/index.js +3 -0
- package/dist/cli/config-loader.js +1 -1
- package/dist/codebuddy/client.d.ts +30 -1
- package/dist/codebuddy/client.js +167 -20
- package/dist/codebuddy/providers/provider-chatgpt-responses.d.ts +5 -0
- package/dist/codebuddy/providers/provider-chatgpt-responses.js +7 -1
- package/dist/codebuddy/providers/provider-gemini-native.js +1 -1
- package/dist/codebuddy/providers/provider-openai-compat.d.ts +5 -0
- package/dist/codebuddy/providers/provider-openai-compat.js +82 -2
- package/dist/codebuddy/tool-definitions/computer-control-tools.js +32 -2
- package/dist/codebuddy/tool-definitions/core-tools.js +3 -3
- package/dist/codebuddy/tool-definitions/index.d.ts +2 -1
- package/dist/codebuddy/tool-definitions/index.js +2 -1
- package/dist/codebuddy/tool-definitions/multimodal-tools.d.ts +1 -0
- package/dist/codebuddy/tool-definitions/multimodal-tools.js +44 -1
- package/dist/codebuddy/tool-definitions/windows-tools.d.ts +2 -0
- package/dist/codebuddy/tool-definitions/windows-tools.js +34 -0
- package/dist/codebuddy/tools.d.ts +8 -1
- package/dist/codebuddy/tools.js +67 -24
- package/dist/commands/cli/hermes-commands.js +108 -0
- package/dist/commands/cli/improve-command.js +123 -0
- package/dist/commands/cli/native-engine-commands.js +102 -3
- package/dist/commands/cli/utility-commands.js +45 -0
- package/dist/commands/enhanced-command-handler.js +2 -2
- package/dist/commands/goal-cli.d.ts +30 -0
- package/dist/commands/goal-cli.js +210 -27
- package/dist/commands/handlers/goal-handler.d.ts +9 -2
- package/dist/commands/handlers/goal-handler.js +36 -9
- package/dist/commands/handlers/infra-handlers.js +36 -0
- package/dist/commands/handlers/memory-handlers.js +96 -1
- package/dist/commands/handlers/missing-handlers.d.ts +1 -1
- package/dist/commands/handlers/missing-handlers.js +26 -3
- package/dist/commands/headless-slash.d.ts +2 -0
- package/dist/commands/headless-slash.js +12 -0
- package/dist/commands/llm-provider-resolution.d.ts +5 -3
- package/dist/commands/llm-provider-resolution.js +87 -33
- package/dist/commands/ollama.d.ts +25 -0
- package/dist/commands/ollama.js +100 -0
- package/dist/commands/provider.d.ts +5 -0
- package/dist/commands/provider.js +106 -55
- package/dist/commands/skills-cli/index.js +123 -0
- package/dist/commands/slash/builtin-commands.js +1 -1
- package/dist/commands/spec-next.js +2 -1
- package/dist/commands/spec-plan.js +5 -16
- package/dist/commands/tunnel.d.ts +2 -0
- package/dist/commands/tunnel.js +48 -0
- package/dist/companion/percepts.js +11 -1
- package/dist/config/config-resolver.d.ts +2 -1
- package/dist/config/config-resolver.js +54 -41
- package/dist/config/constants.d.ts +28 -0
- package/dist/config/constants.js +7 -0
- package/dist/config/env-schema.js +756 -0
- package/dist/config/model-tools.js +4 -4
- package/dist/config/toml-config.d.ts +16 -0
- package/dist/config/toml-config.js +3 -0
- package/dist/context/bootstrap-loader.js +6 -23
- package/dist/context/context-manager-v2.js +1 -0
- package/dist/context/import-directive-parser.d.ts +4 -0
- package/dist/context/import-directive-parser.js +51 -6
- package/dist/context/instruction-excludes.d.ts +30 -1
- package/dist/context/instruction-excludes.js +71 -1
- package/dist/context/jit-context.d.ts +8 -10
- package/dist/context/jit-context.js +28 -106
- package/dist/context/project-context.d.ts +90 -0
- package/dist/context/project-context.js +295 -0
- package/dist/daemon/autonomous-daemon.d.ts +1 -1
- package/dist/daemon/autonomous-daemon.js +3 -3
- package/dist/daemon/autonomous-loop.d.ts +31 -1
- package/dist/daemon/autonomous-loop.js +91 -4
- package/dist/daemon/colab-goal.js +9 -1
- package/dist/daemon/cron-agent-bridge.d.ts +12 -3
- package/dist/daemon/cron-agent-bridge.js +25 -9
- package/dist/desktop/codebuddy-engine-adapter.d.ts +32 -0
- package/dist/desktop/codebuddy-engine-adapter.js +257 -80
- package/dist/desktop/engine-adapter.d.ts +14 -0
- package/dist/desktop-automation/automation-manager.js +16 -0
- package/dist/desktop-automation/omniparser-runner.d.ts +63 -0
- package/dist/desktop-automation/omniparser-runner.js +115 -0
- package/dist/desktop-automation/smart-snapshot.d.ts +11 -0
- package/dist/desktop-automation/smart-snapshot.js +72 -5
- package/dist/doctor/index.js +40 -23
- package/dist/events/types.d.ts +11 -0
- package/dist/fleet/colab-store.js +14 -2
- package/dist/fleet/peer-session-bridge.js +50 -12
- package/dist/fleet/peer-tool-bridge.js +14 -0
- package/dist/goals/goal-decomposer.d.ts +27 -0
- package/dist/goals/goal-decomposer.js +293 -0
- package/dist/goals/goal-judge-client.d.ts +15 -0
- package/dist/goals/goal-judge-client.js +45 -0
- package/dist/goals/goal-judge.d.ts +2 -0
- package/dist/goals/goal-judge.js +35 -6
- package/dist/goals/goal-loop.d.ts +14 -0
- package/dist/goals/goal-loop.js +47 -2
- package/dist/goals/goal-manager.d.ts +6 -0
- package/dist/goals/goal-manager.js +67 -14
- package/dist/goals/goal-state.d.ts +13 -0
- package/dist/goals/goal-state.js +83 -13
- package/dist/goals/goal-store.d.ts +3 -0
- package/dist/goals/goal-store.js +31 -15
- package/dist/harness/contract.d.ts +28 -28
- package/dist/hooks/user-hooks.js +17 -3
- package/dist/identity/identity-manager.js +3 -2
- package/dist/index.js +481 -26
- package/dist/input/text-to-speech.d.ts +2 -6
- package/dist/input/text-to-speech.js +2 -27
- package/dist/integrations/screenpipe/screenpipe-client.d.ts +10 -0
- package/dist/integrations/screenpipe/screenpipe-client.js +12 -2
- package/dist/integrations/tailscale.d.ts +13 -0
- package/dist/integrations/tailscale.js +87 -34
- package/dist/knowledge/workspace-indexer.js +53 -9
- package/dist/mcp/client.js +1 -0
- package/dist/mcp/mcp-desktop-tools.d.ts +23 -0
- package/dist/mcp/mcp-desktop-tools.js +158 -0
- package/dist/mcp/mcp-resources.js +2 -3
- package/dist/mcp/mcp-server.js +2 -0
- package/dist/memory/enhanced-memory.d.ts +10 -0
- package/dist/memory/enhanced-memory.js +33 -2
- package/dist/memory/index.d.ts +3 -1
- package/dist/memory/index.js +4 -1
- package/dist/memory/memory-auto-proposer.d.ts +23 -0
- package/dist/memory/memory-auto-proposer.js +308 -0
- package/dist/memory/memory-candidate-queue.d.ts +92 -0
- package/dist/memory/memory-candidate-queue.js +261 -0
- package/dist/memory/persistent-memory.d.ts +45 -2
- package/dist/memory/persistent-memory.js +236 -40
- package/dist/ml/bayesian-qualifier.d.ts +1 -1
- package/dist/ml/bayesian-qualifier.js +35 -2
- package/dist/openclaw/gateway-bridge.js +5 -0
- package/dist/plugins/marketplace.d.ts +1 -0
- package/dist/plugins/marketplace.js +7 -0
- package/dist/prompts/execution-discipline.d.ts +14 -0
- package/dist/prompts/execution-discipline.js +29 -0
- package/dist/prompts/variation-injector.js +9 -2
- package/dist/protocols/acp/acp-agentic-runner.d.ts +7 -6
- package/dist/protocols/acp/acp-agentic-runner.js +115 -18
- package/dist/protocols/acp/acp-session-store.d.ts +23 -0
- package/dist/protocols/acp/acp-session-store.js +77 -0
- package/dist/protocols/acp/acp-stdio-server.d.ts +7 -0
- package/dist/protocols/acp/acp-stdio-server.js +71 -16
- package/dist/providers/active-llm-registry.d.ts +37 -0
- package/dist/providers/active-llm-registry.js +186 -0
- package/dist/providers/auxiliary-provider.d.ts +25 -0
- package/dist/providers/auxiliary-provider.js +192 -0
- package/dist/providers/codex-oauth.d.ts +1 -1
- package/dist/providers/codex-oauth.js +27 -4
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.js +4 -0
- package/dist/providers/provider-catalog.d.ts +70 -0
- package/dist/providers/provider-catalog.js +738 -0
- package/dist/providers/provider-fallback.d.ts +35 -0
- package/dist/providers/provider-fallback.js +171 -0
- package/dist/providers/xai-oauth.d.ts +128 -0
- package/dist/providers/xai-oauth.js +735 -0
- package/dist/scheduler/cron-scheduler.d.ts +15 -3
- package/dist/scheduler/cron-scheduler.js +53 -7
- package/dist/scheduler/script-runner.d.ts +2 -0
- package/dist/scheduler/script-runner.js +19 -4
- package/dist/scheduler/watchdog-handlers.js +7 -20
- package/dist/search/usearch-index.js +7 -2
- package/dist/security/tool-policy/tool-groups.js +2 -0
- package/dist/sensory/dreaming.d.ts +45 -0
- package/dist/sensory/dreaming.js +114 -0
- package/dist/sensory/heartbeat-scheduler.d.ts +38 -0
- package/dist/sensory/heartbeat-scheduler.js +72 -0
- package/dist/sensory/reactions.d.ts +24 -0
- package/dist/sensory/reactions.js +31 -0
- package/dist/sensory/screen-reaction.d.ts +23 -0
- package/dist/sensory/screen-reaction.js +59 -0
- package/dist/sensory/sensory-bridge.d.ts +23 -0
- package/dist/sensory/sensory-bridge.js +85 -0
- package/dist/sensory/sensory-memory.d.ts +20 -0
- package/dist/sensory/sensory-memory.js +39 -0
- package/dist/sensory/speech-reaction.d.ts +21 -0
- package/dist/sensory/speech-reaction.js +83 -0
- package/dist/sensory/vision-reaction.d.ts +31 -0
- package/dist/sensory/vision-reaction.js +74 -0
- package/dist/server/index.js +106 -2
- package/dist/server/routes/mobile.d.ts +12 -4
- package/dist/server/routes/mobile.js +116 -1
- package/dist/server/tls-config.d.ts +35 -0
- package/dist/server/tls-config.js +142 -0
- package/dist/server/tunnel-manager.d.ts +20 -0
- package/dist/server/tunnel-manager.js +58 -0
- package/dist/server/websocket/fleet-bridge.js +5 -0
- package/dist/services/prompt-builder.d.ts +11 -0
- package/dist/services/prompt-builder.js +124 -17
- package/dist/shared/engine-types.d.ts +15 -1
- package/dist/sidecar/sidecar-bridge.d.ts +1 -0
- package/dist/sidecar/sidecar-bridge.js +21 -10
- package/dist/skills/parser.js +3 -0
- package/dist/skills/skill-importer.d.ts +58 -0
- package/dist/skills/skill-importer.js +261 -0
- package/dist/skills/skill-sources.d.ts +20 -0
- package/dist/skills/skill-sources.js +102 -0
- package/dist/skills/types.d.ts +6 -0
- package/dist/spec/spec-store.js +8 -1
- package/dist/talk-mode/providers/audioreader-tts.d.ts +1 -1
- package/dist/talk-mode/providers/audioreader-tts.js +8 -8
- package/dist/templates/project-scaffolding.js +3 -2
- package/dist/themes/theme-schema.d.ts +10 -10
- package/dist/tools/application-profiles.js +38 -0
- package/dist/tools/bash/bash-tool.d.ts +1 -0
- package/dist/tools/bash/bash-tool.js +16 -2
- package/dist/tools/bash/command-validator.js +3 -0
- package/dist/tools/bash/rtk-rewrite.d.ts +16 -0
- package/dist/tools/bash/rtk-rewrite.js +100 -0
- package/dist/tools/bash/streaming-executor.js +14 -1
- package/dist/tools/computer-control-tool.d.ts +16 -1
- package/dist/tools/computer-control-tool.js +317 -5
- package/dist/tools/document-generator.d.ts +14 -0
- package/dist/tools/document-generator.js +79 -0
- package/dist/tools/execute-code-rpc-invoker.d.ts +26 -2
- package/dist/tools/execute-code-rpc-invoker.js +46 -5
- package/dist/tools/metadata.js +28 -0
- package/dist/tools/office-macro-tool.d.ts +10 -0
- package/dist/tools/office-macro-tool.js +93 -0
- package/dist/tools/register-tool-handler.d.ts +25 -0
- package/dist/tools/register-tool-handler.js +100 -0
- package/dist/tools/registry/index.d.ts +2 -1
- package/dist/tools/registry/index.js +2 -1
- package/dist/tools/registry/memory-tools.d.ts +20 -0
- package/dist/tools/registry/memory-tools.js +225 -3
- package/dist/tools/registry/multimodal-tools.js +2 -2
- package/dist/tools/registry/vision-tools.d.ts +33 -0
- package/dist/tools/registry/vision-tools.js +308 -6
- package/dist/tools/registry/windows-tools.d.ts +2 -0
- package/dist/tools/registry/windows-tools.js +47 -0
- package/dist/tools/registry.d.ts +6 -0
- package/dist/tools/registry.js +8 -0
- package/dist/tools/route-peer-tool.js +14 -0
- package/dist/tools/screenshot-tool.js +14 -2
- package/dist/tools/text-to-speech-tool.d.ts +1 -1
- package/dist/tools/text-to-speech-tool.js +2 -12
- package/dist/tools/tool-selector.js +3 -59
- package/dist/utils/config-validation/schema.d.ts +40 -7
- package/dist/utils/config-validation/schema.js +130 -1
- package/dist/utils/cost-tracker.js +21 -1
- package/dist/utils/disk-guard.d.ts +180 -0
- package/dist/utils/disk-guard.js +404 -0
- package/dist/utils/init-project.d.ts +7 -0
- package/dist/utils/init-project.js +37 -0
- package/dist/utils/interactive-setup.js +35 -2
- package/dist/utils/model-utils.js +11 -2
- package/dist/utils/output-sanitizer.js +11 -0
- package/dist/utils/provider-detector.d.ts +8 -7
- package/dist/utils/provider-detector.js +19 -77
- package/dist/utils/settings-manager.d.ts +19 -0
- package/dist/utils/token-display.d.ts +3 -3
- package/dist/utils/token-display.js +22 -4
- package/dist/wizard/onboarding.d.ts +39 -0
- package/dist/wizard/onboarding.js +433 -21
- package/dist/wizard/provider-onboarding.d.ts +8 -23
- package/dist/wizard/provider-onboarding.js +39 -158
- package/package.json +4 -2
- package/dist/plugins/provider-onboarding.d.ts +0 -23
- package/dist/plugins/provider-onboarding.js +0 -116
- package/dist/utils/kokoro-tts.d.ts +0 -8
- package/dist/utils/kokoro-tts.js +0 -67
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xAI / Grok OAuth — Authorization Code + PKCE flow (subscription login).
|
|
3
|
+
*
|
|
4
|
+
* Lets a SuperGrok / xAI subscriber use their plan as the brain of Code
|
|
5
|
+
* Buddy via a one-time browser login — no `XAI_API_KEY`, no per-token API
|
|
6
|
+
* metering — mirroring the ChatGPT `buddy login` path (`codex-oauth.ts`).
|
|
7
|
+
*
|
|
8
|
+
* Flow (RFC 7636 PKCE + loopback redirect, per RFC 8252):
|
|
9
|
+
* 1. Fetch OIDC discovery (`https://auth.x.ai/.well-known/openid-configuration`)
|
|
10
|
+
* → `authorization_endpoint` + `token_endpoint` (validated to be on *.x.ai).
|
|
11
|
+
* 2. Generate a 64-byte `code_verifier` → SHA-256 `code_challenge` (S256).
|
|
12
|
+
* 3. Spin a callback server on `127.0.0.1:56121` (fallback ephemeral).
|
|
13
|
+
* 4. Open the authorize URL in the browser; the user consents.
|
|
14
|
+
* 5. Browser redirects to `http://127.0.0.1:<port>/callback?code=...&state=...`.
|
|
15
|
+
* 6. Exchange the code (form-encoded, with `code_challenge` re-sent — an xAI
|
|
16
|
+
* defense-in-depth quirk) for `{access_token, refresh_token, ...}`.
|
|
17
|
+
* 7. Tokens land under `~/.codebuddy/xai-auth.json` (0600).
|
|
18
|
+
*
|
|
19
|
+
* Inference: the `access_token` is sent **directly** as `Authorization:
|
|
20
|
+
* Bearer` to `https://api.x.ai/v1` (`/chat/completions` or `/responses`).
|
|
21
|
+
* There is NO key-exchange / management API step — the OAuth token is the
|
|
22
|
+
* inference credential. (Verified against the Hermes Agent reference impl.)
|
|
23
|
+
*
|
|
24
|
+
* Client id `b1a00492-…` is xAI's first-party Grok CLI public OAuth client
|
|
25
|
+
* (paired with PKCE, not a secret) — the same reuse pattern as the ChatGPT
|
|
26
|
+
* Codex CLI client id in `codex-oauth.ts`.
|
|
27
|
+
*/
|
|
28
|
+
import * as http from 'http';
|
|
29
|
+
import * as crypto from 'crypto';
|
|
30
|
+
import * as fs from 'fs';
|
|
31
|
+
import * as path from 'path';
|
|
32
|
+
import * as os from 'os';
|
|
33
|
+
import * as readline from 'readline';
|
|
34
|
+
import open from 'open';
|
|
35
|
+
import { logger } from '../utils/logger.js';
|
|
36
|
+
/** xAI's public Grok CLI OAuth client id (PKCE public client; not a secret). */
|
|
37
|
+
const CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
|
|
38
|
+
/** OAuth issuer — discovery, authorize and token endpoints live under here. */
|
|
39
|
+
const ISSUER = 'https://auth.x.ai';
|
|
40
|
+
const DISCOVERY_URL = `${ISSUER}/.well-known/openid-configuration`;
|
|
41
|
+
/** Hardcoded fallbacks if OIDC discovery is unreachable at login time.
|
|
42
|
+
* The token endpoint is the one observed in the Hermes reference impl. */
|
|
43
|
+
const FALLBACK_AUTHORIZE_ENDPOINT = `${ISSUER}/oauth/authorize`;
|
|
44
|
+
const FALLBACK_TOKEN_ENDPOINT = `${ISSUER}/oauth2/token`;
|
|
45
|
+
/** Scopes — `offline_access` enables refresh_token issuance; `api:access`
|
|
46
|
+
* is the scope that (when entitled) grants api.x.ai inference; `grok-cli:access`
|
|
47
|
+
* identifies the Grok CLI surface. */
|
|
48
|
+
const SCOPES = 'openid profile email offline_access grok-cli:access api:access';
|
|
49
|
+
/** Primary loopback callback port (matches the Grok CLI's registered redirect). */
|
|
50
|
+
const CALLBACK_PORT = 56121;
|
|
51
|
+
const CALLBACK_PATH = '/callback';
|
|
52
|
+
/** Bound on token exchange/refresh fetches so a stalled IdP can't hang an
|
|
53
|
+
* inline refresh (refresh runs on the first chat call when tokens are stale). */
|
|
54
|
+
const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
|
|
55
|
+
/** Short bound on the discovery fetch. */
|
|
56
|
+
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
57
|
+
/** Refresh the access token this many seconds before its JWT `exp`. */
|
|
58
|
+
const ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120;
|
|
59
|
+
/** Attribution marker (best-effort, mirrors Hermes' `referrer=`). */
|
|
60
|
+
const REFERRER = 'code-buddy';
|
|
61
|
+
const AUTH_FILE_PATH = path.join(os.homedir(), '.codebuddy', 'xai-auth.json');
|
|
62
|
+
/** Default inference base URL for the xAI OAuth path. */
|
|
63
|
+
export const XAI_OAUTH_BASE_URL = 'https://api.x.ai/v1';
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Storage
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
67
|
+
function ensureConfigDir() {
|
|
68
|
+
const dir = path.dirname(AUTH_FILE_PATH);
|
|
69
|
+
if (!fs.existsSync(dir)) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function loadAuthFile() {
|
|
74
|
+
try {
|
|
75
|
+
if (!fs.existsSync(AUTH_FILE_PATH))
|
|
76
|
+
return null;
|
|
77
|
+
const raw = fs.readFileSync(AUTH_FILE_PATH, 'utf-8');
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
logger.error('Error reading xai-auth.json', err instanceof Error ? err : { error: String(err) });
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function saveAuthFile(auth) {
|
|
86
|
+
try {
|
|
87
|
+
ensureConfigDir();
|
|
88
|
+
fs.writeFileSync(AUTH_FILE_PATH, JSON.stringify(auth, null, 2), 'utf-8');
|
|
89
|
+
if (process.platform !== 'win32') {
|
|
90
|
+
try {
|
|
91
|
+
fs.chmodSync(AUTH_FILE_PATH, 0o600);
|
|
92
|
+
}
|
|
93
|
+
catch { /* non-fatal */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.error('Error writing xai-auth.json', err instanceof Error ? err : { error: String(err) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Remove cached tokens. `buddy logout xai` calls this. */
|
|
101
|
+
export function clearXaiCredentials() {
|
|
102
|
+
try {
|
|
103
|
+
if (fs.existsSync(AUTH_FILE_PATH)) {
|
|
104
|
+
fs.unlinkSync(AUTH_FILE_PATH);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
logger.error('Error clearing xai credentials', err instanceof Error ? err : { error: String(err) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Whether a non-empty auth file with an access token exists. Used for
|
|
112
|
+
* provider auto-detection (no token loading, just presence). */
|
|
113
|
+
export function hasXaiCredentials() {
|
|
114
|
+
try {
|
|
115
|
+
if (!fs.existsSync(AUTH_FILE_PATH))
|
|
116
|
+
return false;
|
|
117
|
+
const raw = fs.readFileSync(AUTH_FILE_PATH, 'utf-8').trim();
|
|
118
|
+
if (!raw)
|
|
119
|
+
return false;
|
|
120
|
+
const parsed = JSON.parse(raw);
|
|
121
|
+
return Boolean(parsed.tokens?.access_token);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Absolute path to the auth file. Exposed for doctor / debug. */
|
|
128
|
+
export function getXaiAuthFilePath() {
|
|
129
|
+
return AUTH_FILE_PATH;
|
|
130
|
+
}
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
132
|
+
// PKCE
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
134
|
+
function base64URLEncode(buffer) {
|
|
135
|
+
return buffer
|
|
136
|
+
.toString('base64')
|
|
137
|
+
.replace(/\+/g, '-')
|
|
138
|
+
.replace(/\//g, '_')
|
|
139
|
+
.replace(/=/g, '');
|
|
140
|
+
}
|
|
141
|
+
/** Generate a fresh PKCE pair (64 bytes of entropy, S256). */
|
|
142
|
+
function generatePkce() {
|
|
143
|
+
const code_verifier = base64URLEncode(crypto.randomBytes(64));
|
|
144
|
+
const code_challenge = base64URLEncode(crypto.createHash('sha256').update(code_verifier).digest());
|
|
145
|
+
return { code_verifier, code_challenge };
|
|
146
|
+
}
|
|
147
|
+
function randomHex() {
|
|
148
|
+
return crypto.randomBytes(16).toString('hex');
|
|
149
|
+
}
|
|
150
|
+
/** Decode a JWT payload (no signature verification — the IdP vouched for it).
|
|
151
|
+
* Returns null for opaque (non-JWT) tokens. */
|
|
152
|
+
function decodeJwtClaims(token) {
|
|
153
|
+
try {
|
|
154
|
+
const parts = token.split('.');
|
|
155
|
+
if (parts.length !== 3)
|
|
156
|
+
return null;
|
|
157
|
+
const payload = parts[1];
|
|
158
|
+
if (payload === undefined)
|
|
159
|
+
return null;
|
|
160
|
+
const padded = payload + '==='.slice((payload.length + 3) % 4);
|
|
161
|
+
const json = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8');
|
|
162
|
+
return JSON.parse(json);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Seconds until the access token expires, or null when not derivable
|
|
169
|
+
* (opaque token, or missing `exp`). */
|
|
170
|
+
function accessTokenTtlSeconds(accessToken) {
|
|
171
|
+
const claims = decodeJwtClaims(accessToken);
|
|
172
|
+
if (!claims?.exp)
|
|
173
|
+
return null;
|
|
174
|
+
return Math.floor(claims.exp - Date.now() / 1000);
|
|
175
|
+
}
|
|
176
|
+
/** True when the token is a JWT expiring within the refresh skew window.
|
|
177
|
+
* Opaque tokens (no `exp`) are treated as NOT-expiring → refreshed only
|
|
178
|
+
* reactively on a 401. */
|
|
179
|
+
function isAccessTokenExpiring(accessToken) {
|
|
180
|
+
const ttl = accessTokenTtlSeconds(accessToken);
|
|
181
|
+
if (ttl === null)
|
|
182
|
+
return false;
|
|
183
|
+
return ttl <= ACCESS_TOKEN_REFRESH_SKEW_SECONDS;
|
|
184
|
+
}
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
186
|
+
// OIDC discovery
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
188
|
+
/** Validate an endpoint is HTTPS on the `*.x.ai` origin — prevents a
|
|
189
|
+
* poisoned discovery doc from redirecting credentials elsewhere. */
|
|
190
|
+
function isXaiHttpsEndpoint(url) {
|
|
191
|
+
try {
|
|
192
|
+
const u = new URL(url);
|
|
193
|
+
if (u.protocol !== 'https:')
|
|
194
|
+
return false;
|
|
195
|
+
return u.hostname === 'x.ai' || u.hostname.endsWith('.x.ai');
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Fetch + validate the OIDC discovery document. Falls back to the known
|
|
202
|
+
* endpoints if discovery is unreachable or malformed. */
|
|
203
|
+
async function fetchDiscovery() {
|
|
204
|
+
const fallback = {
|
|
205
|
+
authorization_endpoint: FALLBACK_AUTHORIZE_ENDPOINT,
|
|
206
|
+
token_endpoint: FALLBACK_TOKEN_ENDPOINT,
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
const response = await fetch(DISCOVERY_URL, {
|
|
210
|
+
method: 'GET',
|
|
211
|
+
headers: { Accept: 'application/json' },
|
|
212
|
+
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
|
213
|
+
});
|
|
214
|
+
if (!response.ok)
|
|
215
|
+
return fallback;
|
|
216
|
+
const doc = (await response.json());
|
|
217
|
+
const authorize = doc.authorization_endpoint;
|
|
218
|
+
const token = doc.token_endpoint;
|
|
219
|
+
if (typeof authorize === 'string' &&
|
|
220
|
+
typeof token === 'string' &&
|
|
221
|
+
isXaiHttpsEndpoint(authorize) &&
|
|
222
|
+
isXaiHttpsEndpoint(token)) {
|
|
223
|
+
return { authorization_endpoint: authorize, token_endpoint: token };
|
|
224
|
+
}
|
|
225
|
+
return fallback;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return fallback;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
232
|
+
// Authorize URL + token endpoint calls
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
234
|
+
function buildAuthorizeUrl(authorizeEndpoint, redirectUri, codeChallenge, state, nonce) {
|
|
235
|
+
const params = new URLSearchParams({
|
|
236
|
+
response_type: 'code',
|
|
237
|
+
client_id: CLIENT_ID,
|
|
238
|
+
redirect_uri: redirectUri,
|
|
239
|
+
scope: SCOPES,
|
|
240
|
+
code_challenge: codeChallenge,
|
|
241
|
+
code_challenge_method: 'S256',
|
|
242
|
+
state,
|
|
243
|
+
nonce,
|
|
244
|
+
plan: 'generic',
|
|
245
|
+
referrer: REFERRER,
|
|
246
|
+
});
|
|
247
|
+
return `${authorizeEndpoint}?${params.toString()}`;
|
|
248
|
+
}
|
|
249
|
+
/** Exchange the authorization code for tokens. Form-encoded body; the
|
|
250
|
+
* `code_challenge` is re-sent (xAI defense-in-depth re-validation). */
|
|
251
|
+
async function exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, codeChallenge, redirectUri) {
|
|
252
|
+
const params = new URLSearchParams({
|
|
253
|
+
grant_type: 'authorization_code',
|
|
254
|
+
code,
|
|
255
|
+
redirect_uri: redirectUri,
|
|
256
|
+
client_id: CLIENT_ID,
|
|
257
|
+
code_verifier: codeVerifier,
|
|
258
|
+
code_challenge: codeChallenge,
|
|
259
|
+
code_challenge_method: 'S256',
|
|
260
|
+
});
|
|
261
|
+
const response = await fetch(tokenEndpoint, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: {
|
|
264
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
265
|
+
Accept: 'application/json',
|
|
266
|
+
},
|
|
267
|
+
body: params.toString(),
|
|
268
|
+
signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
|
|
269
|
+
});
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
const body = await response.text().catch(() => '');
|
|
272
|
+
throw new Error(`Token exchange failed (${response.status}): ${body.slice(0, 300)}`);
|
|
273
|
+
}
|
|
274
|
+
return (await response.json());
|
|
275
|
+
}
|
|
276
|
+
/** Refresh the access token (form-encoded, grant_type=refresh_token). */
|
|
277
|
+
async function refreshTokens(tokenEndpoint, refreshToken) {
|
|
278
|
+
const params = new URLSearchParams({
|
|
279
|
+
grant_type: 'refresh_token',
|
|
280
|
+
client_id: CLIENT_ID,
|
|
281
|
+
refresh_token: refreshToken,
|
|
282
|
+
});
|
|
283
|
+
const response = await fetch(tokenEndpoint, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: {
|
|
286
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
287
|
+
Accept: 'application/json',
|
|
288
|
+
},
|
|
289
|
+
body: params.toString(),
|
|
290
|
+
signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
|
|
291
|
+
});
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
const body = await response.text().catch(() => '');
|
|
294
|
+
// 403 = entitlement gate (account not authorized for api.x.ai inference).
|
|
295
|
+
if (response.status === 403) {
|
|
296
|
+
throw new Error(`xAI refused the token (403): your subscription may not include API ` +
|
|
297
|
+
`inference access. Set XAI_API_KEY to use the metered API instead.`);
|
|
298
|
+
}
|
|
299
|
+
throw new Error(`Token refresh failed (${response.status}): ${body.slice(0, 300)}`);
|
|
300
|
+
}
|
|
301
|
+
return (await response.json());
|
|
302
|
+
}
|
|
303
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
304
|
+
// Callback server
|
|
305
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
306
|
+
async function tryBindOnce(port, handler) {
|
|
307
|
+
const server = http.createServer(handler);
|
|
308
|
+
await new Promise((resolve, reject) => {
|
|
309
|
+
const onError = (err) => {
|
|
310
|
+
server.removeListener('listening', onListening);
|
|
311
|
+
reject(err);
|
|
312
|
+
};
|
|
313
|
+
const onListening = () => {
|
|
314
|
+
server.removeListener('error', onError);
|
|
315
|
+
resolve();
|
|
316
|
+
};
|
|
317
|
+
server.once('error', onError);
|
|
318
|
+
server.once('listening', onListening);
|
|
319
|
+
server.listen(port, '127.0.0.1');
|
|
320
|
+
});
|
|
321
|
+
const address = server.address();
|
|
322
|
+
const boundPort = typeof address === 'object' && address ? address.port : port;
|
|
323
|
+
return { server, port: boundPort };
|
|
324
|
+
}
|
|
325
|
+
const isAddrInUse = (err) => err.code === 'EADDRINUSE';
|
|
326
|
+
/** Bind the callback server: prefer 56121, fall back to an OS-assigned
|
|
327
|
+
* ephemeral port (RFC 8252 §7.3 — loopback redirects accept any port). */
|
|
328
|
+
async function bindCallbackServer(handler) {
|
|
329
|
+
try {
|
|
330
|
+
return await tryBindOnce(CALLBACK_PORT, handler);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
if (isAddrInUse(err)) {
|
|
334
|
+
// Ephemeral fallback.
|
|
335
|
+
return await tryBindOnce(0, handler);
|
|
336
|
+
}
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Parse what the user pastes back from the browser. xAI's current consent
|
|
341
|
+
* screen shows the bare authorization code in-page (rather than redirecting
|
|
342
|
+
* to the loopback), so accept either a bare code, a full callback URL, or a
|
|
343
|
+
* `?code=...&state=...` fragment. Returns null when no code is found. */
|
|
344
|
+
function parsePastedCallback(raw) {
|
|
345
|
+
const trimmed = raw.trim();
|
|
346
|
+
if (!trimmed)
|
|
347
|
+
return null;
|
|
348
|
+
if (trimmed.includes('code=')) {
|
|
349
|
+
try {
|
|
350
|
+
const qs = trimmed.includes('?') ? trimmed.slice(trimmed.indexOf('?') + 1) : trimmed;
|
|
351
|
+
const params = new URLSearchParams(qs);
|
|
352
|
+
const code = params.get('code');
|
|
353
|
+
if (code) {
|
|
354
|
+
const state = params.get('state');
|
|
355
|
+
return state ? { code, state } : { code };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch { /* fall through to bare-code handling */ }
|
|
359
|
+
}
|
|
360
|
+
return { code: trimmed };
|
|
361
|
+
}
|
|
362
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
363
|
+
// Public API
|
|
364
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
365
|
+
// ── Two-step (begin → complete) flow ────────────────────────────────
|
|
366
|
+
// Works in non-interactive / backgrounded / remote shells where the user
|
|
367
|
+
// can't paste into the process's stdin: `beginLogin()` opens the browser
|
|
368
|
+
// and persists the PKCE material; `completeLogin(code)` exchanges the code
|
|
369
|
+
// the user copied from xAI's in-page consent screen.
|
|
370
|
+
const PENDING_FILE_PATH = path.join(os.homedir(), '.codebuddy', 'xai-login-pending.json');
|
|
371
|
+
function savePending(p) {
|
|
372
|
+
try {
|
|
373
|
+
ensureConfigDir();
|
|
374
|
+
fs.writeFileSync(PENDING_FILE_PATH, JSON.stringify(p, null, 2), 'utf-8');
|
|
375
|
+
if (process.platform !== 'win32') {
|
|
376
|
+
try {
|
|
377
|
+
fs.chmodSync(PENDING_FILE_PATH, 0o600);
|
|
378
|
+
}
|
|
379
|
+
catch { /* non-fatal */ }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
logger.error('Error writing xai-login-pending.json', err instanceof Error ? err : { error: String(err) });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function loadPending() {
|
|
387
|
+
try {
|
|
388
|
+
if (!fs.existsSync(PENDING_FILE_PATH))
|
|
389
|
+
return null;
|
|
390
|
+
return JSON.parse(fs.readFileSync(PENDING_FILE_PATH, 'utf-8'));
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function clearPending() {
|
|
397
|
+
try {
|
|
398
|
+
if (fs.existsSync(PENDING_FILE_PATH))
|
|
399
|
+
fs.unlinkSync(PENDING_FILE_PATH);
|
|
400
|
+
}
|
|
401
|
+
catch { /* ignore */ }
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Begin a login: run discovery, generate PKCE, persist the pending material,
|
|
405
|
+
* and return the authorize URL to open. Does NOT block — the caller opens the
|
|
406
|
+
* browser and later calls `completeLogin(code)` with the in-page code.
|
|
407
|
+
*/
|
|
408
|
+
export async function beginLogin() {
|
|
409
|
+
const discovery = await fetchDiscovery();
|
|
410
|
+
const pkce = generatePkce();
|
|
411
|
+
const state = randomHex();
|
|
412
|
+
const nonce = randomHex();
|
|
413
|
+
// Fixed loopback redirect_uri (must match in authorize + token exchange).
|
|
414
|
+
const redirectUri = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
415
|
+
savePending({
|
|
416
|
+
code_verifier: pkce.code_verifier,
|
|
417
|
+
code_challenge: pkce.code_challenge,
|
|
418
|
+
state,
|
|
419
|
+
nonce,
|
|
420
|
+
redirect_uri: redirectUri,
|
|
421
|
+
authorization_endpoint: discovery.authorization_endpoint,
|
|
422
|
+
token_endpoint: discovery.token_endpoint,
|
|
423
|
+
created_at: new Date().toISOString(),
|
|
424
|
+
});
|
|
425
|
+
const authorizeUrl = buildAuthorizeUrl(discovery.authorization_endpoint, redirectUri, pkce.code_challenge, state, nonce);
|
|
426
|
+
return { authorizeUrl, redirectUri };
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Complete a login started by `beginLogin()`: exchange the authorization code
|
|
430
|
+
* (bare code or a pasted callback URL) for tokens, persist them, and clear the
|
|
431
|
+
* pending file. Throws when there's no pending login or the exchange fails.
|
|
432
|
+
*/
|
|
433
|
+
export async function completeLogin(rawCode) {
|
|
434
|
+
const pending = loadPending();
|
|
435
|
+
if (!pending) {
|
|
436
|
+
throw new Error('No pending xAI login. Run `buddy login xai` first to open the browser.');
|
|
437
|
+
}
|
|
438
|
+
const parsed = parsePastedCallback(rawCode);
|
|
439
|
+
if (!parsed)
|
|
440
|
+
throw new Error('No authorization code provided.');
|
|
441
|
+
if (parsed.state !== undefined && parsed.state !== pending.state) {
|
|
442
|
+
throw new Error('state mismatch (paste the code from the same login attempt).');
|
|
443
|
+
}
|
|
444
|
+
const tokens = await exchangeCodeForTokens(pending.token_endpoint, parsed.code, pending.code_verifier, pending.code_challenge, pending.redirect_uri);
|
|
445
|
+
saveAuthFile({
|
|
446
|
+
tokens,
|
|
447
|
+
discovery: {
|
|
448
|
+
authorization_endpoint: pending.authorization_endpoint,
|
|
449
|
+
token_endpoint: pending.token_endpoint,
|
|
450
|
+
},
|
|
451
|
+
redirect_uri: pending.redirect_uri,
|
|
452
|
+
last_refresh: new Date().toISOString(),
|
|
453
|
+
});
|
|
454
|
+
clearPending();
|
|
455
|
+
const claims = decodeJwtClaims(tokens.access_token);
|
|
456
|
+
const ttl = accessTokenTtlSeconds(tokens.access_token);
|
|
457
|
+
return {
|
|
458
|
+
access_token: tokens.access_token,
|
|
459
|
+
...(claims?.email ? { email: claims.email } : {}),
|
|
460
|
+
...(ttl !== null ? { expires_in_seconds: ttl } : {}),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Run the interactive login flow. Because xAI's consent screen currently
|
|
465
|
+
* shows the authorization code **in-page** (rather than redirecting to the
|
|
466
|
+
* loopback), this races two completion paths:
|
|
467
|
+
* - a loopback callback server on 127.0.0.1:56121 (used if xAI redirects), and
|
|
468
|
+
* - a stdin prompt where the user pastes the in-page code.
|
|
469
|
+
* Whichever resolves first wins. The `redirect_uri` stays byte-identical in
|
|
470
|
+
* the authorize URL and the token exchange (xAI cross-checks it). Returns a
|
|
471
|
+
* minimal `XaiAuth`. Rejects on failure / timeout (5 minutes).
|
|
472
|
+
*/
|
|
473
|
+
export function loginInteractive(openUrl) {
|
|
474
|
+
const pkce = generatePkce();
|
|
475
|
+
const state = randomHex();
|
|
476
|
+
const nonce = randomHex();
|
|
477
|
+
return new Promise((resolve, reject) => {
|
|
478
|
+
let settled = false;
|
|
479
|
+
let serverInstance = null;
|
|
480
|
+
let rl = null;
|
|
481
|
+
let timeoutHandle = null;
|
|
482
|
+
let actualPort = CALLBACK_PORT;
|
|
483
|
+
let discovery = {
|
|
484
|
+
authorization_endpoint: FALLBACK_AUTHORIZE_ENDPOINT,
|
|
485
|
+
token_endpoint: FALLBACK_TOKEN_ENDPOINT,
|
|
486
|
+
};
|
|
487
|
+
const cleanup = () => {
|
|
488
|
+
if (timeoutHandle)
|
|
489
|
+
clearTimeout(timeoutHandle);
|
|
490
|
+
if (serverInstance) {
|
|
491
|
+
try {
|
|
492
|
+
serverInstance.close();
|
|
493
|
+
}
|
|
494
|
+
catch { /* ignore */ }
|
|
495
|
+
}
|
|
496
|
+
if (rl) {
|
|
497
|
+
try {
|
|
498
|
+
rl.close();
|
|
499
|
+
}
|
|
500
|
+
catch { /* ignore */ }
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
/** Exchange a code (from either path) and settle the promise once. */
|
|
504
|
+
const finish = async (code, returnedState) => {
|
|
505
|
+
if (settled)
|
|
506
|
+
return;
|
|
507
|
+
// Enforce state only when we have one to compare (loopback callback, or a
|
|
508
|
+
// pasted full URL). A bare pasted code carries no state — accept it.
|
|
509
|
+
if (returnedState !== undefined && returnedState !== state) {
|
|
510
|
+
settled = true;
|
|
511
|
+
cleanup();
|
|
512
|
+
reject(new Error('state mismatch'));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
settled = true;
|
|
516
|
+
try {
|
|
517
|
+
const redirectUri = `http://127.0.0.1:${actualPort}${CALLBACK_PATH}`;
|
|
518
|
+
const tokens = await exchangeCodeForTokens(discovery.token_endpoint, code, pkce.code_verifier, pkce.code_challenge, redirectUri);
|
|
519
|
+
saveAuthFile({
|
|
520
|
+
tokens,
|
|
521
|
+
discovery,
|
|
522
|
+
redirect_uri: redirectUri,
|
|
523
|
+
last_refresh: new Date().toISOString(),
|
|
524
|
+
});
|
|
525
|
+
cleanup();
|
|
526
|
+
const claims = decodeJwtClaims(tokens.access_token);
|
|
527
|
+
const ttl = accessTokenTtlSeconds(tokens.access_token);
|
|
528
|
+
resolve({
|
|
529
|
+
access_token: tokens.access_token,
|
|
530
|
+
...(claims?.email ? { email: claims.email } : {}),
|
|
531
|
+
...(ttl !== null ? { expires_in_seconds: ttl } : {}),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
cleanup();
|
|
536
|
+
reject(err);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
const requestHandler = async (req, res) => {
|
|
540
|
+
try {
|
|
541
|
+
const url = new URL(req.url || '', `http://127.0.0.1:${actualPort}`);
|
|
542
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
543
|
+
res.writeHead(404);
|
|
544
|
+
res.end('Not found');
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const error = url.searchParams.get('error');
|
|
548
|
+
if (error) {
|
|
549
|
+
const detail = url.searchParams.get('error_description') ?? error;
|
|
550
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
551
|
+
res.end(errorHtml('xAI a refusé la connexion', detail));
|
|
552
|
+
if (!settled) {
|
|
553
|
+
settled = true;
|
|
554
|
+
cleanup();
|
|
555
|
+
reject(new Error(`OAuth provider error: ${detail}`));
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const code = url.searchParams.get('code');
|
|
560
|
+
const returnedState = url.searchParams.get('state') ?? undefined;
|
|
561
|
+
if (!code) {
|
|
562
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
563
|
+
res.end(errorHtml('Réponse invalide', 'code manquant'));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
567
|
+
res.end(successHtml());
|
|
568
|
+
await finish(code, returnedState);
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
try {
|
|
572
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
573
|
+
res.end(errorHtml("Échec de l'échange de jetons", String(err)));
|
|
574
|
+
}
|
|
575
|
+
catch { /* ignore */ }
|
|
576
|
+
if (!settled) {
|
|
577
|
+
settled = true;
|
|
578
|
+
cleanup();
|
|
579
|
+
reject(err);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
// Discovery first; then bind the (best-effort) loopback server and open
|
|
584
|
+
// the browser. A bind failure is non-fatal — the paste path still works.
|
|
585
|
+
fetchDiscovery()
|
|
586
|
+
.then((d) => {
|
|
587
|
+
discovery = d;
|
|
588
|
+
return bindCallbackServer(requestHandler).catch(() => null);
|
|
589
|
+
})
|
|
590
|
+
.then((bound) => {
|
|
591
|
+
if (bound) {
|
|
592
|
+
serverInstance = bound.server;
|
|
593
|
+
actualPort = bound.port;
|
|
594
|
+
}
|
|
595
|
+
const redirectUri = `http://127.0.0.1:${actualPort}${CALLBACK_PATH}`;
|
|
596
|
+
const authUrl = buildAuthorizeUrl(discovery.authorization_endpoint, redirectUri, pkce.code_challenge, state, nonce);
|
|
597
|
+
timeoutHandle = setTimeout(() => {
|
|
598
|
+
if (!settled) {
|
|
599
|
+
settled = true;
|
|
600
|
+
cleanup();
|
|
601
|
+
reject(new Error('Login timed out after 5 minutes'));
|
|
602
|
+
}
|
|
603
|
+
}, 5 * 60 * 1000);
|
|
604
|
+
const fail = () => console.error(`Couldn't auto-open the browser. Open this URL manually:\n${authUrl}`);
|
|
605
|
+
if (openUrl) {
|
|
606
|
+
try {
|
|
607
|
+
const r = openUrl(authUrl);
|
|
608
|
+
if (r instanceof Promise)
|
|
609
|
+
r.catch(fail);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
fail();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
open(authUrl).catch(fail);
|
|
617
|
+
console.log(`\nIf your browser didn't open, visit this URL:\n${authUrl}`);
|
|
618
|
+
}
|
|
619
|
+
// Paste path — xAI shows the code in-page rather than redirecting.
|
|
620
|
+
console.log('\nxAI usually shows a code in the page instead of redirecting.');
|
|
621
|
+
console.log('Copy that code, paste it here, and press Enter');
|
|
622
|
+
console.log('(or just finish in the browser if it redirects):');
|
|
623
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
624
|
+
rl.question('Code: ', (answer) => {
|
|
625
|
+
if (settled)
|
|
626
|
+
return;
|
|
627
|
+
const parsed = parsePastedCallback(answer);
|
|
628
|
+
if (parsed) {
|
|
629
|
+
void finish(parsed.code, parsed.state);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
})
|
|
633
|
+
.catch((err) => {
|
|
634
|
+
if (!settled) {
|
|
635
|
+
settled = true;
|
|
636
|
+
cleanup();
|
|
637
|
+
reject(err);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Returns a valid xAI access token for inference, refreshing it when the
|
|
644
|
+
* JWT is within the skew window (or when `forceRefresh` is set, e.g. after a
|
|
645
|
+
* 401). Returns null when no credentials are on disk, or when a refresh
|
|
646
|
+
* fails (token revoked / entitlement lost) — the caller should re-login.
|
|
647
|
+
*/
|
|
648
|
+
export async function getValidXaiAccessToken(forceRefresh = false) {
|
|
649
|
+
const file = loadAuthFile();
|
|
650
|
+
const accessToken = file?.tokens?.access_token;
|
|
651
|
+
if (!file || !accessToken)
|
|
652
|
+
return null;
|
|
653
|
+
const needsRefresh = forceRefresh || isAccessTokenExpiring(accessToken);
|
|
654
|
+
if (!needsRefresh)
|
|
655
|
+
return accessToken;
|
|
656
|
+
const refreshToken = file.tokens?.refresh_token;
|
|
657
|
+
if (!refreshToken) {
|
|
658
|
+
// No refresh token (e.g. consent without offline_access). Hand back the
|
|
659
|
+
// current token; a 401 will tell the user to re-login.
|
|
660
|
+
return accessToken;
|
|
661
|
+
}
|
|
662
|
+
const tokenEndpoint = file.discovery?.token_endpoint ?? FALLBACK_TOKEN_ENDPOINT;
|
|
663
|
+
try {
|
|
664
|
+
const refreshed = await refreshTokens(tokenEndpoint, refreshToken);
|
|
665
|
+
saveAuthFile({
|
|
666
|
+
...file,
|
|
667
|
+
tokens: { ...file.tokens, ...refreshed },
|
|
668
|
+
last_refresh: new Date().toISOString(),
|
|
669
|
+
});
|
|
670
|
+
return refreshed.access_token;
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
logger.error('xAI token refresh failed', err instanceof Error ? err : { error: String(err) });
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
678
|
+
// Browser response pages
|
|
679
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
680
|
+
function htmlEscape(s) {
|
|
681
|
+
return s
|
|
682
|
+
.replace(/&/g, '&')
|
|
683
|
+
.replace(/</g, '<')
|
|
684
|
+
.replace(/>/g, '>')
|
|
685
|
+
.replace(/"/g, '"');
|
|
686
|
+
}
|
|
687
|
+
function successHtml() {
|
|
688
|
+
return `<!doctype html>
|
|
689
|
+
<html lang="fr"><head><meta charset="utf-8"><title>Code Buddy — Connecté</title>
|
|
690
|
+
<style>body{font-family:system-ui;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
691
|
+
.card{background:#171717;border:1px solid #262626;border-radius:12px;padding:32px 40px;max-width:420px;text-align:center}
|
|
692
|
+
h1{font-size:18px;margin:0 0 8px;color:#a7f3d0}
|
|
693
|
+
p{margin:0;font-size:14px;color:#a3a3a3;line-height:1.5}</style>
|
|
694
|
+
</head><body><div class="card"><h1>✅ Authentifié à xAI / Grok</h1>
|
|
695
|
+
<p>Tu peux fermer cet onglet et retourner dans Code Buddy — ton jeton est stocké.</p>
|
|
696
|
+
<script>setTimeout(() => window.close(), 1200)</script>
|
|
697
|
+
</div></body></html>`;
|
|
698
|
+
}
|
|
699
|
+
function errorHtml(title, detail) {
|
|
700
|
+
return `<!doctype html>
|
|
701
|
+
<html lang="fr"><head><meta charset="utf-8"><title>Code Buddy — Erreur OAuth</title>
|
|
702
|
+
<style>body{font-family:system-ui;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
703
|
+
.card{background:#171717;border:1px solid #7f1d1d;border-radius:12px;padding:32px 40px;max-width:520px}
|
|
704
|
+
h1{font-size:18px;margin:0 0 12px;color:#fca5a5}
|
|
705
|
+
pre{margin:0;font-size:12px;color:#a3a3a3;white-space:pre-wrap;word-break:break-word}</style>
|
|
706
|
+
</head><body><div class="card"><h1>${htmlEscape(title)}</h1><pre>${htmlEscape(detail)}</pre></div></body></html>`;
|
|
707
|
+
}
|
|
708
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
709
|
+
// Test-only exports (not part of the stable public API)
|
|
710
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
711
|
+
function __getTestExports() {
|
|
712
|
+
return {
|
|
713
|
+
buildAuthorizeUrl,
|
|
714
|
+
generatePkce,
|
|
715
|
+
decodeJwtClaims,
|
|
716
|
+
accessTokenTtlSeconds,
|
|
717
|
+
isAccessTokenExpiring,
|
|
718
|
+
isXaiHttpsEndpoint,
|
|
719
|
+
parsePastedCallback,
|
|
720
|
+
base64URLEncode,
|
|
721
|
+
CLIENT_ID,
|
|
722
|
+
SCOPES,
|
|
723
|
+
ISSUER,
|
|
724
|
+
FALLBACK_TOKEN_ENDPOINT,
|
|
725
|
+
FALLBACK_AUTHORIZE_ENDPOINT,
|
|
726
|
+
CALLBACK_PORT,
|
|
727
|
+
CALLBACK_PATH,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
export const __test = new Proxy({}, {
|
|
731
|
+
get(_target, prop) {
|
|
732
|
+
return __getTestExports()[prop];
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
//# sourceMappingURL=xai-oauth.js.map
|