Skip to content

Commit a98dda4

Browse files
authored
Merge pull request #7 from 2233admin/feat/model-provider-mgmt
预存在bug: tests/unit/channels/test_opencli_channel.py两个测试(no such table: browser_bindings)main分支自身CI已连续失败,与本分支无关(diff确认该文件本分支未改动)。
2 parents e60c473 + b8c24a1 commit a98dda4

56 files changed

Lines changed: 6316 additions & 160 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AUDIT-cybernetic-remediation.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# 系统工程审核 + 控制论修复方案
2+
3+
> 产出方式:5 路并行 Sonnet 子 agent 审计(架构/数据链路/安全/前端契约/测试运维),
4+
> P0 级 + 锚定发现由主模型逐条读码复核。日期 2026-07-02。
5+
> 分支 `refactor/thin-channel-thick-runner`
6+
>
7+
> 标注:✅ = 已读码亲验 / ▫ = agent 报告,未独立复核。
8+
9+
## 部署信任模型(已定 2026-07-02)
10+
11+
**局域网通信走 NetBird / WireGuard mesh,不做直接公网端口映射。**
12+
13+
- **入站边界 = WG 层**:只有 fleet overlay 上已认证的 peer 能触达 API/MCP/odp-ingest。
14+
应用层"无鉴权"由此获得真实网络边界兜底 → 相关发现(轮子 7)从 P1 降为 **P2 纵深防御备案**
15+
- **出站威胁不受 WG 保护**:SSRF 类威胁是"服务器被恶意内容(爬到的网页/RSS/被改的 provider
16+
`base_url`)骗着主动发出请求"。WG 挡入站不挡出站——出站可打 fleet overlay(100.80.x.x)、
17+
本机 localhost 服务(redis 6379 等),或把真实 LLM key 外泄到公网。**轮子 4 仍必修(P1)。**
18+
- **LLM agent 是可信 mesh 内的不可信因子**:MCP`create_source`+`trigger_task` 暴露给 agent,
19+
而 agent 处理不可信内容(爬网页/RSS),prompt injection 可触发 cli 渠道任意二进制 →
20+
**轮子 4 的 binary 白名单仍值做(P1 硬化),不因可信 mesh 而免**
21+
22+
---
23+
24+
## 一、控制论诊断:这不是一堆孤立 bug,是四类回路病
25+
26+
维纳四原则拿来当透镜,本次绝大多数发现归到前两类——**反馈回路断裂****信息丢失**
27+
这解释了为什么它们分散在 Python/Rust/React 三层却"长得一样"(同构性),也决定了修法:
28+
不是逐点打补丁,是**装轮子**——一处机制,全层复用,让系统自己纠错(自组织)。
29+
30+
| 控制论原则 | 系统里对应的东西 | 违反它的发现 |
31+
|---|---|---|
32+
| **① 反馈是生命线**(感知差异→调整→再执行) | 重试分类、DLQ、错误上浮、CI 门禁 | #1 重试不触发、#3 DLQ 吞消息、#8 主循环 panic、shadow 错误没人读、前端缺 onError、CI 不测关键路径 |
33+
| **② 信息即控制力**(信息丢=控制力衰减) | accepted 计数真实性、可观测性、凭证保密 | #2 摄入黑洞(假 accepted)、#4 毒消息与 trim 混同、明文 key 外泄、无 healthcheck、shadow 计数只进日志 |
34+
| **③ 同构性**(同一模型跨领域复用) | 错误处理模式在三层重复出现 | 缺 onError(React)≡ 重试漏分类(Celery)≡ DLQ 丢信号(Rust)——**同一个病,该用同一个轮子治** |
35+
| **④ 目的性/自组织**(局部规则+全局反馈→有序) | 校验器、契约、不变量在调用点就地强制 | SSRF 无统一校验器、env 开关散落、鉴权靠"部署层但愿"、游标非原子推进 |
36+
37+
---
38+
39+
## 二、发现台账(记录)
40+
41+
### P0 — 会真丢数/坏/被攻破
42+
43+
| # | 位置 | 问题 | 原则 ||
44+
|---|---|---|---|---|
45+
| P0-1 | `backend/pipeline/error_taxonomy.py` + ODP sink 路径 | `httpx.HTTPStatusError` 不在 `_RETRYABLE`,ODP 回 5xx→判永久→`pipeline.py` 吞成失败结果正常返回→Celery `autoretry_for` 不触发。配 `odp_only` 策略=无处落、无自动重试。**机制现成**:有 `is_retryable_http_status()`+`RetryableHTTPStatus` sentinel,只是 ODP 路径没用 |||
46+
| P0-2 | `odp-rs/.../odp-ingest/src/state.rs:24` + `handlers.rs:99-101` | 漏配 Redis→`bus=None``else { accepted += 1 }`,返回 202 但没入流。纯黑洞,运行期零信号。(注:NDJSON 入口对解析失败是 400,黑洞只在 batch+无 bus) |||
47+
| P0-3 | `odp-rs/.../odp-store/src/reap.rs:56-62`(**本仓 `ef4828d` 引入**) | 毒消息(JSON 反序列化失败)走 DLQ 路径时 `read_entries_by_id` 静默丢弃→不进 `found_ids`→被误判"已 trim"直接 XACK,不写 DLQ 行=静默永久丢,且与"真被 trim"安全分支无法区分 | ①② ||
48+
| P0-4 | `backend/channels/cli_channel.py:40,46` | `binary`+`command` 全来自 channel_config,`create_subprocess_exec(*full_cmd)`=任意二进制执行。配无鉴权 API/MCP=RCE。exec-form 无 shell 注入 |||
49+
50+
### P1 — 严重债/潜伏 bug
51+
52+
| # | 位置 | 问题 | 原则 ||
53+
|---|---|---|---|---|
54+
| P1-1 | `backend/pipeline/storer.py:23,38` | 默认 `forward_to_odp=True`,裸 `ODP_INGEST_URL` 触发,绕开 `write_strategy` 状态机→legacy 源意外泄漏进 ODP + 双入口迷惑。(idempotency_key 去重挡掉字面重复,故非重复灌数)。`HANDOFF-strangler-fig.md` 标了"PR3 必收口" |||
55+
| P1-2 | `backend/schemas/provider.py:34` + `api/v1/providers.py:15` | `GET /providers` 明文返回所有 LLM api_key;`ModelProvider.api_key` 明文存库,没走 `SourceCredential` 的 Fernet |||
56+
| P1-3 | `backend/main.py`(全局) | 全 API 无 AuthN/AuthZ(`api_key_enabled` 定义了没 `Depends` 校验);odp-ingest 无 auth 且默认 bind `0.0.0.0`;CORS debug 下 `["*"]`+`allow_credentials=True` |||
57+
| P1-4 | 见下方 SSRF 枚举 | 响应回显型 SSRF(rss/web_scraper/api/crawl4ai 抓用户 URL→`GET /records` 读回=内网读原语);无 scheme 白名单/私网 IP 拦截/元数据防护;多处 `follow_redirects=True`;provider `base_url` 可改指外部主机→连真实 Bearer key 一起外泄 | ②④ ||
58+
| P1-5 | `odp-rs/.../odp-store/src/main.rs:54,59,60` | 主循环裸 `?`,Redis/PG 瞬断→整进程 panic 退出(reap 有 log-continue,主循环没有) |||
59+
| P1-6 | `backend/pipeline/cursor_store.py` + `runner.py` | SELECT-then-write 无原子 UPSERT/行锁;只有按域名进程内信号量,非 per-source→并发触发同源丢更新(漏抓中间段) | ①④ ||
60+
| P1-7 | `backend/pipeline/sinks/dual_sink.py:57` + `pipeline.py` | shadow 写失败塞进 `SinkResult.errors``pipeline.py` 从不读→影子模式 ODP 长期挂,任务仍显 completed,唯一线索是 worker 日志一行 warning |||
61+
| P1-8 | `.github/workflows/ci.yml` |`pytest -m "not live" --no-cov`——无 `alembic upgrade head` 冒烟、无 `cargo test`(odp-rs 连已有单测都不跑)、coverage gate 关掉。迁移链坏能一路绿灯合并 |||
62+
| P1-9 | `tests/unit/pipeline/test_pipeline_errors.py:34,58,153` | 调用签名跟真实 `run_pipeline(task_id, source, parameters=None,...)` 对不上:传 `run_pipeline(db_session, source, task.id)`,`db_session` 当了 task_id(仅进日志 `%s` 侥幸不炸),`task.id` 当了 parameters(UUID 非 dict)。rss 源无 `session_affinity` capability 跳过 `params.get` 分支→侥幸通过=测了假东西 |||
63+
| P1-10 | `frontend/src/pages/BrowsersPage.tsx:778,528` | 重启 API 按钮 + 实例 agent_url 保存两个 mutation 缺 onError,失败全静默 |||
64+
65+
### P2 — 硬化项
66+
67+
- ▫ odp-ingest `dedup.rs` 无上限/TTL→投毒(自选幂等键让合法事件被当 duplicate 丢)+ 内存 DoS;`handlers.rs:71` 整批持单写锁
68+
-`handlers.rs:30` NDJSON 无显式请求体大小上限(仅靠 axum 默认 2MB)
69+
-`odp-store/main.rs:46` 无优雅关闭(裸 `loop`,无 SIGTERM 处理)→部署强杀撞未 flush 批次
70+
-`docker-compose.yml` odp-ingest/odp-store 无 healthcheck;下游 `depends_on: service_started``service_healthy`→起容器时序竞争
71+
-`.env.example` 与实际 env 读取脱节:`ODP_INGEST_REQUIRED`(fail-open/closed 语义关键开关)、`ODP_INGEST_TIMEOUT`、odp-rs 侧 `ODP_DATABASE_URL/ODP_STORE_BATCH_SIZE/ODP_BUS_*` 全没进主文档
72+
-`frontend/src/pages/SourcesPage.tsx:301` 6 种完工渠道被硬编码标"(开发中)"=误导文案
73+
-`frontend/src/pages/SkillsPage.tsx` RecordWizard 修了点取消泄漏,但没堵路由跳转/后退卸载(缺 unmount cleanup)→录制中离开页面照旧泄漏 pool mutex
74+
-`odp-store/src/reap.rs:38` `p.delivery_count > MAX_DELIVERIES`(=5)实为 6 次才进 DLQ,与注释"5"字面不符(应 `>=`)
75+
-`external_http_processor.py:78` `os.path.expandvars(auth_header)`→配置者设 `$OPENAI_API_KEY` 可把宿主 env 注入出站 header
76+
-`config.py:19,91` 弱默认密钥(`change-me-*`);本机 `.env` 已真实覆盖且已 gitignore,无提交泄密
77+
-`frontend` `connectivity_ok/connectivity_errors` 后端有、前端类型直接丢弃
78+
79+
### SSRF 完整出站枚举(20 点,详见安全 agent 原始报告)
80+
81+
需统一校验器覆盖的用户/DB 供给 URL 出站点:
82+
- 回显型(→records 可读回内网):rss_channel(2)、web_scraper_channel、api_channel、crawl4ai_channel、external_http_processor
83+
- 探测 oracle:source_service `discover-feed`(2)
84+
- 盲打:webhook/feishu/wecom notifier(3)
85+
- **凭证外泄型**(base_url 可改+附真实 key):distill、skill_channel、crawl4ai LLMConfig、openai_processor
86+
- health_check 变体(仅 bool):web_scraper/api/crawl4ai(3)
87+
88+
---
89+
90+
## 三、修复方案 = 8 个"轮子"(可复用机制,非逐点补丁)
91+
92+
排序 = 性价比。每个轮子标:治哪些发现 / 控制论原则 / 大致工作量。
93+
94+
### 轮子 1 — DLQ 毒消息可区分【P0-3】① ② · 小
95+
`reap.rs` 必须把"解析失败"与"真被 trim"分开:`read_entries_by_id` 对解析失败的条目保留原始 bytes 放进 DLQ payload(或单独计数+告警),绝不走"没 entry 就直接 ack"分支。
96+
**这是本仓上轮自己引入的丢数 bug,码在手边,最先修。**
97+
98+
### 轮子 2 — 重试分类闭环【P0-1, P1-5】① · 小
99+
(a) ODP sink/`odp_client` 捕获 `httpx.HTTPStatusError`,对 5xx/429 走已有的 `is_retryable_http_status()`→设 `RetryableHTTPStatus` error_type,让负反馈回路真正闭合。
100+
(b) `odp-store/main.rs` 主循环三个裸 `?` 改 log-and-continue(沿用 reap 的模式),瞬断不再 panic 退出。
101+
102+
### 轮子 3 — 摄入信号真实性【P0-2】② · 小
103+
odp-ingest `bus=None` 时要么 fail-fast 拒绝启动,要么响应显式标 `degraded/no-op`**不计入 accepted**。假 accepted = 被污染的信息 = 假的控制力。
104+
105+
### 轮子 4 — 统一 URL 校验器【P0-4 部分, P1-4, 多个 P2】④ · 中
106+
一个 `safe_url(url)` 模块(仅 http/https;解析后拦 RFC1918/loopback/link-local/元数据 169.254;禁跳转到私网;pin 已解析 IP),在全部 20 个出站点就地调用。**同构性**:一处规则全层复用;**自组织**:每个调用点就地强制不变量,不需中央防火墙。cli_channel 额外加 binary 白名单。
107+
108+
### 轮子 5 — 错误上浮契约【P1-7, P1-10, 重试耗尽告警】① ② · 中
109+
定一条契约:任何失败都必须浮到可观测通道。
110+
- Python:`DualSink` shadow 错误接入 `events.emit(level=warning)` 或写回 `TaskRun`;Celery 重试耗尽发 dedicated 信号而非只写 DB 状态字段。
111+
- React:所有 `useMutation` 缺 onError 的补齐 toast(BrowsersPage 两处起)。
112+
**同构性**:三层同一个"别吞错误"模式,一份契约拉齐。
113+
114+
### 轮子 6 — CI 反馈门禁【P1-8, P0-3/P1-5 回归防护】① · 中
115+
这是元反馈回路——**捕捉断裂反馈回路的系统**。加:
116+
- `alembic upgrade head`(fresh db)+ `downgrade -1 && upgrade head` 冒烟 job
117+
- `cargo test --workspace`(odp-rs)job
118+
- odp-store writer/reap 的 testcontainers 集成 job(redis+postgres,先 2-3 个关键用例:dead_letter 失败不 ack、reap 重复 claim 幂等、savepoint 隔离单条坏消息)
119+
- 恢复 coverage gate
120+
121+
### 轮子 7 — 鉴权边界【P1-3 → 降 P2 备案;P1-2 部分保留】④ · 视情
122+
**已定:WG mesh 承担入站边界**,故应用层鉴权降为纵深防御备案(非急):全局 API-key/session
123+
`Depends`、CORS 修正、bind 收到 WG 接口/127.0.0.1(而非 0.0.0.0,防 split-tunnel 误配扩大暴露面)。
124+
**但 P1-2 的 provider key 加密+响应 mask 保留(挪进轮子 4 一起做)**——因为 key 外泄的真实路径
125+
是 SSRF 出站 exfil + 日志 + DB 备份,不是入站,WG 救不了。
126+
127+
### 轮子 8 — 游标原子推进 + strangler 收口【P1-1, P1-6】① ④ · 中
128+
(a) `source_cursors``INSERT ... ON CONFLICT (source_id) DO UPDATE``SELECT FOR UPDATE`;游标只在"这批已确认落盘"后推进。
129+
(b) `storer.py` 默认 `forward_to_odp=False`,删掉裸 env var 兜底路径,ODP forward 只由显式 `write_strategy` 触发(收口 HANDOFF 标注的 PR3)。
130+
131+
---
132+
133+
## 四、落地批次
134+
135+
|| 内容 | 理由 |
136+
|---|---|---|
137+
| **B1(先)** | 轮子 1+2+3 | 三个数据链路 P0,全在既有职责内,码已读清,小改+补测,直接堵静默丢数 |
138+
| **B2** | 轮子 6 | 装 CI 门禁,给后续所有改动兜底(含 B1 的回归) |
139+
| **B3** | 轮子 4+5 | SSRF 校验器 + 错误上浮,面大但同构,一次机制多点收 |
140+
| **B4** | 轮子 8 | strangler 收口 + 游标原子性,需设计确认 |
141+
| **B5(降级备案)** | 轮子 7 | 部署面已定 WG mesh → 应用层鉴权降 P2 纵深防御,不进主线 |
142+
143+
> 部署面已定(WG mesh),轮子 7 降级备案。B3 的 SSRF 校验器现含 provider key 加密+mask。

