Skip to content

Commit 7b1ff27

Browse files
committed
Fix two build failures CI caught on the first real run
Neither could fail on this machine: pi needs node >=22.19.0 and this box is below it, so the loader assertions skip locally and only ran for the first time in CI. 1. A comment broke the build. A JSDoc line reading "the no*/else branches" closed its own block comment with the */ inside it, turning the rest of the sentence into code: SyntaxError, Unexpected token 'else'. loader.mjs stopped parsing. The pure tests do not import it, so nothing local noticed. Added test/sources-parse.test.mjs: node --check over every runner source. It needs no dependencies and no particular node version, so unlike the loader assertions it runs everywhere -- including here, where the tests that would have caught this cannot. Verified by reintroducing the bug and watching it fail. 2. `npm i -g` does not make a package importable. The runner did `import "@earendil-works/pi-coding-agent"` while the Dockerfile installed it globally; node's ESM resolver walks node_modules upward from the importing file and ignores the global prefix entirely. NODE_PATH does not help either -- it is CommonJS-only. The image built fine and every job would have died on module-not-found. Now a local `npm ci --omit=dev` from the lockfile at /app, with /app/node_modules/.bin on PATH so the pi and playwright-cli binaries come from that same install -- one pin, no chance of the CLI drifting from the imported library. @playwright/cli moves into the runner's dependencies for the same reason, which also pins playwright transitively rather than resolving it at build time via npx. Both are the kind of thing only a real build finds, which is the argument for the image existing before the worker rather than after it.
1 parent 1a0fc0d commit 7b1ff27

6 files changed

Lines changed: 136 additions & 17 deletions

File tree

