Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
101 changes: 73 additions & 28 deletions docs/oauth-subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,39 @@ 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)
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
❯ Sign in with OAuth No API key to copy — one-click browser login (OpenRouter, xAI, ChatGPT, Hugging Face) or device code (Kimi Code).
Paste an API key / browse providers Any of 20+ providers, a local model, or a subscription via proxy.
```

Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:

```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. (`zero auth login`
and the interactive wizard use built-in presets for OpenRouter, xAI,
Kimi Code, ChatGPT, and Hugging Face without exporting
`ZERO_OAUTH_ALLOW_PRESETS`; any field is still overridable with
`ZERO_OAUTH_<NAME>_*` — 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.)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **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 @@ -76,17 +83,57 @@ Pick **Sign in with OAuth** → the list of providers that do real OAuth → cho
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
the provider profile and used normally.
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
ships a built-in preset for the public Grok-CLI client, but to keep third-party
client identities out of the default credential path it is **off by default**.
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
(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
- **xAI (Grok) — built-in preset** — xAI's flow needs an OAuth `client_id`. Zero
ships a built-in preset for the public Grok-CLI client. `zero auth login xai`
(browser, or `--device` for headless) and the interactive wizard enable that
preset automatically (no `ZERO_OAUTH_ALLOW_PRESETS=1` required on those
paths); the token is used directly on `api.x.ai/v1`. Library callers that
construct an `oauth.Manager` without `AllowPresets: true` still need
`ZERO_OAUTH_ALLOW_PRESETS=1`, or can set `ZERO_OAUTH_XAI_CLIENT_ID` (and
endpoints, or an issuer) 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.
- **ChatGPT (Codex) — opt-in preset** — `zero auth chatgpt` opens a browser, you
- **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`).
Both `zero auth kimi` and `zero auth login kimi-code` run through the
`auth login` engine, which enables presets unconditionally — no
`ZERO_OAUTH_ALLOW_PRESETS=1` is needed (same rule as xAI on those paths).
Any field is still overridable with
`ZERO_OAUTH_KIMI_CODE_*`. Setting only `ZERO_OAUTH_KIMI_CODE_ISSUER_URL`
replaces the preset device/token endpoints and uses discovery; leftover
preset destinations are not kept, so header policy and request URLs stay
one coherent configuration. 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). Those headers are minted lazily on the first
request that actually talks to Kimi, not when listing providers. They send
`X-Msh-Platform` (`kimi_code_cli`), `X-Msh-Version` (`unknown`),
`X-Msh-Device-Name` (this machine's hostname), `X-Msh-Device-Model`
(GOOS/GOARCH), `X-Msh-Os-Version` (GOOS), and a persistent
`X-Msh-Device-Id`. These headers 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`). A profile `baseURL`
override strips `X-Msh-*` headers. OAuth endpoint overrides keep them only
for HTTPS `auth.kimi.com` or `api.kimi.com`; any other host also strips
them. 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`).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **ChatGPT (Codex) — built-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`
(the same endpoint the openai/codex CLI uses), with `originator: codex_cli_rs` and
Expand All @@ -95,20 +142,18 @@ Pick **Sign in with OAuth** → the list of providers that do real OAuth → cho
bearer; if the claim is missing (older ChatGPT accounts, or a rotated
authorization server), the Codex backend will 401 and `zero auth status chatgpt`
will show the warning. Like xAI, the preset uses the publicly-shipped Codex CLI
client identity (`app_EMoamEEZ73f0CkXaXp7hrann`) and is opt-in via
`ZERO_OAUTH_ALLOW_PRESETS=1`. As of mid-2026 the Codex backend is
client identity (`app_EMoamEEZ73f0CkXaXp7hrann`) and `zero auth chatgpt` or the
interactive wizard enables it automatically. As of mid-2026 the Codex backend is
Cloudflare-gated: requests from a non-Codex client can still be challenged, and
the `chatgpt-proxy` route in §2 is the conservative fallback.
- **Hugging Face — opt-in preset, BYO client_id** — `zero auth login huggingface`
- **Hugging Face — built-in preset, BYO client_id** — `zero auth login huggingface`
(or `--device` for headless) opens a Hugging Face OAuth flow. The bearer works on
the OpenAI-compatible router at `https://router.huggingface.co/v1` for hundreds
of OSS models (Llama, Qwen, DeepSeek, Mistral, etc.). HF does not ship a
globally-known client_id, so the preset ships endpoints + scopes + the OIDC
issuer pre-filled; you must register a "public" OAuth app (no secret) at
<https://huggingface.co/settings/applications/new> and set the resulting
`client_id` via `ZERO_OAUTH_HUGGINGFACE_CLIENT_ID`. Enable the preset with
`ZERO_OAUTH_ALLOW_PRESETS=1` (or omit it — the BYO client_id path uses
`client_credentials = none` and doesn't need the opt-in). Free tier has strict
`client_id` via `ZERO_OAUTH_HUGGINGFACE_CLIENT_ID`. Free tier has strict
rate limits; Pro removes them.

Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
Expand Down
25 changes: 19 additions & 6 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
}
Expand Down Expand Up @@ -591,13 +603,14 @@ 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.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter'),
"chatgpt" ('zero auth chatgpt'), "kimi" ('zero auth kimi' or
'zero auth login kimi-code'), and "xai" ('zero auth login xai') work out of
the box: "zero auth login" enables baked-in presets for the chosen provider
without exporting ZERO_OAUTH_ALLOW_PRESETS. Any preset field is overridable
via the env vars below. For a custom provider named <name>, set:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
Expand Down
85 changes: 85 additions & 0 deletions internal/cli/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,58 @@ func TestRunAuthLoginValidation(t *testing.T) {
}
}

