Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 5 additions & 6 deletions apps/daemon/src/live-artifacts/schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isReservedProjectFilePath } from '../projects.js';

// Runtime validation lives in the daemon. These mirror the shared DTOs in
// packages/contracts/src/api/live-artifacts.ts without importing daemon internals
// into contracts or forcing the daemon to compile contract source files.
Expand Down Expand Up @@ -329,24 +331,21 @@ function validateRelativePath(value: string, path: string, issues: LiveArtifactV
}
}

// Reserved project path segments rejected by validateProjectPath() in projects.ts.
// Mirrored here (kept in sync with RESERVED_PROJECT_FILE_SEGMENTS) so schema-side
// acceptance stays a subset of what a refresh can actually read.
const RESERVED_READ_JSON_SEGMENTS = new Set(['.live-artifacts']);

// project_files.read_json resolves a selector, feeds it to validateProjectPath()
// (refresh.ts → projects.ts), and then requires a .json extension. validateRelativePath
// alone misses single-dot segments, reserved segments, and the extension, so mirror the
// remaining static rules here — otherwise sources like { path: './report.json' } or
// { file: '.live-artifacts/cache.json' } pass creation yet fail every refresh, recreating
// the persisted-but-unrefreshable artifact class this validation exists to prevent.
// The reserved-path predicate is shared with runtime reads so exact, prefix, and
// case-insensitive aliases cannot drift between registration and refresh.
function validateReadJsonSelector(value: string, path: string, issues: LiveArtifactValidationIssue[]): void {
validateRelativePath(value, path, issues); // absolute / .. / null-byte / length
const segments = value.replace(/\\/g, '/').split('/').filter((part) => part.length > 0);
if (segments.some((part) => part === '.')) {
issues.push({ path, message: `${path} cannot contain '.' path segments` });
}
if (segments.some((part) => RESERVED_READ_JSON_SEGMENTS.has(part))) {
if (isReservedProjectFilePath(value)) {
issues.push({ path, message: `${path} cannot reference a reserved project path` });
}
// Case-sensitive to match executeProjectFilesReadJson's `endsWith('.json')` exactly;
Expand Down
129 changes: 98 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,23 +348,37 @@ 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
// prefix check would let the literal path stay under projectRoot, then
// collectArchiveEntries() / readFile() would follow the symlink at
// open() time and zip files outside the project tree.
archiveRoot = await resolveSafeReal(projectRoot, root);
const projectRootReal = await realpath(projectRoot).catch(() => projectRoot);
const resolvedRootSegments = path
.relative(projectRootReal, archiveRoot)
.split(path.sep)
.filter(Boolean);
assertVisibleForImportedProject(resolvedRootSegments.join('/'), metadata);
if (resolvedRootSegments.some((segment) => isListingSkippedDirName(segment))) {
Comment thread
mturac marked this conversation as resolved.
const err = new Error('archive root is ignored or reserved');
err.code = 'BAD_REQUEST';
throw err;
}
archiveBaseName = path.basename(archiveRoot);
}

Expand All @@ -341,7 +404,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 +449,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 +553,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 +562,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 +1520,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 +1529,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
11 changes: 9 additions & 2 deletions apps/daemon/tests/live-artifacts-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,10 +385,17 @@ describe('live artifact schema validation', () => {

it('rejects single-dot and reserved-path read_json selectors across every alias', () => {
// validateProjectPath (refresh.ts → projects.ts) rejects '.' segments and the
// reserved '.live-artifacts' segment; the executor also requires a .json file.
// shared reserved-path policy; the executor also requires a .json file.
// Schema acceptance must stay a subset of that, or the source persists yet
// fails every refresh with "invalid file name" / "reserved project path".
const badValues = ['./report.json', '.live-artifacts/cache.json', 'nested/./report.json', 'reports/notes.txt'];
const badValues = [
'./report.json',
'.live-artifacts/cache.json',
'.MCP.JSON/config.json',
'.OD-RENAME-123.json',
'nested/./report.json',
'reports/notes.txt',
];
for (const alias of ['path', 'file', 'name'] as const) {
for (const value of badValues) {
const result = validateLiveArtifactCreateInput({
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