Skip to content

Commit 5ac4370

Browse files
committed
feat(kimi-code): store OAuth credentials in the OS keychain
Store OAuth credentials (managed provider logins and MCP server grants) in the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) whenever it is usable, with the plaintext file store as the automatic fallback. The credentials_store key in config.toml picks the mode: auto (default; keychain preferred, plaintext kept in sync as a compatibility bridge for file-only peers), keyring (authoritative, plaintext pruned after migration), or file (plaintext only). KIMI_DISABLE_KEYRING=1 overrides the config and forces the file store.
1 parent 55b422a commit 5ac4370

45 files changed

Lines changed: 4657 additions & 43 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
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.

‎apps/kimi-code/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
},
8080
"optionalDependencies": {
8181
"@mariozechner/clipboard": "^0.3.9",
82+
"@napi-rs/keyring": "1.3.0",
8283
"node-pty": "^1.1.0"
8384
},
8485
"devDependencies": {

‎apps/kimi-code/scripts/native/check-bundle.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const optionalRuntimeRequires = new Set([
2020
'fast-json-stringify/lib/validator',
2121
'utf-8-validate',
2222
]);
23+
// Native bindings that stay external on purpose: installNativeModuleHook
24+
// resolves them from the SEA native-asset cache (or node_modules outside SEA).
25+
const handledNativeRuntimeRequires = new Set(['@napi-rs/keyring']);
2326
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
2427

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

4348
const checkSpecifier = (specifier, kind) => {

‎apps/kimi-code/scripts/native/native-deps.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ const clipboardSubpackageByTarget = Object.freeze({
2727
'win32-x64': '@mariozechner/clipboard-win32-x64-msvc',
2828
});
2929

30+
const keyringSubpackageByTarget = Object.freeze({
31+
'darwin-arm64': '@napi-rs/keyring-darwin-arm64',
32+
'darwin-x64': '@napi-rs/keyring-darwin-x64',
33+
'linux-arm64': '@napi-rs/keyring-linux-arm64-gnu',
34+
'linux-x64': '@napi-rs/keyring-linux-x64-gnu',
35+
'win32-arm64': '@napi-rs/keyring-win32-arm64-msvc',
36+
'win32-x64': '@napi-rs/keyring-win32-x64-msvc',
37+
});
38+
3039
// pi-tui ships platform-specific native helpers (no Linux build):
3140
// - darwin: Shift-modifier detection for Terminal.app Shift+Enter
3241
// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable
@@ -73,6 +82,18 @@ export const nativeDeps = Object.freeze([
7382
collect: 'native-files',
7483
parent: 'clipboard-host',
7584
},
85+
{
86+
id: 'keyring-host',
87+
name: () => '@napi-rs/keyring',
88+
collect: 'js-only',
89+
parent: null,
90+
},
91+
{
92+
id: 'keyring-target',
93+
name: (target) => keyringSubpackageByTarget[target],
94+
collect: 'native-files',
95+
parent: 'keyring-host',
96+
},
7697
{
7798
id: 'pi-tui',
7899
name: () => '@moonshot-ai/pi-tui',

‎apps/kimi-code/src/cli/sub/web/remote-control.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { setTimeout as sleep } from 'node:timers/promises';
55

66
import {
77
createKimiDeviceId,
8-
FileTokenStorage,
98
KIMI_CODE_PROVIDER_NAME,
9+
resolveTokenStorage,
1010
resolveKimiTokenStorageName,
1111
} from '@moonshot-ai/kimi-code-oauth';
1212
import { WebSocket, type RawData } from 'ws';
@@ -293,7 +293,7 @@ export async function startRemoteControl(
293293
if (options.localServerToken.length === 0) {
294294
throw new Error('Remote Control requires local server authentication.');
295295
}
296-
const storage = new FileTokenStorage(join(options.homeDir, 'credentials'));
296+
const storage = resolveTokenStorage(join(options.homeDir, 'credentials'));
297297
const token = await storage.load(
298298
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
299299
);

‎apps/kimi-code/src/main.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { maybeRelaunchWithStagedNativeUpdate } from './cli/update/native-swap';
4040
import { createKimiCodeHostIdentity, getVersion } from './cli/version';
4141
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app';
4242
import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
43+
import { installKeyringBackend } from './native/keyring';
4344
import { installMinidbTextBuildWorker } from './native/minidb-worker';
4445
import { installKapSearchWorker } from './native/search-worker';
4546
import { installNativeModuleHook } from './native/module-hook';
@@ -170,6 +171,11 @@ function bootstrap(): void {
170171
// invalid proxy URL is reported and ignored rather than aborting startup.
171172
installGlobalProxyDispatcher();
172173
installNativeModuleHook();
174+
// Register the OS keychain as an OAuth token-store backend. Whether it is
175+
// actually used is decided inside packages/oauth (the credentials_store
176+
// config, KIMI_DISABLE_KEYRING, and a capability probe); a load failure
177+
// only warns and the file store stays in charge.
178+
installKeyringBackend();
173179
// Best-effort SEA worker installation. Diagnostics are trace-only and avoid
174180
// exposing the user's cache path; failure keeps MiniDb's bounded inline mode.
175181
const workerInstall = installMinidbTextBuildWorker();
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* OS keychain backend registration for OAuth token storage.
3+
*
4+
* `@napi-rs/keyring` is an optional native dependency owned by this app (the
5+
* packages/ layer stays pure TypeScript and receives the binding through
6+
* `registerKeyringBackend`). In a SEA binary the require below is redirected
7+
* to the extracted native-asset cache by `installNativeModuleHook`; outside
8+
* SEA it resolves from node_modules. When the binding cannot be loaded we
9+
* warn and skip registration, and the OAuth toolkit keeps the plaintext-file
10+
* token store. Whether the keychain is actually used is decided per process
11+
* by the toolkit's `resolveTokenStorage` (the `credentials_store` config,
12+
* the `KIMI_DISABLE_KEYRING` kill switch, and a capability probe) —
13+
* registration here is unconditional.
14+
*/
15+
16+
import { createRequire } from 'node:module';
17+
18+
import type {
19+
KeyringApi,
20+
KeyringEntry,
21+
KeyringStorageObserver,
22+
} from '@moonshot-ai/kimi-code-oauth';
23+
import { registerKeyringBackend } from '@moonshot-ai/kimi-code-oauth';
24+
import { log } from '@moonshot-ai/kimi-code-sdk';
25+
import { track } from '@moonshot-ai/kimi-telemetry';
26+
27+
/** The slice of the `@napi-rs/keyring` module shape the storage contract needs. */
28+
interface NapiKeyringModule {
29+
Entry: new (service: string, account: string) => KeyringEntry;
30+
findCredentials: (service: string) => Array<{ account: string; password: string }>;
31+
}
32+
33+
function adaptNapiKeyring(mod: NapiKeyringModule): KeyringApi {
34+
return {
35+
createEntry(service, account) {
36+
return new mod.Entry(service, account);
37+
},
38+
findAccounts(service) {
39+
return mod.findCredentials(service).map((credential) => credential.account);
40+
},
41+
};
42+
}
43+
44+
const keyringObserver: KeyringStorageObserver = {
45+
onBackendSelected(backend, reason) {
46+
log.info('oauth token storage backend selected', { backend, reason });
47+
track('keyring_backend_selected', { backend, reason });
48+
},
49+
onKeyringDegraded(operation, message) {
50+
log.warn('oauth keyring backend degraded to file storage', { operation, message });
51+
track('keyring_degraded', { operation });
52+
},
53+
onMigrated(name) {
54+
log.info('oauth token migrated to the OS keychain', { name });
55+
track('keyring_token_migrated');
56+
},
57+
};
58+
59+
export function installKeyringBackend(): void {
60+
let mod: NapiKeyringModule;
61+
try {
62+
const require = createRequire(import.meta.url);
63+
mod = require('@napi-rs/keyring') as NapiKeyringModule;
64+
} catch (error) {
65+
log.warn('@napi-rs/keyring native binding failed to load; oauth tokens stay in the file store', { error });
66+
return;
67+
}
68+
registerKeyringBackend(adaptNapiKeyring(mod), keyringObserver);
69+
}

‎apps/kimi-code/src/native/module-hook.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
33
import { join } from 'node:path';
44

55
import { getNativePackageRoot } from './native-assets';
6+
import { loadNativePackage } from './native-require';
67

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

@@ -11,7 +12,17 @@ interface ModuleWithLoad {
1112
}
1213

1314
const nodeRequire = createRequire(import.meta.url);
15+
16+
// Bare-specifier native packages that ship as SEA native assets. In a SEA
17+
// binary there is no node_modules next to the executable, so redirect the
18+
// require to the extracted native-asset cache; loadNativePackage returns null
19+
// outside SEA and the normal resolution below takes over. The reentrancy
20+
// guard lets the cache-root require inside loadNativePackage fall through to
21+
// the original loader.
22+
const NATIVE_ASSET_PACKAGES = new Set(['@napi-rs/keyring']);
23+
1424
let installed = false;
25+
let loadingNativePackage = false;
1526

1627
// pi-tui loads its platform-specific native helpers via an absolute-path
1728
// require() computed from import.meta.url / process.execPath
@@ -37,6 +48,15 @@ export function installNativeModuleHook(): void {
3748
parent: unknown,
3849
isMain: boolean,
3950
): unknown {
51+
if (NATIVE_ASSET_PACKAGES.has(request) && !loadingNativePackage) {
52+
loadingNativePackage = true;
53+
try {
54+
const pkg = loadNativePackage<unknown>(request);
55+
if (pkg !== null) return pkg;
56+
} finally {
57+
loadingNativePackage = false;
58+
}
59+
}
4060
if (
4161
typeof request === 'string' &&
4262
PI_TUI_NATIVE_PATTERN.test(request) &&

‎apps/kimi-code/src/native/smoke.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
getNativePackageRoot,
1414
} from './native-assets';
1515

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

1818
function smokePiTuiNativeLoad(): void {
1919
const platform = process.platform;

‎apps/kimi-code/test/cli/web/remote-control.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/**
2+
* Remote Control CLI integration: flags, links, tunnel auth/lifecycle, and process locking.
3+
* Public entry points use local HTTP/WebSocket relays; the OS keyring boundary is faked.
4+
* Run: pnpm --filter @moonshot-ai/kimi-code exec vitest run test/cli/web/remote-control.test.ts
5+
*/
6+
17
import { createServer, type IncomingMessage } from 'node:http';
28
import { spawn } from 'node:child_process';
39
import { mkdtempSync, rmSync } from 'node:fs';
@@ -8,8 +14,14 @@ import { join } from 'node:path';
814
import {
915
FileTokenStorage,
1016
KIMI_CODE_PROVIDER_NAME,
17+
keyringServiceForCredentialsDir,
18+
registerKeyringBackend,
1119
resolveKimiTokenStorageName,
20+
resolveTokenStorage,
1221
type TokenInfo,
22+
type KeyringApi,
23+
type KeyringEntry,
24+
unregisterKeyringBackend,
1325
} from '@moonshot-ai/kimi-code-oauth';
1426
import { afterEach, describe, expect, it, vi } from 'vitest';
1527
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
@@ -41,9 +53,32 @@ const cleanups: Array<() => Promise<void> | void> = [];
4153

4254
afterEach(async () => {
4355
vi.unstubAllEnvs();
56+
unregisterKeyringBackend();
4457
while (cleanups.length > 0) await cleanups.pop()!();
4558
});
4659

60+
class FakeKeyring implements KeyringApi {
61+
private readonly store = new Map<string, string>();
62+
63+
createEntry(service: string, account: string): KeyringEntry {
64+
const key = `${service}\u0000${account}`;
65+
return {
66+
getPassword: () => this.store.get(key) ?? null,
67+
setPassword: (password) => {
68+
this.store.set(key, password);
69+
},
70+
deleteCredential: () => this.store.delete(key),
71+
};
72+
}
73+
74+
findAccounts(service: string): string[] {
75+
const prefix = `${service}\u0000`;
76+
return [...this.store.keys()]
77+
.filter((key) => key.startsWith(prefix))
78+
.map((key) => key.slice(prefix.length));
79+
}
80+
}
81+
4782
describe('Remote Control experimental flag', () => {
4883
it('is off unless the per-feature env or the master switch is truthy', () => {
4984
expect(isRemoteControlEnabled({})).toBe(false);
@@ -202,6 +237,44 @@ describe('Remote Control HTTP forwarding', () => {
202237
});
203238

204239
describe('Remote Control tunnel', () => {
240+
it('authenticates the relay when the Kimi login exists only in the keychain', async () => {
241+
// The suite-level KIMI_DISABLE_KEYRING=1 (vitest.config.ts) would force the
242+
// file store; this test exercises the keychain path specifically.
243+
vi.stubEnv('KIMI_DISABLE_KEYRING', '0');
244+
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-keyring-'));
245+
cleanups.push(() => rmSync(homeDir, { recursive: true, force: true }));
246+
const keyring = new FakeKeyring();
247+
registerKeyringBackend(keyring);
248+
const credentialsDir = join(homeDir, 'credentials');
249+
const storage = resolveTokenStorage(credentialsDir);
250+
await storage.save(
251+
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
252+
TOKEN,
253+
);
254+
expect(keyring.findAccounts(keyringServiceForCredentialsDir(credentialsDir))).toContain(
255+
resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }),
256+
);
257+
258+
const relay = await startAuthRelay();
259+
let handle: RemoteControlHandle | undefined;
260+
cleanups.push(async () => handle?.close());
261+
handle = await startRemoteControl({
262+
homeDir,
263+
localOrigin: 'http://127.0.0.1:1',
264+
localServerToken: 'local-server-token',
265+
relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`,
266+
stderr: { write: () => true },
267+
});
268+
269+
expect(
270+
relay.requests.some(
271+
(request) =>
272+
request.authorization === 'Bearer refresh-token' ||
273+
request.protocol === 'kimi-code.bearer.refresh-token',
274+
),
275+
).toBe(true);
276+
});
277+
205278
it('surfaces register_nak details', async () => {
206279
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-nak-'));
207280
cleanups.push(() => rmSync(homeDir, { recursive: true, force: true }));

0 commit comments

Comments
 (0)