mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
feat(dsh): add optional Docker supervision and shared workspace acceptance
This commit is contained in:
parent
bdc5087f70
commit
9aaf53dbf3
@ -0,0 +1,50 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
|
||||||
|
/** Owns one exact container created before SDK startup. Removal is idempotent and fail-visible. */
|
||||||
|
final class DshDockerProcess extends Process {
|
||||||
|
interface Removal { String remove() throws Exception; }
|
||||||
|
private final Process delegate;
|
||||||
|
private final Removal removal;
|
||||||
|
private final String id;
|
||||||
|
private boolean removed;
|
||||||
|
private final java.util.Deque<Runnable> afterCleanup = new java.util.ArrayDeque<>();
|
||||||
|
synchronized void afterCleanup(Runnable action) {
|
||||||
|
afterCleanup.addLast(action);
|
||||||
|
if (removed) finishBookkeeping();
|
||||||
|
}
|
||||||
|
private void finishBookkeeping() {
|
||||||
|
while (!afterCleanup.isEmpty()) {
|
||||||
|
afterCleanup.getFirst().run();
|
||||||
|
afterCleanup.removeFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DshDockerProcess(Process delegate,Removal removal,String id) {
|
||||||
|
this.delegate=delegate;this.removal=removal;this.id=id;
|
||||||
|
delegate.onExit().thenRunAsync(()->{try{cleanup();}catch(RuntimeException ignored){/* close/cancel retries and exposes failure */}});
|
||||||
|
}
|
||||||
|
synchronized boolean cleanupConfirmed() { return removed && afterCleanup.isEmpty(); }
|
||||||
|
private synchronized void cleanup() {
|
||||||
|
try {
|
||||||
|
if (!removed) {
|
||||||
|
String result=removal.remove();
|
||||||
|
if(!id.equals(result.trim()))throw new IOException("container removal identity mismatch");
|
||||||
|
removed=true;
|
||||||
|
}
|
||||||
|
finishBookkeeping();
|
||||||
|
} catch(Exception error) {throw new IllegalStateException("DSH_DOCKER_CLEANUP_UNCONFIRMED",error);}
|
||||||
|
}
|
||||||
|
@Override public OutputStream getOutputStream(){return delegate.getOutputStream();}
|
||||||
|
@Override public InputStream getInputStream(){return delegate.getInputStream();}
|
||||||
|
@Override public InputStream getErrorStream(){return delegate.getErrorStream();}
|
||||||
|
@Override public int waitFor() throws InterruptedException {int code=delegate.waitFor();cleanup();return code;}
|
||||||
|
@Override public boolean waitFor(long timeout,TimeUnit unit)throws InterruptedException{boolean exited=delegate.waitFor(timeout,unit);if(exited)cleanup();return exited;}
|
||||||
|
@Override public int exitValue(){return delegate.exitValue();}
|
||||||
|
@Override public boolean isAlive(){return delegate.isAlive();}
|
||||||
|
@Override public long pid(){return delegate.pid();}
|
||||||
|
@Override public ProcessHandle toHandle(){return delegate.toHandle();}
|
||||||
|
@Override public void destroy(){cleanup();delegate.destroy();}
|
||||||
|
@Override public Process destroyForcibly(){cleanup();delegate.destroyForcibly();return this;}
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.DshPinnedInstaller;
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** Optional native Docker PID boundary. No shell commands or weak-path fallback. */
|
||||||
|
@Component
|
||||||
|
public class DshProcessSupervisor {
|
||||||
|
private final String mode;
|
||||||
|
private final String docker;
|
||||||
|
private final String images;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
public DshProcessSupervisor(
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.containment.mode:native}") String mode,
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.containment.docker-executable:docker}") String docker,
|
||||||
|
@Value("${mateclaw.agent.runtime.dsh.containment.images-json:}") String images,
|
||||||
|
ObjectMapper mapper) {
|
||||||
|
this.mode=mode;this.docker=docker;this.images=images;this.mapper=mapper;
|
||||||
|
}
|
||||||
|
public Map<String,Object> status() {
|
||||||
|
return Map.of("mode",mode,"requiresDocker", "docker".equals(mode),"verified",false);
|
||||||
|
}
|
||||||
|
public Process start(DshLaunchSpec launch, Map<String,String> environment) throws Exception {
|
||||||
|
if ("native".equals(mode)) return launch.processBuilder(environment).start();
|
||||||
|
if (!"docker".equals(mode)) throw new IOException("DSH_CONTAINMENT_MODE_INVALID");
|
||||||
|
if (images.isBlank()) throw new IOException("DSH_DOCKER_IMAGE_REQUIRED");
|
||||||
|
var catalog=mapper.readTree(images);
|
||||||
|
if (!catalog.isObject()) throw new IOException("DSH_DOCKER_IMAGE_CATALOG_INVALID");
|
||||||
|
String version=managedVersion(launch.command().getFirst());
|
||||||
|
String image=catalog.path(version).asText("");
|
||||||
|
if (!image.matches("sha256:[0-9a-f]{64}")) throw new IOException("DSH_DOCKER_IMMUTABLE_IMAGE_REQUIRED");
|
||||||
|
var prefix=dockerPrefix();
|
||||||
|
String labelsText=command(prefix,List.of("image","inspect","--format","{{json .Config.Labels}}",image),Map.of());
|
||||||
|
var labels=mapper.readTree(labelsText);
|
||||||
|
if (!version.equals(labels.path("vip.mateclaw.dsh.version").asText())
|
||||||
|
|| !lockHash(version).equals(labels.path("vip.mateclaw.dsh.lock-sha256").asText()))
|
||||||
|
throw new IOException("DSH_DOCKER_IMAGE_PROVENANCE_MISMATCH");
|
||||||
|
// Reuse all existing home/symlink validation before creating a container.
|
||||||
|
Map<String,String> sdkEnvironment=new HashMap<>(launch.processBuilder(environment).environment());
|
||||||
|
if (launch.cwd().getParent()==null) throw new IOException("DSH_DOCKER_WORKSPACE_ROOT_INVALID");
|
||||||
|
String owner=UUID.randomUUID().toString();
|
||||||
|
var args=new ArrayList<>(List.of("create","--pull=never","--name","mateclaw-dsh-"+owner,
|
||||||
|
"--label","vip.mateclaw.dsh.owner="+owner,"--label","vip.mateclaw.dsh.jvm="+ProcessHandle.current().pid(),
|
||||||
|
"--init","--interactive","--cap-drop=ALL","--security-opt=no-new-privileges","--pids-limit=256",
|
||||||
|
"--read-only","--tmpfs","/tmp:rw,exec,nosuid,nodev,size=128m","--user",hostIdentity(),"--workdir",launch.cwd().toString()));
|
||||||
|
mount(args,launch.home(),false);mount(args,launch.cwd(),false);
|
||||||
|
for(int i=3;i<launch.command().size();i+=2) {
|
||||||
|
if (!"--patch".equals(launch.command().get(i)) || i+1>=launch.command().size()) throw new IOException("DSH_DOCKER_ARGUMENT_INVALID");
|
||||||
|
mount(args,Path.of(launch.command().get(i+1)),true);
|
||||||
|
}
|
||||||
|
sdkEnvironment.put("HOME",launch.home().toString());
|
||||||
|
for(String key:List.of("DEEPSEEK_API_KEY","DEEPSEEK_BASE_URL","DSH_HOME","DSH_AGENTS_HOME","DSH_CWD")) {
|
||||||
|
if(sdkEnvironment.containsKey(key)){args.add("--env");args.add(key);}
|
||||||
|
}
|
||||||
|
args.add("--env");args.add("HOME="+launch.home());
|
||||||
|
args.add("--env");args.add("SHELL=/bin/bash");
|
||||||
|
args.add(image);args.addAll(launch.command().subList(1,launch.command().size()));
|
||||||
|
String id;
|
||||||
|
try {
|
||||||
|
id=command(prefix,args,sdkEnvironment).trim();
|
||||||
|
if(!id.matches("[0-9a-f]{64}")) throw new IOException("DSH_DOCKER_CREATE_ID_INVALID");
|
||||||
|
} catch (Exception failure) {
|
||||||
|
// create may have succeeded before the client lost its response. No SDK has
|
||||||
|
// started yet. Recover only the exact random name with our ownership label.
|
||||||
|
try {
|
||||||
|
String recovered=command(prefix,List.of("container","ls","--all","--no-trunc","--quiet",
|
||||||
|
"--filter","name=^/mateclaw-dsh-"+owner+"$","--filter","label=vip.mateclaw.dsh.owner="+owner),Map.of());
|
||||||
|
if(recovered.matches("[0-9a-f]{64}")) remove(prefix,recovered);
|
||||||
|
else if(!recovered.isEmpty()) throw new IOException("DSH_DOCKER_CREATE_OWNERSHIP_UNCONFIRMED");
|
||||||
|
} catch (Exception cleanup) { failure.addSuppressed(cleanup); }
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var cli=builder(prefix,List.of("start","--attach","--interactive",id),Map.of()).start();
|
||||||
|
return new DshDockerProcess(cli,()->remove(prefix,id),id);
|
||||||
|
} catch(Exception failure) {
|
||||||
|
try { remove(prefix,id); } catch (Exception cleanup) { failure.addSuppressed(cleanup); }
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static String remove(List<String> prefix,String id) throws Exception {
|
||||||
|
try {
|
||||||
|
String result=command(prefix,List.of("rm","--force",id),Map.of());
|
||||||
|
if(id.equals(result))return id;
|
||||||
|
} catch (Exception failedRemoval) {
|
||||||
|
// A previous removal may have succeeded before a lost CLI response. Confirm
|
||||||
|
// absence against the same pinned daemon; daemon failure cannot prove absence.
|
||||||
|
String remaining=command(prefix,List.of("container","ls","--all","--no-trunc","--quiet","--filter","id="+id),Map.of());
|
||||||
|
if(remaining.isEmpty())return id;
|
||||||
|
throw failedRemoval;
|
||||||
|
}
|
||||||
|
throw new IOException("DSH_DOCKER_REMOVAL_ID_INVALID");
|
||||||
|
}
|
||||||
|
static String lockHash(String version) throws Exception {
|
||||||
|
try(var input=DshProcessSupervisor.class.getResourceAsStream("/dsh/locks/"+version+".json")) {
|
||||||
|
if(input==null)throw new IOException("DSH_DOCKER_LOCK_MISSING");
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private String managedVersion(String executable) throws Exception {
|
||||||
|
Path path=Path.of(executable).toRealPath().getParent();
|
||||||
|
for(int i=0;i<4 && path!=null;i++,path=path.getParent()) {
|
||||||
|
Path file=path.resolve("package.json");
|
||||||
|
if(!Files.isRegularFile(file))continue;
|
||||||
|
var value=mapper.readTree(file.toFile());
|
||||||
|
if("@deepseek-ai/dsh".equals(value.path("name").asText())) {
|
||||||
|
String version=value.path("version").asText();DshPinnedInstaller.release(version);return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IOException("DSH_DOCKER_MANAGED_SDK_REQUIRED");
|
||||||
|
}
|
||||||
|
private List<String> dockerPrefix() throws Exception {
|
||||||
|
String endpoint=System.getenv("DOCKER_HOST");
|
||||||
|
if(endpoint==null || endpoint.isBlank())endpoint=mapper.readTree(command(List.of(docker),List.of("context","inspect","--format","{{json .Endpoints.docker.Host}}"),Map.of())).asText();
|
||||||
|
if(!endpoint.startsWith("unix://"))throw new IOException("DSH_DOCKER_LOCAL_UNIX_ENDPOINT_REQUIRED");
|
||||||
|
return List.of(docker,"--host",endpoint);
|
||||||
|
}
|
||||||
|
private static String hostIdentity() throws IOException {
|
||||||
|
try {var unix=new com.sun.security.auth.module.UnixSystem();return unix.getUid()+":"+unix.getGid();}
|
||||||
|
catch(Throwable unsupported){throw new IOException("DSH_DOCKER_HOST_PLATFORM_UNSUPPORTED");}
|
||||||
|
}
|
||||||
|
private static void mount(List<String> args,Path path,boolean readOnly) throws IOException {
|
||||||
|
Path source=path.toRealPath();
|
||||||
|
// --mount uses CSV parsing. Quote fields independently; no shell interpretation.
|
||||||
|
String src="\"source="+source.toString().replace("\"","\"\"")+"\"";
|
||||||
|
String dst="\"target="+path.toAbsolutePath().normalize().toString().replace("\"","\"\"")+"\"";
|
||||||
|
args.add("--mount");args.add("type=bind,"+src+","+dst+(readOnly?",readonly":""));
|
||||||
|
}
|
||||||
|
private static ProcessBuilder builder(List<String> prefix,List<String> args,Map<String,String> extra) {
|
||||||
|
var argv=new ArrayList<>(prefix);argv.addAll(args);var b=new ProcessBuilder(argv);
|
||||||
|
b.environment().clear();
|
||||||
|
for(String key:List.of("PATH","HOME","TMPDIR","DOCKER_CONFIG","DOCKER_CERT_PATH","DOCKER_TLS_VERIFY","SSL_CERT_FILE")) {
|
||||||
|
String value=System.getenv(key);if(value!=null)b.environment().put(key,value);
|
||||||
|
}
|
||||||
|
// Only selected SDK variables are given to docker create, never in argv.
|
||||||
|
for(String key:List.of("DEEPSEEK_API_KEY","DEEPSEEK_BASE_URL","DSH_HOME","DSH_AGENTS_HOME","DSH_CWD"))if(extra.containsKey(key))b.environment().put(key,extra.get(key));
|
||||||
|
// HOME is required by Docker for its config as well as by the container. Use an explicit
|
||||||
|
// container HOME value, avoiding changing the client's config lookup.
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
private static String command(List<String> prefix,List<String> args,Map<String,String> env) throws Exception {
|
||||||
|
Process p=builder(prefix,args,env).redirectErrorStream(true).start();
|
||||||
|
try {
|
||||||
|
if(!p.waitFor(15,TimeUnit.SECONDS))throw new IOException("DSH_DOCKER_CONTROL_TIMEOUT");
|
||||||
|
byte[] output=p.getInputStream().readNBytes(65537);
|
||||||
|
if(p.exitValue()!=0 || output.length>65536)throw new IOException("DSH_DOCKER_CONTROL_FAILED");
|
||||||
|
return new String(output,StandardCharsets.UTF_8).trim();
|
||||||
|
} finally {if(p.isAlive())p.destroyForcibly();}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -78,6 +78,11 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
log.info("[DSH] SDK runtime adapter initialized");
|
log.info("[DSH] SDK runtime adapter initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DshProcessSupervisor processSupervisor = new DshProcessSupervisor("native", "docker", "", new ObjectMapper());
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
public void setProcessSupervisor(DshProcessSupervisor supervisor) { this.processSupervisor = supervisor; }
|
||||||
|
|
||||||
private DshRuntimeConfiguration runtimeConfig() {
|
private DshRuntimeConfiguration runtimeConfig() {
|
||||||
DshRuntimeConfiguration raw = runtimeConfigService.resolve();
|
DshRuntimeConfiguration raw = runtimeConfigService.resolve();
|
||||||
String cwd = raw.workingDirectory();
|
String cwd = raw.workingDirectory();
|
||||||
@ -260,18 +265,26 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
|
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
|
||||||
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
|
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
|
||||||
DshLaunchSpec launch = DshLaunchSpec.create(configuration, agent.getWorkspaceId(), agent.getId(), session.workingDirectory());
|
DshLaunchSpec launch = DshLaunchSpec.create(configuration, agent.getWorkspaceId(), agent.getId(), session.workingDirectory());
|
||||||
process = launch.processBuilder(childEnvironment(System.getenv(), session, configuration, provider)).start();
|
process = processSupervisor.start(launch, childEnvironment(System.getenv(), session, configuration, provider));
|
||||||
processRef.set(process);
|
processRef.set(process);
|
||||||
|
if (process instanceof DshDockerProcess owned) {
|
||||||
|
var retainedLease = lease;
|
||||||
|
Process retainedProcess = process;
|
||||||
|
owned.afterCleanup(() -> {
|
||||||
|
if (retainedLease != null) retainedLease.close();
|
||||||
|
processRef.compareAndSet(retainedProcess, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
Process startedProcess = process;
|
Process startedProcess = process;
|
||||||
sink.onCancel(() -> { cancelled.set(true); cancelProcess(startedProcess); });
|
sink.onCancel(() -> { cancelled.set(true); cancelProcess(startedProcess); });
|
||||||
if (sink.isCancelled()) return;
|
if (sink.isCancelled()) return;
|
||||||
if (cancelled.get()) throw new InterruptedException("DSH_CANCELLED");
|
if (cancelled.get()) throw new InterruptedException("DSH_CANCELLED");
|
||||||
|
long sequence = 0;
|
||||||
try (DshSdkProcess sdk = new DshSdkProcess(process, objectMapper,
|
try (DshSdkProcess sdk = new DshSdkProcess(process, objectMapper,
|
||||||
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()))) {
|
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()))) {
|
||||||
sdk.initialize(Map.of("cwd", session.workingDirectory().toString(),
|
sdk.initialize(Map.of("cwd", session.workingDirectory().toString(),
|
||||||
"provider", "deepseek-official", "model", effectiveModelName, "maxTokens", maxOutputTokens),
|
"provider", "deepseek-official", "model", effectiveModelName, "maxTokens", maxOutputTokens),
|
||||||
Duration.ofMillis(initializeTimeoutMs));
|
Duration.ofMillis(initializeTimeoutMs));
|
||||||
long sequence = 0;
|
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++,
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++,
|
||||||
RuntimeEventType.RUNTIME_READY, null, Map.of("runtimeProvider", "dsh"))));
|
RuntimeEventType.RUNTIME_READY, null, Map.of("runtimeProvider", "dsh"))));
|
||||||
String promptId = "prompt-" + conversationId;
|
String promptId = "prompt-" + conversationId;
|
||||||
@ -304,34 +317,50 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!sink.isCancelled()) {
|
|
||||||
if (!turn.answer().isEmpty()) sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
|
||||||
conversationId, sequence++, RuntimeEventType.ASSISTANT_DELTA, turn.answer(), Map.of())));
|
|
||||||
latestUsage.set(new RuntimeContextUsage(turn.inputTokens(), turn.outputTokens(), 0));
|
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++, RuntimeEventType.CONTEXT_USAGE,
|
|
||||||
null, Map.of("inputTokens", turn.inputTokens(), "outputTokens", turn.outputTokens(),
|
|
||||||
"promptTokens", turn.inputTokens(), "completionTokens", turn.outputTokens()))));
|
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(conversationId, sequence,
|
|
||||||
turn.terminalType(), turn.terminalType() == RuntimeEventType.COMPLETED ? Map.of()
|
|
||||||
: Map.of("code", "DSH_INCOMPLETE_TURN", "error", "DSH turn ended: " + turn.reason()))));
|
|
||||||
sink.complete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (cancelled.get()) throw new InterruptedException("DSH_CANCELLED");
|
||||||
|
if (!sink.isCancelled()) {
|
||||||
|
if (!turn.answer().isEmpty()) sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
||||||
|
conversationId, sequence++, RuntimeEventType.ASSISTANT_DELTA, turn.answer(), Map.of())));
|
||||||
|
latestUsage.set(new RuntimeContextUsage(turn.inputTokens(), turn.outputTokens(), 0));
|
||||||
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, sequence++, RuntimeEventType.CONTEXT_USAGE,
|
||||||
|
null, Map.of("inputTokens", turn.inputTokens(), "outputTokens", turn.outputTokens(),
|
||||||
|
"promptTokens", turn.inputTokens(), "completionTokens", turn.outputTokens()))));
|
||||||
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(conversationId, sequence,
|
||||||
|
turn.terminalType(), turn.terminalType() == RuntimeEventType.COMPLETED ? Map.of()
|
||||||
|
: Map.of("code", "DSH_INCOMPLETE_TURN", "error", "DSH turn ended: " + turn.reason()))));
|
||||||
|
sink.complete();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
if (!sink.isCancelled() && turn != null && !turn.answer().isEmpty())
|
if (!sink.isCancelled() && turn != null && !turn.answer().isEmpty())
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, 0,
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(conversationId, 0,
|
||||||
RuntimeEventType.ASSISTANT_DELTA, turn.answer(), Map.of())));
|
RuntimeEventType.ASSISTANT_DELTA, turn.answer(), Map.of())));
|
||||||
if (cancelled.get()) {
|
boolean cancellationConfirmed = cancelled.get();
|
||||||
|
if (process instanceof DshDockerProcess owned && !owned.cleanupConfirmed()) {
|
||||||
|
try { owned.destroy(); }
|
||||||
|
catch (RuntimeException unconfirmed) { cancellationConfirmed = false; error = unconfirmed; }
|
||||||
|
}
|
||||||
|
if (cancellationConfirmed) {
|
||||||
if (!sink.isCancelled()) {
|
if (!sink.isCancelled()) {
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(conversationId, 0, RuntimeEventType.CANCELLED, Map.of())));
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal(conversationId, 0, RuntimeEventType.CANCELLED, Map.of())));
|
||||||
sink.complete();
|
sink.complete();
|
||||||
}
|
}
|
||||||
} else sink.error(new IllegalStateException("DSH runtime unavailable: " + DshSdkProcess.redact(error.getMessage(), credential)));
|
} else sink.error(new IllegalStateException("DSH runtime unavailable: " + DshSdkProcess.redact(error.getMessage(), credential)));
|
||||||
if (process != null) process.destroyForcibly();
|
if (process != null && !(process instanceof DshDockerProcess)) process.destroyForcibly();
|
||||||
} finally {
|
} finally {
|
||||||
cancelProcess(process);
|
if (process instanceof DshDockerProcess owned) {
|
||||||
if (process != null) processRef.compareAndSet(process, null);
|
// The ownership callback registered before SDK startup retains the
|
||||||
if (lease != null) lease.close();
|
// lease until both removal and bookkeeping have succeeded.
|
||||||
|
try { cancelProcess(process); }
|
||||||
|
catch (RuntimeException unconfirmed) { log.warn("[DSH] Docker cleanup remains unconfirmed; admission retained"); }
|
||||||
|
} else {
|
||||||
|
try { cancelProcess(process); }
|
||||||
|
finally {
|
||||||
|
if (process != null) processRef.compareAndSet(process, null);
|
||||||
|
if (lease != null) lease.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).subscribeOn(Schedulers.boundedElastic());
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
@ -353,9 +382,29 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
return healthCheck(configuration, true, home);
|
return healthCheck(configuration, true, home);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final ThreadLocal<DshDockerProcess> failedHealthCleanup = new ThreadLocal<>();
|
||||||
|
private final java.util.Set<DshDockerProcess> pendingHealthCleanup = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
public void retryPendingHealthCleanup() {
|
||||||
|
for (DshDockerProcess owned : pendingHealthCleanup) {
|
||||||
|
owned.destroy();
|
||||||
|
if (owned.cleanupConfirmed()) pendingHealthCleanup.remove(owned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void releaseHealthLease(Runnable release) {
|
||||||
|
DshDockerProcess owned = failedHealthCleanup.get();
|
||||||
|
failedHealthCleanup.remove();
|
||||||
|
if (owned == null) release.run();
|
||||||
|
else owned.afterCleanup(release);
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> healthCheck(DshRuntimeConfiguration configuration, boolean task, Path home) {
|
private Map<String, Object> healthCheck(DshRuntimeConfiguration configuration, boolean task, Path home) {
|
||||||
long started = System.currentTimeMillis();
|
long started = System.currentTimeMillis();
|
||||||
|
Process process = null;
|
||||||
|
failedHealthCleanup.remove();
|
||||||
try {
|
try {
|
||||||
|
retryPendingHealthCleanup();
|
||||||
if (MemberFileIsolation.isEnabled()) throw new SecurityException("DSH_MEMBER_ISOLATION_UNSUPPORTED");
|
if (MemberFileIsolation.isEnabled()) throw new SecurityException("DSH_MEMBER_ISOLATION_UNSUPPORTED");
|
||||||
Path cwd = Path.of(configuration.workingDirectory());
|
Path cwd = Path.of(configuration.workingDirectory());
|
||||||
DshLaunchSpec launch = DshLaunchSpec.create(configuration, "health", "health", cwd);
|
DshLaunchSpec launch = DshLaunchSpec.create(configuration, "health", "health", cwd);
|
||||||
@ -363,7 +412,7 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
ModelConfigEntity model = resolveModel(configuration.modelName());
|
ModelConfigEntity model = resolveModel(configuration.modelName());
|
||||||
ModelProviderEntity provider = resolveProvider(model);
|
ModelProviderEntity provider = resolveProvider(model);
|
||||||
RuntimeSession session = new RuntimeSession("health", "health", 0L, 0L, configuration.modelName(), cwd, Map.of());
|
RuntimeSession session = new RuntimeSession("health", "health", 0L, 0L, configuration.modelName(), cwd, Map.of());
|
||||||
Process process = launch.processBuilder(childEnvironment(System.getenv(), session, configuration, provider)).start();
|
process = processSupervisor.start(launch, childEnvironment(System.getenv(), session, configuration, provider));
|
||||||
try (DshSdkProcess sdk = new DshSdkProcess(process, objectMapper,
|
try (DshSdkProcess sdk = new DshSdkProcess(process, objectMapper,
|
||||||
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()))) {
|
firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey()))) {
|
||||||
sdk.initialize(Map.of("cwd", cwd.toString(), "provider", "deepseek-official",
|
sdk.initialize(Map.of("cwd", cwd.toString(), "provider", "deepseek-official",
|
||||||
@ -389,6 +438,10 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
"checkedAt", Instant.now().toString(), "durationMs", System.currentTimeMillis() - started,
|
"checkedAt", Instant.now().toString(), "durationMs", System.currentTimeMillis() - started,
|
||||||
"versionStatus", "UNVERIFIED_VERSION");
|
"versionStatus", "UNVERIFIED_VERSION");
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
|
if (process instanceof DshDockerProcess owned && !owned.cleanupConfirmed()) {
|
||||||
|
pendingHealthCleanup.add(owned);
|
||||||
|
failedHealthCleanup.set(owned);
|
||||||
|
}
|
||||||
return Map.of("success", false, "kind", task ? "task" : "handshake",
|
return Map.of("success", false, "kind", task ? "task" : "handshake",
|
||||||
"message", DshSdkProcess.redact(error.getMessage(), configuration.apiKey()),
|
"message", DshSdkProcess.redact(error.getMessage(), configuration.apiKey()),
|
||||||
"checkedAt", Instant.now().toString(), "durationMs", System.currentTimeMillis() - started);
|
"checkedAt", Instant.now().toString(), "durationMs", System.currentTimeMillis() - started);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import java.util.UUID;
|
|||||||
public class DshManagementController {
|
public class DshManagementController {
|
||||||
private final DshManagementService managementService;
|
private final DshManagementService managementService;
|
||||||
private final DshUpgradeService upgrades;
|
private final DshUpgradeService upgrades;
|
||||||
|
private final vip.mate.agent.runtime.dsh.DshProcessSupervisor processSupervisor;
|
||||||
|
|
||||||
public record UpgradeRequest(String targetVersion, String expectedRevision, String idempotencyKey) {}
|
public record UpgradeRequest(String targetVersion, String expectedRevision, String idempotencyKey) {}
|
||||||
public record RollbackRequest(String expectedRevision, String idempotencyKey) {}
|
public record RollbackRequest(String expectedRevision, String idempotencyKey) {}
|
||||||
@ -28,6 +29,7 @@ public class DshManagementController {
|
|||||||
public R<Map<String, Object>> status() {
|
public R<Map<String, Object>> status() {
|
||||||
Map<String, Object> status = new LinkedHashMap<>(managementService.status());
|
Map<String, Object> status = new LinkedHashMap<>(managementService.status());
|
||||||
status.putAll(upgrades.status());
|
status.putAll(upgrades.status());
|
||||||
|
status.put("containment", processSupervisor.status());
|
||||||
return R.ok(status);
|
return R.ok(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -108,6 +108,7 @@ public class DshManagementService {
|
|||||||
String revision = "";
|
String revision = "";
|
||||||
if (task) taskCheck = Map.of(); else handshake = Map.of();
|
if (task) taskCheck = Map.of(); else handshake = Map.of();
|
||||||
try {
|
try {
|
||||||
|
if (runtime != null) runtime.retryPendingHealthCleanup();
|
||||||
if (configService.generations() != null) lease = configService.generations().acquireLease("health", "health");
|
if (configService.generations() != null) lease = configService.generations().acquireLease("health", "health");
|
||||||
revision = Objects.toString(configService.revision(), "0");
|
revision = Objects.toString(configService.revision(), "0");
|
||||||
DshRuntimeConfiguration configuration = configService.resolve();
|
DshRuntimeConfiguration configuration = configService.resolve();
|
||||||
@ -121,7 +122,13 @@ public class DshManagementService {
|
|||||||
"configRevision", revision, "checkedAt", Instant.now().toString());
|
"configRevision", revision, "checkedAt", Instant.now().toString());
|
||||||
if (task) taskCheck = failed; else handshake = failed;
|
if (task) taskCheck = failed; else handshake = failed;
|
||||||
return failed;
|
return failed;
|
||||||
} finally { if (lease != null) lease.close(); }
|
} finally {
|
||||||
|
if (lease != null) {
|
||||||
|
var retainedLease = lease;
|
||||||
|
if (runtime == null) retainedLease.close();
|
||||||
|
else runtime.releaseHealthLease(retainedLease::close);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> enable() {
|
public Map<String, Object> enable() {
|
||||||
|
|||||||
@ -155,6 +155,10 @@ mateclaw:
|
|||||||
agent:
|
agent:
|
||||||
runtime:
|
runtime:
|
||||||
dsh:
|
dsh:
|
||||||
|
containment:
|
||||||
|
mode: ${MATECLAW_DSH_CONTAINMENT_MODE:native}
|
||||||
|
docker-executable: ${MATECLAW_DSH_DOCKER_EXECUTABLE:docker}
|
||||||
|
images-json: ${MATECLAW_DSH_DOCKER_IMAGES_JSON:}
|
||||||
command: ${DSH_JSONRPC_AGENT:}
|
command: ${DSH_JSONRPC_AGENT:}
|
||||||
cordis-config: ${DSH_CORDIS_CONFIG:}
|
cordis-config: ${DSH_CORDIS_CONFIG:}
|
||||||
working-directory: ${DSH_CWD:}
|
working-directory: ${DSH_CWD:}
|
||||||
|
|||||||
@ -79,3 +79,41 @@ Real SDK/model checks exercised a running Bash sleep: explicit cancellation prod
|
|||||||
The transport now retains observed descendant ProcessHandles while the root is alive so those handles remain available after a crash. Cleanup is best effort: children born and reparented entirely between observations, or detached beforehand, can escape capture. An isolated runtime home does not provide OS containment, and stronger process supervision remains an open gate. Candidate status and strict-member-isolation restrictions remain.
|
The transport now retains observed descendant ProcessHandles while the root is alive so those handles remain available after a crash. Cleanup is best effort: children born and reparented entirely between observations, or detached beforehand, can escape capture. An isolated runtime home does not provide OS containment, and stronger process supervision remains an open gate. Candidate status and strict-member-isolation restrictions remain.
|
||||||
|
|
||||||
Workspace-checkpoint recovery requires inspecting the existing artifact before deciding whether to repeat a write. It does not restore SDK tool state, automatically replay uncertain side effects, or prove exactly-once behavior for external systems. Goal acceptance, call budgets, approvals and application permissions are unchanged; regression covers them separately from these direct runtime probes.
|
Workspace-checkpoint recovery requires inspecting the existing artifact before deciding whether to repeat a write. It does not restore SDK tool state, automatically replay uncertain side effects, or prove exactly-once behavior for external systems. Goal acceptance, call budgets, approvals and application permissions are unchanged; regression covers them separately from these direct runtime probes.
|
||||||
|
|
||||||
|
## Optional Docker process supervision (experimental)
|
||||||
|
|
||||||
|
`native` remains the default. Native descendant observation cannot guarantee cleanup of a process that forks, creates a new session and reparents between observations. Optional `docker` mode creates a separate Linux PID namespace **before** SDK startup. Cancellation, SDK shutdown and loss of the attached Docker client remove only the recorded container ID. Completed/CANCELLED events require confirmed cleanup; a removal failure is an error and retains the conversation's generation lease until an explicit cancellation retry confirms removal. It never falls back to native execution.
|
||||||
|
|
||||||
|
Build a dedicated image from the bundled trusted lock (from the repository root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f mateclaw-server/src/main/resources/dsh/container/Dockerfile \
|
||||||
|
--build-arg DSH_VERSION=0.2.1-rc.2 \
|
||||||
|
--build-arg DSH_LOCK_SHA256=4c17ebb6b36dfdebe24100490a375bb93e72fc7450595751c8737b357ce8987d \
|
||||||
|
-t mateclaw-dsh-local:0.2.1-rc.2 mateclaw-server/src/main/resources/dsh
|
||||||
|
docker image inspect --format '{{.Id}}' mateclaw-dsh-local:0.2.1-rc.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The Dockerfile pins Node 22.20.0 by digest. Review the base, lock, native platform packages and resulting image before use. Configure the returned full `sha256:…` **image ID**, not a tag, then restart the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MATECLAW_DSH_CONTAINMENT_MODE=docker
|
||||||
|
export MATECLAW_DSH_DOCKER_EXECUTABLE=docker
|
||||||
|
export MATECLAW_DSH_DOCKER_IMAGES_JSON='{"0.2.1-rc.2":"sha256:<64 hexadecimal characters from image inspect>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Provide a separate reviewed image for each SDK catalog version that must run, including candidate checks and rollback. The selected host managed executable identifies the trusted SDK version; the container runs its own matching SDK. Image version/lock labels must match. Runtime never pulls an image or upgrades the active generation. `/status` exposes the configured containment mode; it is not a compatibility certificate.
|
||||||
|
|
||||||
|
Requirements: a trusted local Docker daemon reachable through a Unix socket, a matching Linux image architecture, and a host UID/GID supported by Java's Unix identity provider. Remote Docker endpoints and Windows hosts are rejected. The selected daemon endpoint stays fixed through cleanup. The container drops all capabilities, uses `no-new-privileges`, a read-only image filesystem and a bounded PID count. Its independent `/tmp` is executable because the SDK native loader copies dynamic libraries there. Only the explicit workspace, scoped employee home and read-only administrator patch files are mounted. Docker access is privileged host access; credentials passed as environment values are visible to Docker administrators. There is no Docker socket, host PID namespace or privileged mode inside the container.
|
||||||
|
|
||||||
|
**Platform limitation:** on the tested macOS arm64 Docker Desktop setup, SDK model calls work, but its native Landlock runner denies reads from the host shared workspace (`EACCES`). Do not use this mode for workspace tasks on that setup. No sandbox disablement or automatic escalation is applied. Linux native bind mounts and other Desktop file-sharing backends require their own read/write/tool acceptance checks; they are not certified by a successful handshake. Process cleanup probes use container `/tmp`, separately from host-workspace compatibility.
|
||||||
|
|
||||||
|
JVM termination and daemon unavailability remain explicit recovery boundaries. Containers carry `vip.mateclaw.dsh.owner=<random UUID>` and `vip.mateclaw.dsh.jvm=<creator PID>` labels. After confirming the recorded JVM has died, inspect and remove only its identified containers on the original daemon; then perform the stale-lease recovery above. A daemon outage cannot confirm cleanup. Do not remove leases or replay uncertain tool effects merely to admit work. Existing member-isolation rejection, application permissions, Goal rules, bounded text replay and output budgets remain unchanged. Docker process supervision does not add a host approval bridge or certify a complete security sandbox.
|
||||||
|
|
||||||
|
### Native shared-workspace investigation (2026-10-21)
|
||||||
|
|
||||||
|
The reproduced Desktop bind mount is `fakeowner` (filesystem magic `0x6a656a63`), rather than a native Linux filesystem. The exact SDK Landlock launcher rejects reads even with explicit directory/file grants; unrestricted reads succeed. A disposable Docker ext4 volume passes the same sandbox read/write probe and rejects both direct outside writes and symlink escapes. This identifies the filesystem boundary; it does not certify a Desktop configuration fix.
|
||||||
|
|
||||||
|
The selected next step is Docker Desktop's [Synchronized file shares](https://docs.docker.com/desktop/features/synchronized-file-sharing/), which uses a native ext4 cache and bidirectional synchronization. It requires signing in with Pro, Team or Business entitlement. In Settings → Resources → File sharing, create a share for a disposable test directory first and wait for its Watching status. Do not set bind consistency to `consistent`, which bypasses synchronized shares. Validate actual sandbox behavior and host-visible contents before using a production workspace; do not infer Landlock compatibility from filesystem type or handshake alone. Current local validation is waiting for Docker Desktop login/entitlement; synchronized sharing has not yet been certified.
|
||||||
|
|
||||||
|
The opt-in `DshDockerSharedWorkspaceAcceptanceTest` uses `MATECLAW_DSH_ACCEPTANCE_DOCKER_IMAGE` plus `MATECLAW_DSH_ACCEPTANCE_SHARED_WORKSPACE` (an existing test-share directory). It creates its own temporary subdirectory, checks host→sandbox→host data across two writes, and requires `EACCES` for outside and symlink writes. The independent ext4 volume control runs with only the image variable. Disposable owned containers, volumes and directories are cleaned. No workspace copyback implementation, sandbox bypass, automatic escalation or runtime dependency upgrade has been introduced.
|
||||||
|
|||||||
@ -79,3 +79,41 @@ macOS arm64 / Node 22.20.0 / npm 11.6.2 上的候选检查覆盖干净 `npm ci`
|
|||||||
传输层在根进程存活时保留观察到的后代 ProcessHandle,根崩溃后仍可据此清理。该机制是 best effort:在观察间隙内出生并被重新挂接、或提前脱离进程树的后代可能漏捕。独立 home 不等于 OS containment,更强的进程监督仍是待完成验收;候选状态和严格成员隔离限制保持不变。
|
传输层在根进程存活时保留观察到的后代 ProcessHandle,根崩溃后仍可据此清理。该机制是 best effort:在观察间隙内出生并被重新挂接、或提前脱离进程树的后代可能漏捕。独立 home 不等于 OS containment,更强的进程监督仍是待完成验收;候选状态和严格成员隔离限制保持不变。
|
||||||
|
|
||||||
文件检查点恢复需要先核验已有产物,再决定是否重做写入;它不会恢复 SDK 工具状态、自动重放结果不确定的副作用,也不能证明外部系统的 exactly-once。Goal 验收、调用预算、审批和应用权限保持原状,其回归与这些直接运行时探针分别记录。
|
文件检查点恢复需要先核验已有产物,再决定是否重做写入;它不会恢复 SDK 工具状态、自动重放结果不确定的副作用,也不能证明外部系统的 exactly-once。Goal 验收、调用预算、审批和应用权限保持原状,其回归与这些直接运行时探针分别记录。
|
||||||
|
|
||||||
|
## 可选 Docker 进程监督(实验性)
|
||||||
|
|
||||||
|
默认仍为 `native`。原生后代进程观察无法保证捕获观察间隙内 fork、创建独立 session 并重新挂靠的进程。可选 `docker` 模式在 SDK 启动前建立独立 Linux PID 命名空间,取消、SDK 退出或 Docker attach 客户端丢失时,仅删除本次记录的完整容器 ID。确认清理后才发布完成或 CANCELLED;删除失败按错误处理,会话的运行代际租约保留到显式重试取消且确认删除。不会自动回退原生执行。
|
||||||
|
|
||||||
|
在仓库根目录使用内置可信锁构建独立镜像:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f mateclaw-server/src/main/resources/dsh/container/Dockerfile \
|
||||||
|
--build-arg DSH_VERSION=0.2.1-rc.2 \
|
||||||
|
--build-arg DSH_LOCK_SHA256=4c17ebb6b36dfdebe24100490a375bb93e72fc7450595751c8737b357ce8987d \
|
||||||
|
-t mateclaw-dsh-local:0.2.1-rc.2 mateclaw-server/src/main/resources/dsh
|
||||||
|
docker image inspect --format '{{.Id}}' mateclaw-dsh-local:0.2.1-rc.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile 通过摘要固定 Node 22.20.0。先审查基础镜像、锁文件、平台原生依赖及构建产物,再配置返回的完整 `sha256:…` **镜像 ID**,不能填写可变标签。重启后端使配置生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MATECLAW_DSH_CONTAINMENT_MODE=docker
|
||||||
|
export MATECLAW_DSH_DOCKER_EXECUTABLE=docker
|
||||||
|
export MATECLAW_DSH_DOCKER_IMAGES_JSON='{"0.2.1-rc.2":"sha256:<image inspect 返回的 64 位十六进制值>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
每个需要运行的 SDK 目录版本都要提供独立审查过的镜像,包括候选检查与回滚版本。宿主机托管可执行文件用于识别可信 SDK 版本,容器运行对应镜像中的 SDK,版本及锁摘要标签必须匹配。运行时不会拉取镜像或切换活动代际。`/status` 展示配置的监督模式,不代表兼容性认证。
|
||||||
|
|
||||||
|
运行要求:可信的本地 Docker daemon、Unix socket、匹配架构的 Linux 镜像,以及 Java Unix 身份接口支持的宿主机 UID/GID。拒绝远程 Docker endpoint 和 Windows 宿主机;整个清理周期固定使用启动时的 daemon endpoint。容器移除全部 capabilities,使用 `no-new-privileges`、只读镜像文件系统和 PID 数量上限。独立 `/tmp` 允许执行,因为 SDK 原生加载器需要把动态库复制到该目录再加载。仅挂载明确的工作区、员工隔离 home 和只读管理员 patch。Docker 管理权限等同于高权限宿主机访问,Docker 管理员可以读取以环境变量传入的凭据。容器内不挂 Docker socket,不使用宿主机 PID 命名空间或 privileged 模式。
|
||||||
|
|
||||||
|
**平台限制:** 当前实测 macOS arm64 Docker Desktop 环境可执行 SDK 模型调用,但 SDK 原生 Landlock 拒绝读取宿主机共享工作区,报 `EACCES`。该环境暂不能用于工作区任务;不会关闭沙箱或自动提权绕过。Linux 原生 bind mount 和其他 Desktop 文件共享后端仍需独立完成读写、工具验收,握手成功不能替代验收。进程清理测试使用容器 `/tmp`,与宿主机工作区兼容性分开验证。
|
||||||
|
|
||||||
|
JVM 整体退出、daemon 不可用仍是明确的恢复边界。容器带有 `vip.mateclaw.dsh.owner=<随机 UUID>` 和 `vip.mateclaw.dsh.jvm=<创建 JVM PID>` 标签。确认记录的 JVM 已退出后,在原 daemon 上核对并删除其对应容器,再执行上文的失效租约恢复流程。daemon 不可用时不能确认清理;不得仅为恢复接入删除租约或重放结果未知的工具操作。成员隔离拒绝规则、应用权限、Goal、受限文本历史及输出预算保持不变。进程监督不会新增宿主机工具审批桥,也不代表完整安全沙箱认证。
|
||||||
|
|
||||||
|
### 原生共享工作区排查(2026-10-21)
|
||||||
|
|
||||||
|
当前 Desktop bind mount 实际是 `fakeowner`(文件系统 magic 为 `0x6a656a63`),不是原生 Linux 文件系统。使用 SDK 原生 Landlock launcher,即使显式授予目录或单文件权限仍无法读取;未受该规则约束的读取正常。一次性 Docker ext4 卷在相同沙箱探针下读写通过,且直接越界写入与符号链接越界都被拒绝。这定位了文件系统边界,但不代表 Desktop 配置已修复。
|
||||||
|
|
||||||
|
已选择优先验证 Docker Desktop 原生 [Synchronized file shares](https://docs.docker.com/desktop/features/synchronized-file-sharing/),使用 ext4 缓存和双向同步。它要求登录具备 Pro、Team 或 Business 权益的账号。在 Settings → Resources → File sharing 中先对临时测试目录 Create share,等待 Watching 状态。不要将 bind consistency 设为 `consistent`,该值会绕过同步共享。正式工作区启用前必须验证真实沙箱行为与宿主文件可见性,不能仅根据文件系统类型或握手成功推断兼容。目前本机仍等待 Docker Desktop 登录与订阅权益,同步共享尚未验收。
|
||||||
|
|
||||||
|
显式 opt-in 的 `DshDockerSharedWorkspaceAcceptanceTest` 使用 `MATECLAW_DSH_ACCEPTANCE_DOCKER_IMAGE` 和 `MATECLAW_DSH_ACCEPTANCE_SHARED_WORKSPACE`(已存在的测试共享目录)。它创建独立临时子目录,验证两轮宿主→沙箱→宿主文件内容,并要求直接越界和符号链接越界写入报 `EACCES`。只配置镜像变量时可以运行独立 ext4 卷对照。测试仅清理自行创建的容器、卷和目录。未引入应用层工作区写回、关闭沙箱、自动提权或运行时依赖升级。
|
||||||
|
|||||||
13
mateclaw-server/src/main/resources/dsh/container/Dockerfile
Normal file
13
mateclaw-server/src/main/resources/dsh/container/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Pass a reviewed, immutable Node 22 base image (name@sha256:...).
|
||||||
|
ARG NODE_IMAGE=node@sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e
|
||||||
|
FROM ${NODE_IMAGE}
|
||||||
|
ARG DSH_VERSION
|
||||||
|
ARG DSH_LOCK_SHA256
|
||||||
|
LABEL vip.mateclaw.dsh.version="${DSH_VERSION}" \
|
||||||
|
vip.mateclaw.dsh.lock-sha256="${DSH_LOCK_SHA256}"
|
||||||
|
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}))' \
|
||||||
|
&& npm ci --registry=https://registry.npmjs.org --no-audit --no-fund \
|
||||||
|
&& npm cache clean --force
|
||||||
|
ENTRYPOINT ["/opt/mateclaw-dsh/node_modules/.bin/dsh"]
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class DshDockerProcessTest {
|
||||||
|
private Process cli(CompletableFuture<Process> exited) {var p=mock(Process.class);when(p.onExit()).thenReturn(exited);return p;}
|
||||||
|
@Test void cleanupFailureIsVisibleAndRetriedRatherThanMarkedRemoved() {
|
||||||
|
var p=cli(new CompletableFuture<>());var count=new AtomicInteger();
|
||||||
|
var owned=new DshDockerProcess(p,()->{if(count.incrementAndGet()==1)throw new Exception("unavailable");return "id";},"id");
|
||||||
|
assertThrows(IllegalStateException.class,owned::destroy);verify(p,never()).destroy();
|
||||||
|
owned.destroy();owned.destroy();assertEquals(2,count.get());
|
||||||
|
}
|
||||||
|
@Test void losingTheAttachClientRemovesTheExactContainerWithoutPolling() throws Exception {
|
||||||
|
var exited=new CompletableFuture<Process>();var p=cli(exited);var removed=new CountDownLatch(1);
|
||||||
|
new DshDockerProcess(p,()->{removed.countDown();return "owned-id";},"owned-id");
|
||||||
|
exited.complete(p);assertTrue(removed.await(2,TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
@Test void removalOfTheWrongIdentityCannotConfirmCleanup() {
|
||||||
|
var p=cli(new CompletableFuture<>());var owned=new DshDockerProcess(p,()->"different-id","owned-id");
|
||||||
|
assertThrows(IllegalStateException.class,owned::destroyForcibly);verify(p,never()).destroyForcibly();
|
||||||
|
}
|
||||||
|
@Test void uncertainOwnershipRetainsLeaseUntilSuccessfulRetry() {
|
||||||
|
var p=cli(new CompletableFuture<>());var attempts=new AtomicInteger();var releases=new AtomicInteger();
|
||||||
|
var owned=new DshDockerProcess(p,()->{if(attempts.incrementAndGet()<2)throw new Exception("daemon unavailable");return "id";},"id");
|
||||||
|
owned.afterCleanup(releases::incrementAndGet);
|
||||||
|
assertThrows(IllegalStateException.class,owned::destroy);assertEquals(0,releases.get());
|
||||||
|
owned.destroy();owned.destroy();assertEquals(1,releases.get());
|
||||||
|
var late=new AtomicInteger();owned.afterCleanup(late::incrementAndGet);assertEquals(1,late.get());
|
||||||
|
}
|
||||||
|
@Test void confirmedRemovalStillRetriesFailedLeaseBookkeeping() {
|
||||||
|
var p=cli(new CompletableFuture<>());var removals=new AtomicInteger();var releases=new AtomicInteger();
|
||||||
|
var owned=new DshDockerProcess(p,()->{removals.incrementAndGet();return "id";},"id");
|
||||||
|
owned.afterCleanup(()->{if(releases.incrementAndGet()==1)throw new IllegalStateException("store unavailable");});
|
||||||
|
assertThrows(IllegalStateException.class,owned::destroy);assertFalse(owned.cleanupConfirmed());
|
||||||
|
owned.destroy();assertTrue(owned.cleanupConfirmed());assertEquals(1,removals.get());assertEquals(2,releases.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.agent.runtime.contract.*;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.*;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
|
import vip.mate.llm.service.*;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.sql.*;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** Real Docker daemon, immutable SDK image, model and deliberately orphaned tool grandchildren. */
|
||||||
|
@EnabledIfEnvironmentVariable(named="MATECLAW_DSH_ACCEPTANCE_DOCKER_IMAGE",matches="sha256:[0-9a-f]{64}")
|
||||||
|
@Timeout(value=5,unit=TimeUnit.MINUTES)
|
||||||
|
class DshDockerRuntimeAcceptanceTest {
|
||||||
|
@TempDir Path temp;
|
||||||
|
final ObjectMapper json=new ObjectMapper();
|
||||||
|
DshRuntimeService service;
|
||||||
|
RuntimeSession session;
|
||||||
|
final List<String> containers=new CopyOnWriteArrayList<>();
|
||||||
|
String image;
|
||||||
|
@BeforeEach void setup() throws Exception {
|
||||||
|
image=System.getenv("MATECLAW_DSH_ACCEPTANCE_DOCKER_IMAGE");
|
||||||
|
String key=System.getenv("DEEPSEEK_API_KEY"),url=System.getenv().getOrDefault("DEEPSEEK_BASE_URL","https://api.deepseek.com/anthropic");
|
||||||
|
String database=System.getenv("MATECLAW_DSH_ACCEPTANCE_DATABASE");
|
||||||
|
if(database!=null)try(var c=DriverManager.getConnection("jdbc:h2:file:"+database+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;ACCESS_MODE_DATA=r;IFEXISTS=TRUE","sa","");var s=c.createStatement();var r=s.executeQuery("select api_key,base_url from mate_model_provider where provider_id='deepseek'")) {
|
||||||
|
assertTrue(r.next());key=r.getString(1);url=r.getString(2);
|
||||||
|
}
|
||||||
|
assertTrue(key!=null&&!key.isBlank());
|
||||||
|
String exe=System.getenv("MATECLAW_DSH_ACCEPTANCE_EXECUTABLE");assertNotNull(exe);
|
||||||
|
var config=mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration(exe,"",temp.toString(),url,"deepseek-v4-flash",key,"sdk",List.of(),temp.resolve("homes").toString()));
|
||||||
|
var models=mock(ModelConfigService.class);var model=new ModelConfigEntity();model.setModelName("deepseek-v4-flash");model.setMaxTokens(4096);model.setMaxInputTokens(128000);when(models.resolveModel(any())).thenReturn(model);
|
||||||
|
service=new DshRuntimeService(json,models,mock(ModelProviderService.class),config,new ConversationWindowProperties());
|
||||||
|
service.setProcessSupervisor(new DshProcessSupervisor("docker","docker",json.writeValueAsString(Map.of("0.2.1-rc.2",image)),json));
|
||||||
|
session=new RuntimeSession("docker-test","docker-test-"+UUID.randomUUID(),771L,772L,"deepseek-v4-flash",temp,Map.of());
|
||||||
|
|
||||||
|
}
|
||||||
|
@AfterEach void cleanup() {for(String id:containers)try{docker("rm","--force",id);}catch(Exception ignored){}}
|
||||||
|
String docker(String... args) throws Exception {
|
||||||
|
var command=new ArrayList<>(List.of("docker"));command.addAll(List.of(args));
|
||||||
|
Process p=new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||||
|
try{assertTrue(p.waitFor(20,TimeUnit.SECONDS));String out=new String(p.getInputStream().readAllBytes()).trim();if(p.exitValue()!=0)throw new java.io.IOException(out);return out;}finally{if(p.isAlive())p.destroyForcibly();}
|
||||||
|
}
|
||||||
|
static void await(java.util.function.BooleanSupplier condition,int seconds)throws Exception {
|
||||||
|
long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(seconds);while(!condition.getAsBoolean()&&System.nanoTime()<end)Thread.sleep(50);assertTrue(condition.getAsBoolean(),"bounded Docker acceptance condition failed");
|
||||||
|
}
|
||||||
|
void check(String operation) throws Exception {
|
||||||
|
var c=service.start(session);var events=new CopyOnWriteArrayList<RuntimeEvent>();var error=new AtomicReference<Throwable>();var done=new CountDownLatch(1);
|
||||||
|
String fixture="""
|
||||||
|
function main() {
|
||||||
|
const {spawn}=require('node:child_process'),fs=require('node:fs');
|
||||||
|
if(process.argv[2]==='leaf') {
|
||||||
|
fs.writeFileSync('/tmp/escaped.pid',String(process.pid));
|
||||||
|
fs.appendFileSync('/tmp/heartbeat','x');
|
||||||
|
setInterval(()=>fs.appendFileSync('/tmp/heartbeat','x'),100);
|
||||||
|
} else {
|
||||||
|
const stage=process.argv[2]==='middle'?'leaf':'middle';
|
||||||
|
const child=spawn(process.execPath,[__filename,stage],{detached:true,stdio:'ignore'});
|
||||||
|
child.unref();
|
||||||
|
if(!process.argv[2])setTimeout(()=>{},90000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
||||||
|
""";
|
||||||
|
String command="cd /tmp; node detach.cjs; sleep 90";
|
||||||
|
var sub=c.prompt("Use bash to execute exactly this test fixture command: "+command+". Do not run other commands. Do not use background execution. After the command reply UNEXPECTED.").subscribe(events::add,e->{error.set(e);done.countDown();},done::countDown);
|
||||||
|
try {
|
||||||
|
var ownedId=new AtomicReference<String>();
|
||||||
|
await(()->{try {String found=docker("ps","--no-trunc","--quiet","--filter","label=vip.mateclaw.dsh.jvm="+ProcessHandle.current().pid());
|
||||||
|
if(found.matches("[0-9a-f]{64}")){ownedId.set(found);return true;}return done.getCount()==0;}catch(Exception e){return false;}},30);
|
||||||
|
assertNull(error.get(),()->String.valueOf(error.get()));
|
||||||
|
String id=ownedId.get();assertNotNull(id);containers.add(id);
|
||||||
|
// Install the trusted fixture directly before the model invokes its short Bash
|
||||||
|
// command; no model token budget is spent copying an encoded program.
|
||||||
|
docker("exec",id,"node","-e","require('fs').writeFileSync('/tmp/detach.cjs',Buffer.from('"+Base64.getEncoder().encodeToString(fixture.getBytes(java.nio.charset.StandardCharsets.UTF_8))+"','base64'))");
|
||||||
|
await(()->{try{return "true".equals(docker("exec",id,"node","-e","console.log(require('fs').existsSync('/tmp/heartbeat'))"));}catch(Exception e){return done.getCount()==0;}},120);
|
||||||
|
assertNull(error.get(),()->String.valueOf(error.get()));
|
||||||
|
assertEquals(1L,done.getCount(), ()->"fixture stopped before cancellation: "+events);
|
||||||
|
String pid=docker("exec",id,"node","-e","console.log(require('fs').readFileSync('/tmp/escaped.pid','utf8'))");
|
||||||
|
String parent=docker("exec",id,"node","-e","const fs=require('fs'); console.log(fs.readFileSync('/proc/"+pid+"/status','utf8').split('\\n').find(x=>x.startsWith('PPid:')).trim())");
|
||||||
|
assertEquals("PPid:\t1",parent,"double-fork leaf must already be reparented to PID 1");
|
||||||
|
String detachedSession=docker("exec",id,"node","-e","const fs=require('fs'),s=fs.readFileSync('/proc/"+pid+"/stat','utf8');console.log(s.slice(s.lastIndexOf(')')+2).split(' ')[3])");
|
||||||
|
assertEquals(pid,detachedSession,"detached leaf must lead its independent POSIX session");
|
||||||
|
// Independent sibling container must not be selected by cleanup.
|
||||||
|
String sibling=docker("run","--detach","--network=none","--cap-drop=ALL","--entrypoint","/bin/sleep",image,"120");containers.add(sibling);
|
||||||
|
if("cancel".equals(operation))assertTimeoutPreemptively(Duration.ofSeconds(20),()->c.cancel().block());
|
||||||
|
else if("sdk-crash".equals(operation)) docker("exec",id,"node","-e","const fs=require('fs');const ids=fs.readFileSync('/proc/1/task/1/children','utf8').trim().split(/\\s+/).map(Number).filter(x=>x>1).sort((a,b)=>a-b);if(!ids.length)throw Error('SDK root identity');process.kill(ids[0],'SIGKILL')");
|
||||||
|
else {
|
||||||
|
var cli=ProcessHandle.current().descendants().filter(p->p.info().arguments().map(a->Arrays.asList(a).contains(id)).orElse(false)).toList();assertEquals(1,cli.size());cli.getFirst().destroyForcibly();
|
||||||
|
}
|
||||||
|
assertTrue(done.await(30,TimeUnit.SECONDS));
|
||||||
|
assertFalse(events.stream().anyMatch(e->e.type()==RuntimeEventType.COMPLETED));
|
||||||
|
if("cancel".equals(operation)){assertNull(error.get());assertEquals(1,events.stream().filter(e->e.type()==RuntimeEventType.CANCELLED).count());}else assertNotNull(error.get());
|
||||||
|
await(()->{try{return !docker("ps","--all","--no-trunc","--quiet","--filter","id="+id).contains(id);}catch(Exception e){return false;}},15);
|
||||||
|
// The PID namespace no longer exists: no detached leaf can remain alive.
|
||||||
|
assertFalse(docker("ps","--all","--no-trunc","--quiet","--filter","id="+id).contains(id));
|
||||||
|
assertEquals("true",docker("inspect","--format","{{.State.Running}}",sibling));
|
||||||
|
try(var recovery=service.start(session)){
|
||||||
|
var recovered=recovery.prompt("Return exactly the nine uppercase ASCII characters RECOVERED with no punctuation, whitespace or explanation. Do not call tools.").collectList().block(Duration.ofSeconds(120));assertNotNull(recovered);
|
||||||
|
assertEquals(1,recovered.stream().filter(e->e.type()==RuntimeEventType.COMPLETED).count());
|
||||||
|
assertEquals("RECOVERED",recovered.stream().filter(e->e.type()==RuntimeEventType.ASSISTANT_DELTA).map(RuntimeEvent::text).reduce("",String::concat));
|
||||||
|
}
|
||||||
|
String report=System.getenv("MATECLAW_DSH_ACCEPTANCE_REPORT_DIR");if(report!=null){Path path=Path.of(report);Files.createDirectories(path);json.writerWithDefaultPrettyPrinter().writeValue(path.resolve("docker-"+operation+".json").toFile(),Map.of("success",true,"container",id,"escapedPid",pid,"independentSession",true,"adoptedByPid1",true,"pidNamespaceRemoved",true,"siblingPreserved",true,"image",image,"events",events));}
|
||||||
|
}finally{sub.dispose();c.close();}
|
||||||
|
}
|
||||||
|
@Test void cancelCleansDetachedDoubleForkAndPreservesSibling()throws Exception{check("cancel");}
|
||||||
|
@Test void sdkCrashCleansDetachedDoubleForkAndPreservesSibling()throws Exception{check("sdk-crash");}
|
||||||
|
@Test void attachClientCrashCleansDetachedDoubleForkAndPreservesSibling()throws Exception{check("client-crash");}
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
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 java.io.IOException;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/** Environment acceptance, not a substitute for real SDK/model workspace tasks. */
|
||||||
|
@EnabledIfEnvironmentVariable(named = "MATECLAW_DSH_ACCEPTANCE_DOCKER_IMAGE", matches = "sha256:[0-9a-f]{64}")
|
||||||
|
@Timeout(value = 3, unit = TimeUnit.MINUTES)
|
||||||
|
class DshDockerSharedWorkspaceAcceptanceTest {
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
private static final String PROBE = """
|
||||||
|
import fs from 'node:fs'; import cp from 'node:child_process';
|
||||||
|
const root=process.argv[1],expected=process.argv[2];
|
||||||
|
const m=await import('/opt/mateclaw-dsh/node_modules/@deepseek-ai/node-addon-system/lib/index.js');
|
||||||
|
// Establish that the separate outside tmpfs is writable before confinement.
|
||||||
|
fs.writeFileSync('/outside/baseline','outside');
|
||||||
|
const code=`const fs=require('fs'),root=process.argv[1],expected=process.argv[2];
|
||||||
|
if(fs.readFileSync(root+'/host.txt','utf8')!==expected)throw Error('HOST_INPUT_NOT_VISIBLE');
|
||||||
|
fs.writeFileSync(root+'/container.txt',expected+'-container');
|
||||||
|
for(const path of ['/outside/forbidden',root+'/escape/forbidden']) {
|
||||||
|
try{fs.writeFileSync(path,'bad');throw Error('OUTSIDE_WRITE_ALLOWED')}
|
||||||
|
catch(e){if(e.code!=='EACCES')throw e;}
|
||||||
|
}
|
||||||
|
console.log('SANDBOX_READ_WRITE_AND_DENIAL_OK');`;
|
||||||
|
const p=cp.spawnSync(m.launcherPath(),[
|
||||||
|
...m.grantArgs({readOnly:['/'],readWrite:['/tmp',root]}),
|
||||||
|
'--',process.execPath,'-e',code,root,expected
|
||||||
|
],{encoding:'utf8',timeout:15000});
|
||||||
|
console.log(JSON.stringify({filesystem:fs.statfsSync(root).type.toString(16),
|
||||||
|
exit:p.status,out:p.stdout,error:p.stderr}));
|
||||||
|
process.exit(p.status===0?0:1);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private List<String> daemon() throws Exception {
|
||||||
|
String endpoint=System.getenv("DOCKER_HOST");
|
||||||
|
if(endpoint==null||endpoint.isBlank())endpoint=json.readTree(command(List.of("docker","context","inspect","--format","{{json .Endpoints.docker.Host}}"))).asText();
|
||||||
|
assertTrue(endpoint.startsWith("unix://"),"local Unix Docker endpoint required");
|
||||||
|
return List.of("docker","--host",endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String command(List<String> args) throws Exception {
|
||||||
|
Process p=new ProcessBuilder(args).redirectErrorStream(true).start();
|
||||||
|
try {
|
||||||
|
assertTrue(p.waitFor(30,TimeUnit.SECONDS),"bounded Docker acceptance command");
|
||||||
|
String out=new String(p.getInputStream().readNBytes(65537),java.nio.charset.StandardCharsets.UTF_8).trim();
|
||||||
|
assertTrue(out.length()<=65536,"bounded diagnostic output");
|
||||||
|
if(p.exitValue()!=0)throw new IOException(out);
|
||||||
|
return out;
|
||||||
|
} finally {if(p.isAlive())p.destroyForcibly();}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String docker(List<String> daemon,String... args) throws Exception {
|
||||||
|
var argv=new ArrayList<>(daemon);argv.addAll(List.of(args));return command(argv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mount(Path path) throws IOException {
|
||||||
|
String absolute=path.toRealPath().toString();
|
||||||
|
return "type=bind,\"source="+absolute.replace("\"","\"\"")+"\",\"target="+absolute.replace("\"","\"\"")+"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String probe(List<String> daemon,List<String> mounts,String root,String expected) throws Exception {
|
||||||
|
String image=System.getenv("MATECLAW_DSH_ACCEPTANCE_DOCKER_IMAGE");
|
||||||
|
var identity=new com.sun.security.auth.module.UnixSystem();
|
||||||
|
var args=new ArrayList<>(List.of("create","--pull=never","--label","vip.mateclaw.probe=landlock",
|
||||||
|
"--read-only","--cap-drop=ALL","--security-opt=no-new-privileges",
|
||||||
|
"--user",identity.getUid()+":"+identity.getGid(),
|
||||||
|
"--tmpfs","/tmp:rw,exec,nosuid,nodev,size=128m",
|
||||||
|
"--tmpfs","/outside:rw,nosuid,nodev,size=1m,mode=1777","--entrypoint","node"));
|
||||||
|
args.addAll(mounts);args.addAll(List.of(image,"--input-type=module","-e",PROBE,root,expected));
|
||||||
|
var argv=new ArrayList<>(daemon);argv.addAll(args);
|
||||||
|
String id=command(argv);assertTrue(id.matches("[0-9a-f]{64}"));
|
||||||
|
try {return docker(daemon,"start","--attach",id);}
|
||||||
|
finally {docker(daemon,"rm","--force",id);}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void awaitContent(Path path,String expected) throws Exception {
|
||||||
|
long deadline=System.nanoTime()+Duration.ofSeconds(30).toNanos();
|
||||||
|
while(System.nanoTime()<deadline) {
|
||||||
|
if(Files.exists(path)&&expected.equals(Files.readString(path)))return;
|
||||||
|
Thread.sleep(100);
|
||||||
|
}
|
||||||
|
fail("synchronized host content did not become visible within 30 seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameNativeSandboxAllowsExt4VolumeAndStillRejectsEscape() throws Exception {
|
||||||
|
var daemon=daemon();
|
||||||
|
String volume="mateclaw-landlock-control-"+UUID.randomUUID();
|
||||||
|
String image=System.getenv("MATECLAW_DSH_ACCEPTANCE_DOCKER_IMAGE");
|
||||||
|
docker(daemon,"volume","create","--label","vip.mateclaw.probe=landlock",volume);
|
||||||
|
String binding="type=volume,source="+volume+",target=/workspace";
|
||||||
|
try {
|
||||||
|
docker(daemon,"run","--rm","--user","0:0","--cap-drop=ALL","--security-opt=no-new-privileges",
|
||||||
|
"--mount",binding,"--entrypoint","node",image,"-e",
|
||||||
|
"const fs=require('fs');fs.chmodSync('/workspace',0o777);fs.writeFileSync('/workspace/host.txt','control');fs.symlinkSync('/outside','/workspace/escape')");
|
||||||
|
String result=probe(daemon,List.of("--mount",binding),"/workspace","control");
|
||||||
|
assertTrue(result.contains("SANDBOX_READ_WRITE_AND_DENIAL_OK"),result);
|
||||||
|
assertEquals("control-container",docker(daemon,"run","--rm","--read-only","--cap-drop=ALL",
|
||||||
|
"--mount",binding,"--entrypoint","node",image,"-e",
|
||||||
|
"process.stdout.write(require('fs').readFileSync('/workspace/container.txt','utf8'))"));
|
||||||
|
} finally {docker(daemon,"volume","rm",volume);}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@EnabledIfEnvironmentVariable(named="MATECLAW_DSH_ACCEPTANCE_SHARED_WORKSPACE",matches=".+")
|
||||||
|
void nativeSandboxPreservesBidirectionalSharedWorkspaceAndRejectsEscape() throws Exception {
|
||||||
|
Path parent=Path.of(System.getenv("MATECLAW_DSH_ACCEPTANCE_SHARED_WORKSPACE")).toRealPath();
|
||||||
|
Path root=Files.createTempDirectory(parent,"dsh-landlock-");
|
||||||
|
var daemon=daemon();
|
||||||
|
try {
|
||||||
|
Files.writeString(root.resolve("host.txt"),"first");
|
||||||
|
Files.createSymbolicLink(root.resolve("escape"),Path.of("/outside"));
|
||||||
|
String first=probe(daemon,List.of("--mount",mount(root)),root.toString(),"first");
|
||||||
|
assertTrue(first.contains("SANDBOX_READ_WRITE_AND_DENIAL_OK"),first);
|
||||||
|
awaitContent(root.resolve("container.txt"),"first-container");
|
||||||
|
Files.writeString(root.resolve("host.txt"),"second");
|
||||||
|
String second=probe(daemon,List.of("--mount",mount(root)),root.toString(),"second");
|
||||||
|
assertTrue(second.contains("SANDBOX_READ_WRITE_AND_DENIAL_OK"),second);
|
||||||
|
awaitContent(root.resolve("container.txt"),"second-container");
|
||||||
|
String report=System.getenv("MATECLAW_DSH_ACCEPTANCE_REPORT_DIR");
|
||||||
|
if(report!=null) {
|
||||||
|
Path dir=Path.of(report);Files.createDirectories(dir);
|
||||||
|
json.writerWithDefaultPrettyPrinter().writeValue(dir.resolve("shared-workspace.json").toFile(),
|
||||||
|
Map.of("success",true,"first",json.readTree(first),"second",json.readTree(second),
|
||||||
|
"hostVisible",true,"outsideWriteDenied",true,"symlinkEscapeDenied",true));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Do not follow the deliberately escaping symlink or remove caller-owned files.
|
||||||
|
try(var paths=Files.walk(root)) {
|
||||||
|
for(Path path:paths.sorted(Comparator.reverseOrder()).toList())Files.deleteIfExists(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class DshDockerSupervisorTest {
|
||||||
|
@TempDir Path root;
|
||||||
|
@Test void selectedDockerModeNeverFallsBackToNativeOnMissingImageConfiguration() throws Exception {
|
||||||
|
Path marker=root.resolve("native-started");
|
||||||
|
Path executable=Files.writeString(root.resolve("dsh"),"#!/bin/sh\ntouch '"+marker+"'\n");executable.toFile().setExecutable(true);
|
||||||
|
var launch=new DshLaunchSpec(List.of(executable.toString(),"--profile","sdk"),root.resolve("homes/w/a"),root);
|
||||||
|
var supervisor=new DshProcessSupervisor("docker","/missing/docker","",new ObjectMapper());
|
||||||
|
assertThrows(Exception.class,()->supervisor.start(launch,Map.of()));
|
||||||
|
assertFalse(Files.exists(marker),"strict mode must not invoke native fallback");
|
||||||
|
}
|
||||||
|
@Test void unknownModeIsRejectedRatherThanSilentlyUsingNative() throws Exception {
|
||||||
|
var launch=new DshLaunchSpec(List.of("/bin/sh","-c","exit 0"),root.resolve("homes/w/a"),root);
|
||||||
|
assertThrows(Exception.class,()->new DshProcessSupervisor("typo","docker","",new ObjectMapper()).start(launch,Map.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void strictLaunchPinsDaemonImageAndOwnershipWithoutPuttingCredentialsInArguments() throws Exception {
|
||||||
|
var json=new ObjectMapper();
|
||||||
|
Path pkg=Files.createDirectories(root.resolve("managed"));
|
||||||
|
Files.writeString(pkg.resolve("package.json"),"{\"name\":\"@deepseek-ai/dsh\",\"version\":\"0.2.1-rc.2\"}");
|
||||||
|
Path executable=Files.writeString(pkg.resolve("dsh"),"#!/bin/sh\nexit 99\n");executable.toFile().setExecutable(true);
|
||||||
|
Path calls=root.resolve("calls.jsonl");
|
||||||
|
String id="a".repeat(64),image="sha256:"+"b".repeat(64);
|
||||||
|
Path docker=Files.writeString(root.resolve("docker"),"""
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys,json,os,time
|
||||||
|
a=sys.argv[1:]
|
||||||
|
with open(%s,'a') as f: f.write(json.dumps({'argv':a,'secret':os.getenv('DEEPSEEK_API_KEY')})+'\\n')
|
||||||
|
if a[0]=='context': print('"unix:///test/pinned.sock"')
|
||||||
|
elif 'inspect' in a: print(json.dumps({'vip.mateclaw.dsh.version':'0.2.1-rc.2','vip.mateclaw.dsh.lock-sha256':%s}))
|
||||||
|
elif 'create' in a or 'rm' in a: print(%s)
|
||||||
|
elif 'start' in a: time.sleep(30)
|
||||||
|
""".formatted(json.writeValueAsString(calls.toString()),json.writeValueAsString(DshProcessSupervisor.lockHash("0.2.1-rc.2")),json.writeValueAsString(id)));
|
||||||
|
docker.toFile().setExecutable(true);
|
||||||
|
Path cwd=Files.createDirectories(root.resolve("workspace, with spaces"));
|
||||||
|
Path patch=Files.writeString(root.resolve("patch, one.js"),"// patch");
|
||||||
|
var launch=new DshLaunchSpec(List.of(executable.toString(),"--profile","sdk","--patch",patch.toString()),root.resolve("homes/w/a"),cwd);
|
||||||
|
Process process=new DshProcessSupervisor("docker",docker.toString(),json.writeValueAsString(Map.of("0.2.1-rc.2",image)),json)
|
||||||
|
.start(launch,Map.of("DEEPSEEK_API_KEY","fixture-secret"));
|
||||||
|
process.destroy();
|
||||||
|
var records=Files.readAllLines(calls).stream().map(line->{try{return json.readTree(line);}catch(Exception e){throw new RuntimeException(e);}}).toList();
|
||||||
|
var create=records.stream().filter(r->r.path("argv").toString().contains("\"create\"")).findFirst().orElseThrow();
|
||||||
|
String argv=create.path("argv").toString();
|
||||||
|
assertFalse(argv.contains("fixture-secret"));assertEquals("fixture-secret",create.path("secret").asText());
|
||||||
|
assertTrue(argv.contains("--pull=never"));assertTrue(argv.contains(image));assertTrue(argv.contains("readonly"));
|
||||||
|
assertFalse(argv.contains("--privileged"));assertFalse(argv.contains("--pid=host"));
|
||||||
|
assertTrue(records.stream().filter(r->!r.path("argv").get(0).asText().equals("context"))
|
||||||
|
.allMatch(r->r.path("argv").get(0).asText().equals("--host")&&r.path("argv").get(1).asText().equals("unix:///test/pinned.sock")));
|
||||||
|
assertTrue(records.stream().anyMatch(r->r.path("argv").toString().contains("\"rm\"")&&r.path("argv").toString().contains(id)));
|
||||||
|
}
|
||||||
|
@Test void mutableImageTagIsRejectedBeforeDockerOrSdkStarts() throws Exception {
|
||||||
|
Path pkg=Files.createDirectories(root.resolve("sdk"));
|
||||||
|
Files.writeString(pkg.resolve("package.json"),"{\"name\":\"@deepseek-ai/dsh\",\"version\":\"0.2.1-rc.2\"}");
|
||||||
|
Path executable=Files.writeString(pkg.resolve("dsh"),"#!/bin/sh\nexit 99\n");executable.toFile().setExecutable(true);
|
||||||
|
var launch=new DshLaunchSpec(List.of(executable.toString(),"--profile","sdk"),root.resolve("homes/w/a"),root);
|
||||||
|
var error=assertThrows(Exception.class,()->new DshProcessSupervisor("docker","/missing/docker","{\"0.2.1-rc.2\":\"runtime:latest\"}",new ObjectMapper()).start(launch,Map.of()));
|
||||||
|
assertEquals("DSH_DOCKER_IMMUTABLE_IMAGE_REQUIRED",error.getMessage());
|
||||||
|
}
|
||||||
|
@Test void mismatchedImageMetadataCannotReachCreate() throws Exception {
|
||||||
|
Path pkg=Files.createDirectories(root.resolve("sdk"));
|
||||||
|
Files.writeString(pkg.resolve("package.json"),"{\"name\":\"@deepseek-ai/dsh\",\"version\":\"0.2.1-rc.2\"}");
|
||||||
|
Path executable=Files.writeString(pkg.resolve("dsh"),"#!/bin/sh\nexit 99\n");executable.toFile().setExecutable(true);
|
||||||
|
Path marker=root.resolve("create-started");
|
||||||
|
Path docker=Files.writeString(root.resolve("docker"),"""
|
||||||
|
#!/bin/sh
|
||||||
|
case "$*" in
|
||||||
|
*context*) printf '%%s\\n' '"unix:///test/pinned.sock"';;
|
||||||
|
*inspect*) printf '%%s\\n' '{"vip.mateclaw.dsh.version":"0.2.1-rc.2","vip.mateclaw.dsh.lock-sha256":"wrong"}';;
|
||||||
|
*) touch '%s'; exit 1;;
|
||||||
|
esac
|
||||||
|
""".formatted(marker));
|
||||||
|
docker.toFile().setExecutable(true);
|
||||||
|
var launch=new DshLaunchSpec(List.of(executable.toString(),"--profile","sdk"),root.resolve("homes/w/a"),root);
|
||||||
|
String images=new ObjectMapper().writeValueAsString(Map.of("0.2.1-rc.2","sha256:"+"a".repeat(64)));
|
||||||
|
var error=assertThrows(Exception.class,()->new DshProcessSupervisor("docker",docker.toString(),images,new ObjectMapper()).start(launch,Map.of()));
|
||||||
|
assertEquals("DSH_DOCKER_IMAGE_PROVENANCE_MISMATCH",error.getMessage());assertFalse(Files.exists(marker));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import vip.mate.agent.runtime.contract.*;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.*;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
import vip.mate.llm.service.*;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class DshRuntimeTeardownTest {
|
||||||
|
@TempDir Path temp;
|
||||||
|
@Test void cleanupFailureCannotPublishCompleted() throws Exception { check(false); }
|
||||||
|
@Test void cleanupFailureCannotPublishCancelled() throws Exception { check(true); }
|
||||||
|
@Test void failedLeaseReleaseCannotPublishCompletedAndRemainsRetryable() throws Exception { check(false,true); }
|
||||||
|
void check(boolean cancel) throws Exception { check(cancel,false); }
|
||||||
|
void check(boolean cancel, boolean bookkeepingFailure) throws Exception {
|
||||||
|
Path script=temp.resolve("sdk");
|
||||||
|
Files.writeString(script, """
|
||||||
|
#!/bin/sh
|
||||||
|
IFS= read -r initialize
|
||||||
|
printf '%s\\n' "$initialize" > initialize.json
|
||||||
|
id=$(printf '%s' "$initialize" | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p')
|
||||||
|
printf '{"jsonrpc":"2.0","id":"%s","result":{"serverInfo":{"name":"deepseek-harness-sdk-runtime"}}}\\n' "$id"
|
||||||
|
IFS= read -r prompt
|
||||||
|
sleep 1
|
||||||
|
session=$(printf '%s' "$prompt" | sed -n 's/.*"sessionId":"\\([^"]*\\)".*/\\1/p')
|
||||||
|
printf '%s\\n' '{"jsonrpc":"2.0","id":"prompt-limit-test","result":{"messageId":"m1"}}'
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"%s","event":{"seq":1,"type":"agent/inbox/spliced","data":{"inserted":[{"id":"m1"}]}}}}\\n' "$session"
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session.event","params":{"sessionId":"%s","event":{"seq":2,"type":"turn/end","data":{"reason":{"kind":"completed"}}}}}\\n' "$session"
|
||||||
|
printf '{"jsonrpc":"2.0","method":"session.status","params":{"sessionId":"%s","status":"idle"}}\\n' "$session"
|
||||||
|
IFS= read -r shutdown
|
||||||
|
""");
|
||||||
|
|
||||||
|
script.toFile().setExecutable(true);
|
||||||
|
var config=mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration(script.toString(),"",temp.toString(),"","model","","sdk",List.of(),temp.resolve("homes").toString()));
|
||||||
|
var service=new DshRuntimeService(new ObjectMapper(),mock(ModelConfigService.class),mock(ModelProviderService.class),config,new ConversationWindowProperties());
|
||||||
|
var supervisor=mock(DshProcessSupervisor.class);
|
||||||
|
var failRemoval=new AtomicBoolean(true);
|
||||||
|
var releases=new AtomicInteger();
|
||||||
|
var store=mock(DshGenerationStore.class);var lease=mock(DshGenerationStore.Lease.class);
|
||||||
|
when(config.generations()).thenReturn(store);when(store.acquireLease(anyString(),anyString())).thenReturn(lease);
|
||||||
|
doAnswer(invocation->{if(bookkeepingFailure && failRemoval.get())throw new IllegalStateException("store unavailable");releases.incrementAndGet();return null;}).when(lease).close();
|
||||||
|
var raw=new AtomicReference<Process>();
|
||||||
|
when(supervisor.start(any(),any())).thenAnswer(invocation->{
|
||||||
|
Process p=new ProcessBuilder(script.toString()).directory(temp.toFile()).start();raw.set(p);
|
||||||
|
return new DshDockerProcess(p,()->{if(!bookkeepingFailure && failRemoval.get())throw new Exception("daemon unavailable");return "id";},"id");
|
||||||
|
});
|
||||||
|
service.setProcessSupervisor(supervisor);
|
||||||
|
var connection=service.start(new RuntimeSession("test","limit-test",1L,2L,"model",temp,Map.of()));
|
||||||
|
var events=new CopyOnWriteArrayList<RuntimeEvent>();var error=new AtomicReference<Throwable>();
|
||||||
|
var ready=new CountDownLatch(1);var done=new CountDownLatch(1);
|
||||||
|
connection.prompt("fixture").subscribe(e->{events.add(e);if(e.type()==RuntimeEventType.RUNTIME_READY)ready.countDown();},e->{error.set(e);done.countDown();},done::countDown);
|
||||||
|
try {
|
||||||
|
assertTrue(ready.await(5,TimeUnit.SECONDS));
|
||||||
|
if(cancel)assertThrows(RuntimeException.class,()->connection.cancel().block(Duration.ofSeconds(3)));
|
||||||
|
assertTrue(done.await(8,TimeUnit.SECONDS));assertNotNull(error.get());
|
||||||
|
assertTrue(error.get().getMessage().contains("DSH_DOCKER_CLEANUP_UNCONFIRMED"));
|
||||||
|
assertFalse(events.stream().anyMatch(e->e.type()==RuntimeEventType.COMPLETED||e.type()==RuntimeEventType.CANCELLED));
|
||||||
|
} finally {failRemoval.set(false);connection.cancel().block();if(raw.get()!=null)raw.get().destroyForcibly();}
|
||||||
|
assertEquals(1,releases.get(),"lease must release once after confirmed cleanup/retry");
|
||||||
|
}
|
||||||
|
@Test void healthCleanupFailureRetainsLeaseAndNextCheckCanRetryRemoval() throws Exception {
|
||||||
|
var configuration=new DshRuntimeConfiguration("/bin/sh","",temp.toString(),"","model","","sdk",List.of(),temp.resolve("homes").toString());
|
||||||
|
var config=mock(DshRuntimeConfigService.class);when(config.resolve()).thenReturn(configuration);
|
||||||
|
var service=new DshRuntimeService(new ObjectMapper(),mock(ModelConfigService.class),mock(ModelProviderService.class),config,new ConversationWindowProperties());
|
||||||
|
var p=mock(Process.class);when(p.onExit()).thenReturn(new CompletableFuture<>());
|
||||||
|
when(p.getInputStream()).thenReturn(new java.io.ByteArrayInputStream(new byte[0]));
|
||||||
|
when(p.getErrorStream()).thenReturn(new java.io.ByteArrayInputStream(new byte[0]));
|
||||||
|
when(p.getOutputStream()).thenReturn(new java.io.ByteArrayOutputStream());
|
||||||
|
var fail=new AtomicBoolean(true);var releases=new AtomicInteger();
|
||||||
|
var owned=new DshDockerProcess(p,()->{if(fail.get())throw new Exception("daemon down");return "id";},"id");
|
||||||
|
var supervisor=mock(DshProcessSupervisor.class);when(supervisor.start(any(),any())).thenReturn(owned);service.setProcessSupervisor(supervisor);
|
||||||
|
assertEquals(false,service.testConnection(configuration).get("success"));
|
||||||
|
service.releaseHealthLease(releases::incrementAndGet);assertEquals(0,releases.get());
|
||||||
|
assertThrows(IllegalStateException.class,service::retryPendingHealthCleanup);assertEquals(0,releases.get());
|
||||||
|
fail.set(false);service.retryPendingHealthCleanup();assertEquals(1,releases.get());
|
||||||
|
service.retryPendingHealthCleanup();assertEquals(1,releases.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user