Skip to content

Add Plugin: html2video-for-mcode - #41

Open
Wzdhehe wants to merge 75 commits into
MiniMax-AI:mainfrom
Wzdhehe:add-plugin-html2video-for-mcode
Open

Wzdhehe wants to merge 75 commits into
MiniMax-AI:mainfrom
Wzdhehe:add-plugin-html2video-for-mcode

Conversation

@Wzdhehe

@Wzdhehe Wzdhehe commented Sep 17, 2026 •

Copy link
Copy Markdown

What changes

Add Plugin: html2video-for-mcode at plugins/Wzdhehe/html2video-for-mcode.

A Skill that turns a topic, outline, or script into a narrated MP4: HTML slides with staged
entrance animations, a TTS voiceover, burned-in subtitles, and an ASR pass that verifies the
voiceover says what the script says.

User value

After installing, a MiniMax Code user can ask in plain language:

帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。

and get:

out/final.mp4      1920×1080 (or 1080×1920 vertical), H.264 + AAC
out/subs.srt       subtitles for platform upload
out/slide-*.mp4    per-slide segments
preview/*.png      terminal-state frames

What makes it more than a slide exporter:

  • Timing is measured, never hand-written — every slide duration and animation entrance time is
    derived from the TTS audio via ffprobe, so the picture can never lag behind the voiceover.
  • Every slide carries a title layer and a detail layer on separate animation stages, so no page
    is just a headline.
  • Animations are baked into the video — capture steps frames deterministically instead of
    screen-recording.
  • Rendering is gated — a static check refuses slides with undefined CSS variables, missing
    images, external resources, or entrance animations without an animation class: the silent
    failure modes that otherwise ship a broken-looking video while every script reports success.

Plugin submission checklist

  • Plugin lives at plugins/<github-owner>/<plugin-name>.
  • plugin.json name matches the Plugin directory.
  • README.md includes a real example prompt and expected result (bilingual: README.md +
    README.zh-CN.md).
  • LICENSE and plugin.json declare an open-source license (MIT).
  • Required executables, accounts, paid services, and supported platforms are disclosed
    (Node 18+, ffmpeg/ffprobe, Playwright Chromium; MiniMax API key or Token Plan for voice and
    ASR; Windows/macOS/Linux; PowerShell caveat documented).
  • Network destinations and data handled by the plugin are disclosed
    (api.minimaxi.com / api.minimax.io for ASR only when invoked; voice via mcode connectors
    or mmx-cli; image fetching only from URLs the user passes; no telemetry).
  • No credentials, private endpoints, hidden telemetry, installers, symlinks, or native binaries
    are included. The ASR script reads its key from an environment variable or CLI flag at
    runtime and never writes it.
  • Every scaffold TODO has been replaced.
  • Validated through publish/validate-plugin.mjs (an authoring-side tool, deliberately not part of the shipped plugin tree): it stages the Plugin tree into the host checkout, verifies every file in the tree is fingerprint-identical (sha256) to the source tree, then runs the upstream validator → OK plugin Wzdhehe/html2video-for-mcode, exit 0. (Validating the host's npm run check alone is not sufficient: it scans the staged copy under _official-plugins/plugins/**, so a stale staged copy yields a green result that proves nothing.)

Evidence

$ node scripts/validate.mjs
OK   plugin Wzdhehe/html2video-for-mcode
$ echo $?
0

The validator also prints a Validated <N> hosted Plugins summary line; N counts every plugin in the checkout and grows as the host merges unrelated plugins (it grew repeatedly while this PR was open). That line is checkout state, not a property of this PR, so it is not quoted above.

$ node --test "tests/*.test.mjs"     # whole suite: 0 fail — every test runs where its tooling is present; capability-missing tests skip by name, never a vacuous pass

The suite reports 0 fail in every environment measured: the development tree, both published trees, and tool-less sandboxes in both link-capability shapes. Skips are capability-dependent and always named: 0 skips with tools on a symlink-capable system; on stock Windows without Developer Mode the file-symlink canaries skip by name; in tool-less environments every tool-needing test skips with its reason. When ffmpeg / ffprobe / Chromium are absent (the monorepo's own root-level node --test runs in exactly that environment), every test that needs one of them skips with its stated reason — verified in tool-less sandboxes in both link capability shapes: 0 fail, every remaining case skipping with its stated reason (nothing pretends to pass). Skip counts are environment-dependent and drift as each release adds tests — the invariant is stated here, per-version measurements live in the CHANGELOG — and the scoped workflow installs the tools and runs the whole suite for real. The host repository's own test/hosted-plugins.test.mjs contains a symlink fixture that fails on a Windows checkout without Developer Mode (EPERM: operation not permitted, symlink …); that is a pre-existing host-side issue, it reproduces on a clean checkout without this Plugin, it passes on the CI's ubuntu-latest, and this PR does not touch it. The part of npm run check that inspects Plugins passes with exit code 0 when the staged copy is current.

Manual end-to-end test (Windows, Node 24, ffmpeg-static):

  • Scaffolded a project, generated 8 TTS clips, ran plan-timings.mjs → every slide duration and
    stage entrance time derived from measured audio.
  • check-slides.mjs correctly rejects slides with undefined CSS variables (--coral-a/--coral-b),
    missing images, external font links, and data-stage without an animation class; clean slides pass.
  • capture.mjs --mode motion produced 143 frames for an 8.6s slide; frame-diff (PSNR) confirms the
    staged entrance actually renders at its scheduled time (inf before the entrance, ~14 dB across it).
  • build-video.mjs --asr produced a 14.20s MP4 matching the expected duration exactly, full decode
    clean, out/subs.srt generated, and per-sentence ASR parts produced.
  • Theme gate: 13 themes pass WCAG contrast checks; a low-contrast brand accent (#FFB84D on white)
    is rejected with a non-zero exit code, a compliant one (#C2410C) passes.
  • ASR comparison verified offline against synthetic transcripts: simplified Chinese passes,
    traditional characters (Cantonese voice) and mismatched numbers fail with exit code 1.

Full disclosure of dependencies, network access, and data handling is in the Plugin README.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@Wzdhehe
Wzdhehe force-pushed the add-plugin-html2video-for-mcode branch 3 times, most recently from 0d50ebf to 7320ccd Compare September 17, 2026 15:13
Narrated-video pipeline: HTML slides with staged entrance animations, TTS voiceover
measured with ffprobe, burned-in subtitles, deterministic frame-stepping capture,
ffmpeg assembly, and ASR verification.

- 11 Node scripts, no build step; works with mcode connectors or mmx-cli
- 13 themes, 17 layout recipes, image framing primitives
- Subtitles confined to their own windows (no overlap); 16:9 and 9:16 canvases
- Research phase documented: source grading, cross-verification rules, notes template
- Discloses dependencies, accounts, network destinations and data handling (bilingual README)
- Validation: node scripts/validate.mjs -> OK (exit 0)
@Wzdhehe
Wzdhehe force-pushed the add-plugin-html2video-for-mcode branch from 7320ccd to 4104572 Compare September 17, 2026 15:23
Add Plugin: html2video-for-mcode

Narrated-video pipeline: HTML slides with staged entrance animations, TTS voiceover
measured with ffprobe, burned-in subtitles, deterministic frame-stepping capture,
ffmpeg assembly, and ASR verification.

- 11 Node scripts, no build step; works with mcode connectors or mmx-cli
- 13 themes, 17 layout recipes, image framing primitives
- Subtitles confined to their own windows (no overlap); 16:9 and 9:16 canvases
- Research phase documented: source grading, cross-verification rules, notes template,
  plus how to fetch official-site / press-release text (SPA rendering, PDF-first numbers)
- Discloses dependencies, accounts, network destinations and data handling (bilingual README)
- Validation: node scripts/validate.mjs -> OK (exit 0)
… them to the repo root)

Add Plugin: html2video-for-mcode

Narrated-video pipeline: HTML slides with staged entrance animations, TTS voiceover
measured with ffprobe, burned-in subtitles, deterministic frame-stepping capture,
ffmpeg assembly, and ASR verification.

- 11 Node scripts, no build step; works with mcode connectors or mmx-cli
- 13 themes, 17 layout recipes, image framing primitives
- Subtitles confined to their own windows (no overlap); 16:9 and 9:16 canvases
- Research phase documented: source grading, cross-verification rules, notes template,
  plus how to fetch official-site / press-release text (SPA rendering, PDF-first numbers)
- Discloses dependencies, accounts, network destinations and data handling (bilingual README)
- Validation: node scripts/validate.mjs -> OK (exit 0)

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes for exact current head 737ee96.

Blocking security and evidence issues:

  1. Input-derived IDs and paths are not contained. skills/html2video-for-mcode/scripts/capture.mjs:75-77,228-230 uses s.html and s.id to construct paths and recursively deletes the frame directory; build-video.mjs:55-58,191-197 uses t.id for output/frame/ASR paths; plan-timings.mjs:47-50 and build-video.mjs:102-105 also consume script-provided paths. An Agent-editable script.json value such as ../../victim can escape the intended build directory and trigger out-of-scope reads/writes/deletion. Add strict ID validation, resolve-and-containment checks, symlink checks, and malicious-ID tests.
  2. init-project.mjs:7-16,468-477 accepts an existing directory and then overwrites project files without a non-empty check or explicit --force. fetch-official-images.mjs:31-32,102-115 accepts arbitrary --out-dir and overwrites files; prep-image.mjs:64-87 uses ffmpeg -y for arbitrary output. This contradicts the README claim that writes stay inside the supplied project directory and creates destructive overwrite behavior. Default to refusing existing/non-empty targets and require explicit force, with output containment enforced.
  3. asr.mjs:29-32,65-74 allows --base-url / MINIMAX_BASE_URL to replace the endpoint without validation while sending the MiniMax API key as a Bearer token. A misconfiguration or prompt-controlled environment can exfiltrate the credential to arbitrary HTTPS/HTTP endpoints. Default to an HTTPS allowlist for official hosts; make custom endpoints an explicit, separately disclosed dangerous opt-in.
  4. fetch-official-images.mjs:25-29,40-46,98-115 accepts arbitrary http:, https:, and file: URLs and downloads through a browser/request client without protocol, private-address, redirect, or response-size restrictions. This exposes SSRF and local-file-read/copy behavior. Restrict to validated HTTPS public targets, block loopback/private/link-local/metadata addresses and redirects, bound responses, and make local files explicit opt-in with containment.
  5. The PR adds roughly 2,000 lines of executable scripts but no executable test suite; evals/evals.json is prompt/expected-output data and is not run by npm run check, while the repository validator does not execute these scripts or validate .claude-plugin/plugin.json. Add automated negative and smoke tests for containment, overwrite refusal, endpoint allowlist, SSRF/file rejection, and a minimal render/checker/build dry-run. The current [code]smith check is skipped and cannot substitute for this evidence.

Do not approve or merge until these security boundaries and executable test evidence are present on a new head.

@Wzdhehe

Wzdhehe commented Sep 18, 2026

Copy link
Copy Markdown
Author

Thanks for the review — all five blockers were reproduced against the exact head you flagged and are fixed on the new head (v1.1.0). Point-by-point:

1. Input-derived IDs and paths are not contained — fixed.
script.json is treated as untrusted input. New shared helpers in scripts/tools.mjs — safeId (whitelist ^[A-Za-z0-9_-]{1,64}$), safeRel (rejects absolute paths, resolves and requires containment in the project dir, and realpath-checks the deepest existing ancestor to refuse symlink escapes), inside, validateScriptPaths, validateTimingsIds — are called immediately after every JSON.parse in capture.mjs, build-video.mjs, plan-timings.mjs, check-timing.mjs, check-slides.mjs and asr.mjs, covering both the direct script.json chain and the timings.json second-hand chain. bgm.file no longer uses path.resolve (which let an absolute path escape entirely). Frame-picture width/height are coerced to integers in 16–16384 (they were interpolated into an ffmpeg scale= filter string). Evidence: tests/safe-paths.test.mjs — 12 cases including id="../../canary" against a canary directory outside the project (process exits 1, canary files verified intact), html="../outside.html", out-of-project <img src>, audio="../../secret.mp3", absolute bgm.file, and a symlink-escape case.

2. Destructive overwrite behaviour — fixed.
init-project.mjs now refuses a non-empty target directory and lists the files it would reset; re-initialising requires --force, which only resets its own five generated files and never deletes unrelated content (also fixed: --topic was interpolated into template HTML unescaped). fetch-official-images.mjs keeps --out-dir inside the working directory by default and refuses to overwrite existing files without --force (it no longer carries ffmpeg-style -y semantics); prep-image.mjs --crop refuses an existing destination unless --force is given, and the check runs before ffmpeg is invoked. The README claim about write scope is now backed by code. Evidence: tests/no-clobber.test.mjs — 9 cases including "non-empty dir → exit 1, unrelated file untouched" and "existing script.json content survives a refused run".

3. Endpoint replacement could exfiltrate the credential — fixed.
New scripts/url-policy.mjs exports assertAsrEndpoint: only https://api.minimaxi.com and https://api.minimax.io are accepted; any other --base-url / MINIMAX_BASE_URL is rejected before any request is constructed. Custom gateways require the explicit, separately-disclosed --allow-any-endpoint, which prints a warning. Evidence: tests/endpoint-allowlist.test.mjs — a third-party --base-url exits 1 with no request, and a positive case runs a local HTTP server that asserts it received Authorization: Bearer sk-test-not-real, proving the gate sits before the fetch and that the opt-in genuinely works.

4. SSRF / local-file read in fetch-official-images.mjs — fixed.
Same module: assertFetchableUrl / isBlockedHost reject loopback, link-local (incl. 169.254.169.254), private, CGNAT, IPv6 ULA/link-local and IPv4-mapped variants, dotless hostnames and .local/.internal/.localdomain/.home.arpa; only http(s) is allowed; file:// requires explicit --allow-file; URLs with embedded credentials are refused. Every redirect hop is re-validated (maxRedirects: 0 with a manual loop capped at 5), responses are capped at 30 MB (--max-mb), and download filenames go through sanitizeFilename (separators, control chars, leading dots, Windows reserved names). Evidence: tests/fetch-policy.test.mjs — 44 cases across host classification, URL validation, redirect targets and filename sanitisation.

5. No executable test suite — added.
skills/html2video-for-mcode/tests/ — five files, 72 node:test cases, zero dependencies, discovered by the repository-root node --test, so npm run check executes them (same convention as cli-agent-bridge / skill-bridge). The four security files run anywhere; the render smoke test (init → ffmpeg silence → plan-timings → check-slides → capture still → build-video, asserting the produced final.mp4 duration against the measured timings) skips with a stated reason where ffmpeg/Chromium are missing. Because the main CI image has neither, I added a plugin-scoped workflow following the pattern documented in CONTRIBUTING.md and the comments in ci.yml / tool-map-windows.yml: .github/workflows/html2video-for-mcode-smoke.yml (path-filtered to this plugin, installs ffmpeg + Chromium, runs all five files explicitly). Local runs: 72 tests, 0 failures.

Also in this head (non-blocking, from user feedback while the review was open): pure-CSS/SVG chart recipes with entrance-and-growth animations, a one-switch no-fx mode (animation end state vs no-fx frame measured at 51.7 dB PSNR — the画面 is identical), a new static gate for entrance animations whose keyframes never set opacity (they were silently invisible, same root cause class as the undefined-variable case), and the missing roadmap layout snippet.

The validator reports OK plugin Wzdhehe/html2video-for-mcode locally on this head, and the PR touches nothing outside plugins/Wzdhehe/html2video-for-mcode/ plus that single workflow file — upstream root README.md / LICENSE are unchanged (37e4c6cb / 125be1b8).

@Wzdhehe

Wzdhehe commented Sep 18, 2026

Copy link
Copy Markdown
Author

追加:放映页(可以先自己放一遍再渲染)+ 修一个"文档说能用、闸门说不能用"的类

新提交 acdfefd7(插件 38 文件 / workflow 1 文件)在上一条评审回复的修复之上,补了一件这次实现过程中暴露出来的事:

1. 新增 scripts/preview-page.mjs → preview/play/index.html(放映页)

单文件、零依赖、file:// 双击即看:← → 翻页、R 重播入场动画、P 提词面板(该张 clauses 按播放时间高亮)、O 总览、F 全屏、X 动效 / 关动效对照。

为什么不是"直接打开 slides/*.html":动画延迟 --t1/--t2/--t3 与画布尺寸由渲染管线按 timings.json 注入,tokens.css 里只有占位值(--t2:800ms)。实测同一张 t=3.0s:副本里第二层 opacity 0(未入场),原文件里 opacity 1(已入场) —— 直接开原文件看到的是"所有动画挤在开头两秒"的假象。放映页生成快照副本,把实测延迟写进 <html style>(等价于 documentElement.style.setProperty,优先级最高)并加 <base href="../../slides/">,浏览器里的时序才等于成片时序。

2. init-project.mjs --upgrade-css:给老项目的 tokens.css 幂等补上新版 no-fx 规则。此前"用新版 init-project 重生成"的说法是错的 —— 那需要 --force,会重置 script.json。

3. 修 fx-spotlight:它是本技能文档里列为可用的入场类,但关键帧只做 clip-path、没声明 opacity,[data-stage] 的 opacity:0 基础态抬不回来 → 用了就永久隐形。它恰好会被 check-slides 的 5b 项拦住,即"文档说能用、闸门说不允许"。已补 opacity:1。

4. 测试 +21 例(共 93,7 个文件):新增 preview-page(注入实测延迟 / base 顺序 / 自包含无外链 / 越界拒绝 / 部分张缺失时跳过)与 tokens-fx(对模板断言每个非无限入场动画的关键帧都必须声明 opacity、no-fx 必须重置基础态、--upgrade-css 幂等且不碰其他文件);workflow 的显式文件列表已同步覆盖。负向对照:把 fx-spotlight 的 opacity 去掉,该测试确实变红。

5. 文档漂移修正:插件树 README 此前落后仓库树一轮(缺整个 ## Verification 段与收紧后的 Network access / Data use 措辞),两棵树现在字节一致;11-script → 13-script。

npm run check 语义未变;新增测试全部零依赖,不需要 ffmpeg/Chromium(渲染冒烟仍单独一步)。

@Wzdhehe

Wzdhehe commented Sep 18, 2026

Copy link
Copy Markdown
Author

追加:74551814 —— 按使用反馈收窄放映页的定位

试用后的两条反馈,已改:

1. 去掉播放器那套 UI。 放映页的定位就是「把 HTML 画面放一遍」,口播文案是锦上添花;要看时间/节奏就直接看成片。所以删掉了:走秒的计时器(204.2s / 6.3s —— 标签页放着就毫无意义地涨)、进度条、逐句跟读高亮,以及随之而来的常驻 rAF 循环。现在页面是纯静态交互:翻页 / 重播 / 动效对照 / 总览。

2. 口播 UI 按数据决定加不加载,不再强制出现。

情形 页面表现
有 clauses + 有 timings.json 列出该张口播文案(P 可开 / 关)
有 clauses、还没对时 只列文案,标题标「(未对时)」—— 口播还没做也能先看 HTML
没有 clauses,或 --no-script 面板与口播按钮完全不出现,画面占满整宽

顺带修掉一个误导标签:底部提示原写成「X 关配音画对比」(本意是「关动效 / 画面对照」),读起来像是在管音频。现在统一写作「X 动效 / 关动效 对照」,测试里加了断言:页面不得出现「配音」字样。

3. 响应式(此前只考虑了桌面)。 原来右侧固定 320px 面板 + 固定行高的顶/底栏,窄窗口和手机上挤成一团。现在:顶栏/底栏可换行、话题名过长省略号截断;窄窗口与手机上口播面板收成底部抽屉并默认收起(画面优先);手机给触摸条按钮 + 左右滑动翻页;总览网格按宽度自动列数;高度用 100dvh(免得被手机地址栏切掉)。

实测(Playwright,五档视口 + 触屏模拟):1600×900 / 1024×600 / 800×600 / 390×844 / 360×640 全部零横向溢出、零控制台报错,触摸条无标签截断;390×844 上左滑确实翻页;窄屏下点「口播」画面从 385→755px 高。

另外把「还没对时」这一档做实用:没有 timings.json 时,副本按 HTML 里实际用到的 stage 等间隔排(0.3/1.3/2.3s),页面顶部黄条如实标注「不是成片时序」—— 占位值会把动画全挤在 2 秒内,那才是真看不懂。

测试 99 例(+6:不做计时器 / 不得出现「配音」字样 / 响应式与触摸 / 口播三态 / 等间隔兜底)。

@Wzdhehe

Wzdhehe commented Sep 23, 2026

Copy link
Copy Markdown
Author

1.9.9 — cloud-sandbox field batch (sorted by the reporter into skill issues vs environment quirks):

🔴 Entry guard could silently no-op. preview-page.mjs only runs main when executed directly, and the check compared raw paths — an entry reached through a symlinked path has a module URL at the real path but argv[1] at the link path, so the comparison was always false: exit 0, no output, no work (the reporter spent ~10 minutes proving main hadn't run). The check is now tools.isMainModule — realpath both sides plus a same-basename fallback, so the "silent skip" shape is gone in practice. Same comment clarifies: node -e "import(…)" never runs main by design (import semantics) — run CLIs as node <script> …. (The report attributed it to ESM module caching; caches don't span processes — the path-shape mismatch is the mechanism. The symlink case reproduces it; it now runs as a regression case on Linux CI.)

🟡 Subtitle wrap warning mis-fired on landscape. The > 18 characters limit is the single-line baseline at 1080 width (≈18 CJK chars/line); at 1920 the pill fits ≈32 — so 20–32-char sentences were warned "will wrap" without wrapping, and two wrapped lines read fine. plan-timings now estimates line width from the canvas width and warns at three or more lines (naming the estimated width and line count).

🟡/🟢 Documented (render.md + symptoms.md): capture runtime scaling with entrance-choreography windows (measured 5×15s ≈ 2250 frames ≈ 6–8 min; quick passes: fps 24 / trimmed choreography / --ids batches), plus the two sandbox quirks the report flagged as environment-side — partial Playwright browser installs (chromium-* vs chromium_headless_shell-*) and git clone SSL failures (use the codeload zip).

Tests +2 (256 in fourteen files): isMainModule (direct / imported / same-name fallback / symlinked entry) and the width-aware threshold (2 lines silent, 3+ named); red-proofed by reverting the guard to raw-path comparison. Verification: three trees 256 tests / 252 pass / 0 fail / 4 named skips (the symlink cases skip without file-symlink capability and run on Linux CI); mirror CI both platforms: run 35838508331.

Release-gate sequencing: head is now ccc68e2c (superseding c0fa6fd4 and earlier — their queued runs are void). The runs queued on ccc68e2c are the ones to approve; otherwise unchanged (reconciled with current main, git diff --check clean).

@Wzdhehe

Wzdhehe commented Sep 23, 2026

Copy link
Copy Markdown
Author

1.9.10 — round-21 review fixes (a two-axis review of 1.9.9 found one hard inconsistency and one wrong baseline; both reproduced before fixing):

Rule drift (the hard one). 1.9.9 rewrote the subtitle wrap rule in SKILL.md + references/authoring.md but left the old ">18 chars" rule in references/render.md, references/tts-and-timing.md and evals/evals.json — and render.md is a file that commit had itself edited. All five surfaces now carry one rule: width-aware line estimate, warning at three or more pill lines, with the one-line craft guideline (≈18 CJK / ≈42 English) stated separately from the pill capacity.

The published geometry was wrong (correction to the 1.9.9 entry). Its "≈18 CJK per line at 1080, ≈32 at 1920" were the superseded vertical baseline's restatement. The pill's font and padding both scale with --sub-scale = clamp(W/1920, 0.75, 1.25), so capacity is not linear in width — true values ≈24 chars/line at 1080, ≈33 at 1920, ≈35 at 2560. The 18×W/1080 estimate over-warned at 1080 (~27%) and, in the dangerous direction, missed real 3-line wraps at 2560 (42 vs true 35.6). The pill geometry now lives once in tools.SUB_GEOMETRY/subScale/subLineCap — plan-timings derives the cap from it and capture builds the .kit-sub CSS from the same constants (the two copies had already drifted), and the numbers are pinned by a unit test (reverting to the linear formula turns it red).

Entry guard, per the review: the same-basename fallback now announces on stderr when it fires — the trade is "a loud, self-annotated extra run" vs "a silent no-op" (the silent kind cost a field debugging session); its justification no longer claims cases realpath already covers. Small items cleaned: unused fileURLToPath import gone from preview-page.mjs, real → realpathOf, the !entry branch now actually covered by the test (explicit null — undefined hit the default parameter), and the plan-timings test now exercises 2/3/4-line samples so the "3+" predicate is tested at its exact boundary.

Tests +1 (257 in fourteen files). Verification: three trees 257 tests / 253 pass / 0 fail / 4 named skips (symlink-capability cases run on Linux CI); mirror CI both platforms: run 35849400871.

Release-gate sequencing: head is now abd0f7db (superseding ccc68e2c and earlier — their queued runs are void). The runs queued on abd0f7db are the ones to approve; otherwise unchanged (reconciled with current main, git diff --check clean).

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes for exact current head abd0f7db9868eb271f1705187fbeee9ba1ecccaf.

The previous base divergence, whitespace, path-containment and most endpoint/credential findings are now substantially closed. Two blockers remain:

  1. SSRF DNS validation fails open and is not bound to the actual connection. plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs:147-155 catches lookup failure and returns success, after which fetch-official-images.mjs:84-85,155-173 continues/fetches/navigates the original hostname. Even when pre-lookup returns a public IP, the actual Node/Chromium connection resolves the hostname again, allowing DNS rebinding or resolver disagreement to reach loopback/private/metadata destinations. Fail closed on DNS errors and bind policy validation to the actual connected address; add lookup-error and rebinding tests.
  2. Exact-head GitHub execution evidence is still absent. The scoped Linux/Windows workflow is present, but this head only has [code]smith = SKIPPED; there are no completed smoke-linux, smoke-windows, main CI, or CodeQL jobs. Local macOS results include Playwright-dependent skips and cannot replace exact-head Linux/Windows/CodeQL evidence. Run the workflows on this exact head before approval.

The branch now has current main as its merge-base, both manifests are consistent at 1.9.10, git diff --check is clean, and local non-browser tests are positive. [code]smith is skipped and is not test evidence.

@Wzdhehe

Wzdhehe commented Sep 28, 2026

Copy link
Copy Markdown
Author

1.9.11 — blocker 1 (SSRF fail-open / unbound connection) fixed; branch is current with main; the queued runs on this new head still need an admin "Approve and run".

Head is now f1a74dfd (two commits, one release). Both halves of the finding were reproduced before fixing.

Fail closed — assertResolvedHost no longer swallows lookup errors: a failed or empty answer is a dns-error rejection, never a silent pass (skills/.../scripts/url-policy.mjs).

Bound to the actual connection — the veto now rides the connection's own resolution instead of a pre-check:

  • checkedLookup wraps Node's lookup and vets every address the socket could connect to (Happy-Eyeballs picks from the list, so any blocked member rejects the whole set).
  • policyGet is the egress GET: http(s).request carrying that lookup, following no redirects (each hop is re-validated), with the body cap enforced mid-stream rather than trusting content-length. Both download routes (--url and --get) egress through it — the old paths called global fetch() / context.request.get(), each resolving DNS a second time after the pre-check had passed.
  • Chromium cannot inject a lookup, so both browser launches are now tunnelled through a new loopback-only veto proxy (startVetoProxy): the fetch tool's browser (navigation, Chromium-followed redirects and every subresource — CONNECT upstreams run checkedLookup, absolute-form http goes through policyGet) and the render browser in capture.mjs. The per-request route interceptor keeps the string policy and re-resolves uncached — the previous per-host cache widened exactly the rebinding window it guarded.

Tests: +16 → 273 in fifteen files. The lookup-error and rebinding cases assert on request counters of local servers staying zero — on the real net lookup path and through the proxy's CONNECT and absolute-form paths — not on wording. Also covered: response plumbing, the mid-stream cap, a file:// browser smoke with the proxy in the chain, and a structural pin that the render browser's launch carries the proxy. Each guard was red-proofed against its own defect: restoring fail-open, removing the connect-time veto, disabling the CONNECT pre-veto, dropping the render-browser proxy, or re-hard-coding the pill width each reddens exactly its own cases. (The red-proof also caught one vacuous-green test of our own — assertions made inside a lookup callback are swallowed by the promise chain; the new tests capture first and assert after.)

Blocker 2 (exact-head execution evidence) — mechanically unchanged: on this new head the scoped smoke, CI and CodeQL runs again sit in action_required, waiting for a base-repo admin's Approve and run. As the fork author we cannot release them — the approve/dispatch APIs return 403 ... Must have admin rights (documented upthread with the raw responses). The same suite does run natively on every push of the standalone skill repo: run 36378298084 and run 36379486860 (both commits of this release) are green on ubuntu-latest and windows-latest with ffmpeg + chromium installed — offered as suite health, not as exact-head evidence.

git diff --check remains clean, the merge-base is main's current tip (6481e4ae, 0 commits behind), and both manifests are consistent at 1.9.11.

@Wzdhehe

Wzdhehe commented Sep 28, 2026

Copy link
Copy Markdown
Author

1.9.12 — field report: TTS reads numbers by its own rules; display form and spoken form are now separated (clauses[].say)

Head is now c2c44ca6 (two commits, one release). Reported case: 2026年 was read as "两千零二十六年"; the wanted behaviour is the subtitle showing 2026年 while the narration says "二零二六年" (same for 82.3% → "百分之八十二点三" and SWE2.3 → "SWE二点三").

  • New optional field clauses[].say — the spoken form. text stays the on-screen/subtitle form and keeps the natural written form (Arabic numerals, original orthography; spelled-out digits never go on screen). TTS input and the ASR checklist expectation are always say ?? text; the burned-in subtitles and out/subs.srt always come from text. say propagates through plan-timings into timings.json (which is what build-video --asr reads) and applies to the main line only.
  • Year-reading gate in plan-timings (zh/yue, behind an explicit yearGate flag): a four-digit year followed by 年 in the effective spoken text gets a warning that hands over the paste-ready say value. Deliberately narrow — digit-by-digit vs integer reading is intent (2026年 digit-by-digit, 2026点 as an integer), so only the unambiguous year shape is gated; the rest of the number discipline is documented (SKILL.md schema section + tts-and-timing.md, all three reported examples).
  • Our own two-axis review of this increment found four items in the fix itself, closed in the same release (disclosed in the CHANGELOG): clause-start allocation was weighted by the display form while the audio speaks the longer say — time weights, per-clause chars and the pacing check now use say ?? text (subtitle-wrap geometry still uses text); the year regex only matched 19xx/20xx while claiming "four-digit" (1897 now warns too); the zh-guard used a layout metric as a language proxy (now an explicit flag); and two evals.json cases still taught writing the spoken form into the on-screen text.
  • Tests +1 → 274 in fifteen files; six red-proofs across the release (gate off / say propagation dropped / checklist reverted / weights reverted / regex reverted / flag off each redden exactly their own case; the weighted-start assertion pins both candidate formulas so it discriminates rather than vacuously passes). Three trees identical at 274/270/0/4; the standalone repo's dual-platform runs are green at both commits of this release (36392342879, 36395037505) — suite health, not exact-head evidence; the queued smoke/CI/CodeQL runs on this head still await an admin Approve and run.

…to tools), digit-bounded year regex, small cleanups (1.9.12)
@Wzdhehe

Wzdhehe commented Sep 28, 2026

Copy link
Copy Markdown
Author

1.9.12 third commit (90dfad5a) — round-25 self-review of the corrections, all closed. Our two-axis review of the second commit found five items (none in the original feature): the load-bearing one was that check-timing's silence-to-clause alignment still counted pause punctuation on the display form — with say/text punctuation disagreeing, exact mode misaligned and --calibrate would write back wrong starts. matchBoundaries now lives in tools.mjs as the single source and counts on say ?? text, unit-tested with a fixture where the two forms produce different methods/values. Also: the year regex now refuses to slice four digits out of longer runs ("12026年" no longer warns as "2026年"), a self-referencing comment no longer cites line numbers, LANG_CFG states yearGate explicitly on every branch and shares the zh/yue literal, and the evals.json ASR-mismatch case now requires the say rewrite to actually change the pronunciation (九成二-style) rather than restate what TTS already says.

Tests +1 → 275 in fifteen files; both behavioural corrections red-proofed (spoken-form reverted → alignment test red; digit-boundary reverted → the 12026 case red). Three trees identical at 275/271/0/4; the standalone repo's run at this commit is green on both platforms (36400677108) — suite health; the queued smoke/CI/CodeQL runs on this head still await an admin Approve and run.

@Wzdhehe

Wzdhehe commented Sep 28, 2026

Copy link
Copy Markdown
Author

1.9.13 (feb2dd0e) — external play-page review: six findings, all reproduced personally before fixing. The reporter had scripted the play page for automation and hit every one of these live.

  • Self-contained overview thumbnails (P1): the overview grid hardcoded ../<id>.png into the parent preview/ directory — copying play/ alone killed all thumbnails, and a project that hadn't run capture silently got all placeholders. Thumbnails are now bundled into play/thumbs/ at generation (rebuilt fresh, so removed slides leave no stale images), referenced relatively, with the terminal counting what was copied (仅 N/M 张 warning when capture hasn't run for some slides). The dependency boundary is now stated honestly in SKILL.md: the snapshot copies read the project's slides/ via <base href>, so the minimum shareable unit is play/, not index.html alone.
  • window.playPage API (P1): page state used to live entirely in closures, forcing automation to simulate keys and scrape innerText. The page now exposes state (index/total/step/steps/fx/narr/trans/overview) plus go/next/prev/setFx/setNarr/setTrans/overview.
  • Key hints + boundary feedback (P2): the footer now lists every supported key (Space/PageDown/PageUp/Home/End/Esc were handled but never shown), and pressing →/← at the first/last frame flashes an "already first/last" hint instead of doing nothing (the no-op itself stays — non-looping is deliberate).
  • xfade .show leak (P3): the generation-guarded 400ms un-show could leave the old frame at opacity:1 after a superseding action — invisible to the eye but breaking external current-frame detection via offsetParent; show() now normalises the back buffer to hidden at entry. Also renamed addStepScript's stages parameter to stageIds (it held stage ids while the same file's stages is the timings dictionary).
  • Tests +6 → 281 in fifteen files (preview-page): relative-path pin (no '../' + s.id), the full playPage surface, bilingual key hints, boundary-toast wiring, the show-entry normalisation, and a CLI-level case (thumbnail copied / uncaptured slide gets no fake image / terminal counts / stale thumbs cleaned). Four guards red-proofed (thumbs reverted, playPage removed, edge feedback removed, entry normalisation removed — each reddens exactly its own case). Three trees identical at 281/277/0/4; the standalone repo's run at this commit is green on both platforms — suite health only; the queued smoke/CI/CodeQL runs on this head still await an admin Approve and run.

…ate from real geometry, disclaimer cap removed, --ids warnings (1.9.14)
@Wzdhehe

Wzdhehe commented Sep 28, 2026

Copy link
Copy Markdown
Author

1.9.14 (d04562ba) — field handoff: the subtitle pill's max-width was unreachable dead code; subtitles wrapped at half the designed width and the band gate couldn't see the collision. (A/B reproduced on our tree with the reporter's probe before fixing: 22 real narration sentences, 8 wrapped wrong → 0 with the fix.)

  • P0 · width:max-content: the pill is absolutely positioned with left:50%, so its shrink-to-fit available width is the containing block minus the left offset = half the canvas (960px at 1920); translateX(-50%) only shifts. max-width: 1400px could never bind — subtitles wrapped at ≈22 chars instead of ≈33, double-height pills reached 230px from the bottom and covered captions/disclaimers. The pill CSS now lives once in tools.subPillCss() (single source; SUB_GEOMETRY gains the vertical-padding/radius/bottom-fraction/second-line constants so no literal survives outside tools) with width:max-content. Honest consequence: subLineCap's pinned numbers (24/33/35) were the designed values all along — the implementation never matched them until now.
  • P0 · band gate: check-slides 5e hard-coded 168px (single line measures 170; a wrapped pill reaches 230 — a 62px collision zone never reported while two slides were really covered) and only scanned absolutely-positioned rules. The band now comes from tools.subBandTop() (same geometry, bilingual double-line tier per slide's text2), plus a heuristic that flags flow-anchored bottoms (margin-top:auto / flex spacers) — both point at grab-frames for the definitive check, since a still preview cannot show subtitles.
  • P1/P2: .disclaimer no longer bakes in max-width:1240px (trapped full-width multi-line blocks; changes the tokens body, so old projects turn stale until --upgrade-css — designed propagation). --ids unknown ids are now named in a warning with the effective list (capture + grab-frames); the message covers the PowerShell bare-comma shape that eats leading zeros. (The reporter's earlier "--ids only honours the last one" was retracted — quoting the argument fixes it.)
  • Tests +4 → 285 in fifteen files, including a browser-level geometry test (a 30-char clause must stay single-line and wider than 1000px — under the old CSS both asserts redden), band-edge cases (169 warns / 185 silent / 185 warns on a bilingual slide), the flow-bottom heuristic, the .disclaimer pin, and mixed---ids CLI cases. Four red-proofs (max-content removed / band reverted to 168 / heuristic off / warning dropped — each reddens exactly its own case). Three trees identical at 285/281/0/4; the standalone repo's run at this commit is green on both platforms — suite health; the queued smoke/CI/CodeQL runs on this head still await an admin Approve and run.

…SKILL_FRONTMATTER_INVALID) (1.9.15)

Marketplace submission PLUGIN-202610030176 failed validation with
SKILL_FRONTMATTER_INVALID on skills/html2video-for-mcode/SKILL.md. Root cause
(reproduced with PyYAML before fixing): the unquoted frontmatter description
carried one ASCII ": " — "(regulated subjects): finance/..." — and colon-space
inside an unquoted plain scalar is a YAML parse error (ScannerError: mapping
values are not allowed here). Strict parsers reject the file; the community
repo validator reads the line with a regex, which is why 28 hosted validation
rounds never surfaced it.

Fix: the colon becomes an em-dash and the four ** bold markers are stripped
(the marketplace renders this text as plain text). Description 977 -> 970
chars, every trigger phrase kept; frontmatter now parses clean. Package
content changed, so the version increments 1.9.14 -> 1.9.15 in all three
manifests per the submission guide. No code or behavior changed; the suite
stays 285 tests / 0 fail (re-run on this tree before pushing).
@Wzdhehe

Wzdhehe commented Oct 3, 2026

Copy link
Copy Markdown
Author

Head 5144313 — 1.9.15: marketplace-submission fix (SKILL frontmatter), no behavior change

Explaining an out-of-band head bump outside the review loop: the plugin was also submitted to the mcode Plugin Marketplace (the standalone mirror repo carries the same skills/ subtree at its root, with .minimax-plugin/plugin.json). Submission PLUGIN-202610030176 failed automated validation with SKILL_FRONTMATTER_INVALID on skills/html2video-for-mcode/SKILL.md.

Root cause, reproduced before fixing: the frontmatter description contained one unquoted ASCII ": " — "(regulated subjects): finance/…" — and a colon-space inside an unquoted plain scalar is a YAML parse error (ScannerError: mapping values are not allowed here, confirmed with PyYAML on the exact frontmatter). Strict parsers reject the file. This repo's validator reads the description line with a regex, which is why this shape passed all hosted validation rounds — the regex is more permissive than YAML itself.

Fix (description text only, 977 → 970 chars): the colon becomes an em-dash; the four **bold** markers are stripped (the marketplace renders the text as plain text, where markdown shows up literally). Every trigger phrase is kept; the frontmatter now parses clean under PyYAML and stays under the 1024-char cap.

Scope: no code changed. Version increments 1.9.14 → 1.9.15 in plugin.json / .claude-plugin/plugin.json / .minimax-plugin/plugin.json per the submission guide's "any package-content change bumps the version" rule, with CHANGELOG entries. Suite re-run on this tree before pushing: 285 tests, 0 failures (4 capability skips as usual). The standalone mirror repo carries the identical subtree (verified byte-for-byte) and its dual-platform CI is running on head 498a13ee.

As before, the queued smoke/CI/CodeQL runs on this new head sit in action_required awaiting a base-repo admin "Approve and run" (the fork-author 403 evidence is upthread). No review feedback is addressed by this head — it is compatibility-only; the open review items remain untouched.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes for exact current head 51443138757cd240af3ea325a3097d20147472c6.

The new fail-closed DNS lookup and Chromium proxy routing improve the previous SSRF design, but this head still has reproducible blockers:

  1. The veto proxy still allows HTTP requests to literal private addresses. The ordinary HTTP absolute-URL branch enters policyGet() without first applying the URL/host policy. For an IP literal, Node does not invoke the custom DNS lookup, so the private-address veto is skipped. A controlled local origin received the request and returned 200 through the production startVetoProxy path. Apply the same assertFetchableUrl/blocked-host policy to every HTTP proxy request before connecting, and add an end-to-end canary proving loopback/private/link-local/metadata literals receive zero requests.
  2. The exact-head test suite is not green on macOS. The plugin suite reproducibly reports 1 failure (267 pass, 17 capability skips), and repository npm test reports the same failure. preview-page rejects a legitimate thumbnail/audio path because safeRel compares an uncanonicalized non-existent root with a realpath-canonicalized ancestor (/var versus /private/var). Canonicalize the root and nearest existing ancestor consistently, then retain a regression test for this platform alias.
  3. There is still no exact-head GitHub execution evidence. This head exposes only [code]smith = SKIPPED; the scoped Linux/Windows smoke, main CI, and CodeQL jobs did not run. The local run also skipped 17 capability-dependent cases, so it cannot substitute for the claimed Chromium/ffmpeg/cross-platform contract.

Please close the HTTP literal-address bypass, restore a fully passing local suite, and run the exact-head Linux/Windows/CodeQL checks before requesting approval. [code]smith was not used as evidence.

…as roots (review round 7) (1.9.16)

Maintainer review round 7 (CHANGES_REQUESTED on head 5144313). Two of the
three blockers are closed here, each reproduced on our tree first.

1. Veto proxy allowed IP-literal targets. The absolute-form HTTP branch fed straight
   into policyGet, which carried the veto only on the request's lookup option — and for
   an IP literal Node never calls lookup (it connects to the address directly), so the
   veto was a no-op. Reproduced against a controlled local origin through the production
   startVetoProxy: http://127.0.0.1:<port>/secret returned 200 and the origin received
   the request. Fix in two layers: policyGet applies the URL/host policy before
   connecting (the single literal entry point for every caller), and the proxy's HTTP
   branch pre-checks with an attributable error, mirroring the CONNECT branch.
   checkedLookup stays on the request for the DNS/rebinding case.

   The canary asserts zero requests AND that the 502 body names the policy reason
   (blocked-host) — the other blocked literals previously produced 502 because those
   addresses are unreachable, not refused, so a status-only assertion would have been
   green for the wrong reason. A positive control keeps allowed hostnames working.

2. macOS path alias made legitimate paths look like escapes. safeRel/assertContained
   canonicalized the probe side with realpath but compared it against the
   uncanonicalized path.resolve(root) whenever the root did not exist yet. On macOS
   /var/... and /private/var/... are two spellings of one directory, so valid
   project paths were rejected (preview-page refusing thumbnail/audio paths). Both
   sides now use the same canonicalPath rule, and assertContained gained the same
   "root not created yet, so an ancestor probe is legitimate" tolerance safeRel already
   had — still withheld when the root exists, which is the shape that lets a
   project-internal link point at the project's parent. Fail-closed is preserved: an
   existing root whose realpath throws is still rejected. macOS is not reachable from
   here, so the regression reproduces the mechanism with a junction alias and runs
   everywhere; a negative case pins that a genuine escape through a link inside the
   project is still refused.

Tests +6 -> 291 in fifteen files. Red-proofs: removing both vetoes turns the loopback
canary red; removing only policyGet's assertion keeps it green (the layering control);
removing the canonicalPath root side turns the alias regression red. Every restore is
byte-compared. Suite re-run before pushing on all three trees: 291 tests, 0 failures.

3. Exact-head execution evidence is unchanged and still requires a base-repo admin to
   approve the queued runs (fork-author 403 evidence is upthread); the mirror repo's
   dual-platform CI run is the substitute evidence.
@Wzdhehe

Wzdhehe commented Oct 10, 2026

Copy link
Copy Markdown
Author

Round 7 addressed — head aff1ffd7 (1.9.16): blockers 1 and 2 closed, blocker 3 re-documented with fresh evidence

Both fixable blockers were reproduced on our own tree first, then fixed, then proven by red-proofs. Suite: 291 tests, 0 failures (15 files), re-run on all three trees before pushing.

1. The veto proxy allowed HTTP requests to literal private addresses — closed

Reproduced exactly as described, against a controlled local origin through the production startVetoProxy (no injected connect/lookup): an absolute-form request for http://127.0.0.1:<port>/secret returned 200 and the origin received the request.

The mechanism matches your finding: policyGet carried the veto on the request's lookup option, and for an IP literal Node connects to the address directly without ever calling lookup — so the veto was structurally a no-op for literals, and the proxy's absolute-form HTTP branch had no host policy of its own.

Fix, in two layers:

  • policyGet applies the URL/host policy before connecting — the single literal entry point for every caller (the --url/--get download paths and the proxy both inherit it).
  • The proxy's HTTP branch pre-checks with an attributable error, mirroring the CONNECT branch, so a refusal names itself.
  • checkedLookup stays on the request for the hostname/rebinding case; the CONNECT branch is unchanged.

One detail worth flagging, because it changes what the canary has to assert: the other blocked literals (private, metadata, IPv6, 0.0.0.0) already returned 502 because those addresses are unreachable, not because policy refused them. A status-only assertion would therefore have been green for the wrong reason. The new canaries assert zero requests to the origin and that the 502 body names the policy reason (blocked-host), plus a positive control that an allowed hostname still gets served, so the pipe isn't welded shut.

2. macOS /var vs /private/var false rejection — closed

Reproduced the mechanism on our tree with a junction alias (macOS itself is not reachable from here, and the fixture is cross-platform so it runs on every runner): with the root passed in the alias spelling and not yet created, safeRel/assertContained canonicalized the probe side with realpath but compared it against the uncanonicalized path.resolve(root), and a legitimate project path was rejected with exit 1.

Fix: both sides now use the same canonicalPath rule (deepest existing ancestor's realpath plus the not-yet-created tail), and assertContained gained the same "root not created yet, so an ancestor probe is legitimate" tolerance safeRel already had. That tolerance is still withheld whenever the root exists — that is precisely the shape in which a project-internal link points at the project's parent, which is a real escape. Fail-closed is preserved: an existing root whose realpath throws is still rejected, as is any unresolvable or dangling link. A negative case pins that a genuine escape through a link inside the project is still refused.

Tests +6 → 291. Red-proofs, each with a byte-compared restore: removing both vetoes turns the loopback canary red; removing only policyGet's assertion keeps it green (the layering control); removing the canonicalPath root side turns the alias regression red.

3. Exact-head execution evidence — unchanged, not ours to self-serve

The three runs for this head are queued in action_required: CodeQL 38037329837, CI 38037329903, smoke 38037329838. Re-attempting the approval API on this exact head just now returns the same refusal as before:

POST /repos/MiniMax-AI/MiniMax-Code-Plugins/actions/runs/38037329903/approve
403 {"message":"Must have admin rights to Repository."}

As established upthread, a fork author cannot approve (and push-level rights are not sufficient — this needs base-repo admin). What we can produce ourselves is the mirror repository's dual-platform CI: run 4b71a2bf on Wzdhehe/html2video-for-mcode@main runs the same 291-test suite with ffmpeg + Playwright Chromium on ubuntu-latest and windows-latest, and the exact test files under review are byte-identical between the two trees (the release sync enforces it and the remote-tree verification reports zero drift). [code]smith was not used as evidence.

No other review feedback is addressed by this head.

…browser-loopback invariant (1.9.17)

Self-review round on top of the round-7 head: a two-axis /code-review pass over
the delta, every reported finding re-derived on our own tree first. One residual
hardening landed; two reported findings did not survive reproduction.

Landed:

1. Both refusal layers are now separately load-bearing. policyGet applies the host
   policy before connecting (where:'policyGet'); the proxy's absolute-form HTTP branch
   pre-checks with where:'代理 HTTP'. Removing either one alone had left the suite
   green, so neither was pinned; the new tests assert each layer's own label, and
   red-proofs confirm each removal turns exactly its own assertion red. The redundancy
   is deliberate and now documented in code: policyGet is the single entry point for
   every caller (including the download paths), the proxy pre-check keeps refusals
   attributable without depending on policyGet internals.

2. CONNECT refusals are attributable. That branch used to destroy the client socket
   silently while the HTTP branch reported blocked-host, so the comment claiming the
   two were "the same shape" was wrong. CONNECT now answers 502 Bad Gateway with the
   reason in the body - never a tunnel, never an upstream connect. Two existing tests
   asserted the old silent behaviour and were updated by contract; reverting to a bare
   destroy() turns both red.

3. The browser-side invariant is now pinned rather than assumed. Chromium has an
   implicit proxy bypass for loopback, so "all browser egress goes through the veto
   proxy" rested on Playwright defaults. A capture-shaped launch plus a slide whose
   image points at a counting origin asserts 0 requests at the origin; if a future
   Playwright/Chromium changes the default, CI says so.

4. Housekeeping: the root-side canonicalisation was byte-identical in safeRel and
   assertContained; both now call one exported canonicalRoot(root, where), which is
   also where the rule and the fail-closed behaviour live.

Reproduced and refuted (recorded in the CHANGELOG so it is not re-litigated):

- "Chromium bypasses the proxy for loopback, so capture.mjs has an egress hole": with
  an explicit proxy.server the loopback subresource goes through the veto proxy and
  comes back 502 with 0 origin hits, with or without bypass:'<-loopback>'. The test
  above pins the observable outcome anyway.
- "the new ancestor tolerance has no test; relaxing it leaves the suite green":
  relaxing that single gate makes safe-paths fail - the escape-shape test depends on it.
- Also checked: exotic IPv4 spellings (127.1, 0x7f.0.0.1, 2130706433, 0177.0.0.1,
  ::ffff:127.0.0.1) are normalised by the URL parser to 127.0.0.1 and refused, while a
  public address still passes.

Tests +4 -> 295 in fifteen files, 0 failures on all three trees before pushing.
No review feedback from the maintainer is addressed by this head; blocker 3 (exact-head
execution evidence) still needs a base-repo admin to approve the queued runs.
@Wzdhehe

Wzdhehe commented Oct 10, 2026

Copy link
Copy Markdown
Author

Self-review on the round-7 head — head e83aa17d (1.9.17)

Ran a two-axis review over the round-7 delta and re-derived every reported finding on our own tree before acting on it. One residual hardening landed; two reported findings did not survive reproduction, and both are recorded in the CHANGELOG so they are not re-litigated later.

Landed

  1. Both refusal layers are now separately load-bearing. policyGet applies the host policy before connecting (where:'policyGet'); the proxy's absolute-form HTTP branch pre-checks (where:'代理 HTTP'). Removing either one alone had left the whole suite green — so neither was actually pinned. The tests now assert each layer's own label, and red-proofs confirm each removal turns exactly its own assertion red. The redundancy is deliberate and documented in code: policyGet is the single entry point for every caller including the download paths, and the proxy pre-check keeps a refusal attributable without depending on policyGet internals.
  2. CONNECT refusals are now attributable. That branch used to destroy() the client socket silently while the HTTP branch reported the reason, so the comment claiming the two were "the same shape" was wrong. CONNECT now answers 502 Bad Gateway with the reason in the body — never a tunnel, never an upstream connect. Two existing tests asserted the old silent behaviour; they were updated by contract, and reverting to a bare destroy() turns both red again.
  3. The browser-side invariant is pinned rather than assumed. Chromium has an implicit proxy bypass for loopback, so "all browser egress goes through the veto proxy" rested on a Playwright default. A capture-shaped launch plus a slide whose image points at a counting origin now asserts 0 requests at the origin. If a future Playwright/Chromium changes the default, CI reports it instead of the pipeline silently losing its choke point.
  4. Housekeeping: the root-side canonicalisation was byte-identical in safeRel and assertContained; both now call one exported canonicalRoot(root, where), which is also where the rule and the fail-closed behaviour live.

Reported, reproduced, refuted

  • "Chromium bypasses the proxy for loopback, so capture.mjs has an egress hole" — not reproducible in this configuration. With an explicit proxy.server, a slide's http://127.0.0.1:…/ subresource goes through the veto proxy and comes back 502 with 0 origin hits, with or without bypass:'<-loopback>'. We pinned the observable outcome with a test anyway.
  • "the new ancestor tolerance has no test; relaxing it leaves the suite green" — refuted by mutation: relaxing that single gate makes safe-paths fail, because the escape-shape test depends on it.
  • Also verified rather than assumed: exotic IPv4 spellings (127.1, 0x7f.0.0.1, 2130706433, 0177.0.0.1, ::ffff:127.0.0.1) are all normalised by the URL parser to 127.0.0.1 and refused, while a public address still passes.

Tests +4 → 295 in fifteen files, 0 failures on all three trees before pushing (dev, standalone mirror, plugin tree). Sync, fingerprint validation and the upstream validator all pass; both repositories verify byte-identical to the pushed trees.

This head addresses no maintainer feedback — it is the self-review of the round-7 work. Blocker 3 (exact-head execution evidence) still needs a base-repo administrator to approve the queued runs; the mirror repository's dual-platform CI remains the substitute evidence.

…open quoting, three gate bypasses (1.9.18)

Independent security audit of the "parse and generate" dimension — the one the
hosted review rounds never covered (the auditor was barred from reading the earlier review
records). It reported 2 HIGH / 4 MEDIUM / 6 LOW. We reproduced every finding on our own tree
before acting; three of its claims did not survive that, and everything it confirmed is fixed
in this head.

HIGH

- H1 attribute injection in the play page: setAttr re-serialised a value read out of a
  single-quoted attribute into double quotes without escaping, so an author's
  <html class='x" onmouseover=window.FIRED=1 data-x='> came back from addNoFx with a live
  event handler (X, the animation toggle, is the trigger). Values are now HTML-escaped on
  output. The regression test parses attribute names instead of grepping for "on*=" text —
  after the fix that text legitimately lives inside the attribute value — and carries a
  detector self-check so the pair cannot be green for free.
- H2 SRT cue injection: clause text was spliced into out/subs.srt verbatim, so a clause with
  embedded newlines plus a timecode line produced more timecodes than cues — forged cues in a
  file meant for platform upload. SRT generation moved to a single source (tools.buildSrt)
  that strips bidi and control characters, folds all whitespace runs to one space, and turns a
  residual inline "-->" into an arrow (lenient parsers such as ffmpeg's srt demuxer scan lines
  for timecodes). The asserted invariant is line-anchored: timecode lines === cue count.

MEDIUM

- M1 --open could run a second command: cmd /c start relied on Node quoting the target, and
  Node only quotes when the argument contains spaces or quotes — a Windows path may contain &
  without a space. Reproduced with a controlled origin (the second command really executed).
  The target is now quoted explicitly with windowsVerbatimArguments. The test runs both shapes
  and requires the unquoted one to still be exploitable.
- M2 fx-* classes were trusted by prefix: class="fx-notreal" satisfied the data-stage rule
  while nothing animates, and locally defined fx classes with opacity-less keyframes were never
  inspected. Both now consider the slide's own <style>; an undeclared class is named.
- M3 HTML comments could stand in for a definition: <!-- --c-fake: 1 --> satisfied the
  undefined-variable gate. Comments are stripped before both the definition and usage scan.
- M4 external-resource gate holes: only double-quoted src|href="https://..." was seen, so
  <IMG SRC=https://...>, protocol-relative //host/x.png, @import url(...) and
  background:url(...) all passed. The scan is now case-insensitive, covers unquoted values and
  srcset, and covers CSS regions — while deliberately not flagging links written as slide prose.

LOW

- L1 the image gate claimed to prevent second-order escapes but compared lexically; it now
  also compares canonical paths when the file exists. L2 Windows cmd expands %VAR% even inside
  quotes and cannot be escaped on a command line — the ASR path now refuses such paths with an
  actionable message instead of silently mangling them (the previous comment understated it).
  L3 data-style/data-class no longer shadow the real attributes, a > inside a quoted value no
  longer truncates the tag, and a decoy <html> inside a comment is no longer patched.
  L4 control and bidi characters are stripped from transcript text before comparison, printing
  and checklist.md. L5 isBlockedHost now catches the expanded IPv4-mapped form
  (0:0:0:0:0:ffff:7f00:1). L6 ffmpeg's concat list cannot represent a quote inside a quoted
  path, so such paths fail loudly via tools.assertConcatPathSafe instead of writing a silently
  broken list.

Refuted after reproduction (recorded so they are not re-litigated): rmSync(recursive) does not
follow junctions into the project parent (the containment guards are load-bearing); the missing
exact-head runs still cannot be self-served (base-repo admin approval returns 403); and our
escape guards do run (the junction matrices came back blocked).

Tests +19 -> 314 in sixteen files, 0 failures on all three trees before pushing. Seven
red-proofs: reverting each guard turns exactly its own case red, restores byte-compared. One
red-proof did not redden at first, which exposed dead code rather than a weak test — the
sanitizer had a dedicated CRLF fold that the trailing whitespace collapse already subsumed; it
was removed and the proof now pins the load-bearing step.
@Wzdhehe

Wzdhehe commented Oct 10, 2026

Copy link
Copy Markdown
Author

Head 8669d42 (1.9.18) — an independent audit of the dimension the previous rounds never covered

Rather than wait for round 8, we commissioned an audit explicitly barred from reading the earlier review records so it could not anchor on them. It covered four faces; its verdict was that the network and filesystem faces are genuinely solid and that "parse and generate" — reading untrusted content in and writing it back out — had never been looked at. It reported 2 HIGH / 4 MEDIUM / 6 LOW, none of them overlapping your earlier rounds.

We reproduced every finding on our own tree before acting. Everything it confirmed is fixed in this head; three of its claims did not survive reproduction and are recorded as refuted below so nobody re-litigates them.

HIGH (both fixed, both reproduced first)

  • Attribute injection in the play page. setAttr re-serialised a value it had just read out of a single-quoted attribute into double quotes without escaping. An author's <html class='x" onmouseover=window.FIRED=1 data-x='> came back from addNoFx with a live event handler — X, the animation toggle the docs push, is the trigger. Values are now HTML-escaped on the way out, for both the class and style paths. The regression test parses attribute names rather than grepping for on*= text (after the fix that text legitimately lives inside the attribute value, so a text search would report a false red) and carries a detector self-check, so the pair cannot be green for free.
  • SRT cue forging. Clause text was spliced into out/subs.srt verbatim: a clause containing embedded newlines plus a timecode line produced more timecodes than cues — forged cues in a file that gets uploaded to a platform. Generation moved to a single source (tools.buildSrt) that strips bidi and control characters, folds whitespace runs to one space so a cue is always one line, and neutralises a residual inline --> because lenient parsers (ffmpeg's srt demuxer scans lines for timecodes) would still misread it. The invariant asserted is line-anchored: timecode lines === cue count.

MEDIUM (all fixed)

--open could run a second command (Node only quotes an argument when it contains spaces or quotes; a Windows path may contain & without one — reproduced with a controlled origin, the second command really executed; now quoted explicitly with windowsVerbatimArguments, and the test requires the unquoted shape to still be exploitable so it cannot pass for the wrong reason). fx-* classes were trusted by prefix, so fx-notreal passed while nothing animated and locally defined fx classes with opacity-less keyframes were never inspected. HTML comments could stand in for a --var definition. And the external-resource gate only saw double-quoted src|href="https://…" — uppercase SRC=, protocol-relative //host/x.png, @import url() and background:url() all passed; it is now case-insensitive, covers unquoted values and srcset, and covers CSS regions, while deliberately not flagging links written as slide prose.

LOW (all addressed) — the image gate now compares canonical paths when the file exists (it previously only compared lexically while claiming to prevent second-order escapes); the Windows ASR path refuses % in arguments because cmd expands %VAR% even inside quotes and offers no command-line escape (the old comment said this was a loud failure; it could do more than that); data-style/data-class no longer shadow real attributes, > inside a quoted value no longer truncates the tag, and a decoy <html> inside a comment is no longer patched; transcript text is stripped of control and bidi characters before comparison, printing and checklist.md; isBlockedHost now catches the expanded IPv4-mapped IPv6 form; and a concat-list path containing a quote now fails loudly instead of writing a silently broken list.

Refuted after reproduction — rmSync(recursive) does not follow junctions into the project parent (the containment guards are load-bearing; the auditor's own junction matrix came back 10/10 blocked); the missing exact-head runs still cannot be self-served; and our containment guards do run.

Tests +19 → 314 in sixteen files, 0 failures on all three trees before pushing. Seven red-proofs: reverting each guard turns exactly its own case red, with byte-compared restores. One red-proof did not redden on the first attempt, which turned out to be dead code rather than a weak test — the sanitizer had a dedicated CRLF fold that the trailing whitespace collapse already subsumed; it was removed and the proof now pins the load-bearing step.

Blocker 3 is unchanged: the runs for this head are queued in action_required and still need a base-repo administrator to approve them.

…ntics (1.9.19)

One regression, found by reviewing our own previous fix — and the test that
structurally could not have caught it.

--open stopped opening the browser on Windows. The 1.9.18 fix for M1 added
windowsVerbatimArguments so the target would be quoted explicitly instead of relying on Node's
quote-only-when-it-contains-spaces rule. But verbatim mode also stops Node from quoting the
empty-string argument, so start's empty *title* vanished: start "" "<path>" became start
"<path>". START treats the first quoted token as the window title, so with no command left to
run nothing opens — while the script still printed that it had opened the page. A security fix
that broke the feature it was securing.

The rule behind it was measured, not assumed. Using mkdir as a marker: cmd /c start "T" cmd /c
<cmd> runs <cmd> (T became the title), while cmd /c start T cmd /c <cmd> does not (T became a
command that does not exist). One quoted token and no command after it therefore opens nothing.

The empty title is now a literal '""', and the argv construction moved into an exported
openCmdArgv(target, platform) so it can be asserted directly rather than inferred from a spawn.

Why our own M1 test missed it: that test substituted echo for start, and echo has no title
semantics, so "the quoted path is consumed as a title" was outside anything it could observe —
the proxy proved quoting, not START's parser. The replacement asserts on openCmdArgv's argv,
red-proves that the pre-fix shape fails the same assertion, and then runs start itself: the
quoted form must create the marker, the unquoted form must not.

Housekeeping: the new behavioural test runs start /b. Without it every suite run flashes a
console window on the machine running it (measured: 3639 ms and one new conhost without /b;
134 ms and none with it).

Tests +3 -> 317 in sixteen files. Suite re-run before pushing on both shipped trees:
317 tests, 313 pass, 0 fail, 4 named capability skips — all four the file-symlink canaries,
skipped with the reason that this Windows machine has Developer Mode off, and they run on
ubuntu. The directory-junction equivalents of those guards (safeRel escape, L1 image gate)
execute and pass here via the mklink /J fallback, and the render-smoke suite ran for real on
this machine rather than skipping.
@Wzdhehe

Wzdhehe commented Oct 10, 2026

Copy link
Copy Markdown
Author

Heads 51443138 → 241be8f2: what has changed since the round-7 review, and the one item that is still not ours to close

Four versions sit on top of the head you last reviewed. Each was reproduced and red-proofed on our own tree before pushing; the suite was re-run on both shipped trees before this push (317 tests, 313 pass, 0 fail, 4 named capability skips).

Round-7 blockers 1 and 2 — closed in aff1ffd7 (1.9.16)

Blocker 1 reproduced through the production startVetoProxy against a controlled local origin (http://127.0.0.1:<port>/secret returned 200 and the origin received it). Fixed in two layers: policyGet applies the host policy before connecting (the single literal entry point for every caller), and the proxy's HTTP branch pre-checks with an attributable error. Blocker 2 reproduced with a cross-platform junction alias and fixed by giving root and probe sides the same canonicalPath rule. Detail is in the commit body for that head.

1.9.17 — self-review of the above

  • Both refusal layers were not separately pinned: removing either one alone left the suite green. The new tests assert each layer's own where label, so removing either now turns exactly its own assertion red.
  • The CONNECT branch used to destroy() the socket silently while the HTTP branch named the reason — the comment claiming they were "the same shape" was simply wrong. CONNECT now answers 502 with the reason in the body.
  • Chromium has an implicit loopback proxy bypass, so "every browser request goes through the veto proxy" was resting on a library default. It is now measured with a real browser and pinned as a test.
  • Refuted by mutation and recorded so they are not re-litigated: the ancestor-tolerance gate is load-bearing; the exotic IPv4 spellings are all normalised by the URL parser; a reported Chromium bypass did not reproduce.

1.9.18 — a dimension the earlier rounds had not covered

An independent audit of the plugin tree, with the auditor barred from reading the earlier review records so it could not anchor on them. It split the tree into four disjoint surfaces (subprocess/command construction, filesystem containment, egress policy and credentials, parsing/generation and the static gates) and required every finding to ship with a reproduction. It reported 2 HIGH / 4 MEDIUM / 6 LOW.

  • HIGH · attribute injection in the play page. setAttr re-serialised a value it had just read out of a single-quoted attribute into double quotes without escaping, so an author's <html class='x" onmouseover=window.FIRED=1 data-x='> came back from addNoFx with a live event handler (X, the animation toggle the docs push, is the trigger). Values are now HTML-escaped on output. The test parses attribute names instead of searching for on*= text — after the fix that text legitimately lives inside the attribute value, so a text search reports a false red — and carries a detector self-check so the pair cannot be green for free.
  • HIGH · SRT cue injection. Clause text was spliced into out/subs.srt verbatim, so embedded newlines plus a timecode line produced more timecodes than cues — forged cues in a file meant for platform upload. Generation moved to a single source (tools.buildSrt) that strips bidi/control characters, folds whitespace runs so a cue is always one line, and turns a residual inline --> into an arrow (lenient parsers such as ffmpeg's srt demuxer scan lines for timecodes). The asserted invariant is line-anchored: timecode lines === cue count.
  • MEDIUM · four gate/escaping holes: cmd /c start could run a second command because Node only quotes arguments containing spaces and a Windows path may contain &; fx-* classes were trusted by prefix so class="fx-notreal" passed while nothing animated; HTML comments could stand in for a CSS variable definition; and the external-resource gate missed <IMG SRC=https://…>, protocol-relative URLs, @import url(…) and background:url(…).
  • LOW · six, including an image gate that claimed to prevent second-order escapes but compared lexically, and a concat list written with shell-style quoting that ffmpeg's av_get_token does not implement.

Its negative results matter as much as its findings and are the reason we are confident about the earlier rounds: a 31-case proxy-bypass matrix (octal/integer/hex IPv4, 127.1, mapped IPv6, NAT64, 6to4, userinfo, tab smuggling, authority-form, unbracketed IPv6) returned 502 with zero origin hits in every case; a 20-payload subprocess fuzz (", &, |, <, >, ^, (, ), backtick, newline, !) executed nothing; and a 10-junction matrix on planted project subdirectories came back fully blocked. Three of the auditor's claims did not survive reproduction and are recorded as refuted rather than "fixed" in the commit body.

1.9.19 — a regression we introduced while fixing the above

--open stopped opening the browser on Windows. The 1.9.18 fix added windowsVerbatimArguments so the target would be quoted explicitly, but verbatim mode also stops Node quoting the empty-string argument, so start's empty title disappeared: start "" "<path>" became start "<path>". START treats the first quoted token as the window title, so with no command left to run nothing opens — while the script still printed that it had opened the page.

The rule was measured rather than assumed (with mkdir as a marker: start "T" cmd /c <cmd> runs <cmd>; start T cmd /c <cmd> does not). The empty title is now a literal '""', and the argv construction moved into an exported openCmdArgv so it can be asserted directly.

Worth recording why our own M1 test missed it: that test substituted echo for start, and echo has no title semantics, so "the quoted path is consumed as a title" was outside anything it could observe — the proxy proved quoting, not START's parser. The replacement asserts on openCmdArgv's argv, red-proves that the pre-fix shape fails the same assertion, and then runs start itself, requiring the quoted form to create the marker and the unquoted form not to.

Suite state

317 tests, 313 pass, 0 fail, 4 skipped (16 files), re-run on both shipped trees before this push. All four skips are the file-symlink canaries, skipped with the reason that the pushing machine has Windows Developer Mode off — they run on ubuntu-latest. The directory-junction equivalents of those same guards (safeRel escape, the L1 image gate) execute and pass locally via the mklink /J fallback, and the render-smoke suite ran for real rather than skipping. [code]smith was not used as evidence anywhere.

The item that is unchanged and not ours to self-serve

The three runs for this head are queued in action_required: CodeQL 38056794524, CI 38056794577, smoke 38056794587. Approving them requires base-repo admin — re-attempting from the fork returns the same 403 {"message":"Must have admin rights to Repository."} as documented upthread, so it is not something we can trigger from here.

The mirror repository's dual-platform CI runs the same suite with ffmpeg + Playwright Chromium on ubuntu-latest and windows-latest, and the test files under review are byte-identical between the two trees (the release sync enforces it and reports zero drift). We are not offering that as a substitute for the exact-head runs you asked for; the exact-head check is the only thing that closes this item, and it needs one click from a base-repo admin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants