diff --git a/CHANGELOG.md b/CHANGELOG.md index 99aeb45..2d58e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,53 @@ ### Added +- Independent `brand.titleCard` and `brand.endCard` overrides. The existing + `brand.cards` switch remains the default for both, so current configs render + unchanged while artifact-first pieces can omit only the opening card. +- A production pack for Factory AI at Work Gate 1: one pinned landscape master, + three 9:16 cuts, a real-capture contract, publishing copy, contract tests, and + an operator runbook. Its reviewed-pack promoter now binds an exact, + single-filesystem renderer topology before sealing, writes locale-independent + manifests, requires exact relative concat selections, and probes those same + archived segments against the reported shot timeline. Privileged, + environment-scrubbed receipt validation rejects placeholder review evidence; + phase-aware cleanup restores an interrupted writable attempt or reports the + retained sealed path, and a failed post-commit status write cannot misreport + an already verified promotion as a retryable failure. Production receipts + bind one clean source-build attestation and archived claim ledger. The pack + deliberately leaves served-state observation and the three-episode promotion + counter to the ratified channel operator authority. Captured and rendered + media remain ignored. +- Full SHA-256 render provenance for the resolved config, narration script, and + ordered prebaked clip bytes. Clips are rechecked after capture and before + rendering; the existing short config and script hashes remain for + compatibility. The committed source-snapshot launcher additionally binds a + local render to a private detached worktree of the tracked runner, commit, + scoped Git tree, package manifest, and dependency lock before application + modules load. The streamed launcher rejects caller startup files, functions, + command paths, working-directory config, Git environment, global or system + Git config, repository hooks and filesystem monitors, and implicit Node or + package-manager selection. Frozen dependency installation runs without + Doppler secrets, lifecycle scripts, pnpm hook files, or caller npm/pnpm + configuration. A module-local capability prevents another pipeline graph + from reusing the admitted session, and source drift during rendering blocks + the receipt. Direct mutable-checkout use of `--attest-source-build` is + refused, and an otherwise successful render fails if its detached snapshot + cannot be removed. +- A per-run `--out ` override for immutable production attempts. + The target is claimed exclusively and an existing path is refused before + inputs are read or artifacts are changed. On a Linux filesystem under Linux + or WSL, subsequent writes stay bound to a private staging-directory handle. + Publication moves the authenticated claim into the output as + `.agent-demo-video-output-claim` and uses a no-clobber rename without + pathname-based cleanup. The guarantee covers existing targets and + accidental or cooperating collisions, not a malicious same-UID process. + `--clips-dir` and `--script` can select attempt-owned prebaked and narration + inputs without editing a config; configs without the flags retain their + reusable behavior. +- Full-bleed source geometry gates in both preflight and rendering. A finished + composition whose display aspect differs from its output canvas now fails + instead of being silently padded into a valid-size letterboxed file. - `platform` distribution preset. `"shorts"` renders a 9:16 `1080x1920` canvas for Shorts/TikTok/Reels cuts while still capturing web apps at a 16:9 desktop viewport; the framed scene keeps the capture's aspect for the floating window, so nothing is @@ -102,6 +149,12 @@ ### Fixed +- CLI parsing now rejects unknown options, surplus config paths, duplicate + singleton flags, and pipeline-only flags on `login`, so a mistyped production + `--out` cannot silently fall back to a reusable configured output. +- Source-attested production renders now fail if their required + `render-report.json` cannot be built or written. Ordinary renders retain the + historical best-effort provenance behavior. - Every config schema object now rejects unrecognised keys, so a typo in a setting fails loudly instead of silently applying the default it was written to override. diff --git a/README.md b/README.md index e5e04ee..3166070 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,46 @@ shots it could not adjudicate, so a finished video says for itself how it was ch The gate is fail-closed. Set `preflight: false` in the config, or pass `--no-preflight` for a single run, to decline it; a declined run says so in its output. +For a production attempt, pass `--out `. This per-run override +atomically reserves the fresh output name and refuses an existing path before +reading the script or changing an artifact. On a Linux filesystem under Linux +or WSL (not a mounted Windows/DrvFs path), later writes stay bound to a new +private staging-directory handle while the requested name remains occupied. +A changed claim fails the run without deleting the replacement; success parks +and retains the authenticated claim as `.agent-demo-video-output-claim` inside +the output, then publishes the staged directory with a no-clobber rename. This +protects against existing targets and accidental or cooperating concurrent +reuse. It is not an isolation boundary against a malicious process running as +the same Unix user, which can manipulate that user's pathnames and `/proc` +handles. +Omitting the flag preserves the legacy reusable `config.out` behavior. Use +distinct fresh directories for rehearsal and real narration. +`--clips-dir ` can pin a run to attempt-owned copies of +prebaked inputs without changing the config file. `--script ` +does the same for a copied narration manifest. Both overrides require absolute +paths so their meaning never changes with the launch or config directory. + +Every successful `render-report.json` keeps the existing short config and +script hashes and also records full SHA-256 values for the resolved config, +script, and each ordered prebaked clip. Before narration spend, the pipeline +copies each source clip into a private read-only render binding. The renderer +and report consume only those bound bytes, so an ordinary later export to the +operator source path cannot change the artifact or its digest. + +For a local source-run production render, `--attest-source-build` is reserved +for the committed snapshot launcher. The launcher bytes are streamed from one +fixed commit, create a private detached worktree, verify and freeze every +scoped repository byte before application modules load, and then invoke that +snapshot's `src/cli.ts`. Direct mutable-checkout use is refused. The report +records snapshot execution, the commit, scoped Git tree, package manifest, and +dependency-lock hashes, and the pipeline blocks the receipt if that state +changes during rendering. Installed dependency and system-tool bytes remain +reported toolchain inputs, not part of the repository source claim. The frozen +dependency install receives no render secrets and ignores caller npm/pnpm +configuration, lifecycle scripts, and pnpm hook files. Snapshot attestation is +intentionally incompatible with `--render-host` until remote bundles have their +own content-addressed attestation. + ## Authenticated SaaS capture (`target: live`) `target: live` drives an authenticated SaaS app (Slack, Notion, Linear, Stripe, any @@ -255,9 +295,9 @@ Key fields in `demo.config.json` (full schema in `src/types.ts`): | Field | Default | Notes | |---|---|---| -| `script` | — | Path to DEMO_SCRIPT.md | +| `script` | — | Path to DEMO_SCRIPT.md. `--script ` overrides it for one run | | `dashboardBaseUrl` | — | Base URL of the running app (e.g. `http://localhost:3000`) | -| `out` | `"out"` | Output directory | +| `out` | `"out"` | Output directory. On a Linux filesystem under Linux or WSL, `--out ` overrides it for one run, reserves a nonexistent target, renders through a private directory handle, retains the authenticated claim marker, and publishes with a no-clobber rename. This is a cooperative-process guarantee, not hostile same-UID isolation | | `platform` | `"landscape"` | Distribution preset. `"shorts"` renders a 9:16 `1080x1920` canvas for Shorts/TikTok/Reels while still capturing at a 16:9 desktop viewport; the framed scene floats the capture as a window on the tall canvas. Explicit `resolution` / `capture.viewport` override the preset | | `resolution` | preset (`1920×1080` landscape) | Output canvas of the finished video | | `capture.viewport` | preset (follows canvas on landscape; `1920×1080` on shorts) | Browser capture geometry, decoupled from the canvas | @@ -274,14 +314,19 @@ Key fields in `demo.config.json` (full schema in `src/types.ts`): | `theme.frame.enabled` | `true` | Scene framing: the capture floats as a rounded, shadowed window on a gradient backdrop | | `audio.soundDesign` | `true` | Synthesized ambient bed ducked under narration, click ticks, segment sweeps | | `motion.livingCamera` | `true` | Continuous camera path with drift; `motion.zoomOnAction: false` disables all camera motion | -| `brand` | (off) | `{ title, subtitle, url, accent, cards }` adds branded title and end cards | -| `clipsDir` | `"clips/prebaked"` | Where a **bare** prebaked clip filename resolves. Resolved against the config file's directory unless absolute | +| `brand` | (off) | `{ title, subtitle, url, accent, cards }` adds branded title and end cards. `titleCard` / `endCard` may override either side independently while `cards` remains the shared default | +| `clipsDir` | `"clips/prebaked"` | Where a **bare** prebaked clip filename resolves. Resolved against the config file's directory unless absolute; `--clips-dir ` overrides it for one run | | `preflight` | `true` | Fail-closed pre-flight selector gate; see [Pre-flight selector gate](#pre-flight-selector-gate). `false` (or `--no-preflight`) declines it | | `maxDurationSec` | `300` | Hard ceiling for the finished video. The render fails if the result exceeds it. Set it to the length limit you are shipping against. | | `capture.settleMs` | `500` | Budget for the post-navigation readiness wait (fonts ready, visible images decoded). `0` disables the probe. Exceeding the budget warns and records anyway. Under the default `screencast` engine the wait happens BEFORE recording starts, so unsettled frames are excluded; the legacy `recordvideo` engine binds capture at context creation, so there the wait shifts those frames later rather than excluding them. | Sample: `demo.config.sample.json`. +An executable real-production example lives at +[`demos/factory-ai-at-work/gate-01/`](demos/factory-ai-at-work/gate-01/README.md): +one landscape master and three portrait cuts, all pinned and fail-closed on +operator-supplied captures. + ## Third-party tabs For surfaces you cannot or should not drive live (SaaS login walls, desktop apps, external products), pre-capture a clip once and reference it: @@ -310,7 +355,11 @@ Add `- fullBleed: true` to a shot whose clip is ALREADY a finished composition, motion-graphic title card rendered by another tool. The pipeline then skips the window framing and the segment fade-in for that shot, because the clip carries its own framing and its own motion. Without it a full-bleed card is shrunk to `theme.frame.scale` inside a shadowed window it was never -designed for. +designed for. A finished composition must match the output canvas aspect. Preflight probes existing +full-bleed clips before TTS, and the renderer checks again before normalization; a mismatch is +rejected instead of silently padded with bars. Finished compositions must use square sample pixels +(`1:1` SAR). The guard rejects anamorphic input because the current render filters operate on coded +geometry, and normalization pins the same `v:0` stream that the geometry probe validates. ```markdown ### SHOT title diff --git a/demos/factory-ai-at-work/gate-01/CAPTURE_PLAN.md b/demos/factory-ai-at-work/gate-01/CAPTURE_PLAN.md new file mode 100644 index 0000000..3c6299e --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/CAPTURE_PLAN.md @@ -0,0 +1,84 @@ +# Gate 1 Capture Plan + +All footage is captured from real runs owned by the operator. Nothing in this +plan authorizes connecting sensitive folders, exposing credentials, or +publishing an artifact. + +## Common clip contract + +- Master clips are finished 1920x1080 compositions at 30 fps. +- Cut clips are finished 1080x1920 compositions at 30 fps. +- Encode H.264 with yuv420p pixel format, square sample pixels (`1:1` SAR), and + no embedded narration. +- Keep captions out of the source clip. The pipeline burns the pinned captions. +- Blur credentials, account identifiers, notifications, and unrelated files. +- Use only footage captured for this episode or other rights-clean operator + footage. +- Every clip is declared `fullBleed: true`; it already owns its framing, + reframing, labels, motion, and brand cards. +- Match the real file count, prompt, elapsed time, formula cells, notifications, + and session state shown in the capture. If the run differs, update narration + and the run receipt before render. +- Do not use generated product screens or staged timestamps. + +Place clips in these ignored directories relative to this pack. +The rehearsal reads these working files. Before real narration, the runbook +copies the exact twelve files into the fresh attempt's `evidence/clips/` tree; +production preflight and render read only those owned copies. + +## Master clips + +| File | Evidence job | +|---|---| +| `clips/master/01-cold-open.mp4` | Finished organized folder and formula-backed spreadsheet, then the same real folder before organization and the Windows Claude entry point. | +| `clips/master/02-roadmap.mp4` | Four-step strip plus honest Windows 11 and local-versus-cloud setup. | +| `clips/master/03-setup.mp4` | Paid-plan, Windows-version, and admin-rights checklist with no credential or account detail. | +| `clips/master/04-install-it-right.mp4` | Real download, UAC approval, Start-menu sign-in boundary, Virtual Machine Platform setting, restart, and the Cowork selector payoff. | +| `clips/master/05-first-real-task.mp4` | Real staged folder, exact typed prompt, plan, any permission request that actually occurred, folder changes, spreadsheet, and formula spot check. | +| `clips/master/06-where-it-runs.mp4` | Remote-session receipt, lid-close continuation, local-file gate, Hyper-V diagram, safety guidance, and second-device progress with real timestamps. | +| `clips/master/07-anywhere-on-a-schedule.mp4` | Same session on web and phone, a real notification, local-file boundary, scheduled task, on-demand run, and the resulting digest. | +| `clips/master/08-recap.mp4` | Four real payoff shots replayed in order. | +| `clips/master/09-next.mp4` | Channel close with one subscribe action and the next episode topic. | + +The pipeline generates the final 15-second disclosure card. Do not prepend a +title card: the first frame must be the finished artifact. + +## Portrait cut clips + +| File | Evidence job | +|---|---| +| `clips/cut-a/cut-a-install.mp4` | Portrait reframe of the real UAC, Virtual Machine Platform, restart, and Cowork-selector sequence. | +| `clips/cut-b/cut-b-real-job.mp4` | Portrait reframe of the real folder, exact prompt, work progression, folder result, and formula spot check. | +| `clips/cut-c/cut-c-remote.mp4` | Portrait composition of the remote-session receipt, lid-close continuation, desktop-file gate, and safety boundary. | + +## Run-dependent receipts + +Before a production render, record these in the attempt's copied +`PRODUCTION_RECEIPT.md`: + +1. Exact source folder file count and a content inventory showing it contains no + sensitive documents. +2. Exact Cowork prompt used in the run. +3. Whether Cowork showed a permission prompt. If it did not, no permission + prompt may appear in footage. +4. Real elapsed timestamps for task progress and lid-close continuation. +5. Desktop, web, and phone proof for any narration that says the session is on + every device. +6. The spreadsheet formula cells spot checked and their expected totals. +7. The source URLs and access date for every vendor claim shown as a receipt. + +## Mechanical checks + +For every clip: + +```bash +ffprobe -v error \ + -select_streams v:0 \ + -show_entries stream=width,height,pix_fmt,r_frame_rate,sample_aspect_ratio \ + -of default=noprint_wrappers=1 +``` + +Expected master geometry is `1920x1080`; expected cut geometry is `1080x1920`; +pixel format is `yuv420p`; frame rate is `30/1`; sample aspect ratio is `1:1`. +The renderer and preflight both inspect `v:0`, and normalization pins that same +stream. diff --git a/demos/factory-ai-at-work/gate-01/CLAIM_LEDGER.md b/demos/factory-ai-at-work/gate-01/CLAIM_LEDGER.md new file mode 100644 index 0000000..947c6c1 --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/CLAIM_LEDGER.md @@ -0,0 +1,67 @@ +# Gate 1 Claim Ledger + +Verified against live primary documentation: 2026-07-30. + +This ledger covers factual product claims in the master and three cuts. A live +capture is still required for every statement about what happened in Dan's +specific run. + +## Primary sources + +- S1: https://support.claude.com/en/articles/10065433-install-claude-desktop +- S2: https://support.claude.com/en/articles/12622703-deploy-claude-desktop-for-windows +- S3: https://support.claude.com/en/articles/13345190-get-started-with-claude-cowork +- S4: https://support.claude.com/en/articles/15520349-use-claude-cowork-on-web-desktop-and-mobile +- S5: https://support.claude.com/en/articles/13364135-use-claude-cowork-safely +- S6: https://support.claude.com/en/articles/14479288-claude-cowork-architecture-overview +- S7: https://support.claude.com/en/articles/12138966-release-notes + +## Product claims + +| ID | Claim used in narration | Source | +|---|---|---| +| C1 | Cowork is available on paid Pro, Max, Team, and Enterprise plans, not Free. | S1, S3 | +| C2 | Claude Desktop supports Windows 10 or higher and launches from the Start menu. | S1 | +| C3 | Full-feature individual Windows installation, including desktop Cowork, requires administrator privileges and shows UAC; chat can still install without desktop Cowork. | S2 | +| C4 | Windows desktop Cowork requires Virtual Machine Platform; the shown PowerShell command enables it and a restart is required. | S2 | +| C5 | Chat and Cowork share the message box, with Cowork selected at the bottom left. | S3, S4 | +| C6 | Cowork brings Claude Code agentic capabilities to a visual interface without requiring a terminal. | S1, S3 | +| C7 | Desktop local-file access is limited to connected folders rather than the whole drive. | S1, S3, S4, S6 | +| C8 | Cowork can organize files and produce Excel spreadsheets with working formulas. | S1, S3 | +| C9 | Cowork creates a plan, can break complex work into subtasks, coordinates parallel work when appropriate, and shows progress so the operator can steer. | S3 | +| C10 | Permission behavior depends on Manual, Auto, or Skip mode; Manual pauses for approval, while Auto and Skip can move with fewer prompts. | S3 | +| C11 | Sessions run remotely by default in beta, with agent work and code execution in an isolated environment on Anthropic servers and session files saved to the Claude account. | S3, S4, S5, S6 | +| C12 | Remote work continues after the laptop closes; scheduled tasks run with no device online. | S3, S4, S7 | +| C13 | A remote session reaches local files through the open Claude Desktop app, only in connected folders. Contents Claude opens through Desktop are processed on Anthropic's servers. Closing the app ends that local-file access, not the remote session. | S3, S4, S5, S6 | +| C14 | In a local Windows session, code runs inside a Linux VM isolated with Hyper-V. | S6 | +| C15 | Anthropic recommends limiting connected folders and avoiding sensitive material such as financial documents, credentials, and personal records. The desktop gate scopes access; it is not a data-residency boundary. | S5 | +| C16 | Cowork is available on web and current iOS and Android apps; the same remote session can be opened across desktop, web, and mobile. | S3, S4, S7 | +| C17 | Phone notifications arrive when a Cowork task finishes or needs input. | S4 | +| C18 | Scheduled tasks can run automatically or on demand. | S3 | +| C19 | Cowork has internet and browser capabilities, subject to network policy, so a low-risk web digest is a supported task shape. | S3, S5 | +| C20 | The July 7, 2026 release added web and mobile remote sessions and changed the device-continuation model described in the tutorial. | S7 | +| C21 | No Mac is required because Windows is an officially supported Cowork surface. | S1, S3 | +| C22 | No WSL or developer tooling is listed in the official Windows Cowork requirements. This is an inference from the complete published requirements, not a vendor quote. | S1, S2 | + +## Run-dependent receipts + +| ID | Capture-dependent statement | Required proof | +|---|---|---| +| R1 | The source folder contains about forty loose, non-sensitive files. | File inventory and exact count from the captured run. | +| R2 | The spoken task matches what was entered. | Captured prompt text and saved task receipt. | +| R3 | A permission prompt appears in the visual. | Actual prompt from the selected approval mode; remove the visual if none appeared. | +| R4 | Subfolders appear and the final spreadsheet has working formulas. | Before and after folder inventory plus formula-cell spot checks. | +| R5 | Work progresses after the laptop closes. | Real timestamps from the original session and the second device. | +| R6 | The same session is shown on desktop, web, and phone, including a notification. | Captures from all three surfaces tied to the same task. | +| R7 | The scheduled digest ran on demand and produced the shown document. | Scheduled-task history and output artifact. | +| R8 | Every video here is a real run on a real machine. | Rights-clean source inventory and production receipt for every shot. | + +## Drift disposition + +The 2026-07-26 source script said Cowork asks whenever it needs a permission and +the operator approves or denies. Current S3 documents three permission modes, +including modes that do not pause for every action. The production manifests +therefore use mode-specific wording. No broader permission guarantee survives. + +Before the real-voice render, re-open S1 through S7. Any changed claim is +rewritten or cut before recording; a stale source date is not a waiver. diff --git a/demos/factory-ai-at-work/gate-01/PRODUCTION_RECEIPT_TEMPLATE.md b/demos/factory-ai-at-work/gate-01/PRODUCTION_RECEIPT_TEMPLATE.md new file mode 100644 index 0000000..3368481 --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/PRODUCTION_RECEIPT_TEMPLATE.md @@ -0,0 +1,201 @@ +# Factory AI at Work Gate 1 Production Receipt + +Copy this template to the fresh production attempt as +`PRODUCTION_RECEIPT.md`. The committed template is a blank form, not evidence +that a render, review, channel check, or publication happened. + +Replace every placeholder token before reviewed-artifact promotion. Local +promotion requires the exact render handoff status below. It does not +authorize upload or establish a channel gate pass. + +## Receipt identity + +- Run ID: PENDING +- Source commit SHA: PENDING +- Attempt root: PENDING +- Reviewed root: PENDING +- Capture inventory reference: PENDING +- Claim ledger SHA-256: PENDING +- Claim source refresh date: PENDING +- Reviewer: PENDING +- Review start UTC: PENDING +- Review stop UTC: PENDING +- Total Dan review minutes: PENDING +- Review decision and rationale: PENDING +- Render handoff status: PENDING +- Dan publication approval: NOT_REQUESTED +- Channel gate status: NOT_EVALUATED + +Allowed render handoff statuses are `REVIEWED_FOR_PUBLISH_HANDOFF` and +`REJECTED`. Only `REVIEWED_FOR_PUBLISH_HANDOFF` is eligible for local +promotion. The publication and channel fields above remain exactly +`NOT_REQUESTED` and `NOT_EVALUATED` in this immutable pre-upload receipt. +`Source commit SHA` is copied from +`master/render-report.json.sourceBuildAttestation.commit`; all four reports +must carry the same detached-commit-snapshot source-build attestation for that +commit. +Publication remains a separate operator action. Current served state and the +three-episode counter belong only to the ratified channel operator authority, +not this pre-upload receipt. + +## Artifact evidence + +Record measurements and SHA-256 values from the files in this attempt. + +| Artifact | Final SHA-256 | Report SHA-256 | Duration | Geometry and SAR | Video and audio present | TTS real | Voice and model pinned | Parity | Preflight | +|---|---|---|---|---|---|---|---|---|---| +| master | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | +| cut-a | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | +| cut-b | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | +| cut-c | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | + +Use `1920x1080, SAR 1:1` or `1080x1920, SAR 1:1` for geometry. The five +binary evidence cells must be exactly `PASS`, `REAL`, `PINNED`, `PASS`, and +`ENFORCED`, respectively. + +- Input and output hash manifest: `PRODUCTION_RECEIPT.sha256` +- Toolchain versions: PENDING +- YouTube chapters SHA-256: PENDING + +## Run-dependent evidence + +- R1 source folder count and non-sensitive inventory: PENDING +- R2 exact Cowork prompt and task receipt: PENDING +- R3 permission mode, whether a prompt appeared, and matching footage: PENDING +- R4 before and after inventory plus formula-cell spot checks: PENDING +- R5 task and lid-close continuation timestamps: PENDING +- R6 same-session desktop, web, phone, and notification evidence: PENDING +- R7 scheduled-task history, on-demand run, and output artifact: PENDING +- R8 rights-clean shot and music inventory for every clip: PENDING + +## Full final-mix playback and voice delivery + +Play every final mix from frame 1 through the final frame. Record start and +stop timestamps, then disposition every narration block against its delivery +cue. First-and-last-sentence sampling is not sufficient. + +| Artifact or block | Playback or cue evidence | Issues and disposition | +|---|---|---| +| master full mix, start UTC and stop UTC | PENDING | PENDING | +| 01-cold-open | PENDING | PENDING | +| 02-roadmap | PENDING | PENDING | +| 03-setup | PENDING | PENDING | +| 04-install-it-right | PENDING | PENDING | +| 05-first-real-task | PENDING | PENDING | +| 06-where-it-runs | PENDING | PENDING | +| 07-anywhere-on-a-schedule | PENDING | PENDING | +| 08-recap | PENDING | PENDING | +| 09-next | PENDING | PENDING | +| cut-a full mix and hook, start UTC and stop UTC | PENDING | PENDING | +| cut-b full mix and hook, start UTC and stop UTC | PENDING | PENDING | +| cut-c full mix and hook, start UTC and stop UTC | PENDING | PENDING | + +## Twelve-point channel checklist + +All twelve disposition lines must be exactly `PASS` before render handoff. +Record concrete evidence, not an unsupported check mark. A local pass does not +establish the separate post-upload channel gate outcome. + +### 1. Review economics + +- Disposition: PENDING +- Requirement: Dan completed the end-to-end watch, spot checks, and review in + 30 minutes or less, with start and stop timestamps. +- Operator evidence: PENDING + +### 2. Claims + +- Disposition: PENDING +- Requirement: Every technical claim has a primary-source claim-ledger entry + or an executed-run receipt; there are no unsupported statistics, "most + people" assertions, or unshown capability claims. +- Operator evidence: PENDING + +### 3. Real captures + +- Disposition: PENDING +- Requirement: Every screen recording is a real capture of a real run; no + mocked, staged, or generated output is presented as real. +- Operator evidence: PENDING + +### 4. Rights + +- Disposition: PENDING +- Requirement: Footage, music, fonts, and other media are owned or licensed, + with the rights inventory attached. +- Operator evidence: PENDING + +### 5. Dash-clean packaging + +- Disposition: PENDING +- Requirement: The automated scan found zero U+2013, U+2014, or U+2015 + characters in title, description, chapters, thumbnail text, SRT, ASS, pinned + comment, and end-card text. +- Operator evidence: PENDING + +### 6. Title and thumbnail + +- Disposition: PENDING +- Requirement: The title uses an allowed structure, its demand source is + logged, and the thumbnail passes the 168 px legibility and channel rules. +- Operator evidence: PENDING + +### 7. Captions + +- Disposition: PENDING +- Requirement: Captions use at most 42 characters per line, at most 2 lines, + and at most 21 characters per second; copper emphasis renders correctly; + open, middle, and close timing checks pass; all text stays in safe areas. +- Operator evidence: PENDING + +### 8. Audio + +- Disposition: PENDING +- Requirement: Narration is present on all four final mixes; end-to-end + playback found consistent levels and no silence, duplication, clipping, flat + delivery, dead air, or abrupt cuts. +- Operator evidence: PENDING + +### 9. Visual integrity + +- Disposition: PENDING +- Requirement: End-to-end playback found no frozen or all-white segments; + chapters align with block boundaries; the end card holds for 15 seconds with + the disclosure. +- Operator evidence: PENDING + +### 10. Disclosure + +- Disposition: PENDING +- Requirement: The About page was checked current; the description contains + the correct per-video disclosure and voice sentence; the planned answer and + rationale for each target platform AI-content field is recorded before + upload. +- Operator evidence: PENDING +- Planned platform AI-content answers and rationale: PENDING + +### 11. Vertical cuts + +- Disposition: PENDING +- Requirement: Three 1080x1920 cuts are 60 seconds or less, playable end to + end, caption-safe, and each has its own hook. +- Operator evidence: PENDING + +### 12. Provenance + +- Disposition: PENDING +- Requirement: Final render reports, pinned voice parameters, toolchain + versions, claim ledger, review timestamps, disclosure plans, input clips, and + hashes are archived so the shipped render is reproducible from the current + script. +- Operator evidence: PENDING + +## Gate protocol + +- This receipt proves reviewed local evidence only. It does not record served + state or change the channel counter. +- The ratified Factory AI at Work format and current operator state are the + sole authority for the three consecutive episode passes that unlock promotion + and hub linking. Portrait cuts do not count as separate episodes. +- Any post-publish miss makes the affected episode private and is recorded as + an escaped defect and counter reset in that operator authority. diff --git a/demos/factory-ai-at-work/gate-01/PUBLISHING.md b/demos/factory-ai-at-work/gate-01/PUBLISHING.md new file mode 100644 index 0000000..2fed4cd --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/PUBLISHING.md @@ -0,0 +1,113 @@ +# Gate 1 Publishing Copy + +Rendering this pack does not authorize upload or publication. Dan reviews and +approves every publish. + +## Master + +Title: + +`How to Use Claude Cowork on Windows (Step-by-Step Tutorial)` + +Description: + +Install Claude Cowork on Windows and run your first real task: this step by +step tutorial covers the admin install trap, the Virtual Machine Platform +requirement, connecting folders safely, where your session actually runs, and +picking up the same work from web and phone. + +Resources used in this video: + +- PowerShell command from Step 1: + `Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart` +- Download Claude for Windows: https://claude.com/download +- Anthropic install guide: + https://support.claude.com/en/articles/10065433-install-claude-desktop +- Windows deployment and Cowork requirements: + https://support.claude.com/en/articles/12622703-deploy-claude-desktop-for-windows +- Get started with Claude Cowork: + https://support.claude.com/en/articles/13345190-get-started-with-claude-cowork +- Cowork on web, desktop, and mobile: + https://support.claude.com/en/articles/15520349-use-claude-cowork-on-web-desktop-and-mobile +- Use Cowork safely: + https://support.claude.com/en/articles/13364135-use-claude-cowork-safely + +Chapters: + +Generate timestamps from the final reviewed real-voice `render-report.json`. +Do not publish fake-TTS estimates or hand-authored times. The shot-to-label map +is: + +```text +01-cold-open | What you'll build +02-roadmap | The 4 steps +03-setup | What you need +04-install-it-right | Step 1: Install it right (2 traps) +05-first-real-task | Step 2: First real task on real files +06-where-it-runs | Step 3: Where Cowork actually runs +07-anywhere-on-a-schedule | Step 4: Web, phone, and schedules +08-recap | Recap +09-next | What's next +``` + +HOW THIS VIDEO WAS MADE + +This channel is produced by AI under the expressed direction of Dan Mercede +(research, script, voice, edit, captions). Dan reviewed and approved this video +before publish. The screen recordings come from real runs of the tools shown. +They may be trimmed, reframed, labeled, and redacted for privacy; product +states, task results, and timestamps are not fabricated. Narration is an AI +clone of Dan Mercede's own voice, used with his direction. Details and claims +policy: see the channel About page. + +Dan on camera: youtube.com/@danmercede + +Site: danmercede.com + +End-card disclosure: + +`Produced by AI, directed and reviewed by Dan Mercede` + +## Cut A + +Title: + +`Two Windows Settings Claude Cowork Needs` + +Rendered platform-neutral tail: + +Watch the full Cowork walkthrough. Follow Factory AI at Work. + +## Cut B + +Title: + +`Forty Loose Files, One Cowork Job` + +Rendered platform-neutral tail: + +Watch the full Cowork walkthrough. Follow Factory AI at Work. + +## Cut C + +Title: + +`Where Claude Cowork Actually Runs` + +Rendered platform-neutral tail: + +Watch the full Cowork walkthrough. Follow Factory AI at Work. + +## Cut disclosure + +Add this line to every platform caption or description: + +`Produced by AI, directed and reviewed by Dan Mercede` + +Answer each platform's AI-content field from the actual footage and voice used. +Record the answer and rationale in the production receipt. Never infer platform +approval from a label or upload result. + +The rendered tail is identical on Shorts, TikTok, and Reels. Do not replace or +re-render it during platform packaging; that would create bytes outside the +reviewed manifest and playback receipt. diff --git a/demos/factory-ai-at-work/gate-01/README.md b/demos/factory-ai-at-work/gate-01/README.md new file mode 100644 index 0000000..0c83d4d --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/README.md @@ -0,0 +1,95 @@ +# Factory AI at Work: Gate 1 + +This is the executable production pack for: + +`How to Use Claude Cowork on Windows (Step-by-Step Tutorial)` + +It contains one landscape master and three portrait cuts selected in the +approved episode script. The configs pin Dan's approved cloned voice and the +channel palette. The manifests use only operator-supplied prebaked footage +because Windows setup dialogs, Claude Desktop, File Explorer, a physical +laptop, and a phone cannot be reproduced honestly by the headless Linux +capture path. + +## Current state + +The production inputs are versioned. The real captures are not present and are +intentionally ignored by Git. Preflight therefore blocks before narration +spend until every file in `CAPTURE_PLAN.md` has been placed under `clips/`. +Existing clips also fail preflight if their display aspect does not match the +declared landscape or portrait canvas, or if they use non-square sample pixels; +`fullBleed` never hides letterboxing or anamorphic distortion. + +This pack does not publish anything, store an authenticated browser profile, +or claim that Gate 1 has passed. A successful render is an input to the channel +QA gate, not a publish decision. + +The production runbook copies the exact configs, scripts, and capture inputs +into the fresh attempt and renders through per-run `--script` and `--clips-dir` +overrides. A reviewed attempt therefore owns the source bytes it hashes and +does not drift when the next correction replaces pack-local files. Real renders +also use the committed source-snapshot launcher. It streams launcher bytes from +one fixed commit, verifies and freezes a private detached worktree before +application modules load, and binds all four reports to that snapshot commit +and dependency lock. Direct mutable-checkout attestation is rejected. + +## Layout + +- `master/`: one landscape episode, capped at 600 seconds. +- `cuts/cut-a/`: the two Windows setup traps. +- `cuts/cut-b/`: the real file organization job. +- `cuts/cut-c/`: where Cowork sessions and local files run. +- `CAPTURE_PLAN.md`: exact footage contract and run-dependent receipts. +- `CLAIM_LEDGER.md`: current primary-source mapping and drift disposition. +- `PUBLISHING.md`: title, chapters, description, and cut captions. +- `PRODUCTION_RECEIPT_TEMPLATE.md`: versioned review evidence and the exact + twelve-point channel checklist. +- `docs/runbooks/factory-ai-at-work-gate-01-production.md`: render and rollback + procedure. + +## Commands + +From the repository root: + +```bash +set -euo pipefail + +pnpm install --frozen-lockfile +pnpm test -- tests/factory-ai-at-work-pack.test.ts + +FACTORY_REHEARSAL_ROOT="out/factory-ai-at-work/gate-01/attempts/$(date -u +%Y%m%dT%H%M%SZ)-rehearsal" +mkdir -p "$(dirname "$FACTORY_REHEARSAL_ROOT")" +mkdir "$FACTORY_REHEARSAL_ROOT" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/master/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/master" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/cuts/cut-a/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/cut-a" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/cuts/cut-b/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/cut-b" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/cuts/cut-c/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/cut-c" +``` + +Use the runbook for the real-voice render and QA sequence. Do not pass +`--no-preflight` for a production render. Every rehearsal and real render uses +a new, exclusively claimed output root; never rerun into a reviewed directory. +The claim protects against accidental or cooperating reuse, not a hostile +process running under the same Unix user. + +## Known production boundary + +The approved source carries per-block delivery cues. The current pipeline pins +one voice parameter set for the whole render, so those cues remain human +listening criteria rather than a machine-enforced config field. The production +receipt requires an end-to-end final-mix playback and a disposition for every +block. If the clone delivers a block flat, adjust the spoken punctuation or +split and review the voice take; do not silently rotate voices or models. + +The claim ledger was refreshed against live Anthropic documentation on +2026-07-30. That refresh narrowed the approval narration to the documented +Manual, Auto, and Skip modes; the older blanket promise that Cowork always asks +no longer ships. + +The three configs render one platform-neutral tail that is used unchanged on +Shorts, TikTok, and Reels. Platform packaging must not replace the reviewed +bytes. Public upload remains a separate operator action. diff --git a/demos/factory-ai-at-work/gate-01/cuts/cut-a/DEMO_SCRIPT.md b/demos/factory-ai-at-work/gate-01/cuts/cut-a/DEMO_SCRIPT.md new file mode 100644 index 0000000..2a27d0d --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/cuts/cut-a/DEMO_SCRIPT.md @@ -0,0 +1,7 @@ +# Two Windows settings for Claude Cowork + +### SHOT cut-a-install +- target: prebaked +- clip: cut-a-install.mp4 +- fullBleed: true +- narration: Two Windows settings decide whether Claude Cowork works at all. When Windows asks for administrator approval during the Claude install, say yes. Without admin rights, chat still works, but desktop Cowork does not. Then open Windows Features and enable Virtual Machine Platform. The exact PowerShell alternative is on screen and in the full video. Restart the PC, open Claude, and look beside Chat in the message box. When Cowork appears, the Windows setup is complete. Watch the full Cowork walkthrough. Follow Factory AI at Work. diff --git a/demos/factory-ai-at-work/gate-01/cuts/cut-a/demo.config.json b/demos/factory-ai-at-work/gate-01/cuts/cut-a/demo.config.json new file mode 100644 index 0000000..f9bbc13 --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/cuts/cut-a/demo.config.json @@ -0,0 +1,56 @@ +{ + "script": "demos/factory-ai-at-work/gate-01/cuts/cut-a/DEMO_SCRIPT.md", + "dashboardBaseUrl": "https://claude.ai", + "out": "out/factory-ai-at-work/gate-01/cut-a", + "platform": "shorts", + "fps": 30, + "maxDurationSec": 60, + "preflight": true, + "clipsDir": "../../clips/cut-a", + "voice": { + "voiceId": "AwstCxsCY8YE2KYw66By", + "modelId": "eleven_multilingual_v2", + "seed": 42, + "stability": 0.5, + "similarity": 0.75 + }, + "theme": { + "captionFont": "Liberation Sans", + "captionSize": 81, + "cursor": false, + "captionBox": true, + "captionMarginV": 300, + "fadeInMs": 0, + "captions": "wordpop", + "captionAccent": "#E68249", + "frame": { + "enabled": false, + "scale": 0.86, + "radius": 24, + "backdropTop": "#020B29", + "backdropBottom": "#020B29", + "shadow": false + }, + "annotations": { + "enabled": false, + "durationMs": 500, + "fontSize": 24, + "position": "top-right" + } + }, + "audio": { + "soundDesign": true, + "bedDb": -30, + "ticks": false, + "sweeps": false + }, + "motion": { + "zoomOnAction": false, + "livingCamera": false + }, + "brand": { + "title": "Dan Mercede | AI at Work", + "accent": "#E68249", + "cards": false + } +} diff --git a/demos/factory-ai-at-work/gate-01/cuts/cut-b/DEMO_SCRIPT.md b/demos/factory-ai-at-work/gate-01/cuts/cut-b/DEMO_SCRIPT.md new file mode 100644 index 0000000..e2297ac --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/cuts/cut-b/DEMO_SCRIPT.md @@ -0,0 +1,7 @@ +# A real file job for Claude Cowork + +### SHOT cut-b-real-job +- target: prebaked +- clip: cut-b-real-job.mp4 +- fullBleed: true +- narration: A real job for Claude Cowork: forty files of Downloads chaos. Connect only that folder, then describe the outcome, not the steps. Ask Claude to organize every file into sensible subfolders and build an index spreadsheet with one row per file, its new location, and category totals with working formulas. Claude writes a plan and shows the work. In Manual mode, it pauses when an action needs approval; you approve or deny. Watch the folders appear, then spot check the spreadsheet totals. Forty loose files became a clean folder tree and a working index. Cowork did the sorting; you stayed the reviewer. Watch the full Cowork walkthrough. Follow Factory AI at Work. diff --git a/demos/factory-ai-at-work/gate-01/cuts/cut-b/demo.config.json b/demos/factory-ai-at-work/gate-01/cuts/cut-b/demo.config.json new file mode 100644 index 0000000..3faabe2 --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/cuts/cut-b/demo.config.json @@ -0,0 +1,56 @@ +{ + "script": "demos/factory-ai-at-work/gate-01/cuts/cut-b/DEMO_SCRIPT.md", + "dashboardBaseUrl": "https://claude.ai", + "out": "out/factory-ai-at-work/gate-01/cut-b", + "platform": "shorts", + "fps": 30, + "maxDurationSec": 60, + "preflight": true, + "clipsDir": "../../clips/cut-b", + "voice": { + "voiceId": "AwstCxsCY8YE2KYw66By", + "modelId": "eleven_multilingual_v2", + "seed": 42, + "stability": 0.5, + "similarity": 0.75 + }, + "theme": { + "captionFont": "Liberation Sans", + "captionSize": 81, + "cursor": false, + "captionBox": true, + "captionMarginV": 300, + "fadeInMs": 0, + "captions": "wordpop", + "captionAccent": "#E68249", + "frame": { + "enabled": false, + "scale": 0.86, + "radius": 24, + "backdropTop": "#020B29", + "backdropBottom": "#020B29", + "shadow": false + }, + "annotations": { + "enabled": false, + "durationMs": 500, + "fontSize": 24, + "position": "top-right" + } + }, + "audio": { + "soundDesign": true, + "bedDb": -30, + "ticks": false, + "sweeps": false + }, + "motion": { + "zoomOnAction": false, + "livingCamera": false + }, + "brand": { + "title": "Dan Mercede | AI at Work", + "accent": "#E68249", + "cards": false + } +} diff --git a/demos/factory-ai-at-work/gate-01/cuts/cut-c/DEMO_SCRIPT.md b/demos/factory-ai-at-work/gate-01/cuts/cut-c/DEMO_SCRIPT.md new file mode 100644 index 0000000..be4b507 --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/cuts/cut-c/DEMO_SCRIPT.md @@ -0,0 +1,7 @@ +# Where Claude Cowork runs + +### SHOT cut-c-remote +- target: prebaked +- clip: cut-c-remote.mp4 +- fullBleed: true +- narration: Claude Cowork on Windows may run your task on Anthropic's servers, not your PC. Sessions are remote by default. Close your laptop and work keeps going; scheduled tasks can run with no device online. Local-file access is scoped through Claude Desktop to folders you connect. Files Claude opens are processed on Anthropic's servers: the gate limits access, not data residency. Close the app and the session loses those files. Keep sensitive material out. Watch the full Cowork walkthrough. Follow Factory AI at Work. diff --git a/demos/factory-ai-at-work/gate-01/cuts/cut-c/demo.config.json b/demos/factory-ai-at-work/gate-01/cuts/cut-c/demo.config.json new file mode 100644 index 0000000..4ecf94d --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/cuts/cut-c/demo.config.json @@ -0,0 +1,56 @@ +{ + "script": "demos/factory-ai-at-work/gate-01/cuts/cut-c/DEMO_SCRIPT.md", + "dashboardBaseUrl": "https://claude.ai", + "out": "out/factory-ai-at-work/gate-01/cut-c", + "platform": "shorts", + "fps": 30, + "maxDurationSec": 60, + "preflight": true, + "clipsDir": "../../clips/cut-c", + "voice": { + "voiceId": "AwstCxsCY8YE2KYw66By", + "modelId": "eleven_multilingual_v2", + "seed": 42, + "stability": 0.5, + "similarity": 0.75 + }, + "theme": { + "captionFont": "Liberation Sans", + "captionSize": 81, + "cursor": false, + "captionBox": true, + "captionMarginV": 300, + "fadeInMs": 0, + "captions": "wordpop", + "captionAccent": "#E68249", + "frame": { + "enabled": false, + "scale": 0.86, + "radius": 24, + "backdropTop": "#020B29", + "backdropBottom": "#020B29", + "shadow": false + }, + "annotations": { + "enabled": false, + "durationMs": 500, + "fontSize": 24, + "position": "top-right" + } + }, + "audio": { + "soundDesign": true, + "bedDb": -30, + "ticks": false, + "sweeps": false + }, + "motion": { + "zoomOnAction": false, + "livingCamera": false + }, + "brand": { + "title": "Dan Mercede | AI at Work", + "accent": "#E68249", + "cards": false + } +} diff --git a/demos/factory-ai-at-work/gate-01/master/DEMO_SCRIPT.md b/demos/factory-ai-at-work/gate-01/master/DEMO_SCRIPT.md new file mode 100644 index 0000000..320a2bb --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/master/DEMO_SCRIPT.md @@ -0,0 +1,55 @@ +# How to Use Claude Cowork on Windows + +### SHOT 01-cold-open +- target: prebaked +- clip: 01-cold-open.mp4 +- fullBleed: true +- narration: This organized folder and this index spreadsheet, with working formulas, started as forty loose files in a Downloads folder. Claude Cowork did the whole thing, on Windows. No Mac required, no terminal, no workarounds. Here is exactly how to set it up today. + +### SHOT 02-roadmap +- target: prebaked +- clip: 02-roadmap.mp4 +- fullBleed: true +- narration: This tutorial installs Claude Cowork on a Windows PC, covers the two setup traps, and runs a real task on real local files. Then you will see whether Cowork runs on your machine or in the cloud. Four steps, captured live on Windows 11. + +### SHOT 03-setup +- target: prebaked +- clip: 03-setup.mp4 +- fullBleed: true +- narration: You need three things: a paid Pro, Max, Team, or Enterprise plan; Windows 10 or higher; and admin rights for the installer. Cowork is not on the free plan. You do not need developer tools, a terminal, or WSL. + +### SHOT 04-install-it-right +- target: prebaked +- clip: 04-install-it-right.mp4 +- fullBleed: true +- narration: Step one. Install it right the first time, because two Windows settings decide whether Cowork shows up at all. Open your browser and go to claude.com slash download. Grab the Windows app and run the installer. Trap one arrives immediately: Windows pops a User Account Control prompt asking for administrator approval. Say yes. Anthropic is explicit about this one: without admin rights, Claude still installs and chat works fine, but Cowork on desktop is simply not available. If your company manages your machine, this admin install is the one thing to ask IT for. Once it is in, launch Claude from the Start menu and sign in with your paid account. Now trap two. Cowork does its heavy lifting inside a protected environment, and on Windows that requires a system feature called Virtual Machine Platform. If Cowork asks for it, here is the fix. Open Start, type turn Windows features on or off, tick Virtual Machine Platform, and hit OK. Prefer one line of PowerShell instead? The exact command is on screen and in the description. Either way, the feature only takes effect after a restart, so actually restart. Back at your desktop, open Claude and look at the message box. Bottom left: Chat, and right there beside it, Cowork. Claude Cowork is now live on this Windows machine. + +### SHOT 05-first-real-task +- target: prebaked +- clip: 05-first-real-task.mp4 +- fullBleed: true +- narration: Step two. A real job, on real files, not a demo prompt. Here is my test folder: about forty files of pure Downloads chaos. Screenshots, exported notes, PDFs, duplicates. In Claude, switch the selector to Cowork. First, connect the folder. This matters: Cowork reads and writes only in folders you connect. It does not get your whole drive. Now describe the outcome, not the steps. I typed: go through every file in this folder, organize everything into sensible subfolders, and build me an index spreadsheet with one row per file, its new location, and category counts with working formulas. Hit enter and watch. Claude writes a plan first, and you can see every step before and while it works. On bigger jobs it can even split the work into parallel subtasks. Permission behavior follows the mode you choose. In Manual mode, Cowork pauses and asks; you approve or deny. Auto and Skip can move with fewer prompts, so choose the mode to match the stakes. You are the supervisor here, not the typist. While it runs, watch File Explorer: subfolders appear as Claude works. You can see what it is planning and doing the whole way through, and steer when it matters. Claude Cowork just turned forty loose files into a clean folder tree plus an index spreadsheet with live formulas. Spot check the totals; you are still the reviewer. The sorting job is done. + +### SHOT 06-where-it-runs +- target: prebaked +- clip: 06-where-it-runs.mp4 +- fullBleed: true +- narration: Step three. Here is the hidden part: that job may not have run on your PC at all. As of July 2026, Cowork runs sessions remotely by default. The rollout is gradual. The thinking and code execution happen in an isolated, temporary environment on Anthropic's servers, and your sessions and files are saved to your Claude account. One: close your laptop, and the work keeps going. Two: scheduled tasks can run with no device online at all. Three: the desktop gate limits access, not data residency. A remote session reaches this computer only through the Claude Desktop app and only in folders you connect. When Claude opens a local file, its contents are processed on Anthropic's servers. Close the app and the session keeps running, but it loses access to those files. When Cowork executes locally on Windows, the code runs inside an isolated Linux virtual machine under Hyper-V, walled off from Windows itself. That is why the installer wanted Virtual Machine Platform. Caution: Anthropic's own safety guidance says keep genuinely sensitive files, like financial documents, out of connected folders. Give Cowork what an assistant needs, not everything you have. Watch: Claude Cowork keeps working with this laptop closed, because the session lives on Anthropic's servers, not on the PC. + +### SHOT 07-anywhere-on-a-schedule +- target: prebaked +- clip: 07-anywhere-on-a-schedule.mp4 +- fullBleed: true +- narration: Step four. Your desktop is now the anchor, not the cage. Because sessions live with your account, you can pick them up anywhere. On the web, go to claude dot ai, open the Home tab, and there is the same message box, the same Cowork selector, and the same session still running. On your phone, the latest Claude app for iOS or Android has it too. Start a task at your desk, steer it from the couch, answer a question Claude sends you on the go. When the work finishes, or Claude needs your input, your phone gets a notification. Local-file access still goes through the desktop app. If Claude needs a connected folder while you are away, the app has to be open on that computer. This scopes what Claude can reach; files it opens are still processed on Anthropic's servers. Last move: put it on a schedule. Mine is weekly: every Monday morning, compile a short digest of the week's AI announcements from sites I trust and save it as a document. Scheduled tasks run remotely, no device online. You can run a saved task on demand too, which is how I will prove it right now. I ran it once on demand, and there is the digest, in the session, on every device I own. That is Claude Cowork on Windows: installed, tasked, trusted with exactly what you chose, and working while you are not. + +### SHOT 08-recap +- target: prebaked +- clip: 08-recap.mp4 +- fullBleed: true +- narration: Quick replay. One: install with admin approval, enable Virtual Machine Platform, and restart. Two: connect only the folder you need, describe the outcome, and review the result. Three: sessions run remotely by default; Desktop scopes local-file access, but opened contents are processed on Anthropic's servers. Four: continue on web or phone, and schedule work with no device online. Forty messy files went in. A working system came out. + +### SHOT 09-next +- target: prebaked +- clip: 09-next.mp4 +- fullBleed: true +- narration: Factory AI at Work helps professionals get real output from AI, with claims checked against primary docs and receipts in the description. Every video is a real run on a real machine. Subscribe for the next tutorial: Teach Claude a Skill, start to finish. diff --git a/demos/factory-ai-at-work/gate-01/master/demo.config.json b/demos/factory-ai-at-work/gate-01/master/demo.config.json new file mode 100644 index 0000000..be93b93 --- /dev/null +++ b/demos/factory-ai-at-work/gate-01/master/demo.config.json @@ -0,0 +1,61 @@ +{ + "script": "demos/factory-ai-at-work/gate-01/master/DEMO_SCRIPT.md", + "dashboardBaseUrl": "https://claude.ai", + "out": "out/factory-ai-at-work/gate-01/master", + "platform": "landscape", + "fps": 30, + "maxDurationSec": 600, + "preflight": true, + "clipsDir": "../clips/master", + "voice": { + "voiceId": "AwstCxsCY8YE2KYw66By", + "modelId": "eleven_multilingual_v2", + "seed": 42, + "stability": 0.5, + "similarity": 0.75 + }, + "theme": { + "captionFont": "Liberation Sans", + "captionSize": 45, + "cursor": false, + "captionBox": true, + "captionMarginV": 64, + "fadeInMs": 250, + "captions": "wordpop", + "captionAccent": "#E68249", + "frame": { + "enabled": false, + "scale": 0.86, + "radius": 24, + "backdropTop": "#020B29", + "backdropBottom": "#020B29", + "shadow": false + }, + "annotations": { + "enabled": false, + "durationMs": 500, + "fontSize": 24, + "position": "top-right" + } + }, + "audio": { + "soundDesign": true, + "bedDb": -30, + "ticks": false, + "sweeps": true + }, + "motion": { + "zoomOnAction": false, + "livingCamera": false + }, + "brand": { + "title": "Produced by AI, directed and reviewed by Dan Mercede", + "url": "youtube.com/@DanMercedeAI", + "accent": "#E68249", + "cards": true, + "titleCard": false, + "endCard": true, + "titleSec": 2.2, + "endSec": 15 + } +} diff --git a/docs/runbooks/factory-ai-at-work-gate-01-production.md b/docs/runbooks/factory-ai-at-work-gate-01-production.md new file mode 100644 index 0000000..4b5d543 --- /dev/null +++ b/docs/runbooks/factory-ai-at-work-gate-01-production.md @@ -0,0 +1,608 @@ +--- +verified: 2026-07-30 +review_after: 2026-10-30 +topics: [factory-ai-at-work, video, gate-01, production, shorts, elevenlabs] +references: + - demos/factory-ai-at-work/gate-01/README.md + - demos/factory-ai-at-work/gate-01/CAPTURE_PLAN.md + - demos/factory-ai-at-work/gate-01/CLAIM_LEDGER.md + - demos/factory-ai-at-work/gate-01/PUBLISHING.md + - demos/factory-ai-at-work/gate-01/PRODUCTION_RECEIPT_TEMPLATE.md + - demos/factory-ai-at-work/gate-01/master/demo.config.json + - demos/factory-ai-at-work/gate-01/master/DEMO_SCRIPT.md + - demos/factory-ai-at-work/gate-01/cuts/cut-a/demo.config.json + - demos/factory-ai-at-work/gate-01/cuts/cut-a/DEMO_SCRIPT.md + - demos/factory-ai-at-work/gate-01/cuts/cut-b/demo.config.json + - demos/factory-ai-at-work/gate-01/cuts/cut-b/DEMO_SCRIPT.md + - demos/factory-ai-at-work/gate-01/cuts/cut-c/demo.config.json + - demos/factory-ai-at-work/gate-01/cuts/cut-c/DEMO_SCRIPT.md + - src/cli.ts + - src/framing.ts + - src/pipeline.ts + - src/preflight.ts + - src/provenance.ts + - src/render.ts + - src/source-build.ts + - src/git-environment.ts + - scripts/run-source-attested-render.sh + - scripts/cleanup-stale-render-input-root.sh + - scripts/validate-factory-ai-at-work-inputs.ts + - scripts/validate-factory-ai-at-work-receipt.sh + - scripts/promote-factory-ai-at-work-attempt.sh + - tests/factory-ai-at-work-pack.test.ts + - tests/prebaked-input-binding.test.ts + - tests/pipeline.smoke.test.ts + - tests/preflight.smoke.test.ts + - tests/render-aspect-guard.test.ts + - specs/factory-ai-at-work-gate-01-production-pack-spec.md +--- + +# Factory AI at Work Gate 1 Production + +This runbook renders the first channel gate episode and its three portrait +cuts. It stops at reviewed media artifacts. Upload, metadata mutation, and +public publishing are separate operator actions. + +## Preconditions + +1. Work from a clean checkout of the merged pack on a Linux filesystem under + Linux or WSL. Do not place attempts on `/mnt/c` or another DrvFs mount; + immutable `--out` runs authenticate Unix ownership, mode, birth time, and + Linux directory handles. This protects against accidental or cooperating + path reuse, not a malicious process running with the same Unix user. +2. Install the locked dependencies and verify ffmpeg, ffprobe, Chromium, and + the pinned caption font are available. Select a canonical root-owned Node + binary beneath root-owned, non-writable ancestors and the bundled + `pnpm.cjs` entrypoint from the operator-approved toolchain. Do not derive + either production path from the caller's `PATH`. The pnpm entrypoint must + report the repository-pinned `9.12.0`. +3. Capture every source listed in `CAPTURE_PLAN.md` from a real run. Keep the + source media in the ignored pack-local `clips/` directories. +4. Prepare the run-dependent evidence required by + `PRODUCTION_RECEIPT_TEMPLATE.md`. Do not proceed if the file count, prompt, + formula result, session state, or device proof differs from the narration. +5. Re-open every primary source in `CLAIM_LEDGER.md` and update or cut any + claim that drifted since its verification date. +6. Obtain the ElevenLabs key only through the normal secret manager at render + time. Never place it in this repository or a config file. + +## Rollout + +Run from the repository root: + +```bash +pnpm install --frozen-lockfile +pnpm test -- tests/factory-ai-at-work-pack.test.ts +pnpm typecheck +pnpm build +``` + +Confirm the fail-closed gate sees all clips: + +```bash +set -euo pipefail + +FACTORY_REHEARSAL_ID="$(date -u +%Y%m%dT%H%M%SZ)-rehearsal" +FACTORY_REHEARSAL_ROOT="out/factory-ai-at-work/gate-01/attempts/${FACTORY_REHEARSAL_ID}" +mkdir -p -- "$(dirname -- "$FACTORY_REHEARSAL_ROOT")" +mkdir "$FACTORY_REHEARSAL_ROOT" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/master/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/master" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/cuts/cut-a/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/cut-a" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/cuts/cut-b/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/cut-b" +FAKE_TTS=1 pnpm demo demos/factory-ai-at-work/gate-01/cuts/cut-c/demo.config.json \ + --out "$FACTORY_REHEARSAL_ROOT/cut-c" +``` + +The rehearsal must fail before audio creation if any clip is absent. Never use +`--no-preflight` to force a production render. Rehearsal output is not a +production attempt and is never promoted. + +After the keyless rehearsal passes, render through the approved secret-manager +wrapper into a different fresh attempt root: + +```bash +set -euo pipefail + +FACTORY_RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-real" +FACTORY_ATTEMPT_ROOT="out/factory-ai-at-work/gate-01/attempts/${FACTORY_RUN_ID}" +FACTORY_REVIEWED_ROOT="out/factory-ai-at-work/gate-01/reviewed/${FACTORY_RUN_ID}" +mkdir -p -- "$(dirname -- "$FACTORY_ATTEMPT_ROOT")" +mkdir "$FACTORY_ATTEMPT_ROOT" +FACTORY_ATTEMPT_ABS="$(realpath "$FACTORY_ATTEMPT_ROOT")" +test ! -e "$FACTORY_REVIEWED_ROOT" +cp demos/factory-ai-at-work/gate-01/PRODUCTION_RECEIPT_TEMPLATE.md \ + "$FACTORY_ATTEMPT_ROOT/PRODUCTION_RECEIPT.md" +mkdir -p \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/master" \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/cut-a" \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/cut-b" \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/cut-c" \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/master" \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/cuts/cut-a" \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/cuts/cut-b" \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/cuts/cut-c" +cp -- \ + demos/factory-ai-at-work/gate-01/clips/master/01-cold-open.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/02-roadmap.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/03-setup.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/04-install-it-right.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/05-first-real-task.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/06-where-it-runs.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/07-anywhere-on-a-schedule.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/08-recap.mp4 \ + demos/factory-ai-at-work/gate-01/clips/master/09-next.mp4 \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/master/" +cp -- demos/factory-ai-at-work/gate-01/clips/cut-a/cut-a-install.mp4 \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/cut-a/" +cp -- demos/factory-ai-at-work/gate-01/clips/cut-b/cut-b-real-job.mp4 \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/cut-b/" +cp -- demos/factory-ai-at-work/gate-01/clips/cut-c/cut-c-remote.mp4 \ + "$FACTORY_ATTEMPT_ROOT/evidence/clips/cut-c/" +cp -- \ + demos/factory-ai-at-work/gate-01/CAPTURE_PLAN.md \ + demos/factory-ai-at-work/gate-01/CLAIM_LEDGER.md \ + demos/factory-ai-at-work/gate-01/PUBLISHING.md \ + demos/factory-ai-at-work/gate-01/README.md \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/" +cp -- \ + demos/factory-ai-at-work/gate-01/master/DEMO_SCRIPT.md \ + demos/factory-ai-at-work/gate-01/master/demo.config.json \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/master/" +cp -- \ + demos/factory-ai-at-work/gate-01/cuts/cut-a/DEMO_SCRIPT.md \ + demos/factory-ai-at-work/gate-01/cuts/cut-a/demo.config.json \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/cuts/cut-a/" +cp -- \ + demos/factory-ai-at-work/gate-01/cuts/cut-b/DEMO_SCRIPT.md \ + demos/factory-ai-at-work/gate-01/cuts/cut-b/demo.config.json \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/cuts/cut-b/" +cp -- \ + demos/factory-ai-at-work/gate-01/cuts/cut-c/DEMO_SCRIPT.md \ + demos/factory-ai-at-work/gate-01/cuts/cut-c/demo.config.json \ + "$FACTORY_ATTEMPT_ROOT/evidence/source/cuts/cut-c/" +chmod -R a-w "$FACTORY_ATTEMPT_ROOT/evidence" + +# This shell is the source-admission root of trust. Remove every Git authority +# override, use fixed system-tool paths, fix one commit, and execute the +# launcher bytes from that commit rather than the mutable checkout copy. +while IFS= read -r FACTORY_GIT_ENVIRONMENT_NAME; do + case "$FACTORY_GIT_ENVIRONMENT_NAME" in + GIT_*) unset "$FACTORY_GIT_ENVIRONMENT_NAME" ;; + esac +done < <(compgen -e) +unset FACTORY_GIT_ENVIRONMENT_NAME +: "${FACTORY_NODE_BIN:?set the operator-approved canonical root-owned Node binary}" +: "${FACTORY_PNPM_CLI:?set the operator-approved canonical absolute bundled pnpm.cjs path}" +FACTORY_NODE_BIN="$(/usr/bin/realpath -e -- "$FACTORY_NODE_BIN")" +FACTORY_PNPM_CLI="$(/usr/bin/realpath -e -- "$FACTORY_PNPM_CLI")" +test -x "$FACTORY_NODE_BIN" +test "$(/usr/bin/stat -c '%u' -- "$FACTORY_NODE_BIN")" = 0 +test "$((8#$(/usr/bin/stat -c '%a' -- "$FACTORY_NODE_BIN") & 0022))" = 0 +FACTORY_NODE_PARENT="$(/usr/bin/dirname -- "$FACTORY_NODE_BIN")" +while true; do + test "$(/usr/bin/stat -c '%u' -- "$FACTORY_NODE_PARENT")" = 0 + test "$((8#$(/usr/bin/stat -c '%a' -- "$FACTORY_NODE_PARENT") & 0022))" = 0 + test "$FACTORY_NODE_PARENT" = / && break + FACTORY_NODE_PARENT="$(/usr/bin/dirname -- "$FACTORY_NODE_PARENT")" +done +unset FACTORY_NODE_PARENT +test -f "$FACTORY_PNPM_CLI" +test "$("$FACTORY_NODE_BIN" "$FACTORY_PNPM_CLI" --version)" = "9.12.0" +/usr/bin/sha256sum -- "$FACTORY_NODE_BIN" "$FACTORY_PNPM_CLI" +factory_authority_git() { + /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null \ + GIT_ATTR_NOSYSTEM=1 \ + /usr/bin/git --no-replace-objects \ + -c core.hooksPath=/dev/null \ + -c core.fsmonitor=false \ + "$@" +} +FACTORY_SOURCE_REPO="$( + factory_authority_git rev-parse --show-toplevel +)" +FACTORY_SOURCE_REPO="$(/usr/bin/realpath -e -- "$FACTORY_SOURCE_REPO")" +FACTORY_SOURCE_COMMIT="$( + factory_authority_git -C "$FACTORY_SOURCE_REPO" rev-parse HEAD +)" +factory_attested_render() { + factory_authority_git \ + -C "$FACTORY_SOURCE_REPO" show \ + "${FACTORY_SOURCE_COMMIT}:scripts/run-source-attested-render.sh" | + PATH=/usr/bin:/bin LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \ + /usr/bin/doppler run -p claude-code-use -c prd -- \ + /usr/bin/bash --noprofile --norc -p -s -- \ + "$FACTORY_SOURCE_REPO" \ + "$FACTORY_SOURCE_COMMIT" \ + --node-bin "$FACTORY_NODE_BIN" \ + --pnpm-cli "$FACTORY_PNPM_CLI" \ + -- \ + "$@" +} + +factory_attested_render \ + "$FACTORY_ATTEMPT_ABS/evidence/source/master/demo.config.json" \ + --script "$FACTORY_ATTEMPT_ABS/evidence/source/master/DEMO_SCRIPT.md" \ + --clips-dir "$FACTORY_ATTEMPT_ABS/evidence/clips/master" \ + --out "$FACTORY_ATTEMPT_ABS/master" +factory_attested_render \ + "$FACTORY_ATTEMPT_ABS/evidence/source/cuts/cut-a/demo.config.json" \ + --script "$FACTORY_ATTEMPT_ABS/evidence/source/cuts/cut-a/DEMO_SCRIPT.md" \ + --clips-dir "$FACTORY_ATTEMPT_ABS/evidence/clips/cut-a" \ + --out "$FACTORY_ATTEMPT_ABS/cut-a" +factory_attested_render \ + "$FACTORY_ATTEMPT_ABS/evidence/source/cuts/cut-b/demo.config.json" \ + --script "$FACTORY_ATTEMPT_ABS/evidence/source/cuts/cut-b/DEMO_SCRIPT.md" \ + --clips-dir "$FACTORY_ATTEMPT_ABS/evidence/clips/cut-b" \ + --out "$FACTORY_ATTEMPT_ABS/cut-b" +factory_attested_render \ + "$FACTORY_ATTEMPT_ABS/evidence/source/cuts/cut-c/demo.config.json" \ + --script "$FACTORY_ATTEMPT_ABS/evidence/source/cuts/cut-c/DEMO_SCRIPT.md" \ + --clips-dir "$FACTORY_ATTEMPT_ABS/evidence/clips/cut-c" \ + --out "$FACTORY_ATTEMPT_ABS/cut-c" +``` + +The copied configs, scripts, and clips are the production inputs. The per-run +`--script` and `--clips-dir` overrides ensure preflight and render consume those +attempt-owned bytes, not mutable pack-local filenames. The `--out` flag refuses +an existing target before changing it, keeps subsequent writes in a private +handle-bound directory, and publishes it only with a no-clobber rename on Linux +or WSL when the attempt is on a Linux filesystem. The authenticated zero-byte +claim remains inside each successful output as +`.agent-demo-video-output-claim`. The claim and staging checks cover accidental +or cooperating collisions; they do not isolate a process from another +malicious process with the same Unix identity. If any command fails, keep +the partial attempt outside `reviewed/`, +diagnose it, and start a new run ID. An interrupted process can leave an +exclusive claim file plus a hidden `.NAME.stage-*` sibling; quarantine both +after confirming the process is gone, then use a new run ID. + +The committed launcher requires Bash privileged startup mode, which suppresses +`BASH_ENV` and imported functions before streamed bytes execute. It then fixes +`PATH` to system directories, admits only a canonical root-owned Node binary +beneath root-owned non-writable ancestors, uses an explicit package-manager +entrypoint, and pins both selected tool files by SHA-256 for the run. Every +authority-sensitive Git call ignores caller global and system configuration +and disables repository-local hooks and filesystem monitors. It +fixes one full commit, registers a private detached no-checkout worktree, +materializes the commit archive without checkout filters, rebuilds the detached +index, and compares every scoped file byte and executable bit directly with +that commit's Git objects. It installs the frozen dependency lock in a minimal +environment with no Doppler render secrets, caller npm/pnpm configuration, +lifecycle scripts, or pnpm hook files; its home, XDG config, and package store +are private to the disposable snapshot. It then freezes the scoped tree, +changes to that snapshot, and invokes the pinned `tsx` module through the +explicit Node binary and snapshot `tsconfig.json`. Only application execution +receives the render secret. The issued source session is valid only inside the +same snapshot module graph, and the pipeline binds its own module path before +claiming output. Direct mutable-checkout use +of `--attest-source-build` is refused. The report records snapshot execution, +the commit, scoped tree, package manifest, and dependency-lock hashes, and +rechecks the frozen source after render. Installed dependency bytes and system +tools remain part of the declared toolchain boundary rather than the repository +source attestation. Snapshot mode cannot be combined with remote rendering. Copy +`Source commit SHA` into the production receipt from +`master/render-report.json.sourceBuildAttestation.commit`; do not type an +independent `git rev-parse` value. + +## Validation + +For every output: + +1. Require a `render-report.json` with `ttsMode: real`, the pinned voice and + model, parity `ok: true`, a duration at or below its config cap, full config + and script SHA-256 values, and one ordered full SHA-256 for every consumed + prebaked clip. +2. Use ffprobe to confirm the master is 1920x1080 and each cut is 1080x1920, + with both an audio and video stream. +3. Play the master and all three cuts from frame 1 through the final frame. + Listen to every sentence, not a sample. Disposition every block against its + delivery cue in `PRODUCTION_RECEIPT.md`; reject flat, clipped, silent, + duplicated, dead-air, or abruptly cut narration. +4. Inspect captions at the first, middle, and final frame of every block. Keep + text inside the title-safe area and verify the installed face is Liberation + Sans. +5. Watch every source clip end to end for frozen, blank, login, notification, + credential, account, and unrelated-file exposure. +6. Confirm the master cold-opens on the finished artifact and ends on a + 15-second disclosure card. Confirm each portrait cut has its own hook and + payoff and stays at or below 60 seconds. +7. Generate chapter timestamps from the final real-voice master report, require + every chapter to span at least 10 seconds, and spot check every timestamp + against the first frame of that section. +8. Complete all twelve items in the copied production receipt with concrete + evidence, Dan's review timestamps, disclosure-field answers and rationale, + the full source commit copied from the snapshot-attested master report, the + SHA-256 of the archived `evidence/source/CLAIM_LEDGER.md`, and an explicit + `REVIEWED_FOR_PUBLISH_HANDOFF` or `REJECTED` decision. + Local review does not establish a channel gate pass or authorize publishing. + +Example probes: + +```bash +ffprobe -v error -show_entries format=duration \ + -show_entries stream=codec_type,width,height,sample_aspect_ratio \ + -of json "$FACTORY_ATTEMPT_ROOT/master/final.mp4" +rg -n '[\u2013\u2014\u2015]' demos/factory-ai-at-work/gate-01 +``` + +The dash scan returns no matches. + +Require all four real receipts before promotion: + +```bash +"$FACTORY_NODE_BIN" --input-type=module - "$FACTORY_ATTEMPT_ROOT" <<'NODE' +import { readFileSync } from "node:fs"; +const root = process.argv[2]; +for (const name of ["master", "cut-a", "cut-b", "cut-c"]) { + const report = JSON.parse( + readFileSync(`${root}/${name}/render-report.json`, "utf8"), + ); + if (report.ttsMode !== "real") throw new Error(`${name}: TTS is not real`); + if (report.voice.voiceId !== "AwstCxsCY8YE2KYw66By") { + throw new Error(`${name}: wrong voice`); + } + if (report.voice.modelId !== "eleven_multilingual_v2") { + throw new Error(`${name}: wrong voice model`); + } + if ( + report.voice.seed !== 42 || + report.voice.stability !== 0.5 || + report.voice.similarity !== 0.75 + ) { + throw new Error(`${name}: wrong voice parameters`); + } + if (report.render.parity.ok !== true) throw new Error(`${name}: parity failed`); + if (report.timeline.totalSec > report.limits.maxDurationSec) { + throw new Error(`${name}: duration cap exceeded`); + } + if (report.preflight.ran !== true || report.preflight.declined !== false) { + throw new Error(`${name}: preflight was not enforced`); + } +} +console.log("all production receipts passed"); +NODE +``` + +Generate and persist the measured YouTube chapter block: + +```bash +set -euo pipefail + +"$FACTORY_NODE_BIN" --input-type=module - "$FACTORY_ATTEMPT_ROOT" \ + > "$FACTORY_ATTEMPT_ROOT/YOUTUBE_CHAPTERS.txt.tmp" <<'NODE' +import { readFileSync } from "node:fs"; +const root = process.argv[2]; +const { timeline } = JSON.parse( + readFileSync(`${root}/master/render-report.json`, "utf8"), +); +const labels = new Map([ + ["01-cold-open", "What you'll build"], + ["02-roadmap", "The 4 steps"], + ["03-setup", "What you need"], + ["04-install-it-right", "Step 1: Install it right (2 traps)"], + ["05-first-real-task", "Step 2: First real task on real files"], + ["06-where-it-runs", "Step 3: Where Cowork actually runs"], + ["07-anywhere-on-a-schedule", "Step 4: Web, phone, and schedules"], + ["08-recap", "Recap"], + ["09-next", "What's next"], +]); +const lines = []; +const seen = new Set(); +const chapterEntries = []; +for (const { shotId, startSec } of timeline.entries) { + const label = labels.get(shotId); + if (!label) continue; + if (seen.has(shotId)) throw new Error(`duplicate chapter shot: ${shotId}`); + seen.add(shotId); + chapterEntries.push({ shotId, startSec }); + const sec = Math.floor(startSec); + lines.push(`${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")} ${label}`); +} +const missing = [...labels.keys()].filter((shotId) => !seen.has(shotId)); +if (missing.length > 0) throw new Error(`missing chapter shots: ${missing.join(", ")}`); +for (const [index, entry] of chapterEntries.entries()) { + const endSec = chapterEntries[index + 1]?.startSec ?? timeline.totalSec; + const durationSec = endSec - entry.startSec; + if (!Number.isFinite(durationSec) || durationSec < 10) { + throw new Error( + `chapter ${entry.shotId} is shorter than 10 seconds (${durationSec.toFixed(3)}s)`, + ); + } +} +process.stdout.write(`${lines.join("\n")}\n`); +NODE +test -s "$FACTORY_ATTEMPT_ROOT/YOUTUBE_CHAPTERS.txt.tmp" +mv -- "$FACTORY_ATTEMPT_ROOT/YOUTUBE_CHAPTERS.txt.tmp" \ + "$FACTORY_ATTEMPT_ROOT/YOUTUBE_CHAPTERS.txt" +``` + +After all four outputs pass, use the closed-world promoter: + +```bash +set -euo pipefail + +LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \ + /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C \ + /usr/bin/bash --noprofile --norc -p \ + scripts/promote-factory-ai-at-work-attempt.sh \ + --node-bin "$FACTORY_NODE_BIN" \ + "$FACTORY_ATTEMPT_ROOT" \ + "$FACTORY_REVIEWED_ROOT" +LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \ + /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C \ + /usr/bin/bash --noprofile --norc -p \ + scripts/promote-factory-ai-at-work-attempt.sh \ + --node-bin "$FACTORY_NODE_BIN" \ + --verify "$FACTORY_REVIEWED_ROOT" +``` + +The promoter rejects symlinks, special files, hard links, unexpected topology, +nested mounts, blank, hidden, or placeholder review evidence, and +cross-filesystem moves before permission mutation. Both the promoter and its +semantic receipt validator require privileged Bash startup, fix system-tool +lookup, and scrub caller shell, preload, Git, Node, ripgrep, and package-manager +injection variables. The operator command clears dynamic-loader variables +before Bash starts, then passes only the fixed path and locale in the +environment and the already-canonical root-owned Node binary as an explicit +operand. The promoter forwards that same binary to both semantic validation +phases. Both entrypoints require root-owned non-writable ancestry, bind the +file's canonical identity and digest across their functional Node probe, and +then trust the root-owned executable for semantic validation. The promoter +writes the sealed +`PRODUCTION_RECEIPT.sha256` manifest only after moving the authenticated pack +to a private same-filesystem name, makes the tree read-only, regenerates the +full regular-file path-plus-digest set, runs the semantic receipt validator, +probes each final for audio, video, geometry, SAR, and duration, and +independently recomputes the archived config, script, ordered clip-byte +digests, locale-independent manifest order, exact relative concat selections, +per-segment measured durations, timeline, and chapter evidence through +`scripts/validate-factory-ai-at-work-inputs.ts`. It moves through a private +destination-filesystem name and repeats the checks before and after the final +no-clobber rename. An added, removed, changed, or unrelated input therefore +breaks verification instead of remaining outside an open-set `sha256sum -c` +check. After the final reviewed-root verification commits success, a closed +stdout or full logging filesystem may suppress the status digest but cannot +turn that durable state into an unretryable failure; the following independent +`--verify` command still runs. + +Only the reviewed root is eligible for a publishing handoff. The hash receipt +binds every attempt-owned source file and render artifact, including clips, +narration audio, intermediate media, final videos, captions, reports, chapters, +and the completed review receipt. Changing pack-local source or capture +filenames later does not invalidate an older reviewed attempt. + +## Authorized upload boundary + +Do not edit `PRODUCTION_RECEIPT.md` after promotion. Its publication approval +and channel status intentionally remain `NOT_REQUESTED` and `NOT_EVALUATED`. +This pack deliberately stops at immutable reviewed local evidence. It does not +upload, observe served state, maintain the three-video counter, or decide when +promotion and hub linking unlock. After Dan expressly authorizes publication, +use the ratified Factory AI at Work format and current operator state as the +sole channel authority. One full episode, including its required portrait cuts, +is one gate candidate; the cuts never count as additional episodes. + +Verify the served master and all three cuts against the twelve-point channel +checklist. If any later observation finds a miss, make the affected episode +private immediately and record the escaped defect and counter reset in that +operator authority. Do not infer current served truth from this pre-upload +production receipt, and do not create a local counter or head file beside it. + +## Monitoring + +During render, watch for: + +- missing-clip preflight findings; +- real TTS failures or an accidental `FAKE_TTS=1` environment; +- warnings that a clip is shorter than narration and has been frozen; +- parity or duration-cap failures; +- missing or stale `render-report.json`; +- an attempt path that already exists, which means the run ID must be changed + rather than reused. + +The launcher removes its detached source worktree on success, ordinary failure, +HUP, INT, and TERM. If cleanup fails after an otherwise successful render, the +launcher exits nonzero and prints the exact retained snapshot path. An +uncatchable process kill can also leave one private +`/tmp/agent-demo-video-source-snapshot.*/source` worktree registered. After +confirming no launcher or child render is running, remove only the exact path +reported by the launcher or `git worktree list`: + +```bash +set -euo pipefail + +FACTORY_STALE_SOURCE_SNAPSHOT="/tmp/agent-demo-video-source-snapshot.REPLACE/source" +case "$FACTORY_STALE_SOURCE_SNAPSHOT" in + /tmp/agent-demo-video-source-snapshot.*/source) ;; + *) echo "refusing unexpected snapshot path" >&2; exit 1 ;; +esac +GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_ATTR_NOSYSTEM=1 \ + /usr/bin/git --no-replace-objects \ + -c core.hooksPath=/dev/null -c core.fsmonitor=false \ + worktree list --porcelain +test -d "$FACTORY_STALE_SOURCE_SNAPSHOT" +chmod -R u+w -- "$FACTORY_STALE_SOURCE_SNAPSHOT" +GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_ATTR_NOSYSTEM=1 \ + /usr/bin/git --no-replace-objects \ + -c core.hooksPath=/dev/null -c core.fsmonitor=false \ + worktree remove --force "$FACTORY_STALE_SOURCE_SNAPSHOT" +rmdir -- "$(dirname -- "$FACTORY_STALE_SOURCE_SNAPSHOT")" +``` + +The render pipeline separately removes every private +`agent-demo-video-render-inputs-*` binding root on success, ordinary failure, +SIGINT, and SIGTERM. A cleanup failure is classified as +`PRIVATE_INPUT_CLEANUP_FAILED` and prints the exact retained binding path plus +the exact unpublished fresh-output staging path when one exists. If the render +itself also failed, the original pipeline error remains the primary error. If +rendering succeeded but binding cleanup failed, the fresh output claim stays +unpublished; do not promote that attempt. + +An uncatchable kill or host loss can leave one of these binding roots behind. +After confirming that no render process is using the exact path, use the +bounded recovery helper. It removes one explicitly named, current-user-owned +0700 directory directly beneath the selected temporary root. The helper +requires the exact pipeline name +`agent-demo-video-render-inputs--`, the +owned single-link 0400 created-by-pipeline marker, a trusted selected +temporary root and ancestor chain, no live `/proc/`, no target or +descendant mount, and a root at least the declared age (between 60 seconds and +seven days). It refuses symlinks, trailing-slash aliases, and unexpected +names: + +```bash +set -euo pipefail + +FACTORY_STALE_RENDER_INPUT_ROOT="/tmp/agent-demo-video-render-inputs-REPLACE" +LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \ + /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C \ + /usr/bin/bash --noprofile --norc -p \ + scripts/cleanup-stale-render-input-root.sh \ + --tmp-root /tmp \ + --older-than-seconds 3600 \ + "$FACTORY_STALE_RENDER_INPUT_ROOT" +``` + +The cleared loader environment protects Bash itself before the helper starts. +The empty environment and privileged Bash mode then suppress caller startup +files, imported functions, and inherited tool-selection variables before the +exact destructive path is evaluated. A successful deletion remains successful +even if the final stdout status write fails; the helper emits a best-effort +stderr notice instead of reporting that the already-committed removal failed. + +The same-UID isolation boundary still applies: do not run recovery while a +render or another process under the same Unix identity can rename that path. + +The pack promoter also handles ordinary failure, HUP, INT, and TERM by +promotion phase. Before sealing, it restores the authenticated writable pack to +the original attempt path with a no-clobber rename. After sealing begins, it +never makes the pack writable or guesses a destination; it prints the exact +authenticated `.promoting-*` or reviewed path retained for quarantine. Treat a +message that cannot authenticate either path as a stop condition and inspect +both named paths without mutating them. + +After an authorized upload, monitor the platform artifacts themselves for +correct geometry, audio, captions, description disclosure, and chapter timing. +A local green render does not prove that the served uploads are correct. + +## Rollback + +1. Never promote a failed attempt. Leave it under `attempts/` as diagnostic + evidence or move it to a dated local quarantine directory. If the promoter + reports a retained `.promoting-*` path after sealing starts, preserve that + read-only path exactly; do not rename or chmod it into another attempt. +2. Keep the previous reviewed directory byte-for-byte unchanged. +3. Restore the exact configs, scripts, and clips from the last reviewed root's + `evidence/` tree; its recorded source commit provides repository context. +4. Re-run the keyless rehearsal, real render, and full validation sequence + with new rehearsal and production run IDs. +5. If an upload already occurred, use the platform's reversible visibility + control to make it private while correcting it. Preserve the reviewed source + evidence and record the escaped defect in the channel operator authority. +6. Public promotion remains locked until the ratified channel authority records + three consecutive clean episode passes. This Gate 1 pack never changes that + state itself. diff --git a/scripts/cleanup-stale-render-input-root.sh b/scripts/cleanup-stale-render-input-root.sh new file mode 100644 index 0000000..ef036e0 --- /dev/null +++ b/scripts/cleanup-stale-render-input-root.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$-" in + *p*) ;; + *) + builtin printf '%s\n' \ + "Stale render-input recovery requires Bash privileged startup mode (-p)" >&2 + builtin exit 2 + ;; +esac + +# Privileged startup suppresses BASH_ENV and imported functions before this +# destructive helper loads. Pin system tool lookup and prevent loader or shell +# startup namespaces from reaching any child process. +PATH=/usr/bin:/bin +export PATH +hash -r +while IFS= read -r cleanup_environment_name; do + case "$cleanup_environment_name" in + BASH_ENV|ENV|CDPATH|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT) + unset "$cleanup_environment_name" + ;; + esac +done < <(compgen -e) +unset cleanup_environment_name +LC_ALL=C +export LC_ALL + +if [ "$#" -ne 5 ] || [ "${1:-}" != "--tmp-root" ] || [ "${3:-}" != "--older-than-seconds" ]; then + echo "usage: $0 --tmp-root --older-than-seconds <60..604800> " >&2 + exit 2 +fi + +cleanup_tmp_input="$2" +cleanup_age_seconds="$4" +cleanup_target_input="$5" + +case "$cleanup_tmp_input" in + /*) ;; + *) echo "temporary root must be absolute" >&2; exit 2 ;; +esac +case "$cleanup_target_input" in + /*) ;; + *) echo "render-input root must be absolute" >&2; exit 2 ;; +esac +case "$cleanup_target_input" in + /|*/) + echo "render-input root must be one exact canonical directory path without a trailing slash" >&2 + exit 2 + ;; +esac +case "$cleanup_age_seconds" in + ''|*[!0-9]*) echo "stale age must be an integer number of seconds" >&2; exit 2 ;; +esac +if [ "$cleanup_age_seconds" -lt 60 ] || [ "$cleanup_age_seconds" -gt 604800 ]; then + echo "stale age must be between 60 and 604800 seconds" >&2 + exit 2 +fi + +if [ ! -d "$cleanup_tmp_input" ] || [ -L "$cleanup_tmp_input" ]; then + echo "temporary root must be a non-symbolic-link directory" >&2 + exit 1 +fi +cleanup_tmp_root="$(/usr/bin/realpath -e -- "$cleanup_tmp_input")" +if [ "$cleanup_tmp_input" != "$cleanup_tmp_root" ] || [ "$cleanup_tmp_root" = "/" ]; then + echo "temporary root must be supplied as one exact canonical non-root path" >&2 + exit 1 +fi +cleanup_tmp_owner="$(/usr/bin/stat -c '%u' -- "$cleanup_tmp_root")" +cleanup_tmp_mode="$(/usr/bin/stat -c '%a' -- "$cleanup_tmp_root")" +cleanup_tmp_mode_value=$((8#$cleanup_tmp_mode)) +if [ "$cleanup_tmp_owner" -eq 0 ] && (( (cleanup_tmp_mode_value & 01000) != 0 )); then + : +elif [ "$cleanup_tmp_owner" -eq "$EUID" ] && (( (cleanup_tmp_mode_value & 0022) == 0 )); then + : +else + echo "temporary root must be trusted: root-owned sticky or current-user-owned without group/world write" >&2 + exit 1 +fi +cleanup_tmp_ancestor="$cleanup_tmp_root" +while true; do + if [ ! -d "$cleanup_tmp_ancestor" ] || [ -L "$cleanup_tmp_ancestor" ]; then + echo "temporary root ancestor is not trusted: $cleanup_tmp_ancestor" >&2 + exit 1 + fi + cleanup_tmp_ancestor_owner="$(/usr/bin/stat -c '%u' -- "$cleanup_tmp_ancestor")" + cleanup_tmp_ancestor_mode="$(/usr/bin/stat -c '%a' -- "$cleanup_tmp_ancestor")" + cleanup_tmp_ancestor_mode_value=$((8#$cleanup_tmp_ancestor_mode)) + if + [ "$cleanup_tmp_ancestor_owner" -eq 0 ] && + { + (( (cleanup_tmp_ancestor_mode_value & 0022) == 0 )) || + (( (cleanup_tmp_ancestor_mode_value & 01000) != 0 )) + } + then + : + elif + [ "$cleanup_tmp_ancestor_owner" -eq "$EUID" ] && + (( (cleanup_tmp_ancestor_mode_value & 0022) == 0 )) + then + : + else + echo "temporary root ancestor is not trusted: $cleanup_tmp_ancestor" >&2 + exit 1 + fi + [ "$cleanup_tmp_ancestor" = "/" ] && break + cleanup_tmp_ancestor="$(/usr/bin/dirname -- "$cleanup_tmp_ancestor")" +done +cleanup_target_parent="$(/usr/bin/realpath -e -- "$(/usr/bin/dirname -- "$cleanup_target_input")")" +cleanup_target_name="$(/usr/bin/basename -- "$cleanup_target_input")" +if [ "$cleanup_target_parent" != "$cleanup_tmp_root" ]; then + echo "refusing render-input root outside the selected temporary root" >&2 + exit 1 +fi +cleanup_target="$cleanup_tmp_root/$cleanup_target_name" +if [ "$cleanup_target_input" != "$cleanup_target" ]; then + echo "render-input root must be supplied as its exact canonical path" >&2 + exit 1 +fi +case "$cleanup_target_name" in + agent-demo-video-render-inputs-*) + cleanup_target_suffix="${cleanup_target_name#agent-demo-video-render-inputs-}" + ;; + *) + echo "refusing unexpected render-input root name" >&2 + exit 1 + ;; +esac +if [[ ! "$cleanup_target_suffix" =~ ^([0-9]+)-([A-Za-z0-9]{6})$ ]]; then + echo "refusing unexpected render-input root name" >&2 + exit 1 +fi +cleanup_owner_pid="${BASH_REMATCH[1]}" +if [ -d "/proc/$cleanup_owner_pid" ]; then + echo "render-input root owning process is still live: $cleanup_owner_pid" >&2 + exit 1 +fi +if [ ! -d "$cleanup_target" ] || [ -L "$cleanup_target" ]; then + echo "render-input root must be a non-symbolic-link directory" >&2 + exit 1 +fi + +cleanup_owner="$(/usr/bin/stat -c '%u' -- "$cleanup_target")" +cleanup_mode="$(/usr/bin/stat -c '%a' -- "$cleanup_target")" +cleanup_identity="$(/usr/bin/stat -c '%d:%i' -- "$cleanup_target")" +if [ "$cleanup_owner" -ne "$EUID" ] || [ "$cleanup_mode" != "700" ]; then + echo "render-input root must be owned by the current user with mode 0700" >&2 + exit 1 +fi +cleanup_marker="$cleanup_target/.agent-demo-video-private-input-root" +cleanup_marker_expected="agent-demo-video-private-input-root-v1" +if [ -L "$cleanup_marker" ] || [ ! -f "$cleanup_marker" ]; then + echo "render-input root lacks its created-by-pipeline marker" >&2 + exit 1 +fi +cleanup_marker_owner="$(/usr/bin/stat -c '%u' -- "$cleanup_marker")" +cleanup_marker_mode="$(/usr/bin/stat -c '%a' -- "$cleanup_marker")" +cleanup_marker_links="$(/usr/bin/stat -c '%h' -- "$cleanup_marker")" +cleanup_marker_size="$(/usr/bin/stat -c '%s' -- "$cleanup_marker")" +cleanup_marker_content="$(/usr/bin/cat -- "$cleanup_marker")" +if + [ "$cleanup_marker_owner" -ne "$EUID" ] || + [ "$cleanup_marker_mode" != "400" ] || + [ "$cleanup_marker_links" -ne 1 ] || + [ "$cleanup_marker_size" -ne $((${#cleanup_marker_expected} + 1)) ] || + [ "$cleanup_marker_content" != "$cleanup_marker_expected" ] +then + echo "render-input root has an invalid created-by-pipeline marker" >&2 + exit 1 +fi + +cleanup_now="$(/usr/bin/date +%s)" +cleanup_mtime="$(/usr/bin/stat -c '%Y' -- "$cleanup_target")" +cleanup_age=$((cleanup_now - cleanup_mtime)) +if [ "$cleanup_age" -lt "$cleanup_age_seconds" ]; then + echo "render-input root is not old enough for stale recovery: $cleanup_target" >&2 + exit 1 +fi + +# Refuse a target or descendant mount before recursive deletion. Linux +# mountinfo escapes whitespace and backslashes in the mountpoint field; decode +# those four standard sequences before comparing exact path boundaries. +while IFS=' ' read -r _ _ _ _ cleanup_mountpoint _; do + cleanup_mountpoint="${cleanup_mountpoint//\\040/ }" + cleanup_mountpoint="${cleanup_mountpoint//\\011/$'\t'}" + cleanup_mountpoint="${cleanup_mountpoint//\\012/$'\n'}" + cleanup_mountpoint="${cleanup_mountpoint//\\134/\\}" + case "$cleanup_mountpoint" in + "$cleanup_target"|"$cleanup_target"/*) + echo "refusing stale render-input root with a nested mount: $cleanup_mountpoint" >&2 + exit 1 + ;; + esac +done < /proc/self/mountinfo + +# Recheck the authenticated identity immediately before the one exact removal. +# A malicious process with the same Unix identity remains outside this tool's +# isolation boundary, matching the render pipeline's documented boundary. +if [ "$(/usr/bin/stat -c '%d:%i' -- "$cleanup_target")" != "$cleanup_identity" ]; then + echo "render-input root identity changed before removal" >&2 + exit 1 +fi +/usr/bin/rm -rf --one-file-system -- "$cleanup_target" +if [ -e "$cleanup_target" ] || [ -L "$cleanup_target" ]; then + echo "stale render-input root removal did not complete: $cleanup_target" >&2 + exit 1 +fi +if ! builtin printf 'removed stale private render-input root: %s\n' "$cleanup_target"; then + builtin printf \ + 'removed stale private render-input root, but stdout status reporting failed: %s\n' \ + "$cleanup_target" >&2 || : +fi +builtin exit 0 diff --git a/scripts/promote-factory-ai-at-work-attempt.sh b/scripts/promote-factory-ai-at-work-attempt.sh new file mode 100755 index 0000000..869a0db --- /dev/null +++ b/scripts/promote-factory-ai-at-work-attempt.sh @@ -0,0 +1,868 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$-" in + *p*) ;; + *) + builtin printf '%s\n' \ + "Factory promotion requires Bash privileged startup mode (-p)" >&2 + builtin exit 2 + ;; +esac + +# Privileged startup suppresses BASH_ENV and imported functions before this +# script loads. Fix tool lookup and remove the remaining runtime injection +# namespaces before invoking any semantic validator. +PATH=/usr/bin:/bin +export PATH +hash -r +while IFS= read -r factory_environment_name; do + case "$factory_environment_name" in + BASH_ENV|ENV|CDPATH|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|GIT_*|NODE_*|TSX_*|ESBUILD_BINARY_PATH|NPM_CONFIG_*|npm_config_*|PNPM_*|COREPACK_*) + unset "$factory_environment_name" + ;; + esac +done < <(compgen -e) +unset factory_environment_name + +# The sealed manifest is a byte-order contract. Pin collation so creation and +# later verification cannot disagree when the operator's locale changes. +LC_ALL=C +export LC_ALL + +factory_repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +factory_receipt_validator="$factory_repo_root/scripts/validate-factory-ai-at-work-receipt.sh" +factory_expected_attempt_root="" +factory_expected_reviewed_root="" + +factory_strip_trailing_slashes() { + local path="$1" + while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do + path="${path%/}" + done + printf '%s\n' "$path" +} + +factory_require_node_binary() { + local node_input="$1" + local node_path + case "$node_input" in + /*) ;; + *) + echo "Node binary path must be absolute" >&2 + return 1 + ;; + esac + if ! node_path="$(/usr/bin/realpath -e -- "$node_input")"; then + echo "Node binary path does not resolve: $node_input" >&2 + return 1 + fi + if [ "$node_path" != "$node_input" ]; then + echo "Node binary path must already be canonical: $node_input" >&2 + return 1 + fi + if [ -L "$node_path" ] || [ ! -f "$node_path" ] || [ ! -x "$node_path" ]; then + echo "Node binary must be a regular executable file: $node_path" >&2 + return 1 + fi + local node_owner + node_owner="$(/usr/bin/stat -c '%u' -- "$node_path")" + if [ "$node_owner" -ne 0 ]; then + echo "Node binary must be root-owned: $node_path" >&2 + return 1 + fi + local node_permissions + node_permissions="$(/usr/bin/stat -c '%a' -- "$node_path")" + if (( (8#$node_permissions & 0022) != 0 )); then + echo "Node binary must not be group- or world-writable: $node_path" >&2 + return 1 + fi + local node_parent + node_parent="$(/usr/bin/dirname -- "$node_path")" + while true; do + local node_parent_owner + local node_parent_permissions + node_parent_owner="$(/usr/bin/stat -c '%u' -- "$node_parent")" + node_parent_permissions="$(/usr/bin/stat -c '%a' -- "$node_parent")" + if + [ "$node_parent_owner" -ne 0 ] || + (( (8#$node_parent_permissions & 0022) != 0 )) + then + echo "Node binary ancestors must be root-owned without group/world write: $node_parent" >&2 + return 1 + fi + [ "$node_parent" = "/" ] && break + node_parent="$(/usr/bin/dirname -- "$node_parent")" + done + local node_identity + local node_sha256 + node_identity="$(/usr/bin/stat -c '%d:%i' -- "$node_path")" + node_sha256="$(/usr/bin/sha256sum -- "$node_path")" + node_sha256="${node_sha256%% *}" + local node_probe + if ! node_probe="$( + "$node_path" --input-type=module - "$node_path" 2>/dev/null <<'NODE' +import { realpathSync } from "node:fs"; +const expectedPath = process.argv[2]; +if ( + process.release?.name !== "node" || + realpathSync(process.execPath) !== expectedPath +) { + process.exit(1); +} +process.stdout.write("agent-demo-video-node-ok"); +NODE + )" || [ "$node_probe" != "agent-demo-video-node-ok" ]; then + echo "selected executable did not prove it is Node: $node_path" >&2 + return 1 + fi + if + [ "$(/usr/bin/stat -c '%d:%i' -- "$node_path")" != "$node_identity" ] || + [ "$(/usr/bin/sha256sum -- "$node_path" | { read -r hash _; printf '%s' "$hash"; })" != "$node_sha256" ] + then + echo "root-owned Node binary changed during admission: $node_path" >&2 + return 1 + fi + printf '%s\n' "$node_path" +} + +factory_generate_manifest() { + local root="$1" + ( + cd "$root" + find . -xdev -type f ! -path './PRODUCTION_RECEIPT.sha256' -print0 \ + | sort -z \ + | xargs -0 -r sha256sum -- + ) +} + +factory_require_exact_entries() { + local root="$1" + local label="$2" + shift 2 + + local dotglob_was_set=0 + local nullglob_was_set=0 + shopt -q dotglob && dotglob_was_set=1 + shopt -q nullglob && nullglob_was_set=1 + shopt -s dotglob nullglob + local -a entries=("$root"/*) + (( dotglob_was_set )) || shopt -u dotglob + (( nullglob_was_set )) || shopt -u nullglob + + if [ "${#entries[@]}" -ne "$#" ]; then + echo "$label does not contain the exact required entry count" >&2 + return 1 + fi + + local entry + local name + local expected_name + local matched + for entry in "${entries[@]}"; do + name="${entry##*/}" + matched=0 + for expected_name in "$@"; do + if [ "$name" = "$expected_name" ]; then + matched=1 + break + fi + done + if [ "$matched" -ne 1 ]; then + echo "$label contains an unexpected entry: $entry" >&2 + return 1 + fi + done +} + +factory_require_allowed_entries() { + local root="$1" + local label="$2" + shift 2 + + local dotglob_was_set=0 + local nullglob_was_set=0 + shopt -q dotglob && dotglob_was_set=1 + shopt -q nullglob && nullglob_was_set=1 + shopt -s dotglob nullglob + local -a entries=("$root"/*) + (( dotglob_was_set )) || shopt -u dotglob + (( nullglob_was_set )) || shopt -u nullglob + + local entry + local name + local allowed_name + local matched + for entry in "${entries[@]}"; do + name="${entry##*/}" + matched=0 + for allowed_name in "$@"; do + if [ "$name" = "$allowed_name" ]; then + matched=1 + break + fi + done + if [ "$matched" -ne 1 ]; then + echo "$label contains an unexpected entry: $entry" >&2 + return 1 + fi + done +} + +factory_require_named_regular_files() { + local root="$1" + local label="$2" + shift 2 + + local name + for name in "$@"; do + if [ -L "$root/$name" ] || [ ! -f "$root/$name" ]; then + echo "$label must be a regular file: $root/$name" >&2 + return 1 + fi + done +} + +factory_validate_artifact_topology() { + local root="$1" + local artifact="$2" + + factory_require_exact_entries \ + "$root" \ + "$artifact artifact root" \ + .agent-demo-video-output-claim \ + audio \ + seg \ + captions.srt \ + captions.ass \ + video.mp4 \ + audio.mp3 \ + muxed.mp4 \ + final.mp4 \ + render-report.json + factory_require_named_regular_files \ + "$root" \ + "$artifact renderer output" \ + .agent-demo-video-output-claim \ + captions.srt \ + captions.ass \ + video.mp4 \ + audio.mp3 \ + muxed.mp4 \ + final.mp4 \ + render-report.json + test -f "$root/.agent-demo-video-output-claim" + test ! -s "$root/.agent-demo-video-output-claim" + test -d "$root/audio" + test -d "$root/seg" + + local -a shot_ids + local segment_last + case "$artifact" in + master) + shot_ids=( + 01-cold-open + 02-roadmap + 03-setup + 04-install-it-right + 05-first-real-task + 06-where-it-runs + 07-anywhere-on-a-schedule + 08-recap + 09-next + __card-end + ) + segment_last=9 + ;; + cut-a) + shot_ids=(cut-a-install) + segment_last=0 + ;; + cut-b) + shot_ids=(cut-b-real-job) + segment_last=0 + ;; + cut-c) + shot_ids=(cut-c-remote) + segment_last=0 + ;; + *) + echo "unknown production artifact: $artifact" >&2 + return 1 + ;; + esac + + local -a audio_entries=(list.txt bed.wav tick.wav sweep.wav mix.m4a) + local shot_id + for shot_id in "${shot_ids[@]}"; do + audio_entries+=("$shot_id.mp3") + done + + local index + for (( index = 0; index <= segment_last; index++ )); do + audio_entries+=("pad_$index.mp3") + done + factory_require_exact_entries \ + "$root/audio" \ + "$artifact audio artifacts" \ + "${audio_entries[@]}" + factory_require_named_regular_files \ + "$root/audio" \ + "$artifact audio artifact" \ + "${audio_entries[@]}" + + local -a required_segments=(list.txt) + if [ "$artifact" = "master" ]; then + required_segments+=(card_title_text.txt card_url_text.txt card_end.mp4) + fi + for (( index = 0; index <= segment_last; index++ )); do + required_segments+=("seg_$index.mp4") + done + + local -a allowed_segments=("${required_segments[@]}") + for (( index = 0; index <= segment_last; index++ )); do + allowed_segments+=("seg_$index.ext.mp4") + done + factory_require_allowed_entries \ + "$root/seg" \ + "$artifact segment artifacts" \ + "${allowed_segments[@]}" + + local required_segment + for required_segment in "${required_segments[@]}"; do + test -f "$root/seg/$required_segment" + done +} + +factory_validate_topology() { + local root="$1" + local state="$2" + local -a root_entries=( + PRODUCTION_RECEIPT.md + YOUTUBE_CHAPTERS.txt + evidence + master + cut-a + cut-b + cut-c + ) + if [ "$state" = "closed" ]; then + root_entries+=(PRODUCTION_RECEIPT.sha256) + fi + + factory_require_exact_entries "$root" "production root" "${root_entries[@]}" + factory_require_exact_entries "$root/evidence" "evidence root" clips source + factory_require_exact_entries \ + "$root/evidence/clips" \ + "clip evidence root" \ + master cut-a cut-b cut-c + factory_require_exact_entries \ + "$root/evidence/source" \ + "source evidence root" \ + CAPTURE_PLAN.md CLAIM_LEDGER.md PUBLISHING.md README.md master cuts + factory_require_exact_entries \ + "$root/evidence/source/master" \ + "master source evidence" \ + DEMO_SCRIPT.md demo.config.json + factory_require_exact_entries \ + "$root/evidence/source/cuts" \ + "cut source evidence root" \ + cut-a cut-b cut-c + local artifact + for artifact in cut-a cut-b cut-c; do + factory_require_exact_entries \ + "$root/evidence/source/cuts/$artifact" \ + "$artifact source evidence" \ + DEMO_SCRIPT.md demo.config.json + done + factory_require_exact_entries \ + "$root/evidence/clips/master" \ + "master clip evidence" \ + 01-cold-open.mp4 \ + 02-roadmap.mp4 \ + 03-setup.mp4 \ + 04-install-it-right.mp4 \ + 05-first-real-task.mp4 \ + 06-where-it-runs.mp4 \ + 07-anywhere-on-a-schedule.mp4 \ + 08-recap.mp4 \ + 09-next.mp4 + factory_require_exact_entries \ + "$root/evidence/clips/cut-a" \ + "cut-a clip evidence" \ + cut-a-install.mp4 + factory_require_exact_entries \ + "$root/evidence/clips/cut-b" \ + "cut-b clip evidence" \ + cut-b-real-job.mp4 + factory_require_exact_entries \ + "$root/evidence/clips/cut-c" \ + "cut-c clip evidence" \ + cut-c-remote.mp4 + for artifact in master cut-a cut-b cut-c; do + factory_validate_artifact_topology "$root/$artifact" "$artifact" + done +} + +factory_reject_unlisted_types() { + local root="$1" + local unexpected + unexpected="$(find "$root" -xdev ! -type d ! -type f -print -quit)" + if [ -n "$unexpected" ]; then + echo "production attempt contains an unlisted filesystem object: $unexpected" >&2 + return 1 + fi + local hardlink + hardlink="$(find "$root" -xdev -type f -links +1 -print -quit)" + if [ -n "$hardlink" ]; then + echo "production attempt contains a hard-linked file: $hardlink" >&2 + return 1 + fi +} + +factory_reject_nested_mounts() { + local root="$1" + if ! command -v mountpoint >/dev/null 2>&1; then + echo "mountpoint is required to prove the production root has no nested filesystems" >&2 + return 1 + fi + + local nested_mount + if ! nested_mount="$( + find "$root" -xdev -mindepth 1 \ + -exec mountpoint -q --nofollow -- {} \; \ + -print -quit + )"; then + echo "could not inspect production root for nested devices or mounts: $root" >&2 + return 1 + fi + if [ -n "$nested_mount" ]; then + echo "production root contains a nested device or mount: $nested_mount" >&2 + return 1 + fi +} + +factory_assert_bounded_root() { + local root="$1" + local label="$2" + local parent + parent="$(dirname -- "$root")" + if [ "$root" = "/" ] || [ "$parent" = "/" ] || [ "$root" = "$factory_repo_root" ]; then + echo "$label root is too broad for recursive pack operations: $root" >&2 + return 1 + fi +} + +factory_nearest_existing_ancestor() { + local path="$1" + while [ ! -e "$path" ] && [ ! -L "$path" ]; do + local parent + parent="$(dirname -- "$path")" + if [ "$parent" = "$path" ]; then + echo "could not locate an existing destination ancestor for $1" >&2 + return 1 + fi + path="$parent" + done + realpath -e -- "$path" +} + +factory_random_token() { + local token + if ! token="$(od -An -N16 -tx1 /dev/urandom | tr -d '[:space:]')"; then + echo "could not generate a private promotion name" >&2 + return 1 + fi + if [[ ! "$token" =~ ^[0-9a-f]{32}$ ]]; then + echo "private promotion token has an invalid shape" >&2 + return 1 + fi + printf '%s\n' "$token" +} + +factory_validate_attempt_root() { + local root="$1" + test -d "$root" + test ! -L "$root" + factory_reject_unlisted_types "$root" + factory_reject_nested_mounts "$root" + factory_validate_topology "$root" "open" + test -s "$root/PRODUCTION_RECEIPT.md" + test -s "$root/YOUTUBE_CHAPTERS.txt" + for artifact in master cut-a cut-b cut-c; do + test -s "$root/$artifact/final.mp4" + test -s "$root/$artifact/render-report.json" + done + test -d "$root/evidence" + test ! -e "$root/PRODUCTION_RECEIPT.sha256" + + local foreign_owner + foreign_owner="$(find "$root" -xdev ! -uid "$(id -u)" -print -quit)" + if [ -n "$foreign_owner" ]; then + echo "production attempt contains an object owned by another user: $foreign_owner" >&2 + return 1 + fi + + FACTORY_RECEIPT_BASE="$factory_repo_root" \ + FACTORY_EXPECTED_ATTEMPT_ROOT="$factory_expected_attempt_root" \ + FACTORY_EXPECTED_REVIEWED_ROOT="$factory_expected_reviewed_root" \ + /usr/bin/bash --noprofile --norc -p \ + "$factory_receipt_validator" \ + --node-bin "$factory_node_bin" \ + "$root/PRODUCTION_RECEIPT.md" \ + >/dev/null +} + +factory_verify_closed_root() { + local root="$1" + test -d "$root" + test ! -L "$root" + test -s "$root/PRODUCTION_RECEIPT.sha256" + test -s "$root/YOUTUBE_CHAPTERS.txt" + factory_reject_unlisted_types "$root" + factory_reject_nested_mounts "$root" + local writable + writable="$(find "$root" -xdev -perm /222 -print -quit)" + if [ -n "$writable" ]; then + echo "reviewed production root is not sealed read-only: $writable" >&2 + return 1 + fi + factory_validate_topology "$root" "closed" + + local generated_manifest + local recorded_manifest + if ! generated_manifest="$(factory_generate_manifest "$root")"; then + return 1 + fi + if ! recorded_manifest="$(cat -- "$root/PRODUCTION_RECEIPT.sha256")"; then + return 1 + fi + if [ "$generated_manifest" != "$recorded_manifest" ]; then + echo "production manifest is not a closed match for every regular file" >&2 + return 1 + fi + ( + cd "$root" + sha256sum -c --status PRODUCTION_RECEIPT.sha256 + ) + # This is the final semantic check. The closed manifest check above binds the + # exact receipt bytes and every other regular file in this read-only root. + FACTORY_RECEIPT_BASE="$factory_repo_root" \ + FACTORY_EXPECTED_ATTEMPT_ROOT="$factory_expected_attempt_root" \ + FACTORY_EXPECTED_REVIEWED_ROOT="$factory_expected_reviewed_root" \ + /usr/bin/bash --noprofile --norc -p \ + "$factory_receipt_validator" \ + --node-bin "$factory_node_bin" \ + "$root/PRODUCTION_RECEIPT.md" \ + >/dev/null +} + +factory_pack_path_matches_identity() { + local candidate="$1" + local candidate_identity + + [ -n "$candidate" ] || return 1 + [ -d "$candidate" ] || return 1 + [ ! -L "$candidate" ] || return 1 + if ! candidate_identity="$(stat -c '%d:%i' -- "$candidate" 2>/dev/null)"; then + return 1 + fi + [ "$candidate_identity" = "$factory_pack_identity" ] +} + +factory_promotion_cleanup() { + local cleanup_status=$? + local retained_root="" + + # Prevent EXIT recursion and defer any repeated signal until this bounded + # recovery has restored or identified the authenticated pack. + trap - EXIT + trap '' HUP INT TERM + set +e + + case "$factory_promotion_phase" in + private-writable) + if factory_pack_path_matches_identity "$factory_attempt_root"; then + # The private rename did not happen, or recovery already completed. + : + elif factory_pack_path_matches_identity "$factory_promoting_root" && + [ ! -e "$factory_attempt_root" ] && + [ ! -L "$factory_attempt_root" ]; then + if mv -T --no-clobber -- "$factory_promoting_root" "$factory_attempt_root" && + factory_pack_path_matches_identity "$factory_attempt_root" && + [ ! -e "$factory_promoting_root" ] && + [ ! -L "$factory_promoting_root" ]; then + builtin printf 'restored interrupted promotion to %s\n' \ + "$factory_attempt_root" >&2 + else + retained_root="$factory_promoting_root" + fi + else + retained_root="$factory_promoting_root" + fi + if [ -n "$retained_root" ]; then + if factory_pack_path_matches_identity "$retained_root"; then + builtin printf \ + 'could not safely restore interrupted promotion; retained authenticated pack at %s\n' \ + "$retained_root" >&2 + else + builtin printf \ + 'could not authenticate interrupted promotion; inspect %s and %s without mutating either path\n' \ + "$factory_attempt_root" "$factory_promoting_root" >&2 + fi + fi + ;; + sealing|sealed|final-rename) + if factory_pack_path_matches_identity "$factory_promoting_root"; then + builtin printf \ + 'promotion stopped during %s; retained authenticated pack at %s\n' \ + "$factory_promotion_phase" "$factory_promoting_root" >&2 + elif factory_pack_path_matches_identity "$factory_reviewed_root"; then + builtin printf \ + 'promotion stopped during %s; reviewed pack remains at %s\n' \ + "$factory_promotion_phase" "$factory_reviewed_root" >&2 + else + builtin printf \ + 'promotion stopped during %s; inspect %s and %s without mutating either path\n' \ + "$factory_promotion_phase" \ + "$factory_promoting_root" \ + "$factory_reviewed_root" >&2 + fi + ;; + reviewed) + if factory_pack_path_matches_identity "$factory_reviewed_root"; then + builtin printf \ + 'post-promotion verification stopped; reviewed pack remains at %s\n' \ + "$factory_reviewed_root" >&2 + elif factory_pack_path_matches_identity "$factory_promoting_root"; then + builtin printf \ + 'post-promotion verification stopped; sealed pack remains at %s\n' \ + "$factory_promoting_root" >&2 + else + builtin printf \ + 'post-promotion verification stopped; inspect %s and %s without mutating either path\n' \ + "$factory_promoting_root" "$factory_reviewed_root" >&2 + fi + ;; + esac + + exit "$cleanup_status" +} + +if [ "$#" -lt 2 ] || [ "${1:-}" != "--node-bin" ]; then + echo "usage: $0 --node-bin " >&2 + echo " $0 --node-bin --verify " >&2 + exit 2 +fi +factory_node_bin="$(factory_require_node_binary "$2")" +shift 2 + +if [ "${1:-}" = "--verify" ]; then + if [ "$#" -ne 2 ]; then + echo "usage: $0 --node-bin --verify " >&2 + exit 2 + fi + factory_verify_operand="$(factory_strip_trailing_slashes "$2")" + if [ -L "$factory_verify_operand" ]; then + echo "reviewed root may not be a symlink: $2" >&2 + exit 1 + fi + factory_verify_root="$(realpath -e -- "$factory_verify_operand")" + factory_expected_reviewed_root="$factory_verify_root" + factory_verify_closed_root "$factory_verify_root" + exit 0 +fi + +if [ "$#" -ne 2 ]; then + echo "usage: $0 --node-bin " >&2 + exit 2 +fi + +factory_attempt_operand="$(factory_strip_trailing_slashes "$1")" +factory_reviewed_operand="$(factory_strip_trailing_slashes "$2")" +if [ -L "$factory_attempt_operand" ]; then + echo "attempt root may not be a symlink: $1" >&2 + exit 1 +fi +if [ -L "$factory_reviewed_operand" ]; then + echo "reviewed root may not be a symlink: $2" >&2 + exit 1 +fi +factory_attempt_root="$(realpath -e -- "$factory_attempt_operand")" +factory_reviewed_root="$(realpath -m -- "$factory_reviewed_operand")" +factory_reviewed_parent="$(dirname -- "$factory_reviewed_root")" +factory_reviewed_name="$(basename -- "$factory_reviewed_root")" +factory_expected_attempt_root="$factory_attempt_root" +factory_expected_reviewed_root="$factory_reviewed_root" + +factory_assert_bounded_root "$factory_attempt_root" "attempt" +factory_assert_bounded_root "$factory_reviewed_root" "reviewed" +if [ "$factory_attempt_root" = "$factory_reviewed_root" ]; then + echo "attempt and reviewed roots must differ" >&2 + exit 1 +fi +case "$factory_reviewed_root/" in + "$factory_attempt_root/"*) + echo "reviewed root may not be inside the attempt root" >&2 + exit 1 + ;; +esac +case "$factory_attempt_root/" in + "$factory_reviewed_root/"*) + echo "attempt root may not be inside the reviewed root" >&2 + exit 1 + ;; +esac + +test ! -e "$factory_reviewed_root" +test ! -L "$factory_reviewed_root" +factory_pack_identity="$(stat -c '%d:%i' -- "$factory_attempt_root")" +factory_pack_device="$(stat -c '%d' -- "$factory_attempt_root")" + +# Compare the source device with the nearest existing destination ancestor +# before semantic validation and before creating any destination directories. +factory_destination_ancestor="$(factory_nearest_existing_ancestor "$factory_reviewed_parent")" +if [ "$(stat -c '%d' -- "$factory_destination_ancestor")" != "$factory_pack_device" ]; then + echo "attempt and reviewed destination are on different filesystems; refusing promotion" >&2 + exit 1 +fi + +# All checks before this point and the semantic validation below are read-only. +# A typo or unrelated source therefore cannot create a manifest or recursively +# remove write permissions before it proves that it is the expected Gate 1 pack. +factory_validate_attempt_root "$factory_attempt_root" +if [ "$(stat -c '%d:%i' -- "$factory_attempt_root")" != "$factory_pack_identity" ]; then + echo "attempt root changed identity during validation" >&2 + exit 1 +fi + +mkdir -p -- "$factory_reviewed_parent" +factory_reviewed_parent="$(realpath -e -- "$factory_reviewed_parent")" +factory_reviewed_root="$factory_reviewed_parent/$factory_reviewed_name" +factory_expected_reviewed_root="$factory_reviewed_root" +test ! -e "$factory_reviewed_root" +test ! -L "$factory_reviewed_root" +if [ "$(stat -c '%d' -- "$factory_reviewed_parent")" != "$factory_pack_device" ]; then + echo "attempt and reviewed parent are on different filesystems; refusing promotion" >&2 + exit 1 +fi +factory_validate_attempt_root "$factory_attempt_root" +if [ "$(stat -c '%d:%i' -- "$factory_attempt_root")" != "$factory_pack_identity" ]; then + echo "attempt root changed identity while the reviewed parent was prepared" >&2 + exit 1 +fi + +exec {factory_pack_fd}< "$factory_attempt_root" +factory_pack_handle="/proc/$$/fd/$factory_pack_fd" +factory_pack_tree="$factory_pack_handle/" +if [ "$(stat -Lc '%d:%i' -- "$factory_pack_handle")" != "$factory_pack_identity" ]; then + echo "opened pack handle does not identify the validated attempt" >&2 + exit 1 +fi + +# Move the authenticated, still-writable attempt to an unpredictable private +# same-filesystem name before creating a manifest or changing any permissions. +# A cooperative collision leaves the attempt and collision object untouched. +factory_private_token="$(factory_random_token)" +factory_promoting_root="$factory_reviewed_parent/.${factory_reviewed_name}.promoting-$factory_private_token" +test ! -e "$factory_promoting_root" +test ! -L "$factory_promoting_root" +factory_promotion_phase="private-writable" +trap factory_promotion_cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +if ! mv -T --no-clobber -- "$factory_attempt_root" "$factory_promoting_root"; then + factory_promotion_phase="before-private-rename" + echo "could not move the validated pack to its private promotion name" >&2 + exit 1 +fi +if [ -e "$factory_attempt_root" ] || [ ! -d "$factory_promoting_root" ]; then + echo "private promotion rename was not exclusive; inspect the untouched names" >&2 + exit 1 +fi +if [ "$(stat -c '%d:%i' -- "$factory_promoting_root")" != "$factory_pack_identity" ] || \ + [ "$(stat -Lc '%d:%i' -- "$factory_pack_handle")" != "$factory_pack_identity" ]; then + echo "private promotion name does not identify the validated pack" >&2 + exit 1 +fi + +# Recheck the complete open-pack contract at the private pathname. Only after +# that name and the open directory handle agree may sealing mutate the pack. +factory_validate_attempt_root "$factory_promoting_root" +if [ "$(stat -c '%d:%i' -- "$factory_promoting_root")" != "$factory_pack_identity" ]; then + echo "private production pack changed identity before sealing" >&2 + exit 1 +fi +if [ "$(stat -c '%d' -- "$factory_promoting_root")" != \ + "$(stat -c '%d' -- "$factory_reviewed_parent")" ]; then + echo "private pack and reviewed parent are on different filesystems" >&2 + exit 1 +fi + +# Claim the manifest with one O_EXCL-style noclobber open through the +# authenticated directory handle. Keep its descriptor and inode identity while +# sealing and enumerating; never truncate a pathname installed by another run. +factory_promotion_phase="sealing" +set -o noclobber +if ! exec {factory_manifest_fd}> "${factory_pack_tree}PRODUCTION_RECEIPT.sha256"; then + set +o noclobber + echo "production manifest path was claimed concurrently" >&2 + exit 1 +fi +set +o noclobber +factory_manifest_identity="$(stat -Lc '%d:%i' -- "/proc/$$/fd/$factory_manifest_fd")" + +# Recursive permission mutation is rooted only at the authenticated open +# directory handle, after exact topology and nested-mount checks have passed. +chmod -R a-w -- "$factory_pack_tree" +if [ "$(stat -c '%d:%i' -- "$factory_promoting_root")" != "$factory_pack_identity" ] || \ + [ "$(stat -Lc '%d:%i' -- "$factory_pack_handle")" != "$factory_pack_identity" ]; then + echo "private production pack changed identity while being sealed" >&2 + exit 1 +fi +factory_reject_unlisted_types "$factory_pack_tree" +factory_reject_nested_mounts "$factory_pack_tree" +factory_validate_topology "$factory_pack_tree" "closed" +test -s "${factory_pack_tree}YOUTUBE_CHAPTERS.txt" +factory_generate_manifest "$factory_pack_tree" >&"$factory_manifest_fd" +if [ "$(stat -c '%d:%i' -- "${factory_pack_tree}PRODUCTION_RECEIPT.sha256")" != "$factory_manifest_identity" ]; then + echo "production manifest pathname changed identity during creation" >&2 + exit 1 +fi +exec {factory_manifest_fd}>&- +factory_verify_closed_root "$factory_promoting_root" +factory_promotion_phase="sealed" + +factory_promotion_phase="final-rename" +if ! mv -T --no-clobber -- "$factory_promoting_root" "$factory_reviewed_root"; then + echo "could not move the sealed pack to the reviewed target" >&2 + exit 1 +fi +factory_promotion_phase="reviewed" +if [ -e "$factory_promoting_root" ] || [ ! -d "$factory_reviewed_root" ]; then + echo "reviewed target appeared during promotion; sealed pack remains at $factory_promoting_root" >&2 + exit 1 +fi +if [ "$(stat -c '%d:%i' -- "$factory_reviewed_root")" != "$factory_pack_identity" ] || \ + [ "$(stat -Lc '%d:%i' -- "$factory_pack_handle")" != "$factory_pack_identity" ]; then + echo "reviewed target does not identify the sealed pack" >&2 + exit 1 +fi +if ! factory_verify_closed_root "$factory_reviewed_root"; then + echo "post-promotion verification failed; the read-only pack remains at $factory_reviewed_root for quarantine" >&2 + exit 1 +fi +exec {factory_pack_fd}>&- +factory_promotion_phase="complete" +trap - EXIT HUP INT TERM + +# The reviewed state is already committed and independently verified. Status +# output must not retroactively turn that durable success into an unretryable +# failure when stdout closes or its logging filesystem fills. +if ! sha256sum -- "$factory_reviewed_root/PRODUCTION_RECEIPT.sha256"; then + builtin printf \ + 'promotion committed and verified at %s, but its status digest could not be written\n' \ + "$factory_reviewed_root" >&2 || : +fi +exit 0 diff --git a/scripts/run-source-attested-render.sh b/scripts/run-source-attested-render.sh new file mode 100755 index 0000000..95f5167 --- /dev/null +++ b/scripts/run-source-attested-render.sh @@ -0,0 +1,491 @@ +#!/usr/bin/env bash +set -euo pipefail + +# `bash -p` suppresses BASH_ENV and imported shell functions before any streamed +# bytes execute. Fix the remaining command search path before invoking an +# external program, then clear Bash's remembered command locations. +PATH=/usr/bin:/bin +export PATH +hash -r + +factory_fail() { + echo "source-attested render refused: $*" >&2 + exit 1 +} + +# A mutable checkout copy can execute and then restore itself before any +# in-process check. Require the operator to stream this launcher's bytes from +# the fixed commit with `git show ... | bash -p -s -- ...`. +if [ -n "${BASH_SOURCE[0]:-}" ]; then + factory_fail "launcher must be streamed from the fixed Git commit, not executed by pathname" +fi + +case "$-" in + *p*) ;; + *) factory_fail "launcher requires Bash privileged startup mode (-p)" ;; +esac + +# No caller-selected Git authority or Node preload may cross the snapshot +# boundary. The launcher itself uses only Bash and system tools. +while IFS= read -r factory_environment_name; do + case "$factory_environment_name" in + BASH_ENV|ENV|CDPATH|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|GIT_*|TAR_OPTIONS|TAPE|RSH|NODE_*|TSX_*|ESBUILD_BINARY_PATH|NPM_CONFIG_*|npm_config_*|PNPM_*|COREPACK_*|FAKE_TTS) + unset "$factory_environment_name" + ;; + esac +done < <(compgen -e) +unset factory_environment_name +GIT_CONFIG_NOSYSTEM=1 +GIT_CONFIG_GLOBAL=/dev/null +GIT_ATTR_NOSYSTEM=1 +export GIT_CONFIG_NOSYSTEM GIT_CONFIG_GLOBAL GIT_ATTR_NOSYSTEM + +factory_git() { + /usr/bin/git \ + --no-replace-objects \ + -c core.hooksPath=/dev/null \ + -c core.fsmonitor=false \ + "$@" +} + +if [ "$#" -lt 3 ]; then + factory_fail "usage: [--verify-only] [--node-bin --pnpm-cli ] -- [CLI args]" +fi + +factory_repo_input="$1" +factory_commit_input="$2" +shift 2 +factory_verify_only=false +factory_node_input="" +factory_pnpm_cli_input="" +factory_saw_separator=false +while [ "$#" -gt 0 ]; do + case "$1" in + --verify-only) + if [ "$factory_verify_only" = true ]; then + factory_fail "--verify-only may be supplied once" + fi + factory_verify_only=true + shift + ;; + --node-bin) + if [ -n "$factory_node_input" ] || [ "$#" -lt 2 ]; then + factory_fail "--node-bin requires one absolute path and may be supplied once" + fi + factory_node_input="$2" + shift 2 + ;; + --pnpm-cli) + if [ -n "$factory_pnpm_cli_input" ] || [ "$#" -lt 2 ]; then + factory_fail "--pnpm-cli requires one absolute path and may be supplied once" + fi + factory_pnpm_cli_input="$2" + shift 2 + ;; + --) + factory_saw_separator=true + shift + break + ;; + *) + factory_fail "unknown launcher argument before --: $1" + ;; + esac +done +if [ "$factory_saw_separator" = false ]; then + factory_fail "missing -- before render arguments" +fi +if [ "$factory_verify_only" = false ] && [ "$#" -lt 1 ]; then + factory_fail "an absolute config path is required" +fi +if [ "$factory_verify_only" = false ] && { + [ -z "$factory_node_input" ] || [ -z "$factory_pnpm_cli_input" ]; +}; then + factory_fail "render mode requires explicit --node-bin and --pnpm-cli toolchain paths" +fi + +case "$factory_commit_input" in + *[!0-9a-f]*|"") + factory_fail "commit must be one full lowercase hexadecimal object ID" + ;; +esac +if [ "${#factory_commit_input}" -ne 40 ] && [ "${#factory_commit_input}" -ne 64 ]; then + factory_fail "commit must be one full lowercase hexadecimal object ID" +fi + +factory_require_tool_file() { + factory_tool_input="$1" + factory_tool_label="$2" + factory_tool_executable="$3" + case "$factory_tool_input" in + /*) ;; + *) factory_fail "$factory_tool_label path must be absolute" ;; + esac + factory_tool_path="$(/usr/bin/realpath -e -- "$factory_tool_input")" + if [ "$factory_tool_path" != "$factory_tool_input" ]; then + factory_fail "$factory_tool_label path must already be canonical" + fi + if [ -L "$factory_tool_path" ] || [ ! -f "$factory_tool_path" ]; then + factory_fail "$factory_tool_label must be a regular file" + fi + if [ "$factory_tool_executable" = true ] && [ ! -x "$factory_tool_path" ]; then + factory_fail "$factory_tool_label must be executable" + fi + factory_tool_owner="$(/usr/bin/stat -c '%u' -- "$factory_tool_path")" + if [ "$factory_tool_label" = "Node binary" ]; then + if [ "$factory_tool_owner" -ne 0 ]; then + factory_fail "Node binary must be root-owned" + fi + elif [ "$factory_tool_owner" -ne 0 ] && [ "$factory_tool_owner" -ne "$EUID" ]; then + factory_fail "$factory_tool_label must be owned by root or the rendering user" + fi + factory_tool_permissions="$(/usr/bin/stat -c '%a' -- "$factory_tool_path")" + if (( (8#$factory_tool_permissions & 0022) != 0 )); then + factory_fail "$factory_tool_label must not be group- or world-writable" + fi + if [ "$factory_tool_label" = "Node binary" ]; then + factory_tool_parent="$(/usr/bin/dirname -- "$factory_tool_path")" + while true; do + factory_tool_parent_owner="$(/usr/bin/stat -c '%u' -- "$factory_tool_parent")" + factory_tool_parent_permissions="$(/usr/bin/stat -c '%a' -- "$factory_tool_parent")" + if + [ "$factory_tool_parent_owner" -ne 0 ] || + (( (8#$factory_tool_parent_permissions & 0022) != 0 )) + then + factory_fail \ + "Node binary ancestors must be root-owned without group/world write: $factory_tool_parent" + fi + [ "$factory_tool_parent" = "/" ] && break + factory_tool_parent="$(/usr/bin/dirname -- "$factory_tool_parent")" + done + fi + printf '%s\n' "$factory_tool_path" +} + +factory_node_bin="" +factory_pnpm_cli="" +factory_node_sha256="" +factory_pnpm_cli_sha256="" + +factory_verify_toolchain() { + if [ "$( + /usr/bin/sha256sum -- "$factory_node_bin" | { + read -r factory_hash _ + printf '%s\n' "$factory_hash" + } + )" != "$factory_node_sha256" ]; then + factory_fail "Node binary changed during the source-attested render" + fi + if [ "$( + /usr/bin/sha256sum -- "$factory_pnpm_cli" | { + read -r factory_hash _ + printf '%s\n' "$factory_hash" + } + )" != "$factory_pnpm_cli_sha256" ]; then + factory_fail "pnpm CLI changed during the source-attested render" + fi +} + +factory_repo_root="$(/usr/bin/realpath -e -- "$factory_repo_input")" +factory_discovered_root="$( + factory_git -C "$factory_repo_root" rev-parse --show-toplevel +)" +factory_discovered_root="$(/usr/bin/realpath -e -- "$factory_discovered_root")" +if [ "$factory_discovered_root" != "$factory_repo_root" ]; then + factory_fail "repo-root must name the exact Git worktree root" +fi +factory_commit="$( + factory_git -C "$factory_repo_root" rev-parse \ + --verify --end-of-options "${factory_commit_input}^{commit}" +)" +if [ "$factory_commit" != "$factory_commit_input" ]; then + factory_fail "commit did not resolve to the exact supplied full object ID" +fi +factory_git -C "$factory_repo_root" cat-file -e \ + "${factory_commit}:scripts/run-source-attested-render.sh" + +factory_snapshot_parent="$( + /usr/bin/mktemp -d /tmp/agent-demo-video-source-snapshot.XXXXXXXX +)" +/usr/bin/chmod 0700 -- "$factory_snapshot_parent" +factory_snapshot_root="$factory_snapshot_parent/source" +factory_snapshot_added=false + +factory_cleanup() { + factory_status=$? + trap - EXIT HUP INT TERM + factory_cleanup_failed=false + if [ "$factory_snapshot_added" = true ]; then + if ! /usr/bin/chmod -R u+w -- "$factory_snapshot_root"; then + echo "source-attested render cleanup could not make the snapshot removable: $factory_snapshot_root" >&2 + factory_cleanup_failed=true + fi + if ! factory_git -C "$factory_repo_root" worktree remove --force \ + "$factory_snapshot_root"; then + echo "source-attested render cleanup could not unregister the snapshot worktree: $factory_snapshot_root" >&2 + factory_cleanup_failed=true + fi + fi + if ! /usr/bin/rmdir -- "$factory_snapshot_parent"; then + echo "source-attested render cleanup could not remove the snapshot parent: $factory_snapshot_parent" >&2 + factory_cleanup_failed=true + fi + if [ "$factory_cleanup_failed" = true ]; then + if [ -e "$factory_snapshot_root" ] || [ -L "$factory_snapshot_root" ]; then + echo "source-attested render cleanup failed; retained snapshot: $factory_snapshot_root" >&2 + elif [ -e "$factory_snapshot_parent" ] || [ -L "$factory_snapshot_parent" ]; then + echo "source-attested render cleanup failed; retained snapshot parent: $factory_snapshot_parent" >&2 + else + echo "source-attested render cleanup failed after removing the snapshot paths" >&2 + fi + if [ "$factory_status" -eq 0 ]; then + factory_status=74 + fi + fi + exit "$factory_status" +} +trap factory_cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +factory_git -C "$factory_repo_root" worktree add --detach --no-checkout \ + "$factory_snapshot_root" "$factory_commit" >/dev/null +factory_snapshot_added=true +factory_git -C "$factory_repo_root" archive --format=tar "$factory_commit" | + /usr/bin/tar -xf - -C "$factory_snapshot_root" +factory_git -C "$factory_snapshot_root" read-tree "$factory_commit" +if [ "$( + factory_git -C "$factory_snapshot_root" rev-parse --verify HEAD +)" != "$factory_commit" ]; then + factory_fail "detached source snapshot resolved to the wrong commit" +fi +if [ "$( + factory_git -C "$factory_snapshot_root" rev-parse --abbrev-ref HEAD +)" != "HEAD" ]; then + factory_fail "source snapshot is not detached" +fi + +declare -A factory_expected_modes=() +declare -A factory_expected_objects=() +factory_expected_count=0 +while IFS= read -r -d '' factory_tree_entry; do + factory_tree_header="${factory_tree_entry%%$'\t'*}" + factory_tree_path="${factory_tree_entry#*$'\t'}" + read -r factory_tree_mode factory_tree_type factory_tree_object \ + <<< "$factory_tree_header" + if [ "$factory_tree_type" != "blob" ]; then + factory_fail "unsupported scoped Git object: $factory_tree_path" + fi + case "$factory_tree_mode" in + 100644|100755) ;; + *) factory_fail "unsupported scoped Git mode $factory_tree_mode: $factory_tree_path" ;; + esac + case "$factory_tree_path" in + /*|../*|*/../*|*/..) + factory_fail "unsafe scoped Git path: $factory_tree_path" + ;; + esac + if [ -n "${factory_expected_modes[$factory_tree_path]+present}" ]; then + factory_fail "duplicate scoped Git path: $factory_tree_path" + fi + factory_expected_modes["$factory_tree_path"]="$factory_tree_mode" + factory_expected_objects["$factory_tree_path"]="$factory_tree_object" + factory_expected_count=$((factory_expected_count + 1)) +done < <( + factory_git -C "$factory_repo_root" ls-tree -r -z "$factory_commit" -- \ + src \ + scripts/remote-entry.ts \ + scripts/run-source-attested-render.sh \ + package.json \ + pnpm-lock.yaml \ + tsconfig.json +) +if [ "$factory_expected_count" -lt 6 ]; then + factory_fail "fixed commit is missing the source-build scope" +fi + +factory_verify_file() { + factory_absolute_path="$1" + factory_relative_path="${factory_absolute_path#"$factory_snapshot_root/"}" + if [ -z "${factory_expected_modes[$factory_relative_path]+present}" ]; then + factory_fail "unexpected file in source-build scope: $factory_relative_path" + fi + if [ -L "$factory_absolute_path" ] || [ ! -f "$factory_absolute_path" ]; then + factory_fail "scoped source must be a regular file: $factory_relative_path" + fi + if [ "$(/usr/bin/stat -c '%h' -- "$factory_absolute_path")" -ne 1 ]; then + factory_fail "scoped source must have one hard link: $factory_relative_path" + fi + factory_actual_object="$( + factory_git -C "$factory_repo_root" hash-object --no-filters -- \ + "$factory_absolute_path" + )" + if [ "$factory_actual_object" != "${factory_expected_objects[$factory_relative_path]}" ]; then + factory_fail "scoped source bytes differ from commit: $factory_relative_path" + fi + factory_actual_permissions="$(/usr/bin/stat -c '%a' -- "$factory_absolute_path")" + factory_actual_exec=$((8#$factory_actual_permissions & 0111)) + case "${factory_expected_modes[$factory_relative_path]}" in + 100644) + if [ "$factory_actual_exec" -ne 0 ]; then + factory_fail "scoped source executable mode differs from commit: $factory_relative_path" + fi + ;; + 100755) + if [ "$factory_actual_exec" -ne 73 ]; then + factory_fail "scoped source executable mode differs from commit: $factory_relative_path" + fi + ;; + esac + factory_seen_count=$((factory_seen_count + 1)) +} + +factory_verify_snapshot() { + factory_seen_count=0 + while IFS= read -r -d '' factory_actual_path; do + if [ -d "$factory_actual_path" ] && [ ! -L "$factory_actual_path" ]; then + continue + fi + factory_verify_file "$factory_actual_path" + done < <( + /usr/bin/find \ + "$factory_snapshot_root/src" \ + "$factory_snapshot_root/scripts/remote-entry.ts" \ + "$factory_snapshot_root/scripts/run-source-attested-render.sh" \ + "$factory_snapshot_root/package.json" \ + "$factory_snapshot_root/pnpm-lock.yaml" \ + "$factory_snapshot_root/tsconfig.json" \ + -print0 + ) + if [ "$factory_seen_count" -ne "$factory_expected_count" ]; then + factory_fail "source-build scope is missing one or more committed files" + fi +} + +factory_verify_snapshot +if [ "$factory_verify_only" = true ]; then + /usr/bin/chmod -R a-w -- "$factory_snapshot_root/src" + /usr/bin/chmod a-w -- \ + "$factory_snapshot_root" \ + "$factory_snapshot_root/scripts" \ + "$factory_snapshot_root/scripts/remote-entry.ts" \ + "$factory_snapshot_root/scripts/run-source-attested-render.sh" \ + "$factory_snapshot_root/package.json" \ + "$factory_snapshot_root/pnpm-lock.yaml" \ + "$factory_snapshot_root/tsconfig.json" + factory_verify_snapshot + printf 'verified committed source snapshot %s\n' "$factory_commit" + exit 0 +fi + +factory_node_bin="$( + factory_require_tool_file "$factory_node_input" "Node binary" true +)" +factory_pnpm_cli="$( + factory_require_tool_file "$factory_pnpm_cli_input" "pnpm CLI" false +)" +factory_node_sha256="$( + /usr/bin/sha256sum -- "$factory_node_bin" | { + read -r factory_hash _ + printf '%s\n' "$factory_hash" + } +)" +factory_pnpm_cli_sha256="$( + /usr/bin/sha256sum -- "$factory_pnpm_cli" | { + read -r factory_hash _ + printf '%s\n' "$factory_hash" + } +)" + +factory_config_path="$1" +case "$factory_config_path" in + /*) ;; + *) factory_fail "config path must be absolute" ;; +esac +for factory_argument in "$@"; do + if [ "$factory_argument" = "--attest-source-build" ]; then + factory_fail "the committed launcher owns --attest-source-build" + fi + case "$factory_argument" in + --render-host|--render-host=*) + factory_fail "source-attested renders must remain local" + ;; + esac +done +factory_previous_argument="" +for factory_argument in "$@"; do + case "$factory_previous_argument" in + --out|--clips-dir|--script) + case "$factory_argument" in + /*) ;; + *) factory_fail "$factory_previous_argument requires an absolute path" ;; + esac + ;; + esac + case "$factory_argument" in + --out=*|--clips-dir=*|--script=*) + factory_argument_value="${factory_argument#*=}" + case "$factory_argument_value" in + /*) ;; + *) factory_fail "${factory_argument%%=*} requires an absolute path" ;; + esac + ;; + esac + factory_previous_argument="$factory_argument" +done + +factory_package_home="$factory_snapshot_root/.package-home" +factory_package_config_root="$factory_snapshot_root/.package-config" +/usr/bin/mkdir --mode=0700 -- \ + "$factory_package_home" \ + "$factory_package_config_root" + +( + cd "$factory_snapshot_root" + /usr/bin/env -i \ + PATH=/usr/bin:/bin \ + HOME="$factory_package_home" \ + XDG_CONFIG_HOME="$factory_package_config_root" \ + NPM_CONFIG_USERCONFIG=/dev/null \ + NPM_CONFIG_GLOBALCONFIG=/dev/null \ + "$factory_node_bin" "$factory_pnpm_cli" install \ + --frozen-lockfile \ + --ignore-scripts \ + --ignore-pnpmfile \ + --config.userconfig=/dev/null \ + --config.globalconfig=/dev/null \ + --store-dir="$factory_snapshot_root/.pnpm-store" \ + --prod=false +) +factory_verify_toolchain +factory_verify_snapshot +/usr/bin/chmod -R a-w -- "$factory_snapshot_root/src" +/usr/bin/chmod a-w -- \ + "$factory_snapshot_root" \ + "$factory_snapshot_root/scripts" \ + "$factory_snapshot_root/scripts/remote-entry.ts" \ + "$factory_snapshot_root/scripts/run-source-attested-render.sh" \ + "$factory_snapshot_root/package.json" \ + "$factory_snapshot_root/pnpm-lock.yaml" \ + "$factory_snapshot_root/tsconfig.json" +factory_verify_snapshot + +factory_tsx_cli="$factory_snapshot_root/node_modules/tsx/dist/cli.mjs" +if [ -L "$factory_tsx_cli" ] || [ ! -f "$factory_tsx_cli" ]; then + factory_fail "frozen dependency install did not provide the pinned tsx runner" +fi + +factory_verify_toolchain +( + cd "$factory_snapshot_root" + AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_ROOT="$factory_snapshot_root" \ + AGENT_DEMO_VIDEO_SOURCE_AUTHORITY_REPO="$factory_repo_root" \ + AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_COMMIT="$factory_commit" \ + "$factory_node_bin" \ + "$factory_tsx_cli" \ + --tsconfig "$factory_snapshot_root/tsconfig.json" \ + "$factory_snapshot_root/src/cli.ts" \ + "$@" \ + --attest-source-build +) diff --git a/scripts/validate-factory-ai-at-work-inputs.ts b/scripts/validate-factory-ai-at-work-inputs.ts new file mode 100644 index 0000000..6427405 --- /dev/null +++ b/scripts/validate-factory-ai-at-work-inputs.ts @@ -0,0 +1,589 @@ +import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveClipPath } from "../src/clips.js"; +import { loadConfig } from "../src/config.js"; +import { probeDurationSec, probeSizePx } from "../src/ffmpeg.js"; +import { assertFullBleedCanvasAspect } from "../src/framing.js"; +import { parseScript } from "../src/parse-script.js"; +import { + digest, + digestFile, + digestFull, + stableConfigJson, + type ClipInputDigest, + type RenderReport, +} from "../src/provenance.js"; +import type { DemoConfig, TimelineEntry } from "../src/types.js"; +import { + computeSourceBuildAttestation, + type SourceBuildAttestation, +} from "../src/source-build.js"; + +type ArtifactName = "master" | "cut-a" | "cut-b" | "cut-c"; + +type ArtifactContract = { + sourceParts: string[]; + clipDirName: ArtifactName; + shotIds: string[]; + clipNames: string[]; + platform: DemoConfig["platform"]; + resolution: { width: number; height: number }; + maxDurationSec: number; + titleCard: boolean; + endCard: boolean; +}; + +const ARTIFACTS: Record = { + master: { + sourceParts: ["master"], + clipDirName: "master", + shotIds: [ + "01-cold-open", + "02-roadmap", + "03-setup", + "04-install-it-right", + "05-first-real-task", + "06-where-it-runs", + "07-anywhere-on-a-schedule", + "08-recap", + "09-next", + ], + clipNames: [ + "01-cold-open.mp4", + "02-roadmap.mp4", + "03-setup.mp4", + "04-install-it-right.mp4", + "05-first-real-task.mp4", + "06-where-it-runs.mp4", + "07-anywhere-on-a-schedule.mp4", + "08-recap.mp4", + "09-next.mp4", + ], + platform: "landscape", + resolution: { width: 1920, height: 1080 }, + maxDurationSec: 600, + titleCard: false, + endCard: true, + }, + "cut-a": { + sourceParts: ["cuts", "cut-a"], + clipDirName: "cut-a", + shotIds: ["cut-a-install"], + clipNames: ["cut-a-install.mp4"], + platform: "shorts", + resolution: { width: 1080, height: 1920 }, + maxDurationSec: 60, + titleCard: false, + endCard: false, + }, + "cut-b": { + sourceParts: ["cuts", "cut-b"], + clipDirName: "cut-b", + shotIds: ["cut-b-real-job"], + clipNames: ["cut-b-real-job.mp4"], + platform: "shorts", + resolution: { width: 1080, height: 1920 }, + maxDurationSec: 60, + titleCard: false, + endCard: false, + }, + "cut-c": { + sourceParts: ["cuts", "cut-c"], + clipDirName: "cut-c", + shotIds: ["cut-c-remote"], + clipNames: ["cut-c-remote.mp4"], + platform: "shorts", + resolution: { width: 1080, height: 1920 }, + maxDurationSec: 60, + titleCard: false, + endCard: false, + }, +}; + +const SUPPORT_DOCS = ["CAPTURE_PLAN.md", "CLAIM_LEDGER.md", "PUBLISHING.md", "README.md"] as const; +const CHAPTER_LABELS = new Map([ + ["01-cold-open", "What you'll build"], + ["02-roadmap", "The 4 steps"], + ["03-setup", "What you need"], + ["04-install-it-right", "Step 1: Install it right (2 traps)"], + ["05-first-real-task", "Step 2: First real task on real files"], + ["06-where-it-runs", "Step 3: Where Cowork actually runs"], + ["07-anywhere-on-a-schedule", "Step 4: Web, phone, and schedules"], + ["08-recap", "Recap"], + ["09-next", "What's next"], +]); +const EPSILON_SEC = 0.1; + +function fail(message: string): never { + throw new Error(`Factory AI at Work input evidence is invalid: ${message}`); +} + +function requireRegularFile(path: string, label: string): string { + let stat: ReturnType; + try { + stat = lstatSync(path); + } catch (error) { + fail(`${label} is missing at ${path}: ${(error as Error).message}`); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + fail(`${label} must be an archived regular file, not a symlink or other file type: ${path}`); + } + if (stat.size <= 0) fail(`${label} is empty: ${path}`); + return path; +} + +function optionalRegularFile(path: string, label: string): string | undefined { + let stat: ReturnType; + try { + stat = lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + fail(`${label} is inaccessible at ${path}: ${(error as Error).message}`); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + fail(`${label} must be an archived regular file, not a symlink or other file type: ${path}`); + } + if (stat.size <= 0) fail(`${label} is empty: ${path}`); + return path; +} + +function requireArchivedDirectory(path: string, label: string): string { + let stat: ReturnType; + try { + stat = lstatSync(path); + } catch (error) { + fail(`${label} is missing at ${path}: ${(error as Error).message}`); + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + fail(`${label} must be an archived directory, not a symlink or other file type: ${path}`); + } + return path; +} + +function readRequiredText(path: string, label: string): string { + requireRegularFile(path, label); + return readFileSync(path, "utf8"); +} + +function requireExactArray(actual: string[], expected: string[], label: string): void { + if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) { + fail(`${label} must be exactly [${expected.join(", ")}], got [${actual.join(", ")}]`); + } +} + +function requireExactDirectoryEntries(path: string, expected: string[], label: string): void { + requireArchivedDirectory(path, label); + requireExactArray(readdirSync(path).sort(), [...expected].sort(), `${label} entries`); +} + +function requireFinitePositive(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + fail(`${label} must be a finite positive number`); + } + return value; +} + +function expectedTimelineIds(config: DemoConfig, shotIds: string[]): string[] { + return [ + ...(config.brand?.titleCard ? ["__card-title"] : []), + ...shotIds, + ...(config.brand?.endCard ? ["__card-end"] : []), + ]; +} + +async function validateTimeline( + artifact: ArtifactName, + artifactRoot: string, + report: RenderReport, + config: DemoConfig, + expectedIds: string[], +): Promise { + if (!Array.isArray(report.timeline?.entries)) fail(`${artifact} report has no timeline entries`); + const entries = report.timeline.entries as TimelineEntry[]; + requireExactArray(entries.map((entry) => entry?.shotId), expectedIds, `${artifact} timeline IDs`); + if (report.render?.segments !== entries.length) { + fail(`${artifact} report segment count does not match its timeline`); + } + + let nextStart = 0; + for (const [index, entry] of entries.entries()) { + const start = entry?.startSec; + const duration = requireFinitePositive(entry?.durationSec, `${artifact} timeline entry ${index} duration`); + if (typeof start !== "number" || !Number.isFinite(start) || start < 0) { + fail(`${artifact} timeline entry ${index} start must be finite and non-negative`); + } + if (Math.abs(start - nextStart) > EPSILON_SEC) { + fail(`${artifact} timeline is not contiguous at ${entry.shotId}`); + } + nextStart = start + duration; + } + const titleEntry = entries.find((entry) => entry.shotId === "__card-title"); + const endEntry = entries.find((entry) => entry.shotId === "__card-end"); + if ( + (titleEntry && (!config.brand || Math.abs(titleEntry.durationSec - config.brand.titleSec) > EPSILON_SEC)) || + (endEntry && (!config.brand || Math.abs(endEntry.durationSec - config.brand.endSec) > EPSILON_SEC)) + ) { + fail(`${artifact} brand-card timeline duration does not match the archived config`); + } + + const timelineTotal = requireFinitePositive(report.timeline.totalSec, `${artifact} timeline total`); + const renderTotal = requireFinitePositive(report.render?.totalSec, `${artifact} render total`); + if ( + Math.abs(nextStart - timelineTotal) > EPSILON_SEC || + Math.abs(renderTotal - timelineTotal) > EPSILON_SEC + ) { + fail(`${artifact} timeline entries, timeline total, and render total disagree`); + } + + const segmentRoot = requireArchivedDirectory( + join(artifactRoot, "seg"), + `${artifact} segment directory`, + ); + const selectedSegments = entries.map((entry, index) => { + const base = requireRegularFile( + join(segmentRoot, `seg_${index}.mp4`), + `${artifact} base segment ${entry.shotId}`, + ); + return optionalRegularFile( + join(segmentRoot, `seg_${index}.ext.mp4`), + `${artifact} extended segment ${entry.shotId}`, + ) ?? base; + }); + const concatLines = readRequiredText( + join(segmentRoot, "list.txt"), + `${artifact} segment concat list`, + ).split(/\r?\n/).filter((line) => line.length > 0); + if (concatLines.length !== selectedSegments.length) { + fail(`${artifact} segment concat list does not match its timeline length`); + } + for (const [index, selected] of selectedSegments.entries()) { + const expectedLine = `file '${basename(selected)}'`; + if (concatLines[index] !== expectedLine) { + fail( + `${artifact} concat list must select exact relative archived segment ` + + `names; index ${index} must be ${expectedLine}`, + ); + } + } + + const measuredDurations = await Promise.all( + selectedSegments.map(async (path, index) => { + try { + return await probeDurationSec(path); + } catch (error) { + fail( + `${artifact} segment ${entries[index]!.shotId} duration could not be ` + + `probed at ${path}: ${(error as Error).message}`, + ); + } + }), + ); + for (const [index, measured] of measuredDurations.entries()) { + const duration = requireFinitePositive( + measured, + `${artifact} measured segment ${entries[index]!.shotId} duration`, + ); + if (Math.abs(duration - entries[index]!.durationSec) > EPSILON_SEC) { + fail( + `${artifact} measured segment duration does not match its timeline at ` + + `${entries[index]!.shotId}`, + ); + } + } +} + +function validateReportBasics( + artifact: ArtifactName, + report: RenderReport, + config: DemoConfig, + script: string, + clips: ClipInputDigest[], + expectedSourceBuild: SourceBuildAttestation, +): void { + const stableConfig = stableConfigJson(config); + if ( + report.inputs?.configHash !== digest(stableConfig) || + report.inputs?.scriptHash !== digest(script) || + report.inputs?.configSha256 !== digestFull(stableConfig) || + report.inputs?.scriptSha256 !== digestFull(script) || + JSON.stringify(report.inputs?.clips) !== JSON.stringify(clips) + ) { + fail( + `${artifact} render report input hashes do not match the archived ` + + "config, script, and ordered clip bytes", + ); + } + if ( + report.preflight?.ran !== true || + report.preflight?.declined !== false || + report.preflight?.findings !== 0 || + !Array.isArray(report.preflight?.unverifiedShotIds) || + report.preflight.unverifiedShotIds.length !== 0 + ) { + fail(`${artifact} render report does not prove a clean, fully adjudicated preflight`); + } + if ( + report.render?.parity?.ok !== true || + !Array.isArray(report.render?.parity?.problems) || + report.render.parity.problems.length !== 0 + ) { + fail(`${artifact} render report does not prove clean parity`); + } + if (report.limits?.maxDurationSec !== config.maxDurationSec) { + fail(`${artifact} render report cap does not match the archived config`); + } + if (JSON.stringify(report.voice) !== JSON.stringify(config.voice)) { + fail(`${artifact} render report voice does not match the archived config`); + } + if ( + report.renderedOn !== "local" || + report.sourceBuildAttestation?.executionMode !== + "detached-commit-snapshot" || + JSON.stringify(report.sourceBuildAttestation) !== + JSON.stringify(expectedSourceBuild) + ) { + fail( + `${artifact} render report source-build attestation does not match ` + + "the production receipt commit", + ); + } +} + +async function validateArtifact( + root: string, + artifact: ArtifactName, + expectedSourceBuild: SourceBuildAttestation, +): Promise { + const contract = ARTIFACTS[artifact]; + const sourceRoot = requireArchivedDirectory(join(root, "evidence", "source"), "source evidence directory"); + if (contract.sourceParts[0] === "cuts") { + requireArchivedDirectory(join(sourceRoot, "cuts"), "cut source evidence directory"); + } + const sourceDir = join(root, "evidence", "source", ...contract.sourceParts); + const clipsDir = join(root, "evidence", "clips", contract.clipDirName); + requireArchivedDirectory(sourceDir, `${artifact} source directory`); + requireArchivedDirectory(clipsDir, `${artifact} clip directory`); + requireArchivedDirectory(join(root, artifact), `${artifact} artifact directory`); + const configPath = requireRegularFile(join(sourceDir, "demo.config.json"), `${artifact} config`); + const scriptPath = requireRegularFile(join(sourceDir, "DEMO_SCRIPT.md"), `${artifact} script`); + const reportPath = requireRegularFile(join(root, artifact, "render-report.json"), `${artifact} render report`); + + const config = loadConfig(configPath); + // Reproduce the production CLI invocation: these two options deliberately + // replace mutable paths authored in the copied config with attempt-owned + // absolute paths before preflight, capture, and provenance hashing. + config.script = scriptPath; + config.clipsDir = clipsDir; + + if (config.audio.musicPath !== undefined) { + fail( + `${artifact} archived config may not use audio.musicPath; ` + + "Gate 1 permits only the reproducible synthesized sound-design bed", + ); + } + if ( + config.platform !== contract.platform || + config.resolution.width !== contract.resolution.width || + config.resolution.height !== contract.resolution.height || + config.maxDurationSec !== contract.maxDurationSec || + config.preflight !== true || + config.brand?.titleCard !== contract.titleCard || + config.brand?.endCard !== contract.endCard + ) { + fail(`${artifact} archived config does not match the Gate 1 artifact contract`); + } + + const script = readRequiredText(scriptPath, `${artifact} script`); + const manifest = parseScript(script); + requireExactArray(manifest.shots.map((shot) => shot.id), contract.shotIds, `${artifact} shot IDs`); + requireExactArray( + manifest.shots.map((shot) => shot.clip ?? ""), + contract.clipNames, + `${artifact} clip declarations`, + ); + + const clipDigests: ClipInputDigest[] = []; + for (const [index, shot] of manifest.shots.entries()) { + if (shot.target !== "prebaked" || shot.fullBleed !== true || !shot.clip) { + fail(`${artifact} shot ${shot.id} must be a full-bleed prebaked clip`); + } + const expectedClip = join(clipsDir, contract.clipNames[index]!); + const resolvedClip = resolveClipPath(shot.clip, config.clipsDir, config.configDir ?? dirname(configPath)); + if (resolvedClip !== expectedClip) { + fail(`${artifact} shot ${shot.id} resolves outside its archived evidence clip path`); + } + requireRegularFile(resolvedClip, `${artifact} clip ${basename(resolvedClip)}`); + clipDigests.push({ shotId: shot.id, sha256: await digestFile(resolvedClip) }); + const geometry = await probeSizePx(resolvedClip); + assertFullBleedCanvasAspect(config.resolution, geometry, shot.id); + if ( + geometry.width !== contract.resolution.width || + geometry.height !== contract.resolution.height + ) { + fail( + `${artifact} clip ${basename(resolvedClip)} must be ` + + `${contract.resolution.width}x${contract.resolution.height} square-pixel display geometry`, + ); + } + } + + let report: RenderReport; + try { + report = JSON.parse(readFileSync(reportPath, "utf8")) as RenderReport; + } catch (error) { + fail(`${artifact} render report is not valid JSON: ${(error as Error).message}`); + } + validateReportBasics( + artifact, + report, + config, + script, + clipDigests, + expectedSourceBuild, + ); + await validateTimeline( + artifact, + join(root, artifact), + report, + config, + expectedTimelineIds(config, contract.shotIds), + ); + return report; +} + +function validateChapters(root: string, masterReport: RenderReport): void { + const seen = new Set(); + const expectedLines: string[] = []; + const chapterEntries: TimelineEntry[] = []; + for (const entry of masterReport.timeline.entries) { + const label = CHAPTER_LABELS.get(entry.shotId); + if (!label) continue; + if (seen.has(entry.shotId)) fail(`master timeline repeats chapter shot ${entry.shotId}`); + seen.add(entry.shotId); + chapterEntries.push(entry); + const seconds = Math.floor(entry.startSec); + expectedLines.push( + `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} ${label}`, + ); + } + const missing = [...CHAPTER_LABELS.keys()].filter((shotId) => !seen.has(shotId)); + if (missing.length > 0) fail(`master timeline is missing chapter shots: ${missing.join(", ")}`); + for (const [index, entry] of chapterEntries.entries()) { + const endSec = chapterEntries[index + 1]?.startSec ?? masterReport.timeline.totalSec; + const durationSec = endSec - entry.startSec; + if (!Number.isFinite(durationSec) || durationSec < 10) { + fail( + `master chapter ${entry.shotId} is shorter than 10 seconds ` + + `(${durationSec.toFixed(3)}s)`, + ); + } + } + const expected = `${expectedLines.join("\n")}\n`; + const actual = readRequiredText(join(root, "YOUTUBE_CHAPTERS.txt"), "YouTube chapters"); + if (actual !== expected) { + fail("YOUTUBE_CHAPTERS.txt was not derived exactly from the archived master timeline"); + } +} + +async function main(): Promise { + if (process.argv.length !== 4) { + throw new Error( + "usage: pnpm exec tsx scripts/validate-factory-ai-at-work-inputs.ts " + + " ", + ); + } + const rootArg = resolve(process.argv[2]!); + let root: string; + try { + const stat = lstatSync(rootArg); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + fail(`root must be an archived directory, not a symlink or other file type: ${rootArg}`); + } + root = realpathSync(rootArg); + } catch (error) { + fail(`root is missing or inaccessible at ${rootArg}: ${(error as Error).message}`); + } + + requireArchivedDirectory(join(root, "evidence"), "evidence directory"); + const productionReceiptPath = resolve(process.argv[3]!); + if (dirname(productionReceiptPath) !== root) { + fail("production receipt must be a direct child of the validated root"); + } + const productionReceipt = readRequiredText(productionReceiptPath, "production receipt"); + const sourceCommitValues = productionReceipt + .split(/\r?\n/) + .filter((line) => line.startsWith("- Source commit SHA:")) + .map((line) => line.slice("- Source commit SHA:".length).trim()); + if ( + sourceCommitValues.length !== 1 || + !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sourceCommitValues[0] ?? "") + ) { + fail("production receipt must contain one full Source commit SHA"); + } + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + let expectedSourceBuild: SourceBuildAttestation; + try { + expectedSourceBuild = await computeSourceBuildAttestation( + repoRoot, + sourceCommitValues[0], + ); + } catch (error) { + fail( + `production receipt Source commit SHA cannot produce a source-build attestation: ` + + `${(error as Error).message}`, + ); + } + const sourceRoot = join(root, "evidence", "source"); + const clipsRoot = join(root, "evidence", "clips"); + requireExactDirectoryEntries( + sourceRoot, + [...SUPPORT_DOCS, "master", "cuts"], + "source evidence directory", + ); + requireExactDirectoryEntries( + clipsRoot, + ["master", "cut-a", "cut-b", "cut-c"], + "clip evidence directory", + ); + requireExactDirectoryEntries( + join(sourceRoot, "master"), + ["DEMO_SCRIPT.md", "demo.config.json"], + "master source directory", + ); + requireExactDirectoryEntries( + join(sourceRoot, "cuts"), + ["cut-a", "cut-b", "cut-c"], + "cut source directory", + ); + for (const doc of SUPPORT_DOCS) { + requireRegularFile(join(sourceRoot, doc), `support document ${doc}`); + } + for (const artifact of ["cut-a", "cut-b", "cut-c"] as const) { + requireExactDirectoryEntries( + join(sourceRoot, "cuts", artifact), + ["DEMO_SCRIPT.md", "demo.config.json"], + `${artifact} source directory`, + ); + } + for (const artifact of Object.keys(ARTIFACTS) as ArtifactName[]) { + requireExactDirectoryEntries( + join(clipsRoot, artifact), + ARTIFACTS[artifact].clipNames, + `${artifact} clip directory`, + ); + } + let masterReport: RenderReport | undefined; + for (const artifact of Object.keys(ARTIFACTS) as ArtifactName[]) { + const report = await validateArtifact(root, artifact, expectedSourceBuild); + if (artifact === "master") masterReport = report; + } + if (!masterReport) fail("master render report was not validated"); + validateChapters(root, masterReport); + console.log(`Factory AI at Work archived inputs validated: ${root}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/scripts/validate-factory-ai-at-work-receipt.sh b/scripts/validate-factory-ai-at-work-receipt.sh new file mode 100644 index 0000000..0e9adbc --- /dev/null +++ b/scripts/validate-factory-ai-at-work-receipt.sh @@ -0,0 +1,488 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$-" in + *p*) ;; + *) + builtin printf '%s\n' \ + "Factory receipt validation requires Bash privileged startup mode (-p)" >&2 + builtin exit 2 + ;; +esac + +PATH=/usr/bin:/bin +export PATH +hash -r + +# Authority-sensitive Git calls must discover the repository from the explicit +# receipt base, and the validator must not consume caller startup, preload, +# Node, package-manager, or tool-search overrides. +while IFS= read -r factory_environment_name; do + case "$factory_environment_name" in + BASH_ENV|ENV|CDPATH|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|RIPGREP_CONFIG_PATH|GIT_*|NODE_*|TSX_*|ESBUILD_BINARY_PATH|NPM_CONFIG_*|npm_config_*|PNPM_*|COREPACK_*) + unset "$factory_environment_name" + ;; + esac +done < <(compgen -e) +unset factory_environment_name +GIT_CONFIG_NOSYSTEM=1 +GIT_CONFIG_GLOBAL=/dev/null +GIT_ATTR_NOSYSTEM=1 +export GIT_CONFIG_NOSYSTEM GIT_CONFIG_GLOBAL GIT_ATTR_NOSYSTEM + +factory_require_node_binary() { + local node_input="$1" + local node_path + case "$node_input" in + /*) ;; + *) + echo "Node binary path must be absolute" >&2 + return 1 + ;; + esac + if ! node_path="$(/usr/bin/realpath -e -- "$node_input")"; then + echo "Node binary path does not resolve: $node_input" >&2 + return 1 + fi + if [ "$node_path" != "$node_input" ]; then + echo "Node binary path must already be canonical: $node_input" >&2 + return 1 + fi + if [ -L "$node_path" ] || [ ! -f "$node_path" ] || [ ! -x "$node_path" ]; then + echo "Node binary must be a regular executable file: $node_path" >&2 + return 1 + fi + local node_owner + node_owner="$(/usr/bin/stat -c '%u' -- "$node_path")" + if [ "$node_owner" -ne 0 ]; then + echo "Node binary must be root-owned: $node_path" >&2 + return 1 + fi + local node_permissions + node_permissions="$(/usr/bin/stat -c '%a' -- "$node_path")" + if (( (8#$node_permissions & 0022) != 0 )); then + echo "Node binary must not be group- or world-writable: $node_path" >&2 + return 1 + fi + local node_parent + node_parent="$(/usr/bin/dirname -- "$node_path")" + while true; do + local node_parent_owner + local node_parent_permissions + node_parent_owner="$(/usr/bin/stat -c '%u' -- "$node_parent")" + node_parent_permissions="$(/usr/bin/stat -c '%a' -- "$node_parent")" + if + [ "$node_parent_owner" -ne 0 ] || + (( (8#$node_parent_permissions & 0022) != 0 )) + then + echo "Node binary ancestors must be root-owned without group/world write: $node_parent" >&2 + return 1 + fi + [ "$node_parent" = "/" ] && break + node_parent="$(/usr/bin/dirname -- "$node_parent")" + done + local node_identity + local node_sha256 + node_identity="$(/usr/bin/stat -c '%d:%i' -- "$node_path")" + node_sha256="$(/usr/bin/sha256sum -- "$node_path")" + node_sha256="${node_sha256%% *}" + local node_probe + if ! node_probe="$( + "$node_path" --input-type=module - "$node_path" 2>/dev/null <<'NODE' +import { realpathSync } from "node:fs"; +const expectedPath = process.argv[2]; +if ( + process.release?.name !== "node" || + realpathSync(process.execPath) !== expectedPath +) { + process.exit(1); +} +process.stdout.write("agent-demo-video-node-ok"); +NODE + )" || [ "$node_probe" != "agent-demo-video-node-ok" ]; then + echo "selected executable did not prove it is Node: $node_path" >&2 + return 1 + fi + if + [ "$(/usr/bin/stat -c '%d:%i' -- "$node_path")" != "$node_identity" ] || + [ "$(/usr/bin/sha256sum -- "$node_path" | { read -r hash _; printf '%s' "$hash"; })" != "$node_sha256" ] + then + echo "root-owned Node binary changed during admission: $node_path" >&2 + return 1 + fi + printf '%s\n' "$node_path" +} + +if [ "$#" -ne 3 ] || [ "${1:-}" != "--node-bin" ]; then + echo "usage: $0 --node-bin " >&2 + exit 2 +fi + +factory_node_bin="$(factory_require_node_binary "$2")" +factory_receipt_path="$3" +if [ ! -s "$factory_receipt_path" ]; then + echo "production receipt is missing or empty: $factory_receipt_path" >&2 + exit 1 +fi + +if /usr/bin/rg --no-config -i -q -- \ + '(^|[^[:alnum:]])(PENDING|TBD|TODO|FIXME|FILL[ _-]?ME|REPLACE[ _-]?ME|UNKNOWN|N/?A)([^[:alnum:]]|$)' \ + "$factory_receipt_path"; then + echo "production receipt contains placeholder review evidence (for example PENDING or TBD)" >&2 + exit 1 +else + factory_placeholder_scan_status=$? + if [ "$factory_placeholder_scan_status" -ne 1 ]; then + echo "production receipt placeholder scan failed closed" >&2 + exit 1 + fi +fi + +"$factory_node_bin" --input-type=module - "$factory_receipt_path" <<'NODE' +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +const path = process.argv[2]; +const root = dirname(path); +const text = readFileSync(path, "utf8"); +const lines = text.split(/\r?\n/); + +if (/^\s*(?:```|~~~)/m.test(text)) { + throw new Error("production receipt may not hide authoritative fields in a fenced block"); +} +if (//s.test(text) || /^\s*<\/?[A-Za-z][^>]*>/m.test(text)) { + throw new Error("production receipt may not contain HTML blocks or comments"); +} + +function bullet(label) { + const prefix = `- ${label}:`; + const matches = lines.filter((line) => line.startsWith(prefix)); + if (matches.length !== 1) { + throw new Error(`production receipt must contain exactly one ${label} field`); + } + const value = matches[0].slice(prefix.length).trim(); + if (!value) throw new Error(`production receipt field is blank: ${label}`); + return value; +} + +function requirePattern(label, pattern, description) { + const value = bullet(label); + if (!pattern.test(value)) { + throw new Error(`production receipt ${label} must be ${description}`); + } + return value; +} + +requirePattern("Run ID", /^[A-Za-z0-9._-]+$/, "a filesystem-safe run ID"); +const sourceCommit = requirePattern( + "Source commit SHA", + /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i, + "a full Git commit SHA", +); +const attemptRoot = bullet("Attempt root"); +const reviewedRoot = bullet("Reviewed root"); +if (attemptRoot === reviewedRoot) { + throw new Error("production receipt attempt and reviewed roots must differ"); +} +const receiptBase = process.env.FACTORY_RECEIPT_BASE || process.cwd(); +const expectedAttemptRoot = process.env.FACTORY_EXPECTED_ATTEMPT_ROOT; +const expectedReviewedRoot = process.env.FACTORY_EXPECTED_REVIEWED_ROOT; +try { + execFileSync( + "/usr/bin/git", + [ + "--no-replace-objects", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-C", + receiptBase, + "cat-file", + "-e", + `${sourceCommit}^{commit}`, + ], + { stdio: "ignore", timeout: 15_000 }, + ); +} catch { + throw new Error("production receipt Source commit SHA does not resolve to a commit in the source repository"); +} +if (expectedAttemptRoot && resolve(receiptBase, attemptRoot) !== expectedAttemptRoot) { + throw new Error("production receipt Attempt root does not match the promoted source"); +} +if (expectedReviewedRoot && resolve(receiptBase, reviewedRoot) !== expectedReviewedRoot) { + throw new Error("production receipt Reviewed root does not match the promotion target"); +} +bullet("Capture inventory reference"); +const recordedClaimLedgerHash = requirePattern( + "Claim ledger SHA-256", + /^[0-9a-f]{64}$/i, + "a SHA-256", +); +requirePattern("Claim source refresh date", /^\d{4}-\d{2}-\d{2}$/, "an ISO date"); +bullet("Reviewer"); +const reviewStart = requirePattern("Review start UTC", /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "an ISO UTC timestamp"); +const reviewStop = requirePattern("Review stop UTC", /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "an ISO UTC timestamp"); +const reviewMinutes = Number(bullet("Total Dan review minutes")); +if (!Number.isFinite(reviewMinutes) || reviewMinutes <= 0 || reviewMinutes > 30) { + throw new Error("production receipt Total Dan review minutes must be greater than 0 and at most 30"); +} +const measuredReviewMinutes = (Date.parse(reviewStop) - Date.parse(reviewStart)) / 60_000; +if ( + !Number.isFinite(measuredReviewMinutes) || + measuredReviewMinutes <= 0 || + Math.abs(measuredReviewMinutes - reviewMinutes) > 1 +) { + throw new Error("production receipt review timestamps must be ordered and match Total Dan review minutes"); +} +if (bullet("Review decision and rationale").length < 10) { + throw new Error("production receipt Review decision and rationale is too short"); +} +if (bullet("Render handoff status") !== "REVIEWED_FOR_PUBLISH_HANDOFF") { + throw new Error("production receipt must contain exactly one reviewed handoff status"); +} +if (bullet("Dan publication approval") !== "NOT_REQUESTED") { + throw new Error("pre-upload production receipt must keep Dan publication approval at NOT_REQUESTED"); +} +if (bullet("Channel gate status") !== "NOT_EVALUATED") { + throw new Error("pre-upload production receipt must keep channel gate status at NOT_EVALUATED"); +} + +const tableCells = (line) => line.split("|").slice(1, -1).map((cell) => cell.trim()); +const artifactRows = lines + .filter((line) => /^\|\s*(?:master|cut-a|cut-b|cut-c)\s*\|/.test(line)) + .map(tableCells); +const artifactNames = ["master", "cut-a", "cut-b", "cut-c"]; +const sha256File = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); +let actualClaimLedgerHash; +try { + actualClaimLedgerHash = sha256File(join(root, "evidence", "source", "CLAIM_LEDGER.md")); +} catch (error) { + throw new Error(`production receipt archived claim ledger is missing: ${error.message}`); +} +if (recordedClaimLedgerHash.toLowerCase() !== actualClaimLedgerHash) { + throw new Error("production receipt Claim ledger SHA-256 does not match the archived claim ledger"); +} +if ( + artifactRows.length !== artifactNames.length || + artifactNames.some((name) => artifactRows.filter((row) => row[0] === name).length !== 1) +) { + throw new Error("production receipt must contain exactly one artifact row for master and each cut"); +} +for (const row of artifactRows) { + if (row.length !== 10 || row.some((cell) => !cell)) { + throw new Error(`production receipt artifact row is incomplete: ${row[0] ?? "unknown"}`); + } + if (!/^[0-9a-f]{64}$/i.test(row[1]) || !/^[0-9a-f]{64}$/i.test(row[2])) { + throw new Error(`production receipt artifact hashes are invalid: ${row[0]}`); + } + let actualFinalHash; + let actualReportHash; + let report; + let probe; + try { + const finalPath = join(root, row[0], "final.mp4"); + actualFinalHash = sha256File(finalPath); + probe = JSON.parse(execFileSync("/usr/bin/ffprobe", [ + "-v", "error", + "-show_entries", "stream=codec_type,width,height,sample_aspect_ratio:stream_side_data=rotation:format=duration", + "-of", "json", + finalPath, + ], { encoding: "utf8", timeout: 15_000 })); + const reportBytes = readFileSync(join(root, row[0], "render-report.json")); + actualReportHash = createHash("sha256").update(reportBytes).digest("hex"); + report = JSON.parse(reportBytes.toString("utf8")); + } catch (error) { + throw new Error(`production receipt artifact files or report are invalid: ${row[0]}: ${error.message}`); + } + if (row[1].toLowerCase() !== actualFinalHash || row[2].toLowerCase() !== actualReportHash) { + throw new Error(`production receipt artifact hashes do not match reviewed files: ${row[0]}`); + } + const expectedVoice = { + voiceId: "AwstCxsCY8YE2KYw66By", + modelId: "eleven_multilingual_v2", + seed: 42, + stability: 0.5, + similarity: 0.75, + }; + const expectedCap = row[0] === "master" ? 600 : 60; + const reportedDuration = Number.parseFloat(row[3]); + const expectedSize = row[0] === "master" + ? { width: 1920, height: 1080 } + : { width: 1080, height: 1920 }; + const videoStreams = (probe.streams ?? []).filter((stream) => stream.codec_type === "video"); + const audioStreams = (probe.streams ?? []).filter((stream) => stream.codec_type === "audio"); + const video = videoStreams[0]; + const probedDuration = Number.parseFloat(probe.format?.duration ?? ""); + const rotated = (video?.side_data_list ?? []).some( + (sideData) => Number(sideData.rotation ?? 0) !== 0, + ); + if ( + videoStreams.length !== 1 || + audioStreams.length < 1 || + video?.width !== expectedSize.width || + video?.height !== expectedSize.height || + video?.sample_aspect_ratio !== "1:1" || + rotated || + !Number.isFinite(probedDuration) || + report.ttsMode !== "real" || + Object.entries(expectedVoice).some(([key, value]) => report.voice?.[key] !== value) || + report.render?.parity?.ok !== true || + report.preflight?.ran !== true || + report.preflight?.declined !== false || + report.limits?.maxDurationSec !== expectedCap || + !Number.isFinite(report.render?.totalSec) || + !Number.isFinite(report.timeline?.totalSec) || + report.render.totalSec > expectedCap || + report.timeline.totalSec > expectedCap || + Math.abs(report.render.totalSec - report.timeline.totalSec) > 0.1 || + Math.abs(report.render.totalSec - reportedDuration) > 0.1 || + Math.abs(probedDuration - reportedDuration) > 0.1 + ) { + throw new Error(`production receipt render report did not prove real pinned gated output: ${row[0]}`); + } + if (!/^\d+(?:\.\d+)?s$/.test(row[3])) { + throw new Error(`production receipt artifact duration is invalid: ${row[0]}`); + } + const expectedGeometry = row[0] === "master" + ? "1920x1080, SAR 1:1" + : "1080x1920, SAR 1:1"; + if (row[4] !== expectedGeometry) { + throw new Error(`production receipt artifact geometry or SAR is invalid: ${row[0]}`); + } + if (row[5] !== "PASS" || row[6] !== "REAL" || row[7] !== "PINNED") { + throw new Error(`production receipt artifact media or TTS evidence is invalid: ${row[0]}`); + } + if (row[8] !== "PASS" || row[9] !== "ENFORCED") { + throw new Error(`production receipt artifact parity or preflight evidence is invalid: ${row[0]}`); + } +} +if (bullet("Input and output hash manifest") !== "`PRODUCTION_RECEIPT.sha256`") { + throw new Error("production receipt must name the production hash manifest"); +} +bullet("Toolchain versions"); +const recordedChaptersHash = requirePattern( + "YouTube chapters SHA-256", + /^[0-9a-f]{64}$/i, + "a SHA-256", +); +let actualChaptersHash; +try { + actualChaptersHash = sha256File(join(root, "YOUTUBE_CHAPTERS.txt")); +} catch (error) { + throw new Error(`production receipt chapter file is missing: ${error.message}`); +} +if (recordedChaptersHash.toLowerCase() !== actualChaptersHash) { + throw new Error("production receipt chapter hash does not match YOUTUBE_CHAPTERS.txt"); +} + +for (let index = 1; index <= 8; index++) { + const matches = lines.filter((line) => line.startsWith(`- R${index} `)); + if (matches.length !== 1 || !/:\s*\S/.test(matches[0])) { + throw new Error(`production receipt run-dependent evidence is blank or duplicated: R${index}`); + } +} + +const playbackRows = [ + "master full mix, start UTC and stop UTC", + "01-cold-open", + "02-roadmap", + "03-setup", + "04-install-it-right", + "05-first-real-task", + "06-where-it-runs", + "07-anywhere-on-a-schedule", + "08-recap", + "09-next", + "cut-a full mix and hook, start UTC and stop UTC", + "cut-b full mix and hook, start UTC and stop UTC", + "cut-c full mix and hook, start UTC and stop UTC", +]; +for (const name of playbackRows) { + const matches = lines + .filter((line) => line.startsWith(`| ${name} |`)) + .map(tableCells); + if ( + matches.length !== 1 || + matches[0].length !== 3 || + !matches[0][1] || + matches[0][2] !== "PASS" + ) { + throw new Error(`production receipt playback evidence is incomplete: ${name}`); + } +} + +const checks = [ + "Review economics", + "Claims", + "Real captures", + "Rights", + "Dash-clean packaging", + "Title and thumbnail", + "Captions", + "Audio", + "Visual integrity", + "Disclosure", + "Vertical cuts", + "Provenance", +]; +const sectionMarkers = checks.map((name, index) => `### ${index + 1}. ${name}`); +const allNumberedHeadings = text.match(/^### \d+\. .*$/gm) ?? []; +if (allNumberedHeadings.length !== sectionMarkers.length) { + throw new Error("production receipt must contain exactly the twelve named checklist sections"); +} + +let previousStart = -1; +for (let index = 0; index < sectionMarkers.length; index++) { + const marker = sectionMarkers[index]; + if (text.split(marker).length - 1 !== 1) { + throw new Error(`production receipt checklist section is missing or duplicated: ${marker}`); + } + const start = text.indexOf(marker); + if (start <= previousStart) { + throw new Error(`production receipt checklist section is out of order: ${marker}`); + } + const nextMarker = sectionMarkers[index + 1]; + const end = nextMarker ? text.indexOf(nextMarker) : text.length; + const section = text.slice(start, end); + const dispositions = section.match(/^- Disposition:.*$/gm) ?? []; + if (dispositions.length !== 1 || dispositions[0] !== "- Disposition: PASS") { + throw new Error(`production receipt checklist section did not pass: ${marker}`); + } + const evidence = section.match(/^- Operator evidence:\s*(.+)$/gm) ?? []; + if ( + evidence.length !== 1 || + evidence[0].slice("- Operator evidence:".length).trim().length < 3 + ) { + throw new Error(`production receipt checklist evidence is blank: ${marker}`); + } + if (index === 9) { + const planned = section.match(/^- Planned platform AI-content answers and rationale:\s*(.+)$/gm) ?? []; + if ( + planned.length !== 1 || + planned[0].slice("- Planned platform AI-content answers and rationale:".length).trim().length < 3 + ) { + throw new Error("production receipt planned platform AI-content evidence is blank"); + } + } + previousStart = start; +} +NODE + +factory_repo_root="$(cd "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +factory_receipt_file="$(realpath -e -- "$factory_receipt_path")" +factory_receipt_root="$(realpath -e -- "$(dirname -- "$factory_receipt_path")")" +factory_tsx_cli="$factory_repo_root/node_modules/tsx/dist/cli.mjs" +test -f "$factory_tsx_cli" +test ! -L "$factory_tsx_cli" +( + cd "$factory_repo_root" + "$factory_node_bin" \ + "$factory_tsx_cli" \ + --tsconfig "$factory_repo_root/tsconfig.json" \ + "$factory_repo_root/scripts/validate-factory-ai-at-work-inputs.ts" \ + "$factory_receipt_root" \ + "$factory_receipt_file" +) diff --git a/specs/factory-ai-at-work-gate-01-production-pack-spec.md b/specs/factory-ai-at-work-gate-01-production-pack-spec.md new file mode 100644 index 0000000..a061958 --- /dev/null +++ b/specs/factory-ai-at-work-gate-01-production-pack-spec.md @@ -0,0 +1,227 @@ +# Factory AI at Work Gate 1 Production Pack + +Status: active + +## Intent + +The first `Dan Mercede | AI at Work` gate episode has an approved production +script but no versioned input contract for the video pipeline. The episode must +be reproducible as one landscape master and three portrait cuts without +committing credentials, authenticated browser state, captured footage, rendered +media, or publish state. + +The pack turns the approved script into executable pipeline inputs while staying +fail-closed: every screen is a declared operator-supplied capture, and a missing +capture stops before narration spend. + +## Vocabulary + +- **master**: the landscape YouTube episode. +- **cut**: one self-contained portrait video derived from a candidate selected + in the approved episode script. +- **capture contract**: the required clip filename, geometry, and evidence job + that the operator must satisfy with a real recording. +- **production pack**: the four configs, four manifests, capture contract, + publishing copy, versioned production receipt, and operator runbook that + reproduce Gate 1. + +## Scenarios + +### S1 - Artifact-first master with an end card + +Given the Gate 1 master config, when the pipeline builds its segment plan, then +the first frame is the finished Cowork artifact rather than a generated title +card, and the video closes on a 15-second generated disclosure card. Existing +configs that use the single `brand.cards` switch continue to get both cards. + +### S2 - Real-capture-only episode + +Given any Gate 1 manifest, when preflight runs before narration, then every +visual segment resolves to a declared prebaked clip. Missing clips are blocking +findings, no authenticated profile is required, and no mock or generated +product output can silently replace a real capture. + +### S3 - Three distribution-ready cuts + +Given the three selected cut configs, when each is loaded, then it resolves to a +1080 by 1920 canvas with a 1920 by 1080 capture viewport and a hard 60 second +ceiling. Each cut is self-contained, has at least 20 seconds of estimated +narration, leaves real-voice timing headroom below the ceiling, and carries its +own hook and payoff. + +### S4 - Pinned channel identity and disclosure + +Given any Gate 1 config, when narration and styling are resolved, then Dan's +approved cloned voice, model, seed, stability, similarity, copper accent, and +channel palette are explicit rather than inherited from mutable defaults. +Publishing copy and the master end card carry the approved AI-production +disclosure. + +### S5 - Reproducible operator handoff + +Given a new operator with the repository and the real captures, when they follow +the runbook, then they can place clips, run preflight, rehearse without TTS +spend, render with real narration, validate receipts and duration, complete the +versioned twelve-point checklist with end-to-end playback evidence, and roll +back by restoring the previous inputs and outputs. Publishing remains a +separate human action. + +### S6 - Immutable production attempts + +Given an operator supplies a new output directory for a rehearsal or +production attempt, when the CLI starts the pipeline, then it claims that +name atomically before reading inputs, binds later writes to a new private +directory handle on a Linux filesystem under Linux or WSL, retains the +authenticated claim as an output marker, publishes with a no-clobber rename, +and refuses an existing path without changing its artifacts. This protects +against accidental or cooperating path reuse; hostile same-UID processes are +outside the isolation boundary. Production renders +consume attempt-owned copies of every source clip. A reviewed four-output pack +is promoted only after every output and receipt pass validation. + +## Constraints + +- No credentials, browser profiles, source footage, rendered media, or publish + state are committed. +- All product footage is a real operator capture. The pipeline may generate + framing, captions, sound design, and the disclosure end card. +- Captures may be trimmed, reframed, labeled, and redacted for privacy. + Product states, task results, and timestamps are never fabricated. +- Finished capture compositions use square sample pixels. Non-square SAR is + rejected because the render filters operate on coded geometry. +- No new dependency or service. +- Existing demo configs retain their current card behavior. +- The approved Gate 1 factual claims and run-dependent receipt obligations are + not broadened by this pack. +- Fresh-output and pack-promotion race checks assume cooperating processes. + They must never delete or recursively mutate an unauthenticated replacement, + but they do not claim isolation from a malicious process with the same Unix + user identity. +- Repository source attestation covers committed application and launcher + bytes plus the package and lock manifests. Installed dependency bytes, + package-manager code, Node, ffmpeg, and other system tools remain separately + reported toolchain inputs rather than repository source. + +## Acceptance Criteria + +- AC1: the brand config can independently disable the opening card and enable + the closing card; the legacy `cards` value remains the default for both. +- AC2: the master config selects landscape output, a 600 second hard cap, no + opening card, and a 15-second closing disclosure card. Its fake-TTS estimate, + including that card, is at most 75 percent of the cap. +- AC3: exactly three cut configs select the shorts preset and a 60 second hard + cap; their estimated narration windows are between 20 and 45 seconds. +- AC4: all four configs explicitly pin voice ID + `AwstCxsCY8YE2KYw66By`, model `eleven_multilingual_v2`, seed 42, + stability 0.5, and similarity 0.75. +- AC5: all manifest shots are prebaked, name unique clip inputs, parse through + the production parser, and are left to preflight before TTS. Missing clips + and full-bleed clips whose aspect differs from their output canvas or whose + sample aspect ratio is non-square are blocking findings. Gate 1 configs may + not consume an external music path that is absent from archived evidence. +- AC6: the production pack contains no banned Unicode dash characters and the + approved disclosure is present in publishing copy. +- AC7: the operator runbook and receipt template cover rollout, monitoring, + validation, rollback, all twelve channel checks, full final-mix playback, + cue-by-cue voice review, final-render-derived chapter timing with every + chapter spanning at least 10 seconds, and the separation between render + completion and public publishing. The pack stops at reviewed local evidence: + it does not model served state, the three-video streak, or promotion + authority. Those remain in the ratified channel format and operator state. +- AC8: `--out` overrides the config output only for that run, requires a fresh + name, refuses an existing target before changing any artifact, keeps all + render writes in a private handle-bound directory while that name is claimed, + leaves pathnames untouched on claim initialization failure, retains the + authenticated claim as an output marker, and publishes without clobbering a + competing target under the cooperative-process boundary. `--clips-dir` and + `--script` require absolute paths and select attempt-owned clip and narration + sources for that run. + Before narration spend, each prebaked clip is copied into a private read-only + render binding; the renderer and report consume only those bound bytes, so an + ordinary later export to the operator source path cannot change the artifact + or its digest. The binding root is removed on success, ordinary failure, + SIGINT, and SIGTERM. A cleanup failure preserves the primary pipeline error, + blocks fresh-output publication, and reports both the retained binding root + and the unpublished output stage when present. Dashboard-only renders create + no private binding root. The CLI rejects unknown or duplicate options and + extra config paths rather than silently falling back to reusable output. +- AC9: reviewed-pack promotion requires the exact archived Gate 1 support, + config, script, clip, and renderer-output inventory; recomputes full config, + script, claim-ledger, and ordered per-shot clip SHA-256 values; validates the + exact relative concat selection, shot and card timelines against the same + measured archived segment bytes, and derived chapters whose adjacent starts + (or final start and master total) are at least 10 seconds apart; rejects + placeholder review evidence; refuses cross-device or nested-mount mutation before + sealing; and closes every regular file under one locale-independent read-only + manifest. Promotion and receipt validation start in privileged Bash, fix + system-tool lookup, and scrub caller shell, preload, Git, Node, and + package-manager injection variables. The operator entrypoint clears + dynamic-loader variables before Bash starts, admits only the fixed path and + locale in the environment, and passes one already-canonical root-owned Node + binary explicitly to promotion and both receipt-validation phases. Each + entrypoint requires root-owned non-writable ancestry and binds the canonical + file identity and digest across its functional Node probe before accepting + its exit status. If + promotion stops while the + authenticated private pack is still writable, it is restored without + clobbering the attempt path. Once sealing starts, failure retains and reports + the exact authenticated private or reviewed path. Once final verification + commits the reviewed state, a failed status write cannot reverse success or + block the independent verifier. Every production report also carries the same + local source-build attestation for the receipt's commit, scoped Git tree, + package manifest, and dependency lock. The attested mode is valid only when a + launcher streamed from that fixed commit creates a private detached + no-checkout worktree with hooks disabled, materializes committed objects + without checkout filters, directly verifies and freezes the scoped bytes + before application modules load, and runs the snapshot CLI. The streamed + shell starts in privileged mode before reading caller startup files, fixes + system-tool lookup independently of caller `PATH`, and invokes a canonical + root-owned Node file plus the explicit operator-selected package-manager file. + Authority-sensitive Git + calls ignore caller global and system configuration and disable + repository-local hooks and filesystem monitors. Frozen dependency + installation runs in a minimal environment that excludes render secrets, + points user, global, and XDG package-manager configuration at private empty + locations, disables lifecycle scripts and pnpm hook files, and uses an + snapshot-private package store. Application execution changes to the detached + snapshot and pins its `tsconfig.json`; only that execution receives the + render secret. The admitted session is a module-local capability that cannot + be structurally copied into another pipeline graph, and the pipeline verifies + its own module path is in that snapshot before claiming output. Ordinary + mutable-checkout execution cannot emit an accepted attestation; source drift + after admission blocks the render receipt. An attested run also fails if its + required report cannot be built or written, or if an otherwise successful + run cannot remove its detached snapshot. +## Test Seams + +The primary seam is the existing config and manifest interface: +`loadConfig`, `parseScript`, `captureViewport`, and the fake-TTS duration +estimator. One contract test crosses that seam for all four production inputs. + +The independent-card behavior is exercised at the config schema seam. The +pipeline consumes the resolved booleans and does not expose a second card-policy +interface. + +Fresh attempt ownership is exercised at the CLI parser and pipeline entry +seams, before script parsing, preflight, narration, or rendering. + +Receipt promotion is exercised at the shell validator seam with valid, +input-bound, incomplete, failed-item, missing-item, source-mismatch, and +same-geometry clip-substitution, claim-ledger substitution, external-input, +off-root concat-selection, placeholder, hostile startup-environment, +interruption-recovery, cross-filesystem, topology, and post-promotion mutation +cases. Source-build tests exercise +committed-launcher enforcement, private detached-snapshot admission, direct +Git-object byte and mode verification, dirty and untracked source rejection, +exact runner binding, caller shell-startup, function, `PATH`, Git-environment, +global Git-config execution, working-directory, and `tsconfig` rejection, +caller pnpm-config isolation, render-secret exclusion during dependency +installation, structural session forgery rejection, commit recomputation, and +post-render drift. + +## Verification + +- `pnpm test` +- `pnpm typecheck` +- `pnpm build` +- `FAKE_TTS=1 pnpm demo ` after all declared real captures are present diff --git a/specs/shorts-platform-profile-spec.md b/specs/shorts-platform-profile-spec.md index 1261992..ab3cf4d 100644 --- a/specs/shorts-platform-profile-spec.md +++ b/specs/shorts-platform-profile-spec.md @@ -43,9 +43,12 @@ the rest, captions and brand cards lay out on the canvas. does not match the declared content size is rejected loudly before compositing, naming the shot and the `fullBleed: true` escape hatch; padding bars inside the window is never shipped silently. A `fullBleed` shot bypasses - framing and composites directly onto the canvas, so finished portrait - compositions remain first-class in shorts renders. Landscape renders (content - absent or canvas-aspect) keep today's pad-inside-window behavior unchanged. + framing and composites directly onto the canvas, so its source must match the + canvas aspect within encoder-rounding slack; preflight and render both reject + a mismatch rather than padding it. Inputs inspected at this seam must use + square sample pixels and normalization pins the same first video stream that + geometry probing validates. Landscape framed renders (content absent or + canvas-aspect) keep today's pad-inside-window behavior unchanged. 5. **Remote-render parity.** A manifest built from a shorts config renders the same video remotely as locally: canvas and content size both travel. A manifest from an older build, which carries no content size, renders exactly as it does today. @@ -79,6 +82,10 @@ the rest, captions and brand cards lay out on the canvas. geometry mismatches the content size rejects with an error naming the shot and `fullBleed`; matching geometry (within encoder even-rounding slack) passes; canvas-aspect and content-absent configs never reject. +- AC7: an existing `fullBleed` clip whose display aspect differs from the output + canvas is a blocking preflight finding before TTS and is independently + rejected by the renderer before normalization; matching square-pixel aspects + pass, while non-square sample aspect ratios fail closed at both seams. ## Test seams @@ -88,3 +95,4 @@ the rest, captions and brand cards lay out on the canvas. - Manifest build/load round-trip for scenario 5. - The render stage invoked directly on prepared segment files (no capture) for scenario 4. +- The full preflight-to-pipeline seam for full-bleed source geometry. diff --git a/src/cli.test.ts b/src/cli.test.ts index 40182ff..6e4dc10 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseCommand } from "./cli"; +import { main, parseCommand } from "./cli"; describe("parseCommand (CLI dispatch)", () => { it("routes `login ` to the login subcommand", () => { @@ -14,8 +14,131 @@ describe("parseCommand (CLI dispatch)", () => { it("accepts --no-preflight as a per-run decline of the selector gate", () => { expect(parseCommand(["my.json", "--no-preflight"])).toEqual({ cmd: "run", cfgPath: "my.json", preflight: false }); }); + it("accepts both fresh --out forms without swallowing the config path", () => { + expect(parseCommand(["my.json", "--out", "out/attempt-1"])).toEqual({ + cmd: "run", + cfgPath: "my.json", + out: "out/attempt-1", + }); + expect(parseCommand(["--out=out/attempt-2", "my.json"])).toEqual({ + cmd: "run", + cfgPath: "my.json", + out: "out/attempt-2", + }); + }); + it("rejects a missing or option-like --out value", () => { + expect(() => parseCommand(["my.json", "--out"])).toThrow(/--out/); + expect(() => parseCommand(["my.json", "--out", ""])).toThrow(/--out/); + expect(() => parseCommand(["my.json", "--out="])).toThrow(/--out/); + expect(() => parseCommand(["my.json", "--out", "--no-preflight"])).toThrow(/--out/); + }); + it("accepts both per-run --clips-dir forms without swallowing the config path", () => { + expect(parseCommand(["my.json", "--clips-dir", "/attempt/evidence/clips"])).toEqual({ + cmd: "run", + cfgPath: "my.json", + clipsDir: "/attempt/evidence/clips", + }); + expect(parseCommand(["--clips-dir=/attempt/evidence/clips", "my.json"])).toEqual({ + cmd: "run", + cfgPath: "my.json", + clipsDir: "/attempt/evidence/clips", + }); + }); + it("rejects invalid --clips-dir values and login use", () => { + expect(() => parseCommand(["my.json", "--clips-dir"])).toThrow(/--clips-dir/); + expect(() => parseCommand(["my.json", "--clips-dir="])).toThrow(/--clips-dir/); + expect(() => parseCommand(["my.json", "--clips-dir", "--out"])).toThrow(/--clips-dir/); + expect(() => parseCommand(["my.json", "--clips-dir", "evidence/clips"])).toThrow( + /absolute/, + ); + expect(() => parseCommand(["login", "my.json", "--clips-dir", "/tmp/clips"])).toThrow(/pipeline run/); + }); + it("accepts both per-run --script forms and rejects invalid values", () => { + expect(parseCommand(["my.json", "--script", "/attempt/evidence/DEMO_SCRIPT.md"])).toEqual({ + cmd: "run", + cfgPath: "my.json", + script: "/attempt/evidence/DEMO_SCRIPT.md", + }); + expect(parseCommand(["--script=/attempt/evidence/DEMO_SCRIPT.md", "my.json"])).toEqual({ + cmd: "run", + cfgPath: "my.json", + script: "/attempt/evidence/DEMO_SCRIPT.md", + }); + expect(() => parseCommand(["my.json", "--script"])).toThrow(/--script/); + expect(() => parseCommand(["my.json", "--script="])).toThrow(/--script/); + expect(() => parseCommand(["my.json", "--script=DEMO_SCRIPT.md"])).toThrow( + /absolute/, + ); + expect(() => parseCommand(["login", "my.json", "--script", "/tmp/script.md"])).toThrow(/pipeline run/); + }); it("defaults to demo.config.json when no path is given", () => { expect(parseCommand([])).toEqual({ cmd: "run", cfgPath: "demo.config.json" }); expect(parseCommand(["login"])).toEqual({ cmd: "login", cfgPath: "demo.config.json" }); }); + it("rejects unknown options, surplus positionals, and duplicate singleton flags", () => { + expect(() => + parseCommand(["my.json", "--outt", "out/attempt"]), + ).toThrow(/unknown option.*--outt/); + expect(() => + parseCommand(["my.json", "other.json"]), + ).toThrow(/exactly one config path/); + expect(() => + parseCommand(["login", "my.json", "other.json"]), + ).toThrow(/at most one config path/); + expect(() => + parseCommand(["login", "my.json", "--no-preflight"]), + ).toThrow(/--no-preflight.*pipeline run/); + expect(() => + parseCommand(["my.json", "--out", "one", "--out=two"]), + ).toThrow(/--out.*once/); + expect(() => + parseCommand(["my.json", "--no-preflight", "--no-preflight"]), + ).toThrow(/--no-preflight.*once/); + expect(() => + parseCommand(["my.json", "--attest-source-build", "--attest-source-build"]), + ).toThrow(/--attest-source-build.*once/); + }); + it("rejects empty or whitespace-only render hosts", () => { + expect(() => parseCommand(["my.json", "--render-host", ""])).toThrow( + /--render-host/, + ); + expect(() => parseCommand(["my.json", "--render-host", " "])).toThrow( + /--render-host/, + ); + expect(() => parseCommand(["my.json", "--render-host= "])).toThrow( + /--render-host/, + ); + }); + it("accepts --attest-source-build only for a local pipeline run", () => { + expect(parseCommand(["my.json", "--attest-source-build"])).toEqual({ + cmd: "run", + cfgPath: "my.json", + attestSourceBuild: true, + }); + expect(() => parseCommand(["login", "my.json", "--attest-source-build"])).toThrow( + /--attest-source-build.*login/, + ); + expect(() => + parseCommand(["my.json", "--attest-source-build", "--render-host", "render.example"]), + ).toThrow(/--attest-source-build.*--render-host/); + }); + it("refuses attested mode before loading application modules without the committed launcher", async () => { + const names = [ + "AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_ROOT", + "AGENT_DEMO_VIDEO_SOURCE_AUTHORITY_REPO", + "AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_COMMIT", + ] as const; + const previous = Object.fromEntries(names.map((name) => [name, process.env[name]])); + for (const name of names) delete process.env[name]; + try { + await expect( + main(["/does/not/exist.json", "--attest-source-build"]), + ).rejects.toThrow(/committed source snapshot launcher/); + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + }); }); diff --git a/src/cli.ts b/src/cli.ts index 002af01..7c6c57b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,7 @@ #!/usr/bin/env node import { realpathSync } from "node:fs"; +import { isAbsolute } from "node:path"; import { fileURLToPath } from "node:url"; -import { loadConfig } from "./config"; -import { runPipeline } from "./pipeline"; -import { captureLogin } from "./capture"; -import { SshTransport } from "./transport"; /** * Parse the CLI argv (after the node/script args) into a command. @@ -13,55 +10,221 @@ import { SshTransport } from "./transport"; * stage to host H over ssh (local render stays default). * - `--no-preflight` → decline the fail-closed selector gate for this run * (the config key `preflight` is the durable declaration). + * - `--out ` → override config.out, atomically reserve a + * fresh name, and bind writes to private staging. + * - `--clips-dir ` → override clipsDir for this run so production + * can render attempt-owned evidence copies. + * - `--script ` → override the narration manifest for this run + * so production can render an owned script copy. + * - `--attest-source-build` → reserved for the committed detached-snapshot + * launcher; bind and recheck that frozen source. */ export function parseCommand(argv: string[]): { cmd: "login" | "run"; cfgPath: string; renderHost?: string; preflight?: boolean; + out?: string; + clipsDir?: string; + script?: string; + attestSourceBuild?: boolean; } { let renderHost: string | undefined; let preflight: boolean | undefined; + let out: string | undefined; + let clipsDir: string | undefined; + let script: string | undefined; + let attestSource = false; const positional: string[] = []; + const seenFlags = new Set(); + const requireOnce = (flag: string) => { + if (seenFlags.has(flag)) { + throw new Error(`${flag} may be supplied once`); + } + seenFlags.add(flag); + }; + const requireValue = ( + flag: string, + noun: string, + value: string | undefined, + ): string => { + requireOnce(flag); + if (!value || value.trim().length === 0 || value.startsWith("-")) { + throw new Error( + `${flag} requires a non-empty ${noun} argument that does not start with '-'`, + ); + } + return value; + }; + const requireAbsoluteValue = ( + flag: string, + noun: string, + value: string | undefined, + ): string => { + const parsed = requireValue(flag, noun, value); + if (!isAbsolute(parsed)) { + throw new Error(`${flag} requires an absolute ${noun} argument`); + } + return parsed; + }; for (let i = 0; i < argv.length; i++) { const a = argv[i]!; // Consumed as a flag, never pushed to positional: otherwise a leading // --no-preflight would be read as the config path. if (a === "--no-preflight") { + requireOnce("--no-preflight"); preflight = false; continue; } + if (a === "--attest-source-build") { + requireOnce("--attest-source-build"); + attestSource = true; + continue; + } if (a === "--render-host") { - const next = argv[i + 1]; - if (next === undefined || next.startsWith("-")) throw new Error("--render-host requires a host argument"); - renderHost = argv[++i]; + renderHost = requireValue("--render-host", "host", argv[i + 1]); + i++; continue; } if (a.startsWith("--render-host=")) { - renderHost = a.slice("--render-host=".length); - if (!renderHost || renderHost.startsWith("-")) - throw new Error("--render-host requires a non-empty host argument that does not start with '-'"); + renderHost = requireValue( + "--render-host", + "host", + a.slice("--render-host=".length), + ); + continue; + } + if (a === "--out") { + out = requireValue("--out", "new directory", argv[i + 1]); + i++; + continue; + } + if (a.startsWith("--out=")) { + out = requireValue("--out", "new directory", a.slice("--out=".length)); continue; } + if (a === "--clips-dir") { + clipsDir = requireAbsoluteValue("--clips-dir", "directory", argv[i + 1]); + i++; + continue; + } + if (a.startsWith("--clips-dir=")) { + clipsDir = requireAbsoluteValue( + "--clips-dir", + "directory", + a.slice("--clips-dir=".length), + ); + continue; + } + if (a === "--script") { + script = requireAbsoluteValue("--script", "file", argv[i + 1]); + i++; + continue; + } + if (a.startsWith("--script=")) { + script = requireAbsoluteValue( + "--script", + "file", + a.slice("--script=".length), + ); + continue; + } + if (a.startsWith("-")) { + throw new Error(`unknown option: ${a}`); + } positional.push(a); } - if (positional[0] === "login") - return { cmd: "login", cfgPath: positional[1] ?? "demo.config.json", renderHost, ...(preflight === false ? { preflight } : {}) }; - return { cmd: "run", cfgPath: positional[0] ?? "demo.config.json", renderHost, ...(preflight === false ? { preflight } : {}) }; + if (positional[0] === "login") { + if (positional.length > 2) { + throw new Error("login accepts at most one config path"); + } + if (attestSource) + throw new Error("--attest-source-build is only valid for a pipeline run, not login"); + if (renderHost) + throw new Error("--render-host is only valid for a pipeline run, not login"); + if (preflight === false) + throw new Error("--no-preflight is only valid for a pipeline run, not login"); + if (out || clipsDir || script) + throw new Error("--out, --clips-dir, and --script are only valid for a pipeline run, not login"); + return { + cmd: "login", + cfgPath: positional[1] ?? "demo.config.json", + }; + } + if (positional.length > 1) { + throw new Error("pipeline run accepts exactly one config path"); + } + if (attestSource && renderHost) + throw new Error("--attest-source-build cannot be combined with --render-host"); + return { + cmd: "run", + cfgPath: positional[0] ?? "demo.config.json", + renderHost, + ...(preflight === false ? { preflight } : {}), + ...(out ? { out } : {}), + ...(clipsDir ? { clipsDir } : {}), + ...(script ? { script } : {}), + ...(attestSource ? { attestSourceBuild: true } : {}), + }; } export async function main(argv: string[]): Promise { - const { cmd, cfgPath, renderHost, preflight } = parseCommand(argv); + const { + cmd, + cfgPath, + renderHost, + preflight, + out, + clipsDir, + script, + attestSourceBuild: shouldAttestSourceBuild, + } = parseCommand(argv); + // A mutable checkout module could execute and restore its own file before + // an in-process admission check. Refuse the attested mode before importing + // any application module unless the committed launcher established a + // detached snapshot boundary. + if ( + shouldAttestSourceBuild && + ( + !process.env.AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_ROOT || + !process.env.AGENT_DEMO_VIDEO_SOURCE_AUTHORITY_REPO || + !process.env.AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_COMMIT + ) + ) { + throw new Error( + "--attest-source-build requires the committed source snapshot launcher", + ); + } + const sourceBuild = shouldAttestSourceBuild + ? await (await import("./source-build")).attestSourceBuild( + fileURLToPath(import.meta.url), + ) + : undefined; + const { loadConfig } = await import("./config"); const config = loadConfig(cfgPath); // A per-run decline lands ON the config, so the render receipt's config hash // records that this artifact shipped without the gate. if (preflight === false) config.preflight = false; + if (out) config.out = out; + if (clipsDir) config.clipsDir = clipsDir; + if (script) config.script = script; if (cmd === "login") { + const { captureLogin } = await import("./capture"); const dir = await captureLogin(config); console.log("✓ auth profile ready at", dir); return; } - const r = await runPipeline(config, renderHost ? { render: { transport: new SshTransport(renderHost) } } : {}); + // Source admission above runs before importing the pipeline, before a fresh + // output name can be claimed, and before any render artifact can be mutated. + const { runPipeline } = await import("./pipeline"); + const render = renderHost + ? { transport: new (await import("./transport")).SshTransport(renderHost) } + : undefined; + const r = await runPipeline(config, { + ...(render ? { render } : {}), + ...(out ? { requireFreshOut: true } : {}), + ...(sourceBuild ? { sourceBuild } : {}), + }); if (renderHost) console.log(" (render offloaded to " + renderHost + ")"); console.log("✓ wrote", r.outPath, "(" + r.report.totalSec.toFixed(1) + "s, " + r.report.segments + " segments)"); } diff --git a/src/config.test.ts b/src/config.test.ts index c1de4a2..8738e9f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -85,6 +85,56 @@ describe("loadConfig", () => { const cfg = loadConfig(path); expect(cfg.capture.auth).toBeUndefined(); }); + + it("lets an artifact-first video suppress only the opening brand card", () => { + const dir = mkdtempSync(join(tmpdir(), "cfg-")); + const path = join(dir, "demo.config.json"); + writeFileSync(path, JSON.stringify({ + script: "DEMO.md", + dashboardBaseUrl: "http://x", + brand: { + title: "Produced by AI, directed and reviewed by Dan Mercede", + cards: true, + titleCard: false, + endCard: true, + }, + })); + + const cfg = loadConfig(path); + + expect(cfg.brand?.titleCard).toBe(false); + expect(cfg.brand?.endCard).toBe(true); + }); + + it("keeps the legacy brand.cards switch as the default for both cards", () => { + const dir = mkdtempSync(join(tmpdir(), "cfg-")); + const enabledPath = join(dir, "enabled.config.json"); + const disabledPath = join(dir, "disabled.config.json"); + const base = { script: "DEMO.md", dashboardBaseUrl: "http://x" }; + writeFileSync(enabledPath, JSON.stringify({ ...base, brand: { title: "Demo" } })); + writeFileSync(disabledPath, JSON.stringify({ ...base, brand: { title: "Demo", cards: false } })); + + expect(loadConfig(enabledPath).brand).toMatchObject({ titleCard: true, endCard: true }); + expect(loadConfig(disabledPath).brand).toMatchObject({ titleCard: false, endCard: false }); + }); + + it("resolves each partial brand-card override independently", () => { + const dir = mkdtempSync(join(tmpdir(), "cfg-")); + const titleOffPath = join(dir, "title-off.config.json"); + const endOnPath = join(dir, "end-on.config.json"); + const base = { script: "DEMO.md", dashboardBaseUrl: "http://x" }; + writeFileSync(titleOffPath, JSON.stringify({ + ...base, + brand: { title: "Demo", cards: true, titleCard: false }, + })); + writeFileSync(endOnPath, JSON.stringify({ + ...base, + brand: { title: "Demo", cards: false, endCard: true }, + })); + + expect(loadConfig(titleOffPath).brand).toMatchObject({ titleCard: false, endCard: true }); + expect(loadConfig(endOnPath).brand).toMatchObject({ titleCard: false, endCard: true }); + }); }); describe("relative dashboardBaseUrl", () => { diff --git a/src/ffmpeg.test.ts b/src/ffmpeg.test.ts index 0568697..2a4e9ee 100644 --- a/src/ffmpeg.test.ts +++ b/src/ffmpeg.test.ts @@ -19,6 +19,7 @@ describe("ffmpeg arg builders", () => { expect(a.join(" ")).toContain("scale=1920:1080:force_original_aspect_ratio=decrease"); expect(a.join(" ")).toContain("pad=1920:1080"); expect(a.join(" ")).toContain("fps=30"); + expect(a.slice(a.indexOf("-map"), a.indexOf("-map") + 2)).toEqual(["-map", "0:v:0"]); expect(a).toContain("libx264"); expect(a[a.length - 1]).toBe("out.mp4"); }); diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index 0391850..9383afd 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -23,7 +23,10 @@ export function normalizeArgs( ): string[] { const fade = o.fadeInSec && o.fadeInSec > 0 ? `,fade=t=in:st=0:d=${o.fadeInSec}` : ""; const vf = `scale=${o.width}:${o.height}:force_original_aspect_ratio=decrease,pad=${o.width}:${o.height}:(ow-iw)/2:(oh-ih)/2,fps=${o.fps}${fade},format=yuv420p`; - return [...BASE, "-i", input, "-vf", vf, ...x264Args(X264.crfComposite), "-an", output]; + // probeSizePx validates v:0. Pin normalization to the same stream so a + // multi-video container cannot pass geometry on one stream while ffmpeg's + // automatic selection renders a larger, different-aspect stream. + return [...BASE, "-i", input, "-map", "0:v:0", "-vf", vf, ...x264Args(X264.crfComposite), "-an", output]; } export function concatArgs(listFile: string, output: string): string[] { @@ -128,14 +131,24 @@ export function run(bin: string, args: string[]): Promise { export const ffmpeg = (args: string[]) => run("ffmpeg", args); -/** First video stream's DISPLAY WxH, feeding the framed-aspect guard: coded - * size with any 90/270 display rotation applied (matrix side data or legacy - * rotate tag), matching what ffmpeg's autorotation feeds the filter graph. +/** First video stream's square-pixel DISPLAY WxH, feeding the framed-aspect + * guard: coded size with any 90/270 display rotation (matrix side data or + * legacy rotate tag), matching what ffmpeg's autorotation feeds the filter + * graph. Anamorphic input is rejected because the current normalize and frame + * filter chains operate on coded geometry; accepting display-equivalent SAR + * would let the guard approve a composition that later renders stretched or + * padded. * Phone footage is routinely landscape-coded portrait. Unparseable output * rejects (fail closed) rather than defaulting to a geometry. */ export async function probeSizePx(file: string): Promise<{ width: number; height: number }> { return new Promise((res, rej) => { - const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height:stream_tags=rotate:stream_side_data=rotation", "-of", "json", file]); + const p = spawn("ffprobe", [ + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,sample_aspect_ratio:stream_tags=rotate:stream_side_data=rotation", + "-of", "json", + file, + ]); let out = ""; p.stdout.on("data", (d) => (out += d)); // A missing or non-executable ffprobe emits 'error', never 'close': unlistened @@ -147,6 +160,16 @@ export async function probeSizePx(file: string): Promise<{ width: number; height if (c !== 0) throw new Error(`exited ${c}`); const st = JSON.parse(out).streams?.[0]; if (!st || typeof st.width !== "number" || typeof st.height !== "number") throw new Error("no video stream geometry"); + const sar = /^(\d+):(\d+)$/.exec(st.sample_aspect_ratio ?? ""); + if (!sar) throw new Error("missing or invalid sample aspect ratio"); + const sarNumerator = Number.parseInt(sar[1]!, 10); + const sarDenominator = Number.parseInt(sar[2]!, 10); + if (sarNumerator <= 0 || sarDenominator <= 0 || sarNumerator !== sarDenominator) { + throw new Error( + `non-square sample aspect ratio ${st.sample_aspect_ratio}; ` + + "finished compositions must use square pixels", + ); + } let rot = 0; for (const sd of st.side_data_list ?? []) if (typeof sd.rotation === "number") rot = sd.rotation; const tag = Number.parseInt(st.tags?.rotate ?? "", 10); diff --git a/src/framing.test.ts b/src/framing.test.ts index 597f619..914083b 100644 --- a/src/framing.test.ts +++ b/src/framing.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from "vitest"; -import { scaledSize, windowSize, maskGenArgs, shadowGenArgs, frameArgs, assertFramedContentAspect } from "./framing"; +import { + scaledSize, + windowSize, + maskGenArgs, + shadowGenArgs, + frameArgs, + assertFramedContentAspect, + assertFullBleedCanvasAspect, +} from "./framing"; const O = { width: 1920, @@ -54,6 +62,29 @@ describe("assertFramedContentAspect", () => { }); }); +describe("assertFullBleedCanvasAspect", () => { + it("rejects footage whose display aspect would be padded on the canvas", () => { + expect(() => assertFullBleedCanvasAspect( + { width: 1080, height: 1920 }, + { width: 1920, height: 1080 }, + "portrait-proof", + )).toThrow(/portrait-proof.*1920x1080.*1080x1920/s); + }); + + it("accepts same-aspect resizes and encoder rounding slack", () => { + expect(() => assertFullBleedCanvasAspect( + { width: 1080, height: 1920 }, + { width: 540, height: 960 }, + "a", + )).not.toThrow(); + expect(() => assertFullBleedCanvasAspect( + { width: 1920, height: 1080 }, + { width: 1918, height: 1080 }, + "b", + )).not.toThrow(); + }); +}); + describe("maskGenArgs / shadowGenArgs", () => { it("generates a one-frame rounded-rect alpha mask at the scaled size", () => { const args = maskGenArgs(O, "/tmp/mask.png").join(" "); diff --git a/src/framing.ts b/src/framing.ts index 23658b8..c59aedf 100644 --- a/src/framing.ts +++ b/src/framing.ts @@ -54,6 +54,17 @@ export function windowSize(o: FrameOpts): { width: number; height: number } { * a 1920x1080 viewport lands within 1px), not a real aspect difference. */ const MAX_WINDOW_PAD_PX = 2; +/** Padding normalize/frame would add when fitting one aspect into another. */ +function fitPaddingPx( + target: { width: number; height: number }, + probed: { width: number; height: number }, +): number { + const f = Math.min(target.width / probed.width, target.height / probed.height); + const padW = target.width - Math.round(probed.width * f); + const padH = target.height - Math.round(probed.height * f); + return Math.max(padW, padH); +} + /** * Fail-closed guard for framed segments when the window aspect is decoupled * from the canvas (a platform preset like shorts). frameArgs pads a @@ -75,10 +86,7 @@ export function assertFramedContentAspect( // viewport fits exactly), and rounding slack stays a pixel bound instead of // a ratio that widens with resolution. const s = windowSize(o); - const f = Math.min(s.width / probed.width, s.height / probed.height); - const padW = s.width - Math.round(probed.width * f); - const padH = s.height - Math.round(probed.height * f); - if (Math.max(padW, padH) <= MAX_WINDOW_PAD_PX) return; + if (fitPaddingPx(s, probed) <= MAX_WINDOW_PAD_PX) return; throw new Error( `[agent-demo-video] shot "${shotId}": framed clip is ${probed.width}x${probed.height} but the capture viewport is ` + `${c.width}x${c.height}; the aspect mismatch would be padded with bars inside the window. Mark the shot ` + @@ -87,6 +95,24 @@ export function assertFramedContentAspect( ); } +/** + * A full-bleed segment owns the entire output composition. Normalization still + * preserves aspect and pads, so reject any clip that would create visible bars + * instead of silently turning a wrong-aspect source into a valid-size file. + */ +export function assertFullBleedCanvasAspect( + canvas: { width: number; height: number }, + probed: { width: number; height: number }, + shotId: string, +): void { + if (fitPaddingPx(canvas, probed) <= MAX_WINDOW_PAD_PX) return; + throw new Error( + `[agent-demo-video] shot "${shotId}": fullBleed clip is ${probed.width}x${probed.height} but the output canvas is ` + + `${canvas.width}x${canvas.height}; normalization would pad the supposedly finished composition with visible bars. ` + + "Supply a fullBleed clip matching the output canvas aspect.", + ); +} + /** #rrggbb -> ffmpeg 0xRRGGBB. */ function hex(c: string): string { return "0x" + c.replace(/^#/, ""); diff --git a/src/git-environment.ts b/src/git-environment.ts new file mode 100644 index 0000000..d0a377c --- /dev/null +++ b/src/git-environment.ts @@ -0,0 +1,25 @@ +/** + * Return a copy of the process environment that cannot redirect Git to a + * caller-selected repository, worktree, index, object store, or config. + * + * Git has a broad and extensible GIT_* environment surface. An allowlist of + * known repository selectors would become unsafe when Git adds another one, + * so authority-sensitive calls remove the entire namespace, then restore only + * config-neutral values that disable system, global, and system-attribute + * inputs. Command arguments separately disable repository-local fsmonitor and + * hook execution. + */ +export function sanitizedGitEnvironment( + source: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const environment = { ...source }; + for (const name of Object.keys(environment)) { + if (name.startsWith("GIT_")) { + delete environment[name]; + } + } + environment.GIT_CONFIG_NOSYSTEM = "1"; + environment.GIT_CONFIG_GLOBAL = "/dev/null"; + environment.GIT_ATTR_NOSYSTEM = "1"; + return environment; +} diff --git a/src/pipeline.ts b/src/pipeline.ts index 9450457..b25d85e 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,24 +1,829 @@ -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { + constants, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { randomUUID } from "node:crypto"; +import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, writeFile, type FileHandle } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import type { DemoConfig, TtsResult } from "./types"; +import type { DemoConfig, Shot, TtsResult } from "./types"; import { parseScript, deriveSegmentKinds } from "./parse-script"; import { formatPreflightReport, runPreflight } from "./preflight"; import { synthShot } from "./tts"; import { captureShot } from "./capture"; +import { resolveClipPath } from "./clips"; import { titleCardArgs, endCardArgs } from "./cards"; -import { ffmpeg, silentMp3Args } from "./ffmpeg"; +import { ffmpeg, run as runProcess, silentMp3Args } from "./ffmpeg"; import { renderVideo, type RenderResult } from "./render"; import { renderRemote } from "./remote-render"; import type { Transport } from "./transport"; -import { buildRenderReport, digest, stableConfigJson, toolVersions, type PreflightRecord } from "./provenance"; +import { + buildRenderReport, + digest, + digestFile, + digestFull, + persistRenderReport, + stableConfigJson, + toolVersions, + type ClipInputDigest, + type PreflightRecord, +} from "./provenance"; import { resolveTtsMode } from "./tts"; import { captureViewport } from "./platforms"; +import { + assertSourceBuildExecutionContext, + assertSourceBuildUnchanged, + type SourceBuildSession, +} from "./source-build"; -export interface RunPipelineOpts { +interface RunPipelineOpts { /** Offload the render stage to a remote host over the given transport. Absent = local render (default). */ render?: { transport: Transport; bundlePath?: string; workDir?: string }; + /** Refuse an existing output path before reading inputs or mutating artifacts. */ + requireFreshOut?: boolean; + /** Source-run admission created by the CLI before any output claim. */ + sourceBuild?: SourceBuildSession; +} + +interface FreshOutputClaim { + requestedPath: string; + claimPath: string; + stagingPath: string; + recoveryPath: string; + boundPath: string; + parentHandle: FileHandle; + claimHandle: FileHandle; + stagingHandle: FileHandle; + claimDev: number; + claimIno: number; + stagingDev: number; + stagingIno: number; + publishAttempted: boolean; + published: boolean; +} + +type BoundClipInputDigest = ClipInputDigest & { + path: string; + dev: number; + ino: number; +}; + +interface BoundPrebakedInputs { + root?: string; + inputs: BoundClipInputDigest[]; + pathByShotId: Map; +} + +const PRIVATE_INPUT_ROOT_MARKER_NAME = ".agent-demo-video-private-input-root"; +const PRIVATE_INPUT_ROOT_MARKER_CONTENT = "agent-demo-video-private-input-root-v1\n"; + +export class PrivateInputCleanupError extends Error { + readonly code = "PRIVATE_INPUT_CLEANUP_FAILED"; + + constructor( + readonly retainedPath: string, + cause: unknown, + readonly retainedOutputPath?: string, + ) { + super( + `[agent-demo-video] PRIVATE_INPUT_CLEANUP_FAILED: private render-input cleanup failed; ` + + `retained binding path: ${retainedPath}: ` + + `${cause instanceof Error ? cause.message : String(cause)}` + + (retainedOutputPath + ? `; unpublished output remains at: ${retainedOutputPath}` + : ""), + { cause }, + ); + this.name = "PrivateInputCleanupError"; + } +} + +const activePrivateInputRoots = new Map(); +let handlingPrivateInputSignal = false; + +function removePrivateInputSignalHandlers(): void { + process.removeListener("SIGINT", handlePrivateInputSignal); + process.removeListener("SIGTERM", handlePrivateInputSignal); +} + +function addPrivateInputSignalHandlers(): void { + process.prependListener("SIGINT", handlePrivateInputSignal); + process.prependListener("SIGTERM", handlePrivateInputSignal); +} + +function handlePrivateInputSignal(signal: NodeJS.Signals): void { + if (handlingPrivateInputSignal) return; + handlingPrivateInputSignal = true; + const hasOtherSignalListeners = process + .listeners(signal) + .some((listener) => listener !== handlePrivateInputSignal); + removePrivateInputSignalHandlers(); + for (const [root, expectedIdentity] of activePrivateInputRoots) { + try { + if (assertPrivateInputRootIdentity(root, expectedIdentity)) { + rmSync(root, { recursive: true, force: true }); + } + activePrivateInputRoots.delete(root); + } catch (error) { + console.error( + new PrivateInputCleanupError(root, error).message, + ); + } + } + handlingPrivateInputSignal = false; + if (hasOtherSignalListeners) { + // Node suppresses its default exit whenever another listener exists. Let + // the original delivery reach that listener exactly once. If cleanup + // retained a root, keep our handlers armed for a later signal. + if (activePrivateInputRoots.size > 0) addPrivateInputSignalHandlers(); + return; + } + // Re-deliver after synchronous cleanup so ordinary Node signal semantics + // (128 + signal, or signalCode for a direct Node process) remain intact. + process.kill(process.pid, signal); +} + +function registerPrivateInputRoot(root: string, identity: string): void { + if (activePrivateInputRoots.size === 0) { + addPrivateInputSignalHandlers(); + } + activePrivateInputRoots.set(root, identity); +} + +function unregisterPrivateInputRoot(root: string): void { + activePrivateInputRoots.delete(root); + if (activePrivateInputRoots.size === 0) { + removePrivateInputSignalHandlers(); + handlingPrivateInputSignal = false; + } +} + +async function cleanupPrivateInputRoot( + root: string, + retainedOutputPath?: string, +): Promise { + try { + const expectedIdentity = activePrivateInputRoots.get(root); + if (expectedIdentity) { + if (assertPrivateInputRootIdentity(root, expectedIdentity)) { + await rm(root, { recursive: true, force: true }); + } + } else { + try { + lstatSync(root); + throw new Error("private render-input root is no longer registered for cleanup"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + unregisterPrivateInputRoot(root); + } catch (error) { + unregisterPrivateInputRoot(root); + throw new PrivateInputCleanupError(root, error, retainedOutputPath); + } +} + +function assertPrivateInputRootIdentity( + root: string, + expectedIdentity: string, +): boolean { + let current: ReturnType; + try { + current = lstatSync(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + if ( + !current.isDirectory() || + current.isSymbolicLink() || + `${current.dev}:${current.ino}` !== expectedIdentity + ) { + throw new Error(`private render-input root identity changed before cleanup: ${root}`); + } + return true; +} + +async function trustedPrivateInputParent(): Promise { + const input = tmpdir(); + const lexical = resolve(input); + const entry = await lstat(input); + const canonical = await realpath(input); + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + input !== lexical || + canonical !== input || + canonical === "/" + ) { + throw new Error( + `[agent-demo-video] private render-input temporary root is not trusted: ${input}`, + ); + } + + const euid = typeof process.geteuid === "function" + ? process.geteuid() + : entry.uid; + const selectedMode = entry.mode & 0o7777; + const selectedTrusted = + (entry.uid === 0 && (selectedMode & 0o1000) !== 0) || + (entry.uid === euid && (selectedMode & 0o022) === 0); + if (!selectedTrusted) { + throw new Error( + `[agent-demo-video] private render-input temporary root is not trusted: ${canonical}`, + ); + } + + let ancestor = canonical; + while (true) { + const ancestorStat = await lstat(ancestor); + const ancestorMode = ancestorStat.mode & 0o7777; + const rootOwnedTrusted = + ancestorStat.uid === 0 && + ((ancestorMode & 0o022) === 0 || (ancestorMode & 0o1000) !== 0); + const userOwnedTrusted = + ancestorStat.uid === euid && (ancestorMode & 0o022) === 0; + if ( + !ancestorStat.isDirectory() || + ancestorStat.isSymbolicLink() || + (!rootOwnedTrusted && !userOwnedTrusted) + ) { + throw new Error( + `[agent-demo-video] private render-input temporary root ancestor is not trusted: ${ancestor}`, + ); + } + if (ancestor === "/") break; + ancestor = dirname(ancestor); + } + return canonical; +} + +function combinePrimaryAndCleanupErrors( + primary: unknown, + cleanup: PrivateInputCleanupError, +): AggregateError { + const primaryError = primary instanceof Error ? primary : new Error(String(primary)); + return new AggregateError( + [primaryError, cleanup], + `${primaryError.message}; additionally, ${cleanup.message}`, + { cause: primaryError }, + ); +} + +function containsPrivateInputCleanupError(error: unknown): boolean { + const pending: unknown[] = [error]; + const seen = new Set(); + while (pending.length > 0) { + const current = pending.pop(); + if (current instanceof PrivateInputCleanupError) return true; + if (current instanceof AggregateError && !seen.has(current)) { + seen.add(current); + pending.push(...current.errors); + } + } + return false; +} + +function combinePrimaryAndPublicationErrors( + primary: unknown, + publication: unknown, +): AggregateError { + const primaryError = primary instanceof Error ? primary : new Error(String(primary)); + const publicationError = publication instanceof Error + ? publication + : new Error(String(publication)); + return new AggregateError( + [primaryError, publicationError], + `${primaryError.message}; additionally, ${publicationError.message}`, + { cause: primaryError }, + ); +} + +/** + * Snapshot every operator-owned prebaked source into one private directory, + * seal each snapshot read-only, and hash the bytes the renderer will consume. + * + * Re-checking an operator pathname before rendering still leaves a window in + * which an ordinary capture/export process can replace or partially rewrite + * that file before ffmpeg opens it. A private copy closes that race: later + * source-path updates cannot affect either the render or its reported digest. + * A malicious process running as the same Unix uid remains outside the stated + * isolation boundary. + */ +async function bindPrebakedInputs( + shots: Shot[], + config: DemoConfig, + retainedOutputPath?: string, +): Promise { + if (!shots.some((shot) => shot.target === "prebaked")) { + return { inputs: [], pathByShotId: new Map() }; + } + const privateInputParent = await trustedPrivateInputParent(); + const root = mkdtempSync( + join(privateInputParent, `agent-demo-video-render-inputs-${process.pid}-`), + ); + const rootStat = lstatSync(root); + registerPrivateInputRoot(root, `${rootStat.dev}:${rootStat.ino}`); + try { + if ( + !rootStat.isDirectory() || + rootStat.isSymbolicLink() || + (rootStat.mode & 0o777) !== 0o700 || + (typeof process.geteuid === "function" && rootStat.uid !== process.geteuid()) + ) { + throw new Error("private render-input directory did not bind as an owned 0700 directory"); + } + const markerPath = join(root, PRIVATE_INPUT_ROOT_MARKER_NAME); + await writeFile(markerPath, PRIVATE_INPUT_ROOT_MARKER_CONTENT, { + encoding: "utf8", + flag: "wx", + mode: 0o400, + }); + await chmod(markerPath, 0o400); + const markerStat = await lstat(markerPath); + if ( + !markerStat.isFile() || + markerStat.isSymbolicLink() || + markerStat.nlink !== 1 || + (markerStat.mode & 0o777) !== 0o400 || + (typeof process.geteuid === "function" && markerStat.uid !== process.geteuid()) + ) { + throw new Error("private render-input directory marker did not bind as an owned 0400 file"); + } + + const inputs: BoundClipInputDigest[] = []; + const pathByShotId = new Map(); + for (const shot of shots) { + if (shot.target !== "prebaked") continue; + if (!shot.clip) { + throw new Error(`[agent-demo-video] prebaked shot ${shot.id} has no clip path to bind`); + } + const sourcePath = resolveClipPath( + shot.clip, + config.clipsDir, + config.configDir ?? process.cwd(), + ); + const sourceExtension = extname(sourcePath); + const safeExtension = /^\.[A-Za-z0-9]{1,10}$/.test(sourceExtension) + ? sourceExtension + : ".bin"; + const boundPath = join( + root, + `input-${String(inputs.length).padStart(4, "0")}${safeExtension}`, + ); + let sourceHandle: FileHandle | undefined; + let boundHandle: FileHandle | undefined; + try { + const namedSource = await lstat(sourcePath); + if (!namedSource.isFile() || namedSource.isSymbolicLink()) { + throw new Error("prebaked source must be a regular, non-symbolic-link file"); + } + sourceHandle = await open( + sourcePath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + const openedSource = await sourceHandle.stat(); + if ( + !openedSource.isFile() || + openedSource.dev !== namedSource.dev || + openedSource.ino !== namedSource.ino + ) { + throw new Error("prebaked source changed while its regular-file identity was bound"); + } + boundHandle = await open( + boundPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + const copyBuffer = Buffer.allocUnsafe(1024 * 1024); + let readPosition = 0; + while (true) { + const { bytesRead } = await sourceHandle.read( + copyBuffer, + 0, + copyBuffer.length, + readPosition, + ); + if (bytesRead === 0) break; + let written = 0; + while (written < bytesRead) { + const result = await boundHandle.write( + copyBuffer, + written, + bytesRead - written, + readPosition + written, + ); + if (result.bytesWritten === 0) { + throw new Error("private render-input copy made no write progress"); + } + written += result.bytesWritten; + } + readPosition += bytesRead; + } + await boundHandle.sync(); + await boundHandle.close(); + boundHandle = undefined; + await sourceHandle.close(); + sourceHandle = undefined; + await chmod(boundPath, 0o400); + const boundStat = await lstat(boundPath); + if ( + !boundStat.isFile() || + boundStat.isSymbolicLink() || + boundStat.nlink !== 1 || + (boundStat.mode & 0o777) !== 0o400 || + (typeof process.geteuid === "function" && boundStat.uid !== process.geteuid()) + ) { + throw new Error("private render-input copy is not an owned, single-link 0400 file"); + } + if (boundStat.size === 0) { + throw new Error("private render-input copy is empty"); + } + inputs.push({ + shotId: shot.id, + path: boundPath, + sha256: await digestFile(boundPath), + dev: boundStat.dev, + ino: boundStat.ino, + }); + pathByShotId.set(shot.id, boundPath); + } catch (error) { + throw new Error( + `[agent-demo-video] could not bind prebaked input for shot "${shot.id}" at ${sourcePath}: ` + + `${(error as Error).message}`, + { cause: error }, + ); + } finally { + await boundHandle?.close().catch(() => {}); + await sourceHandle?.close().catch(() => {}); + } + } + return { root, inputs, pathByShotId }; + } catch (error) { + try { + await cleanupPrivateInputRoot(root, retainedOutputPath); + } catch (cleanupError) { + throw combinePrimaryAndCleanupErrors( + error, + cleanupError as PrivateInputCleanupError, + ); + } + throw error; + } +} + +async function assertBoundPrebakedInputsUnchanged( + inputs: BoundClipInputDigest[], +): Promise { + for (const input of inputs) { + try { + const currentStat = await lstat(input.path); + if ( + !currentStat.isFile() || + currentStat.isSymbolicLink() || + currentStat.dev !== input.dev || + currentStat.ino !== input.ino || + currentStat.nlink !== 1 || + (currentStat.mode & 0o777) !== 0o400 || + await digestFile(input.path) !== input.sha256 + ) { + throw new Error("identity, mode, link count, or digest changed"); + } + } catch (error) { + throw new Error( + `[agent-demo-video] private prebaked input changed before render for shot "${input.shotId}": ` + + `${(error as Error).message}`, + { cause: error }, + ); + } + } +} + +/** + * Atomically reserve the requested output name with an exclusive regular file, + * then render through an open handle to a private sibling staging directory. + * + * Publishing a directory before opening it leaves a mkdir-to-open race: a + * competing process can replace that directory with reviewed artifacts and + * make the replacement look like the one we just created. An O_EXCL file claim + * has no such public reopen window. The requested name remains occupied for the + * entire render, while every artifact write goes through the staging directory + * handle exposed under /proc. + */ +async function claimFreshOutputDir(requestedPath: string): Promise { + if (process.platform !== "linux") { + throw new Error( + "[agent-demo-video] immutable --out claims require Linux /proc directory handles; " + + "run the production attempt on a Linux filesystem under Linux or WSL", + ); + } + const requestedParent = dirname(requestedPath); + const requestedName = basename(requestedPath); + if (!requestedName || requestedName === "." || requestedName === "..") { + throw new Error(`[agent-demo-video] --out must name a new child directory: ${requestedPath}`); + } + await mkdir(requestedParent, { recursive: true }); + + let parentHandle: FileHandle | undefined; + let claimHandle: FileHandle | undefined; + let stagingHandle: FileHandle | undefined; + let claimPath: string | undefined; + let stagingPath: string | undefined; + let claimDev: number | undefined; + let claimIno: number | undefined; + let stagingDev: number | undefined; + let stagingIno: number | undefined; + try { + parentHandle = await open( + requestedParent, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const boundParent = `/proc/${process.pid}/fd/${parentHandle.fd}`; + claimPath = join(boundParent, requestedName); + try { + claimHandle = await open( + claimPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`[agent-demo-video] fresh output path already exists: ${requestedPath}`); + } + throw error; + } + const claimed = await claimHandle.stat(); + claimDev = claimed.dev; + claimIno = claimed.ino; + const publicClaim = await lstat(requestedPath).catch(() => undefined); + if ( + !publicClaim?.isFile() || + publicClaim.isSymbolicLink() || + publicClaim.dev !== claimDev || + publicClaim.ino !== claimIno + ) { + throw new Error( + `[agent-demo-video] fresh output pathname changed after claim: ${requestedPath}. ` + + `Writes stayed in the private staging directory; the replacement target was not touched.`, + ); + } + + // mkdtemp gives the private stage an unpredictable name and mode 0700. + // Authenticate its creation window and emptiness before the first write + // so an accidentally reused or pre-existing stage is rejected. This is + // not an isolation boundary against a malicious process with the same uid. + const stagingCreateStartedMs = Date.now(); + stagingPath = await mkdtemp(join(boundParent, `.${requestedName}.stage-`)); + const namedStage = await lstat(stagingPath); + stagingHandle = await open( + stagingPath, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const openedStage = await stagingHandle.stat(); + stagingDev = openedStage.dev; + stagingIno = openedStage.ino; + if ( + !namedStage.isDirectory() || + namedStage.isSymbolicLink() || + namedStage.dev !== stagingDev || + namedStage.ino !== stagingIno + ) { + throw new Error( + `[agent-demo-video] fresh output staging directory changed while it was being bound: ${stagingPath}`, + ); + } + const boundPath = `/proc/${process.pid}/fd/${stagingHandle.fd}`; + const recoveryPath = join(requestedParent, basename(stagingPath)); + const bound = await lstat(boundPath); + if (!bound.isSymbolicLink()) { + throw new Error(`[agent-demo-video] could not bind fresh output through ${boundPath}`); + } + const stageMode = Number(openedStage.mode & 0o777); + const birthtimeSlopMs = 1_000; // accommodates filesystems with 1s timestamp granularity + if ( + openedStage.birthtimeMs < stagingCreateStartedMs - birthtimeSlopMs || + openedStage.birthtimeMs > Date.now() + birthtimeSlopMs || + stageMode !== 0o700 || + (typeof process.geteuid === "function" && openedStage.uid !== process.geteuid()) || + (await readdir(boundPath)).length !== 0 + ) { + throw new Error( + `[agent-demo-video] fresh output staging directory was not the new private empty directory: ${stagingPath}. ` + + "Use a Linux filesystem (not a mounted Windows/DrvFs path).", + ); + } + return { + requestedPath, + claimPath, + stagingPath, + recoveryPath, + boundPath, + parentHandle, + claimHandle, + stagingHandle, + claimDev, + claimIno, + stagingDev, + stagingIno, + publishAttempted: false, + published: false, + }; + } catch (error) { + // Never clean these mutable pathnames here. A cooperating caller can + // quarantine an abandoned claim/stage after inspecting it, but a + // lstat-then-rm/unlink sequence could delete an object installed between + // the identity check and the destructive call. + await stagingHandle?.close().catch(() => {}); + await claimHandle?.close().catch(() => {}); + await parentHandle?.close().catch(() => {}); + throw error; + } +} + +async function assertClaimPathUnchanged(claim: FreshOutputClaim): Promise { + try { + const named = await lstat(claim.requestedPath); + if ( + !named.isFile() || + named.isSymbolicLink() || + named.dev !== claim.claimDev || + named.ino !== claim.claimIno + ) { + throw new Error("identity mismatch"); + } + } catch { + throw new Error( + `[agent-demo-video] fresh output pathname changed after claim: ${claim.requestedPath}. ` + + `Writes stayed in the private staging directory; the replacement target was not touched.`, + ); + } +} + +async function assertStagingPathUnchanged(claim: FreshOutputClaim): Promise { + const named = await lstat(claim.stagingPath).catch(() => undefined); + if ( + !named?.isDirectory() || + named.isSymbolicLink() || + named.dev !== claim.stagingDev || + named.ino !== claim.stagingIno + ) { + throw new Error( + `[agent-demo-video] fresh output staging pathname changed: ${claim.stagingPath}`, + ); + } +} + +/** + * Replace the exclusive claim file with the completed (or diagnostic partial) + * staging directory. The two names are siblings, so GNU mv's long-supported + * no-clobber mode is necessarily a same-filesystem, no-replace rename. Because + * mv reports success for one no-clobber collision mode, inode/name checks + * remain the authority. + */ +async function publishFreshOutput(claim: FreshOutputClaim): Promise { + if (claim.published) return; + if (claim.publishAttempted) { + throw new Error( + `[agent-demo-video] fresh output publication was already attempted: ${claim.requestedPath}`, + ); + } + claim.publishAttempted = true; + await assertClaimPathUnchanged(claim); + await assertStagingPathUnchanged(claim); + + // Vacate the public name without unlinking through a mutable pathname. Park + // the claim under an unpredictable sibling name, authenticate the moved + // inode against the still-open handle, then retain it as a marker inside the + // handle-bound stage. Every move is no-clobber, so a cooperative collision + // leaves both objects available for inspection instead of deleting either. + // + // This is not hostile same-UID isolation: a process with the same uid can + // mutate our paths and /proc handles. The contract here is non-destructive + // behavior for accidental/cooperative collisions. + const parkedClaimPath = join( + dirname(claim.claimPath), + `.${basename(claim.claimPath)}.claim-${randomUUID()}`, + ); + await runProcess("mv", [ + "-T", + "--no-clobber", + "--", + claim.claimPath, + parkedClaimPath, + ]); + const claimStillNamed = await lstat(claim.claimPath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ); + const parkedClaim = await lstat(parkedClaimPath).catch(() => undefined); + if ( + claimStillNamed || + !parkedClaim?.isFile() || + parkedClaim.isSymbolicLink() || + parkedClaim.dev !== claim.claimDev || + parkedClaim.ino !== claim.claimIno + ) { + // Best-effort no-clobber restoration only; never remove either pathname. + if (!claimStillNamed && parkedClaim) { + await runProcess("mv", [ + "-T", + "--no-clobber", + "--", + parkedClaimPath, + claim.claimPath, + ]).catch(() => {}); + } + throw new Error( + `[agent-demo-video] fresh output claim identity changed while publication began: ${claim.requestedPath}`, + ); + } + + const claimMarkerPath = join(claim.boundPath, ".agent-demo-video-output-claim"); + if (await lstat(claimMarkerPath).catch(() => undefined)) { + throw new Error( + `[agent-demo-video] fresh output claim marker already exists in staging: ${claim.stagingPath}`, + ); + } + await runProcess("mv", [ + "-T", + "--no-clobber", + "--", + parkedClaimPath, + claimMarkerPath, + ]); + const parkedStillNamed = await lstat(parkedClaimPath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ); + const claimMarker = await lstat(claimMarkerPath).catch(() => undefined); + if ( + parkedStillNamed || + !claimMarker?.isFile() || + claimMarker.isSymbolicLink() || + claimMarker.dev !== claim.claimDev || + claimMarker.ino !== claim.claimIno + ) { + throw new Error( + `[agent-demo-video] fresh output claim could not be retained in staging: ${claim.stagingPath}`, + ); + } + + try { + await runProcess("mv", [ + "-T", + "--no-clobber", + "--", + claim.stagingPath, + claim.claimPath, + ]); + } catch (error) { + const recoveryPath = await realpath(claim.boundPath).catch(() => claim.stagingPath); + throw new Error( + `[agent-demo-video] could not atomically publish fresh output at ${claim.requestedPath}; ` + + `staged artifacts remain at ${recoveryPath}: ${(error as Error).message}`, + ); + } + + const stageStillNamed = await lstat(claim.stagingPath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ); + if (stageStillNamed) { + throw new Error( + `[agent-demo-video] fresh output publication collided at ${claim.requestedPath}; ` + + `the replacement target was not touched and staged artifacts remain at ${claim.stagingPath}`, + ); + } + for (const path of [claim.claimPath, claim.requestedPath]) { + const named = await lstat(path).catch(() => undefined); + if ( + !named?.isDirectory() || + named.isSymbolicLink() || + named.dev !== claim.stagingDev || + named.ino !== claim.stagingIno + ) { + throw new Error( + `[agent-demo-video] fresh output pathname changed during publication: ${claim.requestedPath}`, + ); + } + } + claim.published = true; } /** Path to the built remote-render bundle, resolved relative to this module. */ @@ -56,6 +861,36 @@ function assertBundleFresh(bundlePath: string): void { } export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {}): Promise { + if (opts.sourceBuild) { + if (opts.render) { + throw new Error( + "[agent-demo-video] source-attested pipeline runs cannot use remote rendering", + ); + } + await assertSourceBuildExecutionContext( + opts.sourceBuild, + fileURLToPath(import.meta.url), + ); + } + const receiptConfig = config; + const requestedOut = resolve(config.out); + const freshClaim = opts.requireFreshOut + ? await claimFreshOutputDir(requestedOut) + : undefined; + const out = freshClaim?.boundPath ?? requestedOut; + if (freshClaim) config = { ...config, out }; + let boundPrebakedRoot: string | undefined; + + try { + if (!freshClaim) { + // A receipt must never outlive the render attempt it describes. + // Invalidate it before any fail-fast check, including preflight: + // otherwise a rejected rehearsal can leave the previous run's passing + // report beside stale media and make file presence look like proof of + // the latest attempt. + await rm(join(out, "render-report.json"), { force: true }); + } + // musicPath is an operator-LOCAL file; the remote render never stages it. // Fail fast, before any capture or TTS spend, rather than at the last stage. // Only when sound design is on: with it off the render never reads the file. @@ -68,6 +903,30 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} const md = readFileSync(config.script, "utf8"); const manifest = parseScript(md); const shots = manifest.shots; + const stableConfig = stableConfigJson(receiptConfig); + const configHash = digest(stableConfig); + const scriptHash = digest(md); + const configSha256 = digestFull(stableConfig); + const scriptSha256 = digestFull(md); + + // Bind every prebaked pathname before any preflight tool can open it. The + // preflight manifest points at these private copies, so ffprobe never consumes + // the mutable operator pathname that the renderer has not yet authenticated. + const boundPrebaked = await bindPrebakedInputs( + shots, + config, + freshClaim?.recoveryPath, + ); + boundPrebakedRoot = boundPrebaked.root; + const clipInputs = boundPrebaked.inputs; + const preflightManifest = { + ...manifest, + shots: shots.map((shot) => { + if (shot.target !== "prebaked") return shot; + const boundPath = boundPrebaked.pathByShotId.get(shot.id); + return boundPath ? { ...shot, clip: boundPath } : shot; + }), + }; // 1.5 Pre-flight selector gate — BEFORE any spend. // @@ -78,7 +937,7 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} // selector, with every narration already synthesized. let preflightRecord: PreflightRecord = { ran: false, declined: true, findings: 0, unverifiedShotIds: [] }; if (config.preflight) { - const findings = await runPreflight(manifest, config); + const findings = await runPreflight(preflightManifest, config); if (findings.length > 0) console.warn(formatPreflightReport(findings)); const blocking = findings.filter((f) => f.severity === "blocking"); preflightRecord = { @@ -106,7 +965,6 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} } // 2. Make dirs - const out = resolve(config.out); const audioDir = join(out, "audio"); const segDir = join(out, "seg"); await mkdir(audioDir, { recursive: true }); @@ -118,11 +976,6 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} // that legitimately never needed a key. const ttsMode = resolveTtsMode(); - // A receipt must never outlive the render it describes: final.mp4 is written - // before the parity check throws, so a failed run could otherwise leave the - // new video beside the PREVIOUS run's passing report. - await rm(join(out, "render-report.json"), { force: true }); - // 3. TTS — sequential to respect ElevenLabs concurrency limits const ttsResults: TtsResult[] = []; for (const shot of shots) { @@ -134,9 +987,27 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} for (let i = 0; i < shots.length; i++) { const shot = shots[i]!; const tts = ttsResults[i]!; - const raw = await captureShot(shot, { shotId: shot.id, startSec: 0, durationSec: tts.durationSec }, config, segDir); + let raw: string | undefined; + if (shot.target === "prebaked") { + // captureShot historically removed a reusable output's stale event file + // before returning a prebaked path. Preserve that cleanup without + // reopening the operator-owned source path. + await rm(join(segDir, `events_${shot.id}.json`), { force: true }); + raw = boundPrebaked.pathByShotId.get(shot.id); + } else { + raw = await captureShot( + shot, + { shotId: shot.id, startSec: 0, durationSec: tts.durationSec }, + config, + segDir, + ); + } + if (!raw) { + throw new Error(`[agent-demo-video] no private prebaked input was bound for shot "${shot.id}"`); + } rawSegments.push(raw); } + await assertBoundPrebakedInputsUnchanged(clipInputs); // 4.4 Click offsets for sound-design ticks, read once here so remote renders // get identical ticks (events files are never staged to a render host). @@ -150,10 +1021,12 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} } } - // 4.5 Brand cards: cold-open title + closing URL card as ordinary silent - // segments around the shot list (skip framing via segmentKinds). + // 4.5 Brand cards: optional cold-open title and/or closing URL card as + // ordinary silent segments around the shot list (skip framing via + // segmentKinds). The two resolved booleans preserve brand.cards as the + // legacy shared default while allowing artifact-first cold opens. let segmentKinds: ("shot" | "card")[] = deriveSegmentKinds(shots); - if (config.brand?.cards) { + if (config.brand && (config.brand.titleCard || config.brand.endCard)) { const b = config.brand; const cardBase = { width: config.resolution.width, @@ -177,30 +1050,34 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} // shot can never clobber card artifacts. const titleTextFile = join(segDir, "card_title_text.txt"); await writeFile(titleTextFile, b.title, "utf8"); - const subtitleTextFile = b.subtitle ? join(segDir, "card_subtitle_text.txt") : undefined; + const subtitleTextFile = b.titleCard && b.subtitle ? join(segDir, "card_subtitle_text.txt") : undefined; if (subtitleTextFile) await writeFile(subtitleTextFile, b.subtitle!, "utf8"); - const urlTextFile = b.url ? join(segDir, "card_url_text.txt") : undefined; + const urlTextFile = b.endCard && b.url ? join(segDir, "card_url_text.txt") : undefined; if (urlTextFile) await writeFile(urlTextFile, b.url!, "utf8"); - const titlePath = join(segDir, "card_title.mp4"); - await ffmpeg(titleCardArgs({ ...cardBase, durationSec: b.titleSec }, titlePath, { - titleFile: titleTextFile, - ...(subtitleTextFile ? { subtitleFile: subtitleTextFile } : {}), - })); - const endPath = join(segDir, "card_end.mp4"); - await ffmpeg(endCardArgs({ ...cardBase, durationSec: b.endSec }, endPath, { - titleFile: titleTextFile, - ...(urlTextFile ? { urlFile: urlTextFile } : {}), - })); - - rawSegments.unshift(titlePath); - ttsResults.unshift(await silentCard("__card-title", b.titleSec)); - segmentKinds.unshift("card"); - clickOffsets.unshift([]); - rawSegments.push(endPath); - ttsResults.push(await silentCard("__card-end", b.endSec)); - segmentKinds.push("card"); - clickOffsets.push([]); + if (b.titleCard) { + const titlePath = join(segDir, "card_title.mp4"); + await ffmpeg(titleCardArgs({ ...cardBase, durationSec: b.titleSec }, titlePath, { + titleFile: titleTextFile, + ...(subtitleTextFile ? { subtitleFile: subtitleTextFile } : {}), + })); + rawSegments.unshift(titlePath); + ttsResults.unshift(await silentCard("__card-title", b.titleSec)); + segmentKinds.unshift("card"); + clickOffsets.unshift([]); + } + + if (b.endCard) { + const endPath = join(segDir, "card_end.mp4"); + await ffmpeg(endCardArgs({ ...cardBase, durationSec: b.endSec }, endPath, { + titleFile: titleTextFile, + ...(urlTextFile ? { urlFile: urlTextFile } : {}), + })); + rawSegments.push(endPath); + ttsResults.push(await silentCard("__card-end", b.endSec)); + segmentKinds.push("card"); + clickOffsets.push([]); + } } // 5-13. Render — locally by default, or offloaded to a render host (same renderVideo @@ -221,6 +1098,13 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} result = await renderVideo(inputs); } + // This is a render gate, not best-effort provenance. If any scoped source + // changed while the artifact was being made, fail the run before a report + // can certify the completed bytes. + if (opts.sourceBuild) { + await assertSourceBuildUnchanged(opts.sourceBuild); + } + // 14. Provenance. Written AFTER a successful render so the file's existence // means "this artifact shipped under these inputs". Never gates: a // provenance failure must not discard a completed render. @@ -228,19 +1112,88 @@ export async function runPipeline(config: DemoConfig, opts: RunPipelineOpts = {} const report = buildRenderReport({ voice: config.voice, ttsMode, - configHash: digest(stableConfigJson(config)), - scriptHash: digest(md), + configHash, + scriptHash, + configSha256, + scriptSha256, + clips: clipInputs.map(({ shotId, sha256 }) => ({ shotId, sha256 })), tools: await toolVersions(), timeline: result.report.timeline, render: result.report, maxDurationSec: config.maxDurationSec, renderedOn: opts.render ? "remote" : "local", preflight: preflightRecord, + ...(opts.sourceBuild + ? { sourceBuildAttestation: opts.sourceBuild.attestation } + : {}), }); - await writeFile(join(out, "render-report.json"), JSON.stringify(report, null, 2)); + await persistRenderReport( + join(out, "render-report.json"), + report, + Boolean(opts.sourceBuild), + ); } catch (e) { + if (opts.sourceBuild) { + if ((e as Error).message.includes("required render-report.json")) { + throw e; + } + throw new Error( + `[agent-demo-video] required render-report.json could not be built: ${(e as Error).message}`, + { cause: e }, + ); + } console.warn(`[agent-demo-video] could not write render-report.json: ${(e as Error).message}`); } - return result; + if (boundPrebakedRoot) { + await cleanupPrivateInputRoot( + boundPrebakedRoot, + freshClaim?.recoveryPath, + ); + boundPrebakedRoot = undefined; + } + if (freshClaim) { + await publishFreshOutput(freshClaim); + return { ...result, outPath: join(requestedOut, "final.mp4") }; + } + return result; + } catch (error) { + // Match the historical diagnostic behavior: a failed fresh run still + // exposes its partial artifacts at the requested path when that can be + // done without touching a competing target. Private-input cleanup is the + // first failure action: no output is published while private source bytes + // remain retained. Every secondary error stays structured and preserves + // the original pipeline failure as AggregateError.errors[0]. + const cleanupAlreadyFailed = containsPrivateInputCleanupError(error); + if (!cleanupAlreadyFailed && boundPrebakedRoot) { + try { + await cleanupPrivateInputRoot( + boundPrebakedRoot, + freshClaim?.recoveryPath, + ); + boundPrebakedRoot = undefined; + } catch (cleanupError) { + throw combinePrimaryAndCleanupErrors( + error, + cleanupError as PrivateInputCleanupError, + ); + } + } + if ( + !cleanupAlreadyFailed && + freshClaim && + !freshClaim.publishAttempted + ) { + try { + await publishFreshOutput(freshClaim); + } catch (publishError) { + throw combinePrimaryAndPublicationErrors(error, publishError); + } + } + throw error; + } finally { + await freshClaim?.stagingHandle.close().catch(() => {}); + await freshClaim?.claimHandle.close().catch(() => {}); + await freshClaim?.parentHandle.close().catch(() => {}); + } } diff --git a/src/preflight.ts b/src/preflight.ts index 6cc28b7..8548a48 100644 --- a/src/preflight.ts +++ b/src/preflight.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from "node:fs"; +import { lstatSync } from "node:fs"; import { chromium, type Page } from "playwright"; import { cssInjectScript, overlayInitScript } from "./overlay.js"; import { waitForReady } from "./ready.js"; @@ -7,6 +7,8 @@ import { resolveClipPath } from "./clips.js"; import { redactUrl, redactUrlsInText, scrubControlChars } from "./sanitize.js"; import { SELECTOR_TIMEOUT_MS } from "./timeouts.js"; import { captureViewport } from "./platforms.js"; +import { probeSizePx } from "./ffmpeg.js"; +import { assertFullBleedCanvasAspect } from "./framing.js"; import type { Action, DemoConfig, Manifest, PreflightFinding, Shot } from "./types"; /** Action kinds that cannot run at all without a selector. */ @@ -20,6 +22,15 @@ const SELECTOR_REQUIRED: Action["kind"][] = ["click", "type", "hover", "highligh */ const ambiguityIsFatal = (kind: Action["kind"]): boolean => kind !== "hover"; +const isRegularNonSymlink = (path: string): boolean => { + try { + const entry = lstatSync(path); + return entry.isFile() && !entry.isSymbolicLink(); + } catch { + return false; + } +}; + interface SelectorProbe { shot: Shot; kind: Action["kind"]; @@ -104,7 +115,7 @@ export function structuralFindings(manifest: Manifest, config: DemoConfig): Pref }); } else { const clipPath = resolveClipPath(shot.clip, config.clipsDir, config.configDir ?? process.cwd()); - if (!existsSync(clipPath) || !statSync(clipPath).isFile()) { + if (!isRegularNonSymlink(clipPath)) { findings.push({ shotId: shot.id, kind: "missing-clip", @@ -148,6 +159,38 @@ export function structuralFindings(manifest: Manifest, config: DemoConfig): Pref return findings; } +/** + * Probe finished full-bleed compositions before narration spend. Merely + * finding the file is insufficient: normalize preserves aspect and pads, so a + * landscape mistake can otherwise become a formally valid portrait file with + * hidden bars. + */ +async function clipGeometryFindings( + manifest: Manifest, + config: DemoConfig, +): Promise { + const findings: PreflightFinding[] = []; + for (const shot of manifest.shots) { + if (shot.target !== "prebaked" || !shot.fullBleed || !shot.clip) continue; + const clipPath = resolveClipPath(shot.clip, config.clipsDir, config.configDir ?? process.cwd()); + if (!isRegularNonSymlink(clipPath)) continue; + try { + assertFullBleedCanvasAspect(config.resolution, await probeSizePx(clipPath), shot.id); + } catch (error) { + findings.push({ + shotId: shot.id, + kind: "invalid-clip-geometry", + severity: "blocking", + message: + error instanceof Error + ? error.message + : `shot "${shot.id}": could not verify fullBleed clip geometry`, + }); + } + } + return findings; +} + /** * Count matches the way the RENDER counts them. * @@ -358,7 +401,11 @@ export async function resolveSelectorFindings( * see both classes in one pass rather than one render at a time. */ export async function runPreflight(manifest: Manifest, config: DemoConfig): Promise { - return [...structuralFindings(manifest, config), ...(await resolveSelectorFindings(manifest, config))]; + return [ + ...structuralFindings(manifest, config), + ...(await clipGeometryFindings(manifest, config)), + ...(await resolveSelectorFindings(manifest, config)), + ]; } /** Render findings as operator-readable lines, severity first so a scan sorts itself. */ diff --git a/src/provenance.test.ts b/src/provenance.test.ts index d12226e..f87dcd4 100644 --- a/src/provenance.test.ts +++ b/src/provenance.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect } from "vitest"; -import { buildRenderReport, digest, stableConfigJson } from "./provenance"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildRenderReport, + digest, + digestFile, + digestFull, + persistRenderReport, + stableConfigJson, +} from "./provenance"; +import type { SourceBuildAttestation } from "./source-build"; /** * A judged 2:57 cut re-rendered to 3:25 from the SAME script. It was recorded as @@ -13,6 +24,12 @@ describe("buildRenderReport", () => { voice: { voiceId: "v1", modelId: "eleven_multilingual_v2", seed: 42, stability: 0.5, similarity: 0.75 }, ttsMode: "real" as const, configHash: "cfg", scriptHash: "scr", + configSha256: "c".repeat(64), + scriptSha256: "s".repeat(64), + clips: [ + { shotId: "s1", sha256: "1".repeat(64) }, + { shotId: "s2", sha256: "2".repeat(64) }, + ], tools: { ffmpeg: "6.0", ffprobe: "6.0", playwright: "1.61.1", node: "v22" }, timeline: { entries: [{ shotId: "s1", startSec: 0, durationSec: 3 }], totalSec: 3 }, render: { totalSec: 3, segments: 1, ticks: 0, parity: { ok: true, problems: [] } }, @@ -32,6 +49,9 @@ describe("buildRenderReport", () => { const r = buildRenderReport(base); expect(r.inputs.configHash).toBe("cfg"); expect(r.inputs.scriptHash).toBe("scr"); + expect(r.inputs.configSha256).toBe("c".repeat(64)); + expect(r.inputs.scriptSha256).toBe("s".repeat(64)); + expect(r.inputs.clips.map((clip) => clip.shotId)).toEqual(["s1", "s2"]); expect(r.timeline.entries[0]?.shotId).toBe("s1"); expect(r.timeline.totalSec).toBe(3); }); @@ -41,14 +61,71 @@ describe("buildRenderReport", () => { expect(buildRenderReport({ ...base, renderedOn: "remote" as const }).renderedOn).toBe("remote"); }); + it("carries an optional source-build attestation through unchanged", () => { + const sourceBuildAttestation: SourceBuildAttestation = { + version: 2, + executionMode: "detached-commit-snapshot", + commit: "a".repeat(40), + runner: "src/cli.ts", + scopedPaths: [ + "src", + "scripts/remote-entry.ts", + "scripts/run-source-attested-render.sh", + "package.json", + "pnpm-lock.yaml", + "tsconfig.json", + ], + treeSha256: "b".repeat(64), + packageJsonSha256: "c".repeat(64), + pnpmLockSha256: "d".repeat(64), + }; + const report = buildRenderReport({ ...base, sourceBuildAttestation }); + expect(report.sourceBuildAttestation).toBe(sourceBuildAttestation); + expect(buildRenderReport(base).sourceBuildAttestation).toBeUndefined(); + }); + it("digests content stably and distinguishes different content", () => { expect(digest("a")).toBe(digest("a")); expect(digest("a")).not.toBe(digest("b")); + expect(digest("a")).toHaveLength(16); + expect(digestFull("a")).toHaveLength(64); + expect(digestFull("a").startsWith(digest("a"))).toBe(true); + }); + + it("streams a file into the same full digest as its exact bytes", async () => { + const dir = await mkdtemp(join(tmpdir(), "provenance-digest-")); + const path = join(dir, "clip.mp4"); + await writeFile(path, "exact clip bytes"); + await expect(digestFile(path)).resolves.toBe(digestFull("exact clip bytes")); + }); + + it("fails closed on an attested report write but preserves legacy best-effort writes", async () => { + const dir = await mkdtemp(join(tmpdir(), "provenance-write-")); + const missingPath = join(dir, "missing", "render-report.json"); + const report = buildRenderReport(base); + + await expect( + persistRenderReport(missingPath, report, true), + ).rejects.toThrow(/required render-report\.json/); + + const warnings: string[] = []; + const previousWarn = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args.join(" ")); + try { + await expect( + persistRenderReport(missingPath, report, false), + ).resolves.toBeUndefined(); + } finally { + console.warn = previousWarn; + } + expect(warnings).toEqual([ + expect.stringMatching(/could not write render-report\.json/), + ]); }); }); describe("stableConfigJson", () => { - const base = { script: "DEMO.md", out: "out" } as never; + const base = { script: "DEMO.md", clipsDir: "clips/prebaked", out: "out" } as never; const mk = (o: Record) => ({ ...(base as object), ...o }) as never; it("gives the same digest for a local fixture base across two checkouts", () => { @@ -68,4 +145,22 @@ describe("stableConfigJson", () => { const b = mk({ dashboardBaseUrl: "http://x", capture: { auth: { loginUrl: "https://app/login", profileDir: "/home/bob/.cache/agent-demo-video" } } }); expect(digest(stableConfigJson(a))).toBe(digest(stableConfigJson(b))); }); + + it("normalises the per-attempt script location relative to its copied config", () => { + const a = mk({ dashboardBaseUrl: "http://x", configDir: "/attempt/run-001/evidence/master", script: "/attempt/run-001/evidence/master/DEMO_SCRIPT.md" }); + const b = mk({ dashboardBaseUrl: "http://x", configDir: "/attempt/run-002/evidence/master", script: "/attempt/run-002/evidence/master/DEMO_SCRIPT.md" }); + expect(digest(stableConfigJson(a))).toBe(digest(stableConfigJson(b))); + }); + + it("normalises the per-attempt clips location relative to its copied config", () => { + const a = mk({ dashboardBaseUrl: "http://x", configDir: "/attempt/run-001/evidence/master", clipsDir: "/attempt/run-001/evidence/clips" }); + const b = mk({ dashboardBaseUrl: "http://x", configDir: "/attempt/run-002/evidence/master", clipsDir: "/attempt/run-002/evidence/clips" }); + expect(digest(stableConfigJson(a))).toBe(digest(stableConfigJson(b))); + }); + + it("still distinguishes different clip relationships when no content manifest exists", () => { + const a = mk({ dashboardBaseUrl: "http://x", configDir: "/attempt/run/evidence/master", clipsDir: "/attempt/run/evidence/clips-a" }); + const b = mk({ dashboardBaseUrl: "http://x", configDir: "/attempt/run/evidence/master", clipsDir: "/attempt/run/evidence/clips-b" }); + expect(digest(stableConfigJson(a))).not.toBe(digest(stableConfigJson(b))); + }); }); diff --git a/src/provenance.ts b/src/provenance.ts index c0dfb6f..1913635 100644 --- a/src/provenance.ts +++ b/src/provenance.ts @@ -1,8 +1,12 @@ import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; +import { createReadStream } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { isAbsolute, relative, sep } from "node:path"; import { promisify } from "node:util"; import type { DemoConfig, TimelineEntry } from "./types"; import type { TtsMode } from "./tts"; +import type { SourceBuildAttestation } from "./source-build"; const exec = promisify(execFile); @@ -12,7 +16,7 @@ export type ToolVersions = { ffmpeg: string; ffprobe: string; playwright: string * Whether this artifact's selectors were verified before it was made. * * Without it, a render that DECLINED the gate is indistinguishable from one - * that passed it: the flag survives only inside the 16-hex config digest, which + * that passed it: the flag survives only inside the opaque config digest, which * also moves for any unrelated edit and cannot be read back. `unverifiedShotIds` * names the shots the gate could not check at all (auth-walled live shots), so * "gated and clean" is distinguishable from "gated, but not where it counted". @@ -24,10 +28,21 @@ export type PreflightRecord = { unverifiedShotIds: string[]; }; +export type ClipInputDigest = { + shotId: string; + sha256: string; +}; + export type RenderReport = { voice: DemoConfig["voice"]; ttsMode: TtsMode; - inputs: { configHash: string; scriptHash: string }; + inputs: { + configHash: string; + scriptHash: string; + configSha256: string; + scriptSha256: string; + clips: ClipInputDigest[]; + }; /** Where the ffmpeg work actually happened. `tools` below is probed LOCALLY, so * on "remote" it describes the machine that captured and synthesised, NOT the * one that rendered. Recorded explicitly so a reader is never misled into @@ -38,11 +53,28 @@ export type RenderReport = { render: { totalSec: number; segments: number; ticks: number; parity: { ok: boolean; problems: string[] } }; limits: { maxDurationSec: number }; preflight: PreflightRecord; + sourceBuildAttestation?: SourceBuildAttestation; }; -/** Stable content digest for config/script inputs. Short, for eyeballing in a diff. */ +/** Short stable content digest retained for report compatibility and scanning. */ export function digest(content: string): string { - return createHash("sha256").update(content).digest("hex").slice(0, 16); + return digestFull(content).slice(0, 16); +} + +/** Full SHA-256 content digest for immutable render inputs. */ +export function digestFull(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +/** Stream a potentially large media input instead of loading it all into memory. */ +export function digestFile(path: string): Promise { + return new Promise((resolveDigest, rejectDigest) => { + const hash = createHash("sha256"); + const input = createReadStream(path); + input.on("error", rejectDigest); + input.on("data", (chunk) => hash.update(chunk)); + input.on("end", () => resolveDigest(hash.digest("hex"))); + }); } /** @@ -57,26 +89,62 @@ export function buildRenderReport(o: { ttsMode: TtsMode; configHash: string; scriptHash: string; + configSha256: string; + scriptSha256: string; + clips: ClipInputDigest[]; tools: ToolVersions; timeline: { entries: TimelineEntry[]; totalSec: number }; render: { totalSec: number; segments: number; ticks: number; parity: { ok: boolean; problems: string[] } }; maxDurationSec: number; renderedOn: "local" | "remote"; preflight: PreflightRecord; + sourceBuildAttestation?: SourceBuildAttestation; }): RenderReport { return { voice: o.voice, ttsMode: o.ttsMode, - inputs: { configHash: o.configHash, scriptHash: o.scriptHash }, + inputs: { + configHash: o.configHash, + scriptHash: o.scriptHash, + configSha256: o.configSha256, + scriptSha256: o.scriptSha256, + clips: o.clips, + }, renderedOn: o.renderedOn, tools: o.tools, timeline: o.timeline, render: o.render, limits: { maxDurationSec: o.maxDurationSec }, preflight: o.preflight, + ...(o.sourceBuildAttestation ? { sourceBuildAttestation: o.sourceBuildAttestation } : {}), }; } +/** + * Preserve the historical best-effort report for ordinary renders while + * making source-attested production evidence mandatory. + */ +export async function persistRenderReport( + path: string, + report: RenderReport, + required: boolean, +): Promise { + try { + await writeFile(path, JSON.stringify(report, null, 2)); + } catch (error) { + const detail = (error as Error).message; + if (required) { + throw new Error( + `[agent-demo-video] required render-report.json could not be written: ${detail}`, + { cause: error }, + ); + } + console.warn( + `[agent-demo-video] could not write render-report.json: ${detail}`, + ); + } +} + async function firstLine(cmd: string, args: string[]): Promise { try { const { stdout } = await exec(cmd, args, { timeout: 5000 }); @@ -105,8 +173,8 @@ export async function toolVersions(): Promise { } /** - * Serialise the config for digesting, excluding values loadConfig resolves to - * machine-local absolutes. + * Serialise the config for digesting, excluding machine-local output locations + * and canonicalising values loadConfig resolves to machine-local absolutes. * * `capture.auth.profileDir` becomes $XDG_CACHE_HOME/... and a "./" base becomes * a file:// URL of the checkout path, so hashing the post-load object gives the @@ -116,12 +184,21 @@ export async function toolVersions(): Promise { */ export function stableConfigJson(config: DemoConfig): string { // configDir is the absolute directory of the config FILE, set by loadConfig. - // Hashing it would give the same committed config a different digest on every - // machine — exactly the machine dependence this function exists to remove. - const { out: _out, configDir: _configDir, ...rest } = config; + // Production overrides make script and clipsDir absolute inside a unique + // run-id root. Preserve their relationship to the config (which distinguishes + // genuinely different sources) without hashing that run-id prefix. Programmatic + // configs with no configDir retain their original values. + const { out: _out, configDir, ...rest } = config; + const inputLocation = (location: string): string => { + if (!configDir || !isAbsolute(location)) return location; + const fromConfig = relative(configDir, location); + return fromConfig.split(sep).join("/") || "."; + }; const auth = rest.capture?.auth; return JSON.stringify({ ...rest, + script: inputLocation(rest.script), + clipsDir: inputLocation(rest.clipsDir), // A relative base ("./") is resolved to a file:// URL of THIS checkout, so // hashing it verbatim re-introduces the machine dependence this function // exists to remove. A local fixture base is not part of a config's identity; diff --git a/src/render.ts b/src/render.ts index b5fa201..4dbf845 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,5 +1,5 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import type { DemoConfig, TtsResult } from "./types"; import { ffmpeg, @@ -18,7 +18,13 @@ import { import { toSrt, toWordAss, captionStyle } from "./captions"; import { buildTimeline, reconcileSegmentDuration } from "./timeline"; import { verifyParity } from "./verify"; -import { maskGenArgs, shadowGenArgs, frameArgs, assertFramedContentAspect } from "./framing"; +import { + maskGenArgs, + shadowGenArgs, + frameArgs, + assertFramedContentAspect, + assertFullBleedCanvasAspect, +} from "./framing"; import { ambientBedArgs, tickWavArgs, sweepWavArgs, soundscapeArgs } from "./sound"; /** The render-affecting subset of the demo config (no capture/tts/auth fields). */ @@ -118,6 +124,14 @@ export async function renderVideo(inputs: RenderInputs): Promise { // Cards bake their own fades in cardArgs, so they are excluded here — // stacking the transition fade would double-fade the end card's open. const isCard = inputs.segmentKinds?.[i] === "card"; + const rawSize = isCard || contentDecoupled ? await probeSizePx(rawSegments[i]!) : undefined; + if (isCard) { + assertFullBleedCanvasAspect( + config.resolution, + rawSize!, + tts[i]?.shotId ?? `segment ${i}`, + ); + } const fadeInSec = i > 0 && !isCard && config.theme.fadeInMs > 0 ? config.theme.fadeInMs / 1000 : undefined; if (frame.enabled && maskPng && !isCard) { // Framed-aspect guard: with the window decoupled from the canvas @@ -126,7 +140,7 @@ export async function renderVideo(inputs: RenderInputs): Promise { // skipped entirely for canvas-aspect renders (assert is a no-op there), // so landscape stays argument-identical. if (contentDecoupled) { - assertFramedContentAspect(frameOpts, await probeSizePx(rawSegments[i]!), tts[i]?.shotId ?? `segment ${i}`); + assertFramedContentAspect(frameOpts, rawSize!, tts[i]?.shotId ?? `segment ${i}`); } const rawSec = await probeDurationSec(rawSegments[i]!); await ffmpeg( @@ -209,13 +223,21 @@ export async function renderVideo(inputs: RenderInputs): Promise { // 9. Concat video segments const videoListPath = join(segDir, "list.txt"); - await writeFile(videoListPath, concatListContent(segMp4s), "utf8"); + await writeFile( + videoListPath, + concatListContent(segMp4s.map((path) => basename(path))), + "utf8", + ); const concatVideoPath = join(out, "video.mp4"); await ffmpeg(concatArgs(videoListPath, concatVideoPath)); // 10. Concat audio segments const audioListPath = join(audioDir, "list.txt"); - await writeFile(audioListPath, concatListContent(paddedAudioPaths), "utf8"); + await writeFile( + audioListPath, + concatListContent(paddedAudioPaths.map((path) => basename(path))), + "utf8", + ); const concatAudioPath = join(out, "audio.mp3"); await ffmpeg(concatAudioArgs(audioListPath, concatAudioPath)); diff --git a/src/source-build.test.ts b/src/source-build.test.ts new file mode 100644 index 0000000..64e45ad --- /dev/null +++ b/src/source-build.test.ts @@ -0,0 +1,315 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { chmod, mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { + assertSourceBuildUnchanged, + attestSourceBuild, + computeSourceBuildAttestation, + type SourceBuildSession, + validateSourceBuildAttestation, +} from "./source-build"; + +const exec = promisify(execFile); + +async function git(repo: string, ...args: string[]): Promise { + const { stdout } = await exec("git", args, { cwd: repo }); + return stdout.trim(); +} + +type SourceFixture = { + authorityRoot: string; + root: string; + runner: string; + commit: string; +}; + +async function freezeFixture(root: string): Promise { + await exec("chmod", ["-R", "a-w", join(root, "src")]); + await exec("chmod", [ + "a-w", + root, + join(root, "scripts"), + join(root, "scripts/remote-entry.ts"), + join(root, "scripts/run-source-attested-render.sh"), + join(root, "package.json"), + join(root, "pnpm-lock.yaml"), + join(root, "tsconfig.json"), + ]); +} + +async function fixtureRepo(): Promise { + const authorityRoot = await mkdtemp(join(tmpdir(), "source-build-authority-")); + await mkdir(join(authorityRoot, "src")); + await mkdir(join(authorityRoot, "scripts")); + const files: Record = { + "src/cli.ts": "export const runner = true;\n", + "src/helper.ts": "export const helper = true;\n", + "scripts/remote-entry.ts": "export const remote = true;\n", + "scripts/run-source-attested-render.sh": "#!/usr/bin/env bash\n", + "package.json": '{"name":"fixture"}\n', + "pnpm-lock.yaml": "lockfileVersion: '9.0'\n", + "tsconfig.json": '{"compilerOptions":{}}\n', + }; + for (const [path, content] of Object.entries(files)) { + await writeFile(join(authorityRoot, path), content); + } + await chmod(join(authorityRoot, "scripts/run-source-attested-render.sh"), 0o755); + await git(authorityRoot, "init"); + await git(authorityRoot, "config", "user.email", "source-build@example.test"); + await git(authorityRoot, "config", "user.name", "Source Build Test"); + await git(authorityRoot, "add", "."); + await git(authorityRoot, "commit", "-m", "fixture"); + const commit = await git(authorityRoot, "rev-parse", "HEAD"); + const snapshotParent = await mkdtemp(join(tmpdir(), "source-build-snapshot-")); + await chmod(snapshotParent, 0o700); + const root = join(snapshotParent, "source"); + await git(authorityRoot, "worktree", "add", "--detach", root, commit); + await freezeFixture(root); + return { + authorityRoot, + root, + runner: join(root, "src/cli.ts"), + commit, + }; +} + +async function withSnapshotLaunch( + fixture: SourceFixture, + operation: () => Promise, +): Promise { + const names = { + AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_ROOT: fixture.root, + AGENT_DEMO_VIDEO_SOURCE_AUTHORITY_REPO: fixture.authorityRoot, + AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_COMMIT: fixture.commit, + }; + const previous = Object.fromEntries( + Object.keys(names).map((name) => [name, process.env[name]]), + ); + Object.assign(process.env, names); + try { + return await operation(); + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } +} + +describe("source-build attestation", () => { + it("refuses a source attestation outside the committed snapshot launcher", async () => { + const { runner } = await fixtureRepo(); + await expect(attestSourceBuild(runner)).rejects.toThrow( + /committed source snapshot launcher/, + ); + }); + + it("computes a deterministic commit-bound scoped tree and package hashes", async () => { + const { root, commit } = await fixtureRepo(); + const first = await computeSourceBuildAttestation(root, commit); + const second = await computeSourceBuildAttestation(root, commit); + + expect(second).toEqual(first); + expect(first).toMatchObject({ + version: 2, + executionMode: "detached-commit-snapshot", + commit, + runner: "src/cli.ts", + scopedPaths: [ + "src", + "scripts/remote-entry.ts", + "scripts/run-source-attested-render.sh", + "package.json", + "pnpm-lock.yaml", + "tsconfig.json", + ], + }); + expect(first.treeSha256).toMatch(/^[a-f0-9]{64}$/); + expect(first.packageJsonSha256).toMatch(/^[a-f0-9]{64}$/); + expect(first.pnpmLockSha256).toMatch(/^[a-f0-9]{64}$/); + await expect(validateSourceBuildAttestation(root, first)).resolves.toEqual(first); + }); + + it("recomputes and rejects a forged attestation for any named commit", async () => { + const { root, commit } = await fixtureRepo(); + const attestation = await computeSourceBuildAttestation(root, commit); + await expect( + validateSourceBuildAttestation(root, { ...attestation, treeSha256: "0".repeat(64) }), + ).rejects.toThrow(/does not match commit/); + }); + + it("requires the actual tracked src/cli.ts runner and a clean scoped worktree", async () => { + const fixture = await fixtureRepo(); + const { root, runner } = fixture; + await expect(withSnapshotLaunch(fixture, () => attestSourceBuild(runner))).resolves.toMatchObject({ + attestation: { runner: "src/cli.ts" }, + repoRoot: root, + runnerPath: runner, + }); + + await expect( + withSnapshotLaunch(fixture, () => attestSourceBuild(join(root, "src/helper.ts"))), + ).rejects.toThrow(/src\/cli\.ts runner/); + + await chmod(join(root, "src/helper.ts"), 0o644); + await writeFile(join(root, "src/helper.ts"), "changed\n"); + await chmod(join(root, "src/helper.ts"), 0o444); + await expect( + withSnapshotLaunch(fixture, () => attestSourceBuild(runner)), + ).rejects.toThrow(/working-tree bytes differ/); + + await git(root, "add", "src/helper.ts"); + await expect( + withSnapshotLaunch(fixture, () => attestSourceBuild(runner)), + ).rejects.toThrow(/Git index differs from commit/); + }); + + it("rejects every extra scoped file but permits ignored artifacts outside the scope", async () => { + const fixture = await fixtureRepo(); + const { root, runner } = fixture; + await chmod(root, 0o755); + await mkdir(join(root, "out")); + await writeFile(join(root, "out/ignored.tmp"), "ignored capture output\n"); + await chmod(root, 0o555); + await expect( + withSnapshotLaunch(fixture, () => attestSourceBuild(runner)), + ).resolves.toBeDefined(); + + await chmod(join(root, "src"), 0o755); + await writeFile(join(root, "src/ignored.tmp"), "ignored\n"); + await chmod(join(root, "src/ignored.tmp"), 0o444); + await chmod(join(root, "src"), 0o555); + await expect( + withSnapshotLaunch(fixture, () => attestSourceBuild(runner)), + ).rejects.toThrow(/src\/ignored\.tmp/); + }); + + it("does not trust assume-unchanged or skip-worktree index flags", async () => { + const assumed = await fixtureRepo(); + await git(assumed.root, "update-index", "--assume-unchanged", "src/cli.ts"); + await chmod(assumed.runner, 0o644); + await writeFile(assumed.runner, 'export const runner = "forged";\n'); + await chmod(assumed.runner, 0o444); + await expect( + withSnapshotLaunch(assumed, () => attestSourceBuild(assumed.runner)), + ).rejects.toThrow(/assume-unchanged|working-tree bytes/); + + const skipped = await fixtureRepo(); + await git(skipped.root, "update-index", "--skip-worktree", "src/cli.ts"); + await chmod(skipped.runner, 0o644); + await writeFile(skipped.runner, 'export const runner = "forged";\n'); + await chmod(skipped.runner, 0o444); + await expect( + withSnapshotLaunch(skipped, () => attestSourceBuild(skipped.runner)), + ).rejects.toThrow(/skip-worktree|working-tree bytes/); + }); + + it("does not let inherited Git repository overrides attest a different repository", async () => { + const victim = await fixtureRepo(); + const alternate = await fixtureRepo(); + const forgedRunner = 'export const runner = "forged";\n'; + await writeFile(join(alternate.authorityRoot, "src/cli.ts"), forgedRunner); + await git(alternate.authorityRoot, "add", "src/cli.ts"); + await git(alternate.authorityRoot, "commit", "-m", "forged alternate source"); + await chmod(victim.runner, 0o644); + await writeFile(victim.runner, forgedRunner); + await chmod(victim.runner, 0o444); + + const overrides = { + GIT_DIR: process.env.GIT_DIR, + GIT_WORK_TREE: process.env.GIT_WORK_TREE, + GIT_COMMON_DIR: process.env.GIT_COMMON_DIR, + }; + process.env.GIT_DIR = join(alternate.authorityRoot, ".git"); + process.env.GIT_WORK_TREE = victim.root; + process.env.GIT_COMMON_DIR = join(alternate.authorityRoot, ".git"); + try { + await expect( + withSnapshotLaunch(victim, () => attestSourceBuild(victim.runner)), + ).rejects.toThrow(/working-tree bytes differ from commit/); + } finally { + for (const [name, value] of Object.entries(overrides)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } + }); + + it("does not execute caller-selected global Git configuration", async () => { + const fixture = await fixtureRepo(); + const hostileHome = await mkdtemp(join(tmpdir(), "source-build-hostile-home-")); + const fsmonitorMarker = join(hostileHome, "fsmonitor-ran"); + const fsmonitor = join(hostileHome, "fsmonitor.sh"); + await writeFile( + fsmonitor, + `#!/bin/sh\n: > "${fsmonitorMarker}"\nexit 0\n`, + ); + await chmod(fsmonitor, 0o755); + await writeFile( + join(hostileHome, ".gitconfig"), + `[core]\n\tfsmonitor = ${fsmonitor}\n`, + ); + + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.HOME = hostileHome; + process.env.XDG_CONFIG_HOME = hostileHome; + try { + await expect( + withSnapshotLaunch(fixture, () => attestSourceBuild(fixture.runner)), + ).resolves.toBeDefined(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + expect(existsSync(fsmonitorMarker)).toBe(false); + }); + + it("blocks a completed render claim when the scoped source changes after admission", async () => { + const fixture = await fixtureRepo(); + const { root, runner } = fixture; + const session = await withSnapshotLaunch( + fixture, + () => attestSourceBuild(runner), + ); + await chmod(join(root, "src/helper.ts"), 0o644); + await writeFile(join(root, "src/helper.ts"), "changed during render\n"); + + await expect(assertSourceBuildUnchanged(session)).rejects.toThrow( + /source changed during render/, + ); + }); + + it("rejects a structurally copied source-build session", async () => { + const fixture = await fixtureRepo(); + const session = await withSnapshotLaunch( + fixture, + () => attestSourceBuild(fixture.runner), + ); + const forged = { + ...session, + attestation: { + ...session.attestation, + scopedPaths: [...session.attestation.scopedPaths], + }, + }; + + await expect( + assertSourceBuildUnchanged(forged as unknown as SourceBuildSession), + ).rejects.toThrow( + /issued by this committed snapshot module/, + ); + }); +}); diff --git a/src/source-build.ts b/src/source-build.ts new file mode 100644 index 0000000..4f5d48d --- /dev/null +++ b/src/source-build.ts @@ -0,0 +1,493 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { sanitizedGitEnvironment } from "./git-environment"; + +export const SOURCE_BUILD_SCOPED_PATHS = [ + "src", + "scripts/remote-entry.ts", + "scripts/run-source-attested-render.sh", + "package.json", + "pnpm-lock.yaml", + "tsconfig.json", +] as const; + +export type SourceBuildAttestation = { + version: 2; + executionMode: "detached-commit-snapshot"; + commit: string; + runner: "src/cli.ts"; + scopedPaths: [...typeof SOURCE_BUILD_SCOPED_PATHS]; + treeSha256: string; + packageJsonSha256: string; + pnpmLockSha256: string; +}; + +declare const sourceBuildSessionBrand: unique symbol; + +export type SourceBuildSession = { + readonly [sourceBuildSessionBrand]: true; + attestation: SourceBuildAttestation; + repoRoot: string; + runnerPath: string; +}; + +const issuedSourceBuildSessions = new WeakSet(); + +function runGit(repoRoot: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + "/usr/bin/git", + [ + "--no-replace-objects", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + ...args, + ], + { + cwd: repoRoot, + encoding: "buffer", + env: sanitizedGitEnvironment(), + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (!error) { + resolve(stdout); + return; + } + const detail = stderr.toString("utf8").trim() || error.message; + reject(new Error(`[agent-demo-video] git ${args[0] ?? "command"} failed: ${detail}`, { cause: error })); + }, + ); + }); +} + +function sha256(content: Buffer | string): string { + return createHash("sha256").update(content).digest("hex"); +} + +async function resolveCommit(repoRoot: string, commit: string): Promise { + return (await runGit(repoRoot, ["rev-parse", "--verify", "--end-of-options", `${commit}^{commit}`])) + .toString("utf8") + .trim(); +} + +async function commitFile(repoRoot: string, commit: string, path: string): Promise { + return runGit(repoRoot, ["show", `${commit}:${path}`]); +} + +/** + * Recompute the source-build claim from committed Git objects only. + * + * This is intentionally independent of the current checkout, so Factory or + * another verifier can validate an attestation for any locally available + * commit without checking it out. + */ +export async function computeSourceBuildAttestation( + repoRoot: string, + commit = "HEAD", +): Promise { + const root = await realpath(repoRoot); + const resolvedCommit = await resolveCommit(root, commit); + await runGit(root, ["cat-file", "-e", `${resolvedCommit}:src/cli.ts`]); + const scopedTree = await runGit(root, [ + "ls-tree", + "-r", + "-z", + resolvedCommit, + "--", + ...SOURCE_BUILD_SCOPED_PATHS, + ]); + const treeHash = createHash("sha256") + .update(resolvedCommit) + .update("\0") + .update(scopedTree) + .digest("hex"); + const [packageJson, pnpmLock] = await Promise.all([ + commitFile(root, resolvedCommit, "package.json"), + commitFile(root, resolvedCommit, "pnpm-lock.yaml"), + ]); + return { + version: 2, + executionMode: "detached-commit-snapshot", + commit: resolvedCommit, + runner: "src/cli.ts", + scopedPaths: [...SOURCE_BUILD_SCOPED_PATHS], + treeSha256: treeHash, + packageJsonSha256: sha256(packageJson), + pnpmLockSha256: sha256(pnpmLock), + }; +} + +function attestationsEqual( + actual: SourceBuildAttestation, + expected: SourceBuildAttestation, +): boolean { + return ( + actual.version === expected.version && + actual.executionMode === expected.executionMode && + actual.commit === expected.commit && + actual.runner === expected.runner && + actual.treeSha256 === expected.treeSha256 && + actual.packageJsonSha256 === expected.packageJsonSha256 && + actual.pnpmLockSha256 === expected.pnpmLockSha256 && + actual.scopedPaths.length === expected.scopedPaths.length && + actual.scopedPaths.every((path, index) => path === expected.scopedPaths[index]) + ); +} + +/** Recompute and compare every attested field for the attestation's commit. */ +export async function validateSourceBuildAttestation( + repoRoot: string, + attestation: SourceBuildAttestation, +): Promise { + const expected = await computeSourceBuildAttestation(repoRoot, attestation.commit); + if (!attestationsEqual(attestation, expected)) { + throw new Error( + `[agent-demo-video] source-build attestation does not match commit ${attestation.commit}`, + ); + } + return expected; +} + +async function assertActualRunner(repoRoot: string, runnerPath: string): Promise { + const actualRunner = await realpath(runnerPath); + const expectedRunner = await realpath(join(repoRoot, "src/cli.ts")); + if (actualRunner !== expectedRunner) { + throw new Error( + `[agent-demo-video] --attest-source-build must run the actual tracked src/cli.ts runner; got ` + + relative(repoRoot, actualRunner).split(sep).join("/"), + ); + } + await runGit(repoRoot, ["ls-files", "--error-unmatch", "--", "src/cli.ts"]); + return actualRunner; +} + +type GitTreeEntry = { + mode: string; + object: string; + path: string; +}; + +function parseTreeEntries(bytes: Buffer): GitTreeEntry[] { + return bytes + .toString("utf8") + .split("\0") + .filter(Boolean) + .map((entry) => { + const tab = entry.indexOf("\t"); + const header = entry.slice(0, tab).split(" "); + const path = entry.slice(tab + 1); + if (tab < 0 || header.length !== 3 || !header[0] || !header[2] || !path) { + throw new Error(`[agent-demo-video] could not parse scoped Git tree entry: ${entry}`); + } + return { mode: header[0], object: header[2], path }; + }); +} + +async function collectScopedFiles( + repoRoot: string, + requireReadOnly = false, +): Promise { + const files: string[] = []; + const visit = async (relativePath: string): Promise => { + const absolute = join(repoRoot, relativePath); + const stat = await lstat(absolute); + if (stat.isSymbolicLink()) { + throw new Error(`[agent-demo-video] scoped source path may not be a symlink: ${relativePath}`); + } + if (requireReadOnly && (stat.mode & 0o222) !== 0) { + throw new Error( + `[agent-demo-video] committed source snapshot path must be non-writable: ${relativePath}`, + ); + } + if (stat.isDirectory()) { + const names = await readdir(absolute); + for (const name of names.sort()) { + await visit(join(relativePath, name)); + } + return; + } + if (!stat.isFile()) { + throw new Error(`[agent-demo-video] scoped source path must be a regular file: ${relativePath}`); + } + files.push(relativePath.split(sep).join("/")); + }; + for (const path of SOURCE_BUILD_SCOPED_PATHS) { + await visit(path); + } + return files.sort(); +} + +async function assertScopedPathsClean( + repoRoot: string, + commit: string, + requireReadOnly = false, +): Promise { + const treeEntries = parseTreeEntries(await runGit(repoRoot, [ + "ls-tree", + "-r", + "-z", + commit, + "--", + ...SOURCE_BUILD_SCOPED_PATHS, + ])); + const treeByPath = new Map(treeEntries.map((entry) => [entry.path, entry])); + const actualPaths = await collectScopedFiles(repoRoot, requireReadOnly); + const expectedPaths = [...treeByPath.keys()].sort(); + if ( + actualPaths.length !== expectedPaths.length || + actualPaths.some((path, index) => path !== expectedPaths[index]) + ) { + const actual = new Set(actualPaths); + const expected = new Set(expectedPaths); + const added = actualPaths.filter((path) => !expected.has(path)); + const missing = expectedPaths.filter((path) => !actual.has(path)); + throw new Error( + `[agent-demo-video] scoped source paths are dirty; extra=[${added.join(", ")}], ` + + `missing=[${missing.join(", ")}]`, + ); + } + + const indexEntries = (await runGit(repoRoot, [ + "ls-files", + "--stage", + "-z", + "--", + ...SOURCE_BUILD_SCOPED_PATHS, + ])) + .toString("utf8") + .split("\0") + .filter(Boolean); + const indexByPath = new Map(); + for (const entry of indexEntries) { + const tab = entry.indexOf("\t"); + const [mode, object, stage] = entry.slice(0, tab).split(" "); + const path = entry.slice(tab + 1); + if (tab < 0 || !mode || !object || !stage || !path || indexByPath.has(path)) { + throw new Error(`[agent-demo-video] scoped Git index entry is invalid: ${entry}`); + } + indexByPath.set(path, { mode, object, stage }); + } + + const flags = (await runGit(repoRoot, [ + "ls-files", + "-v", + "-z", + "--", + ...SOURCE_BUILD_SCOPED_PATHS, + ])) + .toString("utf8") + .split("\0") + .filter(Boolean); + const flagByPath = new Map(flags.map((entry) => [entry.slice(2), entry.slice(0, 1)])); + + for (const path of expectedPaths) { + const expected = treeByPath.get(path)!; + const indexed = indexByPath.get(path); + if ( + expected.mode !== "100644" && + expected.mode !== "100755" + ) { + throw new Error(`[agent-demo-video] unsupported scoped Git object mode ${expected.mode}: ${path}`); + } + if ( + !indexed || + indexed.stage !== "0" || + indexed.mode !== expected.mode || + indexed.object !== expected.object + ) { + throw new Error(`[agent-demo-video] scoped Git index differs from commit ${commit}: ${path}`); + } + if (flagByPath.get(path) !== "H") { + throw new Error( + `[agent-demo-video] scoped Git index uses assume-unchanged, skip-worktree, or another ` + + `nonstandard flag: ${path}`, + ); + } + const absolute = join(repoRoot, path); + const stat = await lstat(absolute); + const expectedExecutable = expected.mode === "100755"; + if ( + stat.isSymbolicLink() || + !stat.isFile() || + ((stat.mode & 0o111) !== 0) !== expectedExecutable + ) { + throw new Error(`[agent-demo-video] scoped working-tree type or mode differs from commit: ${path}`); + } + const [workingBytes, committedBytes] = await Promise.all([ + readFile(absolute), + runGit(repoRoot, ["cat-file", "blob", expected.object]), + ]); + if (!workingBytes.equals(committedBytes)) { + throw new Error(`[agent-demo-video] scoped working-tree bytes differ from commit ${commit}: ${path}`); + } + } +} + +/** + * Admit the current source runner and bind it to a clean, committed source + * tree. Ordinary CLI runs never call this function. + */ +export async function attestSourceBuild(runnerPath: string): Promise { + const launchRoot = process.env.AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_ROOT; + const authorityRoot = process.env.AGENT_DEMO_VIDEO_SOURCE_AUTHORITY_REPO; + const launchCommit = process.env.AGENT_DEMO_VIDEO_SOURCE_SNAPSHOT_COMMIT; + if (!launchRoot || !authorityRoot || !launchCommit) { + throw new Error( + `[agent-demo-video] --attest-source-build requires the committed source snapshot launcher`, + ); + } + + const repoRoot = ( + await runGit(dirname(runnerPath), ["rev-parse", "--show-toplevel"]) + ).toString("utf8").trim(); + const root = await realpath(repoRoot); + const expectedRoot = await realpath(launchRoot); + if (root !== expectedRoot) { + throw new Error( + `[agent-demo-video] source snapshot runner root does not match the launcher-owned root`, + ); + } + + const privateParent = await lstat(dirname(root)); + if ( + privateParent.isSymbolicLink() || + !privateParent.isDirectory() || + (privateParent.mode & 0o777) !== 0o700 || + (typeof process.getuid === "function" && privateParent.uid !== process.getuid()) + ) { + throw new Error( + `[agent-demo-video] source snapshot parent must be a private launcher-owned 0700 directory`, + ); + } + + const authority = await realpath(authorityRoot); + const discoveredAuthority = ( + await runGit(authority, ["rev-parse", "--show-toplevel"]) + ).toString("utf8").trim(); + if (await realpath(discoveredAuthority) !== authority) { + throw new Error( + `[agent-demo-video] source snapshot authority must name the repository root`, + ); + } + const resolvedLaunchCommit = await resolveCommit(authority, launchCommit); + if (resolvedLaunchCommit !== launchCommit) { + throw new Error( + `[agent-demo-video] source snapshot launcher must pin one full commit SHA`, + ); + } + const snapshotHead = await resolveCommit(root, "HEAD"); + if (snapshotHead !== resolvedLaunchCommit) { + throw new Error( + `[agent-demo-video] source snapshot HEAD does not match the launcher commit`, + ); + } + const snapshotBranch = ( + await runGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]) + ).toString("utf8").trim(); + if (snapshotBranch !== "HEAD") { + throw new Error( + `[agent-demo-video] source snapshot must be a detached Git worktree`, + ); + } + const [authorityCommon, snapshotCommon] = await Promise.all([ + runGit(authority, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ]), + runGit(root, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ]), + ]); + if ( + await realpath(authorityCommon.toString("utf8").trim()) !== + await realpath(snapshotCommon.toString("utf8").trim()) + ) { + throw new Error( + `[agent-demo-video] source snapshot is not owned by the authority repository`, + ); + } + + const actualRunner = await assertActualRunner(root, runnerPath); + const attestation = await computeSourceBuildAttestation( + root, + resolvedLaunchCommit, + ); + await assertScopedPathsClean(root, attestation.commit, true); + const session = { + attestation, + repoRoot: root, + runnerPath: actualRunner, + } as SourceBuildSession; + issuedSourceBuildSessions.add(session); + return session; +} + +function assertIssuedSourceBuildSession(session: SourceBuildSession): void { + if (!issuedSourceBuildSessions.has(session)) { + throw new Error( + `[agent-demo-video] source-build session was not issued by this committed snapshot module`, + ); + } +} + +/** + * Bind the admitted capability to the exact pipeline and source-admission + * modules executing inside the same detached snapshot module graph. + */ +export async function assertSourceBuildExecutionContext( + session: SourceBuildSession, + pipelineModulePath: string, +): Promise { + assertIssuedSourceBuildSession(session); + const [ + actualPipeline, + expectedPipeline, + actualSourceBuild, + expectedSourceBuild, + ] = await Promise.all([ + realpath(pipelineModulePath), + realpath(join(session.repoRoot, "src/pipeline.ts")), + realpath(fileURLToPath(import.meta.url)), + realpath(join(session.repoRoot, "src/source-build.ts")), + ]); + if ( + actualPipeline !== expectedPipeline || + actualSourceBuild !== expectedSourceBuild + ) { + throw new Error( + `[agent-demo-video] source-build capability cannot cross snapshot module graphs`, + ); + } +} + +/** Blocking post-render drift check for a source-attested run. */ +export async function assertSourceBuildUnchanged(session: SourceBuildSession): Promise { + try { + assertIssuedSourceBuildSession(session); + await assertActualRunner(session.repoRoot, session.runnerPath); + await assertScopedPathsClean( + session.repoRoot, + session.attestation.commit, + true, + ); + const current = await computeSourceBuildAttestation(session.repoRoot, "HEAD"); + if (!attestationsEqual(current, session.attestation)) { + throw new Error(`HEAD no longer matches attested commit ${session.attestation.commit}`); + } + } catch (error) { + throw new Error( + `[agent-demo-video] source changed during render; the completed artifact is not source-attested: ` + + `${(error as Error).message}`, + { cause: error }, + ); + } +} diff --git a/src/types.ts b/src/types.ts index 364e0da..d716c49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -219,10 +219,19 @@ export const DemoConfigSchema = z.object({ subtitle: z.string().optional(), url: z.string().optional(), accent: hexColor().default("#3fb950"), + // Backward-compatible shared switch. titleCard/endCard, when declared, + // override only their own side so an artifact-first piece can cold-open on + // proof while retaining the generated disclosure/end-screen card. cards: z.boolean().default(true), + titleCard: z.boolean().optional(), + endCard: z.boolean().optional(), titleSec: z.number().min(0.5).default(2.2), endSec: z.number().min(0.5).default(3.0), - }).strict().optional(), + }).strict().transform((b) => ({ + ...b, + titleCard: b.titleCard ?? b.cards, + endCard: b.endCard ?? b.cards, + })).optional(), // Optional CSS injected into every captured page before interaction. Use to // stabilise capture of dashboards taller than the output frame — e.g. bound a // growing list's height so the document never overflows the viewport (which @@ -250,6 +259,8 @@ export type PreflightFindingKind = | "missing-selector" /** A prebaked shot's clip is absent at its resolved path. */ | "missing-clip" + /** A finished full-bleed clip cannot fill the declared output canvas without bars. */ + | "invalid-clip-geometry" /** The page a shot opens could not be loaded, or returned a non-OK status. */ | "unreachable" /** Prebaked shot declares selector actions, which capture short-circuits and never runs. */ diff --git a/tests/brand-card-selection.smoke.test.ts b/tests/brand-card-selection.smoke.test.ts new file mode 100644 index 0000000..9ca4f67 --- /dev/null +++ b/tests/brand-card-selection.smoke.test.ts @@ -0,0 +1,88 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { runPipeline } from "../src/pipeline"; +import { DemoConfigSchema } from "../src/types"; + +function sh(bin: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${bin} exited ${code}: ${stderr.slice(0, 400)}`)); + }); + }); +} + +async function renderTimeline(brand: Record): Promise { + vi.stubEnv("FAKE_TTS", "1"); + try { + const dir = await mkdtemp(join(tmpdir(), "brand-card-selection-")); + const clip = join(dir, "proof.mp4"); + await sh("ffmpeg", [ + "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "color=c=blue:s=640x360:d=1", + "-c:v", "libx264", "-pix_fmt", "yuv420p", clip, + ]); + const script = join(dir, "DEMO_SCRIPT.md"); + await writeFile( + script, + [ + "### SHOT proof", + "- target: prebaked", + `- clip: ${clip}`, + "- fullBleed: true", + "- narration: Proof first.", + "", + ].join("\n"), + ); + const config = DemoConfigSchema.parse({ + script, + dashboardBaseUrl: "http://localhost", + out: join(dir, "out"), + resolution: { width: 640, height: 360 }, + audio: { soundDesign: false }, + brand, + }); + + const result = await runPipeline(config); + return result.report.timeline.entries.map((entry) => entry.shotId); + } finally { + vi.unstubAllEnvs(); + } +} + +describe("independent brand-card selection", () => { + it("cold-opens on the first proof shot while retaining only the closing card", async () => { + expect(await renderTimeline({ + title: "Reviewed disclosure", + cards: true, + titleCard: false, + endCard: true, + endSec: 0.5, + })).toEqual(["proof", "__card-end"]); + }, 120_000); + + it("can retain only the opening card", async () => { + expect(await renderTimeline({ + title: "Reviewed disclosure", + cards: true, + titleCard: true, + endCard: false, + titleSec: 0.5, + })).toEqual(["__card-title", "proof"]); + }, 120_000); + + it("keeps the legacy cards-only path rendering both cards", async () => { + expect(await renderTimeline({ + title: "Reviewed disclosure", + cards: true, + titleSec: 0.5, + endSec: 0.5, + })).toEqual(["__card-title", "proof", "__card-end"]); + }, 120_000); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 10f6385..14c9487 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -19,8 +19,10 @@ describe("parseCommand --render-host", () => { renderHost: "build-host", }); }); - it("still parses the login verb (render host irrelevant)", () => { - expect(parseCommand(["login", "cfg.json"])).toEqual({ cmd: "login", cfgPath: "cfg.json", renderHost: undefined }); + it("rejects a render host for login instead of silently ignoring it", () => { + expect(() => + parseCommand(["login", "cfg.json", "--render-host", "build-host"]), + ).toThrow(/--render-host.*pipeline run/); }); it("fails loudly on a missing or option-like --render-host value (no fallback, no ssh option injection)", () => { expect(() => parseCommand(["cfg.json", "--render-host"])).toThrow(/render-host/); diff --git a/tests/factory-ai-at-work-pack.test.ts b/tests/factory-ai-at-work-pack.test.ts new file mode 100644 index 0000000..a826d19 --- /dev/null +++ b/tests/factory-ai-at-work-pack.test.ts @@ -0,0 +1,1852 @@ +import { createHash } from "node:crypto"; +import { closeSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { chmod, link, mkdir, mkdtemp, readFile, rename, rmdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { resolveClipPath } from "../src/clips"; +import { loadConfig } from "../src/config"; +import { estimateDurationSec } from "../src/fake-tts"; +import { parseScript } from "../src/parse-script"; +import { captureViewport } from "../src/platforms"; +import { runPreflight } from "../src/preflight"; +import { digest, digestFull, stableConfigJson } from "../src/provenance"; +import { + computeSourceBuildAttestation, + type SourceBuildAttestation, +} from "../src/source-build"; + +const ROOT = "demos/factory-ai-at-work/gate-01"; +const MASTER = `${ROOT}/master/demo.config.json`; +const CUTS = [ + `${ROOT}/cuts/cut-a/demo.config.json`, + `${ROOT}/cuts/cut-b/demo.config.json`, + `${ROOT}/cuts/cut-c/demo.config.json`, +] as const; +const CONFIGS = [MASTER, ...CUTS] as const; +const CUT_HOOKS: Record = { + [CUTS[0]]: "Two Windows settings decide whether Claude Cowork works at all.", + [CUTS[1]]: "A real job for Claude Cowork: forty files of Downloads chaos.", + [CUTS[2]]: "Claude Cowork on Windows may run your task on Anthropic's servers, not your PC.", +}; +const DISCLOSURE = "Produced by AI, directed and reviewed by Dan Mercede"; +const RECEIPT_VALIDATOR = "scripts/validate-factory-ai-at-work-receipt.sh"; +const ATTEMPT_PROMOTER = "scripts/promote-factory-ai-at-work-attempt.sh"; +const SOURCE_ATTESTED_LAUNCHER = "scripts/run-source-attested-render.sh"; +const NODE_BIN = realpathSync("/usr/bin/node"); +const SOURCE_COMMIT = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); +const VOICE = { + voiceId: "AwstCxsCY8YE2KYw66By", + modelId: "eleven_multilingual_v2", + seed: 42, + stability: 0.5, + similarity: 0.75, +}; +const CHAPTERS_BYTES = [ + "0:00 What you'll build", + "0:10 The 4 steps", + "0:20 What you need", + "0:30 Step 1: Install it right (2 traps)", + "0:40 Step 2: First real task on real files", + "0:50 Step 3: Where Cowork actually runs", + "1:00 Step 4: Web, phone, and schedules", + "1:10 Recap", + "1:20 What's next", + "", +].join("\n"); +const artifactReportBytes = ( + name: string, + ttsMode: "real" | "fake" = "real", + inputs = { + configHash: "a".repeat(16), + scriptHash: "b".repeat(16), + configSha256: "a".repeat(64), + scriptSha256: "b".repeat(64), + clips: [] as Array<{ shotId: string; sha256: string }>, + }, + entries: Array<{ shotId: string; startSec: number; durationSec: number }> = [], + sourceBuildAttestation?: SourceBuildAttestation, +) => { + const totalSec = entries.length > 0 + ? entries.reduce((total, entry) => total + entry.durationSec, 0) + : 1; + return JSON.stringify({ + voice: VOICE, + ttsMode, + inputs, + renderedOn: "local", + tools: { ffmpeg: "6.1", ffprobe: "6.1", playwright: "1.61.1", node: "v22" }, + timeline: { entries, totalSec }, + render: { + totalSec, + segments: entries.length || 1, + ticks: 0, + parity: { ok: true, problems: [] }, + }, + limits: { maxDurationSec: name === "master" ? 600 : 60 }, + preflight: { ran: true, declined: false, findings: 0, unverifiedShotIds: [] }, + ...(sourceBuildAttestation ? { sourceBuildAttestation } : {}), +}); +}; +const sha256Text = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +type ArtifactHashes = Record; +let mediaFixtures: Promise<{ + landscape: Buffer; + landscapeEndCard: Buffer; + landscapeFinal: Buffer; + portrait: Buffer; +}> | undefined; + +async function getMediaFixtures(): Promise<{ + landscape: Buffer; + landscapeEndCard: Buffer; + landscapeFinal: Buffer; + portrait: Buffer; +}> { + if (!mediaFixtures) { + mediaFixtures = (async () => { + const dir = await mkdtemp(join(tmpdir(), "factory-media-fixtures-")); + const render = (name: string, size: string, durationSec = 1) => { + const path = join(dir, `${name}.mp4`); + const result = spawnSync("ffmpeg", [ + "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", `color=c=black:s=${size}:r=30:d=${durationSec}`, + "-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo", + "-t", String(durationSec), + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "35", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + path, + ], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`ffmpeg fixture failed: ${result.stderr}`); + return path; + }; + const landscapePath = render("landscape", "1920x1080", 10); + const landscapeEndCardPath = render("landscape-end-card", "1920x1080", 15); + const landscapeFinalPath = render("landscape-final", "1920x1080", 105); + const portraitPath = render("portrait", "1080x1920"); + return { + landscape: await readFile(landscapePath), + landscapeEndCard: await readFile(landscapeEndCardPath), + landscapeFinal: await readFile(landscapeFinalPath), + portrait: await readFile(portraitPath), + }; + })(); + } + return mediaFixtures; +} + +function loadPack(path: string) { + const config = loadConfig(path); + const manifest = parseScript(readFileSync(config.script, "utf8")); + return { config, manifest }; +} + +function completeProductionReceipt( + template: string, + artifactHashes: ArtifactHashes, + roots: { attempt: string; reviewed: string }, +): string { + let receipt = template; + const field = (label: string, value: string) => { + receipt = receipt.replace(`- ${label}: PENDING`, `- ${label}: ${value}`); + }; + field("Run ID", "20260730T120000Z-real"); + field("Source commit SHA", SOURCE_COMMIT); + field("Attempt root", roots.attempt); + field("Reviewed root", roots.reviewed); + field("Capture inventory reference", "CAPTURE_PLAN.md R1 through R8"); + field( + "Claim ledger SHA-256", + sha256Text(readFileSync(join(roots.attempt, "evidence", "source", "CLAIM_LEDGER.md"))), + ); + field("Claim source refresh date", "2026-07-30"); + field("Reviewer", "Dan Mercede"); + field("Review start UTC", "2026-07-30T12:00:00Z"); + field("Review stop UTC", "2026-07-30T12:24:00Z"); + field("Total Dan review minutes", "24"); + field("Review decision and rationale", "All four final mixes and evidence passed review."); + field("Render handoff status", "REVIEWED_FOR_PUBLISH_HANDOFF"); + for (const [name, geometry] of [ + ["master", "1920x1080, SAR 1:1"], + ["cut-a", "1080x1920, SAR 1:1"], + ["cut-b", "1080x1920, SAR 1:1"], + ["cut-c", "1080x1920, SAR 1:1"], + ] as const) { + receipt = receipt.replace( + new RegExp(`^\\| ${name} \\|.*$`, "m"), + `| ${name} | ${artifactHashes[name]!.final} | ${artifactHashes[name]!.report} | ` + + `${artifactHashes[name]!.durationSec.toFixed(1)}s | ${geometry} | PASS | REAL | ` + + "PINNED | PASS | ENFORCED |", + ); + } + field("Toolchain versions", "ffmpeg 6.1; ffprobe 6.1; node 22.17; playwright 1.61.1"); + field("YouTube chapters SHA-256", sha256Text(CHAPTERS_BYTES)); + for (let index = 1; index <= 8; index++) { + receipt = receipt.replace( + new RegExp(`(^- R${index} .*?: )PENDING$`, "m"), + `$1verified run evidence ${index}`, + ); + } + for (const name of [ + "master full mix, start UTC and stop UTC", + "01-cold-open", + "02-roadmap", + "03-setup", + "04-install-it-right", + "05-first-real-task", + "06-where-it-runs", + "07-anywhere-on-a-schedule", + "08-recap", + "09-next", + "cut-a full mix and hook, start UTC and stop UTC", + "cut-b full mix and hook, start UTC and stop UTC", + "cut-c full mix and hook, start UTC and stop UTC", + ]) { + receipt = receipt.replace( + `| ${name} | PENDING | PENDING |`, + `| ${name} | reviewed from start to finish at recorded UTC times | PASS |`, + ); + } + receipt = receipt + .replaceAll("- Disposition: PENDING", "- Disposition: PASS") + .replaceAll("- Operator evidence: PENDING", "- Operator evidence: measured evidence attached") + .replace( + "- Planned platform AI-content answers and rationale: PENDING", + "- Planned platform AI-content answers and rationale: disclose cloned voice and real edited captures", + ); + return receipt; +} + +async function writeProductionArtifacts(root: string): Promise { + const media = await getMediaFixtures(); + const sourceBuildAttestation = await computeSourceBuildAttestation( + resolve("."), + SOURCE_COMMIT, + ); + const hashes: ArtifactHashes = {}; + const definitions = [ + { + name: "master", + sourceConfig: MASTER, + archivedSource: join(root, "evidence", "source", "master"), + clips: join(root, "evidence", "clips", "master"), + }, + { + name: "cut-a", + sourceConfig: CUTS[0], + archivedSource: join(root, "evidence", "source", "cuts", "cut-a"), + clips: join(root, "evidence", "clips", "cut-a"), + }, + { + name: "cut-b", + sourceConfig: CUTS[1], + archivedSource: join(root, "evidence", "source", "cuts", "cut-b"), + clips: join(root, "evidence", "clips", "cut-b"), + }, + { + name: "cut-c", + sourceConfig: CUTS[2], + archivedSource: join(root, "evidence", "source", "cuts", "cut-c"), + clips: join(root, "evidence", "clips", "cut-c"), + }, + ] as const; + + const sourceRoot = join(root, "evidence", "source"); + await mkdir(sourceRoot, { recursive: true }); + for (const doc of ["CAPTURE_PLAN.md", "CLAIM_LEDGER.md", "PUBLISHING.md", "README.md"]) { + await writeFile(join(sourceRoot, doc), readFileSync(join(ROOT, doc))); + } + + await writeFile(join(root, "YOUTUBE_CHAPTERS.txt"), CHAPTERS_BYTES); + for (const definition of definitions) { + await mkdir(definition.archivedSource, { recursive: true }); + await mkdir(definition.clips, { recursive: true }); + await mkdir(join(root, definition.name), { recursive: true }); + + const sourceScript = definition.sourceConfig.replace("demo.config.json", "DEMO_SCRIPT.md"); + const archivedConfig = join(definition.archivedSource, "demo.config.json"); + const archivedScript = join(definition.archivedSource, "DEMO_SCRIPT.md"); + const scriptBytes = readFileSync(sourceScript); + await writeFile(archivedConfig, readFileSync(definition.sourceConfig)); + await writeFile(archivedScript, scriptBytes); + + const config = loadConfig(archivedConfig); + config.script = archivedScript; + config.clipsDir = definition.clips; + const manifest = parseScript(scriptBytes.toString("utf8")); + const clipBytes = definition.name === "master" ? media.landscape : media.portrait; + for (const shot of manifest.shots) { + await writeFile( + join(definition.clips, shot.clip!), + clipBytes, + ); + } + + const stableConfig = stableConfigJson(config); + const script = scriptBytes.toString("utf8"); + const timelineIds = [ + ...(config.brand?.titleCard ? ["__card-title"] : []), + ...manifest.shots.map((shot) => shot.id), + ...(config.brand?.endCard ? ["__card-end"] : []), + ]; + let startSec = 0; + const entries = timelineIds.map((shotId) => { + const durationSec = shotId === "__card-title" + ? config.brand!.titleSec + : shotId === "__card-end" + ? config.brand!.endSec + : definition.name === "master" + ? 10 + : 1; + const entry = { shotId, startSec, durationSec }; + startSec += durationSec; + return entry; + }); + const reportBytes = artifactReportBytes( + definition.name, + "real", + { + configHash: digest(stableConfig), + scriptHash: digest(script), + configSha256: digestFull(stableConfig), + scriptSha256: digestFull(script), + clips: manifest.shots.map((shot) => ({ + shotId: shot.id, + sha256: sha256Text(clipBytes), + })), + }, + entries, + sourceBuildAttestation, + ); + const finalBytes = definition.name === "master" + ? media.landscapeFinal + : media.portrait; + const artifactRoot = join(root, definition.name); + const audioRoot = join(artifactRoot, "audio"); + const segmentRoot = join(artifactRoot, "seg"); + await mkdir(audioRoot); + await mkdir(segmentRoot); + await writeFile(join(artifactRoot, ".agent-demo-video-output-claim"), ""); + for (const name of [ + "captions.srt", + "captions.ass", + "video.mp4", + "audio.mp3", + "muxed.mp4", + ]) { + await writeFile(join(artifactRoot, name), `fixture ${definition.name} ${name}\n`); + } + for (const name of [ + ...timelineIds.map((shotId) => `${shotId}.mp3`), + ...timelineIds.map((_, index) => `pad_${index}.mp3`), + "list.txt", + "bed.wav", + "tick.wav", + "sweep.wav", + "mix.m4a", + ]) { + await writeFile(join(audioRoot, name), `fixture ${definition.name} ${name}\n`); + } + const selectedSegmentNames = timelineIds.map( + (_, index) => `seg_${index}.mp4`, + ); + await writeFile( + join(segmentRoot, "list.txt"), + selectedSegmentNames + .map((name) => `file '${name}'`) + .join("\n"), + ); + for (const [index, name] of selectedSegmentNames.entries()) { + const segmentBytes = timelineIds[index] === "__card-end" + ? media.landscapeEndCard + : definition.name === "master" + ? media.landscape + : media.portrait; + await writeFile(join(segmentRoot, name), segmentBytes); + } + if (definition.name === "master") { + await writeFile( + join(segmentRoot, "card_title_text.txt"), + "Factory AI at Work\n", + ); + await writeFile( + join(segmentRoot, "card_url_text.txt"), + "youtube.com/@DanMercedeAI\n", + ); + await writeFile( + join(segmentRoot, "card_end.mp4"), + media.landscapeEndCard, + ); + } + await writeFile(join(artifactRoot, "final.mp4"), finalBytes); + await writeFile(join(artifactRoot, "render-report.json"), reportBytes); + hashes[definition.name] = { + final: sha256Text(finalBytes), + report: sha256Text(reportBytes), + durationSec: startSec, + reportBytes, + }; + } + return hashes; +} + +describe("Factory AI at Work Gate 1 production pack", () => { + it("pins the approved voice and keeps every product visual on the real-capture seam", async () => { + const emptyCaptureRoot = await mkdtemp(join(tmpdir(), "factory-gate-01-")); + + for (const path of CONFIGS) { + const { config, manifest } = loadPack(path); + const rawConfig = JSON.parse(readFileSync(path, "utf8")) as { + voice?: unknown; + audio?: { musicPath?: unknown }; + }; + expect(rawConfig.voice, `${path} must own the production voice pin`).toEqual(VOICE); + expect(rawConfig.audio?.musicPath, `${path} may not consume unarchived music`).toBeUndefined(); + expect(config.voice).toEqual(VOICE); + expect(config.preflight).toBe(true); + expect(config.capture.auth).toBeUndefined(); + expect(manifest.shots.length).toBeGreaterThan(0); + expect(manifest.shots.every((shot) => shot.target === "prebaked" && shot.fullBleed === true)).toBe(true); + expect(new Set(manifest.shots.map((shot) => shot.clip)).size).toBe(manifest.shots.length); + const expectedClipDir = path === MASTER + ? resolve(ROOT, "clips/master") + : resolve(ROOT, "clips", path.match(/cut-[abc]/)![0]); + for (const shot of manifest.shots) { + expect(resolveClipPath(shot.clip!, config.clipsDir, config.configDir!)).toBe( + resolve(expectedClipDir, shot.clip!), + ); + } + + // Cross the exact production pre-spend seam against a guaranteed-empty + // capture root. Every declared visual must fail closed as one missing + // operator clip, and no browser or TTS call is needed to prove it. + config.configDir = emptyCaptureRoot; + config.clipsDir = "."; + const findings = await runPreflight(manifest, config); + expect(findings).toHaveLength(manifest.shots.length); + expect(findings.every((finding) => finding.kind === "missing-clip" && finding.severity === "blocking")).toBe(true); + } + }); + + it("cold-opens the master on proof and closes on the 15-second disclosure card", () => { + const { config, manifest } = loadPack(MASTER); + expect(config.platform).toBe("landscape"); + expect(config.resolution).toEqual({ width: 1920, height: 1080 }); + expect(config.maxDurationSec).toBe(600); + expect(config.brand).toMatchObject({ + title: DISCLOSURE, + accent: "#E68249", + titleCard: false, + endCard: true, + endSec: 15, + }); + expect(manifest.shots[0]?.id).toBe("01-cold-open"); + const estimatedMasterSec = manifest.shots.reduce( + (total, shot) => total + estimateDurationSec(shot.narration), + config.brand!.endSec, + ); + // Real ElevenLabs narration has run up to 30% longer than FAKE_TTS in this + // repository. Keep the documented 25% reserve so the first real render + // does not spend on every block and then fail only at the final cap check. + expect(estimatedMasterSec).toBeLessThanOrEqual(config.maxDurationSec * 0.75); + }); + + it("ships exactly three self-contained portrait cuts with timing headroom", () => { + const discoveredCuts = readdirSync(`${ROOT}/cuts`, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `${ROOT}/cuts/${entry.name}/demo.config.json`) + .filter((path) => existsSync(path)) + .sort(); + expect(discoveredCuts).toEqual([...CUTS].sort()); + for (const path of CUTS) { + const { config, manifest } = loadPack(path); + expect(config.platform).toBe("shorts"); + expect(config.resolution).toEqual({ width: 1080, height: 1920 }); + expect(captureViewport(config)).toEqual({ width: 1920, height: 1080 }); + expect(config.maxDurationSec).toBe(60); + expect(config.brand?.titleCard).toBe(false); + expect(config.brand?.endCard).toBe(false); + expect(manifest.shots[0]?.narration).toContain(CUT_HOOKS[path]); + expect(manifest.shots.at(-1)?.narration).toContain( + "Watch the full Cowork walkthrough. Follow Factory AI at Work.", + ); + + const estimatedSec = manifest.shots.reduce( + (total, shot) => total + estimateDurationSec(shot.narration), + 0, + ); + expect(estimatedSec).toBeGreaterThanOrEqual(20); + expect(estimatedSec).toBeLessThanOrEqual(45); + } + }); + + it("fails receipt promotion closed unless all twelve dispositions pass", async () => { + const dir = await mkdtemp(join(tmpdir(), "factory-receipt-validator-")); + const template = readFileSync(`${ROOT}/PRODUCTION_RECEIPT_TEMPLATE.md`, "utf8"); + const artifactHashes = await writeProductionArtifacts(dir); + const completed = completeProductionReceipt(template, artifactHashes, { + attempt: dir, + reviewed: join(dir, "reviewed"), + }); + expect(completed).not.toContain("PENDING"); + const run = (path: string, env: NodeJS.ProcessEnv = process.env) => spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + RECEIPT_VALIDATOR, + "--node-bin", + NODE_BIN, + path, + ], + { encoding: "utf8", env }, + ); + + const validPath = join(dir, "valid.md"); + await writeFile(validPath, completed); + const valid = run(validPath); + expect(valid.status, valid.stderr).toBe(0); + const minimalEnvironmentValid = run(validPath, { + PATH: "/usr/bin:/bin", + LC_ALL: "C", + NODE_OPTIONS: "--require=/definitely/not/a/preload.cjs", + NODE_PATH: "/definitely/not/a/node/path", + TSX_TSCONFIG_PATH: "/definitely/not/a/tsconfig.json", + ELEVENLABS_API_KEY: "must-not-be-needed-for-receipt-validation", + }); + expect(minimalEnvironmentValid.status, minimalEnvironmentValid.stderr).toBe(0); + const esbuildOverrideMarker = join(dir, "caller-esbuild-override-ran"); + const esbuildOverride = join(dir, "caller-esbuild-override"); + await writeFile( + esbuildOverride, + `#!/usr/bin/env bash\nprintf 'executed\\n' > "${esbuildOverrideMarker}"\nexit 97\n`, + ); + await chmod(esbuildOverride, 0o755); + const esbuildScrubbed = run(validPath, { + ...process.env, + ESBUILD_BINARY_PATH: esbuildOverride, + }); + expect(esbuildScrubbed.status, esbuildScrubbed.stderr).toBe(0); + expect(existsSync(esbuildOverrideMarker)).toBe(false); + + const relativeNode = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + RECEIPT_VALIDATOR, + "--node-bin", + "node", + validPath, + ], + { encoding: "utf8" }, + ); + expect(relativeNode.status).not.toBe(0); + expect(relativeNode.stderr).toContain("Node binary path must be absolute"); + const nonNodeExecutable = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + RECEIPT_VALIDATOR, + "--node-bin", + "/usr/bin/true", + validPath, + ], + { encoding: "utf8" }, + ); + expect(nonNodeExecutable.status).not.toBe(0); + expect(nonNodeExecutable.stderr).toContain( + "selected executable did not prove it is Node", + ); + const sentinelSpoof = join(dir, "caller-owned-node-spoof"); + await writeFile( + sentinelSpoof, + "#!/usr/bin/env bash\nprintf '%s' agent-demo-video-node-ok\n", + ); + await chmod(sentinelSpoof, 0o700); + const spoofedNode = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + RECEIPT_VALIDATOR, + "--node-bin", + sentinelSpoof, + validPath, + ], + { encoding: "utf8" }, + ); + expect(spoofedNode.status).not.toBe(0); + expect(spoofedNode.stderr).toContain( + "Node binary must be root-owned", + ); + + const masterSegmentList = join(dir, "master", "seg", "list.txt"); + const originalMasterSegmentList = await readFile(masterSegmentList); + await writeFile( + masterSegmentList, + originalMasterSegmentList + .toString("utf8") + .replace( + "file 'seg_0.mp4'", + "file '/tmp/off-root/seg_0.mp4'", + ), + ); + const offRootSegment = run(validPath); + expect(offRootSegment.status).not.toBe(0); + expect(offRootSegment.stderr).toContain( + "concat list must select exact relative archived segment names", + ); + await writeFile(masterSegmentList, originalMasterSegmentList); + + const archivedMasterScript = join( + dir, + "evidence", + "source", + "master", + "DEMO_SCRIPT.md", + ); + const originalMasterScript = await readFile(archivedMasterScript); + await writeFile( + archivedMasterScript, + Buffer.concat([originalMasterScript, Buffer.from("\nUnreviewed source change.\n")]), + ); + const sourceMismatch = run(validPath); + expect(sourceMismatch.status).not.toBe(0); + expect(sourceMismatch.stderr).toContain("input evidence is invalid"); + await writeFile(archivedMasterScript, originalMasterScript); + + const media = await getMediaFixtures(); + const archivedMasterClip = join( + dir, + "evidence", + "clips", + "master", + "01-cold-open.mp4", + ); + const originalMasterClip = await readFile(archivedMasterClip); + await writeFile(archivedMasterClip, media.landscapeFinal); + const substitutedClip = run(validPath); + expect(substitutedClip.status).not.toBe(0); + expect(substitutedClip.stderr).toContain("ordered clip bytes"); + await writeFile(archivedMasterClip, originalMasterClip); + + const archivedMasterConfig = join( + dir, + "evidence", + "source", + "master", + "demo.config.json", + ); + const originalMasterConfig = await readFile(archivedMasterConfig); + const externalMusicConfig = JSON.parse(originalMasterConfig.toString("utf8")) as { + audio: { musicPath?: string }; + }; + externalMusicConfig.audio.musicPath = "/tmp/unarchived-music.wav"; + await writeFile(archivedMasterConfig, `${JSON.stringify(externalMusicConfig, null, 2)}\n`); + const externalMusic = run(validPath); + expect(externalMusic.status).not.toBe(0); + expect(externalMusic.stderr).toContain("may not use audio.musicPath"); + await writeFile(archivedMasterConfig, originalMasterConfig); + + const archivedClaimLedger = join(dir, "evidence", "source", "CLAIM_LEDGER.md"); + const originalClaimLedger = await readFile(archivedClaimLedger); + await writeFile( + archivedClaimLedger, + readFileSync(join(dir, "evidence", "source", "PUBLISHING.md")), + ); + const claimLedgerMismatch = run(validPath); + expect(claimLedgerMismatch.status).not.toBe(0); + expect(claimLedgerMismatch.stderr).toContain("does not match the archived claim ledger"); + await writeFile(archivedClaimLedger, originalClaimLedger); + + const unresolvedCommitPath = join(dir, "unresolved-commit.md"); + await writeFile( + unresolvedCommitPath, + completed.replace(`- Source commit SHA: ${SOURCE_COMMIT}`, `- Source commit SHA: ${"0".repeat(40)}`), + ); + const unresolvedCommit = run(unresolvedCommitPath); + expect(unresolvedCommit.status).not.toBe(0); + expect(unresolvedCommit.stderr).toContain("does not resolve to a commit"); + + const alternateGitRoot = join(dir, "alternate-git-authority"); + await mkdir(alternateGitRoot); + execFileSync("git", ["init", alternateGitRoot], { stdio: "ignore" }); + execFileSync( + "git", + [ + "-C", + alternateGitRoot, + "-c", + "user.email=factory-test@example.test", + "-c", + "user.name=Factory Test", + "commit", + "--allow-empty", + "-m", + "alternate authority", + ], + { stdio: "ignore" }, + ); + const alternateCommit = execFileSync( + "git", + ["-C", alternateGitRoot, "rev-parse", "HEAD"], + { encoding: "utf8" }, + ).trim(); + const poisonedCommitPath = join(dir, "poisoned-git-authority.md"); + await writeFile( + poisonedCommitPath, + completed.replace( + `- Source commit SHA: ${SOURCE_COMMIT}`, + `- Source commit SHA: ${alternateCommit}`, + ), + ); + const poisonedCommit = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + RECEIPT_VALIDATOR, + "--node-bin", + NODE_BIN, + poisonedCommitPath, + ], + { + encoding: "utf8", + env: { + ...process.env, + GIT_DIR: join(alternateGitRoot, ".git"), + GIT_WORK_TREE: alternateGitRoot, + GIT_COMMON_DIR: join(alternateGitRoot, ".git"), + }, + }, + ); + expect(poisonedCommit.status).not.toBe(0); + expect(poisonedCommit.stderr).toContain( + "Source commit SHA does not resolve to a commit in the source repository", + ); + + const parentCommit = spawnSync( + "git", + ["rev-parse", "--verify", "HEAD^"], + { encoding: "utf8" }, + ); + if (parentCommit.status === 0) { + const unrelatedCommitPath = join(dir, "unrelated-commit.md"); + await writeFile( + unrelatedCommitPath, + completed.replace( + `- Source commit SHA: ${SOURCE_COMMIT}`, + `- Source commit SHA: ${parentCommit.stdout.trim()}`, + ), + ); + const unrelatedCommit = run(unrelatedCommitPath); + expect(unrelatedCommit.status).not.toBe(0); + expect(unrelatedCommit.stderr).toContain( + "source-build attestation does not match the production receipt commit", + ); + } + + const masterReportPath = join(dir, "master", "render-report.json"); + const forgedSourceReport = JSON.parse( + artifactHashes.master!.reportBytes, + ) as { + sourceBuildAttestation: { treeSha256: string }; + }; + forgedSourceReport.sourceBuildAttestation.treeSha256 = "0".repeat(64); + const forgedSourceReportBytes = JSON.stringify(forgedSourceReport); + await writeFile(masterReportPath, forgedSourceReportBytes); + const forgedSourcePath = join(dir, "forged-source-attestation.md"); + await writeFile( + forgedSourcePath, + completed.replace( + artifactHashes.master!.report, + sha256Text(forgedSourceReportBytes), + ), + ); + const forgedSource = run(forgedSourcePath); + expect(forgedSource.status).not.toBe(0); + expect(forgedSource.stderr).toContain( + "source-build attestation does not match the production receipt commit", + ); + await writeFile(masterReportPath, artifactHashes.master!.reportBytes); + + const redistributedTimelineReport = JSON.parse( + artifactHashes.master!.reportBytes, + ) as { + timeline: { + entries: Array<{ + shotId: string; + startSec: number; + durationSec: number; + }>; + }; + }; + redistributedTimelineReport.timeline.entries[0]!.durationSec = 10.2; + redistributedTimelineReport.timeline.entries[1]!.startSec = 10.2; + redistributedTimelineReport.timeline.entries[1]!.durationSec = 9.8; + const redistributedTimelineBytes = JSON.stringify( + redistributedTimelineReport, + ); + await writeFile(masterReportPath, redistributedTimelineBytes); + const redistributedTimelinePath = join(dir, "redistributed-timeline.md"); + await writeFile( + redistributedTimelinePath, + completed.replace( + artifactHashes.master!.report, + sha256Text(redistributedTimelineBytes), + ), + ); + const redistributedTimeline = run(redistributedTimelinePath); + expect(redistributedTimeline.status).not.toBe(0); + expect(redistributedTimeline.stderr).toContain( + "measured segment duration does not match its timeline", + ); + await writeFile(masterReportPath, artifactHashes.master!.reportBytes); + + const shortChapterReport = JSON.parse( + artifactHashes.master!.reportBytes, + ) as { + timeline: { + entries: Array<{ + shotId: string; + startSec: number; + durationSec: number; + }>; + }; + }; + shortChapterReport.timeline.entries[0]!.durationSec = 9.95; + shortChapterReport.timeline.entries[1]!.startSec = 9.95; + shortChapterReport.timeline.entries[1]!.durationSec = 10.05; + const shortChapterReportBytes = JSON.stringify(shortChapterReport); + const shortChapterBytes = CHAPTERS_BYTES.replace( + "0:10 The 4 steps", + "0:09 The 4 steps", + ); + await writeFile(masterReportPath, shortChapterReportBytes); + await writeFile(join(dir, "YOUTUBE_CHAPTERS.txt"), shortChapterBytes); + const shortChapterPath = join(dir, "short-chapter.md"); + await writeFile( + shortChapterPath, + completed + .replace( + artifactHashes.master!.report, + sha256Text(shortChapterReportBytes), + ) + .replace( + sha256Text(CHAPTERS_BYTES), + sha256Text(shortChapterBytes), + ), + ); + const shortChapter = run(shortChapterPath); + expect(shortChapter.status).not.toBe(0); + expect(shortChapter.stderr).toContain( + "master chapter 01-cold-open is shorter than 10 seconds", + ); + await writeFile(masterReportPath, artifactHashes.master!.reportBytes); + await writeFile(join(dir, "YOUTUBE_CHAPTERS.txt"), CHAPTERS_BYTES); + + const failedPath = join(dir, "failed.md"); + await writeFile(failedPath, completed.replace("- Disposition: PASS", "- Disposition: FAIL")); + const failed = run(failedPath); + expect(failed.status).not.toBe(0); + expect(failed.stderr).toContain("did not pass"); + + const missingPath = join(dir, "missing.md"); + await writeFile(missingPath, completed.replace(/^- Disposition: PASS\n/gm, "")); + const missing = run(missingPath); + expect(missing.status).not.toBe(0); + expect(missing.stderr).toContain("did not pass"); + + const duplicateHeadingPath = join(dir, "duplicate-heading.md"); + await writeFile( + duplicateHeadingPath, + completed.replace("### 8. Audio", "### 7. Captions"), + ); + const duplicateHeading = run(duplicateHeadingPath); + expect(duplicateHeading.status).not.toBe(0); + expect(duplicateHeading.stderr).toContain("missing or duplicated"); + + const rejectedPath = join(dir, "rejected.md"); + await writeFile( + rejectedPath, + completed + .replace( + "- Render handoff status: REVIEWED_FOR_PUBLISH_HANDOFF", + "- Render handoff status: REJECTED", + ) + .replace( + "- Review decision and rationale: filled", + "- Review decision and rationale: expected - Render handoff status: REVIEWED_FOR_PUBLISH_HANDOFF but operator rejected the pack", + ), + ); + const rejected = run(rejectedPath); + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toContain("exactly one reviewed handoff status"); + + const incompletePath = join(dir, "incomplete.md"); + await writeFile(incompletePath, template); + const incomplete = run(incompletePath); + expect(incomplete.status).not.toBe(0); + expect(incomplete.stderr).toContain("PENDING"); + + const blankPath = join(dir, "blank.md"); + await writeFile(blankPath, completed.replace("- Reviewer: Dan Mercede", "- Reviewer:")); + expect(run(blankPath).status).not.toBe(0); + + const blankEvidencePath = join(dir, "blank-evidence.md"); + await writeFile( + blankEvidencePath, + completed.replace("- Operator evidence: measured evidence attached", "- Operator evidence:"), + ); + expect(run(blankEvidencePath).status).not.toBe(0); + + const placeholderEvidencePath = join(dir, "placeholder-evidence.md"); + await writeFile( + placeholderEvidencePath, + completed.replace( + "- Operator evidence: measured evidence attached", + "- Operator evidence: TBD", + ), + ); + const placeholderEvidence = run(placeholderEvidencePath); + expect(placeholderEvidence.status).not.toBe(0); + expect(placeholderEvidence.stderr).toContain( + "placeholder review evidence", + ); + const hostileRipgrepConfig = join(dir, "ripgrep.conf"); + await writeFile(hostileRipgrepConfig, "--definitely-not-a-real-option\n"); + const configuredPlaceholder = run(placeholderEvidencePath, { + ...process.env, + RIPGREP_CONFIG_PATH: hostileRipgrepConfig, + }); + expect(configuredPlaceholder.status).not.toBe(0); + expect(configuredPlaceholder.stderr).toContain( + "placeholder review evidence", + ); + + const hiddenPath = join(dir, "hidden.md"); + await writeFile(hiddenPath, `visible prefix \nREJECTED\n`); + const hidden = run(hiddenPath); + expect(hidden.status).not.toBe(0); + expect(hidden.stderr).toContain("HTML"); + + const negativeTokensPath = join(dir, "negative-tokens.md"); + await writeFile( + negativeTokensPath, + completed.replace( + "| PASS | REAL | PINNED | PASS | ENFORCED |", + "| not present | not real | PINNED | not ok | not enforced |", + ), + ); + expect(run(negativeTokensPath).status).not.toBe(0); + + const fakeReportPath = join(dir, "master", "render-report.json"); + const fakeReport = artifactReportBytes("master", "fake"); + await writeFile(fakeReportPath, fakeReport); + const fakeReceiptPath = join(dir, "fake-report.md"); + await writeFile( + fakeReceiptPath, + completed.replace( + artifactHashes.master!.report, + sha256Text(fakeReport), + ), + ); + const fakeReportResult = run(fakeReceiptPath); + expect(fakeReportResult.status).not.toBe(0); + expect(fakeReportResult.stderr).toContain("real pinned gated output"); + await writeFile(fakeReportPath, artifactHashes.master!.reportBytes); + + const reversedTimePath = join(dir, "reversed-time.md"); + await writeFile( + reversedTimePath, + completed + .replace("- Review start UTC: 2026-07-30T12:00:00Z", "- Review start UTC: 2026-07-30T12:24:00Z") + .replace("- Review stop UTC: 2026-07-30T12:24:00Z", "- Review stop UTC: 2026-07-30T12:00:00Z"), + ); + expect(run(reversedTimePath).status).not.toBe(0); + + const wrongGeometryPath = join(dir, "wrong-geometry.md"); + const masterFinalHash = artifactHashes.master!.final; + const masterReportHash = artifactHashes.master!.report; + const masterDuration = artifactHashes.master!.durationSec.toFixed(1); + await writeFile( + wrongGeometryPath, + completed.replace( + `| master | ${masterFinalHash} | ${masterReportHash} | ${masterDuration}s | 1920x1080, SAR 1:1 |`, + `| master | ${masterFinalHash} | ${masterReportHash} | ${masterDuration}s | 1080x1920, SAR 1:1 |`, + ), + ); + expect(run(wrongGeometryPath).status).not.toBe(0); + }); + + it("promotes only a closed read-only file set and detects later mutations or additions", async () => { + const promoterSource = readFileSync(ATTEMPT_PROMOTER, "utf8"); + const finalRenamePhase = promoterSource.indexOf( + 'factory_promotion_phase="final-rename"', + ); + const finalRename = promoterSource.indexOf( + 'mv -T --no-clobber -- "$factory_promoting_root" "$factory_reviewed_root"', + ); + expect(finalRenamePhase).toBeGreaterThan(0); + expect(finalRenamePhase).toBeLessThan(finalRename); + expect(promoterSource).toContain("trap - EXIT\n trap '' HUP INT TERM"); + const missingNodeSelection = spawnSync( + "/usr/bin/bash", + ["--noprofile", "--norc", "-p", ATTEMPT_PROMOTER], + { encoding: "utf8" }, + ); + expect(missingNodeSelection.status).not.toBe(0); + expect(missingNodeSelection.stderr).toContain("--node-bin"); + const relativeNodeSelection = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + ATTEMPT_PROMOTER, + "--node-bin", + "node", + "--verify", + "/definitely/not/a/reviewed/root", + ], + { encoding: "utf8" }, + ); + expect(relativeNodeSelection.status).not.toBe(0); + expect(relativeNodeSelection.stderr).toContain("Node binary path must be absolute"); + const nonNodeSelection = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + ATTEMPT_PROMOTER, + "--node-bin", + "/usr/bin/true", + "--verify", + "/definitely/not/a/reviewed/root", + ], + { encoding: "utf8" }, + ); + expect(nonNodeSelection.status).not.toBe(0); + expect(nonNodeSelection.stderr).toContain( + "selected executable did not prove it is Node", + ); + + const dir = await mkdtemp(join(tmpdir(), "factory-attempt-promoter-")); + const template = readFileSync(`${ROOT}/PRODUCTION_RECEIPT_TEMPLATE.md`, "utf8"); + const makeAttempt = async ( + name: string, + reviewed = join(dir, "reviewed", name), + ) => { + const attempt = join(dir, `${name}-attempt`); + await mkdir(attempt, { recursive: true }); + const hashes = await writeProductionArtifacts(attempt); + await writeFile( + join(attempt, "PRODUCTION_RECEIPT.md"), + completeProductionReceipt(template, hashes, { attempt, reviewed }), + ); + return { attempt, reviewed }; + }; + const run = (...args: string[]) => spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + ATTEMPT_PROMOTER, + "--node-bin", + NODE_BIN, + ...args, + ], + { encoding: "utf8" }, + ); + + const unrelated = join(dir, "unrelated"); + await mkdir(unrelated); + const unrelatedFile = join(unrelated, "keep-writable.txt"); + await writeFile(unrelatedFile, "before"); + expect(run(unrelated, join(dir, "reviewed", "unrelated")).status).not.toBe(0); + expect(existsSync(join(unrelated, "PRODUCTION_RECEIPT.sha256"))).toBe(false); + await writeFile(unrelatedFile, "after"); + expect(await readFile(unrelatedFile, "utf8")).toBe("after"); + + const unexpected = await makeAttempt("unexpected"); + const unexpectedFile = join(unexpected.attempt, "master", "operator-note.txt"); + await writeFile(unexpectedFile, "must stay writable"); + expect(run(unexpected.attempt, unexpected.reviewed).status).not.toBe(0); + expect(existsSync(join(unexpected.attempt, "PRODUCTION_RECEIPT.sha256"))).toBe(false); + await writeFile(unexpectedFile, "still writable"); + expect(await readFile(unexpectedFile, "utf8")).toBe("still writable"); + + const directoryAudio = await makeAttempt("directory-audio"); + const expectedAudioFile = join( + directoryAudio.attempt, + "master", + "audio", + "pad_0.mp3", + ); + await rename(expectedAudioFile, join(dir, "displaced-pad_0.mp3")); + await mkdir(expectedAudioFile); + const directoryAudioRun = run( + directoryAudio.attempt, + directoryAudio.reviewed, + ); + expect(directoryAudioRun.status).not.toBe(0); + expect(directoryAudioRun.stderr).toContain("must be a regular file"); + expect(existsSync(directoryAudio.attempt)).toBe(true); + expect( + existsSync(join(directoryAudio.attempt, "PRODUCTION_RECEIPT.sha256")), + ).toBe(false); + + const startupBypass = await makeAttempt("startup-bypass"); + await writeFile( + join(startupBypass.attempt, "PRODUCTION_RECEIPT.md"), + template, + ); + const bashEnvironment = join(dir, "promoter-bash-env"); + await writeFile( + bashEnvironment, + "bash() { return 0; }\n", + ); + const startupBypassRun = spawnSync( + "/usr/bin/bash", + [ + resolve(ATTEMPT_PROMOTER), + "--node-bin", + NODE_BIN, + startupBypass.attempt, + startupBypass.reviewed, + ], + { + encoding: "utf8", + env: { + ...process.env, + BASH_ENV: bashEnvironment, + }, + }, + ); + expect(startupBypassRun.status).not.toBe(0); + expect(startupBypassRun.stderr).toContain( + "requires Bash privileged startup mode", + ); + + const interrupted = await makeAttempt("interrupted"); + const interruptedProcess = spawn( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + ATTEMPT_PROMOTER, + "--node-bin", + NODE_BIN, + interrupted.attempt, + interrupted.reviewed, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let interruptedStderr = ""; + interruptedProcess.stderr.setEncoding("utf8"); + interruptedProcess.stderr.on("data", (chunk: string) => { + interruptedStderr += chunk; + }); + const interruptedExitPromise = new Promise((resolveExit) => { + interruptedProcess.once("close", resolveExit); + }); + let privatePromotionName: string | undefined; + for (let tries = 0; tries < 5_000; tries++) { + let reviewedEntries: string[] = []; + try { + reviewedEntries = readdirSync(join(dir, "reviewed")); + } catch { + // The promoter has not created the reviewed parent yet. + } + privatePromotionName = reviewedEntries.find((name) => + name.startsWith(".interrupted.promoting-") + ); + if (privatePromotionName) { + interruptedProcess.kill("SIGSTOP"); + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + interruptedProcess.kill("SIGTERM"); + interruptedProcess.kill("SIGCONT"); + break; + } + if (interruptedProcess.exitCode !== null) break; + await new Promise((resolveWait) => setTimeout(resolveWait, 1)); + } + expect(privatePromotionName).toBeTruthy(); + const interruptedExit = await interruptedExitPromise; + expect(interruptedExit).toBe(143); + expect(interruptedStderr).toContain( + `restored interrupted promotion to ${interrupted.attempt}`, + ); + expect(existsSync(interrupted.attempt)).toBe(true); + expect( + existsSync(join(dir, "reviewed", privatePromotionName!)), + ).toBe(false); + + const fullStdout = await makeAttempt("full-stdout"); + const fullStdoutFd = openSync("/dev/full", "w"); + const fullStdoutRun = (() => { + try { + return spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + ATTEMPT_PROMOTER, + "--node-bin", + NODE_BIN, + fullStdout.attempt, + fullStdout.reviewed, + ], + { + encoding: "utf8", + stdio: ["ignore", fullStdoutFd, "pipe"], + }, + ); + } finally { + closeSync(fullStdoutFd); + } + })(); + expect(fullStdoutRun.status, fullStdoutRun.stderr).toBe(0); + expect(run("--verify", fullStdout.reviewed).status).toBe(0); + + const symlinkReal = join(dir, "reviewed", "symlink-real"); + const symlinkAlias = join(dir, "reviewed", "symlink-alias"); + await mkdir(join(dir, "reviewed"), { recursive: true }); + await symlink(symlinkReal, symlinkAlias); + const symlinkedDestination = await makeAttempt("symlink-destination", symlinkReal); + const symlinkRun = run(symlinkedDestination.attempt, symlinkAlias); + expect(symlinkRun.status).not.toBe(0); + expect(symlinkRun.stderr).toContain("reviewed root may not be a symlink"); + expect(existsSync(symlinkedDestination.attempt)).toBe(true); + expect(existsSync(join(symlinkedDestination.attempt, "PRODUCTION_RECEIPT.sha256"))).toBe(false); + + if ( + existsSync("/dev/shm") && + statSync("/dev/shm").dev !== statSync(dir).dev + ) { + const crossFs = await makeAttempt("cross-filesystem"); + const crossFsParent = await mkdtemp("/dev/shm/factory-reviewed-"); + const crossFsRun = run(crossFs.attempt, join(crossFsParent, "reviewed")); + expect(crossFsRun.status).not.toBe(0); + expect(crossFsRun.stderr).toContain("different filesystems"); + expect(existsSync(crossFs.attempt)).toBe(true); + expect(existsSync(join(crossFs.attempt, "PRODUCTION_RECEIPT.sha256"))).toBe(false); + await writeFile(join(crossFs.attempt, "YOUTUBE_CHAPTERS.txt"), CHAPTERS_BYTES); + } + + const tampered = await makeAttempt("tampered"); + const media = await getMediaFixtures(); + await writeFile(join(tampered.attempt, "master", "final.mp4"), media.portrait); + const tamperedRun = run(tampered.attempt, tampered.reviewed); + expect(tamperedRun.status).not.toBe(0); + expect(tamperedRun.stderr).toContain("do not match reviewed files"); + + const changed = await makeAttempt("changed"); + expect(run(changed.attempt, changed.reviewed).status).toBe(0); + expect(run("--verify", changed.reviewed).status).toBe(0); + const localeVerify = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + ATTEMPT_PROMOTER, + "--node-bin", + NODE_BIN, + "--verify", + changed.reviewed, + ], + { + encoding: "utf8", + env: { + ...process.env, + LANG: "en_US.utf8", + LC_ALL: "en_US.utf8", + }, + }, + ); + expect(localeVerify.status, localeVerify.stderr).toBe(0); + const changedReceipt = join(changed.reviewed, "PRODUCTION_RECEIPT.md"); + await chmod(changedReceipt, 0o644); + await writeFile( + changedReceipt, + readFileSync(changedReceipt, "utf8").replace( + "- Render handoff status: REVIEWED_FOR_PUBLISH_HANDOFF", + "- Render handoff status: REJECTED", + ), + ); + expect(run("--verify", changed.reviewed).status).not.toBe(0); + + const added = await makeAttempt("added"); + expect(run(added.attempt, added.reviewed).status).toBe(0); + await chmod(added.reviewed, 0o755); + await writeFile(join(added.reviewed, "unreviewed-variant.mp4"), "not reviewed"); + const extra = run("--verify", added.reviewed); + expect(extra.status).not.toBe(0); + expect(extra.stderr).toMatch(/sealed read-only|closed match/); + + const optionTarget = join(dir, "--version"); + const optionLike = await makeAttempt("option-like", optionTarget); + const optionRun = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + resolve(ATTEMPT_PROMOTER), + "--node-bin", + NODE_BIN, + optionLike.attempt, + "--version", + ], + { cwd: dir, encoding: "utf8" }, + ); + expect(optionRun.status, optionRun.stderr).toBe(0); + expect(run("--verify", optionTarget).status).toBe(0); + }); + + it("streams the fixed launcher into a verified private detached source snapshot", async () => { + const direct = spawnSync( + "bash", + [SOURCE_ATTESTED_LAUNCHER, resolve("."), SOURCE_COMMIT, "--verify-only", "--"], + { encoding: "utf8" }, + ); + expect(direct.status).not.toBe(0); + expect(direct.stderr).toContain( + "launcher must be streamed from the fixed Git commit", + ); + + const fixture = await mkdtemp(join(tmpdir(), "factory-source-launcher-")); + await mkdir(join(fixture, "src")); + await mkdir(join(fixture, "scripts")); + const launcherBytes = readFileSync(SOURCE_ATTESTED_LAUNCHER); + await writeFile( + join(fixture, "src/cli.ts"), + "process.stdout.write('committed runner');\n", + ); + await writeFile( + join(fixture, "scripts/remote-entry.ts"), + "export const remote = true;\n", + ); + await writeFile( + join(fixture, "scripts/run-source-attested-render.sh"), + launcherBytes, + ); + await chmod( + join(fixture, "scripts/run-source-attested-render.sh"), + 0o755, + ); + await writeFile(join(fixture, "package.json"), '{"name":"launcher-fixture"}\n'); + await writeFile(join(fixture, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + await writeFile(join(fixture, "tsconfig.json"), "{}\n"); + execFileSync("git", ["init", fixture], { stdio: "ignore" }); + execFileSync( + "git", + [ + "-C", + fixture, + "-c", + "user.email=factory-test@example.test", + "-c", + "user.name=Factory Test", + "add", + ".", + ], + { stdio: "ignore" }, + ); + execFileSync( + "git", + [ + "-C", + fixture, + "-c", + "user.email=factory-test@example.test", + "-c", + "user.name=Factory Test", + "commit", + "-m", + "launcher fixture", + ], + { stdio: "ignore" }, + ); + const commit = execFileSync( + "git", + ["-C", fixture, "rev-parse", "HEAD"], + { encoding: "utf8" }, + ).trim(); + const hookMarker = join(fixture, "mutable-post-checkout-hook-ran"); + const postCheckoutHook = join(fixture, ".git", "hooks", "post-checkout"); + await writeFile( + postCheckoutHook, + `#!/bin/sh\n: > "${hookMarker}"\n`, + ); + await chmod(postCheckoutHook, 0o755); + await writeFile( + join(fixture, "src/cli.ts"), + "process.stdout.write('dirty mutable runner');\n", + ); + + const unprivileged = spawnSync( + "/usr/bin/bash", + ["-s", "--", fixture, commit, "--verify-only", "--"], + { + encoding: "utf8", + input: launcherBytes, + }, + ); + expect(unprivileged.status).not.toBe(0); + expect(unprivileged.stderr).toContain( + "launcher requires Bash privileged startup mode", + ); + + const startupMarker = join(fixture, "caller-bash-startup-ran"); + const toolMarker = join(fixture, "caller-selected-tool-ran"); + const tarMarker = join(fixture, "caller-tar-option-ran"); + const maliciousBin = join(fixture, "caller-bin"); + const bashEnvironment = join(fixture, "caller-bash-env"); + const tarAction = join(fixture, "caller-tar-action"); + const hostileGitHome = join(fixture, "caller-git-home"); + const fsmonitorMarker = join(fixture, "caller-fsmonitor-ran"); + const fsmonitor = join(hostileGitHome, "fsmonitor.sh"); + await mkdir(maliciousBin); + await mkdir(hostileGitHome); + await writeFile(bashEnvironment, `: > "${startupMarker}"\n`); + await writeFile(tarAction, `#!/bin/sh\n: > "${tarMarker}"\n`); + await chmod(tarAction, 0o755); + await writeFile( + fsmonitor, + `#!/bin/sh\n: > "${fsmonitorMarker}"\nexit 0\n`, + ); + await chmod(fsmonitor, 0o755); + await writeFile( + join(hostileGitHome, ".gitconfig"), + `[core]\n\tfsmonitor = ${fsmonitor}\n`, + ); + for (const tool of ["git", "realpath", "tar"]) { + const path = join(maliciousBin, tool); + await writeFile(path, `#!/bin/sh\n: > "${toolMarker}"\nexit 97\n`); + await chmod(path, 0o755); + } + + const streamed = spawnSync( + "/usr/bin/bash", + ["--noprofile", "--norc", "-p", "-s", "--", fixture, commit, "--verify-only", "--"], + { + encoding: "utf8", + input: launcherBytes, + env: { + ...process.env, + PATH: maliciousBin, + HOME: hostileGitHome, + XDG_CONFIG_HOME: hostileGitHome, + BASH_ENV: bashEnvironment, + TAR_OPTIONS: + `--checkpoint=1 --checkpoint-action=exec=${tarAction}`, + "BASH_FUNC_git%%": + `() { : > "${toolMarker}"; /usr/bin/git "$@"; }`, + GIT_DIR: join(fixture, "caller-selected.git"), + GIT_COMMON_DIR: join(fixture, "caller-selected-common.git"), + GIT_WORK_TREE: fixture, + NODE_OPTIONS: "--require=/definitely/not/a/preload.cjs", + NODE_PATH: "/definitely/not/a/node/path", + TSX_TSCONFIG_PATH: "/definitely/not/a/tsconfig.json", + }, + }, + ); + expect(streamed.status, streamed.stderr).toBe(0); + expect(streamed.stdout).toContain( + `verified committed source snapshot ${commit}`, + ); + expect(readFileSync(join(fixture, "src/cli.ts"), "utf8")).toContain( + "dirty mutable runner", + ); + expect(existsSync(hookMarker)).toBe(false); + expect(existsSync(startupMarker)).toBe(false); + expect(existsSync(toolMarker)).toBe(false); + expect(existsSync(tarMarker)).toBe(false); + expect(existsSync(fsmonitorMarker)).toBe(false); + + const callerRoot = join(fixture, "caller-working-directory"); + const toolchainRoot = join(fixture, "operator-toolchain"); + const invocationMarker = join(fixture, "snapshot-cli-invocation"); + const pnpmInvocationMarker = join(fixture, "snapshot-pnpm-invocation"); + const fakePnpm = join(toolchainRoot, "pnpm.cjs"); + await mkdir(callerRoot); + await mkdir(toolchainRoot); + await writeFile( + join(callerRoot, "tsconfig.json"), + '{"compilerOptions":{"paths":{"zod":["./caller-selected-module.ts"]}}}\n', + ); + await writeFile(join(callerRoot, "caller-selected-module.ts"), "throw new Error('caller cwd');\n"); + const fakeTsx = [ + 'import { writeFileSync } from "node:fs";', + "const lines = [", + " `cwd=${process.cwd()}`,", + " `node_env=${process.env.NODE_ENV ?? \"unset\"}`,", + " `tts_secret=${process.env.ELEVENLABS_API_KEY ?? \"unset\"}`,", + " `esbuild_binary_path=${process.env.ESBUILD_BINARY_PATH ?? \"unset\"}`,", + " ...process.argv.slice(1).map((arg) => `arg=${arg}`),", + "];", + `writeFileSync(${JSON.stringify(invocationMarker)}, \`\${lines.join("\\n")}\\n\`);`, + "", + ].join("\n"); + await writeFile( + fakePnpm, + [ + 'const { mkdirSync, writeFileSync } = require("node:fs");', + "const lines = [", + " `node_env=${process.env.NODE_ENV ?? \"unset\"}`,", + " `tts_secret=${process.env.ELEVENLABS_API_KEY ?? \"unset\"}`,", + " `home=${process.env.HOME ?? \"unset\"}`,", + " `xdg_config_home=${process.env.XDG_CONFIG_HOME ?? \"unset\"}`,", + " `npm_userconfig=${process.env.NPM_CONFIG_USERCONFIG ?? \"unset\"}`,", + " `npm_globalconfig=${process.env.NPM_CONFIG_GLOBALCONFIG ?? \"unset\"}`,", + " ...process.argv.slice(1).map((arg) => `arg=${arg}`),", + "];", + `writeFileSync(${JSON.stringify(pnpmInvocationMarker)}, \`\${lines.join("\\n")}\\n\`);`, + 'mkdirSync("node_modules/tsx/dist", { recursive: true });', + `writeFileSync("node_modules/tsx/dist/cli.mjs", ${JSON.stringify(fakeTsx)});`, + "", + ].join("\n"), + ); + const callerOwnedNode = join(toolchainRoot, "caller-owned-node"); + await writeFile( + callerOwnedNode, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `if [ "\${1:-}" = "${fakePnpm}" ]; then`, + ' mkdir -p node_modules/tsx/dist', + " printf '%s\\n' '// attacker-selected tsx' > node_modules/tsx/dist/cli.mjs", + "fi", + "", + ].join("\n"), + ); + await chmod(callerOwnedNode, 0o700); + const callerOwnedNodeRun = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + "-s", + "--", + fixture, + commit, + "--node-bin", + callerOwnedNode, + "--pnpm-cli", + fakePnpm, + "--", + join(callerRoot, "config.json"), + ], + { + cwd: callerRoot, + encoding: "utf8", + input: launcherBytes, + env: process.env, + }, + ); + expect(callerOwnedNodeRun.status).not.toBe(0); + expect(callerOwnedNodeRun.stderr).toContain( + "Node binary must be root-owned", + ); + + const fullLaunch = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + "-s", + "--", + fixture, + commit, + "--node-bin", + NODE_BIN, + "--pnpm-cli", + fakePnpm, + "--", + join(callerRoot, "config.json"), + ], + { + cwd: callerRoot, + encoding: "utf8", + input: launcherBytes, + env: { + ...process.env, + PATH: maliciousBin, + BASH_ENV: bashEnvironment, + NODE_ENV: "production", + ELEVENLABS_API_KEY: "must-reach-render-only", + TSX_TSCONFIG_PATH: join(callerRoot, "tsconfig.json"), + ESBUILD_BINARY_PATH: "/usr/bin/false", + }, + }, + ); + expect(fullLaunch.status, fullLaunch.stderr).toBe(0); + const invocation = readFileSync(invocationMarker, "utf8"); + const pnpmInvocation = readFileSync(pnpmInvocationMarker, "utf8"); + expect(pnpmInvocation).toContain("node_env=unset"); + expect(pnpmInvocation).toContain("tts_secret=unset"); + expect(pnpmInvocation).toContain("arg=--prod=false"); + expect(pnpmInvocation).toContain("arg=--ignore-pnpmfile"); + expect(pnpmInvocation).toContain("arg=--config.userconfig=/dev/null"); + expect(pnpmInvocation).toContain("arg=--config.globalconfig=/dev/null"); + expect(pnpmInvocation).toMatch( + /^home=\/tmp\/agent-demo-video-source-snapshot\.[^/]+\/source\/\.package-home$/m, + ); + expect(pnpmInvocation).toMatch( + /^xdg_config_home=\/tmp\/agent-demo-video-source-snapshot\.[^/]+\/source\/\.package-config$/m, + ); + expect(pnpmInvocation).toContain("npm_userconfig=/dev/null"); + expect(pnpmInvocation).toContain("npm_globalconfig=/dev/null"); + expect(invocation).toMatch( + /^cwd=\/tmp\/agent-demo-video-source-snapshot\.[^/]+\/source$/m, + ); + expect(invocation).toMatch( + /arg=\/tmp\/agent-demo-video-source-snapshot\.[^/]+\/source\/tsconfig\.json/, + ); + expect(invocation).not.toContain( + `arg=${join(callerRoot, "tsconfig.json")}`, + ); + expect(invocation).toContain("node_env=unset"); + expect(invocation).toContain("tts_secret=must-reach-render-only"); + expect(invocation).toContain("esbuild_binary_path=unset"); + expect(existsSync(startupMarker)).toBe(false); + expect(existsSync(toolMarker)).toBe(false); + + const worktreeList = execFileSync( + "git", + ["-C", fixture, "worktree", "list", "--porcelain"], + { encoding: "utf8" }, + ); + expect(worktreeList.match(/^worktree /gm)).toHaveLength(1); + + const heldGitDirectory = join(fixture, ".git-cleanup-failure-fixture"); + const cleanupFailureTsx = [ + 'import { renameSync } from "node:fs";', + `renameSync(${JSON.stringify(join(fixture, ".git"))}, ${JSON.stringify(heldGitDirectory)});`, + "", + ].join("\n"); + await writeFile( + fakePnpm, + [ + 'const { mkdirSync, writeFileSync } = require("node:fs");', + 'mkdirSync("node_modules/tsx/dist", { recursive: true });', + `writeFileSync("node_modules/tsx/dist/cli.mjs", ${JSON.stringify(cleanupFailureTsx)});`, + "", + ].join("\n"), + ); + let cleanupFailure: + | ReturnType + | undefined; + try { + cleanupFailure = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + "-s", + "--", + fixture, + commit, + "--node-bin", + NODE_BIN, + "--pnpm-cli", + fakePnpm, + "--", + join(callerRoot, "config.json"), + ], + { + cwd: callerRoot, + encoding: "utf8", + input: launcherBytes, + env: process.env, + }, + ); + expect(cleanupFailure.status).not.toBe(0); + expect(cleanupFailure.stderr).toContain( + "source-attested render cleanup failed", + ); + expect(cleanupFailure.stderr).toMatch( + /retained snapshot: \/tmp\/agent-demo-video-source-snapshot\.[^/]+\/source/, + ); + } finally { + if (existsSync(heldGitDirectory)) { + await rename(heldGitDirectory, join(fixture, ".git")); + } + const registeredWorktrees = execFileSync( + "git", + ["-C", fixture, "worktree", "list", "--porcelain"], + { encoding: "utf8" }, + ) + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); + for (const registered of registeredWorktrees) { + if (registered === fixture) continue; + execFileSync( + "git", + ["-C", fixture, "worktree", "remove", "--force", registered], + { stdio: "ignore" }, + ); + await rmdir(dirname(registered)); + } + } + }); + + it("keeps publishing copy disclosed and the complete pack dash-clean", () => { + const publishing = readFileSync(`${ROOT}/PUBLISHING.md`, "utf8"); + const claimLedger = readFileSync(`${ROOT}/CLAIM_LEDGER.md`, "utf8"); + const receipt = readFileSync(`${ROOT}/PRODUCTION_RECEIPT_TEMPLATE.md`, "utf8"); + const masterScript = readFileSync(`${ROOT}/master/DEMO_SCRIPT.md`, "utf8"); + const cutCScript = readFileSync(`${ROOT}/cuts/cut-c/DEMO_SCRIPT.md`, "utf8"); + const runbook = readFileSync("docs/runbooks/factory-ai-at-work-gate-01-production.md", "utf8"); + const sourceLauncher = readFileSync(SOURCE_ATTESTED_LAUNCHER, "utf8"); + const attemptPromoter = readFileSync(ATTEMPT_PROMOTER, "utf8"); + const receiptValidator = readFileSync(RECEIPT_VALIDATOR, "utf8"); + expect(publishing).toContain(DISCLOSURE); + expect(publishing).not.toContain("unmodified captures"); + expect(publishing).toMatch(/product\s+states, task results, and timestamps are not fabricated/); + expect(publishing).not.toMatch(/^\d+:\d{2}\s/m); + for (const shotId of loadPack(MASTER).manifest.shots.map((shot) => shot.id)) { + expect(publishing).toContain(shotId); + } + expect(claimLedger).toContain("Verified against live primary documentation: 2026-07-30."); + expect(claimLedger).toContain("Manual, Auto, or Skip"); + expect(claimLedger).toContain("processed on Anthropic's servers"); + expect(masterScript).toContain("the desktop gate limits access, not data residency"); + expect(cutCScript).toContain("the gate limits access, not data residency"); + for (const [number, heading] of [ + [1, "Review economics"], + [2, "Claims"], + [3, "Real captures"], + [4, "Rights"], + [5, "Dash-clean packaging"], + [6, "Title and thumbnail"], + [7, "Captions"], + [8, "Audio"], + [9, "Visual integrity"], + [10, "Disclosure"], + [11, "Vertical cuts"], + [12, "Provenance"], + ] as const) { + expect(receipt).toContain(`### ${number}. ${heading}`); + } + expect(receipt).toContain("Play every final mix from frame 1 through the final frame."); + expect(receipt).toContain("Claim ledger SHA-256: PENDING"); + expect(receipt).toContain("sourceBuildAttestation.commit"); + expect(receipt).toContain("Planned platform AI-content answers and rationale: PENDING"); + expect(receipt).toContain("sole authority for the three consecutive episode passes"); + expect(runbook).toContain("--out \"$FACTORY_ATTEMPT_ABS/master\""); + expect(runbook).toContain("--script \"$FACTORY_ATTEMPT_ABS/evidence/source/master/DEMO_SCRIPT.md\""); + expect(runbook).toContain("--clips-dir \"$FACTORY_ATTEMPT_ABS/evidence/clips/master\""); + expect(runbook).toContain("--attest-source-build"); + expect(runbook).toContain("scripts/run-source-attested-render.sh"); + expect(runbook).toContain("factory_attested_render"); + expect(runbook).toMatch(/private detached no-checkout\s+worktree/); + expect(runbook).toContain("root-owned Node binary"); + expect(runbook).toContain("/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C"); + expect(runbook).toContain("/usr/bin/git --no-replace-objects"); + for (const authorityScript of [sourceLauncher, receiptValidator]) { + expect(authorityScript).toContain("GIT_CONFIG_NOSYSTEM=1"); + expect(authorityScript).toContain("GIT_CONFIG_GLOBAL=/dev/null"); + expect(authorityScript).toContain("core.fsmonitor=false"); + } + expect(receiptValidator).not.toContain("/usr/bin/node"); + expect(attemptPromoter).toContain("ESBUILD_BINARY_PATH"); + expect(receiptValidator).toContain("ESBUILD_BINARY_PATH"); + expect(sourceLauncher).toContain("ESBUILD_BINARY_PATH"); + expect(runbook).toContain("/usr/bin/doppler run"); + expect(runbook).toContain("/usr/bin/bash --noprofile --norc -p -s --"); + expect(runbook).toContain( + "LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \\\n /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C", + ); + expect( + runbook.match(/--node-bin "\$FACTORY_NODE_BIN"/g), + ).toHaveLength(3); + expect(runbook).toContain("--pnpm-cli \"$FACTORY_PNPM_CLI\""); + expect(runbook).not.toContain("bash -s --"); + expect(runbook).not.toContain("pnpm demo \"$FACTORY_ATTEMPT_ABS/evidence/source"); + expect(runbook).toContain("clips/master/01-cold-open.mp4"); + expect(runbook).toContain("\"$FACTORY_ATTEMPT_ROOT/evidence/clips/master/\""); + expect(runbook).not.toContain("../../../../../demos/factory-ai-at-work/gate-01/clips/"); + expect(runbook).toContain("closed-world promoter"); + expect(runbook).toContain("mkdir \"$FACTORY_ATTEMPT_ROOT\""); + expect(runbook).toContain("PRODUCTION_RECEIPT_TEMPLATE.md"); + expect(runbook).toContain( + "\"$FACTORY_NODE_BIN\" --input-type=module - \"$FACTORY_ATTEMPT_ROOT\"", + ); + expect(runbook).not.toMatch(/(?:^|\n)node --input-type=module/); + expect(runbook).toContain("PRODUCTION_RECEIPT.sha256"); + expect(runbook).toContain("scripts/promote-factory-ai-at-work-attempt.sh"); + expect(runbook).toContain("scripts/cleanup-stale-render-input-root.sh"); + expect(runbook).toContain( + "LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \\\n" + + " /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C \\\n" + + " /usr/bin/bash --noprofile --norc -p \\\n" + + " scripts/cleanup-stale-render-input-root.sh", + ); + expect(runbook).toContain("PRIVATE_INPUT_CLEANUP_FAILED"); + expect(runbook).toContain("scripts/validate-factory-ai-at-work-inputs.ts"); + expect(runbook).toContain(".agent-demo-video-output-claim"); + expect(runbook).toContain("malicious process with the same Unix identity"); + expect(runbook).toContain("YOUTUBE_CHAPTERS.txt"); + expect(runbook).toContain( + '> "$FACTORY_ATTEMPT_ROOT/YOUTUBE_CHAPTERS.txt.tmp"', + ); + expect(runbook).toContain( + 'mv -- "$FACTORY_ATTEMPT_ROOT/YOUTUBE_CHAPTERS.txt.tmp" \\\n' + + ' "$FACTORY_ATTEMPT_ROOT/YOUTUBE_CHAPTERS.txt"', + ); + expect(runbook).toContain("shorter than 10 seconds"); + expect(runbook).toContain("does not"); + expect(runbook).toContain("maintain the three-video counter"); + expect(runbook).not.toContain("PUBLICATION_RECEIPT_TEMPLATE.md"); + expect(runbook).not.toContain("YOUTUBE_CHANNEL_HEAD.json"); + expect(runbook).not.toContain("Consecutive clean pass count"); + expect(runbook).not.toContain("cp --no-clobber"); + expect(runbook.match(/set -euo pipefail/g)?.length).toBeGreaterThanOrEqual(4); + expect(runbook).toContain("timeline.entries"); + + const textFiles = [ + `${ROOT}/README.md`, + `${ROOT}/CAPTURE_PLAN.md`, + `${ROOT}/CLAIM_LEDGER.md`, + `${ROOT}/PUBLISHING.md`, + `${ROOT}/PRODUCTION_RECEIPT_TEMPLATE.md`, + "docs/runbooks/factory-ai-at-work-gate-01-production.md", + "scripts/validate-factory-ai-at-work-inputs.ts", + SOURCE_ATTESTED_LAUNCHER, + RECEIPT_VALIDATOR, + ATTEMPT_PROMOTER, + "src/git-environment.ts", + "src/source-build.ts", + ...CONFIGS, + ...CONFIGS.map((path) => path.replace("demo.config.json", "DEMO_SCRIPT.md")), + ]; + for (const path of textFiles) { + expect(readFileSync(path, "utf8"), `${path} contains a banned Unicode dash`).not.toMatch(/[\u2013\u2014\u2015]/); + } + }); +}); diff --git a/tests/pipeline.smoke.test.ts b/tests/pipeline.smoke.test.ts index ff61eac..70cd18a 100644 --- a/tests/pipeline.smoke.test.ts +++ b/tests/pipeline.smoke.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll } from "vitest"; -import { mkdtemp, writeFile, readFile, stat } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readdir, writeFile, readFile, rename, stat, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { runPipeline } from "../src/pipeline"; @@ -15,7 +15,7 @@ describe("runPipeline (smoke, FAKE_TTS)", () => { const scriptPath = join(dir, "demo.md"); await writeFile(scriptPath, tmpl.replaceAll("FIXTURE_URL", fixture)); const cfg = DemoConfigSchema.parse({ script: scriptPath, dashboardBaseUrl: "http://localhost:3000", out: join(dir, "out"), resolution: { width: 1280, height: 720 } }); - const r = await runPipeline(cfg); + const r = await runPipeline(cfg, { requireFreshOut: true }); expect(r.outPath.endsWith("final.mp4")).toBe(true); expect((await stat(r.outPath)).size).toBeGreaterThan(0); expect(r.report.parity.ok).toBe(true); @@ -32,6 +32,23 @@ describe("runPipeline (smoke, FAKE_TTS)", () => { // runtime change rather than restating the narration estimate. expect(report.timeline.totalSec).toBeCloseTo(r.report.totalSec, 3); expect(report.tools.ffmpeg).not.toBe(""); + expect( + (await readFile(join(dir, "out", "seg", "list.txt"), "utf8")) + .split("\n"), + ).toEqual([ + "file 'seg_0.ext.mp4'", + "file 'seg_1.ext.mp4'", + ]); + expect( + (await readFile(join(dir, "out", "audio", "list.txt"), "utf8")) + .split("\n"), + ).toEqual([ + "file 'pad_0.mp3'", + "file 'pad_1.mp3'", + ]); + const claimMarker = await lstat(join(dir, "out", ".agent-demo-video-output-claim")); + expect(claimMarker.isFile()).toBe(true); + expect(claimMarker.size).toBe(0); }, 120_000); it("S3: enforces a DECLARED cap below the produced runtime, before it can ship", async () => { @@ -62,4 +79,74 @@ describe("runPipeline (smoke, FAKE_TTS)", () => { await expect(runPipeline(DemoConfigSchema.parse({ ...base, maxDurationSec: 1 }))).rejects.toThrow(); await expect(stat(join(out, "render-report.json"))).rejects.toThrow(); }, 240_000); + + it("fresh output refuses an existing reviewed directory without changing its artifact pair", async () => { + const dir = await mkdtemp(join(tmpdir(), "pipe-fresh-")); + const out = join(dir, "reviewed"); + const finalBytes = Buffer.from("reviewed-final"); + const reportBytes = Buffer.from('{"reviewed":true}\n'); + await mkdir(out); + await writeFile(join(out, "final.mp4"), finalBytes); + await writeFile(join(out, "render-report.json"), reportBytes); + const cfg = DemoConfigSchema.parse({ + script: join(dir, "missing.md"), + dashboardBaseUrl: "http://localhost:3000", + out, + resolution: { width: 1280, height: 720 }, + }); + + await expect(runPipeline(cfg, { requireFreshOut: true })) + .rejects.toThrow(/fresh output.*already exists/i); + + expect(await readFile(join(out, "final.mp4"))).toEqual(finalBytes); + expect(await readFile(join(out, "render-report.json"))).toEqual(reportBytes); + expect((await readdir(out)).sort()).toEqual(["final.mp4", "render-report.json"]); + }); + + it("fresh output never writes into a replacement installed after the atomic name claim", async () => { + const dir = await mkdtemp(join(tmpdir(), "pipe-fresh-swap-")); + const fixture = pathToFileURL(resolve("tests/fixtures/page.html")).href; + const tmpl = await readFile(resolve("tests/fixtures/demo.md"), "utf8"); + const scriptPath = join(dir, "demo.md"); + await writeFile(scriptPath, tmpl.replaceAll("FIXTURE_URL", fixture)); + const out = join(dir, "attempt"); + const displaced = join(dir, "claimed-attempt"); + const replacement = join(dir, "reviewed"); + const finalBytes = Buffer.from("reviewed-final"); + const reportBytes = Buffer.from('{"reviewed":true}\n'); + await mkdir(replacement); + await writeFile(join(replacement, "final.mp4"), finalBytes); + await writeFile(join(replacement, "render-report.json"), reportBytes); + const cfg = DemoConfigSchema.parse({ + script: scriptPath, + dashboardBaseUrl: "http://localhost:3000", + out, + resolution: { width: 1280, height: 720 }, + preflight: false, + }); + + const running = runPipeline(cfg, { requireFreshOut: true }); + for (let tries = 0; tries < 1_000; tries++) { + try { + // The public name is an O_EXCL regular-file claim for the whole + // render. Swapping it immediately exercises the old mkdir-to-lstat + // race window: no output directory is ever published before binding. + if (!(await lstat(out)).isFile()) throw new Error("not claimed yet"); + break; + } catch { + await new Promise((resolveWait) => setTimeout(resolveWait, 1)); + } + } + await rename(out, displaced); + await symlink(replacement, out, "dir"); + + await expect(running).rejects.toThrow(/fresh output pathname changed after claim/i); + expect(await readFile(join(replacement, "final.mp4"))).toEqual(finalBytes); + expect(await readFile(join(replacement, "render-report.json"))).toEqual(reportBytes); + expect((await readdir(replacement)).sort()).toEqual(["final.mp4", "render-report.json"]); + expect((await stat(displaced)).isFile()).toBe(true); + const stageName = (await readdir(dir)).find((name) => name.startsWith(".attempt.stage-")); + expect(stageName).toBeTruthy(); + expect((await stat(join(dir, stageName!, "final.mp4"))).size).toBeGreaterThan(0); + }, 120_000); }); diff --git a/tests/prebaked-input-binding.test.ts b/tests/prebaked-input-binding.test.ts new file mode 100644 index 0000000..8ec15cf --- /dev/null +++ b/tests/prebaked-input-binding.test.ts @@ -0,0 +1,1210 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { closeSync, openSync, realpathSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, readFile, readdir, stat, symlink, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { digestFull } from "../src/provenance"; +import { DemoConfigSchema } from "../src/types"; + +const PRIVATE_INPUT_ROOT_MARKER_NAME = ".agent-demo-video-private-input-root"; +const PRIVATE_INPUT_ROOT_MARKER_CONTENT = "agent-demo-video-private-input-root-v1\n"; +const DEAD_RECOVERY_PID = 99_999_999; +const itOnLinux = process.platform === "linux" ? it : it.skip; + +async function markPrivateInputRoot(root: string): Promise { + const marker = join(root, PRIVATE_INPUT_ROOT_MARKER_NAME); + await writeFile(marker, PRIVATE_INPUT_ROOT_MARKER_CONTENT, { + encoding: "utf8", + mode: 0o400, + }); + await chmod(marker, 0o400); +} + +const renderProbe = vi.hoisted(() => ({ + sourcePath: "", + rawPath: "", + rawBytes: "", + rawMode: 0, + parentMode: 0, + clickOffsets: [] as number[][], + failureMessage: "", +})); +const cleanupFault = vi.hoisted(() => ({ + enabled: false, + retainedPath: "", +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rm: async (path: Parameters[0], options?: Parameters[1]) => { + if ( + cleanupFault.enabled && + typeof path === "string" && + path.includes("agent-demo-video-render-inputs-") + ) { + cleanupFault.retainedPath = path; + throw Object.assign(new Error("injected private-input cleanup failure"), { + code: "EIO", + }); + } + return actual.rm(path, options); + }, + }; +}); + +vi.mock("../src/render", () => ({ + renderVideo: vi.fn(async (inputs: { + rawSegments: string[]; + config: { out: string }; + tts: { shotId: string; durationSec: number }[]; + clickOffsets: number[][]; + }) => { + renderProbe.rawPath = inputs.rawSegments[0]!; + renderProbe.rawMode = (await stat(renderProbe.rawPath)).mode & 0o777; + renderProbe.parentMode = (await stat(dirname(renderProbe.rawPath))).mode & 0o777; + renderProbe.clickOffsets = inputs.clickOffsets; + + // Simulate an ordinary exporter atomically updating the operator-owned + // source after pipeline admission but before ffmpeg opens its render input. + await writeFile(renderProbe.sourcePath, "replacement clip bytes", "utf8"); + renderProbe.rawBytes = await readFile(renderProbe.rawPath, "utf8"); + + if (renderProbe.failureMessage) { + throw new Error(renderProbe.failureMessage); + } + + const durationSec = inputs.tts[0]!.durationSec; + return { + outPath: join(inputs.config.out, "final.mp4"), + report: { + totalSec: durationSec, + segments: 1, + ticks: 0, + parity: { ok: true, problems: [] }, + timeline: { + entries: [{ shotId: inputs.tts[0]!.shotId, startSec: 0, durationSec }], + totalSec: durationSec, + }, + }, + }; + }), +})); + +vi.mock("../src/capture", () => ({ + captureShot: vi.fn(async ( + shot: { id: string }, + _timeline: unknown, + _config: unknown, + segDir: string, + ) => { + const rawPath = join(segDir, `raw_${shot.id}.webm`); + await writeFile(rawPath, "captured dashboard bytes", "utf8"); + return rawPath; + }), +})); + +import { PrivateInputCleanupError, runPipeline } from "../src/pipeline"; + +describe("prebaked render-input binding", () => { + beforeAll(() => { + vi.stubEnv("FAKE_TTS", "1"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it("renders and attests a private immutable copy when the operator source is replaced", async () => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-")); + const sourcePath = join(root, "clip.mp4"); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + const eventsPath = join(root, "out", "seg", "events_one.json"); + const originalBytes = "admitted clip bytes"; + renderProbe.sourcePath = sourcePath; + + await mkdir(dirname(eventsPath), { recursive: true }); + await writeFile( + eventsPath, + JSON.stringify([{ kind: "click", tMs: 250 }]), + "utf8", + ); + await writeFile(sourcePath, originalBytes, "utf8"); + await writeFile( + scriptPath, + "# Demo\n### SHOT one\n- target: prebaked\n- clip: clip.mp4\n- narration: Bound input.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: join(root, "out"), + preflight: true, + }); + + await runPipeline(config); + + expect(renderProbe.rawPath).not.toBe(sourcePath); + expect(renderProbe.rawBytes).toBe(originalBytes); + expect(renderProbe.parentMode).toBe(0o700); + expect(renderProbe.rawMode).toBe(0o400); + expect(renderProbe.clickOffsets).toEqual([[]]); + await expect(stat(renderProbe.rawPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(eventsPath)).rejects.toMatchObject({ code: "ENOENT" }); + + const report = JSON.parse( + await readFile(join(root, "out", "render-report.json"), "utf8"), + ); + expect(report.inputs.clips).toEqual([ + { shotId: "one", sha256: digestFull(originalBytes) }, + ]); + }); + + it("fails closed before fresh-output publication when private-input cleanup fails", async () => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-cleanup-")); + const sourcePath = join(root, "clip.mp4"); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + const requestedOut = join(root, "fresh-output"); + renderProbe.sourcePath = sourcePath; + await writeFile(sourcePath, "admitted clip bytes", "utf8"); + await writeFile( + scriptPath, + "# Demo\n### SHOT one\n- target: prebaked\n- clip: clip.mp4\n- narration: Cleanup gate.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: requestedOut, + preflight: true, + }); + + cleanupFault.enabled = true; + try { + const failure = await runPipeline(config, { requireFreshOut: true }).then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toMatchObject({ + name: "PrivateInputCleanupError", + code: "PRIVATE_INPUT_CLEANUP_FAILED", + }); + expect(failure).toMatchObject({ + retainedOutputPath: expect.stringContaining(".fresh-output.stage-"), + }); + expect((await stat((failure as PrivateInputCleanupError).retainedOutputPath!)).isDirectory()) + .toBe(true); + expect((await stat(requestedOut)).isFile()).toBe(true); + expect(cleanupFault.retainedPath).toContain("agent-demo-video-render-inputs-"); + expect((await stat(cleanupFault.retainedPath)).isDirectory()).toBe(true); + } finally { + cleanupFault.enabled = false; + if (cleanupFault.retainedPath) { + await (await import("node:fs/promises")).rm(cleanupFault.retainedPath, { + recursive: true, + force: true, + }); + } + cleanupFault.retainedPath = ""; + } + }); + + it("preserves the primary pipeline error and classifies a concurrent cleanup failure", async () => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-primary-")); + const sourcePath = join(root, "clip.mp4"); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + const requestedOut = join(root, "fresh-output"); + renderProbe.sourcePath = sourcePath; + renderProbe.failureMessage = "primary render fault"; + await writeFile(sourcePath, "admitted clip bytes", "utf8"); + await writeFile( + scriptPath, + "# Demo\n### SHOT one\n- target: prebaked\n- clip: clip.mp4\n- narration: Preserve primary.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: requestedOut, + preflight: true, + }); + + cleanupFault.enabled = true; + try { + const failure = await runPipeline(config, { requireFreshOut: true }).then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors[0]).toMatchObject({ + message: "primary render fault", + }); + expect((failure as AggregateError).errors[1]).toMatchObject({ + name: "PrivateInputCleanupError", + code: "PRIVATE_INPUT_CLEANUP_FAILED", + retainedPath: cleanupFault.retainedPath, + }); + expect((failure as Error).cause).toBe((failure as AggregateError).errors[0]); + expect((await stat(requestedOut)).isFile()).toBe(true); + } finally { + renderProbe.failureMessage = ""; + cleanupFault.enabled = false; + if (cleanupFault.retainedPath) { + await (await import("node:fs/promises")).rm(cleanupFault.retainedPath, { + recursive: true, + force: true, + }); + } + cleanupFault.retainedPath = ""; + } + }); + + it("reports the unpublished fresh-output stage when binding and cleanup both fail", async () => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-aggregate-cleanup-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "prebaked-binding-aggregate-outside-")); + const validSourcePath = join(root, "valid.mp4"); + const invalidSourcePath = join(root, "invalid.mp4"); + const outsidePath = join(outsideRoot, "outside.mp4"); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + const requestedOut = join(root, "fresh-output"); + await writeFile(validSourcePath, "admitted clip bytes", "utf8"); + await writeFile(outsidePath, "must not be copied", "utf8"); + await symlink(outsidePath, invalidSourcePath); + await writeFile( + scriptPath, + "# Demo\n" + + "### SHOT one\n- target: prebaked\n- clip: valid.mp4\n- narration: Bind first input.\n" + + "### SHOT two\n- target: prebaked\n- clip: invalid.mp4\n- narration: Reject second input.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: requestedOut, + preflight: true, + }); + + cleanupFault.enabled = true; + let retainedOutputPath = ""; + try { + const failure = await runPipeline(config, { requireFreshOut: true }).then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors[0]).toMatchObject({ + message: expect.stringContaining( + 'could not bind prebaked input for shot "two"', + ), + }); + expect((failure as AggregateError).errors[1]).toMatchObject({ + name: "PrivateInputCleanupError", + code: "PRIVATE_INPUT_CLEANUP_FAILED", + retainedPath: cleanupFault.retainedPath, + retainedOutputPath: expect.stringContaining(".fresh-output.stage-"), + }); + retainedOutputPath = (failure as AggregateError).errors[1].retainedOutputPath; + expect((await stat(retainedOutputPath)).isDirectory()).toBe(true); + expect((await stat(requestedOut)).isFile()).toBe(true); + } finally { + cleanupFault.enabled = false; + if (cleanupFault.retainedPath) { + await (await import("node:fs/promises")).rm(cleanupFault.retainedPath, { + recursive: true, + force: true, + }); + } + if (retainedOutputPath) { + await (await import("node:fs/promises")).rm(retainedOutputPath, { + recursive: true, + force: true, + }); + } + cleanupFault.retainedPath = ""; + } + }); + + it("rejects a symlinked prebaked source without reading its out-of-directory target", async () => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-symlink-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "prebaked-binding-outside-")); + const sourcePath = join(root, "clip.mp4"); + const outsidePath = join(outsideRoot, "outside.mp4"); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + await writeFile(outsidePath, "must not be copied", "utf8"); + await symlink(outsidePath, sourcePath); + await writeFile( + scriptPath, + "# Demo\n### SHOT one\n- target: prebaked\n- clip: clip.mp4\n- fullBleed: true\n- narration: Reject symlink.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: join(root, "out"), + preflight: true, + }); + + await expect(runPipeline(config)).rejects.toThrow( + /prebaked source must be a regular, non-symbolic-link file/, + ); + expect(await readFile(outsidePath, "utf8")).toBe("must not be copied"); + }); + + it("does not require a private input temporary root for a dashboard-only render", async () => { + const root = await mkdtemp(join(tmpdir(), "dashboard-without-prebaked-binding-")); + const unsafeTmp = await mkdtemp(join(tmpdir(), "dashboard-unsafe-tmp-")); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + renderProbe.sourcePath = join(root, "operator-marker.txt"); + await writeFile(renderProbe.sourcePath, "unchanged before render", "utf8"); + await writeFile( + scriptPath, + "# Demo\n### SHOT one\n- target: dashboard\n- narration: No private input needed.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + dashboardBaseUrl: "http://localhost:3000", + out: join(root, "out"), + preflight: false, + }); + const originalTmpdir = process.env.TMPDIR; + await chmod(unsafeTmp, 0o777); + process.env.TMPDIR = unsafeTmp; + + try { + await expect(runPipeline(config)).resolves.toMatchObject({ + outPath: join(root, "out", "final.mp4"), + }); + expect(await readdir(unsafeTmp)).toEqual([]); + } finally { + if (originalTmpdir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = originalTmpdir; + await chmod(unsafeTmp, 0o700); + await (await import("node:fs/promises")).rm(unsafeTmp, { + recursive: true, + force: true, + }); + } + }); + + it("refuses an unsafe non-sticky TMPDIR before creating a private input root", async () => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-unsafe-tmp-")); + const unsafeTmp = await mkdtemp(join(tmpdir(), "prebaked-binding-parent-")); + const sourcePath = join(root, "clip.mp4"); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + renderProbe.sourcePath = sourcePath; + await writeFile(sourcePath, "admitted clip bytes", "utf8"); + await writeFile( + scriptPath, + "# Demo\n### SHOT one\n- target: prebaked\n- clip: clip.mp4\n- narration: Refuse unsafe temp parent.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: join(root, "out"), + preflight: false, + }); + const originalTmpdir = process.env.TMPDIR; + await chmod(unsafeTmp, 0o777); + process.env.TMPDIR = unsafeTmp; + + try { + await expect(runPipeline(config)).rejects.toThrow( + /temporary root is not trusted/, + ); + expect(await readdir(unsafeTmp)).toEqual([]); + } finally { + if (originalTmpdir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = originalTmpdir; + await chmod(unsafeTmp, 0o700); + await (await import("node:fs/promises")).rm(unsafeTmp, { + recursive: true, + force: true, + }); + } + }); + + it("refuses a private input temporary root beneath an unsafe ancestor", async () => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-unsafe-ancestor-source-")); + const unsafeAncestor = await mkdtemp( + join(tmpdir(), "prebaked-binding-unsafe-ancestor-"), + ); + const selectedTmp = join(unsafeAncestor, "selected"); + const sourcePath = join(root, "clip.mp4"); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + await mkdir(selectedTmp, { mode: 0o700 }); + await chmod(unsafeAncestor, 0o777); + await writeFile(sourcePath, "admitted clip bytes", "utf8"); + await writeFile( + scriptPath, + "# Demo\n### SHOT one\n- target: prebaked\n- clip: clip.mp4\n- narration: Refuse unsafe temp ancestor.\n", + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: join(root, "out"), + preflight: false, + }); + const originalTmpdir = process.env.TMPDIR; + process.env.TMPDIR = selectedTmp; + + try { + await expect(runPipeline(config)).rejects.toThrow( + /temporary root ancestor is not trusted/, + ); + expect(await readdir(selectedTmp)).toEqual([]); + } finally { + if (originalTmpdir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = originalTmpdir; + await chmod(unsafeAncestor, 0o700); + await (await import("node:fs/promises")).rm(unsafeAncestor, { + recursive: true, + force: true, + }); + } + }); + + it.each([ + ["FIFO", "fifo.mp4"], + ["device", "/dev/null"], + ])("rejects a %s prebaked source without blocking", async (_kind, clipName) => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-special-")); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + if (clipName === "fifo.mp4") { + execFileSync("mkfifo", [join(root, clipName)]); + } + await writeFile( + scriptPath, + `# Demo\n### SHOT one\n- target: prebaked\n- clip: ${clipName}\n- narration: Reject special file.\n`, + "utf8", + ); + const config = DemoConfigSchema.parse({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: "http://localhost:3000", + out: join(root, "out"), + preflight: false, + }); + + await expect(runPipeline(config)).rejects.toThrow( + /prebaked source must be a regular, non-symbolic-link file/, + ); + }); + + it.each(["SIGINT", "SIGTERM"] as const)( + "removes the private render-input root on catchable %s", + async (signal) => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-signal-")); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + const clipPath = join(root, "clip.mp4"); + const configPath = join(root, "demo.config.json"); + const fixtureUrl = pathToFileURL(join(process.cwd(), "tests/fixtures/page.html")).href; + await writeFile(clipPath, "signal fixture clip", "utf8"); + await writeFile( + scriptPath, + `# Demo\n### SHOT source\n- target: prebaked\n- clip: clip.mp4\n- narration: Bind signal fixture.\n### SHOT one\n- target: dashboard\n- narration: Signal cleanup.\n- action: goto url="${fixtureUrl}"\n- action: wait ms=30000\n`, + "utf8", + ); + await writeFile( + configPath, + JSON.stringify({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: fixtureUrl, + out: join(root, "out"), + preflight: false, + }), + "utf8", + ); + const child = spawn( + realpathSync(process.execPath), + [ + "--import", + "tsx", + join(process.cwd(), "src/cli.ts"), + configPath, + ], + { + env: { ...process.env, FAKE_TTS: "1" }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + expect(child.pid).toBeTypeOf("number"); + const childRootPrefix = `agent-demo-video-render-inputs-${child.pid}-`; + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveClose) => child.once("close", (code, signal) => resolveClose({ code, signal })), + ); + let privateRoot = ""; + try { + for (let tries = 0; tries < 5_000; tries++) { + const created = (await readdir(tmpdir())).find( + (name) => name.startsWith(childRootPrefix), + ); + if (created) { + privateRoot = join(tmpdir(), created); + break; + } + if (child.exitCode !== null) break; + await new Promise((resolveWait) => setTimeout(resolveWait, 1)); + } + expect(privateRoot, stderr).not.toBe(""); + child.kill(signal); + const exit = await closed; + const expectedCode = signal === "SIGINT" ? 130 : 143; + expect( + exit.code === expectedCode || exit.signal === signal, + `unexpected ${signal} exit: ${JSON.stringify(exit)}`, + ).toBe(true); + await expect(stat(privateRoot)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (privateRoot) { + await (await import("node:fs/promises")).rm(privateRoot, { + recursive: true, + force: true, + }); + } + } + }, + 30_000, + ); + + it.each(["SIGINT", "SIGTERM"] as const)( + "does not redeliver %s to a pre-existing process listener", + async (signal) => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-signal-listener-")); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + const clipPath = join(root, "clip.mp4"); + const configPath = join(root, "demo.config.json"); + const preloaderPath = join(root, "signal-listener.mjs"); + const listenerMarker = join(root, "listener-count.txt"); + const fixtureUrl = pathToFileURL(join(process.cwd(), "tests/fixtures/page.html")).href; + await writeFile(clipPath, "signal listener fixture clip", "utf8"); + await writeFile( + scriptPath, + `# Demo\n### SHOT source\n- target: prebaked\n- clip: clip.mp4\n- narration: Bind signal listener fixture.\n### SHOT one\n- target: dashboard\n- narration: Signal listener cleanup.\n- action: goto url="${fixtureUrl}"\n- action: wait ms=30000\n`, + "utf8", + ); + await writeFile( + configPath, + JSON.stringify({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: fixtureUrl, + out: join(root, "out"), + preflight: false, + }), + "utf8", + ); + await writeFile( + preloaderPath, + [ + 'import { writeFileSync } from "node:fs";', + "let count = 0;", + `process.on(${JSON.stringify(signal)}, () => {`, + " count += 1;", + ` writeFileSync(${JSON.stringify(listenerMarker)}, String(count));`, + " setTimeout(() => process.exit(0), 100);", + "});", + "", + ].join("\n"), + "utf8", + ); + const child = spawn( + realpathSync(process.execPath), + [ + "--import", + "tsx", + "--import", + pathToFileURL(preloaderPath).href, + join(process.cwd(), "src/cli.ts"), + configPath, + ], + { + env: { ...process.env, FAKE_TTS: "1" }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + expect(child.pid).toBeTypeOf("number"); + const childRootPrefix = `agent-demo-video-render-inputs-${child.pid}-`; + const closed = new Promise((resolveClose) => + child.once("close", resolveClose) + ); + let privateRoot = ""; + try { + for (let tries = 0; tries < 5_000; tries++) { + const created = (await readdir(tmpdir())).find( + (name) => name.startsWith(childRootPrefix), + ); + const narrationReady = await stat( + join(root, "out", "audio", "one.mp3"), + ).then( + () => true, + () => false, + ); + if (created && narrationReady) { + privateRoot = join(tmpdir(), created); + break; + } + if (child.exitCode !== null) break; + await new Promise((resolveWait) => setTimeout(resolveWait, 1)); + } + expect(privateRoot, stderr).not.toBe(""); + child.kill(signal); + expect(await closed, stderr).toBe(0); + expect(await readFile(listenerMarker, "utf8")).toBe("1"); + await expect(stat(privateRoot)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (privateRoot) { + await (await import("node:fs/promises")).rm(privateRoot, { + recursive: true, + force: true, + }); + } + } + }, + 30_000, + ); + + it.each(["SIGINT", "SIGTERM"] as const)( + "cleans private inputs before a pre-existing %s listener exits synchronously", + async (signal) => { + const root = await mkdtemp(join(tmpdir(), "prebaked-binding-sync-signal-listener-")); + const scriptPath = join(root, "DEMO_SCRIPT.md"); + const clipPath = join(root, "clip.mp4"); + const configPath = join(root, "demo.config.json"); + const preloaderPath = join(root, "sync-signal-listener.mjs"); + const listenerMarker = join(root, "listener-count.txt"); + const fixtureUrl = pathToFileURL(join(process.cwd(), "tests/fixtures/page.html")).href; + await writeFile(clipPath, "synchronous signal listener fixture clip", "utf8"); + await writeFile( + scriptPath, + `# Demo\n### SHOT source\n- target: prebaked\n- clip: clip.mp4\n- narration: Bind synchronous signal listener fixture.\n### SHOT one\n- target: dashboard\n- narration: Synchronous signal listener cleanup.\n- action: goto url="${fixtureUrl}"\n- action: wait ms=30000\n`, + "utf8", + ); + await writeFile( + configPath, + JSON.stringify({ + script: scriptPath, + clipsDir: root, + dashboardBaseUrl: fixtureUrl, + out: join(root, "out"), + preflight: false, + }), + "utf8", + ); + await writeFile( + preloaderPath, + [ + 'import { writeFileSync } from "node:fs";', + "let count = 0;", + `process.on(${JSON.stringify(signal)}, () => {`, + " count += 1;", + ` writeFileSync(${JSON.stringify(listenerMarker)}, String(count));`, + " process.exit(0);", + "});", + "", + ].join("\n"), + "utf8", + ); + const child = spawn( + realpathSync(process.execPath), + [ + "--import", + "tsx", + "--import", + pathToFileURL(preloaderPath).href, + join(process.cwd(), "src/cli.ts"), + configPath, + ], + { + env: { ...process.env, FAKE_TTS: "1" }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + expect(child.pid).toBeTypeOf("number"); + const childRootPrefix = `agent-demo-video-render-inputs-${child.pid}-`; + const closed = new Promise((resolveClose) => + child.once("close", resolveClose) + ); + let privateRoot = ""; + try { + for (let tries = 0; tries < 5_000; tries++) { + const created = (await readdir(tmpdir())).find( + (name) => name.startsWith(childRootPrefix), + ); + if (created) { + privateRoot = join(tmpdir(), created); + break; + } + if (child.exitCode !== null) break; + await new Promise((resolveWait) => setTimeout(resolveWait, 1)); + } + expect(privateRoot, stderr).not.toBe(""); + child.kill(signal); + expect(await closed, stderr).toBe(0); + expect(await readFile(listenerMarker, "utf8")).toBe("1"); + await expect(stat(privateRoot)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (privateRoot) { + await (await import("node:fs/promises")).rm(privateRoot, { + recursive: true, + force: true, + }); + } + } + }, + 30_000, + ); + + itOnLinux("recovers only one explicitly named owned stale render-input root", async () => { + const staleRoot = await mkdtemp( + join( + tmpdir(), + `agent-demo-video-render-inputs-${DEAD_RECOVERY_PID}-`, + ), + ); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await markPrivateInputRoot(staleRoot); + const runRecovery = () => execFileSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + tmpdir(), + "--older-than-seconds", + "3600", + staleRoot, + ], + { encoding: "utf8" }, + ); + + expect(runRecovery).toThrow(/not old enough/); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(staleRoot, old, old); + expect(runRecovery()).toContain(`removed stale private render-input root: ${staleRoot}`); + await expect(stat(staleRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + itOnLinux("refuses a trailing-slash symlink without deleting through it", async () => { + const selectedTmpRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-parent-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-outside-")); + const marker = join(outsideRoot, "must-survive.txt"); + const staleLink = join( + selectedTmpRoot, + "agent-demo-video-render-inputs-stale-symlink", + ); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await writeFile(marker, "must survive", "utf8"); + await symlink(outsideRoot, staleLink); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(outsideRoot, old, old); + + const runRecovery = () => + execFileSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + `${staleLink}/`, + ], + { encoding: "utf8" }, + ); + + try { + expect(runRecovery).toThrow(); + expect(await readFile(marker, "utf8")).toBe("must survive"); + expect((await stat(outsideRoot)).isDirectory()).toBe(true); + } finally { + await (await import("node:fs/promises")).rm(selectedTmpRoot, { + recursive: true, + force: true, + }); + await (await import("node:fs/promises")).rm(outsideRoot, { + recursive: true, + force: true, + }); + } + }); + + itOnLinux("requires privileged Bash startup for stale-root recovery", async () => { + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + const unprivileged = spawnSync("/usr/bin/bash", [recoveryScript], { + encoding: "utf8", + }); + expect(unprivileged.status).not.toBe(0); + expect(unprivileged.stderr).toContain( + "requires Bash privileged startup mode", + ); + + const selectedTmpRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-startup-")); + const staleRoot = await mkdtemp( + join( + selectedTmpRoot, + `agent-demo-video-render-inputs-${DEAD_RECOVERY_PID}-`, + ), + ); + const bashEnvironment = join(selectedTmpRoot, "hostile-bash-env"); + const startupMarker = join(selectedTmpRoot, "hostile-startup-ran"); + await writeFile(bashEnvironment, `: > "${startupMarker}"\n`, "utf8"); + await markPrivateInputRoot(staleRoot); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(staleRoot, old, old); + + try { + const privileged = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + staleRoot, + ], + { + encoding: "utf8", + env: { ...process.env, BASH_ENV: bashEnvironment }, + }, + ); + expect(privileged.status, privileged.stderr).toBe(0); + await expect(stat(startupMarker)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await (await import("node:fs/promises")).rm(selectedTmpRoot, { + recursive: true, + force: true, + }); + } + }); + + itOnLinux("rejects every unexpected character in a recovery target suffix", async () => { + const selectedTmpRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-name-")); + const unexpectedRoot = join( + selectedTmpRoot, + "agent-demo-video-render-inputs-x totally-unrelated", + ); + const marker = join(unexpectedRoot, "must-survive.txt"); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await mkdir(unexpectedRoot, { mode: 0o700 }); + await writeFile(marker, "must survive", "utf8"); + await markPrivateInputRoot(unexpectedRoot); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(unexpectedRoot, old, old); + + const runRecovery = () => + execFileSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + unexpectedRoot, + ], + { encoding: "utf8" }, + ); + + try { + expect(runRecovery).toThrow(/unexpected render-input root name/); + expect(await readFile(marker, "utf8")).toBe("must survive"); + } finally { + await (await import("node:fs/promises")).rm(selectedTmpRoot, { + recursive: true, + force: true, + }); + } + }); + + itOnLinux("refuses recovery beneath a writable non-sticky temporary root", async () => { + const selectedTmpRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-unsafe-")); + const staleRoot = await mkdtemp( + join( + selectedTmpRoot, + `agent-demo-video-render-inputs-${DEAD_RECOVERY_PID}-`, + ), + ); + const marker = join(staleRoot, "must-survive.txt"); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await writeFile(marker, "must survive", "utf8"); + await markPrivateInputRoot(staleRoot); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(staleRoot, old, old); + await chmod(selectedTmpRoot, 0o777); + + const runRecovery = () => + execFileSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + staleRoot, + ], + { encoding: "utf8" }, + ); + + try { + expect(runRecovery).toThrow(/temporary root must be trusted/); + expect(await readFile(marker, "utf8")).toBe("must survive"); + } finally { + await chmod(selectedTmpRoot, 0o700); + await (await import("node:fs/promises")).rm(selectedTmpRoot, { + recursive: true, + force: true, + }); + } + }); + + itOnLinux("refuses a private temporary root beneath an unsafe ancestor", async () => { + const unsafeAncestor = await mkdtemp(join(tmpdir(), "prebaked-recovery-ancestor-")); + const selectedTmpRoot = join(unsafeAncestor, "private-tmp"); + await mkdir(selectedTmpRoot, { mode: 0o700 }); + const staleRoot = await mkdtemp( + join( + selectedTmpRoot, + `agent-demo-video-render-inputs-${DEAD_RECOVERY_PID}-`, + ), + ); + const marker = join(staleRoot, "must-survive.txt"); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await writeFile(marker, "must survive", "utf8"); + await markPrivateInputRoot(staleRoot); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(staleRoot, old, old); + await chmod(unsafeAncestor, 0o777); + + const runRecovery = () => + execFileSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + staleRoot, + ], + { encoding: "utf8" }, + ); + + try { + expect(runRecovery).toThrow(/temporary root ancestor is not trusted/); + expect(await readFile(marker, "utf8")).toBe("must survive"); + } finally { + await chmod(unsafeAncestor, 0o700); + await (await import("node:fs/promises")).rm(unsafeAncestor, { + recursive: true, + force: true, + }); + } + }); + + itOnLinux("refuses an unmarked directory that only imitates a stale render-input root", async () => { + const selectedTmpRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-unmarked-")); + const imitationRoot = await mkdtemp( + join( + selectedTmpRoot, + `agent-demo-video-render-inputs-${DEAD_RECOVERY_PID}-`, + ), + ); + const marker = join(imitationRoot, "must-survive.txt"); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await writeFile(marker, "must survive", "utf8"); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(imitationRoot, old, old); + + const runRecovery = () => + execFileSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + imitationRoot, + ], + { encoding: "utf8" }, + ); + + try { + expect(runRecovery).toThrow(/created-by-pipeline marker/); + expect(await readFile(marker, "utf8")).toBe("must survive"); + } finally { + await (await import("node:fs/promises")).rm(selectedTmpRoot, { + recursive: true, + force: true, + }); + } + }); + + itOnLinux("refuses recovery while the root's owning process is still live", async () => { + const selectedTmpRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-live-")); + const liveRoot = await mkdtemp( + join( + selectedTmpRoot, + `agent-demo-video-render-inputs-${process.pid}-`, + ), + ); + const marker = join(liveRoot, "must-survive.txt"); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await writeFile(marker, "must survive", "utf8"); + await markPrivateInputRoot(liveRoot); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(liveRoot, old, old); + + const runRecovery = () => + execFileSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + liveRoot, + ], + { encoding: "utf8" }, + ); + + try { + expect(runRecovery).toThrow(/owning process is still live/); + expect(await readFile(marker, "utf8")).toBe("must survive"); + } finally { + await (await import("node:fs/promises")).rm(selectedTmpRoot, { + recursive: true, + force: true, + }); + } + }); + + itOnLinux("reports committed stale-root deletion as success even when stdout is full", async () => { + const selectedTmpRoot = await mkdtemp(join(tmpdir(), "prebaked-recovery-output-")); + const staleRoot = await mkdtemp( + join( + selectedTmpRoot, + `agent-demo-video-render-inputs-${DEAD_RECOVERY_PID}-`, + ), + ); + const recoveryScript = join( + process.cwd(), + "scripts/cleanup-stale-render-input-root.sh", + ); + await markPrivateInputRoot(staleRoot); + const old = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await utimes(staleRoot, old, old); + const fullOutput = openSync("/dev/full", "w"); + + try { + const recovery = spawnSync( + "/usr/bin/bash", + [ + "--noprofile", + "--norc", + "-p", + recoveryScript, + "--tmp-root", + selectedTmpRoot, + "--older-than-seconds", + "3600", + staleRoot, + ], + { + encoding: "utf8", + stdio: ["ignore", fullOutput, "pipe"], + }, + ); + expect(recovery.status, recovery.stderr).toBe(0); + await expect(stat(staleRoot)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + closeSync(fullOutput); + await (await import("node:fs/promises")).rm(selectedTmpRoot, { + recursive: true, + force: true, + }); + } + }); +}); diff --git a/tests/prebaked-narration.smoke.test.ts b/tests/prebaked-narration.smoke.test.ts index f0a6e7a..ac8ef73 100644 --- a/tests/prebaked-narration.smoke.test.ts +++ b/tests/prebaked-narration.smoke.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +7,7 @@ import { runPipeline } from "../src/pipeline"; import { probeDurationSec } from "../src/ffmpeg"; import { estimateDurationSec } from "../src/fake-tts"; import { DemoConfigSchema } from "../src/types"; +import { digestFile, digestFull, stableConfigJson } from "../src/provenance"; function sh(bin: string, args: string[]): Promise { return new Promise((res, rej) => { @@ -71,6 +72,14 @@ describe("runPipeline (prebaked clip shorter than its narration)", () => { // video long would pass the duration-only check above but fail here. const audioSec = await probeDurationSec(join(dir, "out", "audio.mp3")); expect(audioSec).toBeGreaterThanOrEqual(expectedSec - 0.2); + const report = JSON.parse( + await readFile(join(dir, "out", "render-report.json"), "utf8"), + ); + expect(report.inputs.configSha256).toBe(digestFull(stableConfigJson(cfg))); + expect(report.inputs.scriptSha256).toBe(digestFull(md)); + expect(report.inputs.clips).toEqual([ + { shotId: "one", sha256: await digestFile(clip) }, + ]); }, 120_000); it("does not truncate when the clip is only slightly shorter than the narration (sub-tolerance band)", async () => { diff --git a/tests/preflight.smoke.test.ts b/tests/preflight.smoke.test.ts index 685060a..4bad476 100644 --- a/tests/preflight.smoke.test.ts +++ b/tests/preflight.smoke.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, afterAll, beforeAll } from "vitest"; import { existsSync } from "node:fs"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { resolveSelectorFindings } from "../src/preflight"; +import { resolveSelectorFindings, runPreflight } from "../src/preflight"; import { runPipeline } from "../src/pipeline"; +import { ffmpeg } from "../src/ffmpeg"; import { DemoConfigSchema, ManifestSchema } from "../src/types"; const fixture = () => pathToFileURL(resolve("tests/fixtures/page.html")).href; @@ -252,6 +253,90 @@ describe("preflight gate in runPipeline (smoke)", () => { expect(existsSync(join(dir, "out", "audio"))).toBe(false); }, 120_000); + it("invalidates an earlier render receipt before a blocking preflight exits", async () => { + const { dir, scriptPath } = await ambiguousScript(); + const out = join(dir, "out"); + const report = join(out, "render-report.json"); + await mkdir(out, { recursive: true }); + await writeFile(report, '{"parity":{"status":"pass"}}\n'); + const cfg = DemoConfigSchema.parse({ + script: scriptPath, + dashboardBaseUrl: "http://localhost:3000", + out, + resolution: { width: 1280, height: 720 }, + }); + + await expect(runPipeline(cfg)).rejects.toThrow(/preflight/i); + + expect(existsSync(report)).toBe(false); + }, 120_000); + + it("blocks a wrong-aspect fullBleed clip before narration while accepting a matching composition", async () => { + const dir = await mkdtemp(join(tmpdir(), "preflight-fullbleed-")); + const landscape = join(dir, "landscape.mp4"); + const portrait = join(dir, "portrait.mp4"); + const portraitSar = join(dir, "portrait-sar.mp4"); + const portraitSymlink = join(dir, "portrait-symlink.mp4"); + await ffmpeg(["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=red:s=320x180:d=0.2", "-c:v", "libx264", "-pix_fmt", "yuv420p", landscape]); + await ffmpeg(["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=blue:s=180x320:d=0.2", "-c:v", "libx264", "-pix_fmt", "yuv420p", portrait]); + await ffmpeg(["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=green:s=90x320:d=0.2", "-vf", "setsar=2/1", "-c:v", "libx264", "-pix_fmt", "yuv420p", portraitSar]); + await symlink(portrait, portraitSymlink); + const config = DemoConfigSchema.parse({ + script: join(dir, "demo.md"), + dashboardBaseUrl: "http://localhost:3000", + out: join(dir, "out"), + resolution: { width: 360, height: 640 }, + }); + const manifestFor = (clip: string) => ManifestSchema.parse({ + shots: [{ + id: "portrait-proof", + target: "prebaked", + clip, + fullBleed: true, + narration: "A finished portrait composition.", + actions: [], + }], + }); + + expect(await runPreflight(manifestFor(portrait), config)).toEqual([]); + const symlinkFindings = await runPreflight( + manifestFor(portraitSymlink), + config, + ); + expect(symlinkFindings).toMatchObject([{ + shotId: "portrait-proof", + kind: "missing-clip", + severity: "blocking", + }]); + const findings = await runPreflight(manifestFor(landscape), config); + expect(findings).toMatchObject([{ + shotId: "portrait-proof", + kind: "invalid-clip-geometry", + severity: "blocking", + }]); + const sarFindings = await runPreflight(manifestFor(portraitSar), config); + expect(sarFindings).toMatchObject([{ + shotId: "portrait-proof", + kind: "invalid-clip-geometry", + severity: "blocking", + }]); + expect(sarFindings[0]?.message).toMatch(/non-square sample aspect ratio 2:1.*square pixels/s); + + await writeFile( + config.script, + [ + "### SHOT portrait-proof", + "- target: prebaked", + `- clip: ${landscape}`, + "- fullBleed: true", + "- narration: A finished portrait composition.", + "", + ].join("\n"), + ); + await expect(runPipeline(config)).rejects.toThrow(/preflight/i); + expect(existsSync(join(dir, "out", "audio"))).toBe(false); + }, 120_000); + // Declining must restore the pre-gate behaviour AND say so. A silent decline // would read identically to a gate that ran and found nothing, which is the // exact ambiguity this gate exists to remove. diff --git a/tests/render-aspect-guard.test.ts b/tests/render-aspect-guard.test.ts index a1b9e7b..3501c92 100644 --- a/tests/render-aspect-guard.test.ts +++ b/tests/render-aspect-guard.test.ts @@ -35,6 +35,28 @@ describe("renderVideo framed-aspect guard (shorts)", () => { }; await expect(renderVideo(inputs)).rejects.toThrow(/intro.*fullBleed|fullBleed.*intro/s); }, 60_000); + + it("rejects a landscape fullBleed clip on a portrait canvas before normalization", async () => { + const dir = await mkdtemp(join(tmpdir(), "fullbleed-aspect-guard-")); + const landscape = join(dir, "landscape.mp4"); + await ffmpeg(["-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=red:s=320x180:d=0.4", "-c:v", "libx264", "-pix_fmt", "yuv420p", landscape]); + + const inputs: RenderInputs = { + rawSegments: [landscape], + tts: [{ shotId: "portrait-proof", audioPath: join(dir, "unused.mp3"), durationSec: 0.4, alignment: { chars: [], startSec: [], endSec: [] } }], + segmentKinds: ["card"], + config: { + audio: { soundDesign: false, bedDb: -28, ticks: true, sweeps: true }, + resolution: { width: 360, height: 640 }, + fps: 30, + theme: { captionFont: "Liberation Sans", captionSize: 24, cursor: true, captionBox: true, captionMarginV: 20, captions: "block" as const, captionAccent: "#3fb950", fadeInMs: 250, frame: { enabled: false, scale: 0.86, radius: 24, backdropTop: "#101418", backdropBottom: "#1d2733", shadow: false }, annotations: { enabled: true, durationMs: 500, fontSize: 24, position: "top-right" as const } }, + out: join(dir, "out"), + maxDurationSec: 300, + }, + }; + + await expect(renderVideo(inputs)).rejects.toThrow(/portrait-proof.*320x180.*360x640/s); + }, 60_000); }); /** @@ -53,4 +75,46 @@ describe("probeSizePx display geometry", () => { await ffmpeg(["-y", "-hide_banner", "-loglevel", "error", "-display_rotation", "90", "-i", plain, "-c", "copy", rotated]); expect(await probeSizePx(rotated)).toEqual({ width: 180, height: 320 }); }, 60_000); + + it("rejects display-equivalent anamorphic input before normalization can distort it", async () => { + const dir = await mkdtemp(join(tmpdir(), "probe-sar-")); + const sar = join(dir, "sar.mp4"); + await ffmpeg([ + "-y", "-hide_banner", "-loglevel", "error", + // Coded 90x320 with SAR 2:1 displays as portrait 180x320. A display-only + // aspect probe would approve it, but normalize currently operates on the + // coded 9:32 shape and would add bars before preserving the non-square SAR. + "-f", "lavfi", "-i", "color=c=green:s=90x320:d=0.2", + "-vf", "setsar=2/1", + "-c:v", "libx264", "-pix_fmt", "yuv420p", sar, + ]); + + await expect(probeSizePx(sar)).rejects.toThrow(/non-square sample aspect ratio 2:1.*square pixels/s); + }, 60_000); + + it("rejects anamorphic fullBleed input at the direct render seam", async () => { + const dir = await mkdtemp(join(tmpdir(), "render-sar-guard-")); + const sar = join(dir, "portrait-sar.mp4"); + await ffmpeg([ + "-y", "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "color=c=green:s=90x320:d=0.4", + "-vf", "setsar=2/1", + "-c:v", "libx264", "-pix_fmt", "yuv420p", sar, + ]); + const inputs: RenderInputs = { + rawSegments: [sar], + tts: [{ shotId: "sar-proof", audioPath: join(dir, "unused.mp3"), durationSec: 0.4, alignment: { chars: [], startSec: [], endSec: [] } }], + segmentKinds: ["card"], + config: { + audio: { soundDesign: false, bedDb: -28, ticks: true, sweeps: true }, + resolution: { width: 360, height: 640 }, + fps: 30, + theme: { captionFont: "Liberation Sans", captionSize: 24, cursor: true, captionBox: true, captionMarginV: 20, captions: "block" as const, captionAccent: "#3fb950", fadeInMs: 250, frame: { enabled: false, scale: 0.86, radius: 24, backdropTop: "#101418", backdropBottom: "#1d2733", shadow: false }, annotations: { enabled: true, durationMs: 500, fontSize: 24, position: "top-right" as const } }, + out: join(dir, "out"), + maxDurationSec: 300, + }, + }; + + await expect(renderVideo(inputs)).rejects.toThrow(/non-square sample aspect ratio 2:1.*square pixels/s); + }, 60_000); });