Commit Graph

6 Commits

Author SHA1 Message Date
688dce88d1 refactor(rename)!: llmsproxy → modelrouter(module / 二进制 / 路径 / env)
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容,
已部署的机器不会因为顺序问题立刻失效。

- go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。
- `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、
  `/usr/local/bin` 目标全部改为 modelrouter。
- 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与
  `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。
- 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN /
  BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` →
  `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。
- 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退,
  避免升级到一半的 profile 被判成"core binary missing"。
- 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、
  旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。
- `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**,
  否则会部署出一个"能启动但没有 key、没有源"的空实例。
- 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。

兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留,
它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。

本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、
`adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
2026-10-26 22:28:08 +08:00
6fe6014d92 chore: 清理 emoji 与修正发布脚本仓库名
产品代码统一为纯文本风格(照参考实现),去掉 emoji:billing/quota/usage 插件、
channels 的提示文案、WebUI 文案,以及 go 测试、docs、packaging 脚本中的
注意/OK/FAIL 等符号化写法。第三方目录(vendor、node_modules、.probe)不动。

另:packaging/upload-release.sh 的仓库名改为真实全名
JianFeeeee/ModelRouter → HeNanMU-EEA/ModelRouter(前者是重定向,POST/PUT 不跟随
会 404)。
2026-10-13 13:42:57 +08:00
d4ae2a1d13 fix: 回退两处内核改动,channels 改为插件自持持久化(照参考实现)
## 回退的内核改动
用户指出「你又tm改内核?」—— 核对后确认两处本可避免:
  1. internal/lua/vm.go  +32 行  新增 base64_decode 全局函数
  2. internal/lua/plugin_http_handler.go +16 行  InvokeHandler 补 markDirty
工作区已还原到 v2.0.2(git diff v2.0.2 = 0 行)。

## 为什么两处都是错的
参考实现(dsh-our-free-model/vendor/channel-pack)对同一问题的做法:
- 解 JWT:用**语言自带**的 `Buffer.from(..., 'base64url')`,从不为此改宿主。
  → 我改成了插件的纯 Lua b64url_decode(约 20 行),实测解出真 JWT 的
    nickname/sub/exp 全对。
- 状态持久化:插件**自己 writeFileSync+renameSync** 写
  `channel-pack/state.json`,从不依赖宿主 settings。
  → 参考还记下了同构教训(Gitee IKI7WT):DSH 0.1.7 移除 settings.register()
    后账号列表退化为纯内存;正解是**插件探测宿主能力、自持文档**,不是要求
    宿主改。我的 markDirty 修法方向相反:把插件的数据安全外包给宿主某条
    分发路径的实现细节。

