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
This commit is contained in:
iamtoruk 2026-08-28 00:30:43 -07:00
parent 5a72bb35c0
commit 67320432ca
7 changed files with 679 additions and 6 deletions

136
scripts/sign-plugin.mjs Normal file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* Plugin signing utility: keygen and sign.
*
* keygen --out <file> - generate a keypair, print keyId + public key, write private PEM
* sign <dir> - sign a plugin directory using CODEBURN_SIGNING_KEY env var
*/
import { createPrivateKey, createPublicKey, randomBytes } from 'crypto'
import { readdir, readFile, stat, writeFile } from 'fs/promises'
import { join } from 'path'
import process from 'process'
const command = process.argv[2]
if (command === 'keygen') {
await handleKeygen()
} else if (command === 'sign') {
await handleSign()
} else {
console.error('Usage: node scripts/sign-plugin.mjs keygen --out <file>')
console.error(' node scripts/sign-plugin.mjs sign <dir>')
process.exit(1)
}
async function handleKeygen() {
const outIdx = process.argv.indexOf('--out')
if (outIdx < 0 || outIdx + 1 >= process.argv.length) {
console.error('Usage: node scripts/sign-plugin.mjs keygen --out <file>')
process.exit(1)
}
const outFile = process.argv[outIdx + 1]
// Generate a new keypair
const { generateKeyPairSync } = await import('crypto')
const { privateKey: privKeyObj, publicKey: pubKeyObj } = generateKeyPairSync('ed25519')
// Export keys in appropriate formats
const privateKeyPem = privKeyObj.export({ format: 'pem', type: 'pkcs8' })
const publicKeyPem = pubKeyObj.export({ format: 'pem', type: 'spki' })
// Base64 encode the PEM public key for storage
const pubKeyBase64 = Buffer.from(publicKeyPem).toString('base64')
// Generate keyId as 8 hex chars from random bytes
const keyIdBytes = randomBytes(4)
const keyId = keyIdBytes.toString('hex').substring(0, 8)
// Write private key PEM to file
await writeFile(outFile, privateKeyPem, 'utf8')
// Print to stdout
console.log(`keyId: ${keyId}`)
console.log(`public: ${pubKeyBase64}`)
}
async function handleSign() {
const pluginDir = process.argv[3]
if (!pluginDir) {
console.error('Usage: node scripts/sign-plugin.mjs sign <dir>')
process.exit(1)
}
const sigKeyPath = process.env.CODEBURN_SIGNING_KEY
if (!sigKeyPath) {
console.error('CODEBURN_SIGNING_KEY not set')
process.exit(1)
}
// Read the plugin manifest
const manifestFile = join(pluginDir, 'codeburn-plugin.json')
const manifestRaw = JSON.parse(await readFile(manifestFile, 'utf8'))
const { name, version } = manifestRaw
if (!name || !version) {
console.error('Plugin manifest missing name or version')
process.exit(1)
}
// Get file list: all regular files except codeburn-plugin.sig
const files = await getFilesList(pluginDir)
// Build the canonical signing digest
const digest = computeDigest(name, version, files)
// Read private key and sign
const { sign } = await import('crypto')
const privKeyPem = await readFile(sigKeyPath, 'utf8')
const privKey = createPrivateKey(privKeyPem)
const signature = sign(null, Buffer.from(digest), privKey)
const signatureBase64 = signature.toString('base64')
// Extract keyId from the public key derived from the private key
const pubKey = createPublicKey(privKey)
const pubKeyPem = pubKey.export({ format: 'pem', type: 'spki' })
// Hash the PEM to get a consistent keyId
const { createHash } = await import('crypto')
const keyIdBytes = createHash('sha256').update(pubKeyPem).digest().slice(0, 4)
const keyId = keyIdBytes.toString('hex')
// Write signature file
const sigFile = join(pluginDir, 'codeburn-plugin.sig')
const sigData = {
alg: 'ed25519',
keyId,
signature: signatureBase64,
}
await writeFile(sigFile, JSON.stringify(sigData), 'utf8')
console.log(`Signed ${pluginDir}`)
}
async function getFilesList(dir) {
const files = []
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && entry.name !== 'codeburn-plugin.sig') {
const fullPath = join(dir, entry.name)
const stat_info = await stat(fullPath)
if (!stat_info.isFile()) continue
const content = await readFile(fullPath)
const sha256 = await hashSha256(content)
files.push({ path: entry.name, sha256 })
}
}
files.sort((a, b) => a.path.localeCompare(b.path))
return files
}
function computeDigest(name, version, files) {
const canonical = JSON.stringify({ name, version, files })
return canonical
}
async function hashSha256(data) {
const crypto = await import('crypto')
return crypto.createHash('sha256').update(data).digest('hex')
}

