mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(contracts): types for folder-import endpoint
Add ImportFolderRequest, ImportFolderResponse to the public contract
surface. Extend ProjectMetadata with a baseDir field — when set, the
project's files live at this absolute path instead of .od/projects/<id>/.
Stored as the realpath() result so symlinks cannot redirect later writes.
Refs nexu-io/open-design#597
* feat(daemon): support metadata.baseDir for folder-rooted projects
Add resolveProjectDir() and metadata-aware variants of listFiles,
readProjectFile, writeProjectFile, ensureProject so a project's files
can live under metadata.baseDir (the user's chosen folder) instead of
.od/projects/<id>/. metadata.baseDir is opt-in — projects without it
keep the existing .od/projects/<id>/ behavior unchanged.
When listFiles walks a baseDir-rooted project, it skips conventional
build / install dirs (node_modules, .git, dist, build, .next, .nuxt,
.turbo, .cache, .output, out, coverage, __pycache__, .venv, vendor,
target, .od, .tmp) so the file panel stays focused on design content
instead of being dominated by lockfiles and node_modules.
Add detectEntryFile() — best-effort lookup for index.html or any
.html at the folder root, used by the import endpoint to seed the
initial active tab.
Refs nexu-io/open-design#597
* feat(daemon): add POST /api/import/folder endpoint
Creates a project rooted at the submitted local folder. metadata.baseDir
points at that folder and OD reads / writes there directly — no copy,
no shadow tree, mirroring how Cursor / Claude Code / Aider behave. The
user owns the workspace and is responsible for their own version
control.
Safety:
- baseDir is canonicalized via fs.promises.realpath() at import time so
user-controlled symlinks can't redirect later writes. resolveSafe
enforces the bounds check against the literal stored path; without
realpath, a symlink (e.g. ~/sneaky → /etc) would let writeProjectFile
escape the project tree at every later call because the OS follows
the symlink at open() time.
- Post-realpath lstat ensures the canonical target is itself a real
directory (defense-in-depth).
- The data directory (RUNTIME_DATA_DIR) and its descendants are
refused after symlink resolution so a redirect into the daemon's
own state can't masquerade as a project import.
The web client wires this through state/projects.ts → App.tsx,
landing the user on the auto-detected entry file when present.
Refs nexu-io/open-design#597
* feat(desktop): expose native folder picker to renderer
Adds an Electron preload script that exposes window.electronAPI.pickFolder
via contextBridge. Wires dialog.showOpenDialog through ipcMain so the
web UI can open a native folder selector for project import. Browser-only
users fall back to a text input for the absolute path (handled in the
web layer); the picker stays an optional convenience on the desktop
binary.
ipcMain.handle() registers handlers in an internal map that is not
exposed via eventNames(), so the natural-looking guard
if (!ipcMain.eventNames().includes('dialog:pick-folder')) ipcMain.handle(...)
is always true. On a second createDesktopRuntime() call (dev hot-reload,
packaged-vs-electron mode swap) the body re-runs and ipcMain.handle()
throws 'Attempted to register a second handler'. Use removeHandler()
+ handle() unconditionally — removeHandler() is a documented no-op
when nothing is registered, making the pair idempotent.
Includes *.cts in the apps/desktop tsconfig so the preload script is
typechecked.
Refs nexu-io/open-design#597
* feat(web): add 'From existing folder' option to New Project
UI surface for the import flow:
- A new 'Open folder' affordance in NewProjectPanel that uses the
native picker on Electron (window.electronAPI.pickFolder) and falls
back to an absolute-path text input in the browser.
- importFolderProject() in state/projects.ts: typed wrapper around
POST /api/import/folder using @open-design/contracts types.
- App.tsx wires the response: prepend the new project to the list,
navigate to it, and select the auto-detected entry file as the
active tab.
Skill / design-system pickers from the existing prototype tab are
reused — folder import is a project-creation flow, not a separate
project type.
Refs nexu-io/open-design#597
* docs(architecture): document folder-import endpoint
Adds POST /api/import/folder to the daemon API table and a 'Folder
import' section explaining the single-mode design (direct read/write
in metadata.baseDir, mirroring Cursor / Claude Code / Aider), the
realpath() canonicalization, the RUNTIME_DATA_DIR refusal, and the
SKIP_DIRS list applied to listFiles for baseDir-rooted projects.
Refs nexu-io/open-design#597
* test(daemon): unit + integration tests for folder import
Two new files:
apps/daemon/tests/folder-import-projects.test.ts (13 unit tests):
- resolveProjectDir behavior under all metadata combinations,
including the fallback when baseDir is relative and the
isSafeId-bypass when baseDir is set
- detectEntryFile: index.html priority, .html fallback, null when
no html, no descent into subdirs
- listFiles with metadata.baseDir: walk, SKIP_DIRS hides node_modules
/ .git / dist, back-compat for projects without baseDir
apps/daemon/tests/folder-import-route.test.ts (10 integration tests):
- Happy path: baseDir stored in metadata, importedFrom='folder',
conversation created, entry file detected
- Error paths: missing baseDir, empty, relative, non-existent,
pointing at a file
- Security: realpath canonicalization (the symlink test was the one
that surfaced the original /var vs /private/var mismatch in
RUNTIME_DATA_DIR comparison on macOS)
- Security: a symlink that resolves into RUNTIME_DATA_DIR is rejected
after realpath, not before
Refs nexu-io/open-design#597
* fix(daemon): wire baseDir metadata into chat + deploy reads
Two bugs caught in Codex automated review of #624:
1. chat-route was passing the metadata object directly as the listFiles
opts argument: `listFiles(PROJECTS_DIR, projectId, chatMeta)`. The
listFiles contract reads opts.metadata, not opts itself, so this
silently fell back to .od/projects/<id>/ instead of the imported
folder. existingProjectFiles was empty for baseDir-rooted projects.
Wrap as `{ metadata: chatMeta }`.
2. deploy.ts read project files via readProjectFile without the
metadata third argument, so for baseDir-rooted projects the deploy
and preflight endpoints would look in .od/projects/<id>/ and fail
with file-not-found instead of reading the imported folder. Thread
options.metadata through buildDeployFilePlan → readProjectFile and
pass project?.metadata at the two server.ts callsites
(`POST /api/projects/:id/deploy` and the preflight endpoint).
Add a regression test that locks the listFiles contract: passing a
bare metadata object as opts must NOT scan baseDir — it must fall back
to the standard project dir, otherwise callers can leak the wrong
folder by mistake.
Refs nexu-io/open-design#597, #624 (Codex review)
* fix(daemon): ensure correct metadata handling in folder import
Addressed issues with metadata handling in folder import functionality. Updated the listFiles and readProjectFile methods to correctly utilize the metadata.baseDir, ensuring that project files are read from the intended directory. Added regression tests to verify that passing a bare metadata object does not inadvertently scan the baseDir, maintaining the integrity of project file access.
Refs nexu-io/open-design#597
* fix(daemon): security hardening from Codex review of #624
P1 findings from automated review:
1. POST /api/projects + PATCH /api/projects/:id rejected
client-supplied metadata.baseDir. baseDir is privileged: it lets a
project root inside the user's filesystem, and the realpath() +
RUNTIME_DATA_DIR reentry checks live only on /api/import/folder.
Allowing it on the generic create/patch path lets an attacker
smuggle e.g. /etc through and bypass every import-time guard.
Both endpoints now refuse a baseDir field with 400.
2. resolveSafeReal() helper: realpath()s each candidate path (or its
longest existing prefix for write paths) and re-validates against
realpath(projectRoot). The original resolveSafe() only did a
string-prefix check, which was fooled by symlinks *inside* a
baseDir-rooted project. A repo containing 'assets -> /Users/me/.ssh'
passed the literal prefix check but readFile() followed the link
at open() time. resolveSafeReal() is now used by readProjectFile,
writeProjectFile, and deleteProjectFile.
3. Multer chat-upload destination now resolves to metadata.baseDir for
imported folder projects via a module-level lookup wired to db at
startServer() boot. Previously attachments landed in
.od/projects/<id>/ even for baseDir projects, so the agent (which
runs with cwd=baseDir) couldn't open them.
P2 findings:
4. searchProjectFiles threads metadata through listFiles +
resolveProjectDir so /api/projects/:id/search hits the right tree.
5. buildProjectArchive + buildBatchArchive now accept metadata so
'Download .zip' works for imported folder projects.
6. Watcher subscribe() resolves to baseDir for imported projects so
live-reload SSE actually fires when the user edits files in their
own folder. Registry stays keyed by the canonical directory.
7. Template snapshotting reads source-project files with metadata
so a template can be saved from a baseDir-rooted source.
Tests:
- Regression: POST /api/projects with metadata.baseDir → 400.
- Regression: descendant symlink (assets/leak.txt -> /etc/hosts) is
refused on the raw read endpoint.
Refs nexu-io/open-design#597, #624 (Codex P1+P2 review)
* fix(daemon): close two regressions found in #624 review round 2
@mrcfps caught two more correctness gaps:
1. Archive root symlink escape — buildProjectArchive accepts an optional
?root=<subdir> param to scope the zip to a subdirectory. The path was
resolved with the string-only resolveSafe(), so a directory symlink
inside an imported folder (docs -> /Users/me/.ssh) passed the prefix
check and collectArchiveEntries() then walked outside the project
tree. Switch to the symlink-aware resolveSafeReal() — the same one
that already protects raw read/write/delete paths. The walker itself
already skips dirent symlinks via !isDirectory && !isFile, so
canonicalizing the root is the only missing piece.
2. PATCH metadata wiped baseDir — updateProject() replaces metadata
wholesale. The previous guard only blocked an explicit baseDir
change, but a normal patch that *omits* baseDir (a UI editing
linkedDirs only sends { metadata: { kind, linkedDirs } }) silently
detached imported projects from their folder root. Subsequent
reads/writes/watch/deploy fell back to .od/projects/<id>.
Re-stamp the immutable folder-import fields (baseDir, importedFrom='folder')
from the existing project record onto the incoming patch when the
project is imported. A patch that supplies a *different* baseDir
still gets rejected as before; a patch that supplies the *same*
baseDir is accepted as a no-op. A patch on a non-imported project
that tries to set baseDir is also still rejected (preserves the
POST /api/projects guard from the previous round).
Tests:
- archive endpoint: ?root=<symlink-to-/etc> → 400.
- patch endpoint: PATCH that omits baseDir on an imported project keeps
baseDir intact (project still resolves to the user's folder after).
Refs nexu-io/open-design#597, #624 (Codex P1 round 2)
* fix(web): add Indonesian deploy provider copy
---------
Co-authored-by: INFINITY <valentyn.sotov@trendarena.app>
Co-authored-by: Siri-Ray <2667192167@qq.com>
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
import type http from 'node:http';
|
|
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { startServer } from '../src/server.js';
|
|
|
|
describe('POST /api/import/folder', () => {
|
|
let server: http.Server;
|
|
let baseUrl: 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;
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
function makeFolder(): string {
|
|
const d = mkdtempSync(path.join(tmpdir(), 'od-import-'));
|
|
tempDirs.push(d);
|
|
return d;
|
|
}
|
|
|
|
async function importFolder(body: unknown) {
|
|
return fetch(`${baseUrl}/api/import/folder`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
it('creates a project rooted at the submitted folder', async () => {
|
|
const folder = makeFolder();
|
|
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
|
|
|
const resp = await importFolder({ baseDir: folder });
|
|
expect(resp.status).toBe(200);
|
|
const body = (await resp.json()) as {
|
|
project: { id: string; metadata?: { baseDir?: string; importedFrom?: string } };
|
|
conversationId: string;
|
|
entryFile: string | null;
|
|
};
|
|
expect(body.project.metadata?.baseDir).toBeTruthy();
|
|
expect(body.project.metadata?.importedFrom).toBe('folder');
|
|
expect(body.conversationId).toBeTruthy();
|
|
expect(body.entryFile).toBe('index.html');
|
|
});
|
|
|
|
it('auto-detects the entry file when present', async () => {
|
|
const folder = makeFolder();
|
|
await writeFile(path.join(folder, 'index.html'), '');
|
|
const resp = await importFolder({ baseDir: folder });
|
|
const body = (await resp.json()) as { entryFile: string | null };
|
|
expect(body.entryFile).toBe('index.html');
|
|
});
|
|
|
|
it('returns null entryFile when the folder has no html file', async () => {
|
|
const folder = makeFolder();
|
|
await writeFile(path.join(folder, 'README.md'), '# hi');
|
|
const resp = await importFolder({ baseDir: folder });
|
|
const body = (await resp.json()) as { entryFile: string | null };
|
|
expect(body.entryFile).toBeNull();
|
|
});
|
|
|
|
it('rejects when baseDir is missing', async () => {
|
|
const resp = await importFolder({});
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/baseDir required/i);
|
|
});
|
|
|
|
it('rejects when baseDir is empty', async () => {
|
|
const resp = await importFolder({ baseDir: ' ' });
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/baseDir required/i);
|
|
});
|
|
|
|
it('rejects a relative baseDir', async () => {
|
|
const resp = await importFolder({ baseDir: 'relative/path' });
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/absolute/i);
|
|
});
|
|
|
|
it('rejects a non-existent path', async () => {
|
|
const resp = await importFolder({ baseDir: '/this/path/should/not/exist/od-test' });
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/not found/i);
|
|
});
|
|
|
|
it('rejects when the path points at a file', async () => {
|
|
const folder = makeFolder();
|
|
const filePath = path.join(folder, 'file.txt');
|
|
await writeFile(filePath, 'hi');
|
|
const resp = await importFolder({ baseDir: filePath });
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/directory/i);
|
|
});
|
|
|
|
// Security: a user-controlled symlink at baseDir would let writeProjectFile
|
|
// escape the project sandbox at every later call (resolveSafe checks the
|
|
// *literal* baseDir, but the OS follows symlinks at open() time). The
|
|
// realpath() canonicalization at import collapses the chain so the stored
|
|
// baseDir == what the kernel will write to.
|
|
it('canonicalizes symlinks via realpath at import time', async () => {
|
|
const realFolder = makeFolder();
|
|
await writeFile(path.join(realFolder, 'index.html'), '');
|
|
const linkParent = makeFolder();
|
|
const linkPath = path.join(linkParent, 'sneaky');
|
|
symlinkSync(realFolder, linkPath);
|
|
|
|
const resp = await importFolder({ baseDir: linkPath });
|
|
expect(resp.status).toBe(200);
|
|
const body = (await resp.json()) as {
|
|
project: { metadata?: { baseDir?: string } };
|
|
};
|
|
// Stored baseDir must be the realpath, not the symlink path. Use
|
|
// realpath on the temp folder too since macOS prefixes /private/.
|
|
const expected = path.normalize(realFolder);
|
|
expect(body.project.metadata?.baseDir).not.toBe(linkPath);
|
|
// The stored baseDir resolves to realFolder (allowing for /private/ prefix)
|
|
expect(
|
|
body.project.metadata?.baseDir?.endsWith(path.basename(expected)),
|
|
).toBe(true);
|
|
});
|
|
|
|
// Defense against descendant-symlink escape: even after canonicalizing
|
|
// the import-time baseDir, a symlink *inside* the imported folder
|
|
// (e.g. assets -> /Users/me/.ssh) used to pass resolveSafe()'s string
|
|
// check because the literal path stayed under baseDir, but the OS
|
|
// followed the link at open() time and returned bytes from outside
|
|
// the project. resolveSafeReal() canonicalizes each read/write/delete,
|
|
// so any link reaching outside the project root is refused with a
|
|
// 4xx instead of an exfiltration channel.
|
|
// Defense against client-supplied baseDir on the generic create path:
|
|
// /api/import/folder owns the realpath() + RUNTIME_DATA_DIR reentry
|
|
// checks. POST /api/projects (and PATCH) must refuse a metadata.baseDir
|
|
// payload outright, otherwise an attacker bypasses the import-time
|
|
// sandbox guards.
|
|
it('rejects baseDir on the generic POST /api/projects', async () => {
|
|
const resp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: `tmp-${Date.now()}`,
|
|
name: 'sneaky',
|
|
metadata: { kind: 'prototype', baseDir: '/etc' },
|
|
}),
|
|
});
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/baseDir.*import\/folder/i);
|
|
});
|
|
|
|
// Same defense extended to the archive endpoint. resolveSafe() at the
|
|
// archive root only did string-prefix validation; a directory symlink
|
|
// like `docs -> /Users/me/.ssh` would pass and collectArchiveEntries()
|
|
// would zip files outside the imported folder. resolveSafeReal() now
|
|
// canonicalizes the archive root before walking it.
|
|
it('refuses archive root that resolves outside the imported folder', async () => {
|
|
const real = makeFolder();
|
|
await writeFile(path.join(real, 'index.html'), '<!doctype html>');
|
|
try {
|
|
symlinkSync('/etc', path.join(real, 'docs'));
|
|
} catch {
|
|
return;
|
|
}
|
|
const importResp = await importFolder({ baseDir: real });
|
|
const { project } = (await importResp.json()) as { project: { id: string } };
|
|
const archive = await fetch(
|
|
`${baseUrl}/api/projects/${project.id}/archive?root=docs`,
|
|
);
|
|
expect(archive.status).toBe(400);
|
|
});
|
|
|
|
// Regression for the patch-metadata wipe. updateProject() replaces
|
|
// metadata wholesale, so a normal UI patch that omits baseDir would
|
|
// silently detach the project from its imported folder. Verify the
|
|
// route preserves baseDir even when the incoming patch doesn't
|
|
// mention it.
|
|
it('preserves metadata.baseDir when PATCH omits it', async () => {
|
|
const real = makeFolder();
|
|
await writeFile(path.join(real, 'index.html'), '');
|
|
const importResp = await importFolder({ baseDir: real });
|
|
const { project } = (await importResp.json()) as {
|
|
project: { id: string; metadata: { baseDir: string } };
|
|
};
|
|
const originalBaseDir = project.metadata.baseDir;
|
|
expect(originalBaseDir).toBeTruthy();
|
|
|
|
// Patch unrelated metadata field. baseDir is not mentioned.
|
|
const patchResp = await fetch(`${baseUrl}/api/projects/${project.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
metadata: { kind: 'prototype', linkedDirs: [] },
|
|
}),
|
|
});
|
|
expect(patchResp.status).toBe(200);
|
|
const after = (await patchResp.json()) as {
|
|
project: { metadata: { baseDir?: string } };
|
|
};
|
|
expect(after.project.metadata.baseDir).toBe(originalBaseDir);
|
|
});
|
|
|
|
it('refuses raw reads through a descendant symlink that escapes the folder', async () => {
|
|
const real = makeFolder();
|
|
await mkdir(path.join(real, 'assets'));
|
|
// Point a symlink at /etc/hosts (always exists, harmless to read,
|
|
// but unambiguously outside the imported folder).
|
|
try {
|
|
symlinkSync('/etc/hosts', path.join(real, 'assets', 'leak.txt'));
|
|
} catch {
|
|
return;
|
|
}
|
|
const importResp = await importFolder({ baseDir: real });
|
|
expect(importResp.status).toBe(200);
|
|
const { project } = (await importResp.json()) as { project: { id: string } };
|
|
|
|
const raw = await fetch(
|
|
`${baseUrl}/api/projects/${project.id}/raw/assets/leak.txt`,
|
|
);
|
|
expect(raw.status).toBe(400);
|
|
});
|
|
|
|
it('refuses a symlink that resolves into the daemon data directory', async () => {
|
|
// Create a symlink that points into the test's RUNTIME_DATA_DIR (the
|
|
// tmpdir-based path the daemon is using). Without realpath, this would
|
|
// bypass the RUNTIME_DATA_DIR-reentry check.
|
|
const dataDir = process.env.OD_DATA_DIR;
|
|
if (!dataDir) {
|
|
// Test setup didn't pin a data dir — skip this case rather than guess.
|
|
return;
|
|
}
|
|
const linkParent = makeFolder();
|
|
const linkPath = path.join(linkParent, 'into-data');
|
|
try {
|
|
symlinkSync(dataDir, linkPath);
|
|
} catch {
|
|
// Symlink creation may fail in restricted CI environments — skip.
|
|
return;
|
|
}
|
|
const resp = await importFolder({ baseDir: linkPath });
|
|
expect(resp.status).toBe(400);
|
|
const body = (await resp.json()) as { error?: { message?: string } };
|
|
expect(body.error?.message).toMatch(/data directory/i);
|
|
});
|
|
});
|