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.
Files changed (41) hide show
  1. agora_mcp.py +240 -0
  2. agorad/__init__.py +1 -0
  3. agorad/access/__init__.py +1 -0
  4. agorad/access/anchors.py +22 -0
  5. agorad/access/gitx.py +206 -0
  6. agorad/access/oc.py +124 -0
  7. agorad/access/pages.py +25 -0
  8. agorad/access/store.py +181 -0
  9. agorad/core/__init__.py +1 -0
  10. agorad/core/app.py +137 -0
  11. agorad/core/auth.py +134 -0
  12. agorad/core/gate.py +281 -0
  13. agorad/core/health.py +182 -0
  14. agorad/core/msg.py +92 -0
  15. agorad/core/peer.py +90 -0
  16. agorad/core/recover.py +83 -0
  17. agorad/core/report.py +189 -0
  18. agorad/core/state.py +183 -0
  19. agorad/core/task.py +308 -0
  20. agorad/installer.py +44 -0
  21. agorad/main.py +181 -0
  22. agorad/transport/__init__.py +1 -0
  23. agorad/transport/httpd.py +316 -0
  24. board_tui.py +649 -0
  25. opencode_agora-0.1.0.data/data/share/opencode-agora/agora +244 -0
  26. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/cred.ts +112 -0
  27. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/envPath.ts +33 -0
  28. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/hook.ts +132 -0
  29. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/index.ts +160 -0
  30. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/inject.ts +49 -0
  31. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/lift.ts +161 -0
  32. opencode_agora-0.1.0.data/data/share/opencode-agora/agora-plugin/scope.ts +36 -0
  33. opencode_agora-0.1.0.data/data/share/opencode-agora/install.sh +222 -0
  34. opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-dispatch/SKILL.md +95 -0
  35. opencode_agora-0.1.0.data/data/share/opencode-agora/skills/agora-ops/SKILL.md +60 -0
  36. opencode_agora-0.1.0.dist-info/METADATA +195 -0
  37. opencode_agora-0.1.0.dist-info/RECORD +41 -0
  38. opencode_agora-0.1.0.dist-info/WHEEL +5 -0
  39. opencode_agora-0.1.0.dist-info/entry_points.txt +5 -0
  40. opencode_agora-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. 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)