jeo-code 0.7.27 → 0.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,12 +9,13 @@ Specific OAuth flow implementations for various providers.
9
9
  ## Key Files
10
10
  | File | Description |
11
11
  |------|-------------|
12
- | `anthropic.ts` | Brief description of purpose |
13
- | `antigravity.ts` | Brief description of purpose |
14
- | `google-project.ts` | Brief description of purpose |
15
- | `google.ts` | Brief description of purpose |
16
- | `index.ts` | Brief description of purpose |
17
- | `openai.ts` | Brief description of purpose |
12
+ | `anthropic.ts` | Anthropic (claude.ai) PKCE OAuth |
13
+ | `antigravity.ts` | Google Antigravity OAuth (desktop-app client, dedicated project discovery) |
14
+ | `google-project.ts` | Cloud Code Assist project discovery/onboarding shared by gemini + antigravity |
15
+ | `google.ts` | Google Gemini CLI OAuth (authorization-code) |
16
+ | `index.ts` | OAUTH_FLOW_REGISTRY wiring flows to providers |
17
+ | `kimi.ts` | Kimi Code (Moonshot subscription) device-authorization OAuth — no callback server |
18
+ | `openai.ts` | OpenAI Codex PKCE OAuth + device-code fallback when port 1455 is busy |
18
19
 
19
20
  ## Subdirectories
20
21
  *(None)*
@@ -56,6 +56,9 @@ export function discoverAntigravityProjectId(
56
56
  return discoverGoogleProjectId(accessToken, {
57
57
  metadata: { ...ANTIGRAVITY_DISCOVERY_METADATA },
58
58
  extraHeaders: { "User-Agent": getAntigravityUserAgent() },
59
+ // gjc antigravity parity: a reported tier without a project ONBOARDS the
60
+ // default/legacy tier instead of throwing the workspace-project hint.
61
+ alwaysOnboard: true,
59
62
  onProgress: opts.onProgress,
60
63
  });
61
64
  }
@@ -56,6 +56,11 @@ export interface DiscoverProjectOptions {
56
56
  /** Per-request deadline; a stalled discovery fetch must NEVER hang the turn
57
57
  * forever (round-5 #2 — this path runs BEFORE the manager's guarded call). */
58
58
  requestTimeoutMs?: number;
59
+ /** Antigravity semantics (gjc google-antigravity discoverProject): when the account
60
+ * reports a tier but names no project, ONBOARD the default/legacy tier instead of
61
+ * demanding an explicit workspace project. The gemini-cli flow keeps the strict
62
+ * workspace hint (default false). */
63
+ alwaysOnboard?: boolean;
59
64
  }
60
65
 
61
66
  const DISCOVERY_REQUEST_TIMEOUT_MS = 30_000;
