paperclipai/paperclip · error · Error

Trusted viewer must not use symlinks

Error message

Trusted viewer must not use symlinks

What it means

Within the trusted viewer root, index.html must be a regular non-symlink file and assets must be a real non-symlink directory. Symlinks are rejected because a public report must only serve content from the trusted build; a symlink could redirect reads outside the trusted tree. Any violation throws this error.

Source

Thrown at packages/paperclip-runner/scripts/public-eval-viewer.mjs:38

      `<script type="application/json" id="paperclip-eval-report">${encodedPayload}</script>\n    <script type="module"`,
    );
}

export async function trustedViewerFiles(viewerRoot) {
  const rootStat = viewerRoot ? await lstat(viewerRoot) : null;
  if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory())
    throw new Error(
      "A trusted viewer build is required for public chat reports",
    );
  const indexStat = await lstat(join(viewerRoot, "index.html"));
  const assetsStat = await lstat(join(viewerRoot, "assets"));
  if (
    indexStat.isSymbolicLink() ||
    !indexStat.isFile() ||
    assetsStat.isSymbolicLink() ||
    !assetsStat.isDirectory()
  )
    throw new Error("Trusted viewer must not use symlinks");
  const index = await readFile(join(viewerRoot, "index.html"), "utf8");
  const files = new Map();
  for (const entry of await readdir(join(viewerRoot, "assets"), {
    withFileTypes: true,
  })) {
    if (!entry.isFile() || entry.isSymbolicLink() || !ASSET.test(entry.name))
      throw new Error("Unexpected trusted viewer asset");
    files.set(
      `viewer/assets/${entry.name}`,
      await readFile(join(viewerRoot, "assets", entry.name)),
    );
  }
  if (
    ![...files.keys()].some((name) => name.endsWith(".js")) ||
    !index.includes('<script type="module"')
  )
    throw new Error("Incomplete trusted viewer build");
  return { index, files };

View on GitHub (pinned to 01ad858492)

Solutions

  1. Replace symlinks in the viewer build with real files: use 'cp -rL' (dereference) or 'rsync -L' when staging the viewer artifacts.
  2. Re-run the viewer build to regenerate a complete dist with a real index.html and assets/ directory.
  3. Fix the deploy step so it does not create symlinks inside the served viewer root.
  4. Verify the viewer build output still contains index.html and assets/ at the expected locations and update viewerRoot if the layout changed.

Example fix

# before (symlinked deployment)
ln -s /shared/ui/dist/assets viewer/assets
# after
rsync -rL /shared/ui/dist/ viewer/  # real files, no symlinks
Defensive patterns

Strategy: validation

Validate before calling

import { lstat } from 'node:fs/promises';
const idx = await lstat(join(root, 'index.html'));
const assets = await lstat(join(root, 'assets'));
if (idx.isSymbolicLink() || !idx.isFile() || assets.isSymbolicLink() || !assets.isDirectory()) {
  throw new Error('viewer build must contain real index.html and assets/ (no symlinks)');
}

Type guard

async function isRealViewerLayout(root) {
  try {
    const idx = await lstat(join(root, 'index.html'));
    const assets = await lstat(join(root, 'assets'));
    return idx.isFile() && !idx.isSymbolicLink() && assets.isDirectory() && !assets.isSymbolicLink();
  } catch { return false; }
}

Try / catch

try {
  const files = await trustedViewerFiles(viewerRoot);
} catch (err) {
  if (err.message === 'Trusted viewer must not use symlinks') {
    throw new Error(`Viewer build at ${viewerRoot} contains symlinks; restage with cp -rL / rsync -L`);
  }
  throw err;
}

Prevention

When it happens

Trigger: trustedViewerFiles finds index.html is a symlink or not a file, or assets/ is a symlink or not a directory, when scanning the viewer build for a public report.

Common situations: Deployment pipelines that symlink dist files to save space; an artifact sync replacing real files with symlinks; a partially built viewer where assets/ was never emitted; a viewer build layout change.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/6d5805978b639c94. Report an issue: GitHub.