mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
os.homedir() reads USERPROFILE on Windows and never HOME, so the isolation setup and every test that moved home were still resolving the developer's real home directory. Fixtures came back empty, real sessions leaked into counts, and the code under test wrote plans, receipts and a sync ledger into the real ~/.config/codeburn during a run. USERPROFILE now joins the redirected variables and a shared setHome() helper moves HOME and USERPROFILE together; every spawn env carries both. The remaining Windows-only failures were POSIX-shaped fixtures: chmod tricks that only toggle a read-only attribute on NTFS, pid 1 as an always-alive lock holder, splitting paths on '/', bare tar, mode-bit expectations, and per-file timeouts too short for the cost of a tsx spawn. Each gets a platform-aware equivalent so nothing is skipped on Windows and the POSIX branches stay byte-for-byte what they were. Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
706 lines
24 KiB
TypeScript
706 lines
24 KiB
TypeScript
/**
|
|
* Tests for the plugin sync exporter seam (socket phase 2, slice B).
|
|
*
|
|
* Covers: per-call attribute enrichment, extra span generation, guards,
|
|
* isolation, crash handling, and byte-identical baseline.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir, homedir } from 'os'
|
|
import type { CallWithSession, OtlpAttribute } from '../src/sync/otlp.js'
|
|
import { buildOtlpPayload, deriveSpanId } from '../src/sync/otlp.js'
|
|
import { collectPluginEnrichment } from '../src/plugins/exporter.js'
|
|
import { loadPlugins } from '../src/plugins/loader.js'
|
|
import type { PluginLoad } from '../src/plugins/loader.js'
|
|
|
|
function validManifest(name = 'sample') {
|
|
return {
|
|
name,
|
|
version: '0.1.0',
|
|
cliCompat: '>=0.9.22',
|
|
capabilities: {
|
|
commands: [],
|
|
syncAttributes: [{ key: 'sample.score', disclosure: 'numeric score 0..1' }],
|
|
payloadSections: [],
|
|
spanKinds: ['sample.span'],
|
|
},
|
|
}
|
|
}
|
|
|
|
function sampleCall(deduplicationKey: string): CallWithSession {
|
|
return {
|
|
call: {
|
|
deduplicationKey,
|
|
provider: 'claude',
|
|
model: 'claude-3-sonnet',
|
|
timestamp: '2026-01-01T00:00:00Z',
|
|
usage: { inputTokens: 10, outputTokens: 20, cacheReadInputTokens: 0, cachedInputTokens: 0, cacheCreationInputTokens: 0, reasoningTokens: 0 },
|
|
costUSD: 0.001,
|
|
speed: 'average',
|
|
tools: [],
|
|
},
|
|
sessionId: 'session-1',
|
|
workingDirectory: '/tmp',
|
|
}
|
|
}
|
|
|
|
let tmpDir: string
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'plugin-exporter-'))
|
|
})
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe('plugin sync exporter seam', () => {
|
|
it('happy path: exporter returns declared perCall attr and extra span', async () => {
|
|
const pluginDir = join(tmpDir, 'exporter-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Write exporter script
|
|
const traceId = '0'.repeat(32)
|
|
const spanId = '0'.repeat(16)
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
const output = {
|
|
perCall: {
|
|
[data.calls[0].key]: [{ key: 'sample.score', value: { doubleValue: 0.75 } }]
|
|
},
|
|
spans: [{
|
|
kind: 'sample.span',
|
|
traceId: '${traceId}',
|
|
spanId: '${spanId}',
|
|
name: 'exporter span',
|
|
startNano: '1000000000',
|
|
endNano: '2000000000',
|
|
attributes: []
|
|
}]
|
|
};
|
|
process.stdout.write(JSON.stringify(output));
|
|
`
|
|
)
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('exporter-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Should have perCall attr and extra span
|
|
expect(enrichment.perCall.has('key-1')).toBe(true)
|
|
expect(enrichment.extraSpans.length).toBe(1)
|
|
expect(enrichment.extraSpans[0].attributes.some(a => a.key === 'codeburn.span_kind')).toBe(true)
|
|
})
|
|
|
|
it('undeclared attribute key is dropped', async () => {
|
|
const pluginDir = join(tmpDir, 'undeclared-attr-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter returns undeclared key
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
process.stdout.write(JSON.stringify({
|
|
perCall: {
|
|
[data.calls[0].key]: [{ key: 'undeclared.key', value: { stringValue: 'test' } }]
|
|
},
|
|
spans: []
|
|
}));
|
|
`
|
|
)
|
|
|
|
const manifest = { ...validManifest('plugin'), capabilities: { ...validManifest('plugin').capabilities, syncAttributes: [] } }
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest, dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Undeclared key should be filtered out - either missing or empty
|
|
const attrs = enrichment.perCall.get('key-1') ?? []
|
|
expect(attrs.filter(a => a.key === 'undeclared.key').length).toBe(0)
|
|
})
|
|
|
|
it('exporter crash produces stderr notice, no enrichment', async () => {
|
|
const pluginDir = join(tmpDir, 'crash-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter that crashes
|
|
await writeFile(join(exportersDir, 'sync.mjs'), 'process.exit(1);')
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('crash-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const savedStderr = process.stderr.write
|
|
let stderrOutput = ''
|
|
process.stderr.write = (chunk: string | Uint8Array | Buffer): boolean => {
|
|
stderrOutput += chunk.toString()
|
|
return true
|
|
}
|
|
|
|
try {
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Should have no enrichment
|
|
expect(enrichment.perCall.size).toBe(0)
|
|
expect(enrichment.extraSpans.length).toBe(0)
|
|
expect(stderrOutput).toContain('sync exporter failed')
|
|
} finally {
|
|
process.stderr.write = savedStderr
|
|
}
|
|
})
|
|
|
|
it('timeout produces stderr notice, no enrichment', async () => {
|
|
const pluginDir = join(tmpDir, 'timeout-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter that hangs
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
'setTimeout(() => {}, 60000);'
|
|
)
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('timeout-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const savedStderr = process.stderr.write
|
|
let stderrOutput = ''
|
|
process.stderr.write = (chunk: string | Uint8Array | Buffer): boolean => {
|
|
stderrOutput += chunk.toString()
|
|
return true
|
|
}
|
|
|
|
try {
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 100) // 100ms timeout
|
|
|
|
expect(enrichment.perCall.size).toBe(0)
|
|
expect(enrichment.extraSpans.length).toBe(0)
|
|
expect(stderrOutput).toContain('timeout')
|
|
} finally {
|
|
process.stderr.write = savedStderr
|
|
}
|
|
})
|
|
|
|
it('no exporter file is skipped (byte-identical baseline)', async () => {
|
|
const pluginDir = join(tmpDir, 'no-exporter-plugin')
|
|
await mkdir(pluginDir, { recursive: true })
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('no-exporter-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls)
|
|
|
|
// Should have no enrichment (no exporter file)
|
|
expect(enrichment.perCall.size).toBe(0)
|
|
expect(enrichment.extraSpans.length).toBe(0)
|
|
|
|
// Verify payload is byte-identical
|
|
const payload1 = buildOtlpPayload(calls)
|
|
const payload2 = buildOtlpPayload(calls, { pluginEnrichment: enrichment })
|
|
expect(JSON.stringify(payload1)).toBe(JSON.stringify(payload2))
|
|
})
|
|
|
|
it('extra span caps: max 2x calls.length', async () => {
|
|
const pluginDir = join(tmpDir, 'span-cap-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter that tries to return too many spans
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
const spans = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
spans.push({
|
|
kind: 'sample.span',
|
|
traceId: '0'.repeat(32),
|
|
spanId: i.toString().padStart(16, '0'),
|
|
name: 'span ' + i,
|
|
startNano: '1000000000',
|
|
endNano: '2000000000',
|
|
attributes: []
|
|
});
|
|
}
|
|
process.stdout.write(JSON.stringify({ perCall: {}, spans }));
|
|
`
|
|
)
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('span-cap-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1'), sampleCall('key-2')] // 2 calls = max 4 spans
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Should cap at 2x calls.length = 4
|
|
expect(enrichment.extraSpans.length).toBeLessThanOrEqual(4)
|
|
})
|
|
|
|
it('oversized span (>64KB) is dropped', async () => {
|
|
const pluginDir = join(tmpDir, 'oversized-span-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter that returns oversized span with large name (survives filtering)
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const bigName = 'x'.repeat(70 * 1024);
|
|
process.stdout.write(JSON.stringify({
|
|
perCall: {},
|
|
spans: [{
|
|
kind: 'sample.span',
|
|
traceId: '0'.repeat(32),
|
|
spanId: '0'.repeat(16),
|
|
name: bigName,
|
|
startNano: '1000000000',
|
|
endNano: '2000000000',
|
|
attributes: [{ key: 'sample.score', value: { stringValue: 'small' } }]
|
|
}]
|
|
}));
|
|
`
|
|
)
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('oversized-span-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Oversized span should be dropped
|
|
expect(enrichment.extraSpans.length).toBe(0)
|
|
})
|
|
|
|
it('rejected plugins are skipped', async () => {
|
|
const pluginDir = join(tmpDir, 'rejected-plugin')
|
|
await mkdir(pluginDir, { recursive: true })
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'rejected', name: 'rejected-plugin', dir: pluginDir, reason: 'unsigned' },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls)
|
|
|
|
// Rejected plugin should contribute nothing
|
|
expect(enrichment.perCall.size).toBe(0)
|
|
expect(enrichment.extraSpans.length).toBe(0)
|
|
})
|
|
|
|
it('cross-plugin isolation: plugin A cannot emit attrs declared only by B', async () => {
|
|
const pluginADir = join(tmpDir, 'plugin-a')
|
|
const pluginBDir = join(tmpDir, 'plugin-b')
|
|
const exportersA = join(pluginADir, 'exporters')
|
|
const exportersB = join(pluginBDir, 'exporters')
|
|
await mkdir(exportersA, { recursive: true })
|
|
await mkdir(exportersB, { recursive: true })
|
|
|
|
// Plugin A tries to emit attr declared only by B
|
|
await writeFile(
|
|
join(exportersA, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
process.stdout.write(JSON.stringify({
|
|
perCall: {
|
|
[data.calls[0].key]: [{ key: 'plugin-b.attr', value: { stringValue: 'stolen' } }]
|
|
},
|
|
spans: []
|
|
}));
|
|
`
|
|
)
|
|
|
|
// Plugin B declares plugin-b.attr
|
|
await writeFile(join(exportersB, 'sync.mjs'), 'process.stdout.write(JSON.stringify({perCall:{},spans:[]}));')
|
|
|
|
const manifestA = { ...validManifest('plugin-a'), capabilities: { ...validManifest('plugin-a').capabilities, syncAttributes: [] } }
|
|
const manifestB = { ...validManifest('plugin-b'), capabilities: { ...validManifest('plugin-b').capabilities, syncAttributes: [{ key: 'plugin-b.attr', disclosure: 'test' }] } }
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: manifestA, dir: pluginADir },
|
|
{ status: 'loaded', manifest: manifestB, dir: pluginBDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Plugin A's attempt to emit plugin-b.attr should be dropped
|
|
const aAttrs = enrichment.perCall.get('key-1') ?? []
|
|
expect(aAttrs.some(a => a.key === 'plugin-b.attr')).toBe(false)
|
|
})
|
|
|
|
it('path-like stringValue in declared key is sanitized and never appears raw', async () => {
|
|
const pluginDir = join(tmpDir, 'path-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter returns attr with path-like value
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
process.stdout.write(JSON.stringify({
|
|
perCall: {
|
|
[data.calls[0].key]: [{ key: 'sample.score', value: { stringValue: '/Users/secret/key.pem' } }]
|
|
},
|
|
spans: []
|
|
}));
|
|
`
|
|
)
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('path-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Path-like value should be dropped or sanitized (not appear raw)
|
|
const attrs = enrichment.perCall.get('key-1') ?? []
|
|
for (const attr of attrs) {
|
|
if (attr.key === 'sample.score' && 'stringValue' in attr.value) {
|
|
expect(attr.value.stringValue).not.toContain('/Users/secret')
|
|
}
|
|
}
|
|
})
|
|
|
|
it('arrayValue attr on declared key is dropped', async () => {
|
|
const pluginDir = join(tmpDir, 'array-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter returns attr with arrayValue (should be dropped)
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
process.stdout.write(JSON.stringify({
|
|
perCall: {
|
|
[data.calls[0].key]: [{ key: 'sample.score', value: { arrayValue: { values: [{ stringValue: 'item' }] } } }]
|
|
},
|
|
spans: []
|
|
}));
|
|
`
|
|
)
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('array-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// arrayValue should be dropped
|
|
const attrs = enrichment.perCall.get('key-1') ?? []
|
|
expect(attrs.some(a => a.key === 'sample.score' && 'arrayValue' in a.value)).toBe(false)
|
|
})
|
|
|
|
it('two plugins with different declared keys enriching same call both survive', async () => {
|
|
const pluginADir = join(tmpDir, 'merge-a')
|
|
const pluginBDir = join(tmpDir, 'merge-b')
|
|
const exportersA = join(pluginADir, 'exporters')
|
|
const exportersB = join(pluginBDir, 'exporters')
|
|
await mkdir(exportersA, { recursive: true })
|
|
await mkdir(exportersB, { recursive: true })
|
|
|
|
// Plugin A contributes attr-a
|
|
await writeFile(
|
|
join(exportersA, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
process.stdout.write(JSON.stringify({
|
|
perCall: {
|
|
[data.calls[0].key]: [{ key: 'attr.a', value: { stringValue: 'from-a' } }]
|
|
},
|
|
spans: []
|
|
}));
|
|
`
|
|
)
|
|
|
|
// Plugin B contributes attr-b
|
|
await writeFile(
|
|
join(exportersB, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
const data = JSON.parse(input);
|
|
process.stdout.write(JSON.stringify({
|
|
perCall: {
|
|
[data.calls[0].key]: [{ key: 'attr.b', value: { stringValue: 'from-b' } }]
|
|
},
|
|
spans: []
|
|
}));
|
|
`
|
|
)
|
|
|
|
const manifestA = { ...validManifest('merge-a'), capabilities: { ...validManifest('merge-a').capabilities, syncAttributes: [{ key: 'attr.a', disclosure: 'test' }] } }
|
|
const manifestB = { ...validManifest('merge-b'), capabilities: { ...validManifest('merge-b').capabilities, syncAttributes: [{ key: 'attr.b', disclosure: 'test' }] } }
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: manifestA, dir: pluginADir },
|
|
{ status: 'loaded', manifest: manifestB, dir: pluginBDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Both attrs should be present
|
|
const attrs = enrichment.perCall.get('key-1') ?? []
|
|
expect(attrs.some(a => a.key === 'attr.a')).toBe(true)
|
|
expect(attrs.some(a => a.key === 'attr.b')).toBe(true)
|
|
expect(attrs.length).toBeGreaterThanOrEqual(2)
|
|
})
|
|
|
|
it('exporter input includes turn context for calls with edits', async () => {
|
|
const pluginDir = join(tmpDir, 'turn-context-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter that echoes input to a file for inspection
|
|
const echoFile = join(tmpDir, 'exporter-input.json')
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
import * as fs from 'fs';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
fs.writeFileSync(${JSON.stringify(echoFile)}, input);
|
|
process.stdout.write(JSON.stringify({ perCall: {}, spans: [] }));
|
|
`
|
|
)
|
|
|
|
// Create a call with Edit tool to trigger turn context
|
|
const callWithEdits: CallWithSession = {
|
|
call: {
|
|
deduplicationKey: 'key-with-edit',
|
|
provider: 'claude',
|
|
model: 'claude-3-sonnet',
|
|
timestamp: '2026-01-01T00:00:00Z',
|
|
usage: { inputTokens: 10, outputTokens: 20, cacheReadInputTokens: 0, cachedInputTokens: 0, cacheCreationInputTokens: 0, reasoningTokens: 0 },
|
|
costUSD: 0.001,
|
|
speed: 'average',
|
|
tools: ['Edit'],
|
|
},
|
|
sessionId: 'session-1',
|
|
workingDirectory: '/tmp',
|
|
}
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('turn-context-plugin'), dir: pluginDir },
|
|
]
|
|
|
|
await collectPluginEnrichment(loads, [callWithEdits], 5000)
|
|
|
|
// Read back the exporter input and verify turn context
|
|
const inputJson = JSON.parse(await readFile(echoFile, 'utf-8'))
|
|
expect(inputJson.calls).toHaveLength(1)
|
|
expect(inputJson.calls[0].turn).toBeDefined()
|
|
expect(inputJson.calls[0].turn.turnId).toBe(deriveSpanId('key-with-edit'))
|
|
expect(inputJson.calls[0].turn.hasEdits).toBe(true)
|
|
expect(inputJson.calls[0].turn.retries).toBe(0)
|
|
expect(inputJson.calls[0].turn.oneShot).toBe(true)
|
|
})
|
|
|
|
it('exporter input omits turn field for calls without turns', async () => {
|
|
const pluginDir = join(tmpDir, 'no-turn-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter that echoes input to a file
|
|
const echoFile = join(tmpDir, 'exporter-input-2.json')
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as readline from 'readline';
|
|
import * as fs from 'fs';
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let input = '';
|
|
for await (const line of rl) {
|
|
input += line;
|
|
}
|
|
fs.writeFileSync(${JSON.stringify(echoFile)}, input);
|
|
process.stdout.write(JSON.stringify({ perCall: {}, spans: [] }));
|
|
`
|
|
)
|
|
|
|
// Call without edits (no turn context)
|
|
const simpleCall = sampleCall('simple-key')
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('no-turn-plugin'), dir: pluginDir },
|
|
]
|
|
|
|
await collectPluginEnrichment(loads, [simpleCall], 5000)
|
|
|
|
// Read back input - turn field should be omitted for calls without turns
|
|
const inputJson = JSON.parse(await readFile(echoFile, 'utf-8'))
|
|
expect(inputJson.calls).toHaveLength(1)
|
|
// The call may or may not have turn context depending on simplified grouping
|
|
// In this simplified version, we check it's handled gracefully
|
|
expect(inputJson.calls[0]).toHaveProperty('key')
|
|
})
|
|
|
|
it('CODEBURN_PLUGIN_STATE_DIR is set and namespaced by plugin name', async () => {
|
|
const pluginDir = join(tmpDir, 'state-dir-plugin')
|
|
const exportersDir = join(pluginDir, 'exporters')
|
|
await mkdir(exportersDir, { recursive: true })
|
|
|
|
// Exporter that checks env var and reads from state dir
|
|
const checkFile = join(tmpDir, 'env-check.json')
|
|
await writeFile(
|
|
join(exportersDir, 'sync.mjs'),
|
|
`
|
|
import * as fs from 'fs';
|
|
const stateDir = process.env.CODEBURN_PLUGIN_STATE_DIR;
|
|
const envCheck = {
|
|
hasStateDir: !!stateDir,
|
|
stateDirPath: stateDir || null,
|
|
pluginDir: process.env.CODEBURN_PLUGIN_DIR
|
|
};
|
|
fs.writeFileSync(${JSON.stringify(checkFile)}, JSON.stringify(envCheck));
|
|
process.stdout.write(JSON.stringify({ perCall: {}, spans: [] }));
|
|
`
|
|
)
|
|
|
|
const loads: PluginLoad[] = [
|
|
{ status: 'loaded', manifest: validManifest('state-dir-plugin'), dir: pluginDir },
|
|
]
|
|
const calls = [sampleCall('key-1')]
|
|
|
|
await collectPluginEnrichment(loads, calls, 5000)
|
|
|
|
// Check env var was set
|
|
const envCheck = JSON.parse(await readFile(checkFile, 'utf-8'))
|
|
expect(envCheck.hasStateDir).toBe(true)
|
|
// The state dir is a real path, so the separator is the platform's.
|
|
expect(envCheck.stateDirPath).toContain(join('codeburn', 'plugin-state', 'state-dir-plugin'))
|
|
expect(envCheck.stateDirPath).toContain(homedir())
|
|
|
|
// Verify directory exists
|
|
const stateDir = join(homedir(), '.config', 'codeburn', 'plugin-state', 'state-dir-plugin')
|
|
const stats = await mkdir(stateDir, { recursive: true })
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
expect(stats).toBe(true)
|
|
})
|
|
})
|
|
|
|
// Regression probes from the deep review: a plugin must never kill the push.
|
|
describe('exporter hostility: the push always survives', () => {
|
|
it('survives an exporter that exits before reading stdin (EPIPE path)', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'hostile-'))
|
|
try {
|
|
const plug = join(dir, 'evil')
|
|
await mkdir(join(plug, 'exporters'), { recursive: true })
|
|
await writeFile(join(plug, 'codeburn-plugin.json'), JSON.stringify({
|
|
name: 'evil', version: '0.1.0', cliCompat: '>=0.9.22',
|
|
capabilities: { commands: [], syncAttributes: [{ key: 'evil.f', disclosure: 'probe field for tests' }], payloadSections: [], spanKinds: [] },
|
|
}))
|
|
await writeFile(join(plug, 'exporters', 'sync.mjs'), 'process.stdin.destroy()\nprocess.exit(1)\n')
|
|
const loads = await loadPlugins(dir, '0.9.22', { ...process.env, CODEBURN_PLUGIN_DEV: '1' })
|
|
const calls = Array.from({ length: 200 }, (_, i) => ({
|
|
call: { timestamp: '2026-08-28T10:00:00.000Z', deduplicationKey: `k${i}`, padding: 'x'.repeat(2000) },
|
|
sessionId: 's', workingDirectory: '/w', session: null,
|
|
})) as never[]
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 10_000)
|
|
expect(enrichment.extraSpans).toEqual([])
|
|
expect(enrichment.perCall.size).toBe(0)
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('survives an exporter emitting a bare string, and calls without tools', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'hostile2-'))
|
|
try {
|
|
const plug = join(dir, 'weird')
|
|
await mkdir(join(plug, 'exporters'), { recursive: true })
|
|
await writeFile(join(plug, 'codeburn-plugin.json'), JSON.stringify({
|
|
name: 'weird', version: '0.1.0', cliCompat: '>=0.9.22',
|
|
capabilities: { commands: [], syncAttributes: [{ key: 'weird.f', disclosure: 'probe field for tests' }], payloadSections: [], spanKinds: [] },
|
|
}))
|
|
await writeFile(join(plug, 'exporters', 'sync.mjs'),
|
|
'process.stdout.write(JSON.stringify("hello"))\nprocess.stdin.resume()\nprocess.stdin.on("end", () => process.exit(0))\n')
|
|
const loads = await loadPlugins(dir, '0.9.22', { ...process.env, CODEBURN_PLUGIN_DEV: '1' })
|
|
const calls = [{ call: { timestamp: '2026-08-28T10:00:00.000Z', deduplicationKey: 'k1' }, sessionId: 's', workingDirectory: '/w', session: null }] as never[]
|
|
const enrichment = await collectPluginEnrichment(loads, calls, 10_000)
|
|
expect(enrichment.extraSpans).toEqual([])
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|