@@ -147,12 +152,13 @@ export async function discoverGoogleProjectId(
147
152
  if (data.currentTier) {
148
153
  const existing = readProjectId(data.cloudaicompanionProject) ?? envProjectId;
149
154
  if (existing) return existing;
150
- throw new Error(WORKSPACE_PROJECT_HINT);
155
+ if (!opts.alwaysOnboard) throw new Error(WORKSPACE_PROJECT_HINT);
156
+ // alwaysOnboard: fall through and provision the default/legacy tier (gjc antigravity parity).
151
157
  }
152
158
 
153
159
  // Not onboarded yet: provision the default tier (free tier needs no project).
154
160
  const tierId = defaultTierId(data.allowedTiers) || TIER_FREE;
155
- if (tierId !== TIER_FREE && tierId !== TIER_LEGACY && !envProjectId) {
161
+ if (!opts.alwaysOnboard && tierId !== TIER_FREE && tierId !== TIER_LEGACY && !envProjectId) {
156
162
  throw new Error(WORKSPACE_PROJECT_HINT);
157
163
  }
158
164
 
@@ -9,8 +9,22 @@
9
9
  */
10
10
  import { OAuthCallbackFlow } from "../callback-server";
11
11
  import { discoverGoogleProjectId } from "./google-project";
12
+ import { getGeminiCliHeaders } from "../../ai/providers/gemini";
12
13
  import type { OAuthController, OAuthCredentials } from "../types";
13
14
 
15
+ /** Login-time Cloud Code Assist project discovery for a gemini-cli token. Sends the
16
+ * gemini-cli identity headers (User-Agent + Client-Metadata) on loadCodeAssist/
17
+ * onboardUser, matching gjc's google-gemini-cli discovery requests. */
18
+ export function discoverGeminiProjectId(
19
+ accessToken: string,
20
+ opts: { onProgress?: (message: string) => void } = {},
21
+ ): Promise<string> {
22
+ return discoverGoogleProjectId(accessToken, {
23
+ extraHeaders: getGeminiCliHeaders(),
24
+ onProgress: opts.onProgress,
25
+ });
26
+ }
27
+
14
28
  const decode = (s: string) => atob(s);
15
29
  const CLIENT_ID = decode(
16
30
  [
@@ -101,7 +115,7 @@ class GoogleOAuthFlow extends OAuthCallbackFlow {
101
115
  // gjc parity: auto-discover (or provision) the Cloud Code Assist project so
102
116
  // Antigravity models work straight after login — best-effort, never fails login.
103
117
  try {
104
- projectId = await discoverGoogleProjectId(data.access_token);
118
+ projectId = await discoverGeminiProjectId(data.access_token);
105
119
  } catch {
106
120
  projectId = undefined;
107
121
  }
@@ -5,6 +5,7 @@ import { loginAnthropic, refreshAnthropicToken } from "./anthropic";
5
5
  import { loginOpenAI, refreshOpenAIToken } from "./openai";
6
6
  import { loginGoogle, refreshGoogleToken } from "./google";
7
7
  import { loginAntigravity, refreshAntigravityToken } from "./antigravity";
8
+ import { loginKimi, refreshKimiToken } from "./kimi";
8
9
 
9
10
  export interface OAuthFlow {
10
11
  readonly provider: OAuthProvider;
@@ -52,9 +53,18 @@ export const OAUTH_FLOW_REGISTRY: Record<OAuthProvider, OAuthFlow> = {
52
53
  verifiedEndToEnd: true,
53
54
  note: "Antigravity desktop-app OAuth client; serves antigravity/* models (Gemini 3, Claude, GPT-OSS via Cloud Code Assist). The Google Cloud projectId is discovered automatically at login.",
54
55
  },
56
+ kimi: {
57
+ provider: "kimi",
58
+ label: "Kimi Code (Moonshot subscription)",
59
+ login: loginKimi,
60
+ refresh: refreshKimiToken,
61
+ verifiedEndToEnd: true,
62
+ note: "Device-code OAuth (auth.kimi.com); the token is served via the Anthropic-compatible endpoint at api.kimi.com/coding. A KIMI_API_KEY, when set and no OAuth is stored, uses the OpenAI-compatible api.moonshot.ai/v1.",
63
+ },
55
64
  };
56
65
 
57
66
  export { loginAnthropic, refreshAnthropicToken } from "./anthropic";
58
67
  export { loginOpenAI, refreshOpenAIToken } from "./openai";
59
68
  export { loginGoogle, refreshGoogleToken } from "./google";
60
69
  export { loginAntigravity, refreshAntigravityToken, discoverAntigravityProjectId, antigravityClientSecret } from "./antigravity";
70
+ export { loginKimi, refreshKimiToken, getKimiCommonHeaders } from "./kimi";
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Kimi Code (Moonshot subscription) OAuth — device authorization grant.
3
+ * Faithful port of gjc's packages/ai/src/utils/oauth/kimi.ts.
4
+ *
5
+ * No callback server: the user opens `verification_uri_complete` (or enters the
6
+ * user code at `verification_uri`) and jeo polls the token endpoint until the
7
+ * grant is approved. The minted access token is used as `Authorization: Bearer`
8
+ * against the Anthropic-compatible endpoint at https://api.kimi.com/coding.
9
+ */
10
+ import * as crypto from "node:crypto";
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import pkg from "../../../package.json";
15
+ import { jeoEnv } from "../../util/env";
16
+ import type { OAuthController, OAuthCredentials } from "../types";
17
+
18
+ const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
19
+ const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
20
+ const DEVICE_ID_FILENAME = "kimi-device-id";
21
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
22
+ const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
23
+ const OAUTH_EXPIRY_SKEW_MS = 5 * 60 * 1000;
24
+
25
+ interface DeviceAuthorizationResponse {
26
+ user_code?: string;
27
+ device_code?: string;
28
+ verification_uri?: string;
29
+ verification_uri_complete?: string;
30
+ expires_in?: number;
31
+ interval?: number;
32
+ }
33
+
34
+ interface TokenResponse {
35
+ access_token?: string;
36
+ refresh_token?: string;
37
+ expires_in?: number;
38
+ error?: string;
39
+ error_description?: string;
40
+ interval?: number;
41
+ }
42
+
43
+ /** gjc parity: raw (non-JEO_) env overrides for the OAuth host. */
44
+ function resolveOAuthHost(): string {
45
+ return process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
46
+ }
47
+
48
+ function formatDeviceModel(system: string, release: string, arch: string): string {
49
+ return [system, release, arch].filter(Boolean).join(" ").trim();
50
+ }
51
+
52
+ function getDeviceModel(): string {
53
+ const platform = os.platform();
54
+ const release = os.release();
55
+ const arch = os.arch();
56
+ if (platform === "darwin") return formatDeviceModel("macOS", release, arch);
57
+ if (platform === "win32") return formatDeviceModel("Windows", release, arch);
58
+ const label = platform === "linux" ? "Linux" : platform;
59
+ return formatDeviceModel(label, release, arch);
60
+ }
61
+
62
+ // Cached per config-dir path so a JEO_CONFIG_DIR change (tests) is honored.
63
+ const deviceIdCache = new Map<string, string>();
64
+
65
+ /** Stable per-machine device id, persisted like gjc's `<agent-dir>/kimi-device-id`. */
66
+ function getDeviceId(): string {
67
+ const dir = jeoEnv("CONFIG_DIR") || path.join(os.homedir(), ".jeo");
68
+ const deviceIdPath = path.join(dir, DEVICE_ID_FILENAME);
69
+ const cached = deviceIdCache.get(deviceIdPath);
70
+ if (cached) return cached;
71
+ try {
72
+ const trimmed = fs.readFileSync(deviceIdPath, "utf-8").trim();
73
+ if (trimmed) {
74
+ deviceIdCache.set(deviceIdPath, trimmed);
75
+ return trimmed;
76
+ }
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
79
+ }
80
+ const deviceId = crypto.randomUUID().replace(/-/g, "");
81
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
83
+ deviceIdCache.set(deviceIdPath, deviceId);
84
+ return deviceId;
85
+ }
86
+
87
+ /** X-Msh-* device headers sent on every Kimi OAuth AND API request (gjc parity). */
88
+ export function getKimiCommonHeaders(): Record<string, string> {
89
+ return {
90
+ "User-Agent": `KimiCLI/${pkg.version}`,
91
+ "X-Msh-Platform": "kimi_cli",
92
+ "X-Msh-Version": pkg.version,
93
+ "X-Msh-Device-Name": os.hostname(),
94
+ "X-Msh-Device-Model": getDeviceModel(),
95
+ "X-Msh-Os-Version": os.version(),
96
+ "X-Msh-Device-Id": getDeviceId(),
97
+ };
98
+ }
99
+
100
+ /** Abortable sleep; injectable in pollForToken for tests. */
101
+ function wait(ms: number, signal?: AbortSignal): Promise<void> {
102
+ return new Promise((resolve, reject) => {
103
+ if (signal?.aborted) return reject(new Error("Login cancelled"));
104
+ const onAbort = () => {
105
+ clearTimeout(timer);
106
+ reject(new Error("Login cancelled"));
107
+ };
108
+ const timer = setTimeout(() => {
109
+ signal?.removeEventListener("abort", onAbort);
110
+ resolve();
111
+ }, ms);
112
+ signal?.addEventListener("abort", onAbort, { once: true });
113
+ });
114
+ }
115
+
116
+ export async function requestDeviceAuthorization(): Promise<{
117
+ userCode: string;
118
+ deviceCode: string;
119
+ verificationUri: string;
120
+ verificationUriComplete: string;
121
+ expiresInMs: number;
122
+ intervalMs: number;
123
+ }> {
124
+ const response = await fetch(`${resolveOAuthHost()}/api/oauth/device_authorization`, {
125
+ method: "POST",
126
+ headers: {
127
+ "Content-Type": "application/x-www-form-urlencoded",
128
+ ...getKimiCommonHeaders(),
129
+ },
130
+ body: new URLSearchParams({ client_id: CLIENT_ID }),
131
+ });
132
+
133
+ if (!response.ok) {
134
+ const text = await response.text();
135
+ throw new Error(`Kimi device authorization failed: ${response.status} ${text}`);
136
+ }
137
+
138
+ const payload = (await response.json()) as DeviceAuthorizationResponse;
139
+ const { user_code: userCode, device_code: deviceCode, verification_uri: verificationUri } = payload;
140
+ if (!userCode || !deviceCode || !verificationUri) {
141
+ throw new Error("Kimi device authorization response missing required fields");
142
+ }
143
+
144
+ const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : DEFAULT_DEVICE_FLOW_TTL_MS;
145
+ const intervalMs =
146
+ typeof payload.interval === "number" && payload.interval > 0 ? payload.interval * 1000 : DEFAULT_POLL_INTERVAL_MS;
147
+
148
+ return {
149
+ userCode,
150
+ deviceCode,
151
+ verificationUri,
152
+ verificationUriComplete: payload.verification_uri_complete || verificationUri,
153
+ expiresInMs,
154
+ intervalMs,
155
+ };
156
+ }
157
+
158
+ function parseTokenPayload(payload: TokenResponse, refreshTokenFallback?: string): OAuthCredentials {
159
+ if (!payload.access_token || typeof payload.expires_in !== "number") {
160
+ throw new Error("Kimi token response missing required fields");
161
+ }
162
+ const refresh = payload.refresh_token ?? refreshTokenFallback;
163
+ if (!refresh) {
164
+ throw new Error("Kimi token response missing refresh token");
165
+ }
166
+ return {
167
+ access: payload.access_token,
168
+ refresh,
169
+ expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS,
170
+ };
171
+ }
172
+
173
+ /** Poll the token endpoint until the device grant is approved (RFC 8628 semantics). */
174
+ export async function pollForToken(
175
+ deviceCode: string,
176
+ intervalMs: number,
177
+ expiresInMs: number,
178
+ signal?: AbortSignal,
179
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void> = wait,
180
+ ): Promise<OAuthCredentials> {
181
+ const deadline = Date.now() + expiresInMs;
182
+ let waitMs = Math.max(1000, intervalMs);
183
+
184
+ while (Date.now() < deadline) {
185
+ if (signal?.aborted) throw new Error("Login cancelled");
186
+
187
+ const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
188
+ method: "POST",
189
+ headers: {
190
+ "Content-Type": "application/x-www-form-urlencoded",
191
+ ...getKimiCommonHeaders(),
192
+ },
193
+ body: new URLSearchParams({
194
+ client_id: CLIENT_ID,
195
+ device_code: deviceCode,
196
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
197
+ }),
198
+ });
199
+
200
+ const payload = (await response.json()) as TokenResponse;
201
+ if (response.ok && payload.access_token) {
202
+ return parseTokenPayload(payload);
203
+ }
204
+
205
+ const error = payload.error;
206
+ if (error === "authorization_pending") {
207
+ await sleep(waitMs, signal);
208
+ continue;
209
+ }
210
+ if (error === "slow_down") {
211
+ waitMs += 5000;
212
+ const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined;
213
+ if (retryAfter && retryAfter > waitMs) waitMs = retryAfter;
214
+ await sleep(waitMs, signal);
215
+ continue;
216
+ }
217
+ if (error === "expired_token") {
218
+ throw new Error("Kimi device authorization expired");
219
+ }
220
+ if (error === "access_denied") {
221
+ throw new Error("Kimi device authorization denied");
222
+ }
223
+ const description = payload.error_description ? `: ${payload.error_description}` : "";
224
+ throw new Error(`Kimi device flow failed: ${error ?? response.status}${description}`);
225
+ }
226
+
227
+ throw new Error("Kimi device flow timed out");
228
+ }
229
+
230
+ /** Login with Kimi Code OAuth (device code flow — no local callback server). */
231
+ export async function loginKimi(ctrl: OAuthController): Promise<OAuthCredentials> {
232
+ const device = await requestDeviceAuthorization();
233
+ ctrl.onAuth?.({
234
+ url: device.verificationUriComplete,
235
+ instructions: `Enter code: ${device.userCode}`,
236
+ });
237
+ return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, ctrl.signal);
238
+ }
239
+
240
+ /** Refresh a Kimi OAuth access token. */
241
+ export async function refreshKimiToken(refreshToken: string): Promise<OAuthCredentials> {
242
+ const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
243
+ method: "POST",
244
+ headers: {
245
+ "Content-Type": "application/x-www-form-urlencoded",
246
+ ...getKimiCommonHeaders(),
247
+ },
248
+ body: new URLSearchParams({
249
+ grant_type: "refresh_token",
250
+ refresh_token: refreshToken,
251
+ client_id: CLIENT_ID,
252
+ }),
253
+ });
254
+
255
+ if (!response.ok) {
256
+ const payload = (await response.json().catch(() => undefined)) as TokenResponse | undefined;
257
+ const description = payload?.error_description ? `: ${payload.error_description}` : "";
258
+ throw new Error(`Kimi token refresh failed: ${response.status}${description}`);
259
+ }
260
+
261
+ const payload = (await response.json()) as TokenResponse;
262
+ return parseTokenPayload(payload, refreshToken);
263
+ }
@@ -7,7 +7,7 @@
7
7
  * which expects a platform API key. Use this flow with a Codex-compatible