View file

@ -104,6 +104,47 @@ async function main() {
await rm(pluginDir, { recursive: true, force: true })
}
// 7) add/remove flow with CODEBURN_PLUGIN_DEV=1
const addRemoveDir = await mkdtemp(join(tmpdir(), 'smoke-add-remove-'))
try {
const sourceDir = join(addRemoveDir, 'source')
const installDir = join(addRemoveDir, 'installed')
await mkdir(sourceDir, { recursive: true })
await mkdir(installDir, { recursive: true })
await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify({
name: 'dev-plugin', version: '0.1.0', cliCompat: '>=0.9.22',
capabilities: { commands: [], syncAttributes: [], payloadSections: [], spanKinds: [] },
}))
await writeFile(join(sourceDir, 'test.txt'), 'test content')
// Add plugin with dev flag (unsigned)
const addResult = await run(['plugin', 'add', sourceDir, '--dir', installDir], { env: { CODEBURN_PLUGIN_DEV: '1' } })
assertEq(addResult.code, 0, '`plugin add` succeeds with CODEBURN_PLUGIN_DEV=1')
assertContains(addResult.stdout, 'dev-plugin@0.1.0', '`plugin add` confirms installation')
// List should show the added plugin
const listAfterAdd = await run(['plugin', 'list', '--dir', installDir], { env: { CODEBURN_PLUGIN_DEV: '1' } })
assertEq(listAfterAdd.code, 0, '`plugin list` after add exits 0')
assertContains(listAfterAdd.stdout, 'loaded dev-plugin@0.1.0', '`plugin list` shows added plugin')
// Remove without --confirm should fail
const removeNoConfirm = await run(['plugin', 'remove', 'dev-plugin', '--dir', installDir])
assertEq(removeNoConfirm.code, 1, '`plugin remove` without --confirm exits 1')
assertContains(removeNoConfirm.stdout, 'Would remove', '`plugin remove` prints confirmation prompt')
// Remove with --confirm should succeed
const removeConfirm = await run(['plugin', 'remove', 'dev-plugin', '--confirm', '--dir', installDir])
assertEq(removeConfirm.code, 0, '`plugin remove --confirm` succeeds')
assertContains(removeConfirm.stdout, 'removed', '`plugin remove` confirms removal')
// List should be empty after removal
const listAfterRemove = await run(['plugin', 'list', '--dir', installDir])
assertEq(listAfterRemove.code, 0, '`plugin list` after remove exits 0')
assertContains(listAfterRemove.stdout, 'No plugins', '`plugin list` shows empty')
} finally {
await rm(addRemoveDir, { recursive: true, force: true })
}
if (process.exitCode === 1) {
console.error('\nSMOKE FAILED')
} else {

View file

@ -11,8 +11,9 @@
*/
import type { Command } from 'commander'
import { stat } from 'fs/promises'
import { stat, mkdir, readFile, writeFile, rm, readdir } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { defaultPluginsDir, loadPlugins, currentCliVersion, verifyPlugin, readPluginManifestRaw } from './loader.js'
import { parsePluginManifest, type PluginManifest } from './manifest.js'
@ -88,6 +89,60 @@ export function registerPluginCommands(program: Command): void {
throw new Error(`unverified ${name}@${manifest.version} ${result.reason ?? 'verification failed'}`)
}
})
plugin
.command('add <path>')
.description('Install a plugin from a source directory')
.option('--dir <path>', 'Override the plugins directory')
.action(async (sourcePath: string, opts: { dir?: string }) => {
const pluginsDir = opts.dir ?? defaultPluginsDir()
const { raw, reason } = await readPluginManifestRaw(sourcePath)
if (reason) {
throw new Error(`Could not read manifest from ${sourcePath}: ${reason}`)
}
const parsed = parsePluginManifest(raw, `${sourcePath}/codeburn-plugin.json`)
if (!parsed.ok) {
throw new Error(`Invalid manifest: ${parsed.reason}`)
}
const manifest = parsed.manifest
const verified = await verifyPlugin(sourcePath, manifest, process.env)
if (!verified.ok) {
throw new Error(`Plugin verification failed: ${verified.reason ?? 'unknown reason'}`)
}
const destDir = join(pluginsDir, manifest.name)
try {
await stat(destDir)
throw new Error(`Plugin "${manifest.name}" already installed at ${destDir}`)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
await mkdir(destDir, { recursive: true })
const entries = await readdir(sourcePath, { withFileTypes: true })
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isFile()) continue
const srcFile = join(sourcePath, entry.name)
const destFile = join(destDir, entry.name)
const content = await readFile(srcFile)
await writeFile(destFile, content)
}
process.stdout.write(`Plugin "${manifest.name}@${manifest.version}" installed to ${destDir}\n`)
})
plugin
.command('remove <name>')
.description('Remove an installed plugin')
.option('--dir <path>', 'Override the plugins directory')
.option('--confirm', 'Confirm removal')
.action(async (name: string, opts: { dir?: string, confirm?: boolean }) => {
const pluginsDir = opts.dir ?? defaultPluginsDir()
const destDir = join(pluginsDir, name)
if (!opts.confirm) {
process.stdout.write(`Would remove plugin directory: ${destDir}\nUse --confirm to proceed.\n`)
process.exit(1)
}
await rm(destDir, { recursive: true, force: true })
process.stdout.write(`Plugin "${name}" removed.\n`)
})
}
/// Reads the manifest at <dir>/codeburn-plugin.json and parses it via the

