diff --git a/apps/daemon/src/artifact-manifest.ts b/apps/daemon/src/artifact-manifest.ts index ead9b55e0..49b8c6f1c 100644 --- a/apps/daemon/src/artifact-manifest.ts +++ b/apps/daemon/src/artifact-manifest.ts @@ -201,10 +201,15 @@ export function validateArtifactManifestInput( } } - const safeEntry = typeof entry === 'string' ? entry : ''; - if (!safeEntry || safeEntry.length > MAX_ENTRY_LENGTH) { - return { ok: false, error: `artifact entry exceeds max length (${MAX_ENTRY_LENGTH})` }; + const manifestEntry = + typeof manifest.entry === 'string' && manifest.entry.trim() + ? manifest.entry.trim() + : entry; + const entryErr = validateSupportingPath(manifestEntry); + if (entryErr) { + return { ok: false, error: `artifactManifest.entry ${entryErr}` }; } + const safeEntry = (manifestEntry as string).replace(/\\/g, '/'); return { ok: true, value: sanitizeManifest(manifest, safeEntry, options) }; } diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index 5e615f570..fe8203f71 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -1,4 +1,5 @@ import type { Express } from 'express'; +import nodePath from 'node:path'; import type { RouteDeps } from './server-context.js'; import { InlineAssetsLimitError, @@ -358,7 +359,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx const { sendApiError } = ctx.http; const { PROJECTS_DIR } = ctx.paths; const { getProject } = ctx.projectStore; - const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles; + const { listFiles, readProjectFile, resolveProjectFilePath } = ctx.projectFiles; const { isSafeId } = ctx.validation; const { buildProjectArchive, @@ -447,6 +448,30 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx } }); + app.get('/api/projects/:id/export/manifest', async (req, res) => { + try { + if (!isSafeId(req.params.id)) { + return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id'); + } + const project = getProject(db, req.params.id); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + const files = await listFiles(PROJECTS_DIR, req.params.id, { + metadata: project.metadata, + }); + /** @type {import('@open-design/contracts').ProjectExportManifestResponse} */ + const body = buildProjectExportManifestResponse({ + project, + projectId: req.params.id, + files, + }); + res.json(body); + } catch (err: any) { + sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err)); + } + }); + app.post('/api/projects/:id/export/pdf', async (req, res) => { if (typeof desktopPdfExporter !== 'function') { return sendApiError( @@ -656,6 +681,177 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx } +function buildProjectExportManifestResponse({ + project, + projectId, + files, +}: { + project: any; + projectId: string; + files: any[]; +}) { + const sortedFiles = [...files].sort((a, b) => String(a.name).localeCompare(String(b.name))); + const filesByName = new Map(sortedFiles.map((file) => [file.name, file])); + const reasons = new Map>(); + const supportingNames = new Set(); + const artifactNames = new Set(); + const artifacts = []; + + const note = (name: unknown, reason: string) => { + if (typeof name !== 'string' || !filesByName.has(name)) return; + if (!reasons.has(name)) reasons.set(name, new Set()); + reasons.get(name)?.add(reason); + }; + + for (const file of sortedFiles) { + const manifest = file.artifactManifest && typeof file.artifactManifest === 'object' + ? file.artifactManifest + : null; + if (!manifest) continue; + if (isInferredArtifactManifest(manifest)) continue; + artifactNames.add(file.name); + note(file.name, 'artifact-manifest'); + + const artifactSupporting = new Set(); + const addManifestRef = ( + ref: unknown, + reason: string, + options: { allowProjectRootFallback?: boolean; preferProjectRoot?: boolean } = {}, + ) => { + const ownerRelative = normalizeManifestProjectRef(ref, file.name); + const projectRoot = normalizeManifestProjectRootRef(ref); + const candidates = options.preferProjectRoot + ? [projectRoot, ownerRelative] + : [ + ownerRelative, + ...(options.allowProjectRootFallback ? [projectRoot] : []), + ]; + const normalized = candidates.find((candidate) => candidate && filesByName.has(candidate)); + if (!normalized) return; + if (normalized === file.name) return; + supportingNames.add(normalized); + artifactSupporting.add(normalized); + note(normalized, reason); + }; + addManifestRef(manifest.entry, 'artifact-entry', { preferProjectRoot: true }); + if (typeof manifest.primary === 'string') { + addManifestRef(manifest.primary, 'artifact-primary', { preferProjectRoot: true }); + } + if (Array.isArray(manifest.supportingFiles)) { + for (const ref of manifest.supportingFiles) { + addManifestRef(ref, 'artifact-supporting-file', { allowProjectRootFallback: true }); + } + } + + artifacts.push({ + file: file.name, + title: typeof manifest.title === 'string' && manifest.title.trim() + ? manifest.title + : file.name, + kind: typeof manifest.kind === 'string' ? manifest.kind : (file.artifactKind ?? null), + renderer: typeof manifest.renderer === 'string' ? manifest.renderer : null, + status: typeof manifest.status === 'string' ? manifest.status : null, + exports: Array.isArray(manifest.exports) + ? manifest.exports.filter((value: unknown): value is string => typeof value === 'string') + : [], + supportingFiles: Array.from(artifactSupporting).sort((a, b) => a.localeCompare(b)), + updatedAt: typeof manifest.updatedAt === 'string' ? manifest.updatedAt : null, + }); + } + + const entryFile = chooseExportManifestEntryFile(project, sortedFiles, filesByName); + note(entryFile, 'project-entry-file'); + + return { + schema: 'open-design.project-export-manifest.v1', + projectId, + projectName: typeof project?.name === 'string' ? project.name : null, + generatedAt: new Date().toISOString(), + entryFile, + files: sortedFiles.map((file) => ({ + ...file, + included: true, + role: roleForExportManifestFile(file, { + entryFile, + artifactNames, + supportingNames, + }), + reasons: Array.from(reasons.get(file.name) ?? ['visible-project-file']).sort((a, b) => a.localeCompare(b)), + })), + artifacts, + }; +} + +function isInferredArtifactManifest(manifest: any): boolean { + return manifest?.metadata && + typeof manifest.metadata === 'object' && + manifest.metadata.inferred === true; +} + +function chooseExportManifestEntryFile( + project: any, + files: any[], + filesByName: Map, +): string | null { + const metadataEntry = typeof project?.metadata?.entryFile === 'string' + ? project.metadata.entryFile + : null; + if (metadataEntry && filesByName.has(metadataEntry)) return metadataEntry; + for (const file of files) { + const manifest = file.artifactManifest; + if (!manifest || typeof manifest !== 'object') continue; + if (isInferredArtifactManifest(manifest)) continue; + if (manifest.primary === true) return file.name; + if (typeof manifest.primary === 'string') { + const rootPrimary = normalizeManifestProjectRootRef(manifest.primary); + if (rootPrimary && filesByName.has(rootPrimary)) return rootPrimary; + const ownerRelativePrimary = normalizeManifestProjectRef(manifest.primary, file.name); + if (ownerRelativePrimary && filesByName.has(ownerRelativePrimary)) return ownerRelativePrimary; + } + const rootEntry = normalizeManifestProjectRootRef(manifest.entry); + if (rootEntry && filesByName.has(rootEntry)) return rootEntry; + const ownerRelativeEntry = normalizeManifestProjectRef(manifest.entry, file.name); + if (ownerRelativeEntry && filesByName.has(ownerRelativeEntry)) return ownerRelativeEntry; + } + return files.find((file) => /(^|\/)index\.html?$/i.test(file.name))?.name + ?? files.find((file) => file.kind === 'html')?.name + ?? files[0]?.name + ?? null; +} + +function normalizeManifestProjectRootRef(ref: unknown): string | null { + return normalizeManifestProjectRef(ref, ''); +} + +function normalizeManifestProjectRef(ref: unknown, ownerFile: string): string | null { + if (typeof ref !== 'string' || !ref.trim()) return null; + const value = ref.trim(); + if (value.includes('\0') || value.startsWith('/')) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null; + const ownerDir = nodePath.posix.dirname(ownerFile); + const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`; + const normalized = nodePath.posix.normalize(joined).replace(/^\.\//, ''); + if (!normalized || normalized === '.' || normalized.startsWith('../')) return null; + if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null; + return normalized; +} + +function roleForExportManifestFile( + file: any, + refs: { + entryFile: string | null; + artifactNames: Set; + supportingNames: Set; + }, +) { + if (file.name === refs.entryFile) return 'entry'; + if (refs.artifactNames.has(file.name)) return 'artifact'; + if (refs.supportingNames.has(file.name)) return 'supporting'; + if (file.kind === 'image' || file.kind === 'video' || file.kind === 'audio') return 'asset'; + if (file.kind === 'code' || file.kind === 'text') return 'source'; + return 'other'; +} + export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {} export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) { diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index fa5392d9e..8fc193b32 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -1,6 +1,7 @@ -import type { Express } from 'express'; +import { randomUUID } from 'node:crypto'; import { rm } from 'node:fs/promises'; import path from 'node:path'; +import type { Express, Response } from 'express'; import { defaultScenarioPluginIdForProjectMetadata, type PluginManifest, @@ -1208,6 +1209,104 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles; const { buildDocumentPreview } = ctx.documents; const { validateArtifactManifestInput } = ctx.artifacts; + const projectPreviewIframeSandbox = 'allow-scripts allow-forms'; + const projectPreviewCsp = [ + `sandbox ${projectPreviewIframeSandbox}`, + "default-src 'self' data: blob:", + "img-src 'self' data: blob:", + "media-src 'self' data: blob:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "connect-src 'none'", + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + ].join('; '); + const previewScopeRe = /^[A-Za-z0-9_-]{8,128}$/u; + + function setProjectPreviewHeaders(res: Response) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', projectPreviewCsp); + } + + async function sendProjectFile( + req: any, + res: Response, + projectId: string, + relPath: string, + metadata?: unknown, + beforeSend?: (mime: string) => void, + transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string, + ) { + const meta = await resolveProjectFilePath( + PROJECTS_DIR, + projectId, + relPath, + metadata, + ); + beforeSend?.(meta.mime); + + if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) { + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Content-Type', meta.mime); + + if (meta.size === 0) { + res.setHeader('Content-Length', '0'); + return res.status(200).end(); + } + + const range = parseByteRange(req.headers.range, meta.size); + + if (range === 'unsatisfiable') { + res.setHeader('Content-Range', `bytes */${meta.size}`); + return res.status(416).end(); + } + + let start; + let end; + let statusCode; + if (range) { + ({ start, end } = range); + statusCode = 206; + res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`); + res.setHeader('Content-Length', String(end - start + 1)); + } else { + start = 0; + end = meta.size - 1; + statusCode = 200; + res.setHeader('Content-Length', String(meta.size)); + } + + res.status(statusCode); + const stream = fs.createReadStream(meta.filePath, { start, end }); + stream.on('error', (streamErr: any) => { + if (!res.headersSent) { + sendApiError(res, 500, 'STREAM_ERROR', String(streamErr)); + } else { + res.destroy(streamErr); + } + }); + stream.pipe(res); + return; + } + + const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, metadata); + res.type(file.mime).send(transformFile ? transformFile(file) : file.buffer); + } + + function previewFilePathForProject(project: any, queryFile: unknown): string { + if (typeof queryFile === 'string' && queryFile.trim().length > 0) { + return queryFile; + } + const entryFile = project?.metadata?.entryFile; + return typeof entryFile === 'string' && entryFile.length > 0 ? entryFile : 'index.html'; + } + + function encodeProjectPathForUrl(filePath: string): string { + return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/'); + } // Project files. Each project owns a flat folder under .od/projects// // containing every file the user has uploaded, pasted, sketched, or that @@ -1266,6 +1365,79 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } }); + app.get('/api/projects/:id/preview-url', async (req, res) => { + try { + const project = getProject(db, req.params.id); + if (!project) { + sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + return; + } + const requestedPath = previewFilePathForProject(project, req.query.file); + const meta = await resolveProjectFilePath( + PROJECTS_DIR, + project.id, + requestedPath, + project.metadata, + ); + const scope = randomUUID(); + /** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */ + const body = { + url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`, + file: meta.name, + csp: projectPreviewCsp, + iframeSandbox: projectPreviewIframeSandbox, + opaqueOrigin: true, + }; + res.setHeader('Cache-Control', 'no-store'); + res.json(body); + } catch (err: any) { + const status = err && err.code === 'ENOENT' ? 404 : 400; + sendApiError( + res, + status, + status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', + String(err), + ); + } + }); + + app.get(/^\/api\/projects\/([^/]+)\/preview\/([^/]+)\/(.+)$/u, async (req, res) => { + try { + const params = req.params as unknown as { 0?: string; 1?: string; 2?: string }; + const projectId = String(params[0] ?? ''); + const scope = String(params[1] ?? ''); + const relPath = String(params[2] ?? ''); + if (!previewScopeRe.test(scope)) { + sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope'); + return; + } + const project = getProject(db, projectId); + if (!project) { + sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + return; + } + if (req.headers.origin === 'null') { + res.header('Access-Control-Allow-Origin', '*'); + } + await sendProjectFile( + req, + res, + project.id, + relPath, + project.metadata, + () => setProjectPreviewHeaders(res), + ); + } catch (err: any) { + const status = err && err.code === 'ENOENT' ? 404 : 400; + sendApiError( + res, + status, + status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', + String(err), + ); + } + }); + // Preflight for the raw file route. Current artifact fetches are simple GETs // (no preflight needed), but an explicit handler future-proofs the route if @@ -1293,66 +1465,23 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile res.header('Access-Control-Allow-Origin', '*'); } - const meta = await resolveProjectFilePath( - PROJECTS_DIR, + await sendProjectFile( + req, + res, projectId, relPath, project?.metadata, - ); - - if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) { - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Type', meta.mime); - - if (meta.size === 0) { - res.setHeader('Content-Length', '0'); - return res.status(200).end(); - } - - const range = parseByteRange(req.headers.range, meta.size); - - if (range === 'unsatisfiable') { - res.setHeader('Content-Range', `bytes */${meta.size}`); - return res.status(416).end(); - } - - let start; - let end; - let statusCode; - if (range) { - ({ start, end } = range); - statusCode = 206; - res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`); - res.setHeader('Content-Length', String(end - start + 1)); - } else { - start = 0; - end = meta.size - 1; - statusCode = 200; - res.setHeader('Content-Length', String(meta.size)); - } - - res.status(statusCode); - const stream = fs.createReadStream(meta.filePath, { start, end }); - stream.on('error', (streamErr: any) => { - if (!res.headersSent) { - sendApiError(res, 500, 'STREAM_ERROR', String(streamErr)); - } else { - res.destroy(streamErr); + undefined, + (file) => { + if ( + wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) && + /^text\/html(?:;|$)/i.test(file.mime) + ) { + return injectUrlPreviewScrollBridge(file.buffer.toString('utf8')); } - }); - stream.pipe(res); - return; - } - - const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata); - if ( - wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) && - /^text\/html(?:;|$)/i.test(file.mime) - ) { - res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8'))); - return; - } - res.type(file.mime).send(file.buffer); + return file.buffer; + }, + ); } catch (err: any) { const status = err && err.code === 'ENOENT' ? 404 : 400; sendApiError( diff --git a/apps/daemon/src/projects.ts b/apps/daemon/src/projects.ts index b126a71a2..f7c804aaa 100644 --- a/apps/daemon/src/projects.ts +++ b/apps/daemon/src/projects.ts @@ -32,10 +32,16 @@ const FORBIDDEN_SEGMENT = /^$|^\.\.?$/; const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']); const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md'; const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json'; +export const RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS = 1000; export const projectFileRenameTestHooks = { beforeCommit: null as null | ((paths: { source: string; target: string }) => Promise | void), }; +export function isRunTouchedProjectFile(fileMtimeMs, runStartTimeMs) { + if (!Number.isFinite(fileMtimeMs) || !Number.isFinite(runStartTimeMs)) return false; + return fileMtimeMs + RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS >= runStartTimeMs; +} + export function projectDir(projectsRoot, projectId) { if (!isSafeId(projectId)) throw new Error('invalid project id'); return path.join(projectsRoot, projectId); @@ -626,7 +632,8 @@ export async function resolveProjectFilePath(projectsRoot, projectId, name, meta const dir = resolveProjectDir(projectsRoot, projectId, metadata); const file = await resolveSafeReal(dir, name); const st = await stat(file); - const rel = toProjectPath(path.relative(dir, file)); + const rootReal = await realpath(dir).catch(() => dir); + const rel = toProjectPath(path.relative(rootReal, file)); return { filePath: file, name: rel, @@ -880,6 +887,7 @@ export async function renameProjectFile(projectsRoot, projectId, fromName, toNam await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath }); await renameFilePath(source, targetPath, { noOverwrite: true }); await commitArtifactManifestRename(manifestRename, newName); + await updateArtifactManifestRefsForRename(dir, oldName, newName); const st = await stat(targetPath); const manifest = await readManifestForPath(dir, newName); @@ -974,16 +982,22 @@ async function prepareArtifactManifestRename(dir, oldName, newName) { } } - return { oldManifestPath, newManifestPath: targetManifestPath, raw }; + return { oldManifestPath, newManifestPath: targetManifestPath, raw, oldName }; } async function commitArtifactManifestRename(manifestRename, newName) { if (!manifestRename) return; - const { oldManifestPath, newManifestPath, raw } = manifestRename; + const { oldManifestPath, newManifestPath, raw, oldName } = manifestRename; await mkdir(path.dirname(newManifestPath), { recursive: true }); const parsed = parseManifest(raw); if (parsed) { - const validated = validateArtifactManifestInput(parsed, newName); + const parsedEntry = typeof parsed.entry === 'string' + ? parsed.entry.replace(/\\/g, '/') + : ''; + const renamedManifest = parsedEntry === oldName + ? { ...parsed, entry: newName } + : parsed; + const validated = validateArtifactManifestInput(renamedManifest, newName); if (validated.ok && validated.value) { await writeFile(oldManifestPath, JSON.stringify(validated.value, null, 2)); await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true }); @@ -993,6 +1007,153 @@ async function commitArtifactManifestRename(manifestRename, newName) { await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true }); } +async function updateArtifactManifestRefsForRename(dir, oldName, newName) { + const manifests = []; + await collectArtifactManifestFiles(dir, '', manifests); + for (const manifestFile of manifests) { + const ownerName = ownerNameForArtifactManifest(manifestFile.relPath); + if (!ownerName) continue; + let raw; + try { + raw = await readFile(manifestFile.fullPath, 'utf8'); + } catch (err) { + if (err && err.code === 'ENOENT') continue; + throw err; + } + const parsed = parseManifest(raw); + if (!parsed) continue; + + const updated = rewriteArtifactManifestRenameRefs(parsed, { + ownerName, + oldName, + newName, + }); + if (!updated.changed) continue; + + const validated = validateArtifactManifestInput(updated.manifest, ownerName); + if (!validated.ok || !validated.value) continue; + await writeFile(manifestFile.fullPath, JSON.stringify(validated.value, null, 2)); + } +} + +async function collectArtifactManifestFiles(dir, relDir, out) { + let entries = []; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + if (err && err.code === 'ENOENT') return; + throw err; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await collectArtifactManifestFiles(fullPath, relPath, out); + continue; + } + if (entry.isFile() && entry.name.endsWith('.artifact.json')) { + out.push({ relPath, fullPath }); + } + } +} + +function ownerNameForArtifactManifest(manifestName) { + const suffix = '.artifact.json'; + if (!manifestName.endsWith(suffix)) return null; + return manifestName.slice(0, -suffix.length); +} + +function rewriteArtifactManifestRenameRefs(manifest, { ownerName, oldName, newName }) { + let changed = false; + const next = { ...manifest }; + + const entry = rewriteManifestRefForRename(next.entry, ownerName, oldName, newName, { + preferProjectRoot: true, + }); + if (entry.changed) { + next.entry = entry.value; + changed = true; + } + + if (typeof next.primary === 'string') { + const primary = rewriteManifestRefForRename(next.primary, ownerName, oldName, newName, { + preferProjectRoot: true, + }); + if (primary.changed) { + next.primary = primary.value; + changed = true; + } + } + + if (Array.isArray(next.supportingFiles)) { + const supportingFiles = next.supportingFiles.map((ref) => { + const updated = rewriteManifestRefForRename(ref, ownerName, oldName, newName); + if (updated.changed) changed = true; + return updated.value; + }); + if (changed) next.supportingFiles = supportingFiles; + } + + return { changed, manifest: next }; +} + +function rewriteManifestRefForRename( + ref, + ownerName, + oldName, + newName, + options = {}, +) { + if (typeof ref !== 'string') return { changed: false, value: ref }; + const normalized = ref.replace(/\\/g, '/').trim(); + if (!normalized) return { changed: false, value: ref }; + + if (options.preferProjectRoot && normalizeManifestProjectRootRef(normalized) === oldName) { + return { changed: true, value: newName }; + } + + if (normalizeManifestProjectRef(normalized, ownerName) === oldName) { + return { + changed: true, + value: relativeManifestRefForOwner(ownerName, newName), + }; + } + + if (normalized === oldName) { + return { changed: true, value: newName }; + } + + return { changed: false, value: ref }; +} + +function relativeManifestRefForOwner(ownerName, targetName) { + const ownerDir = path.posix.dirname(ownerName); + if (ownerDir === '.') return targetName; + const relative = path.posix.relative(ownerDir, targetName); + if (!relative || relative === '.' || relative.startsWith('../') || relative.includes('/../')) { + return targetName; + } + return relative; +} + +function normalizeManifestProjectRootRef(ref) { + return normalizeManifestProjectRef(ref, ''); +} + +function normalizeManifestProjectRef(ref, ownerName) { + if (typeof ref !== 'string' || !ref.trim()) return null; + const value = ref.trim().replace(/\\/g, '/'); + if (value.includes('\0') || value.startsWith('/')) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null; + const ownerDir = path.posix.dirname(ownerName); + const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`; + const normalized = path.posix.normalize(joined).replace(/^\.\//, ''); + if (!normalized || normalized === '.' || normalized.startsWith('../')) return null; + if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null; + return normalized; +} + export async function removeProjectDir(projectsRoot, projectId) { const dir = projectDir(projectsRoot, projectId); await rm(dir, { recursive: true, force: true }); diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 6ea861454..c254faa51 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -353,6 +353,7 @@ import { assertSandboxProjectRootAvailable, detectEntryFile, ensureProject, + isRunTouchedProjectFile, isSafeId, listFiles, mimeFor, @@ -4174,7 +4175,7 @@ export async function startServer({ // Routes that serve content to sandboxed iframes (Origin: null) for // read-only purposes. All other /api routes reject Origin: null. const _NULL_ORIGIN_SAFE_GET_RE = - /^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/; + /^\/projects\/[^/]+\/(?:raw|preview)\/|^\/codex-pets\/[^/]+\/spritesheet$/; // Reject cross-origin requests to API endpoints. // Health/version remain open for monitoring probes. @@ -12751,7 +12752,7 @@ export async function startServer({ try { const filePath = path.join(dir, f.name); const st = await fs.promises.stat(filePath); - if (st.mtimeMs < runStartTimeMs) continue; + if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue; await reconcileHtmlArtifactManifest( PROJECTS_DIR, run.projectId, diff --git a/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts b/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts index 885f977c7..fbbe80c7b 100644 --- a/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts +++ b/apps/daemon/tests/artifact-manifest-reconcile-on-run-end.test.ts @@ -9,7 +9,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { closeDatabase, insertProject, openDatabase } from '../src/db.js'; -import { reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js'; +import { isRunTouchedProjectFile, reconcileHtmlArtifactManifest, writeProjectFile } from '../src/projects.js'; const PROJECT_ID = 'reconcile-test'; let tempDir = null; @@ -146,6 +146,9 @@ describe('run-end artifact manifest reconciliation (#2893)', () => { // File written during the run await writeProjectFile(projectsRoot, PROJECT_ID, 'new-output.html', '

new

'); + const newPath = path.join(projectsRoot, PROJECT_ID, 'new-output.html'); + const coarseFsTime = new Date(runStartTimeMs - 500); + fs.utimesSync(newPath, coarseFsTime, coarseFsTime); // Simulate the close-handler reconciliation with mtime filter const dir = path.join(projectsRoot, PROJECT_ID); @@ -154,7 +157,7 @@ describe('run-end artifact manifest reconciliation (#2893)', () => { const ext = path.extname(name).toLowerCase(); if (ext !== '.html' && ext !== '.htm') continue; const st = fs.statSync(path.join(dir, name)); - if (st.mtimeMs < runStartTimeMs) continue; + if (!isRunTouchedProjectFile(st.mtimeMs, runStartTimeMs)) continue; await reconcileHtmlArtifactManifest(projectsRoot, PROJECT_ID, name); } diff --git a/apps/daemon/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts new file mode 100644 index 000000000..c56bafc2a --- /dev/null +++ b/apps/daemon/tests/export-manifest-route.test.ts @@ -0,0 +1,364 @@ +import type http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { writeFile as writeFsFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { startServer } from '../src/server.js'; + +describe('project export manifest route', () => { + let server: http.Server; + let baseUrl: string; + const projectsToClean: string[] = []; + const tempDirs: string[] = []; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(async () => { + for (const id of projectsToClean.splice(0)) { + await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {}); + } + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + await new Promise((resolve) => server.close(() => resolve())); + }); + + function makeFolder(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'od-export-manifest-')); + tempDirs.push(dir); + return dir; + } + + async function withSandboxMode(run: () => Promise): Promise { + const previous = process.env.OD_SANDBOX_MODE; + process.env.OD_SANDBOX_MODE = '1'; + try { + return await run(); + } finally { + if (previous == null) delete process.env.OD_SANDBOX_MODE; + else process.env.OD_SANDBOX_MODE = previous; + } + } + + async function createProject( + metadata: Record = { kind: 'prototype', entryFile: 'index.html' }, + ): Promise { + const id = `export-manifest-${randomUUID()}`; + const response = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id, + name: 'Export manifest project', + metadata, + }), + }); + expect(response.ok).toBe(true); + projectsToClean.push(id); + return id; + } + + async function writeFile(projectId: string, body: Record): Promise { + const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.ok).toBe(true); + } + + async function renameFile(projectId: string, from: string, to: string): Promise { + const response = await fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ from, to }), + }); + expect(response.ok).toBe(true); + } + + it('lists exportable project files and artifact sidecar metadata without exposing sidecars', async () => { + const projectId = await createProject(); + await writeFile(projectId, { + name: 'styles.css', + content: 'body { color: black; }', + }); + await writeFile(projectId, { + name: 'assets/logo.svg', + content: '', + }); + await writeFile(projectId, { + name: 'index.html', + content: '', + artifactManifest: { + version: 1, + kind: 'html', + title: 'Reviewed prototype', + entry: 'index.html', + renderer: 'html', + status: 'complete', + exports: ['html', 'zip'], + primary: true, + supportingFiles: ['styles.css', 'assets/logo.svg', 'missing.png'], + updatedAt: '2026-05-28T00:00:00.000Z', + }, + }); + + const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`); + expect(response.ok).toBe(true); + const body = await response.json() as { + schema: string; + projectId: string; + entryFile: string; + files: Array<{ name: string; role: string; reasons: string[]; artifactManifest?: unknown }>; + artifacts: Array<{ file: string; title: string; supportingFiles: string[] }>; + }; + + expect(body).toMatchObject({ + schema: 'open-design.project-export-manifest.v1', + projectId, + entryFile: 'index.html', + }); + expect(body.files.map((file) => file.name)).toEqual([ + 'assets/logo.svg', + 'index.html', + 'styles.css', + ]); + expect(body.files.find((file) => file.name === 'index.html')).toMatchObject({ + role: 'entry', + reasons: expect.arrayContaining(['artifact-manifest', 'project-entry-file']), + }); + expect(body.files.find((file) => file.name === 'styles.css')).toMatchObject({ + role: 'supporting', + reasons: ['artifact-supporting-file'], + }); + expect(body.artifacts).toMatchObject([ + { + file: 'index.html', + title: 'Reviewed prototype', + supportingFiles: ['assets/logo.svg', 'styles.css'], + }, + ]); + expect(body.files.some((file) => file.name.endsWith('.artifact.json'))).toBe(false); + }); + + it('uses artifact primary strings as project-relative entry refs', async () => { + const projectId = await createProject({ kind: 'prototype' }); + await writeFile(projectId, { + name: 'reviewed.html', + content: '
reviewed
', + }); + await writeFile(projectId, { + name: 'preview/wrapper.html', + content: '', + artifactManifest: { + version: 1, + kind: 'html', + title: 'Review wrapper', + renderer: 'html', + status: 'complete', + exports: ['html'], + primary: 'reviewed.html', + }, + }); + + const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`); + expect(response.ok).toBe(true); + const body = await response.json() as { + entryFile: string; + files: Array<{ name: string; role: string; reasons: string[] }>; + }; + + expect(body.entryFile).toBe('reviewed.html'); + expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({ + role: 'entry', + reasons: expect.arrayContaining(['artifact-primary', 'project-entry-file']), + }); + }); + + it('uses artifact entry strings as project-relative entry refs without primary hints', async () => { + const projectId = await createProject({ kind: 'prototype' }); + await writeFile(projectId, { + name: 'index.html', + content: '
fallback
', + }); + await writeFile(projectId, { + name: 'reviewed.html', + content: '
reviewed
', + }); + await writeFile(projectId, { + name: 'preview/wrapper.html', + content: '', + artifactManifest: { + version: 1, + kind: 'html', + title: 'Review wrapper', + entry: 'reviewed.html', + renderer: 'html', + status: 'complete', + exports: ['html'], + }, + }); + + const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`); + expect(response.ok).toBe(true); + const body = await response.json() as { + entryFile: string; + files: Array<{ name: string; role: string; reasons: string[] }>; + }; + + expect(body.entryFile).toBe('reviewed.html'); + expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({ + role: 'entry', + reasons: expect.arrayContaining(['artifact-entry', 'project-entry-file']), + }); + }); + + it('keeps artifact entry refs current when a referenced file is renamed', async () => { + const projectId = await createProject({ kind: 'prototype' }); + await writeFile(projectId, { + name: 'index.html', + content: '
fallback
', + }); + await writeFile(projectId, { + name: 'reviewed.html', + content: '
reviewed
', + }); + await writeFile(projectId, { + name: 'preview/wrapper.html', + content: '', + artifactManifest: { + version: 1, + kind: 'html', + title: 'Review wrapper', + entry: 'reviewed.html', + renderer: 'html', + status: 'complete', + exports: ['html'], + primary: 'reviewed.html', + supportingFiles: ['reviewed.html'], + }, + }); + + await renameFile(projectId, 'reviewed.html', 'reviewed-renamed.html'); + + const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`); + expect(response.ok).toBe(true); + const body = await response.json() as { + entryFile: string; + files: Array<{ name: string; role: string; reasons: string[] }>; + }; + + expect(body.entryFile).toBe('reviewed-renamed.html'); + expect(body.files.find((file) => file.name === 'reviewed-renamed.html')).toMatchObject({ + role: 'entry', + reasons: expect.arrayContaining(['artifact-entry', 'artifact-primary', 'project-entry-file']), + }); + + const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`); + expect(filesResponse.ok).toBe(true); + const filesBody = await filesResponse.json() as { + files: Array<{ + name: string; + artifactManifest?: { + entry?: string; + primary?: string | boolean; + supportingFiles?: string[]; + }; + }>; + }; + expect(filesBody.files.find((file) => file.name === 'preview/wrapper.html')?.artifactManifest) + .toMatchObject({ + entry: 'reviewed-renamed.html', + primary: 'reviewed-renamed.html', + supportingFiles: ['reviewed-renamed.html'], + }); + }); + + it('keeps artifact entry refs current when a referenced file moves out of the wrapper directory', async () => { + const projectId = await createProject({ kind: 'prototype' }); + await writeFile(projectId, { + name: 'index.html', + content: '
fallback
', + }); + await writeFile(projectId, { + name: 'preview/reviewed.html', + content: '
reviewed
', + }); + await writeFile(projectId, { + name: 'preview/wrapper.html', + content: '', + artifactManifest: { + version: 1, + kind: 'html', + title: 'Review wrapper', + entry: 'reviewed.html', + renderer: 'html', + status: 'complete', + exports: ['html'], + primary: 'reviewed.html', + supportingFiles: ['reviewed.html'], + }, + }); + + await renameFile(projectId, 'preview/reviewed.html', 'reviewed.html'); + + const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`); + expect(response.ok).toBe(true); + const body = await response.json() as { + entryFile: string; + files: Array<{ name: string; role: string; reasons: string[] }>; + artifacts: Array<{ file: string; supportingFiles: string[] }>; + }; + + expect(body.entryFile).toBe('reviewed.html'); + expect(body.files.find((file) => file.name === 'reviewed.html')).toMatchObject({ + role: 'entry', + reasons: expect.arrayContaining([ + 'artifact-entry', + 'artifact-primary', + 'artifact-supporting-file', + 'project-entry-file', + ]), + }); + expect(body.artifacts.find((artifact) => artifact.file === 'preview/wrapper.html')) + .toMatchObject({ + supportingFiles: ['reviewed.html'], + }); + }); + + it('rejects invalid project ids before listing files', async () => { + const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`); + expect(response.status).toBe(400); + }); + + it('rejects imported-folder projects in sandbox mode instead of returning an empty manifest', async () => { + const folder = makeFolder(); + await writeFsFile(path.join(folder, 'index.html'), ''); + + const importResponse = await fetch(`${baseUrl}/api/import/folder`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ baseDir: folder }), + }); + expect(importResponse.status).toBe(200); + const importBody = (await importResponse.json()) as { project: { id: string } }; + projectsToClean.push(importBody.project.id); + + await withSandboxMode(async () => { + const response = await fetch(`${baseUrl}/api/projects/${importBody.project.id}/export/manifest`); + expect(response.status).toBe(400); + const body = (await response.json()) as { error?: { message?: string } }; + expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i); + }); + }); +}); diff --git a/apps/daemon/tests/mcp-spawn.test.ts b/apps/daemon/tests/mcp-spawn.test.ts index c4ebc6762..1e325b431 100644 --- a/apps/daemon/tests/mcp-spawn.test.ts +++ b/apps/daemon/tests/mcp-spawn.test.ts @@ -357,6 +357,73 @@ describe('spawn writes external MCP config for Claude Code', () => { } }, 30_000); + it('binds conversation-less runs to the seeded project conversation', async () => { + await withFakeClaude(async () => { + const { id, conversationId } = await createProject(); + const recentConvRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'Recently active' }), + }); + expect(recentConvRes.ok).toBe(true); + const recentConvBody = (await recentConvRes.json()) as { + conversation: { id: string }; + }; + const recentConversationId = recentConvBody.conversation.id; + await fetch(`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + title: 'Recently active', + updatedAt: Date.now() + 60_000, + }), + }); + + const chatRes = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agentId: 'claude', + projectId: id, + message: 'headless fallback prompt', + }), + }); + expect(chatRes.status).toBe(202); + const { runId, conversationId: resolvedConversationId } = (await chatRes.json()) as { + runId: string; + conversationId: string; + }; + expect(resolvedConversationId).toBe(conversationId); + const status = await waitForRunStatus(baseUrl, runId); + expect(status.status).toBe('succeeded'); + + const defaultMessagesRes = await fetch( + `${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`, + ); + expect(defaultMessagesRes.ok).toBe(true); + const defaultMessages = (await defaultMessagesRes.json()) as { + messages: Array<{ role: string; content: string }>; + }; + expect(defaultMessages.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: 'headless fallback prompt', + }), + ]), + ); + + const recentMessagesRes = await fetch( + `${baseUrl}/api/projects/${id}/conversations/${recentConversationId}/messages`, + ); + expect(recentMessagesRes.ok).toBe(true); + const recentMessages = (await recentMessagesRes.json()) as { + messages: Array<{ content: string }>; + }; + expect(recentMessages.messages.some((msg) => msg.content === 'headless fallback prompt')).toBe(false); + }); + }, 30_000); + it('injects run-scoped MCP servers without saving them to the persistent registry', async () => { await withFakeClaude(async () => { const { id, dir } = await createProject(); diff --git a/apps/daemon/tests/project-preview-containment.test.ts b/apps/daemon/tests/project-preview-containment.test.ts new file mode 100644 index 000000000..1bdc59927 --- /dev/null +++ b/apps/daemon/tests/project-preview-containment.test.ts @@ -0,0 +1,122 @@ +import type http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { startServer } from '../src/server.js'; + +describe('project preview containment routes', () => { + let server: http.Server; + let baseUrl: string; + const projectsToClean: string[] = []; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(async () => { + for (const id of projectsToClean.splice(0)) { + await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {}); + } + await new Promise((resolve) => server.close(() => resolve())); + }); + + async function createProject(metadata: Record = {}): Promise { + const id = `preview-containment-${randomUUID()}`; + const response = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id, + name: 'Preview containment project', + metadata, + }), + }); + expect(response.ok).toBe(true); + projectsToClean.push(id); + return id; + } + + async function writeProjectFile(projectId: string, name: string, content: string): Promise { + const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, content }), + }); + expect(response.ok).toBe(true); + } + + it('returns a scoped preview URL with sandbox guidance and serves it with an opaque-origin CSP', async () => { + const projectId = await createProject({ entryFile: 'pages/index.html' }); + await writeProjectFile( + projectId, + 'pages/index.html', + 'Preview', + ); + await writeProjectFile(projectId, 'styles/app.css', 'body { color: black; }'); + + const urlResponse = await fetch( + `${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('pages/index.html')}`, + ); + expect(urlResponse.ok).toBe(true); + expect(urlResponse.headers.get('cache-control')).toBe('no-store'); + const body = await urlResponse.json() as { + url: string; + file: string; + csp: string; + iframeSandbox: string; + opaqueOrigin: true; + }; + + expect(body.file).toBe('pages/index.html'); + expect(body.url).toContain(`/api/projects/${projectId}/preview/`); + expect(body.url).toMatch(/\/preview\/[A-Za-z0-9_-]{8,128}\/pages\/index\.html$/u); + expect(body.iframeSandbox).toBe('allow-scripts allow-forms'); + expect(body.iframeSandbox).not.toContain('allow-same-origin'); + expect(body.csp).toContain('sandbox allow-scripts allow-forms'); + expect(body.csp).toContain("connect-src 'none'"); + expect(body.csp).not.toContain('allow-same-origin'); + expect(body.opaqueOrigin).toBe(true); + + const previewResponse = await fetch(`${baseUrl}${body.url}`, { + headers: { Origin: 'null' }, + }); + expect(previewResponse.status).toBe(200); + expect(previewResponse.headers.get('access-control-allow-origin')).toBe('*'); + expect(previewResponse.headers.get('cache-control')).toBe('no-store'); + expect(previewResponse.headers.get('x-content-type-options')).toBe('nosniff'); + const csp = previewResponse.headers.get('content-security-policy') ?? ''; + expect(csp).toContain('sandbox allow-scripts allow-forms'); + expect(csp).toContain("connect-src 'none'"); + expect(csp).not.toContain('allow-same-origin'); + expect(await previewResponse.text()).toContain('Preview'); + + const scope = body.url.match(/\/preview\/([^/]+)\//u)?.[1]; + expect(scope).toBeTruthy(); + const assetResponse = await fetch( + `${baseUrl}/api/projects/${projectId}/preview/${scope}/styles/app.css`, + { headers: { Origin: 'null' } }, + ); + expect(assetResponse.status).toBe(200); + expect(assetResponse.headers.get('access-control-allow-origin')).toBe('*'); + expect(assetResponse.headers.get('content-type')).toContain('text/css'); + expect(await assetResponse.text()).toContain('color: black'); + }); + + it('rejects invalid preview scopes and escaping preview-url paths', async () => { + const projectId = await createProject(); + await writeProjectFile(projectId, 'index.html', ''); + + const invalidScope = await fetch(`${baseUrl}/api/projects/${projectId}/preview/bad/index.html`); + expect(invalidScope.status).toBe(400); + + const escapingPath = await fetch( + `${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('../index.html')}`, + ); + expect(escapingPath.status).toBe(400); + }); +}); diff --git a/e2e/lib/vitest/smoke-suite.ts b/e2e/lib/vitest/smoke-suite.ts index f6b2a1daa..17caf50c1 100644 --- a/e2e/lib/vitest/smoke-suite.ts +++ b/e2e/lib/vitest/smoke-suite.ts @@ -142,6 +142,8 @@ async function runToolsDevSuite( break; } catch (error) { if (attempt === 3 || !toolsDev.isToolsDevPortConflict(error)) throw error; + await runtime.release().catch(() => {}); + await toolsDev.stopToolsDevWeb(suite).catch(() => {}); runtime = await toolsDev.allocateToolsDevRuntime(); } } diff --git a/e2e/lib/vitest/tools-dev.ts b/e2e/lib/vitest/tools-dev.ts index 2929b6961..9661a7e5e 100644 --- a/e2e/lib/vitest/tools-dev.ts +++ b/e2e/lib/vitest/tools-dev.ts @@ -161,7 +161,8 @@ export function isToolsDevPortConflict(error: unknown): boolean { const text = error instanceof Error ? `${error.message}\n${error.stack ?? ''}` : String(error); - return text.includes('EADDRINUSE'); + return text.includes('EADDRINUSE') || + (text.includes('is already running in namespace') && text.includes('stop it or choose another namespace')); } async function runToolsDevJson(suite: SmokeSuite, args: string[]): Promise { diff --git a/e2e/tests/tools-dev/startup-conflicts.test.ts b/e2e/tests/tools-dev/startup-conflicts.test.ts new file mode 100644 index 000000000..1fa8fce24 --- /dev/null +++ b/e2e/tests/tools-dev/startup-conflicts.test.ts @@ -0,0 +1,19 @@ +// @vitest-environment node + +import { describe, expect, test } from 'vitest'; + +import { isToolsDevPortConflict } from '@/vitest/tools-dev'; + +describe('tools-dev startup conflict detection', () => { + test('classifies port and namespace startup collisions as retryable', () => { + expect(isToolsDevPortConflict(new Error('listen EADDRINUSE: address already in use 127.0.0.1:30123'))).toBe(true); + expect( + isToolsDevPortConflict( + new Error( + 'daemon is already running in namespace e2e-orbit-run-123 at http://127.0.0.1:36695; stop it or choose another namespace', + ), + ), + ).toBe(true); + expect(isToolsDevPortConflict(new Error('daemon exited before readiness'))).toBe(false); + }); +}); diff --git a/packages/contracts/src/api/files.ts b/packages/contracts/src/api/files.ts index 29d223983..cab652da5 100644 --- a/packages/contracts/src/api/files.ts +++ b/packages/contracts/src/api/files.ts @@ -46,6 +46,49 @@ export interface ProjectFilesResponse { files: ProjectFile[]; } +export type ProjectExportManifestFileRole = + | 'entry' + | 'artifact' + | 'supporting' + | 'asset' + | 'source' + | 'other'; + +export interface ProjectExportManifestFile extends ProjectFile { + included: boolean; + role: ProjectExportManifestFileRole; + reasons: string[]; +} + +export interface ProjectExportManifestArtifact { + file: string; + title: string; + kind: ArtifactKind | null; + renderer: string | null; + status: string | null; + exports: string[]; + supportingFiles: string[]; + updatedAt: string | null; +} + +export interface ProjectExportManifestResponse { + schema: 'open-design.project-export-manifest.v1'; + projectId: string; + projectName: string | null; + generatedAt: string; + entryFile: string | null; + files: ProjectExportManifestFile[]; + artifacts: ProjectExportManifestArtifact[]; +} + +export interface ProjectPreviewUrlResponse { + url: string; + file: string; + csp: string; + iframeSandbox: string; + opaqueOrigin: true; +} + export interface ProjectFileResponse { file: ProjectFile; }