diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index 830417cce5..b62b9f1cf3 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -124,7 +124,10 @@ containerized (any machine with Docker): the optional office layer: `image/office/install-office.sh` translates the codev-office setup script system-wide — pandoc + LibreOffice + fonts from Debian, .NET 8 SDK at `/opt/dotnet`, the pinned Python package set, the - bundle's pure-JS node-module trees on `NODE_PATH`, and the office skills at + bundle's node-module trees (pure-JS ones verbatim; sharp's native binary + re-resolved for the guest arch) reachable three ways — `NODE_PATH`, + `~/.node_modules` via skel, and `/usr/local/lib/node`, the one that needs + neither the environment nor `$HOME` — and the office skills at `/opt/codev-skills` with an `/etc/skel` codev.json pointing at them. Fresh sandbox homes get skel (vz at image build; bwrap seeded by the boot script), and images declare extra env in `/etc/codev-guest.env`. diff --git a/packages/codev-sandbox/image/office/install-office.sh b/packages/codev-sandbox/image/office/install-office.sh index d792b57608..e20c05b646 100644 --- a/packages/codev-sandbox/image/office/install-office.sh +++ b/packages/codev-sandbox/image/office/install-office.sh @@ -173,10 +173,14 @@ python3 -c 'import pandas, openpyxl, markitdown, reportlab, pypdf, matplotlib, d # --- JS module trees --- # Merged into one tree the way the setup script merges them under -# ~/.codev-office. Reached two ways on purpose: NODE_PATH in the guest env -# (which the server exports, so its shell tool inherits it) and an -# /etc/skel/.node_modules symlink — one of Node's global require() fallback -# folders — so require() also resolves from a plain shell in any directory. +# ~/.codev-office. Reached three ways on purpose, because each of the first two +# has a way to not be there: NODE_PATH in the guest env (the server exports it, +# so its shell tool inherits it — unless something along the way replaces the +# environment), an /etc/skel/.node_modules symlink (one of Node's global +# require() fallback folders — but only for a process whose HOME is a home the +# skel was copied into, which a root shell's /root is not), and +# /usr/local/lib/node below, which is the same fallback list without either +# dependency. mkdir -p "$OFFICE_HOME/node_modules" staging="$(mktemp -d)" for mod_zip in "$BUNDLE"/*-node-modules.zip; do @@ -188,6 +192,73 @@ done rm -rf "$staging" mkdir -p /etc/skel ln -sfn "$OFFICE_HOME/node_modules" /etc/skel/.node_modules +# $PREFIX/lib/node — the entry in Node's global fallback list that depends on +# neither the environment nor $HOME. Node derives it from its own binary +# (execPath/../../lib/node, so /usr/local/lib/node for the tarball install in +# the Dockerfile), which means require("pptxgenjs") resolves for any user, from +# any directory, with NODE_PATH unset. That is worth having even when the other +# two work: an agent that cannot require() the module goes looking for it, and +# `npm install -g pptxgenjs` cannot fix it either (npm -g installs into +# $PREFIX/lib/node_modules, which is NOT on require()'s search path) in a guest +# with no route off loopback. +# +# The fallback list is consulted only after the full node_modules walk-up, so a +# workspace project's own dependencies always win. The one behaviour this does +# change: a project MISSING a dependency the office tree happens to carry +# (react, react-dom) resolves it from here instead of failing. +mkdir -p /usr/local/lib +ln -sfn "$OFFICE_HOME/node_modules" /usr/local/lib/node + +# Those trees are copied verbatim because they are pure JS — one build serves +# every arch. sharp (the pptx skill's react-icons -> SVG -> PNG path) is the +# exception: it loads a prebuilt libvips binary from an @img/sharp-- +# optional dependency, and the bundle carries the linux-x64 one. On arm64 that +# binary cannot load, so re-resolve it here exactly as every other +# arch-specific component in this file is handled — same version, arch-correct +# build. dpkg's arm64 is npm's arm64, and the Dockerfile refuses any arch that +# is not amd64 or arm64, so ARCH doubles as the --cpu value on this branch. +# +# Resolved in a scratch dir and copied in, NOT installed into the shared tree: +# `npm install` in a node_modules dir with no package.json reifies the tree +# from scratch and drops everything it cannot account for, which would take +# PptxGenJS and Playwright with it. +sharp_pkg="$OFFICE_HOME/node_modules/sharp/package.json" +if [ "$ARCH" != "amd64" ] && [ -f "$sharp_pkg" ]; then + SHARP_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$sharp_pkg")" + sharp_scratch="$(mktemp -d)" + ( cd "$sharp_scratch" && npm init -y >/dev/null \ + && npm install --no-audit --no-fund --os=linux --cpu="$ARCH" --libc=glibc "sharp@$SHARP_VERSION" >/dev/null ) + # A silently-ignored --cpu yields a tree that looks right and throws only in + # the guest, so require the binary package by name rather than trusting npm. + [ -d "$sharp_scratch/node_modules/@img/sharp-linux-$ARCH" ] \ + || { echo "no @img/sharp-linux-$ARCH for sharp $SHARP_VERSION (npm >= 10 needed for --cpu)" >&2; exit 1; } + rm -rf "$OFFICE_HOME/node_modules/@img" + cp -R "$sharp_scratch/node_modules/." "$OFFICE_HOME/node_modules/" + rm -rf "$sharp_scratch" +fi + +# Use the trees rather than just checking they unpacked, the way the LibreOffice +# step converts a document instead of resolving a binary. They arrive by a +# wildcard glob, so a zip that failed to unpack, or a native binary built for +# the other arch, is otherwise invisible until an agent tries to build a deck. +# +# Deliberately run with NODE_PATH unset, from a directory with no relationship +# to the tree: that is the guest's worst case (a shell whose environment did not +# survive, in the user's workspace), so it is what the image should be proven +# against. Passing here means the /usr/local/lib/node fallback carries it alone. +office_node() { ( cd / && env -u NODE_PATH -u NODE_OPTIONS node -e "$1" ); } +if [ -d "$OFFICE_HOME/node_modules/pptxgenjs" ]; then + office_node 'const P=require("pptxgenjs");const p=new P();p.layout="LAYOUT_16x9";p.addSlide().addText("image check",{x:1,y:1,w:8,h:1,fontSize:24});p.writeFile({fileName:"/tmp/office-check.pptx"}).then(function(){process.exit(0);}).catch(function(e){console.error(e);process.exit(1);});' + python3 -m markitdown /tmp/office-check.pptx >/dev/null + rm -f /tmp/office-check.pptx +fi +# Icons are optional per the skill's own docs, so this warns like the playwright +# steps below rather than failing the build: without them decks still come out +# of shapes, text and charts. +if [ -d "$OFFICE_HOME/node_modules/sharp" ]; then + office_node 'const React=require("react"),RDS=require("react-dom/server"),sharp=require("sharp");const {FaCheckCircle}=require("react-icons/fa");const svg=RDS.renderToStaticMarkup(React.createElement(FaCheckCircle,{color:"#000000",size:"64"}));sharp(Buffer.from(svg)).png().toBuffer().then(function(b){process.exit(b.length?0:1);}).catch(function(e){console.error(e);process.exit(1);});' \ + || echo "warn: the pptx icon toolchain is unusable; react-icons artwork will be unavailable" >&2 +fi # --- Chromium for Playwright (pdf cover rendering) --- # Playwright launches the browser build matching its own version, so the