fix(dsh): pin runtime Node to 22 and include bubblewrap (#671)

Co-authored-by: matevip <matevip@mate.vip>
This commit is contained in:
mateaix 2026-10-22 05:38:08 -05:00 committed by GitHub
parent f449fb229e
commit c9c08b86fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 205 additions and 1 deletions

View File

@ -67,6 +67,10 @@ COPY --from=frontend-builder /static mateclaw-server/src/main/resources/static
RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS} RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Node/npm for the managed dsh installer. Keep the runtime on Node 22 even
# when Playwright changes its bundled Node major. Bookworm glibc runs on Noble.
FROM node:22-bookworm-slim AS dsh-node
# Stage 3 — Runtime # Stage 3 — Runtime
# #
# Uses Microsoft's official Playwright image (Ubuntu Noble, glibc) with all three # Uses Microsoft's official Playwright image (Ubuntu Noble, glibc) with all three
@ -78,9 +82,16 @@ RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each # bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
# tag with the matching driver, so mismatched versions cause the java driver to # tag with the matching driver, so mismatched versions cause the java driver to
# re-download browsers at runtime (defeating the whole point of this image). # re-download browsers at runtime (defeating the whole point of this image).
FROM mcr.microsoft.com/playwright:v1.62.0-noble FROM mcr.microsoft.com/playwright:v1.62.0-noble AS runtime-base
WORKDIR /app WORKDIR /app
COPY --from=dsh-node /usr/local/bin/node /usr/local/bin/node
COPY --from=dsh-node /usr/local/lib/node_modules/ /usr/local/lib/node_modules/
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& node -e 'if (process.versions.node.split(".")[0] !== "22") throw Error("dsh requires Node 22")' \
&& npm --version
# JDK 21 is NOT part of the base image (it ships Node for the JS driver). # JDK 21 is NOT part of the base image (it ships Node for the JS driver).
# Install openjdk-21 explicitly and add CJK fonts so Chinese pages render # Install openjdk-21 explicitly and add CJK fonts so Chinese pages render
# correctly in screenshots and snapshots. # correctly in screenshots and snapshots.
@ -111,6 +122,7 @@ RUN apt-get update \
python-is-python3 \ python-is-python3 \
python3-dev \ python3-dev \
build-essential \ build-essential \
bubblewrap \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Remove PEP 668's EXTERNALLY-MANAGED marker so pip can install packages # Remove PEP 668's EXTERNALLY-MANAGED marker so pip can install packages
@ -135,6 +147,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
# silently pin the profile regardless of what compose passes. # silently pin the profile regardless of what compose passes.
ENV SPRING_PROFILES_ACTIVE=mysql ENV SPRING_PROFILES_ACTIVE=mysql
FROM runtime-base AS runtime
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
EXPOSE 18088 EXPOSE 18088

View File

@ -5,6 +5,9 @@ ARG DSH_VERSION
ARG DSH_LOCK_SHA256 ARG DSH_LOCK_SHA256
LABEL vip.mateclaw.dsh.version="${DSH_VERSION}" \ LABEL vip.mateclaw.dsh.version="${DSH_VERSION}" \
vip.mateclaw.dsh.lock-sha256="${DSH_LOCK_SHA256}" vip.mateclaw.dsh.lock-sha256="${DSH_LOCK_SHA256}"
RUN apt-get update \
&& apt-get install -y --no-install-recommends bubblewrap \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/mateclaw-dsh WORKDIR /opt/mateclaw-dsh
COPY locks/${DSH_VERSION}.json package-lock.json COPY locks/${DSH_VERSION}.json package-lock.json
RUN node -e 'const fs=require("fs");const crypto=require("crypto");if(crypto.createHash("sha256").update(fs.readFileSync("package-lock.json")).digest("hex")!==process.env.DSH_LOCK_SHA256)throw Error("lock digest mismatch");const l=JSON.parse(fs.readFileSync("package-lock.json"));fs.writeFileSync("package.json",JSON.stringify({name:"mateclaw-dsh-runtime",version:"1.0.0",private:true,dependencies:l.packages[""].dependencies}))' \ RUN node -e 'const fs=require("fs");const crypto=require("crypto");if(crypto.createHash("sha256").update(fs.readFileSync("package-lock.json")).digest("hex")!==process.env.DSH_LOCK_SHA256)throw Error("lock digest mismatch");const l=JSON.parse(fs.readFileSync("package-lock.json"));fs.writeFileSync("package.json",JSON.stringify({name:"mateclaw-dsh-runtime",version:"1.0.0",private:true,dependencies:l.packages[""].dependencies}))' \

View File

