Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/acp-config-auth.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 89 additions & 4 deletions packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<boolean> {
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<boolean> {
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;
}

/**
Expand Down
207 changes: 201 additions & 6 deletions packages/acp-adapter/test/auth-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,6 +45,13 @@ function makeInMemoryStreamPair(): {
return { agentStream, clientStream };
}

function startAcpServer(
harness: KimiHarness,
agentStream: ReturnType<typeof ndJsonStream>,
): AgentSideConnection {
return new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
}

function makeHarnessWithToken(hasToken: boolean): KimiHarness {
return {
auth: {
Expand All @@ -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 = {
Expand All @@ -84,22 +128,160 @@ 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(
client.newSession({ cwd: '/tmp/x', mcpServers: [] }),
).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', () => {
it('rejects unknown methodId with invalidParams (-32602)', async () => {
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({
Expand All @@ -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' });
Expand All @@ -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({});
});
});