mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
The orchestrator was leaving artifactPath = null on every shipped run because the SHIP <ARTIFACT> body never made it past the parser. Reviewers caught this on PR #1006: a rerun-style endpoint built on top of that null could not return a usable prior-art reference, and tests that synthesized artifactPath via insertCritiqueRun were hiding the gap rather than covering the feature. This PR closes that gap. The parser now hands the orchestrator a ShipArtifactPayload (round, mime, body) through a side-channel callback, and the orchestrator writes the bytes to <artifactsDir>/<projectId>/<runId>/ artifact.<ext> via a new artifact-writer module. The row's artifactPath is the absolute on-disk path. The web layer never sees that path: it fetches the bytes through GET /api/projects/:projectId/critique/:runId/artifact, which the new artifact-handler module serves with a mime-derived Content-Type, X-Content-Type-Options: nosniff, a CSP header for HTML and SVG, and the same cross-project leak guard pattern the interrupt handler uses. The body and mime intentionally never travel on the SSE wire. The SHIP PanelEvent (which doubles as the SSE payload shape) keeps its lightweight artifactRef, and the orchestrator strips body/mime before bus.emit, so a multi-megabyte artifact does not broadcast to every subscriber. The new orchestrator test asserts this explicitly. Defense in depth in the writer + handler: - mime allowlist with text/html, text/css, text/markdown, text/plain, application/json, image/svg+xml; everything else falls through to application/octet-stream + .bin so unknown payloads can't be misinterpreted as a known type; - UTF-8 byte-length cap, configurable via cfg.parserMaxBlockBytes, so multi-byte payloads can't sneak past a JS .length check; - atomic write through a sibling tmp file + rename so a daemon crash mid-write can't leave a half-written artifact under the canonical name; - path-traversal guard on the GET endpoint that resolves the row's artifactPath against the artifacts root and refuses anything that escapes it, refuses non-regular files (symlinks, dirs), and refuses files larger than the response cap. Folded in two non-blocking notes lefarcen left on PR #1016 (the contracts move) since persistence.ts was already in scope here: - P2: introduced CritiquePersistedStatus = CritiqueRunStatus | 'running' in the contracts package. CritiqueRunRow.status and CritiqueRunInsert. status now use it, and the inline `as CritiqueRunStatus | 'running'` widen in interrupt-handler.ts is gone. Public DTOs continue to use the terminal-only CritiqueRunStatus so a future endpoint can't leak a 'running' row through the wire. - P3: added AssertExhaustiveValues + a compile-time assertion that CRITIQUE_RUN_STATUSES covers every CritiqueRunStatus variant. Adding a value to ShipStatus or CritiqueRunStatus without updating the array now fails the build with a tuple naming the missing variants instead of silently dropping out of UI filters. Coverage: 174 critique tests across 14 files pass locally, including the new critique-artifact-writer (13 cases) and critique-artifact-endpoint (11 cases) suites, the inverted critique-lifecycle artifact-persistence test, and the orchestrator happy-path that asserts the SSE ship payload does NOT carry body or mime. Validated: pnpm guard, pnpm --filter @open-design/contracts build, pnpm --filter @open-design/daemon build (full tsc), pnpm --filter @open-design/web typecheck, pnpm --filter @open-design/daemon exec vitest run tests/critique (all green). This is step (b) of the four-step plan that PR #1006's closing comment laid out. Step (a) was the contracts move in PR #1016. Steps (c) (persist original_message_id / agent_id / model_id) and (d) (real rerun endpoint on top of (a)+(b)+(c)) follow. Co-authored-by: Nagendhra <nagendhra405@gmail.com>
150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
/**
|
|
* Tests for the SHIP <ARTIFACT> writer module.
|
|
*
|
|
* The writer is the daemon's only path for turning a parser-side payload
|
|
* into bytes on disk; the artifact endpoint serves whatever this module
|
|
* produced, so the mime → extension contract and the size cap matter for
|
|
* production correctness, not just developer ergonomics.
|
|
*
|
|
* @see specs/current/critique-theater.md § rerun endpoint (Task 6.2)
|
|
*/
|
|
import { mkdtempSync, readFileSync, statSync } from 'node:fs';
|
|
import { rm, readdir } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
ARTIFACT_WRITER_INTERNALS,
|
|
ArtifactEmptyError,
|
|
ArtifactTooLargeError,
|
|
extensionForMime,
|
|
mimeForExtension,
|
|
writeShipArtifact,
|
|
} from '../src/critique/artifact-writer.js';
|
|
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'od-artifact-writer-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('writeShipArtifact', () => {
|
|
it('writes the body verbatim and resolves the canonical mime + extension', async () => {
|
|
const result = await writeShipArtifact(
|
|
dir,
|
|
'<html><body>final</body></html>',
|
|
'text/html',
|
|
);
|
|
|
|
expect(result.mime).toBe('text/html');
|
|
expect(result.extension).toBe('html');
|
|
expect(result.filename).toBe('artifact.html');
|
|
expect(result.absPath).toBe(join(dir, 'artifact.html'));
|
|
expect(result.sizeBytes).toBe(31);
|
|
expect(readFileSync(result.absPath, 'utf8')).toBe('<html><body>final</body></html>');
|
|
});
|
|
|
|
it('strips charset parameters and case before mime lookup', async () => {
|
|
const result = await writeShipArtifact(
|
|
dir,
|
|
'/* css */',
|
|
'Text/CSS; charset=utf-8',
|
|
);
|
|
|
|
expect(result.mime).toBe('text/css');
|
|
expect(result.extension).toBe('css');
|
|
expect(readFileSync(result.absPath, 'utf8')).toBe('/* css */');
|
|
});
|
|
|
|
it('falls back to application/octet-stream + .bin for unknown mime types', async () => {
|
|
const result = await writeShipArtifact(dir, 'binary-ish', 'application/x-zip');
|
|
|
|
expect(result.mime).toBe('application/octet-stream');
|
|
expect(result.extension).toBe('bin');
|
|
expect(result.filename).toBe('artifact.bin');
|
|
expect(readFileSync(result.absPath, 'utf8')).toBe('binary-ish');
|
|
});
|
|
|
|
it('falls back to the binary extension for empty / whitespace-only mime input', async () => {
|
|
const result = await writeShipArtifact(dir, 'something', '');
|
|
|
|
expect(result.mime).toBe('application/octet-stream');
|
|
expect(result.extension).toBe('bin');
|
|
});
|
|
|
|
it('refuses zero-byte bodies with ArtifactEmptyError', async () => {
|
|
await expect(writeShipArtifact(dir, '', 'text/html'))
|
|
.rejects.toBeInstanceOf(ArtifactEmptyError);
|
|
});
|
|
|
|
it('throws ArtifactTooLargeError when the body exceeds the configured cap', async () => {
|
|
const body = 'a'.repeat(2048);
|
|
await expect(
|
|
writeShipArtifact(dir, body, 'text/html', { maxBytes: 1024 }),
|
|
).rejects.toBeInstanceOf(ArtifactTooLargeError);
|
|
});
|
|
|
|
it('rejects via UTF-8 byte length, not JS string length, for multi-byte payloads', async () => {
|
|
// Each Unicode emoji is 4 bytes in UTF-8 but 2 chars in JS string length.
|
|
// Cap of 5 bytes must reject a string whose JS .length is 2 but byte
|
|
// length is 8.
|
|
const body = '🎨🎨';
|
|
expect(body.length).toBe(4);
|
|
expect(Buffer.byteLength(body, 'utf8')).toBe(8);
|
|
await expect(
|
|
writeShipArtifact(dir, body, 'text/html', { maxBytes: 5 }),
|
|
).rejects.toBeInstanceOf(ArtifactTooLargeError);
|
|
});
|
|
|
|
it('does not leave a tmp sibling when the write succeeds', async () => {
|
|
await writeShipArtifact(dir, '<html></html>', 'text/html');
|
|
|
|
const entries = await readdir(dir);
|
|
expect(entries).toEqual(['artifact.html']);
|
|
});
|
|
|
|
it('overwrites a previous artifact in the same directory atomically', async () => {
|
|
const first = await writeShipArtifact(dir, '<html>v1</html>', 'text/html');
|
|
const second = await writeShipArtifact(dir, '<html>v2</html>', 'text/html');
|
|
|
|
expect(second.absPath).toBe(first.absPath);
|
|
expect(readFileSync(second.absPath, 'utf8')).toBe('<html>v2</html>');
|
|
const entries = await readdir(dir);
|
|
expect(entries).toEqual(['artifact.html']);
|
|
});
|
|
|
|
it('writes deterministic file size that matches the byte-length reported on the result', async () => {
|
|
const body = 'a'.repeat(123);
|
|
const result = await writeShipArtifact(dir, body, 'text/plain');
|
|
|
|
expect(result.sizeBytes).toBe(123);
|
|
expect(statSync(result.absPath).size).toBe(123);
|
|
});
|
|
});
|
|
|
|
describe('extensionForMime / mimeForExtension', () => {
|
|
it('round-trips every known mime', () => {
|
|
for (const [mime, ext] of Object.entries(
|
|
ARTIFACT_WRITER_INTERNALS.ARTIFACT_MIME_EXTENSIONS,
|
|
)) {
|
|
expect(extensionForMime(mime)).toBe(ext);
|
|
expect(mimeForExtension(ext)).toBe(mime);
|
|
}
|
|
});
|
|
|
|
it('returns the binary fallback for unknown extensions and mimes', () => {
|
|
expect(extensionForMime('application/x-foo')).toBe(
|
|
ARTIFACT_WRITER_INTERNALS.FALLBACK_EXTENSION,
|
|
);
|
|
expect(mimeForExtension('xyz')).toBe(ARTIFACT_WRITER_INTERNALS.FALLBACK_MIME);
|
|
});
|
|
|
|
it('strips a leading dot from extension lookups', () => {
|
|
expect(mimeForExtension('.html')).toBe('text/html');
|
|
expect(mimeForExtension('html')).toBe('text/html');
|
|
});
|
|
});
|