From 80ce19645ea93ab4d91a72fd4095ef454415a5df Mon Sep 17 00:00:00 2001
From: LiangshouX
Date: Tue, 27 Oct 2026 19:56:45 +0800
Subject: [PATCH] fix(dsh): mount the environment gateway under its own service
key
TypertRemoteService registers itself under the constructor key, so
`super(ctx, remeEnv)` collided with the already-provided `remeEnv`
manager: the gateway plugin failed activation (its async rejection was
logged only), leaving every /api/remeEnv/* call with HTTP 404.
Register the service as `remeEnvGateway` and keep the wire namespace
`remeEnv`, which is what the client calls. Add a real-cordis regression
test that mounts the gateway inside the same isolated realm and asserts
the binding, the namespace, and the SRC-parsed `input` parameter.
Also show the interpreter hint on detection failure so an empty list
never leaves the tab without guidance.
---
integrations/dsh/src/client/env-tab.tsx | 3 +-
integrations/dsh/src/env-gateway.ts | 6 +-
integrations/dsh/tests/env-gateway.test.mjs | 92 +++++++++++++++++++++
3 files changed, 99 insertions(+), 2 deletions(-)
create mode 100644 integrations/dsh/tests/env-gateway.test.mjs
diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/client/env-tab.tsx
index b9359924..18a3efe4 100644
--- a/integrations/dsh/src/client/env-tab.tsx
+++ b/integrations/dsh/src/client/env-tab.tsx
@@ -992,7 +992,8 @@ export function ReMeEnvironmentTab({
{detection.error ? `: ${detection.error}` : ""}
) : null}
- {detected && detected.envs.length === 0 ? (
+ {detection.phase === "error" ||
+ (detected && detected.envs.length === 0) ? (
{t("emptyTitle")}
{t("emptyHint")}
diff --git a/integrations/dsh/src/env-gateway.ts b/integrations/dsh/src/env-gateway.ts
index 30fd0def..562fc015 100644
--- a/integrations/dsh/src/env-gateway.ts
+++ b/integrations/dsh/src/env-gateway.ts
@@ -31,7 +31,11 @@ export class ReMeEnvGateway extends TypertRemoteService {
static inject = ["remeEnv"];
constructor(ctx: Context) {
- super(ctx, "remeEnv");
+ // The service key must differ from the manager registered as `remeEnv`:
+ // TypertRemoteService registers itself under this key, and two providers
+ // for one key fail plugin activation. The wire namespace stays `remeEnv`,
+ // which is the endpoint the client calls.
+ super(ctx, "remeEnvGateway", { namespace: "remeEnv" });
}
/** Detect local Python interpreters; cached briefly unless refreshed. */
diff --git a/integrations/dsh/tests/env-gateway.test.mjs b/integrations/dsh/tests/env-gateway.test.mjs
new file mode 100644
index 00000000..ad01f1ca
--- /dev/null
+++ b/integrations/dsh/tests/env-gateway.test.mjs
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Context } from "@deepseek-ai/cordis";
+
+import { ReMeEnvGateway } from "../dist/env-gateway.js";
+
+/** Manager stub: no subprocesses, just enough surface for the gateway. */
+function stubManager() {
+ return {
+ detectPython: async () => ({
+ platform: "win32",
+ scannedAt: "",
+ envs: [],
+ suggested: "",
+ condaRoot: "",
+ errors: [],
+ }),
+ checkReme: async (pythonPath) => ({
+ installed: false,
+ version: "",
+ versionOk: false,
+ hasCore: false,
+ cliPath: "",
+ error: pythonPath ? "" : "Python interpreter path must be absolute",
+ }),
+ };
+}
+
+/** Mount the gateway inside the same isolated realm the patch creates. */
+async function mounted() {
+ const root = new Context();
+ let realm;
+ await root.plugin((ctx) => {
+ realm = ctx.isolate({ remeMemory: true });
+ });
+ await realm.plugin((ctx) => {
+ ctx.provide("remeMemory", {});
+ ctx.provide("remeEnv", stubManager());
+ });
+ await realm.plugin(ReMeEnvGateway);
+ return { root, realm };
+}
+
+test("gateway mounts without a service-key conflict and keeps the wire namespace", async () => {
+ const { root } = await mounted();
+ const receiver = root.reflect.get("remeEnvGateway", false);
+ assert.ok(receiver, "gateway service is registered under its own key");
+ assert.ok(receiver.typertRemote, "gateway exposes a typertRemote binding");
+ assert.equal(receiver.typertRemote.serviceKey, "remeEnvGateway");
+ assert.equal(receiver.typertRemote.namespace, "remeEnv");
+ // The manager must stay reachable under the key the methods inject.
+ assert.ok(root.reflect.get("remeEnv", false));
+});
+
+test("SRC discovery exposes remeEnv/python with one plain input parameter", async () => {
+ const { root } = await mounted();
+ let binding;
+ for (const [serviceKey, definition] of Object.entries(root.reflect.props)) {
+ if (definition.type !== "service") continue;
+ const receiver = root.reflect.get(serviceKey, false);
+ if (!receiver?.typertRemote) continue;
+ if (receiver.typertRemote.namespace !== "remeEnv") continue;
+ binding = { serviceKey, receiver };
+ }
+ assert.ok(binding, "an active service exports the remeEnv namespace");
+ assert.equal(binding.serviceKey, "remeEnvGateway");
+
+ const source = Function.prototype.toString.call(
+ Object.getPrototypeOf(binding.receiver).python,
+ );
+ const open = source.indexOf("(");
+ const close = source.indexOf(")", open + 1);
+ const parameters = source
+ .slice(open + 1, close)
+ .split(",")
+ .map((part) => part.trim())
+ .filter(Boolean);
+ // The Gateway parses this source, so defaults, destructuring, and rest
+ // parameters would make the endpoint call fail at runtime.
+ assert.deepEqual(parameters, ["input"]);
+
+ const result = await binding.receiver.python({ refresh: true });
+ assert.equal(result.platform, "win32");
+});
+
+test("gateway methods remain callable through the manager after remount", async () => {
+ const { root } = await mounted();
+ const receiver = root.reflect.get("remeEnvGateway", false);
+ const check = await receiver.checkReme({ pythonPath: "" });
+ assert.equal(check.installed, false);
+ assert.match(check.error, /absolute/);
+});