8
8
  * endpoint, or prefer an `OPENAI_API_KEY` for the bundled chat adapter.
9
9
  */
10
- import { OAuthCallbackFlow } from "../callback-server";
10
+ import { CallbackPortUnavailableError, OAuthCallbackFlow } from "../callback-server";
11
11
  import { generatePKCE } from "../pkce";
12
12
  import type { OAuthController, OAuthCredentials } from "../types";
13
13
 
@@ -20,6 +20,15 @@ const SCOPE = "openid profile email offline_access";
20
20
  const TIMEOUT_MS = 15_000;
21
21
  const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
22
22
  const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
23
+ // Device-code (headless) fallback endpoints — gjc openai-codex.ts DEVICE_* parity.
24
+ const DEVICE_USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
25
+ const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
26
+ const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
27
+ const DEVICE_AUTH_URL = "https://auth.openai.com/codex/device";
28
+ const DEVICE_POLL_INTERVAL_MS = 5_000;
29
+ const DEVICE_POLL_SAFETY_MARGIN_MS = 3_000;
30
+ /** Upper bound on device-code polling to avoid infinite loops on server errors. */
31
+ const DEVICE_MAX_POLLS = 120;
23
32
 
24
33
  function decodeJwt<T = Record<string, unknown>>(token: string): T | null {
25
34
  try {
@@ -60,6 +69,11 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
60
69
  throw new Error("OpenAI token response missing required fields");
61
70
  }
62
71
  const { accountId, email } = profileFromToken(data.access_token);
72
+ if (!accountId) {
73
+ // gjc parity (openai-codex.ts:139-141): a Codex token without a chatgpt_account_id
74
+ // claim is unusable for the request path — fail the login instead of storing it.
75
+ throw new Error("Failed to extract accountId from token");
76
+ }
63
77
  return {
64
78
  access: data.access_token,
65
79
  refresh: data.refresh_token,
@@ -93,7 +107,9 @@ class OpenAIOAuthFlow extends OAuthCallbackFlow {
93
107
  state,
94
108
  id_token_add_organizations: "true",
95
109
  codex_cli_simplified_flow: "true",
96
- originator: "jeo",
110
+ // "codex_cli_rs" for self-consistency with jeo's request path (openai-responses.ts
111
+ // sends the same originator header) — deliberate divergence from gjc's "opencode" default.
112
+ originator: "codex_cli_rs",
97
113
  });
98
114
  return { url: `${AUTHORIZE_URL}?${params.toString()}`, instructions: "Complete login in your browser." };
99
115
  }
@@ -104,7 +120,91 @@ class OpenAIOAuthFlow extends OAuthCallbackFlow {
104
120
  }
105
121
 
106
122
  export async function loginOpenAI(ctrl: OAuthController): Promise<OAuthCredentials> {
107
- return new OpenAIOAuthFlow(ctrl).login();
123
+ try {
124
+ return await new OpenAIOAuthFlow(ctrl).login();
125
+ } catch (err) {
126
+ // OpenAI requires the FIXED redirect http://localhost:1455/auth/callback, so a busy
127
+ // port cannot fall back to a random one. Fall back to the device-code flow instead
128
+ // (gjc loginOpenAICodexDevice parity) — no local callback server needed.
129
+ if (err instanceof CallbackPortUnavailableError) {
130
+ ctrl.onProgress?.(`Port ${CALLBACK_PORT} is busy — falling back to device-code login.`);
131
+ return loginOpenAIDevice(ctrl);
132
+ }
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Login using OpenAI's device-code (headless) flow — no local callback server.
139
+ * Port of gjc's loginOpenAICodexDevice (openai-codex.ts): request a user code,
140
+ * surface it via the controller, poll for the authorization code (403/404 =
141
+ * authorization pending), then run the standard token exchange against the
142
+ * fixed device redirect URI.
143
+ */
144
+ export async function loginOpenAIDevice(ctrl: OAuthController): Promise<OAuthCredentials> {
145
+ ctrl.onProgress?.("Initiating device authorization…");
146
+
147
+ const initResponse = await fetch(DEVICE_USERCODE_URL, {
148
+ method: "POST",
149
+ headers: { "content-type": "application/json" },
150
+ body: JSON.stringify({ client_id: CLIENT_ID }),
151
+ signal: AbortSignal.timeout(TIMEOUT_MS),
152
+ });
153
+ if (!initResponse.ok) {
154
+ throw new Error(`Device authorization initiation failed: ${initResponse.status}`);
155
+ }
156
+ const initData = (await initResponse.json()) as {
157
+ device_auth_id?: string;
158
+ user_code?: string;
159
+ interval?: string | number;
160
+ };
161
+ if (!initData.device_auth_id || !initData.user_code) {
162
+ throw new Error("Device authorization response missing required fields");
163
+ }
164
+
165
+ const userCode = initData.user_code;
166
+ const pollIntervalMs =
167
+ (typeof initData.interval === "number"
168
+ ? initData.interval
169
+ : parseInt(String(initData.interval ?? "5"), 10) || 5) *
170
+ 1000 +
171
+ DEVICE_POLL_SAFETY_MARGIN_MS;
172
+
173
+ ctrl.onAuth?.({ url: DEVICE_AUTH_URL, instructions: `Enter code: ${userCode}` });
174
+ ctrl.onProgress?.(`Waiting for browser authorization (code: ${userCode})…`);
175
+
176
+ for (let poll = 0; poll < DEVICE_MAX_POLLS; poll++) {
177
+ await Bun.sleep(poll === 0 ? Math.min(pollIntervalMs, DEVICE_POLL_INTERVAL_MS) : pollIntervalMs);
178
+ if (ctrl.signal?.aborted) {
179
+ throw new Error("Device authorization cancelled");
180
+ }
181
+
182
+ const pollResponse = await fetch(DEVICE_TOKEN_URL, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json" },
185
+ body: JSON.stringify({ device_auth_id: initData.device_auth_id, user_code: userCode }),
186
+ signal: AbortSignal.timeout(TIMEOUT_MS),
187
+ });
188
+
189
+ // 403/404 = authorization pending, keep polling (gjc wire parity).
190
+ if (pollResponse.status === 403 || pollResponse.status === 404) continue;
191
+ if (!pollResponse.ok) {
192
+ throw new Error(`Device token polling failed: ${pollResponse.status}`);
193
+ }
194
+
195
+ const pollData = (await pollResponse.json()) as {
196
+ authorization_code?: string;
197
+ code_verifier?: string;
198
+ };
199
+ if (!pollData.authorization_code || !pollData.code_verifier) {
200
+ throw new Error("Device token response missing authorization_code or code_verifier");
201
+ }
202
+
203
+ ctrl.onProgress?.("Exchanging authorization code for tokens…");
204
+ return exchangeCodeForToken(pollData.authorization_code, pollData.code_verifier, DEVICE_REDIRECT_URI);
205
+ }
206
+
207
+ throw new Error("Device authorization timed out — user did not complete login in time");
108
208
  }
109
209
 
110
210
  export async function refreshOpenAIToken(refreshToken: string): Promise<OAuthCredentials> {
package/src/auth/oauth.ts CHANGED
@@ -44,6 +44,15 @@ export const OAUTH_FLOWS: Record<OAuthProvider, OauthFlowDef> = {
44
44
  "Use this for antigravity/* models; Gemini CLI OAuth can be imported separately but may not satisfy Antigravity backend permissions.",
45
45
  ],
46
46
  },
47
+ kimi: {
48
+ label: "Kimi Code (Moonshot subscription)",
49
+ authorizeUrl: "https://auth.kimi.com",
50
+ instructions: [
51
+ "Device-code OAuth: no callback server. A browser tab opens at auth.kimi.com with the",
52
+ "verification URL; confirm the displayed user code there and jeo polls until approval.",
53
+ "Note: the minted token targets api.kimi.com/coding (Anthropic Messages format), not api.moonshot.ai.",
54
+ ],
55
+ },
47
56
  };