// TestRunAuthKimiAliasForwardsToLoginKimiCode pins the `zero auth kimi`
// sugar: it must forward through runAuthLogin with the kimi-code preset, so
// --help prints parser help without starting a device authorization, and
// invalid flags/positionals are rejected exactly like the canonical
// `zero auth login kimi-code` invocation.
func TestRunAuthKimiAliasForwardsToLoginKimiCode(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer

// --help must produce the login parser's help (exitSuccess) and never
// begin a device authorization.
if code := runWithDeps([]string{"auth", "kimi", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("auth kimi --help exit = %d, stderr=%q", code, stderr.String())
}
helpOut := stdout.String()
for _, want := range []string{"kimi", "login", "--device"} {
if !strings.Contains(helpOut, want) {
t.Fatalf("auth kimi --help missing %q:\n%s", want, helpOut)
}
}

// An unknown flag must be rejected identically to auth login kimi-code.
for _, args := range [][]string{
{"auth", "kimi", "--bogus"},
{"auth", "login", "kimi-code", "--bogus"},
} {
stdout.Reset()
stderr.Reset()
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatalf("%v should be rejected", args)
}
if !strings.Contains(stderr.String(), "unknown flag") {
t.Fatalf("%v stderr = %q, want unknown-flag rejection", args, stderr.String())
}
}

// A stray positional must be rejected identically to auth login kimi-code.
for _, args := range [][]string{
{"auth", "kimi", "extra-positional"},
{"auth", "login", "kimi-code", "extra-positional"},
} {
stdout.Reset()
stderr.Reset()
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatalf("%v should be rejected", args)
}
if !strings.Contains(stderr.String(), "usage:") {
t.Fatalf("%v stderr = %q, want usage error", args, stderr.String())
}
}
}

func TestRunAuthLoginUnknownProvider(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
Expand Down Expand Up @@ -321,3 +373,36 @@ func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
}
return cfg
}

func TestRunAuthKimiHelp(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "kimi", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("auth kimi --help exit = %d, stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "zero auth login") {
t.Fatalf("help text missing login guidance, got: %q", stdout.String())
}
}

