@delorenj/pjangler 1.2.19 → 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 +2353 -518
- package/dist/mcp-server.js +2331 -494
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +6 -4
- package/templates/commonproject/template/.agents/hooks/README.md +13 -26
- package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
- package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
- package/templates/commonproject/template/.agents/local.example.json +2 -8
- package/templates/commonproject/template/.agents/skills.json +6 -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 +16 -16
- 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 +12 -1
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +36 -15
- 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/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
- package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
- 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,110 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Fan the project's enabled skill set out to every agent CLI that does NOT read
|
|
3
|
-
# .agents/ natively, as per-skill symlinks.
|
|
4
|
-
#
|
|
5
|
-
# SSOT: .agents/skills/ (committed inherited skills + on-enter ./skills/* links)
|
|
6
|
-
# Targets: a table of per-CLI skill dirs, each with a SCOPE:
|
|
7
|
-
# global -> a shared per-user dir (e.g. ~/.codex/skills): link ours in, never
|
|
8
|
-
# clobber foreign entries, removed on leave (unlink-*-from-clis.sh).
|
|
9
|
-
# local -> a project-scoped mirror dir (e.g. ./.kimi-code/skills) that is fully
|
|
10
|
-
# ours: stale real copies / foreign symlinks are replaced, and
|
|
11
|
-
# disabled/deferred skills are pruned. Persists across leave.
|
|
12
|
-
#
|
|
13
|
-
# Per-dev controls — .agents/local.json (see .agents/local.example.json):
|
|
14
|
-
# skills.disabled[] never link these
|
|
15
|
-
# skills.defer_to_global if true, skip any skill that ALSO exists in your global
|
|
16
|
-
# SSOT (~/.agents/skills) so your global copy wins and you
|
|
17
|
-
# get zero duplicates. Teammates omit it → inherit all.
|
|
18
|
-
#
|
|
19
|
-
# Add a CLI: append one "dir|scope" line to SKILL_TARGETS. That's the whole change.
|
|
20
|
-
set -euo pipefail
|
|
21
|
-
|
|
22
|
-
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
23
|
-
project_root="$(cd "${script_dir}/../.." && pwd)"
|
|
24
|
-
agents_skills="${project_root}/.agents/skills"
|
|
25
|
-
|
|
26
|
-
SKILL_TARGETS=(
|
|
27
|
-
"${CODEX_HOME:-$HOME/.codex}/skills|global"
|
|
28
|
-
"${project_root}/.kimi-code/skills|local"
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
local_config="${project_root}/.agents/hooks/lib/local-config.sh"
|
|
32
|
-
if [ -f "$local_config" ]; then
|
|
33
|
-
# shellcheck source=/dev/null
|
|
34
|
-
source "$local_config"
|
|
35
|
-
else
|
|
36
|
-
skill_disabled() { return 1; }
|
|
37
|
-
skills_defer_to_global() { return 1; }
|
|
38
|
-
skill_is_global() { return 1; }
|
|
39
|
-
fi
|
|
40
|
-
|
|
41
|
-
if [ ! -d "$agents_skills" ]; then
|
|
42
|
-
echo "No .agents/skills/ found. Nothing to fan out."
|
|
43
|
-
exit 0
|
|
44
|
-
fi
|
|
45
|
-
|
|
46
|
-
defer=0
|
|
47
|
-
skills_defer_to_global && defer=1
|
|
48
|
-
|
|
49
|
-
# Should this skill be skipped (disabled, or deferred to the dev's global system)?
|
|
50
|
-
is_skipped() {
|
|
51
|
-
local name="$1"
|
|
52
|
-
skill_disabled "$name" && return 0
|
|
53
|
-
[ "$defer" = "1" ] && skill_is_global "$name" && return 0
|
|
54
|
-
return 1
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
# readlink with any trailing slash stripped (older links were stored with one).
|
|
58
|
-
_readlink_norm() {
|
|
59
|
-
local l; l="$(readlink "$1" 2>/dev/null || echo "")"
|
|
60
|
-
printf '%s' "${l%/}"
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
link_into() {
|
|
64
|
-
local dir="$1" scope="$2" linked=0 skipped=0 pruned=0
|
|
65
|
-
mkdir -p "$dir"
|
|
66
|
-
for skill_path in "$agents_skills"/*; do
|
|
67
|
-
[ -d "$skill_path" ] || continue
|
|
68
|
-
local name target
|
|
69
|
-
name="$(basename "$skill_path")"
|
|
70
|
-
[[ "$name" == ".system" ]] && continue
|
|
71
|
-
target="${dir}/${name}"
|
|
72
|
-
|
|
73
|
-
if is_skipped "$name"; then
|
|
74
|
-
# Yield the slot: remove a link WE previously made (so the dev's global copy
|
|
75
|
-
# can claim it), and prune stale entries from our fully-managed mirror.
|
|
76
|
-
if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
|
|
77
|
-
rm -f "$target"
|
|
78
|
-
pruned=$((pruned + 1))
|
|
79
|
-
elif [ "$scope" = "local" ] && { [ -L "$target" ] || [ -e "$target" ]; }; then
|
|
80
|
-
rm -rf "$target"
|
|
81
|
-
pruned=$((pruned + 1))
|
|
82
|
-
fi
|
|
83
|
-
skipped=$((skipped + 1))
|
|
84
|
-
continue
|
|
85
|
-
fi
|
|
86
|
-
|
|
87
|
-
if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
|
|
88
|
-
linked=$((linked + 1))
|
|
89
|
-
continue
|
|
90
|
-
fi
|
|
91
|
-
|
|
92
|
-
if [ -e "$target" ] || [ -L "$target" ]; then
|
|
93
|
-
if [ "$scope" = "local" ]; then
|
|
94
|
-
rm -rf "$target" # our mirror: replace stale copy / foreign link
|
|
95
|
-
else
|
|
96
|
-
skipped=$((skipped + 1)) # shared dir: leave foreign entries untouched
|
|
97
|
-
continue
|
|
98
|
-
fi
|
|
99
|
-
fi
|
|
100
|
-
|
|
101
|
-
ln -s "$skill_path" "$target"
|
|
102
|
-
linked=$((linked + 1))
|
|
103
|
-
done
|
|
104
|
-
echo " ${dir} (${scope}): ${linked} linked, ${skipped} skipped, ${pruned} pruned"
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
echo "Fanning .agents/skills -> agent CLIs (defer_to_global=${defer})"
|
|
108
|
-
for entry in "${SKILL_TARGETS[@]}"; do
|
|
109
|
-
link_into "${entry%%|*}" "${entry##*|}"
|
|
110
|
-
done
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Remove this project's skill symlinks from shared (global-scope) per-CLI dirs on
|
|
3
|
-
# leave, so other projects / your global setup aren't polluted. Project-scoped
|
|
4
|
-
# (local-scope) mirrors like ./.kimi-code/skills are left in place — they belong
|
|
5
|
-
# to the repo and cost nothing to keep.
|
|
6
|
-
#
|
|
7
|
-
# Mirrors the SKILL_TARGETS table in link-project-skills-to-clis.sh.
|
|
8
|
-
set -euo pipefail
|
|
9
|
-
|
|
10
|
-
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
-
project_root="$(cd "${script_dir}/../.." && pwd)"
|
|
12
|
-
agents_skills="${project_root}/.agents/skills"
|
|
13
|
-
|
|
14
|
-
SKILL_TARGETS=(
|
|
15
|
-
"${CODEX_HOME:-$HOME/.codex}/skills|global"
|
|
16
|
-
"${project_root}/.kimi-code/skills|local"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
[ -d "$agents_skills" ] || exit 0
|
|
20
|
-
|
|
21
|
-
_readlink_norm() {
|
|
22
|
-
local l; l="$(readlink "$1" 2>/dev/null || echo "")"
|
|
23
|
-
printf '%s' "${l%/}"
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
for entry in "${SKILL_TARGETS[@]}"; do
|
|
27
|
-
dir="${entry%%|*}"
|
|
28
|
-
scope="${entry##*|}"
|
|
29
|
-
[ "$scope" = "global" ] || continue
|
|
30
|
-
[ -d "$dir" ] || continue
|
|
31
|
-
|
|
32
|
-
unlinked=0
|
|
33
|
-
for skill_path in "$agents_skills"/*; do
|
|
34
|
-
[ -e "$skill_path" ] || continue
|
|
35
|
-
name="$(basename "$skill_path")"
|
|
36
|
-
[[ "$name" == ".system" ]] && continue
|
|
37
|
-
target="${dir}/${name}"
|
|
38
|
-
if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
|
|
39
|
-
rm "$target"
|
|
40
|
-
unlinked=$((unlinked + 1))
|
|
41
|
-
fi
|
|
42
|
-
done
|
|
43
|
-
[ "$unlinked" -gt 0 ] && echo "Unlinked ${unlinked} project skill(s) from ${dir}"
|
|
44
|
-
done
|
|
45
|
-
exit 0
|