diff --git a/bun.lock b/bun.lock index 6dc17ff..99288c5 100644 --- a/bun.lock +++ b/bun.lock @@ -84,7 +84,10 @@ "neverthrow": "^8.2.0", "open": "^11.0.0", "pino": "^10.3.1", + "remark-parse": "^11.0.0", "semver": "^7.7.4", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", "zod": "^4.3.6", }, "devDependencies": { diff --git a/packages/annotation/src/AnnotatedMarkdown.test.tsx b/packages/annotation/src/AnnotatedMarkdown.test.tsx new file mode 100644 index 0000000..4c255b5 --- /dev/null +++ b/packages/annotation/src/AnnotatedMarkdown.test.tsx @@ -0,0 +1,42 @@ +import { asset } from '@contextbridge/shared/testFactories'; +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { AnnotatedMarkdown } from './AnnotatedMarkdown.tsx'; + +describe('AnnotatedMarkdown image rendering', () => { + const fixtureAsset = asset.build({ + id: 'abc123', + originalPath: '/tmp/fixture.png', + }); + + it('rewrites local-path img srcs to /assets/ when a matching asset is provided', () => { + render( + ()} + />, + ); + const img = screen.getByAltText('fixture'); + expect(img.getAttribute('src')).toBe('/assets/abc123'); + }); + + it('leaves remote img srcs unchanged', () => { + render( + ()} + />, + ); + const img = screen.getByAltText('cat'); + expect(img.getAttribute('src')).toBe('https://example.com/cat.png'); + }); + + it('leaves local-path img srcs unchanged when assets prop is omitted', () => { + render(()} />); + const img = screen.getByAltText('diagram'); + expect(img.getAttribute('src')).toBe('/tmp/missing.png'); + }); +}); diff --git a/packages/annotation/src/AnnotatedMarkdown.tsx b/packages/annotation/src/AnnotatedMarkdown.tsx index 858e8a1..677ff1f 100644 --- a/packages/annotation/src/AnnotatedMarkdown.tsx +++ b/packages/annotation/src/AnnotatedMarkdown.tsx @@ -1,4 +1,4 @@ -import type { AnnotationTargetKind } from '@contextbridge/shared/annotationSchema'; +import type { AnnotationTargetKind, Asset } from '@contextbridge/shared/annotationSchema'; import { cn } from '@contextbridge/ui/lib/utils'; import { createElement } from 'react'; import type { ComponentPropsWithoutRef, ComponentType, JSX, Ref } from 'react'; @@ -14,9 +14,23 @@ export interface AnnotatedMarkdownProps { content: string; containerRef: Ref; onMouseUp?: () => void; + assets?: Asset[]; } -export function AnnotatedMarkdown({ content, containerRef, onMouseUp }: AnnotatedMarkdownProps) { +export function AnnotatedMarkdown({ content, containerRef, onMouseUp, assets }: AnnotatedMarkdownProps) { + const assetsByPath = new Map(); + for (const asset of assets ?? []) { + assetsByPath.set(asset.originalPath, asset); + } + + const components = { + ...markdownComponents, + img: ({ src, alt, ...rest }: ComponentPropsWithoutRef<'img'>) => { + const match = assetsByPath.get(src ?? ''); + return {alt}; + }, + }; + return (
{content} diff --git a/packages/annotation/src/App.tsx b/packages/annotation/src/App.tsx index 6bfe35f..77db6c6 100644 --- a/packages/annotation/src/App.tsx +++ b/packages/annotation/src/App.tsx @@ -120,6 +120,7 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap key={payload.content} containerRef={annotationInteractions.handlePlanContainer} content={payload.content} + assets={payload.assets} onMouseUp={annotationInteractions.handleSelectionCapture} /> ) : ( diff --git a/packages/cli/package.json b/packages/cli/package.json index 177d07a..8f5bfac 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,7 +32,10 @@ "neverthrow": "^8.2.0", "open": "^11.0.0", "pino": "^10.3.1", + "remark-parse": "^11.0.0", "semver": "^7.7.4", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/packages/cli/src/annotation/extractAssets.test.ts b/packages/cli/src/annotation/extractAssets.test.ts new file mode 100644 index 0000000..bf2217e --- /dev/null +++ b/packages/cli/src/annotation/extractAssets.test.ts @@ -0,0 +1,183 @@ +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { type TestContext, createStubContext, readLogs } from '#src/testHelpers/index.ts'; +import { MAX_ASSET_BYTES, MAX_TOTAL_ASSET_BYTES, extractAssets } from './extractAssets.ts'; + +describe('extractAssets', () => { + let stub: TestContext; + let tmp: string; + + beforeEach(() => { + stub = createStubContext(); + tmp = mkdtempSync(join(tmpdir(), 'cb-extract-assets-')); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns an asset for a single absolute-path image reference that exists on disk', async () => { + const imgPath = join(tmp, 'a.png'); + writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const assets = await extractAssets(stub.context, { + content: `# plan\n\n![diagram](${imgPath})\n`, + }); + + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + originalPath: imgPath, + mimeType: 'image/png', + }); + expect(assets[0]?.id).toMatch(/^[0-9a-f]{12}$/); + expect(assets[0]?.dataBase64).toBe('iVBORw=='); + }); + + it('returns an asset for a file URL image reference', async () => { + const imgPath = join(tmp, 'file-url.png'); + writeFileSync(imgPath, Buffer.from([0x03, 0x04])); + const imgUrl = pathToFileURL(imgPath).toString(); + + const assets = await extractAssets(stub.context, { + content: `![diagram](${imgUrl})\n`, + }); + + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ + originalPath: imgUrl, + mimeType: 'image/png', + dataBase64: 'AwQ=', + }); + }); + + it('dedupes two references with the same as-written path string', async () => { + const imgPath = join(tmp, 'b.png'); + writeFileSync(imgPath, Buffer.from([0x01, 0x02])); + + const assets = await extractAssets(stub.context, { + content: `![one](${imgPath})\n\n![two](${imgPath})\n`, + }); + + expect(assets).toHaveLength(1); + }); + + it('skips non-local URLs silently', async () => { + const assets = await extractAssets(stub.context, { + content: '![cat](https://example.com/cat.png)\n![inline](data:image/png;base64,iVBORw0KGgo=)\n', + }); + + expect(assets).toHaveLength(0); + }); + + it('skips paths whose extension is not in the allowlist (including SVG)', async () => { + const svg = join(tmp, 'c.svg'); + writeFileSync(svg, ''); + + const assets = await extractAssets(stub.context, { + content: `![bad](${svg})\n![nope](/x.txt)\n`, + }); + + expect(assets).toHaveLength(0); + }); + + it('resolves relative paths against sourcePath when sourcePath is set', async () => { + writeFileSync(join(tmp, 'side.png'), Buffer.from([0xff])); + const sourcePath = join(tmp, 'plan.md'); + writeFileSync(sourcePath, ''); + + const assets = await extractAssets(stub.context, { + content: '![side](./side.png)\n', + sourcePath, + }); + + expect(assets).toHaveLength(1); + expect(assets[0]?.originalPath).toBe('./side.png'); + }); + + it('resolves relative paths against process.cwd() when sourcePath is unset (stdin case)', async () => { + const originalCwd = process.cwd(); + writeFileSync(join(tmp, 'side.png'), Buffer.from([0xfe])); + process.chdir(tmp); + try { + const assets = await extractAssets(stub.context, { + content: '![side](./side.png)\n', + }); + expect(assets).toHaveLength(1); + expect(assets[0]?.originalPath).toBe('./side.png'); + } finally { + process.chdir(originalCwd); + } + }); + + it('logs error and skips when a referenced image cannot be read', async () => { + const missing = join(tmp, 'missing.png'); + + const assets = await extractAssets(stub.context, { + content: `![ghost](${missing})\n`, + }); + + expect(assets).toHaveLength(0); + const logs = readLogs(stub.logs); + expect(logs.some((log) => log.level === 50 && log.msg === 'failed to read referenced image')).toBe(true); + }); + + it('logs warning and skips empty image files', async () => { + const empty = join(tmp, 'empty.png'); + writeFileSync(empty, Buffer.from([])); + + const assets = await extractAssets(stub.context, { + content: `![empty](${empty})\n`, + }); + + expect(assets).toHaveLength(0); + const logs = readLogs(stub.logs); + expect(logs.some((log) => log.level === 40 && log.msg === 'referenced image is empty')).toBe(true); + }); + + it('logs warning and skips images over the per-asset size limit', async () => { + const oversized = join(tmp, 'oversized.png'); + writeFileSync(oversized, ''); + truncateSync(oversized, MAX_ASSET_BYTES + 1); + + const assets = await extractAssets(stub.context, { + content: `![oversized](${oversized})\n`, + }); + + expect(assets).toHaveLength(0); + const logs = readLogs(stub.logs); + expect(logs.some((log) => log.level === 40 && log.msg === 'referenced image exceeds per-asset size limit')).toBe( + true, + ); + }); + + it('logs warning and skips images that would exceed the total size limit', async () => { + const first = join(tmp, 'first.png'); + const second = join(tmp, 'second.png'); + const overflow = join(tmp, 'overflow.png'); + writeFileSync(first, ''); + writeFileSync(second, ''); + writeFileSync(overflow, ''); + truncateSync(first, MAX_ASSET_BYTES); + truncateSync(second, MAX_ASSET_BYTES); + truncateSync(overflow, MAX_TOTAL_ASSET_BYTES - 2 * MAX_ASSET_BYTES + 1); + + const assets = await extractAssets(stub.context, { + content: `![first](${first})\n![second](${second})\n![overflow](${overflow})\n`, + }); + + expect(assets).toHaveLength(2); + const logs = readLogs(stub.logs); + expect(logs.some((log) => log.level === 40 && log.msg === 'referenced images exceed total size limit')).toBe(true); + }); + + it('does not extract images inside fenced code blocks', async () => { + const assets = await extractAssets(stub.context, { + content: '```\n![not-an-image](/should/not/extract.png)\n```\n', + }); + + expect(assets).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/annotation/extractAssets.ts b/packages/cli/src/annotation/extractAssets.ts new file mode 100644 index 0000000..53e7574 --- /dev/null +++ b/packages/cli/src/annotation/extractAssets.ts @@ -0,0 +1,301 @@ +import { createHash } from 'node:crypto'; +import type { Stats } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { dirname, extname, isAbsolute, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ASSET_FILE_EXTENSIONS, type Asset, AssetMimeTypeSchema } from '@contextbridge/shared/annotationSchema'; +import type { Image } from 'mdast'; +import { Result, ResultAsync, err, ok } from 'neverthrow'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; +import { visit } from 'unist-util-visit'; +import type { CliContext } from '#src/context.ts'; + +export interface ExtractAssetsInput { + content: string; + sourcePath?: string; +} + +const MIB = 1024 * 1024; +export const MAX_ASSET_BYTES = 10 * MIB; +export const MAX_TOTAL_ASSET_BYTES = 25 * MIB; + +export async function extractAssets(ctx: CliContext, input: ExtractAssetsInput): Promise { + const { content, sourcePath } = input; + const candidates = collectImageCandidates(ctx, content, sourcePath); + return loadAssets(ctx, candidates); +} + +type AssetExtractionIssue = + | { kind: 'unsupportedScheme'; urlString: string } + | { kind: 'unresolvablePath'; urlString: string } + | { kind: 'unsupportedExtension'; urlString: string; readPath: string } + | { kind: 'nonAbsolutePath'; urlString: string; readPath: string } + | { kind: 'statFailed'; urlString: string; readPath: string; err: unknown } + | { kind: 'readFailed'; urlString: string; readPath: string; err: unknown } + | { kind: 'notAFile'; urlString: string; readPath: string } + | { kind: 'unsupportedMime'; urlString: string; readPath: string; mime: string } + | { kind: 'emptyFile'; urlString: string; readPath: string } + | { kind: 'exceedsPerAssetLimit'; urlString: string; readPath: string; bytes: number } + | { kind: 'exceedsTotalLimit'; urlString: string; readPath: string; bytes: number; totalAssetBytes: number }; + +type AssetFilter = (value: T) => Result; + +const ALLOWED_EXTENSIONS: ReadonlySet = new Set(ASSET_FILE_EXTENSIONS); + +interface ImageCandidate { + urlString: string; + readPath: string; +} + +interface AssetAccumulator { + assets: Asset[]; + totalAssetBytes: number; +} + +interface LoadedAsset { + asset: Asset; + byteLength: number; +} + +interface ResolvedImageReadPath { + readPath: string; + resolvedAgainstCwd: boolean; +} + +const safeDecodeURIComponent = Result.fromThrowable( + (value: string) => decodeURIComponent(value), + (cause) => cause, +); + +const safeFileUrlToPath = Result.fromThrowable( + (value: string) => fileURLToPath(value), + (cause) => cause, +); + +function collectImageCandidates(ctx: CliContext, content: string, sourcePath: string | undefined): ImageCandidate[] { + const candidates: ImageCandidate[] = []; + for (const urlString of unique(collectMarkdownImageUrls(content))) { + const result = toImageCandidate(ctx, urlString, sourcePath); + if (result.isOk()) { + candidates.push(result.value); + } else { + logIssue(ctx, result.error); + } + } + return candidates; +} + +function collectMarkdownImageUrls(content: string): string[] { + const tree = unified().use(remarkParse).parse(content); + const imageUrls: string[] = []; + + visit(tree, 'image', (node: Image) => { + if (node.url) imageUrls.push(node.url); + }); + + return imageUrls; +} + +function toImageCandidate( + ctx: CliContext, + urlString: string, + sourcePath: string | undefined, +): Result { + const { logger } = ctx; + + const scheme = detectScheme(urlString); + if (scheme && scheme !== 'file') return err({ kind: 'unsupportedScheme', urlString }); + + const resolved = resolveImageReadPath(urlString, sourcePath); + if (!resolved) return err({ kind: 'unresolvablePath', urlString }); + if (resolved.resolvedAgainstCwd) { + logger.debug({ urlString, cwd: process.cwd() }, 'resolving relative image ref against cwd (no sourcePath)'); + } + + const readPath = normalize(resolved.readPath); + const candidate = { urlString, readPath }; + + return validateExtension(candidate) + .andThen(() => validateAbsolute(candidate)) + .map(() => candidate); +} + +const validateExtension: AssetFilter = ({ urlString, readPath }) => { + const ext = extname(readPath).toLowerCase(); + if (ALLOWED_EXTENSIONS.has(ext)) return ok(undefined); + return err({ kind: 'unsupportedExtension', urlString, readPath }); +}; + +const validateAbsolute: AssetFilter = ({ urlString, readPath }) => { + if (isAbsolute(readPath)) return ok(undefined); + return err({ kind: 'nonAbsolutePath', urlString, readPath }); +}; + +async function loadAssets(ctx: CliContext, candidates: readonly ImageCandidate[]): Promise { + const accumulator = await candidates.reduce>( + async (pendingAccumulator, candidate) => appendCandidateAsset(ctx, await pendingAccumulator, candidate), + Promise.resolve({ assets: [], totalAssetBytes: 0 }), + ); + return accumulator.assets; +} + +async function appendCandidateAsset( + ctx: CliContext, + accumulator: AssetAccumulator, + candidate: ImageCandidate, +): Promise { + const result = await readAsset(candidate, accumulator.totalAssetBytes); + if (result.isErr()) { + logIssue(ctx, result.error); + return accumulator; + } + + const { asset, byteLength } = result.value; + return { + assets: [...accumulator.assets, asset], + totalAssetBytes: accumulator.totalAssetBytes + byteLength, + }; +} + +async function readAsset( + candidate: ImageCandidate, + totalAssetBytes: number, +): Promise> { + const { urlString, readPath } = candidate; + + const statResult = await ResultAsync.fromPromise(stat(readPath), (cause) => cause); + if (statResult.isErr()) return err({ kind: 'statFailed', urlString, readPath, err: statResult.error }); + + const fileTypeCheck = validateIsFile({ ...candidate, info: statResult.value }); + if (fileTypeCheck.isErr()) return err(fileTypeCheck.error); + + const statSizeCheck = validateSizeLimits({ ...candidate, byteLength: statResult.value.size, totalAssetBytes }); + if (statSizeCheck.isErr()) return err(statSizeCheck.error); + + const file = Bun.file(readPath); + const mimeResult = AssetMimeTypeSchema.safeParse(file.type); + if (!mimeResult.success) return err({ kind: 'unsupportedMime', urlString, readPath, mime: file.type }); + + const bytesResult = await ResultAsync.fromPromise(file.bytes(), (cause) => cause); + if (bytesResult.isErr()) return err({ kind: 'readFailed', urlString, readPath, err: bytesResult.error }); + + const bytes = bytesResult.value; + const bytesSizeCheck = validateSizeLimits({ ...candidate, byteLength: bytes.byteLength, totalAssetBytes }); + if (bytesSizeCheck.isErr()) return err(bytesSizeCheck.error); + + return ok({ + asset: createAsset(candidate, bytes, mimeResult.data), + byteLength: bytes.byteLength, + }); +} + +const validateIsFile: AssetFilter = ({ urlString, readPath, info }) => { + if (info.isFile()) return ok(undefined); + return err({ kind: 'notAFile', urlString, readPath }); +}; + +const validateSizeLimits: AssetFilter = ({ + urlString, + readPath, + byteLength, + totalAssetBytes, +}) => { + if (byteLength === 0) return err({ kind: 'emptyFile', urlString, readPath }); + if (byteLength > MAX_ASSET_BYTES) + return err({ kind: 'exceedsPerAssetLimit', urlString, readPath, bytes: byteLength }); + if (totalAssetBytes + byteLength > MAX_TOTAL_ASSET_BYTES) { + return err({ kind: 'exceedsTotalLimit', urlString, readPath, bytes: byteLength, totalAssetBytes }); + } + return ok(undefined); +}; + +function logIssue(ctx: CliContext, issue: AssetExtractionIssue): void { + const { logger } = ctx; + switch (issue.kind) { + case 'unsupportedScheme': + case 'unresolvablePath': + case 'unsupportedExtension': + case 'nonAbsolutePath': + return; + case 'statFailed': + case 'readFailed': + logger.error( + { err: issue.err, urlString: issue.urlString, readPath: issue.readPath }, + 'failed to read referenced image', + ); + return; + case 'notAFile': + logger.warn({ urlString: issue.urlString, readPath: issue.readPath }, 'referenced image is not a file'); + return; + case 'unsupportedMime': + logger.error( + { urlString: issue.urlString, readPath: issue.readPath, mime: issue.mime }, + 'image has unsupported mime type', + ); + return; + case 'emptyFile': + logger.warn({ urlString: issue.urlString, readPath: issue.readPath }, 'referenced image is empty'); + return; + case 'exceedsPerAssetLimit': + logger.warn( + { urlString: issue.urlString, readPath: issue.readPath, bytes: issue.bytes }, + 'referenced image exceeds per-asset size limit', + ); + return; + case 'exceedsTotalLimit': + logger.warn( + { + urlString: issue.urlString, + readPath: issue.readPath, + bytes: issue.bytes, + totalAssetBytes: issue.totalAssetBytes, + }, + 'referenced images exceed total size limit', + ); + return; + } +} + +function createAsset(candidate: ImageCandidate, bytes: Uint8Array, mimeType: Asset['mimeType']): Asset { + const id = createHash('sha256').update(bytes).digest('hex').slice(0, 12); + + return { + id, + originalPath: candidate.urlString, + mimeType, + dataBase64: Buffer.from(bytes).toString('base64'), + }; +} + +function unique(values: readonly T[]): T[] { + return [...new Set(values)]; +} + +function resolveImageReadPath(urlString: string, sourcePath: string | undefined): ResolvedImageReadPath | null { + const scheme = detectScheme(urlString); + if (scheme === 'file') { + const decoded = safeFileUrlToPath(urlString); + return decoded.isOk() ? { readPath: decoded.value, resolvedAgainstCwd: false } : null; + } + + let absolutePath: string; + let resolvedAgainstCwd = false; + if (urlString.startsWith('/')) { + absolutePath = urlString; + } else if (sourcePath) { + absolutePath = resolve(dirname(sourcePath), urlString); + } else { + absolutePath = resolve(process.cwd(), urlString); + resolvedAgainstCwd = true; + } + + const decoded = safeDecodeURIComponent(absolutePath); + return decoded.isOk() ? { readPath: decoded.value, resolvedAgainstCwd } : null; +} + +function detectScheme(value: string): string | null { + if (/^[a-z]:/i.test(value)) return null; + const match = /^([a-z][a-z0-9+.-]*):/i.exec(value); + return match ? match[1]!.toLowerCase() : null; +} diff --git a/packages/cli/src/annotation/runAnnotation.test.ts b/packages/cli/src/annotation/runAnnotation.test.ts index 7f37e50..3e4e94f 100644 --- a/packages/cli/src/annotation/runAnnotation.test.ts +++ b/packages/cli/src/annotation/runAnnotation.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { AnnotationSubmission } from '@contextbridge/shared/annotationSchema'; import { createDeferred } from '@contextbridge/shared/testHelpers'; import { describe, expect, it } from 'bun:test'; @@ -147,4 +150,25 @@ describe('runAnnotation', () => { ).resolves.toEqual(deps.submission); expect(deps.payloads[0]?.metadata?.sourcePath).toBeUndefined(); }); + + it('extracts referenced local images into payload.assets while preserving original content', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'cb-runannotation-img-')); + const imgPath = join(tmp, 'fixture.png'); + writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + try { + const content = `# plan\n\n![diagram](${imgPath})\n`; + const { context } = createStubContext(); + const deps = createAnnotationDependencies(); + + await runAnnotation(context, { content, contentKind: 'plan', entrypoint: 'plan_command' }, deps); + + const captured = deps.payloads[0]; + expect(captured?.assets).toHaveLength(1); + expect(captured?.assets?.[0]?.originalPath).toBe(imgPath); + expect(captured?.content).toBe(content); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/annotation/runAnnotation.ts b/packages/cli/src/annotation/runAnnotation.ts index 6a901f4..39ddd2a 100644 --- a/packages/cli/src/annotation/runAnnotation.ts +++ b/packages/cli/src/annotation/runAnnotation.ts @@ -12,6 +12,7 @@ import type { FrontendConfig } from '@contextbridge/shared/frontendConfigSchema' import { nowInstant } from '@contextbridge/shared/time'; import type { UpdateNotice } from '@contextbridge/shared/updateNoticeSchema'; import type { CliContext } from '#src/context.ts'; +import { extractAssets } from './extractAssets.ts'; import { extractDocumentTitle } from './extractDocumentTitle.ts'; export class AnnotationInterruptedError extends Error { @@ -74,6 +75,14 @@ export async function runAnnotation( }; analytics.capture('plan_review_started', { source: args.entrypoint }); + const assets = await extractAssets(ctx, { + content: args.content, + sourcePath: args.sourcePath, + }); + if (assets.length > 0) { + payload.assets = assets; + } + let server: RunningServer | null = null; let removeSigintHandler = () => {}; let closePromise: Promise | null = null; diff --git a/packages/cli/src/commands/plan.test.ts b/packages/cli/src/commands/plan.test.ts index c2b4e40..0276dcd 100644 --- a/packages/cli/src/commands/plan.test.ts +++ b/packages/cli/src/commands/plan.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve as resolvePath } from 'node:path'; import type { AnnotationSubmission } from '@contextbridge/shared/annotationSchema'; import { annotationSubmission } from '@contextbridge/shared/testFactories'; import { createDeferred } from '@contextbridge/shared/testHelpers'; @@ -58,6 +58,19 @@ describe('plan handler', () => { expect(deps.payloads[0]?.content).toBe('# From positional path\n'); }); + it('forwards args.path as sourcePath to runAnnotation', async () => { + const planPath = join(tmp, 'plan.md'); + writeFileSync(planPath, '# plan\n'); + + const { context, io } = createStubContext(); + const deps = createAnnotationDependencies(); + io.stdin.isTTY = true; + + await runPlan(context, { path: planPath }, deps); + + expect(deps.payloads[0]?.metadata?.sourcePath).toBe(resolvePath(planPath)); + }); + it('prefers the positional path when stdin is also piped', async () => { const planPath = join(tmp, 'plan.md'); writeFileSync(planPath, 'from positional path'); diff --git a/packages/cli/src/commands/plan.ts b/packages/cli/src/commands/plan.ts index 3c715d3..9597887 100644 --- a/packages/cli/src/commands/plan.ts +++ b/packages/cli/src/commands/plan.ts @@ -1,3 +1,4 @@ +import { resolve as resolvePath } from 'node:path'; import { getErrorMessage } from '@contextbridge/shared/errors'; import { type Command, CommanderError, InvalidArgumentError } from 'commander'; import { @@ -42,12 +43,14 @@ export async function runPlan(ctx: CliContext, args: PlanArgs, deps?: Annotation abort(ctx, 'plan', 'input', 'plan content is empty'); } + const sourcePath = path ? resolvePath(path) : undefined; + logger.info({ source, bytes: Buffer.byteLength(content, 'utf8') }, 'plan received'); try { const submission = await runAnnotation( ctx, - { content, contentKind: 'plan', entrypoint: 'plan_command', port }, + { content, contentKind: 'plan', entrypoint: 'plan_command', port, sourcePath }, deps, ); io.writeStdout(formatAgentResponse(PLAN_TEMPLATES, submission, content)); diff --git a/packages/server/src/annotation.ts b/packages/server/src/annotation.ts index fb7f4ce..e965754 100644 --- a/packages/server/src/annotation.ts +++ b/packages/server/src/annotation.ts @@ -1,6 +1,7 @@ import type { AnnotationPayload, AnnotationSubmission } from '@contextbridge/shared/annotationSchema'; import type { FrontendConfig } from '@contextbridge/shared/frontendConfigSchema'; import type { ServerContext } from './context.ts'; +import { handleAsset } from './routes/assets.ts'; import { handleConfig } from './routes/config.ts'; import { handleHtml } from './routes/html.ts'; import { handlePayload } from './routes/payload.ts'; @@ -36,6 +37,7 @@ export function startServer(ctx: ServerContext, opts: StartServerOptions): Runni port: resolveListenPort(opts), routes: { '/': { GET: () => handleHtml(ctx, html) }, + '/assets/:id': { GET: (req) => handleAsset(payload, req.params.id) }, '/config': { GET: () => handleConfig(config) }, '/payload': { GET: () => handlePayload(payload) }, '/update-notice': { GET: () => handleUpdateNotice(checkForUpdate) }, diff --git a/packages/server/src/routes/assets.test.ts b/packages/server/src/routes/assets.test.ts new file mode 100644 index 0000000..5cbd23b --- /dev/null +++ b/packages/server/src/routes/assets.test.ts @@ -0,0 +1,45 @@ +import { fakeBaseContext } from '@contextbridge/context/testHelpers'; +import { annotationPayload, asset } from '@contextbridge/shared/testFactories'; +import { describe, expect, it } from 'bun:test'; +import { withServer } from '#src/testHelpers.ts'; + +describe('GET /assets/:id', () => { + const ctx = fakeBaseContext(); + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const pngAsset = asset.build({ + id: 'abc123', + originalPath: '/x.png', + dataBase64: Buffer.from(pngBytes).toString('base64'), + }); + + it('serves bytes for a known asset id with the right headers', async () => { + const payload = annotationPayload.build({ assets: [pngAsset] }); + + await withServer(ctx, { payload }, async (running) => { + const res = await fetch(`${running.url}/assets/abc123`); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(pngBytes); + }); + }); + + it('returns 404 for an unknown asset id', async () => { + const payload = annotationPayload.build({ assets: [pngAsset] }); + + await withServer(ctx, { payload }, async (running) => { + const res = await fetch(`${running.url}/assets/does-not-exist`); + + expect(res.status).toBe(404); + }); + }); + + it('returns 404 when the payload has no assets field', async () => { + await withServer(ctx, { payload: annotationPayload.build() }, async (running) => { + const res = await fetch(`${running.url}/assets/abc123`); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/packages/server/src/routes/assets.ts b/packages/server/src/routes/assets.ts new file mode 100644 index 0000000..0e13b90 --- /dev/null +++ b/packages/server/src/routes/assets.ts @@ -0,0 +1,15 @@ +import type { AnnotationPayload } from '@contextbridge/shared/annotationSchema'; + +export function handleAsset(payload: AnnotationPayload, id: string): Response { + const asset = payload.assets?.find((candidate) => candidate.id === id); + if (!asset) { + return new Response(null, { status: 404 }); + } + + return new Response(Buffer.from(asset.dataBase64, 'base64'), { + headers: { + 'content-type': asset.mimeType, + 'cache-control': 'no-store', + }, + }); +} diff --git a/packages/shared/src/annotationSchema.test.ts b/packages/shared/src/annotationSchema.test.ts index df723a8..4ac6f94 100644 --- a/packages/shared/src/annotationSchema.test.ts +++ b/packages/shared/src/annotationSchema.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'bun:test'; import { + ASSET_FILE_EXTENSIONS, + ASSET_MIME_TYPES, AnnotationEntrypointSchema, AnnotationPayloadSchema, AnnotationSubmissionSchema, + AssetFileExtensionSchema, + AssetSchema, ContentKindSchema, } from './annotationSchema.ts'; -import { annotationAnchor, annotationThread, commentMessage, globalThread } from './testFactories.ts'; +import { annotationAnchor, annotationThread, asset, commentMessage, globalThread } from './testFactories.ts'; import { Temporal, instantFromString, instantToString } from './time.ts'; describe('AnnotationSubmissionSchema', () => { @@ -173,6 +177,76 @@ describe('AnnotationPayloadSchema', () => { }); }); +describe('AssetSchema', () => { + it('parses a valid asset record', () => { + const parsed = AssetSchema.parse( + asset.build({ id: 'abc123', originalPath: '/Users/alice/diagram.png', dataBase64: 'iVBORw0KGgo=' }), + ); + + expect(parsed).toMatchObject({ + id: 'abc123', + originalPath: '/Users/alice/diagram.png', + mimeType: 'image/png', + dataBase64: 'iVBORw0KGgo=', + }); + }); + + it('rejects an empty id', () => { + expect(() => AssetSchema.parse(asset.build({ id: '' }))).toThrow(); + }); + + it('rejects an empty originalPath', () => { + expect(() => AssetSchema.parse(asset.build({ originalPath: '' }))).toThrow(); + }); + + it('rejects an unsupported mime type', () => { + expect(() => + AssetSchema.parse({ ...asset.build({ originalPath: '/x.svg' }), mimeType: 'image/svg+xml' }), + ).toThrow(); + }); + + it('rejects non-base64 asset data', () => { + expect(() => AssetSchema.parse(asset.build({ dataBase64: 'not base64!' }))).toThrow(); + }); + + it('accepts each allowed mime type', () => { + for (const mimeType of ASSET_MIME_TYPES) { + expect(() => AssetSchema.parse(asset.build({ mimeType }))).not.toThrow(); + } + }); +}); + +describe('AssetFileExtensionSchema', () => { + it('accepts each allowed file extension', () => { + for (const extension of ASSET_FILE_EXTENSIONS) { + expect(AssetFileExtensionSchema.parse(extension)).toBe(extension); + } + }); + + it('rejects svg files', () => { + expect(() => AssetFileExtensionSchema.parse('.svg')).toThrow(); + }); +}); + +describe('AnnotationPayloadSchema with assets', () => { + it('accepts a payload with an optional assets array', () => { + const parsed = AnnotationPayloadSchema.parse({ + content: '# plan', + contentKind: 'plan', + assets: [asset.build({ id: 'abc', originalPath: '/x.png' })], + }); + expect(parsed.assets).toHaveLength(1); + }); + + it('accepts a payload with no assets field (backward compatible)', () => { + const parsed = AnnotationPayloadSchema.parse({ + content: '# plan', + contentKind: 'plan', + }); + expect(parsed.assets).toBeUndefined(); + }); +}); + describe('ContentKindSchema', () => { it('parses plan', () => { expect(ContentKindSchema.parse('plan')).toBe('plan'); diff --git a/packages/shared/src/annotationSchema.ts b/packages/shared/src/annotationSchema.ts index 2129389..e4e3dca 100644 --- a/packages/shared/src/annotationSchema.ts +++ b/packages/shared/src/annotationSchema.ts @@ -133,6 +133,37 @@ export type AnnotationSubmission = z.infer; export const AnnotationEntrypointSchema = z.enum(['plan_command', 'hook_claude', 'hook_codex', 'open_command']); export type AnnotationEntrypoint = z.infer; +export const ASSET_FILE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.bmp'] as const; +export const AssetFileExtensionSchema = z.enum(ASSET_FILE_EXTENSIONS); +export type AssetFileExtension = z.infer; + +export const ASSET_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/x-icon', + 'image/x-ms-bmp', +] as const; +export const AssetMimeTypeSchema = z.enum(ASSET_MIME_TYPES); +export type AssetMimeType = z.infer; + +export const AssetDataBase64Schema = z + .string() + .trim() + .nonempty() + .refine((value) => isStandardBase64(value), { message: 'must be standard base64-encoded asset data' }); +export type AssetDataBase64 = z.infer; + +export const AssetSchema = z.object({ + id: z.string().nonempty(), + originalPath: z.string().nonempty(), + mimeType: AssetMimeTypeSchema, + dataBase64: AssetDataBase64Schema, +}); +export type Asset = z.infer; + export const AnnotationPayloadSchema = z.object({ content: z.string(), title: z @@ -147,5 +178,10 @@ export const AnnotationPayloadSchema = z.object({ sourcePath: z.string().trim().nonempty().optional(), }) .optional(), + assets: z.array(AssetSchema).optional(), }); export type AnnotationPayload = z.infer; + +function isStandardBase64(value: string): boolean { + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value); +} diff --git a/packages/shared/src/testFactories.ts b/packages/shared/src/testFactories.ts index 49709eb..48d2d7f 100644 --- a/packages/shared/src/testFactories.ts +++ b/packages/shared/src/testFactories.ts @@ -2,6 +2,7 @@ import { Factory } from 'fishery'; import type { AnnotationPayload, AnnotationSubmission, + Asset, CommentAuthor, CommentMessage, CommentThread, @@ -15,6 +16,13 @@ export const LOCAL_AUTHOR = { displayName: 'You', }; +export const asset = Factory.define(() => ({ + id: 'asset_01', + originalPath: '/tmp/diagram.png', + mimeType: 'image/png', + dataBase64: 'iVBORw0KGgo=', +})); + export const reviewer = Factory.define(() => ({ id: 'reviewer', kind: 'user',