From a8a0b75bfaf14316e8551f37f3d1a1caa7ee9a13 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 19 Aug 2026 04:30:06 -0400 Subject: [PATCH] fix(dsh): complete cancellable runtime stream lifecycle; make JSON-RPC streaming asynchronous; terminate process trees and inherited pipes; handle cancellation races; propagate usage telemetry; add regression coverage --- .../agent/runtime/dsh/DshRuntimeService.java | 234 ++++++++++++++---- .../dsh/management/DshArtifactInstaller.java | 200 +++++++++++++++ .../dsh/management/DshArtifactManifest.java | 13 + .../management/DshManagementController.java | 55 ++++ .../dsh/management/DshManagementService.java | 130 ++++++++++ .../dsh/management/DshManagementState.java | 21 ++ .../management/DshRuntimeConfigResolver.java | 55 ++++ .../management/DshRuntimeConfigService.java | 112 +++++++++ .../management/DshRuntimeConfiguration.java | 25 ++ .../mate/channel/web/ChatStreamTracker.java | 28 ++- .../system/service/SystemSettingService.java | 10 +- .../src/main/resources/application.yml | 13 +- .../runtime/dsh/DshRuntimeServiceTest.java | 114 +++++++++ .../management/DshManagementStateTest.java | 24 ++ .../DshRuntimeConfigResolverTest.java | 70 ++++++ .../ChatStreamTrackerOrphanPolicyTest.java | 16 ++ mateclaw-ui/src/api/index.ts | 11 + mateclaw-ui/src/i18n/locales/en-US.ts | 1 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 1 + mateclaw-ui/src/router/index.ts | 6 + mateclaw-ui/src/views/Settings/Dsh/index.vue | 113 +++++++++ mateclaw-ui/src/views/Settings/Layout.vue | 6 + 22 files changed, 1201 insertions(+), 57 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactInstaller.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactManifest.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementState.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolver.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfiguration.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshRuntimeServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementStateTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolverTest.java create mode 100644 mateclaw-ui/src/views/Settings/Dsh/index.vue diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java index 25d660cb..5c3c8f80 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java @@ -3,9 +3,9 @@ package vip.mate.agent.runtime.dsh; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.runtime.RuntimeEventProjector; import vip.mate.agent.runtime.contract.RuntimeEvent; @@ -16,6 +16,8 @@ import vip.mate.agent.runtime.contract.AgentRuntimeProvider; import vip.mate.agent.runtime.contract.RuntimeCapabilities; import vip.mate.agent.runtime.contract.RuntimeContextUsage; import vip.mate.agent.runtime.contract.RuntimeValidation; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration; import vip.mate.agent.AgentService; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelProviderEntity; @@ -34,6 +36,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; /** * Adapter for the official DeepSeek Harness SDK JSON-RPC runtime. @@ -48,26 +51,30 @@ public class DshRuntimeService implements AgentRuntimeProvider { private final ObjectMapper objectMapper; private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; - private final String runtimeCommand; - private final String cordisConfig; + private final DshRuntimeConfigService runtimeConfigService; public DshRuntimeService( ObjectMapper objectMapper, ModelConfigService modelConfigService, ModelProviderService modelProviderService, - @Value("${mateclaw.agent.runtime.dsh.command:}") String configuredCommand, - @Value("${mateclaw.agent.runtime.dsh.cordis-config:}") String configuredCordisConfig) { + DshRuntimeConfigService runtimeConfigService) { this.objectMapper = objectMapper; this.modelConfigService = modelConfigService; this.modelProviderService = modelProviderService; - this.runtimeCommand = configuredCommand == null || configuredCommand.isBlank() - ? System.getenv().getOrDefault("DSH_JSONRPC_AGENT", "dsh-jsonrpc-agent") - : configuredCommand.trim(); - this.cordisConfig = resolveCordisConfig(configuredCordisConfig == null || configuredCordisConfig.isBlank() - ? System.getenv().getOrDefault("DSH_CORDIS_CONFIG", "") - : configuredCordisConfig.trim()); - log.info("[DSH] runtime configured: command={}, cordisConfig={}", runtimeCommand, - cordisConfig.isBlank() ? "" : cordisConfig); + this.runtimeConfigService = runtimeConfigService; + DshRuntimeConfiguration configuration = runtimeConfig(); + log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(), + configuration.cordisConfigPath().isBlank() ? "" : configuration.cordisConfigPath()); + } + + private DshRuntimeConfiguration runtimeConfig() { + DshRuntimeConfiguration raw = runtimeConfigService.resolve(); + String command = raw.executablePath(); + if (command == null || command.isBlank()) command = "dsh-jsonrpc-agent"; + String cordis = resolveCordisConfig(raw.cordisConfigPath()); + String cwd = raw.workingDirectory(); + if (cwd == null || cwd.isBlank()) cwd = System.getProperty("user.dir"); + return new DshRuntimeConfiguration(command, cordis, cwd, raw.baseUrl(), raw.modelName(), raw.apiKey()); } private String resolveCordisConfig(String configuredPath) { @@ -90,20 +97,21 @@ public class DshRuntimeService implements AgentRuntimeProvider { @Override public RuntimeValidation validate(RuntimeSession session) { + DshRuntimeConfiguration configuration = runtimeConfig(); if (session == null || session.workspaceId() == null) { return RuntimeValidation.invalid("dsh.workspace_required", "DSH runtime requires a workspace"); } if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) { return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable"); } - if (runtimeCommand.isBlank()) { + if (configuration.executablePath().isBlank()) { return RuntimeValidation.invalid("dsh.command_missing", "DSH runtime command is not configured"); } - Path executable = Path.of(commandLine().get(0)); + Path executable = Path.of(commandLine(configuration.executablePath()).get(0)); if (!executable.isAbsolute() || !Files.isExecutable(executable)) { return RuntimeValidation.invalid("dsh.command_unavailable", "DSH runtime command is not executable"); } - if (!cordisConfig.isBlank() && !Files.isRegularFile(Path.of(cordisConfig))) { + if (!configuration.cordisConfigPath().isBlank() && !Files.isRegularFile(Path.of(configuration.cordisConfigPath()))) { return RuntimeValidation.invalid("dsh.cordis_missing", "DSH Cordis configuration is unavailable"); } return RuntimeValidation.success(); @@ -115,15 +123,18 @@ public class DshRuntimeService implements AgentRuntimeProvider { } public Map diagnostics() { - Path executable = runtimeCommand.isBlank() ? null : Path.of(commandLine().get(0)); + DshRuntimeConfiguration configuration = runtimeConfig(); + Path executable = configuration.executablePath().isBlank() ? null : Path.of(commandLine(configuration.executablePath()).get(0)); return Map.of( "type", type(), - "commandConfigured", !runtimeCommand.isBlank(), - "command", runtimeCommand, + "commandConfigured", !configuration.executablePath().isBlank(), + "command", configuration.executablePath(), "executable", executable == null ? "" : executable.toString(), "executableAvailable", executable != null && Files.isExecutable(executable), - "cordisConfig", cordisConfig, - "cordisConfigAvailable", !cordisConfig.isBlank() && Files.isRegularFile(Path.of(cordisConfig)), + "cordisConfig", configuration.cordisConfigPath(), + "cordisConfigAvailable", !configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())), + "workingDirectory", configuration.workingDirectory(), + "apiKeyConfigured", configuration.apiKey() != null && !configuration.apiKey().isBlank(), "capabilities", Map.of( "cancellation", true, "approvals", false, @@ -155,21 +166,26 @@ public class DshRuntimeService implements AgentRuntimeProvider { agent.setId(session.agentId()); agent.setWorkspaceId(session.workspaceId()); agent.setModelName(session.modelName()); + AtomicReference activeProcess = new AtomicReference<>(); + AtomicReference latestUsage = new AtomicReference<>( + new RuntimeContextUsage(0, 0, 0)); return new AgentRuntimeConnection() { @Override public Flux prompt(String message) { - return stream(agent, message, session.conversationId(), session.modelName()) + return stream(agent, message, session.conversationId(), session.modelName(), + session.workingDirectory(), activeProcess, latestUsage) .map(DshRuntimeService.this::toRuntimeEvent); } @Override public reactor.core.publisher.Mono cancel() { - return reactor.core.publisher.Mono.empty(); + return reactor.core.publisher.Mono.fromRunnable( + () -> cancelProcess(activeProcess.get())); } @Override public reactor.core.publisher.Mono contextUsage() { - return reactor.core.publisher.Mono.just(new RuntimeContextUsage(0, 0, 0)); + return reactor.core.publisher.Mono.just(latestUsage.get()); } }; } @@ -198,31 +214,45 @@ public class DshRuntimeService implements AgentRuntimeProvider { public Flux stream(AgentEntity agent, String message, String conversationId, String modelName) { - return Flux.create(sink -> { + DshRuntimeConfiguration configuration = runtimeConfig(); + return stream(agent, message, conversationId, modelName, + resolveWorkingDirectory(null, configuration), new AtomicReference<>(), + new AtomicReference<>(new RuntimeContextUsage(0, 0, 0))); + } + + private Flux stream(AgentEntity agent, String message, + String conversationId, String modelName, + Path workingDirectory, + AtomicReference processRef, + AtomicReference latestUsage) { + return Flux.create(sink -> { Process process = null; try { + if (sink.isCancelled()) return; + DshRuntimeConfiguration configuration = runtimeConfig(); RuntimeSession session = new RuntimeSession( conversationId, conversationId, agent.getId(), agent.getWorkspaceId(), modelName, - Path.of(System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir"))), + workingDirectory, Map.of()); // Each prompt runs in a fresh child process. DSH persists its // own session log, so reusing the MateClaw conversation id // would make the next turn look like a conflicting live session. String dshSessionId = conversationId + "-" + UUID.randomUUID(); Files.createDirectories(session.workingDirectory()); - ModelProviderEntity provider = resolveProvider(modelName); - String effectiveModelName = resolveModelName(modelName); + String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName; + ModelProviderEntity provider = resolveProvider(requestedModel); + String effectiveModelName = resolveModelName(requestedModel); log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}", modelName == null || modelName.isBlank() ? "" : modelName, effectiveModelName, provider == null ? "" : provider.getProviderId(), provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(), provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank()); - List command = commandLine(); + List command = commandLine(configuration.executablePath()); ProcessBuilder builder = new ProcessBuilder(command) .directory(session.workingDirectory().toFile()) .redirectError(ProcessBuilder.Redirect.PIPE); @@ -230,33 +260,40 @@ public class DshRuntimeService implements AgentRuntimeProvider { // The packaged binary gives the environment variable precedence // over argv. Set the resolved path explicitly so IDEA/.env // inheritance cannot select a different composition. - if (!cordisConfig.isBlank()) { - builder.environment().put("DSH_CORDIS_CONFIG", cordisConfig); + if (!configuration.cordisConfigPath().isBlank()) { + builder.environment().put("DSH_CORDIS_CONFIG", configuration.cordisConfigPath()); } else { builder.environment().remove("DSH_CORDIS_CONFIG"); } log.debug("[DSH] child environment: cordisConfig={}, exists={}", builder.environment().getOrDefault("DSH_CORDIS_CONFIG", ""), - !cordisConfig.isBlank() && Files.isRegularFile(Path.of(cordisConfig))); - if (provider != null) { - if (provider.getApiKey() != null && !provider.getApiKey().isBlank()) { - builder.environment().put("DEEPSEEK_API_KEY", provider.getApiKey()); - } - if (provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank()) { - builder.environment().put("DEEPSEEK_BASE_URL", provider.getBaseUrl()); - } + !configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath()))); + String apiKey = configuration.apiKey(); + if ((apiKey == null || apiKey.isBlank()) && provider != null) apiKey = provider.getApiKey(); + if (apiKey != null && !apiKey.isBlank()) { + builder.environment().put("DEEPSEEK_API_KEY", apiKey); + } + String baseUrl = configuration.baseUrl(); + if ((baseUrl == null || baseUrl.isBlank()) && provider != null) baseUrl = provider.getBaseUrl(); + if (baseUrl != null && !baseUrl.isBlank()) { + builder.environment().put("DEEPSEEK_BASE_URL", baseUrl); } process = builder.start(); - Process activeProcess = process; - Thread stderrLogger = new Thread(() -> logProcessStderr(activeProcess), + processRef.set(process); + if (sink.isCancelled()) { + cancelProcess(process); + return; + } + Process startedProcess = process; + Thread stderrLogger = new Thread(() -> logProcessStderr(startedProcess), "dsh-runtime-stderr-" + conversationId); stderrLogger.setDaemon(true); stderrLogger.start(); - sink.onCancel(() -> activeProcess.destroyForcibly()); + sink.onCancel(() -> cancelProcess(startedProcess)); try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( - activeProcess.getOutputStream(), StandardCharsets.UTF_8)); + process.getOutputStream(), StandardCharsets.UTF_8)); BufferedReader reader = new BufferedReader(new InputStreamReader( - activeProcess.getInputStream(), StandardCharsets.UTF_8))) { + process.getInputStream(), StandardCharsets.UTF_8))) { send(writer, request("initialize", "init-" + conversationId, Map.of( "cwd", session.workingDirectory().toString(), "provider", "deepseek-official", @@ -265,7 +302,7 @@ public class DshRuntimeService implements AgentRuntimeProvider { long sequence = 0; sink.next(RuntimeEventProjector.project(RuntimeEvent.of( conversationId, sequence++, RuntimeEventType.RUNTIME_READY, null, - Map.of("runtimeProvider", "dsh", "runtimeCommand", runtimeCommand)))); + Map.of("runtimeProvider", "dsh", "runtimeCommand", configuration.executablePath())))); String promptId = "prompt-" + conversationId; send(writer, request("session/prompt", promptId, Map.of( @@ -305,6 +342,9 @@ public class DshRuntimeService implements AgentRuntimeProvider { logTerminalReason(event); RuntimeEvent mapped = mapEvent(conversationId, sequence++, event); if (mapped != null) { + if (mapped.type() == RuntimeEventType.CONTEXT_USAGE) { + latestUsage.set(usageFrom(mapped)); + } sink.next(RuntimeEventProjector.project(mapped)); terminal = mapped.terminal(); } @@ -318,7 +358,7 @@ public class DshRuntimeService implements AgentRuntimeProvider { } } if (!terminal) { - int exitCode = activeProcess.waitFor(); + int exitCode = process.waitFor(); sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal( conversationId, sequence, RuntimeEventType.FAILED, Map.of("error", "DSH runtime closed before completion (exit=" + exitCode + ")")))); @@ -328,8 +368,54 @@ public class DshRuntimeService implements AgentRuntimeProvider { } catch (Exception error) { sink.error(new IllegalStateException("DSH runtime unavailable: " + error.getMessage(), error)); if (process != null) process.destroyForcibly(); + } finally { + if (process != null) processRef.compareAndSet(process, null); } - }); + }).subscribeOn(Schedulers.boundedElastic()); + } + + static Path resolveWorkingDirectory(RuntimeSession session, DshRuntimeConfiguration configuration) { + if (session != null && session.workingDirectory() != null) { + return session.workingDirectory().toAbsolutePath().normalize(); + } + return Path.of(configuration.workingDirectory()).toAbsolutePath().normalize(); + } + + static void cancelProcess(Process process) { + if (process == null || !process.isAlive()) return; + + // DSH tools can spawn commands such as `sleep` that inherit the + // JSON-RPC process' stdout pipe. Close the pipes and terminate the + // descendants first; otherwise the parent may die while readLine() + // remains blocked until the child exits naturally. + try { + var descendants = process.descendants(); + if (descendants != null) { + descendants.toList().forEach(DshRuntimeService::cancelProcessHandle); + } + } catch (Exception ignored) { + // The parent teardown below is still the best-effort fallback. + } + closeQuietly(process.getInputStream()); + closeQuietly(process.getErrorStream()); + closeQuietly(process.getOutputStream()); + process.destroy(); + if (process.isAlive()) process.destroyForcibly(); + } + + private static void cancelProcessHandle(ProcessHandle process) { + if (process == null || !process.isAlive()) return; + process.destroy(); + if (process.isAlive()) process.destroyForcibly(); + } + + private static void closeQuietly(java.io.Closeable stream) { + if (stream == null) return; + try { + stream.close(); + } catch (Exception ignored) { + // Cancellation is best effort; the process termination is authoritative. + } } private void logProcessStderr(Process process) { @@ -344,10 +430,34 @@ public class DshRuntimeService implements AgentRuntimeProvider { } } - private List commandLine() { - String[] parts = runtimeCommand.trim().split("\\s+"); + static List commandLine(String commandLine) { List result = new ArrayList<>(); - for (String part : parts) if (!part.isBlank()) result.add(part); + StringBuilder token = new StringBuilder(); + char quote = 0; + boolean escaped = false; + for (char current : commandLine == null ? "".toCharArray() : commandLine.toCharArray()) { + if (escaped) { + token.append(current); + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (quote != 0) { + if (current == quote) quote = 0; + else token.append(current); + } else if (current == '\'' || current == '"') { + quote = current; + } else if (Character.isWhitespace(current)) { + if (!token.isEmpty()) { + result.add(token.toString()); + token.setLength(0); + } + } else { + token.append(current); + } + } + if (escaped) token.append('\\'); + if (quote != 0) throw new IllegalArgumentException("DSH runtime command has an unterminated quote"); + if (!token.isEmpty()) result.add(token.toString()); if (result.isEmpty()) throw new IllegalStateException("DSH runtime command is empty"); log.debug("[DSH] launching command: {}", result); return result; @@ -386,11 +496,22 @@ public class DshRuntimeService implements AgentRuntimeProvider { return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName; } - private RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) { + RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) { String type = event.path("type").asText(""); JsonNode data = event.path("data"); if ("assistant/chunk".equals(type)) { JsonNode chunk = data.has("chunk") ? data.path("chunk") : data; + if ("usage".equals(chunk.path("type").asText())) { + JsonNode usage = chunk.path("usage"); + long inputTokens = usage.path("inputTokens").asLong(0); + long outputTokens = usage.path("outputTokens").asLong(0); + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.CONTEXT_USAGE, + null, Map.of( + "promptTokens", inputTokens, + "completionTokens", outputTokens, + "inputTokens", inputTokens, + "outputTokens", outputTokens)); + } String text = firstText(chunk, data); if (text != null && !text.isEmpty()) { RuntimeEventType eventType = "reasoning-delta".equals(chunk.path("type").asText()) @@ -432,6 +553,17 @@ public class DshRuntimeService implements AgentRuntimeProvider { return null; } + private RuntimeContextUsage usageFrom(RuntimeEvent event) { + return new RuntimeContextUsage( + number(event.data().get("inputTokens")), + number(event.data().get("outputTokens")), + number(event.data().get("contextWindow"))); + } + + private long number(Object value) { + return value instanceof Number number ? Math.max(0, number.longValue()) : 0; + } + private String firstText(JsonNode primary, JsonNode fallback) { String text = primary.path("text").asText(null); if (text != null) return text; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactInstaller.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactInstaller.java new file mode 100644 index 00000000..58a87642 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactInstaller.java @@ -0,0 +1,200 @@ +package vip.mate.agent.runtime.dsh.management; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** Downloads and atomically installs the server-selected DSH artifact. */ +@Service +public class DshArtifactInstaller { + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final URI manifestUri; + private final URI githubReleaseUri; + private final Path installRoot; + + public DshArtifactInstaller( + ObjectMapper objectMapper, + @Value("${mateclaw.agent.runtime.dsh.manifest-url:}") String manifestUrl, + @Value("${mateclaw.agent.runtime.dsh.github-release-url:https://api.github.com/repos/deepseek-ai/deepseek-harness/releases/latest}") String githubReleaseUrl, + @Value("${mateclaw.agent.runtime.dsh.install-root:${user.home}/.mateclaw/runtimes/deepseek-harness}") String installRoot) { + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build(); + this.manifestUri = manifestUrl == null || manifestUrl.isBlank() ? null : URI.create(manifestUrl.trim()); + this.githubReleaseUri = URI.create(githubReleaseUrl.trim()); + this.installRoot = Path.of(installRoot).toAbsolutePath().normalize(); + } + + public boolean isInstalled() { + return Files.isExecutable(installRoot.resolve("dsh-jsonrpc-agent")) + || Files.isExecutable(installRoot.resolve("bin/dsh-jsonrpc-agent")); + } + + public boolean manifestConfigured() { + return manifestUri != null || githubReleaseUri != null; + } + + public boolean privateManifestConfigured() { + return manifestUri != null; + } + + public Path installedExecutable() { + Path direct = installRoot.resolve("dsh-jsonrpc-agent"); + return Files.isExecutable(direct) ? direct : installRoot.resolve("bin/dsh-jsonrpc-agent"); + } + + public Path installedCordisConfig() { + if (!Files.exists(installRoot)) return null; + try (var paths = Files.walk(installRoot)) { + return paths.filter(path -> path.getFileName().toString().equals("cordis.yml")) + .findFirst().orElse(null); + } catch (Exception ignored) { + return null; + } + } + + public DshArtifactManifest loadManifest() throws Exception { + if (manifestUri != null) { + HttpRequest request = HttpRequest.newBuilder(manifestUri).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 == 2) return objectMapper.readValue(response.body(), DshArtifactManifest.class); + } + return loadGithubManifest(); + } + + private DshArtifactManifest loadGithubManifest() throws Exception { + HttpRequest request = HttpRequest.newBuilder(githubReleaseUri) + .header("Accept", "application/vnd.github+json").GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH private manifest unavailable and GitHub fallback failed: HTTP " + response.statusCode()); + JsonNode root = objectMapper.readTree(response.body()); + for (JsonNode asset : root.path("assets")) { + String name = asset.path("name").asText("").toLowerCase(); + String digest = asset.path("digest").asText(""); + if ((name.contains("macos") || name.contains("darwin")) && name.contains("arm64") && digest.startsWith("sha256:")) { + return new DshArtifactManifest("deepseek-harness", root.path("tag_name").asText("latest"), "macos-arm64", + asset.path("browser_download_url").asText(), digest.substring("sha256:".length()), asset.path("size").asLong(0), null); + } + } + throw new IllegalStateException("GitHub DSH release has no macos-arm64 asset with a SHA-256 digest"); + } + + public Path install(DshArtifactManifest manifest) throws Exception { + validateManifest(manifest); + Path parent = installRoot.getParent(); + Files.createDirectories(parent); + Path archive = Files.createTempFile(parent, ".dsh-download-", ".tar.gz"); + Path staging = Files.createTempDirectory(parent, ".dsh-staging-"); + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(manifest.downloadUrl())).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH artifact request failed: HTTP " + response.statusCode()); + try (InputStream input = response.body()) { + Files.copy(input, archive, StandardCopyOption.REPLACE_EXISTING); + } + if (manifest.size() > 0 && Files.size(archive) != manifest.size()) { + throw new IllegalStateException("DSH artifact size mismatch"); + } + verifyChecksum(archive, manifest.sha256()); + verifyArchiveEntries(archive); + runTar(archive, staging); + verifyExtractedTree(staging); + Path executable = findExecutable(staging); + Path executableRelativePath = staging.relativize(executable); + executable.toFile().setExecutable(true, false); + Path backup = parent.resolve(".dsh-previous"); + if (Files.exists(installRoot)) Files.move(installRoot, backup, StandardCopyOption.REPLACE_EXISTING); + Files.move(staging, installRoot, StandardCopyOption.ATOMIC_MOVE); + Files.deleteIfExists(backup); + return installRoot.resolve(executableRelativePath); + } finally { + Files.deleteIfExists(archive); + deleteTree(staging); + } + } + + private void validateManifest(DshArtifactManifest manifest) { + if (manifest == null || manifest.downloadUrl() == null || manifest.downloadUrl().isBlank() + || manifest.sha256() == null || !manifest.sha256().matches("[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("DSH artifact manifest is incomplete or has an invalid checksum"); + } + URI uri = URI.create(manifest.downloadUrl()); + if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("DSH artifact must use HTTPS"); + } + + private void verifyChecksum(Path archive, String expected) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(archive)) { + input.transferTo(new java.security.DigestOutputStream(OutputStreamDiscard.INSTANCE, digest)); + } + String actual = HexFormat.of().formatHex(digest.digest()); + if (!actual.equalsIgnoreCase(expected)) throw new IllegalStateException("DSH artifact checksum mismatch"); + } + + private void verifyArchiveEntries(Path archive) throws Exception { + Process process = new ProcessBuilder("tar", "-tzf", archive.toString()).redirectErrorStream(true).start(); + List entries; + try (InputStream input = process.getInputStream()) { + entries = new String(input.readAllBytes(), StandardCharsets.UTF_8).lines().toList(); + } + if (!process.waitFor(30, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive is not a readable tar.gz"); + for (String entry : entries) { + Path normalized = Path.of(entry).normalize(); + if (entry.startsWith("/") || normalized.startsWith("..")) throw new IllegalArgumentException("DSH archive contains an unsafe path"); + } + } + + private void runTar(Path archive, Path destination) throws Exception { + Process process = new ProcessBuilder("tar", "-xzf", archive.toString(), "-C", destination.toString()).redirectErrorStream(true).start(); + String output; + try (InputStream input = process.getInputStream()) { output = new String(input.readAllBytes(), StandardCharsets.UTF_8); } + if (!process.waitFor(60, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive extraction failed: " + output); + } + + private Path findExecutable(Path staging) throws Exception { + try (var paths = Files.walk(staging)) { + return paths.filter(path -> path.getFileName().toString().equals("dsh-jsonrpc-agent")) + .findFirst().orElseThrow(() -> new IllegalStateException("DSH artifact has no dsh-jsonrpc-agent executable")); + } + } + + private void verifyExtractedTree(Path staging) throws Exception { + try (var paths = Files.walk(staging)) { + for (Path path : paths.toList()) { + if (!Files.isSymbolicLink(path)) continue; + Path target = path.getParent().resolve(Files.readSymbolicLink(path)).normalize(); + if (!target.startsWith(staging)) throw new IllegalArgumentException("DSH archive contains a link outside its staging directory"); + } + } + } + + private void deleteTree(Path root) throws Exception { + if (root == null || !Files.exists(root)) return; + try (var paths = Files.walk(root)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(path -> { + try { Files.deleteIfExists(path); } catch (Exception ignored) { } + }); + } + } + + private static final class OutputStreamDiscard extends java.io.OutputStream { + private static final OutputStreamDiscard INSTANCE = new OutputStreamDiscard(); + @Override public void write(int b) { } + @Override public void write(byte[] b, int off, int len) { } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactManifest.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactManifest.java new file mode 100644 index 00000000..f8410fa7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactManifest.java @@ -0,0 +1,13 @@ +package vip.mate.agent.runtime.dsh.management; + +import java.time.Instant; + +public record DshArtifactManifest( + String name, + String version, + String platform, + String downloadUrl, + String sha256, + long size, + Instant releasedAt) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementController.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementController.java new file mode 100644 index 00000000..01b4eb82 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementController.java @@ -0,0 +1,55 @@ +package vip.mate.agent.runtime.dsh.management; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; + +import java.util.Map; + +@Tag(name = "DeepSeek Harness Runtime Management") +@RestController +@RequestMapping("/api/v1/admin/dsh") +@RequiredArgsConstructor +public class DshManagementController { + private final DshManagementService managementService; + + @Operation(summary = "Get managed DSH runtime status") + @GetMapping("/status") + @RequireGlobalAdmin + public R> status() { return R.ok(managementService.status()); } + + @Operation(summary = "Save managed DSH runtime configuration") + @PutMapping("/config") + @RequireGlobalAdmin + public R> saveConfig(@RequestBody Map values) { + return R.ok(managementService.saveConfig(values)); + } + + @Operation(summary = "Install the server-selected DSH artifact") + @PostMapping("/install") + @RequireGlobalAdmin + public R> install() throws Exception { return R.ok(managementService.install()); } + + @Operation(summary = "Verify DSH runtime configuration") + @PostMapping("/verify") + @RequireGlobalAdmin + public R> verify() { return R.ok(managementService.verify()); } + + @Operation(summary = "Test starting the DSH process") + @PostMapping("/test-connection") + @RequireGlobalAdmin + public R> testConnection() { return R.ok(managementService.testConnection()); } + + @Operation(summary = "Enable managed DSH runtime") + @PostMapping("/enable") + @RequireGlobalAdmin + public R> enable() { return R.ok(managementService.enable()); } + + @Operation(summary = "Disable managed DSH runtime") + @PostMapping("/disable") + @RequireGlobalAdmin + public R> disable() { return R.ok(managementService.disable()); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java new file mode 100644 index 00000000..d48e1cf4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java @@ -0,0 +1,130 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.springframework.stereotype.Service; +import vip.mate.system.service.SystemSettingService; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Service +public class DshManagementService { + private static final String ENABLED_KEY = "dsh.enabled"; + + private final DshRuntimeConfigService configService; + private final DshArtifactInstaller installer; + private final SystemSettingService settings; + + public DshManagementService(DshRuntimeConfigService configService, + DshArtifactInstaller installer, + SystemSettingService settings) { + this.configService = configService; + this.installer = installer; + this.settings = settings; + } + + public Map status() { + DshRuntimeConfiguration configuration = configService.resolve(); + boolean executableAvailable = isExecutable(configuration.executablePath()); + boolean workingDirectoryAvailable = configuration.workingDirectory() != null + && Files.isDirectory(Path.of(configuration.workingDirectory())); + boolean cordisAvailable = configuration.cordisConfigPath() == null + || configuration.cordisConfigPath().isBlank() + || Files.isRegularFile(Path.of(configuration.cordisConfigPath())); + // An empty managed key is valid: DshRuntimeService can reuse the + // existing DeepSeek provider key. The page may still store a managed + // key when the operator wants DSH to be independent from model rows. + boolean enabled = settings.getBool(ENABLED_KEY, false); + DshManagementState state; + if (!executableAvailable) state = DshManagementState.NOT_INSTALLED; + else if (!workingDirectoryAvailable || !cordisAvailable) state = DshManagementState.CONFIG_INVALID; + else if (enabled) state = DshManagementState.ENABLED; + else state = DshManagementState.READY; + + Map result = new LinkedHashMap<>(); + result.put("state", state.name()); + result.put("installed", executableAvailable); + result.put("enabled", enabled); + result.put("config", configuration.publicStatus()); + result.put("managed", configService.managedValues()); + result.put("artifactManifestConfigured", installer.manifestConfigured()); + result.put("privateArtifactManifestConfigured", installer.privateManifestConfigured()); + result.put("checkedAt", Instant.now().toString()); + return result; + } + + public Map saveConfig(Map values) { + configService.save(values); + return status(); + } + + public Map install() throws Exception { + DshArtifactManifest manifest = installer.loadManifest(); + Path executable = installer.install(manifest); + Map installed = new LinkedHashMap<>(); + installed.put("dsh.executable_path", executable.toString()); + Path cordis = installer.installedCordisConfig(); + if (cordis != null) installed.put("dsh.cordis_config_path", cordis.toString()); + configService.save(installed); + return status(); + } + + public Map verify() { + Map result = status(); + boolean ok = "ENABLED".equals(result.get("state")) || "READY".equals(result.get("state")); + result.put("verified", ok); + result.put("verificationMessage", ok ? "DSH executable and configuration are available" : "DSH executable or configuration is unavailable"); + return result; + } + + public Map testConnection() { + DshRuntimeConfiguration configuration = configService.resolve(); + if (!isExecutable(configuration.executablePath())) return Map.of("success", false, "message", "DSH executable is unavailable"); + if (configuration.cordisConfigPath() == null || configuration.cordisConfigPath().isBlank()) { + return Map.of("success", false, "message", "DSH Cordis configuration is unavailable"); + } + try { + ProcessBuilder builder = new ProcessBuilder(configuration.executablePath(), configuration.cordisConfigPath()) + .directory(Path.of(configuration.workingDirectory()).toFile()) + .redirectErrorStream(true); + builder.environment().put("DSH_CWD", configuration.workingDirectory()); + builder.environment().put("DSH_CORDIS_CONFIG", configuration.cordisConfigPath()); + if (configuration.apiKey() != null && !configuration.apiKey().isBlank()) { + builder.environment().put("DEEPSEEK_API_KEY", configuration.apiKey()); + } + if (configuration.baseUrl() != null && !configuration.baseUrl().isBlank()) { + builder.environment().put("DEEPSEEK_BASE_URL", configuration.baseUrl()); + } + Process process = builder.start(); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return Map.of("success", true, "message", "DSH process started"); + } + String output = new String(process.getInputStream().readAllBytes()); + if (process.exitValue() != 0) throw new IllegalStateException(output.isBlank() ? "DSH process exited with code " + process.exitValue() : output.trim()); + return Map.of("success", true, "message", output.trim()); + } catch (Exception error) { + return Map.of("success", false, "message", "DSH connection test failed: " + error.getMessage()); + } + } + + public Map enable() { + Map current = verify(); + if (!Boolean.TRUE.equals(current.get("verified"))) throw new IllegalStateException("DSH must pass verification before enabling"); + settings.saveBool(ENABLED_KEY, true, "Enable managed DeepSeek Harness runtime"); + return status(); + } + + public Map disable() { + settings.saveBool(ENABLED_KEY, false, "Enable managed DeepSeek Harness runtime"); + return status(); + } + + private boolean isExecutable(String path) { + return path != null && !path.isBlank() && Files.isExecutable(Path.of(path)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementState.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementState.java new file mode 100644 index 00000000..2710634c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementState.java @@ -0,0 +1,21 @@ +package vip.mate.agent.runtime.dsh.management; + +/** Lifecycle states exposed by the DSH runtime management screen. */ +public enum DshManagementState { + NOT_INSTALLED, + INSTALLING, + INSTALLED_UNCONFIGURED, + CONFIG_INVALID, + CHECKING, + CHECK_FAILED, + READY, + ENABLED; + + public boolean canEnable() { + return this == READY; + } + + public boolean isOperational() { + return this == ENABLED; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolver.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolver.java new file mode 100644 index 00000000..8cc710a9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolver.java @@ -0,0 +1,55 @@ +package vip.mate.agent.runtime.dsh.management; + +import java.util.Map; + +/** Resolves managed settings first, then application properties, then legacy environment variables. */ +public final class DshRuntimeConfigResolver { + + private DshRuntimeConfigResolver() { + } + + public static DshRuntimeConfiguration resolve( + Map managed, + Map properties, + Map environment) { + return new DshRuntimeConfiguration( + firstNonBlank(managed, properties, environment, + "dsh.executable_path", "mateclaw.agent.runtime.dsh.command", "DSH_JSONRPC_AGENT"), + firstNonBlank(managed, properties, environment, + "dsh.cordis_config_path", "mateclaw.agent.runtime.dsh.cordis-config", "DSH_CORDIS_CONFIG"), + firstNonBlank(managed, properties, environment, + "dsh.working_directory", "mateclaw.agent.runtime.dsh.working-directory", "DSH_CWD"), + firstNonBlank(managed, properties, environment, + "dsh.base_url", "mateclaw.agent.runtime.dsh.base-url", "DEEPSEEK_BASE_URL"), + firstNonBlank(managed, properties, environment, + "dsh.model_name", "mateclaw.agent.runtime.dsh.model-name", "DEEPSEEK_MODEL"), + firstNonBlank(managed, properties, environment, + "dsh.api_key", "mateclaw.agent.runtime.dsh.api-key", "DEEPSEEK_API_KEY")); + } + + private static String firstNonBlank( + Map managed, + Map properties, + Map environment, + String managedKey, + String propertyKey, + String environmentKey) { + String value = value(managed, managedKey); + if (value != null) { + return value; + } + value = value(properties, propertyKey); + if (value != null) { + return value; + } + return value(environment, environmentKey); + } + + private static String value(Map values, String key) { + if (values == null) { + return null; + } + String value = values.get(key); + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigService.java new file mode 100644 index 00000000..904218cd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigService.java @@ -0,0 +1,112 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import vip.mate.system.service.SystemSettingService; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Reads the current DSH configuration without requiring a backend restart. */ +@Service +public class DshRuntimeConfigService { + private static final String[] MANAGED_KEYS = { + "dsh.executable_path", "dsh.cordis_config_path", "dsh.working_directory", + "dsh.base_url", "dsh.model_name", SystemSettingService.DSH_API_KEY_KEY + }; + + private final SystemSettingService settings; + private final Map properties; + + public DshRuntimeConfigService( + SystemSettingService settings, + @Value("${mateclaw.agent.runtime.dsh.command:}") String command, + @Value("${mateclaw.agent.runtime.dsh.cordis-config:}") String cordisConfig, + @Value("${mateclaw.agent.runtime.dsh.working-directory:}") String workingDirectory, + @Value("${mateclaw.agent.runtime.dsh.base-url:}") String baseUrl, + @Value("${mateclaw.agent.runtime.dsh.model-name:}") String modelName, + @Value("${mateclaw.agent.runtime.dsh.api-key:}") String apiKey) { + this.settings = settings; + this.properties = Map.of( + "mateclaw.agent.runtime.dsh.command", command, + "mateclaw.agent.runtime.dsh.cordis-config", cordisConfig, + "mateclaw.agent.runtime.dsh.working-directory", workingDirectory, + "mateclaw.agent.runtime.dsh.base-url", baseUrl, + "mateclaw.agent.runtime.dsh.model-name", modelName, + "mateclaw.agent.runtime.dsh.api-key", apiKey); + } + + public DshRuntimeConfiguration resolve() { + Map managed = new LinkedHashMap<>(); + for (String key : MANAGED_KEYS) { + String defaultValue = key.equals("dsh.working_directory") ? "" : null; + managed.put(key, settings.getString(key, defaultValue)); + } + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(managed, properties, System.getenv()); + String workingDirectory = resolved.workingDirectory(); + if (workingDirectory == null || workingDirectory.isBlank()) workingDirectory = System.getProperty("user.dir"); + String cordisConfig = normalizeCordisConfig(resolved.cordisConfigPath()); + if (cordisConfig.isBlank()) cordisConfig = discoverCordisConfig(resolved.executablePath()); + return new DshRuntimeConfiguration(resolved.executablePath(), cordisConfig, workingDirectory, + resolved.baseUrl(), resolved.modelName(), resolved.apiKey()); + } + + private String discoverCordisConfig(String executable) { + if (executable == null || executable.isBlank()) return ""; + Path binary = Path.of(executable.split("\\s+")[0]).toAbsolutePath().normalize(); + Path packageRoot = binary.getParent(); + if (packageRoot == null) return ""; + Path[] candidates = { + packageRoot.resolve("runtime/cordis.yml"), + packageRoot.resolve("../runtime/cordis.yml").normalize(), + packageRoot.resolve("../examples/jsonrpc-agent/cordis.yml").normalize(), + packageRoot.resolve("../../examples/jsonrpc-agent/cordis.yml").normalize() + }; + for (Path candidate : candidates) if (Files.isRegularFile(candidate)) return candidate.toString(); + return ""; + } + + private String normalizeCordisConfig(String configured) { + if (configured == null || configured.isBlank()) return ""; + Path path = Path.of(configured).toAbsolutePath().normalize(); + if (Files.isRegularFile(path)) return path.toString(); + Path packageDirectory = Files.isDirectory(path) ? path : path.getParent(); + if (packageDirectory == null) return path.toString(); + Path packagedConfig = packageDirectory.resolve("runtime/cordis.yml"); + return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString(); + } + + public Map managedValues() { + Map values = new LinkedHashMap<>(); + for (String key : MANAGED_KEYS) { + String value = settings.getString(key, ""); + if (SystemSettingService.DSH_API_KEY_KEY.equals(key)) { + values.put(key, settings.maskSecret(value)); + } else { + values.put(key, value == null ? "" : value); + } + } + return values; + } + + public void save(Map values) { + if (values == null) return; + save(values, "dsh.executable_path"); + save(values, "dsh.cordis_config_path"); + save(values, "dsh.working_directory"); + save(values, "dsh.base_url"); + save(values, "dsh.model_name"); + String apiKey = values.get(SystemSettingService.DSH_API_KEY_KEY); + if (apiKey != null && !apiKey.isBlank() && !apiKey.startsWith("****")) { + settings.saveString(SystemSettingService.DSH_API_KEY_KEY, apiKey.trim(), "DeepSeek API key for DSH"); + } + } + + private void save(Map values, String key) { + if (values.containsKey(key)) { + settings.saveString(key, values.get(key), "Managed DeepSeek Harness runtime setting"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfiguration.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfiguration.java new file mode 100644 index 00000000..56c88aa1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfiguration.java @@ -0,0 +1,25 @@ +package vip.mate.agent.runtime.dsh.management; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Resolved DSH settings. The API key is deliberately omitted from public projections. */ +public record DshRuntimeConfiguration( + String executablePath, + String cordisConfigPath, + String workingDirectory, + String baseUrl, + String modelName, + String apiKey) { + + public Map publicStatus() { + Map status = new LinkedHashMap<>(); + status.put("executablePath", executablePath); + status.put("cordisConfigPath", cordisConfigPath); + status.put("workingDirectory", workingDirectory); + status.put("baseUrl", baseUrl); + status.put("modelName", modelName); + status.put("apiKeyConfigured", apiKey != null && !apiKey.isBlank()); + return status; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index 7925c12d..1c81a55d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -544,17 +544,41 @@ public class ChatStreamTracker { */ public void setDisposable(String conversationId, Disposable disposable) { RunState state = runs.get(conversationId); - if (state != null) { + if (state == null || disposable == null) return; + boolean disposeImmediately; + synchronized (state.lock) { + if (!isCurrent(state)) return; state.disposable = disposable; + // Stop can win before the asynchronous SSE setup has subscribed + // and registered its Disposable. Do not let that late subscription + // escape the cancellation request. + disposeImmediately = state.done || state.stopRequested.get(); + } + if (disposeImmediately) { + disposeSafely(conversationId, disposable); } } public void setDisposable(RunHandle handle, Disposable disposable) { - if (handle == null) return; + if (handle == null || disposable == null) return; RunState state = handle.state; + boolean disposeImmediately; synchronized (state.lock) { if (!isCurrent(state)) return; state.disposable = disposable; + disposeImmediately = state.done || state.stopRequested.get(); + } + if (disposeImmediately) { + disposeSafely(state.conversationId, disposable); + } + } + + private void disposeSafely(String conversationId, Disposable disposable) { + try { + disposable.dispose(); + } catch (Exception e) { + log.warn("Late stream disposable cancellation failed for {}: {}", + conversationId, e.getMessage()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 4d87f387..c1624da0 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -99,6 +99,9 @@ public class SystemSettingService { */ private static final String WORKSPACE_STORAGE_ROOT_KEY = "workspace.storage_root"; + /** Managed DeepSeek Harness runtime configuration. */ + public static final String DSH_API_KEY_KEY = "dsh.api_key"; + private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey"; private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl"; private static final String FAL_API_KEY_KEY = "falApiKey"; @@ -116,7 +119,7 @@ public class SystemSettingService { private static final Set SENSITIVE_KEYS = Set.of( SERPER_API_KEY_KEY, TAVILY_API_KEY_KEY, WEIXINOA_APP_SECRET_KEY, ZHIPU_API_KEY_KEY, FAL_API_KEY_KEY, KLING_ACCESS_KEY_KEY, KLING_SECRET_KEY_KEY, - RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY); + RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY, DSH_API_KEY_KEY); private final SystemSettingMapper systemSettingMapper; private final SearchProviderRegistry searchProviderRegistry; @@ -656,6 +659,11 @@ public class SystemSettingService { saveValue(key, value, description); } + /** Return a masked representation suitable for an admin status response. */ + public String maskSecret(String value) { + return maskApiKey(value); + } + private String getValue(String key, String defaultValue) { SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper() .eq(SystemSettingEntity::getSettingKey, key) diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 7665c408..504a6604 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -135,9 +135,16 @@ mateclaw: # outside the Spring classpath and can be supplied by environment variables. agent: runtime: - dsh: - command: ${DSH_JSONRPC_AGENT:} - cordis-config: ${DSH_CORDIS_CONFIG:} + dsh: + command: ${DSH_JSONRPC_AGENT:} + cordis-config: ${DSH_CORDIS_CONFIG:} + working-directory: ${DSH_CWD:} + base-url: ${DEEPSEEK_BASE_URL:} + model-name: ${DEEPSEEK_MODEL:} + api-key: ${DEEPSEEK_API_KEY:} + manifest-url: ${DSH_MANIFEST_URL:} + github-release-url: ${DSH_GITHUB_RELEASE_URL:https://api.github.com/repos/deepseek-ai/deepseek-harness/releases/latest} + install-root: ${DSH_INSTALL_ROOT:${user.home}/.mateclaw/runtimes/deepseek-harness} server: # Public base URL used to build absolute download links for tool-generated # files (e.g. https://mateclaw.example.com). Leave empty to fall back to the diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshRuntimeServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshRuntimeServiceTest.java new file mode 100644 index 00000000..59247abe --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshRuntimeServiceTest.java @@ -0,0 +1,114 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import reactor.core.Disposable; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; +import vip.mate.agent.runtime.contract.RuntimeSession; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.llm.service.ModelProviderService; + +import java.nio.file.Path; +import java.util.Map; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTimeout; + +class DshRuntimeServiceTest { + + @Test + void sessionWorkingDirectoryWinsOverGlobalRuntimeDirectory() { + RuntimeSession session = new RuntimeSession( + "session-1", "conversation-1", 1L, 2L, "model", + Path.of("/workspace/agent"), Map.of()); + DshRuntimeConfiguration configuration = new DshRuntimeConfiguration( + "/bin/dsh", "", "/workspace/global", "", "", ""); + + assertEquals(Path.of("/workspace/agent"), + DshRuntimeService.resolveWorkingDirectory(session, configuration)); + } + + @Test + void usageChunkBecomesContextUsageEvent() throws Exception { + DshRuntimeService service = service(); + RuntimeEvent event = service.mapEvent("session-1", 7, + new ObjectMapper().readTree(""" + { + "type":"assistant/chunk", + "data":{"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":45}}} + } + """)); + + assertEquals(RuntimeEventType.CONTEXT_USAGE, event.type()); + assertEquals(123L, ((Number) event.data().get("promptTokens")).longValue()); + assertEquals(45L, ((Number) event.data().get("completionTokens")).longValue()); + assertEquals(123L, ((Number) event.data().get("inputTokens")).longValue()); + assertEquals(45L, ((Number) event.data().get("outputTokens")).longValue()); + } + + @Test + void cancelProcessDestroysLiveProcess() { + Process process = Mockito.mock(Process.class); + Mockito.when(process.isAlive()).thenReturn(true); + + DshRuntimeService.cancelProcess(process); + + Mockito.verify(process).destroy(); + Mockito.verify(process).destroyForcibly(); + } + + @Test + void cancelProcessStopsChildHoldingParentPipes() throws Exception { + Process process = new ProcessBuilder("sh", "-c", "sleep 30").start(); + try { + DshRuntimeService.cancelProcess(process); + assertTrue(process.waitFor(2, TimeUnit.SECONDS), "parent process should stop promptly"); + assertTrue(process.exitValue() != 0 || !process.isAlive()); + } finally { + if (process.isAlive()) process.destroyForcibly(); + } + } + + @Test + void streamSubscriptionReturnsBeforeSynchronousDshReadLoopFinishes() { + DshRuntimeService service = service("/bin/sh -c \"sleep 5\""); + AgentEntity agent = new AgentEntity(); + agent.setId(1L); + agent.setWorkspaceId(2L); + agent.setModelName("model"); + + Disposable subscription = assertTimeout(Duration.ofSeconds(2), () -> + service.stream(agent, "hello", "conversation", "model").subscribe()); + assertFalse(subscription.isDisposed()); + subscription.dispose(); + } + + @Test + void commandLineKeepsQuotedExecutablePathTogether() { + assertEquals(List.of("/opt/Deep Seek/dsh-jsonrpc-agent", "--stdio"), + DshRuntimeService.commandLine("\"/opt/Deep Seek/dsh-jsonrpc-agent\" --stdio")); + } + + private static DshRuntimeService service() { + return service("/bin/dsh"); + } + + private static DshRuntimeService service(String executable) { + DshRuntimeConfigService config = Mockito.mock(DshRuntimeConfigService.class); + Mockito.when(config.resolve()).thenReturn(new DshRuntimeConfiguration( + executable, "", "/tmp", "", "model", "")); + return new DshRuntimeService(new ObjectMapper(), + Mockito.mock(ModelConfigService.class), + Mockito.mock(ModelProviderService.class), config); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementStateTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementStateTest.java new file mode 100644 index 00000000..64f38b01 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementStateTest.java @@ -0,0 +1,24 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DshManagementStateTest { + + @Test + void runtimeCannotBeEnabledBeforeVerificationIsReady() { + assertFalse(DshManagementState.CONFIG_INVALID.canEnable()); + assertFalse(DshManagementState.CHECK_FAILED.canEnable()); + assertTrue(DshManagementState.READY.canEnable()); + } + + @Test + void installationAndVerificationStatesAreNotConfusedWithEnabled() { + assertFalse(DshManagementState.NOT_INSTALLED.isOperational()); + assertFalse(DshManagementState.INSTALLED_UNCONFIGURED.isOperational()); + assertFalse(DshManagementState.READY.isOperational()); + assertTrue(DshManagementState.ENABLED.isOperational()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolverTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolverTest.java new file mode 100644 index 00000000..45656a62 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolverTest.java @@ -0,0 +1,70 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DshRuntimeConfigResolverTest { + + @Test + void databaseValuesOverrideEnvironmentFallback() { + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve( + Map.of( + "dsh.executable_path", "/managed/dsh-agent", + "dsh.cordis_config_path", "/managed/cordis.yml", + "dsh.working_directory", "/managed/workspace", + "dsh.base_url", "https://managed.example.com", + "dsh.model_name", "managed-model", + "dsh.api_key", "managed-secret"), + Map.of( + "mateclaw.agent.runtime.dsh.command", "/legacy/dsh-agent", + "mateclaw.agent.runtime.dsh.cordis-config", "/legacy/cordis.yml"), + Map.of( + "DSH_JSONRPC_AGENT", "/env/dsh-agent", + "DSH_CORDIS_CONFIG", "/env/cordis.yml", + "DSH_CWD", "/env/workspace")); + + assertEquals("/managed/dsh-agent", resolved.executablePath()); + assertEquals("/managed/cordis.yml", resolved.cordisConfigPath()); + assertEquals("/managed/workspace", resolved.workingDirectory()); + assertEquals("https://managed.example.com", resolved.baseUrl()); + assertEquals("managed-model", resolved.modelName()); + assertEquals("managed-secret", resolved.apiKey()); + } + + @Test + void blankDatabaseValuesFallBackToPropertiesThenEnvironment() { + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve( + Map.of( + "dsh.executable_path", "", + "dsh.cordis_config_path", "", + "dsh.working_directory", ""), + Map.of( + "mateclaw.agent.runtime.dsh.command", "/properties/dsh-agent", + "mateclaw.agent.runtime.dsh.cordis-config", "/properties/cordis.yml"), + Map.of( + "DSH_JSONRPC_AGENT", "/env/dsh-agent", + "DSH_CORDIS_CONFIG", "/env/cordis.yml", + "DSH_CWD", "/env/workspace")); + + assertEquals("/properties/dsh-agent", resolved.executablePath()); + assertEquals("/properties/cordis.yml", resolved.cordisConfigPath()); + assertEquals("/env/workspace", resolved.workingDirectory()); + } + + @Test + void apiKeyIsExcludedFromPublicStatusProjection() { + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve( + Map.of("dsh.api_key", "super-secret"), Map.of(), Map.of()); + + Map status = resolved.publicStatus(); + + assertEquals(true, status.get("apiKeyConfigured")); + assertFalse(status.containsKey("apiKey")); + assertNull(status.get("apiKey")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java index 56d47f4e..d52bf1c6 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java @@ -376,6 +376,22 @@ class ChatStreamTrackerOrphanPolicyTest { assertFalse(staleLateDisposable.isDisposed()); } + @Test + @DisplayName("A disposable registered after Stop is requested is cancelled immediately") + void lateDisposableIsCancelledWhenStopWonTheRace() { + ChatStreamTracker tracker = newTracker(); + String cid = "late-stop-disposable"; + ChatStreamTracker.RunHandle handle = tracker.register(cid); + tracker.incrementFlux(cid); + + assertTrue(tracker.requestStop(cid)); + + RecordingDisposable lateDisposable = new RecordingDisposable(); + tracker.setDisposable(handle, lateDisposable); + + assertTrue(lateDisposable.isDisposed()); + } + @Test @DisplayName("A throwing disposable cannot leave an evicting tombstone mapped") void throwingDisposableStillRemovesClaimedState() { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f25584d6..8ae34460 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -754,6 +754,17 @@ export const settingsApi = { getSearchProviders: () => http.get('/settings/search-providers'), } +// ==================== DeepSeek Harness runtime ==================== +export const dshApi = { + status: () => http.get('/admin/dsh/status'), + saveConfig: (data: Record) => http.put('/admin/dsh/config', data), + install: () => http.post('/admin/dsh/install'), + verify: () => http.post('/admin/dsh/verify'), + testConnection: () => http.post('/admin/dsh/test-connection'), + enable: () => http.post('/admin/dsh/enable'), + disable: () => http.post('/admin/dsh/disable'), +} + // ==================== Global outbound proxy ==================== export const proxyApi = { get: () => http.get('/settings/proxy'), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 77a9f9d1..6959f062 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -715,6 +715,7 @@ export default { core: 'Core', connect: 'Connect', system: 'System', + dsh: 'DeepSeek Harness', datasources: 'Datasources', mcpServers: 'MCP Connections', mcpConnections: 'MCP Connections', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 566fc967..ea9c3bbe 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -706,6 +706,7 @@ export default { core: '核心', connect: '连接', system: '系统', + dsh: 'DeepSeek Harness', agent: '智能体', workspace: '工作区', agentContext: '智能体上下文', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 2c59abd0..7aeb0fee 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -149,6 +149,12 @@ const router = createRouter({ component: () => import('@/views/Settings/System/index.vue'), meta: { title: 'Settings - System', requiredCapability: 'manage:settings' }, }, + { + path: 'dsh', + name: 'SettingsDsh', + component: () => import('@/views/Settings/Dsh/index.vue'), + meta: { title: 'Settings - DeepSeek Harness', requiredCapability: 'manage:settings' }, + }, { path: 'image', name: 'SettingsImage', diff --git a/mateclaw-ui/src/views/Settings/Dsh/index.vue b/mateclaw-ui/src/views/Settings/Dsh/index.vue new file mode 100644 index 00000000..49c2f3c0 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Dsh/index.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/mateclaw-ui/src/views/Settings/Layout.vue b/mateclaw-ui/src/views/Settings/Layout.vue index c0870df8..40114f4d 100644 --- a/mateclaw-ui/src/views/Settings/Layout.vue +++ b/mateclaw-ui/src/views/Settings/Layout.vue @@ -97,6 +97,12 @@ const sections = computed(() => [ label: t('settings.sections.system'), icon: '', }, + { + id: 'dsh', + path: '/settings/dsh', + label: t('settings.sections.dsh', 'DeepSeek Harness'), + icon: '', + }, { id: 'image', path: '/settings/image',