@delorenj/pjangler 1.2.21 → 1.2.25
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/dist/index.js +1852 -368
- package/dist/mcp-server.js +1782 -296
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +3 -0
- package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +9 -2
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).parents[1]
|
|
14
|
+
TELEGRAM_SCRIPT = ROOT / "template" / ".scripts" / "30-telegram.sh"
|
|
15
|
+
LIB_SCRIPT = ROOT / "template" / ".scripts" / "_lib.sh"
|
|
16
|
+
REGISTRY_SCRIPT = ROOT / "template" / ".scripts" / "80-registry.sh"
|
|
17
|
+
|
|
18
|
+
BOT_TOKEN = "123456:profile-only-secret"
|
|
19
|
+
OTHER_TOKEN = "654321:different-profile-secret"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_role(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
23
|
+
role = tmp_path / "role"
|
|
24
|
+
scripts = role / ".scripts"
|
|
25
|
+
runtime = role / "runtime"
|
|
26
|
+
scripts.mkdir(parents=True)
|
|
27
|
+
runtime.mkdir()
|
|
28
|
+
shutil.copy2(TELEGRAM_SCRIPT, scripts / TELEGRAM_SCRIPT.name)
|
|
29
|
+
shutil.copy2(LIB_SCRIPT, scripts / LIB_SCRIPT.name)
|
|
30
|
+
(role / "role.yaml").write_text(
|
|
31
|
+
"""repo: demo
|
|
32
|
+
role: pm
|
|
33
|
+
agent_id: demo-pm
|
|
34
|
+
display_name: "Demo PM"
|
|
35
|
+
profile: demo-pm
|
|
36
|
+
telegram:
|
|
37
|
+
provisioning_status: "deferred"
|
|
38
|
+
bot_username: "demo_pm_bot"
|
|
39
|
+
bot_id: ""
|
|
40
|
+
slack:
|
|
41
|
+
provisioning_status: "deferred"
|
|
42
|
+
team_id: ""
|
|
43
|
+
team_name: ""
|
|
44
|
+
bot_user_id: ""
|
|
45
|
+
bot_id: ""
|
|
46
|
+
bot_username: ""
|
|
47
|
+
bloodbank:
|
|
48
|
+
gateway_scope: fleet
|
|
49
|
+
target_agent_id: demo-pm
|
|
50
|
+
plane:
|
|
51
|
+
workspace: "test"
|
|
52
|
+
identifier: ""
|
|
53
|
+
runtime:
|
|
54
|
+
github_owner: "test"
|
|
55
|
+
github_repo: "agent-hm-demo-pm"
|
|
56
|
+
""",
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
)
|
|
59
|
+
registry = tmp_path / "agents-registry.yaml"
|
|
60
|
+
registry.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
|
|
61
|
+
return role, runtime, registry
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fake_bin(tmp_path: Path) -> Path:
|
|
65
|
+
bindir = tmp_path / "bin"
|
|
66
|
+
bindir.mkdir(exist_ok=True)
|
|
67
|
+
curl = bindir / "curl"
|
|
68
|
+
curl.write_text(
|
|
69
|
+
"""#!/usr/bin/env python3
|
|
70
|
+
import json
|
|
71
|
+
import os
|
|
72
|
+
import sys
|
|
73
|
+
|
|
74
|
+
url = sys.argv[-1]
|
|
75
|
+
expected = os.environ.get("EXPECTED_TELEGRAM_TOKEN", "")
|
|
76
|
+
assert expected and url == f"https://api.telegram.org/bot{expected}/getMe"
|
|
77
|
+
print(json.dumps({"ok": True, "result": {"id": 424242, "username": "verified_demo_bot"}}))
|
|
78
|
+
""",
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
)
|
|
81
|
+
curl.chmod(0o755)
|
|
82
|
+
hermes = bindir / "hermes"
|
|
83
|
+
hermes.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
|
84
|
+
hermes.chmod(0o755)
|
|
85
|
+
return bindir
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _run(
|
|
89
|
+
role: Path,
|
|
90
|
+
registry: Path,
|
|
91
|
+
home: Path,
|
|
92
|
+
bindir: Path,
|
|
93
|
+
overrides: dict[str, str] | None = None,
|
|
94
|
+
) -> subprocess.CompletedProcess[str]:
|
|
95
|
+
env = {
|
|
96
|
+
key: value
|
|
97
|
+
for key, value in os.environ.items()
|
|
98
|
+
if not key.startswith("TELEGRAM_") and key != "SKIP_TELEGRAM"
|
|
99
|
+
}
|
|
100
|
+
env.update(
|
|
101
|
+
{
|
|
102
|
+
"HOME": str(home),
|
|
103
|
+
"PATH": f"{bindir}:{env['PATH']}",
|
|
104
|
+
"HERMES_BIN": str(bindir / "hermes"),
|
|
105
|
+
"HERMES_FLEET_ENV": str(home / ".hermes" / "fleet.env"),
|
|
106
|
+
"REGISTRY_FILE": str(registry),
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
env.update(overrides or {})
|
|
110
|
+
return subprocess.run(
|
|
111
|
+
["bash", str(role / ".scripts" / "30-telegram.sh")],
|
|
112
|
+
env=env,
|
|
113
|
+
input="",
|
|
114
|
+
text=True,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
check=False,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _inject_parent_fsync_failure(
|
|
121
|
+
tmp_path: Path, overrides: dict[str, str], parent: Path
|
|
122
|
+
) -> None:
|
|
123
|
+
site_dir = tmp_path / "fsync-failure-site"
|
|
124
|
+
site_dir.mkdir()
|
|
125
|
+
(site_dir / "sitecustomize.py").write_text(
|
|
126
|
+
"""import errno
|
|
127
|
+
import os
|
|
128
|
+
import stat
|
|
129
|
+
|
|
130
|
+
_real_fsync = os.fsync
|
|
131
|
+
_failed_parent = os.path.realpath(os.environ["FAIL_PARENT_FSYNC"])
|
|
132
|
+
|
|
133
|
+
def _fsync(fd):
|
|
134
|
+
if stat.S_ISDIR(os.fstat(fd).st_mode):
|
|
135
|
+
resolved = os.path.realpath(f"/proc/self/fd/{fd}")
|
|
136
|
+
if resolved == _failed_parent:
|
|
137
|
+
raise OSError(errno.EIO, "injected parent directory fsync failure")
|
|
138
|
+
return _real_fsync(fd)
|
|
139
|
+
|
|
140
|
+
os.fsync = _fsync
|
|
141
|
+
""",
|
|
142
|
+
encoding="utf-8",
|
|
143
|
+
)
|
|
144
|
+
overrides["PYTHONPATH"] = str(site_dir)
|
|
145
|
+
overrides["FAIL_PARENT_FSYNC"] = str(parent)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_telegram_ignores_shared_fleet_token_and_defers_noninteractive(tmp_path: Path) -> None:
|
|
149
|
+
role, runtime, registry = _make_role(tmp_path)
|
|
150
|
+
home = tmp_path / "home"
|
|
151
|
+
fleet = home / ".hermes" / "fleet.env"
|
|
152
|
+
fleet.parent.mkdir(parents=True)
|
|
153
|
+
fleet.write_text(
|
|
154
|
+
f"TELEGRAM_BOT_TOKEN={BOT_TOKEN}\nTELEGRAM_ALLOWED_USERS=111\n",
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
result = _run(role, registry, home, _fake_bin(tmp_path))
|
|
159
|
+
|
|
160
|
+
assert result.returncode == 0, result.stderr
|
|
161
|
+
assert "deferred" in result.stderr
|
|
162
|
+
assert not (runtime / ".env").exists()
|
|
163
|
+
assert BOT_TOKEN not in result.stdout + result.stderr
|
|
164
|
+
assert BOT_TOKEN in fleet.read_text(encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_explicit_token_writes_only_private_runtime_env_and_identity(tmp_path: Path) -> None:
|
|
168
|
+
role, runtime, registry = _make_role(tmp_path)
|
|
169
|
+
home = tmp_path / "home"
|
|
170
|
+
fleet = home / ".hermes" / "fleet.env"
|
|
171
|
+
fleet.parent.mkdir(parents=True)
|
|
172
|
+
fleet.write_text("TELEGRAM_ALLOWED_USERS=111,222\n", encoding="utf-8")
|
|
173
|
+
shared = home / ".hermes" / ".env"
|
|
174
|
+
shared.write_text("PROVIDER_KEY=keep-me\n", encoding="utf-8")
|
|
175
|
+
|
|
176
|
+
result = _run(
|
|
177
|
+
role,
|
|
178
|
+
registry,
|
|
179
|
+
home,
|
|
180
|
+
_fake_bin(tmp_path),
|
|
181
|
+
{"TELEGRAM_BOT_TOKEN": BOT_TOKEN, "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
assert result.returncode == 0, result.stderr
|
|
185
|
+
env_file = runtime / ".env"
|
|
186
|
+
env_text = env_file.read_text(encoding="utf-8")
|
|
187
|
+
assert f'TELEGRAM_BOT_TOKEN="{BOT_TOKEN}"' in env_text
|
|
188
|
+
assert 'TELEGRAM_ALLOWED_USERS="111,222"' in env_text
|
|
189
|
+
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
|
190
|
+
assert shared.read_text(encoding="utf-8") == "PROVIDER_KEY=keep-me\n"
|
|
191
|
+
assert fleet.read_text(encoding="utf-8") == "TELEGRAM_ALLOWED_USERS=111,222\n"
|
|
192
|
+
assert BOT_TOKEN not in result.stdout + result.stderr
|
|
193
|
+
|
|
194
|
+
telegram = yaml.safe_load((role / "role.yaml").read_text(encoding="utf-8"))["telegram"]
|
|
195
|
+
assert telegram == {
|
|
196
|
+
"provisioning_status": "verified",
|
|
197
|
+
"bot_username": "verified_demo_bot",
|
|
198
|
+
"bot_id": "424242",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_credential_parent_fsync_failure_is_reported_and_retryable(tmp_path: Path) -> None:
|
|
203
|
+
role, runtime, registry = _make_role(tmp_path)
|
|
204
|
+
home = tmp_path / "home"
|
|
205
|
+
fleet = home / ".hermes" / "fleet.env"
|
|
206
|
+
fleet.parent.mkdir(parents=True)
|
|
207
|
+
fleet.write_text("TELEGRAM_ALLOWED_USERS=111\n", encoding="utf-8")
|
|
208
|
+
bindir = _fake_bin(tmp_path)
|
|
209
|
+
overrides = {
|
|
210
|
+
"TELEGRAM_BOT_TOKEN": BOT_TOKEN,
|
|
211
|
+
"EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
|
|
212
|
+
}
|
|
213
|
+
_inject_parent_fsync_failure(tmp_path, overrides, runtime)
|
|
214
|
+
|
|
215
|
+
failed = _run(role, registry, home, bindir, overrides)
|
|
216
|
+
|
|
217
|
+
assert failed.returncode != 0
|
|
218
|
+
assert "injected parent directory fsync failure" in failed.stderr
|
|
219
|
+
env_file = runtime / ".env"
|
|
220
|
+
assert f'TELEGRAM_BOT_TOKEN="{BOT_TOKEN}"' in env_file.read_text(encoding="utf-8")
|
|
221
|
+
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
|
222
|
+
assert BOT_TOKEN not in failed.stdout + failed.stderr
|
|
223
|
+
assert "demo-pm" in yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]
|
|
224
|
+
assert not (role / ".scripts" / ".done-30-telegram").exists()
|
|
225
|
+
|
|
226
|
+
retried = _run(
|
|
227
|
+
role,
|
|
228
|
+
registry,
|
|
229
|
+
home,
|
|
230
|
+
bindir,
|
|
231
|
+
{"TELEGRAM_BOT_TOKEN": BOT_TOKEN, "EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
assert retried.returncode == 0, retried.stderr
|
|
235
|
+
assert (role / ".scripts" / ".done-30-telegram").exists()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_rejects_token_parked_in_shared_fleet_env(tmp_path: Path) -> None:
|
|
239
|
+
role, runtime, registry = _make_role(tmp_path)
|
|
240
|
+
home = tmp_path / "home"
|
|
241
|
+
fleet = home / ".hermes" / "fleet.env"
|
|
242
|
+
fleet.parent.mkdir(parents=True)
|
|
243
|
+
fleet.write_text(f"TELEGRAM_BOT_TOKEN={BOT_TOKEN}\n", encoding="utf-8")
|
|
244
|
+
|
|
245
|
+
result = _run(
|
|
246
|
+
role,
|
|
247
|
+
registry,
|
|
248
|
+
home,
|
|
249
|
+
_fake_bin(tmp_path),
|
|
250
|
+
{
|
|
251
|
+
"TELEGRAM_BOT_TOKEN": BOT_TOKEN,
|
|
252
|
+
"TELEGRAM_ALLOWED_USERS": "111",
|
|
253
|
+
"EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
assert result.returncode != 0
|
|
258
|
+
assert "already assigned to shared fleet environment" in result.stderr
|
|
259
|
+
assert BOT_TOKEN not in result.stdout + result.stderr
|
|
260
|
+
assert not (runtime / ".env").exists()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_rejects_token_reused_by_another_profile(tmp_path: Path) -> None:
|
|
264
|
+
role, runtime, registry = _make_role(tmp_path)
|
|
265
|
+
home = tmp_path / "home"
|
|
266
|
+
home.mkdir()
|
|
267
|
+
other_role = tmp_path / "other-role"
|
|
268
|
+
other_runtime = other_role / "runtime"
|
|
269
|
+
other_runtime.mkdir(parents=True)
|
|
270
|
+
(other_runtime / ".env").write_text(
|
|
271
|
+
f'TELEGRAM_BOT_TOKEN="{BOT_TOKEN}"\n', encoding="utf-8"
|
|
272
|
+
)
|
|
273
|
+
registry.write_text(
|
|
274
|
+
yaml.safe_dump(
|
|
275
|
+
{
|
|
276
|
+
"schema_version": 1,
|
|
277
|
+
"agents": {
|
|
278
|
+
"other-pm": {
|
|
279
|
+
"role_dir": str(other_role),
|
|
280
|
+
"telegram": {"bot_id": "999999"},
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
sort_keys=False,
|
|
285
|
+
),
|
|
286
|
+
encoding="utf-8",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
result = _run(
|
|
290
|
+
role,
|
|
291
|
+
registry,
|
|
292
|
+
home,
|
|
293
|
+
_fake_bin(tmp_path),
|
|
294
|
+
{
|
|
295
|
+
"TELEGRAM_BOT_TOKEN": BOT_TOKEN,
|
|
296
|
+
"TELEGRAM_ALLOWED_USERS": "111",
|
|
297
|
+
"EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
assert result.returncode != 0
|
|
302
|
+
assert "already assigned to agent other-pm" in result.stderr
|
|
303
|
+
assert BOT_TOKEN not in result.stdout + result.stderr
|
|
304
|
+
assert not (runtime / ".env").exists()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_rejects_verified_bot_identity_owned_by_another_agent(tmp_path: Path) -> None:
|
|
308
|
+
role, runtime, registry = _make_role(tmp_path)
|
|
309
|
+
home = tmp_path / "home"
|
|
310
|
+
home.mkdir()
|
|
311
|
+
registry.write_text(
|
|
312
|
+
yaml.safe_dump(
|
|
313
|
+
{
|
|
314
|
+
"schema_version": 1,
|
|
315
|
+
"agents": {
|
|
316
|
+
"other-pm": {
|
|
317
|
+
"telegram": {
|
|
318
|
+
"provisioning_status": "verified",
|
|
319
|
+
"bot_username": "verified_demo_bot",
|
|
320
|
+
"bot_id": "424242",
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
sort_keys=False,
|
|
326
|
+
),
|
|
327
|
+
encoding="utf-8",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
result = _run(
|
|
331
|
+
role,
|
|
332
|
+
registry,
|
|
333
|
+
home,
|
|
334
|
+
_fake_bin(tmp_path),
|
|
335
|
+
{
|
|
336
|
+
"TELEGRAM_BOT_TOKEN": OTHER_TOKEN,
|
|
337
|
+
"TELEGRAM_ALLOWED_USERS": "111",
|
|
338
|
+
"EXPECTED_TELEGRAM_TOKEN": OTHER_TOKEN,
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
assert result.returncode != 0
|
|
343
|
+
assert "bot identity is already assigned to agent other-pm" in result.stderr
|
|
344
|
+
assert OTHER_TOKEN not in result.stdout + result.stderr
|
|
345
|
+
assert not (runtime / ".env").exists()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_refuses_runtime_env_symlink_before_get_me(tmp_path: Path) -> None:
|
|
349
|
+
role, runtime, registry = _make_role(tmp_path)
|
|
350
|
+
home = tmp_path / "home"
|
|
351
|
+
shared = home / ".hermes" / ".env"
|
|
352
|
+
shared.parent.mkdir(parents=True)
|
|
353
|
+
shared.write_text("PROVIDER_KEY=keep-me\n", encoding="utf-8")
|
|
354
|
+
(runtime / ".env").symlink_to(shared)
|
|
355
|
+
|
|
356
|
+
result = _run(
|
|
357
|
+
role,
|
|
358
|
+
registry,
|
|
359
|
+
home,
|
|
360
|
+
_fake_bin(tmp_path),
|
|
361
|
+
{
|
|
362
|
+
"TELEGRAM_BOT_TOKEN": BOT_TOKEN,
|
|
363
|
+
"TELEGRAM_ALLOWED_USERS": "111",
|
|
364
|
+
"EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
assert result.returncode != 0
|
|
369
|
+
assert "refusing to write Telegram credentials through symlink" in result.stderr
|
|
370
|
+
assert shared.read_text(encoding="utf-8") == "PROVIDER_KEY=keep-me\n"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_registry_persists_telegram_identity_without_token(tmp_path: Path) -> None:
|
|
374
|
+
role, _, registry = _make_role(tmp_path)
|
|
375
|
+
home = tmp_path / "home"
|
|
376
|
+
home.mkdir()
|
|
377
|
+
shutil.copy2(REGISTRY_SCRIPT, role / ".scripts" / REGISTRY_SCRIPT.name)
|
|
378
|
+
role_yaml = role / "role.yaml"
|
|
379
|
+
role_yaml.write_text(
|
|
380
|
+
role_yaml.read_text(encoding="utf-8")
|
|
381
|
+
.replace('provisioning_status: "deferred"', 'provisioning_status: "verified"', 1)
|
|
382
|
+
.replace('bot_username: "demo_pm_bot"', 'bot_username: "verified_demo_bot"')
|
|
383
|
+
.replace('bot_id: ""', 'bot_id: "424242"', 1),
|
|
384
|
+
encoding="utf-8",
|
|
385
|
+
)
|
|
386
|
+
env = {
|
|
387
|
+
key: value for key, value in os.environ.items() if not key.startswith("TELEGRAM_")
|
|
388
|
+
}
|
|
389
|
+
env.update(
|
|
390
|
+
{
|
|
391
|
+
"HOME": str(home),
|
|
392
|
+
"HERMES_FLEET_ENV": str(home / ".hermes" / "fleet.env"),
|
|
393
|
+
"REGISTRY_FILE": str(registry),
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
result = subprocess.run(
|
|
398
|
+
["bash", str(role / ".scripts" / "80-registry.sh")],
|
|
399
|
+
env=env,
|
|
400
|
+
text=True,
|
|
401
|
+
capture_output=True,
|
|
402
|
+
check=False,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
assert result.returncode == 0, result.stderr
|
|
406
|
+
serialized = registry.read_text(encoding="utf-8")
|
|
407
|
+
entry = yaml.safe_load(serialized)["agents"]["demo-pm"]
|
|
408
|
+
assert entry["telegram"] == {
|
|
409
|
+
"provisioning_status": "verified",
|
|
410
|
+
"bot_username": "verified_demo_bot",
|
|
411
|
+
"bot_id": "424242",
|
|
412
|
+
}
|
|
413
|
+
assert "TELEGRAM_BOT_TOKEN" not in serialized
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_concurrent_profiles_cannot_claim_same_telegram_identity(tmp_path: Path) -> None:
|
|
417
|
+
role_a, _, _ = _make_role(tmp_path / "a")
|
|
418
|
+
role_b, _, _ = _make_role(tmp_path / "b")
|
|
419
|
+
role_b_yaml = role_b / "role.yaml"
|
|
420
|
+
role_b_yaml.write_text(
|
|
421
|
+
role_b_yaml.read_text(encoding="utf-8").replace("demo-pm", "demo-reviewer"),
|
|
422
|
+
encoding="utf-8",
|
|
423
|
+
)
|
|
424
|
+
registry = tmp_path / "agents-registry.yaml"
|
|
425
|
+
registry.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
|
|
426
|
+
home = tmp_path / "home"
|
|
427
|
+
home.mkdir()
|
|
428
|
+
bindir = _fake_bin(tmp_path)
|
|
429
|
+
overrides = {
|
|
430
|
+
"TELEGRAM_BOT_TOKEN": BOT_TOKEN,
|
|
431
|
+
"TELEGRAM_ALLOWED_USERS": "111",
|
|
432
|
+
"EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
436
|
+
results = list(
|
|
437
|
+
pool.map(
|
|
438
|
+
lambda role: _run(role, registry, home, bindir, overrides),
|
|
439
|
+
(role_a, role_b),
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
assert sorted(result.returncode for result in results) == [0, 1]
|
|
444
|
+
combined = "".join(result.stdout + result.stderr for result in results)
|
|
445
|
+
assert "bot identity is already assigned" in combined
|
|
446
|
+
assert BOT_TOKEN not in combined
|
|
447
|
+
agents = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]
|
|
448
|
+
assert len(agents) == 1
|
|
449
|
+
assert next(iter(agents.values()))["telegram"]["bot_id"] == "424242"
|
|
450
|
+
assert stat.S_IMODE(registry.stat().st_mode) == 0o600
|
|
451
|
+
assert stat.S_IMODE(Path(f"{registry}.lock").stat().st_mode) == 0o600
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_telegram_refuses_registry_and_lock_symlinks(tmp_path: Path) -> None:
|
|
455
|
+
role, runtime, _ = _make_role(tmp_path)
|
|
456
|
+
home = tmp_path / "home"
|
|
457
|
+
home.mkdir()
|
|
458
|
+
bindir = _fake_bin(tmp_path)
|
|
459
|
+
target = tmp_path / "registry-target.yaml"
|
|
460
|
+
target.write_text("schema_version: 1\nagents: {}\n", encoding="utf-8")
|
|
461
|
+
registry = tmp_path / "agents-registry.yaml"
|
|
462
|
+
registry.unlink()
|
|
463
|
+
registry.symlink_to(target)
|
|
464
|
+
overrides = {
|
|
465
|
+
"TELEGRAM_BOT_TOKEN": BOT_TOKEN,
|
|
466
|
+
"TELEGRAM_ALLOWED_USERS": "111",
|
|
467
|
+
"EXPECTED_TELEGRAM_TOKEN": BOT_TOKEN,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
result = _run(role, registry, home, bindir, overrides)
|
|
471
|
+
|
|
472
|
+
assert result.returncode != 0
|
|
473
|
+
assert "refusing" in result.stderr and "symlink" in result.stderr
|
|
474
|
+
assert not (runtime / ".env").exists()
|
|
475
|
+
assert target.read_text(encoding="utf-8") == "schema_version: 1\nagents: {}\n"
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Bloodbank consumer for hermes agent {{agent_id}}.
|
|
4
|
-
|
|
5
|
-
Subscribes to:
|
|
6
|
-
bloodbank.evt.v1.repo.>
|
|
7
|
-
bloodbank.cmd.v1.agent.>
|
|
8
|
-
|
|
9
|
-
The Bloodbank subject identifies the fixed domain/entity/action route. Repo and
|
|
10
|
-
agent identifiers stay in envelope data, so this consumer filters repo events
|
|
11
|
-
by ``data.repo`` and agent commands by ``data.target_agent_id``.
|
|
12
|
-
|
|
13
|
-
For each event/command received, writes it to the agent's notification queue at
|
|
14
|
-
$HERMES_HOME/bloodbank-inbox/<timestamp>.json so the gateway can ingest it on
|
|
15
|
-
the next tick. Reconnects on broker churn; idempotent across restarts.
|
|
16
|
-
|
|
17
|
-
Generated by hermes-agent-template/runtime-scaffold/bloodbank-consumer.py and
|
|
18
|
-
rendered with role-specific values at provision time. Re-render by re-running
|
|
19
|
-
agents/hermes/{{role}}/.scripts/20-runtime-repo.sh — do NOT edit by hand.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
import asyncio
|
|
23
|
-
import json
|
|
24
|
-
import os
|
|
25
|
-
import pathlib
|
|
26
|
-
import signal
|
|
27
|
-
import sys
|
|
28
|
-
import time
|
|
29
|
-
import uuid
|
|
30
|
-
from datetime import datetime, timezone
|
|
31
|
-
|
|
32
|
-
try:
|
|
33
|
-
import nats
|
|
34
|
-
except ImportError:
|
|
35
|
-
sys.stderr.write("nats-py not installed. install with: pip install nats-py\n")
|
|
36
|
-
sys.exit(2)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
HERMES_HOME = pathlib.Path(os.environ.get("HERMES_HOME", str(pathlib.Path.home() / ".hermes")))
|
|
40
|
-
INBOX_DIR = HERMES_HOME / "bloodbank-inbox"
|
|
41
|
-
INBOX_DIR.mkdir(parents=True, exist_ok=True)
|
|
42
|
-
|
|
43
|
-
NATS_HOST = os.environ.get("BLOODBANK_NATS_HOST", "127.0.0.1")
|
|
44
|
-
NATS_PORT = int(os.environ.get("BLOODBANK_NATS_PORT", "4222"))
|
|
45
|
-
NATS_URL = f"nats://{NATS_HOST}:{NATS_PORT}"
|
|
46
|
-
|
|
47
|
-
AGENT_ID = "{{agent_id}}"
|
|
48
|
-
REPO = "{{repo}}"
|
|
49
|
-
ROLE = "{{role}}"
|
|
50
|
-
PRODUCER = f"hermes-agent:{AGENT_ID}"
|
|
51
|
-
SOURCE = f"hermes://agent/{AGENT_ID}"
|
|
52
|
-
|
|
53
|
-
SUBJECTS = [
|
|
54
|
-
"bloodbank.evt.v1.repo.>",
|
|
55
|
-
"bloodbank.cmd.v1.agent.>",
|
|
56
|
-
]
|
|
57
|
-
|
|
58
|
-
KIND_MARKERS = {"event": "evt", "command": "cmd", "reply": "rpy"}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def _now():
|
|
62
|
-
return datetime.now(timezone.utc).isoformat()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _subject_and_domain(ce_type, kind):
|
|
66
|
-
parts = ce_type.split(".")
|
|
67
|
-
if len(parts) != 5 or parts[0] != "bloodbank" or parts[1] != "v1":
|
|
68
|
-
raise ValueError(f"type {ce_type!r} must match bloodbank.v1.<domain>.<entity>.<action>")
|
|
69
|
-
if kind not in KIND_MARKERS:
|
|
70
|
-
raise ValueError(f"unknown envelope kind {kind!r}")
|
|
71
|
-
_vendor, version, domain, entity, action = parts
|
|
72
|
-
return f"bloodbank.{KIND_MARKERS[kind]}.{version}.{domain}.{entity}.{action}", domain
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def build_envelope(ce_type, data, *, kind="event", correlationid=None, causationid=None):
|
|
76
|
-
"""Mirror of bloodbank/services/agent-hooks/core/envelope.build_envelope."""
|
|
77
|
-
subject, domain = _subject_and_domain(ce_type, kind)
|
|
78
|
-
env = {
|
|
79
|
-
"specversion": "1.0",
|
|
80
|
-
"type": ce_type,
|
|
81
|
-
"id": str(uuid.uuid4()),
|
|
82
|
-
"source": SOURCE,
|
|
83
|
-
"subject": subject,
|
|
84
|
-
"time": _now(),
|
|
85
|
-
"datacontenttype": "application/json",
|
|
86
|
-
"kind": kind,
|
|
87
|
-
"producer": PRODUCER,
|
|
88
|
-
"service": "hermes-agent",
|
|
89
|
-
"domain": domain,
|
|
90
|
-
"actor": {"type": "agent", "agent_id": AGENT_ID, "cli": "hermes"},
|
|
91
|
-
"data": data,
|
|
92
|
-
"correlationid": correlationid or str(uuid.uuid4()),
|
|
93
|
-
"causationid": causationid or str(uuid.uuid4()),
|
|
94
|
-
}
|
|
95
|
-
if kind == "event":
|
|
96
|
-
env["ordering_key"] = f"agent:{AGENT_ID}"
|
|
97
|
-
return env
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _is_for_consumer(subject, payload):
|
|
101
|
-
"""Route canonical subjects using identifiers carried in envelope data."""
|
|
102
|
-
if not isinstance(payload, dict):
|
|
103
|
-
return False
|
|
104
|
-
parts = subject.split(".")
|
|
105
|
-
if len(parts) != 6 or parts[0] != "bloodbank" or parts[2] != "v1":
|
|
106
|
-
return False
|
|
107
|
-
|
|
108
|
-
_vendor, kind, version, domain, entity, action = parts
|
|
109
|
-
if kind not in KIND_MARKERS.values():
|
|
110
|
-
return False
|
|
111
|
-
if KIND_MARKERS.get(payload.get("kind")) != kind:
|
|
112
|
-
return False
|
|
113
|
-
if payload.get("subject") != subject:
|
|
114
|
-
return False
|
|
115
|
-
if payload.get("type") != f"bloodbank.{version}.{domain}.{entity}.{action}":
|
|
116
|
-
return False
|
|
117
|
-
data = payload.get("data")
|
|
118
|
-
if not isinstance(data, dict):
|
|
119
|
-
return False
|
|
120
|
-
if kind == "evt" and domain == "repo":
|
|
121
|
-
return data.get("repo") == REPO
|
|
122
|
-
if kind == "cmd" and domain == "agent":
|
|
123
|
-
return data.get("target_agent_id") == AGENT_ID
|
|
124
|
-
return False
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
async def main():
|
|
128
|
-
nc = await nats.connect(NATS_URL, name=f"hermes-{AGENT_ID}")
|
|
129
|
-
stop = asyncio.Event()
|
|
130
|
-
|
|
131
|
-
async def on_msg(msg):
|
|
132
|
-
try:
|
|
133
|
-
payload = json.loads(msg.data.decode())
|
|
134
|
-
except Exception:
|
|
135
|
-
sys.stderr.write(f"[bloodbank-consumer:{AGENT_ID}] skipped unroutable payload on {msg.subject}\n")
|
|
136
|
-
return
|
|
137
|
-
if not _is_for_consumer(msg.subject, payload):
|
|
138
|
-
return
|
|
139
|
-
entry = {
|
|
140
|
-
"received_at": _now(),
|
|
141
|
-
"subject": msg.subject,
|
|
142
|
-
"reply_to": msg.reply,
|
|
143
|
-
"payload": payload,
|
|
144
|
-
}
|
|
145
|
-
fname = f"{int(time.time()*1000):013d}-{uuid.uuid4().hex[:8]}.json"
|
|
146
|
-
tmp = INBOX_DIR / (fname + ".tmp")
|
|
147
|
-
tmp.write_text(json.dumps(entry, indent=2))
|
|
148
|
-
tmp.rename(INBOX_DIR / fname)
|
|
149
|
-
sys.stderr.write(f"[bloodbank-consumer:{AGENT_ID}] {msg.subject} -> {fname}\n")
|
|
150
|
-
|
|
151
|
-
for subj in SUBJECTS:
|
|
152
|
-
await nc.subscribe(subj, cb=on_msg)
|
|
153
|
-
sys.stderr.write(f"[bloodbank-consumer:{AGENT_ID}] subscribed: {subj}\n")
|
|
154
|
-
|
|
155
|
-
# Announce presence
|
|
156
|
-
online = build_envelope(
|
|
157
|
-
"bloodbank.v1.agent.online.changed",
|
|
158
|
-
{"agent_id": AGENT_ID, "repo": REPO, "role": ROLE, "state": "online"},
|
|
159
|
-
)
|
|
160
|
-
await nc.publish(online["subject"], json.dumps(online).encode())
|
|
161
|
-
|
|
162
|
-
def _shutdown(*_):
|
|
163
|
-
stop.set()
|
|
164
|
-
signal.signal(signal.SIGTERM, _shutdown)
|
|
165
|
-
signal.signal(signal.SIGINT, _shutdown)
|
|
166
|
-
|
|
167
|
-
await stop.wait()
|
|
168
|
-
|
|
169
|
-
offline = build_envelope(
|
|
170
|
-
"bloodbank.v1.agent.online.changed",
|
|
171
|
-
{"agent_id": AGENT_ID, "repo": REPO, "role": ROLE, "state": "offline"},
|
|
172
|
-
)
|
|
173
|
-
await nc.publish(offline["subject"], json.dumps(offline).encode())
|
|
174
|
-
await nc.drain()
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if __name__ == "__main__":
|
|
178
|
-
try:
|
|
179
|
-
asyncio.run(main())
|
|
180
|
-
except KeyboardInterrupt:
|
|
181
|
-
pass
|