@tyhld/conductor 0.3.0 → 0.6.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 +832 -133
- 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,282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""連絡線(hook_datalink.py)の単体テスト。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_hook_datalink.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【何を守っているか】
|
|
8
|
+
(a) どんな失敗でもフックが落ちない(例外を外へ出さず None を返す)
|
|
9
|
+
(b) 失敗の理由が1行残る(307 / 401 / 404 / 500 / 通信断のそれぞれ)
|
|
10
|
+
(c) 3xx を「本文がJSONでない」ではなく【リダイレクト=ルートに届いていない】として扱う
|
|
11
|
+
(d) 記録に秘密(トークン・Authorization・ホスト・クエリ)が載らない
|
|
12
|
+
"""
|
|
13
|
+
import io
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import urllib.error
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
import hook_datalink as dl # noqa: E402
|
|
22
|
+
|
|
23
|
+
failures = []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check(label, got, want):
|
|
27
|
+
ok = got == want
|
|
28
|
+
if not ok:
|
|
29
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
30
|
+
print(f'{"OK " if ok else "NG "} {label:64} {got}')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_true(label, got):
|
|
34
|
+
check(label, bool(got), True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
CFG = {'url': 'https://example.invalid', 'token': 'super-secret-token-value'}
|
|
38
|
+
PATH = '/api/conductor/hook-requests/abc?waitMs=25000'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeResponse(io.BytesIO):
|
|
42
|
+
"""urlopen が返す応答の代役(with で使えて .status と .read() を持つ)。"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, status, body):
|
|
45
|
+
super().__init__(body.encode())
|
|
46
|
+
self.status = status
|
|
47
|
+
|
|
48
|
+
def __enter__(self):
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, *a):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_request(fake, log_file):
|
|
56
|
+
"""urlopen を差し替えて _request を1回だけ走らせ、(返り値, ログ全文) を返す。"""
|
|
57
|
+
original = dl.urllib.request.urlopen
|
|
58
|
+
os.environ[dl.LOG_PATH_ENV] = log_file
|
|
59
|
+
dl.urllib.request.urlopen = fake
|
|
60
|
+
try:
|
|
61
|
+
got = dl._request(CFG, 'POST', PATH, body={'x': 1})
|
|
62
|
+
finally:
|
|
63
|
+
dl.urllib.request.urlopen = original
|
|
64
|
+
text = ''
|
|
65
|
+
if os.path.exists(log_file):
|
|
66
|
+
with open(log_file, encoding='utf-8') as f:
|
|
67
|
+
text = f.read()
|
|
68
|
+
return got, text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def http_error(code, body):
|
|
72
|
+
def fake(req, timeout=None):
|
|
73
|
+
raise urllib.error.HTTPError(
|
|
74
|
+
'https://example.invalid' + PATH, code, 'x', {}, io.BytesIO(body.encode())
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return fake
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def responds(status, body):
|
|
81
|
+
def fake(req, timeout=None):
|
|
82
|
+
return FakeResponse(status, body)
|
|
83
|
+
|
|
84
|
+
return fake
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def raises(exc):
|
|
88
|
+
def fake(req, timeout=None):
|
|
89
|
+
raise exc
|
|
90
|
+
|
|
91
|
+
return fake
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
CASES = [
|
|
95
|
+
# (ラベル, urlopen の代役, ログに必ず入る文字列)
|
|
96
|
+
('307 リダイレクト(ルートに届いていない)', responds(307, 'Redirecting...'), 'HTTP 307'),
|
|
97
|
+
('401 認可されていない', http_error(401, '{"error":"unauthorized"}'), 'HTTP 401'),
|
|
98
|
+
('404 受け口が無い', http_error(404, 'Not Found'), 'HTTP 404'),
|
|
99
|
+
('500 管制側の異常', http_error(500, '{"error":"failed to register"}'), 'HTTP 500'),
|
|
100
|
+
('通信断(接続できない)', raises(urllib.error.URLError('unreachable')), 'URLError'),
|
|
101
|
+
('タイムアウト', raises(TimeoutError('timed out')), 'TimeoutError'),
|
|
102
|
+
('2xx なのに JSON でない', responds(200, '<!DOCTYPE html>'), 'HTTP 200'),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
106
|
+
for i, (label, fake, must_contain) in enumerate(CASES):
|
|
107
|
+
log_file = os.path.join(tmp, f'log{i}.log')
|
|
108
|
+
got, text = run_request(fake, log_file)
|
|
109
|
+
# (a) フックが落ちない=例外が外へ出ず None が返る
|
|
110
|
+
check(f'{label}: 落ちずに None', got, None)
|
|
111
|
+
# (b) 理由が1行残る
|
|
112
|
+
check(f'{label}: 1行だけ残る', text.count('\n'), 1)
|
|
113
|
+
check_true(f'{label}: 状態が読める({must_contain})', must_contain in text)
|
|
114
|
+
# (d) 秘密が載らない
|
|
115
|
+
check_true(f'{label}: トークンを含まない', CFG['token'] not in text)
|
|
116
|
+
check_true(f'{label}: ホストを含まない', 'example.invalid' not in text)
|
|
117
|
+
check_true(f'{label}: Authorization を含まない', 'Authorization' not in text)
|
|
118
|
+
check_true(f'{label}: クエリを含まない', 'waitMs' not in text)
|
|
119
|
+
check_true(f'{label}: パスは残る', '/api/conductor/hook-requests/abc' in text)
|
|
120
|
+
|
|
121
|
+
# ── 成功時は何も残さない(正常運転でログが太らないこと) ──
|
|
122
|
+
log_file = os.path.join(tmp, 'ok.log')
|
|
123
|
+
got, text = run_request(responds(200, '{"id":"r1","status":"waiting"}'), log_file)
|
|
124
|
+
check('200 正常: 中身を返す', got, {'id': 'r1', 'status': 'waiting'})
|
|
125
|
+
check('200 正常: ログを残さない', text, '')
|
|
126
|
+
|
|
127
|
+
# ── 本文が空の 2xx は従来どおり {}(後方互換) ──
|
|
128
|
+
log_file = os.path.join(tmp, 'empty.log')
|
|
129
|
+
got, text = run_request(responds(200, ''), log_file)
|
|
130
|
+
check('204 相当(本文なし): {} を返す', got, {})
|
|
131
|
+
check('204 相当(本文なし): ログを残さない', text, '')
|
|
132
|
+
|
|
133
|
+
# ── 応答本文は短く切る(長い HTML を丸ごと書かない) ──
|
|
134
|
+
log_file = os.path.join(tmp, 'long.log')
|
|
135
|
+
got, text = run_request(responds(307, 'R' * 5000), log_file)
|
|
136
|
+
check_true('長い本文は抜粋だけ', len(text) < 400)
|
|
137
|
+
|
|
138
|
+
# ── 出力先が決められないときは静かに諦める(記録できなくても落ちない) ──
|
|
139
|
+
saved = os.environ.pop(dl.LOG_PATH_ENV, None)
|
|
140
|
+
saved_home = os.environ.pop('HOME', None)
|
|
141
|
+
check('出力先なし: log_path は None', dl.log_path(), None)
|
|
142
|
+
check('出力先なし: 記録は None(例外なし)', dl.record_failure('POST', '/x', status=307), None)
|
|
143
|
+
if saved is not None:
|
|
144
|
+
os.environ[dl.LOG_PATH_ENV] = saved
|
|
145
|
+
if saved_home is not None:
|
|
146
|
+
os.environ['HOME'] = saved_home
|
|
147
|
+
|
|
148
|
+
# ── 書けない場所を指されても落ちない ──
|
|
149
|
+
os.environ[dl.LOG_PATH_ENV] = os.path.join(tmp, 'no-such-dir', 'x.log')
|
|
150
|
+
check('書けない出力先: 落ちずに None', dl.record_failure('POST', '/x', status=500), None)
|
|
151
|
+
os.environ.pop(dl.LOG_PATH_ENV, None)
|
|
152
|
+
|
|
153
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
# 起動記録 record_decision(番人が呼ばれた事実。★秘密もコマンド全文も残さない)
|
|
155
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
print('\n=== 起動記録 record_decision ===')
|
|
157
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
158
|
+
dlog = os.path.join(tmp, 'guard.log')
|
|
159
|
+
os.environ[dl.DECISION_LOG_PATH_ENV] = dlog
|
|
160
|
+
dl.record_decision('Bash', 'gate', 'merge')
|
|
161
|
+
dl.record_decision('Write', 'deny')
|
|
162
|
+
text = open(dlog, encoding='utf-8').read()
|
|
163
|
+
check_true('PreToolUse と記す', 'PreToolUse' in text)
|
|
164
|
+
check_true('判定を残す(gate)', '-> gate' in text)
|
|
165
|
+
check_true('関門種別を残す(merge)', 'gate=merge' in text)
|
|
166
|
+
check_true('ツール名を残す(Write)', 'Write -> deny' in text)
|
|
167
|
+
check_true('2行残る', len([l for l in text.splitlines() if l.strip()]) == 2)
|
|
168
|
+
os.environ.pop(dl.DECISION_LOG_PATH_ENV, None)
|
|
169
|
+
|
|
170
|
+
# 出力先が決められなければ静かに諦める(落ちない)。
|
|
171
|
+
saved_home = os.environ.pop('HOME', None)
|
|
172
|
+
check('起動記録: 出力先なしなら None', dl.record_decision('Bash', 'allow'), None)
|
|
173
|
+
if saved_home is not None:
|
|
174
|
+
os.environ['HOME'] = saved_home
|
|
175
|
+
|
|
176
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
# 失敗の「種別」(★400番台の不備と、到達不能・401・timeout を取り違えない)
|
|
178
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
print('\n=== 失敗の種別 last_failure_kind(不備 vs 不通の区別) ===')
|
|
180
|
+
KIND_CASES = [
|
|
181
|
+
# (ラベル, urlopen の代役, 期待する種別)
|
|
182
|
+
('400 送信内容の不備', http_error(400, '{"error":"sessionId required"}'), dl.FAIL_CLIENT),
|
|
183
|
+
('422 送信内容の不備', http_error(422, '{"error":"invalid"}'), dl.FAIL_CLIENT),
|
|
184
|
+
('409 送信内容の不備', http_error(409, '{"error":"conflict"}'), dl.FAIL_CLIENT),
|
|
185
|
+
('401 認証(相手側/連絡線)', http_error(401, '{}'), dl.FAIL_AUTH),
|
|
186
|
+
('403 認可(相手側/連絡線)', http_error(403, '{}'), dl.FAIL_AUTH),
|
|
187
|
+
('404 受け口なし(不通扱い)', http_error(404, 'Not Found'), dl.FAIL_UNREACHABLE),
|
|
188
|
+
('500 管制側異常(不通扱い)', http_error(500, '{}'), dl.FAIL_UNREACHABLE),
|
|
189
|
+
('307 折返し(不通扱い)', responds(307, 'Redirecting...'), dl.FAIL_UNREACHABLE),
|
|
190
|
+
('タイムアウト(不通扱い)', raises(TimeoutError('x')), dl.FAIL_UNREACHABLE),
|
|
191
|
+
('通信断(不通扱い)', raises(urllib.error.URLError('x')), dl.FAIL_UNREACHABLE),
|
|
192
|
+
]
|
|
193
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
194
|
+
for i, (label, fake, want) in enumerate(KIND_CASES):
|
|
195
|
+
got, _ = run_request(fake, os.path.join(tmp, f'k{i}.log'))
|
|
196
|
+
check(f'{label}: 返りは None', got, None)
|
|
197
|
+
check(f'{label}: 種別={want}', dl.last_failure_kind(), want)
|
|
198
|
+
# ★400 と 不通 は必ず違う種別(取り違えない)。
|
|
199
|
+
run_request(http_error(400, '{}'), os.path.join(tmp, 'c.log'))
|
|
200
|
+
kind_400 = dl.last_failure_kind()
|
|
201
|
+
run_request(raises(TimeoutError('x')), os.path.join(tmp, 'u.log'))
|
|
202
|
+
kind_to = dl.last_failure_kind()
|
|
203
|
+
check('400 と タイムアウト は別種別', kind_400 != kind_to, True)
|
|
204
|
+
check('400 は不備(client)', kind_400, dl.FAIL_CLIENT)
|
|
205
|
+
# 成功したら種別は None に戻る(失敗が尾を引かない)。
|
|
206
|
+
run_request(responds(200, '{"id":"r1"}'), os.path.join(tmp, 'ok.log'))
|
|
207
|
+
check('成功で種別は None に戻る', dl.last_failure_kind(), None)
|
|
208
|
+
|
|
209
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
# sessionId は必ず非空(★空だと管制が 400 で弾く。空文字は絶対に返さない)
|
|
211
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
212
|
+
print('\n=== sessionId は必ず非空 stable_session_id ===')
|
|
213
|
+
check('実際の session_id があればそのまま', dl.stable_session_id('sess-abc', 'm', 's'), 'sess-abc')
|
|
214
|
+
check('空白だけは無効扱い→合成', dl.stable_session_id(' ', 'm', 's').startswith('local-m-s-'), True)
|
|
215
|
+
check_true('None でも空文字を返さない', dl.stable_session_id(None, 'm', 's'))
|
|
216
|
+
check_true('機械名も現場も無くても非空', dl.stable_session_id('', '', ''))
|
|
217
|
+
check('機械名・現場が無ければ既定を入れる',
|
|
218
|
+
dl.stable_session_id('', '', '').startswith('local-unknown-machine-unknown-site-'), True)
|
|
219
|
+
# 同じ入力(同じ親プロセス)なら同じ値=1セッションに束ねられる。
|
|
220
|
+
check('同じ入力なら安定(同値)',
|
|
221
|
+
dl.stable_session_id('', 'm', 's') == dl.stable_session_id('', 'm', 's'), True)
|
|
222
|
+
|
|
223
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
# 到達確認 ping(★入力ガード。壊れた cfg では必ず False=関門はフェイルクローズへ倒れる)
|
|
225
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
print('\n=== 到達確認 ping の入力ガード ===')
|
|
227
|
+
check('ping(None) は False', dl.ping(None), False)
|
|
228
|
+
check('ping({}) は False', dl.ping({}), False)
|
|
229
|
+
check('ping(url欠け) は False', dl.ping({'token': 't'}), False)
|
|
230
|
+
check('ping(token欠け) は False', dl.ping({'url': 'http://x'}), False)
|
|
231
|
+
|
|
232
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
# 到達確認 ping の記録(★以前は全部を握り潰して何も残さなかった=記録漏れを塞ぐ)
|
|
234
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
print('\n=== 到達確認 ping も失敗を1行残す ===')
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def run_ping(fake, log_file):
|
|
239
|
+
"""opener.open を差し替えて ping を1回だけ走らせ、(返り値, ログ全文) を返す。"""
|
|
240
|
+
original = dl.urllib.request.OpenerDirector.open
|
|
241
|
+
os.environ[dl.LOG_PATH_ENV] = log_file
|
|
242
|
+
dl.urllib.request.OpenerDirector.open = lambda self, req, timeout=None: fake(req, timeout)
|
|
243
|
+
try:
|
|
244
|
+
got = dl.ping(CFG, timeout=1)
|
|
245
|
+
finally:
|
|
246
|
+
dl.urllib.request.OpenerDirector.open = original
|
|
247
|
+
text = ''
|
|
248
|
+
if os.path.exists(log_file):
|
|
249
|
+
with open(log_file, encoding='utf-8') as f:
|
|
250
|
+
text = f.read()
|
|
251
|
+
return got, text
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
255
|
+
# 404 は「到達+認証OKでIDが無いだけ」=正常。True かつ記録しない。
|
|
256
|
+
got, text = run_ping(http_error(404, 'Not Found'), os.path.join(tmp, 'p404.log'))
|
|
257
|
+
check('ping 404 は True(到達OK)', got, True)
|
|
258
|
+
check('ping 404 は記録しない(正常)', text, '')
|
|
259
|
+
# 401/403/5xx/折返しは「確認できない」=False かつ1行残す。
|
|
260
|
+
for i, (label, fake, must) in enumerate([
|
|
261
|
+
('401 認証不可', http_error(401, '{}'), 'HTTP 401'),
|
|
262
|
+
('403 認可不可', http_error(403, '{}'), 'HTTP 403'),
|
|
263
|
+
('500 管制側異常', http_error(500, '{}'), 'HTTP 500'),
|
|
264
|
+
('タイムアウト', raises(TimeoutError('x')), 'TimeoutError'),
|
|
265
|
+
('通信断', raises(urllib.error.URLError('x')), 'URLError'),
|
|
266
|
+
]):
|
|
267
|
+
got, text = run_ping(fake, os.path.join(tmp, f'ping{i}.log'))
|
|
268
|
+
check(f'ping {label}: False', got, False)
|
|
269
|
+
check(f'ping {label}: 1行だけ残る', text.count('\n'), 1)
|
|
270
|
+
check_true(f'ping {label}: 状態が読める({must})', must in text)
|
|
271
|
+
check_true(f'ping {label}: トークンを含まない', CFG['token'] not in text)
|
|
272
|
+
check_true(f'ping {label}: ホストを含まない', 'example.invalid' not in text)
|
|
273
|
+
|
|
274
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
print('\n' + '=' * 72)
|
|
276
|
+
if failures:
|
|
277
|
+
print(f'NG {len(failures)} 件:')
|
|
278
|
+
for f in failures:
|
|
279
|
+
print(' -', f)
|
|
280
|
+
sys.exit(1)
|
|
281
|
+
print('すべて期待どおり')
|
|
282
|
+
sys.exit(0)
|