image/Dockerfile

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,36 @@ RUN mkdir -p -m 755 /etc/apt/keyrings \
4040
&& apt-get install -y --no-install-recommends gh \
4141
&& rm -rf /var/lib/apt/lists/*
4242

43-
# The workspace .npmrc's engine-strict does NOT reach a global install: `npm i -g` reads
44-
# only $HOME/.npmrc and $PREFIX/etc/npmrc, never a project file. Set it explicitly or the
45-
# node-floor guard silently does not cover the one install that matters most.
43+
# engine-strict is set explicitly because npm reads it from $HOME/.npmrc and
44+
# $PREFIX/etc/npmrc but NEVER from a project .npmrc for installs outside a project root.
45+
# Without this the node-floor guard silently does not cover the install that matters most.
4646
ENV NPM_CONFIG_ENGINE_STRICT=true
4747

48-
# Exact versions, no ranges. pi breaks between MINORS -- a past regression silently dropped
49-
# sendUserMessage after newSession, and the package was renamed mid-flight.
50-
RUN npm install -g --no-fund --no-audit \
51-
@earendil-works/pi-coding-agent@0.80.7 \
52-
@playwright/cli@0.1.17
48+
# A LOCAL install from the lockfile -- deliberately not `npm i -g`.
49+
#
50+
# A global install puts binaries on PATH but does NOT make a package importable by a bare
51+
# specifier: node's ESM resolver walks node_modules upward from the importing file and
52+
# ignores the global prefix entirely. NODE_PATH does not help either -- it is CommonJS-only
53+
# and the resolver ignores it for ESM. `import "@earendil-works/pi-coding-agent"` from
54+
# /app/image/runner therefore only resolves against a real node_modules above it.
55+
#
56+
# Deps are copied before source so a source edit does not invalidate this layer.
57+
WORKDIR /app
58+
COPY package.json package-lock.json ./
59+
COPY image/runner/package.json ./image/runner/
60+
RUN npm ci --omit=dev --no-audit --no-fund
61+
62+
# The pi and playwright-cli binaries come from that same install: one pin, one copy on disk,
63+
# no chance of the CLI and the imported library drifting to different versions.
64+
ENV PATH="/app/node_modules/.bin:${PATH}"
5365

5466
# Set BEFORE the browser install so root-time install and non-root-time lookup agree.
5567
# Root installs would otherwise land in /root/.cache/ms-playwright, which the non-root
5668
# runtime user cannot see -- a direct collision between two of our own constraints.
57-
# @playwright/cli does NOT install browsers; it is a thin wrapper. The standard installer does.
69+
# @playwright/cli does NOT install browsers; it is a thin wrapper. The standard installer,
70+
# pinned transitively by @playwright/cli and resolved from the lockfile, does.
5871
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
59-
RUN npx --yes playwright@1.62.0-alpha-1783623505000 install --with-deps chromium \
72+
RUN playwright install --with-deps chromium \
6073
&& rm -rf /var/lib/apt/lists/*
6174

6275
# Non-root. CONST-ISOLATION-CONTAINER-PER-JOB.
@@ -71,15 +84,17 @@ RUN useradd --create-home --shell /bin/bash --uid 1001 pi
7184
# obvious fix. Create and chown explicitly.
7285
RUN mkdir -p /home/pi/.pi/agent \
7386
&& chown -R pi:pi /home/pi/.pi \
74-
&& chown -R pi:pi /ms-playwright
87+
&& chown -R pi:pi /ms-playwright \
88+
&& chown -R pi:pi /app
7589

7690
# The safety floor. Deliberately NOT at ~/.pi/agent/APPEND_SYSTEM.md: a trusted project's
7791
# .pi/APPEND_SYSTEM.md shadows that path via an early return in discoverAppendSystemPromptFile,
7892
# which would delete these rules from the prompt with no error and a job that succeeds. The
7993
# runner reads this path explicitly instead, so discovery cannot shadow it.
8094
COPY --chown=pi:pi guardrails/HARD_RULES.md /opt/pi-dispatch/HARD_RULES.md
8195

82-
COPY --chown=pi:pi image/runner /runner
96+
# Source last: everything above is cacheable and this layer changes on every commit.
97+
COPY --chown=pi:pi image/runner /app/image/runner
8398
COPY --chown=pi:pi image/entrypoint.sh /entrypoint.sh
8499
RUN chmod +x /entrypoint.sh
85100

image/entrypoint.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@
1515
# "bad interpreter" inside the container: a confusing error with a boring cause.
1616
set -eu
1717

18-
exec node /runner/run-job.mjs
18+
exec node /app/image/runner/run-job.mjs

image/runner/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"node": ">=22.19.0"
1111
},
1212
"dependencies": {
13-
"@earendil-works/pi-coding-agent": "0.80.7"
13+
"@earendil-works/pi-coding-agent": "0.80.7",
14+
"@playwright/cli": "0.1.17"
1415
}
1516
}

image/runner/src/loader.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ function readIfExists(path) {
2727
* CONST-NO-CONTEXT-FILES-MANDATORY fails open by omission; this is the omission.
2828
*
2929
* - `noSkills`/`noExtensions` suppress cwd/package discovery, which would read the
30-
* CHECKED-OUT branch -- a fork's branch on a PR-triggered job. The additional*Paths
31-
* are merged in both the no*/else branches and are never trust-checked, so they load
30+
* CHECKED-OUT branch -- a fork's branch on a PR-triggered job. The additional paths are
31+
* merged whether or not those flags are set, and are never trust-checked, so they load
3232
* exactly what the worker handed over and nothing from the tree. Project trust is
3333
* therefore never granted: reload() is called without resolveProjectTrust.
3434
*
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
import { readdirSync, statSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
import { test } from "node:test";
7+
8+
/**
9+
* Every runner source must parse.
10+
*
11+
* This exists because of a real bug: a JSDoc line reading "the no*​/else branches" closed
12+
* its own block comment, turning prose into code. loader.mjs stopped parsing, and nothing
13+
* local caught it -- the pure tests do not import that file, and the tests that do cannot
14+
* run here because pi needs a newer node than this machine has. It failed in CI instead.
15+
*
16+
* `node --check` needs no dependencies and no particular node version, so unlike the loader
17+
* assertions this runs EVERYWHERE. Cheap, total, and it would have caught it in a second.
18+
*/
19+
const runnerRoot = fileURLToPath(new URL("..", import.meta.url));
20+
21+
function collect(dir, found = []) {
22+
for (const entry of readdirSync(dir)) {
23+
if (entry === "node_modules") continue;
24+
const path = join(dir, entry);
25+
if (statSync(path).isDirectory()) collect(path, found);
26+
else if (entry.endsWith(".mjs")) found.push(path);
27+
}
28+
return found;
29+
}
30+
31+
const sources = collect(runnerRoot);
32+
33+
test("there are sources to check", () => {
34+
assert.ok(sources.length >= 4, `expected runner sources, found ${sources.length}`);
35+
});
36+
37+
for (const source of sources) {
38+
test(`parses: ${source.slice(runnerRoot.length).replace(/\\/g, "/")}`, () => {
39+
// --check parses without executing or resolving imports, so it works with zero deps.
40+
execFileSync(process.execPath, ["--check", source], { stdio: "pipe" });
41+
});
42+
}

package-lock.json

Lines changed: 62 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)