fix(plugin): validate installed.json records instead of casting them - #2307
fix(plugin): validate installed.json records instead of casting them#2307LHMQ878 wants to merge 3 commits into
Conversation
`readInstalled` checked only that the file parsed and that `plugins` was an array, then cast the result to `InstalledFile`. Every non-optional field in `InstalledRecord` was therefore unverified against the on-disk data, even though `installed.json` is hand-editable and is written by older versions. Consumers dereference those fields unconditionally, so a bad record surfaced as a crash far from its cause: `PluginManager.load` reads `entry.id` on every entry (a `null` entry threw "Cannot read properties of null (reading 'id')"), and the plugins status panel renders `github.ref.value`, which threw for a github record persisted without a `ref`. Validate each record with zod and fail the read with the offending index, so the error names the file and the entry. This keeps the existing "fail loudly" contract: both PluginManagers already capture a load failure and rethrow it from `/plugins` and every mutator with a message pointing at `installed.json`, rather than persisting a partially-loaded state. Unknown keys are preserved so a record written by a newer version still round-trips. Applied to both the agent-core and agent-core-v2 copies of the store.
🦋 Changeset detectedLatest commit: f6127ae The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 79f9b1b4f6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| 'github', | ||
| ]); | ||
|
|
||
| const PluginGithubMetadataSchema: z.ZodType<PluginGithubMetadata> = z.object({ |
There was a problem hiding this comment.
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 👍 / 👎.
| mcpServers: z.record(z.string(), z.object({ enabled: z.boolean() })).optional(), | ||
| }); | ||
|
|
||
| /* |
There was a problem hiding this comment.
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 👍 / 👎.
| // `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. |
There was a problem hiding this comment.
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 👍 / 👎.
… schema Only the top-level record schema was loose. `github`, `github.ref` and the per-server `capabilities.mcpServers` entries used `z.object`, which strips unknown keys, so `readInstalled` silently dropped fields a newer client had written -- and since the store is read-modify-write, the next `writeInstalled` persisted the loss. Also addresses the agent-core-v2 comment convention: the schema rationale moves into the module header and the test rationale into the test name.
|
Thanks — all three addressed in P2, preserve future GitHub metadata — this was a real bug, and worse than describedYou're right, and the reach is wider than The reason this matters more than a lossy read is that the store is read-modify-write — Reproduced against the schemas as they stood, with a record as a newer client would have written it: Note Fixed in both copies by making every level loose, and the test now covers the nesting and the rewrite: it('keeps unknown keys nested inside github and capabilities, and survives a rewrite', async () => {
await writeRaw(home, { version: 1, plugins: [fromNewerVersion] });
const read = await readInstalled(home);
expect(read.plugins[0]).toEqual(fromNewerVersion);
// The load-edit-save path an older client actually takes.
await writeInstalled(home, read);
const reread = await readInstalled(home);
expect(reread.plugins[0]).toEqual(fromNewerVersion);
});Control experiment — I reverted only the nested schemas back to The 17 pre-existing tests pass either way, which confirms nothing else in the suite covered this. P1, v2 inline rationale commentMoved into the top-of-file The v1 copy keeps its inline block comment — P1, v2 inline test commentFolded into the test name: - 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.
+ it('rejects a bad installed.json record at the store, naming its index, rather than dereferencing it in load()', async () => {VerificationThe 3
AI disclosure: I used an AI coding assistant while investigating and preparing this change. I reviewed the diff line by line, ran every command above myself, and can explain why the nested levels needed the same treatment as the top level. |
Problem
readInstalledvalidated the file envelope but not its records:The
as InstalledFilecast makes every non-optional field inInstalledRecordan unchecked assumption about on-disk data — andinstalled.jsonis hand-editable, survives downgrades, and is written by older versions.Consumers dereference those fields unconditionally, so a malformed record surfaces as a crash far from the file that caused it:
PluginManager.loaddoesnext.set(entry.id, await this.materialize(entry))for every entry (packages/agent-core/src/plugin/manager.ts:44-51, and the v2 counterpart atpackages/agent-core-v2/src/app/plugin/manager.ts:62-69). Anullentry throwsCannot read properties of null (reading 'id').materializethen passes an unvalidatedentry.rootstraight intoparseManifest.`${info.github.ref.kind}:${info.github.ref.value}`behind only aninfo.github !== undefinedguard, so a github record persisted without arefthrowsCannot read properties of undefined (reading 'value').persist()also writes such records back out unchanged, so a bad edit is durable.Fix
Validate each record with zod (already a dependency of both packages) and fail the read naming the offending index:
Rejecting rather than dropping is deliberate, and matches the contract already in the code:
throws on a corrupt installed.json instead of silently dropping it.core-impl.ts:277-282captures it, andassertPluginsLoaded(core-impl.ts:1162) rethrows from/pluginsand every mutator with "Fix the file at …/plugins/installed.json and run /plugins reload", whilecreateSessiondegrades silently so the harness still starts.PluginServiceImpl.loadOncedoes the same in v2.persist(), meaning a rejected load cannot overwrite the file with a half-loaded map.Silently dropping records would instead make a user's installed plugins vanish with no explanation.
Unknown keys are preserved (
z.looseObject) so a record written by a newer version round-trips through an older one instead of losing fields on the next write — covered by a test.Applied to both copies of the store (
packages/agent-coreandpackages/agent-core-v2), which are otherwise identical apart from line width.Tests
packages/agent-core/test/plugin/store.test.ts(added to the existing file, no new test file): a table of 9 rejected shapes —null, a bare string, missingid, non-stringroot, unknownsource,enabled: 'yes',installedAt: 5, a github record with noref, a github record withref.kind: 'commit'— plus one asserting the index is reported for the second record, plus the unknown-key round-trip.packages/agent-core-v2/test/app/plugin/manager.test.ts: one consumer-side test thatload()rejects with the index instead of crashing onentry.id.Verification
Control experiment, reverting only the implementation and re-running:
packages/agent-core/src/plugin/store.tsreverted → 10 of the new tests fail, each asAssertionError: promise resolved "{ version: 1, plugins: [ null ] }" instead of rejecting— i.e. the bad record really was handed back typed as anInstalledRecord.packages/agent-core-v2/src/app/plugin/store.tsreverted → the manager test fails withexpected [Function] to throw error matching /plugins\[0\]/ but got 'Cannot read properties of null (reading 'id')', reproducing the crash-far-from-cause this change removes.Suites, against a clean-
mainbaseline (I'm on Windows, so some pre-existing failures are environmental):mainagent-coretest/pluginagent-core-v2test/app/plugin+test/agent/pluginThe 3
agent-corefailures are identical onmain(symlink handling and frontmatter parsing); the v2 file that fails to load isbashParserServiceneeding a built@moonshot-ai/tree-sitter-bash. No new failures.agent-coretsc --noEmit: clean.agent-core-v2tsc --noEmit: only the same two pre-existingCannot find module '@moonshot-ai/tree-sitter-bash'errors (unbuilt workspace package).oxlinton the four changed files: 0 errors; 1 warning on a pre-existing line (manager.test.ts:135, untouched).Note
I found this while trying to reproduce #2553 (kimi-cli). I could not confirm it is that crash — the
github.refpath is only reached from the plugin details view, not when/pluginsopens — so this is filed on its own merits, not as a fix for that issue.