6
src/plugins/keys.ts Normal file
View file

@ -0,0 +1,6 @@
/// Release public keys for ed25519 plugin signature verification.
/// Maps keyId (8 hex chars) to base64-encoded PEM public key.
export const RELEASE_PUBLIC_KEYS: ReadonlyMap<string, string> = new Map([
['f40248d0', 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTU1QTAvMUpLTlBoMGFsL2xMN2xhWFBobWVnZVhzUENqK3RoM1B4LzNWM0U9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo='],
])

View file

@ -15,8 +15,10 @@ import { readdir, readFile, stat } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { createRequire } from 'module'
import { verify as cryptoVerify } from 'crypto'
import { checkCliCompat, parsePluginManifest, type PluginManifest } from './manifest.js'
import { RELEASE_PUBLIC_KEYS } from './keys.js'
/// Resolves from BOTH layouts: src/plugins/loader.ts during tests/tsx and
/// the flattened dist/main.ts bundle at runtime (tsup keeps one file).
@ -48,11 +50,109 @@ export function defaultPluginsDir(): string {
/// Unsigned plugins are refused unless CODEBURN_PLUGIN_DEV=1, and even then
/// only for local development (this flag is expected to be absent in every
/// real environment).
export async function verifyPlugin(dir: string, manifest: PluginManifest, env: NodeJS.ProcessEnv): Promise<{ ok: boolean, reason?: string }> {
export async function verifyPlugin(
dir: string,
manifest: PluginManifest,
env: NodeJS.ProcessEnv,
knownKeys?: ReadonlyMap<string, string>,
): Promise<{ ok: boolean, reason?: string }> {
if (env.CODEBURN_PLUGIN_DEV === '1') return { ok: true }
void dir
void manifest
return { ok: false, reason: 'unsigned plugin (signature verification arrives in 9b; CODEBURN_PLUGIN_DEV=1 allows unsigned plugins for local development only)' }
const keys = knownKeys ?? RELEASE_PUBLIC_KEYS
// Read the signature file
const sigFile = join(dir, 'codeburn-plugin.sig')
let sigData: { alg?: string, keyId?: string, signature?: string }
try {
const content = await readFile(sigFile, 'utf8')
sigData = JSON.parse(content)
} catch {
return { ok: false, reason: 'missing signature file' }
}
if (sigData.alg !== 'ed25519') {
return { ok: false, reason: 'unknown signature algorithm' }
}
const keyId = sigData.keyId
if (!keyId || typeof keyId !== 'string') {
return { ok: false, reason: 'missing key id' }
}
if (!keys.has(keyId)) {
return { ok: false, reason: 'unknown key id' }
}
// Check for symlinks
const hasSymlink = await checkForSymlinks(dir)
if (hasSymlink) {
return { ok: false, reason: 'symlink present' }
}
// Get file list
const files = await getPluginFilesList(dir)
// Compute canonical digest
const canonical = JSON.stringify({
name: manifest.name,
version: manifest.version,
files,
})
// Get public key and verify signature
const pubKeyBase64 = keys.get(keyId)!
const pubKeyPem = Buffer.from(pubKeyBase64, 'base64').toString('utf8')
const signatureBuffer = Buffer.from(sigData.signature ?? '', 'base64')
try {
const isValid = cryptoVerify(null, Buffer.from(canonical), pubKeyPem, signatureBuffer)
if (!isValid) {
return { ok: false, reason: 'bad signature' }
}
} catch {
return { ok: false, reason: 'bad signature' }
}
return { ok: true }
}
async function checkForSymlinks(dir: string): Promise<boolean> {
try {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isSymbolicLink()) return true
}
} catch {
return true
}
return false
}
async function getPluginFilesList(
dir: string,
): Promise<Array<{ path: string, sha256: string }>> {
const { createHash } = await import('crypto')
const files: Array<{ path: string, sha256: string }> = []
try {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === 'codeburn-plugin.sig') continue
if (!entry.isFile()) continue
const fullPath = join(dir, entry.name)
try {
const content = await readFile(fullPath)
const hash = createHash('sha256').update(content).digest('hex')
files.push({ path: entry.name, sha256: hash })
} catch {
continue
}
}
} catch {
return []
}
files.sort((a, b) => a.path.localeCompare(b.path))
return files
}
async function readManifest(dir: string): Promise<{ raw?: unknown, reason?: string }> {

View file

@ -0,0 +1,335 @@
/**
* Tests for ed25519 plugin signature verification and add/remove commands (9b).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { generateKeyPairSync, createPublicKey, sign } from 'crypto'
import { verifyPlugin } from '../src/plugins/loader.js'
import { parsePluginManifest } from '../src/plugins/manifest.js'
function validManifest(name = 'sample') {
return {
name,
version: '0.1.0',
cliCompat: '>=0.9.22',
capabilities: {
commands: [],
syncAttributes: [],
payloadSections: [],
spanKinds: [],
},
}
}
async function generateTestKeyPair() {
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
publicKeyEncoding: { format: 'pem', type: 'spki' },
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
})
// Base64-encode the PEM public key
return {
publicKeyBase64: Buffer.from(publicKey as string).toString('base64'),
privateKeyPem: privateKey as string,
}
}
async function computeDigest(name: string, version: string, files: Array<{ path: string, sha256: string }>) {
return JSON.stringify({ name, version, files })
}
async function signPlugin(pluginDir: string, keyPem: string, keyId: string) {
const { createPrivateKey, createHash } = await import('crypto')
const { readdir } = await import('fs/promises')
const manifest = JSON.parse(await readFile(join(pluginDir, 'codeburn-plugin.json'), 'utf8'))
const { name, version } = manifest
const entries = await readdir(pluginDir, { withFileTypes: true })
const files: Array<{ path: string, sha256: string }> = []
for (const entry of entries) {
if (!entry.isFile() || entry.name === 'codeburn-plugin.sig') continue
const content = await readFile(join(pluginDir, entry.name))
const hash = createHash('sha256').update(content).digest('hex')
files.push({ path: entry.name, sha256: hash })
}
files.sort((a, b) => a.path.localeCompare(b.path))
const canonical = JSON.stringify({ name, version, files })
const privKey = createPrivateKey(keyPem)
const signature = sign(null, Buffer.from(canonical), privKey)
const sigData = {
alg: 'ed25519',
keyId,
signature: signature.toString('base64'),
}
await writeFile(join(pluginDir, 'codeburn-plugin.sig'), JSON.stringify(sigData), 'utf8')
}
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'plugin-signing-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
describe('plugin signature verification (9b)', () => {
it('keygen -> sign -> verify ok round trip with ephemeral key', async () => {
const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair()
const keyId = '12345678'
const knownKeys = new Map([[keyId, publicKeyBase64]])
const pluginDir = join(tmpDir, 'good')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('good')))
await writeFile(join(pluginDir, 'file1.txt'), 'content1')
await signPlugin(pluginDir, privateKeyPem, keyId)
const manifest = validManifest('good')
const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys)
expect(result.ok).toBe(true)
})
it('tampering a file after signing results in digest mismatch', async () => {
const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair()
const keyId = '12345678'
const knownKeys = new Map([[keyId, publicKeyBase64]])
const pluginDir = join(tmpDir, 'tampered')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('tampered')))
await writeFile(join(pluginDir, 'file1.txt'), 'content1')
await signPlugin(pluginDir, privateKeyPem, keyId)
// Tamper with a file
await writeFile(join(pluginDir, 'file1.txt'), 'modified content')
const manifest = validManifest('tampered')
const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys)
expect(result.ok).toBe(false)
expect(result.reason).toMatch(/signature|digest/)
})
it('unknown keyId is rejected', async () => {
const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair()
const keyId = '12345678'
const knownKeys = new Map([['ffffffff', publicKeyBase64]])
const pluginDir = join(tmpDir, 'unknown')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('unknown')))
await writeFile(join(pluginDir, 'file1.txt'), 'content1')
await signPlugin(pluginDir, privateKeyPem, keyId)
const manifest = validManifest('unknown')
const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys)
expect(result.ok).toBe(false)
expect(result.reason).toMatch(/unknown key|key id/)
})
it('missing signature file without dev flag is rejected', 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 manifest = validManifest('unsigned')
const result = await verifyPlugin(pluginDir, manifest, env)
expect(result.ok).toBe(false)
expect(result.reason).toMatch(/signature|unsigned/)
})
it('missing signature file with CODEBURN_PLUGIN_DEV=1 is accepted', async () => {
const pluginDir = join(tmpDir, 'dev-unsigned')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('dev-unsigned')))
const env = { ...process.env, CODEBURN_PLUGIN_DEV: '1' }
const manifest = validManifest('dev-unsigned')
const result = await verifyPlugin(pluginDir, manifest, env)
expect(result.ok).toBe(true)
})
it('symlink in directory causes verification failure', async () => {
const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair()
const keyId = '12345678'
const knownKeys = new Map([[keyId, publicKeyBase64]])
const pluginDir = join(tmpDir, 'symlink')
await mkdir(pluginDir, { recursive: true })
await writeFile(join(pluginDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('symlink')))
await writeFile(join(pluginDir, 'file1.txt'), 'content1')
await signPlugin(pluginDir, privateKeyPem, keyId)
// Add a symlink (this will cause verification to fail)
const targetFile = join(tmpDir, 'target.txt')
await writeFile(targetFile, 'target content')
const { symlink } = await import('fs/promises')
await symlink(targetFile, join(pluginDir, 'link.txt'))
const manifest = validManifest('symlink')
const result = await verifyPlugin(pluginDir, manifest, process.env, knownKeys)
expect(result.ok).toBe(false)
expect(result.reason).toMatch(/symlink/)
})
})
describe('plugin add/remove commands (9b)', () => {
it('plugin add refuses unsigned source without dev flag', async () => {
const { Command } = (await import('commander')).default ?? (await import('commander'))
const { registerPluginCommands } = await import('../src/plugins/cli.js')
const sourceDir = join(tmpDir, 'source')
const pluginDir = await mkdtemp(join(tmpdir(), 'plugins-'))
await mkdir(sourceDir, { recursive: true })
await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('unsigned-plugin')))
await writeFile(join(sourceDir, 'file1.txt'), 'content1')
const program = new Command()
program.exitOverride()
registerPluginCommands(program)
const env = { ...process.env }
delete env.CODEBURN_PLUGIN_DEV
try {
await expect(
program.parseAsync(['node', 'codeburn', 'plugin', 'add', sourceDir, '--dir', pluginDir]),
).rejects.toThrow()
} finally {
await rm(pluginDir, { recursive: true, force: true })
}
})
it('plugin add succeeds on signed source', async () => {
const { Command } = (await import('commander')).default ?? (await import('commander'))
const { registerPluginCommands } = await import('../src/plugins/cli.js')
const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair()
const keyId = '12345678'
const sourceDir = join(tmpDir, 'signed-source')
const pluginDir = await mkdtemp(join(tmpdir(), 'plugins-'))
await mkdir(sourceDir, { recursive: true })
await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('signed-plugin')))
await writeFile(join(sourceDir, 'file1.txt'), 'content1')
await signPlugin(sourceDir, privateKeyPem, keyId)
// Temporarily override RELEASE_PUBLIC_KEYS for this test
const { RELEASE_PUBLIC_KEYS } = await import('../src/plugins/keys.js')
const originalKeys = new Map(RELEASE_PUBLIC_KEYS)
;(RELEASE_PUBLIC_KEYS as any).set(keyId, publicKeyBase64)
const program = new Command()
program.exitOverride()
registerPluginCommands(program)
try {
await program.parseAsync(['node', 'codeburn', 'plugin', 'add', sourceDir, '--dir', pluginDir])
const destDir = join(pluginDir, 'signed-plugin')
const installedManifest = JSON.parse(await readFile(join(destDir, 'codeburn-plugin.json'), 'utf8'))
expect(installedManifest.name).toBe('signed-plugin')
} finally {
// Restore original keys
RELEASE_PUBLIC_KEYS.clear()
for (const [k, v] of originalKeys) {
;(RELEASE_PUBLIC_KEYS as any).set(k, v)
}
await rm(pluginDir, { recursive: true, force: true })
}
})
it('plugin add refuses existing destination', async () => {
const { Command } = (await import('commander')).default ?? (await import('commander'))
const { registerPluginCommands } = await import('../src/plugins/cli.js')
const { publicKeyBase64, privateKeyPem } = await generateTestKeyPair()
const keyId = '12345678'
const sourceDir = join(tmpDir, 'source2')
const pluginDir = await mkdtemp(join(tmpdir(), 'plugins2-'))
const destDir = join(pluginDir, 'existing-plugin')
await mkdir(sourceDir, { recursive: true })
await mkdir(destDir, { recursive: true })
await writeFile(join(sourceDir, 'codeburn-plugin.json'), JSON.stringify(validManifest('existing-plugin')))
await writeFile(join(sourceDir, 'file1.txt'), 'content1')
await signPlugin(sourceDir, privateKeyPem, keyId)
const { RELEASE_PUBLIC_KEYS } = await import('../src/plugins/keys.js')
const originalKeys = new Map(RELEASE_PUBLIC_KEYS)
;(RELEASE_PUBLIC_KEYS as any).set(keyId, publicKeyBase64)
const program = new Command()
program.exitOverride()
registerPluginCommands(program)
try {
await expect(
program.parseAsync(['node', 'codeburn', 'plugin', 'add', sourceDir, '--dir', pluginDir]),
).rejects.toThrow()
} finally {
RELEASE_PUBLIC_KEYS.clear()
for (const [k, v] of originalKeys) {
;(RELEASE_PUBLIC_KEYS as any).set(k, v)
}
await rm(pluginDir, { recursive: true, force: true })
}
})
it('plugin remove refuses without --confirm', async () => {
const { Command } = (await import('commander')).default ?? (await import('commander'))
const { registerPluginCommands } = await import('../src/plugins/cli.js')
const pluginDir = await mkdtemp(join(tmpdir(), 'plugins3-'))
const destDir = join(pluginDir, 'test-plugin')
await mkdir(destDir, { recursive: true })
const program = new Command()
program.exitOverride()
registerPluginCommands(program)
try {
await expect(
program.parseAsync(['node', 'codeburn', 'plugin', 'remove', 'test-plugin', '--dir', pluginDir]),
).rejects.toThrow()
} finally {
await rm(pluginDir, { recursive: true, force: true })
}
})
it('plugin remove deletes with --confirm', async () => {
const { Command } = (await import('commander')).default ?? (await import('commander'))
const { registerPluginCommands } = await import('../src/plugins/cli.js')
const pluginDir = await mkdtemp(join(tmpdir(), 'plugins4-'))
const destDir = join(pluginDir, 'test-plugin')
await mkdir(destDir, { recursive: true })
await writeFile(join(destDir, 'test.txt'), 'test')
const program = new Command()
program.exitOverride()
registerPluginCommands(program)
try {
await program.parseAsync(['node', 'codeburn', 'plugin', 'remove', 'test-plugin', '--dir', pluginDir, '--confirm'])
const { stat } = await import('fs/promises')
await expect(stat(destDir)).rejects.toThrow()
} finally {
await rm(pluginDir, { recursive: true, force: true })
}
})
})

View file

@ -138,7 +138,7 @@ describe('loader: rejection reasons', () => {
expect(loads).toHaveLength(1)
expect(loads[0]!.status).toBe('rejected')
if (loads[0]!.status === 'rejected') {
expect(loads[0]!.reason).toMatch(/unsigned/)
expect(loads[0]!.reason).toMatch(/signature|unsigned/)
}
})