luispater commited on
Commit
89ac31e
·
unverified ·
2 Parent(s): 39e54b61837f9b

Merge remote-tracking branch 'origin/main' into pr-104

Browse files

# Conflicts:
# config.example.yaml
# internal/config/config.go
# sdk/cliproxy/auth/model_name_mappings.go

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/docker-image.yml +2 -1
  2. .github/workflows/release.yaml +2 -1
  3. .gitignore +2 -0
  4. .goreleaser.yml +4 -4
  5. Dockerfile +3 -3
  6. README.md +10 -135
  7. README_CN.md +10 -142
  8. cmd/server/main.go +56 -0
  9. config.example.yaml +45 -16
  10. docker-compose.yml +2 -2
  11. go.mod +2 -1
  12. go.sum +3 -2
  13. internal/api/handlers/management/auth_files.go +318 -0
  14. internal/api/handlers/management/config_basic.go +2 -2
  15. internal/api/handlers/management/oauth_sessions.go +8 -1
  16. internal/api/modules/amp/proxy.go +34 -3
  17. internal/api/modules/amp/response_rewriter.go +58 -2
  18. internal/api/server.go +21 -0
  19. internal/auth/claude/oauth_server.go +11 -0
  20. internal/auth/codex/oauth_server.go +11 -0
  21. internal/auth/copilot/copilot_auth.go +225 -0
  22. internal/auth/copilot/errors.go +187 -0
  23. internal/auth/copilot/oauth.go +255 -0
  24. internal/auth/copilot/token.go +93 -0
  25. internal/auth/iflow/iflow_auth.go +17 -5
  26. internal/auth/kiro/aws.go +305 -0
  27. internal/auth/kiro/aws_auth.go +314 -0
  28. internal/auth/kiro/aws_test.go +161 -0
  29. internal/auth/kiro/codewhisperer_client.go +166 -0
  30. internal/auth/kiro/oauth.go +303 -0
  31. internal/auth/kiro/protocol_handler.go +725 -0
  32. internal/auth/kiro/social_auth.go +403 -0
  33. internal/auth/kiro/sso_oidc.go +1371 -0
  34. internal/auth/kiro/token.go +72 -0
  35. internal/browser/browser.go +416 -14
  36. internal/cmd/auth_manager.go +3 -1
  37. internal/cmd/github_copilot_login.go +44 -0
  38. internal/cmd/kiro_login.go +208 -0
  39. internal/cmd/login.go +2 -1
  40. internal/config/config.go +63 -0
  41. internal/constant/constant.go +3 -0
  42. internal/logging/global_logger.go +1 -0
  43. internal/registry/model_definitions.go +361 -0
  44. internal/registry/model_registry.go +15 -1
  45. internal/runtime/executor/cache_helpers.go +10 -0
  46. internal/runtime/executor/github_copilot_executor.go +399 -0
  47. internal/runtime/executor/kiro_executor.go +0 -0
  48. internal/runtime/executor/proxy_helpers.go +44 -5
  49. internal/runtime/executor/token_helpers.go +276 -15
  50. internal/translator/claude/openai/chat-completions/claude_openai_response.go +4 -0
.github/workflows/docker-image.yml CHANGED
@@ -7,7 +7,7 @@ on:
7
 
8
  env:
9
  APP_NAME: CLIProxyAPI
10
- DOCKERHUB_REPO: eceasy/cli-proxy-api
11
 
12
  jobs:
13
  docker:
@@ -44,3 +44,4 @@ jobs:
44
  tags: |
45
  ${{ env.DOCKERHUB_REPO }}:latest
46
  ${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}
 
 
7
 
8
  env:
9
  APP_NAME: CLIProxyAPI
10
+ DOCKERHUB_REPO: eceasy/cli-proxy-api-plus
11
 
12
  jobs:
13
  docker:
 
44
  tags: |
45
  ${{ env.DOCKERHUB_REPO }}:latest
46
  ${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}
47
+
.github/workflows/release.yaml CHANGED
@@ -23,7 +23,8 @@ jobs:
23
  cache: true
24
  - name: Generate Build Metadata
25
  run: |
26
- echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
 
27
  echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
28
  echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
29
  - uses: goreleaser/goreleaser-action@v4
 
23
  cache: true
24
  - name: Generate Build Metadata
25
  run: |
26
+ VERSION=$(git describe --tags --always --dirty)
27
+ echo "VERSION=${VERSION}" >> $GITHUB_ENV
28
  echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
29
  echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
30
  - uses: goreleaser/goreleaser-action@v4
.gitignore CHANGED
@@ -1,5 +1,6 @@
1
  # Binaries
2
  cli-proxy-api
 
3
  *.exe
4
 
5
  # Configuration
@@ -44,6 +45,7 @@ GEMINI.md
44
  .bmad/*
45
  _bmad/*
46
  _bmad-output/*
 
47
 
48
  # macOS
49
  .DS_Store
 
1
  # Binaries
2
  cli-proxy-api
3
+ cliproxy
4
  *.exe
5
 
6
  # Configuration
 
45
  .bmad/*
46
  _bmad/*
47
  _bmad-output/*
48
+ .mcp/cache/
49
 
50
  # macOS
51
  .DS_Store
.goreleaser.yml CHANGED
@@ -1,5 +1,5 @@
1
  builds:
2
- - id: "cli-proxy-api"
3
  env:
4
  - CGO_ENABLED=0
5
  goos:
@@ -10,11 +10,11 @@ builds:
10
  - amd64
11
  - arm64
12
  main: ./cmd/server/
13
- binary: cli-proxy-api
14
  ldflags:
15
- - -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
16
  archives:
17
- - id: "cli-proxy-api"
18
  format: tar.gz
19
  format_overrides:
20
  - goos: windows
 
1
  builds:
2
+ - id: "cli-proxy-api-plus"
3
  env:
4
  - CGO_ENABLED=0
5
  goos:
 
10
  - amd64
11
  - arm64
12
  main: ./cmd/server/
13
+ binary: cli-proxy-api-plus
14
  ldflags:
15
+ - -s -w -X 'main.Version={{.Version}}-plus' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
16
  archives:
17
+ - id: "cli-proxy-api-plus"
18
  format: tar.gz
19
  format_overrides:
20
  - goos: windows
Dockerfile CHANGED
@@ -12,7 +12,7 @@ ARG VERSION=dev
12
  ARG COMMIT=none
13
  ARG BUILD_DATE=unknown
14
 
15
- RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
16
 
17
  FROM alpine:3.22.0
18
 
@@ -20,7 +20,7 @@ RUN apk add --no-cache tzdata
20
 
21
  RUN mkdir /CLIProxyAPI
22
 
23
- COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
24
 
25
  COPY config.example.yaml /CLIProxyAPI/config.example.yaml
26
 
@@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai
32
 
33
  RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
34
 
35
- CMD ["./CLIProxyAPI"]
 
12
  ARG COMMIT=none
13
  ARG BUILD_DATE=unknown
14
 
15
+ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}-plus' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPIPlus ./cmd/server/
16
 
17
  FROM alpine:3.22.0
18
 
 
20
 
21
  RUN mkdir /CLIProxyAPI
22
 
23
+ COPY --from=builder ./app/CLIProxyAPIPlus /CLIProxyAPI/CLIProxyAPIPlus
24
 
25
  COPY config.example.yaml /CLIProxyAPI/config.example.yaml
26
 
 
32
 
33
  RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
34
 
35
+ CMD ["./CLIProxyAPIPlus"]
README.md CHANGED
@@ -1,148 +1,23 @@
1
- # CLI Proxy API
2
 
3
- English | [中文](README_CN.md)
4
 
5
- A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
6
 
7
- It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
8
 
9
- So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
10
 
11
- ## Sponsor
12
 
13
- [![z.ai](https://assets.router-for.me/english-4.7.png)](https://z.ai/subscribe?ic=8JVLJQFSKB)
14
-
15
- This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
16
-
17
- GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.7 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
18
-
19
- Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
20
-
21
- ---
22
-
23
- <table>
24
- <tbody>
25
- <tr>
26
- <td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
27
- <td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
28
- </tr>
29
- <tr>
30
- <td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
31
- <td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
32
- </tr>
33
- </tbody>
34
- </table>
35
-
36
- ## Overview
37
-
38
- - OpenAI/Gemini/Claude compatible API endpoints for CLI models
39
- - OpenAI Codex support (GPT models) via OAuth login
40
- - Claude Code support via OAuth login
41
- - Qwen Code support via OAuth login
42
- - iFlow support via OAuth login
43
- - Amp CLI and IDE extensions support with provider routing
44
- - Streaming and non-streaming responses
45
- - Function calling/tools support
46
- - Multimodal input support (text and images)
47
- - Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
48
- - Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
49
- - Generative Language API Key support
50
- - AI Studio Build multi-account load balancing
51
- - Gemini CLI multi-account load balancing
52
- - Claude Code multi-account load balancing
53
- - Qwen Code multi-account load balancing
54
- - iFlow multi-account load balancing
55
- - OpenAI Codex multi-account load balancing
56
- - OpenAI-compatible upstream providers via config (e.g., OpenRouter)
57
- - Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
58
-
59
- ## Getting Started
60
-
61
- CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
62
-
63
- ## Management API
64
-
65
- see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
66
-
67
- ## Amp CLI Support
68
-
69
- CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
70
-
71
- - Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
72
- - Management proxy for OAuth authentication and account features
73
- - Smart model fallback with automatic routing
74
- - **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
75
- - Security-first design with localhost-only management endpoints
76
-
77
- **→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
78
-
79
- ## SDK Docs
80
-
81
- - Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
82
- - Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
83
- - Access: [docs/sdk-access.md](docs/sdk-access.md)
84
- - Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
85
- - Custom Provider Example: `examples/custom-provider`
86
 
87
  ## Contributing
88
 
89
- Contributions are welcome! Please feel free to submit a Pull Request.
90
-
91
- 1. Fork the repository
92
- 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
93
- 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
94
- 4. Push to the branch (`git push origin feature/amazing-feature`)
95
- 5. Open a Pull Request
96
-
97
- ## Who is with us?
98
-
99
- Those projects are based on CLIProxyAPI:
100
-
101
- ### [vibeproxy](https://github.com/automazeio/vibeproxy)
102
-
103
- Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
104
-
105
- ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
106
-
107
- Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
108
-
109
- ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
110
-
111
- CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
112
-
113
- ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
114
-
115
- Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.
116
-
117
- ### [Quotio](https://github.com/nguyenphutrong/quotio)
118
-
119
- Native macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
120
-
121
- ### [CodMate](https://github.com/loocor/CodMate)
122
-
123
- Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.
124
-
125
- ### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
126
-
127
- Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth for AI coding tools - no API keys needed.
128
-
129
- ### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
130
-
131
- VSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management.
132
-
133
- > [!NOTE]
134
- > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
135
-
136
- ## More choices
137
-
138
- Those projects are ports of CLIProxyAPI or inspired by it:
139
-
140
- ### [9Router](https://github.com/decolua/9router)
141
-
142
- A Next.js implementation inspired by CLIProxyAPI, easy to install and use, built from scratch with format translation (OpenAI/Claude/Gemini/Ollama), combo system with auto-fallback, multi-account management with exponential backoff, a Next.js web dashboard, and support for CLI tools (Cursor, Claude Code, Cline, RooCode) - no API keys needed.
143
 
144
- > [!NOTE]
145
- > If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
146
 
147
  ## License
148
 
 
1
+ # CLIProxyAPI Plus
2
 
3
+ English | [Chinese](README_CN.md)
4
 
5
+ This is the Plus version of [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI), adding support for third-party providers on top of the mainline project.
6
 
7
+ All third-party provider support is maintained by community contributors; CLIProxyAPI does not provide technical support. Please contact the corresponding community maintainer if you need assistance.
8
 
9
+ The Plus release stays in lockstep with the mainline features.
10
 
11
+ ## Differences from the Mainline
12
 
13
+ - Added GitHub Copilot support (OAuth login), provided by [em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)
14
+ - Added Kiro (AWS CodeWhisperer) support (OAuth login), provided by [fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration), [Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## Contributing
17
 
18
+ This project only accepts pull requests that relate to third-party provider support. Any pull requests unrelated to third-party provider support will be rejected.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ If you need to submit any non-third-party provider changes, please open them against the mainline repository.
 
21
 
22
  ## License
23
 
README_CN.md CHANGED
@@ -1,156 +1,24 @@
1
- # CLI 代理 API
2
 
3
  [English](README.md) | 中文
4
 
5
- 一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口代理服务器
6
 
7
- 现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 列)和 Claude Code
8
 
9
- 您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容客户端和SDK进行访问
10
 
11
- ## 赞助商
12
 
13
- [![bigmodel.cn](https://assets.router-for.me/chinese-4.7.png)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
14
-
15
- 本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
16
-
17
- GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7,为开发者提供顶尖的编码体验。
18
-
19
- 智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
20
-
21
- ---
22
-
23
- <table>
24
- <tbody>
25
- <tr>
26
- <td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
27
- <td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
28
- </tr>
29
- <tr>
30
- <td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
31
- <td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
32
- </tr>
33
- </tbody>
34
- </table>
35
-
36
-
37
- ## 功能特性
38
-
39
- - 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
40
- - 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
41
- - 新增 Claude Code 支持(OAuth 登录)
42
- - 新增 Qwen Code 支持(OAuth 登录)
43
- - 新增 iFlow 支持(OAuth 登录)
44
- - 支持流式与非流式响应
45
- - 函数调用/工具支持
46
- - 多模态输入(文本、图片)
47
- - 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
48
- - 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
49
- - 支持 Gemini AIStudio API 密钥
50
- - 支持 AI Studio Build 多账户轮询
51
- - 支持 Gemini CLI 多账户轮询
52
- - 支持 Claude Code 多账户轮询
53
- - 支持 Qwen Code 多账户轮询
54
- - 支持 iFlow 多账户轮询
55
- - 支持 OpenAI Codex 多账户轮询
56
- - 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
57
- - 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
58
-
59
- ## 新手入门
60
-
61
- CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
62
-
63
- ## 管理 API 文档
64
-
65
- 请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
66
-
67
- ## Amp CLI 支持
68
-
69
- CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
70
-
71
- - 提供商路由别名,兼容 Amp 的 API 路径模式(`/api/provider/{provider}/v1...`)
72
- - 管理代理,处理 OAuth 认证和账号功能
73
- - 智能模型回退与自动路由
74
- - 以安全为先的设计,管理端点仅限 localhost
75
-
76
- **→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
77
-
78
- ## SDK 文档
79
-
80
- - 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
81
- - 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
82
- - 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
83
- - 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
84
- - 自定义 Provider 示例:`examples/custom-provider`
85
 
86
  ## 贡献
87
 
88
- 欢迎贡献!请随时提交 Pull Request。
89
-
90
- 1. Fork 仓库
91
- 2. 创建您的功能分支(`git checkout -b feature/amazing-feature`)
92
- 3. 提交您的更改(`git commit -m 'Add some amazing feature'`)
93
- 4. 推送到分支(`git push origin feature/amazing-feature`)
94
- 5. 打开 Pull Request
95
-
96
- ## 谁与我们在一起?
97
-
98
- 这些项目基于 CLIProxyAPI:
99
-
100
- ### [vibeproxy](https://github.com/automazeio/vibeproxy)
101
-
102
- 一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
103
 
104
- ### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
105
-
106
- 一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
107
-
108
- ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
109
-
110
- CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
111
-
112
- ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
113
-
114
- 基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。
115
-
116
- ### [Quotio](https://github.com/nguyenphutrong/quotio)
117
-
118
- 原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
119
-
120
- ### [CodMate](https://github.com/loocor/CodMate)
121
-
122
- 原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。
123
-
124
- ### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
125
-
126
- 原生 Windows CLIProxyAPI 分支,集成 TUI、系统托盘及多服务商 OAuth 认证,专为 AI 编程工具打造,无需 API 密钥。
127
-
128
- ### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
129
-
130
- 一款 VSCode 扩展,提供了在 VSCode 中快速切换 Claude Code 模型的功能,内置 CLIProxyAPI 作为其后端,支持后台自动启动和关闭。
131
-
132
- > [!NOTE]
133
- > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
134
-
135
- ## 更多选择
136
-
137
- 以下项目是 CLIProxyAPI 的移植版或受其启发:
138
-
139
- ### [9Router](https://github.com/decolua/9router)
140
-
141
- 基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。
142
-
143
- > [!NOTE]
144
- > 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
145
 
146
  ## 许可证
147
 
148
- 此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
149
-
150
- ## 写给所有中国网友的
151
-
152
- QQ 群:188637136
153
-
154
-
155
-
156
- Telegram 群:https://t.me/CLIProxyAPI
 
1
+ # CLIProxyAPI Plus
2
 
3
  [English](README.md) | 中文
4
 
5
+ 这是 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) Plus 版本,在原有基础上增加了第三方供应商支持
6
 
7
+ 所有的第三方供应商支持都由第三方社区维护者提供,CLIProxyAPI 不提供技术支持。如需取得支持,请与对应的社区维护者联系。
8
 
9
+ Plus 版本主线功能与主线功能强制同步
10
 
11
+ ## 与主线版本版本差异
12
 
13
+ - 新增 GitHub Copilot 支持(OAuth 登录),由[em4go](https://github.com/em4go/CLIProxyAPI/tree/feature/github-copilot-auth)提供
14
+ - 新增 Kiro (AWS CodeWhisperer) 支持 (OAuth 登录), 由[fuko2935](https://github.com/fuko2935/CLIProxyAPI/tree/feature/kiro-integration)、[Ravens2121](https://github.com/Ravens2121/CLIProxyAPIPlus/)提供
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## 贡献
17
 
18
+ 该项目仅接受第三方供应商支持的 Pull Request。任何非第三方供应商支持的 Pull Request 都将被拒绝。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ 如果需要提交任何非第三方供应商支持的 Pull Request,请提交到主线版本。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  ## 许可证
23
 
24
+ 此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
 
 
 
 
 
 
 
 
cmd/server/main.go CHANGED
@@ -47,6 +47,19 @@ func init() {
47
  buildinfo.BuildDate = BuildDate
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  // main is the entry point of the application.
51
  // It parses command-line flags, loads configuration, and starts the appropriate
52
  // service based on the provided flags (login, codex-login, or server mode).
@@ -63,10 +76,18 @@ func main() {
63
  var noBrowser bool
64
  var oauthCallbackPort int
65
  var antigravityLogin bool
 
 
 
 
 
 
66
  var projectID string
67
  var vertexImport string
68
  var configPath string
69
  var password string
 
 
70
 
71
  // Define command-line flags for different operation modes.
72
  flag.BoolVar(&login, "login", false, "Login Google Account")
@@ -77,7 +98,15 @@ func main() {
77
  flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
78
  flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
79
  flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
 
 
80
  flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
 
 
 
 
 
 
81
  flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
82
  flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
83
  flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
@@ -456,6 +485,9 @@ func main() {
456
  } else if antigravityLogin {
457
  // Handle Antigravity login
458
  cmd.DoAntigravityLogin(cfg, options)
 
 
 
459
  } else if codexLogin {
460
  // Handle Codex login
461
  cmd.DoCodexLogin(cfg, options)
@@ -468,6 +500,30 @@ func main() {
468
  cmd.DoIFlowLogin(cfg, options)
469
  } else if iflowCookie {
470
  cmd.DoIFlowCookieAuth(cfg, options)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  } else {
472
  // In cloud deploy mode without config file, just wait for shutdown signals
473
  if isCloudDeploy && !configFileExists {
 
47
  buildinfo.BuildDate = BuildDate
48
  }
49
 
50
+ // setKiroIncognitoMode sets the incognito browser mode for Kiro authentication.
51
+ // Kiro defaults to incognito mode for multi-account support.
52
+ // Users can explicitly override with --incognito or --no-incognito flags.
53
+ func setKiroIncognitoMode(cfg *config.Config, useIncognito, noIncognito bool) {
54
+ if useIncognito {
55
+ cfg.IncognitoBrowser = true
56
+ } else if noIncognito {
57
+ cfg.IncognitoBrowser = false
58
+ } else {
59
+ cfg.IncognitoBrowser = true // Kiro default
60
+ }
61
+ }
62
+
63
  // main is the entry point of the application.
64
  // It parses command-line flags, loads configuration, and starts the appropriate
65
  // service based on the provided flags (login, codex-login, or server mode).
 
76
  var noBrowser bool
77
  var oauthCallbackPort int
78
  var antigravityLogin bool
79
+ var kiroLogin bool
80
+ var kiroGoogleLogin bool
81
+ var kiroAWSLogin bool
82
+ var kiroAWSAuthCode bool
83
+ var kiroImport bool
84
+ var githubCopilotLogin bool
85
  var projectID string
86
  var vertexImport string
87
  var configPath string
88
  var password string
89
+ var noIncognito bool
90
+ var useIncognito bool
91
 
92
  // Define command-line flags for different operation modes.
93
  flag.BoolVar(&login, "login", false, "Login Google Account")
 
98
  flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
99
  flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
100
  flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
101
+ flag.BoolVar(&useIncognito, "incognito", false, "Open browser in incognito/private mode for OAuth (useful for multiple accounts)")
102
+ flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)")
103
  flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
104
+ flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
105
+ flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
106
+ flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
107
+ flag.BoolVar(&kiroAWSAuthCode, "kiro-aws-authcode", false, "Login to Kiro using AWS Builder ID (authorization code flow, better UX)")
108
+ flag.BoolVar(&kiroImport, "kiro-import", false, "Import Kiro token from Kiro IDE (~/.aws/sso/cache/kiro-auth-token.json)")
109
+ flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
110
  flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
111
  flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
112
  flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
 
485
  } else if antigravityLogin {
486
  // Handle Antigravity login
487
  cmd.DoAntigravityLogin(cfg, options)
488
+ } else if githubCopilotLogin {
489
+ // Handle GitHub Copilot login
490
+ cmd.DoGitHubCopilotLogin(cfg, options)
491
  } else if codexLogin {
492
  // Handle Codex login
493
  cmd.DoCodexLogin(cfg, options)
 
500
  cmd.DoIFlowLogin(cfg, options)
501
  } else if iflowCookie {
502
  cmd.DoIFlowCookieAuth(cfg, options)
503
+ } else if kiroLogin {
504
+ // For Kiro auth, default to incognito mode for multi-account support
505
+ // Users can explicitly override with --no-incognito
506
+ // Note: This config mutation is safe - auth commands exit after completion
507
+ // and don't share config with StartService (which is in the else branch)
508
+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
509
+ cmd.DoKiroLogin(cfg, options)
510
+ } else if kiroGoogleLogin {
511
+ // For Kiro auth, default to incognito mode for multi-account support
512
+ // Users can explicitly override with --no-incognito
513
+ // Note: This config mutation is safe - auth commands exit after completion
514
+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
515
+ cmd.DoKiroGoogleLogin(cfg, options)
516
+ } else if kiroAWSLogin {
517
+ // For Kiro auth, default to incognito mode for multi-account support
518
+ // Users can explicitly override with --no-incognito
519
+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
520
+ cmd.DoKiroAWSLogin(cfg, options)
521
+ } else if kiroAWSAuthCode {
522
+ // For Kiro auth with authorization code flow (better UX)
523
+ setKiroIncognitoMode(cfg, useIncognito, noIncognito)
524
+ cmd.DoKiroAWSAuthCodeLogin(cfg, options)
525
+ } else if kiroImport {
526
+ cmd.DoKiroImport(cfg, options)
527
  } else {
528
  // In cloud deploy mode without config file, just wait for shutdown signals
529
  if isCloudDeploy && !configFileExists {
config.example.yaml CHANGED
@@ -43,6 +43,11 @@ debug: false
43
  # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
44
  commercial-mode: false
45
 
 
 
 
 
 
46
  # When true, write application logs to rotating files instead of stdout
47
  logging-to-file: false
48
 
@@ -138,6 +143,16 @@ nonstream-keepalive-interval: 0
138
  # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
139
  # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
140
 
 
 
 
 
 
 
 
 
 
 
141
  # OpenAI compatibility providers
142
  # openai-compatibility:
143
  # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
@@ -206,22 +221,22 @@ nonstream-keepalive-interval: 0
206
  # Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
207
  # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
208
  # You can repeat the same name with different aliases to expose multiple client model names.
209
- oauth-model-alias:
210
- antigravity:
211
- - name: "rev19-uic3-1p"
212
- alias: "gemini-2.5-computer-use-preview-10-2025"
213
- - name: "gemini-3-pro-image"
214
- alias: "gemini-3-pro-image-preview"
215
- - name: "gemini-3-pro-high"
216
- alias: "gemini-3-pro-preview"
217
- - name: "gemini-3-flash"
218
- alias: "gemini-3-flash-preview"
219
- - name: "claude-sonnet-4-5"
220
- alias: "gemini-claude-sonnet-4-5"
221
- - name: "claude-sonnet-4-5-thinking"
222
- alias: "gemini-claude-sonnet-4-5-thinking"
223
- - name: "claude-opus-4-5-thinking"
224
- alias: "gemini-claude-opus-4-5-thinking"
225
  # gemini-cli:
226
  # - name: "gemini-2.5-pro" # original model name under this channel
227
  # alias: "g2.5p" # client-visible alias
@@ -232,6 +247,9 @@ oauth-model-alias:
232
  # aistudio:
233
  # - name: "gemini-2.5-pro"
234
  # alias: "g2.5p"
 
 
 
235
  # claude:
236
  # - name: "claude-sonnet-4-5-20250929"
237
  # alias: "cs4.5"
@@ -244,8 +262,15 @@ oauth-model-alias:
244
  # iflow:
245
  # - name: "glm-4.7"
246
  # alias: "glm-god"
 
 
 
 
 
 
247
 
248
  # OAuth provider excluded models
 
249
  # oauth-excluded-models:
250
  # gemini-cli:
251
  # - "gemini-2.5-pro" # exclude specific models (exact match)
@@ -266,6 +291,10 @@ oauth-model-alias:
266
  # - "vision-model"
267
  # iflow:
268
  # - "tstars2.0"
 
 
 
 
269
 
270
  # Optional payload configuration
271
  # payload:
 
43
  # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
44
  commercial-mode: false
45
 
46
+ # Open OAuth URLs in incognito/private browser mode.
47
+ # Useful when you want to login with a different account without logging out from your current session.
48
+ # Default: false (but Kiro auth defaults to true for multi-account support)
49
+ incognito-browser: true
50
+
51
  # When true, write application logs to rotating files instead of stdout
52
  logging-to-file: false
53
 
 
143
  # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
144
  # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
145
 
146
+ # Kiro (AWS CodeWhisperer) configuration
147
+ # Note: Kiro API currently only operates in us-east-1 region
148
+ #kiro:
149
+ # - token-file: "~/.aws/sso/cache/kiro-auth-token.json" # path to Kiro token file
150
+ # agent-task-type: "" # optional: "vibe" or empty (API default)
151
+ # - access-token: "aoaAAAAA..." # or provide tokens directly
152
+ # refresh-token: "aorAAAAA..."
153
+ # profile-arn: "arn:aws:codewhisperer:us-east-1:..."
154
+ # proxy-url: "socks5://proxy.example.com:1080" # optional: proxy override
155
+
156
  # OpenAI compatibility providers
157
  # openai-compatibility:
158
  # - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
 
221
  # Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
222
  # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
223
  # You can repeat the same name with different aliases to expose multiple client model names.
224
+ #oauth-model-alias:
225
+ # antigravity:
226
+ # - name: "rev19-uic3-1p"
227
+ # alias: "gemini-2.5-computer-use-preview-10-2025"
228
+ # - name: "gemini-3-pro-image"
229
+ # alias: "gemini-3-pro-image-preview"
230
+ # - name: "gemini-3-pro-high"
231
+ # alias: "gemini-3-pro-preview"
232
+ # - name: "gemini-3-flash"
233
+ # alias: "gemini-3-flash-preview"
234
+ # - name: "claude-sonnet-4-5"
235
+ # alias: "gemini-claude-sonnet-4-5"
236
+ # - name: "claude-sonnet-4-5-thinking"
237
+ # alias: "gemini-claude-sonnet-4-5-thinking"
238
+ # - name: "claude-opus-4-5-thinking"
239
+ # alias: "gemini-claude-opus-4-5-thinking"
240
  # gemini-cli:
241
  # - name: "gemini-2.5-pro" # original model name under this channel
242
  # alias: "g2.5p" # client-visible alias
 
247
  # aistudio:
248
  # - name: "gemini-2.5-pro"
249
  # alias: "g2.5p"
250
+ # antigravity:
251
+ # - name: "gemini-3-pro-preview"
252
+ # alias: "g3p"
253
  # claude:
254
  # - name: "claude-sonnet-4-5-20250929"
255
  # alias: "cs4.5"
 
262
  # iflow:
263
  # - name: "glm-4.7"
264
  # alias: "glm-god"
265
+ # kiro:
266
+ # - name: "kiro-claude-opus-4-5"
267
+ # alias: "op45"
268
+ # github-copilot:
269
+ # - name: "gpt-5"
270
+ # alias: "copilot-gpt5"
271
 
272
  # OAuth provider excluded models
273
+ # Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
274
  # oauth-excluded-models:
275
  # gemini-cli:
276
  # - "gemini-2.5-pro" # exclude specific models (exact match)
 
291
  # - "vision-model"
292
  # iflow:
293
  # - "tstars2.0"
294
+ # kiro:
295
+ # - "kiro-claude-haiku-4-5"
296
+ # github-copilot:
297
+ # - "raptor-mini"
298
 
299
  # Optional payload configuration
300
  # payload:
docker-compose.yml CHANGED
@@ -1,6 +1,6 @@
1
  services:
2
  cli-proxy-api:
3
- image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
4
  pull_policy: always
5
  build:
6
  context: .
@@ -9,7 +9,7 @@ services:
9
  VERSION: ${VERSION:-dev}
10
  COMMIT: ${COMMIT:-none}
11
  BUILD_DATE: ${BUILD_DATE:-unknown}
12
- container_name: cli-proxy-api
13
  # env_file:
14
  # - .env
15
  environment:
 
1
  services:
2
  cli-proxy-api:
3
+ image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api-plus:latest}
4
  pull_policy: always
5
  build:
6
  context: .
 
9
  VERSION: ${VERSION:-dev}
10
  COMMIT: ${COMMIT:-none}
11
  BUILD_DATE: ${BUILD_DATE:-unknown}
12
+ container_name: cli-proxy-api-plus
13
  # env_file:
14
  # - .env
15
  environment:
go.mod CHANGED
@@ -13,14 +13,15 @@ require (
13
  github.com/joho/godotenv v1.5.1
14
  github.com/klauspost/compress v1.17.4
15
  github.com/minio/minio-go/v7 v7.0.66
 
16
  github.com/sirupsen/logrus v1.9.3
17
- github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
18
  github.com/tidwall/gjson v1.18.0
19
  github.com/tidwall/sjson v1.2.5
20
  github.com/tiktoken-go/tokenizer v0.7.0
21
  golang.org/x/crypto v0.45.0
22
  golang.org/x/net v0.47.0
23
  golang.org/x/oauth2 v0.30.0
 
24
  gopkg.in/natefinch/lumberjack.v2 v2.2.1
25
  gopkg.in/yaml.v3 v3.0.1
26
  )
 
13
  github.com/joho/godotenv v1.5.1
14
  github.com/klauspost/compress v1.17.4
15
  github.com/minio/minio-go/v7 v7.0.66
16
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
17
  github.com/sirupsen/logrus v1.9.3
 
18
  github.com/tidwall/gjson v1.18.0
19
  github.com/tidwall/sjson v1.2.5
20
  github.com/tiktoken-go/tokenizer v0.7.0
21
  golang.org/x/crypto v0.45.0
22
  golang.org/x/net v0.47.0
23
  golang.org/x/oauth2 v0.30.0
24
+ golang.org/x/term v0.37.0
25
  gopkg.in/natefinch/lumberjack.v2 v2.2.1
26
  gopkg.in/yaml.v3 v3.0.1
27
  )
go.sum CHANGED
@@ -116,6 +116,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
116
  github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
117
  github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
118
  github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
 
 
119
  github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
120
  github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
121
  github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -126,8 +128,6 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
126
  github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
127
  github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
128
  github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
129
- github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
130
- github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
131
  github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
132
  github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
133
  github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -169,6 +169,7 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
169
  golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
170
  golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
171
  golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 
172
  golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
173
  golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
174
  golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 
116
  github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
117
  github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
118
  github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
119
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
120
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
121
  github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
122
  github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
123
  github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 
128
  github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
129
  github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
130
  github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 
 
131
  github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
132
  github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
133
  github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 
169
  golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
170
  golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
171
  golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
172
+ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
173
  golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
174
  golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
175
  golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
internal/api/handlers/management/auth_files.go CHANGED
@@ -3,6 +3,9 @@ package management
3
  import (
4
  "bytes"
5
  "context"
 
 
 
6
  "encoding/json"
7
  "errors"
8
  "fmt"
@@ -23,6 +26,7 @@ import (
23
  "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
24
  geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
25
  iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
 
26
  "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
27
  "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
28
  "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
@@ -2287,8 +2291,322 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
2287
  return
2288
  }
2289
  if status != "" {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2290
  c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
2291
  return
2292
  }
2293
  c.JSON(http.StatusOK, gin.H{"status": "wait"})
2294
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import (
4
  "bytes"
5
  "context"
6
+ "crypto/rand"
7
+ "crypto/sha256"
8
+ "encoding/base64"
9
  "encoding/json"
10
  "errors"
11
  "fmt"
 
26
  "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
27
  geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
28
  iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
29
+ kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
30
  "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
31
  "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
32
  "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
 
2291
  return
2292
  }
2293
  if status != "" {
2294
+ if strings.HasPrefix(status, "device_code|") {
2295
+ parts := strings.SplitN(status, "|", 3)
2296
+ if len(parts) == 3 {
2297
+ c.JSON(http.StatusOK, gin.H{
2298
+ "status": "device_code",
2299
+ "verification_url": parts[1],
2300
+ "user_code": parts[2],
2301
+ })
2302
+ return
2303
+ }
2304
+ }
2305
+ if strings.HasPrefix(status, "auth_url|") {
2306
+ authURL := strings.TrimPrefix(status, "auth_url|")
2307
+ c.JSON(http.StatusOK, gin.H{
2308
+ "status": "auth_url",
2309
+ "url": authURL,
2310
+ })
2311
+ return
2312
+ }
2313
  c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
2314
  return
2315
  }
2316
  c.JSON(http.StatusOK, gin.H{"status": "wait"})
2317
  }
2318
+
2319
+ const kiroCallbackPort = 9876
2320
+
2321
+ func (h *Handler) RequestKiroToken(c *gin.Context) {
2322
+ ctx := context.Background()
2323
+
2324
+ // Get the login method from query parameter (default: aws for device code flow)
2325
+ method := strings.ToLower(strings.TrimSpace(c.Query("method")))
2326
+ if method == "" {
2327
+ method = "aws"
2328
+ }
2329
+
2330
+ fmt.Println("Initializing Kiro authentication...")
2331
+
2332
+ state := fmt.Sprintf("kiro-%d", time.Now().UnixNano())
2333
+
2334
+ switch method {
2335
+ case "aws", "builder-id":
2336
+ RegisterOAuthSession(state, "kiro")
2337
+
2338
+ // AWS Builder ID uses device code flow (no callback needed)
2339
+ go func() {
2340
+ ssoClient := kiroauth.NewSSOOIDCClient(h.cfg)
2341
+
2342
+ // Step 1: Register client
2343
+ fmt.Println("Registering client...")
2344
+ regResp, errRegister := ssoClient.RegisterClient(ctx)
2345
+ if errRegister != nil {
2346
+ log.Errorf("Failed to register client: %v", errRegister)
2347
+ SetOAuthSessionError(state, "Failed to register client")
2348
+ return
2349
+ }
2350
+
2351
+ // Step 2: Start device authorization
2352
+ fmt.Println("Starting device authorization...")
2353
+ authResp, errAuth := ssoClient.StartDeviceAuthorization(ctx, regResp.ClientID, regResp.ClientSecret)
2354
+ if errAuth != nil {
2355
+ log.Errorf("Failed to start device auth: %v", errAuth)
2356
+ SetOAuthSessionError(state, "Failed to start device authorization")
2357
+ return
2358
+ }
2359
+
2360
+ // Store the verification URL for the frontend to display.
2361
+ // Using "|" as separator because URLs contain ":".
2362
+ SetOAuthSessionError(state, "device_code|"+authResp.VerificationURIComplete+"|"+authResp.UserCode)
2363
+
2364
+ // Step 3: Poll for token
2365
+ fmt.Println("Waiting for authorization...")
2366
+ interval := 5 * time.Second
2367
+ if authResp.Interval > 0 {
2368
+ interval = time.Duration(authResp.Interval) * time.Second
2369
+ }
2370
+ deadline := time.Now().Add(time.Duration(authResp.ExpiresIn) * time.Second)
2371
+
2372
+ for time.Now().Before(deadline) {
2373
+ select {
2374
+ case <-ctx.Done():
2375
+ SetOAuthSessionError(state, "Authorization cancelled")
2376
+ return
2377
+ case <-time.After(interval):
2378
+ tokenResp, errToken := ssoClient.CreateToken(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode)
2379
+ if errToken != nil {
2380
+ errStr := errToken.Error()
2381
+ if strings.Contains(errStr, "authorization_pending") {
2382
+ continue
2383
+ }
2384
+ if strings.Contains(errStr, "slow_down") {
2385
+ interval += 5 * time.Second
2386
+ continue
2387
+ }
2388
+ log.Errorf("Token creation failed: %v", errToken)
2389
+ SetOAuthSessionError(state, "Token creation failed")
2390
+ return
2391
+ }
2392
+
2393
+ // Success! Save the token
2394
+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
2395
+ email := kiroauth.ExtractEmailFromJWT(tokenResp.AccessToken)
2396
+
2397
+ idPart := kiroauth.SanitizeEmailForFilename(email)
2398
+ if idPart == "" {
2399
+ idPart = fmt.Sprintf("%d", time.Now().UnixNano()%100000)
2400
+ }
2401
+
2402
+ now := time.Now()
2403
+ fileName := fmt.Sprintf("kiro-aws-%s.json", idPart)
2404
+
2405
+ record := &coreauth.Auth{
2406
+ ID: fileName,
2407
+ Provider: "kiro",
2408
+ FileName: fileName,
2409
+ Metadata: map[string]any{
2410
+ "type": "kiro",
2411
+ "access_token": tokenResp.AccessToken,
2412
+ "refresh_token": tokenResp.RefreshToken,
2413
+ "expires_at": expiresAt.Format(time.RFC3339),
2414
+ "auth_method": "builder-id",
2415
+ "provider": "AWS",
2416
+ "client_id": regResp.ClientID,
2417
+ "client_secret": regResp.ClientSecret,
2418
+ "email": email,
2419
+ "last_refresh": now.Format(time.RFC3339),
2420
+ },
2421
+ }
2422
+
2423
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
2424
+ if errSave != nil {
2425
+ log.Errorf("Failed to save authentication tokens: %v", errSave)
2426
+ SetOAuthSessionError(state, "Failed to save authentication tokens")
2427
+ return
2428
+ }
2429
+
2430
+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
2431
+ if email != "" {
2432
+ fmt.Printf("Authenticated as: %s\n", email)
2433
+ }
2434
+ CompleteOAuthSession(state)
2435
+ return
2436
+ }
2437
+ }
2438
+
2439
+ SetOAuthSessionError(state, "Authorization timed out")
2440
+ }()
2441
+
2442
+ // Return immediately with the state for polling
2443
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "state": state, "method": "device_code"})
2444
+
2445
+ case "google", "github":
2446
+ RegisterOAuthSession(state, "kiro")
2447
+
2448
+ // Social auth uses protocol handler - for WEB UI we use a callback forwarder
2449
+ provider := "Google"
2450
+ if method == "github" {
2451
+ provider = "Github"
2452
+ }
2453
+
2454
+ isWebUI := isWebUIRequest(c)
2455
+ if isWebUI {
2456
+ targetURL, errTarget := h.managementCallbackURL("/kiro/callback")
2457
+ if errTarget != nil {
2458
+ log.WithError(errTarget).Error("failed to compute kiro callback target")
2459
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
2460
+ return
2461
+ }
2462
+ if _, errStart := startCallbackForwarder(kiroCallbackPort, "kiro", targetURL); errStart != nil {
2463
+ log.WithError(errStart).Error("failed to start kiro callback forwarder")
2464
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"})
2465
+ return
2466
+ }
2467
+ }
2468
+
2469
+ go func() {
2470
+ if isWebUI {
2471
+ defer stopCallbackForwarder(kiroCallbackPort)
2472
+ }
2473
+
2474
+ socialClient := kiroauth.NewSocialAuthClient(h.cfg)
2475
+
2476
+ // Generate PKCE codes
2477
+ codeVerifier, codeChallenge, errPKCE := generateKiroPKCE()
2478
+ if errPKCE != nil {
2479
+ log.Errorf("Failed to generate PKCE: %v", errPKCE)
2480
+ SetOAuthSessionError(state, "Failed to generate PKCE")
2481
+ return
2482
+ }
2483
+
2484
+ // Build login URL
2485
+ authURL := fmt.Sprintf("%s/login?idp=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&state=%s&prompt=select_account",
2486
+ "https://prod.us-east-1.auth.desktop.kiro.dev",
2487
+ provider,
2488
+ url.QueryEscape(kiroauth.KiroRedirectURI),
2489
+ codeChallenge,
2490
+ state,
2491
+ )
2492
+
2493
+ // Store auth URL for frontend.
2494
+ // Using "|" as separator because URLs contain ":".
2495
+ SetOAuthSessionError(state, "auth_url|"+authURL)
2496
+
2497
+ // Wait for callback file
2498
+ waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-kiro-%s.oauth", state))
2499
+ deadline := time.Now().Add(5 * time.Minute)
2500
+
2501
+ for {
2502
+ if time.Now().After(deadline) {
2503
+ log.Error("oauth flow timed out")
2504
+ SetOAuthSessionError(state, "OAuth flow timed out")
2505
+ return
2506
+ }
2507
+ if data, errRead := os.ReadFile(waitFile); errRead == nil {
2508
+ var m map[string]string
2509
+ _ = json.Unmarshal(data, &m)
2510
+ _ = os.Remove(waitFile)
2511
+ if errStr := m["error"]; errStr != "" {
2512
+ log.Errorf("Authentication failed: %s", errStr)
2513
+ SetOAuthSessionError(state, "Authentication failed")
2514
+ return
2515
+ }
2516
+ if m["state"] != state {
2517
+ log.Errorf("State mismatch")
2518
+ SetOAuthSessionError(state, "State mismatch")
2519
+ return
2520
+ }
2521
+ code := m["code"]
2522
+ if code == "" {
2523
+ log.Error("No authorization code received")
2524
+ SetOAuthSessionError(state, "No authorization code received")
2525
+ return
2526
+ }
2527
+
2528
+ // Exchange code for tokens
2529
+ tokenReq := &kiroauth.CreateTokenRequest{
2530
+ Code: code,
2531
+ CodeVerifier: codeVerifier,
2532
+ RedirectURI: kiroauth.KiroRedirectURI,
2533
+ }
2534
+
2535
+ tokenResp, errToken := socialClient.CreateToken(ctx, tokenReq)
2536
+ if errToken != nil {
2537
+ log.Errorf("Failed to exchange code for tokens: %v", errToken)
2538
+ SetOAuthSessionError(state, "Failed to exchange code for tokens")
2539
+ return
2540
+ }
2541
+
2542
+ // Save the token
2543
+ expiresIn := tokenResp.ExpiresIn
2544
+ if expiresIn <= 0 {
2545
+ expiresIn = 3600
2546
+ }
2547
+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
2548
+ email := kiroauth.ExtractEmailFromJWT(tokenResp.AccessToken)
2549
+
2550
+ idPart := kiroauth.SanitizeEmailForFilename(email)
2551
+ if idPart == "" {
2552
+ idPart = fmt.Sprintf("%d", time.Now().UnixNano()%100000)
2553
+ }
2554
+
2555
+ now := time.Now()
2556
+ fileName := fmt.Sprintf("kiro-%s-%s.json", strings.ToLower(provider), idPart)
2557
+
2558
+ record := &coreauth.Auth{
2559
+ ID: fileName,
2560
+ Provider: "kiro",
2561
+ FileName: fileName,
2562
+ Metadata: map[string]any{
2563
+ "type": "kiro",
2564
+ "access_token": tokenResp.AccessToken,
2565
+ "refresh_token": tokenResp.RefreshToken,
2566
+ "profile_arn": tokenResp.ProfileArn,
2567
+ "expires_at": expiresAt.Format(time.RFC3339),
2568
+ "auth_method": "social",
2569
+ "provider": provider,
2570
+ "email": email,
2571
+ "last_refresh": now.Format(time.RFC3339),
2572
+ },
2573
+ }
2574
+
2575
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
2576
+ if errSave != nil {
2577
+ log.Errorf("Failed to save authentication tokens: %v", errSave)
2578
+ SetOAuthSessionError(state, "Failed to save authentication tokens")
2579
+ return
2580
+ }
2581
+
2582
+ fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
2583
+ if email != "" {
2584
+ fmt.Printf("Authenticated as: %s\n", email)
2585
+ }
2586
+ CompleteOAuthSession(state)
2587
+ return
2588
+ }
2589
+ time.Sleep(500 * time.Millisecond)
2590
+ }
2591
+ }()
2592
+
2593
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "state": state, "method": "social"})
2594
+
2595
+ default:
2596
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid method, use 'aws', 'google', or 'github'"})
2597
+ }
2598
+ }
2599
+
2600
+ // generateKiroPKCE generates PKCE code verifier and challenge for Kiro OAuth.
2601
+ func generateKiroPKCE() (verifier, challenge string, err error) {
2602
+ b := make([]byte, 32)
2603
+ if _, errRead := io.ReadFull(rand.Reader, b); errRead != nil {
2604
+ return "", "", fmt.Errorf("failed to generate random bytes: %w", errRead)
2605
+ }
2606
+ verifier = base64.RawURLEncoding.EncodeToString(b)
2607
+
2608
+ h := sha256.Sum256([]byte(verifier))
2609
+ challenge = base64.RawURLEncoding.EncodeToString(h[:])
2610
+
2611
+ return verifier, challenge, nil
2612
+ }
internal/api/handlers/management/config_basic.go CHANGED
@@ -19,8 +19,8 @@ import (
19
  )
20
 
21
  const (
22
- latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
23
- latestReleaseUserAgent = "CLIProxyAPI"
24
  )
25
 
26
  func (h *Handler) GetConfig(c *gin.Context) {
 
19
  )
20
 
21
  const (
22
+ latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest"
23
+ latestReleaseUserAgent = "CLIProxyAPIPlus"
24
  )
25
 
26
  func (h *Handler) GetConfig(c *gin.Context) {
internal/api/handlers/management/oauth_sessions.go CHANGED
@@ -158,7 +158,12 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool {
158
  return false
159
  }
160
  if session.Status != "" {
161
- return false
 
 
 
 
 
162
  }
163
  if provider == "" {
164
  return true
@@ -231,6 +236,8 @@ func NormalizeOAuthProvider(provider string) (string, error) {
231
  return "antigravity", nil
232
  case "qwen":
233
  return "qwen", nil
 
 
234
  default:
235
  return "", errUnsupportedOAuthFlow
236
  }
 
158
  return false
159
  }
160
  if session.Status != "" {
161
+ if !strings.EqualFold(session.Provider, "kiro") {
162
+ return false
163
+ }
164
+ if !strings.HasPrefix(session.Status, "device_code|") && !strings.HasPrefix(session.Status, "auth_url|") {
165
+ return false
166
+ }
167
  }
168
  if provider == "" {
169
  return true
 
236
  return "antigravity", nil
237
  case "qwen":
238
  return "qwen", nil
239
+ case "kiro":
240
+ return "kiro", nil
241
  default:
242
  return "", errUnsupportedOAuthFlow
243
  }
internal/api/modules/amp/proxy.go CHANGED
@@ -3,8 +3,11 @@ package amp
3
  import (
4
  "bytes"
5
  "compress/gzip"
 
 
6
  "fmt"
7
  "io"
 
8
  "net/http"
9
  "net/http/httputil"
10
  "net/url"
@@ -102,7 +105,15 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
102
  // Modify incoming responses to handle gzip without Content-Encoding
103
  // This addresses the same issue as inline handler gzip handling, but at the proxy level
104
  proxy.ModifyResponse = func(resp *http.Response) error {
105
- // Only process successful responses
 
 
 
 
 
 
 
 
106
  if resp.StatusCode < 200 || resp.StatusCode >= 300 {
107
  return nil
108
  }
@@ -186,9 +197,29 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
186
  return nil
187
  }
188
 
189
- // Error handler for proxy failures
190
  proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
191
- log.Errorf("amp upstream proxy error for %s %s: %v", req.Method, req.URL.Path, err)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  rw.Header().Set("Content-Type", "application/json")
193
  rw.WriteHeader(http.StatusBadGateway)
194
  _, _ = rw.Write([]byte(`{"error":"amp_upstream_proxy_error","message":"Failed to reach Amp upstream"}`))
 
3
  import (
4
  "bytes"
5
  "compress/gzip"
6
+ "context"
7
+ "errors"
8
  "fmt"
9
  "io"
10
+ "net"
11
  "net/http"
12
  "net/http/httputil"
13
  "net/url"
 
105
  // Modify incoming responses to handle gzip without Content-Encoding
106
  // This addresses the same issue as inline handler gzip handling, but at the proxy level
107
  proxy.ModifyResponse = func(resp *http.Response) error {
108
+ // Log upstream error responses for diagnostics (502, 503, etc.)
109
+ // These are NOT proxy connection errors - the upstream responded with an error status
110
+ if resp.StatusCode >= 500 {
111
+ log.Errorf("amp upstream responded with error [%d] for %s %s", resp.StatusCode, resp.Request.Method, resp.Request.URL.Path)
112
+ } else if resp.StatusCode >= 400 {
113
+ log.Warnf("amp upstream responded with client error [%d] for %s %s", resp.StatusCode, resp.Request.Method, resp.Request.URL.Path)
114
+ }
115
+
116
+ // Only process successful responses for gzip decompression
117
  if resp.StatusCode < 200 || resp.StatusCode >= 300 {
118
  return nil
119
  }
 
197
  return nil
198
  }
199
 
200
+ // Error handler for proxy failures with detailed error classification for diagnostics
201
  proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
202
+ // Classify the error type for better diagnostics
203
+ var errType string
204
+ if errors.Is(err, context.DeadlineExceeded) {
205
+ errType = "timeout"
206
+ } else if errors.Is(err, context.Canceled) {
207
+ errType = "canceled"
208
+ } else if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
209
+ errType = "dial_timeout"
210
+ } else if _, ok := err.(net.Error); ok {
211
+ errType = "network_error"
212
+ } else {
213
+ errType = "connection_error"
214
+ }
215
+
216
+ // Don't log as error for context canceled - it's usually client closing connection
217
+ if errors.Is(err, context.Canceled) {
218
+ log.Debugf("amp upstream proxy [%s]: client canceled request for %s %s", errType, req.Method, req.URL.Path)
219
+ } else {
220
+ log.Errorf("amp upstream proxy error [%s] for %s %s: %v", errType, req.Method, req.URL.Path, err)
221
+ }
222
+
223
  rw.Header().Set("Content-Type", "application/json")
224
  rw.WriteHeader(http.StatusBadGateway)
225
  _, _ = rw.Write([]byte(`{"error":"amp_upstream_proxy_error","message":"Failed to reach Amp upstream"}`))
internal/api/modules/amp/response_rewriter.go CHANGED
@@ -29,15 +29,71 @@ func NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRe
29
  }
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  // Write intercepts response writes and buffers them for model name replacement
33
  func (rw *ResponseRewriter) Write(data []byte) (int, error) {
34
- // Detect streaming on first write
35
- if rw.body.Len() == 0 && !rw.isStreaming {
36
  contentType := rw.Header().Get("Content-Type")
37
  rw.isStreaming = strings.Contains(contentType, "text/event-stream") ||
38
  strings.Contains(contentType, "stream")
39
  }
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  if rw.isStreaming {
42
  n, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(data))
43
  if err == nil {
 
29
  }
30
  }
31
 
32
+ const maxBufferedResponseBytes = 2 * 1024 * 1024 // 2MB safety cap
33
+
34
+ func looksLikeSSEChunk(data []byte) bool {
35
+ // Fallback detection: some upstreams may omit/lie about Content-Type, causing SSE to be buffered.
36
+ // Heuristics are intentionally simple and cheap.
37
+ return bytes.Contains(data, []byte("data:")) ||
38
+ bytes.Contains(data, []byte("event:")) ||
39
+ bytes.Contains(data, []byte("message_start")) ||
40
+ bytes.Contains(data, []byte("message_delta")) ||
41
+ bytes.Contains(data, []byte("content_block_start")) ||
42
+ bytes.Contains(data, []byte("content_block_delta")) ||
43
+ bytes.Contains(data, []byte("content_block_stop")) ||
44
+ bytes.Contains(data, []byte("\n\n"))
45
+ }
46
+
47
+ func (rw *ResponseRewriter) enableStreaming(reason string) error {
48
+ if rw.isStreaming {
49
+ return nil
50
+ }
51
+ rw.isStreaming = true
52
+
53
+ // Flush any previously buffered data to avoid reordering or data loss.
54
+ if rw.body != nil && rw.body.Len() > 0 {
55
+ buf := rw.body.Bytes()
56
+ // Copy before Reset() to keep bytes stable.
57
+ toFlush := make([]byte, len(buf))
58
+ copy(toFlush, buf)
59
+ rw.body.Reset()
60
+
61
+ if _, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(toFlush)); err != nil {
62
+ return err
63
+ }
64
+ if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
65
+ flusher.Flush()
66
+ }
67
+ }
68
+
69
+ log.Debugf("amp response rewriter: switched to streaming (%s)", reason)
70
+ return nil
71
+ }
72
+
73
  // Write intercepts response writes and buffers them for model name replacement
74
  func (rw *ResponseRewriter) Write(data []byte) (int, error) {
75
+ // Detect streaming on first write (header-based)
76
+ if !rw.isStreaming && rw.body.Len() == 0 {
77
  contentType := rw.Header().Get("Content-Type")
78
  rw.isStreaming = strings.Contains(contentType, "text/event-stream") ||
79
  strings.Contains(contentType, "stream")
80
  }
81
 
82
+ if !rw.isStreaming {
83
+ // Content-based fallback: detect SSE-like chunks even if Content-Type is missing/wrong.
84
+ if looksLikeSSEChunk(data) {
85
+ if err := rw.enableStreaming("sse heuristic"); err != nil {
86
+ return 0, err
87
+ }
88
+ } else if rw.body.Len()+len(data) > maxBufferedResponseBytes {
89
+ // Safety cap: avoid unbounded buffering on large responses.
90
+ log.Warnf("amp response rewriter: buffer exceeded %d bytes, switching to streaming", maxBufferedResponseBytes)
91
+ if err := rw.enableStreaming("buffer limit"); err != nil {
92
+ return 0, err
93
+ }
94
+ }
95
+ }
96
+
97
  if rw.isStreaming {
98
  n, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(data))
99
  if err == nil {
internal/api/server.go CHANGED
@@ -348,6 +348,12 @@ func (s *Server) setupRoutes() {
348
  },
349
  })
350
  })
 
 
 
 
 
 
351
  s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
352
 
353
  // OAuth callback endpoints (reuse main server port)
@@ -423,6 +429,20 @@ func (s *Server) setupRoutes() {
423
  c.String(http.StatusOK, oauthCallbackSuccessHTML)
424
  })
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  // Management routes are registered lazily by registerManagementRoutes when a secret is configured.
427
  }
428
 
@@ -620,6 +640,7 @@ func (s *Server) registerManagementRoutes() {
620
  mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
621
  mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
622
  mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
 
623
  mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
624
  mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
625
  }
 
348
  },
349
  })
350
  })
351
+
352
+ // Event logging endpoint - handles Claude Code telemetry requests
353
+ // Returns 200 OK to prevent 404 errors in logs
354
+ s.engine.POST("/api/event_logging/batch", func(c *gin.Context) {
355
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
356
+ })
357
  s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
358
 
359
  // OAuth callback endpoints (reuse main server port)
 
429
  c.String(http.StatusOK, oauthCallbackSuccessHTML)
430
  })
431
 
432
+ s.engine.GET("/kiro/callback", func(c *gin.Context) {
433
+ code := c.Query("code")
434
+ state := c.Query("state")
435
+ errStr := c.Query("error")
436
+ if errStr == "" {
437
+ errStr = c.Query("error_description")
438
+ }
439
+ if state != "" {
440
+ _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "kiro", state, code, errStr)
441
+ }
442
+ c.Header("Content-Type", "text/html; charset=utf-8")
443
+ c.String(http.StatusOK, oauthCallbackSuccessHTML)
444
+ })
445
+
446
  // Management routes are registered lazily by registerManagementRoutes when a secret is configured.
447
  }
448
 
 
640
  mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
641
  mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
642
  mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
643
+ mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
644
  mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
645
  mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
646
  }
internal/auth/claude/oauth_server.go CHANGED
@@ -242,6 +242,11 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
242
  platformURL = "https://console.anthropic.com/"
243
  }
244
 
 
 
 
 
 
245
  // Generate success page HTML with dynamic content
246
  successHTML := s.generateSuccessHTML(setupRequired, platformURL)
247
 
@@ -251,6 +256,12 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
251
  }
252
  }
253
 
 
 
 
 
 
 
254
  // generateSuccessHTML creates the HTML content for the success page.
255
  // It customizes the page based on whether additional setup is required
256
  // and includes a link to the platform.
 
242
  platformURL = "https://console.anthropic.com/"
243
  }
244
 
245
+ // Validate platformURL to prevent XSS - only allow http/https URLs
246
+ if !isValidURL(platformURL) {
247
+ platformURL = "https://console.anthropic.com/"
248
+ }
249
+
250
  // Generate success page HTML with dynamic content
251
  successHTML := s.generateSuccessHTML(setupRequired, platformURL)
252
 
 
256
  }
257
  }
258
 
259
+ // isValidURL checks if the URL is a valid http/https URL to prevent XSS
260
+ func isValidURL(urlStr string) bool {
261
+ urlStr = strings.TrimSpace(urlStr)
262
+ return strings.HasPrefix(urlStr, "https://") || strings.HasPrefix(urlStr, "http://")
263
+ }
264
+
265
  // generateSuccessHTML creates the HTML content for the success page.
266
  // It customizes the page based on whether additional setup is required
267
  // and includes a link to the platform.
internal/auth/codex/oauth_server.go CHANGED
@@ -239,6 +239,11 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
239
  platformURL = "https://platform.openai.com"
240
  }
241
 
 
 
 
 
 
242
  // Generate success page HTML with dynamic content
243
  successHTML := s.generateSuccessHTML(setupRequired, platformURL)
244
 
@@ -248,6 +253,12 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
248
  }
249
  }
250
 
 
 
 
 
 
 
251
  // generateSuccessHTML creates the HTML content for the success page.
252
  // It customizes the page based on whether additional setup is required
253
  // and includes a link to the platform.
 
239
  platformURL = "https://platform.openai.com"
240
  }
241
 
242
+ // Validate platformURL to prevent XSS - only allow http/https URLs
243
+ if !isValidURL(platformURL) {
244
+ platformURL = "https://platform.openai.com"
245
+ }
246
+
247
  // Generate success page HTML with dynamic content
248
  successHTML := s.generateSuccessHTML(setupRequired, platformURL)
249
 
 
253
  }
254
  }
255
 
256
+ // isValidURL checks if the URL is a valid http/https URL to prevent XSS
257
+ func isValidURL(urlStr string) bool {
258
+ urlStr = strings.TrimSpace(urlStr)
259
+ return strings.HasPrefix(urlStr, "https://") || strings.HasPrefix(urlStr, "http://")
260
+ }
261
+
262
  // generateSuccessHTML creates the HTML content for the success page.
263
  // It customizes the page based on whether additional setup is required
264
  // and includes a link to the platform.
internal/auth/copilot/copilot_auth.go ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package copilot provides authentication and token management for GitHub Copilot API.
2
+ // It handles the OAuth2 device flow for secure authentication with the Copilot API.
3
+ package copilot
4
+
5
+ import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "time"
12
+
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
15
+ log "github.com/sirupsen/logrus"
16
+ )
17
+
18
+ const (
19
+ // copilotAPITokenURL is the endpoint for getting Copilot API tokens from GitHub token.
20
+ copilotAPITokenURL = "https://api.github.com/copilot_internal/v2/token"
21
+ // copilotAPIEndpoint is the base URL for making API requests.
22
+ copilotAPIEndpoint = "https://api.githubcopilot.com"
23
+
24
+ // Common HTTP header values for Copilot API requests.
25
+ copilotUserAgent = "GithubCopilot/1.0"
26
+ copilotEditorVersion = "vscode/1.100.0"
27
+ copilotPluginVersion = "copilot/1.300.0"
28
+ copilotIntegrationID = "vscode-chat"
29
+ copilotOpenAIIntent = "conversation-panel"
30
+ )
31
+
32
+ // CopilotAPIToken represents the Copilot API token response.
33
+ type CopilotAPIToken struct {
34
+ // Token is the JWT token for authenticating with the Copilot API.
35
+ Token string `json:"token"`
36
+ // ExpiresAt is the Unix timestamp when the token expires.
37
+ ExpiresAt int64 `json:"expires_at"`
38
+ // Endpoints contains the available API endpoints.
39
+ Endpoints struct {
40
+ API string `json:"api"`
41
+ Proxy string `json:"proxy"`
42
+ OriginTracker string `json:"origin-tracker"`
43
+ Telemetry string `json:"telemetry"`
44
+ } `json:"endpoints,omitempty"`
45
+ // ErrorDetails contains error information if the request failed.
46
+ ErrorDetails *struct {
47
+ URL string `json:"url"`
48
+ Message string `json:"message"`
49
+ DocumentationURL string `json:"documentation_url"`
50
+ } `json:"error_details,omitempty"`
51
+ }
52
+
53
+ // CopilotAuth handles GitHub Copilot authentication flow.
54
+ // It provides methods for device flow authentication and token management.
55
+ type CopilotAuth struct {
56
+ httpClient *http.Client
57
+ deviceClient *DeviceFlowClient
58
+ cfg *config.Config
59
+ }
60
+
61
+ // NewCopilotAuth creates a new CopilotAuth service instance.
62
+ // It initializes an HTTP client with proxy settings from the provided configuration.
63
+ func NewCopilotAuth(cfg *config.Config) *CopilotAuth {
64
+ return &CopilotAuth{
65
+ httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}),
66
+ deviceClient: NewDeviceFlowClient(cfg),
67
+ cfg: cfg,
68
+ }
69
+ }
70
+
71
+ // StartDeviceFlow initiates the device flow authentication.
72
+ // Returns the device code response containing the user code and verification URI.
73
+ func (c *CopilotAuth) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
74
+ return c.deviceClient.RequestDeviceCode(ctx)
75
+ }
76
+
77
+ // WaitForAuthorization polls for user authorization and returns the auth bundle.
78
+ func (c *CopilotAuth) WaitForAuthorization(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotAuthBundle, error) {
79
+ tokenData, err := c.deviceClient.PollForToken(ctx, deviceCode)
80
+ if err != nil {
81
+ return nil, err
82
+ }
83
+
84
+ // Fetch the GitHub username
85
+ username, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken)
86
+ if err != nil {
87
+ log.Warnf("copilot: failed to fetch user info: %v", err)
88
+ username = "unknown"
89
+ }
90
+
91
+ return &CopilotAuthBundle{
92
+ TokenData: tokenData,
93
+ Username: username,
94
+ }, nil
95
+ }
96
+
97
+ // GetCopilotAPIToken exchanges a GitHub access token for a Copilot API token.
98
+ // This token is used to make authenticated requests to the Copilot API.
99
+ func (c *CopilotAuth) GetCopilotAPIToken(ctx context.Context, githubAccessToken string) (*CopilotAPIToken, error) {
100
+ if githubAccessToken == "" {
101
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("github access token is empty"))
102
+ }
103
+
104
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotAPITokenURL, nil)
105
+ if err != nil {
106
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
107
+ }
108
+
109
+ req.Header.Set("Authorization", "token "+githubAccessToken)
110
+ req.Header.Set("Accept", "application/json")
111
+ req.Header.Set("User-Agent", copilotUserAgent)
112
+ req.Header.Set("Editor-Version", copilotEditorVersion)
113
+ req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
114
+
115
+ resp, err := c.httpClient.Do(req)
116
+ if err != nil {
117
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
118
+ }
119
+ defer func() {
120
+ if errClose := resp.Body.Close(); errClose != nil {
121
+ log.Errorf("copilot api token: close body error: %v", errClose)
122
+ }
123
+ }()
124
+
125
+ bodyBytes, err := io.ReadAll(resp.Body)
126
+ if err != nil {
127
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
128
+ }
129
+
130
+ if !isHTTPSuccess(resp.StatusCode) {
131
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed,
132
+ fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
133
+ }
134
+
135
+ var apiToken CopilotAPIToken
136
+ if err = json.Unmarshal(bodyBytes, &apiToken); err != nil {
137
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
138
+ }
139
+
140
+ if apiToken.Token == "" {
141
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty copilot api token"))
142
+ }
143
+
144
+ return &apiToken, nil
145
+ }
146
+
147
+ // ValidateToken checks if a GitHub access token is valid by attempting to fetch user info.
148
+ func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bool, string, error) {
149
+ if accessToken == "" {
150
+ return false, "", nil
151
+ }
152
+
153
+ username, err := c.deviceClient.FetchUserInfo(ctx, accessToken)
154
+ if err != nil {
155
+ return false, "", err
156
+ }
157
+
158
+ return true, username, nil
159
+ }
160
+
161
+ // CreateTokenStorage creates a new CopilotTokenStorage from auth bundle.
162
+ func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotTokenStorage {
163
+ return &CopilotTokenStorage{
164
+ AccessToken: bundle.TokenData.AccessToken,
165
+ TokenType: bundle.TokenData.TokenType,
166
+ Scope: bundle.TokenData.Scope,
167
+ Username: bundle.Username,
168
+ Type: "github-copilot",
169
+ }
170
+ }
171
+
172
+ // LoadAndValidateToken loads a token from storage and validates it.
173
+ // Returns the storage if valid, or an error if the token is invalid or expired.
174
+ func (c *CopilotAuth) LoadAndValidateToken(ctx context.Context, storage *CopilotTokenStorage) (bool, error) {
175
+ if storage == nil || storage.AccessToken == "" {
176
+ return false, fmt.Errorf("no token available")
177
+ }
178
+
179
+ // Check if we can still use the GitHub token to get a Copilot API token
180
+ apiToken, err := c.GetCopilotAPIToken(ctx, storage.AccessToken)
181
+ if err != nil {
182
+ return false, err
183
+ }
184
+
185
+ // Check if the API token is expired
186
+ if apiToken.ExpiresAt > 0 && time.Now().Unix() >= apiToken.ExpiresAt {
187
+ return false, fmt.Errorf("copilot api token expired")
188
+ }
189
+
190
+ return true, nil
191
+ }
192
+
193
+ // GetAPIEndpoint returns the Copilot API endpoint URL.
194
+ func (c *CopilotAuth) GetAPIEndpoint() string {
195
+ return copilotAPIEndpoint
196
+ }
197
+
198
+ // MakeAuthenticatedRequest creates an authenticated HTTP request to the Copilot API.
199
+ func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url string, body io.Reader, apiToken *CopilotAPIToken) (*http.Request, error) {
200
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
201
+ if err != nil {
202
+ return nil, fmt.Errorf("failed to create request: %w", err)
203
+ }
204
+
205
+ req.Header.Set("Authorization", "Bearer "+apiToken.Token)
206
+ req.Header.Set("Content-Type", "application/json")
207
+ req.Header.Set("Accept", "application/json")
208
+ req.Header.Set("User-Agent", copilotUserAgent)
209
+ req.Header.Set("Editor-Version", copilotEditorVersion)
210
+ req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
211
+ req.Header.Set("Openai-Intent", copilotOpenAIIntent)
212
+ req.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
213
+
214
+ return req, nil
215
+ }
216
+
217
+ // buildChatCompletionURL builds the URL for chat completions API.
218
+ func buildChatCompletionURL() string {
219
+ return copilotAPIEndpoint + "/chat/completions"
220
+ }
221
+
222
+ // isHTTPSuccess checks if the status code indicates success (2xx).
223
+ func isHTTPSuccess(statusCode int) bool {
224
+ return statusCode >= 200 && statusCode < 300
225
+ }
internal/auth/copilot/errors.go ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package copilot
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "net/http"
7
+ )
8
+
9
+ // OAuthError represents an OAuth-specific error.
10
+ type OAuthError struct {
11
+ // Code is the OAuth error code.
12
+ Code string `json:"error"`
13
+ // Description is a human-readable description of the error.
14
+ Description string `json:"error_description,omitempty"`
15
+ // URI is a URI identifying a human-readable web page with information about the error.
16
+ URI string `json:"error_uri,omitempty"`
17
+ // StatusCode is the HTTP status code associated with the error.
18
+ StatusCode int `json:"-"`
19
+ }
20
+
21
+ // Error returns a string representation of the OAuth error.
22
+ func (e *OAuthError) Error() string {
23
+ if e.Description != "" {
24
+ return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
25
+ }
26
+ return fmt.Sprintf("OAuth error: %s", e.Code)
27
+ }
28
+
29
+ // NewOAuthError creates a new OAuth error with the specified code, description, and status code.
30
+ func NewOAuthError(code, description string, statusCode int) *OAuthError {
31
+ return &OAuthError{
32
+ Code: code,
33
+ Description: description,
34
+ StatusCode: statusCode,
35
+ }
36
+ }
37
+
38
+ // AuthenticationError represents authentication-related errors.
39
+ type AuthenticationError struct {
40
+ // Type is the type of authentication error.
41
+ Type string `json:"type"`
42
+ // Message is a human-readable message describing the error.
43
+ Message string `json:"message"`
44
+ // Code is the HTTP status code associated with the error.
45
+ Code int `json:"code"`
46
+ // Cause is the underlying error that caused this authentication error.
47
+ Cause error `json:"-"`
48
+ }
49
+
50
+ // Error returns a string representation of the authentication error.
51
+ func (e *AuthenticationError) Error() string {
52
+ if e.Cause != nil {
53
+ return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
54
+ }
55
+ return fmt.Sprintf("%s: %s", e.Type, e.Message)
56
+ }
57
+
58
+ // Unwrap returns the underlying cause of the error.
59
+ func (e *AuthenticationError) Unwrap() error {
60
+ return e.Cause
61
+ }
62
+
63
+ // Common authentication error types for GitHub Copilot device flow.
64
+ var (
65
+ // ErrDeviceCodeFailed represents an error when requesting the device code fails.
66
+ ErrDeviceCodeFailed = &AuthenticationError{
67
+ Type: "device_code_failed",
68
+ Message: "Failed to request device code from GitHub",
69
+ Code: http.StatusBadRequest,
70
+ }
71
+
72
+ // ErrDeviceCodeExpired represents an error when the device code has expired.
73
+ ErrDeviceCodeExpired = &AuthenticationError{
74
+ Type: "device_code_expired",
75
+ Message: "Device code has expired. Please try again.",
76
+ Code: http.StatusGone,
77
+ }
78
+
79
+ // ErrAuthorizationPending represents a pending authorization state (not an error, used for polling).
80
+ ErrAuthorizationPending = &AuthenticationError{
81
+ Type: "authorization_pending",
82
+ Message: "Authorization is pending. Waiting for user to authorize.",
83
+ Code: http.StatusAccepted,
84
+ }
85
+
86
+ // ErrSlowDown represents a request to slow down polling.
87
+ ErrSlowDown = &AuthenticationError{
88
+ Type: "slow_down",
89
+ Message: "Polling too frequently. Slowing down.",
90
+ Code: http.StatusTooManyRequests,
91
+ }
92
+
93
+ // ErrAccessDenied represents an error when the user denies authorization.
94
+ ErrAccessDenied = &AuthenticationError{
95
+ Type: "access_denied",
96
+ Message: "User denied authorization",
97
+ Code: http.StatusForbidden,
98
+ }
99
+
100
+ // ErrTokenExchangeFailed represents an error when token exchange fails.
101
+ ErrTokenExchangeFailed = &AuthenticationError{
102
+ Type: "token_exchange_failed",
103
+ Message: "Failed to exchange device code for access token",
104
+ Code: http.StatusBadRequest,
105
+ }
106
+
107
+ // ErrPollingTimeout represents an error when polling times out.
108
+ ErrPollingTimeout = &AuthenticationError{
109
+ Type: "polling_timeout",
110
+ Message: "Timeout waiting for user authorization",
111
+ Code: http.StatusRequestTimeout,
112
+ }
113
+
114
+ // ErrUserInfoFailed represents an error when fetching user info fails.
115
+ ErrUserInfoFailed = &AuthenticationError{
116
+ Type: "user_info_failed",
117
+ Message: "Failed to fetch GitHub user information",
118
+ Code: http.StatusBadRequest,
119
+ }
120
+ )
121
+
122
+ // NewAuthenticationError creates a new authentication error with a cause based on a base error.
123
+ func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
124
+ return &AuthenticationError{
125
+ Type: baseErr.Type,
126
+ Message: baseErr.Message,
127
+ Code: baseErr.Code,
128
+ Cause: cause,
129
+ }
130
+ }
131
+
132
+ // IsAuthenticationError checks if an error is an authentication error.
133
+ func IsAuthenticationError(err error) bool {
134
+ var authenticationError *AuthenticationError
135
+ ok := errors.As(err, &authenticationError)
136
+ return ok
137
+ }
138
+
139
+ // IsOAuthError checks if an error is an OAuth error.
140
+ func IsOAuthError(err error) bool {
141
+ var oAuthError *OAuthError
142
+ ok := errors.As(err, &oAuthError)
143
+ return ok
144
+ }
145
+
146
+ // GetUserFriendlyMessage returns a user-friendly error message based on the error type.
147
+ func GetUserFriendlyMessage(err error) string {
148
+ var authErr *AuthenticationError
149
+ if errors.As(err, &authErr) {
150
+ switch authErr.Type {
151
+ case "device_code_failed":
152
+ return "Failed to start GitHub authentication. Please check your network connection and try again."
153
+ case "device_code_expired":
154
+ return "The authentication code has expired. Please try again."
155
+ case "authorization_pending":
156
+ return "Waiting for you to authorize the application on GitHub."
157
+ case "slow_down":
158
+ return "Please wait a moment before trying again."
159
+ case "access_denied":
160
+ return "Authentication was cancelled or denied."
161
+ case "token_exchange_failed":
162
+ return "Failed to complete authentication. Please try again."
163
+ case "polling_timeout":
164
+ return "Authentication timed out. Please try again."
165
+ case "user_info_failed":
166
+ return "Failed to get your GitHub account information. Please try again."
167
+ default:
168
+ return "Authentication failed. Please try again."
169
+ }
170
+ }
171
+
172
+ var oauthErr *OAuthError
173
+ if errors.As(err, &oauthErr) {
174
+ switch oauthErr.Code {
175
+ case "access_denied":
176
+ return "Authentication was cancelled or denied."
177
+ case "invalid_request":
178
+ return "Invalid authentication request. Please try again."
179
+ case "server_error":
180
+ return "GitHub server error. Please try again later."
181
+ default:
182
+ return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
183
+ }
184
+ }
185
+
186
+ return "An unexpected error occurred. Please try again."
187
+ }
internal/auth/copilot/oauth.go ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package copilot
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "net/url"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
16
+ log "github.com/sirupsen/logrus"
17
+ )
18
+
19
+ const (
20
+ // copilotClientID is GitHub's Copilot CLI OAuth client ID.
21
+ copilotClientID = "Iv1.b507a08c87ecfe98"
22
+ // copilotDeviceCodeURL is the endpoint for requesting device codes.
23
+ copilotDeviceCodeURL = "https://github.com/login/device/code"
24
+ // copilotTokenURL is the endpoint for exchanging device codes for tokens.
25
+ copilotTokenURL = "https://github.com/login/oauth/access_token"
26
+ // copilotUserInfoURL is the endpoint for fetching GitHub user information.
27
+ copilotUserInfoURL = "https://api.github.com/user"
28
+ // defaultPollInterval is the default interval for polling token endpoint.
29
+ defaultPollInterval = 5 * time.Second
30
+ // maxPollDuration is the maximum time to wait for user authorization.
31
+ maxPollDuration = 15 * time.Minute
32
+ )
33
+
34
+ // DeviceFlowClient handles the OAuth2 device flow for GitHub Copilot.
35
+ type DeviceFlowClient struct {
36
+ httpClient *http.Client
37
+ cfg *config.Config
38
+ }
39
+
40
+ // NewDeviceFlowClient creates a new device flow client.
41
+ func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {
42
+ client := &http.Client{Timeout: 30 * time.Second}
43
+ if cfg != nil {
44
+ client = util.SetProxy(&cfg.SDKConfig, client)
45
+ }
46
+ return &DeviceFlowClient{
47
+ httpClient: client,
48
+ cfg: cfg,
49
+ }
50
+ }
51
+
52
+ // RequestDeviceCode initiates the device flow by requesting a device code from GitHub.
53
+ func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) {
54
+ data := url.Values{}
55
+ data.Set("client_id", copilotClientID)
56
+ data.Set("scope", "user:email")
57
+
58
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotDeviceCodeURL, strings.NewReader(data.Encode()))
59
+ if err != nil {
60
+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
61
+ }
62
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
63
+ req.Header.Set("Accept", "application/json")
64
+
65
+ resp, err := c.httpClient.Do(req)
66
+ if err != nil {
67
+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
68
+ }
69
+ defer func() {
70
+ if errClose := resp.Body.Close(); errClose != nil {
71
+ log.Errorf("copilot device code: close body error: %v", errClose)
72
+ }
73
+ }()
74
+
75
+ if !isHTTPSuccess(resp.StatusCode) {
76
+ bodyBytes, _ := io.ReadAll(resp.Body)
77
+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
78
+ }
79
+
80
+ var deviceCode DeviceCodeResponse
81
+ if err = json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil {
82
+ return nil, NewAuthenticationError(ErrDeviceCodeFailed, err)
83
+ }
84
+
85
+ return &deviceCode, nil
86
+ }
87
+
88
+ // PollForToken polls the token endpoint until the user authorizes or the device code expires.
89
+ func (c *DeviceFlowClient) PollForToken(ctx context.Context, deviceCode *DeviceCodeResponse) (*CopilotTokenData, error) {
90
+ if deviceCode == nil {
91
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("device code is nil"))
92
+ }
93
+
94
+ interval := time.Duration(deviceCode.Interval) * time.Second
95
+ if interval < defaultPollInterval {
96
+ interval = defaultPollInterval
97
+ }
98
+
99
+ deadline := time.Now().Add(maxPollDuration)
100
+ if deviceCode.ExpiresIn > 0 {
101
+ codeDeadline := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second)
102
+ if codeDeadline.Before(deadline) {
103
+ deadline = codeDeadline
104
+ }
105
+ }
106
+
107
+ ticker := time.NewTicker(interval)
108
+ defer ticker.Stop()
109
+
110
+ for {
111
+ select {
112
+ case <-ctx.Done():
113
+ return nil, NewAuthenticationError(ErrPollingTimeout, ctx.Err())
114
+ case <-ticker.C:
115
+ if time.Now().After(deadline) {
116
+ return nil, ErrPollingTimeout
117
+ }
118
+
119
+ token, err := c.exchangeDeviceCode(ctx, deviceCode.DeviceCode)
120
+ if err != nil {
121
+ var authErr *AuthenticationError
122
+ if errors.As(err, &authErr) {
123
+ switch authErr.Type {
124
+ case ErrAuthorizationPending.Type:
125
+ // Continue polling
126
+ continue
127
+ case ErrSlowDown.Type:
128
+ // Increase interval and continue
129
+ interval += 5 * time.Second
130
+ ticker.Reset(interval)
131
+ continue
132
+ case ErrDeviceCodeExpired.Type:
133
+ return nil, err
134
+ case ErrAccessDenied.Type:
135
+ return nil, err
136
+ }
137
+ }
138
+ return nil, err
139
+ }
140
+ return token, nil
141
+ }
142
+ }
143
+ }
144
+
145
+ // exchangeDeviceCode attempts to exchange the device code for an access token.
146
+ func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode string) (*CopilotTokenData, error) {
147
+ data := url.Values{}
148
+ data.Set("client_id", copilotClientID)
149
+ data.Set("device_code", deviceCode)
150
+ data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
151
+
152
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotTokenURL, strings.NewReader(data.Encode()))
153
+ if err != nil {
154
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
155
+ }
156
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
157
+ req.Header.Set("Accept", "application/json")
158
+
159
+ resp, err := c.httpClient.Do(req)
160
+ if err != nil {
161
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
162
+ }
163
+ defer func() {
164
+ if errClose := resp.Body.Close(); errClose != nil {
165
+ log.Errorf("copilot token exchange: close body error: %v", errClose)
166
+ }
167
+ }()
168
+
169
+ bodyBytes, err := io.ReadAll(resp.Body)
170
+ if err != nil {
171
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
172
+ }
173
+
174
+ // GitHub returns 200 for both success and error cases in device flow
175
+ // Check for OAuth error response first
176
+ var oauthResp struct {
177
+ Error string `json:"error"`
178
+ ErrorDescription string `json:"error_description"`
179
+ AccessToken string `json:"access_token"`
180
+ TokenType string `json:"token_type"`
181
+ Scope string `json:"scope"`
182
+ }
183
+
184
+ if err = json.Unmarshal(bodyBytes, &oauthResp); err != nil {
185
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, err)
186
+ }
187
+
188
+ if oauthResp.Error != "" {
189
+ switch oauthResp.Error {
190
+ case "authorization_pending":
191
+ return nil, ErrAuthorizationPending
192
+ case "slow_down":
193
+ return nil, ErrSlowDown
194
+ case "expired_token":
195
+ return nil, ErrDeviceCodeExpired
196
+ case "access_denied":
197
+ return nil, ErrAccessDenied
198
+ default:
199
+ return nil, NewOAuthError(oauthResp.Error, oauthResp.ErrorDescription, resp.StatusCode)
200
+ }
201
+ }
202
+
203
+ if oauthResp.AccessToken == "" {
204
+ return nil, NewAuthenticationError(ErrTokenExchangeFailed, fmt.Errorf("empty access token"))
205
+ }
206
+
207
+ return &CopilotTokenData{
208
+ AccessToken: oauthResp.AccessToken,
209
+ TokenType: oauthResp.TokenType,
210
+ Scope: oauthResp.Scope,
211
+ }, nil
212
+ }
213
+
214
+ // FetchUserInfo retrieves the GitHub username for the authenticated user.
215
+ func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string) (string, error) {
216
+ if accessToken == "" {
217
+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("access token is empty"))
218
+ }
219
+
220
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotUserInfoURL, nil)
221
+ if err != nil {
222
+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
223
+ }
224
+ req.Header.Set("Authorization", "Bearer "+accessToken)
225
+ req.Header.Set("Accept", "application/json")
226
+ req.Header.Set("User-Agent", "CLIProxyAPI")
227
+
228
+ resp, err := c.httpClient.Do(req)
229
+ if err != nil {
230
+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
231
+ }
232
+ defer func() {
233
+ if errClose := resp.Body.Close(); errClose != nil {
234
+ log.Errorf("copilot user info: close body error: %v", errClose)
235
+ }
236
+ }()
237
+
238
+ if !isHTTPSuccess(resp.StatusCode) {
239
+ bodyBytes, _ := io.ReadAll(resp.Body)
240
+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes)))
241
+ }
242
+
243
+ var userInfo struct {
244
+ Login string `json:"login"`
245
+ }
246
+ if err = json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
247
+ return "", NewAuthenticationError(ErrUserInfoFailed, err)
248
+ }
249
+
250
+ if userInfo.Login == "" {
251
+ return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("empty username"))
252
+ }
253
+
254
+ return userInfo.Login, nil
255
+ }
internal/auth/copilot/token.go ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package copilot provides authentication and token management functionality
2
+ // for GitHub Copilot AI services. It handles OAuth2 device flow token storage,
3
+ // serialization, and retrieval for maintaining authenticated sessions with the Copilot API.
4
+ package copilot
5
+
6
+ import (
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
13
+ )
14
+
15
+ // CopilotTokenStorage stores OAuth2 token information for GitHub Copilot API authentication.
16
+ // It maintains compatibility with the existing auth system while adding Copilot-specific fields
17
+ // for managing access tokens and user account information.
18
+ type CopilotTokenStorage struct {
19
+ // AccessToken is the OAuth2 access token used for authenticating API requests.
20
+ AccessToken string `json:"access_token"`
21
+ // TokenType is the type of token, typically "bearer".
22
+ TokenType string `json:"token_type"`
23
+ // Scope is the OAuth2 scope granted to the token.
24
+ Scope string `json:"scope"`
25
+ // ExpiresAt is the timestamp when the access token expires (if provided).
26
+ ExpiresAt string `json:"expires_at,omitempty"`
27
+ // Username is the GitHub username associated with this token.
28
+ Username string `json:"username"`
29
+ // Type indicates the authentication provider type, always "github-copilot" for this storage.
30
+ Type string `json:"type"`
31
+ }
32
+
33
+ // CopilotTokenData holds the raw OAuth token response from GitHub.
34
+ type CopilotTokenData struct {
35
+ // AccessToken is the OAuth2 access token.
36
+ AccessToken string `json:"access_token"`
37
+ // TokenType is the type of token, typically "bearer".
38
+ TokenType string `json:"token_type"`
39
+ // Scope is the OAuth2 scope granted to the token.
40
+ Scope string `json:"scope"`
41
+ }
42
+
43
+ // CopilotAuthBundle bundles authentication data for storage.
44
+ type CopilotAuthBundle struct {
45
+ // TokenData contains the OAuth token information.
46
+ TokenData *CopilotTokenData
47
+ // Username is the GitHub username.
48
+ Username string
49
+ }
50
+
51
+ // DeviceCodeResponse represents GitHub's device code response.
52
+ type DeviceCodeResponse struct {
53
+ // DeviceCode is the device verification code.
54
+ DeviceCode string `json:"device_code"`
55
+ // UserCode is the code the user must enter at the verification URI.
56
+ UserCode string `json:"user_code"`
57
+ // VerificationURI is the URL where the user should enter the code.
58
+ VerificationURI string `json:"verification_uri"`
59
+ // ExpiresIn is the number of seconds until the device code expires.
60
+ ExpiresIn int `json:"expires_in"`
61
+ // Interval is the minimum number of seconds to wait between polling requests.
62
+ Interval int `json:"interval"`
63
+ }
64
+
65
+ // SaveTokenToFile serializes the Copilot token storage to a JSON file.
66
+ // This method creates the necessary directory structure and writes the token
67
+ // data in JSON format to the specified file path for persistent storage.
68
+ //
69
+ // Parameters:
70
+ // - authFilePath: The full path where the token file should be saved
71
+ //
72
+ // Returns:
73
+ // - error: An error if the operation fails, nil otherwise
74
+ func (ts *CopilotTokenStorage) SaveTokenToFile(authFilePath string) error {
75
+ misc.LogSavingCredentials(authFilePath)
76
+ ts.Type = "github-copilot"
77
+ if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
78
+ return fmt.Errorf("failed to create directory: %v", err)
79
+ }
80
+
81
+ f, err := os.Create(authFilePath)
82
+ if err != nil {
83
+ return fmt.Errorf("failed to create token file: %w", err)
84
+ }
85
+ defer func() {
86
+ _ = f.Close()
87
+ }()
88
+
89
+ if err = json.NewEncoder(f).Encode(ts); err != nil {
90
+ return fmt.Errorf("failed to write token to file: %w", err)
91
+ }
92
+ return nil
93
+ }
internal/auth/iflow/iflow_auth.go CHANGED
@@ -9,6 +9,7 @@ import (
9
  "io"
10
  "net/http"
11
  "net/url"
 
12
  "strings"
13
  "time"
14
 
@@ -28,10 +29,21 @@ const (
28
  iFlowAPIKeyEndpoint = "https://platform.iflow.cn/api/openapi/apikey"
29
 
30
  // Client credentials provided by iFlow for the Code Assist integration.
31
- iFlowOAuthClientID = "10009311001"
32
- iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
 
33
  )
34
 
 
 
 
 
 
 
 
 
 
 
35
  // DefaultAPIBaseURL is the canonical chat completions endpoint.
36
  const DefaultAPIBaseURL = "https://apis.iflow.cn/v1"
37
 
@@ -72,7 +84,7 @@ func (ia *IFlowAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectUR
72
  form.Set("code", code)
73
  form.Set("redirect_uri", redirectURI)
74
  form.Set("client_id", iFlowOAuthClientID)
75
- form.Set("client_secret", iFlowOAuthClientSecret)
76
 
77
  req, err := ia.newTokenRequest(ctx, form)
78
  if err != nil {
@@ -88,7 +100,7 @@ func (ia *IFlowAuth) RefreshTokens(ctx context.Context, refreshToken string) (*I
88
  form.Set("grant_type", "refresh_token")
89
  form.Set("refresh_token", refreshToken)
90
  form.Set("client_id", iFlowOAuthClientID)
91
- form.Set("client_secret", iFlowOAuthClientSecret)
92
 
93
  req, err := ia.newTokenRequest(ctx, form)
94
  if err != nil {
@@ -104,7 +116,7 @@ func (ia *IFlowAuth) newTokenRequest(ctx context.Context, form url.Values) (*htt
104
  return nil, fmt.Errorf("iflow token: create request failed: %w", err)
105
  }
106
 
107
- basic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + ":" + iFlowOAuthClientSecret))
108
  req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
109
  req.Header.Set("Accept", "application/json")
110
  req.Header.Set("Authorization", "Basic "+basic)
 
9
  "io"
10
  "net/http"
11
  "net/url"
12
+ "os"
13
  "strings"
14
  "time"
15
 
 
29
  iFlowAPIKeyEndpoint = "https://platform.iflow.cn/api/openapi/apikey"
30
 
31
  // Client credentials provided by iFlow for the Code Assist integration.
32
+ iFlowOAuthClientID = "10009311001"
33
+ // Default client secret (can be overridden via IFLOW_CLIENT_SECRET env var)
34
+ defaultIFlowClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
35
  )
36
 
37
+ // getIFlowClientSecret returns the iFlow OAuth client secret.
38
+ // It first checks the IFLOW_CLIENT_SECRET environment variable,
39
+ // falling back to the default value if not set.
40
+ func getIFlowClientSecret() string {
41
+ if secret := os.Getenv("IFLOW_CLIENT_SECRET"); secret != "" {
42
+ return secret
43
+ }
44
+ return defaultIFlowClientSecret
45
+ }
46
+
47
  // DefaultAPIBaseURL is the canonical chat completions endpoint.
48
  const DefaultAPIBaseURL = "https://apis.iflow.cn/v1"
49
 
 
84
  form.Set("code", code)
85
  form.Set("redirect_uri", redirectURI)
86
  form.Set("client_id", iFlowOAuthClientID)
87
+ form.Set("client_secret", getIFlowClientSecret())
88
 
89
  req, err := ia.newTokenRequest(ctx, form)
90
  if err != nil {
 
100
  form.Set("grant_type", "refresh_token")
101
  form.Set("refresh_token", refreshToken)
102
  form.Set("client_id", iFlowOAuthClientID)
103
+ form.Set("client_secret", getIFlowClientSecret())
104
 
105
  req, err := ia.newTokenRequest(ctx, form)
106
  if err != nil {
 
116
  return nil, fmt.Errorf("iflow token: create request failed: %w", err)
117
  }
118
 
119
+ basic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + ":" + getIFlowClientSecret()))
120
  req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
121
  req.Header.Set("Accept", "application/json")
122
  req.Header.Set("Authorization", "Basic "+basic)
internal/auth/kiro/aws.go ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package kiro provides authentication functionality for AWS CodeWhisperer (Kiro) API.
2
+ // It includes interfaces and implementations for token storage and authentication methods.
3
+ package kiro
4
+
5
+ import (
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ )
13
+
14
+ // PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
15
+ type PKCECodes struct {
16
+ // CodeVerifier is the cryptographically random string used to correlate
17
+ // the authorization request to the token request
18
+ CodeVerifier string `json:"code_verifier"`
19
+ // CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
20
+ CodeChallenge string `json:"code_challenge"`
21
+ }
22
+
23
+ // KiroTokenData holds OAuth token information from AWS CodeWhisperer (Kiro)
24
+ type KiroTokenData struct {
25
+ // AccessToken is the OAuth2 access token for API access
26
+ AccessToken string `json:"accessToken"`
27
+ // RefreshToken is used to obtain new access tokens
28
+ RefreshToken string `json:"refreshToken"`
29
+ // ProfileArn is the AWS CodeWhisperer profile ARN
30
+ ProfileArn string `json:"profileArn"`
31
+ // ExpiresAt is the timestamp when the token expires
32
+ ExpiresAt string `json:"expiresAt"`
33
+ // AuthMethod indicates the authentication method used (e.g., "builder-id", "social")
34
+ AuthMethod string `json:"authMethod"`
35
+ // Provider indicates the OAuth provider (e.g., "AWS", "Google")
36
+ Provider string `json:"provider"`
37
+ // ClientID is the OIDC client ID (needed for token refresh)
38
+ ClientID string `json:"clientId,omitempty"`
39
+ // ClientSecret is the OIDC client secret (needed for token refresh)
40
+ ClientSecret string `json:"clientSecret,omitempty"`
41
+ // Email is the user's email address (used for file naming)
42
+ Email string `json:"email,omitempty"`
43
+ // StartURL is the IDC/Identity Center start URL (only for IDC auth method)
44
+ StartURL string `json:"startUrl,omitempty"`
45
+ // Region is the AWS region for IDC authentication (only for IDC auth method)
46
+ Region string `json:"region,omitempty"`
47
+ }
48
+
49
+ // KiroAuthBundle aggregates authentication data after OAuth flow completion
50
+ type KiroAuthBundle struct {
51
+ // TokenData contains the OAuth tokens from the authentication flow
52
+ TokenData KiroTokenData `json:"token_data"`
53
+ // LastRefresh is the timestamp of the last token refresh
54
+ LastRefresh string `json:"last_refresh"`
55
+ }
56
+
57
+ // KiroUsageInfo represents usage information from CodeWhisperer API
58
+ type KiroUsageInfo struct {
59
+ // SubscriptionTitle is the subscription plan name (e.g., "KIRO FREE")
60
+ SubscriptionTitle string `json:"subscription_title"`
61
+ // CurrentUsage is the current credit usage
62
+ CurrentUsage float64 `json:"current_usage"`
63
+ // UsageLimit is the maximum credit limit
64
+ UsageLimit float64 `json:"usage_limit"`
65
+ // NextReset is the timestamp of the next usage reset
66
+ NextReset string `json:"next_reset"`
67
+ }
68
+
69
+ // KiroModel represents a model available through the CodeWhisperer API
70
+ type KiroModel struct {
71
+ // ModelID is the unique identifier for the model
72
+ ModelID string `json:"modelId"`
73
+ // ModelName is the human-readable name
74
+ ModelName string `json:"modelName"`
75
+ // Description is the model description
76
+ Description string `json:"description"`
77
+ // RateMultiplier is the credit multiplier for this model
78
+ RateMultiplier float64 `json:"rateMultiplier"`
79
+ // RateUnit is the unit for rate calculation (e.g., "credit")
80
+ RateUnit string `json:"rateUnit"`
81
+ // MaxInputTokens is the maximum input token limit
82
+ MaxInputTokens int `json:"maxInputTokens,omitempty"`
83
+ }
84
+
85
+ // KiroIDETokenFile is the default path to Kiro IDE's token file
86
+ const KiroIDETokenFile = ".aws/sso/cache/kiro-auth-token.json"
87
+
88
+ // LoadKiroIDEToken loads token data from Kiro IDE's token file.
89
+ func LoadKiroIDEToken() (*KiroTokenData, error) {
90
+ homeDir, err := os.UserHomeDir()
91
+ if err != nil {
92
+ return nil, fmt.Errorf("failed to get home directory: %w", err)
93
+ }
94
+
95
+ tokenPath := filepath.Join(homeDir, KiroIDETokenFile)
96
+ data, err := os.ReadFile(tokenPath)
97
+ if err != nil {
98
+ return nil, fmt.Errorf("failed to read Kiro IDE token file (%s): %w", tokenPath, err)
99
+ }
100
+
101
+ var token KiroTokenData
102
+ if err := json.Unmarshal(data, &token); err != nil {
103
+ return nil, fmt.Errorf("failed to parse Kiro IDE token: %w", err)
104
+ }
105
+
106
+ if token.AccessToken == "" {
107
+ return nil, fmt.Errorf("access token is empty in Kiro IDE token file")
108
+ }
109
+
110
+ return &token, nil
111
+ }
112
+
113
+ // LoadKiroTokenFromPath loads token data from a custom path.
114
+ // This supports multiple accounts by allowing different token files.
115
+ func LoadKiroTokenFromPath(tokenPath string) (*KiroTokenData, error) {
116
+ // Expand ~ to home directory
117
+ if len(tokenPath) > 0 && tokenPath[0] == '~' {
118
+ homeDir, err := os.UserHomeDir()
119
+ if err != nil {
120
+ return nil, fmt.Errorf("failed to get home directory: %w", err)
121
+ }
122
+ tokenPath = filepath.Join(homeDir, tokenPath[1:])
123
+ }
124
+
125
+ data, err := os.ReadFile(tokenPath)
126
+ if err != nil {
127
+ return nil, fmt.Errorf("failed to read token file (%s): %w", tokenPath, err)
128
+ }
129
+
130
+ var token KiroTokenData
131
+ if err := json.Unmarshal(data, &token); err != nil {
132
+ return nil, fmt.Errorf("failed to parse token file: %w", err)
133
+ }
134
+
135
+ if token.AccessToken == "" {
136
+ return nil, fmt.Errorf("access token is empty in token file")
137
+ }
138
+
139
+ return &token, nil
140
+ }
141
+
142
+ // ListKiroTokenFiles lists all Kiro token files in the cache directory.
143
+ // This supports multiple accounts by finding all token files.
144
+ func ListKiroTokenFiles() ([]string, error) {
145
+ homeDir, err := os.UserHomeDir()
146
+ if err != nil {
147
+ return nil, fmt.Errorf("failed to get home directory: %w", err)
148
+ }
149
+
150
+ cacheDir := filepath.Join(homeDir, ".aws", "sso", "cache")
151
+
152
+ // Check if directory exists
153
+ if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
154
+ return nil, nil // No token files
155
+ }
156
+
157
+ entries, err := os.ReadDir(cacheDir)
158
+ if err != nil {
159
+ return nil, fmt.Errorf("failed to read cache directory: %w", err)
160
+ }
161
+
162
+ var tokenFiles []string
163
+ for _, entry := range entries {
164
+ if entry.IsDir() {
165
+ continue
166
+ }
167
+ name := entry.Name()
168
+ // Look for kiro token files only (avoid matching unrelated AWS SSO cache files)
169
+ if strings.HasSuffix(name, ".json") && strings.HasPrefix(name, "kiro") {
170
+ tokenFiles = append(tokenFiles, filepath.Join(cacheDir, name))
171
+ }
172
+ }
173
+
174
+ return tokenFiles, nil
175
+ }
176
+
177
+ // LoadAllKiroTokens loads all Kiro tokens from the cache directory.
178
+ // This supports multiple accounts.
179
+ func LoadAllKiroTokens() ([]*KiroTokenData, error) {
180
+ files, err := ListKiroTokenFiles()
181
+ if err != nil {
182
+ return nil, err
183
+ }
184
+
185
+ var tokens []*KiroTokenData
186
+ for _, file := range files {
187
+ token, err := LoadKiroTokenFromPath(file)
188
+ if err != nil {
189
+ // Skip invalid token files
190
+ continue
191
+ }
192
+ tokens = append(tokens, token)
193
+ }
194
+
195
+ return tokens, nil
196
+ }
197
+
198
+ // JWTClaims represents the claims we care about from a JWT token.
199
+ // JWT tokens from Kiro/AWS contain user information in the payload.
200
+ type JWTClaims struct {
201
+ Email string `json:"email,omitempty"`
202
+ Sub string `json:"sub,omitempty"`
203
+ PreferredUser string `json:"preferred_username,omitempty"`
204
+ Name string `json:"name,omitempty"`
205
+ Iss string `json:"iss,omitempty"`
206
+ }
207
+
208
+ // ExtractEmailFromJWT extracts the user's email from a JWT access token.
209
+ // JWT tokens typically have format: header.payload.signature
210
+ // The payload is base64url-encoded JSON containing user claims.
211
+ func ExtractEmailFromJWT(accessToken string) string {
212
+ if accessToken == "" {
213
+ return ""
214
+ }
215
+
216
+ // JWT format: header.payload.signature
217
+ parts := strings.Split(accessToken, ".")
218
+ if len(parts) != 3 {
219
+ return ""
220
+ }
221
+
222
+ // Decode the payload (second part)
223
+ payload := parts[1]
224
+
225
+ // Add padding if needed (base64url requires padding)
226
+ switch len(payload) % 4 {
227
+ case 2:
228
+ payload += "=="
229
+ case 3:
230
+ payload += "="
231
+ }
232
+
233
+ decoded, err := base64.URLEncoding.DecodeString(payload)
234
+ if err != nil {
235
+ // Try RawURLEncoding (no padding)
236
+ decoded, err = base64.RawURLEncoding.DecodeString(parts[1])
237
+ if err != nil {
238
+ return ""
239
+ }
240
+ }
241
+
242
+ var claims JWTClaims
243
+ if err := json.Unmarshal(decoded, &claims); err != nil {
244
+ return ""
245
+ }
246
+
247
+ // Return email if available
248
+ if claims.Email != "" {
249
+ return claims.Email
250
+ }
251
+
252
+ // Fallback to preferred_username (some providers use this)
253
+ if claims.PreferredUser != "" && strings.Contains(claims.PreferredUser, "@") {
254
+ return claims.PreferredUser
255
+ }
256
+
257
+ // Fallback to sub if it looks like an email
258
+ if claims.Sub != "" && strings.Contains(claims.Sub, "@") {
259
+ return claims.Sub
260
+ }
261
+
262
+ return ""
263
+ }
264
+
265
+ // SanitizeEmailForFilename sanitizes an email address for use in a filename.
266
+ // Replaces special characters with underscores and prevents path traversal attacks.
267
+ // Also handles URL-encoded characters to prevent encoded path traversal attempts.
268
+ func SanitizeEmailForFilename(email string) string {
269
+ if email == "" {
270
+ return ""
271
+ }
272
+
273
+ result := email
274
+
275
+ // First, handle URL-encoded path traversal attempts (%2F, %2E, %5C, etc.)
276
+ // This prevents encoded characters from bypassing the sanitization.
277
+ // Note: We replace % last to catch any remaining encodings including double-encoding (%252F)
278
+ result = strings.ReplaceAll(result, "%2F", "_") // /
279
+ result = strings.ReplaceAll(result, "%2f", "_")
280
+ result = strings.ReplaceAll(result, "%5C", "_") // \
281
+ result = strings.ReplaceAll(result, "%5c", "_")
282
+ result = strings.ReplaceAll(result, "%2E", "_") // .
283
+ result = strings.ReplaceAll(result, "%2e", "_")
284
+ result = strings.ReplaceAll(result, "%00", "_") // null byte
285
+ result = strings.ReplaceAll(result, "%", "_") // Catch remaining % to prevent double-encoding attacks
286
+
287
+ // Replace characters that are problematic in filenames
288
+ // Keep @ and . in middle but replace other special characters
289
+ for _, char := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", " ", "\x00"} {
290
+ result = strings.ReplaceAll(result, char, "_")
291
+ }
292
+
293
+ // Prevent path traversal: replace leading dots in each path component
294
+ // This handles cases like "../../../etc/passwd" → "_.._.._.._etc_passwd"
295
+ parts := strings.Split(result, "_")
296
+ for i, part := range parts {
297
+ for strings.HasPrefix(part, ".") {
298
+ part = "_" + part[1:]
299
+ }
300
+ parts[i] = part
301
+ }
302
+ result = strings.Join(parts, "_")
303
+
304
+ return result
305
+ }
internal/auth/kiro/aws_auth.go ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package kiro provides OAuth2 authentication functionality for AWS CodeWhisperer (Kiro) API.
2
+ // This package implements token loading, refresh, and API communication with CodeWhisperer.
3
+ package kiro
4
+
5
+ import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
17
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
18
+ log "github.com/sirupsen/logrus"
19
+ )
20
+
21
+ const (
22
+ // awsKiroEndpoint is used for CodeWhisperer management APIs (GetUsageLimits, ListProfiles, etc.)
23
+ // Note: This is different from the Amazon Q streaming endpoint (q.us-east-1.amazonaws.com)
24
+ // used in kiro_executor.go for GenerateAssistantResponse. Both endpoints are correct
25
+ // for their respective API operations.
26
+ awsKiroEndpoint = "https://codewhisperer.us-east-1.amazonaws.com"
27
+ defaultTokenFile = "~/.aws/sso/cache/kiro-auth-token.json"
28
+ targetGetUsage = "AmazonCodeWhispererService.GetUsageLimits"
29
+ targetListModels = "AmazonCodeWhispererService.ListAvailableModels"
30
+ targetGenerateChat = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"
31
+ )
32
+
33
+ // KiroAuth handles AWS CodeWhisperer authentication and API communication.
34
+ // It provides methods for loading tokens, refreshing expired tokens,
35
+ // and communicating with the CodeWhisperer API.
36
+ type KiroAuth struct {
37
+ httpClient *http.Client
38
+ endpoint string
39
+ }
40
+
41
+ // NewKiroAuth creates a new Kiro authentication service.
42
+ // It initializes the HTTP client with proxy settings from the configuration.
43
+ //
44
+ // Parameters:
45
+ // - cfg: The application configuration containing proxy settings
46
+ //
47
+ // Returns:
48
+ // - *KiroAuth: A new Kiro authentication service instance
49
+ func NewKiroAuth(cfg *config.Config) *KiroAuth {
50
+ return &KiroAuth{
51
+ httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 120 * time.Second}),
52
+ endpoint: awsKiroEndpoint,
53
+ }
54
+ }
55
+
56
+ // LoadTokenFromFile loads token data from a file path.
57
+ // This method reads and parses the token file, expanding ~ to the home directory.
58
+ //
59
+ // Parameters:
60
+ // - tokenFile: Path to the token file (supports ~ expansion)
61
+ //
62
+ // Returns:
63
+ // - *KiroTokenData: The parsed token data
64
+ // - error: An error if file reading or parsing fails
65
+ func (k *KiroAuth) LoadTokenFromFile(tokenFile string) (*KiroTokenData, error) {
66
+ // Expand ~ to home directory
67
+ if strings.HasPrefix(tokenFile, "~") {
68
+ home, err := os.UserHomeDir()
69
+ if err != nil {
70
+ return nil, fmt.Errorf("failed to get home directory: %w", err)
71
+ }
72
+ tokenFile = filepath.Join(home, tokenFile[1:])
73
+ }
74
+
75
+ data, err := os.ReadFile(tokenFile)
76
+ if err != nil {
77
+ return nil, fmt.Errorf("failed to read token file: %w", err)
78
+ }
79
+
80
+ var tokenData KiroTokenData
81
+ if err := json.Unmarshal(data, &tokenData); err != nil {
82
+ return nil, fmt.Errorf("failed to parse token file: %w", err)
83
+ }
84
+
85
+ return &tokenData, nil
86
+ }
87
+
88
+ // IsTokenExpired checks if the token has expired.
89
+ // This method parses the expiration timestamp and compares it with the current time.
90
+ //
91
+ // Parameters:
92
+ // - tokenData: The token data to check
93
+ //
94
+ // Returns:
95
+ // - bool: True if the token has expired, false otherwise
96
+ func (k *KiroAuth) IsTokenExpired(tokenData *KiroTokenData) bool {
97
+ if tokenData.ExpiresAt == "" {
98
+ return true
99
+ }
100
+
101
+ expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
102
+ if err != nil {
103
+ // Try alternate format
104
+ expiresAt, err = time.Parse("2006-01-02T15:04:05.000Z", tokenData.ExpiresAt)
105
+ if err != nil {
106
+ return true
107
+ }
108
+ }
109
+
110
+ return time.Now().After(expiresAt)
111
+ }
112
+
113
+ // makeRequest sends a request to the CodeWhisperer API.
114
+ // This is an internal method for making authenticated API calls.
115
+ //
116
+ // Parameters:
117
+ // - ctx: The context for the request
118
+ // - target: The API target (e.g., "AmazonCodeWhispererService.GetUsageLimits")
119
+ // - accessToken: The OAuth access token
120
+ // - payload: The request payload
121
+ //
122
+ // Returns:
123
+ // - []byte: The response body
124
+ // - error: An error if the request fails
125
+ func (k *KiroAuth) makeRequest(ctx context.Context, target string, accessToken string, payload interface{}) ([]byte, error) {
126
+ jsonBody, err := json.Marshal(payload)
127
+ if err != nil {
128
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
129
+ }
130
+
131
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, k.endpoint, strings.NewReader(string(jsonBody)))
132
+ if err != nil {
133
+ return nil, fmt.Errorf("failed to create request: %w", err)
134
+ }
135
+
136
+ req.Header.Set("Content-Type", "application/x-amz-json-1.0")
137
+ req.Header.Set("x-amz-target", target)
138
+ req.Header.Set("Authorization", "Bearer "+accessToken)
139
+ req.Header.Set("Accept", "application/json")
140
+
141
+ resp, err := k.httpClient.Do(req)
142
+ if err != nil {
143
+ return nil, fmt.Errorf("request failed: %w", err)
144
+ }
145
+ defer func() {
146
+ if errClose := resp.Body.Close(); errClose != nil {
147
+ log.Errorf("failed to close response body: %v", errClose)
148
+ }
149
+ }()
150
+
151
+ body, err := io.ReadAll(resp.Body)
152
+ if err != nil {
153
+ return nil, fmt.Errorf("failed to read response: %w", err)
154
+ }
155
+
156
+ if resp.StatusCode != http.StatusOK {
157
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
158
+ }
159
+
160
+ return body, nil
161
+ }
162
+
163
+ // GetUsageLimits retrieves usage information from the CodeWhisperer API.
164
+ // This method fetches the current usage statistics and subscription information.
165
+ //
166
+ // Parameters:
167
+ // - ctx: The context for the request
168
+ // - tokenData: The token data containing access token and profile ARN
169
+ //
170
+ // Returns:
171
+ // - *KiroUsageInfo: The usage information
172
+ // - error: An error if the request fails
173
+ func (k *KiroAuth) GetUsageLimits(ctx context.Context, tokenData *KiroTokenData) (*KiroUsageInfo, error) {
174
+ payload := map[string]interface{}{
175
+ "origin": "AI_EDITOR",
176
+ "profileArn": tokenData.ProfileArn,
177
+ "resourceType": "AGENTIC_REQUEST",
178
+ }
179
+
180
+ body, err := k.makeRequest(ctx, targetGetUsage, tokenData.AccessToken, payload)
181
+ if err != nil {
182
+ return nil, err
183
+ }
184
+
185
+ var result struct {
186
+ SubscriptionInfo struct {
187
+ SubscriptionTitle string `json:"subscriptionTitle"`
188
+ } `json:"subscriptionInfo"`
189
+ UsageBreakdownList []struct {
190
+ CurrentUsageWithPrecision float64 `json:"currentUsageWithPrecision"`
191
+ UsageLimitWithPrecision float64 `json:"usageLimitWithPrecision"`
192
+ } `json:"usageBreakdownList"`
193
+ NextDateReset float64 `json:"nextDateReset"`
194
+ }
195
+
196
+ if err := json.Unmarshal(body, &result); err != nil {
197
+ return nil, fmt.Errorf("failed to parse usage response: %w", err)
198
+ }
199
+
200
+ usage := &KiroUsageInfo{
201
+ SubscriptionTitle: result.SubscriptionInfo.SubscriptionTitle,
202
+ NextReset: fmt.Sprintf("%v", result.NextDateReset),
203
+ }
204
+
205
+ if len(result.UsageBreakdownList) > 0 {
206
+ usage.CurrentUsage = result.UsageBreakdownList[0].CurrentUsageWithPrecision
207
+ usage.UsageLimit = result.UsageBreakdownList[0].UsageLimitWithPrecision
208
+ }
209
+
210
+ return usage, nil
211
+ }
212
+
213
+ // ListAvailableModels retrieves available models from the CodeWhisperer API.
214
+ // This method fetches the list of AI models available for the authenticated user.
215
+ //
216
+ // Parameters:
217
+ // - ctx: The context for the request
218
+ // - tokenData: The token data containing access token and profile ARN
219
+ //
220
+ // Returns:
221
+ // - []*KiroModel: The list of available models
222
+ // - error: An error if the request fails
223
+ func (k *KiroAuth) ListAvailableModels(ctx context.Context, tokenData *KiroTokenData) ([]*KiroModel, error) {
224
+ payload := map[string]interface{}{
225
+ "origin": "AI_EDITOR",
226
+ "profileArn": tokenData.ProfileArn,
227
+ }
228
+
229
+ body, err := k.makeRequest(ctx, targetListModels, tokenData.AccessToken, payload)
230
+ if err != nil {
231
+ return nil, err
232
+ }
233
+
234
+ var result struct {
235
+ Models []struct {
236
+ ModelID string `json:"modelId"`
237
+ ModelName string `json:"modelName"`
238
+ Description string `json:"description"`
239
+ RateMultiplier float64 `json:"rateMultiplier"`
240
+ RateUnit string `json:"rateUnit"`
241
+ TokenLimits struct {
242
+ MaxInputTokens int `json:"maxInputTokens"`
243
+ } `json:"tokenLimits"`
244
+ } `json:"models"`
245
+ }
246
+
247
+ if err := json.Unmarshal(body, &result); err != nil {
248
+ return nil, fmt.Errorf("failed to parse models response: %w", err)
249
+ }
250
+
251
+ models := make([]*KiroModel, 0, len(result.Models))
252
+ for _, m := range result.Models {
253
+ models = append(models, &KiroModel{
254
+ ModelID: m.ModelID,
255
+ ModelName: m.ModelName,
256
+ Description: m.Description,
257
+ RateMultiplier: m.RateMultiplier,
258
+ RateUnit: m.RateUnit,
259
+ MaxInputTokens: m.TokenLimits.MaxInputTokens,
260
+ })
261
+ }
262
+
263
+ return models, nil
264
+ }
265
+
266
+ // CreateTokenStorage creates a new KiroTokenStorage from token data.
267
+ // This method converts the token data into a storage structure suitable for persistence.
268
+ //
269
+ // Parameters:
270
+ // - tokenData: The token data to convert
271
+ //
272
+ // Returns:
273
+ // - *KiroTokenStorage: A new token storage instance
274
+ func (k *KiroAuth) CreateTokenStorage(tokenData *KiroTokenData) *KiroTokenStorage {
275
+ return &KiroTokenStorage{
276
+ AccessToken: tokenData.AccessToken,
277
+ RefreshToken: tokenData.RefreshToken,
278
+ ProfileArn: tokenData.ProfileArn,
279
+ ExpiresAt: tokenData.ExpiresAt,
280
+ AuthMethod: tokenData.AuthMethod,
281
+ Provider: tokenData.Provider,
282
+ LastRefresh: time.Now().Format(time.RFC3339),
283
+ }
284
+ }
285
+
286
+ // ValidateToken checks if the token is valid by making a test API call.
287
+ // This method verifies the token by attempting to fetch usage limits.
288
+ //
289
+ // Parameters:
290
+ // - ctx: The context for the request
291
+ // - tokenData: The token data to validate
292
+ //
293
+ // Returns:
294
+ // - error: An error if the token is invalid
295
+ func (k *KiroAuth) ValidateToken(ctx context.Context, tokenData *KiroTokenData) error {
296
+ _, err := k.GetUsageLimits(ctx, tokenData)
297
+ return err
298
+ }
299
+
300
+ // UpdateTokenStorage updates an existing token storage with new token data.
301
+ // This method refreshes the token storage with newly obtained access and refresh tokens.
302
+ //
303
+ // Parameters:
304
+ // - storage: The existing token storage to update
305
+ // - tokenData: The new token data to apply
306
+ func (k *KiroAuth) UpdateTokenStorage(storage *KiroTokenStorage, tokenData *KiroTokenData) {
307
+ storage.AccessToken = tokenData.AccessToken
308
+ storage.RefreshToken = tokenData.RefreshToken
309
+ storage.ProfileArn = tokenData.ProfileArn
310
+ storage.ExpiresAt = tokenData.ExpiresAt
311
+ storage.AuthMethod = tokenData.AuthMethod
312
+ storage.Provider = tokenData.Provider
313
+ storage.LastRefresh = time.Now().Format(time.RFC3339)
314
+ }
internal/auth/kiro/aws_test.go ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package kiro
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "encoding/json"
6
+ "testing"
7
+ )
8
+
9
+ func TestExtractEmailFromJWT(t *testing.T) {
10
+ tests := []struct {
11
+ name string
12
+ token string
13
+ expected string
14
+ }{
15
+ {
16
+ name: "Empty token",
17
+ token: "",
18
+ expected: "",
19
+ },
20
+ {
21
+ name: "Invalid token format",
22
+ token: "not.a.valid.jwt",
23
+ expected: "",
24
+ },
25
+ {
26
+ name: "Invalid token - not base64",
27
+ token: "xxx.yyy.zzz",
28
+ expected: "",
29
+ },
30
+ {
31
+ name: "Valid JWT with email",
32
+ token: createTestJWT(map[string]any{"email": "test@example.com", "sub": "user123"}),
33
+ expected: "test@example.com",
34
+ },
35
+ {
36
+ name: "JWT without email but with preferred_username",
37
+ token: createTestJWT(map[string]any{"preferred_username": "user@domain.com", "sub": "user123"}),
38
+ expected: "user@domain.com",
39
+ },
40
+ {
41
+ name: "JWT with email-like sub",
42
+ token: createTestJWT(map[string]any{"sub": "another@test.com"}),
43
+ expected: "another@test.com",
44
+ },
45
+ {
46
+ name: "JWT without any email fields",
47
+ token: createTestJWT(map[string]any{"sub": "user123", "name": "Test User"}),
48
+ expected: "",
49
+ },
50
+ }
51
+
52
+ for _, tt := range tests {
53
+ t.Run(tt.name, func(t *testing.T) {
54
+ result := ExtractEmailFromJWT(tt.token)
55
+ if result != tt.expected {
56
+ t.Errorf("ExtractEmailFromJWT() = %q, want %q", result, tt.expected)
57
+ }
58
+ })
59
+ }
60
+ }
61
+
62
+ func TestSanitizeEmailForFilename(t *testing.T) {
63
+ tests := []struct {
64
+ name string
65
+ email string
66
+ expected string
67
+ }{
68
+ {
69
+ name: "Empty email",
70
+ email: "",
71
+ expected: "",
72
+ },
73
+ {
74
+ name: "Simple email",
75
+ email: "user@example.com",
76
+ expected: "user@example.com",
77
+ },
78
+ {
79
+ name: "Email with space",
80
+ email: "user name@example.com",
81
+ expected: "user_name@example.com",
82
+ },
83
+ {
84
+ name: "Email with special chars",
85
+ email: "user:name@example.com",
86
+ expected: "user_name@example.com",
87
+ },
88
+ {
89
+ name: "Email with multiple special chars",
90
+ email: "user/name:test@example.com",
91
+ expected: "user_name_test@example.com",
92
+ },
93
+ {
94
+ name: "Path traversal attempt",
95
+ email: "../../../etc/passwd",
96
+ expected: "_.__.__._etc_passwd",
97
+ },
98
+ {
99
+ name: "Path traversal with backslash",
100
+ email: `..\..\..\..\windows\system32`,
101
+ expected: "_.__.__.__._windows_system32",
102
+ },
103
+ {
104
+ name: "Null byte injection attempt",
105
+ email: "user\x00@evil.com",
106
+ expected: "user_@evil.com",
107
+ },
108
+ // URL-encoded path traversal tests
109
+ {
110
+ name: "URL-encoded slash",
111
+ email: "user%2Fpath@example.com",
112
+ expected: "user_path@example.com",
113
+ },
114
+ {
115
+ name: "URL-encoded backslash",
116
+ email: "user%5Cpath@example.com",
117
+ expected: "user_path@example.com",
118
+ },
119
+ {
120
+ name: "URL-encoded dot",
121
+ email: "%2E%2E%2Fetc%2Fpasswd",
122
+ expected: "___etc_passwd",
123
+ },
124
+ {
125
+ name: "URL-encoded null",
126
+ email: "user%00@evil.com",
127
+ expected: "user_@evil.com",
128
+ },
129
+ {
130
+ name: "Double URL-encoding attack",
131
+ email: "%252F%252E%252E",
132
+ expected: "_252F_252E_252E", // % replaced with _, remaining chars preserved (safe)
133
+ },
134
+ {
135
+ name: "Mixed case URL-encoding",
136
+ email: "%2f%2F%5c%5C",
137
+ expected: "____",
138
+ },
139
+ }
140
+
141
+ for _, tt := range tests {
142
+ t.Run(tt.name, func(t *testing.T) {
143
+ result := SanitizeEmailForFilename(tt.email)
144
+ if result != tt.expected {
145
+ t.Errorf("SanitizeEmailForFilename() = %q, want %q", result, tt.expected)
146
+ }
147
+ })
148
+ }
149
+ }
150
+
151
+ // createTestJWT creates a test JWT token with the given claims
152
+ func createTestJWT(claims map[string]any) string {
153
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
154
+
155
+ payloadBytes, _ := json.Marshal(claims)
156
+ payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
157
+
158
+ signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
159
+
160
+ return header + "." + payload + "." + signature
161
+ }
internal/auth/kiro/codewhisperer_client.go ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package kiro provides CodeWhisperer API client for fetching user info.
2
+ package kiro
3
+
4
+ import (
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "time"
11
+
12
+ "github.com/google/uuid"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
15
+ log "github.com/sirupsen/logrus"
16
+ )
17
+
18
+ const (
19
+ codeWhispererAPI = "https://codewhisperer.us-east-1.amazonaws.com"
20
+ kiroVersion = "0.6.18"
21
+ )
22
+
23
+ // CodeWhispererClient handles CodeWhisperer API calls.
24
+ type CodeWhispererClient struct {
25
+ httpClient *http.Client
26
+ machineID string
27
+ }
28
+
29
+ // UsageLimitsResponse represents the getUsageLimits API response.
30
+ type UsageLimitsResponse struct {
31
+ DaysUntilReset *int `json:"daysUntilReset,omitempty"`
32
+ NextDateReset *float64 `json:"nextDateReset,omitempty"`
33
+ UserInfo *UserInfo `json:"userInfo,omitempty"`
34
+ SubscriptionInfo *SubscriptionInfo `json:"subscriptionInfo,omitempty"`
35
+ UsageBreakdownList []UsageBreakdown `json:"usageBreakdownList,omitempty"`
36
+ }
37
+
38
+ // UserInfo contains user information from the API.
39
+ type UserInfo struct {
40
+ Email string `json:"email,omitempty"`
41
+ UserID string `json:"userId,omitempty"`
42
+ }
43
+
44
+ // SubscriptionInfo contains subscription details.
45
+ type SubscriptionInfo struct {
46
+ SubscriptionTitle string `json:"subscriptionTitle,omitempty"`
47
+ Type string `json:"type,omitempty"`
48
+ }
49
+
50
+ // UsageBreakdown contains usage details.
51
+ type UsageBreakdown struct {
52
+ UsageLimit *int `json:"usageLimit,omitempty"`
53
+ CurrentUsage *int `json:"currentUsage,omitempty"`
54
+ UsageLimitWithPrecision *float64 `json:"usageLimitWithPrecision,omitempty"`
55
+ CurrentUsageWithPrecision *float64 `json:"currentUsageWithPrecision,omitempty"`
56
+ NextDateReset *float64 `json:"nextDateReset,omitempty"`
57
+ DisplayName string `json:"displayName,omitempty"`
58
+ ResourceType string `json:"resourceType,omitempty"`
59
+ }
60
+
61
+ // NewCodeWhispererClient creates a new CodeWhisperer client.
62
+ func NewCodeWhispererClient(cfg *config.Config, machineID string) *CodeWhispererClient {
63
+ client := &http.Client{Timeout: 30 * time.Second}
64
+ if cfg != nil {
65
+ client = util.SetProxy(&cfg.SDKConfig, client)
66
+ }
67
+ if machineID == "" {
68
+ machineID = uuid.New().String()
69
+ }
70
+ return &CodeWhispererClient{
71
+ httpClient: client,
72
+ machineID: machineID,
73
+ }
74
+ }
75
+
76
+ // generateInvocationID generates a unique invocation ID.
77
+ func generateInvocationID() string {
78
+ return uuid.New().String()
79
+ }
80
+
81
+ // GetUsageLimits fetches usage limits and user info from CodeWhisperer API.
82
+ // This is the recommended way to get user email after login.
83
+ func (c *CodeWhispererClient) GetUsageLimits(ctx context.Context, accessToken string) (*UsageLimitsResponse, error) {
84
+ url := fmt.Sprintf("%s/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST", codeWhispererAPI)
85
+
86
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
87
+ if err != nil {
88
+ return nil, fmt.Errorf("failed to create request: %w", err)
89
+ }
90
+
91
+ // Set headers to match Kiro IDE
92
+ xAmzUserAgent := fmt.Sprintf("aws-sdk-js/1.0.0 KiroIDE-%s-%s", kiroVersion, c.machineID)
93
+ userAgent := fmt.Sprintf("aws-sdk-js/1.0.0 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererruntime#1.0.0 m/E KiroIDE-%s-%s", kiroVersion, c.machineID)
94
+
95
+ req.Header.Set("Authorization", "Bearer "+accessToken)
96
+ req.Header.Set("x-amz-user-agent", xAmzUserAgent)
97
+ req.Header.Set("User-Agent", userAgent)
98
+ req.Header.Set("amz-sdk-invocation-id", generateInvocationID())
99
+ req.Header.Set("amz-sdk-request", "attempt=1; max=1")
100
+ req.Header.Set("Connection", "close")
101
+
102
+ log.Debugf("codewhisperer: GET %s", url)
103
+
104
+ resp, err := c.httpClient.Do(req)
105
+ if err != nil {
106
+ return nil, fmt.Errorf("request failed: %w", err)
107
+ }
108
+ defer resp.Body.Close()
109
+
110
+ body, err := io.ReadAll(resp.Body)
111
+ if err != nil {
112
+ return nil, fmt.Errorf("failed to read response: %w", err)
113
+ }
114
+
115
+ log.Debugf("codewhisperer: status=%d, body=%s", resp.StatusCode, string(body))
116
+
117
+ if resp.StatusCode != http.StatusOK {
118
+ return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
119
+ }
120
+
121
+ var result UsageLimitsResponse
122
+ if err := json.Unmarshal(body, &result); err != nil {
123
+ return nil, fmt.Errorf("failed to parse response: %w", err)
124
+ }
125
+
126
+ return &result, nil
127
+ }
128
+
129
+ // FetchUserEmailFromAPI fetches user email using CodeWhisperer getUsageLimits API.
130
+ // This is more reliable than JWT parsing as it uses the official API.
131
+ func (c *CodeWhispererClient) FetchUserEmailFromAPI(ctx context.Context, accessToken string) string {
132
+ resp, err := c.GetUsageLimits(ctx, accessToken)
133
+ if err != nil {
134
+ log.Debugf("codewhisperer: failed to get usage limits: %v", err)
135
+ return ""
136
+ }
137
+
138
+ if resp.UserInfo != nil && resp.UserInfo.Email != "" {
139
+ log.Debugf("codewhisperer: got email from API: %s", resp.UserInfo.Email)
140
+ return resp.UserInfo.Email
141
+ }
142
+
143
+ log.Debugf("codewhisperer: no email in response")
144
+ return ""
145
+ }
146
+
147
+ // FetchUserEmailWithFallback fetches user email with multiple fallback methods.
148
+ // Priority: 1. CodeWhisperer API 2. userinfo endpoint 3. JWT parsing
149
+ func FetchUserEmailWithFallback(ctx context.Context, cfg *config.Config, accessToken string) string {
150
+ // Method 1: Try CodeWhisperer API (most reliable)
151
+ cwClient := NewCodeWhispererClient(cfg, "")
152
+ email := cwClient.FetchUserEmailFromAPI(ctx, accessToken)
153
+ if email != "" {
154
+ return email
155
+ }
156
+
157
+ // Method 2: Try SSO OIDC userinfo endpoint
158
+ ssoClient := NewSSOOIDCClient(cfg)
159
+ email = ssoClient.FetchUserEmail(ctx, accessToken)
160
+ if email != "" {
161
+ return email
162
+ }
163
+
164
+ // Method 3: Fallback to JWT parsing
165
+ return ExtractEmailFromJWT(accessToken)
166
+ }
internal/auth/kiro/oauth.go ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package kiro provides OAuth2 authentication for Kiro using native Google login.
2
+ package kiro
3
+
4
+ import (
5
+ "context"
6
+ "crypto/rand"
7
+ "crypto/sha256"
8
+ "encoding/base64"
9
+ "encoding/json"
10
+ "fmt"
11
+ "html"
12
+ "io"
13
+ "net"
14
+ "net/http"
15
+ "strings"
16
+ "time"
17
+
18
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
19
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
20
+ log "github.com/sirupsen/logrus"
21
+ )
22
+
23
+ const (
24
+ // Kiro auth endpoint
25
+ kiroAuthEndpoint = "https://prod.us-east-1.auth.desktop.kiro.dev"
26
+
27
+ // Default callback port
28
+ defaultCallbackPort = 9876
29
+
30
+ // Auth timeout
31
+ authTimeout = 10 * time.Minute
32
+ )
33
+
34
+ // KiroTokenResponse represents the response from Kiro token endpoint.
35
+ type KiroTokenResponse struct {
36
+ AccessToken string `json:"accessToken"`
37
+ RefreshToken string `json:"refreshToken"`
38
+ ProfileArn string `json:"profileArn"`
39
+ ExpiresIn int `json:"expiresIn"`
40
+ }
41
+
42
+ // KiroOAuth handles the OAuth flow for Kiro authentication.
43
+ type KiroOAuth struct {
44
+ httpClient *http.Client
45
+ cfg *config.Config
46
+ }
47
+
48
+ // NewKiroOAuth creates a new Kiro OAuth handler.
49
+ func NewKiroOAuth(cfg *config.Config) *KiroOAuth {
50
+ client := &http.Client{Timeout: 30 * time.Second}
51
+ if cfg != nil {
52
+ client = util.SetProxy(&cfg.SDKConfig, client)
53
+ }
54
+ return &KiroOAuth{
55
+ httpClient: client,
56
+ cfg: cfg,
57
+ }
58
+ }
59
+
60
+ // generateCodeVerifier generates a random code verifier for PKCE.
61
+ func generateCodeVerifier() (string, error) {
62
+ b := make([]byte, 32)
63
+ if _, err := rand.Read(b); err != nil {
64
+ return "", err
65
+ }
66
+ return base64.RawURLEncoding.EncodeToString(b), nil
67
+ }
68
+
69
+ // generateCodeChallenge generates the code challenge from verifier.
70
+ func generateCodeChallenge(verifier string) string {
71
+ h := sha256.Sum256([]byte(verifier))
72
+ return base64.RawURLEncoding.EncodeToString(h[:])
73
+ }
74
+
75
+ // generateState generates a random state parameter.
76
+ func generateState() (string, error) {
77
+ b := make([]byte, 16)
78
+ if _, err := rand.Read(b); err != nil {
79
+ return "", err
80
+ }
81
+ return base64.RawURLEncoding.EncodeToString(b), nil
82
+ }
83
+
84
+ // AuthResult contains the authorization code and state from callback.
85
+ type AuthResult struct {
86
+ Code string
87
+ State string
88
+ Error string
89
+ }
90
+
91
+ // startCallbackServer starts a local HTTP server to receive the OAuth callback.
92
+ func (o *KiroOAuth) startCallbackServer(ctx context.Context, expectedState string) (string, <-chan AuthResult, error) {
93
+ // Try to find an available port - use localhost like Kiro does
94
+ listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", defaultCallbackPort))
95
+ if err != nil {
96
+ // Try with dynamic port (RFC 8252 allows dynamic ports for native apps)
97
+ log.Warnf("kiro oauth: default port %d is busy, falling back to dynamic port", defaultCallbackPort)
98
+ listener, err = net.Listen("tcp", "localhost:0")
99
+ if err != nil {
100
+ return "", nil, fmt.Errorf("failed to start callback server: %w", err)
101
+ }
102
+ }
103
+
104
+ port := listener.Addr().(*net.TCPAddr).Port
105
+ // Use http scheme for local callback server
106
+ redirectURI := fmt.Sprintf("http://localhost:%d/oauth/callback", port)
107
+ resultChan := make(chan AuthResult, 1)
108
+
109
+ server := &http.Server{
110
+ ReadHeaderTimeout: 10 * time.Second,
111
+ }
112
+
113
+ mux := http.NewServeMux()
114
+ mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
115
+ code := r.URL.Query().Get("code")
116
+ state := r.URL.Query().Get("state")
117
+ errParam := r.URL.Query().Get("error")
118
+
119
+ if errParam != "" {
120
+ w.Header().Set("Content-Type", "text/html")
121
+ w.WriteHeader(http.StatusBadRequest)
122
+ fmt.Fprintf(w, `<html><body><h1>Login Failed</h1><p>%s</p><p>You can close this window.</p></body></html>`, html.EscapeString(errParam))
123
+ resultChan <- AuthResult{Error: errParam}
124
+ return
125
+ }
126
+
127
+ if state != expectedState {
128
+ w.Header().Set("Content-Type", "text/html")
129
+ w.WriteHeader(http.StatusBadRequest)
130
+ fmt.Fprint(w, `<html><body><h1>Login Failed</h1><p>Invalid state parameter</p><p>You can close this window.</p></body></html>`)
131
+ resultChan <- AuthResult{Error: "state mismatch"}
132
+ return
133
+ }
134
+
135
+ w.Header().Set("Content-Type", "text/html")
136
+ fmt.Fprint(w, `<html><body><h1>Login Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`)
137
+ resultChan <- AuthResult{Code: code, State: state}
138
+ })
139
+
140
+ server.Handler = mux
141
+
142
+ go func() {
143
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
144
+ log.Debugf("callback server error: %v", err)
145
+ }
146
+ }()
147
+
148
+ go func() {
149
+ select {
150
+ case <-ctx.Done():
151
+ case <-time.After(authTimeout):
152
+ case <-resultChan:
153
+ }
154
+ _ = server.Shutdown(context.Background())
155
+ }()
156
+
157
+ return redirectURI, resultChan, nil
158
+ }
159
+
160
+ // LoginWithBuilderID performs OAuth login with AWS Builder ID using device code flow.
161
+ func (o *KiroOAuth) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, error) {
162
+ ssoClient := NewSSOOIDCClient(o.cfg)
163
+ return ssoClient.LoginWithBuilderID(ctx)
164
+ }
165
+
166
+ // LoginWithBuilderIDAuthCode performs OAuth login with AWS Builder ID using authorization code flow.
167
+ // This provides a better UX than device code flow as it uses automatic browser callback.
168
+ func (o *KiroOAuth) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) {
169
+ ssoClient := NewSSOOIDCClient(o.cfg)
170
+ return ssoClient.LoginWithBuilderIDAuthCode(ctx)
171
+ }
172
+
173
+ // exchangeCodeForToken exchanges the authorization code for tokens.
174
+ func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) {
175
+ payload := map[string]string{
176
+ "code": code,
177
+ "code_verifier": codeVerifier,
178
+ "redirect_uri": redirectURI,
179
+ }
180
+
181
+ body, err := json.Marshal(payload)
182
+ if err != nil {
183
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
184
+ }
185
+
186
+ tokenURL := kiroAuthEndpoint + "/oauth/token"
187
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(string(body)))
188
+ if err != nil {
189
+ return nil, fmt.Errorf("failed to create request: %w", err)
190
+ }
191
+
192
+ req.Header.Set("Content-Type", "application/json")
193
+ req.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
194
+
195
+ resp, err := o.httpClient.Do(req)
196
+ if err != nil {
197
+ return nil, fmt.Errorf("token request failed: %w", err)
198
+ }
199
+ defer resp.Body.Close()
200
+
201
+ respBody, err := io.ReadAll(resp.Body)
202
+ if err != nil {
203
+ return nil, fmt.Errorf("failed to read response: %w", err)
204
+ }
205
+
206
+ if resp.StatusCode != http.StatusOK {
207
+ log.Debugf("token exchange failed (status %d): %s", resp.StatusCode, string(respBody))
208
+ return nil, fmt.Errorf("token exchange failed (status %d)", resp.StatusCode)
209
+ }
210
+
211
+ var tokenResp KiroTokenResponse
212
+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
213
+ return nil, fmt.Errorf("failed to parse token response: %w", err)
214
+ }
215
+
216
+ // Validate ExpiresIn - use default 1 hour if invalid
217
+ expiresIn := tokenResp.ExpiresIn
218
+ if expiresIn <= 0 {
219
+ expiresIn = 3600
220
+ }
221
+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
222
+
223
+ return &KiroTokenData{
224
+ AccessToken: tokenResp.AccessToken,
225
+ RefreshToken: tokenResp.RefreshToken,
226
+ ProfileArn: tokenResp.ProfileArn,
227
+ ExpiresAt: expiresAt.Format(time.RFC3339),
228
+ AuthMethod: "social",
229
+ Provider: "", // Caller should preserve original provider
230
+ }, nil
231
+ }
232
+
233
+ // RefreshToken refreshes an expired access token.
234
+ func (o *KiroOAuth) RefreshToken(ctx context.Context, refreshToken string) (*KiroTokenData, error) {
235
+ payload := map[string]string{
236
+ "refreshToken": refreshToken,
237
+ }
238
+
239
+ body, err := json.Marshal(payload)
240
+ if err != nil {
241
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
242
+ }
243
+
244
+ refreshURL := kiroAuthEndpoint + "/refreshToken"
245
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, refreshURL, strings.NewReader(string(body)))
246
+ if err != nil {
247
+ return nil, fmt.Errorf("failed to create request: %w", err)
248
+ }
249
+
250
+ req.Header.Set("Content-Type", "application/json")
251
+ req.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
252
+
253
+ resp, err := o.httpClient.Do(req)
254
+ if err != nil {
255
+ return nil, fmt.Errorf("refresh request failed: %w", err)
256
+ }
257
+ defer resp.Body.Close()
258
+
259
+ respBody, err := io.ReadAll(resp.Body)
260
+ if err != nil {
261
+ return nil, fmt.Errorf("failed to read response: %w", err)
262
+ }
263
+
264
+ if resp.StatusCode != http.StatusOK {
265
+ log.Debugf("token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
266
+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
267
+ }
268
+
269
+ var tokenResp KiroTokenResponse
270
+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
271
+ return nil, fmt.Errorf("failed to parse token response: %w", err)
272
+ }
273
+
274
+ // Validate ExpiresIn - use default 1 hour if invalid
275
+ expiresIn := tokenResp.ExpiresIn
276
+ if expiresIn <= 0 {
277
+ expiresIn = 3600
278
+ }
279
+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
280
+
281
+ return &KiroTokenData{
282
+ AccessToken: tokenResp.AccessToken,
283
+ RefreshToken: tokenResp.RefreshToken,
284
+ ProfileArn: tokenResp.ProfileArn,
285
+ ExpiresAt: expiresAt.Format(time.RFC3339),
286
+ AuthMethod: "social",
287
+ Provider: "", // Caller should preserve original provider
288
+ }, nil
289
+ }
290
+
291
+ // LoginWithGoogle performs OAuth login with Google using Kiro's social auth.
292
+ // This uses a custom protocol handler (kiro://) to receive the callback.
293
+ func (o *KiroOAuth) LoginWithGoogle(ctx context.Context) (*KiroTokenData, error) {
294
+ socialClient := NewSocialAuthClient(o.cfg)
295
+ return socialClient.LoginWithGoogle(ctx)
296
+ }
297
+
298
+ // LoginWithGitHub performs OAuth login with GitHub using Kiro's social auth.
299
+ // This uses a custom protocol handler (kiro://) to receive the callback.
300
+ func (o *KiroOAuth) LoginWithGitHub(ctx context.Context) (*KiroTokenData, error) {
301
+ socialClient := NewSocialAuthClient(o.cfg)
302
+ return socialClient.LoginWithGitHub(ctx)
303
+ }
internal/auth/kiro/protocol_handler.go ADDED
@@ -0,0 +1,725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package kiro provides custom protocol handler registration for Kiro OAuth.
2
+ // This enables the CLI to intercept kiro:// URIs for social authentication (Google/GitHub).
3
+ package kiro
4
+
5
+ import (
6
+ "context"
7
+ "fmt"
8
+ "html"
9
+ "net"
10
+ "net/http"
11
+ "net/url"
12
+ "os"
13
+ "os/exec"
14
+ "path/filepath"
15
+ "runtime"
16
+ "strings"
17
+ "sync"
18
+ "time"
19
+
20
+ log "github.com/sirupsen/logrus"
21
+ )
22
+
23
+ const (
24
+ // KiroProtocol is the custom URI scheme used by Kiro
25
+ KiroProtocol = "kiro"
26
+
27
+ // KiroAuthority is the URI authority for authentication callbacks
28
+ KiroAuthority = "kiro.kiroAgent"
29
+
30
+ // KiroAuthPath is the path for successful authentication
31
+ KiroAuthPath = "/authenticate-success"
32
+
33
+ // KiroRedirectURI is the full redirect URI for social auth
34
+ KiroRedirectURI = "kiro://kiro.kiroAgent/authenticate-success"
35
+
36
+ // DefaultHandlerPort is the default port for the local callback server
37
+ DefaultHandlerPort = 19876
38
+
39
+ // HandlerTimeout is how long to wait for the OAuth callback
40
+ HandlerTimeout = 10 * time.Minute
41
+ )
42
+
43
+ // ProtocolHandler manages the custom kiro:// protocol handler for OAuth callbacks.
44
+ type ProtocolHandler struct {
45
+ port int
46
+ server *http.Server
47
+ listener net.Listener
48
+ resultChan chan *AuthCallback
49
+ stopChan chan struct{}
50
+ mu sync.Mutex
51
+ running bool
52
+ }
53
+
54
+ // AuthCallback contains the OAuth callback parameters.
55
+ type AuthCallback struct {
56
+ Code string
57
+ State string
58
+ Error string
59
+ }
60
+
61
+ // NewProtocolHandler creates a new protocol handler.
62
+ func NewProtocolHandler() *ProtocolHandler {
63
+ return &ProtocolHandler{
64
+ port: DefaultHandlerPort,
65
+ resultChan: make(chan *AuthCallback, 1),
66
+ stopChan: make(chan struct{}),
67
+ }
68
+ }
69
+
70
+ // Start starts the local callback server that receives redirects from the protocol handler.
71
+ func (h *ProtocolHandler) Start(ctx context.Context) (int, error) {
72
+ h.mu.Lock()
73
+ defer h.mu.Unlock()
74
+
75
+ if h.running {
76
+ return h.port, nil
77
+ }
78
+
79
+ // Drain any stale results from previous runs
80
+ select {
81
+ case <-h.resultChan:
82
+ default:
83
+ }
84
+
85
+ // Reset stopChan for reuse - close old channel first to unblock any waiting goroutines
86
+ if h.stopChan != nil {
87
+ select {
88
+ case <-h.stopChan:
89
+ // Already closed
90
+ default:
91
+ close(h.stopChan)
92
+ }
93
+ }
94
+ h.stopChan = make(chan struct{})
95
+
96
+ // Try ports in known range (must match handler script port range)
97
+ var listener net.Listener
98
+ var err error
99
+ portRange := []int{DefaultHandlerPort, DefaultHandlerPort + 1, DefaultHandlerPort + 2, DefaultHandlerPort + 3, DefaultHandlerPort + 4}
100
+
101
+ for _, port := range portRange {
102
+ listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
103
+ if err == nil {
104
+ break
105
+ }
106
+ log.Debugf("kiro protocol handler: port %d busy, trying next", port)
107
+ }
108
+
109
+ if listener == nil {
110
+ return 0, fmt.Errorf("failed to start callback server: all ports %d-%d are busy", DefaultHandlerPort, DefaultHandlerPort+4)
111
+ }
112
+
113
+ h.listener = listener
114
+ h.port = listener.Addr().(*net.TCPAddr).Port
115
+
116
+ mux := http.NewServeMux()
117
+ mux.HandleFunc("/oauth/callback", h.handleCallback)
118
+
119
+ h.server = &http.Server{
120
+ Handler: mux,
121
+ ReadHeaderTimeout: 10 * time.Second,
122
+ }
123
+
124
+ go func() {
125
+ if err := h.server.Serve(listener); err != nil && err != http.ErrServerClosed {
126
+ log.Debugf("kiro protocol handler server error: %v", err)
127
+ }
128
+ }()
129
+
130
+ h.running = true
131
+ log.Debugf("kiro protocol handler started on port %d", h.port)
132
+
133
+ // Auto-shutdown after context done, timeout, or explicit stop
134
+ // Capture references to prevent race with new Start() calls
135
+ currentStopChan := h.stopChan
136
+ currentServer := h.server
137
+ currentListener := h.listener
138
+ go func() {
139
+ select {
140
+ case <-ctx.Done():
141
+ case <-time.After(HandlerTimeout):
142
+ case <-currentStopChan:
143
+ return // Already stopped, exit goroutine
144
+ }
145
+ // Only stop if this is still the current server/listener instance
146
+ h.mu.Lock()
147
+ if h.server == currentServer && h.listener == currentListener {
148
+ h.mu.Unlock()
149
+ h.Stop()
150
+ } else {
151
+ h.mu.Unlock()
152
+ }
153
+ }()
154
+
155
+ return h.port, nil
156
+ }
157
+
158
+ // Stop stops the callback server.
159
+ func (h *ProtocolHandler) Stop() {
160
+ h.mu.Lock()
161
+ defer h.mu.Unlock()
162
+
163
+ if !h.running {
164
+ return
165
+ }
166
+
167
+ // Signal the auto-shutdown goroutine to exit.
168
+ // This select pattern is safe because stopChan is only modified while holding h.mu,
169
+ // and we hold the lock here. The select prevents panic from double-close.
170
+ select {
171
+ case <-h.stopChan:
172
+ // Already closed
173
+ default:
174
+ close(h.stopChan)
175
+ }
176
+
177
+ if h.server != nil {
178
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
179
+ defer cancel()
180
+ _ = h.server.Shutdown(ctx)
181
+ }
182
+
183
+ h.running = false
184
+ log.Debug("kiro protocol handler stopped")
185
+ }
186
+
187
+ // WaitForCallback waits for the OAuth callback and returns the result.
188
+ func (h *ProtocolHandler) WaitForCallback(ctx context.Context) (*AuthCallback, error) {
189
+ select {
190
+ case <-ctx.Done():
191
+ return nil, ctx.Err()
192
+ case <-time.After(HandlerTimeout):
193
+ return nil, fmt.Errorf("timeout waiting for OAuth callback")
194
+ case result := <-h.resultChan:
195
+ return result, nil
196
+ }
197
+ }
198
+
199
+ // GetPort returns the port the handler is listening on.
200
+ func (h *ProtocolHandler) GetPort() int {
201
+ return h.port
202
+ }
203
+
204
+ // handleCallback processes the OAuth callback from the protocol handler script.
205
+ func (h *ProtocolHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
206
+ code := r.URL.Query().Get("code")
207
+ state := r.URL.Query().Get("state")
208
+ errParam := r.URL.Query().Get("error")
209
+
210
+ result := &AuthCallback{
211
+ Code: code,
212
+ State: state,
213
+ Error: errParam,
214
+ }
215
+
216
+ // Send result
217
+ select {
218
+ case h.resultChan <- result:
219
+ default:
220
+ // Channel full, ignore duplicate callbacks
221
+ }
222
+
223
+ // Send success response
224
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
225
+ if errParam != "" {
226
+ w.WriteHeader(http.StatusBadRequest)
227
+ fmt.Fprintf(w, `<!DOCTYPE html>
228
+ <html>
229
+ <head><title>Login Failed</title></head>
230
+ <body>
231
+ <h1>Login Failed</h1>
232
+ <p>Error: %s</p>
233
+ <p>You can close this window.</p>
234
+ </body>
235
+ </html>`, html.EscapeString(errParam))
236
+ } else {
237
+ fmt.Fprint(w, `<!DOCTYPE html>
238
+ <html>
239
+ <head><title>Login Successful</title></head>
240
+ <body>
241
+ <h1>Login Successful!</h1>
242
+ <p>You can close this window and return to the terminal.</p>
243
+ <script>window.close();</script>
244
+ </body>
245
+ </html>`)
246
+ }
247
+ }
248
+
249
+ // IsProtocolHandlerInstalled checks if the kiro:// protocol handler is installed.
250
+ func IsProtocolHandlerInstalled() bool {
251
+ switch runtime.GOOS {
252
+ case "linux":
253
+ return isLinuxHandlerInstalled()
254
+ case "windows":
255
+ return isWindowsHandlerInstalled()
256
+ case "darwin":
257
+ return isDarwinHandlerInstalled()
258
+ default:
259
+ return false
260
+ }
261
+ }
262
+
263
+ // InstallProtocolHandler installs the kiro:// protocol handler for the current platform.
264
+ func InstallProtocolHandler(handlerPort int) error {
265
+ switch runtime.GOOS {
266
+ case "linux":
267
+ return installLinuxHandler(handlerPort)
268
+ case "windows":
269
+ return installWindowsHandler(handlerPort)
270
+ case "darwin":
271
+ return installDarwinHandler(handlerPort)
272
+ default:
273
+ return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
274
+ }
275
+ }
276
+
277
+ // UninstallProtocolHandler removes the kiro:// protocol handler.
278
+ func UninstallProtocolHandler() error {
279
+ switch runtime.GOOS {
280
+ case "linux":
281
+ return uninstallLinuxHandler()
282
+ case "windows":
283
+ return uninstallWindowsHandler()
284
+ case "darwin":
285
+ return uninstallDarwinHandler()
286
+ default:
287
+ return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
288
+ }
289
+ }
290
+
291
+ // --- Linux Implementation ---
292
+
293
+ func getLinuxDesktopPath() string {
294
+ homeDir, _ := os.UserHomeDir()
295
+ return filepath.Join(homeDir, ".local", "share", "applications", "kiro-oauth-handler.desktop")
296
+ }
297
+
298
+ func getLinuxHandlerScriptPath() string {
299
+ homeDir, _ := os.UserHomeDir()
300
+ return filepath.Join(homeDir, ".local", "bin", "kiro-oauth-handler")
301
+ }
302
+
303
+ func isLinuxHandlerInstalled() bool {
304
+ desktopPath := getLinuxDesktopPath()
305
+ _, err := os.Stat(desktopPath)
306
+ return err == nil
307
+ }
308
+
309
+ func installLinuxHandler(handlerPort int) error {
310
+ // Create directories
311
+ homeDir, err := os.UserHomeDir()
312
+ if err != nil {
313
+ return err
314
+ }
315
+
316
+ binDir := filepath.Join(homeDir, ".local", "bin")
317
+ appDir := filepath.Join(homeDir, ".local", "share", "applications")
318
+
319
+ if err := os.MkdirAll(binDir, 0755); err != nil {
320
+ return fmt.Errorf("failed to create bin directory: %w", err)
321
+ }
322
+ if err := os.MkdirAll(appDir, 0755); err != nil {
323
+ return fmt.Errorf("failed to create applications directory: %w", err)
324
+ }
325
+
326
+ // Create handler script - tries multiple ports to handle dynamic port allocation
327
+ scriptPath := getLinuxHandlerScriptPath()
328
+ scriptContent := fmt.Sprintf(`#!/bin/bash
329
+ # Kiro OAuth Protocol Handler
330
+ # Handles kiro:// URIs - tries CLI first, then forwards to Kiro IDE
331
+
332
+ URL="$1"
333
+
334
+ # Check curl availability
335
+ if ! command -v curl &> /dev/null; then
336
+ echo "Error: curl is required for Kiro OAuth handler" >&2
337
+ exit 1
338
+ fi
339
+
340
+ # Extract code and state from URL
341
+ [[ "$URL" =~ code=([^&]+) ]] && CODE="${BASH_REMATCH[1]}"
342
+ [[ "$URL" =~ state=([^&]+) ]] && STATE="${BASH_REMATCH[1]}"
343
+ [[ "$URL" =~ error=([^&]+) ]] && ERROR="${BASH_REMATCH[1]}"
344
+
345
+ # Try CLI proxy on multiple possible ports (default + dynamic range)
346
+ CLI_OK=0
347
+ for PORT in %d %d %d %d %d; do
348
+ if [ -n "$ERROR" ]; then
349
+ curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?error=$ERROR" && CLI_OK=1 && break
350
+ elif [ -n "$CODE" ] && [ -n "$STATE" ]; then
351
+ curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?code=$CODE&state=$STATE" && CLI_OK=1 && break
352
+ fi
353
+ done
354
+
355
+ # If CLI not available, forward to Kiro IDE
356
+ if [ $CLI_OK -eq 0 ] && [ -x "/usr/share/kiro/kiro" ]; then
357
+ /usr/share/kiro/kiro --open-url "$URL" &
358
+ fi
359
+ `, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4)
360
+
361
+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
362
+ return fmt.Errorf("failed to write handler script: %w", err)
363
+ }
364
+
365
+ // Create .desktop file
366
+ desktopPath := getLinuxDesktopPath()
367
+ desktopContent := fmt.Sprintf(`[Desktop Entry]
368
+ Name=Kiro OAuth Handler
369
+ Comment=Handle kiro:// protocol for CLI Proxy API authentication
370
+ Exec=%s %%u
371
+ Type=Application
372
+ Terminal=false
373
+ NoDisplay=true
374
+ MimeType=x-scheme-handler/kiro;
375
+ Categories=Utility;
376
+ `, scriptPath)
377
+
378
+ if err := os.WriteFile(desktopPath, []byte(desktopContent), 0644); err != nil {
379
+ return fmt.Errorf("failed to write desktop file: %w", err)
380
+ }
381
+
382
+ // Register handler with xdg-mime
383
+ cmd := exec.Command("xdg-mime", "default", "kiro-oauth-handler.desktop", "x-scheme-handler/kiro")
384
+ if err := cmd.Run(); err != nil {
385
+ log.Warnf("xdg-mime registration failed (may need manual setup): %v", err)
386
+ }
387
+
388
+ // Update desktop database
389
+ cmd = exec.Command("update-desktop-database", appDir)
390
+ _ = cmd.Run() // Ignore errors, not critical
391
+
392
+ log.Info("Kiro protocol handler installed for Linux")
393
+ return nil
394
+ }
395
+
396
+ func uninstallLinuxHandler() error {
397
+ desktopPath := getLinuxDesktopPath()
398
+ scriptPath := getLinuxHandlerScriptPath()
399
+
400
+ if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
401
+ return fmt.Errorf("failed to remove desktop file: %w", err)
402
+ }
403
+ if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) {
404
+ return fmt.Errorf("failed to remove handler script: %w", err)
405
+ }
406
+
407
+ log.Info("Kiro protocol handler uninstalled")
408
+ return nil
409
+ }
410
+
411
+ // --- Windows Implementation ---
412
+
413
+ func isWindowsHandlerInstalled() bool {
414
+ // Check registry key existence
415
+ cmd := exec.Command("reg", "query", `HKCU\Software\Classes\kiro`, "/ve")
416
+ return cmd.Run() == nil
417
+ }
418
+
419
+ func installWindowsHandler(handlerPort int) error {
420
+ homeDir, err := os.UserHomeDir()
421
+ if err != nil {
422
+ return err
423
+ }
424
+
425
+ // Create handler script (PowerShell)
426
+ scriptDir := filepath.Join(homeDir, ".cliproxyapi")
427
+ if err := os.MkdirAll(scriptDir, 0755); err != nil {
428
+ return fmt.Errorf("failed to create script directory: %w", err)
429
+ }
430
+
431
+ scriptPath := filepath.Join(scriptDir, "kiro-oauth-handler.ps1")
432
+ scriptContent := fmt.Sprintf(`# Kiro OAuth Protocol Handler for Windows
433
+ param([string]$url)
434
+
435
+ # Load required assembly for HttpUtility
436
+ Add-Type -AssemblyName System.Web
437
+
438
+ # Parse URL parameters
439
+ $uri = [System.Uri]$url
440
+ $query = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
441
+ $code = $query["code"]
442
+ $state = $query["state"]
443
+ $errorParam = $query["error"]
444
+
445
+ # Try multiple ports (default + dynamic range)
446
+ $ports = @(%d, %d, %d, %d, %d)
447
+ $success = $false
448
+
449
+ foreach ($port in $ports) {
450
+ if ($success) { break }
451
+ $callbackUrl = "http://127.0.0.1:$port/oauth/callback"
452
+ try {
453
+ if ($errorParam) {
454
+ $fullUrl = $callbackUrl + "?error=" + $errorParam
455
+ Invoke-WebRequest -Uri $fullUrl -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
456
+ $success = $true
457
+ } elseif ($code -and $state) {
458
+ $fullUrl = $callbackUrl + "?code=" + $code + "&state=" + $state
459
+ Invoke-WebRequest -Uri $fullUrl -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
460
+ $success = $true
461
+ }
462
+ } catch {
463
+ # Try next port
464
+ }
465
+ }
466
+ `, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4)
467
+
468
+ if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
469
+ return fmt.Errorf("failed to write handler script: %w", err)
470
+ }
471
+
472
+ // Create batch wrapper
473
+ batchPath := filepath.Join(scriptDir, "kiro-oauth-handler.bat")
474
+ batchContent := fmt.Sprintf("@echo off\npowershell -ExecutionPolicy Bypass -File \"%s\" %%1\n", scriptPath)
475
+
476
+ if err := os.WriteFile(batchPath, []byte(batchContent), 0644); err != nil {
477
+ return fmt.Errorf("failed to write batch wrapper: %w", err)
478
+ }
479
+
480
+ // Register in Windows registry
481
+ commands := [][]string{
482
+ {"reg", "add", `HKCU\Software\Classes\kiro`, "/ve", "/d", "URL:Kiro Protocol", "/f"},
483
+ {"reg", "add", `HKCU\Software\Classes\kiro`, "/v", "URL Protocol", "/d", "", "/f"},
484
+ {"reg", "add", `HKCU\Software\Classes\kiro\shell`, "/f"},
485
+ {"reg", "add", `HKCU\Software\Classes\kiro\shell\open`, "/f"},
486
+ {"reg", "add", `HKCU\Software\Classes\kiro\shell\open\command`, "/ve", "/d", fmt.Sprintf("\"%s\" \"%%1\"", batchPath), "/f"},
487
+ }
488
+
489
+ for _, args := range commands {
490
+ cmd := exec.Command(args[0], args[1:]...)
491
+ if err := cmd.Run(); err != nil {
492
+ return fmt.Errorf("failed to run registry command: %w", err)
493
+ }
494
+ }
495
+
496
+ log.Info("Kiro protocol handler installed for Windows")
497
+ return nil
498
+ }
499
+
500
+ func uninstallWindowsHandler() error {
501
+ // Remove registry keys
502
+ cmd := exec.Command("reg", "delete", `HKCU\Software\Classes\kiro`, "/f")
503
+ if err := cmd.Run(); err != nil {
504
+ log.Warnf("failed to remove registry key: %v", err)
505
+ }
506
+
507
+ // Remove scripts
508
+ homeDir, _ := os.UserHomeDir()
509
+ scriptDir := filepath.Join(homeDir, ".cliproxyapi")
510
+ _ = os.Remove(filepath.Join(scriptDir, "kiro-oauth-handler.ps1"))
511
+ _ = os.Remove(filepath.Join(scriptDir, "kiro-oauth-handler.bat"))
512
+
513
+ log.Info("Kiro protocol handler uninstalled")
514
+ return nil
515
+ }
516
+
517
+ // --- macOS Implementation ---
518
+
519
+ func getDarwinAppPath() string {
520
+ homeDir, _ := os.UserHomeDir()
521
+ return filepath.Join(homeDir, "Applications", "KiroOAuthHandler.app")
522
+ }
523
+
524
+ func isDarwinHandlerInstalled() bool {
525
+ appPath := getDarwinAppPath()
526
+ _, err := os.Stat(appPath)
527
+ return err == nil
528
+ }
529
+
530
+ func installDarwinHandler(handlerPort int) error {
531
+ // Create app bundle structure
532
+ appPath := getDarwinAppPath()
533
+ contentsPath := filepath.Join(appPath, "Contents")
534
+ macOSPath := filepath.Join(contentsPath, "MacOS")
535
+
536
+ if err := os.MkdirAll(macOSPath, 0755); err != nil {
537
+ return fmt.Errorf("failed to create app bundle: %w", err)
538
+ }
539
+
540
+ // Create Info.plist
541
+ plistPath := filepath.Join(contentsPath, "Info.plist")
542
+ plistContent := `<?xml version="1.0" encoding="UTF-8"?>
543
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
544
+ <plist version="1.0">
545
+ <dict>
546
+ <key>CFBundleIdentifier</key>
547
+ <string>com.cliproxyapi.kiro-oauth-handler</string>
548
+ <key>CFBundleName</key>
549
+ <string>KiroOAuthHandler</string>
550
+ <key>CFBundleExecutable</key>
551
+ <string>kiro-oauth-handler</string>
552
+ <key>CFBundleVersion</key>
553
+ <string>1.0</string>
554
+ <key>CFBundleURLTypes</key>
555
+ <array>
556
+ <dict>
557
+ <key>CFBundleURLName</key>
558
+ <string>Kiro Protocol</string>
559
+ <key>CFBundleURLSchemes</key>
560
+ <array>
561
+ <string>kiro</string>
562
+ </array>
563
+ </dict>
564
+ </array>
565
+ <key>LSBackgroundOnly</key>
566
+ <true/>
567
+ </dict>
568
+ </plist>`
569
+
570
+ if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil {
571
+ return fmt.Errorf("failed to write Info.plist: %w", err)
572
+ }
573
+
574
+ // Create executable script - tries multiple ports to handle dynamic port allocation
575
+ execPath := filepath.Join(macOSPath, "kiro-oauth-handler")
576
+ execContent := fmt.Sprintf(`#!/bin/bash
577
+ # Kiro OAuth Protocol Handler for macOS
578
+
579
+ URL="$1"
580
+
581
+ # Check curl availability (should always exist on macOS)
582
+ if [ ! -x /usr/bin/curl ]; then
583
+ echo "Error: curl is required for Kiro OAuth handler" >&2
584
+ exit 1
585
+ fi
586
+
587
+ # Extract code and state from URL
588
+ [[ "$URL" =~ code=([^&]+) ]] && CODE="${BASH_REMATCH[1]}"
589
+ [[ "$URL" =~ state=([^&]+) ]] && STATE="${BASH_REMATCH[1]}"
590
+ [[ "$URL" =~ error=([^&]+) ]] && ERROR="${BASH_REMATCH[1]}"
591
+
592
+ # Try multiple ports (default + dynamic range)
593
+ for PORT in %d %d %d %d %d; do
594
+ if [ -n "$ERROR" ]; then
595
+ /usr/bin/curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?error=$ERROR" && exit 0
596
+ elif [ -n "$CODE" ] && [ -n "$STATE" ]; then
597
+ /usr/bin/curl -sf --connect-timeout 1 "http://127.0.0.1:$PORT/oauth/callback?code=$CODE&state=$STATE" && exit 0
598
+ fi
599
+ done
600
+ `, handlerPort, handlerPort+1, handlerPort+2, handlerPort+3, handlerPort+4)
601
+
602
+ if err := os.WriteFile(execPath, []byte(execContent), 0755); err != nil {
603
+ return fmt.Errorf("failed to write executable: %w", err)
604
+ }
605
+
606
+ // Register the app with Launch Services
607
+ cmd := exec.Command("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
608
+ "-f", appPath)
609
+ if err := cmd.Run(); err != nil {
610
+ log.Warnf("lsregister failed (handler may still work): %v", err)
611
+ }
612
+
613
+ log.Info("Kiro protocol handler installed for macOS")
614
+ return nil
615
+ }
616
+
617
+ func uninstallDarwinHandler() error {
618
+ appPath := getDarwinAppPath()
619
+
620
+ // Unregister from Launch Services
621
+ cmd := exec.Command("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
622
+ "-u", appPath)
623
+ _ = cmd.Run()
624
+
625
+ // Remove app bundle
626
+ if err := os.RemoveAll(appPath); err != nil && !os.IsNotExist(err) {
627
+ return fmt.Errorf("failed to remove app bundle: %w", err)
628
+ }
629
+
630
+ log.Info("Kiro protocol handler uninstalled")
631
+ return nil
632
+ }
633
+
634
+ // ParseKiroURI parses a kiro:// URI and extracts the callback parameters.
635
+ func ParseKiroURI(rawURI string) (*AuthCallback, error) {
636
+ u, err := url.Parse(rawURI)
637
+ if err != nil {
638
+ return nil, fmt.Errorf("invalid URI: %w", err)
639
+ }
640
+
641
+ if u.Scheme != KiroProtocol {
642
+ return nil, fmt.Errorf("invalid scheme: expected %s, got %s", KiroProtocol, u.Scheme)
643
+ }
644
+
645
+ if u.Host != KiroAuthority {
646
+ return nil, fmt.Errorf("invalid authority: expected %s, got %s", KiroAuthority, u.Host)
647
+ }
648
+
649
+ query := u.Query()
650
+ return &AuthCallback{
651
+ Code: query.Get("code"),
652
+ State: query.Get("state"),
653
+ Error: query.Get("error"),
654
+ }, nil
655
+ }
656
+
657
+ // GetHandlerInstructions returns platform-specific instructions for manual handler setup.
658
+ func GetHandlerInstructions() string {
659
+ switch runtime.GOOS {
660
+ case "linux":
661
+ return `To manually set up the Kiro protocol handler on Linux:
662
+
663
+ 1. Create ~/.local/share/applications/kiro-oauth-handler.desktop:
664
+ [Desktop Entry]
665
+ Name=Kiro OAuth Handler
666
+ Exec=~/.local/bin/kiro-oauth-handler %u
667
+ Type=Application
668
+ Terminal=false
669
+ MimeType=x-scheme-handler/kiro;
670
+
671
+ 2. Create ~/.local/bin/kiro-oauth-handler (make it executable):
672
+ #!/bin/bash
673
+ URL="$1"
674
+ # ... (see generated script for full content)
675
+
676
+ 3. Run: xdg-mime default kiro-oauth-handler.desktop x-scheme-handler/kiro`
677
+
678
+ case "windows":
679
+ return `To manually set up the Kiro protocol handler on Windows:
680
+
681
+ 1. Open Registry Editor (regedit.exe)
682
+ 2. Create key: HKEY_CURRENT_USER\Software\Classes\kiro
683
+ 3. Set default value to: URL:Kiro Protocol
684
+ 4. Create string value "URL Protocol" with empty data
685
+ 5. Create subkey: shell\open\command
686
+ 6. Set default value to: "C:\path\to\handler.bat" "%1"`
687
+
688
+ case "darwin":
689
+ return `To manually set up the Kiro protocol handler on macOS:
690
+
691
+ 1. Create ~/Applications/KiroOAuthHandler.app bundle
692
+ 2. Add Info.plist with CFBundleURLTypes containing "kiro" scheme
693
+ 3. Create executable in Contents/MacOS/
694
+ 4. Run: /System/Library/.../lsregister -f ~/Applications/KiroOAuthHandler.app`
695
+
696
+ default:
697
+ return "Protocol handler setup is not supported on this platform."
698
+ }
699
+ }
700
+
701
+ // SetupProtocolHandlerIfNeeded checks and installs the protocol handler if needed.
702
+ func SetupProtocolHandlerIfNeeded(handlerPort int) error {
703
+ if IsProtocolHandlerInstalled() {
704
+ log.Debug("Kiro protocol handler already installed")
705
+ return nil
706
+ }
707
+
708
+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
709
+ fmt.Println("║ Kiro Protocol Handler Setup Required ║")
710
+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
711
+ fmt.Println("\nTo enable Google/GitHub login, we need to install a protocol handler.")
712
+ fmt.Println("This allows your browser to redirect back to the CLI after authentication.")
713
+ fmt.Println("\nInstalling protocol handler...")
714
+
715
+ if err := InstallProtocolHandler(handlerPort); err != nil {
716
+ fmt.Printf("\n⚠ Automatic installation failed: %v\n", err)
717
+ fmt.Println("\nManual setup instructions:")
718
+ fmt.Println(strings.Repeat("-", 60))
719
+ fmt.Println(GetHandlerInstructions())
720
+ return err
721
+ }
722
+
723
+ fmt.Println("\n✓ Protocol handler installed successfully!")
724
+ return nil
725
+ }
internal/auth/kiro/social_auth.go ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package kiro provides social authentication (Google/GitHub) for Kiro via AuthServiceClient.
2
+ package kiro
3
+
4
+ import (
5
+ "bufio"
6
+ "context"
7
+ "crypto/rand"
8
+ "crypto/sha256"
9
+ "encoding/base64"
10
+ "encoding/json"
11
+ "fmt"
12
+ "io"
13
+ "net/http"
14
+ "net/url"
15
+ "os"
16
+ "os/exec"
17
+ "runtime"
18
+ "strings"
19
+ "time"
20
+
21
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
22
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
23
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
24
+ log "github.com/sirupsen/logrus"
25
+ "golang.org/x/term"
26
+ )
27
+
28
+ const (
29
+ // Kiro AuthService endpoint
30
+ kiroAuthServiceEndpoint = "https://prod.us-east-1.auth.desktop.kiro.dev"
31
+
32
+ // OAuth timeout
33
+ socialAuthTimeout = 10 * time.Minute
34
+ )
35
+
36
+ // SocialProvider represents the social login provider.
37
+ type SocialProvider string
38
+
39
+ const (
40
+ // ProviderGoogle is Google OAuth provider
41
+ ProviderGoogle SocialProvider = "Google"
42
+ // ProviderGitHub is GitHub OAuth provider
43
+ ProviderGitHub SocialProvider = "Github"
44
+ // Note: AWS Builder ID is NOT supported by Kiro's auth service.
45
+ // It only supports: Google, Github, Cognito
46
+ // AWS Builder ID must use device code flow via SSO OIDC.
47
+ )
48
+
49
+ // CreateTokenRequest is sent to Kiro's /oauth/token endpoint.
50
+ type CreateTokenRequest struct {
51
+ Code string `json:"code"`
52
+ CodeVerifier string `json:"code_verifier"`
53
+ RedirectURI string `json:"redirect_uri"`
54
+ InvitationCode string `json:"invitation_code,omitempty"`
55
+ }
56
+
57
+ // SocialTokenResponse from Kiro's /oauth/token endpoint for social auth.
58
+ type SocialTokenResponse struct {
59
+ AccessToken string `json:"accessToken"`
60
+ RefreshToken string `json:"refreshToken"`
61
+ ProfileArn string `json:"profileArn"`
62
+ ExpiresIn int `json:"expiresIn"`
63
+ }
64
+
65
+ // RefreshTokenRequest is sent to Kiro's /refreshToken endpoint.
66
+ type RefreshTokenRequest struct {
67
+ RefreshToken string `json:"refreshToken"`
68
+ }
69
+
70
+ // SocialAuthClient handles social authentication with Kiro.
71
+ type SocialAuthClient struct {
72
+ httpClient *http.Client
73
+ cfg *config.Config
74
+ protocolHandler *ProtocolHandler
75
+ }
76
+
77
+ // NewSocialAuthClient creates a new social auth client.
78
+ func NewSocialAuthClient(cfg *config.Config) *SocialAuthClient {
79
+ client := &http.Client{Timeout: 30 * time.Second}
80
+ if cfg != nil {
81
+ client = util.SetProxy(&cfg.SDKConfig, client)
82
+ }
83
+ return &SocialAuthClient{
84
+ httpClient: client,
85
+ cfg: cfg,
86
+ protocolHandler: NewProtocolHandler(),
87
+ }
88
+ }
89
+
90
+ // generatePKCE generates PKCE code verifier and challenge.
91
+ func generatePKCE() (verifier, challenge string, err error) {
92
+ // Generate 32 bytes of random data for verifier
93
+ b := make([]byte, 32)
94
+ if _, err := rand.Read(b); err != nil {
95
+ return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
96
+ }
97
+ verifier = base64.RawURLEncoding.EncodeToString(b)
98
+
99
+ // Generate SHA256 hash of verifier for challenge
100
+ h := sha256.Sum256([]byte(verifier))
101
+ challenge = base64.RawURLEncoding.EncodeToString(h[:])
102
+
103
+ return verifier, challenge, nil
104
+ }
105
+
106
+ // generateState generates a random state parameter.
107
+ func generateStateParam() (string, error) {
108
+ b := make([]byte, 16)
109
+ if _, err := rand.Read(b); err != nil {
110
+ return "", err
111
+ }
112
+ return base64.RawURLEncoding.EncodeToString(b), nil
113
+ }
114
+
115
+ // buildLoginURL constructs the Kiro OAuth login URL.
116
+ // The login endpoint expects a GET request with query parameters.
117
+ // Format: /login?idp=Google&redirect_uri=...&code_challenge=...&code_challenge_method=S256&state=...&prompt=select_account
118
+ // The prompt=select_account parameter forces the account selection screen even if already logged in.
119
+ func (c *SocialAuthClient) buildLoginURL(provider, redirectURI, codeChallenge, state string) string {
120
+ return fmt.Sprintf("%s/login?idp=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&state=%s&prompt=select_account",
121
+ kiroAuthServiceEndpoint,
122
+ provider,
123
+ url.QueryEscape(redirectURI),
124
+ codeChallenge,
125
+ state,
126
+ )
127
+ }
128
+
129
+ // CreateToken exchanges the authorization code for tokens.
130
+ func (c *SocialAuthClient) CreateToken(ctx context.Context, req *CreateTokenRequest) (*SocialTokenResponse, error) {
131
+ body, err := json.Marshal(req)
132
+ if err != nil {
133
+ return nil, fmt.Errorf("failed to marshal token request: %w", err)
134
+ }
135
+
136
+ tokenURL := kiroAuthServiceEndpoint + "/oauth/token"
137
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(string(body)))
138
+ if err != nil {
139
+ return nil, fmt.Errorf("failed to create token request: %w", err)
140
+ }
141
+
142
+ httpReq.Header.Set("Content-Type", "application/json")
143
+ httpReq.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
144
+
145
+ resp, err := c.httpClient.Do(httpReq)
146
+ if err != nil {
147
+ return nil, fmt.Errorf("token request failed: %w", err)
148
+ }
149
+ defer resp.Body.Close()
150
+
151
+ respBody, err := io.ReadAll(resp.Body)
152
+ if err != nil {
153
+ return nil, fmt.Errorf("failed to read token response: %w", err)
154
+ }
155
+
156
+ if resp.StatusCode != http.StatusOK {
157
+ log.Debugf("token exchange failed (status %d): %s", resp.StatusCode, string(respBody))
158
+ return nil, fmt.Errorf("token exchange failed (status %d)", resp.StatusCode)
159
+ }
160
+
161
+ var tokenResp SocialTokenResponse
162
+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
163
+ return nil, fmt.Errorf("failed to parse token response: %w", err)
164
+ }
165
+
166
+ return &tokenResp, nil
167
+ }
168
+
169
+ // RefreshSocialToken refreshes an expired social auth token.
170
+ func (c *SocialAuthClient) RefreshSocialToken(ctx context.Context, refreshToken string) (*KiroTokenData, error) {
171
+ body, err := json.Marshal(&RefreshTokenRequest{RefreshToken: refreshToken})
172
+ if err != nil {
173
+ return nil, fmt.Errorf("failed to marshal refresh request: %w", err)
174
+ }
175
+
176
+ refreshURL := kiroAuthServiceEndpoint + "/refreshToken"
177
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, refreshURL, strings.NewReader(string(body)))
178
+ if err != nil {
179
+ return nil, fmt.Errorf("failed to create refresh request: %w", err)
180
+ }
181
+
182
+ httpReq.Header.Set("Content-Type", "application/json")
183
+ httpReq.Header.Set("User-Agent", "cli-proxy-api/1.0.0")
184
+
185
+ resp, err := c.httpClient.Do(httpReq)
186
+ if err != nil {
187
+ return nil, fmt.Errorf("refresh request failed: %w", err)
188
+ }
189
+ defer resp.Body.Close()
190
+
191
+ respBody, err := io.ReadAll(resp.Body)
192
+ if err != nil {
193
+ return nil, fmt.Errorf("failed to read refresh response: %w", err)
194
+ }
195
+
196
+ if resp.StatusCode != http.StatusOK {
197
+ log.Debugf("token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
198
+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
199
+ }
200
+
201
+ var tokenResp SocialTokenResponse
202
+ if err := json.Unmarshal(respBody, &tokenResp); err != nil {
203
+ return nil, fmt.Errorf("failed to parse refresh response: %w", err)
204
+ }
205
+
206
+ // Validate ExpiresIn - use default 1 hour if invalid
207
+ expiresIn := tokenResp.ExpiresIn
208
+ if expiresIn <= 0 {
209
+ expiresIn = 3600 // Default 1 hour
210
+ }
211
+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
212
+
213
+ return &KiroTokenData{
214
+ AccessToken: tokenResp.AccessToken,
215
+ RefreshToken: tokenResp.RefreshToken,
216
+ ProfileArn: tokenResp.ProfileArn,
217
+ ExpiresAt: expiresAt.Format(time.RFC3339),
218
+ AuthMethod: "social",
219
+ Provider: "", // Caller should preserve original provider
220
+ }, nil
221
+ }
222
+
223
+ // LoginWithSocial performs OAuth login with Google.
224
+ func (c *SocialAuthClient) LoginWithSocial(ctx context.Context, provider SocialProvider) (*KiroTokenData, error) {
225
+ providerName := string(provider)
226
+
227
+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
228
+ fmt.Printf("║ Kiro Authentication (%s) ║\n", providerName)
229
+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
230
+
231
+ // Step 1: Setup protocol handler
232
+ fmt.Println("\nSetting up authentication...")
233
+
234
+ // Start the local callback server
235
+ handlerPort, err := c.protocolHandler.Start(ctx)
236
+ if err != nil {
237
+ return nil, fmt.Errorf("failed to start callback server: %w", err)
238
+ }
239
+ defer c.protocolHandler.Stop()
240
+
241
+ // Ensure protocol handler is installed and set as default
242
+ if err := SetupProtocolHandlerIfNeeded(handlerPort); err != nil {
243
+ fmt.Println("\n⚠ Protocol handler setup failed. Trying alternative method...")
244
+ fmt.Println(" If you see a browser 'Open with' dialog, select your default browser.")
245
+ fmt.Println(" For manual setup instructions, run: cliproxy kiro --help-protocol")
246
+ log.Debugf("kiro: protocol handler setup error: %v", err)
247
+ // Continue anyway - user might have set it up manually or select browser manually
248
+ } else {
249
+ // Force set our handler as default (prevents "Open with" dialog)
250
+ forceDefaultProtocolHandler()
251
+ }
252
+
253
+ // Step 2: Generate PKCE codes
254
+ codeVerifier, codeChallenge, err := generatePKCE()
255
+ if err != nil {
256
+ return nil, fmt.Errorf("failed to generate PKCE: %w", err)
257
+ }
258
+
259
+ // Step 3: Generate state
260
+ state, err := generateStateParam()
261
+ if err != nil {
262
+ return nil, fmt.Errorf("failed to generate state: %w", err)
263
+ }
264
+
265
+ // Step 4: Build the login URL (Kiro uses GET request with query params)
266
+ authURL := c.buildLoginURL(providerName, KiroRedirectURI, codeChallenge, state)
267
+
268
+ // Set incognito mode based on config (defaults to true for Kiro, can be overridden with --no-incognito)
269
+ // Incognito mode enables multi-account support by bypassing cached sessions
270
+ if c.cfg != nil {
271
+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
272
+ if !c.cfg.IncognitoBrowser {
273
+ log.Info("kiro: using normal browser mode (--no-incognito). Note: You may not be able to select a different account.")
274
+ } else {
275
+ log.Debug("kiro: using incognito mode for multi-account support")
276
+ }
277
+ } else {
278
+ browser.SetIncognitoMode(true) // Default to incognito if no config
279
+ log.Debug("kiro: using incognito mode for multi-account support (default)")
280
+ }
281
+
282
+ // Step 5: Open browser for user authentication
283
+ fmt.Println("\n════════════���═══════════════════════════════════════════════")
284
+ fmt.Printf(" Opening browser for %s authentication...\n", providerName)
285
+ fmt.Println("════════════════════════════════════════════════════════════")
286
+ fmt.Printf("\n URL: %s\n\n", authURL)
287
+
288
+ if err := browser.OpenURL(authURL); err != nil {
289
+ log.Warnf("Could not open browser automatically: %v", err)
290
+ fmt.Println(" ⚠ Could not open browser automatically.")
291
+ fmt.Println(" Please open the URL above in your browser manually.")
292
+ } else {
293
+ fmt.Println(" (Browser opened automatically)")
294
+ }
295
+
296
+ fmt.Println("\n Waiting for authentication callback...")
297
+
298
+ // Step 6: Wait for callback
299
+ callback, err := c.protocolHandler.WaitForCallback(ctx)
300
+ if err != nil {
301
+ return nil, fmt.Errorf("failed to receive callback: %w", err)
302
+ }
303
+
304
+ if callback.Error != "" {
305
+ return nil, fmt.Errorf("authentication error: %s", callback.Error)
306
+ }
307
+
308
+ if callback.State != state {
309
+ // Log state values for debugging, but don't expose in user-facing error
310
+ log.Debugf("kiro: OAuth state mismatch - expected %s, got %s", state, callback.State)
311
+ return nil, fmt.Errorf("OAuth state validation failed - please try again")
312
+ }
313
+
314
+ if callback.Code == "" {
315
+ return nil, fmt.Errorf("no authorization code received")
316
+ }
317
+
318
+ fmt.Println("\n✓ Authorization received!")
319
+
320
+ // Step 7: Exchange code for tokens
321
+ fmt.Println("Exchanging code for tokens...")
322
+
323
+ tokenReq := &CreateTokenRequest{
324
+ Code: callback.Code,
325
+ CodeVerifier: codeVerifier,
326
+ RedirectURI: KiroRedirectURI,
327
+ }
328
+
329
+ tokenResp, err := c.CreateToken(ctx, tokenReq)
330
+ if err != nil {
331
+ return nil, fmt.Errorf("failed to exchange code for tokens: %w", err)
332
+ }
333
+
334
+ fmt.Println("\n✓ Authentication successful!")
335
+
336
+ // Close the browser window
337
+ if err := browser.CloseBrowser(); err != nil {
338
+ log.Debugf("Failed to close browser: %v", err)
339
+ }
340
+
341
+ // Validate ExpiresIn - use default 1 hour if invalid
342
+ expiresIn := tokenResp.ExpiresIn
343
+ if expiresIn <= 0 {
344
+ expiresIn = 3600
345
+ }
346
+ expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
347
+
348
+ // Try to extract email from JWT access token first
349
+ email := ExtractEmailFromJWT(tokenResp.AccessToken)
350
+
351
+ // If no email in JWT, ask user for account label (only in interactive mode)
352
+ if email == "" && isInteractiveTerminal() {
353
+ fmt.Print("\n Enter account label for file naming (optional, press Enter to skip): ")
354
+ reader := bufio.NewReader(os.Stdin)
355
+ var err error
356
+ email, err = reader.ReadString('\n')
357
+ if err != nil {
358
+ log.Debugf("Failed to read account label: %v", err)
359
+ }
360
+ email = strings.TrimSpace(email)
361
+ }
362
+
363
+ return &KiroTokenData{
364
+ AccessToken: tokenResp.AccessToken,
365
+ RefreshToken: tokenResp.RefreshToken,
366
+ ProfileArn: tokenResp.ProfileArn,
367
+ ExpiresAt: expiresAt.Format(time.RFC3339),
368
+ AuthMethod: "social",
369
+ Provider: providerName,
370
+ Email: email, // JWT email or user-provided label
371
+ }, nil
372
+ }
373
+
374
+ // LoginWithGoogle performs OAuth login with Google.
375
+ func (c *SocialAuthClient) LoginWithGoogle(ctx context.Context) (*KiroTokenData, error) {
376
+ return c.LoginWithSocial(ctx, ProviderGoogle)
377
+ }
378
+
379
+ // LoginWithGitHub performs OAuth login with GitHub.
380
+ func (c *SocialAuthClient) LoginWithGitHub(ctx context.Context) (*KiroTokenData, error) {
381
+ return c.LoginWithSocial(ctx, ProviderGitHub)
382
+ }
383
+
384
+ // forceDefaultProtocolHandler sets our protocol handler as the default for kiro:// URLs.
385
+ // This prevents the "Open with" dialog from appearing on Linux.
386
+ // On non-Linux platforms, this is a no-op as they use different mechanisms.
387
+ func forceDefaultProtocolHandler() {
388
+ if runtime.GOOS != "linux" {
389
+ return // Non-Linux platforms use different handler mechanisms
390
+ }
391
+
392
+ // Set our handler as default using xdg-mime
393
+ cmd := exec.Command("xdg-mime", "default", "kiro-oauth-handler.desktop", "x-scheme-handler/kiro")
394
+ if err := cmd.Run(); err != nil {
395
+ log.Warnf("Failed to set default protocol handler: %v. You may see a handler selection dialog.", err)
396
+ }
397
+ }
398
+
399
+ // isInteractiveTerminal checks if stdin is connected to an interactive terminal.
400
+ // Returns false in CI/automated environments or when stdin is piped.
401
+ func isInteractiveTerminal() bool {
402
+ return term.IsTerminal(int(os.Stdin.Fd()))
403
+ }
internal/auth/kiro/sso_oidc.go ADDED
@@ -0,0 +1,1371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package kiro provides AWS SSO OIDC authentication for Kiro.
2
+ package kiro
3
+
4
+ import (
5
+ "bufio"
6
+ "context"
7
+ "crypto/rand"
8
+ "crypto/sha256"
9
+ "encoding/base64"
10
+ "encoding/json"
11
+ "errors"
12
+ "fmt"
13
+ "html"
14
+ "io"
15
+ "net"
16
+ "net/http"
17
+ "os"
18
+ "strings"
19
+ "time"
20
+
21
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
22
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
23
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
24
+ log "github.com/sirupsen/logrus"
25
+ )
26
+
27
+ const (
28
+ // AWS SSO OIDC endpoints
29
+ ssoOIDCEndpoint = "https://oidc.us-east-1.amazonaws.com"
30
+
31
+ // Kiro's start URL for Builder ID
32
+ builderIDStartURL = "https://view.awsapps.com/start"
33
+
34
+ // Default region for IDC
35
+ defaultIDCRegion = "us-east-1"
36
+
37
+ // Polling interval
38
+ pollInterval = 5 * time.Second
39
+
40
+ // Authorization code flow callback
41
+ authCodeCallbackPath = "/oauth/callback"
42
+ authCodeCallbackPort = 19877
43
+
44
+ // User-Agent to match official Kiro IDE
45
+ kiroUserAgent = "KiroIDE"
46
+
47
+ // IDC token refresh headers (matching Kiro IDE behavior)
48
+ idcAmzUserAgent = "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE"
49
+ )
50
+
51
+ // Sentinel errors for OIDC token polling
52
+ var (
53
+ ErrAuthorizationPending = errors.New("authorization_pending")
54
+ ErrSlowDown = errors.New("slow_down")
55
+ )
56
+
57
+ // SSOOIDCClient handles AWS SSO OIDC authentication.
58
+ type SSOOIDCClient struct {
59
+ httpClient *http.Client
60
+ cfg *config.Config
61
+ }
62
+
63
+ // NewSSOOIDCClient creates a new SSO OIDC client.
64
+ func NewSSOOIDCClient(cfg *config.Config) *SSOOIDCClient {
65
+ client := &http.Client{Timeout: 30 * time.Second}
66
+ if cfg != nil {
67
+ client = util.SetProxy(&cfg.SDKConfig, client)
68
+ }
69
+ return &SSOOIDCClient{
70
+ httpClient: client,
71
+ cfg: cfg,
72
+ }
73
+ }
74
+
75
+ // RegisterClientResponse from AWS SSO OIDC.
76
+ type RegisterClientResponse struct {
77
+ ClientID string `json:"clientId"`
78
+ ClientSecret string `json:"clientSecret"`
79
+ ClientIDIssuedAt int64 `json:"clientIdIssuedAt"`
80
+ ClientSecretExpiresAt int64 `json:"clientSecretExpiresAt"`
81
+ }
82
+
83
+ // StartDeviceAuthResponse from AWS SSO OIDC.
84
+ type StartDeviceAuthResponse struct {
85
+ DeviceCode string `json:"deviceCode"`
86
+ UserCode string `json:"userCode"`
87
+ VerificationURI string `json:"verificationUri"`
88
+ VerificationURIComplete string `json:"verificationUriComplete"`
89
+ ExpiresIn int `json:"expiresIn"`
90
+ Interval int `json:"interval"`
91
+ }
92
+
93
+ // CreateTokenResponse from AWS SSO OIDC.
94
+ type CreateTokenResponse struct {
95
+ AccessToken string `json:"accessToken"`
96
+ TokenType string `json:"tokenType"`
97
+ ExpiresIn int `json:"expiresIn"`
98
+ RefreshToken string `json:"refreshToken"`
99
+ }
100
+
101
+ // getOIDCEndpoint returns the OIDC endpoint for the given region.
102
+ func getOIDCEndpoint(region string) string {
103
+ if region == "" {
104
+ region = defaultIDCRegion
105
+ }
106
+ return fmt.Sprintf("https://oidc.%s.amazonaws.com", region)
107
+ }
108
+
109
+ // promptInput prompts the user for input with an optional default value.
110
+ func promptInput(prompt, defaultValue string) string {
111
+ reader := bufio.NewReader(os.Stdin)
112
+ if defaultValue != "" {
113
+ fmt.Printf("%s [%s]: ", prompt, defaultValue)
114
+ } else {
115
+ fmt.Printf("%s: ", prompt)
116
+ }
117
+ input, err := reader.ReadString('\n')
118
+ if err != nil {
119
+ log.Warnf("Error reading input: %v", err)
120
+ return defaultValue
121
+ }
122
+ input = strings.TrimSpace(input)
123
+ if input == "" {
124
+ return defaultValue
125
+ }
126
+ return input
127
+ }
128
+
129
+ // promptSelect prompts the user to select from options using number input.
130
+ func promptSelect(prompt string, options []string) int {
131
+ reader := bufio.NewReader(os.Stdin)
132
+
133
+ for {
134
+ fmt.Println(prompt)
135
+ for i, opt := range options {
136
+ fmt.Printf(" %d) %s\n", i+1, opt)
137
+ }
138
+ fmt.Printf("Enter selection (1-%d): ", len(options))
139
+
140
+ input, err := reader.ReadString('\n')
141
+ if err != nil {
142
+ log.Warnf("Error reading input: %v", err)
143
+ return 0 // Default to first option on error
144
+ }
145
+ input = strings.TrimSpace(input)
146
+
147
+ // Parse the selection
148
+ var selection int
149
+ if _, err := fmt.Sscanf(input, "%d", &selection); err != nil || selection < 1 || selection > len(options) {
150
+ fmt.Printf("Invalid selection '%s'. Please enter a number between 1 and %d.\n\n", input, len(options))
151
+ continue
152
+ }
153
+ return selection - 1
154
+ }
155
+ }
156
+
157
+ // RegisterClientWithRegion registers a new OIDC client with AWS using a specific region.
158
+ func (c *SSOOIDCClient) RegisterClientWithRegion(ctx context.Context, region string) (*RegisterClientResponse, error) {
159
+ endpoint := getOIDCEndpoint(region)
160
+
161
+ payload := map[string]interface{}{
162
+ "clientName": "Kiro IDE",
163
+ "clientType": "public",
164
+ "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
165
+ "grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
166
+ }
167
+
168
+ body, err := json.Marshal(payload)
169
+ if err != nil {
170
+ return nil, err
171
+ }
172
+
173
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/client/register", strings.NewReader(string(body)))
174
+ if err != nil {
175
+ return nil, err
176
+ }
177
+ req.Header.Set("Content-Type", "application/json")
178
+ req.Header.Set("User-Agent", kiroUserAgent)
179
+
180
+ resp, err := c.httpClient.Do(req)
181
+ if err != nil {
182
+ return nil, err
183
+ }
184
+ defer resp.Body.Close()
185
+
186
+ respBody, err := io.ReadAll(resp.Body)
187
+ if err != nil {
188
+ return nil, err
189
+ }
190
+
191
+ if resp.StatusCode != http.StatusOK {
192
+ log.Debugf("register client failed (status %d): %s", resp.StatusCode, string(respBody))
193
+ return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode)
194
+ }
195
+
196
+ var result RegisterClientResponse
197
+ if err := json.Unmarshal(respBody, &result); err != nil {
198
+ return nil, err
199
+ }
200
+
201
+ return &result, nil
202
+ }
203
+
204
+ // StartDeviceAuthorizationWithIDC starts the device authorization flow for IDC.
205
+ func (c *SSOOIDCClient) StartDeviceAuthorizationWithIDC(ctx context.Context, clientID, clientSecret, startURL, region string) (*StartDeviceAuthResponse, error) {
206
+ endpoint := getOIDCEndpoint(region)
207
+
208
+ payload := map[string]string{
209
+ "clientId": clientID,
210
+ "clientSecret": clientSecret,
211
+ "startUrl": startURL,
212
+ }
213
+
214
+ body, err := json.Marshal(payload)
215
+ if err != nil {
216
+ return nil, err
217
+ }
218
+
219
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/device_authorization", strings.NewReader(string(body)))
220
+ if err != nil {
221
+ return nil, err
222
+ }
223
+ req.Header.Set("Content-Type", "application/json")
224
+ req.Header.Set("User-Agent", kiroUserAgent)
225
+
226
+ resp, err := c.httpClient.Do(req)
227
+ if err != nil {
228
+ return nil, err
229
+ }
230
+ defer resp.Body.Close()
231
+
232
+ respBody, err := io.ReadAll(resp.Body)
233
+ if err != nil {
234
+ return nil, err
235
+ }
236
+
237
+ if resp.StatusCode != http.StatusOK {
238
+ log.Debugf("start device auth failed (status %d): %s", resp.StatusCode, string(respBody))
239
+ return nil, fmt.Errorf("start device auth failed (status %d)", resp.StatusCode)
240
+ }
241
+
242
+ var result StartDeviceAuthResponse
243
+ if err := json.Unmarshal(respBody, &result); err != nil {
244
+ return nil, err
245
+ }
246
+
247
+ return &result, nil
248
+ }
249
+
250
+ // CreateTokenWithRegion polls for the access token after user authorization using a specific region.
251
+ func (c *SSOOIDCClient) CreateTokenWithRegion(ctx context.Context, clientID, clientSecret, deviceCode, region string) (*CreateTokenResponse, error) {
252
+ endpoint := getOIDCEndpoint(region)
253
+
254
+ payload := map[string]string{
255
+ "clientId": clientID,
256
+ "clientSecret": clientSecret,
257
+ "deviceCode": deviceCode,
258
+ "grantType": "urn:ietf:params:oauth:grant-type:device_code",
259
+ }
260
+
261
+ body, err := json.Marshal(payload)
262
+ if err != nil {
263
+ return nil, err
264
+ }
265
+
266
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/token", strings.NewReader(string(body)))
267
+ if err != nil {
268
+ return nil, err
269
+ }
270
+ req.Header.Set("Content-Type", "application/json")
271
+ req.Header.Set("User-Agent", kiroUserAgent)
272
+
273
+ resp, err := c.httpClient.Do(req)
274
+ if err != nil {
275
+ return nil, err
276
+ }
277
+ defer resp.Body.Close()
278
+
279
+ respBody, err := io.ReadAll(resp.Body)
280
+ if err != nil {
281
+ return nil, err
282
+ }
283
+
284
+ // Check for pending authorization
285
+ if resp.StatusCode == http.StatusBadRequest {
286
+ var errResp struct {
287
+ Error string `json:"error"`
288
+ }
289
+ if json.Unmarshal(respBody, &errResp) == nil {
290
+ if errResp.Error == "authorization_pending" {
291
+ return nil, ErrAuthorizationPending
292
+ }
293
+ if errResp.Error == "slow_down" {
294
+ return nil, ErrSlowDown
295
+ }
296
+ }
297
+ log.Debugf("create token failed: %s", string(respBody))
298
+ return nil, fmt.Errorf("create token failed")
299
+ }
300
+
301
+ if resp.StatusCode != http.StatusOK {
302
+ log.Debugf("create token failed (status %d): %s", resp.StatusCode, string(respBody))
303
+ return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode)
304
+ }
305
+
306
+ var result CreateTokenResponse
307
+ if err := json.Unmarshal(respBody, &result); err != nil {
308
+ return nil, err
309
+ }
310
+
311
+ return &result, nil
312
+ }
313
+
314
+ // RefreshTokenWithRegion refreshes an access token using the refresh token with a specific region.
315
+ func (c *SSOOIDCClient) RefreshTokenWithRegion(ctx context.Context, clientID, clientSecret, refreshToken, region, startURL string) (*KiroTokenData, error) {
316
+ endpoint := getOIDCEndpoint(region)
317
+
318
+ payload := map[string]string{
319
+ "clientId": clientID,
320
+ "clientSecret": clientSecret,
321
+ "refreshToken": refreshToken,
322
+ "grantType": "refresh_token",
323
+ }
324
+
325
+ body, err := json.Marshal(payload)
326
+ if err != nil {
327
+ return nil, err
328
+ }
329
+
330
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/token", strings.NewReader(string(body)))
331
+ if err != nil {
332
+ return nil, err
333
+ }
334
+
335
+ // Set headers matching kiro2api's IDC token refresh
336
+ // These headers are required for successful IDC token refresh
337
+ req.Header.Set("Content-Type", "application/json")
338
+ req.Header.Set("Host", fmt.Sprintf("oidc.%s.amazonaws.com", region))
339
+ req.Header.Set("Connection", "keep-alive")
340
+ req.Header.Set("x-amz-user-agent", idcAmzUserAgent)
341
+ req.Header.Set("Accept", "*/*")
342
+ req.Header.Set("Accept-Language", "*")
343
+ req.Header.Set("sec-fetch-mode", "cors")
344
+ req.Header.Set("User-Agent", "node")
345
+ req.Header.Set("Accept-Encoding", "br, gzip, deflate")
346
+
347
+ resp, err := c.httpClient.Do(req)
348
+ if err != nil {
349
+ return nil, err
350
+ }
351
+ defer resp.Body.Close()
352
+
353
+ respBody, err := io.ReadAll(resp.Body)
354
+ if err != nil {
355
+ return nil, err
356
+ }
357
+
358
+ if resp.StatusCode != http.StatusOK {
359
+ log.Warnf("IDC token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
360
+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
361
+ }
362
+
363
+ var result CreateTokenResponse
364
+ if err := json.Unmarshal(respBody, &result); err != nil {
365
+ return nil, err
366
+ }
367
+
368
+ expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
369
+
370
+ return &KiroTokenData{
371
+ AccessToken: result.AccessToken,
372
+ RefreshToken: result.RefreshToken,
373
+ ExpiresAt: expiresAt.Format(time.RFC3339),
374
+ AuthMethod: "idc",
375
+ Provider: "AWS",
376
+ ClientID: clientID,
377
+ ClientSecret: clientSecret,
378
+ StartURL: startURL,
379
+ Region: region,
380
+ }, nil
381
+ }
382
+
383
+ // LoginWithIDC performs the full device code flow for AWS Identity Center (IDC).
384
+ func (c *SSOOIDCClient) LoginWithIDC(ctx context.Context, startURL, region string) (*KiroTokenData, error) {
385
+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
386
+ fmt.Println("║ Kiro Authentication (AWS Identity Center) ║")
387
+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
388
+
389
+ // Step 1: Register client with the specified region
390
+ fmt.Println("\nRegistering client...")
391
+ regResp, err := c.RegisterClientWithRegion(ctx, region)
392
+ if err != nil {
393
+ return nil, fmt.Errorf("failed to register client: %w", err)
394
+ }
395
+ log.Debugf("Client registered: %s", regResp.ClientID)
396
+
397
+ // Step 2: Start device authorization with IDC start URL
398
+ fmt.Println("Starting device authorization...")
399
+ authResp, err := c.StartDeviceAuthorizationWithIDC(ctx, regResp.ClientID, regResp.ClientSecret, startURL, region)
400
+ if err != nil {
401
+ return nil, fmt.Errorf("failed to start device auth: %w", err)
402
+ }
403
+
404
+ // Step 3: Show user the verification URL
405
+ fmt.Printf("\n")
406
+ fmt.Println("════════════════════════════════════════════════════════════")
407
+ fmt.Printf(" Confirm the following code in the browser:\n")
408
+ fmt.Printf(" Code: %s\n", authResp.UserCode)
409
+ fmt.Println("════════════════════════════════════════════════════════════")
410
+ fmt.Printf("\n Open this URL: %s\n\n", authResp.VerificationURIComplete)
411
+
412
+ // Set incognito mode based on config
413
+ if c.cfg != nil {
414
+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
415
+ if !c.cfg.IncognitoBrowser {
416
+ log.Info("kiro: using normal browser mode (--no-incognito). Note: You may not be able to select a different account.")
417
+ } else {
418
+ log.Debug("kiro: using incognito mode for multi-account support")
419
+ }
420
+ } else {
421
+ browser.SetIncognitoMode(true)
422
+ log.Debug("kiro: using incognito mode for multi-account support (default)")
423
+ }
424
+
425
+ // Open browser
426
+ if err := browser.OpenURL(authResp.VerificationURIComplete); err != nil {
427
+ log.Warnf("Could not open browser automatically: %v", err)
428
+ fmt.Println(" Please open the URL manually in your browser.")
429
+ } else {
430
+ fmt.Println(" (Browser opened automatically)")
431
+ }
432
+
433
+ // Step 4: Poll for token
434
+ fmt.Println("Waiting for authorization...")
435
+
436
+ interval := pollInterval
437
+ if authResp.Interval > 0 {
438
+ interval = time.Duration(authResp.Interval) * time.Second
439
+ }
440
+
441
+ deadline := time.Now().Add(time.Duration(authResp.ExpiresIn) * time.Second)
442
+
443
+ for time.Now().Before(deadline) {
444
+ select {
445
+ case <-ctx.Done():
446
+ browser.CloseBrowser()
447
+ return nil, ctx.Err()
448
+ case <-time.After(interval):
449
+ tokenResp, err := c.CreateTokenWithRegion(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode, region)
450
+ if err != nil {
451
+ if errors.Is(err, ErrAuthorizationPending) {
452
+ fmt.Print(".")
453
+ continue
454
+ }
455
+ if errors.Is(err, ErrSlowDown) {
456
+ interval += 5 * time.Second
457
+ continue
458
+ }
459
+ browser.CloseBrowser()
460
+ return nil, fmt.Errorf("token creation failed: %w", err)
461
+ }
462
+
463
+ fmt.Println("\n\n✓ Authorization successful!")
464
+
465
+ // Close the browser window
466
+ if err := browser.CloseBrowser(); err != nil {
467
+ log.Debugf("Failed to close browser: %v", err)
468
+ }
469
+
470
+ // Step 5: Get profile ARN from CodeWhisperer API
471
+ fmt.Println("Fetching profile information...")
472
+ profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
473
+
474
+ // Fetch user email
475
+ email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
476
+ if email != "" {
477
+ fmt.Printf(" Logged in as: %s\n", email)
478
+ }
479
+
480
+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
481
+
482
+ return &KiroTokenData{
483
+ AccessToken: tokenResp.AccessToken,
484
+ RefreshToken: tokenResp.RefreshToken,
485
+ ProfileArn: profileArn,
486
+ ExpiresAt: expiresAt.Format(time.RFC3339),
487
+ AuthMethod: "idc",
488
+ Provider: "AWS",
489
+ ClientID: regResp.ClientID,
490
+ ClientSecret: regResp.ClientSecret,
491
+ Email: email,
492
+ StartURL: startURL,
493
+ Region: region,
494
+ }, nil
495
+ }
496
+ }
497
+
498
+ // Close browser on timeout
499
+ if err := browser.CloseBrowser(); err != nil {
500
+ log.Debugf("Failed to close browser on timeout: %v", err)
501
+ }
502
+ return nil, fmt.Errorf("authorization timed out")
503
+ }
504
+
505
+ // LoginWithMethodSelection prompts the user to select between Builder ID and IDC, then performs the login.
506
+ func (c *SSOOIDCClient) LoginWithMethodSelection(ctx context.Context) (*KiroTokenData, error) {
507
+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
508
+ fmt.Println("║ Kiro Authentication (AWS) ║")
509
+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
510
+
511
+ // Prompt for login method
512
+ options := []string{
513
+ "Use with Builder ID (personal AWS account)",
514
+ "Use with IDC Account (organization SSO)",
515
+ }
516
+ selection := promptSelect("\n? Select login method:", options)
517
+
518
+ if selection == 0 {
519
+ // Builder ID flow - use existing implementation
520
+ return c.LoginWithBuilderID(ctx)
521
+ }
522
+
523
+ // IDC flow - prompt for start URL and region
524
+ fmt.Println()
525
+ startURL := promptInput("? Enter Start URL", "")
526
+ if startURL == "" {
527
+ return nil, fmt.Errorf("start URL is required for IDC login")
528
+ }
529
+
530
+ region := promptInput("? Enter Region", defaultIDCRegion)
531
+
532
+ return c.LoginWithIDC(ctx, startURL, region)
533
+ }
534
+
535
+ // RegisterClient registers a new OIDC client with AWS.
536
+ func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResponse, error) {
537
+ payload := map[string]interface{}{
538
+ "clientName": "Kiro IDE",
539
+ "clientType": "public",
540
+ "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
541
+ "grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
542
+ }
543
+
544
+ body, err := json.Marshal(payload)
545
+ if err != nil {
546
+ return nil, err
547
+ }
548
+
549
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/client/register", strings.NewReader(string(body)))
550
+ if err != nil {
551
+ return nil, err
552
+ }
553
+ req.Header.Set("Content-Type", "application/json")
554
+ req.Header.Set("User-Agent", kiroUserAgent)
555
+
556
+ resp, err := c.httpClient.Do(req)
557
+ if err != nil {
558
+ return nil, err
559
+ }
560
+ defer resp.Body.Close()
561
+
562
+ respBody, err := io.ReadAll(resp.Body)
563
+ if err != nil {
564
+ return nil, err
565
+ }
566
+
567
+ if resp.StatusCode != http.StatusOK {
568
+ log.Debugf("register client failed (status %d): %s", resp.StatusCode, string(respBody))
569
+ return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode)
570
+ }
571
+
572
+ var result RegisterClientResponse
573
+ if err := json.Unmarshal(respBody, &result); err != nil {
574
+ return nil, err
575
+ }
576
+
577
+ return &result, nil
578
+ }
579
+
580
+ // StartDeviceAuthorization starts the device authorization flow.
581
+ func (c *SSOOIDCClient) StartDeviceAuthorization(ctx context.Context, clientID, clientSecret string) (*StartDeviceAuthResponse, error) {
582
+ payload := map[string]string{
583
+ "clientId": clientID,
584
+ "clientSecret": clientSecret,
585
+ "startUrl": builderIDStartURL,
586
+ }
587
+
588
+ body, err := json.Marshal(payload)
589
+ if err != nil {
590
+ return nil, err
591
+ }
592
+
593
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/device_authorization", strings.NewReader(string(body)))
594
+ if err != nil {
595
+ return nil, err
596
+ }
597
+ req.Header.Set("Content-Type", "application/json")
598
+ req.Header.Set("User-Agent", kiroUserAgent)
599
+
600
+ resp, err := c.httpClient.Do(req)
601
+ if err != nil {
602
+ return nil, err
603
+ }
604
+ defer resp.Body.Close()
605
+
606
+ respBody, err := io.ReadAll(resp.Body)
607
+ if err != nil {
608
+ return nil, err
609
+ }
610
+
611
+ if resp.StatusCode != http.StatusOK {
612
+ log.Debugf("start device auth failed (status %d): %s", resp.StatusCode, string(respBody))
613
+ return nil, fmt.Errorf("start device auth failed (status %d)", resp.StatusCode)
614
+ }
615
+
616
+ var result StartDeviceAuthResponse
617
+ if err := json.Unmarshal(respBody, &result); err != nil {
618
+ return nil, err
619
+ }
620
+
621
+ return &result, nil
622
+ }
623
+
624
+ // CreateToken polls for the access token after user authorization.
625
+ func (c *SSOOIDCClient) CreateToken(ctx context.Context, clientID, clientSecret, deviceCode string) (*CreateTokenResponse, error) {
626
+ payload := map[string]string{
627
+ "clientId": clientID,
628
+ "clientSecret": clientSecret,
629
+ "deviceCode": deviceCode,
630
+ "grantType": "urn:ietf:params:oauth:grant-type:device_code",
631
+ }
632
+
633
+ body, err := json.Marshal(payload)
634
+ if err != nil {
635
+ return nil, err
636
+ }
637
+
638
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body)))
639
+ if err != nil {
640
+ return nil, err
641
+ }
642
+ req.Header.Set("Content-Type", "application/json")
643
+ req.Header.Set("User-Agent", kiroUserAgent)
644
+
645
+ resp, err := c.httpClient.Do(req)
646
+ if err != nil {
647
+ return nil, err
648
+ }
649
+ defer resp.Body.Close()
650
+
651
+ respBody, err := io.ReadAll(resp.Body)
652
+ if err != nil {
653
+ return nil, err
654
+ }
655
+
656
+ // Check for pending authorization
657
+ if resp.StatusCode == http.StatusBadRequest {
658
+ var errResp struct {
659
+ Error string `json:"error"`
660
+ }
661
+ if json.Unmarshal(respBody, &errResp) == nil {
662
+ if errResp.Error == "authorization_pending" {
663
+ return nil, ErrAuthorizationPending
664
+ }
665
+ if errResp.Error == "slow_down" {
666
+ return nil, ErrSlowDown
667
+ }
668
+ }
669
+ log.Debugf("create token failed: %s", string(respBody))
670
+ return nil, fmt.Errorf("create token failed")
671
+ }
672
+
673
+ if resp.StatusCode != http.StatusOK {
674
+ log.Debugf("create token failed (status %d): %s", resp.StatusCode, string(respBody))
675
+ return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode)
676
+ }
677
+
678
+ var result CreateTokenResponse
679
+ if err := json.Unmarshal(respBody, &result); err != nil {
680
+ return nil, err
681
+ }
682
+
683
+ return &result, nil
684
+ }
685
+
686
+ // RefreshToken refreshes an access token using the refresh token.
687
+ func (c *SSOOIDCClient) RefreshToken(ctx context.Context, clientID, clientSecret, refreshToken string) (*KiroTokenData, error) {
688
+ payload := map[string]string{
689
+ "clientId": clientID,
690
+ "clientSecret": clientSecret,
691
+ "refreshToken": refreshToken,
692
+ "grantType": "refresh_token",
693
+ }
694
+
695
+ body, err := json.Marshal(payload)
696
+ if err != nil {
697
+ return nil, err
698
+ }
699
+
700
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body)))
701
+ if err != nil {
702
+ return nil, err
703
+ }
704
+ req.Header.Set("Content-Type", "application/json")
705
+ req.Header.Set("User-Agent", kiroUserAgent)
706
+
707
+ resp, err := c.httpClient.Do(req)
708
+ if err != nil {
709
+ return nil, err
710
+ }
711
+ defer resp.Body.Close()
712
+
713
+ respBody, err := io.ReadAll(resp.Body)
714
+ if err != nil {
715
+ return nil, err
716
+ }
717
+
718
+ if resp.StatusCode != http.StatusOK {
719
+ log.Debugf("token refresh failed (status %d): %s", resp.StatusCode, string(respBody))
720
+ return nil, fmt.Errorf("token refresh failed (status %d)", resp.StatusCode)
721
+ }
722
+
723
+ var result CreateTokenResponse
724
+ if err := json.Unmarshal(respBody, &result); err != nil {
725
+ return nil, err
726
+ }
727
+
728
+ expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
729
+
730
+ return &KiroTokenData{
731
+ AccessToken: result.AccessToken,
732
+ RefreshToken: result.RefreshToken,
733
+ ExpiresAt: expiresAt.Format(time.RFC3339),
734
+ AuthMethod: "builder-id",
735
+ Provider: "AWS",
736
+ ClientID: clientID,
737
+ ClientSecret: clientSecret,
738
+ }, nil
739
+ }
740
+
741
+ // LoginWithBuilderID performs the full device code flow for AWS Builder ID.
742
+ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, error) {
743
+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
744
+ fmt.Println("║ Kiro Authentication (AWS Builder ID) ║")
745
+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
746
+
747
+ // Step 1: Register client
748
+ fmt.Println("\nRegistering client...")
749
+ regResp, err := c.RegisterClient(ctx)
750
+ if err != nil {
751
+ return nil, fmt.Errorf("failed to register client: %w", err)
752
+ }
753
+ log.Debugf("Client registered: %s", regResp.ClientID)
754
+
755
+ // Step 2: Start device authorization
756
+ fmt.Println("Starting device authorization...")
757
+ authResp, err := c.StartDeviceAuthorization(ctx, regResp.ClientID, regResp.ClientSecret)
758
+ if err != nil {
759
+ return nil, fmt.Errorf("failed to start device auth: %w", err)
760
+ }
761
+
762
+ // Step 3: Show user the verification URL
763
+ fmt.Printf("\n")
764
+ fmt.Println("════════════════════════════════════════════════════════════")
765
+ fmt.Printf(" Open this URL in your browser:\n")
766
+ fmt.Printf(" %s\n", authResp.VerificationURIComplete)
767
+ fmt.Println("════════════════════════════════════════════════════════════")
768
+ fmt.Printf("\n Or go to: %s\n", authResp.VerificationURI)
769
+ fmt.Printf(" And enter code: %s\n\n", authResp.UserCode)
770
+
771
+ // Set incognito mode based on config (defaults to true for Kiro, can be overridden with --no-incognito)
772
+ // Incognito mode enables multi-account support by bypassing cached sessions
773
+ if c.cfg != nil {
774
+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
775
+ if !c.cfg.IncognitoBrowser {
776
+ log.Info("kiro: using normal browser mode (--no-incognito). Note: You may not be able to select a different account.")
777
+ } else {
778
+ log.Debug("kiro: using incognito mode for multi-account support")
779
+ }
780
+ } else {
781
+ browser.SetIncognitoMode(true) // Default to incognito if no config
782
+ log.Debug("kiro: using incognito mode for multi-account support (default)")
783
+ }
784
+
785
+ // Open browser using cross-platform browser package
786
+ if err := browser.OpenURL(authResp.VerificationURIComplete); err != nil {
787
+ log.Warnf("Could not open browser automatically: %v", err)
788
+ fmt.Println(" Please open the URL manually in your browser.")
789
+ } else {
790
+ fmt.Println(" (Browser opened automatically)")
791
+ }
792
+
793
+ // Step 4: Poll for token
794
+ fmt.Println("Waiting for authorization...")
795
+
796
+ interval := pollInterval
797
+ if authResp.Interval > 0 {
798
+ interval = time.Duration(authResp.Interval) * time.Second
799
+ }
800
+
801
+ deadline := time.Now().Add(time.Duration(authResp.ExpiresIn) * time.Second)
802
+
803
+ for time.Now().Before(deadline) {
804
+ select {
805
+ case <-ctx.Done():
806
+ browser.CloseBrowser() // Cleanup on cancel
807
+ return nil, ctx.Err()
808
+ case <-time.After(interval):
809
+ tokenResp, err := c.CreateToken(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode)
810
+ if err != nil {
811
+ if errors.Is(err, ErrAuthorizationPending) {
812
+ fmt.Print(".")
813
+ continue
814
+ }
815
+ if errors.Is(err, ErrSlowDown) {
816
+ interval += 5 * time.Second
817
+ continue
818
+ }
819
+ // Close browser on error before returning
820
+ browser.CloseBrowser()
821
+ return nil, fmt.Errorf("token creation failed: %w", err)
822
+ }
823
+
824
+ fmt.Println("\n\n✓ Authorization successful!")
825
+
826
+ // Close the browser window
827
+ if err := browser.CloseBrowser(); err != nil {
828
+ log.Debugf("Failed to close browser: %v", err)
829
+ }
830
+
831
+ // Step 5: Get profile ARN from CodeWhisperer API
832
+ fmt.Println("Fetching profile information...")
833
+ profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
834
+
835
+ // Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
836
+ email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
837
+ if email != "" {
838
+ fmt.Printf(" Logged in as: %s\n", email)
839
+ }
840
+
841
+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
842
+
843
+ return &KiroTokenData{
844
+ AccessToken: tokenResp.AccessToken,
845
+ RefreshToken: tokenResp.RefreshToken,
846
+ ProfileArn: profileArn,
847
+ ExpiresAt: expiresAt.Format(time.RFC3339),
848
+ AuthMethod: "builder-id",
849
+ Provider: "AWS",
850
+ ClientID: regResp.ClientID,
851
+ ClientSecret: regResp.ClientSecret,
852
+ Email: email,
853
+ }, nil
854
+ }
855
+ }
856
+
857
+ // Close browser on timeout for better UX
858
+ if err := browser.CloseBrowser(); err != nil {
859
+ log.Debugf("Failed to close browser on timeout: %v", err)
860
+ }
861
+ return nil, fmt.Errorf("authorization timed out")
862
+ }
863
+
864
+ // FetchUserEmail retrieves the user's email from AWS SSO OIDC userinfo endpoint.
865
+ // Falls back to JWT parsing if userinfo fails.
866
+ func (c *SSOOIDCClient) FetchUserEmail(ctx context.Context, accessToken string) string {
867
+ // Method 1: Try userinfo endpoint (standard OIDC)
868
+ email := c.tryUserInfoEndpoint(ctx, accessToken)
869
+ if email != "" {
870
+ return email
871
+ }
872
+
873
+ // Method 2: Fallback to JWT parsing
874
+ return ExtractEmailFromJWT(accessToken)
875
+ }
876
+
877
+ // tryUserInfoEndpoint attempts to get user info from AWS SSO OIDC userinfo endpoint.
878
+ func (c *SSOOIDCClient) tryUserInfoEndpoint(ctx context.Context, accessToken string) string {
879
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, ssoOIDCEndpoint+"/userinfo", nil)
880
+ if err != nil {
881
+ return ""
882
+ }
883
+ req.Header.Set("Authorization", "Bearer "+accessToken)
884
+ req.Header.Set("Accept", "application/json")
885
+
886
+ resp, err := c.httpClient.Do(req)
887
+ if err != nil {
888
+ log.Debugf("userinfo request failed: %v", err)
889
+ return ""
890
+ }
891
+ defer resp.Body.Close()
892
+
893
+ if resp.StatusCode != http.StatusOK {
894
+ respBody, _ := io.ReadAll(resp.Body)
895
+ log.Debugf("userinfo endpoint returned status %d: %s", resp.StatusCode, string(respBody))
896
+ return ""
897
+ }
898
+
899
+ respBody, err := io.ReadAll(resp.Body)
900
+ if err != nil {
901
+ return ""
902
+ }
903
+
904
+ log.Debugf("userinfo response: %s", string(respBody))
905
+
906
+ var userInfo struct {
907
+ Email string `json:"email"`
908
+ Sub string `json:"sub"`
909
+ PreferredUsername string `json:"preferred_username"`
910
+ Name string `json:"name"`
911
+ }
912
+
913
+ if err := json.Unmarshal(respBody, &userInfo); err != nil {
914
+ return ""
915
+ }
916
+
917
+ if userInfo.Email != "" {
918
+ return userInfo.Email
919
+ }
920
+ if userInfo.PreferredUsername != "" && strings.Contains(userInfo.PreferredUsername, "@") {
921
+ return userInfo.PreferredUsername
922
+ }
923
+ return ""
924
+ }
925
+
926
+ // fetchProfileArn retrieves the profile ARN from CodeWhisperer API.
927
+ // This is needed for file naming since AWS SSO OIDC doesn't return profile info.
928
+ func (c *SSOOIDCClient) fetchProfileArn(ctx context.Context, accessToken string) string {
929
+ // Try ListProfiles API first
930
+ profileArn := c.tryListProfiles(ctx, accessToken)
931
+ if profileArn != "" {
932
+ return profileArn
933
+ }
934
+
935
+ // Fallback: Try ListAvailableCustomizations
936
+ return c.tryListCustomizations(ctx, accessToken)
937
+ }
938
+
939
+ func (c *SSOOIDCClient) tryListProfiles(ctx context.Context, accessToken string) string {
940
+ payload := map[string]interface{}{
941
+ "origin": "AI_EDITOR",
942
+ }
943
+
944
+ body, err := json.Marshal(payload)
945
+ if err != nil {
946
+ return ""
947
+ }
948
+
949
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://codewhisperer.us-east-1.amazonaws.com", strings.NewReader(string(body)))
950
+ if err != nil {
951
+ return ""
952
+ }
953
+
954
+ req.Header.Set("Content-Type", "application/x-amz-json-1.0")
955
+ req.Header.Set("x-amz-target", "AmazonCodeWhispererService.ListProfiles")
956
+ req.Header.Set("Authorization", "Bearer "+accessToken)
957
+ req.Header.Set("Accept", "application/json")
958
+
959
+ resp, err := c.httpClient.Do(req)
960
+ if err != nil {
961
+ return ""
962
+ }
963
+ defer resp.Body.Close()
964
+
965
+ respBody, _ := io.ReadAll(resp.Body)
966
+
967
+ if resp.StatusCode != http.StatusOK {
968
+ log.Debugf("ListProfiles failed (status %d): %s", resp.StatusCode, string(respBody))
969
+ return ""
970
+ }
971
+
972
+ log.Debugf("ListProfiles response: %s", string(respBody))
973
+
974
+ var result struct {
975
+ Profiles []struct {
976
+ Arn string `json:"arn"`
977
+ } `json:"profiles"`
978
+ ProfileArn string `json:"profileArn"`
979
+ }
980
+
981
+ if err := json.Unmarshal(respBody, &result); err != nil {
982
+ return ""
983
+ }
984
+
985
+ if result.ProfileArn != "" {
986
+ return result.ProfileArn
987
+ }
988
+
989
+ if len(result.Profiles) > 0 {
990
+ return result.Profiles[0].Arn
991
+ }
992
+
993
+ return ""
994
+ }
995
+
996
+ func (c *SSOOIDCClient) tryListCustomizations(ctx context.Context, accessToken string) string {
997
+ payload := map[string]interface{}{
998
+ "origin": "AI_EDITOR",
999
+ }
1000
+
1001
+ body, err := json.Marshal(payload)
1002
+ if err != nil {
1003
+ return ""
1004
+ }
1005
+
1006
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://codewhisperer.us-east-1.amazonaws.com", strings.NewReader(string(body)))
1007
+ if err != nil {
1008
+ return ""
1009
+ }
1010
+
1011
+ req.Header.Set("Content-Type", "application/x-amz-json-1.0")
1012
+ req.Header.Set("x-amz-target", "AmazonCodeWhispererService.ListAvailableCustomizations")
1013
+ req.Header.Set("Authorization", "Bearer "+accessToken)
1014
+ req.Header.Set("Accept", "application/json")
1015
+
1016
+ resp, err := c.httpClient.Do(req)
1017
+ if err != nil {
1018
+ return ""
1019
+ }
1020
+ defer resp.Body.Close()
1021
+
1022
+ respBody, _ := io.ReadAll(resp.Body)
1023
+
1024
+ if resp.StatusCode != http.StatusOK {
1025
+ log.Debugf("ListAvailableCustomizations failed (status %d): %s", resp.StatusCode, string(respBody))
1026
+ return ""
1027
+ }
1028
+
1029
+ log.Debugf("ListAvailableCustomizations response: %s", string(respBody))
1030
+
1031
+ var result struct {
1032
+ Customizations []struct {
1033
+ Arn string `json:"arn"`
1034
+ } `json:"customizations"`
1035
+ ProfileArn string `json:"profileArn"`
1036
+ }
1037
+
1038
+ if err := json.Unmarshal(respBody, &result); err != nil {
1039
+ return ""
1040
+ }
1041
+
1042
+ if result.ProfileArn != "" {
1043
+ return result.ProfileArn
1044
+ }
1045
+
1046
+ if len(result.Customizations) > 0 {
1047
+ return result.Customizations[0].Arn
1048
+ }
1049
+
1050
+ return ""
1051
+ }
1052
+
1053
+ // RegisterClientForAuthCode registers a new OIDC client for authorization code flow.
1054
+ func (c *SSOOIDCClient) RegisterClientForAuthCode(ctx context.Context, redirectURI string) (*RegisterClientResponse, error) {
1055
+ payload := map[string]interface{}{
1056
+ "clientName": "Kiro IDE",
1057
+ "clientType": "public",
1058
+ "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
1059
+ "grantTypes": []string{"authorization_code", "refresh_token"},
1060
+ "redirectUris": []string{redirectURI},
1061
+ "issuerUrl": builderIDStartURL,
1062
+ }
1063
+
1064
+ body, err := json.Marshal(payload)
1065
+ if err != nil {
1066
+ return nil, err
1067
+ }
1068
+
1069
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/client/register", strings.NewReader(string(body)))
1070
+ if err != nil {
1071
+ return nil, err
1072
+ }
1073
+ req.Header.Set("Content-Type", "application/json")
1074
+ req.Header.Set("User-Agent", kiroUserAgent)
1075
+
1076
+ resp, err := c.httpClient.Do(req)
1077
+ if err != nil {
1078
+ return nil, err
1079
+ }
1080
+ defer resp.Body.Close()
1081
+
1082
+ respBody, err := io.ReadAll(resp.Body)
1083
+ if err != nil {
1084
+ return nil, err
1085
+ }
1086
+
1087
+ if resp.StatusCode != http.StatusOK {
1088
+ log.Debugf("register client for auth code failed (status %d): %s", resp.StatusCode, string(respBody))
1089
+ return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode)
1090
+ }
1091
+
1092
+ var result RegisterClientResponse
1093
+ if err := json.Unmarshal(respBody, &result); err != nil {
1094
+ return nil, err
1095
+ }
1096
+
1097
+ return &result, nil
1098
+ }
1099
+
1100
+ // AuthCodeCallbackResult contains the result from authorization code callback.
1101
+ type AuthCodeCallbackResult struct {
1102
+ Code string
1103
+ State string
1104
+ Error string
1105
+ }
1106
+
1107
+ // startAuthCodeCallbackServer starts a local HTTP server to receive the authorization code callback.
1108
+ func (c *SSOOIDCClient) startAuthCodeCallbackServer(ctx context.Context, expectedState string) (string, <-chan AuthCodeCallbackResult, error) {
1109
+ // Try to find an available port
1110
+ listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", authCodeCallbackPort))
1111
+ if err != nil {
1112
+ // Try with dynamic port
1113
+ log.Warnf("sso oidc: default port %d is busy, falling back to dynamic port", authCodeCallbackPort)
1114
+ listener, err = net.Listen("tcp", "127.0.0.1:0")
1115
+ if err != nil {
1116
+ return "", nil, fmt.Errorf("failed to start callback server: %w", err)
1117
+ }
1118
+ }
1119
+
1120
+ port := listener.Addr().(*net.TCPAddr).Port
1121
+ redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, authCodeCallbackPath)
1122
+ resultChan := make(chan AuthCodeCallbackResult, 1)
1123
+
1124
+ server := &http.Server{
1125
+ ReadHeaderTimeout: 10 * time.Second,
1126
+ }
1127
+
1128
+ mux := http.NewServeMux()
1129
+ mux.HandleFunc(authCodeCallbackPath, func(w http.ResponseWriter, r *http.Request) {
1130
+ code := r.URL.Query().Get("code")
1131
+ state := r.URL.Query().Get("state")
1132
+ errParam := r.URL.Query().Get("error")
1133
+
1134
+ // Send response to browser
1135
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
1136
+ if errParam != "" {
1137
+ w.WriteHeader(http.StatusBadRequest)
1138
+ fmt.Fprintf(w, `<!DOCTYPE html>
1139
+ <html><head><title>Login Failed</title></head>
1140
+ <body><h1>Login Failed</h1><p>Error: %s</p><p>You can close this window.</p></body></html>`, html.EscapeString(errParam))
1141
+ resultChan <- AuthCodeCallbackResult{Error: errParam}
1142
+ return
1143
+ }
1144
+
1145
+ if state != expectedState {
1146
+ w.WriteHeader(http.StatusBadRequest)
1147
+ fmt.Fprint(w, `<!DOCTYPE html>
1148
+ <html><head><title>Login Failed</title></head>
1149
+ <body><h1>Login Failed</h1><p>Invalid state parameter</p><p>You can close this window.</p></body></html>`)
1150
+ resultChan <- AuthCodeCallbackResult{Error: "state mismatch"}
1151
+ return
1152
+ }
1153
+
1154
+ fmt.Fprint(w, `<!DOCTYPE html>
1155
+ <html><head><title>Login Successful</title></head>
1156
+ <body><h1>Login Successful!</h1><p>You can close this window and return to the terminal.</p>
1157
+ <script>window.close();</script></body></html>`)
1158
+ resultChan <- AuthCodeCallbackResult{Code: code, State: state}
1159
+ })
1160
+
1161
+ server.Handler = mux
1162
+
1163
+ go func() {
1164
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
1165
+ log.Debugf("auth code callback server error: %v", err)
1166
+ }
1167
+ }()
1168
+
1169
+ go func() {
1170
+ select {
1171
+ case <-ctx.Done():
1172
+ case <-time.After(10 * time.Minute):
1173
+ case <-resultChan:
1174
+ }
1175
+ _ = server.Shutdown(context.Background())
1176
+ }()
1177
+
1178
+ return redirectURI, resultChan, nil
1179
+ }
1180
+
1181
+ // generatePKCEForAuthCode generates PKCE code verifier and challenge for authorization code flow.
1182
+ func generatePKCEForAuthCode() (verifier, challenge string, err error) {
1183
+ b := make([]byte, 32)
1184
+ if _, err := rand.Read(b); err != nil {
1185
+ return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
1186
+ }
1187
+ verifier = base64.RawURLEncoding.EncodeToString(b)
1188
+ h := sha256.Sum256([]byte(verifier))
1189
+ challenge = base64.RawURLEncoding.EncodeToString(h[:])
1190
+ return verifier, challenge, nil
1191
+ }
1192
+
1193
+ // generateStateForAuthCode generates a random state parameter.
1194
+ func generateStateForAuthCode() (string, error) {
1195
+ b := make([]byte, 16)
1196
+ if _, err := rand.Read(b); err != nil {
1197
+ return "", err
1198
+ }
1199
+ return base64.RawURLEncoding.EncodeToString(b), nil
1200
+ }
1201
+
1202
+ // CreateTokenWithAuthCode exchanges authorization code for tokens.
1203
+ func (c *SSOOIDCClient) CreateTokenWithAuthCode(ctx context.Context, clientID, clientSecret, code, codeVerifier, redirectURI string) (*CreateTokenResponse, error) {
1204
+ payload := map[string]string{
1205
+ "clientId": clientID,
1206
+ "clientSecret": clientSecret,
1207
+ "code": code,
1208
+ "codeVerifier": codeVerifier,
1209
+ "redirectUri": redirectURI,
1210
+ "grantType": "authorization_code",
1211
+ }
1212
+
1213
+ body, err := json.Marshal(payload)
1214
+ if err != nil {
1215
+ return nil, err
1216
+ }
1217
+
1218
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body)))
1219
+ if err != nil {
1220
+ return nil, err
1221
+ }
1222
+ req.Header.Set("Content-Type", "application/json")
1223
+ req.Header.Set("User-Agent", kiroUserAgent)
1224
+
1225
+ resp, err := c.httpClient.Do(req)
1226
+ if err != nil {
1227
+ return nil, err
1228
+ }
1229
+ defer resp.Body.Close()
1230
+
1231
+ respBody, err := io.ReadAll(resp.Body)
1232
+ if err != nil {
1233
+ return nil, err
1234
+ }
1235
+
1236
+ if resp.StatusCode != http.StatusOK {
1237
+ log.Debugf("create token with auth code failed (status %d): %s", resp.StatusCode, string(respBody))
1238
+ return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode)
1239
+ }
1240
+
1241
+ var result CreateTokenResponse
1242
+ if err := json.Unmarshal(respBody, &result); err != nil {
1243
+ return nil, err
1244
+ }
1245
+
1246
+ return &result, nil
1247
+ }
1248
+
1249
+ // LoginWithBuilderIDAuthCode performs the authorization code flow for AWS Builder ID.
1250
+ // This provides a better UX than device code flow as it uses automatic browser callback.
1251
+ func (c *SSOOIDCClient) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) {
1252
+ fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
1253
+ fmt.Println("║ Kiro Authentication (AWS Builder ID - Auth Code) ║")
1254
+ fmt.Println("╚══════════════════════════════════════════════════════════╝")
1255
+
1256
+ // Step 1: Generate PKCE and state
1257
+ codeVerifier, codeChallenge, err := generatePKCEForAuthCode()
1258
+ if err != nil {
1259
+ return nil, fmt.Errorf("failed to generate PKCE: %w", err)
1260
+ }
1261
+
1262
+ state, err := generateStateForAuthCode()
1263
+ if err != nil {
1264
+ return nil, fmt.Errorf("failed to generate state: %w", err)
1265
+ }
1266
+
1267
+ // Step 2: Start callback server
1268
+ fmt.Println("\nStarting callback server...")
1269
+ redirectURI, resultChan, err := c.startAuthCodeCallbackServer(ctx, state)
1270
+ if err != nil {
1271
+ return nil, fmt.Errorf("failed to start callback server: %w", err)
1272
+ }
1273
+ log.Debugf("Callback server started, redirect URI: %s", redirectURI)
1274
+
1275
+ // Step 3: Register client with auth code grant type
1276
+ fmt.Println("Registering client...")
1277
+ regResp, err := c.RegisterClientForAuthCode(ctx, redirectURI)
1278
+ if err != nil {
1279
+ return nil, fmt.Errorf("failed to register client: %w", err)
1280
+ }
1281
+ log.Debugf("Client registered: %s", regResp.ClientID)
1282
+
1283
+ // Step 4: Build authorization URL
1284
+ scopes := "codewhisperer:completions,codewhisperer:analysis,codewhisperer:conversations"
1285
+ authURL := fmt.Sprintf("%s/authorize?response_type=code&client_id=%s&redirect_uri=%s&scopes=%s&state=%s&code_challenge=%s&code_challenge_method=S256",
1286
+ ssoOIDCEndpoint,
1287
+ regResp.ClientID,
1288
+ redirectURI,
1289
+ scopes,
1290
+ state,
1291
+ codeChallenge,
1292
+ )
1293
+
1294
+ // Step 5: Open browser
1295
+ fmt.Println("\n════════════════════════════════════════════════════════════")
1296
+ fmt.Println(" Opening browser for authentication...")
1297
+ fmt.Println("════════════════════════════════════════════════════════════")
1298
+ fmt.Printf("\n URL: %s\n\n", authURL)
1299
+
1300
+ // Set incognito mode
1301
+ if c.cfg != nil {
1302
+ browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
1303
+ } else {
1304
+ browser.SetIncognitoMode(true)
1305
+ }
1306
+
1307
+ if err := browser.OpenURL(authURL); err != nil {
1308
+ log.Warnf("Could not open browser automatically: %v", err)
1309
+ fmt.Println(" ⚠ Could not open browser automatically.")
1310
+ fmt.Println(" Please open the URL above in your browser manually.")
1311
+ } else {
1312
+ fmt.Println(" (Browser opened automatically)")
1313
+ }
1314
+
1315
+ fmt.Println("\n Waiting for authorization callback...")
1316
+
1317
+ // Step 6: Wait for callback
1318
+ select {
1319
+ case <-ctx.Done():
1320
+ browser.CloseBrowser()
1321
+ return nil, ctx.Err()
1322
+ case <-time.After(10 * time.Minute):
1323
+ browser.CloseBrowser()
1324
+ return nil, fmt.Errorf("authorization timed out")
1325
+ case result := <-resultChan:
1326
+ if result.Error != "" {
1327
+ browser.CloseBrowser()
1328
+ return nil, fmt.Errorf("authorization failed: %s", result.Error)
1329
+ }
1330
+
1331
+ fmt.Println("\n✓ Authorization received!")
1332
+
1333
+ // Close browser
1334
+ if err := browser.CloseBrowser(); err != nil {
1335
+ log.Debugf("Failed to close browser: %v", err)
1336
+ }
1337
+
1338
+ // Step 7: Exchange code for tokens
1339
+ fmt.Println("Exchanging code for tokens...")
1340
+ tokenResp, err := c.CreateTokenWithAuthCode(ctx, regResp.ClientID, regResp.ClientSecret, result.Code, codeVerifier, redirectURI)
1341
+ if err != nil {
1342
+ return nil, fmt.Errorf("failed to exchange code for tokens: %w", err)
1343
+ }
1344
+
1345
+ fmt.Println("\n✓ Authentication successful!")
1346
+
1347
+ // Step 8: Get profile ARN
1348
+ fmt.Println("Fetching profile information...")
1349
+ profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
1350
+
1351
+ // Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
1352
+ email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
1353
+ if email != "" {
1354
+ fmt.Printf(" Logged in as: %s\n", email)
1355
+ }
1356
+
1357
+ expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
1358
+
1359
+ return &KiroTokenData{
1360
+ AccessToken: tokenResp.AccessToken,
1361
+ RefreshToken: tokenResp.RefreshToken,
1362
+ ProfileArn: profileArn,
1363
+ ExpiresAt: expiresAt.Format(time.RFC3339),
1364
+ AuthMethod: "builder-id",
1365
+ Provider: "AWS",
1366
+ ClientID: regResp.ClientID,
1367
+ ClientSecret: regResp.ClientSecret,
1368
+ Email: email,
1369
+ }, nil
1370
+ }
1371
+ }
internal/auth/kiro/token.go ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package kiro
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ )
9
+
10
+ // KiroTokenStorage holds the persistent token data for Kiro authentication.
11
+ type KiroTokenStorage struct {
12
+ // AccessToken is the OAuth2 access token for API access
13
+ AccessToken string `json:"access_token"`
14
+ // RefreshToken is used to obtain new access tokens
15
+ RefreshToken string `json:"refresh_token"`
16
+ // ProfileArn is the AWS CodeWhisperer profile ARN
17
+ ProfileArn string `json:"profile_arn"`
18
+ // ExpiresAt is the timestamp when the token expires
19
+ ExpiresAt string `json:"expires_at"`
20
+ // AuthMethod indicates the authentication method used
21
+ AuthMethod string `json:"auth_method"`
22
+ // Provider indicates the OAuth provider
23
+ Provider string `json:"provider"`
24
+ // LastRefresh is the timestamp of the last token refresh
25
+ LastRefresh string `json:"last_refresh"`
26
+ }
27
+
28
+ // SaveTokenToFile persists the token storage to the specified file path.
29
+ func (s *KiroTokenStorage) SaveTokenToFile(authFilePath string) error {
30
+ dir := filepath.Dir(authFilePath)
31
+ if err := os.MkdirAll(dir, 0700); err != nil {
32
+ return fmt.Errorf("failed to create directory: %w", err)
33
+ }
34
+
35
+ data, err := json.MarshalIndent(s, "", " ")
36
+ if err != nil {
37
+ return fmt.Errorf("failed to marshal token storage: %w", err)
38
+ }
39
+
40
+ if err := os.WriteFile(authFilePath, data, 0600); err != nil {
41
+ return fmt.Errorf("failed to write token file: %w", err)
42
+ }
43
+
44
+ return nil
45
+ }
46
+
47
+ // LoadFromFile loads token storage from the specified file path.
48
+ func LoadFromFile(authFilePath string) (*KiroTokenStorage, error) {
49
+ data, err := os.ReadFile(authFilePath)
50
+ if err != nil {
51
+ return nil, fmt.Errorf("failed to read token file: %w", err)
52
+ }
53
+
54
+ var storage KiroTokenStorage
55
+ if err := json.Unmarshal(data, &storage); err != nil {
56
+ return nil, fmt.Errorf("failed to parse token file: %w", err)
57
+ }
58
+
59
+ return &storage, nil
60
+ }
61
+
62
+ // ToTokenData converts storage to KiroTokenData for API use.
63
+ func (s *KiroTokenStorage) ToTokenData() *KiroTokenData {
64
+ return &KiroTokenData{
65
+ AccessToken: s.AccessToken,
66
+ RefreshToken: s.RefreshToken,
67
+ ProfileArn: s.ProfileArn,
68
+ ExpiresAt: s.ExpiresAt,
69
+ AuthMethod: s.AuthMethod,
70
+ Provider: s.Provider,
71
+ }
72
+ }
internal/browser/browser.go CHANGED
@@ -6,14 +6,49 @@ import (
6
  "fmt"
7
  "os/exec"
8
  "runtime"
 
 
9
 
 
10
  log "github.com/sirupsen/logrus"
11
- "github.com/skratchdot/open-golang/open"
12
  )
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  // OpenURL opens the specified URL in the default web browser.
15
- // It first attempts to use a platform-agnostic library and falls back to
16
- // platform-specific commands if that fails.
 
17
  //
18
  // Parameters:
19
  // - url: The URL to open.
@@ -21,16 +56,22 @@ import (
21
  // Returns:
22
  // - An error if the URL cannot be opened, otherwise nil.
23
  func OpenURL(url string) error {
24
- fmt.Printf("Attempting to open URL in browser: %s\n", url)
25
 
26
- // Try using the open-golang library first
27
- err := open.Run(url)
 
 
 
 
 
 
28
  if err == nil {
29
- log.Debug("Successfully opened URL using open-golang library")
30
  return nil
31
  }
32
 
33
- log.Debugf("open-golang failed: %v, trying platform-specific commands", err)
34
 
35
  // Fallback to platform-specific commands
36
  return openURLPlatformSpecific(url)
@@ -78,18 +119,379 @@ func openURLPlatformSpecific(url string) error {
78
  return nil
79
  }
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  // IsAvailable checks if the system has a command available to open a web browser.
82
  // It verifies the presence of necessary commands for the current operating system.
83
  //
84
  // Returns:
85
  // - true if a browser can be opened, false otherwise.
86
  func IsAvailable() bool {
87
- // First check if open-golang can work
88
- testErr := open.Run("about:blank")
89
- if testErr == nil {
90
- return true
91
- }
92
-
93
  // Check platform-specific commands
94
  switch runtime.GOOS {
95
  case "darwin":
 
6
  "fmt"
7
  "os/exec"
8
  "runtime"
9
+ "strings"
10
+ "sync"
11
 
12
+ pkgbrowser "github.com/pkg/browser"
13
  log "github.com/sirupsen/logrus"
 
14
  )
15
 
16
+ // incognitoMode controls whether to open URLs in incognito/private mode.
17
+ // This is useful for OAuth flows where you want to use a different account.
18
+ var incognitoMode bool
19
+
20
+ // lastBrowserProcess stores the last opened browser process for cleanup
21
+ var lastBrowserProcess *exec.Cmd
22
+ var browserMutex sync.Mutex
23
+
24
+ // SetIncognitoMode enables or disables incognito/private browsing mode.
25
+ func SetIncognitoMode(enabled bool) {
26
+ incognitoMode = enabled
27
+ }
28
+
29
+ // IsIncognitoMode returns whether incognito mode is enabled.
30
+ func IsIncognitoMode() bool {
31
+ return incognitoMode
32
+ }
33
+
34
+ // CloseBrowser closes the last opened browser process.
35
+ func CloseBrowser() error {
36
+ browserMutex.Lock()
37
+ defer browserMutex.Unlock()
38
+
39
+ if lastBrowserProcess == nil || lastBrowserProcess.Process == nil {
40
+ return nil
41
+ }
42
+
43
+ err := lastBrowserProcess.Process.Kill()
44
+ lastBrowserProcess = nil
45
+ return err
46
+ }
47
+
48
  // OpenURL opens the specified URL in the default web browser.
49
+ // It uses the pkg/browser library which provides robust cross-platform support
50
+ // for Windows, macOS, and Linux.
51
+ // If incognito mode is enabled, it will open in a private/incognito window.
52
  //
53
  // Parameters:
54
  // - url: The URL to open.
 
56
  // Returns:
57
  // - An error if the URL cannot be opened, otherwise nil.
58
  func OpenURL(url string) error {
59
+ log.Debugf("Opening URL in browser: %s (incognito=%v)", url, incognitoMode)
60
 
61
+ // If incognito mode is enabled, use platform-specific incognito commands
62
+ if incognitoMode {
63
+ log.Debug("Using incognito mode")
64
+ return openURLIncognito(url)
65
+ }
66
+
67
+ // Use pkg/browser for cross-platform support
68
+ err := pkgbrowser.OpenURL(url)
69
  if err == nil {
70
+ log.Debug("Successfully opened URL using pkg/browser library")
71
  return nil
72
  }
73
 
74
+ log.Debugf("pkg/browser failed: %v, trying platform-specific commands", err)
75
 
76
  // Fallback to platform-specific commands
77
  return openURLPlatformSpecific(url)
 
119
  return nil
120
  }
121
 
122
+ // openURLIncognito opens a URL in incognito/private browsing mode.
123
+ // It first tries to detect the default browser and use its incognito flag.
124
+ // Falls back to a chain of known browsers if detection fails.
125
+ //
126
+ // Parameters:
127
+ // - url: The URL to open.
128
+ //
129
+ // Returns:
130
+ // - An error if the URL cannot be opened, otherwise nil.
131
+ func openURLIncognito(url string) error {
132
+ // First, try to detect and use the default browser
133
+ if cmd := tryDefaultBrowserIncognito(url); cmd != nil {
134
+ log.Debugf("Using detected default browser: %s %v", cmd.Path, cmd.Args[1:])
135
+ if err := cmd.Start(); err == nil {
136
+ storeBrowserProcess(cmd)
137
+ log.Debug("Successfully opened URL in default browser's incognito mode")
138
+ return nil
139
+ }
140
+ log.Debugf("Failed to start default browser, trying fallback chain")
141
+ }
142
+
143
+ // Fallback to known browser chain
144
+ cmd := tryFallbackBrowsersIncognito(url)
145
+ if cmd == nil {
146
+ log.Warn("No browser with incognito support found, falling back to normal mode")
147
+ return openURLPlatformSpecific(url)
148
+ }
149
+
150
+ log.Debugf("Running incognito command: %s %v", cmd.Path, cmd.Args[1:])
151
+ err := cmd.Start()
152
+ if err != nil {
153
+ log.Warnf("Failed to open incognito browser: %v, falling back to normal mode", err)
154
+ return openURLPlatformSpecific(url)
155
+ }
156
+
157
+ storeBrowserProcess(cmd)
158
+ log.Debug("Successfully opened URL in incognito/private mode")
159
+ return nil
160
+ }
161
+
162
+ // storeBrowserProcess safely stores the browser process for later cleanup.
163
+ func storeBrowserProcess(cmd *exec.Cmd) {
164
+ browserMutex.Lock()
165
+ lastBrowserProcess = cmd
166
+ browserMutex.Unlock()
167
+ }
168
+
169
+ // tryDefaultBrowserIncognito attempts to detect the default browser and return
170
+ // an exec.Cmd configured with the appropriate incognito flag.
171
+ func tryDefaultBrowserIncognito(url string) *exec.Cmd {
172
+ switch runtime.GOOS {
173
+ case "darwin":
174
+ return tryDefaultBrowserMacOS(url)
175
+ case "windows":
176
+ return tryDefaultBrowserWindows(url)
177
+ case "linux":
178
+ return tryDefaultBrowserLinux(url)
179
+ }
180
+ return nil
181
+ }
182
+
183
+ // tryDefaultBrowserMacOS detects the default browser on macOS.
184
+ func tryDefaultBrowserMacOS(url string) *exec.Cmd {
185
+ // Try to get default browser from Launch Services
186
+ out, err := exec.Command("defaults", "read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers").Output()
187
+ if err != nil {
188
+ return nil
189
+ }
190
+
191
+ output := string(out)
192
+ var browserName string
193
+
194
+ // Parse the output to find the http/https handler
195
+ if containsBrowserID(output, "com.google.chrome") {
196
+ browserName = "chrome"
197
+ } else if containsBrowserID(output, "org.mozilla.firefox") {
198
+ browserName = "firefox"
199
+ } else if containsBrowserID(output, "com.apple.safari") {
200
+ browserName = "safari"
201
+ } else if containsBrowserID(output, "com.brave.browser") {
202
+ browserName = "brave"
203
+ } else if containsBrowserID(output, "com.microsoft.edgemac") {
204
+ browserName = "edge"
205
+ }
206
+
207
+ return createMacOSIncognitoCmd(browserName, url)
208
+ }
209
+
210
+ // containsBrowserID checks if the LaunchServices output contains a browser ID.
211
+ func containsBrowserID(output, bundleID string) bool {
212
+ return strings.Contains(output, bundleID)
213
+ }
214
+
215
+ // createMacOSIncognitoCmd creates the appropriate incognito command for macOS browsers.
216
+ func createMacOSIncognitoCmd(browserName, url string) *exec.Cmd {
217
+ switch browserName {
218
+ case "chrome":
219
+ // Try direct path first
220
+ chromePath := "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
221
+ if _, err := exec.LookPath(chromePath); err == nil {
222
+ return exec.Command(chromePath, "--incognito", url)
223
+ }
224
+ return exec.Command("open", "-na", "Google Chrome", "--args", "--incognito", url)
225
+ case "firefox":
226
+ return exec.Command("open", "-na", "Firefox", "--args", "--private-window", url)
227
+ case "safari":
228
+ // Safari doesn't have CLI incognito, try AppleScript
229
+ return tryAppleScriptSafariPrivate(url)
230
+ case "brave":
231
+ return exec.Command("open", "-na", "Brave Browser", "--args", "--incognito", url)
232
+ case "edge":
233
+ return exec.Command("open", "-na", "Microsoft Edge", "--args", "--inprivate", url)
234
+ }
235
+ return nil
236
+ }
237
+
238
+ // tryAppleScriptSafariPrivate attempts to open Safari in private browsing mode using AppleScript.
239
+ func tryAppleScriptSafariPrivate(url string) *exec.Cmd {
240
+ // AppleScript to open a new private window in Safari
241
+ script := fmt.Sprintf(`
242
+ tell application "Safari"
243
+ activate
244
+ tell application "System Events"
245
+ keystroke "n" using {command down, shift down}
246
+ delay 0.5
247
+ end tell
248
+ set URL of document 1 to "%s"
249
+ end tell
250
+ `, url)
251
+
252
+ cmd := exec.Command("osascript", "-e", script)
253
+ // Test if this approach works by checking if Safari is available
254
+ if _, err := exec.LookPath("/Applications/Safari.app/Contents/MacOS/Safari"); err != nil {
255
+ log.Debug("Safari not found, AppleScript private window not available")
256
+ return nil
257
+ }
258
+ log.Debug("Attempting Safari private window via AppleScript")
259
+ return cmd
260
+ }
261
+
262
+ // tryDefaultBrowserWindows detects the default browser on Windows via registry.
263
+ func tryDefaultBrowserWindows(url string) *exec.Cmd {
264
+ // Query registry for default browser
265
+ out, err := exec.Command("reg", "query",
266
+ `HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice`,
267
+ "/v", "ProgId").Output()
268
+ if err != nil {
269
+ return nil
270
+ }
271
+
272
+ output := string(out)
273
+ var browserName string
274
+
275
+ // Map ProgId to browser name
276
+ if strings.Contains(output, "ChromeHTML") {
277
+ browserName = "chrome"
278
+ } else if strings.Contains(output, "FirefoxURL") {
279
+ browserName = "firefox"
280
+ } else if strings.Contains(output, "MSEdgeHTM") {
281
+ browserName = "edge"
282
+ } else if strings.Contains(output, "BraveHTML") {
283
+ browserName = "brave"
284
+ }
285
+
286
+ return createWindowsIncognitoCmd(browserName, url)
287
+ }
288
+
289
+ // createWindowsIncognitoCmd creates the appropriate incognito command for Windows browsers.
290
+ func createWindowsIncognitoCmd(browserName, url string) *exec.Cmd {
291
+ switch browserName {
292
+ case "chrome":
293
+ paths := []string{
294
+ "chrome",
295
+ `C:\Program Files\Google\Chrome\Application\chrome.exe`,
296
+ `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
297
+ }
298
+ for _, p := range paths {
299
+ if _, err := exec.LookPath(p); err == nil {
300
+ return exec.Command(p, "--incognito", url)
301
+ }
302
+ }
303
+ case "firefox":
304
+ if path, err := exec.LookPath("firefox"); err == nil {
305
+ return exec.Command(path, "--private-window", url)
306
+ }
307
+ case "edge":
308
+ paths := []string{
309
+ "msedge",
310
+ `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
311
+ `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
312
+ }
313
+ for _, p := range paths {
314
+ if _, err := exec.LookPath(p); err == nil {
315
+ return exec.Command(p, "--inprivate", url)
316
+ }
317
+ }
318
+ case "brave":
319
+ paths := []string{
320
+ `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
321
+ `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
322
+ }
323
+ for _, p := range paths {
324
+ if _, err := exec.LookPath(p); err == nil {
325
+ return exec.Command(p, "--incognito", url)
326
+ }
327
+ }
328
+ }
329
+ return nil
330
+ }
331
+
332
+ // tryDefaultBrowserLinux detects the default browser on Linux using xdg-settings.
333
+ func tryDefaultBrowserLinux(url string) *exec.Cmd {
334
+ out, err := exec.Command("xdg-settings", "get", "default-web-browser").Output()
335
+ if err != nil {
336
+ return nil
337
+ }
338
+
339
+ desktop := string(out)
340
+ var browserName string
341
+
342
+ // Map .desktop file to browser name
343
+ if strings.Contains(desktop, "google-chrome") || strings.Contains(desktop, "chrome") {
344
+ browserName = "chrome"
345
+ } else if strings.Contains(desktop, "firefox") {
346
+ browserName = "firefox"
347
+ } else if strings.Contains(desktop, "chromium") {
348
+ browserName = "chromium"
349
+ } else if strings.Contains(desktop, "brave") {
350
+ browserName = "brave"
351
+ } else if strings.Contains(desktop, "microsoft-edge") || strings.Contains(desktop, "msedge") {
352
+ browserName = "edge"
353
+ }
354
+
355
+ return createLinuxIncognitoCmd(browserName, url)
356
+ }
357
+
358
+ // createLinuxIncognitoCmd creates the appropriate incognito command for Linux browsers.
359
+ func createLinuxIncognitoCmd(browserName, url string) *exec.Cmd {
360
+ switch browserName {
361
+ case "chrome":
362
+ paths := []string{"google-chrome", "google-chrome-stable"}
363
+ for _, p := range paths {
364
+ if path, err := exec.LookPath(p); err == nil {
365
+ return exec.Command(path, "--incognito", url)
366
+ }
367
+ }
368
+ case "firefox":
369
+ paths := []string{"firefox", "firefox-esr"}
370
+ for _, p := range paths {
371
+ if path, err := exec.LookPath(p); err == nil {
372
+ return exec.Command(path, "--private-window", url)
373
+ }
374
+ }
375
+ case "chromium":
376
+ paths := []string{"chromium", "chromium-browser"}
377
+ for _, p := range paths {
378
+ if path, err := exec.LookPath(p); err == nil {
379
+ return exec.Command(path, "--incognito", url)
380
+ }
381
+ }
382
+ case "brave":
383
+ if path, err := exec.LookPath("brave-browser"); err == nil {
384
+ return exec.Command(path, "--incognito", url)
385
+ }
386
+ case "edge":
387
+ if path, err := exec.LookPath("microsoft-edge"); err == nil {
388
+ return exec.Command(path, "--inprivate", url)
389
+ }
390
+ }
391
+ return nil
392
+ }
393
+
394
+ // tryFallbackBrowsersIncognito tries a chain of known browsers as fallback.
395
+ func tryFallbackBrowsersIncognito(url string) *exec.Cmd {
396
+ switch runtime.GOOS {
397
+ case "darwin":
398
+ return tryFallbackBrowsersMacOS(url)
399
+ case "windows":
400
+ return tryFallbackBrowsersWindows(url)
401
+ case "linux":
402
+ return tryFallbackBrowsersLinuxChain(url)
403
+ }
404
+ return nil
405
+ }
406
+
407
+ // tryFallbackBrowsersMacOS tries known browsers on macOS.
408
+ func tryFallbackBrowsersMacOS(url string) *exec.Cmd {
409
+ // Try Chrome
410
+ chromePath := "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
411
+ if _, err := exec.LookPath(chromePath); err == nil {
412
+ return exec.Command(chromePath, "--incognito", url)
413
+ }
414
+ // Try Firefox
415
+ if _, err := exec.LookPath("/Applications/Firefox.app/Contents/MacOS/firefox"); err == nil {
416
+ return exec.Command("open", "-na", "Firefox", "--args", "--private-window", url)
417
+ }
418
+ // Try Brave
419
+ if _, err := exec.LookPath("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"); err == nil {
420
+ return exec.Command("open", "-na", "Brave Browser", "--args", "--incognito", url)
421
+ }
422
+ // Try Edge
423
+ if _, err := exec.LookPath("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"); err == nil {
424
+ return exec.Command("open", "-na", "Microsoft Edge", "--args", "--inprivate", url)
425
+ }
426
+ // Last resort: try Safari with AppleScript
427
+ if cmd := tryAppleScriptSafariPrivate(url); cmd != nil {
428
+ log.Info("Using Safari with AppleScript for private browsing (may require accessibility permissions)")
429
+ return cmd
430
+ }
431
+ return nil
432
+ }
433
+
434
+ // tryFallbackBrowsersWindows tries known browsers on Windows.
435
+ func tryFallbackBrowsersWindows(url string) *exec.Cmd {
436
+ // Chrome
437
+ chromePaths := []string{
438
+ "chrome",
439
+ `C:\Program Files\Google\Chrome\Application\chrome.exe`,
440
+ `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
441
+ }
442
+ for _, p := range chromePaths {
443
+ if _, err := exec.LookPath(p); err == nil {
444
+ return exec.Command(p, "--incognito", url)
445
+ }
446
+ }
447
+ // Firefox
448
+ if path, err := exec.LookPath("firefox"); err == nil {
449
+ return exec.Command(path, "--private-window", url)
450
+ }
451
+ // Edge (usually available on Windows 10+)
452
+ edgePaths := []string{
453
+ "msedge",
454
+ `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
455
+ `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
456
+ }
457
+ for _, p := range edgePaths {
458
+ if _, err := exec.LookPath(p); err == nil {
459
+ return exec.Command(p, "--inprivate", url)
460
+ }
461
+ }
462
+ return nil
463
+ }
464
+
465
+ // tryFallbackBrowsersLinuxChain tries known browsers on Linux.
466
+ func tryFallbackBrowsersLinuxChain(url string) *exec.Cmd {
467
+ type browserConfig struct {
468
+ name string
469
+ flag string
470
+ }
471
+ browsers := []browserConfig{
472
+ {"google-chrome", "--incognito"},
473
+ {"google-chrome-stable", "--incognito"},
474
+ {"chromium", "--incognito"},
475
+ {"chromium-browser", "--incognito"},
476
+ {"firefox", "--private-window"},
477
+ {"firefox-esr", "--private-window"},
478
+ {"brave-browser", "--incognito"},
479
+ {"microsoft-edge", "--inprivate"},
480
+ }
481
+ for _, b := range browsers {
482
+ if path, err := exec.LookPath(b.name); err == nil {
483
+ return exec.Command(path, b.flag, url)
484
+ }
485
+ }
486
+ return nil
487
+ }
488
+
489
  // IsAvailable checks if the system has a command available to open a web browser.
490
  // It verifies the presence of necessary commands for the current operating system.
491
  //
492
  // Returns:
493
  // - true if a browser can be opened, false otherwise.
494
  func IsAvailable() bool {
 
 
 
 
 
 
495
  // Check platform-specific commands
496
  switch runtime.GOOS {
497
  case "darwin":
internal/cmd/auth_manager.go CHANGED
@@ -6,7 +6,7 @@ import (
6
 
7
  // newAuthManager creates a new authentication manager instance with all supported
8
  // authenticators and a file-based token store. It initializes authenticators for
9
- // Gemini, Codex, Claude, and Qwen providers.
10
  //
11
  // Returns:
12
  // - *sdkAuth.Manager: A configured authentication manager instance
@@ -19,6 +19,8 @@ func newAuthManager() *sdkAuth.Manager {
19
  sdkAuth.NewQwenAuthenticator(),
20
  sdkAuth.NewIFlowAuthenticator(),
21
  sdkAuth.NewAntigravityAuthenticator(),
 
 
22
  )
23
  return manager
24
  }
 
6
 
7
  // newAuthManager creates a new authentication manager instance with all supported
8
  // authenticators and a file-based token store. It initializes authenticators for
9
+ // Gemini, Codex, Claude, Qwen, IFlow, Antigravity, and GitHub Copilot providers.
10
  //
11
  // Returns:
12
  // - *sdkAuth.Manager: A configured authentication manager instance
 
19
  sdkAuth.NewQwenAuthenticator(),
20
  sdkAuth.NewIFlowAuthenticator(),
21
  sdkAuth.NewAntigravityAuthenticator(),
22
+ sdkAuth.NewKiroAuthenticator(),
23
+ sdkAuth.NewGitHubCopilotAuthenticator(),
24
  )
25
  return manager
26
  }
internal/cmd/github_copilot_login.go ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
8
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
9
+ log "github.com/sirupsen/logrus"
10
+ )
11
+
12
+ // DoGitHubCopilotLogin triggers the OAuth device flow for GitHub Copilot and saves tokens.
13
+ // It initiates the device flow authentication, displays the user code for the user to enter
14
+ // at GitHub's verification URL, and waits for authorization before saving the tokens.
15
+ //
16
+ // Parameters:
17
+ // - cfg: The application configuration containing proxy and auth directory settings
18
+ // - options: Login options including browser behavior settings
19
+ func DoGitHubCopilotLogin(cfg *config.Config, options *LoginOptions) {
20
+ if options == nil {
21
+ options = &LoginOptions{}
22
+ }
23
+
24
+ manager := newAuthManager()
25
+ authOpts := &sdkAuth.LoginOptions{
26
+ NoBrowser: options.NoBrowser,
27
+ Metadata: map[string]string{},
28
+ Prompt: options.Prompt,
29
+ }
30
+
31
+ record, savedPath, err := manager.Login(context.Background(), "github-copilot", cfg, authOpts)
32
+ if err != nil {
33
+ log.Errorf("GitHub Copilot authentication failed: %v", err)
34
+ return
35
+ }
36
+
37
+ if savedPath != "" {
38
+ fmt.Printf("Authentication saved to %s\n", savedPath)
39
+ }
40
+ if record != nil && record.Label != "" {
41
+ fmt.Printf("Authenticated as %s\n", record.Label)
42
+ }
43
+ fmt.Println("GitHub Copilot authentication successful!")
44
+ }
internal/cmd/kiro_login.go ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package cmd
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
8
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
9
+ log "github.com/sirupsen/logrus"
10
+ )
11
+
12
+ // DoKiroLogin triggers the Kiro authentication flow with Google OAuth.
13
+ // This is the default login method (same as --kiro-google-login).
14
+ //
15
+ // Parameters:
16
+ // - cfg: The application configuration
17
+ // - options: Login options including Prompt field
18
+ func DoKiroLogin(cfg *config.Config, options *LoginOptions) {
19
+ // Use Google login as default
20
+ DoKiroGoogleLogin(cfg, options)
21
+ }
22
+
23
+ // DoKiroGoogleLogin triggers Kiro authentication with Google OAuth.
24
+ // This uses a custom protocol handler (kiro://) to receive the callback.
25
+ //
26
+ // Parameters:
27
+ // - cfg: The application configuration
28
+ // - options: Login options including prompts
29
+ func DoKiroGoogleLogin(cfg *config.Config, options *LoginOptions) {
30
+ if options == nil {
31
+ options = &LoginOptions{}
32
+ }
33
+
34
+ // Note: Kiro defaults to incognito mode for multi-account support.
35
+ // Users can override with --no-incognito if they want to use existing browser sessions.
36
+
37
+ manager := newAuthManager()
38
+
39
+ // Use KiroAuthenticator with Google login
40
+ authenticator := sdkAuth.NewKiroAuthenticator()
41
+ record, err := authenticator.LoginWithGoogle(context.Background(), cfg, &sdkAuth.LoginOptions{
42
+ NoBrowser: options.NoBrowser,
43
+ Metadata: map[string]string{},
44
+ Prompt: options.Prompt,
45
+ })
46
+ if err != nil {
47
+ log.Errorf("Kiro Google authentication failed: %v", err)
48
+ fmt.Println("\nTroubleshooting:")
49
+ fmt.Println("1. Make sure the protocol handler is installed")
50
+ fmt.Println("2. Complete the Google login in the browser")
51
+ fmt.Println("3. If callback fails, try: --kiro-import (after logging in via Kiro IDE)")
52
+ return
53
+ }
54
+
55
+ // Save the auth record
56
+ savedPath, err := manager.SaveAuth(record, cfg)
57
+ if err != nil {
58
+ log.Errorf("Failed to save auth: %v", err)
59
+ return
60
+ }
61
+
62
+ if savedPath != "" {
63
+ fmt.Printf("Authentication saved to %s\n", savedPath)
64
+ }
65
+ if record != nil && record.Label != "" {
66
+ fmt.Printf("Authenticated as %s\n", record.Label)
67
+ }
68
+ fmt.Println("Kiro Google authentication successful!")
69
+ }
70
+
71
+ // DoKiroAWSLogin triggers Kiro authentication with AWS Builder ID.
72
+ // This uses the device code flow for AWS SSO OIDC authentication.
73
+ //
74
+ // Parameters:
75
+ // - cfg: The application configuration
76
+ // - options: Login options including prompts
77
+ func DoKiroAWSLogin(cfg *config.Config, options *LoginOptions) {
78
+ if options == nil {
79
+ options = &LoginOptions{}
80
+ }
81
+
82
+ // Note: Kiro defaults to incognito mode for multi-account support.
83
+ // Users can override with --no-incognito if they want to use existing browser sessions.
84
+
85
+ manager := newAuthManager()
86
+
87
+ // Use KiroAuthenticator with AWS Builder ID login (device code flow)
88
+ authenticator := sdkAuth.NewKiroAuthenticator()
89
+ record, err := authenticator.Login(context.Background(), cfg, &sdkAuth.LoginOptions{
90
+ NoBrowser: options.NoBrowser,
91
+ Metadata: map[string]string{},
92
+ Prompt: options.Prompt,
93
+ })
94
+ if err != nil {
95
+ log.Errorf("Kiro AWS authentication failed: %v", err)
96
+ fmt.Println("\nTroubleshooting:")
97
+ fmt.Println("1. Make sure you have an AWS Builder ID")
98
+ fmt.Println("2. Complete the authorization in the browser")
99
+ fmt.Println("3. If callback fails, try: --kiro-import (after logging in via Kiro IDE)")
100
+ return
101
+ }
102
+
103
+ // Save the auth record
104
+ savedPath, err := manager.SaveAuth(record, cfg)
105
+ if err != nil {
106
+ log.Errorf("Failed to save auth: %v", err)
107
+ return
108
+ }
109
+
110
+ if savedPath != "" {
111
+ fmt.Printf("Authentication saved to %s\n", savedPath)
112
+ }
113
+ if record != nil && record.Label != "" {
114
+ fmt.Printf("Authenticated as %s\n", record.Label)
115
+ }
116
+ fmt.Println("Kiro AWS authentication successful!")
117
+ }
118
+
119
+ // DoKiroAWSAuthCodeLogin triggers Kiro authentication with AWS Builder ID using authorization code flow.
120
+ // This provides a better UX than device code flow as it uses automatic browser callback.
121
+ //
122
+ // Parameters:
123
+ // - cfg: The application configuration
124
+ // - options: Login options including prompts
125
+ func DoKiroAWSAuthCodeLogin(cfg *config.Config, options *LoginOptions) {
126
+ if options == nil {
127
+ options = &LoginOptions{}
128
+ }
129
+
130
+ // Note: Kiro defaults to incognito mode for multi-account support.
131
+ // Users can override with --no-incognito if they want to use existing browser sessions.
132
+
133
+ manager := newAuthManager()
134
+
135
+ // Use KiroAuthenticator with AWS Builder ID login (authorization code flow)
136
+ authenticator := sdkAuth.NewKiroAuthenticator()
137
+ record, err := authenticator.LoginWithAuthCode(context.Background(), cfg, &sdkAuth.LoginOptions{
138
+ NoBrowser: options.NoBrowser,
139
+ Metadata: map[string]string{},
140
+ Prompt: options.Prompt,
141
+ })
142
+ if err != nil {
143
+ log.Errorf("Kiro AWS authentication (auth code) failed: %v", err)
144
+ fmt.Println("\nTroubleshooting:")
145
+ fmt.Println("1. Make sure you have an AWS Builder ID")
146
+ fmt.Println("2. Complete the authorization in the browser")
147
+ fmt.Println("3. If callback fails, try: --kiro-aws-login (device code flow)")
148
+ return
149
+ }
150
+
151
+ // Save the auth record
152
+ savedPath, err := manager.SaveAuth(record, cfg)
153
+ if err != nil {
154
+ log.Errorf("Failed to save auth: %v", err)
155
+ return
156
+ }
157
+
158
+ if savedPath != "" {
159
+ fmt.Printf("Authentication saved to %s\n", savedPath)
160
+ }
161
+ if record != nil && record.Label != "" {
162
+ fmt.Printf("Authenticated as %s\n", record.Label)
163
+ }
164
+ fmt.Println("Kiro AWS authentication successful!")
165
+ }
166
+
167
+ // DoKiroImport imports Kiro token from Kiro IDE's token file.
168
+ // This is useful for users who have already logged in via Kiro IDE
169
+ // and want to use the same credentials in CLI Proxy API.
170
+ //
171
+ // Parameters:
172
+ // - cfg: The application configuration
173
+ // - options: Login options (currently unused for import)
174
+ func DoKiroImport(cfg *config.Config, options *LoginOptions) {
175
+ if options == nil {
176
+ options = &LoginOptions{}
177
+ }
178
+
179
+ manager := newAuthManager()
180
+
181
+ // Use ImportFromKiroIDE instead of Login
182
+ authenticator := sdkAuth.NewKiroAuthenticator()
183
+ record, err := authenticator.ImportFromKiroIDE(context.Background(), cfg)
184
+ if err != nil {
185
+ log.Errorf("Kiro token import failed: %v", err)
186
+ fmt.Println("\nMake sure you have logged in to Kiro IDE first:")
187
+ fmt.Println("1. Open Kiro IDE")
188
+ fmt.Println("2. Click 'Sign in with Google' (or GitHub)")
189
+ fmt.Println("3. Complete the login process")
190
+ fmt.Println("4. Run this command again")
191
+ return
192
+ }
193
+
194
+ // Save the imported auth record
195
+ savedPath, err := manager.SaveAuth(record, cfg)
196
+ if err != nil {
197
+ log.Errorf("Failed to save auth: %v", err)
198
+ return
199
+ }
200
+
201
+ if savedPath != "" {
202
+ fmt.Printf("Authentication saved to %s\n", savedPath)
203
+ }
204
+ if record != nil && record.Label != "" {
205
+ fmt.Printf("Imported as %s\n", record.Label)
206
+ }
207
+ fmt.Println("Kiro token import successful!")
208
+ }
internal/cmd/login.go CHANGED
@@ -261,7 +261,8 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage
261
  finalProjectID := projectID
262
  if responseProjectID != "" {
263
  if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
264
- log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
 
265
  } else {
266
  finalProjectID = responseProjectID
267
  }
 
261
  finalProjectID := projectID
262
  if responseProjectID != "" {
263
  if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
264
+ log.Warnf("Gemini onboarding returned project %s instead of requested %s; using response project ID.", responseProjectID, projectID)
265
+ finalProjectID = responseProjectID
266
  } else {
267
  finalProjectID = responseProjectID
268
  }
internal/config/config.go CHANGED
@@ -74,6 +74,13 @@ type Config struct {
74
  // GeminiKey defines Gemini API key configurations with optional routing overrides.
75
  GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
76
 
 
 
 
 
 
 
 
77
  // Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
78
  CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
79
 
@@ -91,6 +98,7 @@ type Config struct {
91
  AmpCode AmpCode `yaml:"ampcode" json:"ampcode"`
92
 
93
  // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
 
94
  OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
95
 
96
  // OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.
@@ -104,6 +112,11 @@ type Config struct {
104
  // Payload defines default and override rules for provider payload parameters.
105
  Payload PayloadConfig `yaml:"payload" json:"payload"`
106
 
 
 
 
 
 
107
  legacyMigrationPending bool `yaml:"-" json:"-"`
108
  }
109
 
@@ -377,6 +390,35 @@ type GeminiModel struct {
377
  func (m GeminiModel) GetName() string { return m.Name }
378
  func (m GeminiModel) GetAlias() string { return m.Alias }
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  // OpenAICompatibility represents the configuration for OpenAI API compatibility
381
  // with external providers, allowing model aliases to be routed through OpenAI API format.
382
  type OpenAICompatibility struct {
@@ -479,6 +521,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
479
  cfg.DisableCooling = false
480
  cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
481
  cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
 
482
  if err = yaml.Unmarshal(data, &cfg); err != nil {
483
  if optional {
484
  // In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
@@ -538,6 +581,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
538
  // Sanitize Claude key headers
539
  cfg.SanitizeClaudeKeys()
540
 
 
 
 
541
  // Sanitize OpenAI compatibility providers: drop entries without base-url
542
  cfg.SanitizeOpenAICompatibility()
543
 
@@ -716,6 +762,23 @@ func (cfg *Config) SanitizeClaudeKeys() {
716
  }
717
  }
718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
  // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
720
  func (cfg *Config) SanitizeGeminiKeys() {
721
  if cfg == nil {
 
74
  // GeminiKey defines Gemini API key configurations with optional routing overrides.
75
  GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
76
 
77
+ // KiroKey defines a list of Kiro (AWS CodeWhisperer) configurations.
78
+ KiroKey []KiroKey `yaml:"kiro" json:"kiro"`
79
+
80
+ // KiroPreferredEndpoint sets the global default preferred endpoint for all Kiro providers.
81
+ // Values: "ide" (default, CodeWhisperer) or "cli" (Amazon Q).
82
+ KiroPreferredEndpoint string `yaml:"kiro-preferred-endpoint" json:"kiro-preferred-endpoint"`
83
+
84
  // Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
85
  CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
86
 
 
98
  AmpCode AmpCode `yaml:"ampcode" json:"ampcode"`
99
 
100
  // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
101
+ // Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot.
102
  OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
103
 
104
  // OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.
 
112
  // Payload defines default and override rules for provider payload parameters.
113
  Payload PayloadConfig `yaml:"payload" json:"payload"`
114
 
115
+ // IncognitoBrowser enables opening OAuth URLs in incognito/private browsing mode.
116
+ // This is useful when you want to login with a different account without logging out
117
+ // from your current session. Default: false.
118
+ IncognitoBrowser bool `yaml:"incognito-browser" json:"incognito-browser"`
119
+
120
  legacyMigrationPending bool `yaml:"-" json:"-"`
121
  }
122
 
 
390
  func (m GeminiModel) GetName() string { return m.Name }
391
  func (m GeminiModel) GetAlias() string { return m.Alias }
392
 
393
+ // KiroKey represents the configuration for Kiro (AWS CodeWhisperer) authentication.
394
+ type KiroKey struct {
395
+ // TokenFile is the path to the Kiro token file (default: ~/.aws/sso/cache/kiro-auth-token.json)
396
+ TokenFile string `yaml:"token-file,omitempty" json:"token-file,omitempty"`
397
+
398
+ // AccessToken is the OAuth access token for direct configuration.
399
+ AccessToken string `yaml:"access-token,omitempty" json:"access-token,omitempty"`
400
+
401
+ // RefreshToken is the OAuth refresh token for token renewal.
402
+ RefreshToken string `yaml:"refresh-token,omitempty" json:"refresh-token,omitempty"`
403
+
404
+ // ProfileArn is the AWS CodeWhisperer profile ARN.
405
+ ProfileArn string `yaml:"profile-arn,omitempty" json:"profile-arn,omitempty"`
406
+
407
+ // Region is the AWS region (default: us-east-1).
408
+ Region string `yaml:"region,omitempty" json:"region,omitempty"`
409
+
410
+ // ProxyURL optionally overrides the global proxy for this configuration.
411
+ ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
412
+
413
+ // AgentTaskType sets the Kiro API task type. Known values: "vibe", "dev", "chat".
414
+ // Leave empty to let API use defaults. Different values may inject different system prompts.
415
+ AgentTaskType string `yaml:"agent-task-type,omitempty" json:"agent-task-type,omitempty"`
416
+
417
+ // PreferredEndpoint sets the preferred Kiro API endpoint/quota.
418
+ // Values: "codewhisperer" (default, IDE quota) or "amazonq" (CLI quota).
419
+ PreferredEndpoint string `yaml:"preferred-endpoint,omitempty" json:"preferred-endpoint,omitempty"`
420
+ }
421
+
422
  // OpenAICompatibility represents the configuration for OpenAI API compatibility
423
  // with external providers, allowing model aliases to be routed through OpenAI API format.
424
  type OpenAICompatibility struct {
 
521
  cfg.DisableCooling = false
522
  cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
523
  cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
524
+ cfg.IncognitoBrowser = false // Default to normal browser (AWS uses incognito by force)
525
  if err = yaml.Unmarshal(data, &cfg); err != nil {
526
  if optional {
527
  // In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
 
581
  // Sanitize Claude key headers
582
  cfg.SanitizeClaudeKeys()
583
 
584
+ // Sanitize Kiro keys: trim whitespace from credential fields
585
+ cfg.SanitizeKiroKeys()
586
+
587
  // Sanitize OpenAI compatibility providers: drop entries without base-url
588
  cfg.SanitizeOpenAICompatibility()
589
 
 
762
  }
763
  }
764
 
765
+ // SanitizeKiroKeys trims whitespace from Kiro credential fields.
766
+ func (cfg *Config) SanitizeKiroKeys() {
767
+ if cfg == nil || len(cfg.KiroKey) == 0 {
768
+ return
769
+ }
770
+ for i := range cfg.KiroKey {
771
+ entry := &cfg.KiroKey[i]
772
+ entry.TokenFile = strings.TrimSpace(entry.TokenFile)
773
+ entry.AccessToken = strings.TrimSpace(entry.AccessToken)
774
+ entry.RefreshToken = strings.TrimSpace(entry.RefreshToken)
775
+ entry.ProfileArn = strings.TrimSpace(entry.ProfileArn)
776
+ entry.Region = strings.TrimSpace(entry.Region)
777
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
778
+ entry.PreferredEndpoint = strings.TrimSpace(entry.PreferredEndpoint)
779
+ }
780
+ }
781
+
782
  // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
783
  func (cfg *Config) SanitizeGeminiKeys() {
784
  if cfg == nil {
internal/constant/constant.go CHANGED
@@ -24,4 +24,7 @@ const (
24
 
25
  // Antigravity represents the Antigravity response format identifier.
26
  Antigravity = "antigravity"
 
 
 
27
  )
 
24
 
25
  // Antigravity represents the Antigravity response format identifier.
26
  Antigravity = "antigravity"
27
+
28
+ // Kiro represents the AWS CodeWhisperer (Kiro) provider identifier.
29
+ Kiro = "kiro"
30
  )
internal/logging/global_logger.go CHANGED
@@ -85,6 +85,7 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
85
  func SetupBaseLogger() {
86
  setupOnce.Do(func() {
87
  log.SetOutput(os.Stdout)
 
88
  log.SetReportCaller(true)
89
  log.SetFormatter(&LogFormatter{})
90
 
 
85
  func SetupBaseLogger() {
86
  setupOnce.Do(func() {
87
  log.SetOutput(os.Stdout)
88
+ log.SetLevel(log.InfoLevel)
89
  log.SetReportCaller(true)
90
  log.SetFormatter(&LogFormatter{})
91
 
internal/registry/model_definitions.go CHANGED
@@ -820,3 +820,364 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
820
 
821
  return nil
822
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
 
821
  return nil
822
  }
823
+
824
+ // GetGitHubCopilotModels returns the available models for GitHub Copilot.
825
+ // These models are available through the GitHub Copilot API at api.githubcopilot.com.
826
+ func GetGitHubCopilotModels() []*ModelInfo {
827
+ now := int64(1732752000) // 2024-11-27
828
+ return []*ModelInfo{
829
+ {
830
+ ID: "gpt-4.1",
831
+ Object: "model",
832
+ Created: now,
833
+ OwnedBy: "github-copilot",
834
+ Type: "github-copilot",
835
+ DisplayName: "GPT-4.1",
836
+ Description: "OpenAI GPT-4.1 via GitHub Copilot",
837
+ ContextLength: 128000,
838
+ MaxCompletionTokens: 16384,
839
+ },
840
+ {
841
+ ID: "gpt-5",
842
+ Object: "model",
843
+ Created: now,
844
+ OwnedBy: "github-copilot",
845
+ Type: "github-copilot",
846
+ DisplayName: "GPT-5",
847
+ Description: "OpenAI GPT-5 via GitHub Copilot",
848
+ ContextLength: 200000,
849
+ MaxCompletionTokens: 32768,
850
+ },
851
+ {
852
+ ID: "gpt-5-mini",
853
+ Object: "model",
854
+ Created: now,
855
+ OwnedBy: "github-copilot",
856
+ Type: "github-copilot",
857
+ DisplayName: "GPT-5 Mini",
858
+ Description: "OpenAI GPT-5 Mini via GitHub Copilot",
859
+ ContextLength: 128000,
860
+ MaxCompletionTokens: 16384,
861
+ },
862
+ {
863
+ ID: "gpt-5-codex",
864
+ Object: "model",
865
+ Created: now,
866
+ OwnedBy: "github-copilot",
867
+ Type: "github-copilot",
868
+ DisplayName: "GPT-5 Codex",
869
+ Description: "OpenAI GPT-5 Codex via GitHub Copilot",
870
+ ContextLength: 200000,
871
+ MaxCompletionTokens: 32768,
872
+ },
873
+ {
874
+ ID: "gpt-5.1",
875
+ Object: "model",
876
+ Created: now,
877
+ OwnedBy: "github-copilot",
878
+ Type: "github-copilot",
879
+ DisplayName: "GPT-5.1",
880
+ Description: "OpenAI GPT-5.1 via GitHub Copilot",
881
+ ContextLength: 200000,
882
+ MaxCompletionTokens: 32768,
883
+ },
884
+ {
885
+ ID: "gpt-5.1-codex",
886
+ Object: "model",
887
+ Created: now,
888
+ OwnedBy: "github-copilot",
889
+ Type: "github-copilot",
890
+ DisplayName: "GPT-5.1 Codex",
891
+ Description: "OpenAI GPT-5.1 Codex via GitHub Copilot",
892
+ ContextLength: 200000,
893
+ MaxCompletionTokens: 32768,
894
+ },
895
+ {
896
+ ID: "gpt-5.1-codex-mini",
897
+ Object: "model",
898
+ Created: now,
899
+ OwnedBy: "github-copilot",
900
+ Type: "github-copilot",
901
+ DisplayName: "GPT-5.1 Codex Mini",
902
+ Description: "OpenAI GPT-5.1 Codex Mini via GitHub Copilot",
903
+ ContextLength: 128000,
904
+ MaxCompletionTokens: 16384,
905
+ },
906
+ {
907
+ ID: "gpt-5.2",
908
+ Object: "model",
909
+ Created: now,
910
+ OwnedBy: "github-copilot",
911
+ Type: "github-copilot",
912
+ DisplayName: "GPT-5.2",
913
+ Description: "OpenAI GPT-5.2 via GitHub Copilot",
914
+ ContextLength: 200000,
915
+ MaxCompletionTokens: 32768,
916
+ },
917
+ {
918
+ ID: "claude-haiku-4.5",
919
+ Object: "model",
920
+ Created: now,
921
+ OwnedBy: "github-copilot",
922
+ Type: "github-copilot",
923
+ DisplayName: "Claude Haiku 4.5",
924
+ Description: "Anthropic Claude Haiku 4.5 via GitHub Copilot",
925
+ ContextLength: 200000,
926
+ MaxCompletionTokens: 64000,
927
+ },
928
+ {
929
+ ID: "claude-opus-4.1",
930
+ Object: "model",
931
+ Created: now,
932
+ OwnedBy: "github-copilot",
933
+ Type: "github-copilot",
934
+ DisplayName: "Claude Opus 4.1",
935
+ Description: "Anthropic Claude Opus 4.1 via GitHub Copilot",
936
+ ContextLength: 200000,
937
+ MaxCompletionTokens: 32000,
938
+ },
939
+ {
940
+ ID: "claude-opus-4.5",
941
+ Object: "model",
942
+ Created: now,
943
+ OwnedBy: "github-copilot",
944
+ Type: "github-copilot",
945
+ DisplayName: "Claude Opus 4.5",
946
+ Description: "Anthropic Claude Opus 4.5 via GitHub Copilot",
947
+ ContextLength: 200000,
948
+ MaxCompletionTokens: 64000,
949
+ },
950
+ {
951
+ ID: "claude-sonnet-4",
952
+ Object: "model",
953
+ Created: now,
954
+ OwnedBy: "github-copilot",
955
+ Type: "github-copilot",
956
+ DisplayName: "Claude Sonnet 4",
957
+ Description: "Anthropic Claude Sonnet 4 via GitHub Copilot",
958
+ ContextLength: 200000,
959
+ MaxCompletionTokens: 64000,
960
+ },
961
+ {
962
+ ID: "claude-sonnet-4.5",
963
+ Object: "model",
964
+ Created: now,
965
+ OwnedBy: "github-copilot",
966
+ Type: "github-copilot",
967
+ DisplayName: "Claude Sonnet 4.5",
968
+ Description: "Anthropic Claude Sonnet 4.5 via GitHub Copilot",
969
+ ContextLength: 200000,
970
+ MaxCompletionTokens: 64000,
971
+ },
972
+ {
973
+ ID: "gemini-2.5-pro",
974
+ Object: "model",
975
+ Created: now,
976
+ OwnedBy: "github-copilot",
977
+ Type: "github-copilot",
978
+ DisplayName: "Gemini 2.5 Pro",
979
+ Description: "Google Gemini 2.5 Pro via GitHub Copilot",
980
+ ContextLength: 1048576,
981
+ MaxCompletionTokens: 65536,
982
+ },
983
+ {
984
+ ID: "gemini-3-pro",
985
+ Object: "model",
986
+ Created: now,
987
+ OwnedBy: "github-copilot",
988
+ Type: "github-copilot",
989
+ DisplayName: "Gemini 3 Pro",
990
+ Description: "Google Gemini 3 Pro via GitHub Copilot",
991
+ ContextLength: 1048576,
992
+ MaxCompletionTokens: 65536,
993
+ },
994
+ {
995
+ ID: "grok-code-fast-1",
996
+ Object: "model",
997
+ Created: now,
998
+ OwnedBy: "github-copilot",
999
+ Type: "github-copilot",
1000
+ DisplayName: "Grok Code Fast 1",
1001
+ Description: "xAI Grok Code Fast 1 via GitHub Copilot",
1002
+ ContextLength: 128000,
1003
+ MaxCompletionTokens: 16384,
1004
+ },
1005
+ {
1006
+ ID: "raptor-mini",
1007
+ Object: "model",
1008
+ Created: now,
1009
+ OwnedBy: "github-copilot",
1010
+ Type: "github-copilot",
1011
+ DisplayName: "Raptor Mini",
1012
+ Description: "Raptor Mini via GitHub Copilot",
1013
+ ContextLength: 128000,
1014
+ MaxCompletionTokens: 16384,
1015
+ },
1016
+ }
1017
+ }
1018
+
1019
+ // GetKiroModels returns the Kiro (AWS CodeWhisperer) model definitions
1020
+ func GetKiroModels() []*ModelInfo {
1021
+ return []*ModelInfo{
1022
+ // --- Base Models ---
1023
+ {
1024
+ ID: "kiro-claude-opus-4-5",
1025
+ Object: "model",
1026
+ Created: 1732752000,
1027
+ OwnedBy: "aws",
1028
+ Type: "kiro",
1029
+ DisplayName: "Kiro Claude Opus 4.5",
1030
+ Description: "Claude Opus 4.5 via Kiro (2.2x credit)",
1031
+ ContextLength: 200000,
1032
+ MaxCompletionTokens: 64000,
1033
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1034
+ },
1035
+ {
1036
+ ID: "kiro-claude-sonnet-4-5",
1037
+ Object: "model",
1038
+ Created: 1732752000,
1039
+ OwnedBy: "aws",
1040
+ Type: "kiro",
1041
+ DisplayName: "Kiro Claude Sonnet 4.5",
1042
+ Description: "Claude Sonnet 4.5 via Kiro (1.3x credit)",
1043
+ ContextLength: 200000,
1044
+ MaxCompletionTokens: 64000,
1045
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1046
+ },
1047
+ {
1048
+ ID: "kiro-claude-sonnet-4",
1049
+ Object: "model",
1050
+ Created: 1732752000,
1051
+ OwnedBy: "aws",
1052
+ Type: "kiro",
1053
+ DisplayName: "Kiro Claude Sonnet 4",
1054
+ Description: "Claude Sonnet 4 via Kiro (1.3x credit)",
1055
+ ContextLength: 200000,
1056
+ MaxCompletionTokens: 64000,
1057
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1058
+ },
1059
+ {
1060
+ ID: "kiro-claude-haiku-4-5",
1061
+ Object: "model",
1062
+ Created: 1732752000,
1063
+ OwnedBy: "aws",
1064
+ Type: "kiro",
1065
+ DisplayName: "Kiro Claude Haiku 4.5",
1066
+ Description: "Claude Haiku 4.5 via Kiro (0.4x credit)",
1067
+ ContextLength: 200000,
1068
+ MaxCompletionTokens: 64000,
1069
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1070
+ },
1071
+ // --- Agentic Variants (Optimized for coding agents with chunked writes) ---
1072
+ {
1073
+ ID: "kiro-claude-opus-4-5-agentic",
1074
+ Object: "model",
1075
+ Created: 1732752000,
1076
+ OwnedBy: "aws",
1077
+ Type: "kiro",
1078
+ DisplayName: "Kiro Claude Opus 4.5 (Agentic)",
1079
+ Description: "Claude Opus 4.5 optimized for coding agents (chunked writes)",
1080
+ ContextLength: 200000,
1081
+ MaxCompletionTokens: 64000,
1082
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1083
+ },
1084
+ {
1085
+ ID: "kiro-claude-sonnet-4-5-agentic",
1086
+ Object: "model",
1087
+ Created: 1732752000,
1088
+ OwnedBy: "aws",
1089
+ Type: "kiro",
1090
+ DisplayName: "Kiro Claude Sonnet 4.5 (Agentic)",
1091
+ Description: "Claude Sonnet 4.5 optimized for coding agents (chunked writes)",
1092
+ ContextLength: 200000,
1093
+ MaxCompletionTokens: 64000,
1094
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1095
+ },
1096
+ {
1097
+ ID: "kiro-claude-sonnet-4-agentic",
1098
+ Object: "model",
1099
+ Created: 1732752000,
1100
+ OwnedBy: "aws",
1101
+ Type: "kiro",
1102
+ DisplayName: "Kiro Claude Sonnet 4 (Agentic)",
1103
+ Description: "Claude Sonnet 4 optimized for coding agents (chunked writes)",
1104
+ ContextLength: 200000,
1105
+ MaxCompletionTokens: 64000,
1106
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1107
+ },
1108
+ {
1109
+ ID: "kiro-claude-haiku-4-5-agentic",
1110
+ Object: "model",
1111
+ Created: 1732752000,
1112
+ OwnedBy: "aws",
1113
+ Type: "kiro",
1114
+ DisplayName: "Kiro Claude Haiku 4.5 (Agentic)",
1115
+ Description: "Claude Haiku 4.5 optimized for coding agents (chunked writes)",
1116
+ ContextLength: 200000,
1117
+ MaxCompletionTokens: 64000,
1118
+ Thinking: &ThinkingSupport{Min: 1024, Max: 32000, ZeroAllowed: true, DynamicAllowed: true},
1119
+ },
1120
+ }
1121
+ }
1122
+
1123
+ // GetAmazonQModels returns the Amazon Q (AWS CodeWhisperer) model definitions.
1124
+ // These models use the same API as Kiro and share the same executor.
1125
+ func GetAmazonQModels() []*ModelInfo {
1126
+ return []*ModelInfo{
1127
+ {
1128
+ ID: "amazonq-auto",
1129
+ Object: "model",
1130
+ Created: 1732752000,
1131
+ OwnedBy: "aws",
1132
+ Type: "kiro", // Uses Kiro executor - same API
1133
+ DisplayName: "Amazon Q Auto",
1134
+ Description: "Automatic model selection by Amazon Q",
1135
+ ContextLength: 200000,
1136
+ MaxCompletionTokens: 64000,
1137
+ },
1138
+ {
1139
+ ID: "amazonq-claude-opus-4.5",
1140
+ Object: "model",
1141
+ Created: 1732752000,
1142
+ OwnedBy: "aws",
1143
+ Type: "kiro",
1144
+ DisplayName: "Amazon Q Claude Opus 4.5",
1145
+ Description: "Claude Opus 4.5 via Amazon Q (2.2x credit)",
1146
+ ContextLength: 200000,
1147
+ MaxCompletionTokens: 64000,
1148
+ },
1149
+ {
1150
+ ID: "amazonq-claude-sonnet-4.5",
1151
+ Object: "model",
1152
+ Created: 1732752000,
1153
+ OwnedBy: "aws",
1154
+ Type: "kiro",
1155
+ DisplayName: "Amazon Q Claude Sonnet 4.5",
1156
+ Description: "Claude Sonnet 4.5 via Amazon Q (1.3x credit)",
1157
+ ContextLength: 200000,
1158
+ MaxCompletionTokens: 64000,
1159
+ },
1160
+ {
1161
+ ID: "amazonq-claude-sonnet-4",
1162
+ Object: "model",
1163
+ Created: 1732752000,
1164
+ OwnedBy: "aws",
1165
+ Type: "kiro",
1166
+ DisplayName: "Amazon Q Claude Sonnet 4",
1167
+ Description: "Claude Sonnet 4 via Amazon Q (1.3x credit)",
1168
+ ContextLength: 200000,
1169
+ MaxCompletionTokens: 64000,
1170
+ },
1171
+ {
1172
+ ID: "amazonq-claude-haiku-4.5",
1173
+ Object: "model",
1174
+ Created: 1732752000,
1175
+ OwnedBy: "aws",
1176
+ Type: "kiro",
1177
+ DisplayName: "Amazon Q Claude Haiku 4.5",
1178
+ Description: "Claude Haiku 4.5 via Amazon Q (0.4x credit)",
1179
+ ContextLength: 200000,
1180
+ MaxCompletionTokens: 64000,
1181
+ },
1182
+ }
1183
+ }
internal/registry/model_registry.go CHANGED
@@ -990,7 +990,8 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
990
  }
991
  return result
992
 
993
- case "claude":
 
994
  result := map[string]any{
995
  "id": model.ID,
996
  "object": "model",
@@ -1005,6 +1006,19 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
1005
  if model.DisplayName != "" {
1006
  result["display_name"] = model.DisplayName
1007
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
  return result
1009
 
1010
  case "gemini":
 
990
  }
991
  return result
992
 
993
+ case "claude", "kiro", "antigravity":
994
+ // Claude, Kiro, and Antigravity all use Claude-compatible format for Claude Code client
995
  result := map[string]any{
996
  "id": model.ID,
997
  "object": "model",
 
1006
  if model.DisplayName != "" {
1007
  result["display_name"] = model.DisplayName
1008
  }
1009
+ // Add thinking support for Claude Code client
1010
+ // Claude Code checks for "thinking" field (simple boolean) to enable tab toggle
1011
+ // Also add "extended_thinking" for detailed budget info
1012
+ if model.Thinking != nil {
1013
+ result["thinking"] = true
1014
+ result["extended_thinking"] = map[string]any{
1015
+ "supported": true,
1016
+ "min": model.Thinking.Min,
1017
+ "max": model.Thinking.Max,
1018
+ "zero_allowed": model.Thinking.ZeroAllowed,
1019
+ "dynamic_allowed": model.Thinking.DynamicAllowed,
1020
+ }
1021
+ }
1022
  return result
1023
 
1024
  case "gemini":
internal/runtime/executor/cache_helpers.go CHANGED
@@ -29,6 +29,7 @@ func startCodexCacheCleanup() {
29
  go func() {
30
  ticker := time.NewTicker(codexCacheCleanupInterval)
31
  defer ticker.Stop()
 
32
  for range ticker.C {
33
  purgeExpiredCodexCache()
34
  }
@@ -38,8 +39,10 @@ func startCodexCacheCleanup() {
38
  // purgeExpiredCodexCache removes entries that have expired.
39
  func purgeExpiredCodexCache() {
40
  now := time.Now()
 
41
  codexCacheMu.Lock()
42
  defer codexCacheMu.Unlock()
 
43
  for key, cache := range codexCacheMap {
44
  if cache.Expire.Before(now) {
45
  delete(codexCacheMap, key)
@@ -66,3 +69,10 @@ func setCodexCache(key string, cache codexCache) {
66
  codexCacheMap[key] = cache
67
  codexCacheMu.Unlock()
68
  }
 
 
 
 
 
 
 
 
29
  go func() {
30
  ticker := time.NewTicker(codexCacheCleanupInterval)
31
  defer ticker.Stop()
32
+
33
  for range ticker.C {
34
  purgeExpiredCodexCache()
35
  }
 
39
  // purgeExpiredCodexCache removes entries that have expired.
40
  func purgeExpiredCodexCache() {
41
  now := time.Now()
42
+
43
  codexCacheMu.Lock()
44
  defer codexCacheMu.Unlock()
45
+
46
  for key, cache := range codexCacheMap {
47
  if cache.Expire.Before(now) {
48
  delete(codexCacheMap, key)
 
69
  codexCacheMap[key] = cache
70
  codexCacheMu.Unlock()
71
  }
72
+
73
+ // deleteCodexCache deletes a cache entry.
74
+ func deleteCodexCache(key string) {
75
+ codexCacheMu.Lock()
76
+ delete(codexCacheMap, key)
77
+ codexCacheMu.Unlock()
78
+ }
internal/runtime/executor/github_copilot_executor.go ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package executor
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "context"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "sync"
11
+ "time"
12
+
13
+ "github.com/google/uuid"
14
+ copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
15
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
16
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
17
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
18
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
19
+ log "github.com/sirupsen/logrus"
20
+ "github.com/tidwall/sjson"
21
+ )
22
+
23
+ const (
24
+ githubCopilotBaseURL = "https://api.githubcopilot.com"
25
+ githubCopilotChatPath = "/chat/completions"
26
+ githubCopilotAuthType = "github-copilot"
27
+ githubCopilotTokenCacheTTL = 25 * time.Minute
28
+ // tokenExpiryBuffer is the time before expiry when we should refresh the token.
29
+ tokenExpiryBuffer = 5 * time.Minute
30
+ // maxScannerBufferSize is the maximum buffer size for SSE scanning (20MB).
31
+ maxScannerBufferSize = 20_971_520
32
+
33
+ // Copilot API header values.
34
+ copilotUserAgent = "GithubCopilot/1.0"
35
+ copilotEditorVersion = "vscode/1.100.0"
36
+ copilotPluginVersion = "copilot/1.300.0"
37
+ copilotIntegrationID = "vscode-chat"
38
+ copilotOpenAIIntent = "conversation-panel"
39
+ )
40
+
41
+ // GitHubCopilotExecutor handles requests to the GitHub Copilot API.
42
+ type GitHubCopilotExecutor struct {
43
+ cfg *config.Config
44
+ mu sync.RWMutex
45
+ cache map[string]*cachedAPIToken
46
+ }
47
+
48
+ // cachedAPIToken stores a cached Copilot API token with its expiry.
49
+ type cachedAPIToken struct {
50
+ token string
51
+ expiresAt time.Time
52
+ }
53
+
54
+ // NewGitHubCopilotExecutor constructs a new executor instance.
55
+ func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
56
+ return &GitHubCopilotExecutor{
57
+ cfg: cfg,
58
+ cache: make(map[string]*cachedAPIToken),
59
+ }
60
+ }
61
+
62
+ // Identifier implements ProviderExecutor.
63
+ func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
64
+
65
+ // PrepareRequest implements ProviderExecutor.
66
+ func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
67
+ if req == nil {
68
+ return nil
69
+ }
70
+ ctx := req.Context()
71
+ if ctx == nil {
72
+ ctx = context.Background()
73
+ }
74
+ apiToken, errToken := e.ensureAPIToken(ctx, auth)
75
+ if errToken != nil {
76
+ return errToken
77
+ }
78
+ e.applyHeaders(req, apiToken)
79
+ return nil
80
+ }
81
+
82
+ // HttpRequest injects GitHub Copilot credentials into the request and executes it.
83
+ func (e *GitHubCopilotExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
84
+ if req == nil {
85
+ return nil, fmt.Errorf("github-copilot executor: request is nil")
86
+ }
87
+ if ctx == nil {
88
+ ctx = req.Context()
89
+ }
90
+ httpReq := req.WithContext(ctx)
91
+ if errPrepare := e.PrepareRequest(httpReq, auth); errPrepare != nil {
92
+ return nil, errPrepare
93
+ }
94
+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
95
+ return httpClient.Do(httpReq)
96
+ }
97
+
98
+ // Execute handles non-streaming requests to GitHub Copilot.
99
+ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
100
+ apiToken, errToken := e.ensureAPIToken(ctx, auth)
101
+ if errToken != nil {
102
+ return resp, errToken
103
+ }
104
+
105
+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
106
+ defer reporter.trackFailure(ctx, &err)
107
+
108
+ from := opts.SourceFormat
109
+ to := sdktranslator.FromString("openai")
110
+ originalPayload := bytes.Clone(req.Payload)
111
+ if len(opts.OriginalRequest) > 0 {
112
+ originalPayload = bytes.Clone(opts.OriginalRequest)
113
+ }
114
+ originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
115
+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
116
+ body = e.normalizeModel(req.Model, body)
117
+ body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
118
+ body, _ = sjson.SetBytes(body, "stream", false)
119
+
120
+ url := githubCopilotBaseURL + githubCopilotChatPath
121
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
122
+ if err != nil {
123
+ return resp, err
124
+ }
125
+ e.applyHeaders(httpReq, apiToken)
126
+
127
+ var authID, authLabel, authType, authValue string
128
+ if auth != nil {
129
+ authID = auth.ID
130
+ authLabel = auth.Label
131
+ authType, authValue = auth.AccountInfo()
132
+ }
133
+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
134
+ URL: url,
135
+ Method: http.MethodPost,
136
+ Headers: httpReq.Header.Clone(),
137
+ Body: body,
138
+ Provider: e.Identifier(),
139
+ AuthID: authID,
140
+ AuthLabel: authLabel,
141
+ AuthType: authType,
142
+ AuthValue: authValue,
143
+ })
144
+
145
+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
146
+ httpResp, err := httpClient.Do(httpReq)
147
+ if err != nil {
148
+ recordAPIResponseError(ctx, e.cfg, err)
149
+ return resp, err
150
+ }
151
+ defer func() {
152
+ if errClose := httpResp.Body.Close(); errClose != nil {
153
+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
154
+ }
155
+ }()
156
+
157
+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
158
+
159
+ if !isHTTPSuccess(httpResp.StatusCode) {
160
+ data, _ := io.ReadAll(httpResp.Body)
161
+ appendAPIResponseChunk(ctx, e.cfg, data)
162
+ log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
163
+ err = statusErr{code: httpResp.StatusCode, msg: string(data)}
164
+ return resp, err
165
+ }
166
+
167
+ data, err := io.ReadAll(httpResp.Body)
168
+ if err != nil {
169
+ recordAPIResponseError(ctx, e.cfg, err)
170
+ return resp, err
171
+ }
172
+ appendAPIResponseChunk(ctx, e.cfg, data)
173
+
174
+ detail := parseOpenAIUsage(data)
175
+ if detail.TotalTokens > 0 {
176
+ reporter.publish(ctx, detail)
177
+ }
178
+
179
+ var param any
180
+ converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
181
+ resp = cliproxyexecutor.Response{Payload: []byte(converted)}
182
+ reporter.ensurePublished(ctx)
183
+ return resp, nil
184
+ }
185
+
186
+ // ExecuteStream handles streaming requests to GitHub Copilot.
187
+ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
188
+ apiToken, errToken := e.ensureAPIToken(ctx, auth)
189
+ if errToken != nil {
190
+ return nil, errToken
191
+ }
192
+
193
+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
194
+ defer reporter.trackFailure(ctx, &err)
195
+
196
+ from := opts.SourceFormat
197
+ to := sdktranslator.FromString("openai")
198
+ originalPayload := bytes.Clone(req.Payload)
199
+ if len(opts.OriginalRequest) > 0 {
200
+ originalPayload = bytes.Clone(opts.OriginalRequest)
201
+ }
202
+ originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
203
+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
204
+ body = e.normalizeModel(req.Model, body)
205
+ body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated)
206
+ body, _ = sjson.SetBytes(body, "stream", true)
207
+ // Enable stream options for usage stats in stream
208
+ body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
209
+
210
+ url := githubCopilotBaseURL + githubCopilotChatPath
211
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
212
+ if err != nil {
213
+ return nil, err
214
+ }
215
+ e.applyHeaders(httpReq, apiToken)
216
+
217
+ var authID, authLabel, authType, authValue string
218
+ if auth != nil {
219
+ authID = auth.ID
220
+ authLabel = auth.Label
221
+ authType, authValue = auth.AccountInfo()
222
+ }
223
+ recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
224
+ URL: url,
225
+ Method: http.MethodPost,
226
+ Headers: httpReq.Header.Clone(),
227
+ Body: body,
228
+ Provider: e.Identifier(),
229
+ AuthID: authID,
230
+ AuthLabel: authLabel,
231
+ AuthType: authType,
232
+ AuthValue: authValue,
233
+ })
234
+
235
+ httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
236
+ httpResp, err := httpClient.Do(httpReq)
237
+ if err != nil {
238
+ recordAPIResponseError(ctx, e.cfg, err)
239
+ return nil, err
240
+ }
241
+
242
+ recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
243
+
244
+ if !isHTTPSuccess(httpResp.StatusCode) {
245
+ data, readErr := io.ReadAll(httpResp.Body)
246
+ if errClose := httpResp.Body.Close(); errClose != nil {
247
+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
248
+ }
249
+ if readErr != nil {
250
+ recordAPIResponseError(ctx, e.cfg, readErr)
251
+ return nil, readErr
252
+ }
253
+ appendAPIResponseChunk(ctx, e.cfg, data)
254
+ log.Debugf("github-copilot executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
255
+ err = statusErr{code: httpResp.StatusCode, msg: string(data)}
256
+ return nil, err
257
+ }
258
+
259
+ out := make(chan cliproxyexecutor.StreamChunk)
260
+ stream = out
261
+
262
+ go func() {
263
+ defer close(out)
264
+ defer func() {
265
+ if errClose := httpResp.Body.Close(); errClose != nil {
266
+ log.Errorf("github-copilot executor: close response body error: %v", errClose)
267
+ }
268
+ }()
269
+
270
+ scanner := bufio.NewScanner(httpResp.Body)
271
+ scanner.Buffer(nil, maxScannerBufferSize)
272
+ var param any
273
+
274
+ for scanner.Scan() {
275
+ line := scanner.Bytes()
276
+ appendAPIResponseChunk(ctx, e.cfg, line)
277
+
278
+ // Parse SSE data
279
+ if bytes.HasPrefix(line, dataTag) {
280
+ data := bytes.TrimSpace(line[5:])
281
+ if bytes.Equal(data, []byte("[DONE]")) {
282
+ continue
283
+ }
284
+ if detail, ok := parseOpenAIStreamUsage(line); ok {
285
+ reporter.publish(ctx, detail)
286
+ }
287
+ }
288
+
289
+ chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
290
+ for i := range chunks {
291
+ out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
292
+ }
293
+ }
294
+
295
+ if errScan := scanner.Err(); errScan != nil {
296
+ recordAPIResponseError(ctx, e.cfg, errScan)
297
+ reporter.publishFailure(ctx)
298
+ out <- cliproxyexecutor.StreamChunk{Err: errScan}
299
+ } else {
300
+ reporter.ensurePublished(ctx)
301
+ }
302
+ }()
303
+
304
+ return stream, nil
305
+ }
306
+
307
+ // CountTokens is not supported for GitHub Copilot.
308
+ func (e *GitHubCopilotExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
309
+ return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for github-copilot"}
310
+ }
311
+
312
+ // Refresh validates the GitHub token is still working.
313
+ // GitHub OAuth tokens don't expire traditionally, so we just validate.
314
+ func (e *GitHubCopilotExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
315
+ if auth == nil {
316
+ return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
317
+ }
318
+
319
+ // Get the GitHub access token
320
+ accessToken := metaStringValue(auth.Metadata, "access_token")
321
+ if accessToken == "" {
322
+ return auth, nil
323
+ }
324
+
325
+ // Validate the token can still get a Copilot API token
326
+ copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
327
+ _, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
328
+ if err != nil {
329
+ return nil, statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("github-copilot token validation failed: %v", err)}
330
+ }
331
+
332
+ return auth, nil
333
+ }
334
+
335
+ // ensureAPIToken gets or refreshes the Copilot API token.
336
+ func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
337
+ if auth == nil {
338
+ return "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
339
+ }
340
+
341
+ // Get the GitHub access token
342
+ accessToken := metaStringValue(auth.Metadata, "access_token")
343
+ if accessToken == "" {
344
+ return "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"}
345
+ }
346
+
347
+ // Check for cached API token using thread-safe access
348
+ e.mu.RLock()
349
+ if cached, ok := e.cache[accessToken]; ok && cached.expiresAt.After(time.Now().Add(tokenExpiryBuffer)) {
350
+ e.mu.RUnlock()
351
+ return cached.token, nil
352
+ }
353
+ e.mu.RUnlock()
354
+
355
+ // Get a new Copilot API token
356
+ copilotAuth := copilotauth.NewCopilotAuth(e.cfg)
357
+ apiToken, err := copilotAuth.GetCopilotAPIToken(ctx, accessToken)
358
+ if err != nil {
359
+ return "", statusErr{code: http.StatusUnauthorized, msg: fmt.Sprintf("failed to get copilot api token: %v", err)}
360
+ }
361
+
362
+ // Cache the token with thread-safe access
363
+ expiresAt := time.Now().Add(githubCopilotTokenCacheTTL)
364
+ if apiToken.ExpiresAt > 0 {
365
+ expiresAt = time.Unix(apiToken.ExpiresAt, 0)
366
+ }
367
+ e.mu.Lock()
368
+ e.cache[accessToken] = &cachedAPIToken{
369
+ token: apiToken.Token,
370
+ expiresAt: expiresAt,
371
+ }
372
+ e.mu.Unlock()
373
+
374
+ return apiToken.Token, nil
375
+ }
376
+
377
+ // applyHeaders sets the required headers for GitHub Copilot API requests.
378
+ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
379
+ r.Header.Set("Content-Type", "application/json")
380
+ r.Header.Set("Authorization", "Bearer "+apiToken)
381
+ r.Header.Set("Accept", "application/json")
382
+ r.Header.Set("User-Agent", copilotUserAgent)
383
+ r.Header.Set("Editor-Version", copilotEditorVersion)
384
+ r.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
385
+ r.Header.Set("Openai-Intent", copilotOpenAIIntent)
386
+ r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
387
+ r.Header.Set("X-Request-Id", uuid.NewString())
388
+ }
389
+
390
+ // normalizeModel is a no-op as GitHub Copilot accepts model names directly.
391
+ // Model mapping should be done at the registry level if needed.
392
+ func (e *GitHubCopilotExecutor) normalizeModel(_ string, body []byte) []byte {
393
+ return body
394
+ }
395
+
396
+ // isHTTPSuccess checks if the status code indicates success (2xx).
397
+ func isHTTPSuccess(statusCode int) bool {
398
+ return statusCode >= 200 && statusCode < 300
399
+ }
internal/runtime/executor/kiro_executor.go ADDED
The diff for this file is too large to render. See raw diff
 
internal/runtime/executor/proxy_helpers.go CHANGED
@@ -6,6 +6,7 @@ import (
6
  "net/http"
7
  "net/url"
8
  "strings"
 
9
  "time"
10
 
11
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
@@ -14,11 +15,19 @@ import (
14
  "golang.org/x/net/proxy"
15
  )
16
 
 
 
 
 
 
 
17
  // newProxyAwareHTTPClient creates an HTTP client with proper proxy configuration priority:
18
  // 1. Use auth.ProxyURL if configured (highest priority)
19
  // 2. Use cfg.ProxyURL if auth proxy is not configured
20
  // 3. Use RoundTripper from context if neither are configured
21
  //
 
 
22
  // Parameters:
23
  // - ctx: The context containing optional RoundTripper
24
  // - cfg: The application configuration
@@ -28,11 +37,6 @@ import (
28
  // Returns:
29
  // - *http.Client: An HTTP client with configured proxy or transport
30
  func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
31
- httpClient := &http.Client{}
32
- if timeout > 0 {
33
- httpClient.Timeout = timeout
34
- }
35
-
36
  // Priority 1: Use auth.ProxyURL if configured
37
  var proxyURL string
38
  if auth != nil {
@@ -44,11 +48,39 @@ func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip
44
  proxyURL = strings.TrimSpace(cfg.ProxyURL)
45
  }
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  // If we have a proxy URL configured, set up the transport
48
  if proxyURL != "" {
49
  transport := buildProxyTransport(proxyURL)
50
  if transport != nil {
51
  httpClient.Transport = transport
 
 
 
 
52
  return httpClient
53
  }
54
  // If proxy setup failed, log and fall through to context RoundTripper
@@ -60,6 +92,13 @@ func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip
60
  httpClient.Transport = rt
61
  }
62
 
 
 
 
 
 
 
 
63
  return httpClient
64
  }
65
 
 
6
  "net/http"
7
  "net/url"
8
  "strings"
9
+ "sync"
10
  "time"
11
 
12
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
 
15
  "golang.org/x/net/proxy"
16
  )
17
 
18
+ // httpClientCache caches HTTP clients by proxy URL to enable connection reuse
19
+ var (
20
+ httpClientCache = make(map[string]*http.Client)
21
+ httpClientCacheMutex sync.RWMutex
22
+ )
23
+
24
  // newProxyAwareHTTPClient creates an HTTP client with proper proxy configuration priority:
25
  // 1. Use auth.ProxyURL if configured (highest priority)
26
  // 2. Use cfg.ProxyURL if auth proxy is not configured
27
  // 3. Use RoundTripper from context if neither are configured
28
  //
29
+ // This function caches HTTP clients by proxy URL to enable TCP/TLS connection reuse.
30
+ //
31
  // Parameters:
32
  // - ctx: The context containing optional RoundTripper
33
  // - cfg: The application configuration
 
37
  // Returns:
38
  // - *http.Client: An HTTP client with configured proxy or transport
39
  func newProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
 
 
 
 
 
40
  // Priority 1: Use auth.ProxyURL if configured
41
  var proxyURL string
42
  if auth != nil {
 
48
  proxyURL = strings.TrimSpace(cfg.ProxyURL)
49
  }
50
 
51
+ // Build cache key from proxy URL (empty string for no proxy)
52
+ cacheKey := proxyURL
53
+
54
+ // Check cache first
55
+ httpClientCacheMutex.RLock()
56
+ if cachedClient, ok := httpClientCache[cacheKey]; ok {
57
+ httpClientCacheMutex.RUnlock()
58
+ // Return a wrapper with the requested timeout but shared transport
59
+ if timeout > 0 {
60
+ return &http.Client{
61
+ Transport: cachedClient.Transport,
62
+ Timeout: timeout,
63
+ }
64
+ }
65
+ return cachedClient
66
+ }
67
+ httpClientCacheMutex.RUnlock()
68
+
69
+ // Create new client
70
+ httpClient := &http.Client{}
71
+ if timeout > 0 {
72
+ httpClient.Timeout = timeout
73
+ }
74
+
75
  // If we have a proxy URL configured, set up the transport
76
  if proxyURL != "" {
77
  transport := buildProxyTransport(proxyURL)
78
  if transport != nil {
79
  httpClient.Transport = transport
80
+ // Cache the client
81
+ httpClientCacheMutex.Lock()
82
+ httpClientCache[cacheKey] = httpClient
83
+ httpClientCacheMutex.Unlock()
84
  return httpClient
85
  }
86
  // If proxy setup failed, log and fall through to context RoundTripper
 
92
  httpClient.Transport = rt
93
  }
94
 
95
+ // Cache the client for no-proxy case
96
+ if proxyURL == "" {
97
+ httpClientCacheMutex.Lock()
98
+ httpClientCache[cacheKey] = httpClient
99
+ httpClientCacheMutex.Unlock()
100
+ }
101
+
102
  return httpClient
103
  }
104
 
internal/runtime/executor/token_helpers.go CHANGED
@@ -2,43 +2,109 @@ package executor
2
 
3
  import (
4
  "fmt"
 
 
5
  "strings"
 
6
 
7
  "github.com/tidwall/gjson"
8
  "github.com/tiktoken-go/tokenizer"
9
  )
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  // tokenizerForModel returns a tokenizer codec suitable for an OpenAI-style model id.
12
- func tokenizerForModel(model string) (tokenizer.Codec, error) {
 
13
  sanitized := strings.ToLower(strings.TrimSpace(model))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  switch {
15
  case sanitized == "":
16
- return tokenizer.Get(tokenizer.Cl100kBase)
17
- case strings.HasPrefix(sanitized, "gpt-5"):
18
- return tokenizer.ForModel(tokenizer.GPT5)
19
  case strings.HasPrefix(sanitized, "gpt-5.1"):
20
- return tokenizer.ForModel(tokenizer.GPT5)
 
 
21
  case strings.HasPrefix(sanitized, "gpt-4.1"):
22
- return tokenizer.ForModel(tokenizer.GPT41)
23
  case strings.HasPrefix(sanitized, "gpt-4o"):
24
- return tokenizer.ForModel(tokenizer.GPT4o)
25
  case strings.HasPrefix(sanitized, "gpt-4"):
26
- return tokenizer.ForModel(tokenizer.GPT4)
27
  case strings.HasPrefix(sanitized, "gpt-3.5"), strings.HasPrefix(sanitized, "gpt-3"):
28
- return tokenizer.ForModel(tokenizer.GPT35Turbo)
29
  case strings.HasPrefix(sanitized, "o1"):
30
- return tokenizer.ForModel(tokenizer.O1)
31
  case strings.HasPrefix(sanitized, "o3"):
32
- return tokenizer.ForModel(tokenizer.O3)
33
  case strings.HasPrefix(sanitized, "o4"):
34
- return tokenizer.ForModel(tokenizer.O4Mini)
35
  default:
36
- return tokenizer.Get(tokenizer.O200kBase)
37
  }
 
 
 
 
 
38
  }
39
 
40
  // countOpenAIChatTokens approximates prompt tokens for OpenAI chat completions payloads.
41
- func countOpenAIChatTokens(enc tokenizer.Codec, payload []byte) (int64, error) {
42
  if enc == nil {
43
  return 0, fmt.Errorf("encoder is nil")
44
  }
@@ -62,11 +128,206 @@ func countOpenAIChatTokens(enc tokenizer.Codec, payload []byte) (int64, error) {
62
  return 0, nil
63
  }
64
 
 
65
  count, err := enc.Count(joined)
66
  if err != nil {
67
  return 0, err
68
  }
69
- return int64(count), nil
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  }
71
 
72
  // buildOpenAIUsageJSON returns a minimal usage structure understood by downstream translators.
 
2
 
3
  import (
4
  "fmt"
5
+ "regexp"
6
+ "strconv"
7
  "strings"
8
+ "sync"
9
 
10
  "github.com/tidwall/gjson"
11
  "github.com/tiktoken-go/tokenizer"
12
  )
13
 
14
+ // tokenizerCache stores tokenizer instances to avoid repeated creation
15
+ var tokenizerCache sync.Map
16
+
17
+ // TokenizerWrapper wraps a tokenizer codec with an adjustment factor for models
18
+ // where tiktoken may not accurately estimate token counts (e.g., Claude models)
19
+ type TokenizerWrapper struct {
20
+ Codec tokenizer.Codec
21
+ AdjustmentFactor float64 // 1.0 means no adjustment, >1.0 means tiktoken underestimates
22
+ }
23
+
24
+ // Count returns the token count with adjustment factor applied
25
+ func (tw *TokenizerWrapper) Count(text string) (int, error) {
26
+ count, err := tw.Codec.Count(text)
27
+ if err != nil {
28
+ return 0, err
29
+ }
30
+ if tw.AdjustmentFactor != 1.0 && tw.AdjustmentFactor > 0 {
31
+ return int(float64(count) * tw.AdjustmentFactor), nil
32
+ }
33
+ return count, nil
34
+ }
35
+
36
+ // getTokenizer returns a cached tokenizer for the given model.
37
+ // This improves performance by avoiding repeated tokenizer creation.
38
+ func getTokenizer(model string) (*TokenizerWrapper, error) {
39
+ // Check cache first
40
+ if cached, ok := tokenizerCache.Load(model); ok {
41
+ return cached.(*TokenizerWrapper), nil
42
+ }
43
+
44
+ // Cache miss, create new tokenizer
45
+ wrapper, err := tokenizerForModel(model)
46
+ if err != nil {
47
+ return nil, err
48
+ }
49
+
50
+ // Store in cache (use LoadOrStore to handle race conditions)
51
+ actual, _ := tokenizerCache.LoadOrStore(model, wrapper)
52
+ return actual.(*TokenizerWrapper), nil
53
+ }
54
+
55
  // tokenizerForModel returns a tokenizer codec suitable for an OpenAI-style model id.
56
+ // For Claude models, applies a 1.1 adjustment factor since tiktoken may underestimate.
57
+ func tokenizerForModel(model string) (*TokenizerWrapper, error) {
58
  sanitized := strings.ToLower(strings.TrimSpace(model))
59
+
60
+ // Claude models use cl100k_base with 1.1 adjustment factor
61
+ // because tiktoken may underestimate Claude's actual token count
62
+ if strings.Contains(sanitized, "claude") || strings.HasPrefix(sanitized, "kiro-") || strings.HasPrefix(sanitized, "amazonq-") {
63
+ enc, err := tokenizer.Get(tokenizer.Cl100kBase)
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
+ return &TokenizerWrapper{Codec: enc, AdjustmentFactor: 1.1}, nil
68
+ }
69
+
70
+ var enc tokenizer.Codec
71
+ var err error
72
+
73
  switch {
74
  case sanitized == "":
75
+ enc, err = tokenizer.Get(tokenizer.Cl100kBase)
76
+ case strings.HasPrefix(sanitized, "gpt-5.2"):
77
+ enc, err = tokenizer.ForModel(tokenizer.GPT5)
78
  case strings.HasPrefix(sanitized, "gpt-5.1"):
79
+ enc, err = tokenizer.ForModel(tokenizer.GPT5)
80
+ case strings.HasPrefix(sanitized, "gpt-5"):
81
+ enc, err = tokenizer.ForModel(tokenizer.GPT5)
82
  case strings.HasPrefix(sanitized, "gpt-4.1"):
83
+ enc, err = tokenizer.ForModel(tokenizer.GPT41)
84
  case strings.HasPrefix(sanitized, "gpt-4o"):
85
+ enc, err = tokenizer.ForModel(tokenizer.GPT4o)
86
  case strings.HasPrefix(sanitized, "gpt-4"):
87
+ enc, err = tokenizer.ForModel(tokenizer.GPT4)
88
  case strings.HasPrefix(sanitized, "gpt-3.5"), strings.HasPrefix(sanitized, "gpt-3"):
89
+ enc, err = tokenizer.ForModel(tokenizer.GPT35Turbo)
90
  case strings.HasPrefix(sanitized, "o1"):
91
+ enc, err = tokenizer.ForModel(tokenizer.O1)
92
  case strings.HasPrefix(sanitized, "o3"):
93
+ enc, err = tokenizer.ForModel(tokenizer.O3)
94
  case strings.HasPrefix(sanitized, "o4"):
95
+ enc, err = tokenizer.ForModel(tokenizer.O4Mini)
96
  default:
97
+ enc, err = tokenizer.Get(tokenizer.O200kBase)
98
  }
99
+
100
+ if err != nil {
101
+ return nil, err
102
+ }
103
+ return &TokenizerWrapper{Codec: enc, AdjustmentFactor: 1.0}, nil
104
  }
105
 
106
  // countOpenAIChatTokens approximates prompt tokens for OpenAI chat completions payloads.
107
+ func countOpenAIChatTokens(enc *TokenizerWrapper, payload []byte) (int64, error) {
108
  if enc == nil {
109
  return 0, fmt.Errorf("encoder is nil")
110
  }
 
128
  return 0, nil
129
  }
130
 
131
+ // Count text tokens
132
  count, err := enc.Count(joined)
133
  if err != nil {
134
  return 0, err
135
  }
136
+
137
+ // Extract and add image tokens from placeholders
138
+ imageTokens := extractImageTokens(joined)
139
+
140
+ return int64(count) + int64(imageTokens), nil
141
+ }
142
+
143
+ // countClaudeChatTokens approximates prompt tokens for Claude API chat completions payloads.
144
+ // This handles Claude's message format with system, messages, and tools.
145
+ // Image tokens are estimated based on image dimensions when available.
146
+ func countClaudeChatTokens(enc *TokenizerWrapper, payload []byte) (int64, error) {
147
+ if enc == nil {
148
+ return 0, fmt.Errorf("encoder is nil")
149
+ }
150
+ if len(payload) == 0 {
151
+ return 0, nil
152
+ }
153
+
154
+ root := gjson.ParseBytes(payload)
155
+ segments := make([]string, 0, 32)
156
+
157
+ // Collect system prompt (can be string or array of content blocks)
158
+ collectClaudeSystem(root.Get("system"), &segments)
159
+
160
+ // Collect messages
161
+ collectClaudeMessages(root.Get("messages"), &segments)
162
+
163
+ // Collect tools
164
+ collectClaudeTools(root.Get("tools"), &segments)
165
+
166
+ joined := strings.TrimSpace(strings.Join(segments, "\n"))
167
+ if joined == "" {
168
+ return 0, nil
169
+ }
170
+
171
+ // Count text tokens
172
+ count, err := enc.Count(joined)
173
+ if err != nil {
174
+ return 0, err
175
+ }
176
+
177
+ // Extract and add image tokens from placeholders
178
+ imageTokens := extractImageTokens(joined)
179
+
180
+ return int64(count) + int64(imageTokens), nil
181
+ }
182
+
183
+ // imageTokenPattern matches [IMAGE:xxx tokens] format for extracting estimated image tokens
184
+ var imageTokenPattern = regexp.MustCompile(`\[IMAGE:(\d+) tokens\]`)
185
+
186
+ // extractImageTokens extracts image token estimates from placeholder text.
187
+ // Placeholders are in the format [IMAGE:xxx tokens] where xxx is the estimated token count.
188
+ func extractImageTokens(text string) int {
189
+ matches := imageTokenPattern.FindAllStringSubmatch(text, -1)
190
+ total := 0
191
+ for _, match := range matches {
192
+ if len(match) > 1 {
193
+ if tokens, err := strconv.Atoi(match[1]); err == nil {
194
+ total += tokens
195
+ }
196
+ }
197
+ }
198
+ return total
199
+ }
200
+
201
+ // estimateImageTokens calculates estimated tokens for an image based on dimensions.
202
+ // Based on Claude's image token calculation: tokens ≈ (width * height) / 750
203
+ // Minimum 85 tokens, maximum 1590 tokens (for 1568x1568 images).
204
+ func estimateImageTokens(width, height float64) int {
205
+ if width <= 0 || height <= 0 {
206
+ // No valid dimensions, use default estimate (medium-sized image)
207
+ return 1000
208
+ }
209
+
210
+ tokens := int(width * height / 750)
211
+
212
+ // Apply bounds
213
+ if tokens < 85 {
214
+ tokens = 85
215
+ }
216
+ if tokens > 1590 {
217
+ tokens = 1590
218
+ }
219
+
220
+ return tokens
221
+ }
222
+
223
+ // collectClaudeSystem extracts text from Claude's system field.
224
+ // System can be a string or an array of content blocks.
225
+ func collectClaudeSystem(system gjson.Result, segments *[]string) {
226
+ if !system.Exists() {
227
+ return
228
+ }
229
+ if system.Type == gjson.String {
230
+ addIfNotEmpty(segments, system.String())
231
+ return
232
+ }
233
+ if system.IsArray() {
234
+ system.ForEach(func(_, block gjson.Result) bool {
235
+ blockType := block.Get("type").String()
236
+ if blockType == "text" || blockType == "" {
237
+ addIfNotEmpty(segments, block.Get("text").String())
238
+ }
239
+ // Also handle plain string blocks
240
+ if block.Type == gjson.String {
241
+ addIfNotEmpty(segments, block.String())
242
+ }
243
+ return true
244
+ })
245
+ }
246
+ }
247
+
248
+ // collectClaudeMessages extracts text from Claude's messages array.
249
+ func collectClaudeMessages(messages gjson.Result, segments *[]string) {
250
+ if !messages.Exists() || !messages.IsArray() {
251
+ return
252
+ }
253
+ messages.ForEach(func(_, message gjson.Result) bool {
254
+ addIfNotEmpty(segments, message.Get("role").String())
255
+ collectClaudeContent(message.Get("content"), segments)
256
+ return true
257
+ })
258
+ }
259
+
260
+ // collectClaudeContent extracts text from Claude's content field.
261
+ // Content can be a string or an array of content blocks.
262
+ // For images, estimates token count based on dimensions when available.
263
+ func collectClaudeContent(content gjson.Result, segments *[]string) {
264
+ if !content.Exists() {
265
+ return
266
+ }
267
+ if content.Type == gjson.String {
268
+ addIfNotEmpty(segments, content.String())
269
+ return
270
+ }
271
+ if content.IsArray() {
272
+ content.ForEach(func(_, part gjson.Result) bool {
273
+ partType := part.Get("type").String()
274
+ switch partType {
275
+ case "text":
276
+ addIfNotEmpty(segments, part.Get("text").String())
277
+ case "image":
278
+ // Estimate image tokens based on dimensions if available
279
+ source := part.Get("source")
280
+ if source.Exists() {
281
+ width := source.Get("width").Float()
282
+ height := source.Get("height").Float()
283
+ if width > 0 && height > 0 {
284
+ tokens := estimateImageTokens(width, height)
285
+ addIfNotEmpty(segments, fmt.Sprintf("[IMAGE:%d tokens]", tokens))
286
+ } else {
287
+ // No dimensions available, use default estimate
288
+ addIfNotEmpty(segments, "[IMAGE:1000 tokens]")
289
+ }
290
+ } else {
291
+ // No source info, use default estimate
292
+ addIfNotEmpty(segments, "[IMAGE:1000 tokens]")
293
+ }
294
+ case "tool_use":
295
+ addIfNotEmpty(segments, part.Get("id").String())
296
+ addIfNotEmpty(segments, part.Get("name").String())
297
+ if input := part.Get("input"); input.Exists() {
298
+ addIfNotEmpty(segments, input.Raw)
299
+ }
300
+ case "tool_result":
301
+ addIfNotEmpty(segments, part.Get("tool_use_id").String())
302
+ collectClaudeContent(part.Get("content"), segments)
303
+ case "thinking":
304
+ addIfNotEmpty(segments, part.Get("thinking").String())
305
+ default:
306
+ // For unknown types, try to extract any text content
307
+ if part.Type == gjson.String {
308
+ addIfNotEmpty(segments, part.String())
309
+ } else if part.Type == gjson.JSON {
310
+ addIfNotEmpty(segments, part.Raw)
311
+ }
312
+ }
313
+ return true
314
+ })
315
+ }
316
+ }
317
+
318
+ // collectClaudeTools extracts text from Claude's tools array.
319
+ func collectClaudeTools(tools gjson.Result, segments *[]string) {
320
+ if !tools.Exists() || !tools.IsArray() {
321
+ return
322
+ }
323
+ tools.ForEach(func(_, tool gjson.Result) bool {
324
+ addIfNotEmpty(segments, tool.Get("name").String())
325
+ addIfNotEmpty(segments, tool.Get("description").String())
326
+ if inputSchema := tool.Get("input_schema"); inputSchema.Exists() {
327
+ addIfNotEmpty(segments, inputSchema.Raw)
328
+ }
329
+ return true
330
+ })
331
  }
332
 
333
  // buildOpenAIUsageJSON returns a minimal usage structure understood by downstream translators.
internal/translator/claude/openai/chat-completions/claude_openai_response.go CHANGED
@@ -50,6 +50,10 @@ type ToolCallAccumulator struct {
50
  // Returns:
51
  // - []string: A slice of strings, each containing an OpenAI-compatible JSON response
52
  func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
 
 
 
 
53
  if *param == nil {
54
  *param = &ConvertAnthropicResponseToOpenAIParams{
55
  CreatedAt: 0,
 
50
  // Returns:
51
  // - []string: A slice of strings, each containing an OpenAI-compatible JSON response
52
  func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
53
+ var localParam any
54
+ if param == nil {
55
+ param = &localParam
56
+ }
57
  if *param == nil {
58
  *param = &ConvertAnthropicResponseToOpenAIParams{
59
  CreatedAt: 0,