fix(release): close the 0.19.0 scan findings — sandbox mongo tag, flow-verb timeout walls, video hardening (#329)

- mongo:8-alpine → mongo:8 (tag never existed; a mongo-opted project could spawn no agents) + a Docker Hub tag-existence e2e guard for every sandbox engine
- flow-verb timeouts at both walls: shared SLOW_VERBS policy (i_am_done / submit_up / submit_root / open_pr / i_will_work_on get the 900s server budget); the MCP client now outlasts the server budget (+10s headroom, orchestrator-injected env) so agents receive the middleware's clean 504 envelope instead of dying at the old flat 30s client timeout
- cancellation safety: the quality gate kills+reaps its child on CancelledError; create_pr records the PR via a shield-with-wait-out helper so the write can neither be skipped nor race get_db's rollback
- video engine: renderer sidecar isolated on a render-only network, 2g/2cpu caps, 570s render watchdog with exit-on-hang, 512MB tar decompression cap, CEO notification on terminal render failure, reject under the approve mutex (fail-closed on Redis-down)
- dead python-jose dependency removed (drops ecdsa and its unfixable Minerva advisory PYSEC-2026-1325); panel --font-mono now a real monospace stack

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-08 03:26:12 +02:00
committed by GitHub
co-authored by Renn F
parent 2a9d9e25d9
commit 0bf0cd69b3
36 changed files with 865 additions and 55 deletions
+66 -6
View File
@@ -20,15 +20,61 @@ const DIMENSIONS = {
square: { width: 1080, height: 1080 },
};
// Caps the DECOMPRESSED size (a gzip bomb inflates a tiny upload into a huge
// tar stream); MAX_UPLOAD_BYTES in server.js only bounds the compressed
// bytes on the wire.
const MAX_EXTRACTED_BYTES = Number(
process.env.MAX_EXTRACTED_BYTES ?? 512 * 1024 * 1024,
);
/** Thrown when the tar stream's cumulative entry size crosses
* MAX_EXTRACTED_BYTES — server.js maps this to a 413. */
export class ExtractedSizeExceededError extends Error {
constructor(maxBytes) {
super(`extracted archive exceeds ${maxBytes} byte cap`);
this.name = "ExtractedSizeExceededError";
this.statusCode = 413;
}
}
async function extractTar(tarBuffer, destDir) {
await new Promise((resolve, reject) => {
const extractor = tar.extract({ cwd: destDir });
let extractedBytes = 0;
// ponytail: header-declared entry.size, summed per entry via onentry —
// not a byte-exact streaming cap, but tar headers carry the true
// (post-gunzip) size, so this catches a bomb before most of it lands.
const extractor = tar.extract({
cwd: destDir,
onentry: (entry) => {
extractedBytes += entry.size;
if (extractedBytes > MAX_EXTRACTED_BYTES) {
extractor.destroy(new ExtractedSizeExceededError(MAX_EXTRACTED_BYTES));
}
},
});
extractor.on("finish", resolve);
extractor.on("error", reject);
Readable.from(tarBuffer).pipe(extractor);
});
}
// Deliberately under the orchestrator's 600s client-side HTTP timeout, so
// this fires first and the caller gets a clean error instead of an abandoned
// connection while Chrome is still wedged server-side.
const RENDER_TIMEOUT_SECONDS = Number(
process.env.RENDER_TIMEOUT_SECONDS ?? 570,
);
/** Thrown when a render exceeds RENDER_TIMEOUT_SECONDS — server.js maps
* this to a 500 and then hard-exits the process (see server.js for why). */
export class RenderTimeoutError extends Error {
constructor(seconds) {
super(`render exceeded ${seconds}s timeout`);
this.name = "RenderTimeoutError";
this.statusCode = 500;
}
}
/** Thrown when the requested orientation's HTML file isn't present in the
* composition dir — server.js maps this to a 400 instead of the generic 500
* a deep-in-executeRenderJob failure would otherwise produce. The known
@@ -115,17 +161,31 @@ export async function renderComposition({
height,
fps: FPS,
});
let timer;
try {
await executeRenderJob(job, (progress) => {
console.log(
`hyperframes-renderer: ${compositionId}/${orientation} ${Math.round(
progress.percent * 100,
)}%`,
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(
() => reject(new RenderTimeoutError(RENDER_TIMEOUT_SECONDS)),
RENDER_TIMEOUT_SECONDS * 1000,
);
});
await Promise.race([
executeRenderJob(job, (progress) => {
console.log(
`hyperframes-renderer: ${compositionId}/${orientation} ${Math.round(
progress.percent * 100,
)}%`,
);
}),
timeout,
]);
} catch (err) {
await rm(outDir, { recursive: true, force: true }).catch(() => {});
throw err;
} finally {
// Clear on both success AND timeout-throw so a completed render never
// leaves a dangling timer that fires the watchdog's exit path late.
clearTimeout(timer);
}
return {