Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 87 additions & 31 deletions apps/daemon/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,34 @@ import {
import { isOrchestratorScratchWorkspace } from './workspace-contract.js';

const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.file-versions', '.live-artifacts']);
const RESERVED_PROJECT_FILE_SEGMENTS = new Set([
'.amr-attachments',
'.file-versions',
'.finalize.lock',
'.live-artifacts',
'.mcp.json',
'.od-skills',
Comment thread
mturac marked this conversation as resolved.
'.open-design',
'.pi',
'.transcript.jsonl',
'.transcript.lock',
]);
const RESERVED_PROJECT_FILE_PREFIXES = ['.od-rename-', '.transcript.jsonl.tmp.'];

function isReservedProjectFileSegment(name: string): boolean {
const normalized = name.toLowerCase();
return (
RESERVED_PROJECT_FILE_SEGMENTS.has(normalized) ||
RESERVED_PROJECT_FILE_PREFIXES.some((prefix) => normalized.startsWith(prefix))
);
}

// Listing skips both the generated/installed trees from the shared ignore
// list and the daemon's reserved state directories — the latter must never
// surface even now that other dot-prefixed user content is listed (#6175).
function isListingSkippedDirName(name: string): boolean {
return isIgnoredProjectDirName(name) || isReservedProjectFileSegment(name);
}
const DESIGN_HANDOFF_FILENAME = 'DESIGN-HANDOFF.md';
const DESIGN_MANIFEST_FILENAME = 'DESIGN-MANIFEST.json';
export const RUN_ARTIFACT_RECONCILE_MTIME_GRACE_MS = 1000;
Expand Down Expand Up @@ -141,7 +168,13 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
// Skip generated dependency/build trees for all project roots. Standard OD
// projects can contain framework installs too; surfacing package HTML like
// node_modules/tslib/*.html as artifacts produces blank previews.
await collectFiles(dir, '', out, isIgnoredProjectDirName, dir);
// Dot-prefixed user content (.github/, .vscode/, .notes.md) is listed for
// managed projects (#6175); imported folders keep hiding dot entries to
// stay consistent with assertVisibleForImportedProject, which refuses to
// serve hidden path segments from a user's own directory. Reserved daemon
// state files and directories stay hidden everywhere.
const skipHidden = hasExternalProjectRoot(metadata);
await collectFiles(dir, '', out, isListingSkippedDirName, dir, skipHidden);
Comment thread
mturac marked this conversation as resolved.
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);
const since = Number(opts.since);
Expand All @@ -155,12 +188,20 @@ export async function listProjectFolders(projectsRoot, projectId, opts = {}) {
const metadata = opts?.metadata;
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
const out = [];
await collectFolders(dir, '', out, isIgnoredProjectDirName);
// Same hidden-entry policy as listFiles (#6175).
const skipHidden = hasExternalProjectRoot(metadata);
await collectFolders(dir, '', out, isListingSkippedDirName, skipHidden);
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}

async function collectFolders(dir, relDir, out, shouldSkipDir?: (name: string) => boolean) {
async function collectFolders(
dir,
relDir,
out,
shouldSkipDir?: (name: string) => boolean,
skipHidden = false,
) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
Expand All @@ -170,7 +211,7 @@ async function collectFolders(dir, relDir, out, shouldSkipDir?: (name: string) =
}
for (const e of entries) {
if (!e.isDirectory()) continue;
if (e.name.startsWith('.')) continue;
if (skipHidden && e.name.startsWith('.')) continue;
if (shouldSkipDir?.(e.name)) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
Expand All @@ -182,7 +223,7 @@ async function collectFolders(dir, relDir, out, shouldSkipDir?: (name: string) =
size: 0,
mtime: st.mtimeMs,
});
await collectFolders(full, rel, out, shouldSkipDir);
await collectFolders(full, rel, out, shouldSkipDir, skipHidden);
}
}

