nexu-io/open-design · error · LegacyMigrationError
symlink_in_payload
symlink_in_payload
Error message
legacy payload contains a symlink at "${displayPath}"; refusing to migrate What it means
Thrown by the one-shot legacy `.od/` data migrator at daemon startup when OD_LEGACY_DATA_DIR points at a valid legacy payload but a symlink is discovered anywhere inside a payload entry tree (projects/, artifacts/, connectors/, composio/, or the sqlite/config files). assertNoSymlinks does a recursive lstat walk before fs.cpSync runs because cpSync would otherwise preserve the link and downstream readers (projects.ts) would follow it, escaping the data root. This is a deliberate fail-loud security guard (LegacyMigrationError with code symlink_in_payload), not a recoverable warning — the daemon aborts startup so a crafted legacy dir cannot smuggle content into the new data root.
Source
Thrown at apps/daemon/src/migration/legacy-data-migrator.ts:170
*/
export function dataDirHasExistingPayload(dataDir: string): string[] {
if (!isExistingDir(dataDir)) return [];
const present: string[] = [];
for (const entry of PAYLOAD_ENTRIES) {
if (fs.existsSync(path.join(dataDir, entry))) present.push(entry);
}
return present;
}
/**
* Walk a payload subtree and refuse to copy if any node is a symlink.
* fs.cpSync would otherwise preserve the link and downstream readers
* (projects.ts) would follow it, escaping the data root.
*/
function assertNoSymlinks(srcRoot: string, displayPath = srcRoot): void {
const stat = fs.lstatSync(srcRoot);
if (stat.isSymbolicLink()) {
throw new LegacyMigrationError(
'symlink_in_payload',
`legacy payload contains a symlink at "${displayPath}"; refusing to migrate`,
);
}
if (!stat.isDirectory()) return;
for (const child of fs.readdirSync(srcRoot)) {
assertNoSymlinks(path.join(srcRoot, child), path.join(displayPath, child));
}
}
/**
* Stage every present payload entry into `stagingDir`. Returns the list
* of entries that were copied. We use cpSync with verbatimSymlinks not
* set; the lstat walk above already rejected any symlink, so a non-link
* tree is what cpSync sees.
*/
function stagePayload(legacyDir: string, stagingDir: string): string[] {
fs.mkdirSync(stagingDir, { recursive: true });View on GitHub (pinned to 5be4028344)
Solutions
- Inspect the path named in the message: `ls -la <legacyDir>/<displayPath>` and confirm it is a symlink with `readlink`.
- Replace the symlink with the real file/dir it should contain (`cp -RL` the target into place, or `mv` the content), or delete the symlink if it is not needed.
- Re-launch the daemon with the same OD_LEGACY_DATA_DIR; migration re-runs from scratch because no .migrated-from marker was written on the failed attempt.
- If the symlink is intentional and points within the data root, flatten it by copying dereferenced content (`cp -L`) so the migrator sees a real tree.
- If you cannot remove the link, do not set OD_LEGACY_DATA_DIR on this boot; migrate the legacy payload manually with rsync (`rsync -L` to dereference) into the new data root before starting the daemon.
Example fix
// before: legacy .od/projects/abc -> /shared/projects/abc (symlink) // after (shell, before launching daemon): // rm /path/to/legacy/.od/projects/abc // cp -RL /shared/projects/abc /path/to/legacy/.od/projects/abc // then: OD_LEGACY_DATA_DIR=/path/to/legacy/.od pnpm tools-dev
Defensive patterns
Strategy: validation
Validate before calling
// Run this before setting OD_LEGACY_DATA_DIR on daemon boot.
import * as fs from 'node:fs';
import * as path from 'node:path';
function findSymlinks(root: string, base = root): string[] {
const hits: string[] = [];
let st: fs.Stats;
try { st = fs.lstatSync(root); } catch { return hits; }
if (st.isSymbolicLink()) { hits.push(path.relative(base, root) || '.'); return hits; }
if (!st.isDirectory()) return hits;
for (const child of fs.readdirSync(root)) {
hits.push(...findSymlinks(path.join(root, child), base));
}
return hits;
}
const entries = ['app.sqlite','app.sqlite-shm','app.sqlite-wal','app-config.json','media-config.json','projects','artifacts','connectors','composio'];
const bad = entries.flatMap(e => findSymlinks(path.join(legacyDir, e)));
if (bad.length) throw new Error(`refusing to migrate: symlinks at ${bad.join(', ')}`); Type guard
// Narrow a caught error to a legacy-migration failure.
import { LegacyMigrationError } from './legacy-data-migrator';
function isLegacyMigrationError(e: unknown): e is LegacyMigrationError {
return e instanceof Error && (e as LegacyMigrationError).code !== undefined && e.name === 'LegacyMigrationError';
} Try / catch
try {
migrateLegacyDataDir({ legacyDir: process.env.OD_LEGACY_DATA_DIR, dataDir: RUNTIME_DATA_DIR });
} catch (e) {
if (e instanceof LegacyMigrationError && e.code === 'symlink_in_payload') {
logger.error(`${e.message} Remove the symlink from the legacy payload and relaunch.`);
process.exit(1);
}
throw e;
} Prevention
- Never store symlinks under `.od/projects` or `.od/artifacts` in 0.3.x; copy real content.
- Before upgrading users, scan the legacy `.od/` with `find . -type l` and resolve every hit.
- Treat LegacyMigrationError as fatal at startup; do not swallow it into an empty workspace.
When it happens
Trigger: Daemon boot with OD_LEGACY_DATA_DIR set to a legacy `.od/` that (a) contains app.sqlite, (b) targets a fresh empty new dataDir with no .migrated-from marker, and (c) has at least one symlink anywhere under projects/, artifacts/, connectors/, composio/, or as one of the payload file entries. The check fires from stagePayload() which calls assertNoSymlinks(src) on each present PAYLOAD_ENTRIES entry.
Common situations: Users who symlinked projects/<id> to a shared drive or external workspace under 0.3.x; symlinks created by backup/restore tools or cloud-sync (Dropbox/iCloud) inside `.od/projects`; manually symlinked media-config.json to a dotfile repo; a migrated/copied legacy dir that picked up a relative symlink pointing outside the tree.
Related errors
- BAD_REQUEST
- path escapes project dir
- project_files.read_json does not follow symlinks
- invalid brand id: ${input.brandId}
- invalid design system id: ${designSystemId}
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/137893200da39fe1.
Report an issue: GitHub.