@ -0,0 +1,134 @@
package vip.mate.agent.runtime.dsh.management;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/** Run against the Dockerfile's runtime-base target, without a model key or database.
* MATECLAW_DSH_IMAGE_SMOKE=your-amd64-image mvn -pl mateclaw-server -am test
* -Dtest=DshImageSmokeTest -Dsurefire.failIfNoSpecifiedTests=false
* Default sandbox checks require native amd64 Linux. For an isolated diagnostic
* container on ARM/emulation, MATECLAW_DSH_IMAGE_SMOKE_PRIVILEGED=true explicitly
* enables bwrap namespaces and requires the bwrap backend. This is test-only.
*/
@EnabledIfEnvironmentVariable(named = "MATECLAW_DSH_IMAGE_SMOKE", matches = ".+")
@Timeout(value = 12, unit = TimeUnit.MINUTES)
class DshImageSmokeTest {
@TempDir Path temp;
@Test
void imageSupportsManagedInstall() throws Exception {
checkImage(false);
}
@Test
void imageSupportsManagedInstallAndConfinedBash() throws Exception {
checkImage(true);
}
private void checkImage(boolean checkBash) throws Exception {
// Stage only the installer and its actual dependencies; do not start Spring or use a DB.
Path classes = Files.createDirectories(temp.resolve("classes"));
for (Class<?> type : List.of(DshPinnedInstaller.class, DshPinnedInstaller.Release.class,
DshPinnedInstaller.Installation.class, InstallerMain.class)) {
String resource = type.getName().replace('.', '/') + ".class";
Path target = classes.resolve(resource);
Files.createDirectories(target.getParent());
try (var input = type.getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input);
Files.copy(input, target);
}
}
Path libs = Files.createDirectories(temp.resolve("libs"));
for (String entry : System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")).split(java.io.File.pathSeparator)) {
Path jar = Path.of(entry);
String name = jar.getFileName().toString();
if (name.endsWith(".jar") && (name.startsWith("jackson-") || name.startsWith("spring-core-")
|| name.startsWith("spring-jcl-") || name.startsWith("commons-logging-"))) {
Files.copy(jar, libs.resolve(name));
}
}
for (var release : DshPinnedInstaller.catalog()) {
Path lock = classes.resolve("dsh/locks/" + release.version() + ".json");
Files.createDirectories(lock.getParent());
try (var input = getClass().getResourceAsStream("/dsh/locks/" + release.version() + ".json")) {
assertNotNull(input);
Files.copy(input, lock);
}
}
try (var input = getClass().getResourceAsStream("/dsh/image-bash-smoke.mjs")) {
assertNotNull(input);
Files.copy(input, temp.resolve("bash-smoke.mjs"));
}
var command = new ArrayList<>(List.of("docker", "run", "--rm", "--platform", "linux/amd64",
"--name", "mateclaw-dsh-image-smoke-" + java.util.UUID.randomUUID(),
"--env", "CHECK_DSH_BASH=" + checkBash,
"--volume", temp.toAbsolutePath() + ":/checks:ro", "--entrypoint", "bash",
System.getenv("MATECLAW_DSH_IMAGE_SMOKE"), "-ec", """
node -e 'if (process.versions.node.split(".")[0] !== "22") throw Error("Node 22 required")'
npm --version
command -v bwrap
# Docker may deny user namespaces. Require a working dsh sandbox below.
if bwrap --ro-bind / / --dev /dev --proc /proc --unshare-user \
--unshare-pid --die-with-parent bash -c 'printf BWRAP_OK'; then
echo ' DSH_BWRAP_OK'
else
echo 'DSH_BWRAP_RESTRICTED: host namespace permissions required for bwrap'
fi
for version in 0.2.0-rc.1 0.2.1-rc.2; do
java -cp '/checks/classes:/checks/libs/*' \
'vip.mate.agent.runtime.dsh.management.DshImageSmokeTest$InstallerMain' "$version"
if [ "$CHECK_DSH_BASH" = true ]; then
DSH_PREFIX=/tmp/dsh-install-$version node /checks/bash-smoke.mjs
fi
done
"""));
boolean privileged = Boolean.parseBoolean(System.getenv("MATECLAW_DSH_IMAGE_SMOKE_PRIVILEGED"));
if (privileged) command.addAll(2, List.of("--privileged", "--env", "DSH_EXPECT_BWRAP=true"));
System.out.println("DSH_IMAGE_SMOKE privileged=" + privileged + " checkBash=" + checkBash);
Path log = temp.resolve("smoke.log");
Process process = new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(log.toFile()).start();
try {
assertTrue(process.waitFor(Duration.ofMinutes(10).toMillis(), TimeUnit.MILLISECONDS), "image smoke timeout");
String output = Files.readString(log);
System.out.println(output);
assertEquals(0, process.exitValue(), output);
assertTrue(output.contains("DSH_INSTALL_OK"), output);
if (checkBash) assertTrue(output.contains("DSH_BASH_SANDBOX_OK"), output);
} finally {
if (process.isAlive()) process.destroyForcibly();
Process cleanup = new ProcessBuilder("docker", "rm", "--force", command.get(command.indexOf("--name") + 1))
.redirectOutput(ProcessBuilder.Redirect.DISCARD).redirectError(ProcessBuilder.Redirect.DISCARD).start();
if (!cleanup.waitFor(15, TimeUnit.SECONDS)) cleanup.destroyForcibly();
}
}
public static class InstallerMain {
public static void main(String[] args) throws Exception {
var installed = new DshPinnedInstaller(new ObjectMapper())
.install(args[0], Path.of("/tmp/dsh-install-" + args[0]));
if (!Files.isExecutable(Path.of(installed.executablePath()))) throw new AssertionError("dsh executable");
Process cli = new ProcessBuilder(installed.executablePath(), "--version")
.redirectErrorStream(true).start();
try {
if (!cli.waitFor(20, TimeUnit.SECONDS)) throw new AssertionError("dsh CLI timeout");
String version = new String(cli.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
if (cli.exitValue() != 0 || !version.contains(installed.version())) throw new AssertionError(version);
} finally {
if (cli.isAlive()) cli.destroyForcibly();
}
System.out.println("DSH_INSTALL_OK " + installed.version() + " " + installed.platform());
}
}
}