func TestRunAuthKimiUnknownFlag(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "kimi", "--invalid-flag"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth kimi --invalid-flag should fail")
}
if !strings.Contains(stderr.String(), "unknown flag") {
t.Fatalf("expected unknown flag error, got stderr=%q", stderr.String())
}
}

func TestRunAuthKimiExtraPositional(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "kimi", "unexpected-extra"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth kimi unexpected-extra should fail")
}
if !strings.Contains(stderr.String(), "usage: zero auth login") {
t.Fatalf("expected login usage error, got stderr=%q", stderr.String())
}
}
19 changes: 18 additions & 1 deletion internal/cli/provider_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
baseURL := firstNonEmptyCLI(options.baseURL, descriptor.DefaultBaseURL)
var catalogHeaders map[string]string
if sameProviderSetupBaseURL(baseURL, descriptor.DefaultBaseURL) {
catalogHeaders = descriptor.CustomHeaders
catalogHeaders = stripRuntimeIdentityHeaders(descriptor.CustomHeaders)
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders)
}
Expand All @@ -452,6 +452,23 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile,
return profile, nil
}

func stripRuntimeIdentityHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return nil
}
cleaned := make(map[string]string, len(headers))
for k, v := range headers {
if providercatalog.IsRuntimeIdentityHeader(k) {
continue
}
cleaned[k] = v
}
if len(cleaned) == 0 {
return nil
}
return cleaned
}

func sameProviderSetupBaseURL(left string, right string) bool {
return strings.EqualFold(
strings.TrimRight(strings.TrimSpace(left), "/"),
Expand Down
67 changes: 67 additions & 0 deletions internal/cli/provider_setup_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package cli

import (
"bytes"
"path/filepath"
"testing"

"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
)

// Regression for issue #555's follow-up: `zero providers check` must not
Expand Down Expand Up @@ -66,3 +69,67 @@ func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) {
})
}
}

func isolateKimiDeviceIDStorage(t *testing.T) {
t.Helper()
root := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", root)
t.Setenv("APPDATA", root)
t.Setenv("HOME", root)
}

func TestProvidersAddKimiCodeDoesNotPersistRuntimeHeaders(t *testing.T) {
isolateKimiDeviceIDStorage(t)
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}

var stdout, stderr bytes.Buffer
code := runProviders([]string{"add", "kimi-code"}, &stdout, &stderr, deps)
if code != exitSuccess {
t.Fatalf("providers add kimi-code failed: %d, stderr: %s", code, stderr.String())
}

cfg := readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 1 {
t.Fatalf("expected 1 provider, got %d", len(cfg.Providers))
}
for k := range cfg.Providers[0].CustomHeaders {
if providercatalog.IsRuntimeIdentityHeader(k) {
t.Fatalf("config.json contains persisted runtime identity header %q", k)
}
}

resolved, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatalf("config.Resolve: %v", err)
}
active := resolved.Provider
if active.CustomHeaders["X-Msh-Platform"] != "kimi_code_cli" {
t.Fatalf("resolved profile missing X-Msh-Platform: %#v", active.CustomHeaders)
}
if active.CustomHeaders["X-Msh-Device-Id"] == "" {
t.Fatalf("resolved profile missing X-Msh-Device-Id: %#v", active.CustomHeaders)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestSetupKimiCodeDoesNotPersistRuntimeHeaders(t *testing.T) {
isolateKimiDeviceIDStorage(t)
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}

var stdout, stderr bytes.Buffer
code := runSetup([]string{"kimi-code"}, &stdout, &stderr, deps)
if code != exitSuccess {
t.Fatalf("setup kimi-code failed: %d, stderr: %s", code, stderr.String())
}

cfg := readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 1 {
t.Fatalf("expected 1 provider, got %d", len(cfg.Providers))
}
for k := range cfg.Providers[0].CustomHeaders {
if providercatalog.IsRuntimeIdentityHeader(k) {
t.Fatalf("config.json contains persisted runtime identity header %q", k)
}
}
}
Loading
Loading