Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a3d1bf0
feat(auth): add Kimi Code OAuth login below ChatGPT in options
euxaristia Jul 17, 2026
293a59b
fix(cli): align openrouter line in auth help text
euxaristia Jul 18, 2026
a7484d0
fix(providercatalog): rename Kimi's ID off the moonshot alias, fix it…
euxaristia Jul 18, 2026
cccdfdf
feat(oauth): implement Kimi's required X-Msh-* vendor-identity headers
euxaristia Jul 18, 2026
c9f4de3
fix(cli,tui): forward zero auth kimi's flags, route desktop Enter to …
euxaristia Jul 18, 2026
55f8b4c
fix(providercatalog,oauth,tui,docs): carry Kimi identity end-to-end
euxaristia Jul 18, 2026
d307b90
fix(auth): close remaining Kimi login races flagged in review
euxaristia Jul 19, 2026
39611bb
fix(auth): lazy Kimi device identity and pin alias safety
euxaristia Jul 19, 2026
9ad4761
fix(auth): make cloneDescriptor provider-agnostic with RuntimeHeaders…
euxaristia Jul 20, 2026
de5df90
fix(oauth): pass extraHeaders arg in TestPostTokenRefusesRedirect
euxaristia Jul 20, 2026
590866f
fix(auth): harden Kimi device-id adopt race and docs nits
euxaristia Jul 20, 2026
5c0b207
fix(auth): address Kimi OAuth review findings on headers and cancel
euxaristia Jul 22, 2026
dbf8ccd
fix(auth): repair abandoned empty Kimi device-id files
euxaristia Jul 22, 2026
20ef1fc
fix(config): strip Kimi identity headers when retargeting off the cat…
euxaristia Jul 22, 2026
ad3a11d
fix(kimiidentity): serialize abandoned device-id repair with a lock
euxaristia Jul 22, 2026
8d18921
fix(tui): cancel device polling on every quit path and reject stale a…
euxaristia Jul 22, 2026
86c1eac
docs(oauth): correct the Kimi preset opt-in instructions
euxaristia Jul 22, 2026
1dc66a7
fix(auth): recover stale Kimi repair lock and isolate test identity c…
euxaristia Jul 23, 2026
4b42f9e
fix(auth): address review findings on Kimi identity repair, test isol…
euxaristia Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 49 additions & 16 deletions docs/oauth-subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ before. Tokens are stored 0600 (or the OS keyring with
Running `/provider` opens a **"How do you want to connect?"** chooser:

```text
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI, ChatGPT, Hugging Face)
❯ Sign in with OAuth No API key to copy — browser login (OpenRouter, xAI, ChatGPT, Hugging Face) or device code (xAI, Kimi Code, Hugging Face)
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
```

Expand All @@ -46,23 +46,28 @@ Pick **Sign in with OAuth** → the list of providers that do real OAuth → cho
```text
❯ OpenRouter browser sign-in · creates a key
xAI (Grok) browser or device code
Kimi Code device code (managed coding endpoint)
ChatGPT browser (Codex backend, ChatGPT Plus/Pro)
Hugging Face browser or device code
```

- **OpenRouter / xAI / ChatGPT / Hugging Face** are real OAuth: your browser
opens to approve → done (no key to paste). OpenRouter mints a key; xAI /
ChatGPT / Hugging Face store a refreshable bearer. Hugging Face requires a
one-time OAuth-app registration (no secret needed for "public" apps); the
preset pre-fills scopes, endpoints, and the OIDC issuer. The same chooser
appears in first-run onboarding. (xAI uses an opt-in preset — set
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
- **Device code (headless / SSH):** for a provider that supports it (xAI,
Hugging Face), press **d** on the list to get a code to enter on another
device instead of opening a browser. On an SSH session or headless Linux box
(no `DISPLAY`) device code is used automatically; set `ZERO_OAUTH_DEVICE=1`
to force it anywhere. The CLI equivalent is
`zero auth login <name> --device`.
opens to approve → done (no key to paste). OpenRouter mints a key; xAI /
ChatGPT / Hugging Face store a refreshable bearer. Hugging Face requires a
one-time OAuth-app registration (no secret needed for "public" apps); the
preset pre-fills scopes, endpoints, and the OIDC issuer. Kimi Code is also
real OAuth but has no browser flow at all — see the device-code bullet
below. The same chooser appears in first-run onboarding. (xAI and Kimi Code
use an opt-in preset — set `ZERO_OAUTH_ALLOW_PRESETS=1` or your own
`ZERO_OAUTH_XAI_*` / `ZERO_OAUTH_KIMI_CODE_*`; see below.)
- **Device code (headless / SSH):** for a provider that supports it (xAI, Kimi
Code, Hugging Face), press **d** on the list to get a code to enter on
another device instead of opening a browser. On an SSH session or headless
Linux box (no `DISPLAY`) device code is used automatically; set
`ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI equivalent is
`zero auth login <name> --device`. (Kimi Code is **device-code only** — it
has no loopback/browser flow, so `zero auth kimi` always uses the device
path and pressing plain Enter on it in the wizard does too.)
- **ChatGPT / Claude are intentionally not in this list for the proxy path** —
use the dedicated `chatgpt-proxy` / `custom-anthropic-compatible` preset
(see §2) for subscription-via-proxy. ChatGPT *is* a first-class OAuth
Expand All @@ -83,9 +88,37 @@ Pick **Sign in with OAuth** → the list of providers that do real OAuth → cho
(browser, or `--device` for headless) works one-click; the token is used directly
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
Grok-CLI client that may change without notice.
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
Grok-CLI client that may change without notice.
- **Kimi Code — built-in preset, device-code only** — `zero auth kimi` (or
`zero auth login kimi-code --device`) runs the RFC 8628 device-code flow
against `https://auth.kimi.com`. You approve on another device and enter the
code; the returned access token is stored and used **directly** as a bearer
on Kimi's managed coding endpoint `https://api.kimi.com/coding/v1` (an
OpenAI-compatible chat-completions endpoint) — no ID-token claim extraction
is needed. Kimi has **no browser/loopback flow**, so the device code is the
only path (it is used automatically, and `--device` is accepted but
redundant). The catalog/provider ID is `kimi-code`, not `kimi` — the
`moonshot` provider already uses `kimi` as an alias for its separate,
API-key-based endpoint, so `zero auth kimi` is CLI sugar that forwards to
`kimi-code` rather than reusing that name. Like xAI, the preset ships the
public kimi-cli client identity (`17e5f671-d194-4dfb-9706-5516cb48c098`).
Unlike the raw `ZERO_OAUTH_XAI_*`-only path, no `ZERO_OAUTH_ALLOW_PRESETS=1`
is needed for Kimi: both `zero auth kimi` and `zero auth login kimi-code`
run through the `auth login` engine, which enables presets unconditionally.
Any field is still overridable with
`ZERO_OAUTH_KIMI_CODE_*`. Kimi's backend also requires a handful of
vendor-identity `X-Msh-*` headers across all applicable OAuth and API calls
(device authorization, polling, code exchange, refresh, and managed
runtime/completions requests). These are reverse-engineered from kimi-cli,
not from public documentation — verify against a real login before relying
on this.
This is distinct from the `moonshot` catalog entry, which is the API-key path at
`https://api.moonshot.ai/v1` (set `MOONSHOT_API_KEY`). To override the managed
endpoint, set `baseURL` on the provider profile; to override the OAuth host,
set `ZERO_OAUTH_KIMI_CODE_ISSUER_URL`/`ZERO_OAUTH_KIMI_CODE_DEVICE_URL`/`ZERO_OAUTH_KIMI_CODE_TOKEN_URL`
(the provider resolves as `kimi-code`, so the env prefix is `KIMI_CODE`).
- **ChatGPT (Codex) — opt-in preset** — `zero auth chatgpt` opens a browser, you
approve with your ChatGPT Plus/Pro/Business/Enterprise account, and the bearer is
stored. The bearer routes to `https://chatgpt.com/backend-api/codex/responses`
Expand Down
23 changes: 19 additions & 4 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
case "chatgpt":
return runAuthChatGPT(args[1:], stdout, stderr, deps)
case "kimi":
// Kimi Code is a standard device-code OAuth preset (no bespoke client
// like ChatGPT's Codex flow), so it reuses the generic `auth login`
// engine — which already opts into presets and resolves the baked-in
// kimi-code client_id/endpoints. `zero auth kimi` is sugar for
// `zero auth login kimi-code`, forwarding whatever flags the caller
// passed after "kimi" (--device, --scope, --help) through the real
// parser instead of discarding them: `zero auth kimi --help` must show
// help, not silently start a real device authorization, and an unknown
// flag or extra positional must be rejected the same way `zero auth
// login` rejects one.
return runAuthLogin(append([]string{"kimi-code"}, args[1:]...), stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
}
Expand Down Expand Up @@ -591,12 +603,15 @@ Commands:
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
openrouter Log in to OpenRouter in the browser; mints an API key
chatgpt Log in to ChatGPT in the browser (Codex backend, ChatGPT Plus/Pro)
kimi Log in to Kimi Code via device code (managed coding endpoint)

A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
and "kimi-code" ('zero auth login kimi-code' or 'zero auth kimi') work out of the
box. "xai" ('zero auth login xai') uses a built-in preset that is off by
default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth
login chatgpt' or 'zero auth chatgpt') uses a fixed-port loopback flow against
the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
Expand Down
11 changes: 6 additions & 5 deletions internal/config/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1088,11 +1088,12 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
merged[key] = value
}
profile.CustomHeaders = merged
} else if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") && !canonicalCatalogEndpoint {
// AIMLAPI attribution is owned by the catalog endpoint. A profile can retain
// those generated headers after its base URL is edited; strip their names
// before sending requests to an arbitrary staging/proxy host while preserving
// unrelated headers explicitly supplied by the user.
} else if len(descriptor.CustomHeaders) > 0 && !canonicalCatalogEndpoint {
// Catalog-owned headers (AIMLAPI attribution, Kimi's X-Msh-* device
// identity, etc.) are only valid against the catalog endpoint. A profile
// can retain those generated headers after its base URL is edited; strip
// their names before sending requests to an arbitrary staging/proxy host
// while preserving unrelated headers explicitly supplied by the user.
for profileKey := range profile.CustomHeaders {
for catalogKey := range descriptor.CustomHeaders {
if strings.EqualFold(profileKey, catalogKey) {
Expand Down
44 changes: 44 additions & 0 deletions internal/config/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -1561,6 +1562,49 @@ func TestApplyCatalogDescriptorStripsAimlapiAttributionFromRetargetedProfile(t *
}
}

// setKimiUserConfigRoot redirects os.UserConfigDir() to a throwaway temp dir
// before the kimi-code descriptor's RuntimeHeaders (kimiidentity.Headers) run,
// so this test never creates or touches the real kimi-device-id file.
func setKimiUserConfigRoot(t *testing.T) {
t.Helper()
root := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("APPDATA", root)
case "darwin":
t.Setenv("HOME", root)
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
}

func TestApplyCatalogDescriptorStripsKimiIdentityFromRetargetedProfile(t *testing.T) {
setKimiUserConfigRoot(t)
descriptor, err := providercatalog.Require("kimi-code")
if err != nil {
t.Fatal(err)
}
profile := ProviderProfile{
BaseURL: "https://proxy.example.test/v1",
CustomHeaders: map[string]string{
"x-msh-platform": "kimi_code_cli",
"X-Msh-Device-Id": "persisted-device-id",
"X-Environment": "staging",
},
}

applyCatalogDescriptor(&profile, descriptor, true)

for key := range profile.CustomHeaders {
if strings.HasPrefix(strings.ToLower(key), "x-msh-") {
t.Fatalf("kimi identity headers survived retargeting: %#v", profile.CustomHeaders)
}
}
if profile.CustomHeaders["X-Environment"] != "staging" {
t.Fatalf("user header was removed: %#v", profile.CustomHeaders)
}
}

func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
Expand Down
Loading
Loading