@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
package/lifecycle.py
CHANGED
|
@@ -20,8 +20,14 @@ Python 后端是 dsh 宿主进程 spawn 的子进程。宿主退出/崩溃/被 `
|
|
|
20
20
|
父还活着(例如 stdin 不是管道、或管道被无关方关闭)只记日志不自杀。
|
|
21
21
|
|
|
22
22
|
2. **同库单实例** `SingleInstance`
|
|
23
|
-
- `<db>.lock`
|
|
24
|
-
`
|
|
23
|
+
- `<db>.lock` 上**两道锁**:`flock` 排它(`platcompat.lock_fd`)+ 同一文件上的
|
|
24
|
+
**SQLite 排它锁**(`locking_mode=EXCLUSIVE` + 一次写事务,持锁到进程结束);
|
|
25
|
+
再写 `<db>.pid` 记录 `{pid, ppid, repo_dir, parent_watch, started_at}`。
|
|
26
|
+
- 为什么要两道(2026-10-11,Node 后端 M1):flock 只有 Python 侧有(Node 无
|
|
27
|
+
stdlib flock),Node 版改用 SQLite 自身的排它锁,两种锁**互不相干**
|
|
28
|
+
⇒ 只靠 flock 挡不住 Node 版实例(实测:Node 持锁时 Python 的 flock 照样
|
|
29
|
+
成功)。两边都取 SQLite 锁后,同语言与跨语言互斥都成立,且 `kill -9` 后
|
|
30
|
+
由内核/文件锁自动释放,无需清理残留。
|
|
25
31
|
- 锁被占时:读 pid 记录——若记录进程**由父死感知托管**(`parent_watch=true`)
|
|
26
32
|
且其父已死 ⇒ 判定为**存量孤儿**(升级前的强杀残留),SIGTERM 后重试拿锁;
|
|
27
33
|
否则视为真·另一实例,明确报错退出(防双写同库)。
|
|
@@ -39,6 +45,7 @@ Python 后端是 dsh 宿主进程 spawn 的子进程。宿主退出/崩溃/被 `
|
|
|
39
45
|
import json
|
|
40
46
|
import os
|
|
41
47
|
import signal
|
|
48
|
+
import sqlite3
|
|
42
49
|
import sys
|
|
43
50
|
import threading
|
|
44
51
|
import time
|
|
@@ -225,6 +232,8 @@ class SingleInstance:
|
|
|
225
232
|
self.reason = ""
|
|
226
233
|
self.reaped_pid = 0
|
|
227
234
|
self._fd = None
|
|
235
|
+
# SQLite 排它锁的连接(第二道闸,M1);持锁期间常驻,release 时关闭
|
|
236
|
+
self._lock_conn = None
|
|
228
237
|
|
|
229
238
|
# ---------- 记录读写 ----------
|
|
230
239
|
|
|
@@ -259,17 +268,21 @@ class SingleInstance:
|
|
|
259
268
|
|
|
260
269
|
@staticmethod
|
|
261
270
|
def _looks_like_server(pid):
|
|
262
|
-
"""粗校验 pid 是否是 touchstone
|
|
271
|
+
"""粗校验 pid 是否是 touchstone 的后端(防 PID 复用误杀)。
|
|
263
272
|
|
|
273
|
+
两版后端的命令行都要认:Python 是 `server.py`,Node 是 `backend/server.js`
|
|
274
|
+
(2026-10-11 M1 起 Node 版也走同一套 `.pid` 记录与孤儿收口)。
|
|
264
275
|
读不到命令行时返回 True(宁可不杀——见 acquire 的调用点:仅在
|
|
265
276
|
「pid 记录声明 parent_watch 且其父已死」时才走到这里)。
|
|
266
277
|
"""
|
|
267
278
|
try:
|
|
268
279
|
if sys.platform.startswith("linux"):
|
|
269
280
|
with open(f"/proc/{int(pid)}/cmdline", "rb") as f:
|
|
270
|
-
|
|
281
|
+
cmdline = f.read()
|
|
282
|
+
return b"server.py" in cmdline or b"server.js" in cmdline
|
|
271
283
|
import psutil
|
|
272
|
-
|
|
284
|
+
argv = [str(a) for a in psutil.Process(int(pid)).cmdline()]
|
|
285
|
+
return any("server.py" in a or "server.js" in a for a in argv)
|
|
273
286
|
except Exception: # noqa: BLE001
|
|
274
287
|
return True
|
|
275
288
|
|
|
@@ -308,12 +321,93 @@ class SingleInstance:
|
|
|
308
321
|
|
|
309
322
|
# ---------- 加锁 ----------
|
|
310
323
|
|
|
324
|
+
def _acquire_sqlite_lock(self):
|
|
325
|
+
"""第二道闸:同一锁文件上的 SQLite 排它锁(M1,2026-10-11)。
|
|
326
|
+
|
|
327
|
+
Node 版没有 stdlib `flock`,改用 SQLite 自身的排它锁:在 `<db>.lock` 上
|
|
328
|
+
`PRAGMA locking_mode=EXCLUSIVE` + 一次写事务,锁持有到进程/连接结束
|
|
329
|
+
(`kill -9` 由内核释放,无残留文件要清理)。两种锁机制**互不相干**,所以
|
|
330
|
+
两边都要取这一道,跨语言互斥才成立(实测:Node 持锁时 Python 的 flock
|
|
331
|
+
照样能拿到)。
|
|
332
|
+
|
|
333
|
+
返回 `(状态, 文案)`:
|
|
334
|
+
- `("ok", "")` 拿到(连接挂在 `self._lock_conn`)
|
|
335
|
+
- `("busy", "")` 被占用(调用方报「另一实例」并退出)
|
|
336
|
+
- `("unusable", 文案)` 锁文件本身不可用(损坏/是目录/打不开)——
|
|
337
|
+
**不再降级放行**(2026-10-11 评审 P1):降级等于互斥无声消失,
|
|
338
|
+
实测「先起 Python 再起 Node」双方都能就绪 ⇒ 同库双写。
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
conn = sqlite3.connect(self.lock_path, timeout=0)
|
|
342
|
+
except sqlite3.Error as e:
|
|
343
|
+
return "unusable", self._unusable_reason(e)
|
|
344
|
+
try:
|
|
345
|
+
conn.execute("PRAGMA busy_timeout=0")
|
|
346
|
+
conn.execute("PRAGMA locking_mode=EXCLUSIVE")
|
|
347
|
+
# 必须真写一次:EXCLUSIVE 模式在首个写事务时取锁并一直持有
|
|
348
|
+
conn.execute("CREATE TABLE IF NOT EXISTS lock_holder ("
|
|
349
|
+
" id INTEGER PRIMARY KEY CHECK (id = 1),"
|
|
350
|
+
" pid INTEGER NOT NULL, started_at INTEGER NOT NULL)")
|
|
351
|
+
conn.execute("INSERT OR REPLACE INTO lock_holder(id, pid, started_at)"
|
|
352
|
+
" VALUES(1, ?, ?)", (os.getpid(), int(time.time())))
|
|
353
|
+
conn.commit()
|
|
354
|
+
except sqlite3.Error as e:
|
|
355
|
+
try:
|
|
356
|
+
conn.close()
|
|
357
|
+
except sqlite3.Error:
|
|
358
|
+
pass
|
|
359
|
+
if "locked" in str(e) or "busy" in str(e):
|
|
360
|
+
return "busy", ""
|
|
361
|
+
return "unusable", self._unusable_reason(e)
|
|
362
|
+
self._lock_conn = conn
|
|
363
|
+
return "ok", ""
|
|
364
|
+
|
|
365
|
+
def _unusable_reason(self, error):
|
|
366
|
+
"""锁文件不可用的报错文案(与 Node `lock.js:unusableLockReason` 同义)。"""
|
|
367
|
+
return (f"单实例锁文件不可用:{self.lock_path}(库 {self.db_file})。原因:{error}。"
|
|
368
|
+
f"该文件应为 SQLite 库或 0 字节空文件;内容损坏/被换成目录时无法建立互斥,"
|
|
369
|
+
f"继续启动会与另一版后端同库双写(任务重复执行、队列行被抢)。"
|
|
370
|
+
f"修复:确认没有实例在跑之后把它移走再启动"
|
|
371
|
+
f"(mv {self.lock_path} {self.lock_path}.broken);"
|
|
372
|
+
f"或显式 --allow-shared-db 自担双写风险。")
|
|
373
|
+
|
|
374
|
+
def _release_sqlite_lock(self):
|
|
375
|
+
"""释放 SQLite 排它锁(幂等)。"""
|
|
376
|
+
conn, self._lock_conn = self._lock_conn, None
|
|
377
|
+
if conn is None:
|
|
378
|
+
return
|
|
379
|
+
try:
|
|
380
|
+
conn.close()
|
|
381
|
+
except sqlite3.Error:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
def _try_take_locks(self):
|
|
385
|
+
"""两道锁的状态:`("ok"|"busy"|"unusable", 文案)`。
|
|
386
|
+
|
|
387
|
+
flock 拿到但 SQLite 被占/不可用时**回退 flock**:留着不放会挡住下一个 Python
|
|
388
|
+
实例去走「孤儿收口 → 重试」的路径(它只看到 flock 被占),把可恢复的孤儿变成死锁。
|
|
389
|
+
"""
|
|
390
|
+
if not platcompat.lock_fd(self._fd, blocking=False):
|
|
391
|
+
return "busy", ""
|
|
392
|
+
state, detail = self._acquire_sqlite_lock()
|
|
393
|
+
if state == "ok":
|
|
394
|
+
return "ok", ""
|
|
395
|
+
try:
|
|
396
|
+
platcompat.unlock_fd(self._fd)
|
|
397
|
+
except OSError:
|
|
398
|
+
pass
|
|
399
|
+
return state, detail
|
|
400
|
+
|
|
311
401
|
def acquire(self):
|
|
312
|
-
"""拿同库单实例锁。True=拿到(已写 pid 记录);False
|
|
402
|
+
"""拿同库单实例锁。True=拿到(已写 pid 记录);False=不能独占(`reason` 说明原因)。
|
|
313
403
|
|
|
314
404
|
非阻塞试锁 + 存量孤儿收口:拿到即写记录返回;拿不到先读 pid 记录——
|
|
315
405
|
若记录进程由父死感知托管且其父已死,判为存量孤儿,SIGTERM 后在
|
|
316
406
|
`stale_wait` 内重试;否则立即返回 False 并给出可执行的报错文案。
|
|
407
|
+
|
|
408
|
+
**不再有「降级为无锁运行」分支**(2026-10-11 评审 P1):锁文件打不开/损坏时
|
|
409
|
+
返回 False(调用方 exit 3)并给修复步骤——静默降级会让互斥无声消失、同库双写,
|
|
410
|
+
那比「启动被拒」严重得多。
|
|
317
411
|
"""
|
|
318
412
|
os.makedirs(os.path.dirname(self.lock_path), exist_ok=True)
|
|
319
413
|
if self.allow_shared:
|
|
@@ -322,15 +416,18 @@ class SingleInstance:
|
|
|
322
416
|
try:
|
|
323
417
|
fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
324
418
|
except OSError as e:
|
|
325
|
-
self.
|
|
326
|
-
return
|
|
419
|
+
self.reason = self._unusable_reason(e)
|
|
420
|
+
return False
|
|
327
421
|
self._fd = fd
|
|
328
|
-
deadline = time.time() + max(self.stale_wait, 0.5) + 1.0
|
|
329
422
|
reaped = False
|
|
330
423
|
while True:
|
|
331
|
-
|
|
424
|
+
state, detail = self._try_take_locks()
|
|
425
|
+
if state == "ok":
|
|
332
426
|
self._write_record(os.getpid())
|
|
333
427
|
return True
|
|
428
|
+
if state == "unusable":
|
|
429
|
+
self.reason = detail
|
|
430
|
+
return False
|
|
334
431
|
rec = self._read_record()
|
|
335
432
|
if rec and not reaped and self._reap_stale(rec):
|
|
336
433
|
reaped = True # 孤儿已收口:其锁随进程退出释放,再试
|
|
@@ -343,9 +440,14 @@ class SingleInstance:
|
|
|
343
440
|
f"(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB),"
|
|
344
441
|
f"或显式 --allow-shared-db 自担双写风险。")
|
|
345
442
|
else:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
443
|
+
# 无归属记录时**不要**教用户删锁文件:删掉后新实例会在新 inode 上取锁,
|
|
444
|
+
# 与仍在运行的占用者双写(flock 与 SQLite 锁同理)。给安全的两个出口。
|
|
445
|
+
self.reason = (
|
|
446
|
+
f"锁文件被占用且无归属记录:{self.lock_path}"
|
|
447
|
+
f"(库 {self.db_file})。占用者可能是另一版后端的异常窗口"
|
|
448
|
+
f"(Node 版取锁后、写 .pid 前被杀)或残留的第三方进程;"
|
|
449
|
+
f"如需并行请用独立库(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB),"
|
|
450
|
+
f"或显式 --allow-shared-db 自担双写风险。")
|
|
349
451
|
return False
|
|
350
452
|
# 不可达:while True 内所有分支都 return(保静态检查可读性)
|
|
351
453
|
|
|
@@ -354,6 +456,7 @@ class SingleInstance:
|
|
|
354
456
|
rec = self._read_record()
|
|
355
457
|
if rec and int(rec.get("pid") or 0) == os.getpid():
|
|
356
458
|
self._remove_record()
|
|
459
|
+
self._release_sqlite_lock()
|
|
357
460
|
if self._fd is not None:
|
|
358
461
|
platcompat.unlock_fd(self._fd)
|
|
359
462
|
try:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavinc-cn/touchstone-dsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "AI-agent-driven automated testing platform for DeepSeek Harness (dsh): Markdown case library, bug reports, per-project serial queue, development board and built-in load testing.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Touchstone contributors",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"*.py",
|
|
29
29
|
"requirements.txt",
|
|
30
|
+
"backend/server.js",
|
|
31
|
+
"backend/lib",
|
|
32
|
+
"backend/routes",
|
|
33
|
+
"backend/schema",
|
|
30
34
|
"builtin_prompts",
|
|
31
35
|
"extensions",
|
|
32
36
|
"dsh-plugin/cordis.patch.yml",
|
|
@@ -55,6 +59,7 @@
|
|
|
55
59
|
},
|
|
56
60
|
"scripts": {
|
|
57
61
|
"build:client": "node dsh-plugin/scripts/build.mjs",
|
|
62
|
+
"test:backend": "node --test \"backend/test/**/*.test.mjs\"",
|
|
58
63
|
"prepack": "node dsh-plugin/scripts/prepack-check.mjs"
|
|
59
64
|
},
|
|
60
65
|
"repository": {
|
package/requirements.txt
CHANGED
|
@@ -4,6 +4,7 @@ zstandard>=0.25.0
|
|
|
4
4
|
# 飞书开放平台 REST(M2 入站:回复/补丁/bot 信息;tenant_access_token 自管,2h-5min 刷新)
|
|
5
5
|
requests>=2.31
|
|
6
6
|
# 飞书 ws 长连接(M2 入站事件 im.message.receive_v1;仅承担长连接,REST 走 requests)
|
|
7
|
-
|
|
7
|
+
# ≥1.5.5:M5 配置自动化用 lark_oapi.register_app 做「扫码一键建应用」(低版本无此入口)
|
|
8
|
+
lark-oapi>=1.5.5
|
|
8
9
|
# Windows 原生运行:进程树终止/PID 探活/端口归属(platcompat Windows 分支懒加载,Linux 运行时不导入)
|
|
9
10
|
psutil>=5.9.0
|
package/server.py
CHANGED
|
@@ -17,6 +17,7 @@ runner.py(任务轮次执行)、prompts.py(轮次 prompt)。
|
|
|
17
17
|
|
|
18
18
|
import base64
|
|
19
19
|
import collections
|
|
20
|
+
import faulthandler
|
|
20
21
|
import json
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
@@ -31,6 +32,12 @@ import time
|
|
|
31
32
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
32
33
|
from urllib.parse import parse_qs, quote, unquote
|
|
33
34
|
|
|
35
|
+
# 原生崩溃取证(2026-10-09):本进程 2026-10-08 21:48 在插件形态下两次被 SIGSEGV 带走
|
|
36
|
+
# (薄壳只留一行 `sig=SIGSEGV`,无任何 Python 栈)。faulthandler 常开:SIGSEGV/SIGABRT 等
|
|
37
|
+
# 致命中止时把**各线程的 Python 栈**打印到 stderr ⇒ 插件形态落 `<库目录>/plugin-backend.log`
|
|
38
|
+
# (薄壳逐行落盘)、独立形态落 `.run/server.log`。零成本、不改变任何行为。
|
|
39
|
+
faulthandler.enable()
|
|
40
|
+
|
|
34
41
|
import auth
|
|
35
42
|
import board
|
|
36
43
|
import builtin_assets
|
|
@@ -375,6 +382,29 @@ def _board_session_running(owner_cid, proj, sid):
|
|
|
375
382
|
return board.web_session_busy(proj, sid)
|
|
376
383
|
|
|
377
384
|
|
|
385
|
+
def _session_owned(sid):
|
|
386
|
+
"""会话归属(会话端点 meta.owned;C 批 T8,2026-10-10):**三态**返回。
|
|
387
|
+
|
|
388
|
+
- `True` = 平台自持(驱动池内会话):平台的停止/中断本来就有效,前端照现状渲染;
|
|
389
|
+
- `False` = 外部会话(用户在 dsh GUI 里直跑/接管):轮次由 dsh GUI 持有,平台停不了,
|
|
390
|
+
前端据此出「外部会话」提示条并把「停止」置灰;
|
|
391
|
+
- `None` = 注册表未知(未连接 / 热重载后 /live 未对齐 / 没见过该 sid):前端
|
|
392
|
+
**按「池内」渲染**,与现状一致——「未知 ≠ 外部」是本批统一判定阶梯,
|
|
393
|
+
绝不据不可信快照反向推断成外部会话。
|
|
394
|
+
|
|
395
|
+
判定阶梯与投递前置闸 `chat._external_preflight` 的第 ②③④ 步逐条对齐(未知放行、
|
|
396
|
+
不可信快照放行、owned 为真放行,其余才是外部)。
|
|
397
|
+
|
|
398
|
+
为什么不是 `bool(...)`:外部会话上报的也是 `owned:false`,与「注册表未知」的兜底
|
|
399
|
+
值撞车——布尔下发会让断连/未对齐窗口里的池内会话被误判成外部(误出提示条,还误
|
|
400
|
+
停用「停止」/「取消排队」)。故未知一律下发 `None`,把「未知 ≠ 外部」落进线上格式。
|
|
401
|
+
"""
|
|
402
|
+
st = dshevents.get(sid)
|
|
403
|
+
if st is None or not dshevents.aligned():
|
|
404
|
+
return None
|
|
405
|
+
return bool(st.get("owned"))
|
|
406
|
+
|
|
407
|
+
|
|
378
408
|
def _session_permission_mode(state):
|
|
379
409
|
"""会话级权限档(会话窗「权限」控件的数据源,2026-10-04 修恒置灰)。
|
|
380
410
|
|
|
@@ -453,6 +483,7 @@ PUBLIC_PAGES = {"/login"}
|
|
|
453
483
|
|
|
454
484
|
# 「首次登录强制改密」白名单:must_change_pw=1 的账号只放行这三个端点,其余 API 一律 403
|
|
455
485
|
# (2026-10-02:未设 TS_ADMIN_PASSWORD 时种子口令改为随机生成,登录后必须先改密)
|
|
486
|
+
# 2026-10-08 增例外:dsh 插件形态的免登信任头路径**整体跳过**此门(见 _must_change_pw_blocked)
|
|
456
487
|
MUST_CHANGE_PW_ALLOW = ("/api/auth/me", "/api/auth/change_password", "/api/auth/logout")
|
|
457
488
|
|
|
458
489
|
# 压测指标 SSE 首次连接的回放行数上限(2026-09-19 压测面板重构批次):
|
|
@@ -1031,6 +1062,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1031
1062
|
if path == "/api/me/feishu/slash-commands":
|
|
1032
1063
|
self._api_me_feishu_slash_set(body)
|
|
1033
1064
|
return
|
|
1065
|
+
if path == "/api/me/feishu/provision":
|
|
1066
|
+
self._api_me_feishu_provision_set(body)
|
|
1067
|
+
return
|
|
1068
|
+
if path == "/api/me/feishu/doctor":
|
|
1069
|
+
self._api_me_feishu_doctor(body)
|
|
1070
|
+
return
|
|
1071
|
+
if path == "/api/me/feishu-hook/apply":
|
|
1072
|
+
# 项目推送绑定批量应用(设置页「同时应用到我的全部项目」,2026-10-10)
|
|
1073
|
+
self._api_me_feishu_hook_apply(body)
|
|
1074
|
+
return
|
|
1034
1075
|
m = re.match(r"^/api/admin/users/(\d+)$", path)
|
|
1035
1076
|
if m:
|
|
1036
1077
|
self._api_admin_update_user(int(m.group(1)), body)
|
|
@@ -1276,6 +1317,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1276
1317
|
self._api_me_feishu_outbox()
|
|
1277
1318
|
elif path == "/api/me/feishu/slash-commands":
|
|
1278
1319
|
self._api_me_feishu_slash_get()
|
|
1320
|
+
elif path == "/api/me/feishu/provision":
|
|
1321
|
+
self._api_me_feishu_provision_get()
|
|
1279
1322
|
elif path == "/api/admin/users":
|
|
1280
1323
|
self._api_admin_list_users()
|
|
1281
1324
|
elif path == "/api/admin/rag":
|
|
@@ -1531,11 +1574,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1531
1574
|
self.wfile.write(body)
|
|
1532
1575
|
|
|
1533
1576
|
def _api_change_password(self):
|
|
1577
|
+
"""改密。插件形态(免登信任头)下**不校验原口令**(2026-10-08)。
|
|
1578
|
+
|
|
1579
|
+
为什么:插件形态首装的随机一次性口令只印在启动横幅里(薄壳曾整段丢弃),
|
|
1580
|
+
存量库的旧口令用户也可能不知道——要求原口令等于把面板锁死。信任头本就等价
|
|
1581
|
+
admin 全权(_current_user),这里放行不新增任何权限面。
|
|
1582
|
+
"""
|
|
1534
1583
|
user = self._current_user()
|
|
1535
1584
|
data = self._read_json() or {}
|
|
1536
1585
|
old_pw = _pw_str(data.get("old_password"))
|
|
1537
1586
|
new_pw = _pw_str(data.get("new_password"))
|
|
1538
|
-
if not
|
|
1587
|
+
if not self._trusted_admin_request() and \
|
|
1588
|
+
not auth.verify_password(old_pw, user["pass_hash"], user["salt"]):
|
|
1539
1589
|
self._respond(400, '{"error":"原密码错误"}'.encode("utf-8"),
|
|
1540
1590
|
"application/json; charset=utf-8")
|
|
1541
1591
|
return
|
|
@@ -1683,11 +1733,17 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1683
1733
|
|
|
1684
1734
|
def _api_me_feishu_cfg_get(self):
|
|
1685
1735
|
"""当前用户飞书配置读取(所有用户可访问,各看各的):
|
|
1686
|
-
webhook 打码、secret 只回布尔 + 本人入站长连接状态。
|
|
1736
|
+
webhook 打码、secret 只回布尔 + 本人入站长连接状态。
|
|
1737
|
+
|
|
1738
|
+
`push_enabled` = 推送总控(2026-10-10 新增,默认 true)——与旧 `enabled`
|
|
1739
|
+
(只管「未单独设置的项目按默认事件推送」那条回落腿 + 入站连接)是两个开关,
|
|
1740
|
+
前端把两者分别渲染并各自加文案说明。"""
|
|
1687
1741
|
user = self._current_user()
|
|
1688
1742
|
cfg = feishu.user_config(user["id"])
|
|
1689
1743
|
self._respond(200, json.dumps({
|
|
1690
1744
|
"enabled": bool(cfg.get("enabled", True)),
|
|
1745
|
+
# 缺键按开回显,与 feishu.notify_events 的判定口径一致(存量配置零迁移)
|
|
1746
|
+
"push_enabled": bool(cfg.get("push_enabled", True)),
|
|
1691
1747
|
"default_webhook": self._mask_webhook(cfg.get("default_webhook", "")),
|
|
1692
1748
|
"has_default_secret": bool(cfg.get("default_secret")),
|
|
1693
1749
|
"base_url": cfg.get("base_url", ""),
|
|
@@ -1699,24 +1755,56 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1699
1755
|
}, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
|
1700
1756
|
|
|
1701
1757
|
def _api_me_feishu_cfg_set(self, body):
|
|
1702
|
-
"""
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
已在运行的(变更凭据)为幂等 no-op,需重启站点重建。inbound_started 供前端提示。"""
|
|
1758
|
+
"""当前用户飞书配置更新(业务在 feishu.save_user_config:字段缺省=不改、
|
|
1759
|
+
显式空串=清除;凭据齐全时拉起入站长连接并**即时快检**)。
|
|
1760
|
+
响应 = 既有字段 + verify(快检结果;凭据不全时 null)——前端据此即时提示对错。"""
|
|
1706
1761
|
user = self._current_user()
|
|
1707
|
-
|
|
1708
|
-
if "enabled" in body:
|
|
1709
|
-
cfg["enabled"] = 1 if body["enabled"] else 0
|
|
1710
|
-
for key in ("default_webhook", "default_secret", "base_url",
|
|
1711
|
-
"app_id", "app_secret"):
|
|
1712
|
-
if key in body:
|
|
1713
|
-
cfg[key] = str(body[key] or "").strip()
|
|
1714
|
-
db.set_feishu_user_cfg(user["id"], cfg)
|
|
1715
|
-
started = feishu.start_inbound_for(user["id"])
|
|
1762
|
+
out = feishu.save_user_config(user["id"], body)
|
|
1716
1763
|
self._respond(200, json.dumps(
|
|
1717
|
-
{"ok": True,
|
|
1764
|
+
{"ok": True, **out},
|
|
1718
1765
|
ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
|
1719
1766
|
|
|
1767
|
+
def _api_me_feishu_provision_get(self):
|
|
1768
|
+
"""扫码建应用流程现状(设置页轮询口,只读):能力/总闸/状态/确认链接/步骤。"""
|
|
1769
|
+
user = self._current_user()
|
|
1770
|
+
self._respond(200, json.dumps(feishu.provision_status(user["id"]),
|
|
1771
|
+
ensure_ascii=False).encode("utf-8"),
|
|
1772
|
+
"application/json; charset=utf-8")
|
|
1773
|
+
|
|
1774
|
+
def _api_me_feishu_provision_set(self, body):
|
|
1775
|
+
"""扫码建应用四动作(全部返回 200 + 摘要,页面据此渲染引导;非法 action 400):
|
|
1776
|
+
start=发起(force 覆盖已有应用)/ cancel=取消 / apply=补齐机器人能力+权限+
|
|
1777
|
+
长连接订阅+事件+回调 / publish=提交发布(版本号缺省自动递增)。"""
|
|
1778
|
+
user = self._current_user()
|
|
1779
|
+
action = str(body.get("action") or "")
|
|
1780
|
+
uid = user["id"]
|
|
1781
|
+
if action == "start":
|
|
1782
|
+
out = feishu.provision_start(uid, force=bool(body.get("force")))
|
|
1783
|
+
elif action == "cancel":
|
|
1784
|
+
out = feishu.provision_cancel(uid)
|
|
1785
|
+
elif action == "apply":
|
|
1786
|
+
out = feishu.provision_apply(uid)
|
|
1787
|
+
elif action == "publish":
|
|
1788
|
+
out = feishu.provision_publish(uid,
|
|
1789
|
+
version=str(body.get("version") or "") or None,
|
|
1790
|
+
remark=str(body.get("remark") or ""),
|
|
1791
|
+
changelog=str(body.get("changelog") or ""))
|
|
1792
|
+
else:
|
|
1793
|
+
self._respond(400, json.dumps(
|
|
1794
|
+
{"error": "action 仅支持 start / cancel / apply / publish"},
|
|
1795
|
+
ensure_ascii=False).encode("utf-8"),
|
|
1796
|
+
"application/json; charset=utf-8")
|
|
1797
|
+
return
|
|
1798
|
+
self._respond(200, json.dumps(out, ensure_ascii=False).encode("utf-8"),
|
|
1799
|
+
"application/json; charset=utf-8")
|
|
1800
|
+
|
|
1801
|
+
def _api_me_feishu_doctor(self, body):
|
|
1802
|
+
"""配置自检(八项):只读为主;send_probe=true 时额外发一条测试消息(60s 频控)。"""
|
|
1803
|
+
user = self._current_user()
|
|
1804
|
+
out = feishu.doctor(user["id"], send_probe=bool(body.get("send_probe")))
|
|
1805
|
+
self._respond(200, json.dumps(out, ensure_ascii=False).encode("utf-8"),
|
|
1806
|
+
"application/json; charset=utf-8")
|
|
1807
|
+
|
|
1720
1808
|
def _api_me_feishu_outbox(self):
|
|
1721
1809
|
"""当前用户的最近投递记录(target 打码——URL 本身含 token 即凭据)。"""
|
|
1722
1810
|
user = self._current_user()
|
|
@@ -1970,6 +2058,58 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1970
2058
|
",".join(cur_events), 1 if cur_enabled else 0)
|
|
1971
2059
|
self._respond(200, b'{"ok":true}', "application/json; charset=utf-8")
|
|
1972
2060
|
|
|
2061
|
+
def _api_me_feishu_hook_apply(self, body):
|
|
2062
|
+
"""项目推送绑定**批量应用**(设置页「同时应用到我的全部项目」,2026-10-10)。
|
|
2063
|
+
|
|
2064
|
+
把一份配置写进当前用户**本人的全部未归档项目**(归档项目不再产生推送,
|
|
2065
|
+
故不纳入;跨用户项目一律不碰——多用户隔离红线)。字段语义与单项目端点
|
|
2066
|
+
`_api_feishu_hook_set` 完全对齐:**缺省=不改该字段**,因此前端只在输入框
|
|
2067
|
+
有内容时才带 `webhook_url`/`webhook_secret` 键 ⇒「留空 = 各项目保留原值」,
|
|
2068
|
+
只统一「启用推送」与事件勾选;显式传空串=清除。`events` 走白名单校验。
|
|
2069
|
+
|
|
2070
|
+
返回 {ok, updated, archived_skipped, projects}:projects = 实际写入的项目名
|
|
2071
|
+
(供前端回执列出「改了什么」)。
|
|
2072
|
+
"""
|
|
2073
|
+
user = self._current_user()
|
|
2074
|
+
req_events = None
|
|
2075
|
+
if "events" in body:
|
|
2076
|
+
req_events = body["events"]
|
|
2077
|
+
if not isinstance(req_events, list) \
|
|
2078
|
+
or not set(req_events) <= set(feishu.FEISHU_EVENTS):
|
|
2079
|
+
self._respond(400, '{"error":"events 含非法事件"}'.encode("utf-8"),
|
|
2080
|
+
"application/json; charset=utf-8")
|
|
2081
|
+
return
|
|
2082
|
+
names, skipped = [], 0
|
|
2083
|
+
for proj in db.list_projects(user["id"]):
|
|
2084
|
+
if proj["archived"]:
|
|
2085
|
+
skipped += 1
|
|
2086
|
+
continue
|
|
2087
|
+
row = db.get_feishu_hook(proj["id"])
|
|
2088
|
+
cur_url = (row["webhook_url"] if row is not None else "")
|
|
2089
|
+
cur_secret = (row["webhook_secret"] if row is not None else "")
|
|
2090
|
+
cur_enabled = (bool(row["enabled"]) if row is not None else True)
|
|
2091
|
+
if row is not None:
|
|
2092
|
+
cur_events = [e for e in feishu.FEISHU_EVENTS
|
|
2093
|
+
if e in (row["events"] or "")]
|
|
2094
|
+
else:
|
|
2095
|
+
# 无绑定行的默认事件集与设置页回显一致(卡片待审核默认关)
|
|
2096
|
+
cur_events = list(feishu.FEISHU_DEFAULT_EVENTS)
|
|
2097
|
+
if "webhook_url" in body:
|
|
2098
|
+
cur_url = str(body["webhook_url"] or "").strip()
|
|
2099
|
+
if "webhook_secret" in body:
|
|
2100
|
+
cur_secret = str(body["webhook_secret"] or "").strip()
|
|
2101
|
+
if req_events is not None:
|
|
2102
|
+
cur_events = req_events
|
|
2103
|
+
if "enabled" in body:
|
|
2104
|
+
cur_enabled = bool(body["enabled"])
|
|
2105
|
+
db.set_feishu_hook(proj["id"], cur_url, cur_secret,
|
|
2106
|
+
",".join(cur_events), 1 if cur_enabled else 0)
|
|
2107
|
+
names.append(proj["name"])
|
|
2108
|
+
self._respond(200, json.dumps(
|
|
2109
|
+
{"ok": True, "updated": len(names), "archived_skipped": skipped,
|
|
2110
|
+
"projects": names}, ensure_ascii=False).encode("utf-8"),
|
|
2111
|
+
"application/json; charset=utf-8")
|
|
2112
|
+
|
|
1973
2113
|
# ---------- 个人飞书绑定(M2 入站身份映射) ----------
|
|
1974
2114
|
|
|
1975
2115
|
def _api_me_feishu_bindcode(self):
|
|
@@ -2812,8 +2952,29 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2812
2952
|
# 压缩新建后的绑定)也要归档——I2「done 卡全部绑定会话都归档」。
|
|
2813
2953
|
# best-effort:失败只记提示 + 退避重试,不回滚绑定。
|
|
2814
2954
|
board.archive_bound_session(db.get_board_card(card_id), newly_bound)
|
|
2815
|
-
|
|
2816
|
-
|
|
2955
|
+
rename_info = None
|
|
2956
|
+
if "title" in fields and fields["title"].strip() != (card["title"] or "").strip():
|
|
2957
|
+
# 卡面改名 → 同步改 DSH 会话名(2026-10-10 用户需求):只认**改后的主会话**
|
|
2958
|
+
# (同一次 PATCH 里换绑/解绑以新状态为准),best-effort——失败不改卡面,
|
|
2959
|
+
# 只把原因回给前端(`session_rename.ok=false`)。
|
|
2960
|
+
fresh = db.get_board_card(card_id)
|
|
2961
|
+
sid = str((fresh["session_id"] if fresh else "") or "")
|
|
2962
|
+
if sid:
|
|
2963
|
+
ok, err, accepted = board.rename_card_session(sid, fields["title"].strip())
|
|
2964
|
+
rename_info = {"ok": ok, "session_id": sid}
|
|
2965
|
+
if not ok:
|
|
2966
|
+
rename_info["error"] = err
|
|
2967
|
+
elif accepted and accepted != fields["title"]:
|
|
2968
|
+
# 宿主规范化/截断(`normalizeSessionTitle` 的 UTF-8 字节预算,实测
|
|
2969
|
+
# 约 80 字节):**只如实上报,不回写卡面**——卡面是用户输入,平台不
|
|
2970
|
+
# 替用户缩短内容(2026-10-10 真机教训:回写会把长标题静默截断,
|
|
2971
|
+
# 用户原文丢失)。前端据 `accepted_title` 提示「DSH 侧标题被截断」,
|
|
2972
|
+
# 由用户自行决定是否缩短卡名。
|
|
2973
|
+
rename_info["accepted_title"] = accepted
|
|
2974
|
+
payload = board.card_json(db.get_board_card(card_id))
|
|
2975
|
+
if rename_info:
|
|
2976
|
+
payload["session_rename"] = rename_info
|
|
2977
|
+
self._respond(200, json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
2817
2978
|
"application/json; charset=utf-8")
|
|
2818
2979
|
|
|
2819
2980
|
def _api_board_delete_card(self, project_id, card_id):
|
|
@@ -3162,10 +3323,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
3162
3323
|
try:
|
|
3163
3324
|
# inject=true 时立即注入运行中的当前轮(dsh 等 steer 能力会话生效);
|
|
3164
3325
|
# 项目忙(同项目有单元在跑)时登记统一队列消息单元,返回 queued=true
|
|
3165
|
-
#(前端显示「排队中」,注释见 board.deliver_comment
|
|
3326
|
+
#(前端显示「排队中」,注释见 board.deliver_comment);投递文本恒为评论
|
|
3327
|
+
# 原文、不包【看板评论】前缀(2026-10-10 去前缀;旧客户端多传的 body.raw
|
|
3328
|
+
# 字段静默忽略,不再透传)
|
|
3166
3329
|
rec = board.deliver_comment(row, card, target,
|
|
3167
|
-
inject=bool(body.get("inject"))
|
|
3168
|
-
raw=bool(body.get("raw")))
|
|
3330
|
+
inject=bool(body.get("inject")))
|
|
3169
3331
|
except dshdriver.DshDriverError as e:
|
|
3170
3332
|
# 409 = 会话运行中(deliver_comment 的忙拒绝);其余 code 按 500
|
|
3171
3333
|
if e.args and e.args[0] == 409:
|
|
@@ -3333,13 +3495,19 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
3333
3495
|
# 与「立即送达」按钮(POST .../cards/<cid>/answer/deliver,不等项目空闲)
|
|
3334
3496
|
data["answer_pending"] = board.is_answer_pending(owner_cid)
|
|
3335
3497
|
data["interaction"] = board.interaction_of_sid(sid)
|
|
3498
|
+
# 会话归属三态(C 批 T8):前端按 `owned === false` 出「外部会话」提示条并把
|
|
3499
|
+
# 「停止」置灰;`None`=注册表未知按「池内」渲染(判定口径见 _session_owned)
|
|
3500
|
+
data["owned"] = _session_owned(sid)
|
|
3336
3501
|
data["family"] = family
|
|
3337
3502
|
if family == "dsh_plugin":
|
|
3338
|
-
#
|
|
3339
|
-
#
|
|
3503
|
+
# 上下文圈(2026-10-10 修「恒为 0」):分子/分母都随上面的
|
|
3504
|
+
# `sessparse.load` 从会话存储的两个 last-wins 槽位带出来
|
|
3505
|
+
# (used=最新 usage 的 prompt 侧 token 数,max=最新
|
|
3506
|
+
# `request/context` 的 contextWindow,见 sessparse._dsh_prompt_tokens),
|
|
3507
|
+
# 此处**不再**用 EventHub 的 usage 覆盖:那条只有 total 口径、且插件
|
|
3508
|
+
# 后端一重启就清零;会话文件是持久的,本来就在本次请求里解析过
|
|
3509
|
+
# (零额外解压,`data["ctx"]` 由 load 结果带出,窗口未知时不带该键)。
|
|
3340
3510
|
st = dshevents.get(sid)
|
|
3341
|
-
if st and st.get("usage"):
|
|
3342
|
-
data["ctx"] = {"used": st["usage"].get("total") or 0, "max": None}
|
|
3343
3511
|
# 会话级模型(会话窗「模型」展示):事件流里学到就用它,否则留空
|
|
3344
3512
|
# 由前端回落「项目默认模型」(既有兜底链)
|
|
3345
3513
|
sel = (st or {}).get("model") or {}
|
|
@@ -4809,6 +4977,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
4809
4977
|
ensure_ascii=False).encode("utf-8"),
|
|
4810
4978
|
"application/json; charset=utf-8")
|
|
4811
4979
|
return
|
|
4980
|
+
except RuntimeError as e:
|
|
4981
|
+
# 投递前置闸拒投(chat.DeliveryRefused:外部会话前提不成立,C 批 T5/T8)——
|
|
4982
|
+
# 消息未入队、未落行,回 400 + 闸的中文文案(口径同 _api_board_session_comment)。
|
|
4983
|
+
# 必须排在 dshdriver.DshDriverError 之后:它也是 RuntimeError 子类,先命中 409/500;
|
|
4984
|
+
# 裸 RuntimeError 若逃逸,do_POST 无兜底 ⇒ 用户拿到连接重置而不是要求的明确文案。
|
|
4985
|
+
self._respond(400, json.dumps({"error": str(e)}, ensure_ascii=False).encode("utf-8"),
|
|
4986
|
+
"application/json; charset=utf-8")
|
|
4987
|
+
return
|
|
4812
4988
|
self._respond(200, json.dumps(
|
|
4813
4989
|
{"ok": True, "queued": bool(rec.get("queued")),
|
|
4814
4990
|
"msg_id": rec.get("id") or "", "state": rec.get("state") or "",
|
|
@@ -5029,20 +5205,36 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5029
5205
|
|
|
5030
5206
|
返回 True 表示已回 403、调用方直接 return。前端据 /api/auth/me 的
|
|
5031
5207
|
must_change_password 渲染改密门;此处是服务端兜底(防绕过前端直调 API)。
|
|
5208
|
+
|
|
5209
|
+
例外(2026-10-08,插件形态死锁修复):免登信任头路径整体跳过此闸。
|
|
5210
|
+
插件形态下用户拿不到随机一次性口令(薄壳曾把横幅整段丢弃),拦在这里只会
|
|
5211
|
+
把面板锁死——而信任头本已等价 admin(见 _current_user),跳过不降安全等级;
|
|
5212
|
+
存量库 must_change_pw=1 在插件形态亦据此不再锁死,用户进面板后可在设置页设密码。
|
|
5032
5213
|
"""
|
|
5033
5214
|
if not user or not user["must_change_pw"]:
|
|
5034
5215
|
return False
|
|
5216
|
+
if self._trusted_admin_request():
|
|
5217
|
+
return False
|
|
5035
5218
|
if path in MUST_CHANGE_PW_ALLOW:
|
|
5036
5219
|
return False
|
|
5037
5220
|
self._respond(403, '{"error":"请先修改初始口令"}'.encode("utf-8"),
|
|
5038
5221
|
"application/json; charset=utf-8")
|
|
5039
5222
|
return True
|
|
5040
5223
|
|
|
5224
|
+
def _trusted_admin_request(self):
|
|
5225
|
+
"""本次请求是否由薄壳反代注入的免登信任头判为 admin(--trust-internal-user)。
|
|
5226
|
+
|
|
5227
|
+
仅回环监听 + 显式开关时才可能为真(main() 已限制);该头等价 admin 全权,
|
|
5228
|
+
故以此为据的放行(跳过强制改密门、免原口令改密)不新增任何权限面。
|
|
5229
|
+
"""
|
|
5230
|
+
return bool(TRUST_INTERNAL_USER
|
|
5231
|
+
and self.headers.get(TRUST_USER_HEADER) == "admin")
|
|
5232
|
+
|
|
5041
5233
|
def _current_user(self):
|
|
5042
5234
|
"""按 cookie 取会话用户,无效返回 None。"""
|
|
5043
5235
|
# dsh 插件形态免登:--trust-internal-user 时反代注入的信任头视为 admin 登录
|
|
5044
5236
|
# (main() 已限制仅回环监听可开启);后续 _owned_project 等隔离逻辑不变
|
|
5045
|
-
if
|
|
5237
|
+
if self._trusted_admin_request():
|
|
5046
5238
|
admin = db.get_user_by_name("admin")
|
|
5047
5239
|
if admin is not None:
|
|
5048
5240
|
return admin
|