From ef01f82c675f314c4c9a24b0fae9127e23639caf Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 12:17:35 +0800 Subject: [PATCH 1/8] Smooth quota carousel in the 0.4.1 preview --- AGENTS.md | 8 +++++ CHANGELOG.md | 8 +++++ README.md | 6 ++-- docs/usage.md | 4 +-- package-lock.json | 4 +-- package.json | 2 +- plugin.json | 2 +- public/widget.css | 10 ++++--- public/widget.js | 9 +++--- scripts/source-files.js | 2 +- tests/fixtures/widget-view.js | 27 +++++++++++++---- tests/view-loading.test.js | 41 ++++++++++++++++++++++++++ tests/widget-animation.test.js | 54 ++++++++++++++++++++++++++++++++++ 13 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/widget-animation.test.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3211c2f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# 本对话的版本约定 + +- 用户指定:本对话的后续更新统一保存为 `0.4.1-preview`,在 `preview/0.4.1` 分支开发;不要自动递增版本号或添加新的 preview 序号。 +- 当前继续使用的 `0.4.0-preview.1` 是保留版本。不要覆盖其安装目录、已生成的 0.4.0 构建、运行配置、任务数据或 GitHub `main` 分支。 +- 新版本改动保存在源码和 Git 提交中。没有新的明确安装指令时,不运行安装器、不切换用户正在使用的插件、不发布 Release 或合并到 `main`。 +- 需要构建时,在独立的 0.4.1 源码导出目录构建,避免默认构建路径覆盖现有 0.4.0 产物。 +- 测试使用独立数据目录和模拟 RPC/消息通道,不启动真实任务,不发送聊天消息,不更改 Electron 沙箱或系统 ACL。 +- 当前工作目录的旧 Git 历史包含私有开发记录。公开提交仍通过脱敏独立仓库 `dist/public-source/codex-continuer`,不得把原开发仓库的历史推送出去。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 21381c4..3f27fdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 更新记录 +## 0.4.1-preview — 开发中 + +- 本对话的后续更新固定保存到此版本;保留现有安装版 0.4.0-preview.1。 +- 五小时/七天额度的切换间隔由 5 秒延长到 12 秒,淡出换值和淡入的完整过渡由 480 毫秒延长到 1 秒。 +- 额度文字与背景装饰动画的隐藏阶段解耦,在可见的紧凑仪表中持续显示;背景的 60 秒动画保留。 +- 移除切换时强制读取布局的操作;隐藏、展开面板时仍停止轮播和相关定时器,减少动态效果模式仍直接换值。 +- 本轮验证:236 项回归全部通过,包含真实 Electron 的透明度与背景循环阶段检查;当前安装版 0.4.0-preview.1 的 162 个文件及构建指纹保持不变。 + ## 0.4.0-preview.1 — 2026-10-25 - 公开文档中的真实聊天标识和个人路径脱敏,增加公开源码检查。 diff --git a/README.md b/README.md index 57f9b89..fcb1e42 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ 监测 Codex 账户额度,在额度恢复后接续指定工作,并通过跟随 Codex 窗口的桌面挂件查看状态。 -**当前版本:0.4.0-preview.1 · Windows x64 预览版 · GPL-3.0-only。** 这是独立社区项目,与 OpenAI、Rhine、Clash Verge 无隶属关系。 +**开发版本:0.4.1-preview · Windows x64 预览版 · GPL-3.0-only。** 这是独立社区项目,与 OpenAI、Rhine、Clash Verge 无隶属关系。 + +本对话后续更新统一记录在 `0.4.1-preview` / `preview/0.4.1`。现有安装版 `0.4.0-preview.1` 保留,预览改动不会自动安装或合并到 `main`。 ## 功能 @@ -80,7 +82,7 @@ npm run doctor npm run build:release ``` -`doctor` 只检查版本和帮助信息,不调用模型或发送消息。测试使用独立数据与模拟 RPC/队列,并包含隐藏 Electron 窗口检查。本机已通过 232 项回归;托管 CI 状态以上方徽章和对应提交的工作流结果为准。 +`doctor` 只检查版本和帮助信息,不调用模型或发送消息。测试使用独立数据与模拟 RPC/队列,并包含隐藏 Electron 窗口检查。本机已通过 236 项回归;托管 CI 状态以上方徽章和对应提交的工作流结果为准。 `build:release` 生成便携压缩包、同版本源码归档、`release.json` 和 `SHA256SUMS`。分发二进制时须配套提供源码归档。编译器可通过 `CONTINUER_CSC` 指定。 diff --git a/docs/usage.md b/docs/usage.md index 2d64b05..2ec939d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,12 +1,12 @@ # 功能与使用细节 -**0.4.0-preview.1 · Windows x64 预览版。** 开源准备、验证证据与剩余验收见 [开源准备记录](open-source-readiness.md)。使用范围见 [兼容性说明](compatibility.md),参与开发和反馈见 [CONTRIBUTING.md](../CONTRIBUTING.md)、[SECURITY.md](../SECURITY.md)。本工具独立于 OpenAI、Rhine 和 Clash Verge,不代表这些项目官方产品。 +**0.4.1-preview · Windows x64 预览版。** 开源准备、验证证据与剩余验收见 [开源准备记录](open-source-readiness.md)。使用范围见 [兼容性说明](compatibility.md),参与开发和反馈见 [CONTRIBUTING.md](../CONTRIBUTING.md)、[SECURITY.md](../SECURITY.md)。本工具独立于 OpenAI、Rhine 和 Clash Verge,不代表这些项目官方产品。 一个可运行的本地 Codex 插件:监测账户额度、查看任务计划,并在额度恢复后继续指定工作。v0.3.3 基于 [Rhine 仓库的动画 SVG](https://github.com/empty610/rhine_ui_clash-verge/blob/a298150dbbd13a217bf48fe574f8fd27bfec2b80/src/assets/image/rhine-signal-loop.svg),保留 60 秒的轮廓、飞行、行星、轨道形变动画。本地版本精简重复关键帧与叠加滤镜,将 48 个粒子动画合并为 3 组,并移除方形外框;不使用图片序列缓存或额外合成层。界面使用仓库中的暖灰、琥珀配色,支持明暗主题。 运行诊断、存储恢复、资源上限和发布流程见 [运行、恢复与发布](operations.md)。打开挂件或通过 MCP 冷启动读取状态时只进入查询模式;明确开启调度或继续工作后才执行。版本之外还显示内容指纹 buildId,避免同一版本号下的不同代码被混用。 -收起后显示动态仪表:默认先显示**五小时额度及其重置倒计时**,5 秒后执行刷新转场并显示七天额度,之后每 5 秒交替;原版 SVG 的动画时间线保持连续。倒计时每秒更新,数据未知或过期时显示「待同步」,重置时间已到则显示「待刷新」。这仅切换显示,续作调度仍需所有窗口的真实新额度样本达标。系统开启「减少动态效果」时关闭装饰动画与转场,但保留额度轮播。 +收起后显示动态仪表:默认先显示**五小时额度及其重置倒计时**,每 12 秒切换一次,使用 1 秒的淡出、换值、淡入过渡;额度文字不再随装饰动画长时间隐藏,原版 SVG 的 60 秒动画时间线保持连续。倒计时每秒更新,数据未知或过期时显示「待同步」,重置时间已到则显示「待刷新」。这仅切换显示,续作调度仍需所有窗口的真实新额度样本达标。系统开启「减少动态效果」时关闭装饰动画与转场,但保留额度轮播。 桌面卡片默认 256 px,可在「工作策略与显示」中用滑块调为 160–420 px,也可按住 Ctrl 在卡片上滚动鼠标滚轮调整;大小会保存。**点击整张卡片展开,拖动任意位置移动**;桌面光标移动超过 6 个逻辑像素视为拖动,不触发展开。静止按住或轻微抖动不会移动卡片或保存新位置;失焦、取消或丢失指针捕获时结束拖动。展开/收起先淡出,在不可见时一次切换窗口尺寸和位置,再淡入;两种模式分别记住位置。 diff --git a/package-lock.json b/package-lock.json index cac35ad..1572a9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-continuer", - "version": "0.4.0-preview.1", + "version": "0.4.1-preview", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-continuer", - "version": "0.4.0-preview.1", + "version": "0.4.1-preview", "devDependencies": { "electron": "45.0.0" }, diff --git a/package.json b/package.json index 3e1fb37..3cfde7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-continuer", - "version": "0.4.0-preview.1", + "version": "0.4.1-preview", "private": true, "type": "module", "description": "Local Codex quota monitor and resumable task manager", diff --git a/plugin.json b/plugin.json index dcf907b..387635f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "codex-continuer", - "version": "0.4.0-preview.1", + "version": "0.4.1-preview", "description": "Windows 预览版:Codex 额度监测、原聊天接续与任务管理", "extensions": { "com.openai": { diff --git a/public/widget.css b/public/widget.css index b7726d6..934d26c 100644 --- a/public/widget.css +++ b/public/widget.css @@ -1,11 +1,13 @@ :root{font-family:"Segoe UI","Microsoft YaHei",sans-serif;font-size:12px;color-scheme:dark;--bg:#151b18;--card:#1c241f;--text:#e0e7dc;--muted:#8a998e;--line:#354238;--accent:#c3d49b;--purple:var(--accent);--green:#9cc8b6;--orange:#dba26d;--tint:#b5cd8b12;--shadow:0 12px 35px #0006;--mono:"Bahnschrift","Cascadia Mono","Consolas",monospace}*{box-sizing:border-box}body{margin:0;padding:8px;background:transparent;color:var(--text)}body.light{color-scheme:light;--bg:#e8ece3;--card:#f1f3eb;--text:#2c3a2c;--muted:#6e806e;--line:#c5cebc;--accent:#587c40;--green:#4f8172;--orange:#ae6f37;--tint:#52743c10;--shadow:0 12px 35px #1a2a181c}button,input,textarea,select{font:inherit}button{border:1px solid var(--line);background:transparent;color:var(--text);border-radius:4px;padding:7px 10px;cursor:pointer;transition:border-color .18s,background .18s,color .18s}button:hover{background:var(--tint);border-color:var(--accent);color:var(--accent)}button:disabled{opacity:.38;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible,select:focus-visible{outline:1px solid var(--accent);outline-offset:3px}.primary{background:var(--accent);border-color:var(--accent);color:var(--bg);font-weight:600}.primary:hover{background:var(--accent);color:var(--bg);filter:brightness(1.08)}.shell{height:calc(100dvh - 16px);max-width:416px;margin:auto;border:1px solid #71806b70;border-radius:17px;background:radial-gradient(ellipse at 100% 0%,#708a4b16,transparent 45%),var(--bg);box-shadow:var(--shadow);overflow:hidden;position:relative}.shell:after{content:"";position:absolute;inset:0;pointer-events:none;border-radius:inherit;box-shadow:inset 0 1px 0 #d0e1ba14}#panel{height:100%;display:flex;flex-direction:column}header{display:flex;align-items:center;gap:6px;padding:17px 15px 15px;border-bottom:1px solid var(--line);-webkit-app-region:drag;cursor:grab;user-select:none}.rhine-mark{width:30px;height:33px;display:grid;place-items:center;margin-right:6px;position:relative;border:1px solid var(--line);background:var(--tint);clip-path:polygon(0 0,75% 0,100% 25%,100% 100%,25% 100%,0 75%)}.rhine-mark:before,.rhine-mark:after{content:"";position:absolute;width:15px;height:4px;background:var(--accent);transform:skewY(-30deg)}.rhine-mark:before{top:10px}.rhine-mark:after{bottom:8px}.rhine-mark i{position:absolute;left:8px;top:11px;width:4px;height:13px;background:var(--accent);transform:skewY(-30deg)}.brand{flex:1;min-width:0}.brand strong{font-family:var(--mono);font-size:15px;font-weight:600;letter-spacing:1.3px}.brand strong span{font-size:10px;color:var(--muted)}.brand small{display:block;font-size:9px;letter-spacing:2px;color:var(--muted);margin-top:5px}header button{width:24px;height:26px;padding:0;border-color:transparent;font-size:17px;color:var(--muted);-webkit-app-region:no-drag}.scroll{overflow:auto;flex:1;padding:4px 17px 13px;scrollbar-width:thin;scrollbar-color:var(--line) transparent}.section-label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:14px 0 10px;font-size:10px;color:var(--muted)}.section-label>span:first-child{color:var(--text);font-size:11px;font-weight:600;white-space:nowrap}.section-label em{font-family:var(--mono);font-style:normal;font-size:9px;color:var(--accent);border-right:1px solid var(--line);padding-right:8px;margin-right:5px}.section-label>span:last-child{font-family:var(--mono);font-size:9px}.quota-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.quota{min-width:0;padding:14px 13px 12px;border:1px solid var(--line);border-radius:5px;background:linear-gradient(130deg,#d4e3b904,transparent 65%),var(--card);position:relative;overflow:hidden}.quota:after{content:"";position:absolute;top:0;right:0;width:7px;height:7px;background:var(--accent);opacity:.35;clip-path:polygon(0 0,100% 0,100% 100%)}.quota .caption{color:var(--muted);font-size:10px;overflow-wrap:anywhere}.quota strong{display:block;font-family:var(--mono);font-size:38px;font-weight:500;letter-spacing:-1.7px;color:var(--accent);margin:8px 0 10px;line-height:1}.quota strong small{font-family:"Segoe UI","Microsoft YaHei",sans-serif;font-size:9px;letter-spacing:0;font-weight:400;margin-left:5px;color:var(--muted)}.quota:nth-child(even) strong{color:var(--green)}.meter{height:3px;background:var(--line);overflow:hidden}.meter i{display:block;height:100%;background:var(--accent);box-shadow:0 0 5px #b9cf8255;transition:width .4s}.quota:nth-child(even) .meter i{background:var(--green)}.quota .reset{font-size:9px;color:var(--muted);margin-top:10px;line-height:1.6}.hint{font-size:10px;line-height:1.7;color:var(--muted);margin:9px 0;overflow-wrap:anywhere}#health{display:flex;align-items:center;gap:7px;font-size:9px;margin:10px 0 15px}#health:before{content:"";width:4px;height:4px;flex-shrink:0;background:var(--green);box-shadow:0 0 5px #a4d1b766}.scheduler{display:flex;align-items:center;gap:10px;padding:13px 12px;border:1px solid #a7bf7838;border-left:2px solid var(--accent);border-radius:4px;background:linear-gradient(90deg,#b9d08c0d,transparent);margin:0 0 21px}.scheduler>div{flex:1}.scheduler strong{font-size:11px;color:var(--accent);font-weight:500}.scheduler strong span{font-family:var(--mono);font-size:8px;letter-spacing:.8px;color:var(--muted);margin-left:7px}.scheduler small{display:block;color:var(--muted);font-size:9px;margin-top:5px}.status-beacon{width:5px;height:5px;flex-shrink:0;background:var(--accent);box-shadow:0 0 7px #c4d59b70;animation:beacon 3s ease-in-out infinite}.scheduler.paused .status-beacon{background:var(--muted);animation:none;box-shadow:none}.switch{position:relative;width:30px;height:16px;display:inline-block;flex-shrink:0}.switch input{opacity:0;width:100%;height:100%;margin:0;position:absolute;cursor:pointer;z-index:1}.switch span{display:block;width:100%;height:100%;background:var(--line);border:1px solid #88987b55;border-radius:3px}.switch span:after{content:"";display:block;width:9px;height:10px;position:absolute;left:3px;top:3px;background:var(--muted);border-radius:1px;transition:transform .18s}.switch input:checked+span{background:#afc88430;border-color:var(--accent)}.switch input:checked+span:after{transform:translateX(15px);background:var(--accent);box-shadow:0 0 6px #bccd8b44}.switch input:focus-visible+span{outline:1px solid var(--accent);outline-offset:3px}.tasks-heading>div{display:flex;gap:5px}.tasks-heading button{font-size:9px;padding:5px 7px}.tasks-heading b{font:10px var(--mono);color:var(--accent);border:1px solid var(--line);padding:1px 4px;margin-left:4px}.session-picker{padding:11px 12px;border:1px solid var(--line);border-radius:5px;background:#85937306;margin-bottom:10px}.session-picker>label{display:block;font-size:9px;letter-spacing:1px;color:var(--muted);margin-bottom:8px}.session-picker select{display:block;width:100%;font-size:11px;padding:9px 7px;border:1px solid var(--line);border-radius:3px;color:var(--text);background:var(--bg)}.session-picker .hint{margin:7px 0 11px;font-size:9px}.session-actions{display:flex;justify-content:flex-end;gap:7px}.session-actions button{font-size:10px;padding:6px 9px}.task{background:var(--card);border:1px solid var(--line);border-left:2px solid #aabc8f66;border-radius:5px;padding:12px;margin-bottom:9px}.task-top{display:flex;align-items:center;gap:7px}.task-title{border:0;text-align:left;flex:1;min-width:0;padding:0;font-size:12px;font-weight:500;background:transparent;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{font-size:9px;background:var(--tint);color:var(--accent);padding:3px 6px;border:1px solid #a1b28c26;border-radius:3px;white-space:nowrap}.badge.completed{color:var(--green);border-color:#9cc8b633;background:#9cc8b608}.badge.waiting,.badge.failed,.badge.blocked,.badge.approval{color:var(--orange);border-color:#dba26d33;background:#dba26d08}.path{font:9px var(--mono);color:var(--muted);overflow:hidden;white-space:nowrap;text-overflow:ellipsis;margin:7px 0 11px}.progress{display:flex;gap:9px;align-items:center}.progress .meter{flex:1;height:2px}.progress>span{font:9px var(--mono);color:var(--muted)}.activity{color:var(--muted);font-size:10px;line-height:1.7;margin:9px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-bottom{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #71806b1a;padding-top:7px}.task-bottom label{display:flex;gap:5px;align-items:center;color:var(--muted);font-size:10px}.task-bottom input{accent-color:var(--accent);width:11px;height:11px}.task-bottom button{border:0;color:var(--accent);font-size:10px;padding:3px 0 3px 10px}.empty{color:var(--muted);font-size:11px;text-align:center;padding:22px 5px;line-height:2}.section{border-top:1px solid var(--line);margin-top:15px;padding-top:13px}.section summary{cursor:pointer;font-size:10px;letter-spacing:.7px;color:var(--muted)}#settings-form>label{display:flex;align-items:center;justify-content:space-between;margin:14px 0;font-size:11px}input,textarea{border:1px solid var(--line);background:var(--bg);color:var(--text);border-radius:3px;padding:8px;min-width:0}input[type=number]{width:65px;padding:5px;font-family:var(--mono)}input[type=checkbox]{accent-color:var(--accent)}textarea{resize:vertical;min-height:110px;line-height:1.7}#settings-form>button{font-size:10px}.log{display:flex;gap:10px;font-size:10px;line-height:1.7;margin:10px 0;overflow-wrap:anywhere}.log time{font-family:var(--mono);color:var(--muted);white-space:nowrap}footer{display:flex;justify-content:space-between;gap:8px;border-top:1px solid var(--line);color:var(--muted);font-size:8px;margin:18px 0 2px;padding-top:12px}.footer-mark{font-family:var(--mono);letter-spacing:1px}.approval{background:var(--card);border:1px solid #b99b5c;border-left:2px solid var(--orange);border-radius:4px;padding:12px;margin-bottom:12px}.approval strong{font-size:12px;color:var(--orange)}.approval button{font-size:11px;margin:5px 4px 0 0}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:10px/1.7 var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:9px;max-height:220px;overflow:auto}dialog{width:calc(100% - 34px);max-width:390px;max-height:calc(100dvh - 38px);border:1px solid #81917180;border-top:2px solid var(--accent);border-radius:8px;background:var(--card);color:var(--text);padding:16px;box-shadow:var(--shadow)}dialog::backdrop{background:#070c0bcc;backdrop-filter:blur(4px)}.modal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.modal-head button{border:0;padding:2px 7px;font-size:20px;color:var(--muted)}#modal-body form>label{display:flex;flex-direction:column;gap:7px;margin:12px 0;font-size:11px}#modal-body form>label.check{flex-direction:row;align-items:center}#modal-body form>button{margin-top:8px}.directory{display:flex;gap:5px}.directory input{flex:1}.directory button{padding:5px}#error{color:var(--orange);font-size:11px;line-height:1.5;margin-bottom:0}#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);width:max-content;max-width:90%;border:1px solid var(--line);border-left:2px solid var(--accent);border-radius:4px;background:var(--card);color:var(--text);box-shadow:var(--shadow);padding:10px 15px;font-size:11px;opacity:0;pointer-events:none;z-index:10;transition:opacity .18s}#toast.show{opacity:1}.session{display:block;width:100%;text-align:left;margin-bottom:7px;overflow-wrap:anywhere}.session small{display:block;color:var(--muted);margin-top:5px}.steps{padding-left:22px;font-size:11px;line-height:2}.detail-summary{font-size:11px;line-height:1.8;overflow-wrap:anywhere} /* Original Rhine signal artwork; see THIRD_PARTY_NOTICES.md. */ :root{--bg:#20221d;--card:#272922;--text:#e5e3d8;--muted:#aaa99b;--line:#44473b;--accent:#d7b578;--green:#b7c4ad;--orange:#e58b70;--tint:#d7b5780e;--rhine-line:var(--line);--compact-size:256px;--disc-size:calc(var(--compact-size) - 16px)}body.light{--bg:#eeeee6;--card:#f6f5ef;--text:#292a26;--muted:#73746b;--line:#d5d5c9;--accent:#c7a169;--green:#698474;--orange:#b66f4d;--tint:#c7a16912}body{height:100dvh;overflow:hidden}.shell{width:100%;max-width:none;height:100%;transition:border-radius .28s ease,background-color .28s ease;border-radius:16px;background:var(--bg)}#panel{position:absolute;inset:0;width:100%;height:100%;opacity:1;transform:translateY(0);visibility:visible;transition:opacity .2s ease .06s,transform .28s ease,visibility 0s}#mini{display:block;position:absolute;right:0;bottom:0;width:var(--disc-size);height:var(--disc-size);opacity:0;visibility:hidden;transform:scale(.96);transform-origin:100% 100%;transition:opacity .16s ease,transform .28s ease,visibility 0s .2s;-webkit-app-region:no-drag;cursor:pointer;user-select:none;touch-action:none;outline-offset:-18px}#mini>*{pointer-events:none}#mini:active{cursor:grabbing}.collapsed .shell{border-radius:50%;border-color:transparent;background:transparent;box-shadow:none;overflow:visible}.collapsed .shell:after{opacity:0}.collapsed #panel{opacity:0;visibility:hidden;transform:translateY(8px);pointer-events:none;transition:opacity .12s ease,transform .2s ease,visibility 0s .2s}.collapsed #mini{opacity:1;visibility:visible;transform:scale(1);transition:opacity .24s ease .06s,transform .28s ease,visibility 0s}#rhine-art,#gauge-readout{position:absolute;inset:0;width:100%;height:100%}#rhine-art svg{display:block;width:100%;height:100%;overflow:visible}#mini .rhine-signal-motion{animation-play-state:paused}#mini[data-motion=running] .rhine-signal-motion{animation-play-state:running}#gauge-readout{overflow:visible;font-family:'Courier New',monospace;font-variant-numeric:tabular-nums;fill:#e4eee7}.quota-number{fill:#f8f8ed;font-size:56px;font-weight:600;letter-spacing:-1px}.quota-unit{fill:#e4eee7;font-size:10px;letter-spacing:.3px}#mini-window{font-family:'Microsoft YaHei',sans-serif;font-size:8px;letter-spacing:1px;fill:#d9e5d8}#mini-reset-pixels{fill:#eb6946;filter:url(#rhine-signal-edge-glow)}#mini-reset{fill:#eb6946;font-family:'Microsoft YaHei',sans-serif;font-size:11px}#mini-reset.visually-empty{opacity:0}#mini-reset-label{fill:#d1a08a;font-family:'Microsoft YaHei',sans-serif;font-size:7px;letter-spacing:1.5px}#quota-dot-primary,#quota-dot-secondary{fill:#6a756c;opacity:.35}#quota-dot-primary.selected,#quota-dot-secondary.selected{fill:#f1c782;opacity:.9}#mini-open{position:absolute;right:17%;top:48%;font-size:9px;color:#dce5d2;opacity:.65}#mini:hover #mini-open{opacity:1}.offline #gauge-readout{opacity:.6}.is-switching #mini{pointer-events:none}/* The outer overlay follows the artwork; the inner group handles quota changes. */ -#mini.has-art #gauge-readout,#mini.has-art #mini-open{animation:readout-art-cycle 60s ease-in-out infinite;animation-play-state:paused} -#mini.has-art[data-motion=running] #gauge-readout,#mini.has-art[data-motion=running] #mini-open{animation-play-state:running} +#mini.has-art #mini-open{animation:readout-art-cycle 60s ease-in-out infinite;animation-play-state:paused} +#mini.has-art[data-motion=running] #mini-open{animation-play-state:running} @keyframes readout-art-cycle{0%,20%{opacity:1}21.1667%,64.1667%{opacity:0}65.5%,100%{opacity:1}} -#mini.is-refreshing #quota-readout{animation:quota-reload .48s ease-in-out both} -@keyframes quota-reload{0%,100%{opacity:1}42%,58%{opacity:0}} +/* Keep quota information visible throughout the decorative artwork cycle. */ +#gauge-readout{opacity:1} +#mini.is-refreshing #quota-readout{animation:quota-reload 1s cubic-bezier(.37,0,.63,1) both} +@keyframes quota-reload{0%,100%{opacity:1}45%,55%{opacity:0}} @keyframes beacon{0%,100%{opacity:.5}50%{opacity:1}}.size-setting>span{display:flex;flex-direction:column;align-items:flex-end;gap:5px}.size-setting output{font:10px var(--mono);color:var(--accent)}#compact-size{width:130px;padding:0;accent-color:var(--accent);border:0;background:transparent}.browser{min-height:100dvh;background:var(--bg)}.browser .shell{width:min(416px,100%);height:min(704px,calc(100dvh - 16px));margin:auto;transition:width .28s ease,height .28s ease,border-radius .28s ease,background-color .28s ease}.browser.collapsed .shell{width:var(--disc-size);height:var(--disc-size)}.browser #hide,.browser #follow-label,.browser #reset-position{display:none}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}@media(max-width:340px){.scroll{padding:4px 11px 10px}header{padding:13px 10px;gap:3px}.brand strong{font-size:13px;letter-spacing:.7px}.brand small{letter-spacing:1px}.quota{padding:11px}.quota strong{font-size:32px}.scheduler strong span{display:none}.section-label em{padding-right:5px;margin-right:3px}} .collapsed #mini{width:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px));height:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px))}.browser.collapsed .shell{width:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px));height:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px))} diff --git a/public/widget.js b/public/widget.js index edeff7b..e11eb06 100644 --- a/public/widget.js +++ b/public/widget.js @@ -12,6 +12,7 @@ const bridge = window.continuerDesktop; const labels = { queued: '排队中', waiting: '等待额度', starting: '准备中', running: '进行中', pausing: '暂停中', paused: '已暂停', blocked: '需要关注', failed: '执行失败', completed: '已完成', approval: '等待授权' }; const taskLabel=t=>t.status==="monitoring" ? "监测中" : t.waitReason==="desktop" ? "等待发送通道" : t.status==='waiting' && ['writer','thread'].includes(t.waitReason) ? '等待会话' : labels[t.status] || t.status; const busy = ["monitoring", 'queued', 'waiting', 'starting', 'running', 'approval', 'pausing']; +const QUOTA_CYCLE_MS = 12000, QUOTA_TRANSITION_MS = 1000; const taskDetails = new TaskDetails(api, () => { if (viewVisible() && detailId && $('#modal').open) detail(detailId); }, error => toast(error.message)); const approvalDetails = new ApprovalDetails(api, () => render()); let desktopPreferenceError = null, rendererAnnounced = false, rendererReady = false; @@ -51,11 +52,11 @@ function syncActivity() { clearTimeout(swapTimer); clearTimeout(refreshTimer); if(reducedMotion.matches) { windowSlot=windowSlot==='primary'?'secondary':'primary'; renderGauge(); return; } $('#mini').classList.remove('is-refreshing'); - void $('#mini').offsetWidth; $('#mini').classList.add('is-refreshing'); - swapTimer=setTimeout(() => { windowSlot=windowSlot==='primary'?'secondary':'primary'; renderGauge(); },240); - refreshTimer=setTimeout(() => $('#mini').classList.remove('is-refreshing'),480); - },5000); + // Change the quota, label and reset together at the opacity minimum. + swapTimer=setTimeout(() => { windowSlot=windowSlot==='primary'?'secondary':'primary'; renderGauge(); },QUOTA_TRANSITION_MS / 2); + refreshTimer=setTimeout(() => $('#mini').classList.remove('is-refreshing'),QUOTA_TRANSITION_MS); + },QUOTA_CYCLE_MS); } else if(!active && carouselTimer) { clearInterval(carouselTimer); clearTimeout(swapTimer); clearTimeout(refreshTimer); carouselTimer=null; $('#mini').classList.remove('is-refreshing'); diff --git a/scripts/source-files.js b/scripts/source-files.js index c7fa18d..1850699 100644 --- a/scripts/source-files.js +++ b/scripts/source-files.js @@ -1,7 +1,7 @@ import { readdirSync } from 'node:fs'; import { join } from 'node:path'; export const sourceRoots = ['server', 'public', 'plugin', 'desktop', 'scripts', 'tests', 'docs', 'third_party', '.github']; -export const sourceFiles = ['.gitattributes', '.gitignore', 'README.md', 'LICENSE', 'THIRD_PARTY_NOTICES.md', 'SECURITY.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'package.json', 'package-lock.json', 'plugin.json', 'mcp.json', 'Install-Plugin.ps1', 'Install-Portable.ps1', 'Uninstall-Plugin.ps1', 'Enable-Autostart.ps1', 'Start-Continuer.ps1', 'Stop-Continuer.ps1', 'Start.cmd', 'Open-Widget.cmd']; +export const sourceFiles = ['AGENTS.md', '.gitattributes', '.gitignore', 'README.md', 'LICENSE', 'THIRD_PARTY_NOTICES.md', 'SECURITY.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'package.json', 'package-lock.json', 'plugin.json', 'mcp.json', 'Install-Plugin.ps1', 'Install-Portable.ps1', 'Uninstall-Plugin.ps1', 'Enable-Autostart.ps1', 'Start-Continuer.ps1', 'Stop-Continuer.ps1', 'Start.cmd', 'Open-Widget.cmd']; export function publicFiles(root = '.') { const files = [...sourceFiles]; const visit = directory => { diff --git a/tests/fixtures/widget-view.js b/tests/fixtures/widget-view.js index bc09160..abac21d 100644 --- a/tests/fixtures/widget-view.js +++ b/tests/fixtures/widget-view.js @@ -8,8 +8,9 @@ import { taskMode, wakeExplanation } from '../../public/task-mode.js'; import { quotaWindow, resetCountdown } from '../../public/quota-display.js'; import { pixelText } from '../../public/rhine-readout.js'; -export function widgetView() { - const elements = new Map(), requests = [], sources = [], intervals = new Map(); +export function widgetView({ reducedMotion = true } = {}) { + const elements = new Map(), requests = [], sources = [], intervals = new Map(), timeouts = new Map(); + let now = Date.now(); let serial = 0, desktopListener; const classes = initial => { const values = new Set(initial); return { contains: key => values.has(key), add: key => values.add(key), remove: key => values.delete(key), toggle(key, on = !values.has(key)) { on ? values.add(key) : values.delete(key); } }; }; const element = selector => { @@ -24,15 +25,29 @@ export function widgetView() { const bridge = { onState: callback => { desktopListener = callback; }, async control(action) { if (action === 'renderer-ready') desktop.visible = true; return { ...desktop }; } }; class Feed extends VisibleFeed { constructor(options) { super({ ...options, create: () => { const source = { close() { this.closed = true; } }; sources.push(source); return source; } }); } } const document = { hidden: false, activeElement: null, body: { classList: classes(['collapsed']) }, documentElement: { style: { setProperty() {} } }, querySelector: element, importNode: value => value, addEventListener() {} }; - const context = { document, window: { continuerDesktop: bridge, matchMedia: () => ({ matches: true }), addEventListener() {} }, localStorage: { getItem: () => null, setItem() {} }, - TaskDetails, ApprovalDetails, VisibleFeed: Feed, LazyResource, setHtml, storageNotice, taskMode, wakeExplanation, quotaWindow, resetCountdown, pixelText, accessHeaders: () => ({}), + class ViewDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } } + const context = { document, window: { continuerDesktop: bridge, matchMedia: () => ({ matches: reducedMotion }), addEventListener() {} }, localStorage: { getItem: () => null, setItem() {} }, Date: ViewDate, + TaskDetails, ApprovalDetails, VisibleFeed: Feed, LazyResource, setHtml, storageNotice, taskMode, wakeExplanation, + quotaWindow: (state, slot, connected) => quotaWindow(state, slot, connected, now), resetCountdown: value => resetCountdown(value, now), pixelText, accessHeaders: () => ({}), AbortSignal, getComputedStyle: () => ({ opacity: '1' }), DOMParser: class { parseFromString() { return { querySelector: () => null, documentElement: {} }; } }, - setInterval(callback, period) { const id = ++serial; intervals.set(id, { callback, period }); return id; }, clearInterval: id => intervals.delete(id), setTimeout: () => ++serial, clearTimeout() {}, + setInterval(callback, period) { const id = ++serial; intervals.set(id, { callback, period, at: now + period }); return id; }, clearInterval: id => intervals.delete(id), + setTimeout(callback, period) { const id = ++serial; timeouts.set(id, { callback, at: now + period }); return id; }, clearTimeout: id => timeouts.delete(id), async fetch(path) { requests.push(path); return { ok: true, json: async () => ({ data: [], nextCursor: null }), text: async () => '' }; }, }; const source = readFileSync(new URL('../../public/widget.js', import.meta.url), 'utf8').replace(/^import .*\n/gm, ''); runInNewContext(source, context); - return { requests, sources, intervals, element, + return { requests, sources, intervals, timeouts, element, now: () => now, + advance(duration) { + const end = now + duration; + while (true) { + const next = [...intervals, ...timeouts].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!next) break; + const [id, timer] = next; now = timer.at; + if (intervals.has(id)) timer.at += timer.period; else timeouts.delete(id); + timer.callback(); + } + now = end; + }, desktop(change) { Object.assign(desktop, change); desktopListener({ ...desktop }); }, message(index, state) { sources[index].onmessage({ data: JSON.stringify(state) }); }, flush: () => new Promise(resolve => setImmediate(resolve)), diff --git a/tests/view-loading.test.js b/tests/view-loading.test.js index bc006d3..e4becb0 100644 --- a/tests/view-loading.test.js +++ b/tests/view-loading.test.js @@ -93,3 +93,44 @@ test('widget loads only its current view, stops idle work while hidden and resto assert.equal(f.requests.filter(path => path === '/rhine-signal-loop.svg').length, 1); f.desktop({ visible: false }); assert.equal(f.intervals.size, 0); }); + +async function quotaView(reducedMotion = false) { + const view = widgetView({ reducedMotion }); await view.flush(); + const now = view.now(); + const sample = { connection: { status: 'connected' }, settings: { enabled: false }, sampledAt: now, quota: { rateLimits: { + primary: { usedPercent: 23, windowDurationMins: 300, resetsAt: now / 1000 + 3600 }, + secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: now / 1000 + 7200 }, + } }, tasks: [], approvals: [], logs: [] }; + view.message(0, sample); await view.flush(); return view; +} + +test('quota carousel dwells for twelve seconds and switches all values at the fade midpoint', async () => { + const view = await quotaView(), mini = view.element('#mini'); + assert.equal(view.element('#mini-quota').textContent, '77'); + view.advance(11999); assert.equal(mini.classList.contains('is-refreshing'), false); + view.advance(1); assert.equal(mini.classList.contains('is-refreshing'), true); + view.advance(499); assert.equal(view.element('#mini-window').textContent, '五小时可用额度'); + view.advance(1); assert.equal(view.element('#mini-quota').textContent, '39'); assert.equal(view.element('#mini-window').textContent, '七天可用额度'); + assert.equal(view.element('#mini-reset').textContent, '01:59:48'); + view.advance(500); assert.equal(mini.classList.contains('is-refreshing'), false); + view.advance(11000); assert.equal(mini.classList.contains('is-refreshing'), true); + view.advance(500); assert.equal(view.element('#mini-quota').textContent, '77'); assert.equal(view.element('#mini-window').textContent, '五小时可用额度'); + assert.equal(view.requests.filter(path => path === '/rhine-signal-loop.svg').length, 1); +}); + +test('hiding or expanding during a quota fade cancels the pending swap and animation timers', async () => { + const view = await quotaView(), mini = view.element('#mini'); + view.advance(12250); assert.equal(mini.classList.contains('is-refreshing'), true); + view.desktop({ visible: false }); assert.equal(view.intervals.size, 0); assert.equal(view.timeouts.size, 0); assert.equal(mini.classList.contains('is-refreshing'), false); + view.advance(2000); assert.equal(view.element('#mini-quota').textContent, '77'); + view.desktop({ visible: true }); view.advance(12250); + view.desktop({ expanded: true }); assert.equal(mini.classList.contains('is-refreshing'), false); assert.equal(view.timeouts.size, 0); +}); + +test('reduced motion retains the longer dwell and switches without fade timers', async () => { + const view = await quotaView(true); + view.advance(11999); assert.equal(view.element('#mini-quota').textContent, '77'); + view.advance(1); assert.equal(view.element('#mini-quota').textContent, '39'); + assert.equal(view.element('#mini').classList.contains('is-refreshing'), false); assert.equal(view.timeouts.size, 0); + view.advance(12000); assert.equal(view.element('#mini-quota').textContent, '77'); +}); diff --git a/tests/widget-animation.test.js b/tests/widget-animation.test.js new file mode 100644 index 0000000..11ba159 --- /dev/null +++ b/tests/widget-animation.test.js @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync, realpathSync, existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const execute = promisify(execFile), electron = fileURLToPath(new URL('../node_modules/electron/dist/electron.exe', import.meta.url)); +test('real quota CSS stays visible across artwork phases and fades smoothly over one second', { skip: process.platform !== 'win32' || !existsSync(electron), timeout: 25000 }, async () => { + const parent = realpathSync(tmpdir()), dir = mkdtempSync(join(parent, 'continuer-quota-animation-')); + const output = join(dir, 'result.json'), probe = join(dir, 'probe.cjs'), page = join(dir, 'preview.html'); + const captureDir = process.env.CONTINUER_CAPTURE_DIR ? resolve(process.env.CONTINUER_CAPTURE_DIR) : null; + const css = readFileSync(new URL('../public/widget.css', import.meta.url), 'utf8'); + const art = readFileSync(new URL('../public/rhine-signal-loop.svg', import.meta.url), 'utf8'); + const html = readFileSync(new URL('../public/widget.html', import.meta.url), 'utf8').replace(/]*>[\s\S]*?<\/script>/g, '') + .replace('', '') + .replace('', '').replace('', ''); + writeFileSync(page, html); + writeFileSync(probe, ` + const {app,BrowserWindow}=require('electron');const fs=require('node:fs');const path=require('node:path'); + const mark=stage=>fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({stage}));mark('starting'); + app.setPath('userData',${JSON.stringify(join(dir, 'profile'))}); + app.whenReady().then(async()=>{ + const win=new BrowserWindow({width:256,height:256,show:false,frame:false,transparent:true,webPreferences:{offscreen:true,sandbox:true,contextIsolation:true,nodeIntegration:false}}); + mark('window-created'); + await win.loadFile(${JSON.stringify(page)}); + mark('page-loaded'); + win.webContents.debugger.attach();await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'no-preference'}]}); + mark('media-configured'); + await win.webContents.executeJavaScript("document.querySelector('#mini').classList.add('has-art');document.querySelector('#mini').dataset.motion='running';document.querySelector('#mini-quota').textContent='77';document.querySelector('#mini-reset').textContent='02:00:00';document.querySelector('#mini-reset').classList.remove('visually-empty');document.getAnimations().forEach(a=>a.pause());"); + mark('initialized'); + const phases=[];for(const at of [0,15000,30000,45000,59000])phases.push(await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime="+at+");({at:"+at+",opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName})")); + mark('phases-checked'); + await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=0);document.querySelector('#mini').classList.add('is-refreshing');globalThis.fade=document.querySelector('#quota-readout').getAnimations().find(a=>a.animationName==='quota-reload');fade.pause();"); + const duration=await win.webContents.executeJavaScript('fade.effect.getTiming().duration'); + const frames=[];for(const at of [0,250,500,750,1000]){ + frames.push(await win.webContents.executeJavaScript("fade.currentTime="+at+";if("+at+">=500){document.querySelector('#mini-quota').textContent='39';document.querySelector('#mini-window').textContent='七天可用额度';document.querySelector('#mini-reset').textContent='1天 02:00:00'}({at:"+at+",opacity:Number(getComputedStyle(document.querySelector('#quota-readout')).opacity)})")); + if(${JSON.stringify(captureDir)}){fs.mkdirSync(${JSON.stringify(captureDir)},{recursive:true});await new Promise(r=>setTimeout(r,40));fs.writeFileSync(path.join(${JSON.stringify(captureDir)},'quota-'+at+'.png'),(await win.webContents.capturePage()).toPNG());} + } + fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({phases,frames,duration}));win.destroy();app.quit(); + }).catch(error=>{fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({error:error.stack}));app.exit(1)}); + `); + try { + try { await execute(electron, ['--disable-gpu', probe], { env: { ...process.env, NODE_OPTIONS: '', ELECTRON_RUN_AS_NODE: undefined }, timeout: 20000, windowsHide: true }); } + catch (error) { error.message += existsSync(output) ? '\nProbe: ' + readFileSync(output, 'utf8') : '\nProbe did not start'; throw error; } + const result = JSON.parse(readFileSync(output, 'utf8')); assert.equal(result.error, undefined); + assert.equal(result.duration, 1000); + for (const phase of result.phases) { assert.equal(phase.opacity, 1); assert.equal(phase.animation, 'none'); } + assert.equal(result.frames[0].opacity, 1); assert.equal(result.frames[2].opacity, 0); assert.equal(result.frames[4].opacity, 1); + for (const index of [1, 3]) assert.ok(result.frames[index].opacity > 0 && result.frames[index].opacity < 1); + } finally { assert.equal(dirname(realpathSync(dir)), parent); rmSync(dir, { recursive: true, force: true }); } +}); From 9fac82d4aee43f50096b47a81f8caf80f0eaa469 Mon Sep 17 00:00:00 2001 From: empty610 Date: Mon, 26 Oct 2026 13:24:29 +0800 Subject: [PATCH 2/8] fix: stop background processes when exiting widget --- AGENTS.md | 1 + CHANGELOG.md | 5 +- Enable-Autostart.ps1 | 2 +- README.md | 6 +-- desktop/main.cjs | 35 +++++++++++--- desktop/monitor.cs | 1 + desktop/watch-codex.ps1 | 1 + docs/operations.md | 6 ++- docs/usage.md | 6 +-- plugin/control.js | 2 + plugin/runtime.js | 20 ++++++-- plugin/shutdown.js | 31 ++++++++++++ plugin/stop-owned.ps1 | 29 +++++++++++ public/widget.css | 2 +- public/widget.html | 2 +- public/widget.js | 2 +- scripts/build-plugin.js | 2 +- server/exit-state.cjs | 7 +++ server/index.js | 3 ++ tests/native-monitor.test.js | 5 +- tests/reliability.test.js | 7 +-- tests/shutdown.test.js | 94 ++++++++++++++++++++++++++++++++++++ tests/widget-smoke.test.js | 4 +- 23 files changed, 244 insertions(+), 29 deletions(-) create mode 100644 plugin/shutdown.js create mode 100644 plugin/stop-owned.ps1 create mode 100644 server/exit-state.cjs create mode 100644 tests/shutdown.test.js diff --git a/AGENTS.md b/AGENTS.md index 3211c2f..18c4a1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ - 用户指定:本对话的后续更新统一保存为 `0.4.1-preview`,在 `preview/0.4.1` 分支开发;不要自动递增版本号或添加新的 preview 序号。 - 当前继续使用的 `0.4.0-preview.1` 是保留版本。不要覆盖其安装目录、已生成的 0.4.0 构建、运行配置、任务数据或 GitHub `main` 分支。 - 新版本改动保存在源码和 Git 提交中。没有新的明确安装指令时,不运行安装器、不切换用户正在使用的插件、不发布 Release 或合并到 `main`。 +- 2026-10-26 用户已明确授权:本轮“退出停止所有后台”完成后,正常停止 0.4.0,并安装、打开 0.4.1 供预览。保留 0.4.0 的安装文件和构建备份;后续更新仍沿用上述默认规则。 - 需要构建时,在独立的 0.4.1 源码导出目录构建,避免默认构建路径覆盖现有 0.4.0 产物。 - 测试使用独立数据目录和模拟 RPC/消息通道,不启动真实任务,不发送聊天消息,不更改 Electron 沙箱或系统 ACL。 - 当前工作目录的旧 Git 历史包含私有开发记录。公开提交仍通过脱敏独立仓库 `dist/public-source/codex-continuer`,不得把原开发仓库的历史推送出去。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f27fdc..2f952f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,14 @@ ## 0.4.1-preview — 开发中 +- 关闭按钮、托盘退出和命令行退出统一结束后台服务、跟随及自启动监听器;正常保存停止后仍未退出时,只清理路径核验通过的所属服务进程树。 +- 主动退出后,查询和自动启动不再重建后台服务;手动打开才恢复。演示模式退出与真实服务隔离。 +- 本轮用户明确授权停止 0.4.0 并切换预览 0.4.1;旧版安装文件和 main 分支保留。 - 本对话的后续更新固定保存到此版本;保留现有安装版 0.4.0-preview.1。 - 五小时/七天额度的切换间隔由 5 秒延长到 12 秒,淡出换值和淡入的完整过渡由 480 毫秒延长到 1 秒。 - 额度文字与背景装饰动画的隐藏阶段解耦,在可见的紧凑仪表中持续显示;背景的 60 秒动画保留。 - 移除切换时强制读取布局的操作;隐藏、展开面板时仍停止轮播和相关定时器,减少动态效果模式仍直接换值。 -- 本轮验证:236 项回归全部通过,包含真实 Electron 的透明度与背景循环阶段检查;当前安装版 0.4.0-preview.1 的 162 个文件及构建指纹保持不变。 +- 动画阶段验证:236 项回归全部通过,包含真实 Electron 的透明度与背景循环阶段检查。退出行为加入后共 243 项回归,覆盖所属进程清理、带引号的监听器参数及自动重启阻断;旧版 162 个安装文件及构建指纹保持不变。 ## 0.4.0-preview.1 — 2026-10-25 diff --git a/Enable-Autostart.ps1 b/Enable-Autostart.ps1 index cc27dc4..71439f4 100644 --- a/Enable-Autostart.ps1 +++ b/Enable-Autostart.ps1 @@ -24,7 +24,7 @@ if (Test-Path -LiteralPath $continuerStatusPath) { $continuerSaved = Get-Content -LiteralPath $continuerStatusPath -Raw | ConvertFrom-Json $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$([int]$continuerSaved.pid)" $continuerLegacy = $continuerProcess -and $continuerProcess.Name -eq 'powershell.exe' -and $continuerProcess.CommandLine.Contains((Join-Path $continuerSaved.pluginRoot 'desktop/watch-codex.ps1')) - $continuerNative = $continuerProcess -and $continuerProcess.Name -eq 'continuer-monitor.exe' -and $continuerProcess.ExecutablePath -eq (Join-Path $continuerSaved.pluginRoot 'desktop/continuer-monitor.exe') -and $continuerProcess.CommandLine -match '\s--watch\s' + $continuerNative = $continuerProcess -and $continuerProcess.Name -eq 'continuer-monitor.exe' -and $continuerProcess.ExecutablePath -eq (Join-Path $continuerSaved.pluginRoot 'desktop/continuer-monitor.exe') -and $continuerProcess.CommandLine -match '(?:^|\s)"?--watch"?(?:\s|$)' if ($continuerLegacy -or $continuerNative) { Stop-Process -Id $continuerProcess.ProcessId } } catch { Write-Warning 'Could not stop the previous watcher. Its single-instance lock prevents duplicate watchers.' } } diff --git a/README.md b/README.md index fcb1e42..5a250a6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **开发版本:0.4.1-preview · Windows x64 预览版 · GPL-3.0-only。** 这是独立社区项目,与 OpenAI、Rhine、Clash Verge 无隶属关系。 -本对话后续更新统一记录在 `0.4.1-preview` / `preview/0.4.1`。现有安装版 `0.4.0-preview.1` 保留,预览改动不会自动安装或合并到 `main`。 +本对话后续更新统一记录在 `0.4.1-preview` / `preview/0.4.1`。旧版 `0.4.0-preview.1` 文件保留;版本切换按用户明确指令进行,开发更新不合并到 `main`。 ## 功能 @@ -59,7 +59,7 @@ cd codex-continuer | 导入原聊天 | 一次确认的耗尽恢复周期,向原聊天发送一次“继续”;备注仅本地保存 | 暂停、发送次数到限或发送结果不确定;不自动识别原聊天目标完成 | | 新建工作 | 按原始目标启动执行轮次,要求结构化完成结果 | 完成、阻塞、无工具活动或轮次到限;模型报告完成仍需核对验收 | -浏览器控制台通过 `Start-Continuer.ps1` 打开;启动器提供本地凭据,页面会清除链接中的令牌。不要分享启动链接。隐藏或退出挂件后后台服务仍可运行;停止服务使用 `Stop-Continuer.ps1`。 +浏览器控制台通过 `Start-Continuer.ps1` 打开;启动器提供本地凭据,页面会清除链接中的令牌。不要分享启动链接。隐藏到托盘仍保留运行;点击关闭按钮或选择“退出 Continuer”会保存状态、停止后台服务和监听器,并结束进程。停止超时后仅清理经安装路径核验的所属服务进程树。退出后,普通状态查询和自动启动不会重新拉起;手动打开挂件或浏览器启动器可恢复。 启用或关闭随 Codex 启动: @@ -82,7 +82,7 @@ npm run doctor npm run build:release ``` -`doctor` 只检查版本和帮助信息,不调用模型或发送消息。测试使用独立数据与模拟 RPC/队列,并包含隐藏 Electron 窗口检查。本机已通过 236 项回归;托管 CI 状态以上方徽章和对应提交的工作流结果为准。 +`doctor` 只检查版本和帮助信息,不调用模型或发送消息。测试使用独立数据与模拟 RPC/队列,并包含隐藏 Electron 窗口检查。本机已通过 243 项回归;托管 CI 状态以上方徽章和对应提交的工作流结果为准。 `build:release` 生成便携压缩包、同版本源码归档、`release.json` 和 `SHA256SUMS`。分发二进制时须配套提供源码归档。编译器可通过 `CONTINUER_CSC` 指定。 diff --git a/desktop/main.cjs b/desktop/main.cjs index d27373f..d179e44 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -32,12 +32,29 @@ function diagnostics() { const next = JSON.stringify({ ...runtimeConfig.appInfo(), pid: process.pid, ready: isReady, visible: win.isVisible(), ...state(), bounds: win.getBounds() }); if (next !== lastDiagnostic) { lastDiagnostic = next; try { writeFileSync(join(dataDir, 'widget-status.json'), next); } catch (error) { console.error(`挂件诊断未保存:${error.message}`); } } } -if (!app.requestSingleInstanceLock()) { app.quit(); } -else if (process.argv.includes('--quit')) { app.quit(); } +const ownsInstance = app.requestSingleInstanceLock(); +let quitting, shutdownComplete = false; +if (!ownsInstance) { app.quit(); } +else if (process.argv.includes('--quit')) { app.whenReady().then(quitAll); } else { - app.on('second-instance', (_event, argv) => { if (argv.includes('--quit')) { app.quit(); return; } prefs.hidden = false; if (!argv.includes('--auto')) changeExpanded(true); save(); show(!argv.includes('--auto')); }); + app.on('second-instance', (_event, argv) => { if (argv.includes('--quit')) { void quitAll(); return; } if (closing) return; prefs.hidden = false; if (!argv.includes('--auto')) changeExpanded(true); save(); show(!argv.includes('--auto')); }); app.whenReady().then(start).catch(error => { dialog.showErrorBox('Continuer 启动失败', error.message); app.quit(); }); } +function quitAll() { + if (quitting) return quitting; + closing = true; isReady = false; + pointerDrag?.finish(); cancelTransition(); tracker?.kill(); win?.hide(); + try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: process.pid, ready: false, exitReason: 'quitting', updatedAt: Date.now() })); } catch {} + quitting = (async () => { + try { + const runtime = await import('../plugin/runtime.js'); + const { stopBackground } = await import('../plugin/shutdown.js'); + await stopBackground({ root: runtime.root, dataDir, request: runtime.request, demo: process.env.CONTINUER_DEMO === '1' }); + } catch (error) { console.error('退出后台进程失败:' + error.message); } + finally { shutdownComplete = true; app.quit(); } + })(); + return quitting; +} function state() { return { follow: prefs.follow, expanded: prefs.expanded, hidden: prefs.hidden, preferenceError, tracking: !!anchor, visible: !!win?.isVisible(), compactSize: compactSize(prefs.compactSize), transitioning: !!boundsTimer, transition: pendingTransition ? {id:pendingTransition.id,fromExpanded:pendingTransition.fromExpanded} : null, panelSize, platform: process.platform }; } function cancelTransition() { clearTimeout(boundsTimer); boundsTimer=null; pendingTransition=null; @@ -190,7 +207,7 @@ async function start() { callback({ requestHeaders: details.requestHeaders }); } catch { callback({ cancel: true }); } }); - win.on('close', event => { if (!closing) { event.preventDefault(); prefs.hidden = true; save(); win.hide(); } }); + win.on('close', event => { if (!shutdownComplete) { event.preventDefault(); void quitAll(); } }); // will-move is emitted for native user dragging, not our setBounds calls. win.on('will-move', () => { if (!dragging && !pointerDrag.active) beginDrag(); }); win.on('moved', () => { if (dragging && !pointerDrag.active) finishDrag(); }); @@ -215,6 +232,7 @@ async function start() { ipcMain.handle('continuer:control', (event, action, value) => { if (!allowed(event)) throw new Error('Untrusted widget request'); if (action === 'renderer-ready') { isReady = true; show(); return state(); } + if (action === 'quit') { setImmediate(() => { void quitAll(); }); return { quitting: true }; } if (action === 'transition-ready') { finishTransition(value); return state(); } if (action === 'drag-start') { if (!Number.isInteger(value) || prefs.expanded || boundsTimer || dragging) return { ok:false }; @@ -245,8 +263,8 @@ async function start() { { label: '独立显示面板', click: () => { prefs.follow = false; syncTracker(); reveal(); } }, { label: '重置挂件位置', click: () => { settledPlacement=null; prefs.offsets = {}; prefs.positions = {}; save(); show(true); } }, { type: 'separator' }, - { label: '隐藏挂件(后台继续工作)', click: () => { prefs.hidden = true; save(); show(); } }, - { label: '退出挂件(后台继续工作)', click: () => app.quit() }, + { label: '隐藏到托盘', click: () => { prefs.hidden = true; save(); show(); } }, + { label: '退出 Continuer', click: () => { void quitAll(); } }, ]); const popupMenu = () => { if (menuOpen) return; @@ -265,7 +283,10 @@ async function start() { syncTracker(); await win.loadURL(origin + '/widget'); } -app.on('before-quit', () => { closing = true; pointerDrag?.finish(); cancelTransition(); tracker?.kill(); globalShortcut.unregisterAll(); tray?.destroy(); +app.on('before-quit', event => { + if (!ownsInstance) return; + if (!shutdownComplete) { event.preventDefault(); void quitAll(); return; } + closing = true; pointerDrag?.finish(); cancelTransition(); tracker?.kill(); globalShortcut.unregisterAll(); tray?.destroy(); try { writeFileSync(join(dataDir, 'widget-status.json'), JSON.stringify({ ...runtimeConfig.appInfo(), pid: process.pid, ready: false, exitReason: 'user-quit', updatedAt: Date.now() })); } catch {} }); app.on('window-all-closed', () => app.quit()); diff --git a/desktop/monitor.cs b/desktop/monitor.cs index 1cbdbdb..af6139a 100644 --- a/desktop/monitor.cs +++ b/desktop/monitor.cs @@ -72,6 +72,7 @@ private static void Watch(string node, string root, string data) { string seen = ""; DateTime retryAt = DateTime.MinValue; while (true) { + if (File.Exists(Path.Combine(data, "quit.request"))) { Status(data, root, "stopped", "Continuer exited by user"); return; } try { string key = ContinuerWindows.DesktopKey(); if (key.Length == 0) { diff --git a/desktop/watch-codex.ps1 b/desktop/watch-codex.ps1 index 9f79ea0..b0e4695 100644 --- a/desktop/watch-codex.ps1 +++ b/desktop/watch-codex.ps1 @@ -22,6 +22,7 @@ try { Add-Type -Path (Join-Path $PSScriptRoot 'codex-windows.cs') Write-ContinuerStartupStatus 'watching' 'Waiting for Codex desktop' while ($true) { + if (Test-Path -LiteralPath (Join-Path $DataDir 'quit.request')) { Write-ContinuerStartupStatus 'stopped' 'Continuer exited by user'; break } try { # Inspect only native desktop windows, not every Electron child. $continuerKey = [ContinuerWindows]::DesktopKey() diff --git a/docs/operations.md b/docs/operations.md index c5986bf..b82a77b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,11 +8,11 @@ - MCP 读取状态、列会话或打开挂件时,如果需要冷启动服务,只进入查询模式;不因保存的调度开关为开而执行旧队列。读取现有服务不会改变它原有的运行状态。 - 在页面明确开启调度,或明确创建/继续工作,才退出查询模式。导入原聊天仍保持暂停。服务遇到保存错误后会关闭调度,不能自动恢复执行。 -- `npm start` 是明确的后台执行启动入口,会沿用保存的调度设置。`Start-Continuer.ps1` 与挂件入口冷启动时进入查询模式,并以认证链接打开浏览器;页面明确开启调度后才启动旧队列。`Stop-Continuer.ps1` 使用本地凭据关闭调度并停止服务;只退出或隐藏挂件不停止后台。 +- `npm start` 是明确的后台执行启动入口,会沿用保存的调度设置。`Start-Continuer.ps1` 与挂件入口冷启动时进入查询模式,并以认证链接打开浏览器;页面明确开启调度后才启动旧队列。`Stop-Continuer.ps1` 使用本地凭据关闭调度并停止服务;隐藏到托盘保持运行;退出挂件会一并停止服务与监听器,必要时按安装路径核验后结束所属服务进程树。 - `/api/health` 报告服务 PID、构建、额度连接、存储故障、上次退出、挂件就绪状态、自启动监听器和实时连接数。挂件在首屏状态成功渲染后才发送就绪信号,打开命令等待该信号。 - 服务通过规范化数据目录对应的独占命名管道句柄互斥,不增加常驻进程;进程退出后由系统释放。`service.lock` 仅记录 PID 和租约标识,也会拒绝覆盖仍存活的旧版服务。失效记录在取得互斥句柄后更新;损坏记录仍需先检查进程,不能直接绕过。 - 关闭调度、断连或存储故障会使旧调度失效,即使马上重新开启,也由新的有效调度启动任务。异步准备结束后会重新检查连接、开关及额度;已发出的轮次按原有规则继续或明确中断。 -- 挂件渲染进程异常退出时最多尝试重新加载 3 次;手动退出挂件不会触发重新打开。服务崩溃不会自动重新开启任务调度,下一次查询冷启动仍为查询模式。 +- 挂件渲染进程异常退出时最多尝试重新加载 3 次;手动退出挂件不会触发重新打开。服务崩溃不会自动重新开启任务调度,下一次查询冷启动仍为查询模式。主动退出会写入 quit.request,阻止查询和自动启动拉起后台;手动打开挂件或浏览器启动器才清除。演示模式使用独立 quit-demo.request,不影响真实服务。 - 查询已有服务和等待冷启动完成使用同一套构建、协议及数据目录校验;不能仅凭端口和构建相同就连接其他数据目录。 - 直接执行和原聊天恢复发送均要求新鲜的完整主动额度读取;部分额度通知只更新显示并触发重新读取,不能单独授权启动轮次。 - 隐藏挂件或浏览器页面会暂停前端订阅与界面工作,重新显示时取得最新状态;后台调度和额度读取继续按原有设置运行。原生隐藏启动仅保留就绪所需的一次状态握手。 @@ -52,6 +52,8 @@ ## 位置与便携包 +隔离源码目录生成本机包时,可用 `CONTINUER_BUILD_DATA_DIR` 指向原有数据目录;该值仅写入本机构建的 `runtime.json`,便携包仍不包含绝对路径。版本切换应先停止旧服务并备份状态,再打开新版。 + 本机包保留显式 `runtime.json` 数据路径,当前项目已有的 `data/` 不搬迁。新便携包不包含本机 Node 或数据目录绝对路径,默认使用 `%LOCALAPPDATA%\CodexContinuer`,要求目标电脑安装 Node.js。`CONTINUER_DATA_DIR` 可明确覆盖;`CONTINUER_PORT` 用于插件/挂件端口,服务另兼容 `PORT`。 ```powershell diff --git a/docs/usage.md b/docs/usage.md index 2ec939d..2d61e0b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,7 +10,7 @@ 桌面卡片默认 256 px,可在「工作策略与显示」中用滑块调为 160–420 px,也可按住 Ctrl 在卡片上滚动鼠标滚轮调整;大小会保存。**点击整张卡片展开,拖动任意位置移动**;桌面光标移动超过 6 个逻辑像素视为拖动,不触发展开。静止按住或轻微抖动不会移动卡片或保存新位置;失焦、取消或丢失指针捕获时结束拖动。展开/收起先淡出,在不可见时一次切换窗口尺寸和位置,再淡入;两种模式分别记住位置。 -**右键卡片**可打开与系统托盘相同的菜单:打开面板、跟随 Codex、独立显示、重置位置、隐藏挂件或退出挂件。隐藏或退出挂件时,后台工作继续。 +**右键卡片**可打开与系统托盘相同的菜单:打开面板、跟随 Codex、独立显示、重置位置、隐藏挂件或退出挂件。隐藏到托盘只隐藏界面;关闭按钮和“退出 Continuer”会保存状态、停止本工具的执行轮次与服务、结束跟随和自启动监听进程。已进入原聊天队列的消息仍不能撤回。 卡片采用普通窗口层级,其他应用可以正常覆盖它。独立模式不运行窗口跟随监测;切换到跟随模式时才启动监测,切回独立模式后结束。隐藏窗口使用 Chromium 默认的后台节流。窗口枚举和内存排查记录见 [桌面内存排查](desktop-memory-audit.md)。 @@ -39,7 +39,7 @@ cd .\codex-continuer 也可以双击 `Open-Widget.cmd` 直接打开,或运行 `npm run widget`。**Ctrl+Alt+C** 展开面板,点击减号收起成圆形仪表。收起后点击任意位置展开、拖动移动;展开后拖动顶部标题栏移动。两种状态分别记住位置,跟随模式保存相对 Codex 的偏移。系统托盘和工作策略中提供「重置挂件位置」。跟随模式在 Codex 位于前台时显示,挂件获得焦点时保持显示;其他应用在前台或 Codex 最小化时隐藏。 -这是与 Codex 窗口联动的独立原生挂件,不是向 Codex 内部侧栏注入 UI。插件工具与挂件共用本地服务。隐藏或退出挂件不停止后台调度;停止服务用下方停止脚本。 +这是与 Codex 窗口联动的独立原生挂件,不是向 Codex 内部侧栏注入 UI。插件工具与挂件共用本地服务。隐藏到托盘仍保持运行;退出会停止后台调度和服务。退出标记会阻止普通 MCP 查询或自动启动重新拉起,需手动打开挂件或浏览器启动器恢复。Codex 托管的 MCP 工具连接可保持空闲,不执行后台调度。 ### 随 Codex 启动 @@ -51,7 +51,7 @@ cd .\codex-continuer .\Enable-Autostart.ps1 -Disable ``` -在当前用户的 Windows 登录启动项 `HKCU/Software/Microsoft/Windows/CurrentVersion/Run/CodexContinuer` 中注册隐藏监听器,每 2 秒检查 Codex 桌面主窗口。打开 Codex 时自动打开挂件,保留收起/展开状态且不抢焦点;手动退出挂件后,直到下一次启动 Codex 才会再打开。监听器不启动模型任务,也不把普通 ChatGPT 或 Codex CLI 当作桌面。无需管理员权限,不修改 Codex 快捷方式或安装文件。`data/autostart-status.json` 记录监听器状态;卸载插件前用上述禁用命令移除启动项。 +在当前用户的 Windows 登录启动项 `HKCU/Software/Microsoft/Windows/CurrentVersion/Run/CodexContinuer` 中注册隐藏监听器,每 2 秒检查 Codex 桌面主窗口。打开 Codex 时自动打开挂件,保留收起/展开状态且不抢焦点;手动退出挂件后监听器也会结束,直到手动重新打开才恢复;不会因为重新打开 Codex 就撤销退出操作。监听器不启动模型任务,也不把普通 ChatGPT 或 Codex CLI 当作桌面。无需管理员权限,不修改 Codex 快捷方式或安装文件。`data/autostart-status.json` 记录监听器状态;卸载插件前用上述禁用命令移除启动项。 ### 直接选择对话 diff --git a/plugin/control.js b/plugin/control.js index 7480d0d..1795584 100644 --- a/plugin/control.js +++ b/plugin/control.js @@ -3,6 +3,7 @@ const demo = process.argv.includes('--demo'); process.env.CONTINUER_PORT = process.env.PORT || (demo ? '4318' : process.env.CONTINUER_PORT || '4317'); process.env.CONTINUER_DEMO = demo ? '1' : ''; const runtime = await import('./runtime.js'); +const { default: exitState } = await import('../server/exit-state.cjs'); try { if (process.argv[2] === 'stop') { let state; @@ -13,6 +14,7 @@ try { if (state.app !== 'codex-continuer' || typeof state.diagnostics?.dataDir !== 'string' || normalize(state.diagnostics.dataDir) !== normalize(runtime.dataDir)) throw Error('端口服务不属于当前 Continuer 数据目录'); await runtime.request('/api/shutdown', {}); console.log('Continuer stopped.'); } else if (process.argv[2] === 'url') { + exitState.clear(runtime.dataDir, demo); await runtime.ensureService(); process.stdout.write(runtime.origin + '/#access=' + runtime.readAccessToken()); } else throw Error('Usage: node plugin/control.js url|stop [--demo]'); diff --git a/plugin/runtime.js b/plugin/runtime.js index 8522f09..5e142f0 100644 --- a/plugin/runtime.js +++ b/plugin/runtime.js @@ -5,6 +5,7 @@ import settings from '../server/config.cjs'; import logging from '../server/logging.cjs'; import { diagnostic, alive } from '../server/health.js'; import access from '../server/access.cjs'; +import exitState from '../server/exit-state.cjs'; export const root = settings.root; const config = settings.config; @@ -12,9 +13,9 @@ export const dataDir = settings.dataDir; export const port = settings.port; export const origin = `http://127.0.0.1:${port}`; export const readAccessToken = () => access.readToken(dataDir, process.env.CONTINUER_DEMO === '1'); -export async function request(path, payload) { +export async function request(path, payload, { timeoutMs = 35000 } = {}) { const token = readAccessToken(); - const response = await fetch(origin + path, { signal: AbortSignal.timeout(35000), redirect: 'error', headers: { ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(payload === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, ...(payload === undefined ? {} : { method: 'POST', body: JSON.stringify(payload) }) }); + const response = await fetch(origin + path, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error', headers: { ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(payload === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, ...(payload === undefined ? {} : { method: 'POST', body: JSON.stringify(payload) }) }); const result = await response.json(); if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); return result; @@ -38,6 +39,7 @@ function verifiedService(state) { return state; } export async function ensureService() { + if (exitState.requested(dataDir, process.env.CONTINUER_DEMO === '1')) throw Error('Continuer 已退出,请先手动打开挂件。'); if (starting) return starting; starting = (async () => { try { @@ -48,6 +50,7 @@ export async function ensureService() { } const node = config.nodePath || (process.versions.electron ? process.env.CONTINUER_NODE || 'node' : process.execPath); if (!node) throw new Error('缺少 Node.js 路径,请重新运行插件安装器'); + if (exitState.requested(dataDir, process.env.CONTINUER_DEMO === '1')) throw Error('Continuer 已退出,请先手动打开挂件。'); // Opening a widget or reading MCP state never restores saved scheduling by itself. const child = launch(node, [join(root, 'server/index.js'), '--observe-only', ...(process.env.CONTINUER_DEMO === '1' ? ['--demo'] : [])], 'service', { PORT: String(port) }); let spawnError; child.once('error', e => { spawnError = e; }); @@ -63,13 +66,24 @@ export async function ensureService() { return starting; } export async function openWidget({ automatic = false } = {}) { + if (automatic && exitState.requested(dataDir, process.env.CONTINUER_DEMO === '1')) throw Error('Continuer 已退出,自动启动已暂停。'); + if (!automatic) exitState.clear(dataDir, process.env.CONTINUER_DEMO === '1'); await ensureService(); const executable = join(root, 'node_modules/electron/dist', process.platform === 'win32' ? 'electron.exe' : 'electron'); if (!existsSync(executable)) throw new Error('缺少桌面运行时,请在项目目录运行 npm install 后重新构建插件'); launch(executable, [join(root, 'desktop/main.cjs'), ...(automatic ? ['--auto'] : [])], 'widget', { ELECTRON_RUN_AS_NODE: undefined, CONTINUER_NODE: config.nodePath || process.execPath, CONTINUER_PORT: String(port) }); for (let attempt = 0; attempt < 60; attempt++) { const widget = diagnostic(join(dataDir, 'widget-status.json')); - if (widget?.ready && widget.buildId === settings.appInfo().buildId && alive(widget.pid)) return { launched: true, ready: true, mode: widget.follow ? 'follow-codex' : 'independent', message: '挂件已就绪。可使用 Ctrl+Alt+C 展开。' }; + if (widget?.ready && widget.buildId === settings.appInfo().buildId && alive(widget.pid)) { + const watcher = diagnostic(join(dataDir, 'autostart-status.json')); + const currentWatcher = typeof watcher?.pluginRoot === 'string' && resolve(watcher.pluginRoot).toLowerCase() === resolve(root).toLowerCase() && alive(watcher.pid); + if (!automatic && process.env.CONTINUER_DEMO !== '1' && diagnostic(join(dataDir, 'autostart-preferences.json'))?.enabled && !currentWatcher) { + const node = config.nodePath || process.env.CONTINUER_NODE || process.execPath, native = join(root, 'desktop/continuer-monitor.exe'); + if (existsSync(native)) launch(native, ['--watch', node, root, dataDir], 'watcher'); + else launch('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'desktop/watch-codex.ps1'), '-NodePath', node, '-PluginRoot', root, '-DataDir', dataDir], 'watcher'); + } + return { launched: true, ready: true, mode: widget.follow ? 'follow-codex' : 'independent', message: '挂件已就绪。可使用 Ctrl+Alt+C 展开。' }; + } await new Promise(resolve => setTimeout(resolve, 150)); } throw new Error('挂件未在时限内就绪,请查看 /api/health 和 widget-stderr.log'); diff --git a/plugin/shutdown.js b/plugin/shutdown.js new file mode 100644 index 0000000..2c7bc41 --- /dev/null +++ b/plugin/shutdown.js @@ -0,0 +1,31 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import exitState from '../server/exit-state.cjs'; +import { alive, diagnostic } from '../server/health.js'; + +const execute = promisify(execFile); +const canonical = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); +export async function stopBackground({ root, dataDir, request, demo = false, cleanup = async (phase, pid = 0) => { + await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'plugin/stop-owned.ps1'), '-PluginRoot', root, '-DataDir', dataDir, '-Phase', phase, '-ServicePid', String(pid)], { windowsHide: true, timeout: 10000 }); +}, isAlive = alive, wait = delay, graceMs = 2500 }) { + // Explicit reopening clears this latch; passive MCP reads and autostart do not. + exitState.mark(dataDir, demo); + const errors = []; + if (!demo) try { await cleanup('watcher'); } catch (error) { errors.push(error.message); } + let pid = diagnostic(join(dataDir, demo ? 'demo-service.lock' : 'service.lock'))?.pid; + try { + const state = await request('/api/state', undefined, { timeoutMs: 2000 }); + if (state.app !== 'codex-continuer' || typeof state.diagnostics?.projectRoot !== 'string' || typeof state.diagnostics?.dataDir !== 'string' + || canonical(state.diagnostics.projectRoot) !== canonical(root) || canonical(state.diagnostics.dataDir) !== canonical(dataDir)) throw Error('Refusing to shut down another application or data directory.'); + pid = state.diagnostics.pid; + await request('/api/shutdown', {}, { timeoutMs: 5000 }); + } catch (error) { + if (error.cause?.code !== 'ECONNREFUSED') console.error('正常停止未完成,将核验本应用进程后结束:' + error.message); + } + for (let elapsed = 0; Number.isInteger(pid) && pid > 0 && isAlive(pid) && elapsed < graceMs; elapsed += 100) await wait(100); + try { await cleanup('service', Number.isInteger(pid) && pid > 0 ? pid : 0); } catch (error) { errors.push(error.message); } + if (Number.isInteger(pid) && pid > 0 && isAlive(pid)) errors.push('Continuer service is still running.'); + if (errors.length) throw Error(errors.join('\n')); +} diff --git a/plugin/stop-owned.ps1 b/plugin/stop-owned.ps1 new file mode 100644 index 0000000..4077b44 --- /dev/null +++ b/plugin/stop-owned.ps1 @@ -0,0 +1,29 @@ +param([Parameter(Mandatory=$true)][string]$PluginRoot, [Parameter(Mandatory=$true)][string]$DataDir, [ValidateSet('watcher','service')][string]$Phase, [int]$ServicePid = 0) +$ErrorActionPreference = 'Stop' +$continuerRoot = [IO.Path]::GetFullPath($PluginRoot).TrimEnd('\') +function Test-ContinuerCommand($process, [string]$file) { + return $process -and $process.CommandLine -and $process.CommandLine.Replace('/', '\').IndexOf($file.Replace('/', '\'), [StringComparison]::OrdinalIgnoreCase) -ge 0 +} +if ($Phase -eq 'watcher') { + $continuerStatusFile = Join-Path $DataDir 'autostart-status.json' + if (-not (Test-Path -LiteralPath $continuerStatusFile)) { exit 0 } + $continuerSaved = Get-Content -LiteralPath $continuerStatusFile -Raw | ConvertFrom-Json + if (-not $continuerSaved.pid -or -not $continuerSaved.pluginRoot -or [IO.Path]::GetFullPath($continuerSaved.pluginRoot).TrimEnd('\') -ne $continuerRoot) { exit 0 } + $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$([int]$continuerSaved.pid)" -ErrorAction SilentlyContinue + $continuerNative = $continuerProcess -and $continuerProcess.ExecutablePath -eq (Join-Path $continuerRoot 'desktop/continuer-monitor.exe') -and $continuerProcess.CommandLine -match '(?:^|\s)"?--watch"?(?:\s|$)' + $continuerLegacy = $continuerProcess -and $continuerProcess.Name -eq 'powershell.exe' -and (Test-ContinuerCommand $continuerProcess (Join-Path $continuerRoot 'desktop/watch-codex.ps1')) + if ($continuerNative -or $continuerLegacy) { + $continuerLaunchers = @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$($continuerProcess.ProcessId)" | Where-Object { $_.Name -eq 'node.exe' -and (Test-ContinuerCommand $_ (Join-Path $continuerRoot 'plugin/open.js')) }) + Stop-Process -Id $continuerProcess.ProcessId -ErrorAction SilentlyContinue + foreach ($continuerLauncher in $continuerLaunchers) { Stop-Process -Id $continuerLauncher.ProcessId -ErrorAction SilentlyContinue } + } + exit 0 +} +if ($Phase -eq 'service' -and $ServicePid -gt 0) { + $continuerProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$ServicePid" -ErrorAction SilentlyContinue + if (-not $continuerProcess) { exit 0 } + if ($continuerProcess.Name -notmatch '^(node|electron)\.exe$' -or -not (Test-ContinuerCommand $continuerProcess (Join-Path $continuerRoot 'server/index.js'))) { throw 'Refusing to stop a process outside the selected Continuer installation.' } + # The root PID was verified above; terminate only its remaining process tree. + & (Join-Path $env:SystemRoot 'System32/taskkill.exe') /PID $ServicePid /T /F | Out-Null + if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $ServicePid -ErrorAction SilentlyContinue)) { throw 'Could not terminate the selected Continuer service.' } +} diff --git a/public/widget.css b/public/widget.css index 934d26c..adad780 100644 --- a/public/widget.css +++ b/public/widget.css @@ -8,7 +8,7 @@ #gauge-readout{opacity:1} #mini.is-refreshing #quota-readout{animation:quota-reload 1s cubic-bezier(.37,0,.63,1) both} @keyframes quota-reload{0%,100%{opacity:1}45%,55%{opacity:0}} -@keyframes beacon{0%,100%{opacity:.5}50%{opacity:1}}.size-setting>span{display:flex;flex-direction:column;align-items:flex-end;gap:5px}.size-setting output{font:10px var(--mono);color:var(--accent)}#compact-size{width:130px;padding:0;accent-color:var(--accent);border:0;background:transparent}.browser{min-height:100dvh;background:var(--bg)}.browser .shell{width:min(416px,100%);height:min(704px,calc(100dvh - 16px));margin:auto;transition:width .28s ease,height .28s ease,border-radius .28s ease,background-color .28s ease}.browser.collapsed .shell{width:var(--disc-size);height:var(--disc-size)}.browser #hide,.browser #follow-label,.browser #reset-position{display:none}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}@media(max-width:340px){.scroll{padding:4px 11px 10px}header{padding:13px 10px;gap:3px}.brand strong{font-size:13px;letter-spacing:.7px}.brand small{letter-spacing:1px}.quota{padding:11px}.quota strong{font-size:32px}.scheduler strong span{display:none}.section-label em{padding-right:5px;margin-right:3px}} +@keyframes beacon{0%,100%{opacity:.5}50%{opacity:1}}.size-setting>span{display:flex;flex-direction:column;align-items:flex-end;gap:5px}.size-setting output{font:10px var(--mono);color:var(--accent)}#compact-size{width:130px;padding:0;accent-color:var(--accent);border:0;background:transparent}.browser{min-height:100dvh;background:var(--bg)}.browser .shell{width:min(416px,100%);height:min(704px,calc(100dvh - 16px));margin:auto;transition:width .28s ease,height .28s ease,border-radius .28s ease,background-color .28s ease}.browser.collapsed .shell{width:var(--disc-size);height:var(--disc-size)}.browser #quit,.browser #follow-label,.browser #reset-position{display:none}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}@media(max-width:340px){.scroll{padding:4px 11px 10px}header{padding:13px 10px;gap:3px}.brand strong{font-size:13px;letter-spacing:.7px}.brand small{letter-spacing:1px}.quota{padding:11px}.quota strong{font-size:32px}.scheduler strong span{display:none}.section-label em{padding-right:5px;margin-right:3px}} .collapsed #mini{width:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px));height:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px))}.browser.collapsed .shell{width:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px));height:min(var(--disc-size),calc(100vw - 16px),calc(100dvh - 16px))} /* Keep the panel laid out while the native window changes its bounds. */ diff --git a/public/widget.html b/public/widget.html index 4a98839..751dbc5 100644 --- a/public/widget.html +++ b/public/widget.html @@ -12,7 +12,7 @@
-
CONTINUER / C额度监测 · 持续工作
+
CONTINUER / C额度监测 · 持续工作
diff --git a/public/widget.js b/public/widget.js index e11eb06..6b34cd7 100644 --- a/public/widget.js +++ b/public/widget.js @@ -266,7 +266,7 @@ $('#compact-size').addEventListener('input',e => { void changeSize(e.target.valu $('#collapse').onclick=() => { void setExpanded(false); }; if(bridge) { bridge.onState(applyDesktop); bridge.control('state').then(applyDesktop).catch(e=>toast(e.message)); - $('#hide').onclick=()=>bridge.control('hide'); + $('#quit').onclick=()=>bridge.control('quit'); $('#reset-position').onclick=()=>bridge.control('reset-position').then(()=>toast('挂件位置已重置')); } else { document.body.classList.add('browser'); setSize(Number(localStorage.getItem('continuer-compact-size'))||256); } document.addEventListener('visibilitychange',syncView); diff --git a/scripts/build-plugin.js b/scripts/build-plugin.js index 347e8fe..fca9648 100644 --- a/scripts/build-plugin.js +++ b/scripts/build-plugin.js @@ -46,7 +46,7 @@ cpSync(join(root, 'docs/portable.md'), join(dest, 'docs/portable.md')); cpSync(join(root, 'docs/portable.md'), join(dest, 'README.md')); const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); writeFileSync(join(dest, 'package.json'), JSON.stringify({ name: pkg.name, version: pkg.version, license: pkg.license, private: true, type: 'module', engines: pkg.engines }, null, 2)); -writeFileSync(join(dest, 'runtime.json'), JSON.stringify(portableBuild ? { profile: 'portable' } : { profile: 'local', dataDir: join(root, 'data'), nodePath: process.execPath }, null, 2)); +writeFileSync(join(dest, 'runtime.json'), JSON.stringify(portableBuild ? { profile: 'portable' } : { profile: 'local', dataDir: resolve(process.env.CONTINUER_BUILD_DATA_DIR || join(root, 'data')), nodePath: process.execPath }, null, 2)); mkdirSync(join(dest, 'node_modules/electron'), { recursive: true }); cpSync(join(root, 'node_modules/electron/dist'), join(dest, 'node_modules/electron/dist'), { recursive: true }); // Codex CLI 0.153.x also accepts the legacy plugin layout. diff --git a/server/exit-state.cjs b/server/exit-state.cjs new file mode 100644 index 0000000..a17f8cd --- /dev/null +++ b/server/exit-state.cjs @@ -0,0 +1,7 @@ +const { existsSync, mkdirSync, writeFileSync, unlinkSync } = require('node:fs'); +const { join } = require('node:path'); +const marker = (directory, demo) => join(directory, demo ? 'quit-demo.request' : 'quit.request'); +const requested = (directory, demo = false) => existsSync(marker(directory, demo)); +function mark(directory, demo = false) { mkdirSync(directory, { recursive: true }); writeFileSync(marker(directory, demo), 'User exited Continuer\n', { mode: 0o600 }); } +function clear(directory, demo = false) { try { unlinkSync(marker(directory, demo)); } catch (error) { if (error.code !== 'ENOENT') throw error; } } +module.exports = { requested, mark, clear }; diff --git a/server/index.js b/server/index.js index b530c19..401a3c6 100644 --- a/server/index.js +++ b/server/index.js @@ -14,6 +14,7 @@ import { acquireLease } from './lease.js'; import { createApiRouter, json } from './http-routes.js'; import { createStaticAssets } from './static-assets.js'; import access from './access.cjs'; +import exitState from './exit-state.cjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const demo = process.argv.includes('--demo'); @@ -21,6 +22,8 @@ const port = Number(process.env.PORT || (demo ? 4318 : config.port)); if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid Continuer port'); const origin = `http://127.0.0.1:${port}`; const dataDir = config.dataDir; +if (process.argv.includes('--observe-only') && exitState.requested(dataDir, demo)) throw Error('Continuer 已退出,请先手动打开挂件。'); +if (!process.argv.includes('--observe-only')) exitState.clear(dataDir, demo); logging.installLogs(dataDir, demo ? 'demo' : 'service'); logging.rotateLogs(dataDir); const releaseLease = await acquireLease(dataDir, demo ? 'demo-service' : 'service'); diff --git a/tests/native-monitor.test.js b/tests/native-monitor.test.js index 1812c88..b4bfdfa 100644 --- a/tests/native-monitor.test.js +++ b/tests/native-monitor.test.js @@ -55,5 +55,8 @@ console.log('widget opened');console.error('diagnostic stderr');`); await delay(2100);assert.equal(launches(),2); const output=execFileSync(exe,['--track','123'],{encoding:'utf8',windowsHide:true}); assert.deepEqual(JSON.parse(output),{pid:123}); - } finally { child.kill();await once(child,'exit'); } + const exited=once(child,'exit');writeFileSync(join(data,'quit.request'),'User exit');await exited; + assert.equal(status().state,'stopped'); + const stopped=spawn(exe,args,{env,windowsHide:true,stdio:'ignore'});assert.equal((await once(stopped,'exit'))[0],0);assert.equal(launches(),2); + } finally { if(child.exitCode===null){child.kill();await once(child,'exit');} } }); diff --git a/tests/reliability.test.js b/tests/reliability.test.js index a14e1eb..68c1f85 100644 --- a/tests/reliability.test.js +++ b/tests/reliability.test.js @@ -202,11 +202,12 @@ test('portable configuration uses stable user data even when a legacy data folde env.CONTINUER_DATA_DIR = join(directory, 'explicit-data'); assert.equal(run(), env.CONTINUER_DATA_DIR); }); -test('runtime refuses an incompatible running build before attempting to launch anything', () => { +test('runtime refuses an incompatible running build before attempting to launch anything', t => { + const env = { ...process.env, CONTINUER_DATA_DIR: temporary(t) }; const script = `globalThis.fetch=async()=>({ok:true,json:async()=>({app:'codex-continuer',version:'0.3.3',protocolVersion:1,buildId:'obsolete'})}); const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)}); try{await runtime.ensureService();process.exitCode=1;}catch(error){console.log(error.message);}`; - const output = execFileSync(process.execPath, ['--input-type=module', '--eval', script], { windowsHide: true, encoding: 'utf8' }); + const output = execFileSync(process.execPath, ['--input-type=module', '--eval', script], { env, windowsHide: true, encoding: 'utf8' }); assert.match(output, /构建不一致/); const configUrl = JSON.stringify(new URL('../server/config.cjs', import.meta.url).href); const wrongDirectory = `const config=(await import(${configUrl})).default; globalThis.fetch=async()=>({ok:true,json:async()=>({...config.appInfo(),diagnostics:{dataDir:'wrong-directory'}})}); const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)}); try{await runtime.ensureService();process.exitCode=1;}catch(error){console.log(error.message);}`; - assert.match(execFileSync(process.execPath, ['--input-type=module', '--eval', wrongDirectory], { windowsHide: true, encoding: 'utf8' }), /不同的数据目录/); + assert.match(execFileSync(process.execPath, ['--input-type=module', '--eval', wrongDirectory], { env, windowsHide: true, encoding: 'utf8' }), /不同的数据目录/); }); diff --git a/tests/shutdown.test.js b/tests/shutdown.test.js new file mode 100644 index 0000000..52f75b6 --- /dev/null +++ b/tests/shutdown.test.js @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync, realpathSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { once } from 'node:events'; +import { setTimeout as delay } from 'node:timers/promises'; +import { stopBackground } from '../plugin/shutdown.js'; +import { alive } from '../server/health.js'; +import exitState from '../server/exit-state.cjs'; + +function directory(t) { const parent = realpathSync(tmpdir()), dir = mkdtempSync(join(parent, 'continuer-exit-')); t.after(() => { assert.equal(dirname(realpathSync(dir)), parent); rmSync(dir, { recursive: true, force: true }); }); return dir; } +function fixture(t) { + const dataDir = directory(t), root = join(dataDir, 'plugin'), calls = []; let running = true; + writeFileSync(join(dataDir, 'service.lock'), JSON.stringify({ pid: 123 })); + const options = { root, dataDir, graceMs: 0, isAlive: () => running, + request: async path => { calls.push(path); if (path === '/api/shutdown') { running = false; return {}; } return { app: 'codex-continuer', diagnostics: { projectRoot: root, dataDir, pid: 123 } }; }, + cleanup: async (phase, pid) => { assert.equal(exitState.requested(dataDir), true); calls.push([phase, pid]); if (phase === 'service') running = false; }, + }; + return { options, calls }; +} + +test('exit latches before stopping the watcher and shuts down only its service', async t => { + const f = fixture(t); await stopBackground(f.options); + assert.deepEqual(f.calls, [['watcher', undefined], '/api/state', '/api/shutdown', ['service', 123]]); + assert.equal(exitState.requested(f.options.dataDir), true); +}); + +test('shutdown timeouts still run verified process cleanup', async t => { + const f = fixture(t); f.options.request = async () => { throw Object.assign(Error('not reachable'), { cause: { code: 'ECONNREFUSED' } }); }; + await stopBackground(f.options); assert.deepEqual(f.calls, [['watcher', undefined], ['service', 123]]); +}); + +test('a service on another data directory never receives a shutdown request', async t => { + const f = fixture(t); f.options.request = async path => { assert.notEqual(path, '/api/shutdown'); return { app: 'other' }; }; + await stopBackground(f.options); assert.deepEqual(f.calls, [['watcher', undefined], ['service', 123]]); +}); + +test('demo exit leaves live markers and the startup watcher alone', async t => { + const f = fixture(t); f.options.demo = true; f.options.cleanup = async phase => { assert.equal(phase, 'service'); }; + f.options.request = async path => path === '/api/shutdown' ? {} : { app: 'codex-continuer', diagnostics: { projectRoot: f.options.root, dataDir: f.options.dataDir, pid: 123 } }; + f.options.isAlive = () => false; await stopBackground(f.options); + assert.equal(exitState.requested(f.options.dataDir, true), true); assert.equal(exitState.requested(f.options.dataDir), false); +}); + +test('passive reads and automatic opening do not restart an exited app; manual opening clears the latch', t => { + const dataDir = directory(t); exitState.mark(dataDir); + const code = `let calls=0;globalThis.fetch=async()=>{calls++;throw Error('fixture network failure')};const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)});let blocked=0;for(const action of [()=>runtime.ensureService(),()=>runtime.openWidget({automatic:true})])try{await action()}catch(error){if(error.message.includes('已退出'))blocked++}const before=calls;try{await runtime.openWidget()}catch{};console.log(JSON.stringify({blocked,before,after:calls}));`; + const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', code], { env: { ...process.env, CONTINUER_DATA_DIR: dataDir, CONTINUER_DEMO: '' }, windowsHide: true, encoding: 'utf8' })); + assert.deepEqual(result, { blocked: 2, before: 0, after: 1 }); assert.equal(exitState.requested(dataDir), false); +}); + +test('verified force cleanup terminates an owned service tree and refuses unrelated PIDs', { skip: process.platform !== 'win32', timeout: 20000 }, async t => { + const dataDir = directory(t), root = join(dataDir, 'plugin'); mkdirSync(join(root, 'server'), { recursive: true }); + const script = join(root, 'server/index.js'), childFile = join(dataDir, 'child.json'); + writeFileSync(script, `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore',windowsHide:true});require('node:fs').writeFileSync(${JSON.stringify(childFile)},JSON.stringify({pid:child.pid}));setInterval(()=>{},1000);`); + const server = spawn(process.execPath, [script], { windowsHide: true, stdio: 'ignore' }), exited = once(server, 'exit'); + let childPid; + t.after(async () => { if (server.exitCode === null) server.kill(); if (childPid && alive(childPid)) process.kill(childPid); }); + const helper = fileURLToPath(new URL('../plugin/stop-owned.ps1', import.meta.url)); + const args = pid => ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helper, '-PluginRoot', root, '-DataDir', dataDir, '-Phase', 'service', '-ServicePid', String(pid)]; + const execute = promisify(execFile); + for (let i = 0; i < 40; i++) { try { childPid = JSON.parse(readFileSync(childFile, 'utf8')).pid; break; } catch { await delay(50); } } + assert.ok(childPid); + await assert.rejects(execute('powershell.exe', args(process.pid), { windowsHide: true, timeout: 5000 }), /Refusing to stop/); + assert.equal(alive(process.pid), true); + await execute('powershell.exe', args(server.pid), { windowsHide: true, timeout: 10000 }); await exited; + for (let i = 0; i < 30 && alive(childPid); i++) await delay(50); + assert.equal(alive(childPid), false); +}); + +test('watcher cleanup recognizes quoted --watch arguments used by the startup bootstrap', { skip: process.platform !== 'win32', timeout: 15000 }, async t => { + const dataDir = directory(t), root = join(dataDir, 'plugin'), desktop = join(root, 'desktop'); mkdirSync(desktop, { recursive: true }); + const source = join(dataDir, 'watcher.cs'), executable = join(desktop, 'continuer-monitor.exe'); + mkdirSync(join(root, 'plugin'), { recursive: true }); + const launcherPidFile = join(dataDir, 'launcher.json'); + writeFileSync(join(root, 'plugin/open.js'), `require('node:fs').writeFileSync(${JSON.stringify(launcherPidFile)},JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);`); + writeFileSync(source, 'class Watcher { static void Main(string[] args) { var info=new System.Diagnostics.ProcessStartInfo(args[1], "\\\"" + System.IO.Path.Combine(args[2],"plugin/open.js") + "\\\"");info.UseShellExecute=false;info.CreateNoWindow=true;System.Diagnostics.Process.Start(info);System.Threading.Thread.Sleep(-1); } }'); + execFileSync(join(process.env.SystemRoot, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), ['/nologo', '/target:winexe', '/out:' + executable, source], { windowsHide: true }); + const watcher = spawn(executable, ['"--watch"', '"' + process.execPath + '"', '"' + root + '"', '"' + dataDir + '"'], { windowsHide: true, windowsVerbatimArguments: true, stdio: 'ignore' }); + const exited = once(watcher, 'exit'); writeFileSync(join(dataDir, 'autostart-status.json'), JSON.stringify({ pid: watcher.pid, pluginRoot: root })); + let launcherPid; + try { + for (let i = 0; i < 40; i++) { try { launcherPid = JSON.parse(readFileSync(launcherPidFile, 'utf8')).pid; break; } catch { await delay(50); } } + assert.ok(launcherPid); + const helper = fileURLToPath(new URL('../plugin/stop-owned.ps1', import.meta.url)); + await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helper, '-PluginRoot', root, '-DataDir', dataDir, '-Phase', 'watcher'], { windowsHide: true, timeout: 8000 }); + for (let i = 0; i < 30 && watcher.exitCode === null; i++) await delay(50); + assert.equal(alive(watcher.pid), false); assert.equal(alive(launcherPid), false); await exited; + } finally { if (watcher.exitCode === null) { watcher.kill(); await exited; } if (launcherPid && alive(launcherPid)) process.kill(launcherPid); } +}); diff --git a/tests/widget-smoke.test.js b/tests/widget-smoke.test.js index b8a4a70..5f769d0 100644 --- a/tests/widget-smoke.test.js +++ b/tests/widget-smoke.test.js @@ -53,9 +53,11 @@ for (const expanded of [false, true]) test(`hidden isolated widget is ready with assert.equal(requests.includes('/api/threads'), false, 'Hidden widgets should not load session lists'); assert.equal(requests.includes('/rhine-signal-loop.svg'), false, 'Hidden widgets should not load the animation'); spawn(executable, ['--disable-gpu', 'desktop/main.cjs', '--quit'], { env, windowsHide: true, stdio: 'ignore' }).unref(); - await Promise.race([exited, delay(3000).then(() => { throw Error('Isolated widget did not quit'); })]); + await Promise.race([exited, delay(10000).then(() => { throw Error('Isolated widget did not quit'); })]); const stopped = JSON.parse(readFileSync(join(directory, 'widget-status.json'), 'utf8')); assert.equal(stopped.ready, false); assert.equal(stopped.exitReason, 'user-quit'); + if (server.exitCode === null) await Promise.race([once(server, 'exit'), delay(3000).then(() => { if (server.exitCode === null) throw Error('Widget exit left its service running'); })]); + assert.notEqual(server.exitCode, null); assert.equal(existsSync(join(directory, 'quit-demo.request')), true); } finally { if (widget && widget.exitCode === null) widget.kill(); const exited = server.exitCode === null ? once(server, 'exit') : Promise.resolve(); From 943709cb86d0fb019be34447be98afe5d876a493 Mon Sep 17 00:00:00 2001 From: empty610 Date: Tue, 27 Oct 2026 08:02:08 +0800 Subject: [PATCH 3/8] fix: pin local service identity and tighten approval privacy boundaries --- .gitignore | 1 + CHANGELOG.md | 3 ++ README.md | 2 +- SECURITY.md | 8 ++- Start-Continuer.ps1 | 6 +-- desktop/main.cjs | 7 ++- docs/compatibility.md | 2 + docs/operations.md | 8 +-- docs/portable.md | 2 +- docs/usage.md | 12 ++--- plugin/control.js | 13 ++--- plugin/runtime.js | 12 +++-- public/access.js | 17 ++---- scripts/check-public.js | 3 +- server/config.cjs | 2 +- server/create-tls.ps1 | 19 +++++++ server/index.js | 8 +-- server/local-tls.cjs | 72 +++++++++++++++++++++++++ server/manager.js | 16 ++++-- tests/access.test.js | 13 ++--- tests/fixtures/local-fetch.js | 4 ++ tests/import-http.test.js | 3 +- tests/local-tls.test.js | 88 +++++++++++++++++++++++++++++++ tests/public-privacy.test.js | 28 ++++++++++ tests/quota-wake.test.js | 2 +- tests/reliability.test.js | 7 +-- tests/round3.test.js | 45 +++++++++++++++- tests/service-integration.test.js | 16 +++--- tests/shutdown.test.js | 4 +- tests/widget-smoke.test.js | 26 ++++----- 30 files changed, 366 insertions(+), 83 deletions(-) create mode 100644 server/create-tls.ps1 create mode 100644 server/local-tls.cjs create mode 100644 tests/fixtures/local-fetch.js create mode 100644 tests/local-tls.test.js create mode 100644 tests/public-privacy.test.js diff --git a/.gitignore b/.gitignore index 7576ea1..326f5ae 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ runtime.json .env .env.* access-*.token +tls-*.json *.pfx *.p12 /source-manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f952f4..ee10508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.4.1-preview — 开发中 +- 本地连接改为固定服务证书的 HTTPS,防止端口冒用者在身份校验前收到访问令牌;协议版本为 4,停用浏览器凭据链接,启动脚本统一打开原生挂件。 +- 文件变更、空白命令和含未展示权限字段的审批拒绝并暂停,保留完整命令的审批流程。 +- 测试中的历史聊天标识改为合成标识;隐私检查覆盖全部导出文本,并拒绝导出运行凭据、私钥文件。 - 关闭按钮、托盘退出和命令行退出统一结束后台服务、跟随及自启动监听器;正常保存停止后仍未退出时,只清理路径核验通过的所属服务进程树。 - 主动退出后,查询和自动启动不再重建后台服务;手动打开才恢复。演示模式退出与真实服务隔离。 - 本轮用户明确授权停止 0.4.0 并切换预览 0.4.1;旧版安装文件和 main 分支保留。 diff --git a/README.md b/README.md index 5a250a6..5998ff5 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ cd codex-continuer | 导入原聊天 | 一次确认的耗尽恢复周期,向原聊天发送一次“继续”;备注仅本地保存 | 暂停、发送次数到限或发送结果不确定;不自动识别原聊天目标完成 | | 新建工作 | 按原始目标启动执行轮次,要求结构化完成结果 | 完成、阻塞、无工具活动或轮次到限;模型报告完成仍需核对验收 | -浏览器控制台通过 `Start-Continuer.ps1` 打开;启动器提供本地凭据,页面会清除链接中的令牌。不要分享启动链接。隐藏到托盘仍保留运行;点击关闭按钮或选择“退出 Continuer”会保存状态、停止后台服务和监听器,并结束进程。停止超时后仅清理经安装路径核验的所属服务进程树。退出后,普通状态查询和自动启动不会重新拉起;手动打开挂件或浏览器启动器可恢复。 +`Start-Continuer.ps1` 打开原生挂件;浏览器凭据链接已停用。本地客户端通过固定证书校验服务身份后才发送凭据。隐藏到托盘仍保留运行;点击关闭按钮或选择“退出 Continuer”会保存状态、停止后台服务和监听器,并结束进程。停止超时后仅清理经安装路径核验的所属服务进程树。退出后,普通状态查询和自动启动不会重新拉起;手动打开挂件可恢复。 启用或关闭随 Codex 启动: diff --git a/SECURITY.md b/SECURITY.md index 496ecf6..417f7b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,15 @@ 当前只维护最新 Windows 预览版。任务目标、输出、路径、备注和历史记录可能包含私人信息。不要上传 `data/`、`.cache/`、`runtime.json`、访问令牌文件、浏览器启动链接或未脱敏日志。 -HTTP 服务只绑定 127.0.0.1。所有 `/api/` 读取、实时订阅和控制都要求随机 Bearer 令牌,并保留 Host、Origin 与写请求头校验。令牌由服务写入数据目录,MCP 和本机启动器读取;浏览器启动链接通过 fragment 携带令牌,页面立即清除链接并仅保存在当前 origin 的 sessionStorage。浏览器实时订阅使用带请求头的 fetch,原生挂件保留 EventSource 并由主进程注入同一认证头;不使用 Cookie 或查询参数传递凭据。退出浏览器会话后需从启动器重新打开。 +HTTPS 服务只绑定 127.0.0.1。MCP、启停入口和原生挂件先校验数据目录中的固定服务证书,再发送随机 Bearer 令牌;端口被其他程序占用或证书不匹配时拒绝连接,不降级到 HTTP。所有 `/api/` 读取、实时订阅和控制仍保留 Host、Origin 与写请求头校验。原生挂件由主进程为本窗口、本服务的 API 注入认证头。浏览器凭据链接已停用,启动脚本统一打开原生挂件。 + +证书通过 Windows PowerShell / .NET 在内存中生成,不写入系统证书库。数据目录中的 `tls-live.json` / `tls-demo.json` 含私钥,必须与访问令牌一样保密。真实与演示身份隔离,证书有效期两年;损坏、过期或公私钥不匹配时拒绝启动,不自动覆盖。公开源码检查覆盖全部导出文本中的聊天标识,并拒绝导出运行凭据文件。 此机制不防御已经能读取数据目录、浏览器会话或同用户进程内存的程序。Windows 文件权限取决于数据目录继承的 ACL;Node 的 mode 参数不能建立额外 Windows 用户隔离。请把数据放在个人用户目录;不支持多人共享、反向代理、对公网暴露或不可信本机管理员场景。 -重置凭据前先停止服务和挂件,备份数据,再移除对应 `access-live.token` 或 `access-demo.token`。下次启动创建新令牌,旧页面需通过启动器重新打开。凭据损坏时服务拒绝启动,不自动覆盖可疑文件。不要删除任务状态文件来解决登录问题。 +重置凭据或更换过期证书前先停止服务、挂件及旧客户端,备份数据,再成对移除对应的 `access-live.token` 与 `tls-live.json`(演示模式为 `access-demo.token` 与 `tls-demo.json`)。下次启动创建新令牌和证书。不要删除任务状态文件来解决登录问题。 + +挂件仅允许审阅完整命令的命令执行审批。文件变更审批、空命令以及含未展示权限字段的请求会拒绝并暂停,需用户在原 Codex 中检查、处理;不会用缺少补丁内容的“允许”按钮授权文件写入。 仓库已启用 GitHub Private vulnerability reporting,请通过 [私密漏洞报告](https://github.com/empty610/codex-continuer/security/advisories/new) 或仓库 Security → Report a vulnerability 私下报告。请勿把可利用细节或私人日志放在公开 Issue。提供版本、最小复现和脱敏影响说明,不提供凭据或真实聊天数据。 diff --git a/Start-Continuer.ps1 b/Start-Continuer.ps1 index 893fe60..a0e3965 100644 --- a/Start-Continuer.ps1 +++ b/Start-Continuer.ps1 @@ -1,9 +1,7 @@ param([switch]$Demo, [switch]$NoBrowser) $ErrorActionPreference = 'Stop' $continuerNode = (Get-Command node -ErrorAction Stop).Source -$continuerArguments = @((Join-Path $PSScriptRoot 'plugin/control.js'), 'url') +$continuerArguments = @((Join-Path $PSScriptRoot 'plugin/control.js'), $(if ($NoBrowser) { 'start' } else { 'open' })) if ($Demo) { $continuerArguments += '--demo' } -$continuerUrl = & $continuerNode @continuerArguments +& $continuerNode @continuerArguments if ($LASTEXITCODE -ne 0) { throw 'Continuer startup failed. Check the data directory logs.' } -Write-Host ('Codex Continuer: ' + ($continuerUrl -split '#')[0]) -if (-not $NoBrowser) { Start-Process $continuerUrl } diff --git a/desktop/main.cjs b/desktop/main.cjs index d179e44..4e66038 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -7,6 +7,7 @@ const { place, placeIndependent, offsetFrom, sizes, compactSize, samePlacementCo const { createPointerDrag } = require('./pointer-drag.cjs'); const runtimeConfig = require('../server/config.cjs'); const logging = require('../server/logging.cjs'); +const localTls = require('../server/local-tls.cjs'); app.setName('Codex Continuer'); const dataDir = runtimeConfig.dataDir; @@ -19,7 +20,7 @@ try { prefs = { ...prefs, ...JSON.parse(readFileSync(preferenceFile, 'utf8')) }; let preferenceError = null; const save = () => { try { writeFileSync(preferenceFile, JSON.stringify(prefs)); preferenceError = null; } catch (error) { preferenceError = error.message; console.error(`挂件设置未保存:${error.message}`); } }; const port = runtimeConfig.port; -const origin = `http://127.0.0.1:${port}`; +const origin = `https://127.0.0.1:${port}`; let win, tray, widgetMenu, tracker, anchor, pointerDrag, isReady = false, targetVisible = false, closing = false, lastDiagnostic, lastSentState, menuOpen = false, dragging = false, boundsTimer; let panelSize = { width: sizes.expanded.width - 18, height: sizes.expanded.height - 18 }; let settledPlacement, restoringMode=false; @@ -198,6 +199,10 @@ async function start() { win.webContents.on('console-message', details => { if (details.level === 'error') console.error('挂件页面错误:' + details.message.slice(0, 2000)); }); win.webContents.on('will-navigate', (event, url) => { if (url !== origin + '/widget') event.preventDefault(); }); win.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + const pinned = localTls.readIdentity(dataDir, process.env.CONTINUER_DEMO === '1').certificate; + win.webContents.session.setCertificateVerifyProc((request, callback) => { + callback(request.hostname === '127.0.0.1' && localTls.matchesCertificate(request.certificate.data, pinned) ? 0 : -2); + }); // Keep the native credential in the main process and never attach it to // another window, origin, static asset or redirected request. win.webContents.session.webRequest.onBeforeSendHeaders({ urls: [origin + '/api/*'] }, (details, callback) => { diff --git a/docs/compatibility.md b/docs/compatibility.md index 6a06676..2bc62e1 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,6 +2,8 @@ 当前版本为 Windows 预览版。主要验证环境为 Windows x64、Node.js 24、Electron 45.0.0;最低 Node 版本为 22.12。Windows ARM64、macOS、Linux 未完成发布验收,不声明支持。源码编译原生监听器需要 Visual Studio Build Tools 的 MSBuild/Roslyn,`vswhere` 可自动定位,也可设置 `CONTINUER_CSC`。 +本地协议版本 4 使用固定证书的 HTTPS。首次启动需要 Windows PowerShell 和 .NET Framework 4.7.2 或更高版本生成证书,不修改系统信任库。旧 HTTP 客户端与浏览器凭据链接不再兼容,启动入口改为原生挂件。升级前需正常停止旧服务和挂件,保留任务数据;证书重置步骤见 SECURITY.md。 + Codex app-server 协议基线为本机验证过的 CLI 0.153.2。不能据此保证所有后续版本兼容。RPC CLI 与桌面队列 CLI 可能是不同安装:分别用 `CODEX_BINARY`、`CODEX_QUEUE_BINARY` 指定真实可执行文件。工具会对队列命令进行能力检查;失败时停止发送,不降级为接管原聊天。 运行 `npm run doctor`(源码)或 `node scripts/doctor.js`(安装包)只调用 `--version` 和 `--help`。输出分别报告 app-server、plugin 和 queue 能力,不读取聊天,不发送消息;`--strict` 对核心能力缺失返回失败,缺少 queue 时不能使用原聊天接续。 diff --git a/docs/operations.md b/docs/operations.md index b82a77b..aba88cc 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,20 +2,20 @@ 当前代码版本来自 `package.json`。`/api/health`、页面版本和 MCP 状态同时给出内容指纹 `buildId`、协议版本和状态 schema 版本;相同语义版本的不同运行代码也能区分。 -当前 HTTP 协议版本为 2,状态 schema 仍为 1。授权决定必须携带对应请求的 `detailToken`,旧页面或旧插件需要重新加载;升级不迁移或清空真实任务。 +当前本地 HTTPS 协议版本为 4,状态 schema 仍为 1。所有客户端固定校验数据目录中的服务证书;不兼容旧 HTTP 客户端,升级时需先正常停止旧服务与挂件,再一起切换客户端。授权决定必须携带对应请求的 `detailToken`;升级不迁移或清空真实任务。 ## 启动与停止 - MCP 读取状态、列会话或打开挂件时,如果需要冷启动服务,只进入查询模式;不因保存的调度开关为开而执行旧队列。读取现有服务不会改变它原有的运行状态。 - 在页面明确开启调度,或明确创建/继续工作,才退出查询模式。导入原聊天仍保持暂停。服务遇到保存错误后会关闭调度,不能自动恢复执行。 -- `npm start` 是明确的后台执行启动入口,会沿用保存的调度设置。`Start-Continuer.ps1` 与挂件入口冷启动时进入查询模式,并以认证链接打开浏览器;页面明确开启调度后才启动旧队列。`Stop-Continuer.ps1` 使用本地凭据关闭调度并停止服务;隐藏到托盘保持运行;退出挂件会一并停止服务与监听器,必要时按安装路径核验后结束所属服务进程树。 +- `npm start` 是明确的后台执行启动入口,会沿用保存的调度设置。`Start-Continuer.ps1` 与挂件入口冷启动时进入查询模式,并打开原生挂件;页面明确开启调度后才启动旧队列。`Stop-Continuer.ps1` 使用本地凭据关闭调度并停止服务;隐藏到托盘保持运行;退出挂件会一并停止服务与监听器,必要时按安装路径核验后结束所属服务进程树。 - `/api/health` 报告服务 PID、构建、额度连接、存储故障、上次退出、挂件就绪状态、自启动监听器和实时连接数。挂件在首屏状态成功渲染后才发送就绪信号,打开命令等待该信号。 - 服务通过规范化数据目录对应的独占命名管道句柄互斥,不增加常驻进程;进程退出后由系统释放。`service.lock` 仅记录 PID 和租约标识,也会拒绝覆盖仍存活的旧版服务。失效记录在取得互斥句柄后更新;损坏记录仍需先检查进程,不能直接绕过。 - 关闭调度、断连或存储故障会使旧调度失效,即使马上重新开启,也由新的有效调度启动任务。异步准备结束后会重新检查连接、开关及额度;已发出的轮次按原有规则继续或明确中断。 -- 挂件渲染进程异常退出时最多尝试重新加载 3 次;手动退出挂件不会触发重新打开。服务崩溃不会自动重新开启任务调度,下一次查询冷启动仍为查询模式。主动退出会写入 quit.request,阻止查询和自动启动拉起后台;手动打开挂件或浏览器启动器才清除。演示模式使用独立 quit-demo.request,不影响真实服务。 +- 挂件渲染进程异常退出时最多尝试重新加载 3 次;手动退出挂件不会触发重新打开。服务崩溃不会自动重新开启任务调度,下一次查询冷启动仍为查询模式。主动退出会写入 quit.request,阻止查询和自动启动拉起后台;手动打开挂件才清除。演示模式使用独立 quit-demo.request,不影响真实服务。 - 查询已有服务和等待冷启动完成使用同一套构建、协议及数据目录校验;不能仅凭端口和构建相同就连接其他数据目录。 - 直接执行和原聊天恢复发送均要求新鲜的完整主动额度读取;部分额度通知只更新显示并触发重新读取,不能单独授权启动轮次。 -- 隐藏挂件或浏览器页面会暂停前端订阅与界面工作,重新显示时取得最新状态;后台调度和额度读取继续按原有设置运行。原生隐藏启动仅保留就绪所需的一次状态握手。 +- 隐藏挂件会暂停前端订阅与界面工作,重新显示时取得最新状态;后台调度和额度读取继续按原有设置运行。原生隐藏启动仅保留就绪所需的一次状态握手。 ## 数据保存失败与恢复 diff --git a/docs/portable.md b/docs/portable.md index b054cac..8ff0c17 100644 --- a/docs/portable.md +++ b/docs/portable.md @@ -11,7 +11,7 @@ 安装不会开启调度。重载 Codex 插件后再打开挂件;原聊天接续额外要求桌面附带 CLI 支持 `queue --thread --message`。可在插件目录运行 `node scripts/doctor.js --strict` 检查能力;此命令不启动模型,不发送消息。 -浏览器界面请从插件目录运行 `Start-Continuer.ps1`,它使用本地访问凭据打开页面。直接访问裸地址不会获得任务信息。不要转发带 `#access=` 的启动链接或上传数据目录、诊断日志。 +从插件目录运行 `Start-Continuer.ps1` 打开原生挂件。浏览器凭据链接已停用;客户端先校验本地固定证书再发送凭据。不要上传数据目录、`tls-*.json`、访问令牌或未脱敏诊断日志。 卸载时,在实际安装的插件目录运行 `Uninstall-Plugin.ps1`,依次关闭自启动、停止服务、退出挂件并移除插件注册。任务数据保留在 `%LOCALAPPDATA%\CodexContinuer`,自定义 `CONTINUER_DATA_DIR` 时以该目录为准。卸载不会删除数据;旧版本升级前先停止其服务。 diff --git a/docs/usage.md b/docs/usage.md index 2d61e0b..d2e6b71 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -39,7 +39,7 @@ cd .\codex-continuer 也可以双击 `Open-Widget.cmd` 直接打开,或运行 `npm run widget`。**Ctrl+Alt+C** 展开面板,点击减号收起成圆形仪表。收起后点击任意位置展开、拖动移动;展开后拖动顶部标题栏移动。两种状态分别记住位置,跟随模式保存相对 Codex 的偏移。系统托盘和工作策略中提供「重置挂件位置」。跟随模式在 Codex 位于前台时显示,挂件获得焦点时保持显示;其他应用在前台或 Codex 最小化时隐藏。 -这是与 Codex 窗口联动的独立原生挂件,不是向 Codex 内部侧栏注入 UI。插件工具与挂件共用本地服务。隐藏到托盘仍保持运行;退出会停止后台调度和服务。退出标记会阻止普通 MCP 查询或自动启动重新拉起,需手动打开挂件或浏览器启动器恢复。Codex 托管的 MCP 工具连接可保持空闲,不执行后台调度。 +这是与 Codex 窗口联动的独立原生挂件,不是向 Codex 内部侧栏注入 UI。插件工具与挂件共用本地服务。隐藏到托盘仍保持运行;退出会停止后台调度和服务。退出标记会阻止普通 MCP 查询或自动启动重新拉起,需手动打开挂件恢复。Codex 托管的 MCP 工具连接可保持空闲,不执行后台调度。 ### 随 Codex 启动 @@ -57,7 +57,7 @@ cd .\codex-continuer 「我的工作」会自动读取本机 Codex 对话,按最近更新时间排列,可刷新、加载更多,并记住选中项。选择后点击「加入我的工作」,可填写仅本地保存的监测备注。已有项目目录自动读取,加入后保持暂停。当前桌面页面的前台对话 ID 未通过这里使用的接口公开,因此需要从列表选择一次,不将最近更新时间当作当前页面身份。 -同一聊天同时只允许一个导入操作;浏览器、挂件和 MCP 共用这项保护。重复请求会提示已添加或正在添加,不会创建第二条记录,也不会重置已有监测。读取失败后可以重新导入,不同聊天可以同时添加。 +同一聊天同时只允许一个导入操作;挂件和 MCP 共用这项保护。重复请求会提示已添加或正在添加,不会创建第二条记录,也不会重置已有监测。读取失败后可以重新导入,不同聊天可以同时添加。 插件工具:`continuer_open_widget`、`continuer_status`、`continuer_list_sessions`、`continuer_create_task`、`continuer_import_session`、`continuer_task_action`、`continuer_update_settings`。读状态不会创建任务,导入会话保持暂停,授权决定由用户在面板中操作。 @@ -77,7 +77,7 @@ codex plugin list --json ## 启动 -浏览器完整控制台仍可使用。后台服务无第三方 npm 运行时依赖;原生挂件另外使用 Electron。 +启动入口统一使用原生挂件,浏览器凭据链接已停用。后台服务无第三方 npm 运行时依赖;原生挂件另外使用 Electron。 Windows 双击 `Start.cmd`,或在 PowerShell 中运行: @@ -86,7 +86,7 @@ cd .\codex-continuer .\Start-Continuer.ps1 ``` -启动脚本以查询模式启动本地服务,并用带访问凭据的链接打开浏览器。页面立即清除 URL 中的凭据;直接输入裸地址只能打开静态页面,读取任务和执行控制需要凭据。不要分享启动链接。关闭浏览器不会关闭服务。 +启动脚本以查询模式启动本地 HTTPS 服务并打开原生挂件;客户端固定校验服务证书后才发送凭据。`-NoBrowser` 参数保留兼容,仅启动查询模式服务;`node plugin/control.js url` 已停用且不会输出凭据。 也可前台运行: @@ -139,7 +139,7 @@ CLI 已登录时不必重复登录。普通 Windows 用户环境即可运行, - 额度类失败按协议错误码判断,至少退避 60 秒;其他失败需人工处理。 - 新建工作每轮要求结构化 `done / continue / blocked` 结果;`continue` 且存在工具活动才可自动开始下一轮。结果不明确、需要输入、没有工具活动或达到轮次上限均停止。原聊天只监测额度并发送消息,不读取这类完成结果。 - `done` 由模型报告,不是独立验证器。验收要求应写入目标,用户可检查输出后手动标记完成。 -- 使用 `workspace-write` 沙箱和 `on-request` 审批策略。支持命令与文件变更审批;其他类型交互会中断并要求手工处理。 +- 使用 `workspace-write` 沙箱和 `on-request` 审批策略。支持可完整审阅的命令审批;文件变更、空命令、未展示的权限字段及其他交互会拒绝并暂停,需用户到原 Codex 手工处理。 - 只主动管理本工具创建或导入的任务;不会接管未导入的聊天。其他 Codex 客户端的实时进度不保证跨进程同步;不应在两个客户端同时执行同一会话。 - 电脑和本地服务必须保持运行,关机或休眠期间不能续作。启用「随 Codex 启动」后,重新打开 Codex 会恢复挂件与本地服务。 @@ -162,7 +162,7 @@ node scripts/probe.js ## 数据与部署 -- 默认仅绑定 `127.0.0.1`,校验 Host、Origin、写请求头和随机 Bearer 令牌;所有 `/api/` 接口均需认证,不提供跨站访问。信任边界和凭据重置见 SECURITY.md。 +- 默认仅绑定 `127.0.0.1`,固定校验 HTTPS 服务证书,并校验 Host、Origin、写请求头和随机 Bearer 令牌;所有 `/api/` 接口均需认证,不提供跨站访问。信任边界、私钥文件和凭据重置见 SECURITY.md。 - 任务、计划、输出、最近 300 条活动与 240 个额度样本保存在项目 `data/`。写入使用临时文件再替换。 - 状态带 schema 版本和格式校验,保留上次有效备份;损坏文件不会被空状态覆盖。保存失败会停止后续自动调度与发送,页面提供重试保存或明确恢复备份,恢复后调度保持关闭。 - 状态与 SSE 仅传任务摘要,目标、计划和输出在打开详情时读取;实时连接最多 16 个,对持续背压的客户端断开重连。最多新建/导入 100 条记录,会话选择最多 300 项。 diff --git a/plugin/control.js b/plugin/control.js index 1795584..058ad58 100644 --- a/plugin/control.js +++ b/plugin/control.js @@ -1,4 +1,4 @@ -// Shared authenticated transport for the PowerShell browser/stop launchers. +// Shared pinned transport for the PowerShell native/start/stop launchers. const demo = process.argv.includes('--demo'); process.env.CONTINUER_PORT = process.env.PORT || (demo ? '4318' : process.env.CONTINUER_PORT || '4317'); process.env.CONTINUER_DEMO = demo ? '1' : ''; @@ -8,14 +8,15 @@ try { if (process.argv[2] === 'stop') { let state; try { state = await runtime.request('/api/state'); } - catch (error) { if (error.cause?.code === 'ECONNREFUSED') { console.log('Continuer is not running.'); process.exit(0); } throw error; } + catch (error) { if (error.code === 'TLS_IDENTITY_MISSING' || error.cause?.code === 'ECONNREFUSED') { console.log('Continuer is not running.'); process.exit(0); } throw error; } const { resolve } = await import('node:path'); const normalize = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); if (state.app !== 'codex-continuer' || typeof state.diagnostics?.dataDir !== 'string' || normalize(state.diagnostics.dataDir) !== normalize(runtime.dataDir)) throw Error('端口服务不属于当前 Continuer 数据目录'); await runtime.request('/api/shutdown', {}); console.log('Continuer stopped.'); - } else if (process.argv[2] === 'url') { + } else if (process.argv[2] === 'open' || process.argv[2] === 'start') { exitState.clear(runtime.dataDir, demo); - await runtime.ensureService(); - process.stdout.write(runtime.origin + '/#access=' + runtime.readAccessToken()); - } else throw Error('Usage: node plugin/control.js url|stop [--demo]'); + if (process.argv[2] === 'open') console.log(JSON.stringify(await runtime.openWidget())); + else { await runtime.ensureService(); console.log('Continuer started in observation mode.'); } + } else if (process.argv[2] === 'url') throw Error('浏览器凭据链接已停用,请运行 node plugin/open.js 打开原生挂件。'); + else throw Error('Usage: node plugin/control.js open|start|stop [--demo]'); } catch (error) { console.error(error.message); process.exitCode = 1; } diff --git a/plugin/runtime.js b/plugin/runtime.js index 5e142f0..ef5ad51 100644 --- a/plugin/runtime.js +++ b/plugin/runtime.js @@ -6,16 +6,18 @@ import logging from '../server/logging.cjs'; import { diagnostic, alive } from '../server/health.js'; import access from '../server/access.cjs'; import exitState from '../server/exit-state.cjs'; +import localTls from '../server/local-tls.cjs'; export const root = settings.root; const config = settings.config; export const dataDir = settings.dataDir; export const port = settings.port; -export const origin = `http://127.0.0.1:${port}`; +export const origin = `https://127.0.0.1:${port}`; export const readAccessToken = () => access.readToken(dataDir, process.env.CONTINUER_DEMO === '1'); export async function request(path, payload, { timeoutMs = 35000 } = {}) { const token = readAccessToken(); - const response = await fetch(origin + path, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error', headers: { ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(payload === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, ...(payload === undefined ? {} : { method: 'POST', body: JSON.stringify(payload) }) }); + if (typeof path !== 'string' || !path.startsWith('/api/') || path.includes('\\') || new URL(origin + path).origin !== origin) throw Error('Invalid API path'); + const response = await localTls.fetchLocal(origin + path, { signal: AbortSignal.timeout(timeoutMs), headers: { ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(payload === undefined ? {} : { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }) }, ...(payload === undefined ? {} : { method: 'POST', body: JSON.stringify(payload) }) }, localTls.requestOptions(dataDir, process.env.CONTINUER_DEMO === '1')); const result = await response.json(); if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); return result; @@ -46,7 +48,7 @@ export async function ensureService() { const state = await request('/api/state'); return verifiedService(state); } catch (error) { - if (!error.cause || error.cause.code !== 'ECONNREFUSED') throw error; + if (error.code !== 'TLS_IDENTITY_MISSING' && error.cause?.code !== 'ECONNREFUSED') throw error; } const node = config.nodePath || (process.versions.electron ? process.env.CONTINUER_NODE || 'node' : process.execPath); if (!node) throw new Error('缺少 Node.js 路径,请重新运行插件安装器'); @@ -54,11 +56,11 @@ export async function ensureService() { // Opening a widget or reading MCP state never restores saved scheduling by itself. const child = launch(node, [join(root, 'server/index.js'), '--observe-only', ...(process.env.CONTINUER_DEMO === '1' ? ['--demo'] : [])], 'service', { PORT: String(port) }); let spawnError; child.once('error', e => { spawnError = e; }); - for (let i = 0; i < 60; i++) { + for (let i = 0; i < 120; i++) { if (spawnError) throw spawnError; await new Promise(resolve => setTimeout(resolve, 150)); let state; - try { state = await request('/api/state'); } catch (error) { if (error.cause?.code === 'ECONNREFUSED') continue; throw error; } + try { state = await request('/api/state'); } catch (error) { if (error.code === 'TLS_IDENTITY_MISSING' || error.cause?.code === 'ECONNREFUSED') continue; throw error; } return verifiedService(state); } throw new Error('本地服务启动失败,请查看 data/service-stderr.log'); diff --git a/public/access.js b/public/access.js index 5fd8f04..863f876 100644 --- a/public/access.js +++ b/public/access.js @@ -1,17 +1,10 @@ -// A fragment is never sent in an HTTP URL. Clear it before making requests. -// sessionStorage is scoped to this origin (including its port), unlike cookies. -const storageKey = 'continuer-access'; +// Browser credential links are retired. Only the pinned native window receives +// API authorization from its main process; discard legacy browser credentials. if (typeof window !== 'undefined' && typeof location !== 'undefined') { - const token = new URLSearchParams(location.hash.slice(1)).get('access'); - if (token) { - history.replaceState(null, '', location.pathname + location.search); - if (/^[a-f0-9]{64}$/.test(token)) sessionStorage.setItem(storageKey, token); - } -} -export function accessHeaders() { - const token = typeof sessionStorage === 'undefined' ? null : sessionStorage.getItem(storageKey); - return token ? { Authorization: 'Bearer ' + token } : {}; + if (new URLSearchParams(location.hash.slice(1)).has('access')) history.replaceState(null, '', location.pathname + location.search); + sessionStorage.removeItem('continuer-access'); } +export function accessHeaders() { return {}; } // Native EventSource cannot send authorization headers. Preserve its small // onmessage/onerror/close contract using a bounded fetch stream with retries. diff --git a/scripts/check-public.js b/scripts/check-public.js index 318732c..fdecc63 100644 --- a/scripts/check-public.js +++ b/scripts/check-public.js @@ -12,10 +12,11 @@ export function checkPublic(root = '.') { ['personal home path', /[A-Za-z]:[\\/]Users[\\/](?!Public\b|Default\b)[^\\/\s"'<>`%]+/i], ]; for (const file of files) { + if (/(?:^|\/)(?:runtime\.json|tls-[^/]+\.json|access-[^/]+\.token|\.env(?:\.[^/]+)?)$|\.(?:pfx|p12)$/i.test(file)) { failures.push(file + ': private runtime file'); continue; } if (/\.(png|ico)$/.test(file)) continue; const text = readFileSync(join(root, file), 'utf8'); for (const [kind, pattern] of patterns) if (pattern.test(text)) failures.push(file + ': ' + kind); - if (/^(docs\/|README)/.test(file) && /\b01[a-f0-9]{6}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\b/i.test(text)) failures.push(file + ': live conversation identifier'); + if (/\b01[a-f0-9]{6}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\b/i.test(text)) failures.push(file + ': live conversation identifier'); } const provenance = JSON.parse(readFileSync(join(root, 'third_party/rhine/UPSTREAM.json'), 'utf8')); for (const [file, info] of Object.entries(provenance.files)) if (createHash('sha256').update(readFileSync(join(root, 'third_party/rhine', file))).digest('hex') !== info.sha256) failures.push('Upstream source changed: ' + file); diff --git a/server/config.cjs b/server/config.cjs index 9b022e6..443df6d 100644 --- a/server/config.cjs +++ b/server/config.cjs @@ -4,7 +4,7 @@ const { createHash } = require('node:crypto'); const root = resolve(__dirname, '..'); const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; -const protocolVersion = 3, schemaVersion = 1; +const protocolVersion = 4, schemaVersion = 1; let config = {}; try { config = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')); } catch {} const dataDir = resolve(process.env.CONTINUER_DATA_DIR || config.dataDir || diff --git a/server/create-tls.ps1 b/server/create-tls.ps1 new file mode 100644 index 0000000..1b0b0f4 --- /dev/null +++ b/server/create-tls.ps1 @@ -0,0 +1,19 @@ +param([Parameter(Mandatory=$true)][string]$DataDir, [switch]$Demo) +$ErrorActionPreference = 'Stop' +# Storeless CertificateRequest: no trusted-root import or system certificate changes. +$continuerTokenFile = Join-Path $DataDir $(if ($Demo) { 'access-demo.token' } else { 'access-live.token' }) +$continuerPassword = [IO.File]::ReadAllText($continuerTokenFile).Trim() +if ($continuerPassword -notmatch '^[a-f0-9]{64}$') { throw 'Invalid local credential' } +$continuerRsa = [Security.Cryptography.RSACng]::new(2048) +try { + $continuerRequest = [Security.Cryptography.X509Certificates.CertificateRequest]::new('CN=Continuer Local', $continuerRsa, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) + # Subject Alternative Name: IP address 127.0.0.1 (DER SEQUENCE of IP). + $continuerRequest.CertificateExtensions.Add([Security.Cryptography.X509Certificates.X509Extension]::new('2.5.29.17', [byte[]](48,6,135,4,127,0,0,1), $false)) + $continuerRequest.CertificateExtensions.Add([Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true)) + $continuerCertificate = $continuerRequest.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-5), [DateTimeOffset]::UtcNow.AddYears(2)) + try { + $continuerPem = "-----BEGIN CERTIFICATE-----`n" + [Convert]::ToBase64String($continuerCertificate.RawData, [Base64FormattingOptions]::InsertLineBreaks) + "`n-----END CERTIFICATE-----`n" + # Captured by Node's execFile pipe, never inherited by a console or log. + @{ certificate=$continuerPem; pfx=[Convert]::ToBase64String($continuerCertificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $continuerPassword)) } | ConvertTo-Json -Compress + } finally { $continuerCertificate.Dispose() } +} finally { $continuerRsa.Dispose() } diff --git a/server/index.js b/server/index.js index 401a3c6..c56545c 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,4 @@ -import { createServer } from 'node:http'; +import { createServer } from 'node:https'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { CodexRpc } from './rpc.js'; @@ -15,12 +15,13 @@ import { createApiRouter, json } from './http-routes.js'; import { createStaticAssets } from './static-assets.js'; import access from './access.cjs'; import exitState from './exit-state.cjs'; +import localTls from './local-tls.cjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const demo = process.argv.includes('--demo'); const port = Number(process.env.PORT || (demo ? 4318 : config.port)); if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid Continuer port'); -const origin = `http://127.0.0.1:${port}`; +const origin = `https://127.0.0.1:${port}`; const dataDir = config.dataDir; if (process.argv.includes('--observe-only') && exitState.requested(dataDir, demo)) throw Error('Continuer 已退出,请先手动打开挂件。'); if (!process.argv.includes('--observe-only')) exitState.clear(dataDir, demo); @@ -29,6 +30,7 @@ logging.rotateLogs(dataDir); const releaseLease = await acquireLease(dataDir, demo ? 'demo-service' : 'service'); process.once('exit', releaseLease); const accessToken = access.createToken(dataDir, demo); +const secureOptions = await localTls.serverOptions(dataDir, demo, accessToken); const store = new Store(resolve(dataDir, demo ? 'demo-state.json' : 'state.json')); if (demo) { try { if (!store.load()) store.save(demoState()); } catch { /* Manager exposes recovery without overwriting damaged data. */ } } const rpc = demo ? new DemoRpc() : new CodexRpc(); @@ -49,7 +51,7 @@ manager.on('persistence-error', error => console.error(`输出合并保存失败 manager.on('operation-error', error => console.error(`操作未完成:${error.message}`)); const routeApi = createApiRouter({ manager, health, stream, rpc, demo, summary, stop }); const serveStatic = createStaticAssets(root); -const server = createServer(async (req, res) => { +const server = createServer(secureOptions, async (req, res) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'"); diff --git a/server/local-tls.cjs b/server/local-tls.cjs new file mode 100644 index 0000000..d136c86 --- /dev/null +++ b/server/local-tls.cjs @@ -0,0 +1,72 @@ +const { X509Certificate } = require('node:crypto'); +const { existsSync, lstatSync, readFileSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const https = require('node:https'); +const tls = require('node:tls'); + +const identityPath = (directory, demo = false) => join(directory, demo ? 'tls-demo.json' : 'tls-live.json'); +function certificate(pem) { + const value = new X509Certificate(pem), now = Date.now(); + if (!value.checkIP('127.0.0.1') || now < Date.parse(value.validFrom) || now >= Date.parse(value.validTo)) throw Error('本地服务证书无效或已过期,请停止服务后检查数据目录'); + return value; +} +function readIdentity(directory, demo = false) { + const file = identityPath(directory, demo); + if (!existsSync(file)) return null; + const info = lstatSync(file); + if (!info.isFile() || info.isSymbolicLink() || info.size > 32768) throw Error('本地服务证书文件无效'); + const identity = JSON.parse(readFileSync(file, 'utf8')); + if (typeof identity.certificate !== 'string' || typeof identity.pfx !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(identity.pfx)) throw Error('本地服务证书文件无效'); + certificate(identity.certificate); + return identity; +} +async function serverOptions(directory, demo, password) { + let identity = readIdentity(directory, demo); + if (!identity) { + const { stdout } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(__dirname, 'create-tls.ps1'), '-DataDir', directory, ...(demo ? ['-Demo'] : [])], { windowsHide: true, timeout: 15000, maxBuffer: 32768 }); + identity = JSON.parse(stdout.replace(/^\uFEFF/, '')); + certificate(identity.certificate); + // Service lease owns creation. Never overwrite a damaged/existing identity. + tls.createSecureContext({ pfx: Buffer.from(identity.pfx, 'base64'), passphrase: password }); + writeFileSync(identityPath(directory, demo), JSON.stringify(identity), { flag: 'wx', mode: 0o600, flush: true }); + } + const options = { pfx: Buffer.from(identity.pfx, 'base64'), passphrase: password, minVersion: 'TLSv1.2' }; + // Bind the public pin and private key envelope before opening any listener. + const context = tls.createSecureContext(options); + const actual = context.context.getCertificate(); + if (!actual || !new X509Certificate(actual).raw.equals(certificate(identity.certificate).raw)) throw Error('本地服务证书与私钥不匹配'); + return options; +} +function matchesCertificate(pem, expected) { + try { return certificate(pem).raw.equals(certificate(expected).raw); } catch { return false; } +} +function requestOptions(directory, demo = false) { + const identity = readIdentity(directory, demo); + if (!identity) throw Object.assign(Error('缺少本地服务证书,请先启动新版 Continuer'), { code: 'TLS_IDENTITY_MISSING' }); + const pinned = certificate(identity.certificate); + return { ca: identity.certificate, rejectUnauthorized: true, minVersion: 'TLSv1.2', checkServerIdentity: (host, peer) => tls.checkServerIdentity(host, peer) || (peer.raw?.equals(pinned.raw) ? undefined : Error('本地服务证书不匹配')) }; +} +// Fetch-shaped response for shared launchers/tests, with no ambient CA trust, +// redirects, unbounded body, or credentials written before a verified handshake. +function fetchLocal(url, options = {}, identityOptions) { + const target = new URL(url); + if (target.protocol !== 'https:' || target.hostname !== '127.0.0.1' || target.username || target.password) return Promise.reject(Error('Invalid local HTTPS destination')); + return new Promise((resolve, reject) => { + const req = https.request(target, { ...identityOptions, agent: false, method: options.method || 'GET', headers: options.headers, signal: options.signal }, res => { + const chunks = []; let size = 0; + res.on('data', chunk => { size += chunk.length; if (size > 4 * 1024 * 1024) { const error = Error('本地响应超过长度限制'); reject(error); res.destroy(error); } else chunks.push(chunk); }); + res.on('error', reject); res.on('aborted', () => reject(Error('本地响应被中断'))); + res.on('end', () => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.statusCode !== 304) return reject(Error('本地服务重定向被拒绝')); + const body = Buffer.concat(chunks).toString('utf8'); + resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, headers: new Headers(Object.entries(res.headers).flatMap(([key,value]) => Array.isArray(value) ? value.map(v => [key,v]) : value == null ? [] : [[key,String(value)]])), text: async () => body, json: async () => JSON.parse(body) }); + }); + }); + req.on('error', error => reject(Error('本地安全连接失败:' + error.message, { cause: error }))); + if (options.body !== undefined) req.write(options.body); + req.end(); + }); +} +module.exports = { identityPath, readIdentity, serverOptions, requestOptions, matchesCertificate, fetchLocal }; diff --git a/server/manager.js b/server/manager.js index aa27257..629dfd2 100644 --- a/server/manager.js +++ b/server/manager.js @@ -436,8 +436,17 @@ export class Manager extends EventEmitter { if (task && task.mode !== 'quota-wake' && this.deferTurnEvent(task, msg, true)) return; if (!task || !this.matchesTurn(task, msg.params || {})) { this.rejectRequest(msg); return; } if (task.status === 'pausing') { this.rejectRequest(msg); return; } - const supported = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(msg.method); - const reviewable = (msg.params.command == null || (typeof msg.params.command === 'string' && msg.params.command.length <= 12000)) + // File changes need a complete patch/scope view. Until that exists, leave + // these requests to the original Codex client instead of approving blindly. + const supported = msg.method === 'item/commandExecution/requestApproval'; + const commandFields = ['threadId', 'turnId', 'itemId', 'approvalId', 'command', 'reason', 'cwd', 'commandActions', 'kind', 'startedAtMs']; + // CLI 0.153.2 permits null optional scope fields. A non-null scope needs + // its own review UI; unknown fields still fail closed, even when null. + const absentScopeFields = ['additionalPermissions', 'availableDecisions', 'environmentId', 'networkApprovalContext', 'proposedExecpolicyAmendment', 'proposedNetworkPolicyAmendments']; + const reviewable = typeof msg.params.command === 'string' && !!msg.params.command.trim() && msg.params.command.length <= 12000 + && Object.keys(msg.params).every(key => commandFields.includes(key) || (absentScopeFields.includes(key) && msg.params[key] === null)) + && (msg.params.kind === undefined || msg.params.kind === 'command') + && (msg.params.startedAtMs === undefined || Number.isSafeInteger(msg.params.startedAtMs)) && (msg.params.reason == null || (typeof msg.params.reason === 'string' && msg.params.reason.length <= 2000)) && (msg.params.cwd == null || typeof msg.params.cwd === 'string'); if (supported && reviewable) { @@ -448,7 +457,7 @@ export class Manager extends EventEmitter { } else { this.rejectRequest(msg); this.cancelApprovals(task); - task.status = 'pausing'; task.activity = reviewable ? '需要用户输入,请在 Codex 中处理后继续' : '授权内容超出可审阅范围,已拒绝,请在原 Codex 会话核对后继续'; + task.status = 'pausing'; task.activity = msg.method === 'item/fileChange/requestApproval' ? '文件修改范围无法完整审阅,已拒绝;请在原 Codex 中核对后手动继续' : reviewable ? '需要用户输入,请在 Codex 中处理后继续' : '授权内容超出可审阅范围,已拒绝,请在原 Codex 会话核对后继续'; if (task.turnId) void this.safelyAsync(() => this.interruptTask(task)); } this.changed(); @@ -471,6 +480,7 @@ export class Manager extends EventEmitter { if (this.persistence.status !== 'ok') throw new Error('请先恢复数据保存,再处理授权'); const request = this.approvals.get(String(id)); if (!request || typeof token !== 'string' || token !== request.detailToken || !['accept', 'decline'].includes(decision)) throw new Error('授权请求已过期或决定无效'); + if (request.method !== 'item/commandExecution/requestApproval' || typeof request.command !== 'string' || !request.command.trim()) throw new Error('授权内容无法完整审阅,请在原 Codex 中处理'); const task = this.state.tasks.find(t => t.id === request.taskId); if (this.stopped || !this.rpc.ready || !task || task.status !== 'approval' || !this.matchesTurn(task, request)) throw new Error('授权所属轮次已失效或正在暂停'); // Confirm storage before granting an operation permission. diff --git a/tests/access.test.js b/tests/access.test.js index f5e0560..212849b 100644 --- a/tests/access.test.js +++ b/tests/access.test.js @@ -21,15 +21,16 @@ test('credentials are random, persistent, mode-separated and malformed files fai assert.throws(() => access.createToken(dir), /凭据文件无效/); assert.equal(readFileSync(file, 'utf8'), 'damaged'); }); -test('browser bootstrap erases the secret fragment and stores only within its origin session', () => { +test('retired browser bootstrap discards legacy credentials and never authorizes requests', () => { const token = 'a'.repeat(64), saved = new Map(); let replaced; const source = readFileSync(new URL('../public/access.js', import.meta.url), 'utf8').replace(/^export /gm, '') + '\nthis.headers = accessHeaders();'; - const context = { window: {}, location: { hash: '#access=' + token, pathname: '/', search: '' }, history: { replaceState(_state, _title, url) { replaced = url; } }, sessionStorage: { setItem: (key, value) => saved.set(key, value), getItem: key => saved.get(key) }, URLSearchParams }; - runInNewContext(source, context); assert.equal(replaced, '/'); assert.equal(context.headers.Authorization, 'Bearer ' + token); - assert.equal(saved.size, 1); + saved.set('continuer-access', token); + const context = { window: {}, location: { hash: '#access=' + token, pathname: '/', search: '' }, history: { replaceState(_state, _title, url) { replaced = url; } }, sessionStorage: { removeItem: key => saved.delete(key), getItem: key => saved.get(key) }, URLSearchParams }; + runInNewContext(source, context); assert.equal(replaced, '/'); assert.equal(context.headers.Authorization, undefined); + assert.equal(saved.size, 0); }); -test('authenticated streams send header credentials, decode split UTF-8 and stop after close', async t => { +test('browser streams do not send legacy credentials, decode split UTF-8 and stop after close', async t => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: { getItem: () => 'b'.repeat(64) } }); t.after(() => { if (descriptor) Object.defineProperty(globalThis, 'sessionStorage', descriptor); else delete globalThis.sessionStorage; }); @@ -40,7 +41,7 @@ test('authenticated streams send header credentials, decode split UTF-8 and stop const bytes = new TextEncoder().encode('data: {"text":"中文😀"}\n\ndata: {"stale":true}\n\n'); for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); await received; assert.deepEqual(messages, [{ text: '中文😀' }]); - assert.equal(options.headers.Authorization, 'Bearer ' + 'b'.repeat(64)); assert.equal(options.credentials, 'omit'); assert.equal(options.redirect, 'error'); assert.equal(options.signal.aborted, true); + assert.equal(options.headers.Authorization, undefined); assert.equal(options.credentials, 'omit'); assert.equal(options.redirect, 'error'); assert.equal(options.signal.aborted, true); }); test('unauthorized streams do not reconnect, and oversized frames are rejected', async () => { diff --git a/tests/fixtures/local-fetch.js b/tests/fixtures/local-fetch.js new file mode 100644 index 0000000..a9a477f --- /dev/null +++ b/tests/fixtures/local-fetch.js @@ -0,0 +1,4 @@ +import localTls from '../../server/local-tls.cjs'; +export function pinnedFetch(directory, demo = false) { + return (url, options = {}) => localTls.fetchLocal(url, { signal: AbortSignal.timeout(5000), ...options }, localTls.requestOptions(directory, demo)); +} diff --git a/tests/import-http.test.js b/tests/import-http.test.js index 830aecc..0c400d6 100644 --- a/tests/import-http.test.js +++ b/tests/import-http.test.js @@ -9,6 +9,7 @@ import { dirname, join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { dispatch } from '../plugin/tools.js'; import access from '../server/access.cjs'; +import { pinnedFetch } from './fixtures/local-fetch.js'; test('HTTP and MCP imports share one reservation and persist one recovery delivery', { timeout: 20000 }, async () => { const temporaryRoot = realpathSync(tmpdir()); @@ -34,7 +35,7 @@ test('HTTP and MCP imports share one reservation and persist one recovery delive }); const exited = once(child, 'exit'); let stderr = ''; child.stderr.on('data', data => { stderr += data; }); - const origin = `http://127.0.0.1:${port}`; + const origin = `https://127.0.0.1:${port}`, fetch = pinnedFetch(dataDir, true); async function request(path, body) { const response = await fetch(origin + path, { signal: AbortSignal.timeout(3000), headers: { Authorization: 'Bearer ' + access.readToken(dataDir, true) }, diff --git a/tests/local-tls.test.js b/tests/local-tls.test.js new file mode 100644 index 0000000..51523c3 --- /dev/null +++ b/tests/local-tls.test.js @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer as tcpServer } from 'node:net'; +import { createServer } from 'node:https'; +import { mkdtempSync, realpathSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import localTls from '../server/local-tls.cjs'; +import access from '../server/access.cjs'; + +function temporary(t) { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-pinned-tls-')); + t.after(() => { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); }); + return directory; +} +async function identity(t, demo = false) { + const directory = temporary(t), token = access.createToken(directory, demo); + const options = await localTls.serverOptions(directory, demo, token); + return { directory, token, options, client: localTls.requestOptions(directory, demo) }; +} +async function listen(t, server) { + server.on('tlsClientError', () => {}); + const sockets = new Set(); server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)); }); + return `https://127.0.0.1:${server.address().port}`; +} + +test('plaintext port squatters never receive the access token or private request body', async t => { + const f = await identity(t); let received = Buffer.alloc(0); + const origin = await listen(t, tcpServer(socket => socket.on('data', bytes => { received = Buffer.concat([received, bytes]); socket.destroy(); }))); + await assert.rejects(localTls.fetchLocal(origin + '/api/tasks', { method: 'POST', headers: { Authorization: 'Bearer ' + f.token }, body: 'private fixture objective', signal: AbortSignal.timeout(2000) }, f.client)); + assert.equal(received.includes(Buffer.from(f.token)), false); + assert.equal(received.includes(Buffer.from('private fixture objective')), false); + assert.ok(received.length > 0, 'listener observed only the TLS handshake'); +}); + +test('wrong TLS identity is rejected before authenticated HTTP; the pinned service still works', async t => { + const f = await identity(t), attacker = await identity(t); let requests = 0; + const fake = await listen(t, createServer(attacker.options, (_req,res) => { requests++; res.end('{}'); })); + await assert.rejects(localTls.fetchLocal(fake + '/api/state', { headers: { Authorization: 'Bearer ' + f.token } }, f.client)); + assert.equal(requests, 0); + const real = await listen(t, createServer(f.options, (req,res) => { assert.equal(req.headers.authorization, 'Bearer ' + f.token); res.end('{"ok":true}'); })); + assert.deepEqual(await (await localTls.fetchLocal(real + '/api/state', { headers: { Authorization: 'Bearer ' + f.token } }, f.client)).json(), { ok: true }); + const pinned = localTls.readIdentity(f.directory).certificate; + assert.equal(localTls.matchesCertificate(pinned, pinned), true); + assert.equal(localTls.matchesCertificate(localTls.readIdentity(attacker.directory).certificate, pinned), false); + assert.equal(localTls.matchesCertificate('invalid', pinned), false); +}); + +test('TLS identities persist, separate demo/live and refuse corruption without replacement', async t => { + const f = await identity(t), file = localTls.identityPath(f.directory), original = readFileSync(file); + await localTls.serverOptions(f.directory, false, f.token); assert.deepEqual(readFileSync(file), original); + assert.equal(localTls.readIdentity(f.directory, true), null); + const demoToken = access.createToken(f.directory, true); await localTls.serverOptions(f.directory, true, demoToken); + assert.notEqual(localTls.readIdentity(f.directory).certificate, localTls.readIdentity(f.directory, true).certificate); + writeFileSync(file, '{damaged'); + await assert.rejects(localTls.serverOptions(f.directory, false, f.token)); + assert.equal(readFileSync(file, 'utf8'), '{damaged'); +}); + +test('pinned transport refuses redirects, truncation, oversized responses and nonlocal schemes', async t => { + const f = await identity(t); + const origin = await listen(t, createServer(f.options, (req,res) => { + if (req.url === '/redirect') { res.writeHead(302, {Location:'https://example.invalid/'}); res.end(); } + else if (req.url === '/truncated') { res.writeHead(200, {'Content-Length':100}); res.end('{}'); } + else res.end('x'.repeat(4 * 1024 * 1024 + 1)); + })); + await assert.rejects(localTls.fetchLocal(origin + '/redirect', {}, f.client), /重定向/); + await assert.rejects(localTls.fetchLocal(origin + '/truncated', { signal: AbortSignal.timeout(2000) }, f.client)); + await assert.rejects(localTls.fetchLocal(origin + '/oversized', {}, f.client), /长度/); + await assert.rejects(localTls.fetchLocal('http://127.0.0.1:4317/', {}, f.client), /destination/); + await assert.rejects(localTls.fetchLocal('https://example.invalid/', {}, f.client), /destination/); +}); + +test('retired browser URL command emits no credential and never starts a service', async t => { + const directory = temporary(t), token = access.createToken(directory); + try { + await promisify(execFile)(process.execPath, ['plugin/control.js', 'url'], { env: { ...process.env, CONTINUER_DATA_DIR: directory }, windowsHide: true }); + assert.fail('Legacy URL command should fail closed'); + } catch (error) { + assert.match(error.stderr, /浏览器凭据链接已停用/); + assert.equal((error.stdout + error.stderr).includes(token), false); + assert.equal(localTls.readIdentity(directory), null); + } +}); diff --git a/tests/public-privacy.test.js b/tests/public-privacy.test.js new file mode 100644 index 0000000..2f8b497 --- /dev/null +++ b/tests/public-privacy.test.js @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { publicFiles } from '../scripts/source-files.js'; +import { checkPublic } from '../scripts/check-public.js'; + +test('public privacy guard rejects live identifiers in every exported text surface', t => { + const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-public-privacy-')); + t.after(() => { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); }); + for (const name of publicFiles()) { const target = join(directory, name); mkdirSync(dirname(target), { recursive: true }); copyFileSync(name, target); } + assert.ok(checkPublic(directory).files > 100); + for (const name of ['server/tls-live.json', 'plugin/access-demo.token', 'public/runtime.json', 'tests/.env.local', 'desktop/private.pfx']) { + const file = join(directory, name); writeFileSync(file, 'synthetic-private-data'); + assert.throws(() => checkPublic(directory), error => error.message.includes(name + ': private runtime file')); + rmSync(file); + } + // Deliberately assembled so the regression source never embeds a live ID. + const candidate = ['01abcdef', '1111', '7111', '8111', '111111111111'].join('-'); + for (const name of ['tests/quota-wake.test.js', 'scripts/check.js', 'mcp.json', 'CONTRIBUTING.md', '.github/workflows/windows.yml']) { + const file = join(directory, name), original = readFileSync(file); + writeFileSync(file, original.toString() + '\n' + candidate); + assert.throws(() => checkPublic(directory), error => error.message.includes(name + ': live conversation identifier') && !error.message.includes(candidate)); + writeFileSync(file, original); + } + assert.ok(checkPublic(directory).files > 100); +}); diff --git a/tests/quota-wake.test.js b/tests/quota-wake.test.js index 4e13dd5..a030db1 100644 --- a/tests/quota-wake.test.js +++ b/tests/quota-wake.test.js @@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events'; import { Manager } from '../server/manager.js'; import { QueueSender } from '../server/queue-sender.js'; -const threadId = '01a16385-26c1-79e3-a661-09e3fc8ed62e'; +const threadId = '11111111-1111-4111-8111-111111111111'; const sample = (used, secondary = 10) => ({ rateLimits: { primary: { usedPercent: used }, secondary: { usedPercent: secondary } } }); async function setup(saved, send) { const rpc = new EventEmitter(); rpc.ready = true; rpc.calls = []; diff --git a/tests/reliability.test.js b/tests/reliability.test.js index 68c1f85..d4e89da 100644 --- a/tests/reliability.test.js +++ b/tests/reliability.test.js @@ -81,7 +81,7 @@ test('approval and outbox side effects cannot cross a failed durable save', () = const { m, rpc } = manager({ load: () => null, save: () => { if (fail) throw Error('disk fixture'); } }); const task = m.create({ name: 'Fixture', cwd: 'E:\\fixture', prompt: 'Goal' }); Object.assign(task, { status: 'approval', threadId: 'thread', turnId: 'turn' }); - m.approvals.set('1', { id: 1, detailToken: 'fixture', taskId: task.id, threadId: task.threadId, turnId: task.turnId }); fail = true; + m.approvals.set('1', { id: 1, detailToken: 'fixture', taskId: task.id, threadId: task.threadId, turnId: task.turnId, method: 'item/commandExecution/requestApproval', command: 'echo fixture' }); fail = true; assert.throws(() => m.approve(1, 'accept', 'fixture'), /disk fixture/); assert.deepEqual(rpc.sent, []); fail = false; m.recoverStorage('retry'); m.stop(); }); @@ -204,10 +204,11 @@ test('portable configuration uses stable user data even when a legacy data folde test('runtime refuses an incompatible running build before attempting to launch anything', t => { const env = { ...process.env, CONTINUER_DATA_DIR: temporary(t) }; - const script = `globalThis.fetch=async()=>({ok:true,json:async()=>({app:'codex-continuer',version:'0.3.3',protocolVersion:1,buildId:'obsolete'})}); const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)}); try{await runtime.ensureService();process.exitCode=1;}catch(error){console.log(error.message);}`; + const transport = `const localTls=(await import(${JSON.stringify(new URL('../server/local-tls.cjs', import.meta.url).href)})).default;localTls.requestOptions=()=>({});`; + const script = transport + `localTls.fetchLocal=async()=>({ok:true,json:async()=>({app:'codex-continuer',version:'0.3.3',protocolVersion:1,buildId:'obsolete'})}); const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)}); try{await runtime.ensureService();process.exitCode=1;}catch(error){console.log(error.message);}`; const output = execFileSync(process.execPath, ['--input-type=module', '--eval', script], { env, windowsHide: true, encoding: 'utf8' }); assert.match(output, /构建不一致/); const configUrl = JSON.stringify(new URL('../server/config.cjs', import.meta.url).href); - const wrongDirectory = `const config=(await import(${configUrl})).default; globalThis.fetch=async()=>({ok:true,json:async()=>({...config.appInfo(),diagnostics:{dataDir:'wrong-directory'}})}); const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)}); try{await runtime.ensureService();process.exitCode=1;}catch(error){console.log(error.message);}`; + const wrongDirectory = transport + `const config=(await import(${configUrl})).default; localTls.fetchLocal=async()=>({ok:true,json:async()=>({...config.appInfo(),diagnostics:{dataDir:'wrong-directory'}})}); const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)}); try{await runtime.ensureService();process.exitCode=1;}catch(error){console.log(error.message);}`; assert.match(execFileSync(process.execPath, ['--input-type=module', '--eval', wrongDirectory], { env, windowsHide: true, encoding: 'utf8' }), /不同的数据目录/); }); diff --git a/tests/round3.test.js b/tests/round3.test.js index 9b5fff4..fe8e423 100644 --- a/tests/round3.test.js +++ b/tests/round3.test.js @@ -103,6 +103,48 @@ for (const command of ['x'.repeat(12001), { hidden: 'not a reviewable command' } assert.equal(task.status, 'pausing'); assert.equal(f.rpc.calls.some(call => call.method === 'turn/interrupt'), true); }); +test('file-change approvals fail closed without presenting a misleading allow button', async t => { + const f = fixture(t), task = f.manager.create(input); await f.manager.tick(); + f.manager.request({ id: 55, method: 'item/fileChange/requestApproval', params: { threadId: task.threadId, turnId: task.turnId, itemId: 'change', reason: 'fixture reason', grantRoot: 'E:\\outside' } }); + assert.equal(f.manager.approvals.size, 0); assert.equal(f.rpc.sent[0].error.code, -32601); + assert.equal(task.status, 'pausing'); assert.match(task.activity, /文件修改范围/); + assert.equal(f.rpc.calls.some(call => call.method === 'turn/interrupt'), true); + assert.throws(() => f.manager.approve(55, 'accept', 'guess'), /过期/); +}); + +test('commands without visible text or with undisplayed permission fields are rejected', async t => { + for (const extras of [{ command: '' }, { command: ' ' }, { command: null }, ...[ + { additionalPermissions: { network: true } }, { proposedExecpolicyAmendment: ['all'] }, { proposedNetworkPolicyAmendments: [] }, + { environmentId: 'remote' }, { networkApprovalContext: {} }, { availableDecisions: ['acceptForSession'] }, { kind: 'stdin' }, { kind: null }, { unknownScope: null }, + ].map(scope => ({ command: 'fixture', ...scope }))]) { + const f = fixture(t), task = f.manager.create(input); await f.manager.tick(); + f.manager.request({ id: 56, method: 'item/commandExecution/requestApproval', params: { threadId: task.threadId, turnId: task.turnId, ...extras } }); + assert.equal(f.manager.approvals.size, 0); assert.equal(f.rpc.sent[0].error.code, -32601); + } +}); + +test('baseline command approvals preserve null optional scopes and review metadata', async t => { + const f = fixture(t), task = f.manager.create(input); await f.manager.tick(); + f.manager.request({ id: 58, method: 'item/commandExecution/requestApproval', params: { + threadId: task.threadId, turnId: task.turnId, itemId: 'command', approvalId: null, + command: 'echo fixture', cwd: input.cwd, reason: null, commandActions: null, kind: 'command', startedAtMs: 1000, + additionalPermissions: null, availableDecisions: null, environmentId: null, networkApprovalContext: null, + proposedExecpolicyAmendment: null, proposedNetworkPolicyAmendments: null, + } }); + const approval = f.manager.approvals.get('58'); assert.ok(approval); + assert.equal(approval.command, 'echo fixture'); assert.equal(approval.cwd, input.cwd); + f.manager.approve(58, 'accept', approval.detailToken); + assert.equal(f.rpc.sent.at(-1).result.decision, 'accept'); +}); + +test('approve also rejects injected incomplete file-change records at its final boundary', async t => { + const f = fixture(t), task = f.manager.create(input); await f.manager.tick(); + f.manager.approvals.set('57', { id: 57, detailToken: 'fixture', method: 'item/fileChange/requestApproval', taskId: task.id, threadId: task.threadId, turnId: task.turnId }); + task.status = 'approval'; + assert.throws(() => f.manager.approve(57, 'accept', 'fixture'), /完整审阅/); + assert.equal(f.rpc.sent.some(message => message.result?.decision === 'accept'), false); +}); + test('a late interrupt failure cannot block a newer turn', async t => { const f = fixture(t), task = f.manager.create(input); await f.manager.tick(); const oldTurn = task.turnId, original = f.rpc.call, held = deferred(); f.rpc.call = (method, params) => method === 'turn/interrupt' ? held.promise : original(method, params); @@ -126,7 +168,8 @@ test('cold-start handshake refuses a matching build from another data directory' const code = `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {EventEmitter} from 'node:events'; cp.spawn=()=>Object.assign(new EventEmitter(),{unref(){}});syncBuiltinESMExports(); const config=(await import(${JSON.stringify(config)})).default; let calls=0; - globalThis.fetch=async()=>{if(!calls++)throw Object.assign(new Error('not running'),{cause:{code:'ECONNREFUSED'}});return {ok:true,json:async()=>({...config.appInfo(),diagnostics:{dataDir:'wrong-directory'}})}}; + const localTls=(await import(${JSON.stringify(new URL('../server/local-tls.cjs', import.meta.url).href)})).default;localTls.requestOptions=()=>({}); + localTls.fetchLocal=async()=>{if(!calls++)throw Object.assign(new Error('not running'),{cause:{code:'ECONNREFUSED'}});return {ok:true,json:async()=>({...config.appInfo(),diagnostics:{dataDir:'wrong-directory'}})}}; try {await (await import(${JSON.stringify(runtime)})).ensureService();console.log(JSON.stringify({accepted:true}));}catch(error){console.log(JSON.stringify({accepted:false,error:error.message}));}`; const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', code], { windowsHide: true, env: { ...process.env, CONTINUER_DATA_DIR: directory }, encoding: 'utf8', timeout: 12000 })); assert.equal(result.accepted, false); assert.match(result.error, /不同的数据目录/); diff --git a/tests/service-integration.test.js b/tests/service-integration.test.js index f9cfe54..7cdd813 100644 --- a/tests/service-integration.test.js +++ b/tests/service-integration.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { promisify } from 'node:util'; import { execFile } from 'node:child_process'; import { createServer } from 'node:net'; -import { request as httpRequest } from 'node:http'; +import { request as httpRequest } from 'node:https'; import { mkdtempSync, realpathSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -12,13 +12,15 @@ import { setTimeout as delay } from 'node:timers/promises'; import { Store } from '../server/store.js'; import { alive, diagnostic } from '../server/health.js'; import access from '../server/access.cjs'; +import localTls from '../server/local-tls.cjs'; +import { pinnedFetch } from './fixtures/local-fetch.js'; const execute = promisify(execFile); async function service(t, { corrupt = false } = {}) { const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-cold-start-')); const probe = createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); - const origin = `http://127.0.0.1:${port}`; + const origin = `https://127.0.0.1:${port}`, fetch = pinnedFetch(directory); const preload = join(directory, 'mock-channels.mjs'), callsFile = join(directory, 'calls.jsonl'); // This preload is inherited by the launched service. Neither real Codex nor // real queue binaries can be reached even if the scheduling assertion fails. @@ -69,11 +71,11 @@ async function service(t, { corrupt = false } = {}) { return JSON.parse(result.stdout); }; const calls = () => existsSync(callsFile) ? readFileSync(callsFile, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line).method) : []; - return { directory, file, origin, env, request, status, calls }; + return { directory, file, origin, env, request, status, calls, fetch }; } test('real MCP cold startup remains read-only until explicit activation; summaries, details and assets work over HTTP', { timeout: 25000 }, async t => { - const f = await service(t), read = await f.status(); + const f = await service(t), read = await f.status(), fetch = f.fetch; assert.equal(read.observationOnly, true); assert.equal(read.settings.enabled, false); await f.status('continuer_list_sessions'); await f.request('/api/refresh', {}); assert.equal(f.calls().includes('turn/start'), false); assert.equal(f.calls().includes('fixture/queue'), false); @@ -93,7 +95,7 @@ test('real MCP cold startup remains read-only until explicit activation; summari assert.ok(visited.has('/view-lifecycle.js')); const script = await fetch(f.origin + '/app.js'), etag = script.headers.get('etag'); await script.text(); assert.ok(etag); const unchanged = await fetch(f.origin + '/app.js', { headers: { 'If-None-Match': etag } }); assert.equal(unchanged.status, 304); assert.equal(await unchanged.text(), ''); - const health = await f.request('/api/health'); assert.equal(health.buildId, snapshot.buildId); assert.equal(health.protocolVersion, 3); + const health = await f.request('/api/health'); assert.equal(health.buildId, snapshot.buildId); assert.equal(health.protocolVersion, 4); assert.equal(health.resources.taskLimit, 100); assert.equal(health.dataDir, f.directory); // A second writer cannot touch the same state, even before binding its port. await assert.rejects(execute(process.execPath, ['server/index.js'], { env: f.env, timeout: 5000, windowsHide: true })); @@ -114,7 +116,7 @@ test('real MCP cold startup remains read-only until explicit activation; summari }); test('all API routes reject absent, wrong, cookie and query credentials before any work', { timeout: 25000 }, async t => { - const f = await service(t); await f.status(); + const f = await service(t); await f.status(); const fetch = f.fetch; for (const path of ['/api/state', '/api/health', '/api/threads', '/api/events', '/api/logs', '/api/tasks/direct', '/api/approvals/88', '/x/../api/state', '/api/unknown']) { for (const headers of [{}, { Authorization: 'Bearer ' + '0'.repeat(64) }, { Cookie: 'access=' + access.readToken(f.directory) }]) { const response = await fetch(f.origin + path, { headers }); assert.equal(response.status, 401, path); assert.doesNotMatch(await response.text(), /Private execution goal|Private monitoring note/); @@ -133,7 +135,7 @@ test('real HTTP requests preserve UTF-8 codepoints split across writes', { timeo const input = { name: '分片中文任务😀', cwd: f.directory, prompt: '核对中文😀验收标准', continuous: false }; const bytes = Buffer.from(JSON.stringify(input)), split = bytes.indexOf(Buffer.from('核')) + 1; const result = await new Promise((resolve, reject) => { - const req = httpRequest(f.origin + '/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Continuer-Client': '1', 'Content-Length': bytes.length, Authorization: 'Bearer ' + access.readToken(f.directory) } }, res => { + const req = httpRequest(f.origin + '/api/tasks', { ...localTls.requestOptions(f.directory), method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Continuer-Client': '1', 'Content-Length': bytes.length, Authorization: 'Bearer ' + access.readToken(f.directory) } }, res => { res.setEncoding('utf8'); let content = ''; res.on('data', chunk => { content += chunk; }); res.on('end', () => { try { assert.equal(res.statusCode, 200); resolve(JSON.parse(content)); } catch (error) { reject(error); } }); }); diff --git a/tests/shutdown.test.js b/tests/shutdown.test.js index 52f75b6..a97e346 100644 --- a/tests/shutdown.test.js +++ b/tests/shutdown.test.js @@ -48,7 +48,7 @@ test('demo exit leaves live markers and the startup watcher alone', async t => { test('passive reads and automatic opening do not restart an exited app; manual opening clears the latch', t => { const dataDir = directory(t); exitState.mark(dataDir); - const code = `let calls=0;globalThis.fetch=async()=>{calls++;throw Error('fixture network failure')};const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)});let blocked=0;for(const action of [()=>runtime.ensureService(),()=>runtime.openWidget({automatic:true})])try{await action()}catch(error){if(error.message.includes('已退出'))blocked++}const before=calls;try{await runtime.openWidget()}catch{};console.log(JSON.stringify({blocked,before,after:calls}));`; + const code = `const localTls=(await import(${JSON.stringify(new URL('../server/local-tls.cjs', import.meta.url).href)})).default;localTls.requestOptions=()=>({});let calls=0;localTls.fetchLocal=async()=>{calls++;throw Error('fixture network failure')};const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)});let blocked=0;for(const action of [()=>runtime.ensureService(),()=>runtime.openWidget({automatic:true})])try{await action()}catch(error){if(error.message.includes('已退出'))blocked++}const before=calls;try{await runtime.openWidget()}catch{};console.log(JSON.stringify({blocked,before,after:calls}));`; const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', code], { env: { ...process.env, CONTINUER_DATA_DIR: dataDir, CONTINUER_DEMO: '' }, windowsHide: true, encoding: 'utf8' })); assert.deepEqual(result, { blocked: 2, before: 0, after: 1 }); assert.equal(exitState.requested(dataDir), false); }); @@ -65,7 +65,7 @@ test('verified force cleanup terminates an owned service tree and refuses unrela const execute = promisify(execFile); for (let i = 0; i < 40; i++) { try { childPid = JSON.parse(readFileSync(childFile, 'utf8')).pid; break; } catch { await delay(50); } } assert.ok(childPid); - await assert.rejects(execute('powershell.exe', args(process.pid), { windowsHide: true, timeout: 5000 }), /Refusing to stop/); + await assert.rejects(execute('powershell.exe', args(process.pid), { windowsHide: true, timeout: 10000 }), /Refusing to stop/); assert.equal(alive(process.pid), true); await execute('powershell.exe', args(server.pid), { windowsHide: true, timeout: 10000 }); await exited; for (let i = 0; i < 30 && alive(childPid); i++) await delay(50); diff --git a/tests/widget-smoke.test.js b/tests/widget-smoke.test.js index 5f769d0..f200e20 100644 --- a/tests/widget-smoke.test.js +++ b/tests/widget-smoke.test.js @@ -9,24 +9,26 @@ import { once } from 'node:events'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import access from '../server/access.cjs'; +import { pinnedFetch } from './fixtures/local-fetch.js'; const executable = fileURLToPath(new URL('../node_modules/electron/dist/electron.exe', import.meta.url)); -for (const expanded of [false, true]) test(`hidden isolated widget is ready without loading unused views (expanded: ${expanded})`, { skip: process.platform !== 'win32' || !existsSync(executable), timeout: 25000 }, async () => { +for (const expanded of [false, true]) test(`hidden isolated widget is ready without loading unused views (expanded: ${expanded})`, { skip: process.platform !== 'win32' || !existsSync(executable), timeout: 40000 }, async () => { const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-widget-smoke-')); writeFileSync(join(directory, 'widget-preferences.json'), JSON.stringify({ follow: false, hidden: true, expanded })); const trace = join(directory, 'requests.jsonl'), preload = join(directory, 'trace.mjs'); - writeFileSync(preload, `import http from 'node:http';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs'; - const original=http.createServer;http.createServer=listener=>original((req,res)=>{appendFileSync(${JSON.stringify(trace)},JSON.stringify(req.url)+'\\n');res.on('finish',()=>appendFileSync(${JSON.stringify(trace + '.responses')},JSON.stringify({path:req.url,status:res.statusCode,authenticated:!!req.headers.authorization})+'\\n'));return listener(req,res)});syncBuiltinESMExports();`); + writeFileSync(preload, `import http from 'node:https';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs'; + const original=http.createServer;http.createServer=(options,listener)=>original(options,(req,res)=>{appendFileSync(${JSON.stringify(trace)},JSON.stringify(req.url)+'\\n');res.on('finish',()=>appendFileSync(${JSON.stringify(trace + '.responses')},JSON.stringify({path:req.url,status:res.statusCode,authenticated:!!req.headers.authorization})+'\\n'));return listener(req,res)});syncBuiltinESMExports();`); const probe = createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); const env = { ...process.env, CONTINUER_DATA_DIR: directory, CONTINUER_DEMO: '1', CONTINUER_PORT: String(port), PORT: String(port), ELECTRON_RUN_AS_NODE: undefined }; const server = spawn(process.execPath, ['server/index.js', '--demo', '--observe-only'], { env: { ...env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, windowsHide: true, stdio: 'ignore' }); - const authenticatedFetch = (url, options = {}) => fetch(url, { ...options, headers: { ...options.headers, Authorization: 'Bearer ' + access.readToken(directory, true) } }); + const secureFetch = pinnedFetch(directory, true); + const authenticatedFetch = (url, options = {}) => secureFetch(url, { ...options, headers: { ...options.headers, Authorization: 'Bearer ' + access.readToken(directory, true) } }); let widget; try { let running = false; - for (let i = 0; i < 80; i++) { - try { const response = await authenticatedFetch(`http://127.0.0.1:${port}/api/health`); running = response.ok; if (running) break; } catch {} + for (let i = 0; i < 300; i++) { + try { const response = await authenticatedFetch(`https://127.0.0.1:${port}/api/health`); running = response.ok; if (running) break; } catch {} await delay(50); } assert.equal(running, true); @@ -44,10 +46,10 @@ for (const expanded of [false, true]) test(`hidden isolated widget is ready with const responses = existsSync(trace + '.responses') ? readFileSync(trace + '.responses', 'utf8').slice(-3000) : ''; assert.equal(status?.ready, true, `exit=${widget.exitCode}; ${diagnostic}; ${nativeErrors}; responses=${responses}`); assert.equal(status.visible, false); assert.equal(status.hidden, true); assert.equal(status.follow, false); assert.equal(status.buildId.length, 20); - const health = await (await authenticatedFetch(`http://127.0.0.1:${port}/api/health`)).json(); + const health = await (await authenticatedFetch(`https://127.0.0.1:${port}/api/health`)).json(); assert.equal(health.widget.ready, true); assert.equal(health.observationOnly, true); let clients = health.resources.sseClients; - for (let i = 0; clients && i < 25; i++) { await delay(50); clients = (await (await authenticatedFetch(`http://127.0.0.1:${port}/api/health`)).json()).resources.sseClients; } + for (let i = 0; clients && i < 25; i++) { await delay(50); clients = (await (await authenticatedFetch(`https://127.0.0.1:${port}/api/health`)).json()).resources.sseClients; } assert.equal(clients, 0, 'Hidden widget must release its bootstrap stream'); const requests = readFileSync(trace, 'utf8').trim().split('\n').map(line => JSON.parse(line)); assert.equal(requests.includes('/api/threads'), false, 'Hidden widgets should not load session lists'); @@ -59,10 +61,10 @@ for (const expanded of [false, true]) test(`hidden isolated widget is ready with if (server.exitCode === null) await Promise.race([once(server, 'exit'), delay(3000).then(() => { if (server.exitCode === null) throw Error('Widget exit left its service running'); })]); assert.notEqual(server.exitCode, null); assert.equal(existsSync(join(directory, 'quit-demo.request')), true); } finally { - if (widget && widget.exitCode === null) widget.kill(); + if (widget && widget.exitCode === null) { const closed = once(widget, 'exit'); widget.kill(); await closed; } const exited = server.exitCode === null ? once(server, 'exit') : Promise.resolve(); - try { await authenticatedFetch(`http://127.0.0.1:${port}/api/shutdown`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }, body: '{}' }); } catch {} - await Promise.race([exited, delay(2000)]); if (server.exitCode === null) server.kill(); - await delay(200); assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); + try { await authenticatedFetch(`https://127.0.0.1:${port}/api/shutdown`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }, body: '{}' }); } catch {} + await Promise.race([exited, delay(2000)]); if (server.exitCode === null) { server.kill(); await exited; } + await delay(200); assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } }); From 64fe71b8c141e0abc2a4800bcebcdfd5ab01708e Mon Sep 17 00:00:00 2001 From: empty610 Date: Tue, 27 Oct 2026 13:42:39 +0800 Subject: [PATCH 4/8] fix: restrict MCP execution and harden local security boundaries --- .github/dependabot.yml | 12 ++++++ CHANGELOG.md | 3 ++ CONTRIBUTING.md | 4 ++ SECURITY.md | 8 ++++ desktop/main.cjs | 4 +- docs/operations.md | 2 +- docs/usage.md | 2 +- plugin/mcp-server.js | 2 +- plugin/tools.js | 10 ++--- public/approval-details.js | 2 +- scripts/check-public.js | 1 + server/http-body.js | 17 ++++---- server/http-policy.js | 22 ++++++++++ server/http-routes.js | 15 ++++--- server/index.js | 12 ++++-- server/manager.js | 69 ++++++++++++++++--------------- server/project-path.js | 17 ++++++++ tests/import-http.test.js | 2 +- tests/monitor-policy.test.js | 7 ++-- tests/public-privacy.test.js | 4 ++ tests/round2.test.js | 3 +- tests/security-boundaries.test.js | 61 +++++++++++++++++++++++++++ tests/service-integration.test.js | 13 ++++-- 23 files changed, 222 insertions(+), 70 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 server/http-policy.js create mode 100644 server/project-path.js create mode 100644 tests/security-boundaries.test.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04f4c02 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee10508..571462a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.4.1-preview — 开发中 +- MCP 限制为查询和收紧执行范围的操作;创建、恢复、开启及策略修改改由用户在挂件中操作。 +- 审批详情令牌移到 POST 正文;窗口增加重定向限制并关闭开发者工具,补齐 CSP、HTTP 错误展示和分组请求限速。 +- 新建和恢复执行统一拒绝 UNC/设备项目路径;公开检查增加本地 Bearer 明文识别,加入 Dependabot 更新计划。 - 本地连接改为固定服务证书的 HTTPS,防止端口冒用者在身份校验前收到访问令牌;协议版本为 4,停用浏览器凭据链接,启动脚本统一打开原生挂件。 - 文件变更、空白命令和含未展示权限字段的审批拒绝并暂停,保留完整命令的审批流程。 - 测试中的历史聊天标识改为合成标识;隐私检查覆盖全部导出文本,并拒绝导出运行凭据、私钥文件。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81155bd..daf73d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,4 +6,8 @@ 提交前检查日志、截图和 Git diff 是否包含本机路径、聊天 ID、凭据或任务内容。不要提交 data、缓存、运行时或分发包。素材与依赖必须记录来源、许可证和修改日期。贡献按 GPL-3.0-only 提交;第三方原件保留各自版权和许可声明。 +公开仓库 main 要求通过 PR 合入、通过 verify 检查并满足签名提交规则,禁止强推和删除;单人维护不强制第二位审批者。不要为绕过签名要求重写旧公开历史。可使用 GitHub 验证签名的网页合并流程,或维护者自行配置并登记签名密钥;自动化不会替维护者生成身份密钥。 + +Actions 仅允许所需组件且强制完整 SHA 固定。Dependabot 安全更新与 CodeQL 默认扫描在仓库设置中启用,版本更新计划位于 .github/dependabot.yml;该文件需进入默认分支后计划才生效。未来 Release 启用不可变发布,发布前完成所有附件并验证来源证明;本项目尚未提供 Windows Authenticode 签名,不能将普通 SHA-256 校验表当作发布者签名。 + 维护者在合并前确认测试、跨机器验收范围和发布说明;安全问题使用 SECURITY.md 指定渠道。 diff --git a/SECURITY.md b/SECURITY.md index 417f7b5..511d7e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,6 +12,14 @@ HTTPS 服务只绑定 127.0.0.1。MCP、启停入口和原生挂件先校验数 挂件仅允许审阅完整命令的命令执行审批。文件变更审批、空命令以及含未展示权限字段的请求会拒绝并暂停,需用户在原 Codex 中检查、处理;不会用缺少补丁内容的“允许”按钮授权文件写入。 +MCP 工具不能创建、恢复、开启持续工作/监测或开启调度,也不能更改执行上限与额度策略。这些授权必须在挂件中手动操作;MCP 保留查询、暂停、结束、暂停导入和关闭调度。该边界约束 Continuer 的工具接口,不防御已拥有本机命令执行能力或能读取数据目录的其他工具。 + +审批详情令牌使用 POST 正文传输,不放入 URL。未知内部 HTTP 异常返回固定提示;明确的输入校验错误保留可读说明。API 使用独立的未认证、读取、写入请求预算,耗尽返回 429,不能据此保证抵御本机进程的持续资源耗尽攻击。窗口拒绝离开固定挂件页面的导航及重定向,禁用开发者工具;CSP 禁止 base、表单提交和插件对象。 + +新建任务在访问文件系统前拒绝 UNC、设备命名空间、盘符相对路径;恢复的直接执行任务在调用 Codex 前再次检查。只接受本地盘符绝对路径。字符串检查不识别已有映射网络盘或本地重解析点的最终目标,请使用自己信任的项目目录。 + +任务名、活动、错误及日志可能包含私人内容。Windows 下文件保护依赖继承的 ACL,不能把 Node 的 0600 当作额外用户隔离;不要共享数据目录。当前凭据轮换使用上文的停机重置步骤,没有在线自动轮换入口。 + 仓库已启用 GitHub Private vulnerability reporting,请通过 [私密漏洞报告](https://github.com/empty610/codex-continuer/security/advisories/new) 或仓库 Security → Report a vulnerability 私下报告。请勿把可利用细节或私人日志放在公开 Issue。提供版本、最小复现和脱敏影响说明,不提供凭据或真实聊天数据。 这是独立社区工具。原聊天接续受账号额度及 Codex 自身审批、沙箱和执行端状态约束,不绕过额度或权限限制。 diff --git a/desktop/main.cjs b/desktop/main.cjs index 4e66038..f70b526 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -194,10 +194,10 @@ async function start() { const runtime = await import('../plugin/runtime.js'); await runtime.ensureService(); const display = screen.getPrimaryDisplay().workArea; - win = new BrowserWindow({ x: display.x + display.width - sizes.compact.width - 16, y: display.y + display.height - sizes.compact.height - 16, ...sizes.compact, show: false, alwaysOnTop: false, frame: false, thickFrame: false, roundedCorners: false, transparent: true, backgroundColor: '#00000000', resizable: false, skipTaskbar: true, hasShadow: false, autoHideMenuBar: true, title: 'Codex Continuer Widget', webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true } }); + win = new BrowserWindow({ x: display.x + display.width - sizes.compact.width - 16, y: display.y + display.height - sizes.compact.height - 16, ...sizes.compact, show: false, alwaysOnTop: false, frame: false, thickFrame: false, roundedCorners: false, transparent: true, backgroundColor: '#00000000', resizable: false, skipTaskbar: true, hasShadow: false, autoHideMenuBar: true, title: 'Codex Continuer Widget', webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, devTools: false } }); win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); win.webContents.on('console-message', details => { if (details.level === 'error') console.error('挂件页面错误:' + details.message.slice(0, 2000)); }); - win.webContents.on('will-navigate', (event, url) => { if (url !== origin + '/widget') event.preventDefault(); }); + for (const name of ['will-navigate', 'will-redirect']) win.webContents.on(name, (event, url) => { if (url !== origin + '/widget') event.preventDefault(); }); win.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); const pinned = localTls.readIdentity(dataDir, process.env.CONTINUER_DEMO === '1').certificate; win.webContents.session.setCertificateVerifyProc((request, callback) => { diff --git a/docs/operations.md b/docs/operations.md index aba88cc..451481d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -42,7 +42,7 @@ - 输出预览最多 16000 个 UTF-16 单元,结构化摘要最多 2000 个;完整内容在原 Codex 会话查看。 - `/api/state` 和 SSE 只传任务摘要,活动预览最多 240 个字符、日志预览最多 180 个;`GET /api/tasks/{id}` 按需读取完整活动、路径、目标、备注、计划和保留输出。详情只缓存一个选中任务,按任务自身内容变化失效;无关额度刷新不重新读取,迟到响应不会覆盖新选择。 -- 组合摘要预算为 768 KiB;需要时进一步精简历史及授权预览并显示说明,磁盘内容不变。长授权通过 `GET /api/approvals/{id}?token={detailToken}` 获取完整内容,在完整命令到达前页面禁用允许操作;过期令牌返回失效。 +- 组合摘要预算为 768 KiB;需要时进一步精简历史及授权预览并显示说明,磁盘内容不变。长授权通过 `POST /api/approvals/{id}`、正文 `{"token":"对应 detailToken"}` 获取完整内容,旧 GET 查询令牌入口已移除;在完整命令到达前页面禁用允许操作,过期令牌返回失效。 - 保存最近 300 条活动、240 个额度样本;普通快照带最近 50 条活动。日志导出使用 `/api/logs`,取最多 300 条。 - 最多新建/导入 100 条管理记录;导入提交前也检查上限。会话选择最多保留 300 项,审批最多 50 项,计划最多 100 步。 - 最多 16 个 SSE 连接。每个连接只允许一份未排出的状态,单帧最多 1 MiB;持续背压或 15 秒不排空的客户端断开并由页面重连,不积累历史帧。 diff --git a/docs/usage.md b/docs/usage.md index d2e6b71..889e09e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -59,7 +59,7 @@ cd .\codex-continuer 同一聊天同时只允许一个导入操作;挂件和 MCP 共用这项保护。重复请求会提示已添加或正在添加,不会创建第二条记录,也不会重置已有监测。读取失败后可以重新导入,不同聊天可以同时添加。 -插件工具:`continuer_open_widget`、`continuer_status`、`continuer_list_sessions`、`continuer_create_task`、`continuer_import_session`、`continuer_task_action`、`continuer_update_settings`。读状态不会创建任务,导入会话保持暂停,授权决定由用户在面板中操作。 +插件工具:`continuer_open_widget`、`continuer_status`、`continuer_list_sessions`、`continuer_import_session`、`continuer_task_action`、`continuer_update_settings`。MCP 仅可查询、打开挂件、暂停状态导入、暂停/结束任务、关闭持续工作和关闭调度。创建任务、恢复执行、开启监测以及修改执行上限/额度策略必须由用户在挂件中操作;旧的 create/resume/enabled=true 工具调用会被拒绝,模型提供“已确认”字段也不能绕过。 本机构建产物在 `dist/marketplace/plugins/codex-continuer`,runtime 保留本项目数据目录与 Node 路径。`npm run build:plugin -- --portable` 另生成 `dist/portable-marketplace`,不绑定本机绝对路径,默认使用目标电脑的 `%LOCALAPPDATA%\CodexContinuer`,需安装 Node。两种包都带 portable 插件清单和 CLI 兼容清单;白名单不包含任务历史、凭据和缓存。构建在暂存目录完成并验证 SHA-256 清单后才替换正式包,避免旧文件残留。 diff --git a/plugin/mcp-server.js b/plugin/mcp-server.js index a321d45..0321aca 100644 --- a/plugin/mcp-server.js +++ b/plugin/mcp-server.js @@ -15,7 +15,7 @@ input.on('line', async line => { if (message.id == null) return; try { let result; - if (message.method === 'initialize') result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'codex-continuer', version: config.version }, instructions: 'Continuer manages only explicitly created/imported tasks. Read status without starting tasks. Starting, resuming or enabling scheduling needs user intent. The widget is a native companion window that follows Codex, not a modification to Codex UI. Never approve a task permission request on the user’s behalf.' }; + if (message.method === 'initialize') result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'codex-continuer', version: config.version }, instructions: 'Continuer manages only explicitly created/imported tasks. Read status without starting tasks. Creating work, resuming, enabling monitoring or scheduling, and changing execution policy require the user to operate the native widget. MCP cannot authorize these actions. The widget is a native companion window that follows Codex, not a modification to Codex UI. Never approve a task permission request on the user’s behalf.' }; else if (message.method === 'ping') result = {}; else if (message.method === 'tools/list') result = { tools }; else if (message.method === 'tools/call') { diff --git a/plugin/tools.js b/plugin/tools.js index dd7d69d..bd59b5f 100644 --- a/plugin/tools.js +++ b/plugin/tools.js @@ -1,17 +1,15 @@ -import { MAX_POLL_SECONDS } from '../server/policy.js'; const object = properties => ({ type: 'object', properties, additionalProperties: false }); const text = { type: 'string', minLength: 1 }; -const taskInput = { ...object({ name: text, cwd: text, prompt: text, continuous: { type: 'boolean' } }), required: ['name', 'cwd', 'prompt', 'continuous'] }; export const tools = [ { name: 'continuer_open_widget', description: '打开跟随 Codex 窗口的 Continuer 额度和任务挂件。只显示界面,不新建或继续任务。', inputSchema: object({}), annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, { name: 'continuer_status', description: '读取 Codex 额度、已管理任务进度、持续工作开关及连接状态。未知额度不是零。', inputSchema: object({}), annotations: { readOnlyHint: true, openWorldHint: false } }, { name: 'continuer_list_sessions', description: '分页读取可导入的 Codex 本机会话;读取不会自动接管会话。', inputSchema: object({ cursor: { type: 'string' } }), annotations: { readOnlyHint: true, openWorldHint: false } }, - { name: 'continuer_create_task', description: '用户明确要求开始项目工作时创建任务。调度开启且额度允许时会执行本地代码工作。必须使用用户提供或已确认的项目目录与目标。', inputSchema: taskInput, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true } }, { name: 'continuer_import_session', description: '将原聊天加入额度恢复监测,导入后暂停。note 为可选本地备注,不发送给聊天;旧 prompt 字段仅作为备注兼容。每次确认耗尽并恢复后只发送固定“继续”,累计受 maxWakeSends 限制。不读取原聊天完成状态,不承诺完成目标。', inputSchema: { ...object({ threadId: text, note: { type: 'string', minLength: 0 }, prompt: { ...text, description: '旧版兼容备注;不发送给聊天,不可与 note 同时提供' } }), required: ['threadId'] }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, - { name: 'continuer_task_action', description: '按用户要求操作任务。直接执行模式 resume 排队工作,complete 标记完成并移除;原聊天模式 resume 仅开启额度监测,pause 仅暂停监测,complete 仅结束监测并移除记录,不确认原聊天工作完成。监测累计发送次数不会因暂停或重新开启而清零。', inputSchema: { ...object({ id: text, action: { enum: ['pause', 'resume', 'complete', 'continuous'], type: 'string' }, value: { type: 'boolean' } }), required: ['id', 'action'] }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true } }, - { name: 'continuer_update_settings', description: '按用户要求调整调度、额度阈值与 10–90 秒同步周期。maxRounds 为直接执行轮次上限,maxWakeSends 为每条原聊天监测记录的累计发送次数上限。提高发送上限不会自动重启已停止的监测。', inputSchema: object({ enabled: { type: 'boolean' }, minimumRemaining: { type: 'integer', minimum: 1, maximum: 99 }, pollSeconds: { type: 'integer', minimum: 10, maximum: MAX_POLL_SECONDS }, maxRounds: { type: 'integer', minimum: 1, maximum: 100 }, maxWakeSends: { type: 'integer', minimum: 1, maximum: 100 } }), annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, + { name: 'continuer_task_action', description: '暂停任务、关闭持续工作或结束并移除记录。原聊天 complete 仅结束监测,不确认工作完成。恢复执行、开启监测和持续工作必须由用户在挂件中操作。', inputSchema: { ...object({ id: text, action: { enum: ['pause', 'complete', 'continuous'], type: 'string' }, value: { type: 'boolean', enum: [false] } }), required: ['id', 'action'] }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false } }, + { name: 'continuer_update_settings', description: '仅允许关闭后台调度。开启调度、调整额度阈值及执行上限必须由用户在挂件中操作。', inputSchema: { ...object({ enabled: { type: 'boolean', enum: [false] } }), required: ['enabled'] }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, ]; export function validate(name, input) { + if (name === 'continuer_create_task') throw new Error('创建或启动工作需要用户在 Continuer 挂件中操作'); const spec = tools.find(t => t.name === name)?.inputSchema; if (!spec) throw new Error('Unknown tool'); if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Arguments must be an object'); @@ -35,7 +33,7 @@ export async function dispatch(name, args, runtime) { return { app: state.app, version: state.version, buildId: state.buildId, protocolVersion: state.protocolVersion, observationOnly: state.observationOnly, persistence: state.persistence, diagnostics: state.diagnostics, connection: state.connection, quota: state.quota, sampledAt: state.sampledAt, settings: state.settings, tasks: state.tasks.map(({ id, mode, name, cwd, threadId, status, progress, activity, continuous, rounds, sentCount, wake }) => ({ id, mode, name, cwd, threadId, status, progress, activity, continuous, rounds, sentCount, wake })), pendingApprovals: state.approvals.length }; } if (name === 'continuer_list_sessions') return runtime.request('/api/threads' + (args.cursor ? '?cursor=' + encodeURIComponent(args.cursor) : '')); - const routes = { continuer_create_task: '/api/tasks', continuer_import_session: '/api/import', continuer_update_settings: '/api/settings' }; + const routes = { continuer_import_session: '/api/import', continuer_update_settings: '/api/settings' }; const { id, ...payload } = args; const result = await runtime.request(name === 'continuer_task_action' ? `/api/tasks/${encodeURIComponent(id)}` : routes[name], name === 'continuer_task_action' ? payload : args); return { ok: true, settings: result.settings, tasks: result.tasks.map(({ id, mode, name, status, continuous, activity, rounds, sentCount }) => ({ id, mode, name, status, continuous, activity, rounds, sentCount })) }; diff --git a/public/approval-details.js b/public/approval-details.js index 1ce1ee1..178b44e 100644 --- a/public/approval-details.js +++ b/public/approval-details.js @@ -11,7 +11,7 @@ export class ApprovalDetails { if (!slot || slot.token !== summary.detailToken) { slot = { token: summary.detailToken }; this.slots.set(id, slot); } if (!slot.value && !slot.pending && (!slot.retryAt || Date.now() >= slot.retryAt)) { slot.pending = true; - this.request(`/api/approvals/${encodeURIComponent(id)}?token=${encodeURIComponent(slot.token)}`).then(result => { + this.request(`/api/approvals/${encodeURIComponent(id)}`, { token: slot.token }).then(result => { if (this.slots.get(id) !== slot) return; if (result.approval.detailToken !== slot.token) throw new Error('授权请求已更新'); slot.value = result.approval; slot.pending = false; this.changed(); diff --git a/scripts/check-public.js b/scripts/check-public.js index fdecc63..371d0a2 100644 --- a/scripts/check-public.js +++ b/scripts/check-public.js @@ -6,6 +6,7 @@ import { publicFiles } from './source-files.js'; export function checkPublic(root = '.') { const failures = [], files = publicFiles(root); const patterns = [ + ['local Bearer credential', /\bBearer\s+[a-f0-9]{64}\b/i], ['private key', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/], ['API credential', /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{24,}/], ['GitHub credential', /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{40,})/], diff --git a/server/http-body.js b/server/http-body.js index 3e98785..036924e 100644 --- a/server/http-body.js +++ b/server/http-body.js @@ -1,16 +1,19 @@ // Decode across network chunk boundaries; never replace a split UTF-8 codepoint. +import { ClientError } from './http-policy.js'; export async function readJsonBody(req) { const decoder = new TextDecoder('utf-8', { fatal: true }); + const decode = (chunk, options) => { try { return decoder.decode(chunk, options); } catch { throw new ClientError('请求正文不是有效 UTF-8'); } }; let content = '', bytes = 0; for await (const chunk of req) { bytes += chunk.length; - if (bytes > 160000) throw new Error('请求过大'); - content += decoder.decode(chunk, { stream: true }); - if (content.length > 40000) throw new Error('请求过大'); + if (bytes > 160000) throw new ClientError('请求过大'); + content += decode(chunk, { stream: true }); + if (content.length > 40000) throw new ClientError('请求过大'); } - content += decoder.decode(); - if (content.length > 40000) throw new Error('请求过大'); - const result = content ? JSON.parse(content) : {}; - if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('请求正文必须为 JSON 对象'); + content += decode(); + if (content.length > 40000) throw new ClientError('请求过大'); + let result; + try { result = content ? JSON.parse(content) : {}; } catch { throw new ClientError('请求正文不是有效 JSON'); } + if (!result || typeof result !== 'object' || Array.isArray(result)) throw new ClientError('请求正文必须为 JSON 对象'); return result; } diff --git a/server/http-policy.js b/server/http-policy.js new file mode 100644 index 0000000..f69456c --- /dev/null +++ b/server/http-policy.js @@ -0,0 +1,22 @@ +// Only deliberate local validation messages may cross the HTTP boundary. +export class ClientError extends Error {} +export function clientErrorMessage(error) { + if (error instanceof ClientError) return error.message; + if (error?.code === 'ENOENT') return '项目目录不存在'; + return '操作失败,请检查本地服务状态后重试'; +} + +// Fixed-size buckets: unauthenticated traffic cannot spend a client's budget. +export function createRequestLimiter(now = () => performance.now()) { + const budgets = { unauthorized: [60, 1], read: [300, 5], write: [120, 2] }; + const buckets = new Map(); + return (authorized, method) => { + const key = !authorized ? 'unauthorized' : method === 'GET' ? 'read' : 'write'; + const [capacity, perSecond] = budgets[key], time = now(); + const bucket = buckets.get(key) || { tokens: capacity, time }; + bucket.tokens = Math.min(capacity, bucket.tokens + Math.max(0, time - bucket.time) * perSecond / 1000); bucket.time = time; + buckets.set(key, bucket); + if (bucket.tokens < 1) return false; + bucket.tokens--; return true; + }; +} diff --git a/server/http-routes.js b/server/http-routes.js index 977ca68..68dda05 100644 --- a/server/http-routes.js +++ b/server/http-routes.js @@ -1,4 +1,5 @@ import { readJsonBody } from './http-body.js'; +import { ClientError } from './http-policy.js'; export function json(res, code, data) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); @@ -13,7 +14,7 @@ export function createApiRouter({ manager, health, stream, rpc, demo, summary, s ['/api/storage/recover', input => manager.recoverStorage(input.source)], ['/api/tasks', input => manager.create(input)], ['/api/import', input => { - if (input.note !== undefined && input.prompt !== undefined) throw new Error('请仅提供监测备注 note;prompt 为旧版兼容字段'); + if (input.note !== undefined && input.prompt !== undefined) throw new ClientError('请仅提供监测备注 note;prompt 为旧版兼容字段'); return manager.importThread(input.threadId, input.note ?? input.prompt); }], ['/api/approval', input => manager.approve(input.id, input.decision, input.token)], @@ -26,11 +27,7 @@ export function createApiRouter({ manager, health, stream, rpc, demo, summary, s if (path === '/api/state') json(res, 200, summary()); else if (path === '/api/health') json(res, 200, health.snapshot()); else if (path === '/api/logs') json(res, 200, { logs: manager.state.logs }); - else if (/^\/api\/approvals\/[^/]+$/.test(path)) { - const approval = manager.approvals.get(decodeURIComponent(path.split('/').pop())); - if (approval && approval.detailToken === url.searchParams.get('token')) json(res, 200, { approval }); - else json(res, 404, { error: '授权请求已失效' }); - } else if (/^\/api\/tasks\/[^/]+$/.test(path)) { + else if (/^\/api\/tasks\/[^/]+$/.test(path)) { const task = manager.taskSnapshot(decodeURIComponent(path.split('/').pop())); json(res, task ? 200 : 404, task ? { task } : { error: '任务不存在' }); } else if (path === '/api/events') { @@ -40,6 +37,12 @@ export function createApiRouter({ manager, health, stream, rpc, demo, summary, s return true; } if (req.method !== 'POST') return false; + if (/^\/api\/approvals\/[^/]+$/.test(path)) { + const input = await readJsonBody(req), approval = manager.approvals.get(decodeURIComponent(path.split('/').pop())); + if (typeof input.token === 'string' && approval && approval.detailToken === input.token) json(res, 200, { approval }); + else json(res, 404, { error: '授权请求已失效' }); + return true; + } const taskId = /^\/api\/tasks\/[^/]+$/.test(path) ? decodeURIComponent(path.split('/').pop()) : null; const mutation = mutations.get(path) || (taskId !== null ? input => manager.action(taskId, input.action, input.value) : null); if (!mutation && path !== '/api/shutdown') return false; diff --git a/server/index.js b/server/index.js index c56545c..fe2d3a2 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import { createStaticAssets } from './static-assets.js'; import access from './access.cjs'; import exitState from './exit-state.cjs'; import localTls from './local-tls.cjs'; +import { clientErrorMessage, createRequestLimiter } from './http-policy.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const demo = process.argv.includes('--demo'); @@ -51,10 +52,11 @@ manager.on('persistence-error', error => console.error(`输出合并保存失败 manager.on('operation-error', error => console.error(`操作未完成:${error.message}`)); const routeApi = createApiRouter({ manager, health, stream, rpc, demo, summary, stop }); const serveStatic = createStaticAssets(root); +const allowRequest = createRequestLimiter(); const server = createServer(secureOptions, async (req, res) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer'); - res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'"); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; object-src 'none'"); // Loopback binding + exact Host + same-origin mutations prevent DNS rebinding/CSRF. if (req.headers.host !== `127.0.0.1:${port}`) return json(res, 403, { error: '请使用 127.0.0.1 访问' }); if (req.headers.origin && req.headers.origin !== origin) return json(res, 403, { error: '跨站请求被拒绝' }); @@ -62,11 +64,15 @@ const server = createServer(secureOptions, async (req, res) => { try { const url = new URL(req.url, origin); if (url.origin !== origin) return json(res, 403, { error: '请求来源无效' }); - if (url.pathname.startsWith('/api/') && !access.authorized(req, accessToken)) return json(res, 401, { error: '请用启动脚本重新打开 Continuer;本地 API 需要访问凭据' }); + if (url.pathname.startsWith('/api/')) { + const authorized = access.authorized(req, accessToken); + if (!allowRequest(authorized, req.method)) { res.setHeader('Retry-After', '1'); return json(res, 429, { error: '请求过于频繁,请稍后重试' }); } + if (!authorized) return json(res, 401, { error: '请用启动脚本重新打开 Continuer;本地 API 需要访问凭据' }); + } if (await routeApi(req, res, url)) return; if (await serveStatic(req, res, url)) return; json(res, 404, { error: '接口或页面不存在' }); - } catch (error) { json(res, 400, { error: error.code === 'ENOENT' ? '项目目录不存在' : error.message }); } + } catch (error) { json(res, 400, { error: clientErrorMessage(error) }); } }); server.listen(port, '127.0.0.1', () => { health.record('running'); console.log(`Codex Continuer ${demo ? '(DEMO)' : ''}: ${origin}`); manager.start(); }); server.on('error', error => { console.error(error.message); process.exitCode = 1; health.record('failed', 'listen-error'); manager.safely(() => manager.stop()); }); diff --git a/server/manager.js b/server/manager.js index 629dfd2..00a34c3 100644 --- a/server/manager.js +++ b/server/manager.js @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events'; import { randomUUID } from 'node:crypto'; -import { statSync } from 'node:fs'; -import { isAbsolute } from 'node:path'; +import { ClientError } from './http-policy.js'; +import { checkedProjectPath, localProjectPath } from './project-path.js'; import { quotaAvailable, isQuotaError, isWriterConflict, parseOutcome, progress, buckets, MAX_POLL_SECONDS } from './policy.js'; import { resetWake, tickQuotaWake, enforceWakeLimit } from './quota-wake.js'; import { OUTPUT_LIMIT, retainOutput, outcomeSummary } from './output.js'; @@ -145,13 +145,13 @@ export class Manager extends EventEmitter { this.emit('change'); this.emit('persistence-error', error); } activate() { - if (this.persistence.status !== 'ok') throw new Error('数据保存失败,恢复存储后再开启调度'); + if (this.persistence.status !== 'ok') throw new ClientError('数据保存失败,恢复存储后再开启调度'); this.observationOnly = false; } recoverStorage(source = 'retry') { - if (this.wakeExecutions.size) throw new Error('续作消息仍在发送,请等待发送结果确认后再恢复存储'); + if (this.wakeExecutions.size) throw new ClientError('续作消息仍在发送,请等待发送结果确认后再恢复存储'); if (source === 'backup') { - if (this.scheduling || this.executions.size || this.state.tasks.some(t => ['starting', ...ACTIVE_TURN_STATUSES].includes(t.status))) throw new Error('请先确认当前工作已停止,再恢复备份'); + if (this.scheduling || this.executions.size || this.state.tasks.some(t => ['starting', ...ACTIVE_TURN_STATUSES].includes(t.status))) throw new ClientError('请先确认当前工作已停止,再恢复备份'); this.scheduleEpoch++; this.state = this.store.restoreBackup(state => { state.settings.enabled = false; @@ -164,11 +164,11 @@ export class Manager extends EventEmitter { this.approvals.clear(); this.persistence = { status: 'ok', message: null }; this.pendingSave = false; this.emit('change'); } else if (source === 'retry') { - if (this.persistence.needsRestore) throw new Error('主状态文件损坏,请明确选择恢复备份'); + if (this.persistence.needsRestore) throw new ClientError('主状态文件损坏,请明确选择恢复备份'); this.scheduleEpoch++; for (const task of this.state.tasks) if (task.wake?.delivery === 'sending') { task.wake.delivery = 'uncertain'; task.status = 'blocked'; task.activity = '发送记录保存失败,请检查原聊天后手动继续'; } this.state.settings.enabled = false; this.pendingSave = true; this.flush(); this.emit('change'); - } else throw new Error('恢复方式无效'); + } else throw new ClientError('恢复方式无效'); } clearCompletedTasks() { const completedIds = new Set(this.state.tasks.filter(t => t.status === 'completed').map(t => t.id)); @@ -287,6 +287,7 @@ export class Manager extends EventEmitter { task.status = 'queued'; task.activity = '启动已取消,等待有效调度和新鲜额度'; this.changed(); return false; }; try { + task.cwd = localProjectPath(task.cwd); task.turnId = null; task.status = 'starting'; task.waitReason=null; task.activity = '正在恢复会话…'; this.changed(); if (task.rounds >= this.state.settings.maxRounds) { @@ -315,7 +316,7 @@ export class Manager extends EventEmitter { outputSchema: outcomeSchema, }); if (!current()) return; - if (typeof result.turn?.id !== 'string' || !result.turn.id) throw new Error('未收到有效的轮次 ID,请检查原会话后手动继续'); + if (typeof result.turn?.id !== 'string' || !result.turn.id) throw new ClientError('未收到有效的轮次 ID,请检查原会话后手动继续'); execution.pending = false; if (ACTIVE_TURN_STATUSES.includes(task.status)) task.turnId = result.turn.id; this.log(`开始工作:${task.name}`, 'success', task.id); @@ -477,12 +478,12 @@ export class Manager extends EventEmitter { } } approve(id, decision, token) { - if (this.persistence.status !== 'ok') throw new Error('请先恢复数据保存,再处理授权'); + if (this.persistence.status !== 'ok') throw new ClientError('请先恢复数据保存,再处理授权'); const request = this.approvals.get(String(id)); - if (!request || typeof token !== 'string' || token !== request.detailToken || !['accept', 'decline'].includes(decision)) throw new Error('授权请求已过期或决定无效'); - if (request.method !== 'item/commandExecution/requestApproval' || typeof request.command !== 'string' || !request.command.trim()) throw new Error('授权内容无法完整审阅,请在原 Codex 中处理'); + if (!request || typeof token !== 'string' || token !== request.detailToken || !['accept', 'decline'].includes(decision)) throw new ClientError('授权请求已过期或决定无效'); + if (request.method !== 'item/commandExecution/requestApproval' || typeof request.command !== 'string' || !request.command.trim()) throw new ClientError('授权内容无法完整审阅,请在原 Codex 中处理'); const task = this.state.tasks.find(t => t.id === request.taskId); - if (this.stopped || !this.rpc.ready || !task || task.status !== 'approval' || !this.matchesTurn(task, request)) throw new Error('授权所属轮次已失效或正在暂停'); + if (this.stopped || !this.rpc.ready || !task || task.status !== 'approval' || !this.matchesTurn(task, request)) throw new ClientError('授权所属轮次已失效或正在暂停'); // Confirm storage before granting an operation permission. this.pendingSave = true; this.flush(); this.rpc.send({ id: request.id, result: { decision } }); @@ -492,36 +493,36 @@ export class Manager extends EventEmitter { this.changed(); } create(input) { - if (this.state.tasks.length >= 100) throw new Error('最多管理 100 条记录,请先结束不再需要的工作'); - for (const key of ['name', 'cwd', 'prompt']) if (typeof input[key] !== 'string' || !input[key].trim()) throw new Error('请填写名称、项目目录和工作目标'); - if (input.name.length > 120 || input.prompt.length > 12000) throw new Error('名称或目标过长'); - if (!this.demo && (!isAbsolute(input.cwd) || !statSync(input.cwd).isDirectory())) throw new Error('项目目录必须是存在的绝对路径'); - const task = { id: randomUUID(), name: input.name.trim(), cwd: input.cwd.trim(), prompt: input.prompt.trim(), threadId: null, continuous: input.continuous === true, status: 'queued', activity: '等待调度', rounds: 0, plan: [], output: '', createdAt: Date.now(), updatedAt: Date.now() }; + if (this.state.tasks.length >= 100) throw new ClientError('最多管理 100 条记录,请先结束不再需要的工作'); + for (const key of ['name', 'cwd', 'prompt']) if (typeof input[key] !== 'string' || !input[key].trim()) throw new ClientError('请填写名称、项目目录和工作目标'); + if (input.name.length > 120 || input.prompt.length > 12000) throw new ClientError('名称或目标过长'); + const cwd = checkedProjectPath(input.cwd, { demo: this.demo }); + const task = { id: randomUUID(), name: input.name.trim(), cwd, prompt: input.prompt.trim(), threadId: null, continuous: input.continuous === true, status: 'queued', activity: '等待调度', rounds: 0, plan: [], output: '', createdAt: Date.now(), updatedAt: Date.now() }; this.state.tasks.unshift(task); this.log(`添加工作:${task.name}`); this.changed(); this.activate(); return task; } async action(id, action, value) { const task = this.state.tasks.find(t => t.id === id); - if (!task) throw new Error('任务不存在'); + if (!task) throw new ClientError('任务不存在'); if (task.mode === 'quota-wake') { if ((this.wakeExecutions.has(task.id) || task.wake?.delivery === 'uncertain') && (action === 'pause' || (action === 'continuous' && value === false))) { task.continuous = false; task.status = 'paused'; task.waitReason = null; task.retryAt = 0; task.activity = '监测已暂停,发送结果仍需确认;请核对原聊天,已发出的消息无法撤回'; this.changed(); return; } if (this.wakeExecutions.has(task.id)) { - throw new Error('续作消息发送结果仍在确认,请等待后再开启或结束监测'); + throw new ClientError('续作消息发送结果仍在确认,请等待后再开启或结束监测'); } - if ((action === 'resume' || (action === 'continuous' && value === true)) && (task.sentCount || 0) >= this.state.settings.maxWakeSends) throw new Error('已达到原聊天发送次数上限,请提高发送上限后再开启监测;累计次数不会重置'); + if ((action === 'resume' || (action === 'continuous' && value === true)) && (task.sentCount || 0) >= this.state.settings.maxWakeSends) throw new ClientError('已达到原聊天发送次数上限,请提高发送上限后再开启监测;累计次数不会重置'); if (action === 'continuous') { - if (typeof value !== 'boolean') throw new Error('开关值无效'); + if (typeof value !== 'boolean') throw new ClientError('开关值无效'); if (task.continuous !== value) { task.continuous = value; resetWake(task); } } else if (action === 'pause') { task.continuous = false; resetWake(task); } else if (action === 'resume') { task.continuous = true; resetWake(task); } else if (action === 'complete') { task.continuous = false; task.status = 'completed'; this.log(`已结束监测:${task.name};工作结果请在原聊天确认`, 'info', task.id); } - else throw new Error('不支持的操作'); + else throw new ClientError('不支持的操作'); this.changed(); if (action === 'resume' || (action === 'continuous' && value === true)) this.activate(); return; } if (action === 'continuous') { - if (typeof value !== 'boolean') throw new Error('开关值无效'); + if (typeof value !== 'boolean') throw new ClientError('开关值无效'); task.continuous = value; if (!value && ['waiting', 'queued'].includes(task.status)) { task.status = 'paused'; task.activity = '持续工作已关闭'; } if (value && task.status==='blocked' && isWriterConflict({message:task.activity})) this.waitForThread(task,'writer'); @@ -536,21 +537,21 @@ export class Manager extends EventEmitter { } return; } else if (action === 'resume') { - if (['starting', 'running', 'approval', 'pausing'].includes(task.status)) throw new Error('任务尚在运行或停止中'); + if (['starting', 'running', 'approval', 'pausing'].includes(task.status)) throw new ClientError('任务尚在运行或停止中'); task.status = 'queued'; task.rounds = 0; task.retryAt = 0; task.activity = '等待可用额度'; } else if (action === 'complete') { - if (['starting', 'running', 'approval', 'pausing'].includes(task.status)) throw new Error('请先暂停任务'); + if (['starting', 'running', 'approval', 'pausing'].includes(task.status)) throw new ClientError('请先暂停任务'); task.status = 'completed'; task.activity = '用户确认完成'; - } else throw new Error('不支持的操作'); + } else throw new ClientError('不支持的操作'); if (task.status!=='waiting') { task.waitReason=null; if (task.status==='paused') task.retryAt=0; } this.changed(); if (action === 'resume' || (action === 'continuous' && value === true)) this.activate(); } updateSettings(input) { const s = this.state.settings; - if (input.enabled !== undefined && typeof input.enabled !== 'boolean') throw new Error('开关值无效'); + if (input.enabled !== undefined && typeof input.enabled !== 'boolean') throw new ClientError('开关值无效'); for (const [key, min, max] of [['pollSeconds', 10, MAX_POLL_SECONDS], ['minimumRemaining', 1, 99], ['maxRounds', 1, 100], ['maxWakeSends', 1, 100]]) { - if (input[key] !== undefined) { if (!Number.isInteger(input[key]) || input[key] < min || input[key] > max) throw new Error(`${key} 必须为 ${min}–${max} 的整数`); } + if (input[key] !== undefined) { if (!Number.isInteger(input[key]) || input[key] < min || input[key] > max) throw new ClientError(`${key} 必须为 ${min}–${max} 的整数`); } } for (const key of ['pollSeconds', 'minimumRemaining', 'maxRounds', 'maxWakeSends']) if (input[key] !== undefined) s[key] = input[key]; if (input.enabled !== undefined) s.enabled = input.enabled; @@ -561,17 +562,17 @@ export class Manager extends EventEmitter { } async listThreads(cursor = null) { await this.rpc.connect?.(); return this.rpc.call('thread/list', { limit: 30, sortKey: 'updated_at', cursor }); } async importThread(id, note = '') { - if (this.state.tasks.some(t => t.threadId === id)) throw new Error('该会话已添加'); - if (this.importingThreads.has(id)) throw new Error('该会话正在添加,请勿重复导入'); - if (this.state.tasks.length >= 100) throw new Error('最多管理 100 条记录,请先结束不再需要的工作'); - if (typeof note !== 'string' || note.length > 12000) throw new Error('监测备注必须为不超过 12000 字符的文本'); + if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError('该会话已添加'); + if (this.importingThreads.has(id)) throw new ClientError('该会话正在添加,请勿重复导入'); + if (this.state.tasks.length >= 100) throw new ClientError('最多管理 100 条记录,请先结束不再需要的工作'); + if (typeof note !== 'string' || note.length > 12000) throw new ClientError('监测备注必须为不超过 12000 字符的文本'); // Reserve before yielding: HTTP and MCP imports share this manager. this.importingThreads.add(id); try { const { thread } = await this.rpc.call('thread/read', { threadId: id, includeTurns: false }); // Another task path may have claimed the thread while the read was pending. - if (this.state.tasks.some(t => t.threadId === id)) throw new Error('该会话已添加'); - if (this.state.tasks.length >= 100) throw new Error('最多管理 100 条记录,请先结束不再需要的工作'); + if (this.state.tasks.some(t => t.threadId === id)) throw new ClientError('该会话已添加'); + if (this.state.tasks.length >= 100) throw new ClientError('最多管理 100 条记录,请先结束不再需要的工作'); const task = { id: randomUUID(), mode: 'quota-wake', threadId: id, name: (thread.name || thread.preview || 'Codex 会话').slice(0, 80), cwd: thread.cwd, note: note.trim(), continuous: false, status: 'paused', activity: '会话已添加,开启额度监测后在恢复时发送“继续”;工作结果请在原聊天确认', sentCount: 0, wake: { armed: false, delivery: 'idle' }, plan: [], output: '', createdAt: Date.now(), updatedAt: Date.now() }; this.state.tasks.unshift(task); this.log(`已添加会话:${task.name}`); this.changed(); return task; } finally { this.importingThreads.delete(id); } diff --git a/server/project-path.js b/server/project-path.js new file mode 100644 index 0000000..dc2ba69 --- /dev/null +++ b/server/project-path.js @@ -0,0 +1,17 @@ +import { win32 } from 'node:path'; +import { statSync } from 'node:fs'; +import { ClientError } from './http-policy.js'; + +// Check the string before touching the filesystem: UNC/device stat can send SMB +// authentication. Accept drive-qualified Windows paths only, including in demo. +export function localProjectPath(value) { + if (typeof value !== 'string') throw new ClientError('项目目录必须是本地盘符绝对路径'); + const path = value.trim().replaceAll('/', '\\'); + if (!/^[a-z]:\\(?!\\)/i.test(path) || /[\x00-\x1f]/.test(path) || path.slice(2).includes(':')) throw new ClientError('项目目录必须是本地盘符绝对路径,不支持网络或设备路径'); + return win32.normalize(path); +} +export function checkedProjectPath(value, { demo = false, stat = statSync } = {}) { + const path = localProjectPath(value); + if (!demo && !stat(path).isDirectory()) throw new ClientError('项目目录必须是存在的本地目录'); + return path; +} diff --git a/tests/import-http.test.js b/tests/import-http.test.js index 0c400d6..87c7cdc 100644 --- a/tests/import-http.test.js +++ b/tests/import-http.test.js @@ -71,7 +71,7 @@ test('HTTP and MCP imports share one reservation and persist one recovery delive assert.equal(persisted[0].wake.delivery, 'sent'); // The real HTTP routes share the tighter limits and mode-specific schema. await assert.rejects(request('/api/settings', { pollSeconds: 91 }), /90/); - await dispatch('continuer_update_settings', { pollSeconds: 90, maxWakeSends: 1 }, runtime); + await request('/api/settings', { pollSeconds: 90, maxWakeSends: 1 }); const capped = (await request('/api/state')).tasks.find(task => task.id === imported[0].id); assert.equal(capped.status, 'paused'); assert.equal(capped.continuous, false); await assert.rejects(request(`/api/tasks/${capped.id}`, { action: 'resume' }), /上限/); diff --git a/tests/monitor-policy.test.js b/tests/monitor-policy.test.js index 7dc8163..28f3ae4 100644 --- a/tests/monitor-policy.test.js +++ b/tests/monitor-policy.test.js @@ -18,17 +18,18 @@ function fixture(saved, send) { return { m, rpc, saves, sent, cycle }; } -test('backend and MCP accept 90 seconds and atomically reject larger or invalid policies', () => { +test('backend accepts 90 seconds while MCP cannot change execution policies', () => { const { m } = fixture(); m.updateSettings({ pollSeconds: 90, maxWakeSends: 1 }); - validate('continuer_update_settings', { pollSeconds: 90, maxWakeSends: 1 }); + assert.throws(() => validate('continuer_update_settings', { pollSeconds: 90, maxWakeSends: 1 })); const before = structuredClone(m.state.settings); for (const input of [{ pollSeconds: 91 }, { pollSeconds: 300 }, { pollSeconds: 9 }, { pollSeconds: 90.1 }, { maxWakeSends: 0 }, { maxWakeSends: 101 }]) { assert.throws(() => m.updateSettings({ enabled: false, ...input })); assert.throws(() => validate('continuer_update_settings', input)); assert.deepEqual(m.state.settings, before); } - assert.equal(tools.find(t => t.name === 'continuer_update_settings').inputSchema.properties.pollSeconds.maximum, MAX_POLL_SECONDS); + assert.equal(MAX_POLL_SECONDS, 90); + assert.deepEqual(Object.keys(tools.find(t => t.name === 'continuer_update_settings').inputSchema.properties), ['enabled']); }); test('legacy polling values shrink to 90 and migration is saved without enabling scheduling', () => { diff --git a/tests/public-privacy.test.js b/tests/public-privacy.test.js index 2f8b497..895c7f3 100644 --- a/tests/public-privacy.test.js +++ b/tests/public-privacy.test.js @@ -11,6 +11,10 @@ test('public privacy guard rejects live identifiers in every exported text surfa t.after(() => { assert.equal(dirname(realpathSync(directory)), parent); rmSync(directory, { recursive: true, force: true }); }); for (const name of publicFiles()) { const target = join(directory, name); mkdirSync(dirname(target), { recursive: true }); copyFileSync(name, target); } assert.ok(checkPublic(directory).files > 100); + const credentialFile = join(directory, 'tests/quota-wake.test.js'), credentialOriginal = readFileSync(credentialFile); + writeFileSync(credentialFile, credentialOriginal.toString() + '\nBearer ' + 'a'.repeat(64)); + assert.throws(() => checkPublic(directory), /local Bearer credential/); + writeFileSync(credentialFile, credentialOriginal); for (const name of ['server/tls-live.json', 'plugin/access-demo.token', 'public/runtime.json', 'tests/.env.local', 'desktop/private.pfx']) { const file = join(directory, name); writeFileSync(file, 'synthetic-private-data'); assert.throws(() => checkPublic(directory), error => error.message.includes(name + ': private runtime file')); diff --git a/tests/round2.test.js b/tests/round2.test.js index 7be3dd4..d124674 100644 --- a/tests/round2.test.js +++ b/tests/round2.test.js @@ -208,9 +208,10 @@ test('detail previews preserve full paths/activity and failed requests can retry }); test('truncated approvals cannot be accepted before full content arrives and stale details are ignored', async () => { - const pending = [], details = new ApprovalDetails(() => { const held = deferred(); pending.push(held); return held.promise; }, () => {}); + const pending = [], requests = [], details = new ApprovalDetails((...args) => { requests.push(args); const held = deferred(); pending.push(held); return held.promise; }, () => {}); const summary = { id: 1, detailToken: 'old', command: 'preview…', requiresDetails: true }; details.readAll([summary]); assert.equal(details.ready(summary), false); + assert.deepEqual(requests[0], ['/api/approvals/1', { token: 'old' }]); const current = { ...summary, detailToken: 'new' }; details.readAll([current]); pending[0].resolve({ approval: { ...summary, command: 'obsolete' } }); await Promise.resolve(); assert.equal(details.ready(current), false); pending[1].resolve({ approval: { ...current, command: 'full command' } }); await Promise.resolve(); diff --git a/tests/security-boundaries.test.js b/tests/security-boundaries.test.js new file mode 100644 index 0000000..ebd5042 --- /dev/null +++ b/tests/security-boundaries.test.js @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { tools, dispatch } from '../plugin/tools.js'; +import { localProjectPath, checkedProjectPath } from '../server/project-path.js'; +import { ClientError, clientErrorMessage, createRequestLimiter } from '../server/http-policy.js'; +import { Manager } from '../server/manager.js'; + +test('MCP cannot authorize work or widen policy, even with an agent-supplied confirmation', async () => { + let calls = 0; const runtime = { ensureService: async () => { calls++; }, request: async () => { calls++; return { tasks: [] }; } }; + const blocked = [ + ['continuer_create_task', { name: 'fixture', cwd: 'E:\\fixture', prompt: 'fixture', continuous: false }], + ['continuer_task_action', { id: 'fixture', action: 'resume' }], + ['continuer_task_action', { id: 'fixture', action: 'continuous', value: true }], + ['continuer_update_settings', { enabled: true }], + ...['maxRounds', 'maxWakeSends', 'minimumRemaining', 'pollSeconds'].map(key => ['continuer_update_settings', { enabled: false, [key]: 10 }]), + ['continuer_import_session', { threadId: 'fixture', confirmed: true }], + ]; + for (const [name, input] of blocked) await assert.rejects(dispatch(name, input, runtime)); + assert.equal(calls, 0); assert.equal(tools.some(tool => tool.name === 'continuer_create_task'), false); + for (const args of [{ id: 'fixture', action: 'pause' }, { id: 'fixture', action: 'continuous', value: false }]) await dispatch('continuer_task_action', args, runtime); + await dispatch('continuer_update_settings', { enabled: false }, runtime); + assert.equal(calls, 6); +}); + +const rejectedPaths = ['\\\\host\\share', '//host/share', '\\/host/share', '/\\host/share', '\\\\?\\C:\\folder', '\\\\.\\C:\\folder', '\\??\\C:\\folder', 'C:folder', '\\folder', '/folder', 'C:\\\\host', 'C:\\folder:stream', 'C:\\bad\0folder']; +test('network and device paths are rejected before filesystem access', () => { + let stats = 0; const stat = () => { stats++; return { isDirectory: () => true }; }; + for (const path of rejectedPaths) assert.throws(() => checkedProjectPath(path, { stat }), /本地盘符/); + assert.equal(stats, 0); + assert.equal(checkedProjectPath(' E:/fixture/../project ', { stat }), 'E:\\project'); assert.equal(stats, 1); + assert.equal(localProjectPath('C:\\'), 'C:\\'); +}); + +test('persisted direct tasks with unsafe cwd cannot reach any execution RPC', async () => { + for (const cwd of rejectedPaths) { + let calls = 0; const rpc = new EventEmitter(); rpc.ready = true; rpc.call = async () => { calls++; throw Error('Unexpected RPC'); }; rpc.close = () => {}; + const state = { settings: { enabled: true, pollSeconds: 30, maxRounds: 20, maxWakeSends: 20, minimumRemaining: 5 }, tasks: [{ id: 'fixture', name: 'fixture', cwd, prompt: 'fixture', status: 'queued', rounds: 0, continuous: true, plan: [], output: '' }], logs: [], history: [] }; + const manager = new Manager(rpc, { load: () => state, save() {} }, { demo: true }); manager.connection.status = 'connected'; + manager.setQuota({ rateLimits: { primary: { usedPercent: 0 } } }); + await manager.run(state.tasks[0]); assert.equal(calls, 0); assert.equal(state.tasks[0].status, 'blocked'); + assert.equal(manager.state.tasks.length, 1); manager.stop(); + } +}); + +test('HTTP errors expose only intentional validation messages', () => { + const privateMessage = 'fixture internal path and credential'; + assert.equal(clientErrorMessage(new ClientError('名称过长')), '名称过长'); + assert.equal(clientErrorMessage(Object.assign(Error(privateMessage), { code: 'ENOENT' })), '项目目录不存在'); + assert.doesNotMatch(clientErrorMessage(Error(privateMessage)), /fixture|credential|path/); + assert.doesNotMatch(clientErrorMessage({ message: privateMessage, expose: true }), /fixture/); +}); + +test('request budgets refill without untrusted traffic starving authenticated reads or writes', () => { + let now = 0; const allow = createRequestLimiter(() => now); + for (let i = 0; i < 60; i++) assert.equal(allow(false, 'GET'), true); + assert.equal(allow(false, 'POST'), false); assert.equal(allow(true, 'GET'), true); assert.equal(allow(true, 'POST'), true); + for (let i = 1; i < 120; i++) assert.equal(allow(true, 'POST'), true); + assert.equal(allow(true, 'POST'), false); assert.equal(allow(true, 'GET'), true); + now = 1000; assert.equal(allow(false, 'GET'), true); assert.equal(allow(true, 'POST'), true); +}); diff --git a/tests/service-integration.test.js b/tests/service-integration.test.js index 7cdd813..1d70b78 100644 --- a/tests/service-integration.test.js +++ b/tests/service-integration.test.js @@ -105,14 +105,16 @@ test('real MCP cold startup remains read-only until explicit activation; summari assert.equal(f.calls().filter(method => method === 'fixture/queue').length, 1); const approval = (await f.request('/api/state')).approvals[0]; assert.equal(approval.requiresDetails, true); assert.ok(approval.command.length <= 1000); - const complete = await f.request(`/api/approvals/88?token=${approval.detailToken}`); + const complete = await f.request('/api/approvals/88', { token: approval.detailToken }); assert.equal(complete.approval.command.endsWith('fixture-end'), true); - await assert.rejects(f.request('/api/approvals/88?token=obsolete'), /失效/); + await assert.rejects(f.request('/api/approvals/88', { token: 'obsolete' }), /失效/); + await assert.rejects(f.request(`/api/approvals/88?token=${approval.detailToken}`), /不存在/); + await assert.rejects(f.request(`/api/approvals/88?token=${approval.detailToken}`, {}), /失效/); await assert.rejects(f.request('/api/approval', {id:88,decision:'accept'}), /过期/); await assert.rejects(f.request('/api/approval', {id:88,decision:'accept',token:'obsolete'}), /过期/); await f.request('/api/approval', {id:88,decision:'decline',token:approval.detailToken}); assert.equal(f.calls().filter(method => method === 'fixture/approval').length, 1); - await assert.rejects(f.request(`/api/approvals/88?token=${approval.detailToken}`), /失效/); + await assert.rejects(f.request('/api/approvals/88', { token: approval.detailToken }), /失效/); }); test('all API routes reject absent, wrong, cookie and query credentials before any work', { timeout: 25000 }, async t => { @@ -123,10 +125,15 @@ test('all API routes reject absent, wrong, cookie and query credentials before a } } assert.equal((await fetch(f.origin + '/api/state?access=' + access.readToken(f.directory))).status, 401); + const policy = (await fetch(f.origin + '/widget')).headers.get('content-security-policy'); + for (const directive of ["base-uri 'none'", "form-action 'none'", "object-src 'none'"]) assert.ok(policy.includes(directive)); for (const path of ['/api/tasks', '/api/settings', '/api/approval', '/api/import', '/api/shutdown']) { const response = await fetch(f.origin + path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Continuer-Client': '1' }, body: '{}' }); assert.equal(response.status, 401, path); } const crossOrigin = await fetch(f.origin + '/api/state', { headers: { Authorization: 'Bearer ' + access.readToken(f.directory), Origin: 'https://example.invalid' } }); assert.equal(crossOrigin.status, 403); + const flood = await Promise.all(Array.from({ length: 80 }, () => fetch(f.origin + '/api/state'))); + assert.ok(flood.some(response => response.status === 429)); + assert.equal((await f.request('/api/health')).app, 'codex-continuer'); assert.equal((await f.request('/api/state')).tasks.length, 2); assert.equal(f.calls().includes('turn/start'), false); assert.equal(f.calls().includes('fixture/queue'), false); }); From 698f6ffd01000e196ffc6b133d3b87442e19c8cf Mon Sep 17 00:00:00 2001 From: empty610 Date: Tue, 27 Oct 2026 22:01:31 +0800 Subject: [PATCH 5/8] docs: update README for the 0.4.1 preview --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5998ff5..8b8dbc4 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@ 监测 Codex 账户额度,在额度恢复后接续指定工作,并通过跟随 Codex 窗口的桌面挂件查看状态。 -**开发版本:0.4.1-preview · Windows x64 预览版 · GPL-3.0-only。** 这是独立社区项目,与 OpenAI、Rhine、Clash Verge 无隶属关系。 - -本对话后续更新统一记录在 `0.4.1-preview` / `preview/0.4.1`。旧版 `0.4.0-preview.1` 文件保留;版本切换按用户明确指令进行,开发更新不合并到 `main`。 +**开发版本:0.4.1-preview · Windows x64 预览版 · GPL-3.0-only。** ## 功能 @@ -89,10 +87,11 @@ npm run build:release ## 范围与隐私 - 当前主要验证 Windows x64、Node.js 24、Codex CLI 协议基线 0.153.2;外部接口升级可能需要适配。 +- 目前仅支持中文,在工作恢复时也发送且只发送“继续”,对于需求新阶段新引导词的对话可能不适配。 - 电脑、服务和原聊天执行端必须可用。排队成功不等于任务完成;已进入 Codex 队列的消息不能由本工具撤回。 -- 跨机器、完整真实额度耗尽恢复和长期无人值守仍待验收;原生监听器尚未代码签名。 -- 凭据由 Codex 管理;本工具的任务、输出、路径、备注和日志仍可能包含私人信息。不要上传 `data/`、`.cache/`、`runtime.json`、令牌或未脱敏诊断。 -- 本地访问控制不防御能够读取同用户数据目录、浏览器会话或进程内存的程序,不支持公网部署或多人共享。 +- 跨机器、完整真实额度耗尽恢复和长期无人值守仍然需要验收;原生监听器尚未代码签名。 +- 凭据由 Codex 管理;本工具的任务、输出、路径、备注和日志仍可能包含私人信息。不要上传 `data/`、`.cache/`、`runtime.json`、令牌或未脱敏诊断以及一切可能涉及安全问题的信息。 +- 本地访问控制不防御能够读取同用户数据目录、浏览器会话或进程内存的程序,也不支持公网部署或多人共享。 更多内容见 [文档索引](docs/README.md)、[使用细节](docs/usage.md)、[运行与恢复](docs/operations.md)、[兼容性](docs/compatibility.md)。 From 26a29b349fc809a5dc8e9c5bfacf28fa5298b806 Mon Sep 17 00:00:00 2001 From: empty610 Date: Tue, 27 Oct 2026 22:11:57 +0800 Subject: [PATCH 6/8] test: handle Windows short paths and cold TLS startup in CI --- tests/import-http.test.js | 12 +++++++----- tests/shutdown.test.js | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/import-http.test.js b/tests/import-http.test.js index 87c7cdc..5cc5da7 100644 --- a/tests/import-http.test.js +++ b/tests/import-http.test.js @@ -11,7 +11,7 @@ import { dispatch } from '../plugin/tools.js'; import access from '../server/access.cjs'; import { pinnedFetch } from './fixtures/local-fetch.js'; -test('HTTP and MCP imports share one reservation and persist one recovery delivery', { timeout: 20000 }, async () => { +test('HTTP and MCP imports share one reservation and persist one recovery delivery', { timeout: 30000 }, async () => { const temporaryRoot = realpathSync(tmpdir()); const dataDir = mkdtempSync(join(temporaryRoot, 'continuer-import-http-')); const portProbe = createServer(); @@ -46,12 +46,14 @@ test('HTTP and MCP imports share one reservation and persist one recovery delive return result; } try { - let ready = false; - for (let i = 0; i < 100; i++) { + let ready = false, startupError; + // A cold Windows TLS identity can take longer under concurrent CI load. + const startupDeadline = Date.now() + 20000; + while (Date.now() < startupDeadline) { if (child.exitCode !== null) throw Error(`Demo server exited: ${stderr}`); - try { await request('/api/state'); ready = true; break; } catch { await delay(50); } + try { await request('/api/state'); ready = true; break; } catch (error) { startupError = error; await delay(50); } } - assert.equal(ready, true, stderr); + assert.equal(ready, true, stderr || startupError?.message); const threadId = '11111111-1111-4111-8111-111111111111'; const input = { threadId, prompt: 'Isolated import regression' }; const runtime = { ensureService: async () => {}, request }; diff --git a/tests/shutdown.test.js b/tests/shutdown.test.js index a97e346..2e7c856 100644 --- a/tests/shutdown.test.js +++ b/tests/shutdown.test.js @@ -12,7 +12,7 @@ import { stopBackground } from '../plugin/shutdown.js'; import { alive } from '../server/health.js'; import exitState from '../server/exit-state.cjs'; -function directory(t) { const parent = realpathSync(tmpdir()), dir = mkdtempSync(join(parent, 'continuer-exit-')); t.after(() => { assert.equal(dirname(realpathSync(dir)), parent); rmSync(dir, { recursive: true, force: true }); }); return dir; } +function directory(t) { const parent = realpathSync.native(tmpdir()), dir = mkdtempSync(join(parent, 'continuer-exit-')); t.after(() => { assert.equal(dirname(realpathSync.native(dir)), parent); rmSync(dir, { recursive: true, force: true }); }); return dir; } function fixture(t) { const dataDir = directory(t), root = join(dataDir, 'plugin'), calls = []; let running = true; writeFileSync(join(dataDir, 'service.lock'), JSON.stringify({ pid: 123 })); From 0f65b4060267c485a74554d61f2a7d0c1cf4cace Mon Sep 17 00:00:00 2001 From: empty610 Date: Tue, 27 Oct 2026 22:56:41 +0800 Subject: [PATCH 7/8] test: replace generated code and regex HTML filtering with static fixtures --- tests/fixtures/shutdown-owned-launcher.cjs | 4 ++ tests/fixtures/shutdown-owned-service.cjs | 8 ++++ tests/fixtures/shutdown-passive.mjs | 14 ++++++ tests/fixtures/widget-animation.cjs | 52 ++++++++++++++++++++++ tests/fixtures/widget-trace.mjs | 12 +++++ tests/shutdown.test.js | 14 +++--- tests/widget-animation.test.js | 35 +++------------ tests/widget-smoke.test.js | 8 ++-- 8 files changed, 107 insertions(+), 40 deletions(-) create mode 100644 tests/fixtures/shutdown-owned-launcher.cjs create mode 100644 tests/fixtures/shutdown-owned-service.cjs create mode 100644 tests/fixtures/shutdown-passive.mjs create mode 100644 tests/fixtures/widget-animation.cjs create mode 100644 tests/fixtures/widget-trace.mjs diff --git a/tests/fixtures/shutdown-owned-launcher.cjs b/tests/fixtures/shutdown-owned-launcher.cjs new file mode 100644 index 0000000..56fecb1 --- /dev/null +++ b/tests/fixtures/shutdown-owned-launcher.cjs @@ -0,0 +1,4 @@ +const { writeFileSync } = require('node:fs'); + +writeFileSync(process.env.CONTINUER_TEST_LAUNCHER_PID_FILE, JSON.stringify({ pid: process.pid })); +setInterval(() => {}, 1000); diff --git a/tests/fixtures/shutdown-owned-service.cjs b/tests/fixtures/shutdown-owned-service.cjs new file mode 100644 index 0000000..4ac400d --- /dev/null +++ b/tests/fixtures/shutdown-owned-service.cjs @@ -0,0 +1,8 @@ +const { spawn } = require('node:child_process'); +const { writeFileSync } = require('node:fs'); + +if (process.argv[2] !== '--child') { + const child = spawn(process.execPath, [__filename, '--child'], { stdio: 'ignore', windowsHide: true }); + writeFileSync(process.argv[2], JSON.stringify({ pid: child.pid })); +} +setInterval(() => {}, 1000); diff --git a/tests/fixtures/shutdown-passive.mjs b/tests/fixtures/shutdown-passive.mjs new file mode 100644 index 0000000..fd17555 --- /dev/null +++ b/tests/fixtures/shutdown-passive.mjs @@ -0,0 +1,14 @@ +import localTls from '../../server/local-tls.cjs'; + +// Install the transport mock before runtime captures its configuration. +localTls.requestOptions = () => ({}); +let calls = 0; +localTls.fetchLocal = async () => { calls++; throw Error('fixture network failure'); }; +const runtime = await import('../../plugin/runtime.js'); +let blocked = 0; +for (const action of [() => runtime.ensureService(), () => runtime.openWidget({ automatic: true })]) { + try { await action(); } catch (error) { if (error.message.includes('已退出')) blocked++; } +} +const before = calls; +try { await runtime.openWidget(); } catch {} +console.log(JSON.stringify({ blocked, before, after: calls })); diff --git a/tests/fixtures/widget-animation.cjs b/tests/fixtures/widget-animation.cjs new file mode 100644 index 0000000..4f12bd0 --- /dev/null +++ b/tests/fixtures/widget-animation.cjs @@ -0,0 +1,52 @@ +const { app, BrowserWindow } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); + +const page = process.env.CONTINUER_TEST_ANIMATION_PAGE; +const output = process.env.CONTINUER_TEST_ANIMATION_OUTPUT; +const captureDir = process.env.CONTINUER_TEST_ANIMATION_CAPTURE; +const mark = stage => fs.writeFileSync(output, JSON.stringify({ stage })); +mark('starting'); +app.setPath('userData', path.join(path.dirname(output), 'profile')); +app.whenReady().then(async () => { + const win = new BrowserWindow({ width: 256, height: 256, show: false, frame: false, transparent: true, webPreferences: { offscreen: true, sandbox: true, contextIsolation: true, nodeIntegration: false } }); + mark('window-created'); + await win.loadURL('about:blank'); + win.webContents.debugger.attach(); + const command = (method, params) => win.webContents.debugger.sendCommand(method, params); + const { result: globalObject } = await command('Runtime.evaluate', { expression: 'globalThis' }); + // HTML is protocol data, never part of executable source. Parse it inertly + // and remove all script elements before importing the production markup. + const { result, exceptionDetails } = await command('Runtime.callFunctionOn', { + objectId: globalObject.objectId, + functionDeclaration: `function (html) { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + parsed.querySelectorAll('script').forEach(script => script.remove()); + document.replaceChild(document.importNode(parsed.documentElement, true), document.documentElement); + return { scriptsRemaining: document.querySelectorAll('script').length, scriptRan: !!globalThis.fixtureScriptRan }; + }`, + arguments: [{ value: fs.readFileSync(page, 'utf8') }], returnByValue: true, + }); + if (exceptionDetails) throw Error(exceptionDetails.text); + mark('page-loaded'); + await command('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }); + mark('media-configured'); + await win.webContents.executeJavaScript("document.querySelector('#mini').classList.add('has-art');document.querySelector('#mini').dataset.motion='running';document.querySelector('#mini-quota').textContent='77';document.querySelector('#mini-reset').textContent='02:00:00';document.querySelector('#mini-reset').classList.remove('visually-empty');document.getAnimations().forEach(a=>a.pause());"); + mark('initialized'); + const phases = []; + for (const at of [0, 15000, 30000, 45000, 59000]) phases.push(await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=" + at + ");({at:" + at + ",opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName})")); + mark('phases-checked'); + await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=0);document.querySelector('#mini').classList.add('is-refreshing');globalThis.fade=document.querySelector('#quota-readout').getAnimations().find(a=>a.animationName==='quota-reload');fade.pause();"); + const duration = await win.webContents.executeJavaScript('fade.effect.getTiming().duration'); + const frames = []; + for (const at of [0, 250, 500, 750, 1000]) { + frames.push(await win.webContents.executeJavaScript("fade.currentTime=" + at + ";if(" + at + ">=500){document.querySelector('#mini-quota').textContent='39';document.querySelector('#mini-window').textContent='七天可用额度';document.querySelector('#mini-reset').textContent='1天 02:00:00'}({at:" + at + ",opacity:Number(getComputedStyle(document.querySelector('#quota-readout')).opacity)})")); + if (captureDir) { + fs.mkdirSync(captureDir, { recursive: true }); + await new Promise(resolve => setTimeout(resolve, 40)); + fs.writeFileSync(path.join(captureDir, 'quota-' + at + '.png'), (await win.webContents.capturePage()).toPNG()); + } + } + fs.writeFileSync(output, JSON.stringify({ ...result.value, phases, frames, duration })); + win.destroy(); app.quit(); +}).catch(error => { fs.writeFileSync(output, JSON.stringify({ error: error.stack })); app.exit(1); }); diff --git a/tests/fixtures/widget-trace.mjs b/tests/fixtures/widget-trace.mjs new file mode 100644 index 0000000..89c8f6a --- /dev/null +++ b/tests/fixtures/widget-trace.mjs @@ -0,0 +1,12 @@ +import http from 'node:https'; +import { syncBuiltinESMExports } from 'node:module'; +import { appendFileSync } from 'node:fs'; + +const trace = process.env.CONTINUER_TEST_TRACE_FILE; +const original = http.createServer; +http.createServer = (options, listener) => original(options, (req, res) => { + appendFileSync(trace, JSON.stringify(req.url) + '\n'); + res.on('finish', () => appendFileSync(trace + '.responses', JSON.stringify({ path: req.url, status: res.statusCode, authenticated: !!req.headers.authorization }) + '\n')); + return listener(req, res); +}); +syncBuiltinESMExports(); diff --git a/tests/shutdown.test.js b/tests/shutdown.test.js index 2e7c856..bc18026 100644 --- a/tests/shutdown.test.js +++ b/tests/shutdown.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; -import { mkdtempSync, realpathSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, realpathSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -48,16 +48,16 @@ test('demo exit leaves live markers and the startup watcher alone', async t => { test('passive reads and automatic opening do not restart an exited app; manual opening clears the latch', t => { const dataDir = directory(t); exitState.mark(dataDir); - const code = `const localTls=(await import(${JSON.stringify(new URL('../server/local-tls.cjs', import.meta.url).href)})).default;localTls.requestOptions=()=>({});let calls=0;localTls.fetchLocal=async()=>{calls++;throw Error('fixture network failure')};const runtime=await import(${JSON.stringify(new URL('../plugin/runtime.js', import.meta.url).href)});let blocked=0;for(const action of [()=>runtime.ensureService(),()=>runtime.openWidget({automatic:true})])try{await action()}catch(error){if(error.message.includes('已退出'))blocked++}const before=calls;try{await runtime.openWidget()}catch{};console.log(JSON.stringify({blocked,before,after:calls}));`; - const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', code], { env: { ...process.env, CONTINUER_DATA_DIR: dataDir, CONTINUER_DEMO: '' }, windowsHide: true, encoding: 'utf8' })); + const probe = fileURLToPath(new URL('./fixtures/shutdown-passive.mjs', import.meta.url)); + const result = JSON.parse(execFileSync(process.execPath, [probe], { env: { ...process.env, CONTINUER_DATA_DIR: dataDir, CONTINUER_DEMO: '' }, windowsHide: true, encoding: 'utf8' })); assert.deepEqual(result, { blocked: 2, before: 0, after: 1 }); assert.equal(exitState.requested(dataDir), false); }); test('verified force cleanup terminates an owned service tree and refuses unrelated PIDs', { skip: process.platform !== 'win32', timeout: 20000 }, async t => { const dataDir = directory(t), root = join(dataDir, 'plugin'); mkdirSync(join(root, 'server'), { recursive: true }); const script = join(root, 'server/index.js'), childFile = join(dataDir, 'child.json'); - writeFileSync(script, `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore',windowsHide:true});require('node:fs').writeFileSync(${JSON.stringify(childFile)},JSON.stringify({pid:child.pid}));setInterval(()=>{},1000);`); - const server = spawn(process.execPath, [script], { windowsHide: true, stdio: 'ignore' }), exited = once(server, 'exit'); + copyFileSync(new URL('./fixtures/shutdown-owned-service.cjs', import.meta.url), script); + const server = spawn(process.execPath, [script, childFile], { windowsHide: true, stdio: 'ignore' }), exited = once(server, 'exit'); let childPid; t.after(async () => { if (server.exitCode === null) server.kill(); if (childPid && alive(childPid)) process.kill(childPid); }); const helper = fileURLToPath(new URL('../plugin/stop-owned.ps1', import.meta.url)); @@ -77,10 +77,10 @@ test('watcher cleanup recognizes quoted --watch arguments used by the startup bo const source = join(dataDir, 'watcher.cs'), executable = join(desktop, 'continuer-monitor.exe'); mkdirSync(join(root, 'plugin'), { recursive: true }); const launcherPidFile = join(dataDir, 'launcher.json'); - writeFileSync(join(root, 'plugin/open.js'), `require('node:fs').writeFileSync(${JSON.stringify(launcherPidFile)},JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);`); + copyFileSync(new URL('./fixtures/shutdown-owned-launcher.cjs', import.meta.url), join(root, 'plugin/open.js')); writeFileSync(source, 'class Watcher { static void Main(string[] args) { var info=new System.Diagnostics.ProcessStartInfo(args[1], "\\\"" + System.IO.Path.Combine(args[2],"plugin/open.js") + "\\\"");info.UseShellExecute=false;info.CreateNoWindow=true;System.Diagnostics.Process.Start(info);System.Threading.Thread.Sleep(-1); } }'); execFileSync(join(process.env.SystemRoot, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'), ['/nologo', '/target:winexe', '/out:' + executable, source], { windowsHide: true }); - const watcher = spawn(executable, ['"--watch"', '"' + process.execPath + '"', '"' + root + '"', '"' + dataDir + '"'], { windowsHide: true, windowsVerbatimArguments: true, stdio: 'ignore' }); + const watcher = spawn(executable, ['"--watch"', '"' + process.execPath + '"', '"' + root + '"', '"' + dataDir + '"'], { env: { ...process.env, CONTINUER_TEST_LAUNCHER_PID_FILE: launcherPidFile }, windowsHide: true, windowsVerbatimArguments: true, stdio: 'ignore' }); const exited = once(watcher, 'exit'); writeFileSync(join(dataDir, 'autostart-status.json'), JSON.stringify({ pid: watcher.pid, pluginRoot: root })); let launcherPid; try { diff --git a/tests/widget-animation.test.js b/tests/widget-animation.test.js index 11ba159..d767495 100644 --- a/tests/widget-animation.test.js +++ b/tests/widget-animation.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { mkdtempSync, realpathSync, existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, realpathSync, existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -14,38 +14,17 @@ test('real quota CSS stays visible across artwork phases and fades smoothly over const captureDir = process.env.CONTINUER_CAPTURE_DIR ? resolve(process.env.CONTINUER_CAPTURE_DIR) : null; const css = readFileSync(new URL('../public/widget.css', import.meta.url), 'utf8'); const art = readFileSync(new URL('../public/rhine-signal-loop.svg', import.meta.url), 'utf8'); - const html = readFileSync(new URL('../public/widget.html', import.meta.url), 'utf8').replace(/]*>[\s\S]*?<\/script>/g, '') + const html = readFileSync(new URL('../public/widget.html', import.meta.url), 'utf8') .replace('', '') - .replace('', '').replace('', ''); + .replace('', '').replace('', '') + + ''; writeFileSync(page, html); - writeFileSync(probe, ` - const {app,BrowserWindow}=require('electron');const fs=require('node:fs');const path=require('node:path'); - const mark=stage=>fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({stage}));mark('starting'); - app.setPath('userData',${JSON.stringify(join(dir, 'profile'))}); - app.whenReady().then(async()=>{ - const win=new BrowserWindow({width:256,height:256,show:false,frame:false,transparent:true,webPreferences:{offscreen:true,sandbox:true,contextIsolation:true,nodeIntegration:false}}); - mark('window-created'); - await win.loadFile(${JSON.stringify(page)}); - mark('page-loaded'); - win.webContents.debugger.attach();await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'no-preference'}]}); - mark('media-configured'); - await win.webContents.executeJavaScript("document.querySelector('#mini').classList.add('has-art');document.querySelector('#mini').dataset.motion='running';document.querySelector('#mini-quota').textContent='77';document.querySelector('#mini-reset').textContent='02:00:00';document.querySelector('#mini-reset').classList.remove('visually-empty');document.getAnimations().forEach(a=>a.pause());"); - mark('initialized'); - const phases=[];for(const at of [0,15000,30000,45000,59000])phases.push(await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime="+at+");({at:"+at+",opacity:Number(getComputedStyle(document.querySelector('#gauge-readout')).opacity),animation:getComputedStyle(document.querySelector('#gauge-readout')).animationName})")); - mark('phases-checked'); - await win.webContents.executeJavaScript("document.getAnimations().forEach(a=>a.currentTime=0);document.querySelector('#mini').classList.add('is-refreshing');globalThis.fade=document.querySelector('#quota-readout').getAnimations().find(a=>a.animationName==='quota-reload');fade.pause();"); - const duration=await win.webContents.executeJavaScript('fade.effect.getTiming().duration'); - const frames=[];for(const at of [0,250,500,750,1000]){ - frames.push(await win.webContents.executeJavaScript("fade.currentTime="+at+";if("+at+">=500){document.querySelector('#mini-quota').textContent='39';document.querySelector('#mini-window').textContent='七天可用额度';document.querySelector('#mini-reset').textContent='1天 02:00:00'}({at:"+at+",opacity:Number(getComputedStyle(document.querySelector('#quota-readout')).opacity)})")); - if(${JSON.stringify(captureDir)}){fs.mkdirSync(${JSON.stringify(captureDir)},{recursive:true});await new Promise(r=>setTimeout(r,40));fs.writeFileSync(path.join(${JSON.stringify(captureDir)},'quota-'+at+'.png'),(await win.webContents.capturePage()).toPNG());} - } - fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({phases,frames,duration}));win.destroy();app.quit(); - }).catch(error=>{fs.writeFileSync(${JSON.stringify(output)},JSON.stringify({error:error.stack}));app.exit(1)}); - `); + copyFileSync(new URL('./fixtures/widget-animation.cjs', import.meta.url), probe); try { - try { await execute(electron, ['--disable-gpu', probe], { env: { ...process.env, NODE_OPTIONS: '', ELECTRON_RUN_AS_NODE: undefined }, timeout: 20000, windowsHide: true }); } + try { await execute(electron, ['--disable-gpu', probe], { env: { ...process.env, NODE_OPTIONS: '', ELECTRON_RUN_AS_NODE: undefined, CONTINUER_TEST_ANIMATION_PAGE: page, CONTINUER_TEST_ANIMATION_OUTPUT: output, CONTINUER_TEST_ANIMATION_CAPTURE: captureDir || '' }, timeout: 20000, windowsHide: true }); } catch (error) { error.message += existsSync(output) ? '\nProbe: ' + readFileSync(output, 'utf8') : '\nProbe did not start'; throw error; } const result = JSON.parse(readFileSync(output, 'utf8')); assert.equal(result.error, undefined); + assert.equal(result.scriptsRemaining, 0); assert.equal(result.scriptRan, false); assert.equal(result.duration, 1000); for (const phase of result.phases) { assert.equal(phase.opacity, 1); assert.equal(phase.animation, 'none'); } assert.equal(result.frames[0].opacity, 1); assert.equal(result.frames[2].opacity, 0); assert.equal(result.frames[4].opacity, 1); diff --git a/tests/widget-smoke.test.js b/tests/widget-smoke.test.js index f200e20..9ebbdc3 100644 --- a/tests/widget-smoke.test.js +++ b/tests/widget-smoke.test.js @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'; import { createServer } from 'node:net'; import { once } from 'node:events'; import { setTimeout as delay } from 'node:timers/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import access from '../server/access.cjs'; import { pinnedFetch } from './fixtures/local-fetch.js'; @@ -15,13 +15,11 @@ const executable = fileURLToPath(new URL('../node_modules/electron/dist/electron for (const expanded of [false, true]) test(`hidden isolated widget is ready without loading unused views (expanded: ${expanded})`, { skip: process.platform !== 'win32' || !existsSync(executable), timeout: 40000 }, async () => { const parent = realpathSync(tmpdir()), directory = mkdtempSync(join(parent, 'continuer-widget-smoke-')); writeFileSync(join(directory, 'widget-preferences.json'), JSON.stringify({ follow: false, hidden: true, expanded })); - const trace = join(directory, 'requests.jsonl'), preload = join(directory, 'trace.mjs'); - writeFileSync(preload, `import http from 'node:https';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs'; - const original=http.createServer;http.createServer=(options,listener)=>original(options,(req,res)=>{appendFileSync(${JSON.stringify(trace)},JSON.stringify(req.url)+'\\n');res.on('finish',()=>appendFileSync(${JSON.stringify(trace + '.responses')},JSON.stringify({path:req.url,status:res.statusCode,authenticated:!!req.headers.authorization})+'\\n'));return listener(req,res)});syncBuiltinESMExports();`); + const trace = join(directory, 'requests.jsonl'), preload = new URL('./fixtures/widget-trace.mjs', import.meta.url); const probe = createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); const env = { ...process.env, CONTINUER_DATA_DIR: directory, CONTINUER_DEMO: '1', CONTINUER_PORT: String(port), PORT: String(port), ELECTRON_RUN_AS_NODE: undefined }; - const server = spawn(process.execPath, ['server/index.js', '--demo', '--observe-only'], { env: { ...env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, windowsHide: true, stdio: 'ignore' }); + const server = spawn(process.execPath, ['server/index.js', '--demo', '--observe-only'], { env: { ...env, CONTINUER_TEST_TRACE_FILE: trace, NODE_OPTIONS: `--import=${preload.href}` }, windowsHide: true, stdio: 'ignore' }); const secureFetch = pinnedFetch(directory, true); const authenticatedFetch = (url, options = {}) => secureFetch(url, { ...options, headers: { ...options.headers, Authorization: 'Bearer ' + access.readToken(directory, true) } }); let widget; From 8628d41318a92c23b02e9d46caa22f41c5ef90b5 Mon Sep 17 00:00:00 2001 From: empty610 Date: Tue, 27 Oct 2026 23:08:26 +0800 Subject: [PATCH 8/8] test: record fixed route labels instead of raw request URLs --- tests/fixtures/widget-trace.mjs | 8 ++++++-- tests/widget-smoke.test.js | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/widget-trace.mjs b/tests/fixtures/widget-trace.mjs index 89c8f6a..4623d6e 100644 --- a/tests/fixtures/widget-trace.mjs +++ b/tests/fixtures/widget-trace.mjs @@ -3,10 +3,14 @@ import { syncBuiltinESMExports } from 'node:module'; import { appendFileSync } from 'node:fs'; const trace = process.env.CONTINUER_TEST_TRACE_FILE; +const routes = ['/api/health', '/api/state', '/api/events', '/api/threads', '/widget.html', '/widget.css', '/widget.js', '/rhine-signal-loop.svg']; +// The test observes route categories, not arbitrary request text or queries. +const routeLabel = url => routes.find(route => url === route || url.startsWith(route + '?')) || 'other'; const original = http.createServer; http.createServer = (options, listener) => original(options, (req, res) => { - appendFileSync(trace, JSON.stringify(req.url) + '\n'); - res.on('finish', () => appendFileSync(trace + '.responses', JSON.stringify({ path: req.url, status: res.statusCode, authenticated: !!req.headers.authorization }) + '\n')); + const route = routeLabel(req.url); + appendFileSync(trace, JSON.stringify(route) + '\n'); + res.on('finish', () => appendFileSync(trace + '.responses', JSON.stringify({ path: route, status: res.statusCode, authenticated: !!req.headers.authorization }) + '\n')); return listener(req, res); }); syncBuiltinESMExports(); diff --git a/tests/widget-smoke.test.js b/tests/widget-smoke.test.js index 9ebbdc3..a9f2131 100644 --- a/tests/widget-smoke.test.js +++ b/tests/widget-smoke.test.js @@ -50,6 +50,7 @@ for (const expanded of [false, true]) test(`hidden isolated widget is ready with for (let i = 0; clients && i < 25; i++) { await delay(50); clients = (await (await authenticatedFetch(`https://127.0.0.1:${port}/api/health`)).json()).resources.sseClients; } assert.equal(clients, 0, 'Hidden widget must release its bootstrap stream'); const requests = readFileSync(trace, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + assert.ok(requests.includes('/api/health'), 'The trace must record the known health route'); assert.equal(requests.includes('/api/threads'), false, 'Hidden widgets should not load session lists'); assert.equal(requests.includes('/rhine-signal-loop.svg'), false, 'Hidden widgets should not load the animation'); spawn(executable, ['--disable-gpu', 'desktop/main.cjs', '--quit'], { env, windowsHide: true, stdio: 'ignore' }).unref();