fix(dsh): harden environment management from review

- wire the package mirror into installs
- wait for service exit, escalate to SIGKILL, keep the record on failure
- fail closed on ownership and clear dead service records
- clear the start lock on failure and reject overlapping starts
- bound probes, make config writes atomic, scope auth tokens
- fix docs and add regression tests
This commit is contained in:
LiangshouX 2026-10-28 23:13:49 +08:00
parent 559efcee28
commit fddeb8d710
31 changed files with 1458 additions and 317 deletions

View file

@ -110,24 +110,24 @@ If an earlier upgrade already renamed `settings.yaml` to `settings.yaml.imported
![ReMe Memory plugin configuration](./figures/reme-memory-settings.png)
| UI meaning | Configuration key | Default | Description |
| ---------------------- | ---------------------- | ----------------------- | --------------------------------------------------------------------------------------------------- |
| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. |
| Auth token (optional) | `authToken` | empty | Bearer token for a self-hosted gateway; stored in clear text in the profile, trusted networks only. |
| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. |
| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. |
| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. |
| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. |
| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. |
| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. |
| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. |
| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. |
| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. |
| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. |
| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. |
| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. |
| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. |
| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. |
| UI meaning | Configuration key | Default | Description |
| ---------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. |
| Auth token (optional) | `authToken` | empty | Bearer token for a self-hosted gateway; set in the profile only (not shown in this form), stored in clear text, trusted networks only. |
| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. |
| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. |
| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. |
| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. |
| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. |
| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. |
| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. |
| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. |
| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. |
| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. |
| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. |
| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. |
| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. |
| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. |
Deployment configuration also supports `REME_URL`, or `REME_HOST` together with `REME_PORT`. The timer-only test option `dreamIntervalMs` is intentionally excluded from user settings.

View file

@ -115,24 +115,24 @@ dsh web --no-open --port 3090
截图中的测试配置使用 `http://127.0.0.1:3457`、English 指引、默认搜索数量 5、搜索超时 10 秒,并启用了自动记忆和“Exclude subagents”。完整字段如下:
| 界面含义 | 配置键 | 默认值 | 说明 |
| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------- |
| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 |
| 服务令牌(可选) | `authToken` | 空 | 自部署网关的 Bearer 令牌;明文保存在 profile 中,仅建议内网/受信环境使用。 |
| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 |
| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 |
| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 |
| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 |
| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 |
| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 |
| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 |
| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 |
| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 |
| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 |
| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 |
| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 |
| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 |
| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 |
| 界面含义 | 配置键 | 默认值 | 说明 |
| -------------------- | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| 服务地址 | `endpoint` | `http://127.0.0.1:2333` | DSH 访问的绝对 HTTP(S) 地址,不支持 URL 内嵌凭据、查询参数或片段。 |
| 服务令牌(可选) | `authToken` | 空 | 自部署网关的 Bearer 令牌;仅通过 profile 配置(表单中不显示),明文保存,仅建议内网/受信环境使用。 |
| 记忆指引语言 | `language` | `en` | `en` 或 `zh`;决定新会话中注入的指引语言。 |
| 默认搜索数量 | `searchLimit` | `5` | `reme_search` 未传 `limit` 时的默认结果上限,范围 1–50。 |
| 搜索超时(毫秒) | `requestTimeoutMs` | `10000` | `search` 请求的超时,范围 1,000–120,000。 |
| 自动记录已完成的对话 | `autoMemoryEnabled` | `true` | 是否捕获完成的用户/助手回合并提交给 `auto_memory`。 |
| 排除子 Agent | `rootAgentsOnly` | `true` | 开启时,只为根 Agent 注入指引并捕获对话。 |
| 自动记忆提交间隔 | `autoMemoryInterval` | `5` | 每完成多少轮对话提交一次,范围 1–1,000。退出时插件会在时间预算内尽力排空。 |
| 自动整理记忆 | `autoDreamEnabled` | `true` | 是否按照每日计划调用 `auto_dream`。 |
| 整理计划 | `dreamCron` | `0 23 * * *` | 五段 cron 表达式,按 `timezone` 解释;默认每天 23:00。 |
| 整理指引 | `dreamHint` | 空 | 可选的整理提示,原样交给 `auto_dream`。 |
| 工作区时区 | `timezone` | `Asia/Shanghai` | 有效的 IANA 时区,用于每日批次和 cron 计划。 |
| 后台任务超时(毫秒) | `backgroundTimeoutMs` | `3600000` | `auto_memory` 和 `auto_dream` 的最大等待时间。 |
| 退出等待(毫秒) | `shutdownTimeoutMs` | `5000` | DSH 退出时等待后台任务排空的时间预算。 |
| 失败重试间隔(毫秒) | `autoMemoryRetryMs` | `1000` | 首次失败后的等待时间,连续失败时翻倍。 |
| 最大重试间隔(毫秒) | `autoMemoryRetryMaxMs` | `60000` | 重试等待时间的上限。 |
部署层还支持 `REME_URL`,或组合使用 `REME_HOST` 与 `REME_PORT`。仅供定时器测试的 `dreamIntervalMs` 不出现在用户设置中。

View file

@ -28,10 +28,11 @@ time and the two refresh actions.
## Python discovery
ReMe needs **Python 3.11 or newer**. The tab probes these channels on the Host and keeps only interpreters that
answered `--version`:
answered its verification snippet (a `-c` probe reporting `sys.executable`, the version and the base prefix):
- Windows: PEP 514 registry entries (`HKLM`/`HKCU`), the `py` launcher list, `PATH`, and `conda env list`.
- macOS and Linux: `PATH`, `/usr/bin`, Homebrew and `pyenv` locations, and `conda env list`.
- macOS and Linux: `PATH` (`which -a`), the well-known `/usr/bin`, `/usr/local`, Homebrew (`/opt/homebrew`),
`~/.pyenv/shims` and `~/.local/bin` locations, and `conda env list`.
Microsoft Store placeholder links under `WindowsApps` are skipped, because they are shell aliases rather than
interpreters. When conda is present, named environments are listed both under `conda env list` and as
@ -91,6 +92,8 @@ DSH settings.
component unconditionally, so `EMBEDDING_API_KEY` must be non-empty even without vector search (`EMPTY` is the
documented placeholder). A duplicated key is rewritten in every position, and the file keeps its own line
ending.
- The write is atomic and owner-only (0600 on POSIX); the previous file is kept as `.env.bak`, so a failed save
cannot lose unrelated keys.
- **Test LLM connection** runs a one-token `chat/completions` request from inside the workspace with the selected
interpreter, and adds a hint for `401`/`403` (key), `404` (model or base URL) and network errors.
- Drafts are bound to the workspace they were typed in: switching the workspace drops them, so a key typed for
@ -115,10 +118,13 @@ processes it manages when the Host exits. The plugin owns a small service adapte
**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.
- **Stop** tree-kills the recorded process (on Windows `taskkill /T /F`) and clears the record. A service started
elsewhere stays untouched and is only observed.
- **Start** is serialized: two requests cannot spawn two services, and the second observes the first launch.
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.
- **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.
- **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
@ -131,7 +137,7 @@ process.
| DSH | `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, `0.2.2-alpha.2` |
| Node.js | `^22.19.0` or `>=24.0.0`; current DSH releases need Node 24.9 or newer, and the compatibility matrix is validated with Node 24.16 |
| Windows | Tested on Windows 11 with Miniconda: registry/PATH/conda discovery, `tasklist`/`taskkill` for identity and stop, `PYTHONUTF8`/`PYTHONIOENCODING` for pip output |
| macOS / Linux | Discovery, detached start and `SIGTERM` stop are covered by unit tests; documented but less exercised on real machines than Windows |
| macOS / Linux | Discovery, detached spawn, identity lookup and stop are covered by unit tests that run a real child process; still less exercised on real machines than Windows |
## Troubleshooting
@ -152,7 +158,7 @@ the file is left unchanged.
**The service reports “Port occupied”.** Something else listens on the endpoint. Change the port in the service
URL (Memory settings) or stop the other program.
**The service does not become healthy.** The Start action polls for 90 seconds and then reports a timeout. Use
**The service does not become healthy.** The Start action polls for 120 seconds and then reports a timeout. Use
**View log** (`$DSH_HOME`-independent: the workspace `service.log`) for the startup trace — a missing `reme-ai`,
an invalid `.env`, or a port conflict all appear there.

View file

@ -24,10 +24,12 @@
## Python 探测
ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列来源,只保留能回应 `--version` 的解释器:
ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列来源,只保留能通过验证片段(上报 `sys.executable`、
版本号与 base prefix 的 `-c` 探测)的解释器:
- Windows:PEP 514 注册表项(`HKLM`/`HKCU`)、`py` 启动器列表、`PATH`、`conda env list`。
- macOS / Linux:`PATH`、`/usr/bin`、Homebrew 与 `pyenv` 常见路径、`conda env list`。
- macOS / Linux:`PATH`(`which -a`)、常见的 `/usr/bin`、`/usr/local`、Homebrew(`/opt/homebrew`)、
`~/.pyenv/shims` 与 `~/.local/bin` 路径,以及 `conda env list`。
`WindowsApps` 下的 Microsoft Store 占位符会被跳过,因为它们只是别名而不是解释器。装有 conda 时,命名环境会同时出现在
`conda env list` 与环境列表中,conda 根目录单独上报。
@ -75,6 +77,7 @@ ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把
- 清空非密钥字段并保存即表示**删除**该值。只有编辑过的字段会被写入,文件其余部分的注释、未知键与顺序保持不变。
- 保存时会校验合并后的整份文件,而不只是本次输入:ReMe 0.4.0.x 会无条件实例化 embedding 组件,因此即使不使用向量检索
`EMBEDDING_API_KEY` 也必须非空(`EMPTY` 是文档化的占位值)。重复出现的键会在每一处被改写,文件原有的换行风格也会保留。
- 写入是原子且仅属主可读写的(POSIX 上为 0600);旧文件会保留为 `.env.bak`,因此一次失败的保存不会丢掉其它键。
- **测试 LLM 连接**在 workspace 内、用所选解释器发送一次只需 1 个 token 的 `chat/completions` 请求,并对 `401`/`403`
(密钥)、`404`(模型名或 Base URL)与网络错误给出提示。
- 草稿与输入它的 workspace 绑定:切换 workspace 会丢弃草稿,因此为某个 workspace 输入的密钥不会被保存到另一个。
@ -95,9 +98,11 @@ ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把
- 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间),
所以解释器缺失时不会留下残留状态。
- 只有记录与当前地址一致、pid 仍存活、且进程映像仍是 Python 或 `reme` 时,服务才被判定为“由本插件托管”并给出**停止**按钮。
2333 端口上的记录绝不会声称管理 2444 端口上的服务,被复用的 pid 也不会被误认。
- **停止**会树杀记录的进程(Windows 上为 `taskkill /T /F`)并清除记录。别处启动的服务不受影响,只会被观察。
- **启动**是串行化的:两个请求不会拉起两个服务,第二个会看到第一个的启动结果。
2333 端口上的记录绝不会声称管理 2444 端口上的服务,被复用的 pid 也不会被误认。读不到进程映像时按失败关闭处理:
显示为外部进程,不给出停止按钮。
- **停止**会树杀记录的进程(POSIX 上按进程组、Windows 上为 `taskkill /T /F`),等待其退出,忽略优雅信号时升级为
`SIGKILL`,并**在 pid 真正消失之后**才清除记录。别处启动的服务不受影响,只会被观察。
- **启动**是串行化的:两个请求不会拉起两个服务;启动仍在进行时到达的请求会被告知,而不是假装正在启动。
- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。
Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一被刻意分离出去的进程。
@ -109,7 +114,7 @@ Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一被刻
| DSH | `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2`、`0.2.2-alpha.2` |
| Node.js | `^22.19.0` 或 `>=24.0.0`;当前 DSH 需要 Node 24.9 以上,兼容矩阵以 Node 24.16 验证 |
| Windows | 在 Windows 11 + Miniconda 上实测:注册表/PATH/conda 探测、`tasklist`/`taskkill` 身份校验与停止、`PYTHONUTF8`/`PYTHONIOENCODING` 保证 pip 输出不乱码 |
| macOS/Linux | 探测、分离启动与 `SIGTERM` 停止均有单元测试覆盖;真机验证程度低于 Windows |
| macOS/Linux | 探测、分离启动、身份查询与停止均有运行真实子进程的单元测试覆盖;真机验证程度仍低于 Windows |
## 常见问题
@ -126,7 +131,7 @@ Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一被刻
**服务显示“端口被占用”。** 该端口被其它程序占用。请在服务地址(记忆设置)中换一个端口,或停止占用它的程序。
**服务迟迟未就绪。** 启动会轮询 90 秒后报超时。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、
**服务迟迟未就绪。** 启动会轮询 120 秒后报超时。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、
`.env` 不合法、端口冲突都会体现在那里。
**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从

View file

@ -357,6 +357,8 @@ function ReMeSettingsCard({
<button
type="button"
role="tab"
id="reme-tab-memory"
aria-controls="reme-tabpanel-memory"
aria-selected={tab === "memory"}
className={tab === "memory" ? "active" : ""}
onClick={() => setTab("memory")}
@ -366,6 +368,8 @@ function ReMeSettingsCard({
<button
type="button"
role="tab"
id="reme-tab-environment"
aria-controls="reme-tabpanel-environment"
aria-selected={tab === "environment"}
className={tab === "environment" ? "active" : ""}
onClick={() => setTab("environment")}
@ -373,71 +377,79 @@ function ReMeSettingsCard({
{t("tabEnvironment")}
</button>
</div>
{tab === "environment" ? (
rpc && envT ? (
<ReMeEnvironmentTab scope={scope} rpc={rpc} t={envT} />
) : (
<div
role="tabpanel"
id={`reme-tabpanel-${tab}`}
aria-labelledby={`reme-tab-${tab}`}
>
{tab === "environment" ? (
rpc && envT ? (
<ReMeEnvironmentTab scope={scope} rpc={rpc} t={envT} />
) : (
<p className="reme-settings-muted">{t("loading")}</p>
)
) : value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
)
) : value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
) : (
<>
{shell.dirty ? (
<p className="reme-settings-pending">{t("unsaved")}</p>
) : null}
<fieldset
className="reme-settings-fields"
disabled={busy || !shell.writable}
>
<SettingsForm draft={field} edit={edit} t={t} />
</fieldset>
{!shell.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{blocked ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!shell.writable || busy}
onClick={reset}
) : (
<>
{shell.dirty ? (
<p className="reme-settings-pending">{t("unsaved")}</p>
) : null}
<fieldset
className="reme-settings-fields"
disabled={busy || !shell.writable}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{shell.failed ? (
<span className="reme-settings-error">{t("saveFailed")}</span>
) : null}
{saved && !shell.dirty ? (
<span className="reme-settings-success">{t("saved")}</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!shell.dirty || busy}
onClick={discard}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={!shell.writable || !shell.dirty || blocked || busy}
onClick={() => void save()}
>
{t(busy ? "saving" : "save")}
</button>
</div>
</>
)}
<SettingsForm draft={field} edit={edit} t={t} />
</fieldset>
{!shell.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{blocked ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!shell.writable || busy}
onClick={reset}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{shell.failed ? (
<span className="reme-settings-error">
{t("saveFailed")}
</span>
) : null}
{saved && !shell.dirty ? (
<span className="reme-settings-success">{t("saved")}</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!shell.dirty || busy}
onClick={discard}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={!shell.writable || !shell.dirty || blocked || busy}
onClick={() => void save()}
>
{t(busy ? "saving" : "save")}
</button>
</div>
</>
)}
</div>
</div>
</div>
);

View file

@ -1,6 +1,10 @@
import type { SettingsFieldSpec } from "@deepseek-ai/dsh-client-ui-primitives";
import { validEndpoint } from "../endpoint.js";
import { validTimezone } from "../scheduling.js";
// Re-exported for callers that imported it from this module before it moved.
export { validTimezone };
/**
* How each memory-settings field converts between its stored value and the text
@ -84,16 +88,6 @@ export function timeFromCron(cron: string): string {
).padStart(2, "0")}`;
}
/** A timezone the browser's `Intl` accepts, which is what DSH validates with. */
export function validTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en", { timeZone: value.trim() }).format(0);
return value.trim().length > 0;
} catch {
return false;
}
}
export const SETTINGS_SPECS: SettingsFieldSpec[] = [
{
// The endpoint loses its trailing slashes on the way in, the way the
@ -133,6 +127,8 @@ export const SETTINGS_SPECS: SettingsFieldSpec[] = [
field: "timezone",
format: (value) => (typeof value === "string" ? value : ""),
parse: (text) =>
validTimezone(text) ? { kind: "set", value: text.trim() } : undefined,
validTimezone(text.trim())
? { kind: "set", value: text.trim() }
: undefined,
},
];

View file

@ -1186,6 +1186,9 @@ function diagnosticClient(settings: ReMeSettings): ReMeClient {
endpoint: settings.endpoint,
requestTimeoutMs: settings.requestTimeoutMs,
backgroundTimeoutMs: settings.backgroundTimeoutMs,
// The memory runtime authenticates, so diagnostics must too; otherwise a
// gateway-protected service shows 401s that look like a broken workspace.
authToken: settings.authToken,
});
}

