Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .changeset/acp-api-key-auth-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@moonshot-ai/kimi-code': patch
Comment thread
omnilyra marked this conversation as resolved.
---

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.
24 changes: 23 additions & 1 deletion packages/node-sdk/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ export class KimiAuthFacade {
}

async status(providerName?: string | undefined): Promise<AuthStatus> {
return this.toolkit.status(providerName, this.resolveRuntimeManagedAuth(providerName).oauthRef);
return this.toolkit.status(
providerName,
this.resolveRuntimeManagedAuth(providerName).oauthRef,
{ apiKeyProviders: this.resolveApiKeyProviders() },
);
}

async login(
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions packages/node-sdk/test/auth-facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 24 additions & 8 deletions packages/oauth/src/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,18 +135,34 @@ export class KimiOAuthToolkit<TConfig = unknown> {
async status(
providerName?: string | undefined,
oauthRef?: KimiOAuthTokenRef | undefined,
options?: { readonly apiKeyProviders?: readonly string[] | undefined },
): Promise<AuthStatus> {
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(
Expand Down
54 changes: 53 additions & 1 deletion packages/oauth/test/toolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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);
});
Expand Down