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:
LiangshouX 2026-10-27 18:48:55 +08:00
parent ad9a4c2888
commit db985b3625
9 changed files with 1020 additions and 5 deletions

View file

@ -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;
}

View file

@ -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!,

View file

@ -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}}
`;

View file

@ -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(

View file

@ -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;

View file

@ -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 },
);
}
}

View 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) };
}
}

View file

@ -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;

View 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/);
});