Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ file is the human-readable summary of what changed and why.

### Desktop / Web UI

- **The office skills now work inside a sandbox** — the document skills
(Word, Excel, PowerPoint, PDF) were installed into the sandbox image, but
several pieces they depend on were not, so they failed once you actually
used them. LibreOffice was installed in a state where it could not start at
all, which broke every document conversion. The .NET packages the Word skill
compiles against were absent, and a sandbox has no route to the internet to
fetch them, so that skill could not run. The browser the PDF skill uses to
render covers was missing entirely. All three now ship in the image, and the
image build refuses to finish if LibreOffice cannot convert a document, so
this class of breakage cannot reach you again. The skills also now match the
versions they were tested against — LibreOffice 26.2.5, pandoc 3.10.1, .NET
8.0.423 and a fixed set of Python packages — instead of whichever versions
the build happened to pick up. **Requires the updated sandbox image**: use a
sandbox's **Reset** button to pick it up. Sandboxes are noticeably larger to
download as a result.
- **Sandboxes can be stopped** — a sandbox ran from the moment the app started
until it was deleted, and every sandbox on the machine booted at launch, so
three sandboxes meant three virtual machines holding memory and CPU whether
Expand Down
8 changes: 4 additions & 4 deletions packages/codev-sandbox/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ RUN cd /opt && npm install --no-save --no-audit --no-fund jsonc-parser @lydell/n
# because sandbox homes are fresh. Skipped when OFFICE=0.
FROM base AS office
ARG OFFICE=1
COPY office/install-office.sh /tmp/install-office.sh
COPY office/bundle/ /tmp/office-bundle/
RUN if [ "$OFFICE" = "1" ]; then bash /tmp/install-office.sh /tmp/office-bundle; fi \
&& rm -rf /tmp/office-bundle /tmp/install-office.sh
# Bind-mounted, not COPYed: the bundle is well over a gigabyte, and a COPY
# would commit all of it to a layer that the install step cannot delete again.
RUN --mount=type=bind,source=office,target=/office \
if [ "$OFFICE" = "1" ]; then bash /office/install-office.sh /office/bundle; fi

# Guest layer: the vz init (PID 1) and the mount points every backend expects.
# Harmless in the bwrap/wsl tars.
Expand Down
318 changes: 268 additions & 50 deletions packages/codev-sandbox/image/office/install-office.sh
Original file line number Diff line number Diff line change
@@ -1,77 +1,295 @@
#!/usr/bin/env bash
# System-wide translation of codev-office-ubuntu-setup.sh for the sandbox
# guest image. Runs inside the Docker build as root; $1 is the staged bundle
# dir (skill zips + pure-JS node-module zips — arch-independent parts only;
# dotnet/pandoc/libreoffice/wheels are fetched arch-correct right here, since
# the BUILD machine has internet even though the sealed guest will not).
# dir (the whole codev-office-ubuntu bundle, minus its macOS-only artifacts).
#
# The bundle is the proven set: the office skills are tested against exactly
# these component versions, so these are the versions installed here. Every
# version is read off the bundle's own filenames rather than pinned again in
# this file, so refreshing the bundle moves the image with it.
#
# The bundle's binaries are x86_64-only. On arm64 the same version is fetched
# arch-correct instead (the build machine has internet; the sealed guest will
# not) — the arch changes, the version never does. Anything the bundle pins
# that cannot be matched on this arch is a hard failure, not a substitution.
#
# The per-user layout of the setup script becomes system-wide here, because
# sandbox homes are fresh: /etc/skel seeds each one (vz at image build, other
# backends from their boot scripts).
set -euxo pipefail
BUNDLE="${1:-/tmp/office-bundle}"
ARCH="$(dpkg --print-architecture)"
OFFICE_HOME=/opt/codev-office

# pandoc + LibreOffice + fonts from Debian (available on amd64 and arm64).
bundled() { ls "$BUNDLE"/$1 2>/dev/null | head -1 || true; }
require() { [ -n "$2" ] || { echo "office bundle is missing $1" >&2; exit 1; }; }

