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
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""入向 JSON 口(B4):12 工具端点+/cred_for_sid(插件凭据拉取面)+/view+/health;
|
|
2
|
+
单线程串行+后台门队列。
|
|
3
|
+
|
|
4
|
+
transport 只 import agorad.core(R2)——access 件经 App 属性触达(store.home
|
|
5
|
+
下 wiki 裸读写,无 import)。鉴权两段(R18):X-Agora-Token→identify(无牌
|
|
6
|
+
401)→may 粗判(越权 403+审计行);归属细判与保险丝归 TaskService。done
|
|
7
|
+
特殊路径=gate 六拒同步应答(拒收落审计),受理绿→落库→1-worker 串行队列后台跑门体。
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import queue
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from agorad.core import report
|
|
17
|
+
from agorad.core.auth import EV_AUTH_DENIED
|
|
18
|
+
from agorad.core.msg import NotChainEdge
|
|
19
|
+
from agorad.core.peer import Rejected as PeerRejected
|
|
20
|
+
from agorad.core.state import EV_RUNNING, InvalidTransition
|
|
21
|
+
from agorad.core.task import Rejected
|
|
22
|
+
|
|
23
|
+
_TOOLS = frozenset({"task_create", "task_dispatch", "task_update", "task_cancel",
|
|
24
|
+
"post", "check_mail", "board", "doctor", "repo_board",
|
|
25
|
+
"wiki_read", "wiki_write", "enforce"})
|
|
26
|
+
_POST_KINDS = ("note", "question", "answer", "done", "verify", "verify_reject",
|
|
27
|
+
"peer")
|
|
28
|
+
_PULL_STATES = frozenset({"dispatched", "running", "done", "verify_failed",
|
|
29
|
+
"reopened", "blocked"}) # 拉取面=在飞非冻结(F25 修正;牌活性二道守卫)
|
|
30
|
+
_DENY_HINT = {"worker": "worker 面:post/check_mail/repo_board/wiki_read/wiki_write/"
|
|
31
|
+
"task_create/task_dispatch/wait;task_update/task_cancel 仅限己建子任务;"
|
|
32
|
+
"需登记 verify→post(kind=verify) 提案验收命令"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
36
|
+
app = None # make_server 经子类属性注入
|
|
37
|
+
gate_q = None
|
|
38
|
+
|
|
39
|
+
def log_message(self, *a): # 测试/常驻输出 pristine
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
# ---- HTTP 谓词 ----
|
|
43
|
+
|
|
44
|
+
def do_GET(self):
|
|
45
|
+
name = self.path.strip("/").split("?")[0]
|
|
46
|
+
if name not in ("view", "health"):
|
|
47
|
+
return self._json(404, {"error": "not found"})
|
|
48
|
+
ident = self.app.auth.identify(self.headers.get("X-Agora-Token", ""))
|
|
49
|
+
if ident is None:
|
|
50
|
+
return self._json(401, {"error": "无牌或牌无效"})
|
|
51
|
+
sid, role = ident
|
|
52
|
+
sid = self._bind_caller(sid, role)
|
|
53
|
+
tool = "board" if name == "view" else "doctor" # 查询面同级(may 矩阵)
|
|
54
|
+
if not self.app.auth.may(role, tool, None, None):
|
|
55
|
+
return self._deny(sid, role, tool, None)
|
|
56
|
+
if name == "view":
|
|
57
|
+
self._html(200, report.view_html(self.app.state.snapshot()))
|
|
58
|
+
else:
|
|
59
|
+
self._json(200, {"ok": True,
|
|
60
|
+
"enforce_on": self.app.auth.enforce_on()})
|
|
61
|
+
|
|
62
|
+
def do_POST(self):
|
|
63
|
+
sid = None
|
|
64
|
+
try:
|
|
65
|
+
name = self.path.strip("/").split("?")[0]
|
|
66
|
+
if name not in _TOOLS and name != "cred_for_sid":
|
|
67
|
+
return self._json(404, {"error": "not found"})
|
|
68
|
+
n = int(self.headers.get("Content-Length", 0))
|
|
69
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
70
|
+
if not isinstance(body, dict):
|
|
71
|
+
raise ValueError("body 须为 JSON 对象")
|
|
72
|
+
if name == "cred_for_sid": # 插件拉取面先于鉴权:无牌是常态(缓存未命中才拉)
|
|
73
|
+
return self._cred_for_sid(body)
|
|
74
|
+
token = self.headers.get("X-Agora-Token", "")
|
|
75
|
+
ident = self.app.auth.identify(token)
|
|
76
|
+
if ident is None:
|
|
77
|
+
return self._json(401, {"error": "无牌或牌无效"})
|
|
78
|
+
sid, role = ident
|
|
79
|
+
sid = self._bind_caller(sid, role) # 改绑后行取新 sid:开工/归属按牌面一致判定
|
|
80
|
+
tid = body.get("task")
|
|
81
|
+
row = None
|
|
82
|
+
if tid is not None:
|
|
83
|
+
try:
|
|
84
|
+
row = self.app.state.get(tid)
|
|
85
|
+
except KeyError:
|
|
86
|
+
return self._json(404, {"error": f"task {tid} 不存在"})
|
|
87
|
+
if not self.app.auth.may(role, name, tid, row):
|
|
88
|
+
return self._deny(sid, role, name, tid)
|
|
89
|
+
self.app.health.touch(sid) # last_seen:每次工具调用即刷新
|
|
90
|
+
if (row and row["state"] in ("dispatched", "verify_failed", "reopened")
|
|
91
|
+
and row.get("worker_sid") == sid):
|
|
92
|
+
# worker 调用即开工(含 verify_failed/reopened 续修边):→running+审计
|
|
93
|
+
self.app.state.transition(tid, EV_RUNNING)
|
|
94
|
+
self.app.store.append_event({"ts": time.time(), "actor": sid,
|
|
95
|
+
"event": EV_RUNNING, "task": tid})
|
|
96
|
+
return self._json(200, self._tool(name, sid, role, token, body))
|
|
97
|
+
except Rejected as e: # 归属细判/保险丝拒(报因在消息)
|
|
98
|
+
return self._json(403, {"error": str(e)})
|
|
99
|
+
except PeerRejected as e: # F22 peer 域拒(权限/软限/无作者任务)
|
|
100
|
+
return self._json(403, {"error": str(e)})
|
|
101
|
+
except NotChainEdge as e:
|
|
102
|
+
return self._bad(sid, name, str(e))
|
|
103
|
+
except InvalidTransition as e:
|
|
104
|
+
return self._json(409, {"error": str(e)})
|
|
105
|
+
except FileNotFoundError as e:
|
|
106
|
+
return self._json(404, {"error": str(e)})
|
|
107
|
+
except KeyError as e: # 缺必填字段
|
|
108
|
+
return self._bad(sid, name, f"缺字段: {e}")
|
|
109
|
+
except (ValueError, OSError) as e:
|
|
110
|
+
return self._bad(sid, name, str(e))
|
|
111
|
+
except Exception as e: # 兜底:带因 500,不让连接静默断
|
|
112
|
+
return self._json(500, {"error": str(e)})
|
|
113
|
+
|
|
114
|
+
# ---- 工具面 ----
|
|
115
|
+
|
|
116
|
+
def _tool(self, name, sid, role, token, body):
|
|
117
|
+
app = self.app
|
|
118
|
+
if name == "task_create":
|
|
119
|
+
vp = body.get("verify_pending", False)
|
|
120
|
+
vc = body.get("verify_cmd")
|
|
121
|
+
if not isinstance(vp, bool): # "false" 字符串得 True——豁免旗语义反转拒收
|
|
122
|
+
raise ValueError("verify_pending 须为布尔")
|
|
123
|
+
if vc is not None and not isinstance(vc, str): # 非串 500 裸奔→400 报因
|
|
124
|
+
raise ValueError("verify_cmd 须为字符串")
|
|
125
|
+
return {"task": app.task.create(body["goal"], body["project_dir"],
|
|
126
|
+
body.get("after"), token,
|
|
127
|
+
title=body.get("title"),
|
|
128
|
+
target_branch=body.get("target_branch"),
|
|
129
|
+
verify_cmd=vc, verify_pending=vp)}
|
|
130
|
+
if name == "task_dispatch":
|
|
131
|
+
return {"task": body["task"],
|
|
132
|
+
"token": app.task.dispatch(body["task"], token)}
|
|
133
|
+
if name == "task_update":
|
|
134
|
+
vc = body["verify_cmd"]
|
|
135
|
+
if not isinstance(vc, str): # 同 create 口径:非串 400 报因,不落 500
|
|
136
|
+
raise ValueError("verify_cmd 须为字符串")
|
|
137
|
+
app.task.update_verify(body["task"], token, vc)
|
|
138
|
+
return {"ok": True}
|
|
139
|
+
if name == "task_cancel":
|
|
140
|
+
force = body.get("force", False)
|
|
141
|
+
if not isinstance(force, bool): # "true" 字符串得 True——强杀语义反转拒收
|
|
142
|
+
raise ValueError("force 须为布尔")
|
|
143
|
+
return app.task.cancel(body["task"], token, force=force) # F28 忙碌闸应答直透
|
|
144
|
+
if name == "post":
|
|
145
|
+
return self._post(sid, role, body)
|
|
146
|
+
if name == "check_mail":
|
|
147
|
+
return {"messages": app.router.check_mail(sid)}
|
|
148
|
+
if name == "board":
|
|
149
|
+
snap = app.state.snapshot()
|
|
150
|
+
if "task" in body:
|
|
151
|
+
return report.detail(snap, app.store, body["task"])
|
|
152
|
+
return {"buckets": report.buckets(snap),
|
|
153
|
+
"chain": report.chain_view(snap, sid),
|
|
154
|
+
"forest": report.forest_view(snap)}
|
|
155
|
+
if name == "doctor":
|
|
156
|
+
return report.doctor_view(app.state.snapshot(), app.store.home)
|
|
157
|
+
if name == "repo_board": # F22:worker 按己任务仓分区;root/dispatcher 可传 repo 诊断
|
|
158
|
+
snap = app.state.snapshot()
|
|
159
|
+
repo = (app.peer.partition(sid) if role == "worker"
|
|
160
|
+
else body.get("repo"))
|
|
161
|
+
return app.peer.board(snap, repo)
|
|
162
|
+
if name == "wiki_read":
|
|
163
|
+
return {"text": self._wiki(body["path"]).read_text(
|
|
164
|
+
encoding="utf-8")}
|
|
165
|
+
if name == "wiki_write":
|
|
166
|
+
rel = Path(body["path"])
|
|
167
|
+
if rel.parts and rel.parts[0] == "tasks": # 任务页服务独占刷新
|
|
168
|
+
raise Rejected("wiki/tasks/ 前缀拒写(服务独占)")
|
|
169
|
+
p = self._wiki(rel)
|
|
170
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
p.write_text(str(body["text"]), encoding="utf-8")
|
|
172
|
+
return {"ok": True}
|
|
173
|
+
if name == "enforce": # may() 已滤非 root;开/关审计在 Auth
|
|
174
|
+
on = body["on"]
|
|
175
|
+
if not isinstance(on, bool): # "false" 字符串得 True——语义反转拒收
|
|
176
|
+
raise ValueError("on 须为布尔")
|
|
177
|
+
app.auth.set_enforce(on, role)
|
|
178
|
+
return {"enforce_on": app.auth.enforce_on()}
|
|
179
|
+
raise KeyError(name)
|
|
180
|
+
|
|
181
|
+
def _bind_caller(self, sid, role):
|
|
182
|
+
"""R25-A root 活绑定+gt-d55b9e worker 换号自愈:MCP 壳透传 X-Agora-Sid
|
|
183
|
+
(调用者真实会话 sid)即对齐牌面;无头直连(T4 冒烟/看板 tui)不动绑定。
|
|
184
|
+
返回对齐后 sid——改绑发生时后续归属判定须以新 sid 走。"""
|
|
185
|
+
caller = self.headers.get("X-Agora-Sid", "")
|
|
186
|
+
if not caller:
|
|
187
|
+
return sid
|
|
188
|
+
if role == "root":
|
|
189
|
+
return self.app.bind_main(sid, caller) # 对齐后 sid:改绑当笔即生效(F21 前置)
|
|
190
|
+
elif role == "worker":
|
|
191
|
+
return self.app.bind_worker(sid, caller)
|
|
192
|
+
return sid
|
|
193
|
+
|
|
194
|
+
def _deny(self, sid, role, tool, tid):
|
|
195
|
+
"""越权 403:POST/GET 共用(审计行同形);hint 非空附可用面退路(F13)。"""
|
|
196
|
+
hint = _DENY_HINT.get(role, "")
|
|
197
|
+
self.app.store.append_event(
|
|
198
|
+
{"ts": time.time(), "actor": sid, "event": EV_AUTH_DENIED,
|
|
199
|
+
"tool": tool, "task": tid, "note": f"role={role}"})
|
|
200
|
+
msg = f"{tool} 拒绝(role={role})"
|
|
201
|
+
if hint:
|
|
202
|
+
msg += f"——{hint}"
|
|
203
|
+
return self._json(403, {"error": msg})
|
|
204
|
+
|
|
205
|
+
def _bad(self, sid, tool, msg):
|
|
206
|
+
"""F13 参数错 400:落 bad_request 审计行(可计量)后带因返回。"""
|
|
207
|
+
if sid is not None: # 鉴权前解析错属协议噪声,不混入参数错计量
|
|
208
|
+
try: # best-effort:审计写失败不影响 400 应答
|
|
209
|
+
self.app.store.append_event(
|
|
210
|
+
{"ts": time.time(), "actor": sid,
|
|
211
|
+
"event": "bad_request", "tool": tool or "?",
|
|
212
|
+
"note": msg[:200]})
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return self._json(400, {"error": msg})
|
|
216
|
+
|
|
217
|
+
def _post(self, sid, role, body):
|
|
218
|
+
app, kind = self.app, body["kind"]
|
|
219
|
+
if kind == "peer": # F22 写面:无任务目标,作者=调用者任务;仅 worker 牌
|
|
220
|
+
if role != "worker":
|
|
221
|
+
raise PeerRejected("peer 留言仅 worker 牌可写")
|
|
222
|
+
return {"message": app.peer.post(sid, str(body["body"]))}
|
|
223
|
+
tid = body["task"]
|
|
224
|
+
if kind not in _POST_KINDS: # F13 客户端 kind 白名单;内部直调 Router 不经此路
|
|
225
|
+
raise ValueError(f"kind 须为 {'/'.join(_POST_KINDS)} 之一(收到 {kind!r})")
|
|
226
|
+
t = app.state.get(tid)
|
|
227
|
+
if kind == "done": # F7 入口:六拒同步应答,受理绿才落库并入门
|
|
228
|
+
if t.get("worker_sid") != sid:
|
|
229
|
+
raise NotChainEdge(f"{sid}->{tid}: done 仅任务 worker")
|
|
230
|
+
r = app.gate.accept_done(tid, sid, str(body["body"]).strip())
|
|
231
|
+
if r != "accepted":
|
|
232
|
+
return {"result": r}
|
|
233
|
+
app.router.post(sid, ("up", tid), "done", str(body["body"]))
|
|
234
|
+
self.gate_q.put(tid) # 门体后台串行
|
|
235
|
+
return {"result": "accepted"}
|
|
236
|
+
if not app.router.is_dispatcher(sid, t):
|
|
237
|
+
app.bind_dispatcher(role, sid, t) # F21:root 持牌+登记号死→改绑后放行
|
|
238
|
+
t = app.state.get(tid)
|
|
239
|
+
edge = ("down", tid) if self.app.router.is_dispatcher(sid, t) else ("up", tid)
|
|
240
|
+
return {"message": app.router.post(sid, edge, kind, str(body["body"]))}
|
|
241
|
+
|
|
242
|
+
def _cred_for_sid(self, body):
|
|
243
|
+
"""F25 修正推改拉面:插件 hook 缓存未命中按会话 sid 拉凭据。
|
|
244
|
+
|
|
245
|
+
sid 是某在飞(非冻结)任务 worker_sid 且牌活 → {tid, env_dir, token},
|
|
246
|
+
否则 404;无牌调用放行(server 仅绑 127.0.0.1=回环单用户 plaza 信任
|
|
247
|
+
模型,与既有 localhost 面同口径);每请求一行审计,防噪归插件侧节流。
|
|
248
|
+
"""
|
|
249
|
+
asker = str(body.get("sid") or "")
|
|
250
|
+
hit = next(((tid, t) for tid, t in self.app.state.snapshot()["tasks"].items()
|
|
251
|
+
if t.get("worker_sid") == asker and t["state"] in _PULL_STATES
|
|
252
|
+
and t.get("env_dir")), None)
|
|
253
|
+
tok = self.app.auth.token_for(asker) if hit else None # 牌废(取消/重派)即 404
|
|
254
|
+
self.app.store.append_event({"ts": time.time(), "actor": asker or "?",
|
|
255
|
+
"event": "cred_pull",
|
|
256
|
+
"task": hit[0] if tok else None})
|
|
257
|
+
if not tok:
|
|
258
|
+
return self._json(404, {"error": "no cred for sid"})
|
|
259
|
+
# F27 等价性注释:此处返回 token 的信任边界与直读 $AGORA_HOME/tokens 凭据文件
|
|
260
|
+
# 等价(单用户回环模型,本端点仅 127.0.0.1 可达)——未新增攻击面,勿过度加固。
|
|
261
|
+
return self._json(200, {"tid": hit[0], "env_dir": hit[1]["env_dir"],
|
|
262
|
+
"token": tok})
|
|
263
|
+
|
|
264
|
+
def _wiki(self, rel):
|
|
265
|
+
root = (Path(self.app.store.home) / "wiki").resolve()
|
|
266
|
+
p = (root / rel).resolve()
|
|
267
|
+
p.relative_to(root) # 越出 wiki 根即 ValueError → 400
|
|
268
|
+
return p
|
|
269
|
+
|
|
270
|
+
# ---- 应答 ----
|
|
271
|
+
|
|
272
|
+
def _json(self, code, obj):
|
|
273
|
+
body = json.dumps(obj, ensure_ascii=False).encode()
|
|
274
|
+
self.send_response(code)
|
|
275
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
276
|
+
self.send_header("Content-Length", str(len(body)))
|
|
277
|
+
self.end_headers()
|
|
278
|
+
self.wfile.write(body)
|
|
279
|
+
|
|
280
|
+
def _html(self, code, text):
|
|
281
|
+
body = text.encode()
|
|
282
|
+
self.send_response(code)
|
|
283
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
284
|
+
self.send_header("Content-Length", str(len(body)))
|
|
285
|
+
self.end_headers()
|
|
286
|
+
self.wfile.write(body)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def make_server(app, port: int = 0):
|
|
290
|
+
"""单线程 HTTPServer(串行口径 §4.8)+后台门队列(1 worker 串行)。
|
|
291
|
+
|
|
292
|
+
装配即重排 done 残留(kill -9 门中崩溃):重启后 verify 幂等重跑,
|
|
293
|
+
pending-cancel 与门闩照常由门体结算(F-4)。
|
|
294
|
+
"""
|
|
295
|
+
q = queue.Queue()
|
|
296
|
+
for tid, t in app.state.snapshot()["tasks"].items():
|
|
297
|
+
if t["state"] == "done":
|
|
298
|
+
q.put(tid)
|
|
299
|
+
|
|
300
|
+
def consume():
|
|
301
|
+
while True:
|
|
302
|
+
tid = q.get()
|
|
303
|
+
try:
|
|
304
|
+
app.gate.run(tid)
|
|
305
|
+
except Exception: # 门体异常不杀 worker 线程:状态以快照行兜底
|
|
306
|
+
pass
|
|
307
|
+
finally:
|
|
308
|
+
q.task_done()
|
|
309
|
+
|
|
310
|
+
threading.Thread(target=consume, daemon=True, name="gate").start()
|
|
311
|
+
|
|
312
|
+
class Handler(_Handler):
|
|
313
|
+
pass
|
|
314
|
+
Handler.app = app
|
|
315
|
+
Handler.gate_q = q
|
|
316
|
+
return HTTPServer(("127.0.0.1", port), Handler)
|