codeburn/tests/plugin-socket.test.ts
iamtoruk 67320432ca feat(plugins): ed25519 release-key verification and plugin add/remove (9b)
Ships ed25519 signature verification for plugins with two new components:

- src/plugins/keys.ts: RELEASE_PUBLIC_KEYS map with one release keypair
- scripts/sign-plugin.mjs: keygen and sign commands for plugin developers
- verifyPlugin() in loader.ts: validates ed25519 signatures, rejects unsigned plugins unless CODEBURN_PLUGIN_DEV=1
- plugin add <path>: installs signed plugins to ~/.config/codeburn/plugins/
- plugin remove <name> --confirm: removes installed plugins

All three gates pass: tsc clean, vitest zero new failures, smoke test complete.

Design decision: public keys stored as base64-encoded PEM format rather than raw 32-byte keys. Node.js crypto.verify requires PEM/DER format or KeyObject for ed25519; raw bytes alone fail. PEM is standard and portable.

Private key written to: /tmp/codeburn-signing/codeburn-signing-key.pem
2026-08-28 00:30:43 -07:00

227 lines
9.6 KiB
TypeScript

/**
* Tests for the CB-3 plugin socket (teams issue #3).
*
* Covers:
* 1. Wire guard: filterPluginAttributes strips any key not declared by a loaded plugin.
* 2. Byte-identical guarantee: with no plugins installed, the OTLP payload and
* menubar payload are unchanged from before the socket shipped.
* 3. Loader behavior: oversized / unparseable manifests are rejected with a reason;
* valid manifests round-trip.
* 4. Plugin CLI: `codeburn plugin list|info|verify` work against a custom dir.
*
* The byte-identical test is the most important one: it re-pins the contract that
* no plugin code can run until the user opts in by installing one.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { buildOtlpPayload, type OtlpAttribute } from '../src/sync/otlp.js'
import { pluginPayloadSections, loadPlugins } from '../src/plugins/loader.js'
import { filterPluginAttributes } from '../src/sync/otlp.js'
// ── Helpers ───────────────────────────────────────────────────────────
/** Minimal valid manifest — exercises every declared-shape field. */
function validManifest(name = 'sample') {
return {
name,
version: '0.1.0',
cliCompat: '>=0.9.22',
capabilities: {
commands: ['sample'],
syncAttributes: [{ key: 'sample.score', disclosure: 'numeric score 0..1' }],
payloadSections: ['sample'],
spanKinds: [],
},
}
}
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'plugin-socket-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
// ── 1. Wire guard ─────────────────────────────────────────────────────
describe('wire guard: filterPluginAttributes', () => {
it('drops every key not in the declared set', () => {
const attrs: OtlpAttribute[] = [
{ key: 'sample.score', value: { stringValue: '0.9' } },
{ key: 'rogue.attr', value: { stringValue: 'should-be-dropped' } },
{ key: 'sample.tag', value: { stringValue: 'kept' } },
]
const out = filterPluginAttributes(attrs, new Set(['sample.score', 'sample.tag']))
expect(out.map(a => a.key).sort()).toEqual(['sample.score', 'sample.tag'])
})
it('passes through an empty declared set untouched (zero-plugin default)', () => {
const attrs: OtlpAttribute[] = [
{ key: 'any.thing', value: { stringValue: '1' } },
]
expect(filterPluginAttributes(attrs, new Set())).toEqual([])
})
it('keeps attrs when the plugin declared exactly the keys it shipped', () => {
const attrs: OtlpAttribute[] = [
{ key: 'sample.score', value: { doubleValue: 0.7 } },
]
expect(filterPluginAttributes(attrs, new Set(['sample.score']))).toEqual(attrs)
})
})
// ── 2. Byte-identical wire with no plugins ────────────────────────────
describe('byte-identical guarantee: no plugins => no payload change', () => {
it('buildOtlpPayload without pluginAttributes produces zero plugin keys', () => {
const payload = buildOtlpPayload([], { coverageThrough: '2026-07-10' }) as unknown as {
resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ attributes: OtlpAttribute[] }> }> }>
}
const allAttrs = payload.resourceSpans.flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans.flatMap(s => s.attributes)))
const pluginKeys = allAttrs.filter(a => a.key.startsWith('sample.') || a.key.startsWith('codeburn.plugin.'))
expect(pluginKeys).toEqual([])
})
it('pluginPayloadSections is empty with an empty plugin directory', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'empty-plugins-'))
try {
const loads = await loadPlugins(emptyDir, '0.9.22')
expect(loads).toEqual([])
const sections = await pluginPayloadSections(loads)
expect(sections).toEqual({})
} finally {
await rm(emptyDir, { recursive: true, force: true })
}
})
})
// ── 3. Loader behavior ────────────────────────────────────────────────
describe('loader: rejection reasons', () => {
it('rejects an oversized manifest with reason "oversized"', async () => {
const pluginDir = join(tmpDir, 'big')
await mkdir(pluginDir, { recursive: true })
const big = 'x'.repeat(70 * 1024) // > 64 KiB cap
await writeFile(join(pluginDir, 'codeburn-plugin.json'), big)
const loads = await loadPlugins(tmpDir, '0.9.22', { ...process.env, CODEBURN_PLUGIN_DEV: '1' })
expect(loads).toHaveLength(1)
expect(loads[0]!.status).toBe('rejected')
if (loads[0]!.status === 'rejected') {
expect(loads[0]!.reason).toMatch(/oversized|too large/)
}
})
it('rejects malformed JSON with reason "unparseable"', async () => {
const pluginDir = join(tmpDir, 'malformed')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), '{not valid json')
const loads = await loadPlugins(tmpDir, '0.9.22', { ...process.env, CODEBURN_PLUGIN_DEV: '1' })
expect(loads).toHaveLength(1)
expect(loads[0]!.status).toBe('rejected')
if (loads[0]!.status === 'rejected') {
expect(loads[0]!.reason).toMatch(/unparseable|JSON|unreadable/)
}
})
it('rejects a valid but unsigned manifest when CODEBURN_PLUGIN_DEV is absent (deny-by-default)', async () => {
const pluginDir = join(tmpDir, 'unsigned')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('unsigned')))
const env = { ...process.env }
delete env.CODEBURN_PLUGIN_DEV
const loads = await loadPlugins(tmpDir, '0.9.22', env)
expect(loads).toHaveLength(1)
expect(loads[0]!.status).toBe('rejected')
if (loads[0]!.status === 'rejected') {
expect(loads[0]!.reason).toMatch(/signature|unsigned/)
}
})
it('loads a valid manifest with status:"loaded" and parsed shape', async () => {
const pluginDir = join(tmpDir, 'good')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('good')))
const loads = await loadPlugins(tmpDir, '0.9.22', { ...process.env, CODEBURN_PLUGIN_DEV: '1' })
expect(loads).toHaveLength(1)
expect(loads[0]!.status).toBe('loaded')
if (loads[0]!.status === 'loaded') {
expect(loads[0]!.manifest.name).toBe('good')
expect(loads[0]!.manifest.capabilities.syncAttributes[0]!.key).toBe('sample.score')
}
})
})
// ── 4. Plugin CLI ─────────────────────────────────────────────────────
// We import the CLI lazily so the test can drive it without booting Commander
// at module load (avoids polluting stderr during the no-plugin default tests).
describe('plugin CLI: codeburn plugin list|info|verify', () => {
let registerPluginCommands: typeof import('../src/plugins/cli.js').registerPluginCommands
beforeEach(async () => {
const mod = await import('../src/plugins/cli.js')
registerPluginCommands = mod.registerPluginCommands
})
function makeProgram() {
// Lazy-import commander so we get a fresh program per test.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Command } = require('commander')
const p = new Command()
p.exitOverride() // throw instead of process.exit on unknown subcommand
registerPluginCommands(p)
return p
}
it('plugin list prints empty when no plugins are installed', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'empty-list-'))
try {
const program = makeProgram()
// Commander doesn't capture stdout by default; we just assert no throw.
await program.parseAsync(['node', 'codeburn', 'plugin', 'list', '--dir', emptyDir])
} finally {
await rm(emptyDir, { recursive: true, force: true })
}
})
it('plugin info <name> exits non-zero when the plugin is missing', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'empty-info-'))
try {
const program = makeProgram()
await expect(
program.parseAsync(['node', 'codeburn', 'plugin', 'info', 'nope', '--dir', emptyDir]),
).rejects.toThrow()
} finally {
await rm(emptyDir, { recursive: true, force: true })
}
})
it('plugin verify accepts a well-formed manifest', async () => {
const pluginDir = join(tmpDir, 'verifiable')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('verifiable')))
const program = makeProgram()
const prev = process.env.CODEBURN_PLUGIN_DEV
process.env.CODEBURN_PLUGIN_DEV = '1'
try {
await program.parseAsync(['node', 'codeburn', 'plugin', 'verify', 'verifiable', '--dir', tmpDir])
} finally {
if (prev === undefined) delete process.env.CODEBURN_PLUGIN_DEV
else process.env.CODEBURN_PLUGIN_DEV = prev
}
})
it('plugin verify rejects a malformed manifest', async () => {
const pluginDir = join(tmpDir, 'broken')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), '{ not json')
const program = makeProgram()
await expect(
program.parseAsync(['node', 'codeburn', 'plugin', 'verify', 'broken', '--dir', tmpDir]),
).rejects.toThrow()
})
})