# dpkg -i does not resolve dependencies and the guest is a slim Debian: the
# TDF LibreOffice debs and the .NET SDK both need libraries that are not here
# by default. `apt-get install -f` after each dpkg pulls what they name; ICU
# is installed up front because dotnet refuses to run without it (the setup
# script's alternative, DOTNET_SYSTEM_GLOBALIZATION_INVARIANT, silently
# changes culture behaviour, so give it the real thing instead).
apt-get update
apt-get install -y --no-install-recommends \
pandoc libreoffice-writer libreoffice-calc libreoffice-impress \
fonts-dejavu fonts-liberation2
rm -rf /var/lib/apt/lists/*
apt-get install -y --no-install-recommends libicu72 fonts-dejavu fonts-liberation2

# .NET SDK 8 into /opt/dotnet (the docx/xlsx skills drive OpenXML with it).
case "$ARCH" in
amd64) DOTNET_ARCH=x64 ;;
arm64) DOTNET_ARCH=arm64 ;;
esac
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
bash /tmp/dotnet-install.sh --channel 8.0 --install-dir /opt/dotnet --architecture "$DOTNET_ARCH"
rm /tmp/dotnet-install.sh
# --- .NET SDK (required by the docx/xlsx skills) ---
# An 8.x SDK specifically, and this exact 8.x: only SDK 8 carries the net8.0
# targeting packs locally, and a newer SDK tries to restore them from NuGet,
# which the offline feed does not carry (NU1101).
dotnet_tar="$(bundled 'dotnet-sdk-*-linux-x64.tar.gz')"
require "dotnet-sdk-*-linux-x64.tar.gz" "$dotnet_tar"
DOTNET_VERSION="$(basename "$dotnet_tar" | sed -E 's/^dotnet-sdk-(.+)-linux-x64\.tar\.gz$/\1/')"
mkdir -p /opt/dotnet
if [ "$ARCH" = "amd64" ]; then
tar -xzf "$dotnet_tar" -C /opt/dotnet
else
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
bash /tmp/dotnet-install.sh --version "$DOTNET_VERSION" --install-dir /opt/dotnet --architecture arm64
rm /tmp/dotnet-install.sh
fi
ln -sf /opt/dotnet/dotnet /usr/local/bin/dotnet
dotnet --list-sdks

# Python package set mirrored from the office bundle's wheel list, installed
# into the system python at build time (the guest never runs pip online).
pip3 install --break-system-packages --no-cache-dir \
pandas openpyxl 'markitdown[pptx]' reportlab pypdf matplotlib

# Prebuilt pure-JS node module trees from the bundle, merged into one tree on
# NODE_PATH (mirrors the setup script's ~/.node_modules require() fallback).
if compgen -G "$BUNDLE/*-node-modules.zip" >/dev/null; then
mkdir -p /opt/codev-office/node_modules /tmp/modules-staging
for mod_zip in "$BUNDLE"/*-node-modules.zip; do
unzip -q "$mod_zip" -d /tmp/modules-staging/current
if [ -d /tmp/modules-staging/current/node_modules ]; then
cp -R /tmp/modules-staging/current/node_modules/. /opt/codev-office/node_modules/
fi
rm -rf /tmp/modules-staging/current
done
dotnet --list-sdks | grep -q "^${DOTNET_VERSION} " || {
echo "expected .NET SDK ${DOTNET_VERSION}, got: $(dotnet --list-sdks)" >&2
exit 1
}

# --- pandoc ---
pandoc_deb="$(bundled 'pandoc-*-amd64.deb')"
require "pandoc-*-amd64.deb" "$pandoc_deb"
PANDOC_VERSION="$(basename "$pandoc_deb" | sed -E 's/^pandoc-([0-9.]+)-[0-9]+-amd64\.deb$/\1/')"
if [ "$ARCH" = "amd64" ]; then
pandoc_install="$pandoc_deb"
else
pandoc_install=/tmp/pandoc-arm64.deb
curl -fsSL -o "$pandoc_install" \
"https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/$(basename "${pandoc_deb%-amd64.deb}-arm64.deb")"
fi
dpkg -i "$pandoc_install" || apt-get install -y --no-install-recommends -f
rm -f /tmp/pandoc-arm64.deb
pandoc --version | head -1 | grep -q "${PANDOC_VERSION}"

# Skills into a system path; /etc/skel seeds each fresh sandbox home with the
# config pointing at them (vz homes get skel at image build, bwrap homes are
# seeded by the provider boot script).
if compgen -G "$BUNDLE/*-skill.zip" >/dev/null; then
mkdir -p /opt/codev-skills
for skill_zip in "$BUNDLE"/*-skill.zip; do
name="$(basename "$skill_zip" .zip)"
name="${name%-skill}"
unzip -q "$skill_zip" -d "/opt/codev-skills/$name"
# --- LibreOffice ---
lo_tar="$(bundled 'LibreOffice_*_Linux_x86-64_deb.tar.gz')"
require "LibreOffice_*_Linux_x86-64_deb.tar.gz" "$lo_tar"
LO_VERSION="$(basename "$lo_tar" | sed -E 's/^LibreOffice_(.+)_Linux_x86-64_deb\.tar\.gz$/\1/')"
if [ "$ARCH" = "amd64" ]; then
lo_install="$lo_tar"
else
lo_install=/tmp/libreoffice-aarch64.tar.gz
curl -fsSL -o "$lo_install" \
"https://download.documentfoundation.org/libreoffice/stable/${LO_VERSION}/deb/aarch64/LibreOffice_${LO_VERSION}_Linux_aarch64_deb.tar.gz"
fi
lo_tmp="$(mktemp -d)"
tar -xzf "$lo_install" -C "$lo_tmp"
dpkg -i "$lo_tmp"/LibreOffice_*/DEBS/*.deb || apt-get install -y --no-install-recommends -f
rm -rf "$lo_tmp" /tmp/libreoffice-aarch64.tar.gz
# The TDF debs declare almost no dependencies, so `dpkg -i` succeeds and the
# `-f` fallback above never fires — while LibreOffice still needs a pile of
# system libraries a slim Debian does not carry. Hand-listing them does not
# work: it dlopens several (libnss3's libssl3.so, libcairo.so.2), so they are
# invisible to ldd and surface only as "error while loading shared libraries"
# the first time soffice runs. Debian packages the same software, so its
# libreoffice-core dependencies are the authoritative list — take those and
# let apt resolve them, rather than maintaining a guess here.
apt-get install -y --no-install-recommends $(
apt-cache depends --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances \
libreoffice-core | awk '/Depends:/ {print $2}' | grep -vE '^(libreoffice|ure|<)' | sort -u
)
# The debs only create a versioned /usr/local/bin/libreofficeX.Y symlink, but
# doc_to_docx.sh (docx skill) resolves LibreOffice solely as `soffice` on PATH.
if ! command -v soffice >/dev/null 2>&1; then
for candidate in /opt/libreoffice*/program/soffice; do
[ -x "$candidate" ] && ln -sf "$candidate" /usr/local/bin/soffice && break
done
mkdir -p /etc/skel/.config/codev
cat > /etc/skel/.config/codev/codev.json <<'JSON'
fi
command -v soffice >/dev/null 2>&1 || { echo "soffice is not on PATH after installing LibreOffice" >&2; exit 1; }
# Convert something, rather than just checking the binary is on PATH: a
# LibreOffice missing a shared library resolves fine as a path and fails only
# when the docx skill first tries to use it, which is far too late to find out.
lo_check="$(mktemp -d)"
printf 'sandbox image check' > "$lo_check/probe.txt"
# Guarded so a failing soffice does not trip `set -e` before the check below
# can say what went wrong; its stderr is kept for the same reason.
HOME="$lo_check" soffice --headless --convert-to pdf "$lo_check/probe.txt" --outdir "$lo_check" || true
[ -s "$lo_check/probe.pdf" ] || { echo "LibreOffice ${LO_VERSION} cannot convert a document" >&2; exit 1; }
rm -rf "$lo_check"

