@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.
Files changed (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. 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,6 +0,0 @@
1
- {
2
- "pid": 868618,
3
- "version": "0.9.9",
4
- "socketPath": "/home/delorenj/code/hermes-agent-template/.codegraph/daemon.sock",
5
- "startedAt": 1782538939056
6
- }
@@ -1,10 +0,0 @@
1
- {
2
- "sessionID": "ses_0e5b28303ffeXxL53hZjKggXDW",
3
- "updatedAt": "2026-06-30T21:06:55.034Z",
4
- "sources": {
5
- "background-task": {
6
- "state": "idle",
7
- "updatedAt": "2026-06-30T21:06:55.034Z"
8
- }
9
- }
10
- }
@@ -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