From afb795b8cd76f293bab745c11bfce3d06576db13 Mon Sep 17 00:00:00 2001 From: quickbeard Date: Wed, 19 Aug 2026 14:48:16 -0700 Subject: [PATCH 1/2] fix(sandbox): re-resolve sharp for the guest arch in the office layer The office layer copies the bundle's node-module trees in verbatim, which was right for exactly as long as they were pure JS. The codev-office bundle is gaining a fourth tree for the pptx skill's icon path (react + react-dom + react-icons + sharp), and sharp loads a prebuilt libvips binary from an @img/sharp-- optional dependency. The ubuntu bundle carries the linux-x64 one, so on an arm64 guest require("sharp") would throw and the skill's whole Icons section would be dead there - while the amd64 image worked, which is the shape of bug that reaches users rather than CI. Handled the way every other arch-specific component in this file already is: the version is read off what the bundle proved, and only the binary is re-fetched arch-correct. Two details are load-bearing. It resolves in a scratch dir rather than installing into the shared tree, because `npm install` in a node_modules dir with no package.json reifies the tree and drops everything it cannot account for - PptxGenJS and Playwright included. And it asserts @img/sharp-linux- by name afterwards, because a --cpu that npm ignores produces a tree that looks correct and fails only in the guest. The whole block is inert until a bundle actually carries sharp, so this can land before the bundle does. Also adds the Node-side verification the layer never had. Everything else here is verified by use - dotnet --list-sdks, soffice converting a document, importing the Python set - but the module trees arrive through a wildcard glob and nothing looked at them again, so a zip that failed to unpack surfaced only when an agent tried to build a deck. It now compiles a one-slide deck and reads it back with markitdown (fatal, matching the setup script), and runs the react-icons -> SVG -> PNG round trip as a warning, since the skill's own docs call icons optional. Co-Authored-By: Claude Opus 5 (1M context) --- packages/codev-sandbox/README.md | 3 +- .../image/office/install-office.sh | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index 830417cce5..99ea17158b 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -124,7 +124,8 @@ 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 on `NODE_PATH` (pure-JS ones verbatim; sharp's + native binary re-resolved for the guest arch), 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..ff21e19fd7 100644 --- a/packages/codev-sandbox/image/office/install-office.sh +++ b/packages/codev-sandbox/image/office/install-office.sh @@ -189,6 +189,52 @@ rm -rf "$staging" mkdir -p /etc/skel ln -sfn "$OFFICE_HOME/node_modules" /etc/skel/.node_modules +# 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. +export NODE_PATH="$OFFICE_HOME/node_modules" +if [ -d "$OFFICE_HOME/node_modules/pptxgenjs" ]; then + node -e '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 + node -e '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 # bundle ships the module tree and the browser together. That zip is an From f373f4f367aa827500928c45566919d68c413683 Mon Sep 17 00:00:00 2001 From: quickbeard Date: Wed, 19 Aug 2026 15:13:05 -0700 Subject: [PATCH 2/2] fix(sandbox): resolve the office modules without NODE_PATH or $HOME The office tree was reachable two ways, and both can be absent at once. A sandbox shell reported HOME=/root while every backend sets HOME=/home/codev (codev-init, wsl/provider.ts, vz/provider.ts), so the boot environment had not survived to it: no NODE_PATH, and no ~/.node_modules either, because that symlink is seeded from /etc/skel into /home/codev and /root never got one. require("pptxgenjs") then fails in an image that contains pptxgenjs. What the agent does next is the part worth designing against. It goes looking - a `find /` that the shell kills at 60s, an ls of /usr/lib/node_modules that the tarball install never used, a `which` for a library that has no bin - and then falls back to `npm install -g pptxgenjs`, which cannot work twice over: the guest has no route off loopback, and npm -g installs into $PREFIX/lib/node_modules, which is not on require()'s search path anyway. So add the third route: $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, so /usr/local/lib/node for the Dockerfile's tarball install, and require() then resolves for any user, from any directory, with NODE_PATH unset. The list is consulted only after the full node_modules walk-up, so a workspace project's own dependencies still win; the one behaviour it changes is that a project MISSING a dependency the office tree carries (react, react-dom) resolves it from here instead of failing. The verification added earlier now runs with NODE_PATH unset from an unrelated directory, which is both the guest's worst case and the only way to prove the new route rather than the old one. Co-Authored-By: Claude Opus 5 (1M context) --- packages/codev-sandbox/README.md | 6 ++- .../image/office/install-office.sh | 39 +++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index 99ea17158b..b62b9f1cf3 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -124,8 +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 node-module trees on `NODE_PATH` (pure-JS ones verbatim; sharp's - native binary re-resolved for the guest arch), 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 ff21e19fd7..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,22 @@ 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 @@ -221,9 +241,14 @@ fi # 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. -export NODE_PATH="$OFFICE_HOME/node_modules" +# +# 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 - node -e '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);});' + 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 @@ -231,7 +256,7 @@ fi # 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 - node -e '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);});' \ + 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