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
+3 -1
View File
@@ -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);
+7 -6
View File
@@ -387,10 +387,11 @@ async function processPipelineStep(job: Job<ToolJobData>): Promise<ToolJobResult
if (!prevRow || prevRow.status === "failed" || !prevRow.outputRefs?.[0]) {
// Previous step failed -- propagate the error without processing.
const prevError =
const prevError = stripInternalPaths(
prevRow?.status === "failed"
? ((prevRow.error as { message?: string } | null)?.message ?? "Processing failed")
: "Previous step has no output";
: "Previous step has no output",
);
await db
.update(schema.jobs)
.set({ status: "failed", completedAt: new Date(), error: { message: prevError } })
@@ -426,7 +427,7 @@ async function processPipelineStep(job: Job<ToolJobData>): Promise<ToolJobResult
// Step failed -- return failure marker. processToolJob already
// updated the DB row to "failed" and emitted a terminal event
// on the step's own progress channel.
const errorMsg = err instanceof Error ? err.message : String(err);
const errorMsg = stripInternalPaths(err instanceof Error ? err.message : String(err));
return {
outputRefs: [],
filename: data.filename,
@@ -494,7 +495,7 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
// ── Failure path ────────────────────────────────────────────
if (failedAtStep !== null) {
const errorMsg = `Step ${failedAtStep + 1}: ${failError}`;
const errorMsg = stripInternalPaths(`Step ${failedAtStep + 1}: ${failError}`);
await db
.update(schema.jobs)
@@ -596,7 +597,7 @@ async function processBatchChild(job: Job<ToolJobData>): Promise<ToolJobResult>
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<ToolJobData>): Promise<ToolJobResul
} else {
const errorMsg = (row.error as { message?: string } | null)?.message ?? "Processing failed";
const inputFilename = row.inputRefs?.[0]?.split("/").pop() ?? `file-${i}`;
manifest.push({ index: i, filename: inputFilename, error: errorMsg });
manifest.push({ index: i, filename: inputFilename, error: stripInternalPaths(errorMsg) });
}
}