# --- Python packages ---
# amd64 installs the bundle's wheels verbatim and offline. arm64 has no x86_64
# wheels to install, so it resolves from PyPI under a constraints file built
# from those same wheels: same versions, including transitive ones, arch-correct
# binaries. Guest python is 3.11, so cp311 is the self-consistent selection.
python3 - "$BUNDLE/python-wheels" > /tmp/office-constraints.txt <<'PY'
import os, re, sys

# Pick, per distribution, the bundle wheel that pip would install on cp311:
# an exact cp311 build first, then a forward-compatible abi3 build (magika
# ships cp39-abi3, protobuf cp310-abi3 — miss these and arm64 silently
# resolves a newer version than the bundle proved), then a pure-python wheel.
def rank(pytag, abitag, plattag):
if "cp311" in pytag:
return 3
if abitag == "abi3" and re.fullmatch(r"cp(\d+)", pytag) and int(pytag[2:]) <= 311:
return 2
# abi-less wheels, whether universal (py3-none-any) or platform-tagged
# (magika ships py3-none-manylinux_x86_64). Only the version is taken from
# these, so an x86_64 tag here still yields the right pin for arm64, where
# pip picks that version's own aarch64-compatible wheel.
if abitag == "none" and pytag.startswith("py"):
return 1
return 0

