@tyhld/conductor 0.3.0 → 0.7.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 +301 -19
- package/dist/cli.js +143 -18
- package/dist/ear-routing.js +57 -0
- package/dist/ear.js +57 -0
- package/dist/env-file-perm.js +67 -0
- package/dist/nudge.js +172 -0
- package/dist/realtime-parse.js +116 -0
- package/dist/realtime.js +251 -0
- package/dist/relay-runner.js +65 -0
- package/dist/relay.js +911 -134
- package/dist/websocket-transport.js +66 -0
- package/launchd/ear-install.sh +96 -0
- package/package.json +30 -1
- package/sales-template/README.md +185 -25
- package/sales-template/install.sh +890 -0
- package/sales-template/launchd/install.sh +151 -0
- package/sales-template/settings.json +26 -97
- package/sales-template/setup.sh +754 -117
- package/sales-template/systemd/README.md +28 -4
- package/sales-template/systemd/install.sh +54 -5
- package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
- package/sales-template/uninstall.sh +245 -0
- package/scripts/hooks/README.md +246 -0
- package/scripts/hooks/cc2_guard.py +145 -0
- package/scripts/hooks/codex-hooks.sample.json +58 -0
- package/scripts/hooks/hook_datalink.py +440 -0
- package/scripts/hooks/install-codex-hooks.sh +127 -0
- package/scripts/hooks/notification_hook.py +167 -0
- package/scripts/hooks/permission_request_hook.py +207 -0
- package/scripts/hooks/policy.py +759 -0
- package/scripts/hooks/settings.sample.json +142 -0
- package/scripts/hooks/stop_hook.py +275 -0
- package/scripts/hooks/summary_ja.py +155 -0
- package/scripts/hooks/test_hook_datalink.py +282 -0
- package/scripts/hooks/test_policy.py +1241 -0
- package/skills/conductor-craftsman/SKILL.md +40 -0
- package/systemd/conductor-ear.service +63 -0
- package/systemd/conductor@.service +62 -0
- package/systemd/ear-install.sh +131 -0
- package/systemd/guard-sync-install.sh +94 -0
- package/systemd/tyhld-guard-sync.service +28 -0
- package/systemd/tyhld-guard-sync.timer +25 -0
- package/sales-template/cc2_guard.py +0 -395
- package/sales-template/systemd/conductor@.service +0 -47
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hook_datalink.py — フックと管制の連絡線(クライアント側)。
|
|
3
|
+
|
|
4
|
+
【役割】
|
|
5
|
+
フックが「これを実行してよいか」を管制へ【データで】送り、人の答えを受け取って戻る。
|
|
6
|
+
画面の読み取り(tmux スクレイプ)に依存しない経路。
|
|
7
|
+
|
|
8
|
+
【★製品固有の前提を持ち込まないこと】
|
|
9
|
+
このファイルは将来そのまま共通部品として切り出す。したがってここに入れてよいのは
|
|
10
|
+
・やり取りの形(送る値/受け取る値)
|
|
11
|
+
・待ち方(短く待つ×繰り返し)
|
|
12
|
+
・接続先と鍵の読み取り(環境変数のみ)
|
|
13
|
+
だけ。判定(何を止めるか)は policy.py、文言の組み立ては summary_ja.py が持つ。
|
|
14
|
+
|
|
15
|
+
【接続先】環境変数から読む。無ければ何もしない(フックは黙って委譲する)。
|
|
16
|
+
CONDUCTOR_URL … 管制のURL(例 https://example.vercel.app)
|
|
17
|
+
CONDUCTOR_TOKEN … Bearer トークン(中央 TY Auth 発行のテナントトークン)
|
|
18
|
+
どちらも未設定なら $HOME/.conductor.env(KEY=VALUE 形式)から補う。
|
|
19
|
+
|
|
20
|
+
【★通らなかった理由を必ず1行残す】
|
|
21
|
+
通信は「フックを絶対に落とさない」ため例外を握るが、握った事実を残さないと後から原因が
|
|
22
|
+
分からない。実地でこれが起きた: 受け口が middleware に登録されておらず POST が 307 で
|
|
23
|
+
サインイン画面へ折り返されていたのに、フック側には何の痕跡も無く、特定に丸1日かかった
|
|
24
|
+
(調査 a35b74f0)。よって _request が失敗したときだけ CONDUCTOR_HOOK_LOG(既定
|
|
25
|
+
$HOME/.conductor-hook.log)へ1行追記する。
|
|
26
|
+
|
|
27
|
+
残すのは【メソッド・パス・状態・応答の短い抜粋】だけ。
|
|
28
|
+
★秘密は絶対に載せない: Authorization ヘッダ・トークン・接続先ホスト・URLのクエリは
|
|
29
|
+
いずれも記録しない(パスは "?" の手前で切る)。
|
|
30
|
+
|
|
31
|
+
【依存】標準ライブラリのみ(urllib)。外部パッケージを増やさない。
|
|
32
|
+
"""
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import time
|
|
36
|
+
import urllib.error
|
|
37
|
+
import urllib.request
|
|
38
|
+
|
|
39
|
+
# 1回の問い合わせでサーバが待つ上限(秒)。管制側の HOOK_POLL_MAX_MS と揃える。
|
|
40
|
+
POLL_WAIT_MS = 25_000
|
|
41
|
+
# フックが1件の確認を待てる上限(秒)。フックの既定タイムアウト 600 秒に合わせる。
|
|
42
|
+
MAX_WAIT_SEC = 600
|
|
43
|
+
# 通信1回のタイムアウト(秒)。サーバの待ち時間+余裕。
|
|
44
|
+
HTTP_TIMEOUT_SEC = 35
|
|
45
|
+
|
|
46
|
+
# 診断ログの出力先を差し替える環境変数(未設定なら $HOME/.conductor-hook.log)。
|
|
47
|
+
LOG_PATH_ENV = 'CONDUCTOR_HOOK_LOG'
|
|
48
|
+
# 既定のファイル名。接続設定 $HOME/.conductor.env と同じ場所に置く。
|
|
49
|
+
DEFAULT_LOG_NAME = '.conductor-hook.log'
|
|
50
|
+
# 番人が呼ばれた事実を残す「起動記録」の出力先(診断ログとは別ファイルにする)。
|
|
51
|
+
# ★診断ログ(.conductor-hook.log)は「通信失敗時だけ」書く失敗ログで、番人(PreToolUse)が
|
|
52
|
+
# 呼ばれたかは分からない。それが原因特定を遅らせた(調査 09c70615)。用途が違うので分ける。
|
|
53
|
+
DECISION_LOG_PATH_ENV = 'CONDUCTOR_GUARD_LOG'
|
|
54
|
+
DEFAULT_DECISION_LOG_NAME = '.conductor-guard.log'
|
|
55
|
+
# 応答本文をこの文字数だけ残す(原因が読める最小限。長い HTML を丸ごと書かない)。
|
|
56
|
+
BODY_EXCERPT_CHARS = 120
|
|
57
|
+
# ログがこの大きさを超えたら作り直す(放置しても際限なく育たないようにする)。
|
|
58
|
+
LOG_MAX_BYTES = 1_000_000
|
|
59
|
+
# 起動記録の出力先が決まらないと record_decision は黙って何もしない。
|
|
60
|
+
|
|
61
|
+
# ── 送信失敗の「種別」───────────────────────────────────────────────────────
|
|
62
|
+
# ★呼び出し側が【こちらの送信内容の不備】と【相手側/連絡線の問題】を区別するために読む。
|
|
63
|
+
# 実地でこれが混同された: sessionId 空で 400 が返っていたのに、呼び出し側は「管制に到達
|
|
64
|
+
# できない」と読み替えてフェイルクローズしていた(報告 CC1・本ADR)。両者は原因も直し方も
|
|
65
|
+
# 人へ出す文言も違うので、種別を分けて持てるようにする。
|
|
66
|
+
FAIL_CLIENT = 'client' # 4xx のうち送信内容の不備(400/402/405-499…)。管制には届いている。
|
|
67
|
+
FAIL_AUTH = 'auth' # 401/403 … 認証・認可(トークン/権限=相手側・連絡線の設定)。
|
|
68
|
+
FAIL_UNREACHABLE = 'unreachable' # 到達不能・タイムアウト・折返し(3xx)・受け口なし(404)・管制側異常(5xx)。
|
|
69
|
+
|
|
70
|
+
# 直近の _request 失敗の種別(成功すると None に戻す)。フックは1回1プロセスなので単一で足りる。
|
|
71
|
+
_last_failure = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def last_failure_kind():
|
|
75
|
+
"""直近の送信失敗の種別(FAIL_*)を返す。無ければ None。★register 等の直後に読む。"""
|
|
76
|
+
return _last_failure
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _classify_status(status):
|
|
80
|
+
"""HTTP 状態から失敗の種別を決める。★400番台でも 401/403 と 404 は相手側扱い。"""
|
|
81
|
+
if status in (401, 403):
|
|
82
|
+
return FAIL_AUTH
|
|
83
|
+
if status == 404:
|
|
84
|
+
return FAIL_UNREACHABLE # 受け口が無い=連絡線/配備の問題であって送信内容の不備ではない
|
|
85
|
+
if 400 <= status < 500:
|
|
86
|
+
return FAIL_CLIENT # 400/422/409/405… こちらの送信内容の不備
|
|
87
|
+
return FAIL_UNREACHABLE # 3xx(折返し) / 5xx(管制側異常) / その他
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_env_file(path):
|
|
91
|
+
"""KEY=VALUE 形式のファイルを辞書で返す(読めなければ空)。"""
|
|
92
|
+
out = {}
|
|
93
|
+
try:
|
|
94
|
+
with open(path, encoding='utf-8') as f:
|
|
95
|
+
for line in f:
|
|
96
|
+
line = line.strip()
|
|
97
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
98
|
+
continue
|
|
99
|
+
k, v = line.split('=', 1)
|
|
100
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
101
|
+
except Exception: # noqa: BLE001
|
|
102
|
+
return {}
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_config(env=None):
|
|
107
|
+
"""接続先と鍵を集める。揃わなければ None(=連絡線を使わない)。"""
|
|
108
|
+
env = os.environ if env is None else env
|
|
109
|
+
url = env.get('CONDUCTOR_URL', '').strip()
|
|
110
|
+
token = env.get('CONDUCTOR_TOKEN', '').strip()
|
|
111
|
+
if not url or not token:
|
|
112
|
+
home = env.get('HOME', '')
|
|
113
|
+
if home:
|
|
114
|
+
fromfile = _load_env_file(os.path.join(home, '.conductor.env'))
|
|
115
|
+
url = url or fromfile.get('CONDUCTOR_URL', '').strip()
|
|
116
|
+
token = token or fromfile.get('CONDUCTOR_TOKEN', '').strip()
|
|
117
|
+
if not url or not token:
|
|
118
|
+
return None
|
|
119
|
+
return {'url': url.rstrip('/'), 'token': token}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def stable_session_id(session_id, machine='', site='', env=None):
|
|
123
|
+
"""★空でない sessionId を必ず返す(空文字は絶対に返さない)。
|
|
124
|
+
|
|
125
|
+
管制は sessionId が空だと 400 {"error":"sessionId required"} で登録を弾く。実際の
|
|
126
|
+
session_id が取れないときは、機械名・現場・番人プロセスの親ID から復元可能な値を組む。
|
|
127
|
+
形: `local-<機械名>-<現場>-p<親PID>`
|
|
128
|
+
★親PID(os.getppid)を使うのは、同じセッション中の何度もの番人呼び出しで【同じ値】に
|
|
129
|
+
なるため(=管制側で1セッションとして束ねられる)。起動時刻をそのまま入れると呼び出し
|
|
130
|
+
ごとに値が変わり、1セッションが多数に分裂してしまう。安定を優先して親PIDを採る。
|
|
131
|
+
★秘密は含めない(機械名・現場・PIDのみ)。"""
|
|
132
|
+
sid = (session_id or '').strip()
|
|
133
|
+
if sid:
|
|
134
|
+
return sid
|
|
135
|
+
machine = (machine or '').strip() or 'unknown-machine'
|
|
136
|
+
site = (site or '').strip() or 'unknown-site'
|
|
137
|
+
try:
|
|
138
|
+
ppid = os.getppid()
|
|
139
|
+
except Exception: # noqa: BLE001 取れなくても空文字は返さない
|
|
140
|
+
ppid = 0
|
|
141
|
+
return f'local-{machine}-{site}-p{ppid}'
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def log_path(env=None):
|
|
145
|
+
"""診断ログ(通信失敗の記録)の出力先。決められなければ None(=記録しない)。"""
|
|
146
|
+
env = os.environ if env is None else env
|
|
147
|
+
explicit = (env.get(LOG_PATH_ENV) or '').strip()
|
|
148
|
+
if explicit:
|
|
149
|
+
return explicit
|
|
150
|
+
home = (env.get('HOME') or '').strip()
|
|
151
|
+
return os.path.join(home, DEFAULT_LOG_NAME) if home else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def decision_log_path(env=None):
|
|
155
|
+
"""起動記録(番人が呼ばれた事実)の出力先。決められなければ None(=記録しない)。"""
|
|
156
|
+
env = os.environ if env is None else env
|
|
157
|
+
explicit = (env.get(DECISION_LOG_PATH_ENV) or '').strip()
|
|
158
|
+
if explicit:
|
|
159
|
+
return explicit
|
|
160
|
+
home = (env.get('HOME') or '').strip()
|
|
161
|
+
return os.path.join(home, DEFAULT_DECISION_LOG_NAME) if home else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _append_line(target, line):
|
|
165
|
+
"""1行を追記する。上限を超えていたら作り直す(際限なく育てない)。
|
|
166
|
+
★書けない環境(読み取り専用HOME等)でもフックは止めない(例外を握って None)。"""
|
|
167
|
+
if not target:
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
mode = 'a'
|
|
171
|
+
try:
|
|
172
|
+
if os.path.getsize(target) > LOG_MAX_BYTES:
|
|
173
|
+
mode = 'w' # 履歴より「今」が読めることを優先して作り直す
|
|
174
|
+
except OSError:
|
|
175
|
+
pass
|
|
176
|
+
with open(target, mode, encoding='utf-8') as f:
|
|
177
|
+
f.write(line)
|
|
178
|
+
except Exception: # noqa: BLE001
|
|
179
|
+
return None
|
|
180
|
+
return line
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _excerpt(text):
|
|
184
|
+
"""応答本文の先頭を1行に畳んで短く切る(改行・制御文字を潰す)。"""
|
|
185
|
+
if not text:
|
|
186
|
+
return ''
|
|
187
|
+
flat = ' '.join(str(text).split())
|
|
188
|
+
return flat[:BODY_EXCERPT_CHARS]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def record_failure(method, path, status=None, body='', error='', env=None):
|
|
192
|
+
"""通らなかった1回を1行だけ残す。★記録自体が失敗してもフックは止めない。
|
|
193
|
+
|
|
194
|
+
形: `<ISO時刻> <METHOD> <パス> -> <状態> body="<抜粋>"`
|
|
195
|
+
状態は HTTP のステータス(307 / 401 / 404 / 500 …)か、通信できなかったときの
|
|
196
|
+
例外名(timeout / URLError …)。★秘密は載せない: ホスト・クエリ・トークンは書かない。
|
|
197
|
+
"""
|
|
198
|
+
# クエリには待ち時間やID以外が将来入り得る。原因特定にも不要なので必ず落とす。
|
|
199
|
+
safe_path = str(path).split('?', 1)[0]
|
|
200
|
+
state = f'HTTP {status}' if status is not None else (error or 'error')
|
|
201
|
+
line = (
|
|
202
|
+
f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} {method} {safe_path} '
|
|
203
|
+
f'-> {state} body="{_excerpt(body)}"\n'
|
|
204
|
+
)
|
|
205
|
+
return _append_line(log_path(env), line)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def record_decision(tool_name, decision, gate=None, env=None):
|
|
209
|
+
"""番人(PreToolUse)が呼ばれた1回を1行だけ残す(起動記録)。
|
|
210
|
+
|
|
211
|
+
形: `<ISO時刻> PreToolUse <tool> -> <decision>[ gate=<種別>]`
|
|
212
|
+
★秘密もコマンド全文も残さない: ツール名・判定・関門種別だけ(=最小限)。
|
|
213
|
+
「いつ・何のツールに・どう判定したか」が後から読めれば、壊れた時期の特定に足りる。
|
|
214
|
+
★上限つき追記(_append_line)でローテーションするので肥大しない。
|
|
215
|
+
"""
|
|
216
|
+
safe_tool = str(tool_name or 'unknown').split()[0][:40]
|
|
217
|
+
safe_dec = str(decision or '?')[:16]
|
|
218
|
+
suffix = f' gate={str(gate)[:16]}' if gate else ''
|
|
219
|
+
line = f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} PreToolUse {safe_tool} -> {safe_dec}{suffix}\n'
|
|
220
|
+
return _append_line(decision_log_path(env), line)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def record_notification(notification_type, forwarded, reason='', env=None):
|
|
224
|
+
"""Claude Code 自身の通知(Notification)を受けた1回を1行だけ残す(★ADR-016 の見張り)。
|
|
225
|
+
|
|
226
|
+
形: `<ISO時刻> Notification <種別> -> forwarded|skipped(<理由>)`
|
|
227
|
+
【なぜ要るか】端末にだけ出て管制に出ない問いは、これまで「起きたことすら分からなかった」。
|
|
228
|
+
2026-09-01(Playwright)と 2026-09-02(GitHub Actions)で同じ型が2回起きたのに、
|
|
229
|
+
2回目も端末を見ていた人しか気づけなかった。種別だけでも残しておけば、
|
|
230
|
+
新しい型が出たとき「いつから・どの種別が来ていたか」を後から数えられる。
|
|
231
|
+
★秘密も本文も残さない: 通知の種別と、送ったか送らなかったか(と短い理由)だけ。
|
|
232
|
+
"""
|
|
233
|
+
safe_type = str(notification_type or 'unknown').split()[0][:40]
|
|
234
|
+
verdict = 'forwarded' if forwarded else 'skipped'
|
|
235
|
+
suffix = f'({str(reason)[:32]})' if (reason and not forwarded) else ''
|
|
236
|
+
line = (f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} Notification {safe_type} '
|
|
237
|
+
f'-> {verdict}{suffix}\n')
|
|
238
|
+
return _append_line(decision_log_path(env), line)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ─── 「その問いはもう管制に出してある」印(★二重カードを作らないため) ───────────────
|
|
242
|
+
# PermissionRequest / Stop は自分でカードを出して人の答えを待つ。その最中に Claude Code の
|
|
243
|
+
# Notification(「許可待ちです」「入力待ちです」)が鳴ると、同じ止まりで2枚出てしまう
|
|
244
|
+
# (ADR-013 で潰したのと同じ病気)。待っている間だけ印を置き、Notification 側は印が新しければ
|
|
245
|
+
# 何もしない。★印はプロセス間の合図なので一時領域に置く(設定ファイルには一切触れない)。
|
|
246
|
+
HANDOFF_TTL_SEC = 15 * 60
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def handoff_marker_path(session_id='', env=None):
|
|
250
|
+
"""『いま管制がこの現場の問いを受け持っている』印の置き場(一時領域)。"""
|
|
251
|
+
env = os.environ if env is None else env
|
|
252
|
+
base = (env.get('TMPDIR') or '/tmp').rstrip('/')
|
|
253
|
+
safe = ''.join(c for c in str(session_id or 'default') if c.isalnum() or c in '-_')[:64]
|
|
254
|
+
return os.path.join(base, f'.tyhld-hook-handoff-{safe or "default"}')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def mark_handoff(session_id='', env=None):
|
|
258
|
+
"""印を置く/新しくする。失敗しても黙って進む(フックは止めない)。"""
|
|
259
|
+
path = handoff_marker_path(session_id, env)
|
|
260
|
+
try:
|
|
261
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
262
|
+
f.write(str(int(time.time())))
|
|
263
|
+
except Exception: # noqa: BLE001
|
|
264
|
+
return None
|
|
265
|
+
return path
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def clear_handoff(session_id='', env=None):
|
|
269
|
+
"""印を消す(カードを閉じたとき)。"""
|
|
270
|
+
try:
|
|
271
|
+
os.unlink(handoff_marker_path(session_id, env))
|
|
272
|
+
except Exception: # noqa: BLE001
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def handoff_active(session_id='', env=None, now=None):
|
|
277
|
+
"""印が新しければ True(=別のフックが既に管制へ出している)。
|
|
278
|
+
|
|
279
|
+
★古い印は無効にする。フックが強制終了して印が残っても、次の停止が
|
|
280
|
+
永久に「出さない」側へ倒れない(=黙って見えなくならない)。
|
|
281
|
+
"""
|
|
282
|
+
path = handoff_marker_path(session_id, env)
|
|
283
|
+
try:
|
|
284
|
+
age = (time.time() if now is None else now) - os.path.getmtime(path)
|
|
285
|
+
except Exception: # noqa: BLE001
|
|
286
|
+
return False
|
|
287
|
+
return 0 <= age < HANDOFF_TTL_SEC
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _request(cfg, method, path, body=None, timeout=HTTP_TIMEOUT_SEC):
|
|
291
|
+
"""管制へ1回だけ問い合わせる。失敗は None(例外を投げない=フックを止めない)。
|
|
292
|
+
|
|
293
|
+
★失敗したときは record_failure で理由を1行残す(握るが、握った事実は残す)。
|
|
294
|
+
★同時に last_failure_kind()(FAIL_*)を更新し、呼び出し側が「送信内容の不備(4xx)」と
|
|
295
|
+
「到達不能・認証・タイムアウト」を取り違えないようにする。成功時は None に戻す。
|
|
296
|
+
"""
|
|
297
|
+
global _last_failure
|
|
298
|
+
_last_failure = None
|
|
299
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
300
|
+
req = urllib.request.Request(
|
|
301
|
+
f"{cfg['url']}{path}",
|
|
302
|
+
data=data,
|
|
303
|
+
method=method,
|
|
304
|
+
headers={
|
|
305
|
+
'Authorization': f"Bearer {cfg['token']}",
|
|
306
|
+
'Content-Type': 'application/json',
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
try:
|
|
310
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
311
|
+
status = getattr(res, 'status', None)
|
|
312
|
+
raw = res.read().decode('utf-8', 'replace')
|
|
313
|
+
except urllib.error.HTTPError as e: # 4xx / 5xx
|
|
314
|
+
raw = ''
|
|
315
|
+
try:
|
|
316
|
+
raw = e.read().decode('utf-8', 'replace')
|
|
317
|
+
except Exception: # noqa: BLE001
|
|
318
|
+
pass
|
|
319
|
+
_last_failure = _classify_status(e.code)
|
|
320
|
+
record_failure(method, path, status=e.code, body=raw)
|
|
321
|
+
return None
|
|
322
|
+
except Exception as e: # noqa: BLE001 通信不能・タイムアウト・不正URL 等
|
|
323
|
+
_last_failure = FAIL_UNREACHABLE
|
|
324
|
+
record_failure(method, path, error=type(e).__name__)
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
# ── 3xx はルート本体に届いていない(リダイレクト)=明確な異常として扱う ──
|
|
328
|
+
# urllib は POST の 307 を例外にせず「成功応答」として返すため、ここで弾かないと
|
|
329
|
+
# 本文 "Redirecting..." を JSON として読もうとして「本文がJSONでない」に化ける。
|
|
330
|
+
# それが実地で原因特定を丸一日遅らせた(調査 a35b74f0)。
|
|
331
|
+
if status is not None and status >= 300:
|
|
332
|
+
_last_failure = _classify_status(status)
|
|
333
|
+
record_failure(method, path, status=status, body=raw)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
if not raw.strip():
|
|
337
|
+
return {}
|
|
338
|
+
try:
|
|
339
|
+
return json.loads(raw)
|
|
340
|
+
except Exception: # noqa: BLE001 2xx なのに JSON でない=受け口の想定違い
|
|
341
|
+
# 2xx なのに JSON でない=受け口の想定違い(相手側)。送信内容の不備ではない。
|
|
342
|
+
_last_failure = FAIL_UNREACHABLE
|
|
343
|
+
record_failure(method, path, status=status, body=raw)
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
348
|
+
"""リダイレクトを追わない。サインインへの折返し(3xx)を『不通』として検出するため。
|
|
349
|
+
追従すると signin ページの 200(HTML) を掴んで『到達OK』に化ける(本日の307と同型の罠)。"""
|
|
350
|
+
|
|
351
|
+
def redirect_request(self, *a, **k): # noqa: D401
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# 到達確認に使う、実在しない正規UUID形式のID。到達+認証OKなら 404 が返る(実測済み)。
|
|
356
|
+
_PING_ID = '00000000-0000-4000-8000-000000000000'
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def ping(cfg, timeout=8):
|
|
360
|
+
"""管制に到達でき、認証も通るかを軽く確かめる。★カードを作らない副作用ゼロの確認。
|
|
361
|
+
|
|
362
|
+
返り: True=到達OK(確認カードを出せる見込み) / False=不通・認証不可・折返し。
|
|
363
|
+
仕組み: 実在しないIDへ認証つきで GET し、HTTP状態で判断する(実測に基づく)。
|
|
364
|
+
404 … 到達して認証も通り「そのIDが無い」だけ → True
|
|
365
|
+
2xx … 到達(通常は起きないが到達とみなす) → True
|
|
366
|
+
3xx … サインインへの折返し(受け口に届いていない) → False
|
|
367
|
+
401/403 … 認証拒否(confirm を登録できない) → False
|
|
368
|
+
5xx・通信不能・タイムアウト → False(安全側=止める材料)
|
|
369
|
+
★どんな例外でも False を返す(=関門はフェイルクローズへ倒れる)。
|
|
370
|
+
"""
|
|
371
|
+
if not isinstance(cfg, dict) or not cfg.get('url') or not cfg.get('token'):
|
|
372
|
+
return False
|
|
373
|
+
# ★到達確認が「確認できない」で倒れたときも1行残す。以前ここは全部を握り潰して
|
|
374
|
+
# 何も残さなかったため、関門が黙って deny された理由が後から読めなかった(本ADR)。
|
|
375
|
+
# 404 は「到達+認証OKで、そのIDが無いだけ」=正常なので記録しない。
|
|
376
|
+
ping_path = f'/api/conductor/hook-requests/{_PING_ID}'
|
|
377
|
+
try:
|
|
378
|
+
opener = urllib.request.build_opener(_NoRedirect)
|
|
379
|
+
req = urllib.request.Request(
|
|
380
|
+
f"{cfg['url']}{ping_path}",
|
|
381
|
+
method='GET',
|
|
382
|
+
headers={'Authorization': f"Bearer {cfg['token']}"},
|
|
383
|
+
)
|
|
384
|
+
try:
|
|
385
|
+
with opener.open(req, timeout=timeout) as res:
|
|
386
|
+
status = getattr(res, 'status', 0) or 0
|
|
387
|
+
if 200 <= status < 300:
|
|
388
|
+
return True
|
|
389
|
+
record_failure('GET', ping_path, status=status) # 折返し等が成功応答で返る場合
|
|
390
|
+
return False
|
|
391
|
+
except urllib.error.HTTPError as e:
|
|
392
|
+
if e.code == 404 or 200 <= e.code < 300:
|
|
393
|
+
return True
|
|
394
|
+
record_failure('GET', ping_path, status=e.code) # 3xx(折返し)/401・403/5xx を記録
|
|
395
|
+
return False # 3xx(折返し) / 401・403(認証不可) / 5xx はいずれも「確認できない」
|
|
396
|
+
except Exception as e: # noqa: BLE001 通信不能・タイムアウト・不正URL 等
|
|
397
|
+
record_failure('GET', ping_path, error=type(e).__name__)
|
|
398
|
+
return False
|
|
399
|
+
except Exception: # noqa: BLE001
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def register(cfg, payload):
|
|
404
|
+
"""確認を1件登録する。返り: {'id':…, 'status':…, 'decision':…} / None。"""
|
|
405
|
+
return _request(cfg, 'POST', '/api/conductor/hook-requests', payload)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def poll_once(cfg, request_id, wait_ms=POLL_WAIT_MS):
|
|
409
|
+
"""答えを短く待つ。返り: {'status','decision','outcome'} / None。"""
|
|
410
|
+
return _request(
|
|
411
|
+
cfg, 'GET', f'/api/conductor/hook-requests/{request_id}?waitMs={int(wait_ms)}'
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def abandon(cfg, request_id):
|
|
416
|
+
"""フックが去ることを知らせる(カードを即座に閉じる)。"""
|
|
417
|
+
return _request(cfg, 'DELETE', f'/api/conductor/hook-requests/{request_id}', timeout=10)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def wait_for_decision(cfg, request_id, max_wait_sec=MAX_WAIT_SEC, sleep=time.sleep):
|
|
421
|
+
"""短く待つ×繰り返しで人の答えを待つ。
|
|
422
|
+
|
|
423
|
+
返り: 'allow' | 'allow_always' | 'deny' | None(時間切れ・通信不能)
|
|
424
|
+
★答えないことは許可ではない。時間切れは None を返し、呼び出し側は【許可しない】。
|
|
425
|
+
"""
|
|
426
|
+
deadline = time.time() + max_wait_sec
|
|
427
|
+
while time.time() < deadline:
|
|
428
|
+
remain_ms = max(1000, int((deadline - time.time()) * 1000))
|
|
429
|
+
res = poll_once(cfg, request_id, min(POLL_WAIT_MS, remain_ms))
|
|
430
|
+
if res is None:
|
|
431
|
+
# 通信不能。少し待って再試行(管制の一時的な不調でフックを殺さない)。
|
|
432
|
+
sleep(3)
|
|
433
|
+
continue
|
|
434
|
+
outcome = res.get('outcome')
|
|
435
|
+
if outcome == 'decided':
|
|
436
|
+
return res.get('decision')
|
|
437
|
+
if outcome == 'gone':
|
|
438
|
+
return None
|
|
439
|
+
# pending … そのまま次の周回へ(サーバ側で待っているので追加のsleepは不要)
|
|
440
|
+
return None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# install-codex-hooks.sh — Codex の職人へ番人(安全装置)を登録する。★ADR-022
|
|
4
|
+
#
|
|
5
|
+
# ★これは「1つの実装・2人の呼び手」です。新規設置(sales-template/setup.sh)と
|
|
6
|
+
# 稼働機の更新(scripts/update-guard.sh)の両方がこのスクリプトを呼びます。
|
|
7
|
+
# 同じ処理を2か所に書くと片方が古くなる事故が起きる(ADR-011/013/019 で繰り返した型)。
|
|
8
|
+
#
|
|
9
|
+
# 使い方:
|
|
10
|
+
# bash scripts/hooks/install-codex-hooks.sh # 予定を表示するだけ(既定)
|
|
11
|
+
# APPLY=1 bash scripts/hooks/install-codex-hooks.sh # 実際に登録する
|
|
12
|
+
#
|
|
13
|
+
# やること(冪等:何度実行しても同じ結果):
|
|
14
|
+
# 1. Codex を使う機体かを見る。使わない機体では何もしない(~/.codex も作らない)
|
|
15
|
+
# 2. 見本(codex-hooks.sample.json)のイベントを、コマンドのパスだけ固定パス $GUARD_HOME へ
|
|
16
|
+
# 書き換えて ~/.codex/hooks.json へ登録する
|
|
17
|
+
# 3. 顧客が足した他のイベント・他のキーは触らない(見本のイベントだけ差し替える)
|
|
18
|
+
#
|
|
19
|
+
# ★Claude 側(~/.claude/settings.json)には一切触れません。
|
|
20
|
+
#
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
GUARD_HOME="${GUARD_HOME:-$HOME/.tyhld/hooks}"
|
|
25
|
+
CODEX_SAMPLE="${CODEX_SAMPLE:-$SCRIPT_DIR/codex-hooks.sample.json}"
|
|
26
|
+
CODEX_HOOKS="${CODEX_HOOKS:-$HOME/.codex/hooks.json}"
|
|
27
|
+
APPLY="${APPLY:-0}"
|
|
28
|
+
|
|
29
|
+
info() { echo "[codex-hooks] $*"; }
|
|
30
|
+
warn() { echo "[codex-hooks] 注意: $*" >&2; }
|
|
31
|
+
|
|
32
|
+
# --- 1) Codex を使う機体か ---------------------------------------------------
|
|
33
|
+
# 使わない機体に ~/.codex を作らない(余計なものを置かない)。
|
|
34
|
+
# ・codex コマンドがある/~/.codex が既にある/CONDUCTOR_AGENT_KIND=codex のいずれか。
|
|
35
|
+
uses_codex=0
|
|
36
|
+
command -v codex >/dev/null 2>&1 && uses_codex=1
|
|
37
|
+
[[ -d "$HOME/.codex" ]] && uses_codex=1
|
|
38
|
+
[[ "$(printf '%s' "${CONDUCTOR_AGENT_KIND:-}" | tr '[:upper:]' '[:lower:]')" == "codex" ]] && uses_codex=1
|
|
39
|
+
|
|
40
|
+
if [[ "$uses_codex" != "1" ]]; then
|
|
41
|
+
info "この機体は Codex を使っていません(登録しません)"
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
if [[ ! -f "$CODEX_SAMPLE" ]]; then
|
|
46
|
+
warn "Codex 用の見本が見つかりません: $CODEX_SAMPLE(登録をスキップ)"
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# --- 2) 見本どおりに登録する -------------------------------------------------
|
|
51
|
+
info "登録先: $CODEX_HOOKS"
|
|
52
|
+
APPLY="$APPLY" GUARD_HOME="$GUARD_HOME" python3 - "$CODEX_HOOKS" "$CODEX_SAMPLE" <<'PYEOF'
|
|
53
|
+
import json, os, shutil, sys, time
|
|
54
|
+
|
|
55
|
+
dst_path, sample_path = sys.argv[1], sys.argv[2]
|
|
56
|
+
apply_mode = os.environ.get("APPLY") == "1"
|
|
57
|
+
guard_home = os.environ["GUARD_HOME"]
|
|
58
|
+
|
|
59
|
+
def at(cmd):
|
|
60
|
+
""""python3 /どこか/xxx.py" → "python3 <GUARD_HOME>/xxx.py"(Claude 側と同じ勤務用コピー方式)。"""
|
|
61
|
+
parts = cmd.split()
|
|
62
|
+
parts[-1] = os.path.join(guard_home, os.path.basename(parts[-1]))
|
|
63
|
+
return " ".join(parts)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
sample_hooks = json.load(open(sample_path, encoding="utf-8")).get("hooks") or {}
|
|
67
|
+
except Exception as e: # noqa: BLE001
|
|
68
|
+
print(f"[codex-hooks] 注意: 見本が読めません: {e}", file=sys.stderr)
|
|
69
|
+
sys.exit(0)
|
|
70
|
+
if not sample_hooks:
|
|
71
|
+
print("[codex-hooks] 注意: 見本に hooks がありません(登録しません)", file=sys.stderr)
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
desired = {}
|
|
75
|
+
for ev, groups in sample_hooks.items():
|
|
76
|
+
new_groups = []
|
|
77
|
+
for g in (groups or []):
|
|
78
|
+
ng = dict(g)
|
|
79
|
+
ng["hooks"] = [
|
|
80
|
+
{**h, "command": at(h["command"])}
|
|
81
|
+
if isinstance(h, dict) and isinstance(h.get("command"), str) and h["command"].strip() else h
|
|
82
|
+
for h in (g.get("hooks") or [])
|
|
83
|
+
]
|
|
84
|
+
new_groups.append(ng)
|
|
85
|
+
desired[ev] = new_groups
|
|
86
|
+
|
|
87
|
+
# 既存を読む(無ければ空から作る。壊れていたら触らない)。
|
|
88
|
+
cur = {}
|
|
89
|
+
if os.path.exists(dst_path):
|
|
90
|
+
try:
|
|
91
|
+
cur = json.load(open(dst_path, encoding="utf-8"))
|
|
92
|
+
except Exception as e: # noqa: BLE001
|
|
93
|
+
print(f"[codex-hooks] 注意: JSON として読めないので触りません: {dst_path} ({e})", file=sys.stderr)
|
|
94
|
+
sys.exit(0)
|
|
95
|
+
if not isinstance(cur, dict):
|
|
96
|
+
cur = {}
|
|
97
|
+
|
|
98
|
+
hooks = cur.get("hooks")
|
|
99
|
+
merged = dict(hooks) if isinstance(hooks, dict) else {}
|
|
100
|
+
for ev in desired: # ★見本のイベントだけ差し替える(他は温存)
|
|
101
|
+
merged[ev] = desired[ev]
|
|
102
|
+
|
|
103
|
+
if cur.get("hooks") == merged:
|
|
104
|
+
print("[codex-hooks] 最新です(変更なし)")
|
|
105
|
+
sys.exit(0)
|
|
106
|
+
|
|
107
|
+
print("[codex-hooks] 登録するイベント: " + " / ".join(sorted(desired)))
|
|
108
|
+
if not apply_mode:
|
|
109
|
+
print("[codex-hooks] (予定)上記を登録します(APPLY=1 で実行)")
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
cur["hooks"] = merged
|
|
113
|
+
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
|
114
|
+
if os.path.exists(dst_path):
|
|
115
|
+
shutil.copy2(dst_path, dst_path + ".bak." + time.strftime("%Y%m%d-%H%M%S"))
|
|
116
|
+
tmp = dst_path + ".tmp"
|
|
117
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
118
|
+
json.dump(cur, f, indent=2, ensure_ascii=False)
|
|
119
|
+
f.write("\n")
|
|
120
|
+
os.replace(tmp, dst_path) # 置換は原子的=途中で壊れたファイルを残さない
|
|
121
|
+
print("[codex-hooks] 登録しました")
|
|
122
|
+
PYEOF
|
|
123
|
+
|
|
124
|
+
if [[ "$APPLY" == "1" ]]; then
|
|
125
|
+
info "★人の手番: Codex を起動すると『Hooks need review』が1回出ます。"
|
|
126
|
+
info " 『2. Trust all and continue』を選んでください(選ぶまで番人は動きません)。"
|
|
127
|
+
fi
|