Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
53 changes: 52 additions & 1 deletion packages/agent-core-v2/src/app/plugin/store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,51 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';

import { z } from 'zod';

import type { PluginCapabilityState, PluginGithubMetadata, PluginSource } from './types';

const INSTALLED_REL = path.join('plugins', 'installed.json');

const PluginSourceSchema: z.ZodType<PluginSource> = z.enum(['local-path', 'zip-url', 'github']);

const PluginGithubMetadataSchema: z.ZodType<PluginGithubMetadata> = z.object({
owner: z.string(),
repo: z.string(),
ref: z.object({
kind: z.enum(['branch', 'tag', 'sha']),
value: z.string(),
}),
installedSha: z.string().optional(),
});

const PluginCapabilityStateSchema: z.ZodType<PluginCapabilityState> = z.object({
mcpServers: z.record(z.string(), z.object({ enabled: z.boolean() })).optional(),
});

/*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the v2 inline rationale comment

In the v2 package, comments are constrained to the top-of-file /** */ module header, but this new block comment sits in the body next to schema declarations and explains implementation rationale. Please remove it or fold only the module-level responsibility into the header so the file stays within the v2 comment convention.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L13-L13

Useful? React with 👍 / 👎.

Records are validated field by field rather than trusted from disk: `installed.json`
is hand-editable, survives downgrades, and is written by older versions, so a
structurally valid file can still carry records that violate the type. Consumers
dereference these fields unconditionally — `PluginManager.load` reads `entry.id`
and `entry.root`, and the TUI renders `github.ref.value` — so an unchecked cast
turns a bad record into a crash far from the file that caused it.

Unknown keys are kept (`loose`) so a record written by a newer version round-trips
through an older one instead of losing fields on the next write.
*/
const InstalledRecordSchema = z.looseObject({
id: z.string(),
root: z.string(),
source: PluginSourceSchema,
enabled: z.boolean(),
installedAt: z.string(),
updatedAt: z.string().optional(),
originalSource: z.string().optional(),
capabilities: PluginCapabilityStateSchema.optional(),
github: PluginGithubMetadataSchema.optional(),
});

export interface InstalledRecord {
readonly id: string;
readonly root: string;
Expand Down Expand Up @@ -38,7 +79,17 @@ export async function readInstalled(kimiHomeDir: string): Promise<InstalledFile>
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) {
throw new Error('installed.json is not a valid InstalledFile object');
}
return parsed;
const plugins = parsed.plugins.map((entry, index) => {
const record = InstalledRecordSchema.safeParse(entry);
if (!record.success) {
throw new Error(
`plugins[${index}] is not a valid installed record: ${record.error.message}`,
{ cause: record.error },
);
}
return record.data as InstalledRecord;
});
return { ...parsed, plugins };
} catch (error) {
throw new Error(`Failed to parse ${filePath}: ${(error as Error).message}`, { cause: error });
}
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-core-v2/test/app/plugin/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ describe('PluginManager', () => {
]);
});

it('fails the load with the offending index when installed.json holds a bad record', async () => {
// `load()` dereferences `entry.id` and `entry.root` for every record, so a
// malformed one has to be rejected at the store instead of crashing here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the v2 test rationale out of the body

This new inline test comment is inside a v2 test body, but the scoped guide allows comments only in the top-of-file /** */ header rather than beside statements. Please encode this rationale in the test name/assertion or the file header instead of leaving an inline body comment.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L13-L13

Useful? React with 👍 / 👎.

await writeFile(
join(home, 'plugins', 'installed.json'),
JSON.stringify({ version: 1, plugins: [null] }),
'utf8',
);
const manager = new PluginManager({ kimiHomeDir: home });
await expect(manager.load()).rejects.toThrow(/plugins\[0\]/);
});

