mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-15 03:35:32 +00:00
* feat(mcp): inspect effective authorization state * test(agent-core-v2): register MCP auth coordinator fixture * fix(mcp): validate runtime names against full catalog * fix(mcp): reconnect after pending auth updates * docs(mcp): describe auth coordinator collaborator * fix(mcp): ignore disabled runtime name collisions * fix(mcp): serialize OAuth token refresh * test(mcp): await OAuth credential writes * fix(mcp): queue trailing credential reconnect * fix(oauth): preserve access-only refresh winners * fix(mcp): preserve legacy offline auth state * fix(mcp): redact inspection credentials * refactor(mcp): keep app inspection on v1 * fix(mcp): guard legacy auth status mutations * fix(mcp): avoid deterministic legacy auth probes * fix(mcp): cover initialization credential updates --------- Co-authored-by: 刘仲诺 <liuzhongnuo@dev.msh.team>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { McpOAuthService } from '../../src/mcp/oauth/service';
|
|
import { KimiCore } from '../../src/rpc/core-impl';
|
|
|
|
describe('KimiCore plugin RPCs', () => {
|
|
it('install → list → setEnabled → remove round trip', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({ name: 'demo', version: '1.0.0' }),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const installed = await core.installPlugin({ source: pluginRoot });
|
|
expect(installed.id).toBe('demo');
|
|
expect(installed.version).toBe('1.0.0');
|
|
|
|
const list = await core.listPlugins({});
|
|
expect(list).toHaveLength(1);
|
|
|
|
await core.setPluginEnabled({ id: 'demo', enabled: false });
|
|
const after = await core.listPlugins({});
|
|
expect(after[0]?.enabled).toBe(false);
|
|
|
|
await core.removePlugin({ id: 'demo' });
|
|
await expect(core.listPlugins({})).resolves.toEqual([]);
|
|
});
|
|
|
|
it('installPlugin ignores forged marketplace context from public RPC callers', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({ name: 'demo', version: '1.0.0' }),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const installed = await core.installPlugin({
|
|
source: pluginRoot,
|
|
marketplace: { id: 'demo', tier: 'official' },
|
|
} as never);
|
|
|
|
expect((installed as { marketplace?: unknown }).marketplace).toBeUndefined();
|
|
});
|
|
|
|
it('setPluginMcpServerEnabled toggles plugin MCP state', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({
|
|
name: 'demo',
|
|
mcpServers: {
|
|
finance: { command: 'finance-mcp' },
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
await core.installPlugin({ source: pluginRoot });
|
|
await core.setPluginMcpServerEnabled({ id: 'demo', server: 'finance', enabled: true });
|
|
|
|
await expect(core.getPluginInfo({ id: 'demo' })).resolves.toEqual(
|
|
expect.objectContaining({
|
|
mcpServers: expect.arrayContaining([
|
|
expect.objectContaining({ name: 'finance', enabled: true }),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('inspects global and plugin MCP servers through the v1 app catalog', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(home, 'mcp.json'),
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
global: { command: 'global-mcp', env: { GLOBAL_SECRET: 'global-secret-value' } },
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({
|
|
name: 'demo',
|
|
mcpServers: {
|
|
local: { command: 'local-mcp', env: { PLUGIN_SECRET: 'plugin-secret-value' } },
|
|
remote: {
|
|
transport: 'http',
|
|
url: 'https://mcp.example.test/service',
|
|
auth: 'oauth',
|
|
},
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await core.installPlugin({ source: pluginRoot });
|
|
await core.setPluginMcpServerEnabled({ id: 'demo', server: 'remote', enabled: false });
|
|
|
|
const inspections = await core.inspectAppMcpServers({});
|
|
expect(inspections).toEqual([
|
|
expect.objectContaining({
|
|
serverId: 'global:global',
|
|
locator: { source: 'global', name: 'global' },
|
|
runtimeName: 'global',
|
|
origin: 'global',
|
|
editable: true,
|
|
authStatus: 'not-applicable',
|
|
config: expect.objectContaining({ envKeys: ['GLOBAL_SECRET'] }),
|
|
}),
|
|
expect.objectContaining({
|
|
serverId: 'plugin:demo:local',
|
|
locator: { source: 'plugin', pluginId: 'demo', serverName: 'local' },
|
|
runtimeName: 'plugin-demo:local',
|
|
origin: 'plugin',
|
|
editable: false,
|
|
enabled: true,
|
|
authStatus: 'not-applicable',
|
|
config: expect.objectContaining({ envKeys: expect.arrayContaining(['PLUGIN_SECRET']) }),
|
|
}),
|
|
expect.objectContaining({
|
|
serverId: 'plugin:demo:remote',
|
|
locator: { source: 'plugin', pluginId: 'demo', serverName: 'remote' },
|
|
runtimeName: 'plugin-demo:remote',
|
|
origin: 'plugin',
|
|
editable: false,
|
|
enabled: false,
|
|
authStatus: 'not-applicable',
|
|
}),
|
|
]);
|
|
expect(JSON.stringify(inspections)).not.toContain('global-secret-value');
|
|
expect(JSON.stringify(inspections)).not.toContain('plugin-secret-value');
|
|
|
|
const oauth = new McpOAuthService({ kimiHomeDir: home });
|
|
await oauth
|
|
.getProvider('plugin-demo:remote', 'https://mcp.example.test/service')
|
|
.saveTokens({ access_token: 'plugin-test-token', token_type: 'Bearer' });
|
|
await core.resetMcpServerAuth({
|
|
locator: { source: 'plugin', pluginId: 'demo', serverName: 'remote' },
|
|
});
|
|
expect(oauth.hasTokens('plugin-demo:remote', 'https://mcp.example.test/service')).toBe(false);
|
|
|
|
await oauth
|
|
.getProvider('plugin-demo:remote', 'https://mcp.example.test/service')
|
|
.saveTokens({ access_token: 'plugin-test-token', token_type: 'Bearer' });
|
|
await core.setPluginMcpServerEnabled({ id: 'demo', server: 'remote', enabled: true });
|
|
await core.addGlobalMcpServer({
|
|
server: {
|
|
name: 'plugin-demo:remote',
|
|
transport: 'http',
|
|
url: 'https://global.example.test/service',
|
|
auth: 'oauth',
|
|
},
|
|
});
|
|
const locator = { source: 'plugin', pluginId: 'demo', serverName: 'remote' } as const;
|
|
await expect(core.beginMcpServerAuth({ locator })).rejects.toThrow(
|
|
'is shared by multiple enabled servers',
|
|
);
|
|
await expect(core.resetMcpServerAuth({ locator })).rejects.toThrow(
|
|
'is shared by multiple enabled servers',
|
|
);
|
|
expect(oauth.hasTokens('plugin-demo:remote', 'https://mcp.example.test/service')).toBe(true);
|
|
});
|
|
|
|
it('injects persisted managed Kimi Code environment into the datasource plugin MCP server', async () => {
|
|
const previousBaseUrl = process.env['KIMI_CODE_BASE_URL'];
|
|
const previousCodeOAuthHost = process.env['KIMI_CODE_OAUTH_HOST'];
|
|
const previousOAuthHost = process.env['KIMI_OAUTH_HOST'];
|
|
delete process.env['KIMI_CODE_BASE_URL'];
|
|
delete process.env['KIMI_CODE_OAUTH_HOST'];
|
|
delete process.env['KIMI_OAUTH_HOST'];
|
|
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
try {
|
|
await writeFile(
|
|
path.join(home, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "https://api.dev.example.test/coding/v1"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" }
|
|
`,
|
|
'utf8',
|
|
);
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({
|
|
name: 'kimi-datasource',
|
|
mcpServers: {
|
|
data: { command: 'node', args: ['./bin/kimi-datasource.mjs'] },
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await new Promise((r) => setImmediate(r));
|
|
await core.installPlugin({ source: pluginRoot });
|
|
|
|
const mcpConfig = (
|
|
core as unknown as {
|
|
mergePluginMcpConfig(base: undefined): {
|
|
servers: Record<string, { env?: Record<string, string> }>;
|
|
};
|
|
}
|
|
).mergePluginMcpConfig(undefined);
|
|
|
|
expect(mcpConfig.servers['plugin-kimi-datasource:data']?.env).toEqual(
|
|
expect.objectContaining({
|
|
KIMI_CODE_BASE_URL: 'https://api.dev.example.test/coding/v1',
|
|
KIMI_CODE_OAUTH_HOST: 'https://auth.dev.example.test',
|
|
}),
|
|
);
|
|
} finally {
|
|
restoreEnv('KIMI_CODE_BASE_URL', previousBaseUrl);
|
|
restoreEnv('KIMI_CODE_OAUTH_HOST', previousCodeOAuthHost);
|
|
restoreEnv('KIMI_OAUTH_HOST', previousOAuthHost);
|
|
}
|
|
});
|
|
|
|
it('throws PLUGIN_LOAD_FAILED on every RPC when installed.json is corrupt', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
await mkdir(path.join(home, 'plugins'), { recursive: true });
|
|
await writeFile(path.join(home, 'plugins', 'installed.json'), '{ not json', 'utf8');
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
|
|
// Driving an awaiting RPC first ensures the load promise has settled
|
|
// and captured pluginsLoadError before the read RPCs run.
|
|
await expect(core.installPlugin({ source: '/tmp/nonexistent' })).rejects.toThrow(/load/i);
|
|
await expect(core.listPlugins({})).rejects.toThrow(/load/i);
|
|
await expect(core.getPluginInfo({ id: 'demo' })).rejects.toThrow(/load/i);
|
|
await expect(core.setPluginEnabled({ id: 'demo', enabled: false })).rejects.toThrow(/load/i);
|
|
await expect(
|
|
core.setPluginMcpServerEnabled({ id: 'demo', server: 'finance', enabled: true }),
|
|
).rejects.toThrow(/load/i);
|
|
await expect(core.removePlugin({ id: 'demo' })).rejects.toThrow(/load/i);
|
|
|
|
// installed.json must NOT have been overwritten by the failed install.
|
|
const { readFile } = await import('node:fs/promises');
|
|
const onDisk = await readFile(path.join(home, 'plugins', 'installed.json'), 'utf8');
|
|
expect(onDisk).toBe('{ not json');
|
|
|
|
// Fixing the file and calling reload clears the error state.
|
|
await writeFile(
|
|
path.join(home, 'plugins', 'installed.json'),
|
|
JSON.stringify({ version: 1, plugins: [] }),
|
|
'utf8',
|
|
);
|
|
await core.reloadPlugins({});
|
|
await expect(core.listPlugins({})).resolves.toEqual([]);
|
|
});
|
|
|
|
it('listPlugins waits for initial plugin load', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({ name: 'demo' }),
|
|
'utf8',
|
|
);
|
|
await mkdir(path.join(home, 'plugins'), { recursive: true });
|
|
await writeFile(
|
|
path.join(home, 'plugins', 'installed.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
plugins: [
|
|
{
|
|
id: 'demo',
|
|
root: pluginRoot,
|
|
source: 'local-path',
|
|
enabled: true,
|
|
installedAt: '2026-05-25T09:00:00Z',
|
|
},
|
|
],
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
|
|
await expect(core.listPlugins({})).resolves.toContainEqual(
|
|
expect.objectContaining({ id: 'demo' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
function restoreEnv(name: string, value: string | undefined): void {
|
|
if (value === undefined) {
|
|
delete process.env[name];
|
|
return;
|
|
}
|
|
process.env[name] = value;
|
|
}
|