mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-05 07:39:47 +00:00
feat(dsh): manage ReMe .env settings from the Environment tab
- read the workspace .env as a masked projection: non-secret values are editable, API keys return only •••• plus their last four characters - merge edits into the existing file, preserving comments, ordering and unknown variables; validate the effective file so an untouched real key still satisfies the LLM requirement - require a non-empty EMBEDDING_API_KEY (EMPTY placeholder fills in on first save), because ReMe 0.4.0.x aborts startup without one - test the configured LLM endpoint from inside the workspace with the selected interpreter, classifying 401/404/network failures - add the remeWorkspaceDir setting (empty means ~/.reme) consumed by the .env editor and later service management Real-machine check: masked read leaks no secret, temp-workspace write is idempotent and rejects an empty embedding key, and the LLM probe against the local ~/.reme/.env returned HTTP 200.
This commit is contained in:
parent
ad9a4c2888
commit
db985b3625
9 changed files with 1020 additions and 5 deletions
|
|
@ -9,6 +9,7 @@ import type {
|
|||
PythonEnv,
|
||||
PythonOrigin,
|
||||
} from "../pyenv/types.js";
|
||||
import type { EnvSummary, LlmTestResult } from "../pyenv/workspace.js";
|
||||
import { ReMeClient } from "../reme/client.js";
|
||||
import type { ReMeSettings } from "../types.js";
|
||||
import type { StatusRpc } from "./status-page.js";
|
||||
|
|
@ -83,6 +84,33 @@ export const envEn = {
|
|||
mirrorAliyun: "Aliyun",
|
||||
condaHint:
|
||||
"In a conda environment pip still works; if dependency conflicts appear, prefer a dedicated environment.",
|
||||
configHint:
|
||||
"ReMe reads credentials from the workspace .env at service start. Secrets stay in that file; DSH settings never store them.",
|
||||
workspaceDir: "Workspace directory",
|
||||
workspaceDirHint:
|
||||
"Empty means ~/.reme. Restart the service after changing it.",
|
||||
saveWorkspace: "Save path",
|
||||
envLoading: "Loading .env…",
|
||||
envMissing: "No .env yet; saving creates it.",
|
||||
envPath: "File",
|
||||
llmGroup: "LLM (auto memory / consolidation)",
|
||||
embeddingGroup: "Embedding",
|
||||
backendLabel: "Backend",
|
||||
modelLabel: "Model",
|
||||
apiKeyLabel: "API key",
|
||||
baseUrlLabel: "Base URL",
|
||||
secretSet: "Set {masked}; leave blank to keep",
|
||||
secretUnset: "Not set",
|
||||
embeddingHint:
|
||||
"ReMe 0.4.0.x requires a non-empty key even without vector search; EMPTY is the placeholder.",
|
||||
saveEnv: "Save to .env",
|
||||
savingEnv: "Saving…",
|
||||
savedEnv: "Saved to {path}",
|
||||
saveEnvFailed: "Saving failed",
|
||||
testLlmButton: "Test LLM connection",
|
||||
testingLlm: "Testing…",
|
||||
llmOk: "LLM reachable ({ms} ms)",
|
||||
llmFailed: "LLM test failed",
|
||||
configTitle: "ReMe parameters",
|
||||
controlTitle: "Service control",
|
||||
upcoming:
|
||||
|
|
@ -155,6 +183,32 @@ export const envZh: typeof envEn = {
|
|||
mirrorAliyun: "阿里云镜像",
|
||||
condaHint:
|
||||
"conda 环境中使用 pip 通常没有问题;如遇依赖冲突,建议使用专用环境。",
|
||||
configHint:
|
||||
"ReMe 在服务启动时从 workspace 的 .env 读取凭据。密钥只保存在该文件里,不写入 DSH 设置。",
|
||||
workspaceDir: "Workspace 目录",
|
||||
workspaceDirHint: "留空表示 ~/.reme;修改后需重启服务。",
|
||||
saveWorkspace: "保存路径",
|
||||
envLoading: "正在读取 .env…",
|
||||
envMissing: "尚未创建 .env;保存后自动创建。",
|
||||
envPath: "文件",
|
||||
llmGroup: "LLM(自动记忆 / 记忆整理)",
|
||||
embeddingGroup: "Embedding",
|
||||
backendLabel: "后端",
|
||||
modelLabel: "模型",
|
||||
apiKeyLabel: "API Key",
|
||||
baseUrlLabel: "Base URL",
|
||||
secretSet: "已设置 {masked};留空保持不变",
|
||||
secretUnset: "未设置",
|
||||
embeddingHint:
|
||||
"ReMe 0.4.0.x 即使不使用向量检索也要求此项非空,占位值 EMPTY 即可。",
|
||||
saveEnv: "保存到 .env",
|
||||
savingEnv: "正在保存…",
|
||||
savedEnv: "已保存到 {path}",
|
||||
saveEnvFailed: "保存失败",
|
||||
testLlmButton: "测试 LLM 连接",
|
||||
testingLlm: "正在测试…",
|
||||
llmOk: "LLM 连接正常({ms} ms)",
|
||||
llmFailed: "LLM 测试失败",
|
||||
configTitle: "ReMe 参数",
|
||||
controlTitle: "服务控制",
|
||||
upcoming: "该分区将随环境管理特性的后续里程碑提供。",
|
||||
|
|
@ -196,6 +250,22 @@ interface InstallState {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
interface EnvState {
|
||||
phase: "loading" | "ready" | "error";
|
||||
data?: EnvSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface EnvResultState {
|
||||
kind: "" | "saved" | "failed";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface LlmTestState {
|
||||
phase: "idle" | "running" | "ok" | "fail";
|
||||
result?: LlmTestResult;
|
||||
}
|
||||
|
||||
function isLocalEndpoint(endpoint: string): boolean {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
|
|
@ -285,6 +355,15 @@ export function ReMeEnvironmentTab({
|
|||
const [confirming, setConfirming] = useState(false);
|
||||
const [mirror, setMirror] = useState("");
|
||||
const [now, setNow] = useState(0);
|
||||
const [env, setEnv] = useState<EnvState>({ phase: "loading" });
|
||||
const [envDraft, setEnvDraft] = useState<Record<string, string>>({});
|
||||
const [envSaving, setEnvSaving] = useState(false);
|
||||
const [envResult, setEnvResult] = useState<EnvResultState>({ kind: "" });
|
||||
const [llmTest, setLlmTest] = useState<LlmTestState>({ phase: "idle" });
|
||||
const [envTick, setEnvTick] = useState(0);
|
||||
const [workspaceInput, setWorkspaceInput] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Derived values must precede hook dependency arrays.
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
|
|
@ -297,6 +376,8 @@ export function ReMeEnvironmentTab({
|
|||
const local = isLocalEndpoint(endpoint);
|
||||
const healthCurrent =
|
||||
health.phase !== "loading" && health.endpoint === endpoint;
|
||||
const workspaceDir = value?.remeWorkspaceDir ?? "";
|
||||
const workspaceDraft = workspaceInput ?? workspaceDir;
|
||||
|
||||
const loadDetection = useCallback(
|
||||
async (signal: AbortSignal, refresh: boolean) => {
|
||||
|
|
@ -497,6 +578,40 @@ export function ReMeEnvironmentTab({
|
|||
return () => clearInterval(timer);
|
||||
}, [install.phase]);
|
||||
|
||||
const loadEnv = useCallback(
|
||||
async (signal: AbortSignal, dir: string) => {
|
||||
try {
|
||||
const result = await rpc.call(
|
||||
"/api",
|
||||
"remeEnv/readEnv",
|
||||
{ args: { input: { workspaceDir: dir } } },
|
||||
signal,
|
||||
);
|
||||
if (!result.ok)
|
||||
throw new Error(result.error.message || result.error.code);
|
||||
setEnv((current) => ({
|
||||
...current,
|
||||
phase: "ready",
|
||||
data: parseEnvSummary(result.value),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!signal.aborted)
|
||||
setEnv((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[rpc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadEnv(controller.signal, workspaceDir);
|
||||
return () => controller.abort();
|
||||
}, [loadEnv, workspaceDir, envTick]);
|
||||
|
||||
if (snapshot.status === "unavailable") return null;
|
||||
|
||||
const select = (env: PythonEnv): void => {
|
||||
|
|
@ -542,6 +657,99 @@ export function ReMeEnvironmentTab({
|
|||
}
|
||||
};
|
||||
|
||||
const envValue = (key: string): string => {
|
||||
const draft = envDraft[key];
|
||||
if (draft !== undefined) return draft;
|
||||
const entry = env.data?.entries.find((item) => item.key === key);
|
||||
return entry?.secret ? "" : entry?.value ?? "";
|
||||
};
|
||||
|
||||
const envEntry = (key: string) =>
|
||||
env.data?.entries.find((item) => item.key === key);
|
||||
|
||||
const updateEnvDraft = (key: string, next: string): void => {
|
||||
setEnvDraft((current) => ({ ...current, [key]: next }));
|
||||
setEnvResult({ kind: "" });
|
||||
};
|
||||
|
||||
const saveEnv = async (): Promise<void> => {
|
||||
if (!env.data || envSaving) return;
|
||||
const values: Record<string, string> = {};
|
||||
for (const entry of env.data.entries) {
|
||||
if (!entry.secret) {
|
||||
const next = envValue(entry.key).trim();
|
||||
if (next !== "") values[entry.key] = next;
|
||||
continue;
|
||||
}
|
||||
const typed = envDraft[entry.key];
|
||||
if (typed !== undefined && typed !== "") values[entry.key] = typed;
|
||||
else if (!entry.set && entry.key === "EMBEDDING_API_KEY")
|
||||
values[entry.key] = "EMPTY";
|
||||
}
|
||||
setEnvSaving(true);
|
||||
setEnvResult({ kind: "" });
|
||||
try {
|
||||
const result = await rpc.call("/api", "remeEnv/writeEnv", {
|
||||
args: { input: { workspaceDir, values } },
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(result.error.message || result.error.code);
|
||||
setEnv((current) => ({
|
||||
...current,
|
||||
phase: "ready",
|
||||
data: parseEnvSummary(result.value),
|
||||
}));
|
||||
setEnvDraft({});
|
||||
setEnvResult({ kind: "saved" });
|
||||
} catch (error) {
|
||||
setEnvResult({ kind: "failed", message: safeError(error) });
|
||||
} finally {
|
||||
setEnvSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runLlmTest = async (): Promise<void> => {
|
||||
if (!selectedPath || llmTest.phase === "running") return;
|
||||
setLlmTest({ phase: "running" });
|
||||
try {
|
||||
const result = await rpc.call("/api", "remeEnv/testLlm", {
|
||||
args: { input: { workspaceDir, pythonPath: selectedPath } },
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(result.error.message || result.error.code);
|
||||
const data = parseLlmResult(result.value);
|
||||
setLlmTest({ phase: data.ok ? "ok" : "fail", result: data });
|
||||
} catch (error) {
|
||||
setLlmTest({
|
||||
phase: "fail",
|
||||
result: {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: safeError(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const saveWorkspace = async (): Promise<void> => {
|
||||
if (!value || !snapshot.writable) return;
|
||||
await scope
|
||||
.mutate(
|
||||
[
|
||||
{
|
||||
op: "set",
|
||||
path: ["remeWorkspaceDir"],
|
||||
value: workspaceDraft.trim(),
|
||||
},
|
||||
],
|
||||
snapshot.revision,
|
||||
)
|
||||
.catch(() => undefined);
|
||||
setWorkspaceInput(undefined);
|
||||
setEnvTick((tick) => tick + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="reme-env">
|
||||
<section className="reme-settings-section">
|
||||
|
|
@ -895,9 +1103,222 @@ export function ReMeEnvironmentTab({
|
|||
</section>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("configTitle")}</h4>
|
||||
<p className="reme-settings-muted reme-env-upcoming">
|
||||
{t("upcoming")}
|
||||
</p>
|
||||
<p className="reme-settings-muted">{t("configHint")}</p>
|
||||
<div className="reme-settings-grid">
|
||||
<EnvField label={t("workspaceDir")} hint={t("workspaceDirHint")}>
|
||||
<div className="reme-env-inline">
|
||||
<input
|
||||
value={workspaceDraft}
|
||||
placeholder="~/.reme"
|
||||
onChange={(event) => setWorkspaceInput(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!snapshot.writable || envSaving}
|
||||
onClick={() => void saveWorkspace()}
|
||||
>
|
||||
{t("saveWorkspace")}
|
||||
</button>
|
||||
</div>
|
||||
</EnvField>
|
||||
</div>
|
||||
{env.phase === "loading" ? (
|
||||
<p className="reme-settings-muted">{t("envLoading")}</p>
|
||||
) : null}
|
||||
{env.phase === "error" ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("saveEnvFailed")}
|
||||
{env.error ? `: ${env.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{env.phase === "ready" && env.data ? (
|
||||
<>
|
||||
<p className="reme-settings-muted">
|
||||
{env.data.exists
|
||||
? `${t("envPath")}: ${env.data.envPath}`
|
||||
: t("envMissing")}
|
||||
</p>
|
||||
<div className="reme-settings-grid reme-env-config">
|
||||
<h5 className="reme-env-group">{t("llmGroup")}</h5>
|
||||
<EnvField label={t("backendLabel")}>
|
||||
<select
|
||||
value={envValue("LLM_BACKEND")}
|
||||
onChange={(event) =>
|
||||
updateEnvDraft("LLM_BACKEND", event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{envOptions(LLM_BACKENDS, envValue("LLM_BACKEND")).map(
|
||||
(name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</EnvField>
|
||||
<EnvField label={t("modelLabel")}>
|
||||
<input
|
||||
value={envValue("LLM_MODEL_NAME")}
|
||||
placeholder="qwen3.7-plus"
|
||||
onChange={(event) =>
|
||||
updateEnvDraft("LLM_MODEL_NAME", event.target.value)
|
||||
}
|
||||
/>
|
||||
</EnvField>
|
||||
<EnvField
|
||||
label={t("apiKeyLabel")}
|
||||
hint={
|
||||
envEntry("LLM_API_KEY")?.set
|
||||
? t("secretSet").replace(
|
||||
"{masked}",
|
||||
envEntry("LLM_API_KEY")?.masked ?? "",
|
||||
)
|
||||
: t("secretUnset")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={envDraft["LLM_API_KEY"] ?? ""}
|
||||
placeholder={envEntry("LLM_API_KEY")?.masked ?? ""}
|
||||
onChange={(event) =>
|
||||
updateEnvDraft("LLM_API_KEY", event.target.value)
|
||||
}
|
||||
/>
|
||||
</EnvField>
|
||||
<EnvField label={t("baseUrlLabel")}>
|
||||
<input
|
||||
value={envValue("LLM_BASE_URL")}
|
||||
placeholder="https://…/v1"
|
||||
onChange={(event) =>
|
||||
updateEnvDraft("LLM_BASE_URL", event.target.value)
|
||||
}
|
||||
/>
|
||||
</EnvField>
|
||||
<h5 className="reme-env-group">{t("embeddingGroup")}</h5>
|
||||
<EnvField label={t("backendLabel")}>
|
||||
<select
|
||||
value={envValue("EMBEDDING_BACKEND")}
|
||||
onChange={(event) =>
|
||||
updateEnvDraft("EMBEDDING_BACKEND", event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{envOptions(
|
||||
EMBEDDING_BACKENDS,
|
||||
envValue("EMBEDDING_BACKEND"),
|
||||
).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</EnvField>
|
||||
<EnvField label={t("modelLabel")}>
|
||||
<input
|
||||
value={envValue("EMBEDDING_MODEL_NAME")}
|
||||
placeholder="text-embedding-v4"
|
||||
onChange={(event) =>
|
||||
updateEnvDraft(
|
||||
"EMBEDDING_MODEL_NAME",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</EnvField>
|
||||
<EnvField
|
||||
label={t("apiKeyLabel")}
|
||||
hint={
|
||||
envEntry("EMBEDDING_API_KEY")?.set
|
||||
? t("secretSet").replace(
|
||||
"{masked}",
|
||||
envEntry("EMBEDDING_API_KEY")?.masked ?? "",
|
||||
)
|
||||
: t("secretUnset")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={envDraft["EMBEDDING_API_KEY"] ?? ""}
|
||||
placeholder={
|
||||
envEntry("EMBEDDING_API_KEY")?.masked ?? "EMPTY"
|
||||
}
|
||||
onChange={(event) =>
|
||||
updateEnvDraft("EMBEDDING_API_KEY", event.target.value)
|
||||
}
|
||||
/>
|
||||
</EnvField>
|
||||
<EnvField label={t("baseUrlLabel")} hint={t("embeddingHint")}>
|
||||
<input
|
||||
value={envValue("EMBEDDING_BASE_URL")}
|
||||
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
onChange={(event) =>
|
||||
updateEnvDraft("EMBEDDING_BASE_URL", event.target.value)
|
||||
}
|
||||
/>
|
||||
</EnvField>
|
||||
</div>
|
||||
<div className="reme-env-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={envSaving || !snapshot.writable}
|
||||
onClick={() => void saveEnv()}
|
||||
>
|
||||
{t(envSaving ? "savingEnv" : "saveEnv")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!selectedPath || llmTest.phase === "running"}
|
||||
onClick={() => void runLlmTest()}
|
||||
>
|
||||
{t(
|
||||
llmTest.phase === "running"
|
||||
? "testingLlm"
|
||||
: "testLlmButton",
|
||||
)}
|
||||
</button>
|
||||
<span className="reme-settings-result" role="status">
|
||||
{envResult.kind === "saved" ? (
|
||||
<span className="reme-settings-success">
|
||||
{t("savedEnv").replace(
|
||||
"{path}",
|
||||
env.data?.envPath ?? "",
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{envResult.kind === "failed" ? (
|
||||
<span className="reme-settings-error">
|
||||
{`${t("saveEnvFailed")}${
|
||||
envResult.message ? `: ${envResult.message}` : ""
|
||||
}`}
|
||||
</span>
|
||||
) : null}
|
||||
{llmTest.phase === "ok" && llmTest.result ? (
|
||||
<span className="reme-settings-success">
|
||||
{t("llmOk").replace(
|
||||
"{ms}",
|
||||
String(llmTest.result.latencyMs),
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{llmTest.phase === "fail" && llmTest.result ? (
|
||||
<span className="reme-settings-error">
|
||||
{`${t("llmFailed")}${
|
||||
llmTest.result.error
|
||||
? `: ${llmTest.result.error}`
|
||||
: ""
|
||||
}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="reme-settings-section">
|
||||
<h4>{t("controlTitle")}</h4>
|
||||
|
|
@ -910,3 +1331,59 @@ export function ReMeEnvironmentTab({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LLM_BACKENDS = [
|
||||
"openai",
|
||||
"dashscope",
|
||||
"deepseek",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"moonshot",
|
||||
"ollama",
|
||||
"xai",
|
||||
] as const;
|
||||
const EMBEDDING_BACKENDS = [
|
||||
"openai",
|
||||
"dashscope",
|
||||
"dashscope_multimodal",
|
||||
"gemini",
|
||||
"ollama",
|
||||
] as const;
|
||||
|
||||
function envOptions(list: readonly string[], current: string): string[] {
|
||||
return current && !list.includes(current) ? [...list, current] : [...list];
|
||||
}
|
||||
|
||||
function EnvField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: JSX.Element;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="reme-settings-field">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
{hint ? <small>{hint}</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvSummary(value: unknown): EnvSummary {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!Array.isArray((value as { entries?: unknown }).entries)
|
||||
)
|
||||
throw new Error("Invalid .env summary response");
|
||||
return value as unknown as EnvSummary;
|
||||
}
|
||||
|
||||
function parseLlmResult(value: unknown): LlmTestResult {
|
||||
if (typeof value !== "object" || value === null)
|
||||
throw new Error("Invalid LLM test response");
|
||||
return value as unknown as LlmTestResult;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,6 +354,10 @@ function ReMeSettingsCard({
|
|||
typeof value.authToken === "string" ? value.authToken : "",
|
||||
pythonPath:
|
||||
typeof value.pythonPath === "string" ? value.pythonPath : "",
|
||||
remeWorkspaceDir:
|
||||
typeof value.remeWorkspaceDir === "string"
|
||||
? value.remeWorkspaceDir
|
||||
: "",
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
|
@ -755,7 +759,10 @@ function draftFrom(value: ReMeSettings): Draft {
|
|||
|
||||
function parseDraft(
|
||||
draft: Draft,
|
||||
passthrough?: Pick<ReMeSettings, "authToken" | "pythonPath">,
|
||||
passthrough?: Pick<
|
||||
ReMeSettings,
|
||||
"authToken" | "pythonPath" | "remeWorkspaceDir"
|
||||
>,
|
||||
): ReMeSettings | undefined {
|
||||
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
|
||||
const backgroundTimeoutMs = boundedInteger(
|
||||
|
|
@ -799,6 +806,7 @@ function parseDraft(
|
|||
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
|
||||
authToken: passthrough?.authToken ?? "",
|
||||
pythonPath: passthrough?.pythonPath ?? "",
|
||||
remeWorkspaceDir: passthrough?.remeWorkspaceDir ?? "",
|
||||
requestTimeoutMs: requestTimeoutMs!,
|
||||
backgroundTimeoutMs: backgroundTimeoutMs!,
|
||||
shutdownTimeoutMs: shutdownTimeoutMs!,
|
||||
|
|
|
|||
|
|
@ -66,5 +66,9 @@ export const styles = `
|
|||
.reme-env-progress{padding-top:10px}
|
||||
.reme-env-log{max-height:220px;overflow:auto;margin:6px 0 0;border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.reme-env-result{padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-env-inline{display:flex;gap:8px;align-items:center}
|
||||
.reme-env-inline input{flex:1;min-width:0}
|
||||
.reme-env-config{padding:4px 0 0}
|
||||
.reme-env-group{margin:14px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;letter-spacing:.02em}
|
||||
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export const Config = z.object({
|
|||
.default("")
|
||||
.description("Local Python interpreter selected in environment management")
|
||||
.volatile(),
|
||||
remeWorkspaceDir: z
|
||||
.string()
|
||||
.default("")
|
||||
.description("ReMe workspace directory (empty means ~/.reme)")
|
||||
.volatile(),
|
||||
requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(),
|
||||
backgroundTimeoutMs: z
|
||||
.natural()
|
||||
|
|
@ -100,6 +105,7 @@ const DEFAULT_CONFIG: Readonly<ReMeConfig> = Object.freeze({
|
|||
endpoint: "http://127.0.0.1:2333",
|
||||
authToken: "",
|
||||
pythonPath: "",
|
||||
remeWorkspaceDir: "",
|
||||
requestTimeoutMs: 10000,
|
||||
backgroundTimeoutMs: 3600000,
|
||||
shutdownTimeoutMs: 5000,
|
||||
|
|
@ -160,6 +166,10 @@ export function resolveConfig(
|
|||
);
|
||||
config.pythonPath =
|
||||
typeof config.pythonPath === "string" ? config.pythonPath.trim() : "";
|
||||
config.remeWorkspaceDir =
|
||||
typeof config.remeWorkspaceDir === "string"
|
||||
? config.remeWorkspaceDir.trim()
|
||||
: "";
|
||||
const home = env.DSH_HOME?.trim() || `${homedir()}/.dsh`;
|
||||
const queueDir = config.autoMemoryQueueDir || `${home}/reme-memory/outbox`;
|
||||
config.autoMemoryQueueDir = resolve(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|||
import type { InstallStatus } from "./pyenv/install.js";
|
||||
import type { RemeCheck } from "./pyenv/reme.js";
|
||||
import type { PythonDetection } from "./pyenv/types.js";
|
||||
import type { EnvSummary, LlmTestResult } from "./pyenv/workspace.js";
|
||||
import type { ReMeEnvManager } from "./env-manager.js";
|
||||
|
||||
declare module "@deepseek-ai/cordis" {
|
||||
|
|
@ -71,6 +72,38 @@ export class ReMeEnvGateway extends TypertRemoteService {
|
|||
async installCancel(input: { jobId?: string } | undefined): Promise<boolean> {
|
||||
return this.ctx.remeEnv.installCancel(input?.jobId ?? "");
|
||||
}
|
||||
|
||||
/** Masked .env values for the workspace; secrets are never returned. */
|
||||
@Remote("readEnv")
|
||||
async readEnv(
|
||||
input: { workspaceDir?: string } | undefined,
|
||||
): Promise<EnvSummary> {
|
||||
return this.ctx.remeEnv.readEnv(input?.workspaceDir ?? "");
|
||||
}
|
||||
|
||||
/** Merge edited values into the workspace .env and return fresh state. */
|
||||
@Remote("writeEnv")
|
||||
async writeEnv(
|
||||
input:
|
||||
| { workspaceDir?: string; values?: Record<string, string> }
|
||||
| undefined,
|
||||
): Promise<EnvSummary> {
|
||||
return this.ctx.remeEnv.writeEnv(
|
||||
input?.workspaceDir ?? "",
|
||||
input?.values ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Probe the configured LLM endpoint with the selected interpreter. */
|
||||
@Remote("testLlm")
|
||||
async testLlm(
|
||||
input: { workspaceDir?: string; pythonPath?: string } | undefined,
|
||||
): Promise<LlmTestResult> {
|
||||
return this.ctx.remeEnv.testLlm(
|
||||
input?.workspaceDir ?? "",
|
||||
input?.pythonPath ?? "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ReMeEnvGateway;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
|
|
@ -12,6 +12,16 @@ import {
|
|||
import { checkReme, type RemeCheck } from "./pyenv/reme.js";
|
||||
import { createNodeRunner } from "./pyenv/runner.js";
|
||||
import type { PythonDetection } from "./pyenv/types.js";
|
||||
import {
|
||||
envSummary,
|
||||
parseEnvFile,
|
||||
resolveWorkspaceDir,
|
||||
testLlm,
|
||||
validateEnvUpdate,
|
||||
writeEnvFile,
|
||||
type EnvSummary,
|
||||
type LlmTestResult,
|
||||
} from "./pyenv/workspace.js";
|
||||
|
||||
const CACHE_MS = 60_000;
|
||||
|
||||
|
|
@ -71,4 +81,40 @@ export class ReMeEnvManager {
|
|||
installCancel(jobId?: string): boolean {
|
||||
return this.installs.cancel(jobId);
|
||||
}
|
||||
|
||||
/** Masked .env projection for the selected workspace. */
|
||||
readEnv(workspaceDir: string): EnvSummary {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
return envSummary(join(dir, ".env"), dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge edited values into the workspace .env. Validation covers the whole
|
||||
* effective file, so an untouched real key still satisfies the requirement.
|
||||
*/
|
||||
writeEnv(workspaceDir: string, values: Record<string, string>): EnvSummary {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
const path = join(dir, ".env");
|
||||
let existing: Record<string, string> = {};
|
||||
try {
|
||||
existing = parseEnvFile(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
// First write creates the file.
|
||||
}
|
||||
validateEnvUpdate({ ...existing, ...values });
|
||||
writeEnvFile(path, values);
|
||||
return envSummary(path, dir);
|
||||
}
|
||||
|
||||
/** One-off LLM connectivity probe reading the workspace .env. */
|
||||
async testLlm(
|
||||
workspaceDir: string,
|
||||
pythonPath: string,
|
||||
): Promise<LlmTestResult> {
|
||||
const dir = resolveWorkspaceDir(workspaceDir, homedir());
|
||||
return testLlm(
|
||||
{ run: createNodeRunner(this.env) },
|
||||
{ workspaceDir: dir, pythonPath },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
261
integrations/dsh/src/pyenv/workspace.ts
Normal file
261
integrations/dsh/src/pyenv/workspace.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import { safeError } from "../endpoint.js";
|
||||
import type { RunCommand } from "./runner.js";
|
||||
|
||||
/** Keys the Environment tab manages; split for the two form groups. */
|
||||
export const LLM_KEYS = [
|
||||
"LLM_BACKEND",
|
||||
"LLM_MODEL_NAME",
|
||||
"LLM_API_KEY",
|
||||
"LLM_BASE_URL",
|
||||
] as const;
|
||||
export const EMBEDDING_KEYS = [
|
||||
"EMBEDDING_BACKEND",
|
||||
"EMBEDDING_MODEL_NAME",
|
||||
"EMBEDDING_API_KEY",
|
||||
"EMBEDDING_BASE_URL",
|
||||
] as const;
|
||||
const MANAGED_KEYS: readonly string[] = [...LLM_KEYS, ...EMBEDDING_KEYS];
|
||||
const SECRET_KEYS = new Set(["LLM_API_KEY", "EMBEDDING_API_KEY"]);
|
||||
|
||||
export interface EnvEntry {
|
||||
key: string;
|
||||
secret: boolean;
|
||||
/** True when the key has a non-empty value on disk. */
|
||||
set: boolean;
|
||||
/** Masked tail for secrets; the plain value for non-secret keys. */
|
||||
masked: string;
|
||||
/** Plain value for non-secret keys; empty for secrets. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EnvSummary {
|
||||
workspaceDir: string;
|
||||
envPath: string;
|
||||
exists: boolean;
|
||||
entries: EnvEntry[];
|
||||
}
|
||||
|
||||
export interface LlmTestResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
latencyMs: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Empty means "~/.reme"; "~" expands against home; relative paths are rejected. */
|
||||
export function resolveWorkspaceDir(raw: string, home: string): string {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
const expanded = trimmed.startsWith("~")
|
||||
? join(home, trimmed.slice(1).replace(/^[\\/]+/, ""))
|
||||
: trimmed;
|
||||
const value = expanded || join(home, ".reme");
|
||||
if (!isAbsolute(value))
|
||||
throw new TypeError("ReMe workspace directory must be an absolute path");
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
/** Parse KEY=VALUE lines; comments, blanks, quotes and unknown keys are tolerated. */
|
||||
export function parseEnvFile(text: string): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const equals = trimmed.indexOf("=");
|
||||
if (equals <= 0) continue;
|
||||
const key = trimmed.slice(0, equals).trim();
|
||||
let value = trimmed.slice(equals + 1).trim();
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")))
|
||||
)
|
||||
value = value.slice(1, -1);
|
||||
entries[key] = value;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite only the lines whose keys appear in updates; every other line
|
||||
* (comments, unknown variables, ordering) is preserved byte-for-byte.
|
||||
*/
|
||||
export function mergeEnvFile(
|
||||
existingText: string,
|
||||
updates: Record<string, string>,
|
||||
): string {
|
||||
const pending = new Map(Object.entries(updates));
|
||||
const lines = existingText.length ? existingText.split(/\r?\n/) : [];
|
||||
const output: string[] = [];
|
||||
for (const line of lines) {
|
||||
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
||||
const key = match?.[1] ?? "";
|
||||
if (key && pending.has(key)) {
|
||||
output.push(`${key}=${pending.get(key) ?? ""}`);
|
||||
pending.delete(key);
|
||||
continue;
|
||||
}
|
||||
output.push(line);
|
||||
}
|
||||
// Normalize the trailing newline before appending new keys.
|
||||
while (output.length && output[output.length - 1] === "") output.pop();
|
||||
for (const [key, value] of pending) output.push(`${key}=${value}`);
|
||||
return `${output.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** "••••" plus the last four characters; empty values stay empty. */
|
||||
export function maskSecret(value: string): string {
|
||||
if (!value) return "";
|
||||
return value.length > 4 ? `••••${value.slice(-4)}` : "••••";
|
||||
}
|
||||
|
||||
/** Read-only projection for the tab: secrets are never returned in clear. */
|
||||
export function envSummary(path: string, workspaceDir: string): EnvSummary {
|
||||
let text = "";
|
||||
let exists = false;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
exists = true;
|
||||
} catch {
|
||||
// A missing .env is the valid first-run state.
|
||||
}
|
||||
const parsed = parseEnvFile(text);
|
||||
const entries = MANAGED_KEYS.map((key) => {
|
||||
const value = parsed[key] ?? "";
|
||||
const secret = SECRET_KEYS.has(key);
|
||||
return {
|
||||
key,
|
||||
secret,
|
||||
set: value !== "",
|
||||
masked: secret ? maskSecret(value) : value,
|
||||
value: secret ? "" : value,
|
||||
};
|
||||
});
|
||||
return { workspaceDir, envPath: path, exists, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a .env that cannot start ReMe. Version 0.4.0.x instantiates the
|
||||
* embedding component unconditionally, so an empty key aborts startup.
|
||||
*/
|
||||
export function validateEnvUpdate(values: Record<string, string>): void {
|
||||
if (!(values["LLM_API_KEY"] ?? "").trim())
|
||||
throw new TypeError("LLM_API_KEY is required");
|
||||
if (!(values["EMBEDDING_API_KEY"] ?? "").trim())
|
||||
throw new TypeError(
|
||||
"EMBEDDING_API_KEY must be non-empty (use EMPTY when vector search is disabled)",
|
||||
);
|
||||
}
|
||||
|
||||
/** Merge updates into an existing .env, creating parent directories as needed. */
|
||||
export function writeEnvFile(
|
||||
path: string,
|
||||
updates: Record<string, string>,
|
||||
): void {
|
||||
let existing = "";
|
||||
try {
|
||||
existing = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
// First write creates the file.
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, mergeEnvFile(existing, updates), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the configured LLM endpoint from inside the workspace directory, so
|
||||
* the snippet reads the same .env the service would discover.
|
||||
*/
|
||||
const LLM_TEST_SNIPPET = `
|
||||
import json, time, urllib.request, urllib.error
|
||||
env = {}
|
||||
try:
|
||||
with open(".env", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
env[key.strip()] = value.strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
base = env.get("LLM_BASE_URL", "").rstrip("/")
|
||||
key = env.get("LLM_API_KEY", "")
|
||||
model = env.get("LLM_MODEL_NAME", "")
|
||||
if not base or not key or not model:
|
||||
print(json.dumps({"ok": False, "status": 0, "latencyMs": 0, "error": "LLM config incomplete (LLM_BASE_URL / LLM_API_KEY / LLM_MODEL_NAME)"}))
|
||||
raise SystemExit(0)
|
||||
body = json.dumps({"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}).encode()
|
||||
request = urllib.request.Request(base + "/chat/completions", data=body, headers={"Content-Type": "application/json", "Authorization": "Bearer " + key}, method="POST")
|
||||
started = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
response.read()
|
||||
print(json.dumps({"ok": 200 <= response.status < 300, "status": response.status, "latencyMs": int((time.time() - started) * 1000), "error": ""}))
|
||||
except urllib.error.HTTPError as error:
|
||||
print(json.dumps({"ok": False, "status": error.code, "latencyMs": int((time.time() - started) * 1000), "error": "HTTP " + str(error.code)}))
|
||||
except Exception as error:
|
||||
print(json.dumps({"ok": False, "status": 0, "latencyMs": int((time.time() - started) * 1000), "error": str(error)[:300]}))
|
||||
`;
|
||||
|
||||
/** Parse the JSON line the LLM test snippet prints. */
|
||||
export function parseLlmTest(text: string): LlmTestResult {
|
||||
for (const line of text.split(/\r?\n/).reverse()) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{")) continue;
|
||||
try {
|
||||
const value = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
return {
|
||||
ok: value.ok === true,
|
||||
status: typeof value.status === "number" ? value.status : 0,
|
||||
latencyMs: typeof value.latencyMs === "number" ? value.latencyMs : 0,
|
||||
error: typeof value.error === "string" ? value.error : "",
|
||||
};
|
||||
} catch {
|
||||
// Try an earlier line.
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: "LLM test produced no result",
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the LLM probe with the selected interpreter inside the workspace. */
|
||||
export async function testLlm(
|
||||
io: { run: RunCommand },
|
||||
options: { workspaceDir: string; pythonPath: string },
|
||||
): Promise<LlmTestResult> {
|
||||
try {
|
||||
const result = await io.run(options.pythonPath, ["-c", LLM_TEST_SNIPPET], {
|
||||
cwd: options.workspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (result.error)
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: safeError(result.error),
|
||||
};
|
||||
const parsed = parseLlmTest(result.stdout);
|
||||
if (!parsed.ok && parsed.error) {
|
||||
const hint =
|
||||
parsed.status === 401 || parsed.status === 403
|
||||
? "check LLM_API_KEY"
|
||||
: parsed.status === 404
|
||||
? "check LLM_MODEL_NAME / LLM_BASE_URL"
|
||||
: /refused|timed out|getaddrinfo|Name or service/i.test(parsed.error)
|
||||
? "check LLM_BASE_URL and network"
|
||||
: "";
|
||||
if (hint) parsed.error = `${parsed.error} (${hint})`;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, latencyMs: 0, error: safeError(error) };
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export interface ReMeConfigInput {
|
|||
endpoint?: string;
|
||||
authToken?: string;
|
||||
pythonPath?: string;
|
||||
remeWorkspaceDir?: string;
|
||||
requestTimeoutMs?: number;
|
||||
backgroundTimeoutMs?: number;
|
||||
shutdownTimeoutMs?: number;
|
||||
|
|
@ -26,6 +27,7 @@ export interface ReMeConfig extends ReMeClientConfig {
|
|||
shutdownTimeoutMs: number;
|
||||
authToken: string;
|
||||
pythonPath: string;
|
||||
remeWorkspaceDir: string;
|
||||
autoMemoryEnabled: boolean;
|
||||
autoMemoryInterval: number;
|
||||
autoMemoryQueueDir: string;
|
||||
|
|
|
|||
174
integrations/dsh/tests/env-workspace.test.mjs
Normal file
174
integrations/dsh/tests/env-workspace.test.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
envSummary,
|
||||
maskSecret,
|
||||
mergeEnvFile,
|
||||
parseEnvFile,
|
||||
parseLlmTest,
|
||||
resolveWorkspaceDir,
|
||||
testLlm,
|
||||
validateEnvUpdate,
|
||||
} from "../dist/pyenv/workspace.js";
|
||||
|
||||
test("resolves the workspace directory with ~/.reme defaults", () => {
|
||||
const home = process.platform === "win32" ? "C:\\Users\\u" : "/home/u";
|
||||
assert.equal(resolveWorkspaceDir("", home), join(home, ".reme"));
|
||||
assert.equal(resolveWorkspaceDir("~/.reme", home), join(home, ".reme"));
|
||||
assert.equal(resolveWorkspaceDir("~", home), home);
|
||||
assert.throws(() => resolveWorkspaceDir("relative/dir", home));
|
||||
});
|
||||
|
||||
test("parses KEY=VALUE lines tolerating comments, blanks and quotes", () => {
|
||||
const parsed = parseEnvFile(
|
||||
[
|
||||
"# service credentials",
|
||||
"",
|
||||
"LLM_BACKEND=openai",
|
||||
'LLM_MODEL_NAME="qwen3.7-plus"',
|
||||
"LLM_API_KEY='sk-test'",
|
||||
"UNKNOWN_KEY=kept",
|
||||
"BROKEN LINE",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.deepEqual(parsed, {
|
||||
LLM_BACKEND: "openai",
|
||||
LLM_MODEL_NAME: "qwen3.7-plus",
|
||||
LLM_API_KEY: "sk-test",
|
||||
UNKNOWN_KEY: "kept",
|
||||
});
|
||||
});
|
||||
|
||||
test("merges updates while preserving comments, order and unknown keys", () => {
|
||||
const existing = [
|
||||
"# managed by hand",
|
||||
"LLM_BACKEND=openai",
|
||||
"LLM_API_KEY=old-key",
|
||||
"",
|
||||
"EMBEDDING_API_KEY=EMPTY",
|
||||
"# keep me",
|
||||
].join("\n");
|
||||
const merged = mergeEnvFile(existing, {
|
||||
LLM_API_KEY: "new-key",
|
||||
LLM_BASE_URL: "https://example.com/v1",
|
||||
});
|
||||
assert.match(merged, /^# managed by hand$/m);
|
||||
assert.match(merged, /^LLM_API_KEY=new-key$/m);
|
||||
assert.doesNotMatch(merged, /old-key/);
|
||||
assert.match(merged, /^EMBEDDING_API_KEY=EMPTY$/m);
|
||||
assert.match(merged, /^# keep me$/m);
|
||||
assert.match(merged, /^LLM_BASE_URL=https:\/\/example\.com\/v1$/m);
|
||||
assert.ok(merged.endsWith("\n"));
|
||||
// Idempotent updates do not duplicate keys.
|
||||
assert.equal(
|
||||
(
|
||||
mergeEnvFile(merged, { LLM_API_KEY: "new-key" }).match(
|
||||
/^LLM_API_KEY=/gm,
|
||||
) ?? []
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("masks secrets down to the last four characters", () => {
|
||||
assert.equal(maskSecret("sk-abcdefgh"), "••••efgh");
|
||||
assert.equal(maskSecret("abc"), "••••");
|
||||
assert.equal(maskSecret(""), "");
|
||||
});
|
||||
|
||||
test("summaries expose masked secrets and plain non-secret values", (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-env-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, ".env");
|
||||
writeFileSync(
|
||||
path,
|
||||
"LLM_MODEL_NAME=qwen3.7-plus\nLLM_API_KEY=sk-super-secret\n",
|
||||
);
|
||||
const summary = envSummary(path, directory);
|
||||
assert.equal(summary.exists, true);
|
||||
assert.equal(summary.envPath, path);
|
||||
const model = summary.entries.find((entry) => entry.key === "LLM_MODEL_NAME");
|
||||
assert.equal(model.value, "qwen3.7-plus");
|
||||
assert.equal(model.secret, false);
|
||||
const key = summary.entries.find((entry) => entry.key === "LLM_API_KEY");
|
||||
assert.equal(key.set, true);
|
||||
assert.equal(key.secret, true);
|
||||
assert.equal(key.value, "");
|
||||
assert.equal(key.masked, "••••cret");
|
||||
assert.doesNotMatch(JSON.stringify(summary), /super-secret/);
|
||||
});
|
||||
|
||||
test("validates the embedding key that aborts ReMe 0.4.0.x startup", () => {
|
||||
assert.throws(() =>
|
||||
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "" }),
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateEnvUpdate({ LLM_API_KEY: "", EMBEDDING_API_KEY: "EMPTY" }),
|
||||
);
|
||||
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "EMPTY" });
|
||||
validateEnvUpdate({ LLM_API_KEY: "sk", EMBEDDING_API_KEY: "real-key" });
|
||||
});
|
||||
|
||||
test("parses LLM test results from noisy python output", () => {
|
||||
assert.deepEqual(
|
||||
parseLlmTest(
|
||||
'warn\n{"ok": true, "status": 200, "latencyMs": 321, "error": ""}\n',
|
||||
),
|
||||
{ ok: true, status: 200, latencyMs: 321, error: "" },
|
||||
);
|
||||
assert.deepEqual(parseLlmTest("no json"), {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: "LLM test produced no result",
|
||||
});
|
||||
});
|
||||
|
||||
test("runs the LLM probe inside the workspace and classifies failures", async (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "reme-llm-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const io = {
|
||||
run: async (exe, args, options) => {
|
||||
calls.push({ exe, args, options });
|
||||
const stdout =
|
||||
calls.length === 1
|
||||
? '{"ok": true, "status": 200, "latencyMs": 120, "error": ""}\n'
|
||||
: '{"ok": false, "status": 401, "latencyMs": 40, "error": "HTTP 401"}\n';
|
||||
return { code: 0, stdout, stderr: "" };
|
||||
},
|
||||
};
|
||||
const ok = await testLlm(io, {
|
||||
workspaceDir: directory,
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
});
|
||||
assert.equal(ok.ok, true);
|
||||
assert.equal(ok.latencyMs, 120);
|
||||
assert.equal(calls[0].options.cwd, directory);
|
||||
assert.equal(calls[0].exe, "C:\\env\\python.exe");
|
||||
|
||||
const denied = await testLlm(io, {
|
||||
workspaceDir: directory,
|
||||
pythonPath: "C:\\env\\python.exe",
|
||||
});
|
||||
assert.equal(denied.ok, false);
|
||||
assert.match(denied.error, /LLM_API_KEY/);
|
||||
|
||||
const broken = await testLlm(
|
||||
{
|
||||
run: async () => ({
|
||||
code: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
error: "spawn ENOENT",
|
||||
}),
|
||||
},
|
||||
{ workspaceDir: directory, pythonPath: "C:\\missing\\python.exe" },
|
||||
);
|
||||
assert.equal(broken.ok, false);
|
||||
assert.match(broken.error, /ENOENT/);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue