{"record":{"id":"137893200da39fe1","repo":"nexu-io/open-design","slug":"symlink-in-payload","errorCode":"symlink_in_payload","errorMessage":"legacy payload contains a symlink at \"${displayPath}\"; refusing to migrate","messagePattern":"legacy payload contains a symlink at \"(.+?)\"; refusing to migrate","errorType":"exception","errorClass":"LegacyMigrationError","httpStatus":null,"severity":"error","filePath":"apps/daemon/src/migration/legacy-data-migrator.ts","lineNumber":170,"sourceCode":" */\nexport function dataDirHasExistingPayload(dataDir: string): string[] {\n  if (!isExistingDir(dataDir)) return [];\n  const present: string[] = [];\n  for (const entry of PAYLOAD_ENTRIES) {\n    if (fs.existsSync(path.join(dataDir, entry))) present.push(entry);\n  }\n  return present;\n}\n\n/**\n * Walk a payload subtree and refuse to copy if any node is a symlink.\n * fs.cpSync would otherwise preserve the link and downstream readers\n * (projects.ts) would follow it, escaping the data root.\n */\nfunction assertNoSymlinks(srcRoot: string, displayPath = srcRoot): void {\n  const stat = fs.lstatSync(srcRoot);\n  if (stat.isSymbolicLink()) {\n    throw new LegacyMigrationError(\n      'symlink_in_payload',\n      `legacy payload contains a symlink at \"${displayPath}\"; refusing to migrate`,\n    );\n  }\n  if (!stat.isDirectory()) return;\n  for (const child of fs.readdirSync(srcRoot)) {\n    assertNoSymlinks(path.join(srcRoot, child), path.join(displayPath, child));\n  }\n}\n\n/**\n * Stage every present payload entry into `stagingDir`. Returns the list\n * of entries that were copied. We use cpSync with verbatimSymlinks not\n * set; the lstat walk above already rejected any symlink, so a non-link\n * tree is what cpSync sees.\n */\nfunction stagePayload(legacyDir: string, stagingDir: string): string[] {\n  fs.mkdirSync(stagingDir, { recursive: true });","sourceCodeStart":152,"sourceCodeEnd":188,"githubUrl":"https://github.com/nexu-io/open-design/blob/5be4028344c2eb4c667c5a97bda8f750c5597ef7/apps/daemon/src/migration/legacy-data-migrator.ts#L152-L188","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: legacy .od/projects/abc -> /shared/projects/abc (symlink)\n// after (shell, before launching daemon):\n//   rm /path/to/legacy/.od/projects/abc\n//   cp -RL /shared/projects/abc /path/to/legacy/.od/projects/abc\n// then: OD_LEGACY_DATA_DIR=/path/to/legacy/.od pnpm tools-dev","handlingStrategy":"validation","validationCode":"// Run this before setting OD_LEGACY_DATA_DIR on daemon boot.\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nfunction findSymlinks(root: string, base = root): string[] {\n  const hits: string[] = [];\n  let st: fs.Stats;\n  try { st = fs.lstatSync(root); } catch { return hits; }\n  if (st.isSymbolicLink()) { hits.push(path.relative(base, root) || '.'); return hits; }\n  if (!st.isDirectory()) return hits;\n  for (const child of fs.readdirSync(root)) {\n    hits.push(...findSymlinks(path.join(root, child), base));\n  }\n  return hits;\n}\n\nconst entries = ['app.sqlite','app.sqlite-shm','app.sqlite-wal','app-config.json','media-config.json','projects','artifacts','connectors','composio'];\nconst bad = entries.flatMap(e => findSymlinks(path.join(legacyDir, e)));\nif (bad.length) throw new Error(`refusing to migrate: symlinks at ${bad.join(', ')}`);","typeGuard":"// Narrow a caught error to a legacy-migration failure.\nimport { LegacyMigrationError } from './legacy-data-migrator';\nfunction isLegacyMigrationError(e: unknown): e is LegacyMigrationError {\n  return e instanceof Error && (e as LegacyMigrationError).code !== undefined && e.name === 'LegacyMigrationError';\n}","tryCatchPattern":"try {\n  migrateLegacyDataDir({ legacyDir: process.env.OD_LEGACY_DATA_DIR, dataDir: RUNTIME_DATA_DIR });\n} catch (e) {\n  if (e instanceof LegacyMigrationError && e.code === 'symlink_in_payload') {\n    logger.error(`${e.message} Remove the symlink from the legacy payload and relaunch.`);\n    process.exit(1);\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["security","migration","filesystem","symlink","daemon-startup","typescript"],"backgroundTag":null,"analyzedSha":"5be4028344c2eb4c667c5a97bda8f750c5597ef7","analyzedAt":"2026-08-12T12:03:58.812Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}