## channels 改为插件自持存储
- `.channels-store.json`(tmp + rename 原子替换),只放**索引与配置**
  (账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
  凭据仍在 .secrets/(0600),两者分离:即便文档泄露也不含 token。
- 变更点同步落盘(不用「置标志+定时 flush」):本插件无 hooks、45 个端点
  各自变更状态,还有大量内部互调;挂在某个边界就必须保证**每条**路径都
  经过它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。
  状态实测 ~1.7KB,写入只发生在运维动作上,不在请求热路径。
- 黑名单写入收口到 setModelDisabled(写点曾有 5+ 处,散着写必漏)。
- 存量迁移:读内核遗留 `.channels.state.json`(v2.0.4 内核写的)→ 写自有
  文档 → **写成功才删**遗留(写失败还删=用一次迁移丢数据);自有存储已存在
  时也删遗留,否则内核 restore() 会在启动时用它覆盖。

## 顺带修了两个真实缺陷
1. **测试污染生产目录**(严重):插件 storeDir() 读 /etc/llmsproxy/config.yaml
   决定落盘位置,而既有测试都没隔离 → 一次全量测试把 26 个测试账号写进了
   **生产** .channels-store.json(含 fixture-a/example 等夹具 id)。
   prod 文件已删除(真实账号另有来源);kernelTestCore 现在隔离插件目录,
   且尊重调用方已设的值(多次「重启」需共用同一目录)。
2. model_set_disabled 改了写入口后仍引用已删除的局部变量(nil 索引崩溃)。

## 判据(变异全部咬人)
- TestChannelsStoreIsPluginOwned:状态写进**插件自己的**文件、不含凭据字段、
  新实例读回。变异(markDirty 空操作)→ FAIL。
- TestChannelsMigrationKeepsLegacyOnWriteFailure:**写失败必须保留遗留文件**。
  变异(无条件删)→ FAIL。(这条单独测:迁移成功后的删除有两条路径互相掩盖,
  只测「最终文件没了」区分不出「写失败也删了」。)
- TestChannelsMigratesLegacyKernelState:遗留账号迁入内存 + 自有存储写出 +
  遗留文件删除。
- 内核对照 v2.0.2:diff 为空。

go test ./internal/core/(78s)与 ./internal/lua/(63s)全绿;
全量测试后确认生产插件目录未被写入。
2026-10-13 09:18:15 +08:00
652a035d47 fix(channels): 测试夹具不再内联进生产目录 + callback 池路径漏网
用户指出「为什么生产插件会有实例平台?」—— 确实是设计错误。

## ① 夹具内联进生产源码(根因)
`PLATFORMS` 里内联了 4 个测试夹具:
  example / googleish / loopbackish / cookieish
后果:生产 `/api/plugins/http/channels/platforms` 返回 **16 个平台,其中 4 个是
夹具**,运维在 UI 上真的能选到。夹具属于测试,不属于发行物。

修法:
- 生产 `PLATFORMS` 只留 12 个真实厂商。
- 新增 `plugin.state.injected_platforms` + `allPlatforms()`;`platformById`
  先查内建再查注入表。
- 新增端点 `channels/inject_platforms`(roles=admin):运行时整表替换注入,
  边界校验 —— 不得覆盖内建平台(注入不能劫持真实厂商)、拒绝重复 id、
  拒绝无 id、限 32 条;空数组 = 清空。
- 8 处运行时遍历点(list_platforms/provider_status/backup/auto_checkin/
  gateway 开关等)改用 `allPlatforms()`;4 处**加载期**遍历(HMAC 钩子绑定、
  declaredHosts)保持 `PLATFORMS` —— 它们在 plugin.state 建立之前、注入尚未
  发生,改了无意义。
- 测试改为经 `injectTestPlatforms` 注入夹具(channels_plugin_test /
  channels_stress_test);e2e 本就用真实平台(minimax/cline/opencode/qoder),
  无需改动。

## ② callback 路径的 account_id 漏网
UI 流程 = account/add 建占位账号 → login 带 account_id → 完成时回填。
`oauth_callback`(trae/gemini/lobsterai 的 callback 流)调 complete_login 时
**没传 account_id** —— 与 buddy poll_login 同一缺陷,此前只修了 device-poll /
state-poll 两条分支,这条是漏网:凭据落到与占位账号无关的 `channels:<plat>`,
池账号永远 has_credential=false。

## 判据
- TestInjectPlatformsRejectsBadInput:注入生效 + 三条拒绝规则 + 空数组清空
- TestTraeCallbackPoolPath:callback 池路径凭据落 `channels:trae:<aid>` 且
  account_list 报 has_credential=true
- 变异:callback 去掉 account_id → TestTraeCallbackPoolPath 立即 FAIL(已实测)

go test ./internal/core/ 全绿(119s)。
2026-10-13 08:33:28 +08:00
74ec1e7874 refactor(lua): 内核回归六项职责 —— 登录/浏览器全部移入插件
用户钉死了内核的职责边界,本提交把越界部分全部拆除:

  内核只做:加载适配器 · auto 调度 · 加载插件 · 调度插件 · 维护源与 key · 签发密钥

## 架构纠正(用户指出的根本错误)

我此前把信任模型弄反了。实测:插件 VM 的 os.execute/io.popen/package 全部可达
(true,true,true) —— 插件从来不是 OS 级沙箱。正确的边界是:

  插件   可信(运维安装),非沙箱,有完整 OS 能力 → 平台知识/登录/浏览器归它
  适配器 不可信(可被动态安装),请求级 → 沙箱归它(12 个内置适配器实测均不碰
         os/io/package,后续沙箱化零风险,属独立一步)

## 删除(内核越界部分)

- plugin_browser.go        内核浏览器管理器(Xvfb/chromium/x11vnc/websockify
                           进程栈、回环监听器)—— 388 行全删,浏览器由插件用
                           os.execute 自己拉起(实测 spawn+reap 闭环可用)
- plugin_login.go          登录会话状态机 + LoginFlow 枚举 + VNC 全局门禁
                           —— 457 行全删。"登录"从内核概念中消失
- gateway/browser_proxy.go viewer 反代
- handlePluginLogin API    登录会话列表端点
- vnc_enabled 全局开关     改为 per-plugin 授权(见下)

## 新增(内核职责内的通用机制,无任何登录/浏览器语义)

- plugin_outbound.go       通用额外出站主机授权:插件声明 plugin.hosts(请求)
                           → 运维在设置页逐个批准(授权)。与 kernel actions
                           同一"声明≠授权"模型。内核只知道白名单加减,不知道
                           host 被用来干什么
- on_unload 生命周期钩子   卸载是"调度插件"的一部分:内核在关闭插件状态前调用
                           插件导出的 on_unload,让它自清理外部资源。内核不知道
                           资源是什么(进程/句柄/远端会话都行)。带进程回收回归
                           测试 TestOnUnloadReapsPluginProcesses

## channels.lua 全量接管

- 登录会话自持:plugin.state.sessions(nonce 只在 begin 响应出现一次,
  惰性过期清理)
- 服务端浏览器栈自持:spawnBrowser 用 io.popen 拉起 Xvfb/chromium/x11vnc/
  websockify 并记 pid,reapBrowser 回收;on_unload 兜底
- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化(轮询/nonce 回调)
  → ③ VNC 无头浏览器兜底(无浏览器系统/自动化无法完成的场景,如回环重定向
  必须落在服务端)
- plugin.auth_hosts → plugin.hosts(语义泛化:出站主机授权与登录无关)

## 验收

- 全仓旧符号零残留(grep 权威确认,代码+注释+lua+测试)
- 内核非测试代码零厂商名
- build + vet 全绿;lua/core/gateway/types/scheduler/provider 全量测试通过
- channels 端到端(登录→凭据→add_source→模型可见)继续通过
2026-10-11 16:24:55 +08:00
fa50ec8bc7 feat(lua): 服务端浏览器登录(VNC)+ 内嵌页签 —— 回环回调的唯一自动化路径
用户指明的场景修正了我的一个实质错误。我之前写"VNC 不解决取凭据,凭据还在
浏览器 profile 里"——这对 cookie 类成立,但对**回环重定向**不成立:

  浏览器在用户机器 → redirect 到 127.0.0.1:P → 打到**用户的**回环 → 服务端收不到 code
  浏览器在服务端   → redirect 到 127.0.0.1:P → 打到**服务端的**回环 → 插件直接收到 code

后者不需要"提取凭据",code 自己流到监听方。所以对回环类登录,服务端浏览器
不是权宜之计而是**唯一自动化路径**。已实测验证(浏览器在服务端时 loopback
回调的 code 落到了服务端监听器)。

## 实现

plugin_browser.go:Xvfb(:N) ← chromium → x11vnc → websockify(noVNC) + 回环回调监听。
五个进程一起起停,因为失败模式是顺序相关的(崩掉的会话留下的 Xvfb 会让下一个
chromium 以看起来像浏览器 bug 的方式失败)。

**默认关闭,且不是"开了 VNC 就能登全部"**:每个平台单独走 vnc 流程才启动。
理由是这套东西风险最高——在服务端跑真实浏览器登录用户的第三方账号。

**边界**:插件拿到的是 view_url,**不是浏览器控制句柄**。CDP 端口不进 Lua。
否则一个插件就能读所有标签页的 cookie,而那个浏览器登录的是运维的账号。

**teardown 必须删 profile**:里面是活的第三方 session cookie,删它是安全契约
的一部分,不是收尾工作。

## viewer 反代(browser_proxy.go)

/browser/<id>/... → 该会话的 websockify。为什么不直连端口:
1. 鉴权——直连等于把已登录的浏览器暴露给任何能访问该端口的人(实测未鉴权
   访问得 302 → /login,带 admin key 得 3912 字节 noVNC 页面)
2. WebSocket——noVNC 核心是 WS 升级,httputil.ReverseProxy 原生处理
3. 同源——WS 路径不需要跨域配置

## 内嵌在插件页签(用户要求)

viewer 用 iframe 内嵌进 channels 插件的登录区,而不是让人跳出去开新窗口:
- 插件 UI 拿到 view_url 就塞进 iframe src,登录完成/失效时清空 src 并隐藏
- 插件只对 `flow = "vnc"` 的平台起浏览器;其他流程继续用用户自己的浏览器
  (等于白开一个高风险的浏览器是浪费)

## 插件本体:channels(同类插件)

单插件多平台,4 个平台示范四种流程(link-poll / callback / paste / vnc)。
登录 → 凭据进保险库(不进 state)→ add_source 接入内核 → 定时领额度。
声明式:plugin.kernel 申请内核动作、plugin.auth_hosts 申请登录主机,
设置页显示"申请中"由运维批准。平台表加一条就多一个平台,内核零改动。

开发中被测试抓到的三个真实缺陷(都在我自己的代码里):
1. handler 返回 `{url=…}` 没有 status/json → 内核**静默丢弃**,200 空响应、
   无任何错误。已把所有 handler 统一为 {status,json},并加契约测试锁死。
2. 插件把 req.body 当表用 → 实际是 JSON **字符串**,所有平台参数都读不到。
   加 bodyOf() 统一解码。
3. 顺带发现工作树里 discover.go 被改成 `return "image"`(非本次改动,像是早先
   变异测试留下的),会改变模型类型推断;已还原。

测试:服务端浏览器会话(进程栈/默认拒绝/回调捕获/teardown 清理 profile)、
viewer 反代鉴权、插件 VNC 端到端、channels 插件端到端(含未授权时如实报告
source_error 而非假装成功)、handler 契约。
2026-10-11 14:26:28 +08:00