diff --git a/.changeset/acp-config-auth.md b/.changeset/acp-config-auth.md new file mode 100644 index 000000000..1884e0e12 --- /dev/null +++ b/.changeset/acp-config-auth.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow ACP sessions to start with configured non-OAuth model credentials instead of requiring terminal login. diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 607e7ef6e..f4294aa4d 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -44,7 +44,14 @@ import { type SetSessionModelResponse, type Stream, } from '@agentclientprotocol/sdk'; -import type { KimiHarness, Session, SessionSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { + KimiConfig, + KimiHarness, + ModelAlias, + ProviderConfig, + Session, + SessionSummary, +} from '@moonshot-ai/kimi-code-sdk'; import { log } from '@moonshot-ai/kimi-code-sdk'; import { LocalKaos, type Kaos } from '@moonshot-ai/kaos'; @@ -107,12 +114,90 @@ function toResolvedSlashCommands( /** * Inline auth gate — moved out of `KimiAuthFacade.hasUsableToken()` so * the SDK doesn't have to carry an ACP-specific convenience method. - * Mirrors the original semantics exactly: any provider with `hasToken` - * set counts as authed. + * OAuth tokens still count as authed, but ACP can also start when the + * active model resolves to a provider with config-file credentials. */ async function harnessIsAuthed(harness: KimiHarness): Promise { const status = await harness.auth.status(); - return status.providers.some((entry) => entry.hasToken === true); + if (status.providers.some((entry) => entry.hasToken)) return true; + return hasUsableConfiguredDefaultModel(harness); +} + +async function hasUsableConfiguredDefaultModel(harness: KimiHarness): Promise { + if (typeof harness.getConfig !== 'function') return false; + let config: KimiConfig; + try { + config = await harness.getConfig(); + } catch (error) { + log.warn('acp: harness.getConfig threw during auth gate; requiring terminal auth', { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + + if (config.defaultModel === undefined) return false; + const alias = config.models?.[config.defaultModel]; + if (alias === undefined) return false; + + const provider = providerForAlias(config, alias); + return provider !== undefined && providerHasNonOAuthCredentials(provider); +} + +function providerForAlias(config: KimiConfig, alias: ModelAlias): ProviderConfig | undefined { + const providerName = alias.provider ?? config.defaultProvider; + return providerName === undefined ? undefined : config.providers[providerName]; +} + +function providerHasNonOAuthCredentials(provider: ProviderConfig): boolean { + if (provider.oauth !== undefined) return false; + switch (provider.type) { + case 'anthropic': + return hasProviderValue(provider, 'ANTHROPIC_API_KEY'); + case 'openai': + case 'openai_responses': + return hasProviderValue(provider, 'OPENAI_API_KEY'); + case 'kimi': + return hasProviderValue(provider, 'KIMI_API_KEY'); + case 'google-genai': + return hasProviderValue(provider, 'GOOGLE_API_KEY'); + case 'vertexai': + return ( + hasProviderValue(provider, 'VERTEXAI_API_KEY') || + hasEnvValue(provider, 'GOOGLE_API_KEY') || + (hasEnvValue(provider, 'GOOGLE_CLOUD_PROJECT') && + (hasEnvValue(provider, 'GOOGLE_CLOUD_LOCATION') || + vertexAILocationFromBaseUrl(provider.baseUrl) !== undefined)) + ); + default: { + const exhaustive: never = provider.type; + return exhaustive; + } + } +} + +function hasProviderValue(provider: ProviderConfig, envKey: string): boolean { + return nonEmptyString(provider.apiKey) !== undefined || hasEnvValue(provider, envKey); +} + +function hasEnvValue(provider: ProviderConfig, envKey: string): boolean { + return nonEmptyString(provider.env?.[envKey]) !== undefined; +} + +function vertexAILocationFromBaseUrl(baseUrl: string | undefined): string | undefined { + const url = nonEmptyString(baseUrl); + if (url === undefined) return undefined; + try { + const host = new URL(url).hostname; + const suffix = '-aiplatform.googleapis.com'; + return host.endsWith(suffix) ? nonEmptyString(host.slice(0, -suffix.length)) : undefined; + } catch { + return undefined; + } +} + +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; } /** diff --git a/packages/acp-adapter/test/auth-gate.test.ts b/packages/acp-adapter/test/auth-gate.test.ts index f1180bb31..a2fe7e515 100644 --- a/packages/acp-adapter/test/auth-gate.test.ts +++ b/packages/acp-adapter/test/auth-gate.test.ts @@ -14,7 +14,7 @@ import { type WriteTextFileRequest, type WriteTextFileResponse, } from '@agentclientprotocol/sdk'; -import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import type { KimiConfig, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import { AcpServer } from '../src/server'; import { AUTHED_STATUS, UNAUTHED_STATUS } from './_helpers/harness-stubs'; @@ -45,6 +45,13 @@ function makeInMemoryStreamPair(): { return { agentStream, clientStream }; } +function startAcpServer( + harness: KimiHarness, + agentStream: ReturnType, +): AgentSideConnection { + return new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); +} + function makeHarnessWithToken(hasToken: boolean): KimiHarness { return { auth: { @@ -53,12 +60,49 @@ function makeHarnessWithToken(hasToken: boolean): KimiHarness { } as unknown as KimiHarness; } +function configuredModelConfig(provider: KimiConfig['providers'][string]): KimiConfig { + return { + providers: { local: provider }, + defaultModel: 'local/gpt', + models: { + 'local/gpt': { + provider: 'local', + model: 'gpt-4o', + maxContextSize: 128000, + }, + }, + }; +} + +function makeHarnessWithConfig(config: KimiConfig, hasToken = false): { + harness: KimiHarness; + createCalls: Array<{ id?: string; workDir: string }>; +} { + const createCalls: Array<{ id?: string; workDir: string }> = []; + const harness = { + auth: { + status: async () => (hasToken ? AUTHED_STATUS : UNAUTHED_STATUS), + }, + getConfig: async () => config, + createSession: async (options: { id?: string; workDir: string }) => { + createCalls.push(options); + return { + id: options.id ?? 'session-fallback', + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + }, + } as unknown as KimiHarness; + return { harness, createCalls }; +} + describe('AcpServer auth gate', () => { it('rejects session/new with auth_required (-32000) when no token', async () => { const harness = makeHarnessWithToken(false); const { agentStream, clientStream } = makeInMemoryStreamPair(); - new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + startAcpServer(harness, agentStream); const client = new ClientSideConnection((_a) => new StubClient(), clientStream); const request: NewSessionRequest = { @@ -84,7 +128,7 @@ describe('AcpServer auth gate', () => { } as unknown as KimiHarness; const { agentStream, clientStream } = makeInMemoryStreamPair(); - new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + startAcpServer(harness, agentStream); const client = new ClientSideConnection((_a) => new StubClient(), clientStream); await expect( @@ -92,6 +136,144 @@ describe('AcpServer auth gate', () => { ).rejects.toMatchObject({ code: -32000 }); expect(createCalled).toBe(false); }); + + it('accepts a configured default model with an api_key provider', async () => { + const { harness, createCalls } = makeHarnessWithConfig( + configuredModelConfig({ type: 'openai', apiKey: 'sk-test' }), + ); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.newSession({ cwd: '/tmp/configured', mcpServers: [] }); + + expect(response.sessionId).toBeTruthy(); + expect(createCalls).toHaveLength(1); + expect(createCalls[0]?.workDir).toBe('/tmp/configured'); + }); + + it('accepts provider env-table credentials without an OAuth token', async () => { + const { harness, createCalls } = makeHarnessWithConfig( + configuredModelConfig({ type: 'openai', env: { OPENAI_API_KEY: 'sk-env' } }), + ); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.newSession({ cwd: '/tmp/env', mcpServers: [] })).resolves.toMatchObject({ + sessionId: expect.any(String), + }); + expect(createCalls).toHaveLength(1); + }); + + it('rejects config credentials when no default model resolves to them', async () => { + const { harness, createCalls } = makeHarnessWithConfig({ + providers: { local: { type: 'openai', apiKey: 'sk-test' } }, + models: {}, + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.newSession({ cwd: '/tmp/no-model', mcpServers: [] })).rejects.toMatchObject({ + code: -32000, + }); + expect(createCalls).toHaveLength(0); + }); + + it('does not trim the configured default model before resolving it', async () => { + const { harness, createCalls } = makeHarnessWithConfig({ + providers: { local: { type: 'openai', apiKey: 'sk-test' } }, + defaultModel: ' local/gpt ', + models: { + 'local/gpt': { + provider: 'local', + model: 'gpt-4o', + maxContextSize: 128000, + }, + }, + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.newSession({ cwd: '/tmp/spaced-model', mcpServers: [] })).rejects.toMatchObject({ + code: -32000, + }); + expect(createCalls).toHaveLength(0); + }); + + it('rejects mixed api_key and OAuth provider config without a token', async () => { + const { harness, createCalls } = makeHarnessWithConfig( + configuredModelConfig({ + type: 'kimi', + apiKey: 'sk-test', + oauth: { storage: 'file', key: 'kimi' }, + }), + ); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.newSession({ cwd: '/tmp/mixed-auth', mcpServers: [] })).rejects.toMatchObject({ + code: -32000, + }); + expect(createCalls).toHaveLength(0); + }); + + it('rejects Vertex AI service-account config without a resolvable location', async () => { + const { harness, createCalls } = makeHarnessWithConfig( + configuredModelConfig({ + type: 'vertexai', + baseUrl: 'https://example.test/v1', + env: { GOOGLE_CLOUD_PROJECT: 'project' }, + }), + ); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.newSession({ cwd: '/tmp/vertexai', mcpServers: [] })).rejects.toMatchObject({ + code: -32000, + }); + expect(createCalls).toHaveLength(0); + }); + + it('keeps the OAuth token short-circuit even when config loading fails', async () => { + const createCalls: Array<{ id?: string; workDir: string }> = []; + const harness = { + auth: { + status: async () => AUTHED_STATUS, + }, + getConfig: async () => { + throw new Error('config unavailable'); + }, + createSession: async (options: { id?: string; workDir: string }) => { + createCalls.push(options); + return { + id: options.id ?? 'session-fallback', + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + }, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.newSession({ cwd: '/tmp/token', mcpServers: [] })).resolves.toMatchObject({ + sessionId: expect.any(String), + }); + expect(createCalls).toHaveLength(1); + }); }); describe('AcpServer.authenticate', () => { @@ -99,7 +281,7 @@ describe('AcpServer.authenticate', () => { const harness = makeHarnessWithToken(true); const { agentStream, clientStream } = makeInMemoryStreamPair(); - new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + startAcpServer(harness, agentStream); const client = new ClientSideConnection((_a) => new StubClient(), clientStream); await expect(client.authenticate({ methodId: 'unknown' })).rejects.toMatchObject({ @@ -111,7 +293,7 @@ describe('AcpServer.authenticate', () => { const harness = makeHarnessWithToken(true); const { agentStream, clientStream } = makeInMemoryStreamPair(); - new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + startAcpServer(harness, agentStream); const client = new ClientSideConnection((_a) => new StubClient(), clientStream); const result = await client.authenticate({ methodId: 'login' }); @@ -124,11 +306,24 @@ describe('AcpServer.authenticate', () => { const harness = makeHarnessWithToken(false); const { agentStream, clientStream } = makeInMemoryStreamPair(); - new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + startAcpServer(harness, agentStream); const client = new ClientSideConnection((_a) => new StubClient(), clientStream); await expect(client.authenticate({ methodId: 'login' })).rejects.toMatchObject({ code: -32000, }); }); + + it('returns void when config credentials are already usable', async () => { + const { harness } = makeHarnessWithConfig( + configuredModelConfig({ type: 'kimi', apiKey: 'sk-kimi' }), + ); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const result = await client.authenticate({ methodId: 'login' }); + expect(result ?? {}).toEqual({}); + }); });