def version_key(version):
return tuple(int(part) if part.isdigit() else 0 for part in re.split(r"[.\-+]", version))

best = {}
for name in os.listdir(sys.argv[1]):
if not name.endswith(".whl"):
continue
dist, version, rest = name[:-4].split("-", 2)
tags = rest.split("-")
if len(tags) < 3:
continue
scored = rank(tags[-3], tags[-2], tags[-1])
if not scored:
continue
key = dist.replace("_", "-").lower()
if key not in best or (scored, version_key(version)) > best[key][0]:
best[key] = ((scored, version_key(version)), version)
for key in sorted(best):
print(f"{key}=={best[key][1]}")
PY
PIP_PACKAGES=(pandas openpyxl 'markitdown[pptx]' reportlab pypdf matplotlib)
if [ "$ARCH" = "amd64" ]; then
pip3 install --break-system-packages --no-cache-dir \
--no-index --find-links "$BUNDLE/python-wheels" "${PIP_PACKAGES[@]}"
else
pip3 install --break-system-packages --no-cache-dir \
-c /tmp/office-constraints.txt "${PIP_PACKAGES[@]}"
fi
rm -f /tmp/office-constraints.txt
python3 -c 'import pandas, openpyxl, markitdown, reportlab, pypdf, matplotlib, defusedxml'