GOAL-2.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# GOAL-2 — opencli-admin Phase 2/3: AuthManager + session affinity 泛化
2+
3+
> `/loop` 自驱(接 strangler-fig GOAL 完成 `09e4860` 后的新里程碑)。同纪律:
4+
> 每轮读本文件 → 下个未完 PR → 端到端做+测绿 → 自检 staged → auto-commit → 勾掉。
5+
> 命中真分叉 → 停问,别 big-bang。
6+
> 用户 2026-07-01 授权**每 PR 绿即 auto-commit**(仅限此 goal,显式 add 路径,push 等用户)。
7+
8+
## 坐标
9+
- repo `D:\projects\opencli-admin` 分支 `refactor/thin-channel-thick-runner`(接 `09e4860`
10+
- 测试闸 `uv run pytest tests/unit --no-cov -q`(基线 **379**);PowerShell 跑
11+
- ⚠️ 永不 stage:`backend/api/v1/chat.py``PR-DESCRIPTION.md``HANDOFF-strangler-fig.md``GOAL.md``GOAL-2.md`
12+
13+
## Track 1 — session affinity 泛化(Phase 3,低风险)
14+
- [x] **PR-A** — pipeline 绑定 gate 改读 `capabilities.session_affinity`;opencli+skill 声明 `Capabilities(session_affinity=True)``073d391`,382 passed,行为零变)。
15+
- [x] **PR-B** `6e08d41`(404 passed)— 按域名并发上限 = **task 层进程内**(option ②,用户拍板):`domain_limiter.domain_of(source)` 取 host + `domain_slot()` per-domain semaphore(`PER_DOMAIN_CONCURRENCY` 默认 3,registry 按 `(loop,domain)` 键),runner Phase3 外套。全渠道覆盖(含 opencli/skill)。跨 worker 严格限 = 换 Redis(同插入点,缓)。
16+
17+
> **GOAL-2 完成**(2026-07-01):Track-1(PR-A+PR-B) + Track-2(PR-C) 全落,379→**404 passed**,零回归,**已 push fork**`09e4860..6e08d41`)。
18+
19+
## Track 2 — AuthManager + 加密凭据(Phase 2)✅ **DONE**
20+
- [x] **PR-C** `d4aa324`(397 passed)— Fernet 加密凭据存储(`source_credentials` 表 + migration `q7l8m9n0o1p2`) + `AuthManager`(store/resolve/`resolve_context→AuthContext`) + channel_runner 注入 AuthContext(替占位) + api_channel 内联明文 **deprecation warning**`cryptography` 提为直接依赖。已选方案=Fernet + env `CREDENTIAL_ENCRYPTION_KEY` + 表;接线深度到 AuthManager(未强迁 api_channel→`fetch()`=follow-up)。
21+
<details>原决策记录(已决议):
22+
1. 加密方案:Fernet/AES(`cryptography` 依赖在否待查)
23+
2. master key 来源:env var
24+
3. 存储:新 `source_credentials` 表 / `DataSource` 字段
25+
4. 迁移现有内联 secret
26+
5. 接线深度:api_channel 还没上 `fetch()``AuthContext` 走 runner 注入需 api_channel 迁厚契约(run_channel 已建 `AuthContext(kind=cap.auth_kind)` 占位)
27+
28+
## 每 PR 验收 / 停止条件 / 提交策略 = 同 `GOAL.md`
29+
行为零变(旧路径)、全 tests/unit 绿、显式 stage、auto-commit、真分叉停。

0 commit comments

Comments
 (0)