Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.
Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.
Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.
31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
262 lines
8.8 KiB
TypeScript
262 lines
8.8 KiB
TypeScript
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
decideHtmlArtifactWrite,
|
|
deriveHtmlSlug,
|
|
deriveHtmlTitle,
|
|
deriveMarkdownSlug,
|
|
detectHtmlArtifact,
|
|
HTML_ARTIFACT_MAX_BYTES,
|
|
writeHtmlArtifact,
|
|
writeMarkdownArtifact,
|
|
} from '../artifacts.js';
|
|
import { PathScopeError } from '../path_guard.js';
|
|
|
|
describe('deriveMarkdownSlug', () => {
|
|
it('uses the first # heading when present', () => {
|
|
expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
|
|
});
|
|
|
|
it('falls back to first 6 words', () => {
|
|
const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
|
|
expect(s).toBe('the-quick-brown-fox-jumps-over');
|
|
});
|
|
|
|
it('returns "artifact" for empty input', () => {
|
|
expect(deriveMarkdownSlug('')).toBe('artifact');
|
|
});
|
|
|
|
it('caps at 60 chars and lowercases', () => {
|
|
const long = '# ' + 'A'.repeat(200);
|
|
const s = deriveMarkdownSlug(long);
|
|
expect(s.length).toBeLessThanOrEqual(60);
|
|
expect(s).toMatch(/^[a-z0-9-]+$/);
|
|
});
|
|
|
|
it('strips trailing punctuation', () => {
|
|
expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
|
|
});
|
|
});
|
|
|
|
describe('deriveHtmlSlug', () => {
|
|
it('prefers payload.title when set', () => {
|
|
expect(
|
|
deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
|
|
).toBe('my-title');
|
|
});
|
|
|
|
it('falls back to <title> tag', () => {
|
|
expect(
|
|
deriveHtmlSlug({
|
|
html_content: '<html><head><title>Page Title</title></head></html>',
|
|
title: null,
|
|
}),
|
|
).toBe('page-title');
|
|
});
|
|
|
|
it('falls back to first <h1> when no <title>', () => {
|
|
expect(
|
|
deriveHtmlSlug({
|
|
html_content: '<html><body><h1>Heading One</h1></body></html>',
|
|
title: null,
|
|
}),
|
|
).toBe('heading-one');
|
|
});
|
|
|
|
it('falls back to inner text words', () => {
|
|
expect(
|
|
deriveHtmlSlug({
|
|
html_content: '<div>one two three four five six seven</div>',
|
|
title: null,
|
|
}),
|
|
).toBe('one-two-three-four-five-six');
|
|
});
|
|
});
|
|
|
|
describe('deriveHtmlTitle', () => {
|
|
it('returns <title> content', () => {
|
|
expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
|
|
});
|
|
|
|
it('falls back to <h1>', () => {
|
|
expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
|
|
});
|
|
|
|
it('falls back to first 80 chars of inner text', () => {
|
|
const html = '<div>' + 'x '.repeat(100) + '</div>';
|
|
const t = deriveHtmlTitle(html);
|
|
expect(t).not.toBeNull();
|
|
expect(t!.length).toBeLessThanOrEqual(80);
|
|
});
|
|
|
|
it('returns null for empty html', () => {
|
|
expect(deriveHtmlTitle('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('detectHtmlArtifact', () => {
|
|
it('detects <!DOCTYPE html> prefix case-insensitively', () => {
|
|
const html = '<!doctype HTML><html><body>x</body></html>';
|
|
expect(detectHtmlArtifact(html)).toBe(html);
|
|
});
|
|
|
|
it('strips leading/trailing whitespace before matching', () => {
|
|
const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
|
|
expect(detectHtmlArtifact(html)).toBe(html.trim());
|
|
});
|
|
|
|
it('detects fenced ```html block wrapping entire message', () => {
|
|
const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
|
|
expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
|
|
});
|
|
|
|
it('rejects plain markdown', () => {
|
|
expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
|
|
});
|
|
|
|
it('rejects message with prose before the doctype', () => {
|
|
expect(
|
|
detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('rejects empty input', () => {
|
|
expect(detectHtmlArtifact('')).toBeNull();
|
|
expect(detectHtmlArtifact(' \n ')).toBeNull();
|
|
});
|
|
|
|
it('rejects fenced block without doctype/<html>', () => {
|
|
expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
|
|
});
|
|
|
|
it('accepts fenced block containing <html> tag (no doctype)', () => {
|
|
const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
|
|
expect(r).toContain('<html>');
|
|
});
|
|
});
|
|
|
|
describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
|
|
let projectRoot: string;
|
|
beforeEach(async () => {
|
|
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(projectRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('writes a markdown artifact under .boocode/artifacts/', async () => {
|
|
const result = await writeMarkdownArtifact(
|
|
{ content: '# Hello\n\nbody' },
|
|
{ projectId: 'pid', projectRoot },
|
|
);
|
|
expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
|
|
expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
|
|
const written = await readFile(result.path, 'utf8');
|
|
expect(written).toBe('# Hello\n\nbody');
|
|
});
|
|
|
|
it('writes an html artifact', async () => {
|
|
const result = await writeHtmlArtifact(
|
|
{
|
|
html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
|
|
char_count: 56,
|
|
title: 'X',
|
|
},
|
|
{ projectId: 'pid', projectRoot },
|
|
);
|
|
expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
|
|
const written = await readFile(result.path, 'utf8');
|
|
expect(written).toContain('<!DOCTYPE html>');
|
|
});
|
|
|
|
it('creates the artifacts directory if absent', async () => {
|
|
// Confirm the writer mkdir-recursive's the artifacts dir on first call.
|
|
const result = await writeMarkdownArtifact(
|
|
{ content: '# T' },
|
|
{ projectId: 'pid', projectRoot },
|
|
);
|
|
expect(result.path).toContain('.boocode/artifacts');
|
|
});
|
|
});
|
|
|
|
describe('1MB cap behavior', () => {
|
|
it('reports the correct byte threshold', () => {
|
|
expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
|
|
});
|
|
|
|
it('exceeds threshold for oversize payload', () => {
|
|
const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
|
expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
|
|
HTML_ARTIFACT_MAX_BYTES,
|
|
);
|
|
});
|
|
|
|
it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
|
|
// Detection is content-shape; the cap check lives in finalizeCompletion
|
|
// (error-handler.ts). This test pins that contract: the helper does not
|
|
// silently drop oversize payloads on the floor.
|
|
const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
|
|
expect(detectHtmlArtifact(big)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('decideHtmlArtifactWrite', () => {
|
|
// Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
|
|
// the warn-and-skip decision without mocking the full InferenceContext.
|
|
it('returns write=true for payloads under the cap', () => {
|
|
const html = '<!DOCTYPE html><html></html>';
|
|
const decision = decideHtmlArtifactWrite(html);
|
|
expect(decision.write).toBe(true);
|
|
expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
|
|
});
|
|
|
|
it('returns write=false with cap_exceeded reason for oversize payloads', () => {
|
|
const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
|
const decision = decideHtmlArtifactWrite(big);
|
|
expect(decision.write).toBe(false);
|
|
if (!decision.write) {
|
|
expect(decision.reason).toBe('cap_exceeded');
|
|
expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
|
|
}
|
|
});
|
|
|
|
it('accepts payload exactly at the cap (boundary)', () => {
|
|
// byteLen === cap should write; only strictly greater skips.
|
|
const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
|
const decision = decideHtmlArtifactWrite(exact);
|
|
expect(decision.write).toBe(true);
|
|
expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
|
|
});
|
|
});
|
|
|
|
describe('symlink escape protection', () => {
|
|
// Closes the gap where `.boocode/artifacts` is a symlink pointing
|
|
// outside the project root. The lexical prefix check on the resolved
|
|
// candidate path passes (it's under projectRoot textually), but the
|
|
// post-mkdir realpath verification must catch the escape.
|
|
let projectRoot: string;
|
|
let outside: string;
|
|
beforeEach(async () => {
|
|
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
|
|
outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
|
|
});
|
|
afterEach(async () => {
|
|
await rm(projectRoot, { recursive: true, force: true });
|
|
await rm(outside, { recursive: true, force: true });
|
|
});
|
|
|
|
it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
|
|
// Create .boocode dir, then make `artifacts` a symlink pointing outside.
|
|
await mkdir(join(projectRoot, '.boocode'), { recursive: true });
|
|
await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
|
|
await expect(
|
|
writeMarkdownArtifact(
|
|
{ content: '# Hello' },
|
|
{ projectId: 'pid', projectRoot },
|
|
),
|
|
).rejects.toBeInstanceOf(PathScopeError);
|
|
});
|
|
});
|