it('installs a local-path plugin into the managed root', async () => {
const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-install-source-'));
try {
Expand Down
57 changes: 56 additions & 1 deletion packages/agent-core/src/plugin/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';

import { z } from 'zod';

import type {
PluginCapabilityState,
PluginGithubMetadata,
Expand All @@ -9,6 +11,49 @@ import type {

const INSTALLED_REL = path.join('plugins', 'installed.json');

const PluginSourceSchema: z.ZodType<PluginSource> = z.enum([
'local-path',
'zip-url',
'github',
]);

const PluginGithubMetadataSchema: z.ZodType<PluginGithubMetadata> = z.object({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve future GitHub metadata during validation

When a newer install record has extra fields inside github (for example metadata added by a later client), this z.object parse strips those keys before readInstalled returns, so a caller that reads and writes the file back no longer round-trips the record even though the top-level schema is loose. Please make the nested metadata/ref schemas loose or passthrough as well; the v2 copy has the same issue.

Useful? React with 👍 / 👎.

owner: z.string(),
repo: z.string(),
ref: z.object({
kind: z.enum(['branch', 'tag', 'sha']),
value: z.string(),
}),
installedSha: z.string().optional(),
});

const PluginCapabilityStateSchema: z.ZodType<PluginCapabilityState> = z.object({
mcpServers: z.record(z.string(), z.object({ enabled: z.boolean() })).optional(),
});

/*
Records are validated field by field rather than trusted from disk: `installed.json`
is hand-editable, survives downgrades, and is written by older versions, so a
structurally valid file can still carry records that violate the type. Consumers
dereference these fields unconditionally — `PluginManager.load` reads `entry.id`
and `entry.root`, and the TUI renders `github.ref.value` — so an unchecked cast
turns a bad record into a crash far from the file that caused it.

Unknown keys are kept (`loose`) so a record written by a newer version round-trips
through an older one instead of losing fields on the next write.
*/
const InstalledRecordSchema = z.looseObject({
id: z.string(),
root: z.string(),
source: PluginSourceSchema,
enabled: z.boolean(),
installedAt: z.string(),
updatedAt: z.string().optional(),
originalSource: z.string().optional(),
capabilities: PluginCapabilityStateSchema.optional(),
github: PluginGithubMetadataSchema.optional(),
});

export interface InstalledRecord {
readonly id: string;
readonly root: string;
Expand Down Expand Up @@ -42,7 +87,17 @@ export async function readInstalled(kimiHomeDir: string): Promise<InstalledFile>
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) {
throw new Error('installed.json is not a valid InstalledFile object');
}
return parsed;
const plugins = parsed.plugins.map((entry, index) => {
const record = InstalledRecordSchema.safeParse(entry);
if (!record.success) {
throw new Error(
`plugins[${index}] is not a valid installed record: ${record.error.message}`,
{ cause: record.error },
);
}
return record.data as InstalledRecord;
});
return { ...parsed, plugins };
} catch (error) {
throw new Error(
`Failed to parse ${filePath}: ${(error as Error).message}`,
Expand Down
65 changes: 65 additions & 0 deletions packages/agent-core/test/plugin/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,69 @@ describe('plugin store', () => {
expect(record?.source).toBe('zip-url');
expect((record as { github?: unknown } | undefined)?.github).toBeUndefined();
});

async function writeRaw(home: string, file: unknown): Promise<void> {
await writeInstalled(home, { version: 1, plugins: [] });
await writeFile(
path.join(home, 'plugins', 'installed.json'),
JSON.stringify(file),
'utf8',
);
}

const VALID_RECORD = {
id: 'demo',
root: '/tmp/demo',
source: 'local-path',
enabled: true,
installedAt: '2026-05-25T09:00:00Z',
};

it.each([
['a null entry', null],
['a non-object entry', 'demo'],
['a record with no id', { ...VALID_RECORD, id: undefined }],
['a record with a non-string root', { ...VALID_RECORD, root: 42 }],
['a record with an unknown source', { ...VALID_RECORD, source: 'ftp' }],
['a record with a non-boolean enabled', { ...VALID_RECORD, enabled: 'yes' }],
['a record with a non-string installedAt', { ...VALID_RECORD, installedAt: 5 }],
[
'a github record with no ref',
{ ...VALID_RECORD, source: 'github', github: { owner: 'a', repo: 'b' } },
],
[
'a github record with an unknown ref kind',
{
...VALID_RECORD,
source: 'github',
github: { owner: 'a', repo: 'b', ref: { kind: 'commit', value: 'abc' } },
},
],
])('rejects %s instead of returning it as an InstalledRecord', async (_label, entry) => {
const home = await makeKimiHome();
await writeRaw(home, { version: 1, plugins: [entry] });
await expect(readInstalled(home)).rejects.toThrow(/plugins\[0\]/);
});

it('names the offending index so the user can find the bad record', async () => {
const home = await makeKimiHome();
await writeRaw(home, {
version: 1,
plugins: [VALID_RECORD, { ...VALID_RECORD, id: 'other', enabled: 'yes' }],
});
await expect(readInstalled(home)).rejects.toThrow(/plugins\[1\]/);
});

it('keeps unknown keys so a record from a newer version round-trips', async () => {
const home = await makeKimiHome();
await writeRaw(home, {
version: 1,
plugins: [{ ...VALID_RECORD, futureField: { nested: true } }],
});
const result = await readInstalled(home);
expect(result.plugins).toHaveLength(1);
expect((result.plugins[0] as { futureField?: unknown }).futureField).toEqual({
nested: true,
});
});
});