Expand Down Expand Up @@ -262,7 +303,14 @@ export async function detectEntryFile(dir: string): Promise<string | null> {
return null;
}

async function collectFiles(dir, relDir, out, shouldSkipDir?: (name: string) => boolean, projectRoot = dir) {
async function collectFiles(
dir,
relDir,
out,
shouldSkipDir?: (name: string) => boolean,
projectRoot = dir,
skipHidden = false,
) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
Expand All @@ -271,12 +319,13 @@ async function collectFiles(dir, relDir, out, shouldSkipDir?: (name: string) =>
throw err;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
if (skipHidden && e.name.startsWith('.')) continue;
if (isReservedProjectFileSegment(e.name)) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (shouldSkipDir?.(e.name)) continue;
await collectFiles(full, rel, out, shouldSkipDir, projectRoot);
await collectFiles(full, rel, out, shouldSkipDir, projectRoot, skipHidden);
continue;
}
if (!e.isFile()) continue;
Expand All @@ -299,16 +348,19 @@ async function collectFiles(dir, relDir, out, shouldSkipDir?: (name: string) =>
}

// Build a ZIP of every file under the project directory (or under `root`,
// if it points at a subdirectory). Mirrors listFiles' filtering — dotfiles
// and `.artifact.json` sidecars are excluded — so the archive matches what
// the user sees in the file panel. Used by the "Download as .zip" share
// menu item, which exports the user's actual project tree (e.g. the
// uploaded `ui-design/` folder), not just the rendered HTML.
// if it points at a subdirectory). Mirrors listFiles' filtering: managed
// projects keep user-owned dotfiles, imported folders keep hiding all hidden
// segments, and ignored/reserved trees plus `.artifact.json` sidecars stay
// excluded everywhere. Used by the "Download as .zip" share menu item, which
// exports the user's actual project tree (e.g. the uploaded `ui-design/`
// folder), not just the rendered HTML.
export async function buildProjectArchive(projectsRoot, projectId, root, metadata?) {
const projectRoot = resolveProjectDir(projectsRoot, projectId, metadata);
const skipHidden = hasExternalProjectRoot(metadata);
let archiveRoot = projectRoot;
let archiveBaseName = '';
if (typeof root === 'string' && root.trim().length > 0) {
assertVisibleForImportedProject(root, metadata);
Comment thread
mturac marked this conversation as resolved.
// Use the symlink-aware resolver so that an imported folder containing
// e.g. `docs -> /Users/me/.ssh` cannot exfiltrate via
// GET /api/projects/:id/archive?root=docs. resolveSafe()'s string
Expand Down Expand Up @@ -341,7 +393,7 @@ export async function buildProjectArchive(projectsRoot, projectId, root, metadat
}

const entries = [];
await collectArchiveEntries(archiveRoot, '', entries);
await collectArchiveEntries(archiveRoot, '', entries, skipHidden);
if (entries.length === 0) {
const err = new Error('archive root is empty');
err.code = 'ENOENT';
Expand Down Expand Up @@ -386,20 +438,23 @@ export async function buildBatchArchive(projectsRoot, projectId, fileNames, meta
}

// Mirror the visible-file allowlist from collectFiles/collectArchiveEntries:
// reject any hidden segment, .artifact.json sidecars, and symlinks at any
// level of the path (not just the final basename).
// imported folders reject every hidden segment; managed projects allow
// user dotfiles but still reject ignored/reserved trees. Sidecars and
// symlinks remain ineligible everywhere.
const relSegments = path.relative(projectRoot, filePath).split(path.sep);
let hidden = false;
for (const seg of relSegments) {
if (seg.startsWith('.')) {
hidden = true;
break;
}
}
if (hidden) {
const importedHidden =
hasExternalProjectRoot(metadata) && relSegments.some((seg) => seg.startsWith('.'));
if (importedHidden) {
rejected.push({ name, reason: 'hidden segments are not eligible for archive' });
continue;
}
// Only directory segments participate in the shared directory ignore
// policy. A regular user file may legitimately be named `build` or
// `vendor`; validateProjectPath() already rejects reserved state segments.
if (relSegments.slice(0, -1).some((seg) => isIgnoredProjectDirName(seg))) {
rejected.push({ name, reason: 'ignored directory segments are not eligible for archive' });
continue;
}
if (path.basename(filePath).endsWith('.artifact.json')) {
rejected.push({ name, reason: 'artifact sidecars are not eligible for archive' });
continue;
Expand Down Expand Up @@ -487,7 +542,7 @@ export async function buildBatchArchive(projectsRoot, projectId, fileNames, meta
return { buffer, baseName: '' };
}

async function collectArchiveEntries(dir, relDir, out) {
async function collectArchiveEntries(dir, relDir, out, skipHidden = false) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
Expand All @@ -496,13 +551,14 @@ async function collectArchiveEntries(dir, relDir, out) {
throw err;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
if (skipHidden && e.name.startsWith('.')) continue;
if (isReservedProjectFileSegment(e.name)) continue;
if (!e.isDirectory() && !e.isFile()) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (isIgnoredProjectDirName(e.name)) continue;
await collectArchiveEntries(full, rel, out);
if (isListingSkippedDirName(e.name)) continue;
await collectArchiveEntries(full, rel, out, skipHidden);
continue;
}
if (e.name.endsWith('.artifact.json')) continue;
Expand Down Expand Up @@ -1453,7 +1509,7 @@ export function validateProjectPath(raw) {
if (parts.length === 0 || parts.some((p) => FORBIDDEN_SEGMENT.test(p))) {
throw new Error('invalid file name');
}
if (parts.some((part) => RESERVED_PROJECT_FILE_SEGMENTS.has(part))) {
if (parts.some((part) => isReservedProjectFileSegment(part))) {
throw new Error('reserved project path');
}
return parts.join('/');
Expand All @@ -1462,7 +1518,7 @@ export function validateProjectPath(raw) {
export function isReservedProjectFilePath(raw) {
try {
const normalized = String(raw ?? '').replace(/\\/g, '/');
return normalized.split('/').filter(Boolean).some((part) => RESERVED_PROJECT_FILE_SEGMENTS.has(part));
return normalized.split('/').filter(Boolean).some((part) => isReservedProjectFileSegment(part));
} catch {
return false;
}
Expand Down
14 changes: 11 additions & 3 deletions apps/daemon/tests/project-archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ describe('buildProjectArchive', () => {
.filter((entry) => !entry.dir)
.map((entry) => entry.name)
.sort();
expect(fileEntries).toEqual(['DESIGN-HANDOFF.md', 'DESIGN-MANIFEST.json', 'frames/phone.html', 'index.html', 'src/app.css']);
expect(fileEntries).toEqual([
'.hidden',
'DESIGN-HANDOFF.md',
'DESIGN-MANIFEST.json',
'frames/phone.html',
'index.html',
'src/app.css',
]);
});

it('zips the whole project when no root is given', async () => {
Expand All @@ -49,10 +56,11 @@ describe('buildProjectArchive', () => {
expect(fileEntries).toContain('DESIGN-HANDOFF.md');
expect(fileEntries).toContain('DESIGN-MANIFEST.json');
expect(fileEntries).toContain('README.md');
expect(fileEntries).toContain('ui-design/.hidden');
expect(fileEntries).toContain('ui-design/index.html');
expect(fileEntries).toContain('ui-design/src/app.css');
// dotfiles and .artifact.json sidecars are filtered, matching listFiles
expect(fileEntries.find((n) => n.includes('.hidden'))).toBeUndefined();
// Invariant: managed-project archives match listFiles and keep user
// dotfiles, while generated .artifact.json sidecars remain excluded.
expect(fileEntries.find((n) => n.endsWith('.artifact.json'))).toBeUndefined();
});

Expand Down
Loading
Loading