View File

@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { mkdirSync, existsSync, readFileSync } from 'node:fs';
const require = createRequire(`${process.env.DSH_PREFIX}/package.json`);
const load = async name => import(pathToFileURL(require.resolve(`@deepseek-ai/${name}`)).href);
const { Context } = await load('cordis');
const ctx = new Context();
const version = require('@deepseek-ai/dsh/package.json').version;
mkdirSync('/tmp/dsh-smoke-workspace', { recursive: true });
for (const [name, config] of [
['dsh-system-prompt', {}], ['dsh-session-projection', {}],
['dsh-fs-local', {}], ['dsh-working-directory', { defaultDirectory: '/tmp/dsh-smoke-workspace' }],
['dsh-subprocess-local', {}], ['dsh-sandbox-local', {}],
['dsh-sandbox-policy', { mode: 'workspace-write', workspaceRoot: '/tmp/dsh-smoke-workspace' }],
['dsh-bash-sandbox', {}], ['dsh-shell-env', {}], ['dsh-tools', {}],
['dsh-tool-bash', { enableRunInBackground: false }],
]) {
// This service was introduced after the server's default 0.2.0-rc.1 release.
if (name === 'dsh-working-directory' && version === '0.2.0-rc.1') continue;
const plugin = await load(name);
ctx.plugin(plugin.default ?? plugin, config);
}
for (let i=0; i<100 && !ctx.tools?.get('bash'); i++) await new Promise(resolve => setTimeout(resolve, 20));
try {
assert.ok(ctx.tools?.get('bash'), 'Bash tool must register');
const confined = await ctx.sandbox.confine(['bash', '-c', 'true'], {
mode: 'workspace-write', workspaceRoot: '/tmp/dsh-smoke-workspace',
});
console.log(`DSH_SANDBOX_BACKEND ${confined.argv[0]}`);
if (process.env.DSH_EXPECT_BWRAP === 'true') {
assert.equal(confined.argv[0], 'bwrap', 'privileged smoke must use bwrap');
console.log('DSH_BWRAP_BACKEND_OK');
}
const result = await ctx.tools.execute({callId:'smoke', name:'bash', arguments:{
description:'Write and read sandbox smoke marker',
command:"printf DSH_BASH_OK > marker; cat marker", workdir:'/tmp/dsh-smoke-workspace',
}, signal:AbortSignal.timeout(30000)});
console.log(JSON.stringify(result));
assert.ok(!result.isError, 'Bash tool must succeed');
assert.equal(result.value.exitCode, 0);
assert.equal(result.value.sandbox.enforcement, 'full');
assert.equal(result.value.sandbox.mode, 'workspace-write');
assert.equal(readFileSync('/tmp/dsh-smoke-workspace/marker','utf8'), 'DSH_BASH_OK');
const denied = await ctx.tools.execute({callId:'denied',name:'bash',arguments:{
description:'Check sandbox blocks writes outside workspace',
command:'printf forbidden > /etc/dsh-smoke-forbidden',workdir:'/tmp/dsh-smoke-workspace',
},signal:AbortSignal.timeout(30000)});
console.log(JSON.stringify(denied));
assert.equal(denied.value.sandbox.denied, true);
assert.notEqual(denied.value.exitCode, 0);
assert.equal(existsSync('/etc/dsh-smoke-forbidden'),false, 'sandbox must block outside write');
console.log('DSH_BASH_SANDBOX_OK');
} finally { await ctx.fiber.dispose(); }