diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index 6c5b672b..b0bc3abb 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -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} +# 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 # # 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 # 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). -FROM mcr.microsoft.com/playwright:v1.62.0-noble +FROM mcr.microsoft.com/playwright:v1.62.0-noble AS runtime-base 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). # Install openjdk-21 explicitly and add CJK fonts so Chinese pages render # correctly in screenshots and snapshots. @@ -111,6 +122,7 @@ RUN apt-get update \ python-is-python3 \ python3-dev \ build-essential \ + bubblewrap \ && rm -rf /var/lib/apt/lists/* # 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. ENV SPRING_PROFILES_ACTIVE=mysql +FROM runtime-base AS runtime COPY --from=builder /build/mateclaw-server/target/*.jar app.jar EXPOSE 18088 diff --git a/mateclaw-server/src/main/resources/dsh/container/Dockerfile b/mateclaw-server/src/main/resources/dsh/container/Dockerfile index 3852ffd9..2678ac40 100644 --- a/mateclaw-server/src/main/resources/dsh/container/Dockerfile +++ b/mateclaw-server/src/main/resources/dsh/container/Dockerfile @@ -5,6 +5,9 @@ ARG DSH_VERSION ARG DSH_LOCK_SHA256 LABEL vip.mateclaw.dsh.version="${DSH_VERSION}" \ 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 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}))' \ diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshImageSmokeTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshImageSmokeTest.java new file mode 100644 index 00000000..19dc161a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshImageSmokeTest.java @@ -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()); + } + } +} diff --git a/mateclaw-server/src/test/resources/dsh/image-bash-smoke.mjs b/mateclaw-server/src/test/resources/dsh/image-bash-smoke.mjs new file mode 100644 index 00000000..11cc3a0e --- /dev/null +++ b/mateclaw-server/src/test/resources/dsh/image-bash-smoke.mjs @@ -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(); }