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.
This commit is contained in:
SnapOtter
2026-06-13 10:36:01 +08:00
parent b564932083
commit fc718c1684
3 changed files with 66 additions and 8 deletions
+56 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatZodErrors } from "../../../apps/api/src/lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../../apps/api/src/lib/errors.js";
describe("formatZodErrors", () => {
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]");
});
});