Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2563c1b
feat(oauth): add keychain-backed token storage with file fallback
Brooooooklyn Jun 14, 2026
789ed25
fix(oauth): harden keyring migration, remove, and probe against races
Brooooooklyn Jun 14, 2026
2149175
feat(oauth): use keychain storage by default in the OAuth toolkit
Brooooooklyn Jun 14, 2026
4848f3f
build(kimi-code): collect @napi-rs/keyring in the native SEA build
Brooooooklyn Jun 14, 2026
1d8335a
fix(kimi-code): route @napi-rs/keyring through the native-asset modul…
Brooooooklyn Jun 14, 2026
5e2901e
build(nix): update fetchPnpmDeps hash for @napi-rs/keyring
Brooooooklyn Jun 17, 2026
7caa1a5
fix(oauth): align keychain storage with file backend (per-profile nam…
Brooooooklyn Jun 14, 2026
5d589d9
fix(oauth): reconcile newer plaintext token on keychain hit (sequenti…
Brooooooklyn Jun 14, 2026
dc70216
docs(oauth): clarify keychain reconcile comments + pin strict-newer t…
Brooooooklyn Jun 14, 2026
b617256
fix(oauth): compare token issuance time, not expiry, in keychain reco…
Brooooooklyn Jun 14, 2026
4ce649a
docs(oauth): note issuedAt graceful-degradation on rehydrated tokens
Brooooooklyn Jun 14, 2026
11bf327
docs(oauth): document keychain-by-default storage and KIMI_DISABLE_KE…
Brooooooklyn Jun 14, 2026
fcc1148
test(oauth): use \x00 escape instead of literal NUL in fake keyring keys
Brooooooklyn Jun 14, 2026
81a14bc
fix(oauth): surface failed keychain deletes on logout (@napi-rs/keyri…
Brooooooklyn Jun 14, 2026
725f64b
fix(oauth): prune stale plaintext copy after keychain save (prevent f…
Brooooooklyn Jun 14, 2026
843abd7
docs(oauth): clarify save() prune is a deliberate fail-closed choice …
Brooooooklyn Jun 14, 2026
7aa600f
fix(oauth): reject keyrings that cannot delete during capability probe
Brooooooklyn Jun 15, 2026
4fae69a
docs(oauth): document+test that a file tombstone never force-revokes …
Brooooooklyn Jun 15, 2026
c9c6196
fix(oauth): validate token name before keychain writes (file-backend …
Brooooooklyn Jun 15, 2026
7118757
fix(oauth): prove keychain credential absence via service listing on …
Brooooooklyn Jun 15, 2026
8d5d6ca
fix(oauth): confirm probe sentinel deletion via service listing (cons…
Brooooooklyn Jun 15, 2026
9a46a99
chore: add changeset for keychain credential storage
Brooooooklyn Jun 15, 2026
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
6 changes: 6 additions & 0 deletions .changeset/keychain-credential-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": minor
"@moonshot-ai/kimi-code-oauth": minor
Comment thread
Brooooooklyn marked this conversation as resolved.
---

Store OAuth credentials in the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) by default, falling back to the plaintext file store when the keychain is unavailable — unsupported platform, missing/locked backend, native binary not loadable, or `KIMI_DISABLE_KEYRING=1`. Existing plaintext credentials are migrated into the keychain on first read and then deleted, so users stay logged in and the on-disk secret is removed. Set `KIMI_DISABLE_KEYRING=1` to opt out and keep using the plaintext 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 @@ -75,6 +75,7 @@
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.2",
"@napi-rs/keyring": "1.3.0",
Comment thread
Brooooooklyn marked this conversation as resolved.
"chalk": "^5.4.1",
"cli-highlight": "^2.1.11",
"commander": "^13.1.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/scripts/native/check-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const optionalRuntimeRequires = new Set([
'utf-8-validate',
]);
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
const handledNativeRuntimeRequires = new Set(['koffi']);
const handledNativeRuntimeRequires = new Set(['koffi', '@napi-rs/keyring']);

function isAllowedSpecifier(specifier) {
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;
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',
});

const koffiTripletByTarget = Object.freeze({
'darwin-arm64': 'darwin_arm64',
'darwin-x64': 'darwin_x64',
Expand Down Expand Up @@ -68,6 +77,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: () => '@earendil-works/pi-tui',
Expand Down
5 changes: 3 additions & 2 deletions apps/kimi-code/src/native/module-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface ModuleWithLoad {
}

const nodeRequire = createRequire(import.meta.url);
const NATIVE_ASSET_PACKAGES = new Set(['koffi', '@napi-rs/keyring']);
let installed = false;
let loadingNativePackage = false;

Expand All @@ -26,10 +27,10 @@ export function installNativeModuleHook(): void {
parent: unknown,
isMain: boolean,
): unknown {
if (request === 'koffi' && !loadingNativePackage) {
if (NATIVE_ASSET_PACKAGES.has(request) && !loadingNativePackage) {
loadingNativePackage = true;
try {
const pkg = loadNativePackage<unknown>('koffi');
const pkg = loadNativePackage<unknown>(request);
if (pkg !== null) return pkg;
} finally {
loadingNativePackage = false;
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
@@ -1,6 +1,6 @@
import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets';

const smokePackages = ['@mariozechner/clipboard', 'koffi'];
const smokePackages = ['@mariozechner/clipboard', 'koffi', '@napi-rs/keyring'];

export function runNativeAssetSmokeIfRequested(): boolean {
if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false;
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 now defaults to 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
2 changes: 2 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ Each entry in the `providers` table defines an API provider, keyed by a unique n
| `env` | `table<string, string>` | No | Fallback source for provider credentials; see below |
| `custom_headers` | `table<string, string>` | No | Custom HTTP headers attached to each request |

> **OAuth credential storage**: OAuth tokens are stored in the operating system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) by default. Any pre-existing plaintext credential file is migrated into the keychain and then removed. The `oauth.storage` field records where the token lives and is injected automatically — it does not select the backend. Set [`KIMI_DISABLE_KEYRING=1`](./env-vars.md#runtime-switches) to force plaintext-file storage; this is also the automatic fallback when no keychain is available.

**`env` sub-table**: You can write provider-conventional key names (such as `KIMI_API_KEY`) inside `[providers.<name>.env]` as a fallback source for `api_key` / `base_url`. This sub-table is **read only from the config file** and does not modify the shell environment:

```toml
Expand Down
1 change: 1 addition & 0 deletions docs/en/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `KIMI_MODEL_THINKING_KEEP` | Moonshot preserved-thinking passthrough (`thinking.keep`); applies to the `kimi` provider only, and only while Thinking is on | A value the API accepts, e.g. `all` |
| `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable |
| `KIMI_DISABLE_KEYRING` | Force plaintext-file OAuth credential storage instead of the OS keychain (also the automatic fallback when no keychain is available) | `1` to force the file backend |

## Diagnostic logs

Expand Down
2 changes: 2 additions & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ timeout = 5
| `env` | `table<string, string>` | 否 | 供应商凭证的备用来源,详见下文 |
| `custom_headers` | `table<string, string>` | 否 | 每次请求附加的自定义 HTTP 头 |

> **OAuth 凭据存储**:OAuth 令牌默认存放在操作系统密钥链(macOS Keychain、Windows 凭据管理器、Linux Secret Service)中。已有的明文凭据文件会被迁移到密钥链并随后删除。`oauth.storage` 字段记录令牌所在位置、由登录流程自动注入,并不用于选择后端。设置 [`KIMI_DISABLE_KEYRING=1`](./env-vars.md#运行时开关) 可强制使用明文文件存储;当系统没有可用密钥链时也会自动回退到该方式。

**`env` 子表**:可以把供应商惯用的键名(如 `KIMI_API_KEY`)写在 `[providers.<name>.env]` 里,作为 `api_key` / `base_url` 的备用来源。这个子表**只在配置文件里读取**,不会修改 shell 环境:

```toml
Expand Down
1 change: 1 addition & 0 deletions docs/zh/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ kimi
| `KIMI_MODEL_THINKING_KEEP` | Moonshot 保留思考透传(`thinking.keep`),仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | API 接受的值,如 `all` |
| `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 |
| `KIMI_DISABLE_KEYRING` | 强制 OAuth 凭据使用明文文件存储而非操作系统密钥链(系统无可用密钥链时也会自动回退到该方式) | `1` 表示强制使用文件后端 |

## 诊断日志

Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-u+u5Vm6UgrMW/SwiBoSz2WhKp8GOehk4p6euwlinwFI=";
hash = "sha256-XbJ8lTNywbZ+saZ33A7qAa9V3JHYgPBM1Rh2ySenvxs=";
};

nativeBuildInputs = [
Expand Down
1 change: 1 addition & 0 deletions packages/node-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
},
"dependencies": {
"@antfu/utils": "^9.3.0",
"@napi-rs/keyring": "1.3.0",
"smol-toml": "^1.6.1",
"yazl": "^3.3.1",
"zod": "^4.3.6"
Expand Down
5 changes: 5 additions & 0 deletions packages/node-sdk/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export default defineConfig({
name: 'kimi-sdk',
env: {
KIMI_LOG_LEVEL: 'off',
// Keep credential tests hermetic: the OAuth toolkit now defaults to a
// keychain-backed store via resolveTokenStorage, and the oauth source
// alias resolves the native @napi-rs/keyring binary. Force the file
// backend so tests never read/write the developer's real OS keychain.
KIMI_DISABLE_KEYRING: '1',
},
include: ['test/**/*.test.ts'],
},
Expand Down
2 changes: 2 additions & 0 deletions packages/oauth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@
"scripts": {
"build": "tsdown",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@napi-rs/keyring": "1.3.0",
"proper-lockfile": "^4.1.2"
},
"devDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions packages/oauth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export { tokenFromWire, tokenToWire } from './types';
export type { TokenStorage } from './storage';
export { FileTokenStorage } from './storage';

export { KeyringTokenStorage, resolveTokenStorage } from './keyring-storage';
export type { KeyringApi, KeyringEntry } from './keyring-storage';

export type { DevicePollResult, RefreshOptions } from './oauth';
export { pollDeviceToken, refreshAccessToken, requestDeviceAuthorization } from './oauth';

Expand Down
Loading