From db985b3625cef5f5f0bcd77401cdf15ebd1dfdbb Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Tue, 27 Oct 2026 18:48:55 +0800 Subject: [PATCH] feat(dsh): manage ReMe .env settings from the Environment tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- integrations/dsh/src/client/env-tab.tsx | 483 +++++++++++++++++- integrations/dsh/src/client/index.tsx | 10 +- integrations/dsh/src/client/styles.ts | 4 + integrations/dsh/src/config.ts | 10 + integrations/dsh/src/env-gateway.ts | 33 ++ integrations/dsh/src/env-manager.ts | 48 +- integrations/dsh/src/pyenv/workspace.ts | 261 ++++++++++ integrations/dsh/src/types.ts | 2 + integrations/dsh/tests/env-workspace.test.mjs | 174 +++++++ 9 files changed, 1020 insertions(+), 5 deletions(-) create mode 100644 integrations/dsh/src/pyenv/workspace.ts create mode 100644 integrations/dsh/tests/env-workspace.test.mjs diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/client/env-tab.tsx index 8ec122c0..7f6d2539 100644 --- a/integrations/dsh/src/client/env-tab.tsx +++ b/integrations/dsh/src/client/env-tab.tsx @@ -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({ phase: "loading" }); + const [envDraft, setEnvDraft] = useState>({}); + const [envSaving, setEnvSaving] = useState(false); + const [envResult, setEnvResult] = useState({ kind: "" }); + const [llmTest, setLlmTest] = useState({ phase: "idle" }); + const [envTick, setEnvTick] = useState(0); + const [workspaceInput, setWorkspaceInput] = useState( + 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 => { + if (!env.data || envSaving) return; + const values: Record = {}; + 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 => { + 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 => { + 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 (
@@ -895,9 +1103,222 @@ export function ReMeEnvironmentTab({

{t("configTitle")}

-

- {t("upcoming")} -

+

{t("configHint")}

+
+ +
+ setWorkspaceInput(event.target.value)} + /> + +
+
+
+ {env.phase === "loading" ? ( +

{t("envLoading")}

+ ) : null} + {env.phase === "error" ? ( +

+ {t("saveEnvFailed")} + {env.error ? `: ${env.error}` : ""} +

+ ) : null} + {env.phase === "ready" && env.data ? ( + <> +

+ {env.data.exists + ? `${t("envPath")}: ${env.data.envPath}` + : t("envMissing")} +

+
+
{t("llmGroup")}
+ + + + + + updateEnvDraft("LLM_MODEL_NAME", event.target.value) + } + /> + + + + updateEnvDraft("LLM_API_KEY", event.target.value) + } + /> + + + + updateEnvDraft("LLM_BASE_URL", event.target.value) + } + /> + +
{t("embeddingGroup")}
+ + + + + + updateEnvDraft( + "EMBEDDING_MODEL_NAME", + event.target.value, + ) + } + /> + + + + updateEnvDraft("EMBEDDING_API_KEY", event.target.value) + } + /> + + + + updateEnvDraft("EMBEDDING_BASE_URL", event.target.value) + } + /> + +
+
+ + + + {envResult.kind === "saved" ? ( + + {t("savedEnv").replace( + "{path}", + env.data?.envPath ?? "", + )} + + ) : null} + {envResult.kind === "failed" ? ( + + {`${t("saveEnvFailed")}${ + envResult.message ? `: ${envResult.message}` : "" + }`} + + ) : null} + {llmTest.phase === "ok" && llmTest.result ? ( + + {t("llmOk").replace( + "{ms}", + String(llmTest.result.latencyMs), + )} + + ) : null} + {llmTest.phase === "fail" && llmTest.result ? ( + + {`${t("llmFailed")}${ + llmTest.result.error + ? `: ${llmTest.result.error}` + : "" + }`} + + ) : null} + +
+ + ) : null}

{t("controlTitle")}

@@ -910,3 +1331,59 @@ export function ReMeEnvironmentTab({
); } + +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 ( +
+ + {children} + {hint ? {hint} : null} +
+ ); +} + +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; +} diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index e73931f2..24e6f26e 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -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, + 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!, diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts index be3a2ab7..5a8c6d97 100644 --- a/integrations/dsh/src/client/styles.ts +++ b/integrations/dsh/src/client/styles.ts @@ -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}} `; diff --git a/integrations/dsh/src/config.ts b/integrations/dsh/src/config.ts index f402df4a..884d287a 100644 --- a/integrations/dsh/src/config.ts +++ b/integrations/dsh/src/config.ts @@ -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 = 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( diff --git a/integrations/dsh/src/env-gateway.ts b/integrations/dsh/src/env-gateway.ts index d853ef6f..d1263144 100644 --- a/integrations/dsh/src/env-gateway.ts +++ b/integrations/dsh/src/env-gateway.ts @@ -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 { 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 { + 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 } + | undefined, + ): Promise { + 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 { + return this.ctx.remeEnv.testLlm( + input?.workspaceDir ?? "", + input?.pythonPath ?? "", + ); + } } export default ReMeEnvGateway; diff --git a/integrations/dsh/src/env-manager.ts b/integrations/dsh/src/env-manager.ts index 6639070b..a7bd27cb 100644 --- a/integrations/dsh/src/env-manager.ts +++ b/integrations/dsh/src/env-manager.ts @@ -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): EnvSummary { + const dir = resolveWorkspaceDir(workspaceDir, homedir()); + const path = join(dir, ".env"); + let existing: Record = {}; + 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 { + const dir = resolveWorkspaceDir(workspaceDir, homedir()); + return testLlm( + { run: createNodeRunner(this.env) }, + { workspaceDir: dir, pythonPath }, + ); + } } diff --git a/integrations/dsh/src/pyenv/workspace.ts b/integrations/dsh/src/pyenv/workspace.ts new file mode 100644 index 00000000..1d9b10a4 --- /dev/null +++ b/integrations/dsh/src/pyenv/workspace.ts @@ -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 { + const entries: Record = {}; + 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 { + 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): 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, +): 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; + 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 { + 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) }; + } +} diff --git a/integrations/dsh/src/types.ts b/integrations/dsh/src/types.ts index 6cb0a468..8fae0403 100644 --- a/integrations/dsh/src/types.ts +++ b/integrations/dsh/src/types.ts @@ -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; diff --git a/integrations/dsh/tests/env-workspace.test.mjs b/integrations/dsh/tests/env-workspace.test.mjs new file mode 100644 index 00000000..7045d412 --- /dev/null +++ b/integrations/dsh/tests/env-workspace.test.mjs @@ -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/); +});