From a91022a5bc4d881fda4789ed5756dd014c5545f6 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 23:42:02 +0800 Subject: [PATCH] fix(dsh): verify service identity before touching a recorded pid - require command-line evidence for owned service processes - discard unprovable stale records instead of blocking startup - identify and stop our own unhealthy conflict; serialize stop with start - reject IPv6 loopback endpoints - rotate oversized logs and fix tailing multibyte cuts - add tests for env writes, client failures and real spawner --- integrations/dsh/docs/environment.md | 25 ++- integrations/dsh/docs/environment_ZH.md | 20 +- .../dsh/src/environment/client/service.tsx | 14 +- .../dsh/src/environment/client/tab.tsx | 2 +- .../dsh/src/environment/host/detect.ts | 4 + .../dsh/src/environment/host/install.ts | 18 +- integrations/dsh/src/environment/host/log.ts | 24 +++ .../dsh/src/environment/host/manager.ts | 147 +++++++++++--- .../dsh/src/environment/host/service.ts | 182 +++++++++++++++--- integrations/dsh/tests/env-manager.test.mjs | 123 +++++++++++- integrations/dsh/tests/env-reme.test.mjs | 3 +- integrations/dsh/tests/env-runner.test.mjs | 46 +++++ integrations/dsh/tests/env-service.test.mjs | 84 +++++++- integrations/dsh/tests/env-tab.test.mjs | 94 ++++++++- 14 files changed, 693 insertions(+), 93 deletions(-) create mode 100644 integrations/dsh/src/environment/host/log.ts diff --git a/integrations/dsh/docs/environment.md b/integrations/dsh/docs/environment.md index 08ad8eab..bb81c112 100644 --- a/integrations/dsh/docs/environment.md +++ b/integrations/dsh/docs/environment.md @@ -70,7 +70,8 @@ The installation is a Host-owned job: - Progress is polled by job id, so a second installation for another interpreter cannot replace what you see. - **Cancel** stops pip and its build children, and the interpreter stays locked until the process has actually exited. -- The log is written to `$DSH_HOME/reme-memory/install-.log` and its tail is shown in place. +- The log is written to `$DSH_HOME/reme-memory/install-.log` and its tail is shown in place; install and + service logs are rotated to `.log.1` once they pass 5 MB. - When the plugin unloads, running jobs are stopped. ## Workspace `.env` @@ -117,18 +118,22 @@ processes it manages when the Host exits. The plugin owns a small service adapte - The launch is recorded in `$DSH_HOME/reme-memory/service.json` (pid, endpoint, workspace, command, start time) **after** the OS acknowledged the spawn, so a missing interpreter leaves no state behind. - A service is reported as managed — and offered a **Stop** button — only when the record matches the endpoint on - screen, the pid is still alive, and its image is still a Python or `reme` executable. A record for port 2333 - never claims a service on port 2444, and a recycled pid is not mistaken for this service. When the image cannot - be read, ownership fails closed: the process is shown as foreign rather than offered a Stop button. + screen, the pid is still alive, its image is still a Python or `reme` executable, **and** its command line still + carries the recorded `workspace_dir` and `service.port`. The command line is what makes a recycled pid safe: an + image name alone cannot prove which launch a pid belongs to. A record for port 2333 never claims a service on + port 2444. When the evidence cannot be read, ownership stays unproven: the process is shown as foreign, Stop + refuses to touch it, and a later Start drops the stale record instead of being blocked forever. +- A service that occupies the port but fails the health check is reported as a conflict; if it is provably this + plugin's service, the tab still offers **Stop**. - **Stop** tree-kills the recorded process (process group on POSIX, `taskkill /T /F` on Windows), waits for it to - exit, escalates to `SIGKILL` if it ignores the graceful signal, and clears the record only after the pid is - gone. A service started elsewhere stays untouched and is only observed. -- **Start** is serialized: two requests cannot spawn two services, and a request that arrives while a launch is - still in flight is told so instead of silently pretending to start. + exit, escalates (POSIX: `SIGTERM` then `SIGKILL`; Windows: force `taskkill` again), and clears the record only + after the pid is gone. A service started elsewhere stays untouched and is only observed. +- **Start** is serialized: two requests cannot spawn two services, and a second request observes the launch + already in flight. Before replacing a recorded launch that never became healthy, Start only kills it when + ownership is proven; otherwise it drops the record without touching the process. - **Auto-start** (`autoStart`, off by default) pulls the service up when DSH starts, if it is not already running. -Python probes and pip installs are bounded Host operations; the service is the only intentionally detached -process. +Python probes and pip installs are bounded Host operations; the service is the only process that must outlive DSH. ## Platform support diff --git a/integrations/dsh/docs/environment_ZH.md b/integrations/dsh/docs/environment_ZH.md index 2e9a2945..ef4ac4f3 100644 --- a/integrations/dsh/docs/environment_ZH.md +++ b/integrations/dsh/docs/environment_ZH.md @@ -60,7 +60,7 @@ ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列 - 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。 - 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。 - **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。 -- 日志写入 `$DSH_HOME/reme-memory/install-.log`,页面直接显示尾部。 +- 日志写入 `$DSH_HOME/reme-memory/install-.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`。 - 插件卸载时,仍在运行的安装会被停止。 ## Workspace `.env` @@ -97,15 +97,19 @@ ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把 - 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间), 所以解释器缺失时不会留下残留状态。 -- 只有记录与当前地址一致、pid 仍存活、且进程映像仍是 Python 或 `reme` 时,服务才被判定为“由本插件托管”并给出**停止**按钮。 - 2333 端口上的记录绝不会声称管理 2444 端口上的服务,被复用的 pid 也不会被误认。读不到进程映像时按失败关闭处理: - 显示为外部进程,不给出停止按钮。 -- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,忽略优雅信号时升级为 - `SIGKILL`,并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响,只会被观察。 -- **启动**是串行化的:两个请求不会拉起两个服务;启动仍在进行时到达的请求会被告知,而不是假装正在启动。 +- 只有记录与当前地址一致、pid 仍存活、进程映像仍是 Python 或 `reme`,**且命令行仍包含记录的 `workspace_dir` 与 + `service.port`** 时,服务才被判定为“由本插件托管”并给出**停止**按钮。命令行是防止 pid 复用的关键证据:只看映像名 + 无法证明 pid 属于哪次启动。2333 端口上的记录绝不会声称管理 2444 端口上的服务。证据读不到时按失败关闭处理:显示为 + 外部进程,停止操作不会碰它;之后点启动会丢弃这条过期记录,而不是永久卡住。 +- 服务占用端口但健康检查失败时显示为“端口被占用”;若能证明是本插件启动的,标签页仍给出**停止**按钮。 +- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,必要时升级(POSIX: + `SIGTERM` 后 `SIGKILL`;Windows:再次强制 `taskkill`),并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响, + 只会被观察。 +- **启动**是串行化的:两个请求不会拉起两个服务,后到的请求会观察已在进行的启动。替换“一直没有就绪”的旧记录时,只有 + 归属被证明才会杀掉它;证明不了就只丢弃记录,不碰进程。 - **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。 -Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一被刻意分离出去的进程。 +Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一需要比 DSH 活得更久的进程。 ## 平台支持 diff --git a/integrations/dsh/src/environment/client/service.tsx b/integrations/dsh/src/environment/client/service.tsx index a15d88e7..cafe02d9 100644 --- a/integrations/dsh/src/environment/client/service.tsx +++ b/integrations/dsh/src/environment/client/service.tsx @@ -289,6 +289,12 @@ export function ServiceSection({ }): JSX.Element { const { state, stopping, stopError, log } = control; const [confirming, setConfirming] = useState(false); + // A conflict can still be our own unhealthy service (booting, wrong token); + // offer the same Stop affordance instead of leaving the tab without a way + // out other than editing the record by hand. + const canStop = + state.phase === "running" || + (state.phase === "conflict" && state.data?.managed === true); return (
@@ -338,11 +344,13 @@ export function ServiceSection({ {t("recheck")} - {state.phase === "running" ? ( + {state.phase === "running" || state.phase === "conflict" ? (

{state.data?.managed ? fill(t("managedNote"), { pid: state.data?.pid ?? "" }) - : t("foreignNote")} + : state.phase === "running" + ? t("foreignNote") + : null}

) : null} {state.phase === "conflict" ? ( @@ -359,7 +367,7 @@ export function ServiceSection({

) : null}
- {state.phase === "running" ? ( + {canStop ? ( confirming ? (

{t("stopConfirm")}

diff --git a/integrations/dsh/src/environment/client/tab.tsx b/integrations/dsh/src/environment/client/tab.tsx index b9d570e4..ac2e8c2c 100644 --- a/integrations/dsh/src/environment/client/tab.tsx +++ b/integrations/dsh/src/environment/client/tab.tsx @@ -32,7 +32,7 @@ function isLocalEndpoint(endpoint: string): boolean { const url = new URL(endpoint); return ( url.protocol === "http:" && - ["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname) + ["127.0.0.1", "localhost"].includes(url.hostname) ); } catch { return false; diff --git a/integrations/dsh/src/environment/host/detect.ts b/integrations/dsh/src/environment/host/detect.ts index a2a7ed3a..42231540 100644 --- a/integrations/dsh/src/environment/host/detect.ts +++ b/integrations/dsh/src/environment/host/detect.ts @@ -483,11 +483,15 @@ export async function detectPythonEnvs(io: DetectIO): Promise { const first = windows ? await io.run("where.exe", ["python"], { timeoutMs: 5_000 }) : await io.run("which", ["-a", "python3"], { timeoutMs: 5_000 }); + // A non-zero exit just means "no match"; a spawn/timeout error is a real + // diagnostic and must not disappear from the report. + if (first.error) errors.push(`python: ${safeError(first.error)}`); let stdout = first.stdout; if (!windows) { const extra = await io.run("which", ["-a", "python"], { timeoutMs: 5_000, }); + if (extra.error) errors.push(`python: ${safeError(extra.error)}`); stdout = `${stdout}\n${extra.stdout}`; } for (const exe of parsePathList(stdout)) { diff --git a/integrations/dsh/src/environment/host/install.ts b/integrations/dsh/src/environment/host/install.ts index 662b0508..860c5f91 100644 --- a/integrations/dsh/src/environment/host/install.ts +++ b/integrations/dsh/src/environment/host/install.ts @@ -3,6 +3,7 @@ import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs"; import { join } from "node:path"; import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js"; +import { openAppendLog } from "./log.js"; import { isAbsolutePath } from "./paths.js"; export interface InstallChild { @@ -57,12 +58,17 @@ export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string { fd = openSync(path, "r"); const size = fstatSync(fd).size; const length = Math.min(size, maxBytes); + const start = size - length; const buffer = Buffer.alloc(length); - readSync(fd, buffer, 0, length, Math.max(0, size - length)); - const text = buffer - .toString("utf8") - .replaceAll("\r\n", "\n") - .replaceAll("\r", "\n"); + readSync(fd, buffer, 0, length, start); + let text = buffer.toString("utf8"); + if (start > 0) { + // The cut may be mid-line and even mid-character; drop that partial line + // so the tail never starts with a replacement character. + const firstBreak = text.indexOf("\n"); + text = firstBreak >= 0 ? text.slice(firstBreak + 1) : text; + } + text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n"); return text .split("\n") .filter((line) => line.trim().length > 0) @@ -87,7 +93,7 @@ export function createInstallSpawner( ): InstallSpawner { return { start({ exe, args, logPath }) { - const fd = openSync(logPath, "a"); + const fd = openAppendLog(logPath); let child: ReturnType; try { child = spawn(exe, args, { diff --git a/integrations/dsh/src/environment/host/log.ts b/integrations/dsh/src/environment/host/log.ts new file mode 100644 index 00000000..c4bb2317 --- /dev/null +++ b/integrations/dsh/src/environment/host/log.ts @@ -0,0 +1,24 @@ +import { mkdirSync, openSync, renameSync, statSync } from "node:fs"; +import { dirname } from "node:path"; + +/** Rotate a log once it grows past this size; only one backup is kept. */ +export const LOG_ROTATE_BYTES = 5 * 1024 * 1024; + +/** + * Open a log for appending, rotating an oversized one to `.1` first. + * Install and service logs otherwise grow without bound because both are + * append-only and outlive many sessions. + */ +export function openAppendLog( + path: string, + maxBytes = LOG_ROTATE_BYTES, +): number { + mkdirSync(dirname(path), { recursive: true }); + try { + if (statSync(path).size > maxBytes) renameSync(path, `${path}.1`); + } catch { + // A missing log has nothing to rotate; a locked one (Windows, while the + // service still writes it) is left alone rather than failing the launch. + } + return openSync(path, "a"); +} diff --git a/integrations/dsh/src/environment/host/manager.ts b/integrations/dsh/src/environment/host/manager.ts index b7b71400..7992cf26 100644 --- a/integrations/dsh/src/environment/host/manager.ts +++ b/integrations/dsh/src/environment/host/manager.ts @@ -9,7 +9,11 @@ import { checkReme, MINIMUM_REME, remeVersionAtLeast } from "./reme.js"; import { createNodeRunner } from "./runner.js"; import { createServiceIO, + processAlive, readServiceState, + removeServiceState, + serviceEndpointKey, + serviceOwnership, serviceStatus, startService, stopService, @@ -181,21 +185,30 @@ export class ReMeEnvManager { async serviceStatus(endpoint: string, authToken = ""): Promise { const endpointValue = this.endpoint(endpoint); const persisted = readServiceState(this.serviceStatePath); + // A malformed record must not turn the log path into a relative one. + let logDir = resolveWorkspaceDir("", homedir()); + const recordedDir = persisted?.workspaceDir?.trim() ?? ""; + if (recordedDir) { + try { + logDir = resolveWorkspaceDir(recordedDir, homedir()); + } catch { + // Keep the default log directory. + } + } const status = await serviceStatus(this.serviceIO, { endpoint: endpointValue, statePath: this.serviceStatePath, authToken, - logPath: join( - persisted?.workspaceDir ?? resolveWorkspaceDir("", homedir()), - "service.log", - ), + logPath: join(logDir, "service.log"), }); if (this.starting?.endpoint === endpointValue) { - // Running is the happy end; stopped without a live pid means the launch - // died before it ever listened, so a retry must not be blocked by the - // grace period. A stopped status *with* a pid is a service still booting. + // Running and conflict both mean the port answered: the launch is no + // longer "in flight". Stopped without a live pid means it died before it + // ever listened, so a retry must not be blocked either. A stopped status + // *with* a pid is a service still booting. if ( status.phase === "running" || + status.phase === "conflict" || (status.phase === "stopped" && status.pid === undefined) ) this.starting = undefined; @@ -224,14 +237,20 @@ export class ReMeEnvManager { return run; } - /** Stop the plugin-managed service; a foreign service stays untouched. */ - async serviceStop(endpoint: string): Promise { - const result = await stopService(this.serviceIO, { - statePath: this.serviceStatePath, - endpoint: this.endpoint(endpoint), - }); - if (result.stopped) this.starting = undefined; - return result; + /** + * Stop the plugin-managed service; a foreign service stays untouched. Stop + * joins the start queue, so a request that arrives while a launch is still + * being recorded waits for it and then stops that launch instead of + * reporting "nothing started". + */ + serviceStop(endpoint: string): Promise { + const endpointValue = this.endpoint(endpoint); + const run = this.startQueue.then(() => this.stopLocked(endpointValue)); + this.startQueue = run.then( + () => undefined, + () => undefined, + ); + return run; } /** Tail of the workspace service log. */ @@ -288,6 +307,16 @@ export class ReMeEnvManager { return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`; } + private async stopLocked(endpointValue: string): Promise { + const result = await stopService(this.serviceIO, { + statePath: this.serviceStatePath, + endpoint: endpointValue, + }); + if (result.stopped && this.starting?.endpoint === endpointValue) + this.starting = undefined; + return result; + } + private async startLocked( endpoint: string, pythonPath: string, @@ -301,13 +330,14 @@ export class ReMeEnvManager { const starting = this.starting; if (starting && Date.now() - starting.at < START_GRACE_MS) { if (starting.endpoint === endpointValue) + // A genuine launch is in flight. Observing it is not an error: the + // client keeps polling just as the first request does. return { started: false, alreadyRunning: true, command: "", logPath, startedAt: now, - error: "The service is already starting for this endpoint", }; // The service record is per install, not per endpoint; spawning a second // endpoint's service now would overwrite the first one's record and @@ -332,22 +362,23 @@ export class ReMeEnvManager { startedAt: now, ...(current.version ? { version: current.version } : {}), }; - if (current.phase === "conflict") - return { - started: false, - alreadyRunning: false, - command: "", - logPath, - startedAt: now, - error: "Another process is already listening on this endpoint", - }; - // A previous launch that never became healthy may still be alive. Stop it - // before spawning a replacement, so it cannot be orphaned when the state - // record is overwritten. - if (current.pid !== undefined) { + if (current.phase === "conflict") { + // The port is answered by something that is not a healthy service. A + // provable launch of ours (still booting or rejecting our token) is + // stopped and replaced; anything else is left alone. + if (!current.managed) + return { + started: false, + alreadyRunning: false, + command: "", + logPath, + startedAt: now, + error: "Another process is already listening on this endpoint", + }; const previous = await stopService(this.serviceIO, { statePath: this.serviceStatePath, endpoint: endpointValue, + ownership: "owned", }); if (!previous.stopped) return { @@ -361,6 +392,64 @@ export class ReMeEnvManager { }`, }; } + // A record for a *different* endpoint that is still alive would be + // overwritten by the new launch, leaving that endpoint unable to stop it. + // Only a provably owned process blocks the new start; an unprovable record + // is stale and is discarded, just like the same-endpoint case below. + const recorded = readServiceState(this.serviceStatePath); + if ( + recorded && + recorded.pid !== undefined && + serviceEndpointKey(recorded.endpoint) !== + serviceEndpointKey(endpointValue) && + processAlive(this.serviceIO, recorded.pid) + ) { + const other = await serviceOwnership( + this.serviceIO, + recorded, + recorded.endpoint, + ); + if (other === "owned") + return { + started: false, + alreadyRunning: false, + command: "", + logPath, + startedAt: now, + error: `A service for ${recorded.endpoint} is still running; stop it from that endpoint first`, + }; + removeServiceState(this.serviceStatePath); + } + // A previous launch that never became healthy may still be alive. Stop it + // when ownership is proven; when it cannot be proven, drop the stale record + // without killing anything so a recycled pid is never touched. + if (current.pid !== undefined) { + const ownership = await serviceOwnership( + this.serviceIO, + recorded, + endpointValue, + ); + if (ownership === "owned") { + const previous = await stopService(this.serviceIO, { + statePath: this.serviceStatePath, + endpoint: endpointValue, + ownership, + }); + if (!previous.stopped) + return { + started: false, + alreadyRunning: false, + command: "", + logPath, + startedAt: now, + error: `A previous launch could not be stopped: ${ + previous.reason ?? "unknown reason" + }`, + }; + } else { + removeServiceState(this.serviceStatePath); + } + } const path = pythonPath.trim(); if (!path) return { diff --git a/integrations/dsh/src/environment/host/service.ts b/integrations/dsh/src/environment/host/service.ts index f7bd370d..fbb7c91f 100644 --- a/integrations/dsh/src/environment/host/service.ts +++ b/integrations/dsh/src/environment/host/service.ts @@ -3,7 +3,6 @@ import { closeSync, existsSync, mkdirSync, - openSync, readFileSync, renameSync, rmSync, @@ -14,6 +13,7 @@ import { basename, dirname } from "node:path"; import { safeError } from "../../endpoint.js"; import type { ServiceStartResult, ServiceState } from "../types.js"; +import { openAppendLog } from "./log.js"; /** * The local ReMe service is a *detached* process: it outlives DSH, which is the @@ -65,6 +65,8 @@ export interface ServiceIO { probePid(pid: number): void; /** OS image name of a pid, or "" when it cannot be read. */ processImage(pid: number): Promise; + /** Full command line of a pid, or "" when it cannot be read. */ + processCommandLine?(pid: number): Promise; exists(path: string): boolean; platform: string; } @@ -109,9 +111,11 @@ export function parseLocalEndpoint(endpoint: string): { throw new TypeError( "Local service management requires an http:// endpoint", ); - if (!["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname)) + // The service is launched with `service.host=127.0.0.1`; accepting an IPv6 + // loopback would spawn a process that can never answer that endpoint. + if (!["127.0.0.1", "localhost"].includes(url.hostname)) throw new TypeError( - "Local service management requires a loopback endpoint", + "Local service management requires an IPv4 loopback endpoint", ); const port = Number(url.port || "80"); if (!Number.isInteger(port) || port < 1 || port > 65535) @@ -202,7 +206,8 @@ export function writeServiceState(path: string, state: PersistedService): void { } } -function clearServiceState(path: string): void { +/** Drop the service record without touching the recorded process. */ +export function removeServiceState(path: string): void { try { rmSync(path, { force: true }); } catch { @@ -210,6 +215,8 @@ function clearServiceState(path: string): void { } } +const clearServiceState = removeServiceState; + function alive(io: ServiceIO, pid?: number): boolean { if (!isValidPid(pid)) return false; try { @@ -220,6 +227,11 @@ function alive(io: ServiceIO, pid?: number): boolean { } } +/** Whether a pid is alive, rejecting pids this plugin never records. */ +export function processAlive(io: ServiceIO, pid?: number): boolean { + return alive(io, pid); +} + /** * Whether an OS image name is a process that could be running the ReMe * service: an exact Python-family name or `reme`, never a prefix match (which @@ -231,26 +243,89 @@ export function isServiceImage(image: string): boolean { } /** - * Whether the recorded process is the service this plugin started for the - * endpoint on screen. The saved endpoint must match the requested one, the pid - * must still be alive, and its image must still be a Python (or `reme`) - * executable, which is what a recycled pid would rarely be. + * Ownership of the recorded process, as far as this plugin can prove it. * - * This guards a destructive operation, so it fails *closed*: an unreadable - * image is not proof of ownership. + * - `owned`: endpoint, pid, image and command line all match the launch this + * plugin recorded. + * - `foreign`: at least one piece of evidence contradicts the record. + * - `unknown`: the process exists but its image or command line could not be + * read (localized `tasklist`, `ps` failure, missing permissions). + * + * Stop is destructive, so it only accepts `owned`; starting can discard an + * `unknown` record (without killing it) instead of being blocked forever. + */ +export type ServiceOwnership = "owned" | "foreign" | "unknown"; + +/** Compare paths and ports with separators and case normalized. */ +function normalizeCommandText(value: string): string { + return value.replaceAll("\\\\", "\\").replaceAll("/", "\\").toLowerCase(); +} + +/** + * The recorded launch always passes `workspace_dir=` and + * `service.port=` as argv entries (also inside the Python wrapper's + * `-c` script), so their presence is strong evidence that this pid is the + * process the record describes — not merely another Python that reused the pid. + */ +function commandMatches(command: string, persisted: PersistedService): boolean { + let port: number; + try { + port = parseLocalEndpoint(persisted.endpoint).port; + } catch { + return false; + } + const workspace = persisted.workspaceDir.trim(); + if (!workspace) return false; + const text = normalizeCommandText(command); + const marker = `workspace_dir=${normalizeCommandText(workspace)}`; + const index = text.indexOf(marker); + if (index < 0) return false; + // Require a boundary so `C:\a` does not match another service using `C:\ab`. + const next = text[index + marker.length] ?? ""; + if (next && !/[\s"',\]]/.test(next)) return false; + return new RegExp(`service\\.port=${port}(?!\\d)`).test(text); +} + +export async function serviceOwnership( + io: ServiceIO, + persisted: PersistedService | undefined, + endpoint: string, +): Promise { + if (!persisted || !isValidPid(persisted.pid)) return "foreign"; + if (serviceEndpointKey(persisted.endpoint) !== serviceEndpointKey(endpoint)) + return "foreign"; + if (!alive(io, persisted.pid)) return "foreign"; + let image = ""; + try { + image = await io.processImage(persisted.pid); + } catch { + image = ""; + } + if (!image.trim()) return "unknown"; + if (!isServiceImage(image)) return "foreign"; + let command = ""; + try { + command = (await io.processCommandLine?.(persisted.pid)) ?? ""; + } catch { + command = ""; + } + if (!command.trim()) return "unknown"; + return commandMatches(command, persisted) ? "owned" : "foreign"; +} + +/** + * Whether the recorded process is provably the service this plugin started + * for the endpoint on screen. + * + * This guards a destructive operation, so it fails *closed*: unreadable + * evidence is not proof of ownership. */ export async function ownsService( io: ServiceIO, persisted: PersistedService | undefined, endpoint: string, ): Promise { - if (!persisted || !isValidPid(persisted.pid)) return false; - if (serviceEndpointKey(persisted.endpoint) !== serviceEndpointKey(endpoint)) - return false; - if (!alive(io, persisted.pid)) return false; - const image = await io.processImage(persisted.pid); - if (!image.trim()) return false; - return isServiceImage(image); + return (await serviceOwnership(io, persisted, endpoint)) === "owned"; } /** Running / conflict / stopped, with ownership of the running process. */ @@ -276,11 +351,15 @@ export async function serviceStatus( // A record whose process is gone must not be reported, or a later start // would keep seeing a stale pid. Remove it while nobody can be relying on it. if (persisted && !recordAlive) clearServiceState(options.statePath); + // Ownership is expensive (image plus command line). During boot the service + // is polled every second while `stopped`, where it is not needed, so it is + // computed lazily only for the phases that report it. + const ownership = async (): Promise => + recordForEndpoint && recordAlive + ? serviceOwnership(io, persisted, options.endpoint) + : "foreign"; if (health.ok) { - const managed = - recordForEndpoint && - recordAlive && - (await ownsService(io, persisted, options.endpoint)); + const managed = (await ownership()) === "owned"; return { phase: "running", managed, @@ -300,19 +379,26 @@ export async function serviceStatus( } catch { // A remote or https endpoint is health-checked only; it has no local port. } - if (portOpen) + if (portOpen) { + // Our own service can occupy the port without being healthy (still + // booting, or a rejected token); identify it so the tab can stop it. + const managed = (await ownership()) === "owned"; return { phase: "conflict", - managed: false, + managed, endpoint: options.endpoint, + ...(managed && persisted?.pid !== undefined + ? { pid: persisted.pid } + : {}), logPath: options.logPath, }; + } return { phase: "stopped", managed: false, endpoint: options.endpoint, // A live record for this endpoint means a launch that never became - // healthy; the manager stops it before spawning a replacement. + // healthy; the manager stops or discards it before spawning a replacement. ...(recordForEndpoint && recordAlive && persisted?.pid !== undefined ? { pid: persisted.pid } : {}), @@ -463,6 +549,8 @@ export async function stopService( timeoutMs?: number; forceTimeoutMs?: number; pollMs?: number; + /** Precomputed ownership from the caller; avoids a second process probe. */ + ownership?: ServiceOwnership; }, ): Promise { const persisted = readServiceState(options.statePath); @@ -470,7 +558,16 @@ export async function stopService( if (persisted) clearServiceState(options.statePath); return { stopped: false, reason: "No service started by this plugin" }; } - if (!(await ownsService(io, persisted, options.endpoint))) + const ownership = + options.ownership ?? + (await serviceOwnership(io, persisted, options.endpoint)); + if (ownership === "unknown") + return { + stopped: false, + reason: + "The recorded process identity could not be verified; refusing to stop it", + }; + if (ownership !== "owned") return { stopped: false, reason: "The recorded service does not belong to this endpoint", @@ -509,7 +606,7 @@ export async function stopService( return { stopped: false, pid, - reason: "The service did not exit after SIGKILL", + reason: "The service did not exit after the force-kill", }; } @@ -572,7 +669,7 @@ export function createServiceIO(): ServiceIO { }); }, async spawnService({ exe, args, cwd, logPath, env }) { - const fd = openSync(logPath, "a"); + const fd = openAppendLog(logPath); let child: ReturnType; try { child = spawn(exe, args, { @@ -657,6 +754,37 @@ export function createServiceIO(): ServiceIO { ); }); }, + processCommandLine(pid) { + return new Promise((resolve) => { + if (!isValidPid(pid)) { + resolve(""); + return; + } + if (process.platform === "win32") { + // wmic is absent from recent Windows builds, so ask CIM. The encoding + // prefix keeps non-ASCII workspace paths intact through the pipe. + execFile( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `[Console]::OutputEncoding=[Text.Encoding]::UTF8; (Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`, + ], + { windowsHide: true, timeout: 5000 }, + (error, stdout) => + resolve(error ? "" : String(stdout ?? "").trim()), + ); + return; + } + execFile( + "ps", + ["-p", String(pid), "-o", "args="], + { timeout: 3000 }, + (error, stdout) => resolve(error ? "" : String(stdout ?? "").trim()), + ); + }); + }, exists: existsSync, platform: process.platform, }; diff --git a/integrations/dsh/tests/env-manager.test.mjs b/integrations/dsh/tests/env-manager.test.mjs index 85603a99..6f31d04c 100644 --- a/integrations/dsh/tests/env-manager.test.mjs +++ b/integrations/dsh/tests/env-manager.test.mjs @@ -1,11 +1,20 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ReMeEnvManager } from "../dist/environment/host/manager.js"; -import { writeServiceState } from "../dist/environment/host/service.js"; +import { + readServiceState, + writeServiceState, +} from "../dist/environment/host/service.js"; /** A service adapter that records launches instead of starting processes. */ function fakeServiceIO() { @@ -24,6 +33,7 @@ function fakeServiceIO() { killTree() {}, probePid() {}, processImage: async () => "python.exe", + processCommandLine: async () => "", exists: () => true, platform: "win32", }, @@ -137,6 +147,8 @@ test("stops a previous launch that never became healthy before replacing it", as calls.killTree.push(pid); running = false; }; + io.processCommandLine = async () => + `reme start workspace_dir=${directory} service.port=2334`; const result = await manager.serviceStart( "http://127.0.0.1:2334", @@ -148,6 +160,96 @@ test("stops a previous launch that never became healthy before replacing it", as assert.equal(calls.spawn.length, 1); }); +test("an unprovable previous record is discarded without killing it", async (t) => { + const { manager, calls, directory, io } = managerIn(t); + manager.serviceStatus = async (endpoint) => ({ + phase: "stopped", + managed: false, + endpoint, + pid: 6000, + logPath: join(directory, "service.log"), + }); + const statePath = join(directory, "reme-memory", "service.json"); + writeServiceState(statePath, { + pid: 6000, + endpoint: "http://127.0.0.1:2334", + workspaceDir: directory, + pythonPath: "D:\\env\\python.exe", + command: "reme start", + startedAt: "2026-10-27T00:00:00.000Z", + }); + // The process exists, but its command line cannot be read, so ownership is + // unproven: never kill it, just drop the stale record and start. + io.processCommandLine = async () => ""; + const result = await manager.serviceStart( + "http://127.0.0.1:2334", + "D:\\env\\python.exe", + join(directory, "workspace"), + ); + assert.equal(result.started, true); + assert.deepEqual(calls.killTree, []); + assert.equal(calls.spawn.length, 1); + assert.equal(readServiceState(statePath)?.pid, 5001); +}); + +test("refuses to overwrite a live record owned by another endpoint", async (t) => { + const { manager, calls, directory, io } = managerIn(t); + manager.serviceStatus = async (endpoint) => ({ + phase: "stopped", + managed: false, + endpoint, + logPath: join(directory, "service.log"), + }); + writeServiceState(join(directory, "reme-memory", "service.json"), { + pid: 6000, + endpoint: "http://127.0.0.1:2333", + workspaceDir: directory, + pythonPath: "D:\\env\\python.exe", + command: "reme start", + startedAt: "2026-10-27T00:00:00.000Z", + }); + io.processCommandLine = async () => + `reme start workspace_dir=${directory} service.port=2333`; + const result = await manager.serviceStart( + "http://127.0.0.1:2334", + "D:\\env\\python.exe", + join(directory, "workspace"), + ); + assert.equal(result.started, false); + assert.match(result.error, /still running/); + assert.equal(calls.spawn.length, 0); +}); + +test("stop waits for an in-flight start and then stops that launch", async (t) => { + const { manager, calls, directory, io } = managerIn(t); + let running = true; + io.killTree = (pid) => { + calls.killTree.push(pid); + running = false; + }; + io.probePid = () => { + if (!running) throw new Error("ESRCH"); + }; + io.processCommandLine = async () => + `reme start workspace_dir=${join( + directory, + "workspace", + )} service.port=2334`; + + const start = manager.serviceStart( + "http://127.0.0.1:2334", + "D:\\env\\python.exe", + join(directory, "workspace"), + ); + // Arrives while the start is still being recorded, so it must join the queue + // rather than answering "No service started by this plugin". + const stop = manager.serviceStop("http://127.0.0.1:2334"); + const [started, stopped] = await Promise.all([start, stop]); + assert.equal(started.started, true); + assert.equal(stopped.stopped, true); + assert.deepEqual(calls.killTree, [5001]); +}); + test("probes health from the Host and rejects a non-http endpoint", async (t) => { const { manager } = managerIn(t); const health = await manager.health("http://127.0.0.1:2334"); @@ -182,3 +284,20 @@ test("validates the merged .env, not just the submitted values", async (t) => { ); assert.match(readFileSync(envPath, "utf8"), /^LLM_API_KEY=sk-keep$/m); }); + +test("writes .env atomically with a backup and owner-only mode", (t) => { + const { manager, directory } = managerIn(t); + const envPath = join(directory, ".env"); + writeFileSync( + envPath, + "LLM_API_KEY=sk-old\nEMBEDDING_API_KEY=EMPTY\nLLM_MODEL_NAME=old\n", + ); + + manager.writeEnv(directory, { LLM_MODEL_NAME: "new" }); + assert.match(readFileSync(envPath, "utf8"), /^LLM_MODEL_NAME=new$/m); + const backup = readFileSync(`${envPath}.bak`, "utf8"); + assert.match(backup, /^LLM_MODEL_NAME=old$/m); + assert.match(backup, /^LLM_API_KEY=sk-old$/m); + if (process.platform !== "win32") + assert.equal(statSync(envPath).mode & 0o777, 0o600); +}); diff --git a/integrations/dsh/tests/env-reme.test.mjs b/integrations/dsh/tests/env-reme.test.mjs index ff8b1d0b..31174e4f 100644 --- a/integrations/dsh/tests/env-reme.test.mjs +++ b/integrations/dsh/tests/env-reme.test.mjs @@ -181,8 +181,9 @@ function fakeSpawner() { onExit(callback) { child.exit = callback; }, - kill() { + kill(force = false) { child.killed = true; + child.force = force; }, }; children.push(child); diff --git a/integrations/dsh/tests/env-runner.test.mjs b/integrations/dsh/tests/env-runner.test.mjs index 4e359b7b..51140692 100644 --- a/integrations/dsh/tests/env-runner.test.mjs +++ b/integrations/dsh/tests/env-runner.test.mjs @@ -1,6 +1,18 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + closeSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInstallSpawner } from "../dist/environment/host/install.js"; +import { openAppendLog } from "../dist/environment/host/log.js"; import { createNodeRunner } from "../dist/environment/host/runner.js"; test("a timeout settles with an explicit error instead of hanging", async () => { @@ -28,3 +40,37 @@ test("a child that exits on SIGTERM is still reported as a timeout", async () => ); assert.match(result.error ?? "", /timed out after 200 ms/); }); + +test("the real install spawner starts and stops a process", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "reme-spawner-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const spawner = createInstallSpawner({ PATH: process.env.PATH }); + const logPath = join(directory, "install.log"); + const child = spawner.start({ + exe: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + logPath, + }); + assert.ok(child.pid !== undefined && child.pid > 1); + + const exited = new Promise((resolve) => child.onExit(resolve)); + child.kill(); + const outcome = await Promise.race([ + exited, + new Promise((resolve) => setTimeout(() => resolve("timeout"), 5_000)), + ]); + assert.notEqual(outcome, "timeout", "the install process must actually exit"); + assert.match(readFileSync(logPath, "utf8"), /^$/); +}); + +test("an oversized log is rotated before the next append", (t) => { + const directory = mkdtempSync(join(tmpdir(), "reme-log-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const path = join(directory, "install.log"); + writeFileSync(path, "x".repeat(200)); + + const fd = openAppendLog(path, 100); + closeSync(fd); + assert.equal(readFileSync(`${path}.1`, "utf8").length, 200); + assert.equal(statSync(path).size, 0); +}); diff --git a/integrations/dsh/tests/env-service.test.mjs b/integrations/dsh/tests/env-service.test.mjs index c7c0504b..10f8ddce 100644 --- a/integrations/dsh/tests/env-service.test.mjs +++ b/integrations/dsh/tests/env-service.test.mjs @@ -11,6 +11,7 @@ import { ownsService, parseLocalEndpoint, readServiceState, + serviceOwnership, serviceStatus, startService, stopService, @@ -34,6 +35,7 @@ function fakeIO(overrides = {}) { }, probePid() {}, processImage: async () => "python.exe", + processCommandLine: async () => "", exists: () => true, platform: "win32", ...overrides, @@ -41,6 +43,12 @@ function fakeIO(overrides = {}) { return { io, calls }; } +/** The command line a launch for `statePath` would actually have. */ +function commandFor(statePath, port = 2334) { + const state = readServiceState(statePath); + return `reme start workspace_dir=${state.workspaceDir} service.port=${port}`; +} + function tempState(t, prefix) { const directory = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(directory, { recursive: true, force: true })); @@ -77,6 +85,9 @@ test("accepts only loopback HTTP endpoints and validates the port", () => { () => parseLocalEndpoint("https://127.0.0.1:2333"), /http:\/\//, ); + // The launch binds service.host=127.0.0.1, so an IPv6 loopback endpoint + // would start a service that can never answer it. + assert.throws(() => parseLocalEndpoint("http://[::1]:2333"), /IPv4/); }); test("builds reme start with the workspace cwd contract", () => { @@ -140,6 +151,7 @@ test("derives running, conflict and stopped phases", async (t) => { assert.equal(status.version, "0.4.0.9"); record(statePath); + runningIo.io.processCommandLine = async () => commandFor(statePath); status = await serviceStatus(runningIo.io, base); assert.equal(status.phase, "running"); assert.equal(status.managed, true); @@ -176,6 +188,7 @@ test("a loopback alias still identifies the service this plugin started", async record(statePath, { endpoint: "http://localhost:2334/" }); const { io } = fakeIO({ fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }), + processCommandLine: async () => commandFor(statePath), }); const status = await serviceStatus(io, { endpoint: ENDPOINT, @@ -186,6 +199,23 @@ test("a loopback alias still identifies the service this plugin started", async assert.equal(status.pid, 777); }); +test("a conflict still identifies a provably owned service", async (t) => { + const { directory, statePath } = tempState(t, "reme-conflict-"); + record(statePath); + const { io } = fakeIO({ + portOpen: async () => true, + processCommandLine: async () => commandFor(statePath), + }); + const status = await serviceStatus(io, { + endpoint: ENDPOINT, + statePath, + logPath: join(directory, "service.log"), + }); + assert.equal(status.phase, "conflict"); + assert.equal(status.managed, true); + assert.equal(status.pid, 777); +}); + test("a recycled pid that is no longer the service is not managed", async (t) => { const { statePath } = tempState(t, "reme-pid-"); record(statePath); @@ -217,6 +247,40 @@ test("a recycled pid that is no longer the service is not managed", async (t) => `image ${JSON.stringify(image)} must not be owned`, ); } + // A Python image whose command line describes another launch is foreign, + // not ours; an unreadable command line is unproven, not ownership. + const { io: otherLaunch } = fakeIO({ + processCommandLine: async () => "python -m http.server 2334", + }); + assert.equal( + await serviceOwnership(otherLaunch, readServiceState(statePath), ENDPOINT), + "foreign", + ); + const { io: unproven } = fakeIO(); + assert.equal( + await serviceOwnership(unproven, readServiceState(statePath), ENDPOINT), + "unknown", + ); + assert.equal( + await ownsService(unproven, readServiceState(statePath), ENDPOINT), + false, + ); + // Matching is boundary-aware: a longer workspace path or port is another + // launch, not this one. + const state = readServiceState(statePath); + for (const command of [ + `reme start workspace_dir=${state.workspaceDir}2 service.port=2334`, + `reme start workspace_dir=${state.workspaceDir} service.port=23340`, + ]) { + const { io: longer } = fakeIO({ + processCommandLine: async () => command, + }); + assert.equal( + await serviceOwnership(longer, state, ENDPOINT), + "foreign", + command, + ); + } }); test("a dead record is cleared instead of reporting a stale pid", async (t) => { @@ -286,7 +350,9 @@ test("records nothing when the spawn fails", async (t) => { test("stops the managed pid and clears the state file after it exits", async (t) => { const { directory, statePath } = tempState(t, "reme-stop-"); record(statePath, { pid: 999, workspaceDir: directory }); - const { io, calls } = fakeIO(); + const { io, calls } = fakeIO({ + processCommandLine: async () => commandFor(statePath), + }); let running = true; io.probePid = () => { if (!running) throw new Error("ESRCH"); @@ -313,7 +379,9 @@ test("stops the managed pid and clears the state file after it exits", async (t) test("escalates to SIGKILL and keeps the record when the service never exits", async (t) => { const { directory, statePath } = tempState(t, "reme-stubborn-"); record(statePath, { pid: 999, workspaceDir: directory }); - const { io, calls } = fakeIO(); + const { io, calls } = fakeIO({ + processCommandLine: async () => commandFor(statePath), + }); const stopped = await stopService(io, { statePath, endpoint: ENDPOINT, @@ -340,6 +408,17 @@ test("refuses to stop a service recorded for another endpoint", async (t) => { assert.ok(readServiceState(statePath), "the foreign record is left in place"); }); +test("refuses to stop a process whose identity cannot be verified", async (t) => { + const { statePath } = tempState(t, "reme-unknown-"); + record(statePath); + const { io, calls } = fakeIO(); // image readable, command line not + const stopped = await stopService(io, { statePath, endpoint: ENDPOINT }); + assert.equal(stopped.stopped, false); + assert.match(stopped.reason, /could not be verified/); + assert.deepEqual(calls.killTree, []); + assert.equal(readServiceState(statePath)?.pid, 777); +}); + /* ------------------------------------------------------------------ */ /* Real IO: the pieces the fake adapter above cannot vouch for. */ /* ------------------------------------------------------------------ */ @@ -402,6 +481,7 @@ test("real IO spawns, identifies and stops a detached process", async (t) => { assert.ok(pid !== undefined && pid > 1); assert.doesNotThrow(() => io.probePid(pid)); assert.doesNotMatch(await io.processImage(pid), /^$/); + assert.match(await io.processCommandLine(pid), /node/i); io.killTree(pid); const deadline = Date.now() + 5_000; diff --git a/integrations/dsh/tests/env-tab.test.mjs b/integrations/dsh/tests/env-tab.test.mjs index 43e0eeff..1336b452 100644 --- a/integrations/dsh/tests/env-tab.test.mjs +++ b/integrations/dsh/tests/env-tab.test.mjs @@ -116,8 +116,9 @@ function texts(node, found = []) { return found; } -async function mount(overrides = {}) { +async function mount(overrides = {}, options = {}) { const calls = { mutate: [], rpc: [] }; + const responses = { ...RESPONSES, ...(options.responses ?? {}) }; const listeners = new Set(); // A new object per change, the way the Host-backed form publishes a snapshot. let snapshot = { @@ -142,13 +143,13 @@ async function mount(overrides = {}) { getSnapshot: () => snapshot, mutate: async (ops, revision) => { calls.mutate.push({ ops, revision }); - return true; + return options.mutate ? options.mutate() : true; }, }; const rpc = { call: async (_path, method, payload) => { calls.rpc.push({ method, input: payload?.args?.input }); - return { ok: true, value: RESPONSES[method] ?? null }; + return { ok: true, value: responses[method] ?? null }; }, }; @@ -171,7 +172,13 @@ async function mount(overrides = {}) { }; for (const listener of listeners) listener(); }, - restore: () => listeners.clear(), + restore: () => { + // Unmount so the effect cleanup paths (aborts, timers) actually run. + act(() => { + renderer.unmount(); + }); + listeners.clear(); + }, }; } @@ -352,3 +359,82 @@ test("probes the service from the Host, never from the browser", async (t) => { "a local endpoint is covered by the service probe already", ); }); + +test("a remote endpoint never runs the local setup steps", async (t) => { + const { calls, restore } = await mount( + { endpoint: "http://10.0.0.5:2333" }, + { + responses: { + "remeEnv/health": { + endpoint: "http://10.0.0.5:2333", + ok: false, + version: "", + error: "unreachable", + checkedAt: new Date().toISOString(), + }, + }, + }, + ); + t.after(restore); + const methods = calls.rpc.map((call) => call.method); + assert.ok( + methods.includes("remeEnv/health"), + "a remote endpoint is only health-checked", + ); + for (const local of [ + "remeEnv/python", + "remeEnv/checkReme", + "remeEnv/readEnv", + "remeEnv/serviceStatus", + ]) + assert.equal(methods.includes(local), false, `${local} must not run`); +}); + +test("shows why Stop left the service alone", async (t) => { + const { renderer, restore } = await mount( + {}, + { + responses: { + "remeEnv/serviceStatus": { + phase: "running", + managed: true, + pid: 98820, + version: "0.4.0.9", + endpoint: "http://127.0.0.1:2333", + logPath: "C:\\Users\\test\\.reme\\service.log", + }, + "remeEnv/serviceStop": { + stopped: false, + reason: "The recorded process identity could not be verified", + }, + }, + }, + ); + t.after(restore); + await act(async () => { + buttonByText(renderer, envZh.stopService).props.onClick(); + }); + await act(async () => { + buttonByText(renderer, envZh.stopService).props.onClick(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + assert.match(texts(renderer.toJSON()).join(" "), /could not be verified/); +}); + +test("surfaces a rejected settings write", async (t) => { + const { renderer, restore } = await mount({}, { mutate: async () => false }); + t.after(restore); + const checkbox = renderer.root.findAll( + (node) => node.type === "input" && node.props.type === "checkbox", + )[0]; + assert.ok(checkbox); + await act(async () => { + checkbox.props.onChange({ target: { checked: true } }); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + assert.match(texts(renderer.toJSON()).join(" "), /设置未保存/); +});