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:
Resham Joshi 2026-08-28 07:07:40 -07:00 committed by GitHub
commit aca73fb8d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 790 additions and 2 deletions

View 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) })

View file

@ -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')

View file

@ -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
View 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
View 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
View 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
}

View file

@ -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`),
})
}

View file

@ -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),

View file

@ -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 }),
})
}

View file

@ -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
View 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()
})
})