48
57
 
49
58
  export async function openInBrowser(url: string): Promise<void> {
@@ -37,6 +37,11 @@ export interface RefreshResult {
37
37
  credential: Credential;
38
38
  }
39
39
 
40
+ /** Refresh-ahead window: a token expiring within this many ms is treated as stale
41
+ * and refreshed NOW, so it never dies mid-request. gjc parity: auth-storage.ts
42
+ * OAUTH_REFRESH_SKEW_MS = 60_000. */
43
+ export const OAUTH_REFRESH_SKEW_MS = 60_000;
44
+
40
45
  /**
41
46
  * Exchange the stored refresh token for a fresh access token via the provider's
42
47
  * real OAuth token endpoint, persist it, and return the updated credential.
@@ -56,7 +61,7 @@ export async function refreshOAuthToken(provider: AuthProvider): Promise<Refresh
56
61
  return { refreshed: false, reason, credential: await resolveCredential(provider) };
57
62
  }
58
63
 
59
- if (stored.expires && stored.expires > Date.now()) {
64
+ if (stored.expires && stored.expires > Date.now() + OAUTH_REFRESH_SKEW_MS) {
60
65
  return {
61
66
  refreshed: true,
62
67
  reason: "already_refreshed",
@@ -6,16 +6,16 @@ import { jeoEnv } from "../util/env";
6
6
 
7
7
 
8
8
  /** Providers with an interactive OAuth login + refresh flow. */
9
- export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
9
+ export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity" | "kimi";
10
10
  /** Every provider jeo resolves a credential for: OAuth-capable ∪ API-key-only. */
11
- export type AuthProvider = OAuthProvider | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax" | "tencent";
11
+ export type AuthProvider = OAuthProvider | "xai" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax" | "tencent";
12
12
 
13
13
 
14
- export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"];
14
+ export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity", "kimi"];
15
15
  /** API-key-only providers (no OAuth flow) — resolved from config.providers / `<NAME>_API_KEY`. */
16
- export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "kimi", "groq", "deepseek", "mistral", "openrouter", "together", "cerebras", "fireworks", "nvidia", "alibaba-coding-plan", "huggingface", "nanogpt", "qwen-portal", "synthetic", "venice", "zenmux", "qianfan", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "minimax-code", "minimax-code-cn", "zai", "minimax", "tencent"];
16
+ export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "groq", "deepseek", "mistral", "openrouter", "together", "cerebras", "fireworks", "nvidia", "alibaba-coding-plan", "huggingface", "nanogpt", "qwen-portal", "synthetic", "venice", "zenmux", "qianfan", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "minimax-code", "minimax-code-cn", "zai", "minimax", "tencent"];
17
17
 
