mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-30 02:43:34 +00:00
Merge pull request #1151 from getagentseal/feat/plugin-socket
feat(plugins): CB-3 plugin socket - wire guard, CLI inspector, payload seam
This commit is contained in:
commit
aca73fb8d0
11 changed files with 790 additions and 2 deletions
114
scripts/smoke-plugin-socket.mjs
Normal file
114
scripts/smoke-plugin-socket.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Smoke test for the plugin socket step 9.
|
||||
*
|
||||
* Exercises:
|
||||
* - `codeburn plugin list` against an empty dir (default state)
|
||||
* - `codeburn plugin list` against a dir with one valid + one rejected plugin
|
||||
* - `codeburn plugin info <name>` for the loaded plugin
|
||||
* - `codeburn plugin verify <name>` (uses CODEBURN_PLUGIN_DEV=1 since signing lands in 9b)
|
||||
* - `codeburn plugin verify <name>` for the rejected plugin
|
||||
* - `codeburn --help` to confirm the subcommand shows up
|
||||
*
|
||||
* Exits 0 on full success, 1 on any failure.
|
||||
*/
|
||||
import { spawn } from 'child_process'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const CLI = join(import.meta.dirname, '..', 'dist', 'main.js')
|
||||
|
||||
async function run(args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [CLI, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...opts.env },
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', d => { stdout += d.toString() })
|
||||
child.stderr.on('data', d => { stderr += d.toString() })
|
||||
child.on('close', code => resolve({ code, stdout, stderr }))
|
||||
child.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function assertEq(actual, expected, label) {
|
||||
if (actual !== expected) {
|
||||
console.error(`FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(`PASS: ${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertContains(actual, needle, label) {
|
||||
if (!actual.includes(needle)) {
|
||||
console.error(`FAIL: ${label}\n needle: ${JSON.stringify(needle)}\n actual: ${JSON.stringify(actual)}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(`PASS: ${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1) help
|
||||
const help = await run(['--help'])
|
||||
assertEq(help.code, 0, '`codeburn --help` exits 0')
|
||||
assertContains(help.stdout, 'plugin', '`codeburn --help` mentions the plugin subcommand')
|
||||
|
||||
// 2) plugin list — empty
|
||||
const emptyDir = await mkdtemp(join(tmpdir(), 'smoke-empty-'))
|
||||
try {
|
||||
const r = await run(['plugin', 'list', '--dir', emptyDir])
|
||||
assertEq(r.code, 0, '`plugin list` against empty dir exits 0')
|
||||
assertContains(r.stdout, 'No plugins found', '`plugin list` reports empty')
|
||||
} finally {
|
||||
await rm(emptyDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// 3) plugin list — mixed
|
||||
const pluginDir = await mkdtemp(join(tmpdir(), 'smoke-plugins-'))
|
||||
try {
|
||||
const goodDir = join(pluginDir, 'good-plugin')
|
||||
const badDir = join(pluginDir, 'bad-plugin')
|
||||
await mkdir(goodDir, { recursive: true })
|
||||
await mkdir(badDir, { recursive: true })
|
||||
await writeFile(join(goodDir, 'codeburn-plugin.json'), JSON.stringify({
|
||||
name: 'good-plugin', version: '0.1.0', cliCompat: '>=0.9.22',
|
||||
capabilities: { commands: ['good'], syncAttributes: [], payloadSections: [], spanKinds: [] },
|
||||
}))
|
||||
await writeFile(join(badDir, 'codeburn-plugin.json'), '{not json')
|
||||
|
||||
const r = await run(['plugin', 'list', '--dir', pluginDir], { env: { CODEBURN_PLUGIN_DEV: '1' } })
|
||||
assertEq(r.code, 0, '`plugin list` with one valid + one rejected exits 0')
|
||||
assertContains(r.stdout, 'loaded good-plugin@0.1.0', '`plugin list` shows the loaded plugin')
|
||||
assertContains(r.stdout, 'rejected bad-plugin', '`plugin list` shows the rejected plugin')
|
||||
|
||||
// 4) info <name>
|
||||
const info = await run(['plugin', 'info', 'good-plugin', '--dir', pluginDir], { env: { CODEBURN_PLUGIN_DEV: '1' } })
|
||||
assertEq(info.code, 0, '`plugin info good-plugin` exits 0')
|
||||
assertContains(info.stdout, '"name": "good-plugin"', '`plugin info` prints the manifest')
|
||||
|
||||
// 5) verify <name> — good (with dev flag)
|
||||
const verify = await run(['plugin', 'verify', 'good-plugin', '--dir', pluginDir], { env: { CODEBURN_PLUGIN_DEV: '1' } })
|
||||
assertEq(verify.code, 0, '`plugin verify good-plugin` exits 0 under CODEBURN_PLUGIN_DEV=1')
|
||||
assertContains(verify.stdout, 'verified good-plugin@0.1.0', '`plugin verify` prints the verified line')
|
||||
|
||||
// 6) verify <name> — bad (malformed manifest)
|
||||
const verifyBad = await run(['plugin', 'verify', 'bad-plugin', '--dir', pluginDir])
|
||||
assertEq(verifyBad.code, 1, '`plugin verify bad-plugin` exits 1 for malformed manifest')
|
||||
assertContains(verifyBad.stdout + verifyBad.stderr, 'missing or unreadable', '`plugin verify` reports the read failure')
|
||||
} finally {
|
||||
await rm(pluginDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (process.exitCode === 1) {
|
||||
console.error('\nSMOKE FAILED')
|
||||
} else {
|
||||
console.log('\nSMOKE PASSED')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
|
|
@ -36,6 +36,7 @@ import { runOptimize } from './optimize.js'
|
|||
import { registerActCommands } from './act/cli.js'
|
||||
import { registerGuardCommands } from './guard/cli.js'
|
||||
import { registerSyncCommands } from './sync/cli.js'
|
||||
import { registerPluginCommands } from './plugins/cli.js'
|
||||
import { runContextCommand } from './context-tree.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import { computeBudgetStatus, daysInMonth, diffCalendarDays, type BudgetStatus, type BudgetTier } from './budget.js'
|
||||
|
|
@ -2603,6 +2604,7 @@ program
|
|||
registerActCommands(program)
|
||||
registerGuardCommands(program)
|
||||
registerSyncCommands(program)
|
||||
registerPluginCommands(program)
|
||||
|
||||
program
|
||||
.command('serve')
|
||||
|
|
|
|||
|
|
@ -203,6 +203,11 @@ export type MenubarPayload = {
|
|||
/// a converged one. Distinct from `stale`: a first paint is fresh but
|
||||
/// partial, a stale payload is complete but old.
|
||||
hydration?: HydrationState
|
||||
/// Add-only plugin socket sections (teams issue #3), keyed
|
||||
/// `<plugin>.<section>`. Present only when a loaded plugin declared the
|
||||
/// section AND its command wrote it. Surfaces render what they recognize
|
||||
/// and ignore the rest; absence always means "no plugin output today".
|
||||
plugins?: Record<string, unknown>
|
||||
current: {
|
||||
label: string
|
||||
cost: number
|
||||
|
|
|
|||
122
src/plugins/cli.ts
Normal file
122
src/plugins/cli.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* codeburn plugin — CLI commands for the plugin socket (teams issue #3).
|
||||
*
|
||||
* Registers: plugin list | info <name> | verify <name>
|
||||
*
|
||||
* The socket is the user-facing escape hatch: when a plugin is silently
|
||||
* rejected (bad manifest, name/dir mismatch, CLI version out of range,
|
||||
* unsigned without CODEBURN_PLUGIN_DEV=1), `codeburn plugin list` prints
|
||||
* the reason. There is no on-the-wire behavior here — this is a read-only
|
||||
* inspector for the manifest layer.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { stat } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
import { defaultPluginsDir, loadPlugins, currentCliVersion, verifyPlugin, readPluginManifestRaw } from './loader.js'
|
||||
import { parsePluginManifest, type PluginManifest } from './manifest.js'
|
||||
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program
|
||||
.command('plugin')
|
||||
.description('Inspect the plugin socket: list, info, verify (no installation; see docs/sync/README.md for `codeburn plugin add`)')
|
||||
|
||||
plugin
|
||||
.command('list')
|
||||
.description('List every plugin the loader found, with status (loaded | rejected) and reason for rejections')
|
||||
.option('--dir <path>', 'Override the plugins directory (defaults to ~/.config/codeburn/plugins)')
|
||||
.action(async (opts: { dir?: string }) => {
|
||||
const loads = await loadPlugins(opts.dir)
|
||||
if (loads.length === 0) {
|
||||
process.stdout.write(`No plugins found in ${opts.dir ?? defaultPluginsDir()}.\n`)
|
||||
return
|
||||
}
|
||||
for (const load of loads) {
|
||||
if (load.status === 'loaded') {
|
||||
const m = load.manifest
|
||||
const caps: string[] = []
|
||||
if (m.capabilities.commands.length > 0) caps.push(`commands=${m.capabilities.commands.length}`)
|
||||
if (m.capabilities.syncAttributes.length > 0) caps.push(`syncAttrs=${m.capabilities.syncAttributes.length}`)
|
||||
if (m.capabilities.payloadSections.length > 0) caps.push(`sections=${m.capabilities.payloadSections.length}`)
|
||||
process.stdout.write(`loaded ${m.name}@${m.version} (${caps.join(', ')})\n`)
|
||||
} else {
|
||||
process.stdout.write(`rejected ${load.name} ${load.reason}\n`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
plugin
|
||||
.command('info <name>')
|
||||
.description('Print the full manifest of a loaded plugin plus on-disk payload sections')
|
||||
.option('--dir <path>', 'Override the plugins directory')
|
||||
.action(async (name: string, opts: { dir?: string }) => {
|
||||
const loads = await loadPlugins(opts.dir)
|
||||
const loaded = loads.find((l): l is Extract<typeof l, { status: 'loaded' }> => l.status === 'loaded' && l.manifest.name === name)
|
||||
if (loaded) {
|
||||
const m = loaded.manifest
|
||||
process.stdout.write(JSON.stringify(m, null, 2) + '\n')
|
||||
const sections = await listOnDiskSections(loaded.dir, m)
|
||||
if (sections.length > 0) {
|
||||
process.stdout.write(`\non-disk payload sections: ${sections.join(', ')}\n`)
|
||||
} else {
|
||||
process.stdout.write(`\nno on-disk payload sections yet (plugin has not written any).\n`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const rejected = loads.find((l): l is Extract<typeof l, { status: 'rejected' }> => l.status === 'rejected' && l.name === name)
|
||||
if (rejected) {
|
||||
throw new Error(`Plugin "${name}" is not loaded: ${rejected.reason}`)
|
||||
}
|
||||
throw new Error(`Plugin "${name}" not found in ${opts.dir ?? defaultPluginsDir()}.`)
|
||||
})
|
||||
|
||||
plugin
|
||||
.command('verify <name>')
|
||||
.description('Re-run the verification hook for a named plugin and print the result (release-key signing lands in 9b)')
|
||||
.option('--dir <path>', 'Override the plugins directory')
|
||||
.action(async (name: string, opts: { dir?: string }) => {
|
||||
const dir = join(opts.dir ?? defaultPluginsDir(), name)
|
||||
const manifest = await readManifestForVerify(dir, name)
|
||||
if (!manifest) {
|
||||
throw new Error(`Plugin "${name}" could not be loaded for verify.`)
|
||||
}
|
||||
const result = await verifyPlugin(dir, manifest, process.env)
|
||||
if (result.ok) {
|
||||
process.stdout.write(`verified ${name}@${manifest.version}\n`)
|
||||
} else {
|
||||
throw new Error(`unverified ${name}@${manifest.version} ${result.reason ?? 'verification failed'}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the manifest at <dir>/codeburn-plugin.json and parses it via the
|
||||
/// same loader path (so a verify command reports the same shape list/info do).
|
||||
async function readManifestForVerify(dir: string, name: string): Promise<PluginManifest | null> {
|
||||
const { raw, reason } = await readPluginManifestRaw(dir)
|
||||
if (reason) {
|
||||
process.stderr.write(`Plugin "${name}": ${reason}\n`)
|
||||
return null
|
||||
}
|
||||
const parsed = parsePluginManifest(raw, `${name}/codeburn-plugin.json`)
|
||||
if (!parsed.ok) {
|
||||
process.stderr.write(`Plugin "${name}": ${parsed.reason}\n`)
|
||||
return null
|
||||
}
|
||||
return parsed.manifest
|
||||
}
|
||||
|
||||
async function listOnDiskSections(dir: string, m: PluginManifest): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
for (const name of m.capabilities.payloadSections) {
|
||||
const file = join(dir, 'sections', `${name}.json`)
|
||||
try {
|
||||
const info = await stat(file)
|
||||
if (info.isFile() && info.size <= 256 * 1024) out.push(name)
|
||||
} catch { /* missing is fine, sections are optional */ }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Re-export so consumers can pin the version pinned by the socket itself.
|
||||
export { defaultPluginsDir, currentCliVersion }
|
||||
142
src/plugins/loader.ts
Normal file
142
src/plugins/loader.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/// Plugin loader for the codeburn plugin socket (phase-1 mechanism).
|
||||
///
|
||||
/// Plugins live under ~/.config/codeburn/plugins/<name>/codeburn-plugin.json.
|
||||
/// Loading is deny-by-default: bad manifest, name/directory mismatch, CLI
|
||||
/// version outside cliCompat, or a failed verification hook each REJECT the
|
||||
/// plugin, and the rejection reason is what the surfaces print.
|
||||
///
|
||||
/// Verification (phase 9b) will check a release-key signature over the
|
||||
/// plugin directory. The seam is `verifyPlugin()` below and nothing else:
|
||||
/// until signing ships, only `CODEBURN_PLUGIN_DEV=1` loads an unsigned
|
||||
/// plugin, so there is exactly one auditable line between "signed" and
|
||||
/// "on the wire".
|
||||
|
||||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
import { checkCliCompat, parsePluginManifest, type PluginManifest } from './manifest.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).
|
||||
const { version: PKG_VERSION } = ((): { version: string } => {
|
||||
const req = createRequire(import.meta.url)
|
||||
try {
|
||||
return req('../package.json')
|
||||
} catch {
|
||||
return req('../../package.json')
|
||||
}
|
||||
})()
|
||||
|
||||
/// Same source of truth as `codeburn --version` (root package.json).
|
||||
export function currentCliVersion(): string {
|
||||
return PKG_VERSION
|
||||
}
|
||||
|
||||
export const MANIFEST_FILE = 'codeburn-plugin.json'
|
||||
|
||||
export type PluginLoad =
|
||||
| { status: 'loaded', manifest: PluginManifest, dir: string }
|
||||
| { status: 'rejected', name: string, dir: string, reason: string }
|
||||
|
||||
export function defaultPluginsDir(): string {
|
||||
return join(homedir(), '.config', 'codeburn', 'plugins')
|
||||
}
|
||||
|
||||
/// The 9b seam. Return ok:false to keep a plugin off the machine's output.
|
||||
/// 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 }> {
|
||||
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)' }
|
||||
}
|
||||
|
||||
async function readManifest(dir: string): Promise<{ raw?: unknown, reason?: string }> {
|
||||
try {
|
||||
const file = join(dir, MANIFEST_FILE)
|
||||
const info = await stat(file)
|
||||
if (!info.isFile() || info.size > 64 * 1024) return { reason: `${MANIFEST_FILE} missing or too large` }
|
||||
return { raw: JSON.parse(await readFile(file, 'utf8')) }
|
||||
} catch {
|
||||
return { reason: `${MANIFEST_FILE} missing or unreadable` }
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed for `codeburn plugin verify` so the verify path uses the same
|
||||
* read+parse pipeline as the loader. Returns {raw} on success, {reason} on
|
||||
* any failure (missing file, oversized, unparseable JSON). */
|
||||
export async function readPluginManifestRaw(dir: string): Promise<{ raw?: unknown, reason?: string }> {
|
||||
return readManifest(dir)
|
||||
}
|
||||
|
||||
export async function loadPlugins(
|
||||
pluginsDir: string = defaultPluginsDir(),
|
||||
cliVersion: string = currentCliVersion(),
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<PluginLoad[]> {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = (await readdir(pluginsDir, { withFileTypes: true }))
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
} catch {
|
||||
return [] // no plugins directory yet: an empty socket, the normal case
|
||||
}
|
||||
const loads: PluginLoad[] = []
|
||||
for (const entry of entries.sort()) {
|
||||
const dir = join(pluginsDir, entry)
|
||||
const { raw, reason } = await readManifest(dir)
|
||||
if (reason) { loads.push({ status: 'rejected', name: entry, dir, reason }); continue }
|
||||
const parsed = parsePluginManifest(raw, `${entry}/${MANIFEST_FILE}`)
|
||||
if (!parsed.ok) { loads.push({ status: 'rejected', name: entry, dir, reason: parsed.reason }); continue }
|
||||
const manifest = parsed.manifest
|
||||
if (manifest.name !== entry) {
|
||||
loads.push({ status: 'rejected', name: entry, dir, reason: `manifest name "${manifest.name}" does not match directory "${entry}"` })
|
||||
continue
|
||||
}
|
||||
const compat = checkCliCompat(manifest.cliCompat, cliVersion)
|
||||
if (compat) {
|
||||
loads.push({ status: 'rejected', name: entry, dir, reason: `plugin "${manifest.name}" ${compat}; this CLI is ${cliVersion}` })
|
||||
continue
|
||||
}
|
||||
const verified = await verifyPlugin(dir, manifest, env)
|
||||
if (!verified.ok) { loads.push({ status: 'rejected', name: entry, dir, reason: verified.reason ?? 'verification failed' }); continue }
|
||||
loads.push({ status: 'loaded', manifest, dir })
|
||||
}
|
||||
return loads
|
||||
}
|
||||
|
||||
/// Declared sync attributes across loaded plugins, keyed for the wire guard
|
||||
/// in sync/otlp.ts. Rejected plugins contribute nothing: nothing a plugin
|
||||
/// declared but failed to load can widen the wire.
|
||||
export function declaredSyncAttributes(loads: PluginLoad[]): Map<string, PluginManifest['capabilities']['syncAttributes'][number]> {
|
||||
const declared = new Map<string, PluginManifest['capabilities']['syncAttributes'][number]>()
|
||||
for (const load of loads) {
|
||||
if (load.status !== 'loaded') continue
|
||||
for (const attr of load.manifest.capabilities.syncAttributes) declared.set(attr.key, attr)
|
||||
}
|
||||
return declared
|
||||
}
|
||||
|
||||
/// Add-only payload sections. A plugin contributes at most its declared
|
||||
/// sections; each section is one small JSON file the plugin's own commands
|
||||
/// maintain. Undeclared names, nested paths, and oversized files never load.
|
||||
export async function pluginPayloadSections(loads: PluginLoad[]): Promise<Record<string, unknown>> {
|
||||
const sections: Record<string, unknown> = {}
|
||||
for (const load of loads) {
|
||||
if (load.status !== 'loaded') continue
|
||||
for (const name of load.manifest.capabilities.payloadSections) {
|
||||
try {
|
||||
const file = join(load.dir, 'sections', `${name}.json`)
|
||||
const info = await stat(file)
|
||||
if (!info.isFile() || info.size > 256 * 1024) continue
|
||||
sections[`${load.manifest.name}.${name}`] = JSON.parse(await readFile(file, 'utf8'))
|
||||
} catch { /* no section written yet: omit it, sections are optional */ }
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
77
src/plugins/manifest.ts
Normal file
77
src/plugins/manifest.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/// Plugin manifest schema for the codeburn plugin socket (teams issue #3,
|
||||
/// phase-1 mechanism). One extension point lives in the CLI; every surface
|
||||
/// consumes CLI output, so a plugin extends all of them through here.
|
||||
///
|
||||
/// The manifest is a security contract, not metadata: a plugin may only add
|
||||
/// what it declares, and the CLI's disclosure renderings are generated from
|
||||
/// these declarations. Unknown fields are REJECTED (strict) so a plugin can
|
||||
/// smuggle no capability through a field the loader forgot to read.
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/// Lower-case dotted key: `teams.section`, `ai.task_category`, ...
|
||||
const DOTTED_KEY = /^[a-z][a-z0-9]*(?:[._][a-z0-9]+)+$/
|
||||
|
||||
const SUBCOMMAND = /^[a-z][a-z0-9-]{0,31}$/
|
||||
|
||||
export const pluginManifestSchema = z.object({
|
||||
name: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, 'plugin name: lower-case, digits, dashes'),
|
||||
version: z.string().min(1).max(32),
|
||||
/// Space-separated comparators over the CLI version, e.g. ">=0.9.22 <0.11".
|
||||
cliCompat: z.string().min(1).max(128),
|
||||
capabilities: z.object({
|
||||
commands: z.array(z.string().regex(SUBCOMMAND)).max(16).default([]),
|
||||
/// Extra wire fields. A plugin attribute that is not declared here never
|
||||
/// reaches the wire (enforced in sync/otlp.ts, not merely discouraged).
|
||||
syncAttributes: z.array(z.object({
|
||||
key: z.string().regex(DOTTED_KEY).max(128),
|
||||
/// Shown to members in `sync push --dry-run`. The disclosure is the
|
||||
/// gate: an attribute without one is refused at parse time.
|
||||
disclosure: z.string().min(10).max(500),
|
||||
}).strict()).max(64).default([]),
|
||||
payloadSections: z.array(z.string().regex(SUBCOMMAND)).max(16).default([]),
|
||||
spanKinds: z.array(z.string().regex(DOTTED_KEY).max(128)).max(16).default([]),
|
||||
}).strict().default({}),
|
||||
}).strict()
|
||||
|
||||
export type PluginManifest = z.infer<typeof pluginManifestSchema>
|
||||
|
||||
export type ParseResult = { ok: true, manifest: PluginManifest } | { ok: false, reason: string }
|
||||
|
||||
export function parsePluginManifest(raw: unknown, source: string): ParseResult {
|
||||
const parsed = pluginManifestSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0]
|
||||
return { ok: false, reason: `${source}: ${first?.path.join('.') || '(root)'}: ${first?.message ?? 'invalid manifest'}` }
|
||||
}
|
||||
return { ok: true, manifest: parsed.data }
|
||||
}
|
||||
|
||||
/// Minimal semver-ish compare: dot-separated numeric segments, missing = 0.
|
||||
/// Enough for the ">=x.y.z <a.b.c" ranges manifests declare; no dependency.
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map(s => parseInt(s, 10) || 0)
|
||||
const pb = b.split('.').map(s => parseInt(s, 10) || 0)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
|
||||
if (d !== 0) return d < 0 ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/// Returns null when the CLI version satisfies the range, otherwise the
|
||||
/// offending comparator (so the CLI can print a polite refusal).
|
||||
export function checkCliCompat(cliCompat: string, cliVersion: string): string | null {
|
||||
for (const term of cliCompat.trim().split(/\s+/)) {
|
||||
const m = /^(>=|<=|>|<|=)?(\d+(?:\.\d+)*)$/.exec(term)
|
||||
if (!m) return `unparseable range term "${term}"`
|
||||
const cmp = compareVersions(cliVersion, m[2]!)
|
||||
const ok = m[1] === '>=' ? cmp >= 0
|
||||
: m[1] === '<=' ? cmp <= 0
|
||||
: m[1] === '>' ? cmp > 0
|
||||
: m[1] === '<' ? cmp < 0
|
||||
: cmp === 0
|
||||
if (!ok) return `requires codeburn ${term}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import { createCredentialStore } from './credentials.js'
|
|||
import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js'
|
||||
import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js'
|
||||
import { batchAttributionItems, wireProjectName } from './otlp.js'
|
||||
import { loadPlugins, declaredSyncAttributes, type PluginLoad } from '../plugins/loader.js'
|
||||
|
||||
export function registerSyncCommands(program: Command): void {
|
||||
const sync = program
|
||||
|
|
@ -291,6 +292,14 @@ export function registerSyncCommands(program: Command): void {
|
|||
// Flatten + filter against sent-ledger
|
||||
const { allCalls, unsent, held, frozen } = collectUnsentCalls(projects, Date.now(), { plans })
|
||||
|
||||
// Plugin socket: load declared sync attribute keys once for the dry-run
|
||||
// disclosure and the real-push wire guard. Loader is directory-stat only
|
||||
// when no plugins are installed, so the no-plugin case is byte-identical
|
||||
// to before the socket shipped.
|
||||
const pluginLoads: PluginLoad[] = await loadPlugins()
|
||||
const pluginKeys = declaredSyncAttributes(pluginLoads)
|
||||
const pluginAttributeKeys: ReadonlySet<string> = new Set(pluginKeys.keys())
|
||||
|
||||
// Attribution records (opt-in): session→commit correlation computed
|
||||
// locally from the same parsed projects. Reuses the yield engine.
|
||||
let attributionUnsent: Awaited<ReturnType<typeof collectUnsentAttribution>>['unsent'] = []
|
||||
|
|
@ -322,6 +331,25 @@ export function registerSyncCommands(program: Command): void {
|
|||
const covered = toPushList.filter(c => c.session?.subscriptionCovered === true).length
|
||||
const uncovered = toPushList.filter(c => c.session?.subscriptionCovered === false).length
|
||||
process.stderr.write(`[dry-run] Fields: ${withLineage}/${toPushCount} spans carry lineage (ai.work_unit_id/session_role/lineage_evidence), ${withCacheTokens} carry cache tokens, ai.subscription_covered true on ${covered} / false on ${uncovered} / omitted on ${toPushCount - covered - uncovered}; codeburn.coverage_through: ${coverageThrough ?? 'unavailable'}\n`)
|
||||
|
||||
// Plugin socket disclosure (teams issue #3): a member sees every
|
||||
// loaded plugin and every declared sync attribute, so a plugin
|
||||
// cannot widen the wire silently. Empty socket => line is omitted.
|
||||
const loadedPlugins = pluginLoads.filter(l => l.status === 'loaded')
|
||||
const rejectedPlugins = pluginLoads.filter(l => l.status === 'rejected')
|
||||
if (loadedPlugins.length > 0 || rejectedPlugins.length > 0) {
|
||||
const loadedSummary = loadedPlugins.map(l => {
|
||||
const attrs = l.manifest.capabilities.syncAttributes
|
||||
return attrs.length > 0
|
||||
? `${l.manifest.name}@${l.manifest.version} [${attrs.map(a => `${a.key} - ${a.disclosure}`).join('; ')}]`
|
||||
: `${l.manifest.name}@${l.manifest.version} (no sync attributes declared)`
|
||||
}).join(' | ')
|
||||
process.stderr.write(`[dry-run] Plugins loaded: ${loadedPlugins.length} (${loadedSummary})\n`)
|
||||
if (rejectedPlugins.length > 0) {
|
||||
const rejectedSummary = rejectedPlugins.map(l => `${l.name} (${l.reason})`).join('; ')
|
||||
process.stderr.write(`[dry-run] Plugins rejected: ${rejectedPlugins.length} (${rejectedSummary})\n`)
|
||||
}
|
||||
}
|
||||
if (unsent.length > MAX_PER_PUSH) {
|
||||
process.stderr.write(`[dry-run] ${unsent.length - MAX_PER_PUSH} more calls exceed the ${MAX_PER_PUSH} safety limit — a second push would be needed\n`)
|
||||
}
|
||||
|
|
@ -367,6 +395,10 @@ export function registerSyncCommands(program: Command): void {
|
|||
accessToken: tokens.access_token,
|
||||
batches,
|
||||
...(coverageThrough ? { coverageThrough } : {}),
|
||||
// Empty values array is the no-plugin-runtime case: the wire guard
|
||||
// in otlp.ts drops everything when `values` is empty, so the wire
|
||||
// is byte-identical until a real plugin supplies attrs.
|
||||
pluginAttributes: { keys: pluginAttributeKeys, values: [] },
|
||||
log: msg => process.stderr.write(`${msg}\n`),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,10 +205,54 @@ export interface BuildOtlpOptions {
|
|||
* watermark is not trusted.
|
||||
*/
|
||||
coverageThrough?: string
|
||||
/**
|
||||
* Extra span attributes contributed by loaded plugins (plugin socket,
|
||||
* teams issue #3). This is the enforcement point, not a convention: an
|
||||
* attribute survives only if its key is in `pluginAttributeKeys` (the
|
||||
* union of loaded manifests' declared `syncAttributes`) and is not a
|
||||
* core key. Undeclared, core-shadowing, or unsanitizable values are
|
||||
* dropped here, so no plugin - signed or dev-flagged - can widen the
|
||||
* wire beyond what its disclosed manifest declared.
|
||||
*/
|
||||
pluginAttributes?: OtlpAttribute[]
|
||||
pluginAttributeKeys?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/// Attribute keys the core emitter writes. Plugins add fields; they never
|
||||
/// overwrite these, so a plugin cannot lie about cost, tokens, or lineage.
|
||||
export const CORE_SYNC_ATTRIBUTE_KEYS: ReadonlySet<string> = new Set([
|
||||
'ai.provider', 'ai.model', 'ai.input_tokens', 'ai.output_tokens', 'ai.cost_usd', 'ai.speed',
|
||||
'ai.project', 'ai.tools', 'ai.cost_estimated', 'ai.work_unit_id', 'ai.session_role',
|
||||
'ai.lineage_evidence', 'ai.cache_read_tokens', 'ai.cache_write_tokens', 'ai.call_count',
|
||||
'ai.session_duration_ms', 'ai.subscription_covered', 'codeburn.device_id',
|
||||
'codeburn.coverage_through', 'codeburn.attribution_methodology',
|
||||
'git.repo', 'git.sha', 'git.commit_count', 'git.in_main', 'git.was_reverted', 'git.pr_links',
|
||||
])
|
||||
|
||||
/// Wire guard for plugin-supplied attributes (see BuildOtlpOptions).
|
||||
export function filterPluginAttributes(attrs: OtlpAttribute[], declaredKeys: ReadonlySet<string>): OtlpAttribute[] {
|
||||
const kept: OtlpAttribute[] = []
|
||||
for (const attr of attrs) {
|
||||
if (!declaredKeys.has(attr.key) || CORE_SYNC_ATTRIBUTE_KEYS.has(attr.key)) continue
|
||||
const v = attr.value
|
||||
if ('stringValue' in v) {
|
||||
// Same #1128 sanitizer vocabulary as every other wire string; a plugin
|
||||
// value that looks like a path, URL, or credential never ships.
|
||||
const safe = sanitizeIdentifier(v.stringValue, 256)
|
||||
if (safe) kept.push({ key: attr.key, value: { stringValue: safe } })
|
||||
} else if ('intValue' in v || 'doubleValue' in v || 'boolValue' in v) {
|
||||
kept.push(attr)
|
||||
}
|
||||
// arrayValue and anything else: plugins may not ship structured values.
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
export function buildOtlpPayload(calls: CallWithSession[], opts?: BuildOtlpOptions): OtlpPayload {
|
||||
const deviceId = getDeviceId()
|
||||
const guardedPluginAttributes = opts?.pluginAttributes && opts.pluginAttributeKeys
|
||||
? filterPluginAttributes(opts.pluginAttributes, opts.pluginAttributeKeys)
|
||||
: []
|
||||
|
||||
const spans: OtlpSpan[] = calls.map(({ call, sessionId, workingDirectory, session }) => {
|
||||
const startNano = toUnixNano(call.timestamp)
|
||||
|
|
@ -283,6 +327,10 @@ export function buildOtlpPayload(calls: CallWithSession[], opts?: BuildOtlpOptio
|
|||
}
|
||||
}
|
||||
|
||||
// Plugin socket: declared-and-guarded plugin fields only (see
|
||||
// filterPluginAttributes). No plugins => no change to any byte.
|
||||
attributes.push(...guardedPluginAttributes)
|
||||
|
||||
return {
|
||||
traceId: deriveTraceId(sessionId),
|
||||
spanId: deriveSpanId(call.deduplicationKey),
|
||||
|
|
|
|||
|
|
@ -345,6 +345,14 @@ export interface SendBatchesOptions {
|
|||
log?: (msg: string) => void
|
||||
/** ISO date stamped as codeburn.coverage_through on every batch. Omit when unproven. */
|
||||
coverageThrough?: string
|
||||
/**
|
||||
* Plugin-socket extension point (teams issue #3): the set of attribute keys
|
||||
* loaded plugins have DECLARED on their manifests, plus the runtime-supplied
|
||||
* attribute values to attach to every span. The wire guard in otlp.ts drops
|
||||
* anything not in the declared set, so an empty values array (the common
|
||||
* case with no plugin runtime yet) keeps every batch byte-identical.
|
||||
*/
|
||||
pluginAttributes?: { keys: ReadonlySet<string>; values: import('./otlp.js').OtlpAttribute[] }
|
||||
/** Injectable sleep for tests. Defaults to real setTimeout. */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
/** Max wait per 429 (caps Retry-After). Default 120s. */
|
||||
|
|
@ -378,7 +386,12 @@ export function parseRetryAfterMs(value: string | null): number | null {
|
|||
export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult> {
|
||||
return sendBatchesCore({
|
||||
...opts,
|
||||
buildPayload: batch => buildOtlpPayload(batch, opts.coverageThrough ? { coverageThrough: opts.coverageThrough } : undefined),
|
||||
buildPayload: batch => buildOtlpPayload(batch, {
|
||||
...(opts.coverageThrough ? { coverageThrough: opts.coverageThrough } : {}),
|
||||
...(opts.pluginAttributes
|
||||
? { pluginAttributes: opts.pluginAttributes.values, pluginAttributeKeys: opts.pluginAttributes.keys }
|
||||
: {}),
|
||||
}),
|
||||
toOutbound: c => ({ key: c.call.deduplicationKey, ts: c.call.timestamp, costUSD: c.call.costUSD }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarP
|
|||
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete, sessionHydrationSnapshot } from './parser.js'
|
||||
import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
|
||||
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { loadPlugins, pluginPayloadSections } from './plugins/loader.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKeyInTz } from './day-aggregator.js'
|
||||
|
|
@ -1025,5 +1026,10 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// instead, so the two are never conflated.
|
||||
const partialFirstPaint = hydration?.deferredForFirstPaint === true
|
||||
const stale = hydration?.complete === false && !partialFirstPaint ? true : undefined
|
||||
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory, stale, hydrationStateFor(hydration))
|
||||
const payload = buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory, stale, hydrationStateFor(hydration))
|
||||
// Plugin socket: add-only sections from loaded plugins (empty socket by
|
||||
// default, so the payload is byte-identical without plugins installed).
|
||||
const pluginSections = await pluginPayloadSections(await loadPlugins())
|
||||
if (Object.keys(pluginSections).length > 0) payload.plugins = pluginSections
|
||||
return payload
|
||||
}
|
||||
|
|
|
|||
227
tests/plugin-socket.test.ts
Normal file
227
tests/plugin-socket.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* 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(/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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue