Skip to content

Commit 1118e9c

Browse files
refactor(server): run agent runtime on Node 24
1 parent 4bce941 commit 1118e9c

55 files changed

Lines changed: 540 additions & 495 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
Run sandboxed practice reviews and mentor sessions on Node.js 24 with a 256 MB JavaScript heap ceiling and scoped runner filesystem permissions.
6+
7+
**Operators:** Upgrade the agent image and server together. Runtime contract v2 reports a mismatched image as unsupported; follow the coordinated upgrade steps in `MIGRATION.md`.

.github/workflows/ci-quality-gates.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ jobs:
7979
if: steps.should_run.outputs.run == 'true'
8080
uses: ./.github/actions/setup-bun
8181

82+
- name: Setup agent Node runtime
83+
if: steps.should_run.outputs.run == 'true' && matrix.check == 'application-server-quality'
84+
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
85+
with:
86+
node-version: 24.19.0
87+
8288
- name: Setup caches
8389
if: steps.should_run.outputs.run == 'true'
8490
uses: ./.github/actions/setup-caches

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ jobs:
217217
set -euo pipefail
218218
docker pull "$IMAGE"
219219
docker run --rm --entrypoint /bin/sh "$IMAGE" -c \
220-
'bun --version && ! command -v node >/dev/null 2>&1'
220+
'node --version && for runtime in bun npm npx corepack yarn yarnpkg pnpm pnpx; do ! command -v "$runtime"; done'
221221
222222
expected=$(grep -oE 'RUNTIME_CONTRACT_VERSION = [0-9]+' "$LAYOUT" | grep -oE '[0-9]+$')
223223
[ -n "$expected" ] || { echo "::error::Could not read RUNTIME_CONTRACT_VERSION from $LAYOUT"; exit 1; }