18
- /** Narrow an AuthProvider to the OAuth-capable subset (xai/kimi have no OAuth flow). */
18
+ /** Narrow an AuthProvider to the OAuth-capable subset (xai has no OAuth flow). */
19
19
  export function isOAuthProvider(p: AuthProvider): p is OAuthProvider {
20
20
  return (OAUTH_PROVIDERS as readonly string[]).includes(p);
21
21
  }
@@ -1027,6 +1027,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1027
1027
  maxTokens: resolveMaxOutputTokens(activeModel, sessionThinking),
1028
1028
  reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
1029
1029
  signal: ac.signal,
1030
+ sessionKey: sessionId,
1030
1031
  steer: drainSteer,
1031
1032
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1032
1033
  });
@@ -1046,6 +1047,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1046
1047
  maxTokens: resolveMaxOutputTokens(activeModel, sessionThinking),
1047
1048
  reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
1048
1049
  signal: ac.signal,
1050
+ sessionKey: sessionId,
1049
1051
  steer: drainSteer,
1050
1052
  events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
1051
1053
  });
@@ -2693,7 +2695,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2693
2695
  // TTY only — the caller prints scriptable guidance otherwise.
2694
2696
  const pickApiKeyProvider = async (statuses: Awaited<ReturnType<typeof describeAllProviders>>): Promise<AuthProvider | undefined> => {
2695
2697
  const subs = new Set<string>(SUBSCRIPTION_PROVIDER_NAMES); // surfaced under OAuth/subscription login instead
2696
- const keyed = new Set<string>(API_KEY_ONLY_PROVIDERS);
2698
+ // kimi is dual-credential (moonshot API key OR Kimi Code OAuth) — keep it key-settable.
2699
+ const keyed = new Set<string>([...API_KEY_ONLY_PROVIDERS, "kimi"]);
2697
2700
  const list = apiKeyPicker(statuses.filter(s => keyed.has(s.name) && !subs.has(s.name)), true);
2698
2701
  let chosen: ProviderName | undefined;
2699
2702
  await runSelectPicker(
@@ -3693,7 +3696,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3693
3696
  // `/provider key [name] [key]` → store an API key for an API-key-only provider
3694
3697
  // (groq/deepseek/mistral/…). Interactive: pick the provider, then paste the key.
3695
3698
  if (name === "key") {
3696
- const keyed = new Set<string>(API_KEY_ONLY_PROVIDERS);
3699
+ // kimi is dual-credential (moonshot API key OR Kimi Code OAuth) — keep it key-settable.
3700
+ const keyed = new Set<string>([...API_KEY_ONLY_PROVIDERS, "kimi"]);
3697
3701
  let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => keyed.has(t)) as AuthProvider | undefined;
3698
3702
  // A trailing token after the provider name is treated as the key itself.
3699
3703
  const inlineKey = target ? tokens.slice(1).filter(t => t.toLowerCase() !== target).pop() : undefined;
@@ -437,6 +437,9 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
437
437
  cwd: ctx.cwd,
438
438
  model,
439
439
  maxSteps,
440
+ // Per-run prompt-cache key: each worker replays its own growing history every
441
+ // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
442
+ sessionKey: crypto.randomUUID(),
440
443
  // Bounded delegation: ralph/team subagents keep an exact step contract; the
441
444
  // orchestrator owns retries, so the gjc step-extension flow is disabled here.
442
445
  budget: { maxExtensions: 0 },