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
5 changes: 5 additions & 0 deletions .changeset/keyring-credential-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Store OAuth credentials in the operating system keychain (macOS Keychain, Windows Credential Manager, or Linux Secret Service) when available, keeping the plaintext files in sync for compatibility; set `credentials_store` in `config.toml` (`auto` / `keyring` / `file`) to control the behavior, or `KIMI_DISABLE_KEYRING=1` to force the file store.
1 change: 1 addition & 0 deletions apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.9",
"@napi-rs/keyring": "1.3.0",
"node-pty": "^1.1.0"
},
"devDependencies": {
Expand Down
7 changes: 6 additions & 1 deletion apps/kimi-code/scripts/native/check-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const optionalRuntimeRequires = new Set([
'fast-json-stringify/lib/validator',
'utf-8-validate',
]);
// Native bindings that stay external on purpose: installNativeModuleHook
// resolves them from the SEA native-asset cache (or node_modules outside SEA).
const handledNativeRuntimeRequires = new Set(['@napi-rs/keyring']);
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);

function executableLines(text) {
Expand All @@ -37,7 +40,9 @@ function checkBundle(bundlePath, { worker = false } = {}) {
if (!existsSync(bundlePath)) return [`bundle does not exist: ${bundlePath}`];
const text = readFileSync(bundlePath, 'utf-8');
const errors = [];
const allowedExternal = worker ? new Set() : optionalRuntimeRequires;
const allowedExternal = worker
? new Set()
: new Set([...optionalRuntimeRequires, ...handledNativeRuntimeRequires]);
const allowedRelative = worker ? new Set() : optionalRelativeRuntimeRequires;

const checkSpecifier = (specifier, kind) => {
Expand Down
21 changes: 21 additions & 0 deletions apps/kimi-code/scripts/native/native-deps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ const clipboardSubpackageByTarget = Object.freeze({
'win32-x64': '@mariozechner/clipboard-win32-x64-msvc',
});

const keyringSubpackageByTarget = Object.freeze({
'darwin-arm64': '@napi-rs/keyring-darwin-arm64',
'darwin-x64': '@napi-rs/keyring-darwin-x64',
'linux-arm64': '@napi-rs/keyring-linux-arm64-gnu',
'linux-x64': '@napi-rs/keyring-linux-x64-gnu',
'win32-arm64': '@napi-rs/keyring-win32-arm64-msvc',
'win32-x64': '@napi-rs/keyring-win32-x64-msvc',
});

// pi-tui ships platform-specific native helpers (no Linux build):
// - darwin: Shift-modifier detection for Terminal.app Shift+Enter
// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable
Expand Down Expand Up @@ -73,6 +82,18 @@ export const nativeDeps = Object.freeze([
collect: 'native-files',
parent: 'clipboard-host',
},
{
id: 'keyring-host',
name: () => '@napi-rs/keyring',
collect: 'js-only',
parent: null,
},
{
id: 'keyring-target',
name: (target) => keyringSubpackageByTarget[target],
collect: 'native-files',
parent: 'keyring-host',
},
{
id: 'pi-tui',
name: () => '@moonshot-ai/pi-tui',
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/src/cli/sub/web/remote-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { setTimeout as sleep } from 'node:timers/promises';

import {
createKimiDeviceId,
FileTokenStorage,
KIMI_CODE_PROVIDER_NAME,
resolveTokenStorage,
resolveKimiTokenStorageName,
} from '@moonshot-ai/kimi-code-oauth';
import { WebSocket, type RawData } from 'ws';
Expand Down Expand Up @@ -293,7 +293,7 @@ export async function startRemoteControl(
if (options.localServerToken.length === 0) {
throw new Error('Remote Control requires local server authentication.');
}
const storage = new FileTokenStorage(join(options.homeDir, 'credentials'));
const storage = resolveTokenStorage(join(options.homeDir, 'credentials'));
const token = await storage.load(
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
);
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { maybeRelaunchWithStagedNativeUpdate } from './cli/update/native-swap';
import { createKimiCodeHostIdentity, getVersion } from './cli/version';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app';
import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
import { installKeyringBackend } from './native/keyring';
import { installMinidbTextBuildWorker } from './native/minidb-worker';
import { installKapSearchWorker } from './native/search-worker';
import { installNativeModuleHook } from './native/module-hook';
Expand Down Expand Up @@ -170,6 +171,11 @@ function bootstrap(): void {
// invalid proxy URL is reported and ignored rather than aborting startup.
installGlobalProxyDispatcher();
installNativeModuleHook();
// Register the OS keychain as an OAuth token-store backend. Whether it is
// actually used is decided inside packages/oauth (the credentials_store
// config, KIMI_DISABLE_KEYRING, and a capability probe); a load failure
// only warns and the file store stays in charge.
installKeyringBackend();
// Best-effort SEA worker installation. Diagnostics are trace-only and avoid
// exposing the user's cache path; failure keeps MiniDb's bounded inline mode.
const workerInstall = installMinidbTextBuildWorker();
Expand Down
69 changes: 69 additions & 0 deletions apps/kimi-code/src/native/keyring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* OS keychain backend registration for OAuth token storage.
*
* `@napi-rs/keyring` is an optional native dependency owned by this app (the
* packages/ layer stays pure TypeScript and receives the binding through
* `registerKeyringBackend`). In a SEA binary the require below is redirected
* to the extracted native-asset cache by `installNativeModuleHook`; outside
* SEA it resolves from node_modules. When the binding cannot be loaded we
* warn and skip registration, and the OAuth toolkit keeps the plaintext-file
* token store. Whether the keychain is actually used is decided per process
* by the toolkit's `resolveTokenStorage` (the `credentials_store` config,
* the `KIMI_DISABLE_KEYRING` kill switch, and a capability probe) —
* registration here is unconditional.
*/

import { createRequire } from 'node:module';

import type {
KeyringApi,
KeyringEntry,
KeyringStorageObserver,
} from '@moonshot-ai/kimi-code-oauth';
import { registerKeyringBackend } from '@moonshot-ai/kimi-code-oauth';
import { log } from '@moonshot-ai/kimi-code-sdk';
import { track } from '@moonshot-ai/kimi-telemetry';

/** The slice of the `@napi-rs/keyring` module shape the storage contract needs. */
interface NapiKeyringModule {
Entry: new (service: string, account: string) => KeyringEntry;
findCredentials: (service: string) => Array<{ account: string; password: string }>;
}

function adaptNapiKeyring(mod: NapiKeyringModule): KeyringApi {
return {
createEntry(service, account) {
return new mod.Entry(service, account);
},
findAccounts(service) {
return mod.findCredentials(service).map((credential) => credential.account);
},
};
}

const keyringObserver: KeyringStorageObserver = {
onBackendSelected(backend, reason) {
log.info('oauth token storage backend selected', { backend, reason });
track('keyring_backend_selected', { backend, reason });
},
onKeyringDegraded(operation, message) {
log.warn('oauth keyring backend degraded to file storage', { operation, message });
track('keyring_degraded', { operation });
},
onMigrated(name) {
log.info('oauth token migrated to the OS keychain', { name });
track('keyring_token_migrated');
},
};

export function installKeyringBackend(): void {
let mod: NapiKeyringModule;
try {
const require = createRequire(import.meta.url);
mod = require('@napi-rs/keyring') as NapiKeyringModule;
} catch (error) {
log.warn('@napi-rs/keyring native binding failed to load; oauth tokens stay in the file store', { error });
return;
}
registerKeyringBackend(adaptNapiKeyring(mod), keyringObserver);
}
20 changes: 20 additions & 0 deletions apps/kimi-code/src/native/module-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
import { join } from 'node:path';

import { getNativePackageRoot } from './native-assets';
import { loadNativePackage } from './native-require';

type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown;

Expand All @@ -11,7 +12,17 @@ interface ModuleWithLoad {
}

const nodeRequire = createRequire(import.meta.url);

// Bare-specifier native packages that ship as SEA native assets. In a SEA
// binary there is no node_modules next to the executable, so redirect the
// require to the extracted native-asset cache; loadNativePackage returns null
// outside SEA and the normal resolution below takes over. The reentrancy
// guard lets the cache-root require inside loadNativePackage fall through to
// the original loader.
const NATIVE_ASSET_PACKAGES = new Set(['@napi-rs/keyring']);

let installed = false;
let loadingNativePackage = false;

// pi-tui loads its platform-specific native helpers via an absolute-path
// require() computed from import.meta.url / process.execPath
Expand All @@ -37,6 +48,15 @@ export function installNativeModuleHook(): void {
parent: unknown,
isMain: boolean,
): unknown {
if (NATIVE_ASSET_PACKAGES.has(request) && !loadingNativePackage) {
loadingNativePackage = true;
try {
const pkg = loadNativePackage<unknown>(request);
if (pkg !== null) return pkg;
} finally {
loadingNativePackage = false;
}
}
if (
typeof request === 'string' &&
PI_TUI_NATIVE_PATTERN.test(request) &&
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/native/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
getNativePackageRoot,
} from './native-assets';

const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui'];
const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui', '@napi-rs/keyring'];

function smokePiTuiNativeLoad(): void {
const platform = process.platform;
Expand Down
73 changes: 73 additions & 0 deletions apps/kimi-code/test/cli/web/remote-control.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/**
* Remote Control CLI integration: flags, links, tunnel auth/lifecycle, and process locking.
* Public entry points use local HTTP/WebSocket relays; the OS keyring boundary is faked.
* Run: pnpm --filter @moonshot-ai/kimi-code exec vitest run test/cli/web/remote-control.test.ts
*/

import { createServer, type IncomingMessage } from 'node:http';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
Expand All @@ -8,8 +14,14 @@ import { join } from 'node:path';
import {
FileTokenStorage,
KIMI_CODE_PROVIDER_NAME,
keyringServiceForCredentialsDir,
registerKeyringBackend,
resolveKimiTokenStorageName,
resolveTokenStorage,
type TokenInfo,
type KeyringApi,
type KeyringEntry,
unregisterKeyringBackend,
} from '@moonshot-ai/kimi-code-oauth';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
Expand Down Expand Up @@ -41,9 +53,32 @@ const cleanups: Array<() => Promise<void> | void> = [];

afterEach(async () => {
vi.unstubAllEnvs();
unregisterKeyringBackend();
while (cleanups.length > 0) await cleanups.pop()!();
});

class FakeKeyring implements KeyringApi {
private readonly store = new Map<string, string>();

createEntry(service: string, account: string): KeyringEntry {
const key = `${service}\u0000${account}`;
return {
getPassword: () => this.store.get(key) ?? null,
setPassword: (password) => {
this.store.set(key, password);
},
deleteCredential: () => this.store.delete(key),
};
}

findAccounts(service: string): string[] {
const prefix = `${service}\u0000`;
return [...this.store.keys()]
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
}
}

describe('Remote Control experimental flag', () => {
it('is off unless the per-feature env or the master switch is truthy', () => {
expect(isRemoteControlEnabled({})).toBe(false);
Expand Down Expand Up @@ -202,6 +237,44 @@ describe('Remote Control HTTP forwarding', () => {
});

describe('Remote Control tunnel', () => {
it('authenticates the relay when the Kimi login exists only in the keychain', async () => {
// The suite-level KIMI_DISABLE_KEYRING=1 (vitest.config.ts) would force the
// file store; this test exercises the keychain path specifically.
vi.stubEnv('KIMI_DISABLE_KEYRING', '0');
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-keyring-'));
cleanups.push(() => rmSync(homeDir, { recursive: true, force: true }));
const keyring = new FakeKeyring();
registerKeyringBackend(keyring);
const credentialsDir = join(homeDir, 'credentials');
const storage = resolveTokenStorage(credentialsDir);
await storage.save(
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
TOKEN,
);
expect(keyring.findAccounts(keyringServiceForCredentialsDir(credentialsDir))).toContain(
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
);

const relay = await startAuthRelay();
let handle: RemoteControlHandle | undefined;
cleanups.push(async () => handle?.close());
handle = await startRemoteControl({
homeDir,
localOrigin: 'http://127.0.0.1:1',
localServerToken: 'local-server-token',
relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`,
stderr: { write: () => true },
});

expect(
relay.requests.some(
(request) =>
request.authorization === 'Bearer refresh-token' ||
request.protocol === 'kimi-code.bearer.refresh-token',
),
).toBe(true);
});

it('surfaces register_nak details', async () => {
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-nak-'));
cleanups.push(() => rmSync(homeDir, { recursive: true, force: true }));
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/tsdown.native.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const builtins = new Set([
...builtinModules,
...builtinModules.map((name) => `node:${name}`),
]);
const optionalNativeDependencies = new Set(['cpu-features']);
const optionalNativeDependencies = new Set(['cpu-features', '@napi-rs/keyring']);

function shouldAlwaysBundle(id: string): boolean {
if (builtins.has(id) || id.startsWith('node:')) return false;
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export default defineConfig({
name: 'cli',
env: {
KIMI_LOG_LEVEL: 'off',
// Keep credential tests hermetic: the OAuth toolkit can select a
// keychain-backed store via resolveTokenStorage. Force the file backend
// so tests never read/write the developer's real OS keychain.
KIMI_DISABLE_KEYRING: '1',
},
include: ['test/**/*.test.ts', 'test/**/*.test.tsx'],
},
Expand Down
1 change: 1 addition & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `extra_agent_dirs` | `array<string>` | — | Extra custom agent search directories, layered on top of the default directories |
| `builtin_product_skills` | `boolean` | `true` | Whether the built-in skills that document Kimi Code itself are offered to the model: `update-config`, `custom-theme`, `mcp-config`, `check-kimi-code-docs`, and `import-from-cc-codex`. Turning them off trims their names and descriptions from the system prompt, at the cost of the guided flows for those tasks. Read by the default `agent-core-v2` engine; ignored when `KIMI_CODE_LEGACY_FLAG=1` selects the legacy engine |
| `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` |
| `credentials_store` | `string` | `auto` | Where OAuth credentials are stored when the OS keychain is available: `auto` (keychain preferred, plaintext files kept in sync as a compatibility bridge), `keyring` (keychain authoritative, plaintext copies removed after migration), or `file` (plaintext files only); see [Data locations](./data-locations.md) |
| `providers` | `table` | `{}` | API provider table → [`providers`](#providers) |
| `models` | `table` | — | Model alias table → [`models`](#models) |
| `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) |
Expand Down
6 changes: 3 additions & 3 deletions docs/en/configuration/data-locations.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Each top-level file under the data root serves a specific purpose; most are mana
- **`mcp.json`**: user-level MCP server declarations, merged with the project-local `.kimi-code/mcp.json` on startup. See [MCP](../customization/mcp.md).
- **`skills/`**: Kimi-specific user-level Skills. This directory moves with `KIMI_CODE_HOME`; generic cross-tool Skills can still live under `~/.agents/skills/`. See [Agent Skills](../customization/skills.md).
- **`plugins/installed.json`**: records installed plugins, each plugin's enabled state, and MCP server capability state changes made via `/plugins` or `/plugins mcp disable|enable`. Files installed from local paths or zip URLs are copied to `plugins/managed/<id>/`. See [Plugins](../customization/plugins.md).
- **`credentials/`**: OAuth credential directory, with permissions `0o700` (directory) / `0o600` (files), readable and writable only by the current user. Managed provider credentials are stored as `credentials/<name>.json`; MCP server credentials are stored under `credentials/mcp/`. Credentials are written using an atomic flow (tmp → fsync → rename) to prevent corruption.
- **`credentials/`**: OAuth credential directory, with permissions `0o700` (directory) / `0o600` (files), readable and writable only by the current user. In file-storage mode, managed provider credentials are stored as `credentials/<name>.json` and MCP server credentials under `credentials/mcp/`; credentials are written using an atomic flow (tmp → fsync → rename) to prevent corruption. When the operating system keychain is available (macOS Keychain, Windows Credential Manager, or Linux Secret Service), OAuth credentials are stored in the keychain and the plaintext files are kept in sync as a compatibility bridge, so older builds and tools that only read the files keep working; this is controlled by the `credentials_store` key in `config.toml` — `auto` (the default) keeps both stores in sync, `keyring` makes the keychain authoritative and removes plaintext copies after migration, and `file` disables the keychain entirely; `KIMI_DISABLE_KEYRING=1` overrides the config and forces the file store (see [Environment variables](./env-vars.md#runtime-switches)). The keychain is separate from this directory, so deleting these files does not remove keychain credentials; when the keychain is unavailable the plaintext files remain the fallback, and keychain-only credentials stay unavailable until the keychain recovers or you sign in again.

## Session data

Expand Down Expand Up @@ -113,8 +113,8 @@ Deleting the data root directory (`~/.kimi-code/` or the path set by `KIMI_CODE_
| Clear input history | Delete `~/.kimi-code/user-history/` |
| Reset update state | Delete `~/.kimi-code/updates/latest.json` |
| Force re-download of managed `rg` and `fd` | Delete `~/.kimi-code/bin/` |
| Clear provider OAuth login state | Run `/logout`, or delete the corresponding `credentials/<name>.json` |
| Clear MCP server OAuth login state | Delete `credentials/mcp/` (`/logout` does not clear MCP credentials) |
| Clear provider OAuth login state | Run `/logout`; deleting `credentials/<name>.json` only applies when file storage is active |
| Clear MCP server OAuth login state | Use the MCP server's auth reset/logout action; deleting `credentials/mcp/` only applies when file storage is active (`/logout` does not clear MCP credentials) |
| Remove user-level MCP declarations | Delete `$KIMI_CODE_HOME/mcp.json` (default `~/.kimi-code/mcp.json`) |
| Clear global Kimi-specific agent instructions | Delete `$KIMI_CODE_HOME/AGENTS.md` (default `~/.kimi-code/AGENTS.md`) |
| Clear plugin install records | Delete `$KIMI_CODE_HOME/plugins/` (local plugin source directories are not affected) |
Expand Down
Loading
Loading