From fc718c168487de1213c978797c3fc84288f26513 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 10:36:01 +0800 Subject: [PATCH] fix(jobs): strip internal paths from all worker SSE error frames Closes #71. Several error paths in the worker could leak internal filesystem paths (/tmp/workspace, /data/ai/venv, /app) through SSE frames, resultPayload objects, and Redis batch-error lists. The existing stripInternalPaths call at worker.ts line 345 only covered the single-file processToolJob catch block. Wrapped 6 additional call sites: - processPipelineStep: prevError from DB and catch errorMsg - processPipelineFinalize: composed errorMsg reaching SSE, recordChildOutcome, and resultPayload - processBatchChild: catch error reaching recordChildOutcome and resultPayload - processBatchFinalize: manifest errorMsg from DB rows - recordChildOutcome (batch-progress.ts): defense-in-depth strip before Redis rpush Added 10 unit tests for stripInternalPaths covering /tmp, /data, /app, /opt, /home, /workspace, multi-path messages, safe passthrough, and pipeline-step wrapping. --- apps/api/src/jobs/batch-progress.ts | 4 +- apps/api/src/jobs/worker.ts | 13 ++++--- tests/unit/api/errors.test.ts | 57 ++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/api/src/jobs/batch-progress.ts b/apps/api/src/jobs/batch-progress.ts index ee1fc8d5..6af1eb28 100644 --- a/apps/api/src/jobs/batch-progress.ts +++ b/apps/api/src/jobs/batch-progress.ts @@ -12,6 +12,7 @@ * "failed" only when every file failed */ +import { stripInternalPaths } from "../lib/errors.js"; import { updateJobProgress } from "../routes/progress.js"; import { sharedRedis } from "./connection.js"; import { bullPrefix } from "./types.js"; @@ -37,7 +38,8 @@ export async function recordChildOutcome( const base = `${bullPrefix()}:batch:${parentId}`; const done = await r.incr(`${base}:${error ? "failed" : "done"}`); const other = Number((await r.get(`${base}:${error ? "done" : "failed"}`)) ?? 0); - if (error) await r.rpush(`${base}:errors`, JSON.stringify({ filename, error })); + if (error) + await r.rpush(`${base}:errors`, JSON.stringify({ filename, error: stripInternalPaths(error) })); await r.expire(`${base}:done`, 3600); await r.expire(`${base}:failed`, 3600); await r.expire(`${base}:errors`, 3600); diff --git a/apps/api/src/jobs/worker.ts b/apps/api/src/jobs/worker.ts index ba7fafd6..a9a5ab76 100644 --- a/apps/api/src/jobs/worker.ts +++ b/apps/api/src/jobs/worker.ts @@ -387,10 +387,11 @@ async function processPipelineStep(job: Job): Promise): Promise): Promise): Promise await recordChildOutcome(job.data.parentId!, job.data.totalFiles!, job.data.filename); return result; } catch (err) { - const error = err instanceof Error ? err.message : String(err); + const error = stripInternalPaths(err instanceof Error ? err.message : String(err)); await recordChildOutcome(job.data.parentId!, job.data.totalFiles!, job.data.filename, error); // Return a completed job with a failure marker so the parent runs. return { @@ -647,7 +648,7 @@ async function processBatchFinalize(job: Job): Promise { it("formats single issue with path", () => { @@ -34,3 +34,58 @@ describe("formatZodErrors", () => { expect(result).toBe("settings.quality: Too high"); }); }); + +describe("stripInternalPaths", () => { + it("strips /tmp scratch paths", () => { + const msg = "ENOENT: no such file or directory, open '/tmp/workspace/abc123/input.png'"; + expect(stripInternalPaths(msg)).toBe("ENOENT: no such file or directory, open '[internal]'"); + }); + + it("strips /data/ai/venv Python traceback paths", () => { + const msg = 'File "/data/ai/venv/lib/python3.11/site-packages/rembg/bg.py", line 42, in remove'; + expect(stripInternalPaths(msg)).toBe('File "[internal]", line 42, in remove'); + }); + + it("strips /app container paths", () => { + const msg = "Error loading model from /app/models/realesrgan-x4.pth"; + expect(stripInternalPaths(msg)).toBe("Error loading model from [internal]"); + }); + + it("strips /opt paths", () => { + const msg = "Cannot find /opt/libreoffice/program/soffice"; + expect(stripInternalPaths(msg)).toBe("Cannot find [internal]"); + }); + + it("strips /home paths", () => { + const msg = "Permission denied: /home/node/.cache/sharp"; + expect(stripInternalPaths(msg)).toBe("Permission denied: [internal]"); + }); + + it("strips /workspace paths", () => { + const msg = "failed at /workspace/snapotter/packages/image-engine/src/convert.ts:42"; + expect(stripInternalPaths(msg)).toBe("failed at [internal]"); + }); + + it("strips multiple paths in one message", () => { + const msg = "cp: /tmp/scratch/a.png -> /data/output/b.png failed"; + expect(stripInternalPaths(msg)).toBe("cp: [internal] -> [internal] failed"); + }); + + it("leaves safe messages untouched", () => { + const msg = "Invalid image format: expected PNG or JPEG"; + expect(stripInternalPaths(msg)).toBe(msg); + }); + + it("leaves timeout messages untouched", () => { + const msg = "Timed out after 120s"; + expect(stripInternalPaths(msg)).toBe(msg); + }); + + it("handles pipeline step error wrapping", () => { + const msg = "Step 2: Command failed: /tmp/workspace/job123/ffmpeg -i /data/input.mp4"; + const result = stripInternalPaths(msg); + expect(result).not.toContain("/tmp/"); + expect(result).not.toContain("/data/"); + expect(result).toBe("Step 2: Command failed: [internal] -i [internal]"); + }); +});