# --- 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.
mkdir -p "$OFFICE_HOME/node_modules"
staging="$(mktemp -d)"
for mod_zip in "$BUNDLE"/*-node-modules.zip; do
[ -f "$mod_zip" ] || continue
unzip -q "$mod_zip" -d "$staging/current"
[ -d "$staging/current/node_modules" ] && cp -R "$staging/current/node_modules/." "$OFFICE_HOME/node_modules/"
rm -rf "$staging/current"
done
rm -rf "$staging"
mkdir -p /etc/skel
ln -sfn "$OFFICE_HOME/node_modules" /etc/skel/.node_modules

# --- 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
# x86_64 build; on arm64 the bundled module installs its own matching browser.
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"
playwright_cli="$OFFICE_HOME/node_modules/playwright/cli.js"
if [ "$ARCH" = "amd64" ] && [ -f "$BUNDLE/ms-playwright-chromium.zip" ]; then
unzip -q "$BUNDLE/ms-playwright-chromium.zip" -d "$PLAYWRIGHT_BROWSERS_PATH"
elif [ -f "$playwright_cli" ]; then
NODE_PATH="$OFFICE_HOME/node_modules" node "$playwright_cli" install chromium
fi
# The browser binary alone is not enough on a slim Debian: without its shared
# libraries Chromium is present but cannot launch, which surfaces only at
# runtime as a failed pdf cover. Non-fatal like every other playwright step in
# the setup script — the rest of the pdf skill works without covers.
if [ -f "$playwright_cli" ]; then
NODE_PATH="$OFFICE_HOME/node_modules" node "$playwright_cli" install-deps chromium \
|| echo "warn: playwright install-deps failed; pdf cover rendering may be unavailable" >&2
fi
ls "$PLAYWRIGHT_BROWSERS_PATH"/chromium-* >/dev/null 2>&1 \
|| echo "warn: no chromium in $PLAYWRIGHT_BROWSERS_PATH; pdf cover rendering unavailable" >&2

# --- Offline NuGet feed ---
# Required, exactly as in the setup script: the docx skill's CLI restores
# DocumentFormat.OpenXml and friends from here, and a sealed sandbox has no
# route to nuget.org. The config clears nuget.org so a restore fails loudly
# rather than hanging on an unreachable host.
feed_src="$BUNDLE/nuget-offline-feed"
[ -d "$feed_src" ] || { echo "nuget-offline-feed not found in the bundle" >&2; exit 1; }
feed="$OFFICE_HOME/nuget-offline-feed"
mkdir -p "$feed" /etc/skel/.nuget/NuGet
cp "$feed_src"/*.nupkg "$feed/"
cat > /etc/skel/.nuget/NuGet/NuGet.Config <<EOF
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="offline" value="$feed" />
</packageSources>
</configuration>
EOF

# --- Skills ---
# Into a system path; the skel config below points every fresh home at it.
mkdir -p /opt/codev-skills
for skill_zip in "$BUNDLE"/*-skill.zip; do
[ -f "$skill_zip" ] || continue
name="$(basename "$skill_zip" .zip)"
name="${name%-skill}"
# Unzip through a staging dir and move the inner tree up, exactly as the
# setup script does: the zips carry their own top-level <skill>/ directory,
# so unzipping straight into the destination buries SKILL.md a level deeper
# than the proven layout. Discovery survives that (its glob is recursive and
# these skills name themselves in frontmatter), but a skill without a
# frontmatter name would silently fail to load, since the filename fallback
# only applies to a SKILL.md sitting directly in the configured directory.
staging="$(mktemp -d)"
unzip -q "$skill_zip" -d "$staging"
rm -rf "/opt/codev-skills/$name"
if [ -d "$staging/$name" ]; then
mv "$staging/$name" "/opt/codev-skills/$name"
else
mv "$staging" "/opt/codev-skills/$name"
fi
rm -rf "$staging"
done
for required in minimax-docx minimax-xlsx; do
[ -f "/opt/codev-skills/$required/SKILL.md" ] \
|| { echo "$required is missing or has no SKILL.md at its root" >&2; exit 1; }
done
mkdir -p /etc/skel/.config/codev
cat > /etc/skel/.config/codev/codev.json <<'JSON'
{
"skills": {
"paths": ["/opt/codev-skills"]
}
}
JSON
fi

# Env the guest boot paths source (vz init and the bwrap boot script).
cat > /etc/codev-guest.env <<'ENV'
# Env the guest boot paths source (vz init and the bwrap/wsl boot scripts).
cat > /etc/codev-guest.env <<ENV
export DOTNET_ROOT=/opt/dotnet
export NODE_PATH=/opt/codev-office/node_modules
export NODE_PATH=$OFFICE_HOME/node_modules
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
export DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV

# Record what actually went in, so a built image can be audited without
# re-deriving versions from its filesystem.
cat > /etc/codev-office-versions <<ENV
dotnet=$DOTNET_VERSION
pandoc=$PANDOC_VERSION
libreoffice=$LO_VERSION
arch=$ARCH
ENV

rm -rf /var/lib/apt/lists/*

# The image build already populated /home/codev before this layer; re-apply
# skel so baked homes (vz disk) get the skills config too.
# skel so baked homes (vz disk) get the skills config, the NuGet config and
# the module link too.
cp -Rn /etc/skel/. /home/codev/ 2>/dev/null || true
chown -R codev:codev /home/codev
Loading
Loading