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,244 @@
1
+ #!/usr/bin/env python3
2
+ """agora CLI(F14):bash 可判定的只读条件谓词——看门狗规范条件等待。
3
+
4
+ wait 子命令阻塞等待任务终态(merged/closed/blocked/cancelled),对齐
5
+ docker wait / kubectl wait 约定。status 子命令(F28 防误杀)单查任务三态
6
+ 判读:数据全取既有 /board 单任务视图+/doctor(零服务端新端点),分类
7
+ 客户端推导。取数照抄 board_tui.py 形态:
8
+ $AGORA_HOME/.agora/{root.token,service.json},零直读状态文件、零格式逆向。
9
+ 非 MCP agora.wait(seconds)(那是 agent 回合内本地睡眠,两者不互替)。
10
+ """
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ TERMINAL = frozenset(("merged", "closed", "blocked", "cancelled"))
21
+ _INFLIGHT = ("todo", "dispatched", "running", "done", "verify_failed", "reopened")
22
+ _INTERVAL = 15 # 内部轮询间隔(秒)
23
+ _HTTP_TIMEOUT = 5 # 单次请求超时(board_tui 同值)
24
+ _TIMEOUT_MSG = "须为非负有限秒数"
25
+ _STALE_AFTER = 1200 # 心跳冷阈值(秒)——与 agorad report._STALE_AFTER 同口径(客户端推导锚)
26
+
27
+
28
+ class Fail(Exception):
29
+ """终局异常(不可重试)——exit 2。"""
30
+
31
+
32
+ class Transient(Exception):
33
+ """瞬时不可达(连接拒等 OSError 族)——阻塞形态内部重试吸收。"""
34
+
35
+
36
+ class Rejected(Transient):
37
+ """认牌失败(401/403)——牌换代窗口瞬态;不跟随换模时仍终局 Fail。"""
38
+
39
+
40
+ def _pointer_home():
41
+ """~/.agora-home 指针回退(gt-32c051 统一发现桥;board_tui 同形)。"""
42
+ try:
43
+ return ((Path.home() / ".agora-home").read_text(encoding="utf-8")
44
+ .split("\n", 1)[0].strip() or None)
45
+ except (OSError, ValueError): # ValueError=坏字节(UnicodeDecodeError 非 OSError 族)
46
+ return None
47
+
48
+
49
+ def discover(home=None):
50
+ """root 牌+端口:$AGORA_HOME/.agora/{root.token,service.json}(board_tui 同形)。"""
51
+ home = Path(home or os.environ.get("AGORA_HOME") or _pointer_home()
52
+ or Path.home() / "plaza")
53
+ sj = json.loads((home / ".agora" / "service.json").read_text(encoding="utf-8"))
54
+ port = int(sj["port"]) # 非数端口(半写形态)在此炸 ValueError/TypeError,不外溢 fetch
55
+ token = (home / ".agora" / "root.token").read_text(encoding="utf-8").strip()
56
+ if "\n" in token or "\r" in token: # 控制符会让 putheader 带牌面炸进 stderr/折叠改头
57
+ raise ValueError("root 牌撕裂(含换行/回车)")
58
+ return f"http://127.0.0.1:{port}", token
59
+
60
+
61
+ def _call(base, token, path, body):
62
+ """HTTP 取数缝(fetch/status 共用):POST <path> 带 root 牌回解析 JSON。
63
+
64
+ 401/403→Rejected(换模跟随窗按瞬态)、其余 HTTP 错→Fail(404 注任务未知)、
65
+ 网络错(OSError 族)→Transient(可重试)。
66
+ """
67
+ req = urllib.request.Request(
68
+ base + path, data=json.dumps(body).encode(),
69
+ headers={"Content-Type": "application/json", "X-Agora-Token": token},
70
+ method="POST")
71
+ try:
72
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as r:
73
+ return json.loads(r.read().decode())
74
+ except urllib.error.HTTPError as e:
75
+ if e.code in (401, 403): # 认牌失败:牌换代/撕裂对窗口,跟随形态按瞬态
76
+ raise Rejected(f"HTTP {e.code}: {path}") from None
77
+ note = "(任务未知)" if e.code == 404 and path == "/board" else ""
78
+ raise Fail(f"HTTP {e.code}" + note + f": {path}") from None
79
+ except OSError as e: # URLError(连接拒等)=瞬时,可重试
80
+ raise Transient(str(e)) from None
81
+
82
+
83
+ def fetch(base, token, tid):
84
+ """POST /board 单任务视图 → (state, commit)。报因尾附 tid(看门狗聚合 stderr 可归任务)。"""
85
+ try:
86
+ t = _call(base, token, "/board", {"task": tid})["task"]
87
+ except (Fail, Rejected) as e:
88
+ raise type(e)(f"{e}: {tid}") from None
89
+ return t["state"], t.get("commit")
90
+
91
+
92
+ def classify(state):
93
+ """状态分类缝(exit code 决策):终态→0,在飞(含未知态)→1。"""
94
+ return 0 if state in TERMINAL else 1
95
+
96
+
97
+ def wait(tid, base, token, timeout=1800, once=False, interval=_INTERVAL,
98
+ clock=time.monotonic, pause=time.sleep, reread=None):
99
+ """轮询至终态:返回 (exit, stdout 行);重试至超时仍不可达 raise Fail。
100
+
101
+ reread(run_wait 注入 discover)每轮轮询前重读指针——服务换模
102
+ (杀旧拉新、端口/牌换代)即跟随新端点续等,不对死端口空转到超时;
103
+ 跟随时认牌失败(401/403,撕裂对/换代窗口)按瞬态重试。
104
+ """
105
+ deadline = clock() + timeout
106
+ while True:
107
+ if reread:
108
+ try:
109
+ nb, nt = reread()
110
+ if nb and nt: # 半写空端点/空牌不采纳,保持旧端点下轮再读
111
+ base, token = nb, nt
112
+ except (OSError, ValueError, TypeError, KeyError):
113
+ pass # 指针半写/缺失(含非 dict JSON)保持旧端点,下轮再读
114
+ try:
115
+ state, commit = fetch(base, token, tid)
116
+ except Transient as e:
117
+ if isinstance(e, Rejected) and reread is None:
118
+ raise Fail(str(e)) from None # 不跟随换模:认牌失败维持终局
119
+ if once or clock() >= deadline:
120
+ head = ("认牌失败" if isinstance(e, Rejected)
121
+ else "服务不可达" if once else "服务不可达(重试至超时)")
122
+ raise Fail(f"{head}: {e}") from None
123
+ else:
124
+ if classify(state) == 0:
125
+ return 0, (f"{state} @{commit[:12]}" if commit else state)
126
+ if once or clock() >= deadline:
127
+ return 1, f"in-flight: {state}"
128
+ pause(min(interval, max(0.0, deadline - clock())))
129
+
130
+
131
+ def run_wait(a):
132
+ base, token = discover()
133
+ if not token:
134
+ raise Fail("root 牌缺失")
135
+ code, line = wait(a.task_id, base, token, timeout=a.timeout, once=a.once,
136
+ reread=discover)
137
+ print(line)
138
+ return code
139
+
140
+
141
+ def status_view(tid, task, stale_ids, now):
142
+ """F28 三态判读(客户端推导,零服务端新端点):数据=/board 单任务视图+/doctor。
143
+
144
+ fresh=心跳近(last_seen 距今≤_STALE_AFTER);busy_round=在飞+心跳冷+
145
+ 不在 doctor stale_candidates(基线含 env 活性,env 在写=深潜中);stale_candidate
146
+ =在 doctor 僵态候选单。终态或无 worker=无判读(class None)。
147
+ """
148
+ state = task["state"]
149
+ seen = task.get("last_seen")
150
+ ago = None if not seen else max(0, int(now - seen))
151
+ if state in TERMINAL or not task.get("worker_sid"):
152
+ cls = None
153
+ elif tid in stale_ids:
154
+ cls = "stale_candidate"
155
+ elif ago is None or ago > _STALE_AFTER:
156
+ cls = "busy_round"
157
+ else:
158
+ cls = "fresh"
159
+ return {"task": tid, "state": state, "last_seen_ago": ago, "class": cls}
160
+
161
+
162
+ def run_status(a):
163
+ base, token = discover()
164
+ if not token:
165
+ raise Fail("root 牌缺失")
166
+ now = time.time()
167
+ try:
168
+ task = _call(base, token, "/board", {"task": a.task_id})["task"]
169
+ doc = _call(base, token, "/doctor", {})
170
+ except Fail as e: # 报因尾附 tid(镜像 fetch:看门狗聚合 stderr 可归任务)
171
+ raise type(e)(f"{e}: {a.task_id}") from None
172
+ except Transient as e: # 单查不重试:判读要新鲜,旧数据不如报错
173
+ head = "认牌失败" if isinstance(e, Rejected) else "服务不可达"
174
+ raise Fail(f"{head}: {e}: {a.task_id}") from None
175
+ v = status_view(a.task_id, task,
176
+ {tid for tid, _ in doc.get("stale_candidates", [])},
177
+ now) # 旧 daemon 无该键→空集降级,不裸 KeyError
178
+ if a.json:
179
+ print(json.dumps(v, ensure_ascii=False))
180
+ else:
181
+ ago = "-" if v["last_seen_ago"] is None else f"{v['last_seen_ago']}s"
182
+ print(f"{v['task']} state={v['state']} last_seen={ago} "
183
+ f"class={v['class'] or '-'}")
184
+ return 0
185
+
186
+
187
+ def _timeout_arg(v):
188
+ """非负有限秒数;拒 nan/inf/负值(nan 会让 deadline 比较恒 False 变热轮询)。"""
189
+ try:
190
+ t = float(v)
191
+ except (ValueError, TypeError):
192
+ raise argparse.ArgumentTypeError(_TIMEOUT_MSG) from None
193
+ if not 0 <= t < float("inf"):
194
+ raise argparse.ArgumentTypeError(_TIMEOUT_MSG)
195
+ return t
196
+
197
+
198
+ def _parser():
199
+ ap = argparse.ArgumentParser(
200
+ prog="agora",
201
+ description="agora CLI——bash 可判定的只读条件谓词(F14)。")
202
+ sub = ap.add_subparsers(dest="cmd", required=True, metavar="command")
203
+ w = sub.add_parser(
204
+ "wait", help="阻塞等待任务终态(--once 单查)",
205
+ description="阻塞等待任务终态(--once 单查);非 MCP agora.wait(seconds)"
206
+ "(那是 agent 回合内本地睡眠)。",
207
+ epilog="退出码:0=终态到达(merged/closed/blocked/cancelled 任一)\n"
208
+ f"1=--timeout 到期仍在飞({'/'.join(_INFLIGHT)}),"
209
+ "或 --once 下当前在飞\n"
210
+ "2=异常(用法错/任务未知 404/root 牌缺失/认牌失败/重试至超时仍不可达)",
211
+ formatter_class=argparse.RawDescriptionHelpFormatter)
212
+ w.add_argument("task_id", metavar="task-id", help="任务 id(ag-xxxxxx 或历史 task-/gt-xxxxxx)")
213
+ w.add_argument("--timeout", type=_timeout_arg, default=1800,
214
+ help="到期仍在飞退出 1(秒);缺省 1800(防调用方永挂)")
215
+ w.add_argument("--once", action="store_true", help="单查形态,立即返回不阻塞")
216
+ w.set_defaults(fn=run_wait)
217
+ s = sub.add_parser(
218
+ "status", help="任务三态判读单查(F28 防误杀:cancel 前先看一眼)",
219
+ description="单查任务三态判读:state+last_seen 距今秒+分类——"
220
+ "fresh(心跳近)/busy_round(在飞+心跳冷+非僵态候选)/"
221
+ "stale_candidate(在 doctor 僵态候选单);数据取 /board+/doctor。",
222
+ epilog="退出码:0=取数成功(判读读输出,不作退出码)\n"
223
+ "2=异常(用法错/任务未知/不可达/认牌失败)",
224
+ formatter_class=argparse.RawDescriptionHelpFormatter)
225
+ s.add_argument("task_id", metavar="task-id", help="任务 id(ag-xxxxxx 或历史 task-/gt-xxxxxx)")
226
+ s.add_argument("--json", action="store_true", help="机器可读 JSON 形态")
227
+ s.set_defaults(fn=run_status)
228
+ return ap
229
+
230
+
231
+ def main(argv=None):
232
+ argv = sys.argv[1:] if argv is None else argv
233
+ try:
234
+ a = _parser().parse_args(argv)
235
+ return a.fn(a)
236
+ except KeyboardInterrupt:
237
+ return 130 # 打断≠终态:非零防看门狗误判 merged
238
+ except Exception as e:
239
+ print(f"agora: {e}", file=sys.stderr)
240
+ return 2
241
+
242
+
243
+ if __name__ == "__main__":
244
+ sys.exit(main())
@@ -0,0 +1,112 @@
1
+ /**
2
+ * cred 凭据管家(设计 §5.2):会话牌缓存+落盘读取,供 hook 判定链核验。
3
+ * 牌随任务生死——cancel/重派即废(缓存清理由 inject 侧 T3 对齐)。
4
+ * F25 修正推改拉:推送通道(inject_silent)证伪后,缓存未命中经 pullCredential
5
+ * 主动向 agorad /cred_for_sid 拉取自愈(节流,失败安全向 null=默认拒)。
6
+ */
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { AGORAD_TIMEOUT_MS, agoradUrl } from "./lift";
10
+
11
+ export type AgoraRole = "root" | "dispatcher" | "worker";
12
+ export type Cred = { token: string; sid: string; role: AgoraRole; tid?: string; envDir?: string };
13
+
14
+ const cache = new Map<string, Cred>(); // sid → cred
15
+
16
+ export function cacheCredential(cred: Cred): void {
17
+ cache.set(cred.sid, cred);
18
+ }
19
+
20
+ export function getCredential(sid: string): Cred | undefined {
21
+ return cache.get(sid);
22
+ }
23
+
24
+ // 拉取节流:每 sid 每窗至多一拉(未命中也计窗,保护 agorad);按 sid 而非进程级
25
+ // ——同宿主多 worker 会话共享插件进程,进程级窗口会让别家首拉被误堵一窗
26
+ const PULL_INTERVAL_MS = 30_000;
27
+ const pullSeen = new Map<string, number>(); // sid->上次拉取时刻
28
+ const lastVerdict = new Map<string, PullReason>(); // sid->窗内回放判据(评审 high 修复)
29
+
30
+ /** 测试注入口:清节流窗。 */
31
+ export function resetCredPull(): void {
32
+ pullSeen.clear();
33
+ lastVerdict.clear();
34
+ }
35
+
36
+ /** 拉取拒因四分支(F27):ok=命中;not_worker=404 服务侧无在飞任务绑本会话(主会话常态);
37
+ * unreachable=连接拒/超时/5xx/形状缺(异常安全向);throttled=节流窗内零网络。 */
38
+ export type PullReason = "ok" | "not_worker" | "unreachable" | "throttled";
39
+ export type PullResult = { reason: PullReason; cred: Cred | null };
40
+
41
+ /** 缓存未命中拉取自愈(F25 修正推改拉):命中即填缓存并返回;其余按拒因枚举返回
42
+ * cred=null=走现有默认拒文案。短超时 2s:不拖垮工具调用判定链。 */
43
+ export async function pullCredential(sid: string, port: number): Promise<PullResult> {
44
+ const now = Date.now();
45
+ if (now - (pullSeen.get(sid) ?? 0) < PULL_INTERVAL_MS) {
46
+ // 窗内零网络但回放上次判据——裸 throttled 会把已知 not_worker 降级:root 牌永不入
47
+ // 缓存,主会话窗内第二次调用将断附牌/误吃 READ_HINT(≤30s 陈旧度与节流设计同界)。
48
+ return { reason: lastVerdict.get(sid) ?? "throttled", cred: null };
49
+ }
50
+ for (const [k, ts] of pullSeen) { // 出账:只留窗内活跃 sid,防常驻进程无界增长
51
+ if (now - ts >= PULL_INTERVAL_MS) {
52
+ pullSeen.delete(k);
53
+ lastVerdict.delete(k);
54
+ }
55
+ }
56
+ pullSeen.set(sid, now);
57
+ try {
58
+ const r = await fetch(agoradUrl(port, "/cred_for_sid"), {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ sid }),
62
+ signal: AbortSignal.timeout(AGORAD_TIMEOUT_MS),
63
+ });
64
+ if (r.status === 404) {
65
+ lastVerdict.set(sid, "not_worker"); // 非在飞 worker:常态未命中(窗内回放防降级)
66
+ return { reason: "not_worker", cred: null };
67
+ }
68
+ if (!r.ok) {
69
+ lastVerdict.set(sid, "unreachable");
70
+ return { reason: "unreachable", cred: null };
71
+ }
72
+ const d = (await r.json()) as { tid?: string; env_dir?: string; token?: string };
73
+ // 形状缺=异常安全向(归 unreachable:调用方不得回落 root 附牌)
74
+ if (!d.token || !d.env_dir) {
75
+ lastVerdict.set(sid, "unreachable");
76
+ return { reason: "unreachable", cred: null };
77
+ }
78
+ const cred: Cred = { token: d.token, sid, role: "worker", tid: d.tid, envDir: d.env_dir };
79
+ cacheCredential(cred);
80
+ lastVerdict.delete(sid); // ok 判据随缓存生效(后续走缓存命中,不再进本函数)
81
+ return { reason: "ok", cred };
82
+ } catch {
83
+ lastVerdict.set(sid, "unreachable"); // 服务不可达/超时:默认拒(与判定链服务不可达同口径)
84
+ return { reason: "unreachable", cred: null };
85
+ }
86
+ }
87
+
88
+ const CRED_FILE = ".agora-cred.json";
89
+
90
+ /** 读会话目录内的落盘凭据;缺失/损坏一律 null(调用方按无凭据拒,安全方向)。 */
91
+ export function readCredential(sessionDir: string): Cred | null {
92
+ try {
93
+ const cred = JSON.parse(readFileSync(join(sessionDir, CRED_FILE), "utf8")) as Cred;
94
+ return cred.token ? cred : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** 落盘凭据(0600);T5 实证 v2.1.4 无插件可见会话目录——inject 路径不落盘(内存缓存+
101
+ * 服务侧 reinject 重发兜底),本函数留给未来选定落盘点(如 $AGORA_HOME)时复用。 */
102
+ export function writeCredential(sessionDir: string, cred: Cred): void {
103
+ writeFileSync(join(sessionDir, CRED_FILE), JSON.stringify(cred) + "\n", { mode: 0o600 });
104
+ }
105
+
106
+ // 附牌形状(T5 实证):tool.execute.before 改 ev.input 直达 MCP——index.ts 对 agora_* 工具
107
+ // 直接写 input._token;本函数保留纯函数形态供非钩子面复用与测试。
108
+ export function attachToken(args: Record<string, unknown>, sid: string): Record<string, unknown> {
109
+ const cred = cache.get(sid);
110
+ if (cred) args._token = cred.token;
111
+ return args;
112
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * worker PATH 补全(F18):opencode 服务层剥离 PATH(worker 实测只剩
3
+ * ".opencode/bin:/usr/local/bin:/usr/bin:/bin"),bun 等用户级工具失联。
4
+ * 接缝=shell spawn 前钩子(v2.2.0 实证:shell 工具/终端同经 Shell.create→
5
+ * trigger("shell","create.before",payload),payload.env 按引用直改直达 spawn env):
6
+ * 基于既有 PATH 去重前插 $HOME/.bun/bin 与 $HOME/.local/bin,不硬编码全量。
7
+ */
8
+ import { homedir } from "node:os";
9
+ import { delimiter, isAbsolute, join } from "node:path";
10
+
11
+ const EXTRA = [".bun/bin", ".local/bin"];
12
+
13
+ /** shell "create.before" 载荷本地形状(运行时未契约化,偏离由 onShellCreate 安全向兜住)。 */
14
+ export type ShellCreatePayload = { env?: Record<string, string | undefined> };
15
+
16
+ /** 原地补全 env.PATH:只加缺失项(前插保序去重),幂等;无 PATH 视为空;home 空/相对均不动(相对路径前插=CWD 依赖,安全向)。 */
17
+ export function augmentPath(env: Record<string, string | undefined>): void {
18
+ const home = env.HOME || homedir();
19
+ if (!isAbsolute(home)) return;
20
+ const parts = env.PATH ? env.PATH.split(delimiter) : []; // 保留既有空项(POSIX 空项=CWD),契约只加不减
21
+ const missing = EXTRA.map((d) => join(home, d)).filter((p) => !parts.includes(p));
22
+ env.PATH = [...missing, ...parts].join(delimiter);
23
+ }
24
+
25
+ /** shell "create.before" 钩子入口:payload 缺 env 或补全异常均静默(安全向=不动 spawn env,绝不阻断 Shell.create)。 */
26
+ export function onShellCreate(payload: ShellCreatePayload | undefined): void {
27
+ if (!payload?.env) return;
28
+ try {
29
+ augmentPath(payload.env);
30
+ } catch {
31
+ // 运行时载荷偏离本地类型(如 PATH 非串)也不得炸钩子——钩子横切每次 shell spawn
32
+ }
33
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * hook 强制判定链(F1,设计 §5.1):拦截判定纯函数,opencode 接线形状 T5 已实证收紧
3
+ * (tool.execute.before 覆盖 MCP 工具+改参直达;cwd=会话 location,异步取)。
4
+ * 语义与 agorad 服务侧一致:豁免面 B3(非 git、/tmp/opencode、enforce off)、
5
+ * 读引导(state 账本经工具面,events/wiki 开放,主会话例外)、主会话对仓写入同拦。
6
+ */
7
+ import { existsSync, realpathSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
10
+ import type { Cred, PullReason } from "./cred";
11
+
12
+ export const DISPATCH_HINT =
13
+ "被拦:本机 git 仓直改仅限委托的隔离环境(cwd∈任务 env)。请经 agora 任务面派发(task_create/task_dispatch)持凭据作业;直接改仓仅限豁免面。";
14
+ export const NO_CRED_HINT =
15
+ "被拦:无凭据缓存——可经真实 prompt 通道重注 BOOT+credential 自愈(配方见任务页),或 post(kind=question) 求派发方。";
16
+ export const READ_HINT =
17
+ "被拦:tasks.jsonl 与 messages/ 属账本面,请走工具面(board/check_mail);events.jsonl 与 wiki 可直接读。主会话不受此限。";
18
+
19
+ const TMP_ROOT = "/tmp/opencode"; // B3 设计定值
20
+ const WRITE_TOOLS = new Set(["edit", "write"]);
21
+ const READISH_TOOLS = new Set(["read", "bash"]);
22
+
23
+ export function agoraHome(homeDir = homedir()): string {
24
+ // 判定链信任锚仅 env(gt-32c051 裁定 A):~/.agora-home 指针是被辖方可写文件,
25
+ // 若跟随则 enforce 旗/读边界可被一行 bash 挪根(终闸 C 册 blocker 实证)——禁加指针回退。
26
+ // homeDir 参数仅供测试注入口(Bun 缓存 homedir)。
27
+ if (process.env.AGORA_HOME) return process.env.AGORA_HOME;
28
+ return join(homeDir, "plaza");
29
+ }
30
+
31
+ /** realpath 折叠:末端不存在时折叠到最深存在祖先再拼回(write 目标常尚未创建)。 */
32
+ function fold(p: string): string {
33
+ let cur = resolve(p);
34
+ const tail: string[] = [];
35
+ for (;;) {
36
+ try {
37
+ return join(realpathSync.native(cur), ...tail);
38
+ } catch {
39
+ const parent = dirname(cur);
40
+ if (parent === cur) return resolve(p);
41
+ tail.unshift(basename(cur));
42
+ cur = parent;
43
+ }
44
+ }
45
+ }
46
+
47
+ function under(root: string, p: string): boolean {
48
+ const r = fold(root);
49
+ return p === r || p.startsWith(r + sep);
50
+ }
51
+
52
+ /** git 仓判定:沿目标向上找 .git(目录或文件——worktree/submodule 形态)。 */
53
+ function inGitRepo(p: string): boolean {
54
+ let cur = resolve(p);
55
+ for (;;) {
56
+ if (existsSync(join(cur, ".git"))) return true;
57
+ const parent = dirname(cur);
58
+ if (parent === cur) return false;
59
+ cur = parent;
60
+ }
61
+ }
62
+
63
+ /** 该工具是否落在判定链可能产出 hint 的类(write 引导面+read/bash 账本读引导面)——
64
+ * enforce 旗按需拉取的预过滤(T6 评审:豁免类工具不付拉旗开销)。 */
65
+ export function enforceRelevant(tool: string): boolean {
66
+ return WRITE_TOOLS.has(tool) || READISH_TOOLS.has(tool);
67
+ }
68
+
69
+ /** write 判定类(F25 修正:仅 write 工具缓存未命中触发 cred_for_sid 拉取)。 */
70
+ export function isWriteTool(tool: string): boolean {
71
+ return WRITE_TOOLS.has(tool);
72
+ }
73
+
74
+ /** read/bash 目标落 state 账本子树(READ_HINT 面才消费凭据)——F27 readish 拉取 gate,
75
+ * 防普通 read 每次白付拉取 HTTP。 */
76
+ export function readishGated(tool: string, targetPath: string): boolean {
77
+ return READISH_TOOLS.has(tool) && under(join(agoraHome(), "state"), fold(targetPath));
78
+ }
79
+
80
+ /** wrong-door 拒因+副本换算(尽力而为):envDir basename 形如 {repo}__wt-* →
81
+ * 主仓根=envDir 父目录/{repo};cwd 落主仓内则附副本对应路径,推导不出退化为仅列两侧实值。 */
82
+ export function wrongDoorHint(envDir: string, cwd: string): string {
83
+ const m = basename(envDir).match(/^(.+)__wt-/);
84
+ const mainRoot = m ? join(dirname(envDir), m[1]) : null;
85
+ const conv =
86
+ mainRoot && under(mainRoot, cwd)
87
+ ? `;若编辑目标在主仓,对应副本路径=${join(envDir, relative(mainRoot, cwd))}`
88
+ : "";
89
+ return `被拦:工作目录 ${cwd} 在任务 env ${envDir} 外${conv}——请在副本内编辑。`;
90
+ }
91
+
92
+ /**
93
+ * 判定链(F1):返回引导文案=拒,null=放行。
94
+ * 凭据"活动"的本地代理=持牌+envDir+cwd∈envDir;活动任务终审在服务侧(T5 接线对齐)。
95
+ * pull=本次拉取拒因(F27,缺省 null):404(not_worker)=服务侧无在飞 worker 绑定本会话,
96
+ * 单用户模型下即主会话(root 牌从不入插件缓存,404 是主会话的唯一可判据——裁定 A)。
97
+ * readish 面:404→按「主会话例外」放行;worker 凭据/不可达/节流→READ_HINT(fail-closed)。
98
+ * write 面(F27 收殓残留 finding,裁定 A 镜像):404→DISPATCH_HINT(主会话直改的正确指引
99
+ * =派任务,替代生产不可达的 root 缓存死条文路由);200→已填缓存按 worker env 判定;
100
+ * 不可达/节流→NO_CRED_HINT(fail-closed,未知身份不给主会话指引)。
101
+ */
102
+ export function shouldBlock(
103
+ tool: string,
104
+ targetPath: string,
105
+ cwd: string,
106
+ cred: Cred | undefined | null,
107
+ enforceOn: boolean,
108
+ pull: PullReason | null = null,
109
+ ): string | null {
110
+ if (!enforceOn) return null;
111
+ const target = fold(targetPath);
112
+ if (READISH_TOOLS.has(tool)) {
113
+ if (!under(join(agoraHome(), "state"), target)) return null; // events/wiki/其余裸读开放
114
+ if (cred?.role === "root") return null; // 主会话例外全开
115
+ if (pull === "not_worker") return null; // F27 裁定 A:非在飞 worker=可信主类,放行
116
+ return READ_HINT;
117
+ }
118
+ if (!WRITE_TOOLS.has(tool)) return null;
119
+ if (under(TMP_ROOT, target) || !inGitRepo(target)) return null; // 豁免面 B3
120
+ if (cred?.token && cred.envDir && under(cred.envDir, cwd)) return null;
121
+ // 拒因分流:缓存命中 root 牌=防御位(生产不入缓存)维持 DISPATCH_HINT;无凭据按拉取
122
+ // 判据路由(F27 收殓残留 finding,裁定 A 镜像)——404(not_worker)=主会话直改→
123
+ // DISPATCH_HINT(「静待重注入」对主会话是死路,正确指引是派任务);200 命中已填缓存
124
+ // 走上行 worker env 判定;不可达/节流=未知身份维持 NO_CRED_HINT fail-closed。
125
+ // 服务不可达默认拒由 index 在 lift 败态时拼 LIFT_RECOVER 附着,不在此分支。
126
+ if (cred?.role === "root") return DISPATCH_HINT;
127
+ if (!cred?.token || !cred.envDir) {
128
+ if (pull === "not_worker") return DISPATCH_HINT; // F27 裁定 A 镜像:救活写面死条文路由
129
+ return NO_CRED_HINT;
130
+ }
131
+ return wrongDoorHint(cred.envDir, cwd);
132
+ }