diff --git a/src/main/database/migrations/057-kimi-code-provider.ts b/src/main/database/migrations/057-kimi-code-provider.ts new file mode 100644 index 00000000..b2323b7a --- /dev/null +++ b/src/main/database/migrations/057-kimi-code-provider.ts @@ -0,0 +1,8 @@ +export const kimiCodeProviderSchema = ` + insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at) + select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'kimi-code', 'Kimi Code', 'oauth_pkce', 'kimi-for-coding', null, 0, datetime('now') + from organizations; + + insert into schema_migrations(version, name, applied_at) + values (57, 'kimi_code_provider', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index ac9ba3b3..fd17abc4 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -55,6 +55,7 @@ import { autonomousPauseSchema } from "./migrations/053-autonomous-pause"; import { closeStaleWakeTasksSchema } from "./migrations/054-close-stale-wake-tasks"; import { taskOriginSchema } from "./migrations/055-task-origin"; import { kimiProvidersSchema } from "./migrations/056-kimi-providers"; +import { kimiCodeProviderSchema } from "./migrations/057-kimi-code-provider"; interface Migration { version: number; @@ -119,6 +120,7 @@ const MIGRATIONS: Migration[] = [ { version: 54, name: "close_stale_wake_tasks", sql: closeStaleWakeTasksSchema }, { version: 55, name: "task_origin", sql: taskOriginSchema }, { version: 56, name: "kimi_providers", sql: kimiProvidersSchema }, + { version: 57, name: "kimi_code_provider", sql: kimiCodeProviderSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/oauth/kimi-code-flow.ts b/src/main/services/oauth/kimi-code-flow.ts new file mode 100644 index 00000000..9f4f6aad --- /dev/null +++ b/src/main/services/oauth/kimi-code-flow.ts @@ -0,0 +1,189 @@ +/** + * Kimi Code (kimi.com plan) OAuth — RFC 8628 device-code flow. + * + * Mirrors the official MoonshotAI/kimi-cli implementation: form-encoded + * requests against auth.kimi.com carrying the X-Msh-* device headers the + * backend requires, then Bearer access tokens against the coding endpoint. + */ +import { createHash } from "node:crypto"; +import { hostname, platform as osPlatform, release as osRelease, userInfo } from "node:os"; +import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; + +// Published public client id of the official Kimi CLI; reused by third-party +// coding agents per Kimi's device-flow integration guidance. +const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const OAUTH_HOST = "https://auth.kimi.com"; +export const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; +const TOKEN_EXPIRY_MARGIN_MS = 5 * 60 * 1000; +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const DEFAULT_DEVICE_CODE_LIFETIME_SECONDS = 900; + +/** + * Stable per-machine device identity (32 hex chars, matching the CLI's + * uuid4().hex format). Derived instead of stored so every call site — login, + * refresh — reports the same device without a new persistence surface. + */ +function kimiDeviceId(): string { + return createHash("sha256") + .update(`agentcompany|${hostname()}|${osPlatform()}|${userInfo().username}`) + .digest("hex") + .slice(0, 32); +} + +function asciiHeaderValue(value: string, fallback: string): string { + const sanitized = value.replace(/[^\x20-\x7e]/g, "").trim(); + return sanitized || fallback; +} + +/** The auth backend rejects requests that omit these device headers. */ +function deviceHeaders(): Record { + return { + "X-Msh-Platform": "agentcompany", + "X-Msh-Version": "1.0", + "X-Msh-Device-Name": asciiHeaderValue(hostname(), "unknown"), + "X-Msh-Device-Model": asciiHeaderValue(osPlatform(), "unknown"), + "X-Msh-Os-Version": asciiHeaderValue(osRelease(), "unknown"), + "X-Msh-Device-Id": kimiDeviceId(), + }; +} + +async function postForm(path: string, form: Record): Promise<{ status: number; data: Record }> { + const response = await fetch(`${OAUTH_HOST}${path}`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + ...deviceHeaders(), + }, + body: new URLSearchParams(form), + }); + const text = await response.text(); + let data: unknown; + try { + data = JSON.parse(text); + } catch { + throw new Error(`Kimi OAuth endpoint returned a non-JSON response: ${response.status} ${text.slice(0, 200)}`); + } + if (typeof data !== "object" || data === null || Array.isArray(data)) { + throw new Error(`Kimi OAuth endpoint returned an unexpected payload: ${response.status}`); + } + return { status: response.status, data: data as Record }; +} + +interface DeviceAuthorization { + device_code: string; + user_code: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +async function requestDeviceAuthorization(): Promise { + const { status, data } = await postForm("/api/oauth/device_authorization", { client_id: CLIENT_ID }); + if (status !== 200) throw new Error(`Kimi device authorization failed: ${status} ${JSON.stringify(data)}`); + const deviceCode = data.device_code; + const userCode = data.user_code; + const verificationUriComplete = data.verification_uri_complete; + if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUriComplete !== "string") { + throw new Error("Invalid device authorization response from Kimi."); + } + return { + device_code: deviceCode, + user_code: userCode, + verification_uri_complete: verificationUriComplete, + expires_in: typeof data.expires_in === "number" && data.expires_in > 0 ? data.expires_in : DEFAULT_DEVICE_CODE_LIFETIME_SECONDS, + interval: typeof data.interval === "number" && data.interval > 0 ? data.interval : DEFAULT_POLL_INTERVAL_SECONDS, + }; +} + +function credentialsFromToken(raw: Record): OAuthCredentials { + const access = raw.access_token; + const refresh = raw.refresh_token; + const expiresIn = raw.expires_in; + if (typeof access !== "string" || typeof refresh !== "string" || typeof expiresIn !== "number") { + throw new Error("Kimi token response was missing required fields."); + } + return { access, refresh, expires: Date.now() + expiresIn * 1000 - TOKEN_EXPIRY_MARGIN_MS }; +} + +export type KimiPollDecision = + | { kind: "token"; credentials: OAuthCredentials } + | { kind: "pending" } + | { kind: "expired" } + | { kind: "error"; message: string }; + +/** Pure interpreter of a device-token poll response (unit-tested). */ +export function interpretKimiPollResponse(status: number, raw: Record): KimiPollDecision { + if (status === 200 && typeof raw.access_token === "string") { + return { kind: "token", credentials: credentialsFromToken(raw) }; + } + const error = typeof raw.error === "string" ? raw.error : ""; + if (error === "expired_token") return { kind: "expired" }; + if (error === "access_denied") { + const description = typeof raw.error_description === "string" && raw.error_description ? `: ${raw.error_description}` : ""; + return { kind: "error", message: `Kimi sign-in was denied${description}` }; + } + // The reference CLI keeps polling on every other status (authorization_pending, + // slow_down, transient 5xx); the device-code deadline bounds the wait. + return { kind: "pending" }; +} + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Login cancelled")); + return; + } + const timeout = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new Error("Login cancelled")); + }, + { once: true }, + ); + }); +} + +async function pollForToken(device: DeviceAuthorization, signal?: AbortSignal): Promise { + const deadline = Date.now() + device.expires_in * 1000; + const intervalMs = Math.max(1000, device.interval * 1000); + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error("Login cancelled"); + await abortableSleep(Math.min(intervalMs, deadline - Date.now()), signal); + const { status, data } = await postForm("/api/oauth/token", { + client_id: CLIENT_ID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + const decision = interpretKimiPollResponse(status, data); + if (decision.kind === "token") return decision.credentials; + if (decision.kind === "expired") throw new Error("Kimi device code expired. Please try signing in again."); + if (decision.kind === "error") throw new Error(decision.message); + } + throw new Error("Kimi device flow timed out. Please try signing in again."); +} + +/** Refresh an expired Kimi Code access token (they live ~15 minutes). */ +export async function refreshKimiCode(refreshToken: string): Promise { + const { status, data } = await postForm("/api/oauth/token", { + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + if (status !== 200) throw new Error(`Kimi token refresh failed: ${status} ${JSON.stringify(data)}`); + return credentialsFromToken(data); +} + +export const kimiCodeFlow: OAuthFlow = { + providerKey: "kimi-code", + async login(callbacks: OAuthLoginCallbacks): Promise { + const device = await requestDeviceAuthorization(); + callbacks.onAuth({ + url: device.verification_uri_complete, + instructions: `Approve code ${device.user_code} in your browser to link your Kimi plan.`, + }); + return pollForToken(device, callbacks.signal); + }, +}; diff --git a/src/main/services/oauth/oauth-flows.ts b/src/main/services/oauth/oauth-flows.ts index 8c265621..463fce6f 100644 --- a/src/main/services/oauth/oauth-flows.ts +++ b/src/main/services/oauth/oauth-flows.ts @@ -2,6 +2,7 @@ import { githubCopilotFlow } from "./github-copilot-flow"; import { openaiCodexFlow } from "./openai-codex-flow"; import { antigravityFlow } from "./google-antigravity-flow"; import { anthropicClaudeFlow } from "./anthropic-claude-flow"; +import { kimiCodeFlow } from "./kimi-code-flow"; import type { OAuthFlow } from "./oauth-types"; /** Provider keys (from the catalog) mapped to their native OAuth login flow. */ @@ -10,6 +11,7 @@ const OAUTH_FLOWS: Record = { "gpt-account": openaiCodexFlow, "google-antigravity": antigravityFlow, "claude-pro": anthropicClaudeFlow, + "kimi-code": kimiCodeFlow, }; export function hasOAuthFlow(providerKey: string): boolean { diff --git a/src/main/services/provider-adapters.ts b/src/main/services/provider-adapters.ts index 872668dc..4b77c8c5 100644 --- a/src/main/services/provider-adapters.ts +++ b/src/main/services/provider-adapters.ts @@ -24,6 +24,7 @@ import { backgroundUnsupported, retrieveUnsupported } from "./provider-backgroun import { authorizeWithParser, refreshUnsupported, revokeLocalProfile } from "./provider-auth-lifecycle"; import { getJson, postEventStream, postJson, type JsonRecord } from "./provider-http"; import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch"; +import { KIMI_CODE_BASE_URL } from "./oauth/kimi-code-flow"; const ANTHROPIC_VERSION = "2023-06-01"; const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14"; @@ -233,6 +234,12 @@ export const providerAdapters: ProviderAdapterRegistry = { "anthropic-beta": CLAUDE_PRO_BETA, }), }, + "kimi-code": { + ...browserTokenAdapter("kimi-code", PROVIDER_AUTH_TYPE.OAUTH_PKCE), + listModels: (ctx) => fetchOpenAiModels(`${KIMI_CODE_BASE_URL}/models`, { + authorization: `Bearer ${requireAccessTokenProfile(ctx.authProfile).accessToken}`, + }), + }, "azure-openai": { providerKey: "azure-openai", authType: PROVIDER_AUTH_TYPE.CLOUD, diff --git a/src/main/services/provider-tool-turns/credential-resolver.ts b/src/main/services/provider-tool-turns/credential-resolver.ts index 873de525..cebb7317 100644 --- a/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/src/main/services/provider-tool-turns/credential-resolver.ts @@ -28,6 +28,7 @@ import { refreshCodex } from "../oauth/openai-codex-flow"; import { refreshClaudePro } from "../oauth/anthropic-claude-flow"; import { refreshAntigravity } from "../oauth/google-antigravity-flow"; import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow"; +import { refreshKimiCode } from "../oauth/kimi-code-flow"; interface StoredOAuth { access: string; @@ -63,6 +64,8 @@ function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise = { "github-copilot": runCopilotTurn, "google-antigravity": runAntigravityTurn, "claude-pro": runClaudeProTurn, + "kimi-code": runKimiCodeTurn, "custom-openai": runCustomOpenAiTurn, "custom-anthropic": runCustomAnthropicTurn, "azure-openai": runAzureTurn, diff --git a/src/main/services/provider-tool-turns/turns.ts b/src/main/services/provider-tool-turns/turns.ts index f3b342a8..f17c1c3c 100644 --- a/src/main/services/provider-tool-turns/turns.ts +++ b/src/main/services/provider-tool-turns/turns.ts @@ -14,6 +14,7 @@ import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "./bedr import { signAwsRequest } from "./aws-sigv4"; import { getGcpAccessToken } from "./gcp-auth"; import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow"; +import { KIMI_CODE_BASE_URL } from "../oauth/kimi-code-flow"; const ANTIGRAVITY_VERSION = "1.21.9"; const ANTIGRAVITY_ENDPOINT = "https://cloudcode-pa.googleapis.com"; @@ -108,6 +109,22 @@ export async function runClaudeProTurn(input: ProviderToolTurnInput): Promise { + const access = requireAccessToken(input.credential, "Kimi Code"); + const body: JsonRecord = { + model: input.model, + messages: toChatMessages(input.instructions, input.messages), + tools: chatToolDefs(input.tools), + tool_choice: "auto", + stream: false, + }; + const response = await postJson(`${KIMI_CODE_BASE_URL}/chat/completions`, { authorization: `Bearer ${access}` }, body); + const parsed = parseChatChoice(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; +} + // ---- GPT account / OpenAI Codex (OAuth, SSE) ---- export async function runCodexTurn(input: ProviderToolTurnInput): Promise { diff --git a/src/shared/provider-catalog.ts b/src/shared/provider-catalog.ts index 649bcb18..6075bb79 100644 --- a/src/shared/provider-catalog.ts +++ b/src/shared/provider-catalog.ts @@ -119,6 +119,15 @@ const RAW_CATALOG: Omit[] signInUrl: "https://chatgpt.com/codex", credentialHelp: "Sign in to ChatGPT Plus/Pro in the browser; AgentCompany finishes the handshake.", }, + { + key: "kimi-code", + label: "Kimi Code", + authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE, + defaultModel: "kimi-for-coding", + capabilityTags: ["account", "coding"], + signInUrl: "https://www.kimi.com/code", + credentialHelp: "Sign in with your Kimi plan account in the browser; AgentCompany finishes the device-code handshake.", + }, { key: "azure-openai", label: "Azure OpenAI", diff --git a/src/shared/provider-models.ts b/src/shared/provider-models.ts index 7eeec100..810db793 100644 --- a/src/shared/provider-models.ts +++ b/src/shared/provider-models.ts @@ -226,6 +226,10 @@ export const PROVIDER_MODELS: Record = { ], kimi: KIMI_MODELS, "kimi-cn": KIMI_MODELS, + "kimi-code": [ + { id: "kimi-for-coding", name: "Kimi for Coding" }, + { id: "kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" }, + ], openrouter: [ { id: "anthropic/claude-sonnet-4.5", name: "Anthropic: Claude Sonnet 4.5" }, { id: "anthropic/claude-opus-4.5", name: "Anthropic: Claude Opus 4.5" }, diff --git a/tests/contract/provider-contracts.test.ts b/tests/contract/provider-contracts.test.ts index db8797d1..821487d9 100644 --- a/tests/contract/provider-contracts.test.ts +++ b/tests/contract/provider-contracts.test.ts @@ -20,6 +20,7 @@ const PROVIDER_SECRET_SAMPLES: Record = { openrouter: "sk-or-live-provider", kimi: "sk-live-provider", "kimi-cn": "sk-live-provider", + "kimi-code": "kimi-session-token", "github-copilot": "ghu_live_provider", "google-antigravity": "antigravity-session", "gpt-account": "chatgpt-session", diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 749a791a..05c4d148 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57]); connection.close(); }); diff --git a/tests/unit/provider-oauth.test.ts b/tests/unit/provider-oauth.test.ts index 8152bb1f..43fd88e3 100644 --- a/tests/unit/provider-oauth.test.ts +++ b/tests/unit/provider-oauth.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { describe, expect, test } from "vitest"; import { interpretPollResponse } from "../../src/main/services/oauth/github-copilot-flow"; +import { interpretKimiPollResponse } from "../../src/main/services/oauth/kimi-code-flow"; import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow"; import { readProjectId, resolveAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow"; import { generatePkce } from "../../src/main/services/oauth/pkce"; @@ -33,6 +34,40 @@ describe("github copilot device-code poll interpreter", () => { }); }); +describe("kimi code device-code poll interpreter", () => { + test("returns credentials with an expiry margin when the token arrives", () => { + const before = Date.now(); + const decision = interpretKimiPollResponse(200, { access_token: "kimi_access", refresh_token: "kimi_refresh", expires_in: 900 }); + expect(decision.kind).toBe("token"); + if (decision.kind === "token") { + expect(decision.credentials.access).toBe("kimi_access"); + expect(decision.credentials.refresh).toBe("kimi_refresh"); + expect(decision.credentials.expires).toBeGreaterThan(before); + expect(decision.credentials.expires).toBeLessThan(before + 900 * 1000); + } + }); + + test("rejects a token response missing required fields", () => { + expect(() => interpretKimiPollResponse(200, { access_token: "kimi_access" })).toThrow("missing required fields"); + }); + + test("keeps polling while authorization is pending", () => { + expect(interpretKimiPollResponse(400, { error: "authorization_pending" })).toEqual({ kind: "pending" }); + expect(interpretKimiPollResponse(400, { error: "slow_down" })).toEqual({ kind: "pending" }); + expect(interpretKimiPollResponse(500, {})).toEqual({ kind: "pending" }); + }); + + test("stops on an expired device code", () => { + expect(interpretKimiPollResponse(400, { error: "expired_token" })).toEqual({ kind: "expired" }); + }); + + test("fails when the user denies the request", () => { + const decision = interpretKimiPollResponse(400, { error: "access_denied", error_description: "user declined" }); + expect(decision.kind).toBe("error"); + if (decision.kind === "error") expect(decision.message).toContain("user declined"); + }); +}); + describe("openai codex account id extraction", () => { test("reads the chatgpt account id from the token claims", () => { const token = makeJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct_42" } }); diff --git a/tests/unit/provider-tool-turns.test.ts b/tests/unit/provider-tool-turns.test.ts index 2ef9e193..b87f4ae4 100644 --- a/tests/unit/provider-tool-turns.test.ts +++ b/tests/unit/provider-tool-turns.test.ts @@ -186,7 +186,7 @@ describe("aws sigv4", () => { describe("tool-turn router", () => { test("supports every agent-capable provider (incl. cloud and custom) and rejects unknown ones", () => { - for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) { + for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "kimi-code", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) { expect(supportsToolTurn(key)).toBe(true); expect(typeof selectToolTurn(key)).toBe("function"); }