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
- Replace symlinks in the viewer build with real files: use 'cp -rL' (dereference) or 'rsync -L' when staging the viewer artifacts.
- Re-run the viewer build to regenerate a complete dist with a real index.html and assets/ directory.
- Fix the deploy step so it does not create symlinks inside the served viewer root.
- 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
- Stage viewer artifacts with dereferencing copies (rsync -L, cp -rL), never symlinks.
- Add a CI check that lstat on index.html and assets/ shows no symlinks.
- Keep the viewer build output layout stable (index.html + assets/ at root).
- Serve only from inside the trusted root; reject any entry with symlink members.
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
- Access denied
- native_runtime_context_entry_outside_bundle
- ACPX provider package issuer for ${packageName} resolves out
- ACPX ${agent} runtime executable must be a real regular file
- ACPX snapshot escaped its package
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/6d5805978b639c94.
Report an issue: GitHub.