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/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 94dca02bf..89bd10a19 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -135,18 +135,34 @@ 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); const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); - return { - providers: [ - { - providerName: name, - hasToken: await this.managerFor(name, oauthKey, oauthHost).hasToken(), - }, - ], - }; + 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: + apiKeyProviderNames.has(name) || (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. 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 apiKeyProviderName of options?.apiKeyProviders ?? []) { + if (apiKeyProviderName === name) continue; + providers.push({ providerName: apiKeyProviderName, hasToken: true }); + } + return { providers }; } async login( diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 73e091644..a6a0a5827 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -133,6 +133,58 @@ describe('KimiOAuthToolkit', () => { await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('access-1'); }); + it('surfaces API-key providers as authed when passed via apiKeyProviders', async () => { + const storage = new MemoryTokenStorage(); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + // 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); + }); + + it('returns only the OAuth slot when no API-key providers are passed', async () => { + const storage = new MemoryTokenStorage(); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + 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('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')); @@ -494,7 +546,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); });