From 871a393917b0420ef94c332f3e9b6be020034b92 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Thu, 28 May 2026 19:48:55 +0400 Subject: [PATCH 1/9] feat(daemon): add project export manifest --- apps/daemon/src/import-export-routes.ts | 172 +++++++++++++++++- .../tests/export-manifest-route.test.ts | 122 +++++++++++++ packages/contracts/src/api/files.ts | 35 ++++ 3 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 apps/daemon/tests/export-manifest-route.test.ts diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index 5e615f570..dea93265f 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,151 @@ 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) => { + const normalized = normalizeManifestProjectRef(ref, file.name); + if (!normalized || !filesByName.has(normalized)) return; + if (normalized === file.name) return; + supportingNames.add(normalized); + artifactSupporting.add(normalized); + note(normalized, reason); + }; + addManifestRef(manifest.entry, 'artifact-entry'); + if (typeof manifest.primary === 'string') { + addManifestRef(manifest.primary, 'artifact-primary'); + } + if (Array.isArray(manifest.supportingFiles)) { + for (const ref of manifest.supportingFiles) { + addManifestRef(ref, 'artifact-supporting-file'); + } + } + + 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; + const primary = files.find((file) => { + const manifest = file.artifactManifest; + if (!manifest || typeof manifest !== 'object') return false; + return manifest.primary === true || manifest.primary === file.name; + }); + if (primary?.name) return primary.name; + return files.find((file) => /(^|\/)index\.html?$/i.test(file.name))?.name + ?? files.find((file) => file.kind === 'html')?.name + ?? files[0]?.name + ?? null; +} + +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/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts new file mode 100644 index 000000000..eac4b4f6a --- /dev/null +++ b/apps/daemon/tests/export-manifest-route.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 export manifest route', () => { + 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(): 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: { kind: 'prototype', entryFile: 'index.html' }, + }), + }); + 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); + } + + 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('rejects invalid project ids before listing files', async () => { + const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`); + expect(response.status).toBe(400); + }); +}); diff --git a/packages/contracts/src/api/files.ts b/packages/contracts/src/api/files.ts index 29d223983..450b56261 100644 --- a/packages/contracts/src/api/files.ts +++ b/packages/contracts/src/api/files.ts @@ -46,6 +46,41 @@ 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 ProjectFileResponse { file: ProjectFile; } From 34ecd800aeec5832363ca10d0ca763d537f73504 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Thu, 28 May 2026 21:33:00 +0400 Subject: [PATCH 2/9] fix(daemon): honor export manifest primary refs --- apps/daemon/src/import-export-routes.ts | 36 ++++++++++++----- .../tests/export-manifest-route.test.ts | 40 ++++++++++++++++++- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index dea93265f..a6489e5f8 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -713,9 +713,19 @@ function buildProjectExportManifestResponse({ note(file.name, 'artifact-manifest'); const artifactSupporting = new Set(); - const addManifestRef = (ref: unknown, reason: string) => { - const normalized = normalizeManifestProjectRef(ref, file.name); - if (!normalized || !filesByName.has(normalized)) return; + const addManifestRef = ( + ref: unknown, + reason: string, + options: { preferProjectRoot?: boolean } = {}, + ) => { + const candidates = options.preferProjectRoot + ? [ + normalizeManifestProjectRootRef(ref), + normalizeManifestProjectRef(ref, file.name), + ] + : [normalizeManifestProjectRef(ref, file.name)]; + const normalized = candidates.find((candidate) => candidate && filesByName.has(candidate)); + if (!normalized) return; if (normalized === file.name) return; supportingNames.add(normalized); artifactSupporting.add(normalized); @@ -723,7 +733,7 @@ function buildProjectExportManifestResponse({ }; addManifestRef(manifest.entry, 'artifact-entry'); if (typeof manifest.primary === 'string') { - addManifestRef(manifest.primary, 'artifact-primary'); + addManifestRef(manifest.primary, 'artifact-primary', { preferProjectRoot: true }); } if (Array.isArray(manifest.supportingFiles)) { for (const ref of manifest.supportingFiles) { @@ -785,18 +795,26 @@ function chooseExportManifestEntryFile( ? project.metadata.entryFile : null; if (metadataEntry && filesByName.has(metadataEntry)) return metadataEntry; - const primary = files.find((file) => { + for (const file of files) { const manifest = file.artifactManifest; - if (!manifest || typeof manifest !== 'object') return false; - return manifest.primary === true || manifest.primary === file.name; - }); - if (primary?.name) return primary.name; + if (!manifest || typeof manifest !== 'object') continue; + if (manifest.primary === true) return file.name; + if (typeof manifest.primary !== 'string') continue; + 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; + } 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(); diff --git a/apps/daemon/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts index eac4b4f6a..489ef5a31 100644 --- a/apps/daemon/tests/export-manifest-route.test.ts +++ b/apps/daemon/tests/export-manifest-route.test.ts @@ -25,7 +25,9 @@ describe('project export manifest route', () => { await new Promise((resolve) => server.close(() => resolve())); }); - async function createProject(): Promise { + 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', @@ -33,7 +35,7 @@ describe('project export manifest route', () => { body: JSON.stringify({ id, name: 'Export manifest project', - metadata: { kind: 'prototype', entryFile: 'index.html' }, + metadata, }), }); expect(response.ok).toBe(true); @@ -115,6 +117,40 @@ describe('project export manifest route', () => { 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('rejects invalid project ids before listing files', async () => { const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`); expect(response.status).toBe(400); From a5f09334a225e661324929f526d2a27ffc9a999a Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Thu, 28 May 2026 21:35:45 +0400 Subject: [PATCH 3/9] fix(daemon): bind headless runs to default conversation --- apps/daemon/tests/mcp-spawn.test.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) 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(); From de6c0d498ea557997d40a362510c685a7ee38597 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Thu, 28 May 2026 21:58:49 +0400 Subject: [PATCH 4/9] test(e2e): retry tools-dev namespace startup collisions --- e2e/lib/vitest/smoke-suite.ts | 2 ++ e2e/lib/vitest/tools-dev.ts | 3 ++- e2e/tests/tools-dev/startup-conflicts.test.ts | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/tools-dev/startup-conflicts.test.ts 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); + }); +}); From 393bfd0c6e5c30fe96be569859aa369a19129042 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Fri, 29 May 2026 08:57:53 +0400 Subject: [PATCH 5/9] test(daemon): reject sandbox imported export manifests --- .../tests/export-manifest-route.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/daemon/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts index 489ef5a31..edea6b60c 100644 --- a/apps/daemon/tests/export-manifest-route.test.ts +++ b/apps/daemon/tests/export-manifest-route.test.ts @@ -1,5 +1,9 @@ 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'; @@ -8,6 +12,7 @@ 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 { @@ -22,9 +27,29 @@ describe('project export manifest route', () => { 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 { @@ -155,4 +180,25 @@ describe('project export manifest route', () => { 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); + }); + }); }); From 646fa370d228a96aadce8d87148d33fe6485b6d8 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Fri, 29 May 2026 14:07:33 +0400 Subject: [PATCH 6/9] fix(daemon): honor artifact manifest entry refs --- apps/daemon/src/artifact-manifest.ts | 11 ++++-- apps/daemon/src/import-export-routes.ts | 18 ++++++--- .../tests/export-manifest-route.test.ts | 38 +++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) 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 a6489e5f8..fbd06bbff 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -731,7 +731,7 @@ function buildProjectExportManifestResponse({ artifactSupporting.add(normalized); note(normalized, reason); }; - addManifestRef(manifest.entry, 'artifact-entry'); + addManifestRef(manifest.entry, 'artifact-entry', { preferProjectRoot: true }); if (typeof manifest.primary === 'string') { addManifestRef(manifest.primary, 'artifact-primary', { preferProjectRoot: true }); } @@ -798,12 +798,18 @@ function chooseExportManifestEntryFile( 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') continue; - 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; + 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 diff --git a/apps/daemon/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts index edea6b60c..2ae5a924c 100644 --- a/apps/daemon/tests/export-manifest-route.test.ts +++ b/apps/daemon/tests/export-manifest-route.test.ts @@ -176,6 +176,44 @@ describe('project export manifest route', () => { }); }); + 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('rejects invalid project ids before listing files', async () => { const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`); expect(response.status).toBe(400); From 328d893b4ff8d2ed8fa702f915ab0f6dcd8ad6b8 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Fri, 29 May 2026 14:20:05 +0400 Subject: [PATCH 7/9] fix(daemon): update renamed artifact manifest entries --- apps/daemon/src/projects.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/daemon/src/projects.ts b/apps/daemon/src/projects.ts index b126a71a2..cfd59f6ae 100644 --- a/apps/daemon/src/projects.ts +++ b/apps/daemon/src/projects.ts @@ -974,16 +974,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 }); From f2e04df500c1e99d6104f4b22ec75bfd4832b3fa Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Sat, 30 May 2026 20:55:09 +0400 Subject: [PATCH 8/9] fix(daemon): update artifact refs during rename --- apps/daemon/src/projects.ts | 148 ++++++++++++++++++ .../tests/export-manifest-route.test.ts | 70 +++++++++ 2 files changed, 218 insertions(+) diff --git a/apps/daemon/src/projects.ts b/apps/daemon/src/projects.ts index cfd59f6ae..1b8e72627 100644 --- a/apps/daemon/src/projects.ts +++ b/apps/daemon/src/projects.ts @@ -880,6 +880,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); @@ -999,6 +1000,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/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts index 2ae5a924c..fe79e7d1d 100644 --- a/apps/daemon/tests/export-manifest-route.test.ts +++ b/apps/daemon/tests/export-manifest-route.test.ts @@ -77,6 +77,15 @@ describe('project export manifest route', () => { 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, { @@ -214,6 +223,67 @@ describe('project export manifest route', () => { }); }); + 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('rejects invalid project ids before listing files', async () => { const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`); expect(response.status).toBe(400); From 0bffe6ba4079410fa36e7116f1a51a6100ab100d Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Sat, 30 May 2026 22:29:01 +0400 Subject: [PATCH 9/9] fix(daemon): preserve export refs across directory moves --- apps/daemon/src/import-export-routes.ts | 16 +++--- .../tests/export-manifest-route.test.ts | 52 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index fbd06bbff..fe8203f71 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -716,14 +716,16 @@ function buildProjectExportManifestResponse({ const addManifestRef = ( ref: unknown, reason: string, - options: { preferProjectRoot?: boolean } = {}, + options: { allowProjectRootFallback?: boolean; preferProjectRoot?: boolean } = {}, ) => { + const ownerRelative = normalizeManifestProjectRef(ref, file.name); + const projectRoot = normalizeManifestProjectRootRef(ref); const candidates = options.preferProjectRoot - ? [ - normalizeManifestProjectRootRef(ref), - normalizeManifestProjectRef(ref, file.name), - ] - : [normalizeManifestProjectRef(ref, file.name)]; + ? [projectRoot, ownerRelative] + : [ + ownerRelative, + ...(options.allowProjectRootFallback ? [projectRoot] : []), + ]; const normalized = candidates.find((candidate) => candidate && filesByName.has(candidate)); if (!normalized) return; if (normalized === file.name) return; @@ -737,7 +739,7 @@ function buildProjectExportManifestResponse({ } if (Array.isArray(manifest.supportingFiles)) { for (const ref of manifest.supportingFiles) { - addManifestRef(ref, 'artifact-supporting-file'); + addManifestRef(ref, 'artifact-supporting-file', { allowProjectRootFallback: true }); } } diff --git a/apps/daemon/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts index fe79e7d1d..c56bafc2a 100644 --- a/apps/daemon/tests/export-manifest-route.test.ts +++ b/apps/daemon/tests/export-manifest-route.test.ts @@ -284,6 +284,58 @@ describe('project export manifest route', () => { }); }); + 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);