mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
fix(dsh): pin runtime Node to 22 and include bubblewrap (#671)
Co-authored-by: matevip <matevip@mate.vip>
This commit is contained in:
parent
f449fb229e
commit
c9c08b86fe
@ -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
|
||||||
|
|||||||
@ -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}))' \
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
mateclaw-server/src/test/resources/dsh/image-bash-smoke.mjs
Normal file
54
mateclaw-server/src/test/resources/dsh/image-bash-smoke.mjs
Normal 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(); }
|
||||||
Loading…
Reference in New Issue
Block a user