jeo-code 0.7.26 → 0.7.28

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.ja.md +2 -2
  3. package/README.ko.md +2 -2
  4. package/README.md +2 -2
  5. package/README.zh.md +2 -2
  6. package/package.json +1 -1
  7. package/src/agent/compaction.ts +24 -0
  8. package/src/agent/config-schema.ts +1 -0
  9. package/src/agent/engine.ts +20 -5
  10. package/src/agent/loop.ts +3 -0
  11. package/src/agent/state.ts +84 -5
  12. package/src/agent/task-tool.ts +5 -2
  13. package/src/ai/model-catalog.ts +23 -0
  14. package/src/ai/model-discovery.ts +6 -2
  15. package/src/ai/model-manager.ts +60 -13
  16. package/src/ai/providers/anthropic.ts +62 -18
  17. package/src/ai/providers/antigravity.ts +34 -22
  18. package/src/ai/providers/gemini.ts +81 -27
  19. package/src/ai/providers/kimi.ts +44 -5
  20. package/src/ai/providers/openai-responses.ts +36 -2
  21. package/src/ai/types.ts +10 -0
  22. package/src/auth/callback-server.ts +19 -5
  23. package/src/auth/flows/AGENTS.md +7 -6
  24. package/src/auth/flows/antigravity.ts +3 -0
  25. package/src/auth/flows/google-project.ts +8 -2
  26. package/src/auth/flows/google.ts +15 -1
  27. package/src/auth/flows/index.ts +10 -0
  28. package/src/auth/flows/kimi.ts +263 -0
  29. package/src/auth/flows/openai.ts +103 -3
  30. package/src/auth/oauth.ts +9 -0
  31. package/src/auth/refresh.ts +6 -1
  32. package/src/auth/storage.ts +5 -5
  33. package/src/commands/launch/input.ts +244 -9
  34. package/src/commands/launch/slash-views.ts +5 -2
  35. package/src/commands/launch.ts +64 -28
  36. package/src/commands/team.ts +3 -0
  37. package/src/tui/clipboard.ts +49 -0
  38. package/src/tui/components/welcome.ts +98 -39
  39. package/src/tui/terminal.ts +5 -3
  40. package/src/util/provider-error.ts +4 -12
  41. package/src/util/retry.ts +88 -31
@@ -27,6 +27,19 @@ export interface OAuthCallbackFlowOptions {
27
27
 
28
28
  export type CallbackResult = { code: string; state: string };
29
29
 
30
+ /** Thrown when the fixed callback port cannot be bound and the provider forbids a
31
+ * port fallback (fixed redirect URI). Typed so callers (e.g. the OpenAI flow) can
32
+ * detect the bind failure and fall back to the device-code flow instead of
33
+ * string-matching the error message. */
34
+ export class CallbackPortUnavailableError extends Error {
35
+ readonly port: number;
36
+ constructor(port: number) {
37
+ super(`OAuth callback port ${port} unavailable and this provider requires a fixed redirect URI.`);
38
+ this.name = "CallbackPortUnavailableError";
39
+ this.port = port;
40
+ }
41
+ }
42
+
30
43
  // "jeo" wordmark — bold forged monospace lettering on a neon blue→violet→pink
31
44
  // gradient (the mascot's synthwave palette), generated via god-tibo-imagen and
32
45
  // matching jeo-pi's bold-wordmark typographic treatment. Embedded as a data URI
@@ -125,6 +138,7 @@ export abstract class OAuthCallbackFlow {
125
138
  Bun.serve({
126
139
  hostname: this.callbackHostname,
127
140
  port,
141
+ reusePort: false, // gjc parity: never share the port — a busy port must FAIL, not silently split traffic
128
142
  fetch: req => this.#handle(req, expectedState),
129
143
  });
130
144
  try {
@@ -134,9 +148,7 @@ export abstract class OAuthCallbackFlow {
134
148
  return { server, redirectUri };
135
149
  } catch {
136
150
  if (this.fixedRedirectUri) {
137
- throw new Error(
138
- `OAuth callback port ${this.preferredPort} unavailable and this provider requires a fixed redirect URI.`
139
- );
151
+ throw new CallbackPortUnavailableError(this.preferredPort);
140
152
  }
141
153
  const server = serve(0);
142
154
  const redirectUri = `http://${this.callbackHostname}:${server.port}${this.callbackPath}`;
@@ -172,7 +184,7 @@ export abstract class OAuthCallbackFlow {
172
184
  });
173
185
 
174
186
  return new Response(renderHtml(ok, message), {
175
- status: ok ? 200 : 400,
187
+ status: ok ? 200 : 500, // gjc parity: provider/CSRF failures are server-side errors (500), not 400
176
188
  headers: { "content-type": "text/html" },
177
189
  });
178
190
  }
@@ -206,7 +218,9 @@ export abstract class OAuthCallbackFlow {
206
218
  .then(input => {
207
219
  const parsed = parseCallbackInput(input);
208
220
  if (!parsed.code) return null;
209
- if (expectedState && parsed.state !== expectedState) return null; // reject missing OR mismatched state
221
+ // gjc parity: a bare pasted code WITHOUT a state is accepted (the provider
222
+ // may strip it); only an explicitly MISMATCHED state is rejected.
223
+ if (expectedState && parsed.state && parsed.state !== expectedState) return null;
210
224
  return { code: parsed.code, state: parsed.state ?? "" } as CallbackResult;
211
225
  })
212
226
  .catch(() => null),
@@ -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
  }