[eric] runner: the image had node but no npm, so CreateApp scaffolded an app it could never build

This commit is contained in:
ciregenz
2026-08-01 01:20:11 -07:00
parent f401793604
commit e9ed479b93
2 changed files with 74 additions and 2 deletions
+30 -2
View File
@@ -39,6 +39,21 @@ RUN printf '{"name":"router-stage","version":"0.0.0","private":true}\n' > packag
&& test -f node_modules/9router/app/server.js \
&& test -z "$(find node_modules/9router -name '*.node' -print -quit)"
# The App Builder's template dependencies, installed once here and shipped ALREADY EXTRACTED at
# the digest path backend/apps/outputs/view_builder_templates.py already probes. Without it the
# first CreateApp in a run pays a cold npm install against the public registry, and a run with no
# egress just fails. NOT $BUILDPLATFORM: vite pulls in a platform-specific esbuild, so this has to
# resolve on the arch the container will actually run on.
FROM node:${NODE_VERSION}-bookworm-slim AS webapp-template
WORKDIR /stage
COPY backend/apps/outputs/webapp_template/frontend/package.json ./package.json
RUN set -eux; \
npm install --no-audit --no-fund --loglevel=error --ignore-scripts; \
test -x node_modules/.bin/vite; \
digest="$(sha256sum package.json | cut -c1-12)"; \
mkdir -p "/out/${digest}"; \
mv node_modules "/out/${digest}/node_modules"
# Webpack output is architecture-independent, so this runs natively on the build host rather than under emulation.
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS frontend
WORKDIR /src
@@ -104,6 +119,10 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*
COPY --from=node /usr/local/bin/node /usr/local/bin/node
# npm and npx too, not just node. They are shims into lib/node_modules, so copying the tree and
# re-linking is the only way to get them; a `node` with no `npm` is what left the App Builder
# scaffolding an app it could never install, build or serve.
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=pydeps /opt/pydeps /usr/local
# Numeric owner on every /app copy, because a `chown -R /app` afterwards rewrites the whole tree into a second layer and the image pays for it twice (that cost 493MB before this line existed). Numeric, not `runner`, because the user is created further down.
@@ -119,8 +138,14 @@ COPY --from=frontend --chown=10001:10001 /src/dist /app/frontend
COPY --from=uv --chown=10001:10001 /stage/uv /app/backend/uv-bin/uv
COPY --from=uv --chown=10001:10001 /stage/uvx /app/backend/uv-bin/uvx
# Also after backend/, and at the exact path bundled_extracted_modules() looks for.
COPY --from=webapp-template --chown=10001:10001 /out /app/backend/apps/outputs/webapp_template_cache
RUN set -eux; \
if ls /app/backend/.env* >/dev/null 2>&1; then echo "a dotenv reached the image; fix Dockerfile.dockerignore" >&2; exit 1; fi; \
ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \
ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \
npm --version >/dev/null; \
mkdir -p /app/python-env/bin; \
ln -s /usr/local/bin/python3 /app/python-env/bin/python3; \
find /app/backend -name '__pycache__' -type d -prune -exec rm -rf {} +; \
@@ -129,7 +154,8 @@ RUN set -eux; \
ln -s /data/openswarm /app/backend/data; \
mkdir -p /tmp/.X11-unix; \
chmod 1777 /tmp/.X11-unix; \
chown runner:runner /data
chown runner:runner /data; \
printf '[user]\n\tname = OpenSwarm Cloud Run\n\temail = cloud-run@openswarm.local\n[init]\n\tdefaultBranch = main\n[safe]\n\tdirectory = *\n' > /etc/gitconfig
USER runner
WORKDIR /app
@@ -144,6 +170,8 @@ ENV HOME=/home/runner \
OPENSWARM_PORT=8324 \
DATA_DIR=/data/9router \
NODE_ENV=production \
ELECTRON_BIN=/app/electron-runtime/electron
ELECTRON_BIN=/app/electron-runtime/electron \
OPENSWARM_RUN_WORKSPACE=/data/workspace \
OPENSWARM_NODE_PATH=/usr/local/bin/node
ENTRYPOINT ["python3", "-m", "runner.main"]
+44
View File
@@ -42,6 +42,50 @@ Exit codes: `0` ok, `1` runner crash, `2` bad spec, `3` credential expired on ar
`4` backend never came up, `5` workflow failed, `6` wall-clock cap hit,
`7` no Electron window ever registered.
## Files the run makes
`/data/workspace` is the agent's working directory and **the only path whose contents survive**.
It is seeded as `default_folder` before the backend boots, and the agent is told in its system
prompt that files saved there come back and everything else is destroyed.
After the workflow reaches a terminal state (including a timeout, so partial work still lands) the
runner walks that directory and POSTs each file to `callback.artifacts_url`, then sends the
terminal report. That order is load-bearing: the per-run callback token is refused once the run is
closed, so uploading afterwards would be rejected.
Caps, applied in the runner AND again at the control plane, which is the one that counts:
| Limit | Value |
| --- | --- |
| One file | 20 MB |
| One run, all files | 50 MB |
| Files per run | 40 |
Nothing is ever truncated. A file past a cap is not sent and instead arrives as a row in the
report's `files[]` carrying a written reason, so the user reads "your 512 MB render could not be
sent" rather than finding a 20 MB fragment. `.git`, `node_modules`, `__pycache__`, `.venv`,
`.claude` and the usual caches are skipped, and symlinks are never followed.
## Skills and connected apps
`skills[]` in the run spec is written to `~/.claude/skills/<id>/` before boot. This is not a
nicety: the backend registers the Skill tool only when at least one non-built-in skill exists on
disk, so a container without them has no Skill tool at all and answers from general knowledge in
the same confident voice it would use with the real thing.
`unavailable_mcp_servers[]` is **names only**. The user's MCP credentials (Slack session cookies,
Notion and GitHub access tokens, Google refresh tokens) never leave their machine, so the names go
up purely so the run's system prompt can tell the agent which apps exist and are out of reach.
`McpServerNote` forbids extra fields, so there is no shape a secret could travel in.
## What the image carries for the App Builder
`node`, `npm` and `npx`, plus the App Builder template's `node_modules` pre-installed at the digest
path `bundled_extracted_modules()` probes. Without npm, `CreateApp` scaffolded an app that could
never install, build or serve; without the baked cache, the first `CreateApp` in a run would pay a
cold registry install. `git` also carries a system identity (`/etc/gitconfig`), so a workflow that
commits does not die on "Author identity unknown".
## The renderer
OpenSwarm's browser tier is not an HTTP client. Element serialization and every click,