fix(skills,producer): terminate ffprobe options in shipped skill scripts

The contract test only walked packages/*/src and only .ts, so it could not see
the shipped agent tools under skills/**, which are .mjs/.cjs. 19 call sites
there and in package tests were still missing `--` immediately before the
input while the suite reported the bug class closed — a dash-prefixed filename
is parsed as an option and fails the same way.

Sweeps packages/, skills/ and scripts/ now, including .mjs/.cjs and test
files (dither.test.mjs was one of the broken sites). Excludes only the
contract test itself, which documents the contract with example argvs
including a deliberately misordered one.

Two guards were fixed while widening: the terminator must never be inserted
after `-i`, which consumes the next token (a blind pass hit an ffmpeg input
and a base64 -i), and comment prose describing a spawn is not a spawn.

Also routes every audioPadTrim probe failure through one sanitizer at the
boundary. runFfprobeJson scrubbed its own stderr, but
defaultProbeVideoFrameInfo threw `no video stream in ${videoPath}` raw into
the public PadTrimAudioResult.error, and an injected probe can throw anything.
The redaction unit tests all passed with the caller wiring deleted; the new
public-path regressions fail without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-04 02:23:35 -07:00
co-authored by Claude Opus 5
parent e79ab3ab31
commit 255cf92915
22 changed files with 155 additions and 24 deletions
@@ -150,6 +150,7 @@ function probeStream(
"-count_packets",
"-of",
"json",
"--",
outputPath,
],
{ stdio: "pipe" },
@@ -418,6 +419,7 @@ describe("assemble()", () => {
"stream=r_frame_rate,avg_frame_rate,duration",
"-of",
"json",
"--",
outputPath,
],
{ stdio: "pipe" },
@@ -67,6 +67,7 @@ describe.skipIf(!hasFfmpeg)("audio pad real-media packet contract", () => {
"packet=duration_time",
"-of",
"json",
"--",
output,
],
{ encoding: "utf8" },
@@ -270,3 +270,71 @@ describe("padOrTrimAudioToVideoFrameCount", () => {
expect(result.targetDurationSeconds).toBe(6);
});
});
// ── Public-path path redaction ────────────────────────────────────────────
//
// The redaction helpers have their own unit tests, but those pass whether or
// not this module actually CALLS them: deleting the wiring in
// padOrTrimAudioToVideoFrameCount left every one of them green. These drive
// the public entry point and assert on the public `PadTrimAudioResult.error`,
// which is what reaches logs, telemetry, and the caller.
describe("PadTrimAudioResult.error never carries the input path", () => {
const cases: Array<{ name: string; videoPath: string; secret: string }> = [
{
name: "a dash-prefixed relative path",
videoPath: "./assets/-customer-secret-intro.mp4",
secret: "customer-secret-intro",
},
{
name: "a non-allowlisted absolute root",
videoPath: "/data/acme-secret/video.mp4",
secret: "acme-secret",
},
{
name: "a bare relative path",
videoPath: "customer/acme-secret/video.mp4",
secret: "acme-secret",
},
];
for (const { name, videoPath, secret } of cases) {
it(`redacts ${name} raised by the video probe`, async () => {
const result = await padOrTrimAudioToVideoFrameCount({
videoPath,
audioPath: "/tmp/audio.aac",
outputPath: "/tmp/out.aac",
// Reproduces the real thrower: defaultProbeVideoFrameInfo raises
// `ffprobe found no video stream in ${videoPath}` with the raw path.
probeVideoFrameInfo: () =>
Promise.reject(new Error(`ffprobe found no video stream in ${videoPath}`)),
probeAudioInfo: () => Promise.resolve({ durationSeconds: 1 }),
runFfmpeg: () => Promise.resolve({ success: true }),
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.error ?? "").not.toContain(secret);
expect(result.error ?? "").not.toContain(videoPath);
// Still diagnosable — the failure mode survives redaction.
expect(result.error ?? "").toContain("failed to probe video");
});
}
it("redacts raw ffprobe stderr surfaced through the audio probe", async () => {
const result = await padOrTrimAudioToVideoFrameCount({
videoPath: "/tmp/v.mp4",
audioPath: "/data/acme-secret/audio.aac",
outputPath: "/tmp/out.aac",
probeVideoFrameInfo: () => Promise.resolve({ frameCount: 30, fpsNum: 30, fpsDen: 1 }),
probeAudioInfo: () =>
Promise.reject(
new Error("/data/acme-secret/audio.aac: Invalid data found when processing input"),
),
runFfmpeg: () => Promise.resolve({ success: true }),
});
expect(result.success).toBe(false);
expect(result.error ?? "").not.toContain("acme-secret");
expect(result.error ?? "").toContain("failed to probe audio");
});
});
@@ -215,6 +215,25 @@ function formatSeconds(sec: number): string {
return sec.toFixed(6);
}
/**
* Every probe failure message, sanitized once, at the one place they all pass
* through on their way into the public `PadTrimAudioResult.error`.
*
* `runFfprobeJson` already scrubs the stderr it raises, but it is not the only
* thrower: `defaultProbeVideoFrameInfo` raises
* `ffprobe found no video stream in ${videoPath}` with the raw path, and a
* caller-supplied `probeVideoFrameInfo` / `probeAudioInfo` can raise anything
* at all. Sanitizing per-thrower is a list that will drift; sanitizing at the
* boundary cannot be bypassed by adding a new throw upstream.
*
* Known paths first (this function has them in hand, so no pattern has to
* recognise them), then the generic shape-based scrub for anything the message
* picked up elsewhere.
*/
function sanitizeProbeFailure(message: string, paths: readonly string[]): string {
return redactTelemetryString(redactKnownPaths(message, paths));
}
/**
* Pad or trim `audio.aac` so its exact duration matches `frameCount / fps`
* for the assembled video.
@@ -235,12 +254,16 @@ export async function padOrTrimAudioToVideoFrameCount(
probeAudio(input.audioPath, input.signal),
]);
const probePaths = [input.videoPath, input.audioPath, input.outputPath];
if (videoResult.status === "rejected") {
return failResult(
input.outputPath,
0,
audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
`audioPadTrim: failed to probe video: ${(videoResult.reason as Error).message}`,
`audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
(videoResult.reason as Error).message,
probePaths,
)}`,
);
}
if (audioResult.status === "rejected") {
@@ -248,7 +271,10 @@ export async function padOrTrimAudioToVideoFrameCount(
input.outputPath,
0,
0,
`audioPadTrim: failed to probe audio: ${(audioResult.reason as Error).message}`,
`audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
(audioResult.reason as Error).message,
probePaths,
)}`,
);
}
@@ -20,7 +20,19 @@ import { join, relative } from "node:path";
* than needing to be remembered.
*/
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
const PACKAGES = join(REPO_ROOT, "packages");
/**
* Roots to sweep.
*
* `skills/` is here because leaving it out was not a scoping choice, it was a
* hole: the shipped agent tools under `skills/**` spawn ffprobe directly, and
* 17 of those call sites were missing the terminator while this suite reported
* the bug class closed. They are distributed to users, not fixtures.
*/
const SWEEP_ROOTS = ["packages", "skills", "scripts"];
/** `.mjs`/`.cjs` are first-class here — the skill scripts are not TypeScript. */
const SOURCE_EXT = /\.(?:ts|mjs|cjs|js)$/;
/**
* The caller set as of the sweep that introduced this contract.
@@ -62,15 +74,25 @@ function mentionsProbe(src: string): boolean {
// an opaquely-named variable (`spawn(command, argv)`) is invisible here.
// Those still get caught by argv matching whenever their flags are literal —
// widen this if one ever slips through both.
// Comments and doc prose describing a spawn are not a spawn:
// `tts.test.mjs` explains `ffprobeDuration's spawnSync("ffprobe", ...) call`
// in a comment and was reported as an unclassified caller.
const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
return /(?:spawn|spawnSync|execFile\w*|exec)\s*\(\s*[^,)]*(?:ffprobe|ffProbe|probeBin|probePath)/i.test(
src,
code,
);
}
const SKIP_DIRS = new Set(["node_modules", "dist"]);
function isSourceFile(entry: string): boolean {
return entry.endsWith(".ts") && !entry.includes(".test.");
if (!SOURCE_EXT.test(entry) || entry.endsWith(".d.ts")) return false;
// This file documents the contract with example argvs, including a
// deliberately misordered one. Scanning itself reports its own prose.
if (entry === "ffprobeArgvContract.test.ts") return false;
// Test files are swept too. A test that probes a rendered output is itself a
// caller, and `dither.test.mjs` was one of the 17 broken sites.
return true;
}
function discoverCallers(): { found: string[]; unclassified: string[] } {
@@ -94,12 +116,12 @@ function discoverCallers(): { found: string[]; unclassified: string[] } {
else if (isSourceFile(entry)) classify(abs);
}
};
for (const pkg of readdirSync(PACKAGES)) {
const src = join(PACKAGES, pkg, "src");
for (const root of SWEEP_ROOTS) {
const abs = join(REPO_ROOT, root);
try {
if (statSync(src).isDirectory()) walk(src);
if (statSync(abs).isDirectory()) walk(abs);
} catch {
/* package without src */
/* root absent in a partial checkout */
}
}
return { found: found.sort(), unclassified: unclassified.sort() };