From 80e7098b7bae9e607c61ef6054e5b9ec390e33f3 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Thu, 06 Aug 2026 07:07:12 +0000 Subject: [PATCH] build: guard bundled worker freshness --- lith/deploy.fish | 21 ++++++++++++++++++++- system/package.json | 1 + system/public/aesthetic.computer/lib/disk-worker-manifest.json | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- system/public/aesthetic.computer/lib/disk.worker.65043469c4f1.mjs | 51 ++++++++++++++++++++++++++++++++++++++++----------- system/scripts/build-disk-worker.mjs | 6 ++++++ system/scripts/disk-worker-integrity.mjs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/scripts/verify-disk-worker.mjs | 18 ++++++++++++++++++ tests/performance/boot-performance.test.mjs | 31 ++++++++++++++++++++++++++----- 8 file(s) changed, 238 insertion(s)(+), 20 deletion(s)(-) diff --git a/lith/deploy.fish b/lith/deploy.fish --- a/lith/deploy.fish +++ b/lith/deploy.fish @@ -187,7 +187,13 @@ end end echo -e "$GREEN-> Deploying branch $TARGET_BRANCH at $ORIGIN_HEAD...$NC" -ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ +set PREVIOUS_HEAD (ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && git rev-parse HEAD") +if test -z "$PREVIOUS_HEAD" + echo -e "$RED x Could not resolve the currently deployed commit.$NC" + exit 1 +end + +if not ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ cd $REMOTE_DIR && \ git fetch origin $TARGET_BRANCH --quiet && \ if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then \ @@ -197,6 +203,19 @@ git checkout -B $TARGET_BRANCH origin/$TARGET_BRANCH --quiet; \ fi && \ git reset --hard origin/$TARGET_BRANCH --quiet && \ git rev-parse HEAD > system/public/.commit-ref" + echo -e "$RED x Failed to check out origin/$TARGET_BRANCH on $TARGET_HOST.$NC" + exit 1 +end + +echo -e "$GREEN-> Verifying disk worker bundle freshness...$NC" +if not ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR/system && node scripts/verify-disk-worker.mjs" + echo -e "$RED x Disk worker bundle is missing or stale; restoring $PREVIOUS_HEAD.$NC" + ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ +cd $REMOTE_DIR && \ +git reset --hard $PREVIOUS_HEAD --quiet && \ +git rev-parse HEAD > system/public/.commit-ref" + exit 1 +end # Upload env (only if the vault has one — otherwise keep the remote's existing env) # Note: lith.service reads EnvironmentFile=/opt/ac/system/.env, so the canonical diff --git a/system/package.json b/system/package.json --- a/system/package.json +++ b/system/package.json @@ -26,6 +26,7 @@ "media-server-caddy-old": "caddy file-server --root public --listen :8111 --browse | jq .", "media-server-caddy": "caddy run --config Caddyfile", "media-server-nginx": "sudo nginx -s stop; sudo nginx -c $(realpath ./nginx.conf) && sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log", "build:disk-worker": "node scripts/build-disk-worker.mjs", + "verify:disk-worker": "node scripts/verify-disk-worker.mjs", "codespaces-dev-debug": "kill-port 8889 8888 8080 8000 8111 3333; netlify dev --debug", "codespaces-dev-public": "kill-port 8080 8888 8889 8000 8111 3333; netlify dev --live", "local-dev-netlify": "http-server public -p 8080 --cors -c-1 -i -s --P https://127.0.0.1:443?", diff --git a/system/public/aesthetic.computer/lib/disk-worker-manifest.json b/system/public/aesthetic.computer/lib/disk-worker-manifest.json --- a/system/public/aesthetic.computer/lib/disk-worker-manifest.json +++ b/system/public/aesthetic.computer/lib/disk-worker-manifest.json @@ -1,5 +1,62 @@ { - "filename": "disk.worker.6e14aa1aee71.mjs", - "sha256": "6e14aa1aee717741aec199a170ec47153fc5126c84c69b6e383fe010b5840ec4", - "bytes": 1652555 + "filename": "disk.worker.65043469c4f1.mjs", + "sha256": "65043469c4f1971344309067fa053bd59ef709597daa0269b588becafbd4c6a3", + "bytes": 1653600, + "sourceSha256": "cffecaf41984a79486466e22fe465725c64d92dfb2f70aba93f6d914ff8ef6cd", + "sources": [ + "public/aesthetic.computer/dep/@akamfoad/qr/qr.mjs", + "public/aesthetic.computer/dep/gl-matrix/common.mjs", + "public/aesthetic.computer/dep/gl-matrix/mat3.mjs", + "public/aesthetic.computer/dep/gl-matrix/mat4.mjs", + "public/aesthetic.computer/dep/gl-matrix/quat.mjs", + "public/aesthetic.computer/dep/gl-matrix/vec2.mjs", + "public/aesthetic.computer/dep/gl-matrix/vec3.mjs", + "public/aesthetic.computer/dep/gl-matrix/vec4.mjs", + "public/aesthetic.computer/dep/idb.js", + "public/aesthetic.computer/dep/nanoid/nanoid.js", + "public/aesthetic.computer/dep/nanoid/url-alphabet/index.js", + "public/aesthetic.computer/disks/common/debug.mjs", + "public/aesthetic.computer/disks/common/fonts.mjs", + "public/aesthetic.computer/disks/common/tape-player.mjs", + "public/aesthetic.computer/lib/ask.mjs", + "public/aesthetic.computer/lib/cam-doll.mjs", + "public/aesthetic.computer/lib/chat.mjs", + "public/aesthetic.computer/lib/color-highlighting.mjs", + "public/aesthetic.computer/lib/disk.mjs", + "public/aesthetic.computer/lib/fade-state.mjs", + "public/aesthetic.computer/lib/frame-capture.mjs", + "public/aesthetic.computer/lib/gamepad-mappings.mjs", + "public/aesthetic.computer/lib/geo.mjs", + "public/aesthetic.computer/lib/gizmo.mjs", + "public/aesthetic.computer/lib/graph.mjs", + "public/aesthetic.computer/lib/headers.mjs", + "public/aesthetic.computer/lib/help.mjs", + "public/aesthetic.computer/lib/helpers.mjs", + "public/aesthetic.computer/lib/kidlisp.mjs", + "public/aesthetic.computer/lib/l5.mjs", + "public/aesthetic.computer/lib/logs.mjs", + "public/aesthetic.computer/lib/melody-highlighter.mjs", + "public/aesthetic.computer/lib/melody-parser.mjs", + "public/aesthetic.computer/lib/note-colors.mjs", + "public/aesthetic.computer/lib/num.mjs", + "public/aesthetic.computer/lib/p5-worker.mjs", + "public/aesthetic.computer/lib/pack-mode.mjs", + "public/aesthetic.computer/lib/pads.mjs", + "public/aesthetic.computer/lib/parse.mjs", + "public/aesthetic.computer/lib/piece-permissions.mjs", + "public/aesthetic.computer/lib/platform.mjs", + "public/aesthetic.computer/lib/pmove.mjs", + "public/aesthetic.computer/lib/redact.mjs", + "public/aesthetic.computer/lib/restricted-api.mjs", + "public/aesthetic.computer/lib/shop.mjs", + "public/aesthetic.computer/lib/socket.mjs", + "public/aesthetic.computer/lib/sound/sound-whitelist.mjs", + "public/aesthetic.computer/lib/text.mjs", + "public/aesthetic.computer/lib/ticker.mjs", + "public/aesthetic.computer/lib/type.mjs", + "public/aesthetic.computer/lib/ui.mjs", + "public/aesthetic.computer/systems/nopaint.mjs", + "public/aesthetic.computer/systems/prompt-system.mjs", + "public/aesthetic.computer/systems/world.mjs" + ] } diff --git a/system/public/aesthetic.computer/lib/disk.worker.6e14aa1aee71.mjs b/system/public/aesthetic.computer/lib/disk.worker.65043469c4f1.mjs rename from system/public/aesthetic.computer/lib/disk.worker.6e14aa1aee71.mjs rename to system/public/aesthetic.computer/lib/disk.worker.65043469c4f1.mjs --- a/system/public/aesthetic.computer/lib/disk.worker.6e14aa1aee71.mjs +++ b/system/public/aesthetic.computer/lib/disk.worker.65043469c4f1.mjs @@ -6317,8 +6317,47 @@ const cX = Math.floor(crop.x); const cY = Math.floor(crop.y); const cW = Math.floor(crop.w); const cH = Math.floor(crop.h); + const srcWidth = from.width; + const srcHeight = from.height; + const targetW = tWidth || (scale7 && typeof scale7 === "number" ? Math.floor(cW * scale7) : cW); + const targetH = tHeight || (scale7 && typeof scale7 === "number" ? Math.floor(cH * scale7) : cH); + if ((targetW !== cW || targetH !== cH) && !angle3 && (sourcePixels.byteOffset & 3) === 0 && (pixels.byteOffset & 3) === 0) { + const scaleX = cW / targetW; + const scaleY = cH / targetH; + let startY = Math.max(0, -destY); + let endY = Math.min(targetH, height - destY); + let startX = Math.max(0, -destX); + let endX = Math.min(targetW, width - destX); + if (activeMask) { + const maskX = activeMask.x + panTranslation.x; + const maskY = activeMask.y + panTranslation.y; + startX = Math.max(startX, maskX - destX); + endX = Math.min(endX, maskX + activeMask.width - destX); + startY = Math.max(startY, maskY - destY); + endY = Math.min(endY, maskY + activeMask.height - destY); + } + if (endX > startX && endY > startY) { + blitCropScale( + sourcePixels, + srcWidth, + srcHeight, + cX, + cY, + scaleX, + scaleY, + destX, + destY, + pixels, + width, + startX, + startY, + endX, + endY + ); + } + return; + } const croppedPixels = new Uint8ClampedArray(cW * cH * 4); - const srcWidth = from.width; for (let y = 0; y < cH; y++) { const srcY = cY + y; if (srcY >= 0 && srcY < from.height) { @@ -6348,8 +6387,6 @@ width: cW, height: cH, pixels: croppedPixels }; - const targetW = tWidth || (scale7 && typeof scale7 === "number" ? Math.floor(cW * scale7) : cW); - const targetH = tHeight || (scale7 && typeof scale7 === "number" ? Math.floor(cH * scale7) : cH); if ((targetW !== cW || targetH !== cH) && !angle3) { const scaleX = cW / targetW; const scaleY = cH / targetH; @@ -41829,14 +41866,6 @@ const c4 = pixels2.subarray(i2, i2 + 4); const p = { x: i2 / 4 % width2, y: floor11(i2 / 4 / width2) }; shader(p, c4); } - } - } else if (lastActiveVideo) { - const { pixels: pixels2 } = lastActiveVideo; - for (let i2 = 0; i2 < pixels2.length; i2 += 4) { - pixels2[i2] = 255; - pixels2[i2 + 1] = 0; - pixels2[i2 + 2] = 0; - pixels2[i2 + 3] = 255; } } return activeVideo || lastActiveVideo; diff --git a/system/scripts/build-disk-worker.mjs b/system/scripts/build-disk-worker.mjs --- a/system/scripts/build-disk-worker.mjs +++ b/system/scripts/build-disk-worker.mjs @@ -3,6 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { computeSourceSha256 } from "./disk-worker-integrity.mjs"; const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const systemDir = process.env.AC_DISK_WORKER_SYSTEM_DIR @@ -35,6 +36,7 @@ platform: "browser", target: ["es2022"], treeShaking: true, legalComments: "none", + metafile: true, write: false, plugins: [preserveRuntimeImports], }); @@ -47,10 +49,14 @@ ); const sha256 = createHash("sha256").update(output).digest("hex"); const filename = `disk.worker.${sha256.slice(0, 12)}.mjs`; +const sources = Object.keys(result.metafile?.inputs || {}).sort(); +const sourceSha256 = await computeSourceSha256(systemDir, sources); const manifest = { filename, sha256, bytes: output.length, + sourceSha256, + sources, }; await mkdir(libDir, { recursive: true }); diff --git a/system/scripts/disk-worker-integrity.mjs b/system/scripts/disk-worker-integrity.mjs new file mode 100644 --- /dev/null +++ b/system/scripts/disk-worker-integrity.mjs @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const WORKER_FILENAME = /^disk\.worker\.[a-f0-9]{12}\.mjs$/; +const SHA256 = /^[a-f0-9]{64}$/; + +function validateSources(sources) { + if (!Array.isArray(sources) || sources.length === 0) { + throw new Error("worker manifest has no source list"); + } + const sorted = [...new Set(sources)].sort(); + if (sorted.length !== sources.length || sorted.some((source, i) => source !== sources[i])) { + throw new Error("worker manifest sources must be unique and sorted"); + } + for (const source of sorted) { + if ( + typeof source !== "string" || + path.isAbsolute(source) || + source.split(/[\\/]/).includes("..") + ) { + throw new Error(`invalid worker source path: ${source}`); + } + } + return sorted; +} + +export async function computeSourceSha256(systemDir, sources) { + const hash = createHash("sha256"); + hash.update("ac-disk-worker-sources-v1\0"); + for (const source of validateSources(sources)) { + const bytes = await readFile(path.join(systemDir, source)); + hash.update(source); + hash.update("\0"); + hash.update(String(bytes.length)); + hash.update("\0"); + hash.update(bytes); + hash.update("\0"); + } + return hash.digest("hex"); +} + +export async function verifyDiskWorker(systemDir) { + const libDir = path.join(systemDir, "public/aesthetic.computer/lib"); + const manifest = JSON.parse( + await readFile(path.join(libDir, "disk-worker-manifest.json"), "utf8"), + ); + if (!WORKER_FILENAME.test(manifest.filename || "")) { + throw new Error("invalid worker filename"); + } + if (!SHA256.test(manifest.sha256 || "") || !SHA256.test(manifest.sourceSha256 || "")) { + throw new Error("invalid worker manifest hash"); + } + + const worker = await readFile(path.join(libDir, manifest.filename)); + const workerSha256 = createHash("sha256").update(worker).digest("hex"); + if (worker.length !== manifest.bytes || workerSha256 !== manifest.sha256) { + throw new Error("worker artifact does not match its manifest"); + } + + const sourceSha256 = await computeSourceSha256(systemDir, manifest.sources); + if (sourceSha256 !== manifest.sourceSha256) { + throw new Error("worker bundle is stale; run npm run build:disk-worker"); + } + + return { ...manifest, sourceCount: manifest.sources.length }; +} diff --git a/system/scripts/verify-disk-worker.mjs b/system/scripts/verify-disk-worker.mjs new file mode 100644 --- /dev/null +++ b/system/scripts/verify-disk-worker.mjs @@ -0,0 +1,18 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { verifyDiskWorker } from "./disk-worker-integrity.mjs"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const systemDir = process.env.AC_DISK_WORKER_SYSTEM_DIR + ? path.resolve(process.env.AC_DISK_WORKER_SYSTEM_DIR) + : path.resolve(scriptsDir, ".."); + +try { + const result = await verifyDiskWorker(systemDir); + console.log( + `${result.filename} verified (${result.bytes} bytes, ${result.sourceCount} sources)`, + ); +} catch (error) { + console.error(`Disk worker verification failed: ${error.message}`); + process.exit(1); +} diff --git a/tests/performance/boot-performance.test.mjs b/tests/performance/boot-performance.test.mjs --- a/tests/performance/boot-performance.test.mjs +++ b/tests/performance/boot-performance.test.mjs @@ -42,6 +42,7 @@ const TEST_URL = process.env.TEST_URL || "https://aesthetic.computer"; const BLOCK_WORKER_MANIFEST = process.env.AC_BLOCK_WORKER_MANIFEST === "1"; const EXPECT_WORKER_FALLBACK = process.env.AC_EXPECT_WORKER_FALLBACK === "1"; const EXPECT_STANDARD_WORKER = process.env.AC_EXPECT_STANDARD_WORKER === "1"; +const EXPECT_WORKER_BUNDLE = process.env.AC_EXPECT_WORKER_BUNDLE === "1"; const OUT_DIR = "./tests/performance/reports"; const CORE = ["boot.mjs", "bios.mjs", "disk.mjs", "kidlisp.mjs", "graph.mjs"]; const BOOT_BUDGET = 6000; // ms, anon boot target — warn (don't fail) over this @@ -94,6 +95,21 @@ } catch {} return origFetch.apply(this, arguments); }; }); +} + +const NAVIGATION_CONTEXT_ERROR = + /Execution context was destroyed|Cannot find context with specified id|Most likely because of a navigation/i; + +async function evaluateThroughUrlRewrite(page, fn, ...args) { + for (let attempt = 0; attempt < 10; attempt++) { + try { + return await page.evaluate(fn, ...args); + } catch (error) { + if (!NAVIGATION_CONTEXT_ERROR.test(error?.message || String(error))) throw error; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + throw new Error("Page context did not settle after URL rewrite"); } // Wait for the authoritative complete stamp. Fall back to a settled timeline + @@ -102,7 +118,7 @@ async function waitForBoot(page, maxMs = 60000) { const t0 = Date.now(); let last = -1, stable = 0; while (Date.now() - t0 < maxMs) { - const s = await page.evaluate(() => { + const s = await evaluateThroughUrlRewrite(page, () => { const c = document.querySelector("canvas"); return { complete: window.__bootComplete, @@ -180,7 +196,7 @@ const booted = await waitForBoot(page); const wallMs = Date.now() - wallStart; - const data = await page.evaluate(() => { + const data = await evaluateThroughUrlRewrite(page, () => { let auth = null; try { auth = window.acAuthTiming?.computeDurations?.() || null; } catch {} const nav = performance.getEntriesByType("navigation")[0] || {}; @@ -197,7 +213,7 @@ hasCanvas: !!document.querySelector("canvas"), }; }); - const resources = await page.evaluate(() => + const resources = await evaluateThroughUrlRewrite(page, () => performance.getEntriesByType("resource") .map((r) => ({ name: (r.name.split("/").pop() || r.name).split("?")[0], ms: r.duration, size: r.transferSize })) .sort((a, b) => b.ms - a.ms).slice(0, 12)); @@ -261,8 +277,13 @@ const workerBundleOk = EXPECT_STANDARD_WORKER ? data.workerBundle && !data.workerBundle.requested && !data.workerBundle.active : EXPECT_WORKER_FALLBACK ? data.workerBundle?.requested && Boolean(data.workerBundle.fallback) - : !data.workerBundle?.requested || - (data.workerBundle.active && data.workerBundle.ready && !data.workerBundle.fallback); + : EXPECT_WORKER_BUNDLE + ? data.workerBundle?.requested && + data.workerBundle.active && + data.workerBundle.ready && + !data.workerBundle.fallback + : !data.workerBundle?.requested || + (data.workerBundle.active && data.workerBundle.ready && !data.workerBundle.fallback); const ok = booted.done && data.hasCanvas && timeline.length > 0 && workerBundleOk; console.log(ok ? "✅ booted with a render surface" : "❌ boot did not complete / no canvas"); if (bootMs > BOOT_BUDGET) console.log(`⚠️ boot ${bootMs}ms over ${BOOT_BUDGET}ms budget (baseline note, not a failure)`); -- tangled.sh