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

  1. Inspect the path named in the message: `ls -la <legacyDir>/<displayPath>` and confirm it is a symlink with `readlink`.
  2. 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.
  3. 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.
  4. 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.
  5. 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

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


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/137893200da39fe1. Report an issue: GitHub.