View file

@ -31,7 +31,7 @@ export interface DependencyState {
/** Wall clock tick while an installation runs, for the elapsed label. */
now: number;
recheck(): void;
start(upgrade: boolean): Promise<void>;
start(upgrade: boolean, indexUrl?: string): Promise<void>;
cancel(): Promise<void>;
dismiss(): void;
}
@ -42,9 +42,14 @@ export interface DependencyState {
*/
export function useDependency(
rpc: StatusRpc,
options: { pythonPath: string; onInstalled(): void },
options: {
pythonPath: string;
/** False for a remote endpoint, whose local steps are hidden. */
enabled?: boolean;
onInstalled(): void;
},
): DependencyState {
const { pythonPath, onInstalled } = options;
const { pythonPath, enabled = true, onInstalled } = options;
const [check, setCheck] = useState<CheckState>({
phase: "idle",
forPath: "",
@ -56,7 +61,7 @@ export function useDependency(
installed.current = onInstalled;
useEffect(() => {
if (!pythonPath) return;
if (!enabled || !pythonPath) return;
const controller = new AbortController();
void (async () => {
try {
@ -66,6 +71,7 @@ export function useDependency(
{ pythonPath },
controller.signal,
);
if (controller.signal.aborted) return;
setCheck({
phase: "ready",
data: parseRemeCheck(value),
@ -82,18 +88,28 @@ export function useDependency(
}
})();
return () => controller.abort();
}, [rpc, pythonPath, tick]);
}, [rpc, pythonPath, tick, enabled]);
// Progress belongs to an interpreter: switching interpreters must not keep
// showing (or cancelling) a job that was started for the previous one.
const installFor = useRef(pythonPath);
useEffect(() => {
if (installFor.current === pythonPath) return;
installFor.current = pythonPath;
setInstall({ phase: "idle" });
}, [pythonPath]);
// Restoring a running job is per interpreter: a job started for another
// interpreter belongs to that interpreter's section, not this one.
useEffect(() => {
if (!pythonPath) return;
if (!enabled || !pythonPath) return;
const controller = new AbortController();
void (async () => {
try {
const status = parseInstallStatus(
await envCall(rpc, "installStatus", {}, controller.signal),
);
if (controller.signal.aborted) return;
if (status?.phase !== "running" || status.pythonPath !== pythonPath)
return;
setInstall({ phase: "running", jobId: status.id, job: status });
@ -102,7 +118,7 @@ export function useDependency(
}
})();
return () => controller.abort();
}, [rpc, pythonPath]);
}, [rpc, pythonPath, enabled]);
const jobId = install.jobId;
useEffect(() => {
@ -161,12 +177,16 @@ export function useDependency(
}, []);
const start = useCallback(
async (upgrade: boolean) => {
async (upgrade: boolean, indexUrl?: string) => {
if (!pythonPath || install.phase === "running") return;
setInstall({ phase: "idle" });
try {
const status = parseInstallStatus(
await envCall(rpc, "install", { pythonPath, upgrade }),
await envCall(rpc, "install", {
pythonPath,
upgrade,
...(indexUrl ? { indexUrl } : {}),
}),
);
setInstall(
status && status.phase === "running"
@ -235,6 +255,8 @@ export function DependencySection({
? { tone: "progress" as const, text: t("depMissing") }
: checkData.versionOk && checkData.hasCore
? { tone: "ok" as const, text: t("depReady") }
: checkData.versionOk
? { tone: "progress" as const, text: t("coreMissing") }
: { tone: "progress" as const, text: t("depUpgrade") };
return (
@ -279,6 +301,9 @@ export function DependencySection({
{checkError ? `: ${checkError}` : ""}
</p>
) : null}
{checkData?.error ? (
<p className="reme-settings-error">{checkData.error}</p>
) : null}
{checkData ? (
<div className="reme-env-metrics">
<div className="reme-env-metric">
@ -361,7 +386,12 @@ export function DependencySection({
className="reme-settings-save"
onClick={() => {
setConfirming(false);
void dependency.start(!checkData.versionOk);
// The mirror choice reaches pip as `--index-url`; without
// it the selector would be decorative.
void dependency.start(
!checkData.versionOk,
mirror || undefined,
);
}}
>
{t("confirmInstall")}

View file

@ -1,6 +1,5 @@
/** Copy for the Environment tab, registered under `reme.environment`. */
export const envEn = {
connectionTitle: "Service connection",
endpointLabel: "Service URL",
checking: "Checking…",
connected: "Connected",
@ -19,7 +18,6 @@ export const envEn = {
"Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
miniconda: "Miniconda download",
recommended: "recommended",
okBadge: "OK",
tooOld: "Needs 3.11+",
originConda: "Conda ({name})",
originPythonOrg: "python.org installer",
@ -103,7 +101,7 @@ export const envEn = {
serviceRunning: "Running",
startService: "Start service",
stopService: "Stop service",
startTimeout: "The service did not become healthy within 90 seconds",
startTimeout: "The service did not become healthy within 120 seconds",
stopConfirm:
"Stop the ReMe service? Automatic memory in flight may need a retry.",
foreignNote:
@ -115,6 +113,8 @@ export const envEn = {
autoStartLabel: "Start the service automatically with DSH",
managedNote: "Managed by this plugin · PID {pid}",
serviceError: "Service operation failed",
settingsWriteFailed:
"The setting was not saved. The form may have changed in another window; retry.",
configTitle: "ReMe parameters",
controlTitle: "Service control",
heroEyebrow: "ReMe local runtime",
@ -131,8 +131,6 @@ export const envEn = {
scannedAt: "Detected at {time}",
currentInterpreter: "Current interpreter",
notInDetection: "not detected",
autoSelected:
"The recommended interpreter is used until you pick another one.",
savingSelection: "Saving…",
changeInterpreter: "Change interpreter",
collapseList: "Collapse list",
@ -153,7 +151,6 @@ export const envEn = {
};
export const envZh: typeof envEn = {
connectionTitle: "服务连接",
endpointLabel: "服务地址",
checking: "正在检查…",
connected: "已连接",
@ -171,7 +168,6 @@ export const envZh: typeof envEn = {
"可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
miniconda: "下载 Miniconda",
recommended: "推荐",
okBadge: "可用",
tooOld: "需要 3.11+",
originConda: "Conda({name})",
originPythonOrg: "python.org 安装器",
@ -252,7 +248,7 @@ export const envZh: typeof envEn = {
serviceRunning: "运行中",
startService: "启动服务",
stopService: "停止服务",
startTimeout: "服务在 90 秒内未就绪",
startTimeout: "服务在 120 秒内未就绪",
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
@ -261,6 +257,7 @@ export const envZh: typeof envEn = {
autoStartLabel: "随 DSH 启动时自动拉起服务",
managedNote: "由本插件托管 · PID {pid}",
serviceError: "服务操作失败",
settingsWriteFailed: "设置未保存;表单可能已在其他窗口更新,请重试。",
configTitle: "ReMe 参数",
controlTitle: "服务控制",
heroEyebrow: "ReMe 本地运行时",
@ -277,7 +274,6 @@ export const envZh: typeof envEn = {
scannedAt: "探测于 {time}",
currentInterpreter: "当前解释器",
notInDetection: "不在探测结果中",
autoSelected: "当前使用推荐解释器;选择其他解释器后会写入配置。",
savingSelection: "正在保存…",
changeInterpreter: "更换解释器",
collapseList: "收起列表",

View file

@ -40,10 +40,12 @@ export function usePython(
options: {
pythonPath: string;
writable: boolean;
/** False for a remote endpoint, whose local steps are hidden. */
enabled?: boolean;
onSelect(exe: string): Promise<unknown>;
},
): PythonSelection {
const { pythonPath, writable, onSelect } = options;
const { pythonPath, writable, enabled = true, onSelect } = options;
const [detection, setDetection] = useState<DetectionState>({
phase: "loading",
});
@ -51,6 +53,7 @@ export function usePython(
const [saving, setSaving] = useState("");
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
void (async () => {
try {
@ -60,6 +63,7 @@ export function usePython(
{ refresh: tick > 0 },
controller.signal,
);
if (controller.signal.aborted) return;
setDetection({ phase: "ready", data: parseDetection(value) });
} catch (error) {
if (!controller.signal.aborted)
@ -67,7 +71,7 @@ export function usePython(
}
})();
return () => controller.abort();
}, [rpc, tick]);
}, [rpc, tick, enabled]);
const detected = detection.phase === "ready" ? detection.data : undefined;
const suggested = detected?.suggested ?? "";
@ -215,11 +219,6 @@ export function PythonSection({
</span>
</div>
) : null}
{!selectedPath && suggested ? (
<p className="reme-settings-muted reme-env-note">
{t("autoSelected")}
</p>
) : null}
{others.length > 0 ? (
<div className="reme-env-picker">
<div className="reme-env-picker-bar">

View file

@ -48,66 +48,168 @@ export async function envCall(
}
function record(value: unknown, what: string): Record<string, unknown> {
if (typeof value !== "object" || value === null)
if (typeof value !== "object" || value === null || Array.isArray(value))
throw new Error(`Invalid ${what} response`);
return value as Record<string, unknown>;
}
/** Guard a detection payload: the interpreter list is what the tab needs. */
function fail(what: string): never {
throw new Error(`Invalid ${what} response`);
}
function stringField(
body: Record<string, unknown>,
key: string,
what: string,
): string {
const value = body[key];
if (typeof value !== "string") fail(what);
return value;
}
function booleanField(
body: Record<string, unknown>,
key: string,
what: string,
): boolean {
const value = body[key];
if (typeof value !== "boolean") fail(what);
return value;
}
function arrayField(
body: Record<string, unknown>,
key: string,
what: string,
): unknown[] {
const value = body[key];
if (!Array.isArray(value)) fail(what);
return value;
}
/**
* Guard a detection payload. The renderers dereference `errors`, `origin` and
* the per-interpreter fields, so a Host contract drift must surface as an
* error in the section, not as a blank page.
*/
export function parseDetection(value: unknown): PythonDetection {
const body = record(value, "environment detection");
if (!Array.isArray(body.envs))
throw new Error("Invalid environment detection response");
const what = "environment detection";
const body = record(value, what);
stringField(body, "platform", what);
stringField(body, "scannedAt", what);
stringField(body, "suggested", what);
stringField(body, "condaRoot", what);
arrayField(body, "errors", what);
for (const item of arrayField(body, "envs", what)) {
const env = record(item, what);
stringField(env, "exe", what);
stringField(env, "version", what);
booleanField(env, "ok", what);
record(env.origin, what);
}
return value as PythonDetection;
}
export function parseRemeCheck(value: unknown): RemeCheck {
return record(value, "ReMe check") as unknown as RemeCheck;
const what = "ReMe check";
const body = record(value, what);
booleanField(body, "installed", what);
booleanField(body, "versionOk", what);
booleanField(body, "hasCore", what);
stringField(body, "version", what);
stringField(body, "cliPath", what);
return value as unknown as RemeCheck;
}
/** `null` is the valid "no job yet" answer, so it is not an error. */
export function parseInstallStatus(value: unknown): InstallStatus | null {
if (typeof value !== "object" || value === null) return null;
const body = value as Record<string, unknown>;
return typeof body.id === "string" && typeof body.phase === "string"
? (value as unknown as InstallStatus)
: null;
if (value === null) return null;
const what = "install status";
const body = record(value, what);
stringField(body, "id", what);
stringField(body, "pythonPath", what);
stringField(body, "startedAt", what);
stringField(body, "logPath", what);
const phase = stringField(body, "phase", what);
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
fail(what);
return value as unknown as InstallStatus;
}
export function parseEnvSummary(value: unknown): EnvSummary {
const body = record(value, ".env summary");
if (!Array.isArray(body.entries))
throw new Error("Invalid .env summary response");
const what = ".env summary";
const body = record(value, what);
stringField(body, "workspaceDir", what);
stringField(body, "envPath", what);
booleanField(body, "exists", what);
for (const item of arrayField(body, "entries", what)) {
const entry = record(item, what);
stringField(entry, "key", what);
booleanField(entry, "secret", what);
booleanField(entry, "set", what);
stringField(entry, "masked", what);
}
return value as unknown as EnvSummary;
}
export function parseLlmResult(value: unknown): LlmTestResult {
return record(value, "LLM test") as unknown as LlmTestResult;
const what = "LLM test";
const body = record(value, what);
booleanField(body, "ok", what);
if (typeof body.status !== "number" || typeof body.latencyMs !== "number")
fail(what);
return value as unknown as LlmTestResult;
}
export function parseHealth(value: unknown): ConnectionHealth {
const body = record(value, "health");
if (typeof body.ok !== "boolean") throw new Error("Invalid health response");
const what = "health";
const body = record(value, what);
booleanField(body, "ok", what);
stringField(body, "version", what);
stringField(body, "error", what);
stringField(body, "endpoint", what);
return value as unknown as ConnectionHealth;
}
export function parseServiceState(value: unknown): ServiceState {
const body = record(value, "service status");
if (typeof body.phase !== "string")
throw new Error("Invalid service status response");
const what = "service status";
const body = record(value, what);
const phase = stringField(body, "phase", what);
if (!["running", "stopped", "conflict"].includes(phase)) fail(what);
booleanField(body, "managed", what);
stringField(body, "endpoint", what);
stringField(body, "logPath", what);
return value as unknown as ServiceState;
}
export function parseServiceStart(value: unknown): ServiceStartResult {
const body = record(value, "service start");
if (typeof body.started !== "boolean")
throw new Error("Invalid service start response");
const what = "service start";
const body = record(value, what);
booleanField(body, "started", what);
booleanField(body, "alreadyRunning", what);
stringField(body, "command", what);
stringField(body, "logPath", what);
stringField(body, "startedAt", what);
return value as unknown as ServiceStartResult;
}
export interface ServiceStopView {
stopped: boolean;
pid?: number;
reason?: string;
}
export function parseServiceStop(value: unknown): ServiceStopView {
const what = "service stop";
const body = record(value, what);
booleanField(body, "stopped", what);
return value as ServiceStopView;
}
export function parseServiceLog(value: unknown): ServiceLog {
const body = record(value, "service log");
if (typeof body.lines !== "string")
throw new Error("Invalid service log response");
const what = "service log";
const body = record(value, what);
stringField(body, "lines", what);
stringField(body, "logPath", what);
return value as unknown as ServiceLog;
}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import type { ServiceState } from "../types.js";
@ -11,10 +11,15 @@ import {
parseServiceLog,
parseServiceStart,
parseServiceState,
parseServiceStop,
} from "./rpc.js";
/** How long a detached start may take to answer `/health_check`. */
const START_DEADLINE_MS = 90_000;
/**
* How long a detached start may take to answer `/health_check`. Kept at the
* Host's start grace period, so the client never gives up while the Host still
* considers the launch in flight.
*/
const START_DEADLINE_MS = 120_000;
export interface ServiceViewState {
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
@ -31,6 +36,8 @@ export interface ServiceLogView {
export interface ServiceControl {
state: ServiceViewState;
stopping: boolean;
/** Why the last Stop request left the service alone, if it did. */
stopError: string;
start(): Promise<void>;
stop(): Promise<void>;
refresh(): void;
@ -58,12 +65,22 @@ export function useService(
const { t, endpoint, local, pythonPath, workspaceDir, authToken } = options;
const [state, setState] = useState<ServiceViewState>({ phase: "checking" });
const [stopping, setStopping] = useState(false);
const [stopError, setStopError] = useState("");
const [tick, setTick] = useState(0);
const [log, setLog] = useState<ServiceLogView>({
open: false,
lines: "",
logPath: "",
});
const startController = useRef<AbortController | undefined>(undefined);
// A new endpoint has no known phase yet; keeping the previous endpoint's
// "running + old pid/version" on screen would mislabel the new one.
useEffect(() => {
setState({ phase: "checking" });
}, [endpoint]);
useEffect(() => () => startController.current?.abort(), []);
useEffect(() => {
if (!local || !endpoint) return;
@ -100,17 +117,22 @@ export function useService(
const start = useCallback(async () => {
if (!pythonPath || state.phase === "starting") return;
startController.current?.abort();
const controller = new AbortController();
startController.current = controller;
setState({ phase: "starting" });
setStopError("");
setLog({ open: false, lines: "", logPath: "" });
try {
const started = parseServiceStart(
await envCall(rpc, "serviceStart", {
endpoint,
pythonPath,
workspaceDir,
authToken,
}),
await envCall(
rpc,
"serviceStart",
{ endpoint, pythonPath, workspaceDir, authToken },
controller.signal,
),
);
if (controller.signal.aborted) return;
if (started.error) {
setState({ phase: "error", error: started.error });
return;
@ -118,13 +140,19 @@ export function useService(
const deadline = Date.now() + START_DEADLINE_MS;
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (controller.signal.aborted) return;
if (Date.now() > deadline) {
setState({ phase: "error", error: t("startTimeout") });
return;
}
try {
const status = parseServiceState(
await envCall(rpc, "serviceStatus", { endpoint, authToken }),
await envCall(
rpc,
"serviceStatus",
{ endpoint, authToken },
controller.signal,
),
);
if (status.phase === "running") {
setState({ phase: "running", data: status });
@ -135,6 +163,7 @@ export function useService(
}
}
} catch (error) {
if (controller.signal.aborted) return;
setState({ phase: "error", error: safeError(error) });
}
}, [authToken, endpoint, pythonPath, rpc, state.phase, t, workspaceDir]);
@ -143,14 +172,17 @@ export function useService(
if (stopping) return;
setStopping(true);
try {
await envCall(rpc, "serviceStop", { endpoint });
const result = parseServiceStop(
await envCall(rpc, "serviceStop", { endpoint }),
);
setStopError(result.stopped ? "" : result.reason || t("serviceError"));
refresh();
} catch (error) {
setState({ phase: "error", error: safeError(error) });
} finally {
setStopping(false);
}
}, [endpoint, refresh, rpc, stopping]);
}, [endpoint, refresh, rpc, stopping, t]);
const viewLog = useCallback(async () => {
try {
@ -168,7 +200,17 @@ export function useService(
[],
);
return { state, stopping, start, stop, refresh, log, viewLog, dismissLog };
return {
state,
stopping,
stopError,
start,
stop,
refresh,
log,
viewLog,
dismissLog,
};
}
export interface ConnectionState {
@ -245,7 +287,7 @@ export function ServiceSection({
writable: boolean;
onAutoStart(checked: boolean): void;
}): JSX.Element {
const { state, stopping, log } = control;
const { state, stopping, stopError, log } = control;
const [confirming, setConfirming] = useState(false);
return (
@ -311,6 +353,11 @@ export function ServiceSection({
{`${t("serviceError")}: ${state.error}`}
</p>
) : null}
{stopError ? (
<p className="reme-settings-error" role="alert">
{stopError}
</p>
) : null}
<div className="reme-env-cta">
{state.phase === "running" ? (
confirming ? (
@ -368,6 +415,7 @@ export function ServiceSection({
<input
type="checkbox"
checked={autoStart}
disabled={!writable}
onChange={(event) => {
if (!writable) return;
onAutoStart(event.target.checked);

View file

@ -2,6 +2,7 @@ import { useCallback, useState, useSyncExternalStore } from "react";
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
import type { RemeCheck } from "../types.js";
import type { ReMeSettings } from "../../types.js";
import { DependencySection, useDependency } from "./dependency.js";
@ -25,16 +26,38 @@ export interface ReMeEnvironmentTabProps {
t: EnvTranslator;
}
/** Only a loopback endpoint can be managed as a local service. */
/** Only a loopback HTTP endpoint can be managed as a local service. */
function isLocalEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
return ["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname);
return (
url.protocol === "http:" &&
["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname)
);
} catch {
return false;
}
}
/**
* Compare endpoints the way the Host does, so a default port or a trailing
* slash cannot make the Host's answer look like it belongs to an older URL.
*/
function endpointKey(endpoint: string): string {
try {
const url = new URL(endpoint);
const port = url.port || (url.protocol === "https:" ? "443" : "80");
return `${
url.protocol
}//${url.hostname.toLowerCase()}:${port}${url.pathname.replace(
/\/+$/,
"",
)}`;
} catch {
return endpoint.trim();
}
}
function connectionBadge(
t: EnvTranslator,
connection: ConnectionState,
@ -109,24 +132,41 @@ export function ReMeEnvironmentTab({
const workspaceDir = value?.remeWorkspaceDir ?? "";
const authToken = value?.authToken ?? "";
const [heroTick, setHeroTick] = useState(0);
// Settings writes are invisible to the sections otherwise: a rejected
// revision or a transport error would silently roll the control back.
const [settingsError, setSettingsError] = useState("");
const writeSetting = useCallback(
(field: string, next: unknown): Promise<unknown> => {
if (!snapshot.writable) return Promise.resolve(false);
return scope
.mutate([{ op: "set", path: [field], value: next }], snapshot.revision)
.catch(() => undefined);
async (field: string, next: unknown): Promise<boolean> => {
if (!snapshot.writable) return false;
try {
const applied = await scope.mutate(
[{ op: "set", path: [field], value: next }],
snapshot.revision,
);
if (applied === false) {
setSettingsError(t("settingsWriteFailed"));
return false;
}
setSettingsError("");
return true;
} catch (error) {
setSettingsError(safeError(error));
return false;
}
},
[scope, snapshot.revision, snapshot.writable],
[scope, snapshot.revision, snapshot.writable, t],
);
const python = usePython(rpc, {
pythonPath: value?.pythonPath ?? "",
writable: snapshot.writable,
enabled: local,
onSelect: (exe) => writeSetting("pythonPath", exe),
});
const dependency = useDependency(rpc, {
pythonPath: python.selectedPath,
enabled: local,
onInstalled: () => {
python.refresh();
},
@ -134,6 +174,7 @@ export function ReMeEnvironmentTab({
const workspace = useWorkspace(rpc, {
workspaceDir,
writable: snapshot.writable,
enabled: local,
onWorkspaceDir: (dir) => writeSetting("remeWorkspaceDir", dir),
});
const service = useService(rpc, {
@ -154,18 +195,30 @@ export function ReMeEnvironmentTab({
tick: heroTick,
authToken,
});
// The Host names the endpoint its answer belongs to; comparing that with the
// settings endpoint is what makes a stale answer fail the `current` test.
const serviceEndpoint = service.state.data?.endpoint ?? endpoint;
const connection: ConnectionState = local
? service.state.phase === "running"
? { phase: "ok", endpoint, version: service.state.data?.version ?? "" }
? {
phase: "ok",
endpoint: serviceEndpoint,
version: service.state.data?.version ?? "",
}
: service.state.phase === "checking" || service.state.phase === "starting"
? { phase: "loading", endpoint }
: { phase: "fail", endpoint, error: service.state.error }
? { phase: "loading", endpoint: serviceEndpoint }
: {
phase: "fail",
endpoint: serviceEndpoint,
error: service.state.error,
}
: remote;
if (snapshot.status === "unavailable") return null;
const current =
connection.phase !== "loading" && connection.endpoint === endpoint;
connection.phase !== "loading" &&
endpointKey(connection.endpoint) === endpointKey(endpoint);
const ready =
current &&
connection.phase === "ok" &&
@ -241,6 +294,11 @@ export function ReMeEnvironmentTab({
{connection.error}
</p>
) : null}
{settingsError ? (
<p className="reme-settings-error reme-env-hero-error" role="alert">
{settingsError}
</p>
) : null}
{!local ? (
<p className="reme-settings-muted reme-env-notice">
{t("remoteNotice")}

View file

@ -144,10 +144,12 @@ export function useWorkspace(
options: {
workspaceDir: string;
writable: boolean;
/** False for a remote endpoint, whose local steps are hidden. */
enabled?: boolean;
onWorkspaceDir(dir: string): Promise<unknown>;
},
): WorkspaceState {
const { workspaceDir, writable, onWorkspaceDir } = options;
const { workspaceDir, writable, enabled = true, onWorkspaceDir } = options;
const [env, setEnv] = useState<EnvState>({ phase: "loading" });
const [draft, setDraft] = useState<Draft>({ dir: "", values: {} });
const [saving, setSaving] = useState(false);
@ -158,12 +160,18 @@ export function useWorkspace(
// Read by a save that resolves after a workspace switch, so the answer for
// the previous workspace is dropped instead of shown against the new one.
const latestDir = useRef(workspaceDir);
const llmController = useRef<AbortController | undefined>(undefined);
useEffect(() => {
latestDir.current = workspaceDir;
// A probe for the previous workspace must not land on the new one.
llmController.current?.abort();
}, [workspaceDir]);
useEffect(() => () => llmController.current?.abort(), []);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
void (async () => {
try {
@ -173,6 +181,7 @@ export function useWorkspace(
{ workspaceDir },
controller.signal,
);
if (controller.signal.aborted) return;
setEnv({
phase: "ready",
data: parseEnvSummary(value),
@ -188,7 +197,7 @@ export function useWorkspace(
}
})();
return () => controller.abort();
}, [rpc, workspaceDir, tick]);
}, [rpc, workspaceDir, tick, enabled]);
// A workspace switch drops drafts typed for the previous workspace, so a key
// typed for one workspace can never be written into another. The read is
@ -262,13 +271,24 @@ export function useWorkspace(
const testLlm = useCallback(
async (pythonPath: string) => {
if (!pythonPath || llmTest.phase === "running") return;
const dir = workspaceDir;
llmController.current?.abort();
const controller = new AbortController();
llmController.current = controller;
setLlmTest({ phase: "running" });
try {
const data = parseLlmResult(
await envCall(rpc, "testLlm", { workspaceDir, pythonPath }),
await envCall(
rpc,
"testLlm",
{ workspaceDir: dir, pythonPath },
controller.signal,
),
);
if (controller.signal.aborted || latestDir.current !== dir) return;
setLlmTest({ phase: data.ok ? "ok" : "fail", result: data });
} catch (error) {
if (controller.signal.aborted || latestDir.current !== dir) return;
setLlmTest({
phase: "fail",
result: {
@ -287,7 +307,10 @@ export function useWorkspace(
if (!writable) return;
const next = workspaceInput ?? workspaceDir;
setWorkspaceInput(undefined);
await onWorkspaceDir(next.trim()).catch(() => undefined);
const saved = await onWorkspaceDir(next.trim()).catch(() => false);
// A rejected save is reported by the settings writer; do not reload the
// old directory as if the new one had been accepted.
if (saved === false) return;
setTick((current) => current + 1);
}, [onWorkspaceDir, workspaceDir, workspaceInput, writable]);

View file

@ -225,6 +225,24 @@ export function makeCondaRunner(
};
}
/**
* Unix interpreters that a `which -a python3` can miss when the shell has a
* trimmed PATH: the system install, Homebrew on both Apple Silicon and Intel,
* and pyenv/local shims. Windows has `where` and the registry for this.
*/
function commonPythonPaths(io: DetectIO, windows: boolean): string[] {
if (windows) return [];
const path = pathOf(io.homedir);
return [
"/usr/bin/python3",
"/usr/bin/python",
"/usr/local/bin/python3",
"/opt/homebrew/bin/python3",
path.join(io.homedir, ".pyenv", "shims", "python3"),
path.join(io.homedir, ".local", "bin", "python3"),
];
}
function commonCondaPaths(io: DetectIO, windows: boolean): string[] {
const programData = io.env["ProgramData"] ?? "";
const path = windows ? win32 : pathOf(io.homedir);
@ -340,7 +358,11 @@ function classifyOrigin(
};
if ([...candidate.sources].some((source) => source.startsWith("registry")))
return { kind: "python.org" };
if (candidate.sources.has("PATH") || candidate.sources.has("which"))
if (
candidate.sources.has("PATH") ||
candidate.sources.has("which") ||
candidate.sources.has("well-known")
)
return { kind: "system" };
return { kind: "unknown" };
}
@ -472,6 +494,10 @@ export async function detectPythonEnvs(io: DetectIO): Promise<PythonDetection> {
if (isWindowsAppsAlias(exe)) continue;
addCandidate(candidates, exe, windows ? "PATH" : "which", windows);
}
// Well-known Unix install locations, independent of the Host PATH.
for (const exe of commonPythonPaths(io, windows))
if (io.exists(exe))
addCandidate(candidates, exe, "well-known", windows);
})(),
// conda: PATH first, then well-known install roots.
(async () => {

View file

@ -1,14 +1,15 @@
import { spawn } from "node:child_process";
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
import { join } from "node:path";
import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js";
import { pathOf } from "./paths.js";
import { isAbsolutePath } from "./paths.js";
export interface InstallChild {
pid: number | undefined;
onExit(callback: (code: number | null) => void): void;
kill(): void;
/** `force` escalates to SIGKILL on POSIX; Windows always force-kills. */
kill(force?: boolean): void;
}
export interface InstallSpawner {
@ -33,18 +34,32 @@ export function pipArgs(request: InstallRequest): string[] {
if (indexUrl) {
if (!/^https?:\/\/\S+$/i.test(indexUrl))
throw new TypeError("Package index must be an http(s) URL");
// Credentials embedded in the URL would be echoed in the job command, the
// install log and the browser; pip's own config is the safe place for them.
const parsed = new URL(indexUrl);
if (parsed.username || parsed.password)
throw new TypeError(
"Package index must not embed credentials; configure them in pip.conf",
);
args.push("--index-url", indexUrl);
}
args.push("reme-ai[core]");
return args;
}
/** Last `maxLines` non-empty lines of a log file, normalized to "\n". */
/**
* Last `maxLines` non-empty lines of a log file, normalized to "\n". Only the
* tail is read: this runs on every 2 s poll and the log can grow for minutes.
*/
export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
let fd: number | undefined;
try {
const buffer = readFileSync(path);
fd = openSync(path, "r");
const size = fstatSync(fd).size;
const length = Math.min(size, maxBytes);
const buffer = Buffer.alloc(length);
readSync(fd, buffer, 0, length, Math.max(0, size - length));
const text = buffer
.subarray(Math.max(0, buffer.length - maxBytes))
.toString("utf8")
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n");
@ -55,6 +70,14 @@ export function tailLog(path: string, maxLines = 30, maxBytes = 8192): string {
.join("\n");
} catch {
return "";
} finally {
if (fd !== undefined) {
try {
closeSync(fd);
} catch {
// Already closed.
}
}
}
}
@ -70,6 +93,9 @@ export function createInstallSpawner(
child = spawn(exe, args, {
shell: false,
windowsHide: true,
// POSIX: lead a process group so a cancel can stop pip's build
// backends too, not only pip itself.
detached: process.platform !== "win32",
env: {
...base,
PYTHONIOENCODING: "utf-8",
@ -94,17 +120,30 @@ export function createInstallSpawner(
child.on("close", finish);
child.on("error", () => finish(null));
},
kill() {
kill(force = false) {
if (child.pid !== undefined && process.platform === "win32") {
// Tree-kill: pip's build backends are child processes.
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
windowsHide: true,
stdio: "ignore",
});
const killer = spawn(
"taskkill",
["/PID", String(child.pid), "/T", "/F"],
{ windowsHide: true, stdio: "ignore" },
);
// Without this listener an async spawn failure would exit the Host.
killer.on("error", () => undefined);
killer.unref();
return;
}
const signal = force ? "SIGKILL" : "SIGTERM";
// The install process leads its own group on POSIX, so pip's build
// backends receive the signal as well.
try {
child.kill("SIGTERM");
if (child.pid !== undefined) process.kill(-child.pid, signal);
return;
} catch {
// Not a group leader (e.g. spawn failed); signal the process only.
}
try {
child.kill(signal);
} catch {
// Already gone.
}
@ -148,7 +187,7 @@ export class InstallJobs {
const pythonPath = (request.pythonPath ?? "").trim();
// The interpreter may be written for another platform than the host, so its
// own flavor decides whether the path is absolute.
if (!pythonPath || !pathOf(pythonPath).isAbsolute(pythonPath))
if (!pythonPath || !isAbsolutePath(pythonPath))
throw new TypeError(
"Install requires an absolute Python interpreter path",
);
@ -199,8 +238,13 @@ export class InstallJobs {
if (!job || job.phase !== "running" || job.cancelling) return false;
job.cancelling = true;
job.child.kill();
// A process that ignores the kill must not lock the interpreter forever.
job.cancelTimer = setTimeout(() => this.finish(job, null), 15_000);
// A process that ignores the graceful kill must not keep the interpreter
// locked, so escalate before the last-resort fallback settles the job.
job.cancelTimer = setTimeout(() => {
job.child.kill(true);
job.cancelTimer = setTimeout(() => this.finish(job, null), 15_000);
job.cancelTimer.unref();
}, 5_000);
job.cancelTimer.unref();
return true;
}

View file

@ -2,9 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { safeError } from "../../endpoint.js";
import { detectPythonEnvs } from "./detect.js";
import { createInstallSpawner, InstallJobs, tailLog } from "./install.js";
import { checkReme, remeVersionAtLeast } from "./reme.js";
import { checkReme, MINIMUM_REME, remeVersionAtLeast } from "./reme.js";
import { createNodeRunner } from "./runner.js";
import {
createServiceIO,
@ -189,8 +190,16 @@ export class ReMeEnvManager {
"service.log",
),
});
if (status.phase === "running" && this.starting?.endpoint === endpointValue)
this.starting = undefined;
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.
if (
status.phase === "running" ||
(status.phase === "stopped" && status.pid === undefined)
)
this.starting = undefined;
}
return status;
}
@ -268,7 +277,15 @@ export class ReMeEnvManager {
}
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new TypeError("ReMe endpoint must be an http(s) URL");
return value;
if (url.username || url.password)
throw new TypeError("ReMe endpoint must not contain credentials");
if (url.search || url.hash)
throw new TypeError(
"ReMe endpoint must not contain a query string or fragment",
);
// The client appends job names to the endpoint, so it never keeps a
// trailing slash; normalising here is what makes records comparable.
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
}
private async startLocked(
@ -281,17 +298,30 @@ export class ReMeEnvManager {
const dir = resolveWorkspaceDir(workspaceDir, homedir());
const logPath = join(dir, "service.log");
const now = new Date().toISOString();
if (
this.starting?.endpoint === endpointValue &&
Date.now() - this.starting.at < START_GRACE_MS
)
const starting = this.starting;
if (starting && Date.now() - starting.at < START_GRACE_MS) {
if (starting.endpoint === endpointValue)
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
// orphan it.
return {
started: false,
alreadyRunning: true,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: `A service start for ${starting.endpoint} is still in progress`,
};
}
this.starting = undefined;
const current = await this.serviceStatus(endpointValue, authToken);
if (current.phase === "running")
return {
@ -302,6 +332,35 @@ 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) {
const previous = await stopService(this.serviceIO, {
statePath: this.serviceStatePath,
endpoint: endpointValue,
});
if (!previous.stopped)
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: `A previous launch could not be stopped: ${
previous.reason ?? "unknown reason"
}`,
};
}
const path = pythonPath.trim();
if (!path)
return {
@ -322,17 +381,45 @@ export class ReMeEnvManager {
startedAt: now,
error: "reme-ai is not installed in the selected interpreter",
};
if (!check.versionOk)
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: `reme-ai ${
check.version || "(unknown version)"
} is older than ${MINIMUM_REME}; upgrade it first`,
};
// Set before the spawn so a second request arriving while the service is
// booting observes the first launch instead of spawning another one. It is
// cleared again when the spawn fails, so a retry is possible immediately.
this.starting = { endpoint: endpointValue, at: Date.now() };
return startService(this.serviceIO, {
endpoint: endpointValue,
workspaceDir: dir,
pythonPath: path,
cliPath: check.cliPath,
// 0.4.0.x has no jobs.<name>.enabled; only newer releases accept it.
disableDreamCron: remeVersionAtLeast(check.version, "0.4.1"),
statePath: this.serviceStatePath,
logPath,
env: this.env,
});
try {
const result = await startService(this.serviceIO, {
endpoint: endpointValue,
workspaceDir: dir,
pythonPath: path,
cliPath: check.cliPath,
// 0.4.0.x has no jobs.<name>.enabled; only newer releases accept it.
disableDreamCron: remeVersionAtLeast(check.version, "0.4.1"),
statePath: this.serviceStatePath,
logPath,
env: this.env,
});
if (!result.started) this.starting = undefined;
return result;
} catch (error) {
this.starting = undefined;
return {
started: false,
alreadyRunning: false,
command: "",
logPath,
startedAt: now,
error: `Could not start the ReMe service: ${safeError(error)}`,
};
}
}
}

View file

@ -20,3 +20,16 @@ export function isWindowsPath(value: string): boolean {
export function pathOf(value: string): PlatformPath {
return isWindowsPath(value) ? win32 : posix;
}
/**
* Whether a path is absolute in its own platform's semantics.
*
* `win32.isAbsolute("\\python.exe")` is true, but such a path is relative to
* the current drive, which is not what the interpreter and install checks must
* accept. Windows therefore needs a drive letter or a UNC prefix.
*/
export function isAbsolutePath(value: string): boolean {
if (isWindowsPath(value))
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
return value.startsWith("/");
}

View file

@ -1,7 +1,7 @@
import { win32 } from "node:path";
import type { RemeCheck, RemeProbe } from "../types.js";
import { pathOf } from "./paths.js";
import { isAbsolutePath, pathOf } from "./paths.js";
import type { RunCommand } from "./runner.js";
/** Minimum reme-ai version this DSH integration is tested against. */
@ -54,15 +54,25 @@ export async function probeReme(
timeoutMs: 10_000,
});
if (result.error || result.code !== 0)
return { installed: false, version: "", versionOk: false };
return {
installed: false,
version: "",
versionOk: false,
...(result.error ? { error: result.error } : {}),
};
const version = parseRemeVersion(result.stdout);
return {
installed: version !== "",
version,
versionOk: remeVersionAtLeast(version),
};
} catch {
return { installed: false, version: "", versionOk: false };
} catch (error) {
return {
installed: false,
version: "",
versionOk: false,
error: String(error),
};
}
}
@ -95,7 +105,7 @@ export async function checkReme(
pythonPath: string,
): Promise<RemeCheck> {
const path = pythonPath.trim();
if (!path || !pathOf(path).isAbsolute(path)) {
if (!path || !isAbsolutePath(path)) {
return {
installed: false,
version: "",
@ -112,7 +122,7 @@ export async function checkReme(
versionOk: false,
hasCore: false,
cliPath: "",
error: "",
error: probe.error ?? "",
};
}
let hasCore = false;

View file

@ -53,10 +53,42 @@ export function createNodeRunner(
}
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const timers: ReturnType<typeof setTimeout>[] = [];
const settle = (result: RunResult): void => {
if (settled) return;
settled = true;
for (const timer of timers) clearTimeout(timer);
resolve(result);
};
const timeoutMs = options.timeoutMs ?? 10_000;
const timer = setTimeout(() => {
// The caller must see an explicit error: an exit caused by our own
// timeout is not "the interpreter has no reme-ai".
timedOut = true;
child.kill();
}, options.timeoutMs ?? 10_000);
// A child that ignores SIGTERM must not make the probe hang forever:
// force it and settle, rather than waiting for a close that may never
// arrive.
const grace = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// Already exited.
}
settle({
code: null,
stdout,
stderr,
error: `Command timed out after ${timeoutMs} ms`,
});
}, 1_000);
grace.unref();
timers.push(grace);
}, timeoutMs);
timer.unref();
timers.push(timer);
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
@ -64,12 +96,17 @@ export function createNodeRunner(
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({ code: null, stdout, stderr, error: String(error) });
settle({ code: null, stdout, stderr, error: String(error) });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
settle({
code,
stdout,
stderr,
...(timedOut
? { error: `Command timed out after ${timeoutMs} ms` }
: {}),
});
});
});
}

View file

@ -5,11 +5,12 @@ import {
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createConnection } from "node:net";
import { dirname } from "node:path";
import { basename, dirname } from "node:path";
import { safeError } from "../../endpoint.js";
import type { ServiceStartResult, ServiceState } from "../types.js";
@ -56,7 +57,11 @@ export interface ServiceIO {
logPath: string;
env: NodeJS.ProcessEnv;
}): Promise<{ pid: number | undefined }>;
killTree(pid: number): void;
/**
* Ask the process, and its process group on POSIX, to stop. `force`
* escalates to `SIGKILL` (always `/F` on Windows).
*/
killTree(pid: number, force?: boolean): void;
probePid(pid: number): void;
/** OS image name of a pid, or "" when it cannot be read. */
processImage(pid: number): Promise<string>;
@ -64,7 +69,30 @@ export interface ServiceIO {
platform: string;
}
/** Only loopback endpoints may be managed as a local service. */
/** A pid we are willing to signal: > 1, so kill(0)/kill(-1) is impossible. */
export function isValidPid(pid: unknown): pid is number {
return typeof pid === "number" && Number.isInteger(pid) && pid > 1;
}
/**
* Canonical identity of an endpoint for service-record matching. Loopback
* aliases and default ports compare equal, so saving `localhost:2333` does not
* make the service this plugin started on `127.0.0.1:2333` look foreign.
*/
export function serviceEndpointKey(endpoint: string): string {
try {
const url = new URL(endpoint.trim());
const hostname = url.hostname.toLowerCase();
const host = hostname === "localhost" ? "127.0.0.1" : hostname;
const port = url.port || (url.protocol === "https:" ? "443" : "80");
const path = url.pathname.replace(/\/+$/, "");
return `${url.protocol}//${host}:${port}${path}`;
} catch {
return endpoint.trim();
}
}
/** Only loopback HTTP endpoints may be managed as a local service. */
export function parseLocalEndpoint(endpoint: string): {
host: string;
port: number;
@ -75,13 +103,17 @@ export function parseLocalEndpoint(endpoint: string): {
} catch {
throw new TypeError("Invalid ReMe endpoint");
}
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new TypeError("ReMe endpoint must be an http(s) URL");
// The service this plugin launches is plain HTTP, so an https loopback URL
// could never become healthy and must not be offered as manageable.
if (url.protocol !== "http:")
throw new TypeError(
"Local service management requires an http:// endpoint",
);
if (!["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname))
throw new TypeError(
"Local service management requires a loopback endpoint",
);
const port = Number(url.port || (url.protocol === "https:" ? "443" : "80"));
const port = Number(url.port || "80");
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new TypeError("Invalid ReMe endpoint port");
return { host: "127.0.0.1", port };
@ -129,21 +161,57 @@ export function buildStartCommand(options: {
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Read a record defensively: a malformed file is "no record", not a crash. */
export function readServiceState(path: string): PersistedService | undefined {
let value: unknown;
try {
return JSON.parse(readFileSync(path, "utf8")) as PersistedService;
value = JSON.parse(readFileSync(path, "utf8"));
} catch {
return undefined;
}
if (!isRecord(value)) return undefined;
const endpoint = typeof value.endpoint === "string" ? value.endpoint : "";
if (!endpoint.trim()) return undefined;
return {
...(isValidPid(value.pid) ? { pid: value.pid } : {}),
endpoint,
workspaceDir:
typeof value.workspaceDir === "string" ? value.workspaceDir : "",
pythonPath: typeof value.pythonPath === "string" ? value.pythonPath : "",
command: typeof value.command === "string" ? value.command : "",
startedAt: typeof value.startedAt === "string" ? value.startedAt : "",
};
}
export function writeServiceState(path: string, state: PersistedService): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(state, null, 2), "utf8");
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, JSON.stringify(state, null, 2), {
encoding: "utf8",
mode: 0o600,
});
try {
renameSync(temporary, path);
} catch (error) {
rmSync(temporary, { force: true });
throw error;
}
}
function clearServiceState(path: string): void {
try {
rmSync(path, { force: true });
} catch {
// A stale state file is retried by the next status read.
}
}
function alive(io: ServiceIO, pid?: number): boolean {
if (pid === undefined) return false;
if (!isValidPid(pid)) return false;
try {
io.probePid(pid);
return true;
@ -152,23 +220,37 @@ function alive(io: ServiceIO, pid?: number): boolean {
}
}
/**
* 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
* would accept e.g. `pycharm64.exe`).
*/
export function isServiceImage(image: string): boolean {
const name = basename(image.trim().replaceAll("\\", "/")).toLowerCase();
return /^(python|pythonw|reme)([0-9.]*)?(\.exe)?$/.test(name);
}
/**
* 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.
*
* This guards a destructive operation, so it fails *closed*: an unreadable
* image is not proof of ownership.
*/
export async function ownsService(
io: ServiceIO,
persisted: PersistedService | undefined,
endpoint: string,
): Promise<boolean> {
if (!persisted || persisted.pid === undefined) return false;
if (persisted.endpoint.trim() !== endpoint.trim()) return false;
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)).toLowerCase();
if (image === "") return true;
return /^(reme|python|py)/.test(image);
const image = await io.processImage(persisted.pid);
if (!image.trim()) return false;
return isServiceImage(image);
}
/** Running / conflict / stopped, with ownership of the running process. */
@ -186,8 +268,19 @@ export async function serviceStatus(
options.authToken ?? "",
);
const persisted = readServiceState(options.statePath);
const recordForEndpoint =
persisted !== undefined &&
serviceEndpointKey(persisted.endpoint) ===
serviceEndpointKey(options.endpoint);
const recordAlive = alive(io, persisted?.pid);
// 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);
if (health.ok) {
const managed = await ownsService(io, persisted, options.endpoint);
const managed =
recordForEndpoint &&
recordAlive &&
(await ownsService(io, persisted, options.endpoint));
return {
phase: "running",
managed,
@ -200,8 +293,14 @@ export async function serviceStatus(
logPath: options.logPath,
};
}
const { host, port } = parseLocalEndpoint(options.endpoint);
if (await io.portOpen(host, port))
let portOpen = false;
try {
const { host, port } = parseLocalEndpoint(options.endpoint);
portOpen = await io.portOpen(host, port);
} catch {
// A remote or https endpoint is health-checked only; it has no local port.
}
if (portOpen)
return {
phase: "conflict",
managed: false,
@ -212,7 +311,11 @@ export async function serviceStatus(
phase: "stopped",
managed: false,
endpoint: options.endpoint,
...(persisted?.pid === undefined ? {} : { pid: persisted.pid }),
// A live record for this endpoint means a launch that never became
// healthy; the manager stops it before spawning a replacement.
...(recordForEndpoint && recordAlive && persisted?.pid !== undefined
? { pid: persisted.pid }
: {}),
logPath: options.logPath,
};
}
@ -235,16 +338,28 @@ export async function startService(
env: NodeJS.ProcessEnv;
},
): Promise<ServiceStartResult> {
mkdirSync(options.workspaceDir, { recursive: true });
const command = buildStartCommand({
cliPath: options.cliPath,
pythonPath: options.pythonPath,
workspaceDir: options.workspaceDir,
endpoint: options.endpoint,
disableDreamCron: options.disableDreamCron,
exists: io.exists,
});
const startedAt = new Date().toISOString();
let command: { exe: string; args: string[]; display: string };
try {
mkdirSync(options.workspaceDir, { recursive: true });
command = buildStartCommand({
cliPath: options.cliPath,
pythonPath: options.pythonPath,
workspaceDir: options.workspaceDir,
endpoint: options.endpoint,
disableDreamCron: options.disableDreamCron,
exists: io.exists,
});
} catch (error) {
return {
started: false,
alreadyRunning: false,
command: "",
logPath: options.logPath,
startedAt,
error: `Could not start the ReMe service: ${safeError(error)}`,
};
}
let child: { pid: number | undefined };
try {
child = await io.spawnService({
@ -269,14 +384,32 @@ export async function startService(
error: `Could not start the ReMe service: ${safeError(error)}`,
};
}
writeServiceState(options.statePath, {
pid: child.pid,
endpoint: options.endpoint,
workspaceDir: options.workspaceDir,
pythonPath: options.pythonPath,
command: command.display,
startedAt,
});
try {
writeServiceState(options.statePath, {
pid: child.pid,
endpoint: options.endpoint,
workspaceDir: options.workspaceDir,
pythonPath: options.pythonPath,
command: command.display,
startedAt,
});
} catch (error) {
// The process exists but could not be recorded; leaving it running would
// make it unmanageable, so stop it before reporting the failure.
try {
if (child.pid !== undefined) io.killTree(child.pid, true);
} catch {
// Best effort: the error below already explains the launch failed.
}
return {
started: false,
alreadyRunning: false,
command: command.display,
logPath: options.logPath,
startedAt,
error: `Could not record the ReMe service: ${safeError(error)}`,
};
}
return {
started: true,
alreadyRunning: false,
@ -287,34 +420,97 @@ export async function startService(
};
}
const STOP_TIMEOUT_MS = 5_000;
const STOP_FORCE_TIMEOUT_MS = 3_000;
const STOP_POLL_MS = 100;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** Poll until the pid disappears; true when it exited within `timeoutMs`. */
async function waitForExit(
io: ServiceIO,
pid: number,
timeoutMs: number,
pollMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (!alive(io, pid)) return true;
const remaining = deadline - Date.now();
if (remaining <= 0) return false;
await delay(Math.max(1, Math.min(pollMs, remaining)));
}
}
/**
* Stop the service this plugin started for the endpoint, and only that one: a
* service on another port, or a foreign process, is left running.
*
* The record is removed only after the process is confirmed gone. Otherwise a
* SIGTERM the service ignored would delete the pid, leaving a running process
* that the tab can never stop again.
*/
export async function stopService(
io: ServiceIO,
options: { statePath: string; endpoint: string },
options: {
statePath: string;
endpoint: string;
/** Grace period before escalating to SIGKILL (tests shorten it). */
timeoutMs?: number;
forceTimeoutMs?: number;
pollMs?: number;
},
): Promise<ServiceStopResult> {
const persisted = readServiceState(options.statePath);
if (!persisted || persisted.pid === undefined)
if (!persisted || !isValidPid(persisted.pid)) {
if (persisted) clearServiceState(options.statePath);
return { stopped: false, reason: "No service started by this plugin" };
}
if (!(await ownsService(io, persisted, options.endpoint)))
return {
stopped: false,
reason: "The recorded service does not belong to this endpoint",
};
const pid = persisted.pid;
const pollMs = options.pollMs ?? STOP_POLL_MS;
try {
io.killTree(pid);
} catch {
// Already exited.
}
try {
rmSync(options.statePath, { force: true });
} catch {
// A stale state file is harmless; the next start overwrites it.
if (
await waitForExit(io, pid, options.timeoutMs ?? STOP_TIMEOUT_MS, pollMs)
) {
clearServiceState(options.statePath);
return { stopped: true, pid };
}
return { stopped: true, pid };
// The service ignored (or slowly handled) SIGTERM: escalate, then only drop
// the record once the process is really gone.
try {
io.killTree(pid, true);
} catch {
// Already exited.
}
if (
await waitForExit(
io,
pid,
options.forceTimeoutMs ?? STOP_FORCE_TIMEOUT_MS,
pollMs,
)
) {
clearServiceState(options.statePath);
return { stopped: true, pid };
}
return {
stopped: false,
pid,
reason: "The service did not exit after SIGKILL",
};
}
/** Node-backed IO: fetch health, probe the port, spawn detached, tree-kill. */
@ -402,30 +598,50 @@ export function createServiceIO(): ServiceIO {
child.unref();
return { pid: child.pid };
},
killTree(pid) {
killTree(pid, force = false) {
if (!isValidPid(pid)) return;
if (process.platform === "win32") {
spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
windowsHide: true,
stdio: "ignore",
});
// A spawn failure is an async event; without a listener it would exit
// the Host.
child.on("error", () => undefined);
child.unref();
return;
}
// The service was spawned detached, so it leads its own process group;
// signalling the group stops pip's children too.
const signal = force ? "SIGKILL" : "SIGTERM";
try {
process.kill(pid, "SIGTERM");
process.kill(-pid, signal);
return;
} catch {
// Not a group leader (e.g. a record from an older launch).
}
try {
process.kill(pid, signal);
} catch {
// Already exited.
}
},
probePid(pid) {
if (!isValidPid(pid))
throw new RangeError("Refusing to probe an invalid pid");
process.kill(pid, 0);
},
processImage(pid) {
return new Promise((resolve) => {
if (!isValidPid(pid)) {
resolve("");
return;
}
if (process.platform === "win32") {
execFile(
"tasklist",
["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
{ windowsHide: true },
{ windowsHide: true, timeout: 3000 },
(error, stdout) => {
const match = /^"([^"]+)"/.exec(String(stdout ?? "").trim());
resolve(error ? "" : match?.[1] ?? "");
@ -433,8 +649,11 @@ export function createServiceIO(): ServiceIO {
);
return;
}
execFile("ps", ["-p", String(pid), "-o", "comm="], (error, stdout) =>
resolve(error ? "" : String(stdout ?? "").trim()),
execFile(
"ps",
["-p", String(pid), "-o", "comm="],
{ timeout: 3000 },
(error, stdout) => resolve(error ? "" : String(stdout ?? "").trim()),
);
});
},

View file

@ -1,4 +1,13 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { safeError } from "../../endpoint.js";
@ -6,13 +15,13 @@ import type { EnvSummary, LlmTestResult } from "../types.js";
import type { RunCommand } from "./runner.js";
/** Keys the Environment tab manages; split for the two form groups. */
export const LLM_KEYS = [
const LLM_KEYS = [
"LLM_BACKEND",
"LLM_MODEL_NAME",
"LLM_API_KEY",
"LLM_BASE_URL",
] as const;
export const EMBEDDING_KEYS = [
const EMBEDDING_KEYS = [
"EMBEDDING_BACKEND",
"EMBEDDING_MODEL_NAME",
"EMBEDDING_API_KEY",
@ -138,10 +147,33 @@ export function validateEnvUpdate(values: Record<string, string>): void {
/**
* Write a fully merged `.env`. The caller merges and validates first, so the
* file on disk is exactly what was validated.
*
* The write is atomic (same-directory temp file plus rename) and keeps the
* previous file as `.env.bak`: a crash or a full disk must not take unrelated
* keys such as `LLM_API_KEY` with it. New files are owner-only (0600) because
* they hold credentials.
*/
export function writeEnvText(path: string, text: string): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, text, "utf8");
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, text, { encoding: "utf8", mode: 0o600 });
try {
if (existsSync(path)) {
const backup = `${path}.bak`;
copyFileSync(path, backup);
// The backup holds the same credentials, so it must not be more readable
// than the file it preserves.
try {
chmodSync(backup, 0o600);
} catch {
// Windows has no POSIX mode bits; the copy is still user-owned.
}
}
renameSync(temporary, path);
} catch (error) {
rmSync(temporary, { force: true });
throw error;
}
}
/**

View file

@ -47,6 +47,8 @@ export interface RemeProbe {
version: string;
/** True when the version satisfies the minimum this integration targets. */
versionOk: boolean;
/** Non-empty when the probe itself failed (spawn error, timeout). */
error?: string;
}
export interface RemeCheck extends RemeProbe {

View file

@ -52,9 +52,15 @@ export class ReMeClient {
sessionId: string,
options: AutoMemoryOptions = {},
): Promise<ReMeResult> {
const config = {
...this.configSource(),
...(options.endpoint ? { endpoint: options.endpoint } : {}),
const base = this.configSource();
const endpoint = options.endpoint || base.endpoint;
// A recovered batch keeps the endpoint it was queued for. The token is
// scoped to the *configured* endpoint, so it must not ride along when the
// batch goes somewhere else.
const config: ReMeClientConfig = {
...base,
endpoint,
...(endpoint === base.endpoint ? {} : { authToken: "" }),
};
return this.request(
"auto_memory",

View file

@ -100,6 +100,32 @@ test("uses the persisted destination and request ID when sending a recovered bat
}
});
test("scopes the bearer token to the endpoint it was configured for", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init) => {
requests.push({ url, authorization: init.headers.Authorization });
return Response.json({ success: true });
};
try {
const client = new ReMeClient({
endpoint: "http://new.test",
authToken: "current-token",
requestTimeoutMs: 1000,
backgroundTimeoutMs: 1000,
});
// The current endpoint keeps the token.
await client.search("fact");
assert.equal(requests.at(-1).authorization, "Bearer current-token");
// A recovered batch for another endpoint must not receive it.
await client.autoMemory([], "session", { endpoint: "http://old.test" });
assert.equal(requests.at(-1).url, "http://old.test/auto_memory");
assert.equal(requests.at(-1).authorization, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
test("calls ReMe jobs with their native request and response envelopes", async () => {
const calls = [];
const originalFetch = globalThis.fetch;

View file

@ -607,6 +607,49 @@ test("detects Unix interpreters and merges paths that resolve together", async (
assert.equal(named.version, "3.11.9");
});
test("probes well-known Unix locations beyond the Host PATH", async () => {
const homebrew = "/opt/homebrew/bin/python3";
const pyenv = "/Users/u/.pyenv/shims/python3";
const rules = [
{
match: (exe) => exe === "which",
result: { code: 1, stdout: "", stderr: "" },
},
verifyRule(homebrew, "3.12.4", "/opt/homebrew"),
// pyenv shims report the real interpreter they dispatch to.
{
match: (actual, args) =>
actual === pyenv &&
args[0] === VERIFY_ARG0 &&
String(args[1]).includes("import sys"),
result: {
code: 0,
stdout:
'{"exe":"/Users/u/.pyenv/versions/3.12.4/bin/python3","ver":"3.12.4","base":"/Users/u/.pyenv/versions/3.12.4"}\n',
stderr: "",
},
},
remeRule(homebrew, "0.4.1.11"),
remeRule(pyenv, null),
remeRule("/Users/u/.pyenv/versions/3.12.4/bin/python3", null),
];
const detection = await detectPythonEnvs({
run: fakeRun(rules),
platform: "darwin",
env: {},
homedir: "/Users/u",
exists: (path) => path === homebrew || path === pyenv,
});
assert.deepEqual(
detection.envs.map((env) => env.exe).sort(),
[homebrew, "/Users/u/.pyenv/versions/3.12.4/bin/python3"].sort(),
);
assert.ok(
detection.envs.every((env) => env.origin.kind === "system"),
"well-known paths are classified as system installations",
);
});
test("collects conda failures without rejecting the whole detection", async () => {
const { io, miniconda } = windowsFixture();
io.run = async (exe, args) => {

View file

@ -5,10 +5,11 @@ 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";
/** A service adapter that records launches instead of starting processes. */
function fakeServiceIO() {
const calls = { spawn: [] };
const calls = { killTree: [], spawn: [] };
return {
calls,
io: {
@ -49,7 +50,7 @@ function managerIn(t) {
endpoint: endpoint || "http://127.0.0.1:2333",
logPath: join(directory, "service.log"),
});
return { manager, calls, directory };
return { manager, calls, directory, io };
}
test("serializes concurrent starts into one service launch", async (t) => {
@ -69,6 +70,84 @@ test("serializes concurrent starts into one service launch", async (t) => {
assert.equal(second.alreadyRunning, true);
});
test("a failed spawn can be retried immediately", async (t) => {
const { manager, calls, directory, io } = managerIn(t);
io.spawnService = async (options) => {
calls.spawn.push(options);
if (calls.spawn.length === 1) throw new Error("spawn ENOENT");
return { pid: 7000 };
};
const options = [
"http://127.0.0.1:2334",
"D:\\env\\python.exe",
join(directory, "workspace"),
];
const first = await manager.serviceStart(...options);
assert.equal(first.started, false);
assert.match(first.error, /ENOENT/);
// The start marker was cleared with the failure; a retry must really spawn
// instead of being reported as "already starting".
const second = await manager.serviceStart(...options);
assert.equal(second.started, true);
assert.equal(calls.spawn.length, 2);
});
test("refuses to spawn while another process owns the port", async (t) => {
const { manager, calls, directory } = managerIn(t);
manager.serviceStatus = async (endpoint) => ({
phase: "conflict",
managed: false,
endpoint,
logPath: join(directory, "service.log"),
});
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, /already listening/);
assert.equal(calls.spawn.length, 0);
});
test("stops a previous launch that never became healthy before replacing it", async (t) => {
const { manager, calls, directory, io } = managerIn(t);
manager.serviceStatus = async (endpoint) => ({
phase: "stopped",
managed: false,
endpoint,
// A live record for this endpoint means a launch that is still booting.
pid: 6000,
logPath: join(directory, "service.log"),
});
writeServiceState(join(directory, "reme-memory", "service.json"), {
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",
});
let running = true;
io.probePid = () => {
if (!running) throw new Error("ESRCH");
};
io.killTree = (pid) => {
calls.killTree.push(pid);
running = false;
};
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, [6000]);
assert.equal(calls.spawn.length, 1);
});
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");

View file

@ -148,6 +148,15 @@ test("composes pip arguments for install, upgrade and mirrors", () => {
assert.throws(() =>
pipArgs({ pythonPath: "C:\\env\\python.exe", indexUrl: "ftp://x" }),
);
// Credentials would be echoed in the job command, the log and the browser.
assert.throws(
() =>
pipArgs({
pythonPath: "C:\\env\\python.exe",
indexUrl: "https://user:token@mirror.example/simple",
}),
/must not embed credentials/,
);
});
test("tails a log with CRLF and progress carriage returns", (t) => {
@ -281,5 +290,8 @@ test("reuses the running job for the same interpreter and validates paths", (t)
const second = jobs.start({ pythonPath: "C:\\env\\python.exe" });
assert.equal(first.id, second.id);
assert.throws(() => jobs.start({ pythonPath: "relative\\python.exe" }));
// Root-relative Windows paths pass win32.isAbsolute but are not absolute in
// the sense the install needs (they resolve against the current drive).
assert.throws(() => jobs.start({ pythonPath: "\\python.exe" }));
assert.equal(jobs.status("unknown-job"), null);
});

View file

@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createNodeRunner } from "../dist/environment/host/runner.js";
test("a timeout settles with an explicit error instead of hanging", async () => {
// The child ignores SIGTERM, so the grace path must force it and settle.
const run = createNodeRunner({ PATH: process.env.PATH });
const started = Date.now();
const result = await run(
process.execPath,
["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
{ timeoutMs: 200 },
);
assert.match(result.error, /timed out after 200 ms/);
assert.ok(
Date.now() - started < 5_000,
"the probe must not wait for a process that never closes",
);
});
test("a child that exits on SIGTERM is still reported as a timeout", async () => {
const run = createNodeRunner({ PATH: process.env.PATH });
const result = await run(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 200 },
);
assert.match(result.error ?? "", /timed out after 200 ms/);
});

View file

@ -1,11 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer } from "node:http";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildStartCommand,
createServiceIO,
ownsService,
parseLocalEndpoint,
readServiceState,
@ -18,7 +20,7 @@ import {
const ENDPOINT = "http://127.0.0.1:2334";
function fakeIO(overrides = {}) {
const calls = { killTree: [], spawn: [] };
const calls = { killTree: [], force: [], spawn: [] };
const io = {
fetchHealth: async () => ({ ok: false, version: "", error: "" }),
portOpen: async () => false,
@ -26,8 +28,9 @@ function fakeIO(overrides = {}) {
calls.spawn.push(options);
return { pid: 4321 };
},
killTree(pid) {
killTree(pid, force = false) {
calls.killTree.push(pid);
if (force) calls.force.push(pid);
},
probePid() {},
processImage: async () => "python.exe",
@ -56,7 +59,7 @@ function record(statePath, overrides = {}) {
});
}
test("accepts only loopback endpoints and validates the port", () => {
test("accepts only loopback HTTP endpoints and validates the port", () => {
assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334"), {
host: "127.0.0.1",
port: 2334,
@ -68,6 +71,12 @@ test("accepts only loopback endpoints and validates the port", () => {
assert.throws(() => parseLocalEndpoint("http://10.0.0.5:2333"));
assert.throws(() => parseLocalEndpoint("not-a-url"));
assert.throws(() => parseLocalEndpoint("http://127.0.0.1:0"));
// The plugin starts a plain HTTP service, so https on loopback can never be
// healthy; it must not be offered as manageable.
assert.throws(
() => parseLocalEndpoint("https://127.0.0.1:2333"),
/http:\/\//,
);
});
test("builds reme start with the workspace cwd contract", () => {
@ -162,6 +171,21 @@ test("a service on another endpoint is not reported as managed", async (t) => {
assert.equal(status.pid, undefined);
});
test("a loopback alias still identifies the service this plugin started", async (t) => {
const { directory, statePath } = tempState(t, "reme-alias-");
record(statePath, { endpoint: "http://localhost:2334/" });
const { io } = fakeIO({
fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }),
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
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);
@ -182,6 +206,35 @@ test("a recycled pid that is no longer the service is not managed", async (t) =>
await ownsService(gone, readServiceState(statePath), ENDPOINT),
false,
);
// Ownership guards a destructive action: an image that cannot be read is
// not evidence that the pid is ours, and a prefix match would accept e.g.
// pycharm64.exe.
for (const image of ["", " ", "pycharm64.exe", "python-config"]) {
const { io: unreadable } = fakeIO({ processImage: async () => image });
assert.equal(
await ownsService(unreadable, readServiceState(statePath), ENDPOINT),
false,
`image ${JSON.stringify(image)} must not be owned`,
);
}
});
test("a dead record is cleared instead of reporting a stale pid", async (t) => {
const { directory, statePath } = tempState(t, "reme-dead-");
record(statePath);
const { io } = fakeIO({
probePid() {
throw new Error("ESRCH");
},
});
const status = await serviceStatus(io, {
endpoint: ENDPOINT,
statePath,
logPath: join(directory, "service.log"),
});
assert.equal(status.phase, "stopped");
assert.equal(status.pid, undefined);
assert.equal(readServiceState(statePath), undefined);
});
test("starts detached with the GBK-safe environment and records state", async (t) => {
@ -230,11 +283,24 @@ test("records nothing when the spawn fails", async (t) => {
assert.equal(readServiceState(statePath), undefined);
});
test("stops the managed pid and clears the state file", 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 stopped = await stopService(io, { statePath, endpoint: ENDPOINT });
let running = true;
io.probePid = () => {
if (!running) throw new Error("ESRCH");
};
io.killTree = (pid) => {
calls.killTree.push(pid);
running = false;
};
const stopped = await stopService(io, {
statePath,
endpoint: ENDPOINT,
timeoutMs: 100,
pollMs: 5,
});
assert.deepEqual(stopped, { stopped: true, pid: 999 });
assert.deepEqual(calls.killTree, [999]);
assert.equal(readServiceState(statePath), undefined);
@ -244,6 +310,25 @@ test("stops the managed pid and clears the state file", 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 stopped = await stopService(io, {
statePath,
endpoint: ENDPOINT,
timeoutMs: 10,
forceTimeoutMs: 10,
pollMs: 2,
});
assert.equal(stopped.stopped, false);
assert.match(stopped.reason, /did not exit/);
assert.deepEqual(calls.killTree, [999, 999]);
assert.deepEqual(calls.force, [999]);
// The pid survives in the record, so Stop can be tried again later.
assert.equal(readServiceState(statePath)?.pid, 999);
});
test("refuses to stop a service recorded for another endpoint", async (t) => {
const { statePath } = tempState(t, "reme-refuse-");
record(statePath, { endpoint: "http://127.0.0.1:2333" });
@ -254,3 +339,80 @@ test("refuses to stop a service recorded for another endpoint", async (t) => {
assert.deepEqual(calls.killTree, []);
assert.ok(readServiceState(statePath), "the foreign record is left in place");
});
/* ------------------------------------------------------------------ */
/* Real IO: the pieces the fake adapter above cannot vouch for. */
/* ------------------------------------------------------------------ */
async function listening(handler, t) {
const server = createServer(handler);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => server.close());
const address = server.address();
return `http://127.0.0.1:${address.port}`;
}
test("real IO accepts only a ReMe health report", async (t) => {
const reme = await listening((request, response) => {
response.setHeader("Content-Type", "application/json");
if (request.url === "/health_check")
response.end(
JSON.stringify({
success: true,
metadata: { health: { version: "0.4.2.1", healthy: true } },
}),
);
else response.end("{}");
}, t);
const generic = await listening((_request, response) => {
response.setHeader("Content-Type", "application/json");
response.end("{}");
}, t);
const io = createServiceIO();
const good = await io.fetchHealth(reme, "");
assert.equal(good.ok, true);
assert.equal(good.version, "0.4.2.1");
const empty = await io.fetchHealth(generic, "");
assert.equal(empty.ok, false);
assert.match(empty.error, /ReMe health report/);
// The Authorization header is actually sent when a token is configured.
let authorization = "";
const guarded = await listening((request, response) => {
authorization = String(request.headers.authorization ?? "");
response.end("{}");
}, t);
await io.fetchHealth(guarded, "secret-token");
assert.equal(authorization, "Bearer secret-token");
});
test("real IO spawns, identifies and stops a detached process", async (t) => {
const directory = mkdtempSync(join(tmpdir(), "reme-real-service-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const io = createServiceIO();
const { pid } = await io.spawnService({
exe: process.execPath,
args: ["-e", "setInterval(() => {}, 1000)"],
cwd: directory,
logPath: join(directory, "service.log"),
env: process.env,
});
assert.ok(pid !== undefined && pid > 1);
assert.doesNotThrow(() => io.probePid(pid));
assert.doesNotMatch(await io.processImage(pid), /^$/);
io.killTree(pid);
const deadline = Date.now() + 5_000;
let running = true;
while (running && Date.now() < deadline) {
try {
io.probePid(pid);
await new Promise((resolve) => setTimeout(resolve, 50));
} catch {
running = false;
}
}
assert.equal(running, false, "the detached process must actually exit");
});

View file

@ -52,13 +52,21 @@ const RESPONSES = {
},
"remeEnv/installStatus": null,
"remeEnv/readEnv": {
workspaceDir: "C:\\Users\\test\\.reme",
exists: true,
envPath: "C:\\Users\\test\\.reme\\.env",
entries: [
{ key: "LLM_BACKEND", value: "dashscope", secret: false, set: true },
{
key: "LLM_BACKEND",
value: "dashscope",
masked: "dashscope",
secret: false,
set: true,
},
{
key: "LLM_MODEL_NAME",
value: "qwen3.7-plus",
masked: "qwen3.7-plus",
secret: false,
set: true,
},
@ -70,6 +78,7 @@ const RESPONSES = {
managed: false,
pid: 98820,
endpoint: "http://127.0.0.1:2333",
logPath: "C:\\Users\\test\\.reme\\service.log",
},
};
@ -250,6 +259,32 @@ test("marks the suggested interpreter and explains why", async (t) => {
]);
});
test("sends the chosen package mirror with the install request", async (t) => {
const { renderer, calls, restore } = await mount();
t.after(restore);
const select = renderer.root.findAll((node) => node.type === "select")[0];
await act(async () => {
select.props.onChange({
target: { value: "https://pypi.tuna.tsinghua.edu.cn/simple" },
});
});
await act(async () => {
buttonByText(renderer, envZh.reinstall).props.onClick();
});
await act(async () => {
buttonByText(renderer, envZh.confirmInstall).props.onClick();
});
const install = calls.rpc.filter((call) => call.method === "remeEnv/install");
assert.ok(install.length > 0, "the install request must be sent");
assert.deepEqual(install.at(-1).input, {
pythonPath: SUGGESTED,
upgrade: false,
indexUrl: "https://pypi.tuna.tsinghua.edu.cn/simple",
});
});
test("drops .env drafts when the workspace changes", async (t) => {
const { renderer, calls, setValue, restore } = await mount();
t.after(restore);