MIGRATION.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ Before upgrading to any new `0.x.0` version:
6363
Entries exist only for releases that need operator action. Everything else is in the
6464
[release notes](https://github.com/ls1intum/Hephaestus/releases).
6565

66+
### v0.76.0
67+
68+
#### 🔴 Upgrade the server and agent image together
69+
70+
**Affected**: every deployment that runs practice reviews or mentor sessions.
71+
72+
**Before**: runtime contract v1 executes staged TypeScript with Bun.
73+
74+
**After**: runtime contract v2 executes it with Node.js 24, a 256 MB V8 heap ceiling, and scoped
75+
runner filesystem permissions. The server reports an image with a different contract as unsupported before sandbox work
76+
starts.
77+
78+
**Migration**: deploy the application server/worker and the matching `hephaestus-agent` image from the
79+
same release. Do not reuse or independently pin an older agent image. After deployment, verify the agent
80+
image reports `hephaestus.agent.runtime-contract=2`, contains Node 24, and does not contain Bun.
81+
6682
### v0.75.0
6783

6884
#### 🔴 Practice reviews now require the person being evaluated to be a workspace member

bunfig.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,3 @@ hoist = false
44
publicHoistPattern = ["react", "react-dom", "@types/react", "@types/react-dom", "@types/hast"]
55
minimumReleaseAge = 259200
66
peer = true
7-
8-
[run]
9-
bun = true

docker/agents/pi/Dockerfile

Lines changed: 25 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,38 @@
11
# syntax=docker/dockerfile:1.4
2-
ARG DEBIAN_TAG=bookworm-slim
3-
FROM debian:${DEBIAN_TAG}
2+
# renovate: datasource=docker depName=node
3+
ARG NODE_VERSION=24.19.0
4+
FROM node:${NODE_VERSION}-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df
45

5-
ARG TARGETARCH
6-
7-
RUN apt-get update -qq && apt-get install -y --no-install-recommends git findutils tree jq curl ca-certificates unzip && \
6+
RUN apt-get update -qq && apt-get install -y --no-install-recommends git findutils tree jq ca-certificates grep && \
87
rm -rf /var/lib/apt/lists/*
98

10-
# Refresh the release-asset hashes from SHASUMS256.txt with every version bump.
11-
# renovate: datasource=github-releases depName=oven-sh/bun extractVersion=^bun-v(?<version>.*)$
12-
ARG BUN_VERSION=1.4.0
13-
RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}" && \
14-
case "${arch}" in \
15-
amd64|x86_64) bun_arch="x64"; bun_sha256="2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452" ;; \
16-
arm64|aarch64) bun_arch="aarch64"; bun_sha256="4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e" ;; \
17-
*) echo "Unsupported TARGETARCH/architecture: ${arch}" >&2; exit 1 ;; \
18-
esac && \
19-
curl --fail --show-error --silent --location --retry 3 --retry-delay 2 \
20-
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${bun_arch}.zip" \
21-
-o /tmp/bun.zip && \
22-
echo "${bun_sha256} /tmp/bun.zip" | sha256sum -c - && \
23-
unzip -o /tmp/bun.zip -d /tmp && \
24-
mv "/tmp/bun-linux-${bun_arch}/bun" /usr/local/bin/bun && \
25-
chmod +x /usr/local/bin/bun && \
26-
rm -rf /tmp/bun*
27-
28-
# Provide a resolvable non-root home and passwd entry.
29-
RUN groupadd --gid 1000 agent && \
30-
useradd --uid 1000 --gid 1000 --home-dir /home/agent --create-home --shell /bin/bash agent
9+
RUN groupmod --new-name agent node && \
10+
usermod --login agent --home /home/agent --move-home --shell /bin/bash node
3111

3212
# renovate: datasource=npm depName=@earendil-works/pi-coding-agent
3313
ARG PI_VERSION=0.84.3
3414
RUN mkdir -p /opt/pi-sdk && cd /opt/pi-sdk && \
3515
printf '{"name":"hephaestus-pi-sdk","private":true,"dependencies":{"@earendil-works/pi-coding-agent":"%s"}}\n' \
3616
"${PI_VERSION}" > package.json && \
37-
BUN_INSTALL_CACHE_DIR=/tmp/bun-cache bun install --production --ignore-scripts --no-progress && \
38-
rm -rf /tmp/bun-cache && \
17+
npm install --omit=dev --ignore-scripts --no-audit --no-fund && \
3918
test -d /opt/pi-sdk/node_modules/@earendil-works/pi-coding-agent \
4019
|| (echo "Pi SDK not found at /opt/pi-sdk/node_modules" >&2; exit 1) && \
4120
chown -R 1000:1000 /opt/pi-sdk && \
4221
mkdir -p /workspace && chown 1000:1000 /workspace
4322

44-
# Resolve the SDK through the same workspace symlink used at runtime.
4523
RUN mkdir -p /tmp/abi-check && ln -sf /opt/pi-sdk/node_modules /tmp/abi-check/node_modules && \
4624
cd /tmp/abi-check && \
4725
printf 'const sdk = await import("@earendil-works/pi-coding-agent");\nif (!sdk || typeof sdk !== "object") { throw new Error("Pi SDK import yielded no module namespace"); }\nconsole.log("pi sdk exports:", Object.keys(sdk).length);\n' > check.ts && \
48-
bun check.ts && \
26+
node --permission --allow-fs-read=/tmp/abi-check --allow-fs-read=/opt/pi-sdk check.ts && \
27+
! node --permission --allow-fs-read=/tmp/abi-check --allow-fs-read=/opt/pi-sdk \
28+
-e 'require("node:fs").readFileSync("/etc/passwd")' 2>/dev/null && \
29+
! node --permission --allow-fs-read=/tmp/abi-check --allow-fs-read=/opt/pi-sdk \
30+
-e 'require("node:child_process").spawnSync("true")' 2>/dev/null && \
4931
rm -rf /tmp/abi-check
5032

5133
COPY --chown=1000:1000 precompute/runner.ts /opt/precompute/runner.ts
5234
COPY --chown=1000:1000 precompute/lib/ /opt/precompute/lib/
5335

54-
# Neutralize git hooks/external-command vectors (DockerSandboxAdapter re-enforces at runtime).
5536
RUN git config --system core.hooksPath /nonexistent && \
5637
git config --system core.fsmonitor false && \
5738
git config --system safe.directory /workspace/repo
@@ -61,28 +42,23 @@ ENV GIT_PAGER=cat
6142
ENV GIT_TERMINAL_PROMPT=0
6243
ENV LANG=C.UTF-8
6344

64-
# Check both PATH and standard binary directories after all installation layers.
65-
RUN set -eu; \
66-
for b in node nodejs npm npx corepack yarn pnpm; do \
67-
if command -v "$b" >/dev/null 2>&1; then \
68-
echo "FATAL: '$b' resolves to $(command -v "$b") — this image must be Bun-only." >&2; exit 1; \
69-
fi; \
70-
done; \
71-
found="$(find /usr/bin /usr/local/bin /bin /sbin /usr/sbin /opt -maxdepth 3 \
72-
\( -name node -o -name nodejs -o -name npm -o -name npx -o -name corepack \) \
45+
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
46+
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
47+
/usr/local/bin/yarn /usr/local/bin/yarnpkg /usr/local/bin/pnpm /usr/local/bin/pnpx && \
48+
set -eu; \
49+
if command -v bun >/dev/null 2>&1; then \
50+
echo "FATAL: Bun resolves to $(command -v bun); this image must be Node-only." >&2; exit 1; \
51+
fi; \
52+
found="$(find /usr/bin /usr/local/bin /bin /sbin /usr/sbin /opt -maxdepth 3 -name bun \
7353
\( -type f -o -type l \) 2>/dev/null || true)"; \
7454
if [ -n "$found" ]; then \
75-
echo "FATAL: Node.js artefacts present in the image:" >&2; echo "$found" >&2; exit 1; \
55+
echo "FATAL: Bun artefacts present in the image:" >&2; echo "$found" >&2; exit 1; \
7656
fi; \
77-
echo "OK: no node/npm in image; runtime is $(bun --version) at $(command -v bun)"
57+
node --version; \
58+
for manager in npm npx corepack yarn yarnpkg pnpm pnpx; do ! command -v "$manager"; done
7859

79-
# The build above proves the runtime contract — Bun resolves and imports the SDK exactly as
80-
# PiRuntimeFactory arranges it, and no Node survives. These labels are the only part of that proof a
81-
# server can read before it commits a job to a container: it compares the contract version against
82-
# SandboxLayout.RUNTIME_CONTRACT_VERSION, which AgentImageContractSyncTest pins to the line below.
83-
# Bump both together when an older image could no longer run the staged runners. ADR 0031.
84-
LABEL hephaestus.agent.runtime-contract=1
85-
LABEL hephaestus.agent.bun-version=${BUN_VERSION}
60+
LABEL hephaestus.agent.runtime-contract=2
61+
LABEL hephaestus.agent.node-version=${NODE_VERSION}
8662
LABEL hephaestus.agent.pi-version=${PI_VERSION}
8763

8864
USER 1000:1000

docker/agents/precompute/lib/context.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* they never decide.
1111
*/
1212

13-
import { isJsonObject } from "./practice-contract";
13+
import { readFile } from "node:fs/promises";
14+
15+
import { isJsonObject } from "./practice-contract.ts";
1416

1517
/**
1618
* Best-effort JSON read of a context file; returns `null` when absent/unreadable (the common case).
@@ -25,9 +27,7 @@ export async function readContextJson(
2527
): Promise<unknown> {
2628
if (!contextDir) return null;
2729
try {
28-
const file = Bun.file(`${contextDir}/${name}`);
29-
if (!(await file.exists())) return null;
30-
return await file.json();
30+
return JSON.parse(await readFile(`${contextDir}/${name}`, "utf8"));
3131
} catch {
3232
return null;
3333
}

docker/agents/precompute/lib/diff-parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DiffFile, DiffHunk } from "./types";
1+
import type { DiffFile, DiffHunk } from "./types.ts";
22

33
/**
44
* Parse a unified diff (with optional [L<n>] annotations) into structured DiffFile objects.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { globSync } from "node:fs";
2+
import { join, relative } from "node:path";
3+
4+
/** Return matching files in stable order. */
5+
export function globFilesSync(pattern: string, cwd: string): string[] {
6+
return globSync(pattern, { cwd, withFileTypes: true })
7+
.filter((entry) => entry.isFile())
8+
.map((entry) => relative(cwd, join(entry.parentPath, entry.name)))
9+
.toSorted();
10+
}
Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { afterEach, describe, expect, it } from "bun:test";
2-
import { mkdir, mkdtemp, rm } from "node:fs/promises";
1+
import assert from "node:assert/strict";
2+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5+
import { afterEach, describe, it } from "node:test";
56

6-
import { findFiles, grep } from "./grep";
7+
import { globFilesSync } from "./files.ts";
8+
import { findFiles, grep } from "./grep.ts";
79

810
const tempDirs: string[] = [];
911

@@ -17,12 +19,12 @@ afterEach(async () => {
1719
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
1820
});
1921

20-
describe("grep", () => {
21-
it("treats fixed-string patterns literally without shell interpretation", async () => {
22+
void describe("grep", () => {
23+
void it("treats fixed-string patterns literally without shell interpretation", async () => {
2224
const dir = await createTempDir();
2325
const nestedDir = join(dir, "nested dir");
2426
await mkdir(nestedDir, { recursive: true });
25-
await Bun.write(
27+
await writeFile(
2628
join(nestedDir, "example.ts"),
2729
"const marker = \"literal $(echo nope) 'quotes'\";\n",
2830
);
@@ -32,73 +34,84 @@ describe("grep", () => {
3234
glob: "**/*.ts",
3335
});
3436

35-
expect(matches).toHaveLength(1);
36-
expect(matches[0]?.file).toBe("nested dir/example.ts");
37+
assert.equal(matches.length, 1);
38+
assert.equal(matches[0]?.file, "nested dir/example.ts");
3739
});
3840

39-
it("enforces maxResults globally across files", async () => {
41+
void it("enforces maxResults globally across files", async () => {
4042
const dir = await createTempDir();
41-
await Bun.write(join(dir, "one.txt"), "needle\nneedle\n");
42-
await Bun.write(join(dir, "two.txt"), "needle\nneedle\n");
43-
await Bun.write(join(dir, "three.txt"), "needle\nneedle\n");
43+
await writeFile(join(dir, "one.txt"), "needle\nneedle\n");
44+
await writeFile(join(dir, "two.txt"), "needle\nneedle\n");
45+
await writeFile(join(dir, "three.txt"), "needle\nneedle\n");
4446

4547
const matches = await grep("needle", dir, {
4648
fixedString: true,
4749
maxResults: 2,
4850
});
4951

50-
expect(matches).toHaveLength(2);
51-
expect(matches.every((match) => match.content === "needle")).toBe(true);
52+
assert.equal(matches.length, 2);
53+
assert.equal(
54+
matches.every((match) => match.content === "needle"),
55+
true,
56+
);
5257
});
5358

54-
it("applies path-aware glob filters instead of basename-only includes", async () => {
59+
void it("applies path-aware glob filters instead of basename-only includes", async () => {
5560
const dir = await createTempDir();
5661
const nestedDir = join(dir, "src", "nested");
5762
await mkdir(nestedDir, { recursive: true });
58-
await Bun.write(join(nestedDir, "match.ts"), "needle\n");
59-
await Bun.write(join(nestedDir, "skip.js"), "needle\n");
63+
await writeFile(join(nestedDir, "match.ts"), "needle\n");
64+
await writeFile(join(nestedDir, "skip.js"), "needle\n");
6065

6166
const matches = await grep("needle", dir, {
6267
fixedString: true,
6368
glob: "src/**/*.ts",
6469
});
6570

66-
expect(matches).toHaveLength(1);
67-
expect(matches[0]?.file).toBe("src/nested/match.ts");
71+
assert.equal(matches.length, 1);
72+
assert.equal(matches[0]?.file, "src/nested/match.ts");
6873
});
6974

70-
it("auto-expands basename-only globs to recursive matching", async () => {
75+
void it("auto-expands basename-only globs to recursive matching", async () => {
7176
const dir = await createTempDir();
7277
await mkdir(join(dir, "src", "Views"), { recursive: true });
73-
await Bun.write(join(dir, "src", "Views", "ContentView.swift"), 'print("hello")\n');
74-
await Bun.write(join(dir, "RootFile.swift"), 'print("root")\n');
78+
await writeFile(join(dir, "src", "Views", "ContentView.swift"), 'print("hello")\n');
79+
await writeFile(join(dir, "RootFile.swift"), 'print("root")\n');
7580

7681
const matches = await grep("print", dir, {
7782
fixedString: true,
7883
glob: "*.swift",
7984
});
8085

81-
expect(matches).toHaveLength(2);
86+
assert.equal(matches.length, 2);
8287
const files = matches.map((m) => m.file).toSorted();
83-
expect(files).toContain("RootFile.swift");
84-
expect(files).toContain("src/Views/ContentView.swift");
88+
assert.ok(files.includes("RootFile.swift"));
89+
assert.ok(files.includes("src/Views/ContentView.swift"));
8590
});
8691

87-
it("finds extension matches without shelling out and skips ignored paths", async () => {
92+
void it("finds extension matches without shelling out and skips ignored paths", async () => {
8893
const dir = await createTempDir();
8994
await mkdir(join(dir, "src", "nested"), { recursive: true });
9095
await mkdir(join(dir, ".hidden"), { recursive: true });
9196
await mkdir(join(dir, "node_modules", "pkg"), { recursive: true });
9297
await mkdir(join(dir, ".build"), { recursive: true });
9398

94-
await Bun.write(join(dir, "src", "nested", "match.swift"), "struct Match {}\n");
95-
await Bun.write(join(dir, ".hidden", "hidden.swift"), "struct Hidden {}\n");
96-
await Bun.write(join(dir, "node_modules", "pkg", "dep.swift"), "struct Dep {}\n");
97-
await Bun.write(join(dir, ".build", "generated.swift"), "struct Generated {}\n");
99+
await writeFile(join(dir, "src", "nested", "match.swift"), "struct Match {}\n");
100+
await writeFile(join(dir, ".hidden", "hidden.swift"), "struct Hidden {}\n");
101+
await writeFile(join(dir, "node_modules", "pkg", "dep.swift"), "struct Dep {}\n");
102+
await writeFile(join(dir, ".build", "generated.swift"), "struct Generated {}\n");
98103

99104
const files = findFiles(dir, "swift");
100105

101-
expect(files).toHaveLength(1);
102-
expect(files[0]).toBe(join(dir, "src", "nested", "match.swift"));
106+
assert.equal(files.length, 1);
107+
assert.equal(files[0], join(dir, "src", "nested", "match.swift"));
108+
});
109+
110+
void it("never returns a directory whose name matches the file pattern", async () => {
111+
const dir = await createTempDir();
112+
await mkdir(join(dir, "directory.ts"));
113+
await writeFile(join(dir, "file.ts"), "export {};\n");
114+
115+
assert.deepEqual(globFilesSync("*.ts", dir), ["file.ts"]);
103116
});
104117
});

0 commit comments

Comments
 (0)