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

  1. Pass an outputPath inside the tracker workspace, e.g. output/<name>.pdf.
  2. Render into the workspace, then move the finished PDF to the desired external location.
  3. 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

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


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)