Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7043ff4
feat(oauth): add keychain-backed token storage with file fallback
Brooooooklyn Jun 14, 2026
4fbddc7
fix(oauth): harden keyring migration, remove, and probe against races
Brooooooklyn Jun 14, 2026
5f6d1f9
feat(oauth): use keychain storage by default in the OAuth toolkit
Brooooooklyn Jun 14, 2026
c82d63d
build(kimi-code): collect @napi-rs/keyring in the native SEA build
Brooooooklyn Jun 14, 2026
8d19420
fix(kimi-code): route @napi-rs/keyring through the native-asset modul…
Brooooooklyn Jun 14, 2026
a1fe92f
build(nix): update fetchPnpmDeps hash for @napi-rs/keyring
Brooooooklyn Jun 14, 2026
1ca5cf8
fix(oauth): align keychain storage with file backend (per-profile nam…
Brooooooklyn Jun 14, 2026
014cba1
fix(oauth): reconcile newer plaintext token on keychain hit (sequenti…
Brooooooklyn Jun 14, 2026
1b6a6a7
docs(oauth): clarify keychain reconcile comments + pin strict-newer t…
Brooooooklyn Jun 14, 2026
5a9d7b4
fix(oauth): compare token issuance time, not expiry, in keychain reco…
Brooooooklyn Jun 14, 2026
7c0790f
docs(oauth): note issuedAt graceful-degradation on rehydrated tokens
Brooooooklyn Jun 14, 2026
a6e31c4
docs(oauth): document keychain-by-default storage and KIMI_DISABLE_KE…
Brooooooklyn Jun 14, 2026
de3c089
test(oauth): use \x00 escape instead of literal NUL in fake keyring keys
Brooooooklyn Jun 14, 2026
73a3c96
fix(oauth): surface failed keychain deletes on logout (@napi-rs/keyri…
Brooooooklyn Jun 14, 2026
b5647a1
fix(oauth): prune stale plaintext copy after keychain save (prevent f…
Brooooooklyn Jun 14, 2026
a790e84
docs(oauth): clarify save() prune is a deliberate fail-closed choice …
Brooooooklyn Jun 14, 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
1 change: 1 addition & 0 deletions apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"dependencies": {
"@earendil-works/pi-tui": "^0.74.0",
"@mariozechner/clipboard": "^0.3.2",
"@napi-rs/keyring": "1.3.0",
"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 @@ -21,7 +21,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 @@ -142,7 +142,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-XwkLwxWZtOaw1N1GKR9G3z0yhXO/lDB5+O+VKtgxKWo=";
hash = "sha256-Z8KU7I5yA+Pds+52GTkkxnu4sDdnjPTbTlrxJZq7+ww=";
};

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 @@ -19,6 +19,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