mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
co-authored by
Claude Opus 5
parent
e79ab3ab31
commit
255cf92915
@@ -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() };
|
||||
|
||||
Reference in New Issue
Block a user