santifer/career-ops · error · Error
Refusing to write the PDF outside the tracker workspace
Error message
Refusing to write the PDF outside the tracker workspace: ${outputPath} What it means
The single-render PDF path guards its destination before doing any work: if outputPath is not inside the tracker workspace (realpath-based check via isWorkspaceOutputPath), the render is refused before directories are created, Chromium is launched, or temp files are written. This is the single-invocation counterpart of the batch entry guard (#2844).
Solutions
- Pass an outputPath inside the tracker workspace, e.g. output/<name>.pdf.
- Render into the workspace, then move the finished PDF to the desired external location.
- Check for symlinks: a path that looks inside the tree may resolve outside via a symlinked ancestor.
Example fix
// before
await renderPdf({ outputPath: '/home/me/Desktop/cv.pdf' });
// after
await renderPdf({ outputPath: 'output/cv.pdf' });
// then move it
mvSync('output/cv.pdf', '/home/me/Desktop/cv.pdf'); Defensive patterns
Strategy: validation
Validate before calling
import { isAbsolute, resolve } from 'node:path';
function assertSafeOutput(p, root) {
const r = resolve(root, p);
if (!r.startsWith(resolve(root) + '/')) throw new Error('output must be inside workspace');
return r;
} Try / catch
try {
await generatePdf(opts);
} catch (err) {
if (err.message.includes('outside the tracker workspace')) {
console.error('Render into output/ inside the repo, then move the file externally.');
} else throw err;
} Prevention
- Default output paths to output/ inside the repo root.
- Render first, move/copy externally second.
- Watch for symlinked directories that resolve outside the tree.
When it happens
Trigger: Calling the render entrypoint (or its opts.outputPath) with a path outside the workspace root: '../x.pdf', an absolute path in another directory, or a symlink that resolves outside the tree.
Common situations: Users asking for a CV PDF to be saved to Desktop/Downloads or a shared folder; CI jobs with cwd set outside the repo; symlinked output directories.
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
- output escapes the tracker workspace
- ⚠️ Font reference escapes fonts/, keeping original…
- escapes the tracker workspace: (workspaceRoot= canonical=…
- local-parser: path escapes the project root
- Unsupported template format
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/f77c57873a09324a.
Report an issue: GitHub.
Appendix: source
Thrown at generate-pdf.mjs:1697
const format = opts.format || 'a4';
const outputRoot = opts.workspaceRoot || workspaceRoot;
const requestedBaseDir = resolve(opts.baseDir || outputRoot);
// Temporary HTML is an output too: never let an external input path or
// caller-supplied baseDir choose an arbitrary directory. If the requested
// directory is outside the tracker workspace (or escapes through a symlink),
// keep the render workspace-owned while still allowing the input itself to
// be read.
const baseDir = isWorkspaceOutputPath(
resolve(requestedBaseDir, '.career-ops-render-anchor'),
outputRoot,
) ? requestedBaseDir : resolve(outputRoot);
const reportNum = opts.reportNum || '';
const inputPath = opts.inputPath || '';
// Reject an escaping destination before creating directories, launching
// Chromium, or writing any renderer temporary files (#2844).
if (!isWorkspaceOutputPath(outputPath, outputRoot)) {
throw new Error(`Refusing to write the PDF outside the tracker workspace: ${outputPath}`);
}
mkdirSync(dirname(outputPath), { recursive: true });
// Inject the user's theme tokens (config/profile.yml `style:`) as CSS custom
// properties so the templates' var(--x, <default>) reads pick them up (#1837).
// No `style:` block → no tokens → byte-identical output. Both the CV path and
// the cover-letter path flow through here, so both are themed from one place.
const styleTokens = opts.styleTokens ?? readStyleTokens();
html = injectThemeStyle(html, styleTokens);
html = injectPrintPageCss(html, format);
html = await inlineLocalFonts(html);
// Write HTML to a temp file in baseDir so page.goto() gives a file://
// origin that can load local images, fonts, and other resources.
const tmpHtmlPath = resolve(baseDir, `.career-ops-render-${randomUUID()}.html`);
const { writeFile, unlink } = await import('fs/promises');View on GitHub (pinned to aac998c7ed)