From 298388dead2b461ae6930d0da21671e9f89032bc Mon Sep 17 00:00:00 2001 From: omnilyra Date: Fri, 3 Jul 2026 10:16:03 +0800 Subject: [PATCH 1/3] fix(oauth): treat API-key providers as authed in ACP session gate API-key login (open-platform path) writes apiKey into the config file but not the OAuth credentials file, so the ACP session auth gate only checked for an OAuth token and returned auth_required (-32000) for every session/new, session/load, and session/resume even with a valid key. initialize still succeeded (no credentials), so clients saw init OK followed by an auth failure. KimiOAuthToolkit.status() now also surfaces providers configured with a non-empty apiKey, so the gate treats API-key users as authed. --- .changeset/acp-api-key-auth-gate.md | 14 ++++++++ packages/oauth/src/toolkit.ts | 31 +++++++++++----- packages/oauth/test/toolkit.test.ts | 56 ++++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 .changeset/acp-api-key-auth-gate.md diff --git a/.changeset/acp-api-key-auth-gate.md b/.changeset/acp-api-key-auth-gate.md new file mode 100644 index 000000000..fa7ddb348 --- /dev/null +++ b/.changeset/acp-api-key-auth-gate.md @@ -0,0 +1,14 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +Fix ACP `session/new` rejecting API-key-only users with `auth_required`. + +API-key login (open-platform path) writes `apiKey` into the config file but not the +OAuth credentials file, so the ACP session auth gate β€” which only checked for an OAuth +token β€” returned `auth_required` (`-32000`) for every `session/new`, `session/load`, +and `session/resume`, even though the API key was valid. `initialize` still succeeded +(it carries no credentials), so ACP clients saw init OK followed by an auth failure. + +`KimiOAuthToolkit.status()` now also surfaces providers configured with a non-empty +`apiKey`, so the gate treats API-key users as authed. diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 94dca02bf..f80c3fde5 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -30,6 +30,7 @@ import { resolveKimiCodeOAuthKey, type ManagedKimiCodeProvisionResult, type ManagedKimiConfigAdapter, + type ManagedKimiConfigShape, } from './managed-kimi-code'; import { fetchManagedUsage, @@ -139,14 +140,28 @@ export class KimiOAuthToolkit { const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); - return { - providers: [ - { - providerName: name, - hasToken: await this.managerFor(name, oauthKey, oauthHost).hasToken(), - }, - ], - }; + const providers: AuthProviderStatus[] = [ + { + providerName: name, + hasToken: await this.managerFor(name, oauthKey, oauthHost).hasToken(), + }, + ]; + // API-key providers authenticate with a configured `apiKey` instead of an + // OAuth token file, so they have no credentials entry for `hasToken()` to + // find. Without surfacing them here the ACP session gate (which treats any + // provider with `hasToken` as authed) rejects API-key-only users with + // `auth_required`, even though their key is valid. + if (this.configAdapter !== undefined) { + const config = (await this.configAdapter.read()) as ManagedKimiConfigShape; + for (const [key, entry] of Object.entries(config.providers ?? {})) { + if (typeof entry !== 'object' || entry === null) continue; + const apiKey = (entry as { apiKey?: unknown }).apiKey; + if (typeof apiKey === 'string' && apiKey.length > 0) { + providers.push({ providerName: key, hasToken: true }); + } + } + } + return { providers }; } async login( diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 73e091644..8655dccfc 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -133,6 +133,60 @@ describe('KimiOAuthToolkit', () => { await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('access-1'); }); + it('reports API-key providers as authed even without an OAuth token', async () => { + const storage = new MemoryTokenStorage(); + const config: ManagedKimiConfigShape = { + providers: { + 'moonshot-cn': { type: 'kimi', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, + }, + }; + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + configAdapter: { + read: () => config, + write: vi.fn(), + apply: () => ({ defaultModel: 'moonshot-cn/kimi-for-coding', defaultThinking: true }), + }, + }); + + const status = await toolkit.status(); + // The managed OAuth slot has no token file, but the API-key provider must + // still surface as authed so the ACP session gate does not reject it. + expect(status.providers).toContainEqual({ providerName: 'moonshot-cn', hasToken: true }); + expect(status.providers.some((entry) => entry.hasToken)).toBe(true); + }); + + it('does not report API-key providers when the key is empty', async () => { + const storage = new MemoryTokenStorage(); + const config: ManagedKimiConfigShape = { + providers: { + // Managed OAuth provisioning writes an empty `apiKey`; it must not + // count as an API-key provider. + [KIMI_CODE_PROVIDER_NAME]: { type: 'kimi', apiKey: '' }, + }, + }; + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + configAdapter: { + read: () => config, + write: vi.fn(), + apply: () => ({ defaultModel: 'kimi-code/kimi-for-coding', defaultThinking: true }), + }, + }); + + const status = await toolkit.status(); + expect(status.providers).toEqual([ + { providerName: KIMI_CODE_PROVIDER_NAME, hasToken: false }, + ]); + expect(status.providers.some((entry) => entry.hasToken)).toBe(false); + }); + it('resolves bearer token providers using the configured oauth key', async () => { const storage = new MemoryTokenStorage(); storage.tokens.set('custom-kimi-code', token('custom-access')); @@ -494,7 +548,7 @@ describe('KimiOAuthToolkit', () => { key: devOauthKey, oauthHost: devOauthHost, }); - const modelRequest = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + const modelRequest = fetchMock.mock.calls[0]?.[1]; expect(new Headers(modelRequest?.headers).get('authorization')).toBe('Bearer dev-access'); expect(write).toHaveBeenCalledWith(config); }); From c787d70e55a8aea04b6270ca39759201f2d3f8e8 Mon Sep 17 00:00:00 2001 From: omnilyra Date: Fri, 3 Jul 2026 10:43:18 +0800 Subject: [PATCH 2/3] fix(oauth): resolve API-key providers via lenient config read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the API-key provider scan out of KimiOAuthToolkit.status() (which was wired to the strict readConfigFileForUpdate read path) and into KimiAuthFacade, which resolves the provider names via loadRuntimeConfigSafe β€”the same lenient loader resolveManagedAuth already uses for token/status resolution. status() now receives the resolved names as an apiKeyProviders option. This preserves the degraded-config contract: a broken unrelated section (e.g. an invalid [loop_control]) must not abort status resolution and regress OAuth-backed sessions that previously worked. --- packages/node-sdk/src/auth.ts | 24 +++++++++++++++- packages/node-sdk/test/auth-facade.test.ts | 23 ++++++++++++++++ packages/oauth/src/toolkit.ts | 17 ++++-------- packages/oauth/test/toolkit.test.ts | 32 ++++------------------ 4 files changed, 58 insertions(+), 38 deletions(-) diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 4f02747f8..9f4cf8d61 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -119,7 +119,11 @@ export class KimiAuthFacade { } async status(providerName?: string | undefined): Promise { - return this.toolkit.status(providerName, this.resolveRuntimeManagedAuth(providerName).oauthRef); + return this.toolkit.status( + providerName, + this.resolveRuntimeManagedAuth(providerName).oauthRef, + { apiKeyProviders: this.resolveApiKeyProviders() }, + ); } async login( @@ -294,6 +298,24 @@ export class KimiAuthFacade { }; } + /** + * Provider names configured with a non-empty `apiKey`. Like + * {@link resolveManagedAuth} this reads via the lenient loader so a broken + * unrelated config section cannot abort status resolution. The managed OAuth + * slot is provisioned with an empty `apiKey`, so it is excluded by the + * non-empty check. + */ + private resolveApiKeyProviders(): string[] { + const { config } = loadRuntimeConfigSafe(this.options.configPath); + const names: string[] = []; + for (const [name, provider] of Object.entries(config.providers)) { + if (provider.apiKey && provider.apiKey.length > 0) { + names.push(name); + } + } + return names; + } + private resolveRuntimeManagedAuth(providerName?: string | undefined): { readonly oauthRef: OAuthRef; readonly baseUrl?: string | undefined; diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 978bd2578..1e814e60c 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -153,6 +153,29 @@ max_steps_per_turn = "abc" }); }); + it('reports an API-key provider as authed even with a broken unrelated config section', async () => { + // No OAuth token file β€” the user authenticated via API key only. A broken + // [loop_control] must not abort status resolution (the read path uses the + // lenient loader, not the strict write-path reader). + await writeFile( + join(homeDir, 'config.toml'), + ` +[providers."moonshot-cn"] +type = "kimi" +base_url = "https://api.moonshot.cn/v1" +api_key = "sk-test" + +[loop_control] +max_steps_per_turn = "abc" +`, + ); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + const status = await harness.auth.status(); + expect(status.providers).toContainEqual({ providerName: 'moonshot-cn', hasToken: true }); + expect(status.providers.some((entry) => entry.hasToken)).toBe(true); + }); + it('resolves cached access tokens from the configured scoped OAuth ref', async () => { const oauthKey = resolveKimiCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test', diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index f80c3fde5..97dcc743c 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -30,7 +30,6 @@ import { resolveKimiCodeOAuthKey, type ManagedKimiCodeProvisionResult, type ManagedKimiConfigAdapter, - type ManagedKimiConfigShape, } from './managed-kimi-code'; import { fetchManagedUsage, @@ -136,6 +135,7 @@ export class KimiOAuthToolkit { async status( providerName?: string | undefined, oauthRef?: KimiOAuthTokenRef | undefined, + options?: { readonly apiKeyProviders?: readonly string[] | undefined }, ): Promise { const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); @@ -150,16 +150,11 @@ export class KimiOAuthToolkit { // OAuth token file, so they have no credentials entry for `hasToken()` to // find. Without surfacing them here the ACP session gate (which treats any // provider with `hasToken` as authed) rejects API-key-only users with - // `auth_required`, even though their key is valid. - if (this.configAdapter !== undefined) { - const config = (await this.configAdapter.read()) as ManagedKimiConfigShape; - for (const [key, entry] of Object.entries(config.providers ?? {})) { - if (typeof entry !== 'object' || entry === null) continue; - const apiKey = (entry as { apiKey?: unknown }).apiKey; - if (typeof apiKey === 'string' && apiKey.length > 0) { - providers.push({ providerName: key, hasToken: true }); - } - } + // `auth_required`, even though their key is valid. The caller resolves the + // list of API-key provider names from a lenient config read so a broken + // unrelated config section cannot abort status resolution. + for (const providerName of options?.apiKeyProviders ?? []) { + providers.push({ providerName, hasToken: true }); } return { providers }; } diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 8655dccfc..a300048b0 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -133,51 +133,31 @@ describe('KimiOAuthToolkit', () => { await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('access-1'); }); - it('reports API-key providers as authed even without an OAuth token', async () => { + it('surfaces API-key providers as authed when passed via apiKeyProviders', async () => { const storage = new MemoryTokenStorage(); - const config: ManagedKimiConfigShape = { - providers: { - 'moonshot-cn': { type: 'kimi', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, - }, - }; const toolkit = new KimiOAuthToolkit({ homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), identity: TEST_IDENTITY, storage, now: () => 100, - configAdapter: { - read: () => config, - write: vi.fn(), - apply: () => ({ defaultModel: 'moonshot-cn/kimi-for-coding', defaultThinking: true }), - }, }); - const status = await toolkit.status(); // The managed OAuth slot has no token file, but the API-key provider must // still surface as authed so the ACP session gate does not reject it. + const status = await toolkit.status(undefined, undefined, { + apiKeyProviders: ['moonshot-cn'], + }); expect(status.providers).toContainEqual({ providerName: 'moonshot-cn', hasToken: true }); - expect(status.providers.some((entry) => entry.hasToken)).toBe(true); + expect(status.providers.some((entry) => entry.hasToken)).toBe(true); }); - it('does not report API-key providers when the key is empty', async () => { + it('returns only the OAuth slot when no API-key providers are passed', async () => { const storage = new MemoryTokenStorage(); - const config: ManagedKimiConfigShape = { - providers: { - // Managed OAuth provisioning writes an empty `apiKey`; it must not - // count as an API-key provider. - [KIMI_CODE_PROVIDER_NAME]: { type: 'kimi', apiKey: '' }, - }, - }; const toolkit = new KimiOAuthToolkit({ homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), identity: TEST_IDENTITY, storage, now: () => 100, - configAdapter: { - read: () => config, - write: vi.fn(), - apply: () => ({ defaultModel: 'kimi-code/kimi-for-coding', defaultThinking: true }), - }, }); const status = await toolkit.status(); From 67247e0e4dcf227aca12114e9c7198696a547c7a Mon Sep 17 00:00:00 2001 From: omnilyra Date: Fri, 3 Jul 2026 11:18:18 +0800 Subject: [PATCH 3/3] fix(oauth): merge API-key status into the primary status entry When the requested provider is itself an API-key provider (e.g. status('moonshot-cn') after an open-platform login), the OAuth probe reports hasToken:false because no token file exists. Previously a second duplicate entry with hasToken:true was appended, so callers reading providers[0] or find() by name still saw the stale false. Now the API-key check is folded into the primary entry: if the requested name is in apiKeyProviders, hasToken is true without a duplicate. Other API-key providers are still appended as before. --- packages/oauth/src/toolkit.ts | 12 +++++++++--- packages/oauth/test/toolkit.test.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 97dcc743c..89bd10a19 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -140,10 +140,15 @@ export class KimiOAuthToolkit { const name = providerName ?? KIMI_CODE_PROVIDER_NAME; const oauthHost = this.oauthHostFor(oauthRef); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); + const apiKeyProviderNames = new Set(options?.apiKeyProviders ?? []); + // The requested provider may itself authenticate via an API key (no OAuth + // token file). Merge that case into the primary entry instead of appending + // a duplicate, so callers reading providers[0] see the correct status. const providers: AuthProviderStatus[] = [ { providerName: name, - hasToken: await this.managerFor(name, oauthKey, oauthHost).hasToken(), + hasToken: + apiKeyProviderNames.has(name) || (await this.managerFor(name, oauthKey, oauthHost).hasToken()), }, ]; // API-key providers authenticate with a configured `apiKey` instead of an @@ -153,8 +158,9 @@ export class KimiOAuthToolkit { // `auth_required`, even though their key is valid. The caller resolves the // list of API-key provider names from a lenient config read so a broken // unrelated config section cannot abort status resolution. - for (const providerName of options?.apiKeyProviders ?? []) { - providers.push({ providerName, hasToken: true }); + for (const apiKeyProviderName of options?.apiKeyProviders ?? []) { + if (apiKeyProviderName === name) continue; + providers.push({ providerName: apiKeyProviderName, hasToken: true }); } return { providers }; } diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index a300048b0..a6a0a5827 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -167,6 +167,24 @@ describe('KimiOAuthToolkit', () => { expect(status.providers.some((entry) => entry.hasToken)).toBe(false); }); + it('merges the API-key status into the primary entry when the requested provider is an API-key provider', async () => { + const storage = new MemoryTokenStorage(); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + // `moonshot-cn` has no OAuth token file, so the OAuth probe alone would + // report hasToken:false. Because it is listed as an API-key provider, the + // primary entry must reflect hasToken:true with no duplicate appended. + const status = await toolkit.status('moonshot-cn', undefined, { + apiKeyProviders: ['moonshot-cn'], + }); + expect(status.providers).toEqual([{ providerName: 'moonshot-cn', hasToken: true }]); + }); + it('resolves bearer token providers using the configured oauth key', async () => { const storage = new MemoryTokenStorage(); storage.tokens.set('custom-kimi-code', token('custom-access'));