opencode-agora 0.1.0__py3-none-any.whl
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.
- agora_mcp.py +240 -0
- agorad/__init__.py +1 -0
- agorad/access/__init__.py +1 -0
- agorad/access/anchors.py +22 -0
- agorad/access/gitx.py +206 -0
- agorad/access/oc.py +124 -0
- agorad/access/pages.py +25 -0
- agorad/access/store.py +181 -0
- agorad/core/__init__.py +1 -0
- agorad/core/app.py +137 -0
- agorad/core/auth.py +134 -0
- agorad/core/gate.py +281 -0
- agorad/core/health.py +182 -0
- agorad/core/msg.py +92 -0
- agorad/core/peer.py +90 -0
- agorad/core/recover.py +83 -0
- agorad/core/report.py +189 -0
- agorad/core/state.py +183 -0
- agorad/core/task.py +308 -0
- agorad/installer.py +44 -0
- agorad/main.py +181 -0
- agorad/transport/__init__.py +1 -0
- agorad/transport/httpd.py +316 -0
- board_tui.py +649 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora +244 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/cred.ts +112 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/envPath.ts +33 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/hook.ts +132 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/index.ts +160 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/inject.ts +49 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/lift.ts +161 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/scope.ts +36 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/install.sh +222 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-dispatch/SKILL.md +95 -0
- opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-ops/SKILL.md +60 -0
- opencode_agora-0.1.0.dist-info/METADATA +195 -0
- opencode_agora-0.1.0.dist-info/RECORD +41 -0
- opencode_agora-0.1.0.dist-info/WHEEL +5 -0
- opencode_agora-0.1.0.dist-info/entry_points.txt +5 -0
- opencode_agora-0.1.0.dist-info/licenses/LICENSE +21 -0
- opencode_agora-0.1.0.dist-info/top_level.txt +3 -0
agorad/access/store.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""账本唯一磁盘接触点:append-only 账本四原语+flock 单实例。
|
|
2
|
+
|
|
3
|
+
布局 $AGORA_HOME:state/tasks.jsonl(全量快照行)、state/messages/<id>.md
|
|
4
|
+
(不可变,头部 key: value 行+空行+正文)、events.jsonl(审计)、
|
|
5
|
+
易失件(service.json 等,可覆写非账本)。
|
|
6
|
+
"""
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
TAIL_BYTES = 1 << 20 # 启动尾读窗口:load 耗时与账本大小解耦;窗内无完整行回退全扫
|
|
13
|
+
COMPACT_LINES = 500 # 瘦身阈值:行数超限即原子重写仅留末完整行(flock 单写者、迁移路径时机避开启动),账本有上界
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Store:
|
|
17
|
+
def __init__(self, home: Path):
|
|
18
|
+
self.home = Path(home)
|
|
19
|
+
self._tasks = self.home / "state" / "tasks.jsonl"
|
|
20
|
+
self._events = self.home / "events.jsonl"
|
|
21
|
+
self._messages = self.home / "state" / "messages"
|
|
22
|
+
self._lock_path = self.home / "agorad.lock"
|
|
23
|
+
self._lock_fd = None
|
|
24
|
+
# 单写者内存计数器;重启扫目录文件名 max+1 恢复
|
|
25
|
+
self._next_mid = self.scan_max_msg_id() + 1
|
|
26
|
+
self._task_lines = None # 懒播种的账本行数(flock 单写者安全):免每次追加全文件重扫;崩溃漂移只挪瘦身时机
|
|
27
|
+
|
|
28
|
+
def append_state(self, snap: dict) -> None:
|
|
29
|
+
if self._task_lines is None: # 播种在追加前:计磁盘实有行,此后内存自增不重扫
|
|
30
|
+
self._task_lines = self._count_lines() if self._tasks.exists() else 0
|
|
31
|
+
line = json.dumps(snap, ensure_ascii=False)
|
|
32
|
+
self._append_line(self._tasks, line) # 先追加后瘦身:崩溃至多留下超限账本,状态不丢
|
|
33
|
+
self._task_lines += 1
|
|
34
|
+
if self._task_lines > COMPACT_LINES:
|
|
35
|
+
try:
|
|
36
|
+
self._compact(line)
|
|
37
|
+
self._task_lines = 1
|
|
38
|
+
except OSError:
|
|
39
|
+
pass # 追加已 fsync 持久化,瘦身属维护动作:失败不回滚成功语义,计数器留超阈待下次追加重试
|
|
40
|
+
|
|
41
|
+
def load_state(self) -> dict | None:
|
|
42
|
+
if not self._tasks.exists():
|
|
43
|
+
return None
|
|
44
|
+
if self._tasks.stat().st_size > TAIL_BYTES:
|
|
45
|
+
snap = self._load_tail()
|
|
46
|
+
if snap is not None:
|
|
47
|
+
return snap
|
|
48
|
+
# 尾窗无完整行(巨行/断尾贯穿全窗)→ 回退全文件扫,末行胜出语义不变
|
|
49
|
+
snap = None
|
|
50
|
+
with self._tasks.open(encoding="utf-8") as f:
|
|
51
|
+
for line in f:
|
|
52
|
+
try:
|
|
53
|
+
snap = json.loads(line) # 断尾行丢弃,取最后完整行
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
return snap
|
|
57
|
+
|
|
58
|
+
def _load_tail(self) -> dict | None:
|
|
59
|
+
with self._tasks.open("rb") as f:
|
|
60
|
+
f.seek(-TAIL_BYTES, os.SEEK_END)
|
|
61
|
+
block = f.read()
|
|
62
|
+
i = block.find(b"\n") # 跨窗切割的首段残行丢弃,只解析完整行
|
|
63
|
+
if i == -1:
|
|
64
|
+
return None
|
|
65
|
+
snap = None
|
|
66
|
+
for raw in block[i + 1:].split(b"\n"):
|
|
67
|
+
try:
|
|
68
|
+
snap = json.loads(raw) # 断尾行丢弃,取最后完整行(同全扫口径)
|
|
69
|
+
except ValueError:
|
|
70
|
+
pass
|
|
71
|
+
return snap
|
|
72
|
+
|
|
73
|
+
def _count_lines(self) -> int:
|
|
74
|
+
n = 0
|
|
75
|
+
with self._tasks.open("rb") as f:
|
|
76
|
+
for chunk in iter(lambda: f.read(TAIL_BYTES), b""):
|
|
77
|
+
n += chunk.count(b"\n")
|
|
78
|
+
return n
|
|
79
|
+
|
|
80
|
+
def _compact(self, line: str) -> None:
|
|
81
|
+
# 刚追加行即末完整行(flock 单写者);原子重写同 write_ephemeral 口径
|
|
82
|
+
tmp = self._tasks.with_name(self._tasks.name + ".tmp")
|
|
83
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
84
|
+
f.write(line + "\n")
|
|
85
|
+
f.flush()
|
|
86
|
+
os.fsync(f.fileno())
|
|
87
|
+
os.replace(tmp, self._tasks)
|
|
88
|
+
|
|
89
|
+
def append_event(self, ev: dict) -> None:
|
|
90
|
+
self._append_line(self._events, json.dumps(ev, ensure_ascii=False))
|
|
91
|
+
|
|
92
|
+
def write_message(self, fm: dict, body: str) -> int:
|
|
93
|
+
mid = self._next_mid
|
|
94
|
+
self._next_mid += 1
|
|
95
|
+
header = f"id: {mid}\n" + "".join(
|
|
96
|
+
f"{k}: {fm[k]}\n" for k in sorted(fm)
|
|
97
|
+
)
|
|
98
|
+
path = self._messages / f"{mid:08d}.md" # 补零保证文件名字典序=msg-id 数值序
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
with path.open("x", encoding="utf-8") as f: # "x"=已存在即拒,id 不可变
|
|
101
|
+
f.write(f"{header}\n{body}")
|
|
102
|
+
f.flush()
|
|
103
|
+
os.fsync(f.fileno())
|
|
104
|
+
return mid
|
|
105
|
+
|
|
106
|
+
def scan_max_msg_id(self) -> int:
|
|
107
|
+
if not self._messages.exists():
|
|
108
|
+
return 0
|
|
109
|
+
return max((int(p.stem) for p in self._messages.glob("*.md")), default=0)
|
|
110
|
+
|
|
111
|
+
def read_messages(self) -> list[dict]:
|
|
112
|
+
"""全库消息按 msg-id 升序(补零文件名序=id 序);frontmatter 键+id(int)+body。
|
|
113
|
+
|
|
114
|
+
门中崩溃撕裂件(坏头/断尾/空文件)跳过——同 load_state 断尾容错口径。
|
|
115
|
+
"""
|
|
116
|
+
out = []
|
|
117
|
+
for p in sorted(self._messages.glob("*.md")):
|
|
118
|
+
try:
|
|
119
|
+
head, _, body = p.read_text(encoding="utf-8").partition("\n\n")
|
|
120
|
+
fm = dict(line.split(": ", 1) for line in head.splitlines())
|
|
121
|
+
fm["id"] = int(fm["id"])
|
|
122
|
+
fm["body"] = body
|
|
123
|
+
except (ValueError, KeyError, OSError):
|
|
124
|
+
continue
|
|
125
|
+
out.append(fm)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
def read_events(self, tid=None) -> list[dict]:
|
|
129
|
+
"""events.jsonl 全量(tid 给定则按 task 过滤)按行序;断尾行丢弃同 load_state。"""
|
|
130
|
+
out = []
|
|
131
|
+
if not self._events.exists():
|
|
132
|
+
return out
|
|
133
|
+
with self._events.open(encoding="utf-8") as f:
|
|
134
|
+
for line in f:
|
|
135
|
+
try:
|
|
136
|
+
ev = json.loads(line)
|
|
137
|
+
except ValueError:
|
|
138
|
+
continue
|
|
139
|
+
if tid is None or ev.get("task") == tid:
|
|
140
|
+
out.append(ev)
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def write_ephemeral(self, name: str, data: dict) -> None:
|
|
144
|
+
path = self.home / name
|
|
145
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
147
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
148
|
+
f.write(json.dumps(data, ensure_ascii=False) + "\n")
|
|
149
|
+
f.flush()
|
|
150
|
+
os.fsync(f.fileno())
|
|
151
|
+
os.replace(tmp, path) # 原子覆写:读者(lift 解析 service.json)不见半写
|
|
152
|
+
|
|
153
|
+
def read_ephemeral(self, name: str) -> dict | None:
|
|
154
|
+
path = self.home / name
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
159
|
+
except ValueError:
|
|
160
|
+
return None # 断尾/损坏件视为缺席,同 load_state 容错口径
|
|
161
|
+
|
|
162
|
+
def flock_singleton(self) -> bool:
|
|
163
|
+
if self._lock_fd is not None:
|
|
164
|
+
return True
|
|
165
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
fd = os.open(self._lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
167
|
+
try:
|
|
168
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
169
|
+
except OSError:
|
|
170
|
+
os.close(fd)
|
|
171
|
+
return False
|
|
172
|
+
self._lock_fd = fd # 持有至进程退出,关闭即释放
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _append_line(path: Path, line: str) -> None:
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
with path.open("a", encoding="utf-8") as f:
|
|
179
|
+
f.write(line + "\n")
|
|
180
|
+
f.flush()
|
|
181
|
+
os.fsync(f.fileno())
|
agorad/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""agorad 域核心:纯逻辑,只 import stdlib;访问层对象一律构造注入。"""
|
agorad/core/app.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""装配容器(R2):字段即依赖图——main 独占构造,transport 只 import 本模块。"""
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
EV_MAIN_BOUND = "main_sid_bound"
|
|
6
|
+
EV_WORKER_REBOUND = "worker_rebound"
|
|
7
|
+
EV_DISPATCHER_REBOUND = "dispatcher_rebind"
|
|
8
|
+
|
|
9
|
+
# worker 换号自愈口径活动态(词汇对齐 task._ACTIVE;core 禁兄弟 import 故字面量本地定义)
|
|
10
|
+
_ACTIVE = {"dispatched", "running", "done", "verify_failed", "reopened", "blocked"}
|
|
11
|
+
_NESTED_OPEN = _ACTIVE | {"todo"} # 已建未派子任务同样保留父 worker 所有权
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class App:
|
|
16
|
+
"""聚合根:core 服务+注入的 access 件+装配元数据+换号自愈绑定面(R25-A/gt-d55b9e)。"""
|
|
17
|
+
store: object
|
|
18
|
+
state: object
|
|
19
|
+
auth: object
|
|
20
|
+
router: object
|
|
21
|
+
task: object
|
|
22
|
+
peer: object
|
|
23
|
+
gate: object
|
|
24
|
+
health: object
|
|
25
|
+
recover: object
|
|
26
|
+
report: object
|
|
27
|
+
oc: object
|
|
28
|
+
gitx: object
|
|
29
|
+
main_sid: str
|
|
30
|
+
|
|
31
|
+
def bind_main(self, sid, caller):
|
|
32
|
+
"""R25-A:root 牌调用登记主会话真实 sid(多主会话最近活跃覆盖)。
|
|
33
|
+
|
|
34
|
+
sid=牌当前 sid(占位 "main" 或重启前旧绑定),caller=X-Agora-Sid 真实
|
|
35
|
+
会话——改绑牌使后续 task 行 dispatcher 落真实 sid,拍人链边直达真会话;
|
|
36
|
+
重启后 main_sid 复位与牌不同步亦经此自愈。任务 worker 边会话拒绝绑定:
|
|
37
|
+
worker 凭据缺失回落 root 牌(B1)+携 sid 属误附,不得抢主会话绑定。
|
|
38
|
+
返回对齐后 sid(拒绑=原 sid)——本笔请求后续归属判定即以新 sid 走。
|
|
39
|
+
"""
|
|
40
|
+
if caller != sid:
|
|
41
|
+
if any(t.get("worker_sid") == caller
|
|
42
|
+
for t in self.state.snapshot()["tasks"].values()):
|
|
43
|
+
return sid
|
|
44
|
+
self.auth.rebind(sid, caller)
|
|
45
|
+
if self.main_sid != caller:
|
|
46
|
+
self.main_sid = caller
|
|
47
|
+
self.store.append_event({"ts": time.time(), "actor": caller,
|
|
48
|
+
"event": EV_MAIN_BOUND})
|
|
49
|
+
return caller
|
|
50
|
+
|
|
51
|
+
def bind_worker(self, sid, caller):
|
|
52
|
+
"""gt-d55b9e:worker 牌换号自愈——持牌即身份,漂移即改绑(镜像 bind_main)。
|
|
53
|
+
|
|
54
|
+
sid=牌注册 sid(dispatch 时落行的 worker_sid),caller=X-Agora-Sid
|
|
55
|
+
真实续接会话。注册 sid 是某活动任务 worker_sid 且两者漂移 → rebind
|
|
56
|
+
(旧 sid 入血统,token 本体不变)+任务行 worker_sid 同步覆写(与
|
|
57
|
+
recover.reinject 同口径)。改绑波及 authorship(gt-83185d R2):worker
|
|
58
|
+
作为派发方的活动嵌套子任务行 dispatcher_sid 一并覆写为 caller——
|
|
59
|
+
嵌套任务所有权在换号后保持,不被归属终审 dispatcher_sid==caller 卡死。
|
|
60
|
+
守卫:终态/无主 sid 一律不动;worker_sid 行与 dispatcher_sid 行分别
|
|
61
|
+
覆写对应字段不串;已占 caller sid 拒绑(下)原样——worker 改绑只动
|
|
62
|
+
worker 牌,主会话/他牌不受累。返回改绑后 sid(调用方刷新身份)。
|
|
63
|
+
"""
|
|
64
|
+
if caller == sid:
|
|
65
|
+
return sid
|
|
66
|
+
tasks = self.state.snapshot()["tasks"]
|
|
67
|
+
# 已占 sid 不抢绑(镜像 bind_main 守卫):X-Agora-Sid 是自证头,worker 可从
|
|
68
|
+
# 任务页读到他任务 sid——caller 已是主会话/任一任务 dispatcher/worker 即拒
|
|
69
|
+
# (防跨任务冒充;真续接 sid 全新必不撞)。
|
|
70
|
+
if caller == self.main_sid or any(
|
|
71
|
+
caller in (t.get("worker_sid"), t.get("dispatcher_sid"))
|
|
72
|
+
for t in tasks.values()):
|
|
73
|
+
return sid
|
|
74
|
+
hit = [tid for tid, t in tasks.items()
|
|
75
|
+
if t.get("worker_sid") == sid and t["state"] in _ACTIVE]
|
|
76
|
+
if not hit:
|
|
77
|
+
return sid
|
|
78
|
+
# 快照后门线程可能已结案:零活动行不改绑(TOCTOU 复检,镜像 bind_dispatcher)
|
|
79
|
+
if not any(self.state.get(tid)["state"] in _ACTIVE for tid in hit):
|
|
80
|
+
return sid
|
|
81
|
+
self.auth.rebind(sid, caller)
|
|
82
|
+
for tid in hit:
|
|
83
|
+
if self.state.get(tid)["state"] not in _ACTIVE:
|
|
84
|
+
continue # 改绑窗内结案:终态行冻结不覆写(TOCTOU 复检)
|
|
85
|
+
self.state.set_fields(tid, worker_sid=caller)
|
|
86
|
+
self.store.append_event({"ts": time.time(), "actor": sid,
|
|
87
|
+
"event": EV_WORKER_REBOUND, "task": tid,
|
|
88
|
+
"from": sid, "to": caller})
|
|
89
|
+
# R2 波及:嵌套子任务行只覆写 dispatcher_sid 字段(worker_sid 行不串)
|
|
90
|
+
nested = [tid for tid, t in tasks.items()
|
|
91
|
+
if t.get("dispatcher_sid") == sid and t["state"] in _NESTED_OPEN]
|
|
92
|
+
for tid in nested:
|
|
93
|
+
if self.state.get(tid)["state"] not in _NESTED_OPEN:
|
|
94
|
+
continue # 同上:终态行冻结不覆写(TOCTOU 复检)
|
|
95
|
+
self.state.set_fields(tid, dispatcher_sid=caller)
|
|
96
|
+
self.store.append_event({"ts": time.time(), "actor": sid,
|
|
97
|
+
"event": EV_WORKER_REBOUND, "task": tid,
|
|
98
|
+
"from": sid, "to": caller})
|
|
99
|
+
return caller
|
|
100
|
+
|
|
101
|
+
def bind_dispatcher(self, role, sid, t):
|
|
102
|
+
"""F21:dispatcher 侧换号改绑(镜像 bind_worker,补壳重启死号缺口)——
|
|
103
|
+
root 牌 post 命中「非链边」前判愈合:登记 dispatcher_sid ≠ 调用者且该
|
|
104
|
+
会话已不存在(oc 存在性检查)→ auth.rebind(旧号入血统)+活动任务行
|
|
105
|
+
dispatcher_sid 同步覆写+dispatcher_rebind 审计(含新旧 sid 与任务),
|
|
106
|
+
本笔 post 按链边放行。守卫(不满足保持既有拒绝):非 root 牌、活号
|
|
107
|
+
(防活号互抢)、任务终态(冻结不动)、oc 不可达≠死(recover 同口径免判)。"""
|
|
108
|
+
d = t.get("dispatcher_sid")
|
|
109
|
+
if role != "root" or not d or d == sid:
|
|
110
|
+
return
|
|
111
|
+
tasks = self.state.snapshot()["tasks"]
|
|
112
|
+
hit = [tid for tid, x in tasks.items()
|
|
113
|
+
if x.get("dispatcher_sid") == d and x["state"] in _ACTIVE]
|
|
114
|
+
if not hit:
|
|
115
|
+
return # 终态不触发改绑
|
|
116
|
+
# 已占 sid 不抢绑(镜像 bind_worker 防跨任务冒充;真续接号全新必不撞;
|
|
117
|
+
# main_sid 不在守卫内:root 牌对齐后 sid==main_sid 是常态非冒充)。
|
|
118
|
+
if any(sid in (x.get("worker_sid"), x.get("dispatcher_sid"))
|
|
119
|
+
for x in tasks.values()):
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
if self.oc.alive(d):
|
|
123
|
+
return # 登记会话仍活不抢绑
|
|
124
|
+
except Exception: # oc 不可达≠会话死:本轮免判(recover.startup 同口径)
|
|
125
|
+
return
|
|
126
|
+
# 探活窗内可能已全数结案:零活动行不改牌(TOCTOU 复检——rebind 是
|
|
127
|
+
# 持久身份换绑,无活动行即无消费方,凭陈旧快照换牌=无谓身份漂移)
|
|
128
|
+
if not any(self.state.get(tid)["state"] in _ACTIVE for tid in hit):
|
|
129
|
+
return
|
|
130
|
+
self.auth.rebind(d, sid)
|
|
131
|
+
for tid in hit:
|
|
132
|
+
if self.state.get(tid)["state"] not in _ACTIVE:
|
|
133
|
+
continue # 探活窗内门体或已结案:终态行冻结不覆写(TOCTOU 复检)
|
|
134
|
+
self.state.set_fields(tid, dispatcher_sid=sid)
|
|
135
|
+
self.store.append_event({"ts": time.time(), "actor": sid,
|
|
136
|
+
"event": EV_DISPATCHER_REBOUND,
|
|
137
|
+
"task": tid, "from": d, "to": sid})
|
agorad/core/auth.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""会话牌(token→sid/role)与 enforce 旗标:独立持久化+角色判定矩阵(设计 §4.9)。
|
|
2
|
+
|
|
3
|
+
牌不入 tasks 快照行与消息库,经 write_ephemeral("tokens") 独立落盘,kill -9 后
|
|
4
|
+
新实例从盘恢复;enforce 旗标只内存持有、重启默认 on,开/关与拒绝均写审计
|
|
5
|
+
(events.jsonl 经注入 store.append_event)。Store 构造注入,core 只 import stdlib。
|
|
6
|
+
"""
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
# worker 可创建并管理自己的嵌套子任务;update/cancel 的他任务隔离由 TaskService 归属终审
|
|
11
|
+
WORKER_TOOLS = {"post", "check_mail", "repo_board", "wiki_read", "wiki_write",
|
|
12
|
+
"task_create", "task_dispatch", "task_update", "task_cancel"}
|
|
13
|
+
# dispatcher 通用面=worker 面+建任务/观测;task_update/task_cancel 为 task 域单列
|
|
14
|
+
DISPATCHER_TOOLS = WORKER_TOOLS | {"task_create", "board", "doctor"}
|
|
15
|
+
|
|
16
|
+
# 血统封顶(gt-83185d R1):牌血统最多留最近 16 条曾用名(FIFO 最旧出局),
|
|
17
|
+
# 防持牌调用每次携随机新 X-Agora-Sid 把血统表与落盘无限撑大
|
|
18
|
+
LINEAGE_CAP = 16
|
|
19
|
+
|
|
20
|
+
# 审计事件名常量
|
|
21
|
+
EV_ENFORCE_ON = "enforce_on"
|
|
22
|
+
EV_ENFORCE_OFF = "enforce_off"
|
|
23
|
+
EV_AUTH_DENIED = "auth_denied"
|
|
24
|
+
EV_ROOT_ROTATED = "root_rotated"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Auth:
|
|
28
|
+
"""每会话一牌:root/dispatcher/worker;多主会话各持 root 牌(角色非单例)。"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, store):
|
|
31
|
+
self._store = store
|
|
32
|
+
data = store.read_ephemeral("tokens") or {}
|
|
33
|
+
self._tokens = {t: list(v) for t, v in data.items()} # token -> [sid, role]
|
|
34
|
+
self._enforce = True # 重启默认 on(旗标不落盘)
|
|
35
|
+
|
|
36
|
+
def issue(self, sid, role):
|
|
37
|
+
token = secrets.token_hex()
|
|
38
|
+
self._tokens[token] = [sid, role, []]
|
|
39
|
+
self._persist()
|
|
40
|
+
return token
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _lineage(v):
|
|
44
|
+
"""牌血统集(F16 曾用名);旧格式两元组缺字段=空集。"""
|
|
45
|
+
return v[2] if len(v) > 2 else []
|
|
46
|
+
|
|
47
|
+
def same_lineage(self, current_sid, historical_sid):
|
|
48
|
+
"""F16:current_sid 现持牌的血统集含 historical_sid(换号链边连续)。"""
|
|
49
|
+
return any(historical_sid in self._lineage(v)
|
|
50
|
+
for v in self._tokens.values() if v[0] == current_sid)
|
|
51
|
+
|
|
52
|
+
def identify(self, token):
|
|
53
|
+
v = self._tokens.get(token)
|
|
54
|
+
return (v[0], v[1]) if v else None
|
|
55
|
+
|
|
56
|
+
def rebind(self, old_sid, new_sid):
|
|
57
|
+
"""按 sid 改绑牌到新会话(值 [new_sid, role],token 本体不变)——
|
|
58
|
+
重注入后旧牌即续用;old_sid 入血统集随牌落盘(F16 换号链边连续)。
|
|
59
|
+
返回牌文本(供 inject 送达),无牌 None。"""
|
|
60
|
+
for tok, v in self._tokens.items():
|
|
61
|
+
if v[0] == old_sid:
|
|
62
|
+
v[0] = new_sid
|
|
63
|
+
if len(v) < 3: # 旧格式起档
|
|
64
|
+
v.append([])
|
|
65
|
+
if old_sid not in v[2]:
|
|
66
|
+
v[2].append(old_sid)
|
|
67
|
+
del v[2][:-LINEAGE_CAP] # 封顶:最旧出局
|
|
68
|
+
self._persist()
|
|
69
|
+
return tok
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def token_for(self, sid):
|
|
73
|
+
"""按 sid 查牌文本(F25 周期重注入只读面,不改绑不轮换);无牌 None。"""
|
|
74
|
+
return next((tok for tok, v in self._tokens.items() if v[0] == sid), None)
|
|
75
|
+
|
|
76
|
+
def revoke(self, token):
|
|
77
|
+
if token not in self._tokens:
|
|
78
|
+
return False
|
|
79
|
+
del self._tokens[token]
|
|
80
|
+
self._persist()
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
def revoke_session(self, sid):
|
|
84
|
+
"""按 sid 作废该会话全部牌(worker 单牌语义;cancel/重派/门尾取消共用)。"""
|
|
85
|
+
hit = [t for t, v in self._tokens.items() if v[0] == sid]
|
|
86
|
+
for t in hit:
|
|
87
|
+
del self._tokens[t]
|
|
88
|
+
if hit:
|
|
89
|
+
self._persist()
|
|
90
|
+
return bool(hit)
|
|
91
|
+
|
|
92
|
+
def rotate_root(self):
|
|
93
|
+
# doctor 一键轮换:全部旧 root 牌即废,新牌沿用最近 root 的 sid;其余角色不受累
|
|
94
|
+
roots = [t for t, v in self._tokens.items() if v[1] == "root"]
|
|
95
|
+
sid = self._tokens[roots[-1]][0] if roots else "root"
|
|
96
|
+
for t in roots:
|
|
97
|
+
del self._tokens[t]
|
|
98
|
+
token = self.issue(sid, "root")
|
|
99
|
+
self._store.append_event({"ts": time.time(), "actor": "root",
|
|
100
|
+
"event": EV_ROOT_ROTATED, "note": f"revoked={len(roots)}"})
|
|
101
|
+
return token
|
|
102
|
+
|
|
103
|
+
def may(self, role, tool, tid, state):
|
|
104
|
+
"""矩阵:root→任意;worker→白名单;dispatcher→通用面+task 域(须有任务行)。
|
|
105
|
+
|
|
106
|
+
state=目标任务快照行(State.snapshot()["tasks"][tid]),无行即非己任;
|
|
107
|
+
归属终审(dispatcher_sid==caller sid)由持 actor_token 的 TaskService 负责。
|
|
108
|
+
"""
|
|
109
|
+
if role == "root":
|
|
110
|
+
return True
|
|
111
|
+
if role == "worker":
|
|
112
|
+
return tool in WORKER_TOOLS
|
|
113
|
+
if role == "dispatcher":
|
|
114
|
+
if tool in ("task_update", "task_cancel"):
|
|
115
|
+
return state is not None
|
|
116
|
+
return tool in DISPATCHER_TOOLS
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def set_enforce(self, on, role):
|
|
120
|
+
"""仅 root 生效;开/关与非 root 拒绝均写 events 审计行。"""
|
|
121
|
+
if role != "root":
|
|
122
|
+
self._store.append_event({"ts": time.time(), "actor": role,
|
|
123
|
+
"event": EV_AUTH_DENIED, "note": "enforce"})
|
|
124
|
+
return False
|
|
125
|
+
self._enforce = bool(on)
|
|
126
|
+
self._store.append_event({"ts": time.time(), "actor": role,
|
|
127
|
+
"event": EV_ENFORCE_ON if self._enforce else EV_ENFORCE_OFF})
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def enforce_on(self):
|
|
131
|
+
return self._enforce
|
|
132
|
+
|
|
133
|
+
def _persist(self):
|
|
134
|
+
self._store.write_ephemeral("tokens", self._tokens)
|