@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,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).parents[1]
|
|
12
|
+
FLEET_SYNC = ROOT / "scripts" / "fleet-sync.sh"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fixture(tmp_path: Path) -> tuple[dict[str, str], Path, Path, Path]:
|
|
16
|
+
home = tmp_path / "home"
|
|
17
|
+
role = tmp_path / "role"
|
|
18
|
+
runtime = role / "runtime"
|
|
19
|
+
runtime.mkdir(parents=True)
|
|
20
|
+
(runtime / "profile.yaml").write_text(
|
|
21
|
+
"config:\n inherit_from: default\n save_mode: delta\n", encoding="utf-8"
|
|
22
|
+
)
|
|
23
|
+
(role / "role.yaml").write_text("profile: demo-pm\n", encoding="utf-8")
|
|
24
|
+
registry = tmp_path / "agents-registry.yaml"
|
|
25
|
+
registry.write_text(
|
|
26
|
+
yaml.safe_dump(
|
|
27
|
+
{
|
|
28
|
+
"schema_version": 1,
|
|
29
|
+
"agents": {
|
|
30
|
+
"demo-pm": {
|
|
31
|
+
"role_dir": str(role),
|
|
32
|
+
"profile_name": "demo-pm",
|
|
33
|
+
"systemd": {
|
|
34
|
+
"gateway_unit": "hermes-demo-pm-gateway.service",
|
|
35
|
+
"consumer_unit": "hermes-demo-pm-consumer.service",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
sort_keys=False,
|
|
41
|
+
),
|
|
42
|
+
encoding="utf-8",
|
|
43
|
+
)
|
|
44
|
+
unit_dir = home / ".config" / "systemd" / "user"
|
|
45
|
+
unit_dir.mkdir(parents=True)
|
|
46
|
+
consumer = unit_dir / "hermes-demo-pm-consumer.service"
|
|
47
|
+
consumer.write_text("legacy\n", encoding="utf-8")
|
|
48
|
+
fake_bin = tmp_path / "bin"
|
|
49
|
+
fake_bin.mkdir()
|
|
50
|
+
retired = tmp_path / "retired"
|
|
51
|
+
systemctl = fake_bin / "systemctl"
|
|
52
|
+
systemctl.write_text(
|
|
53
|
+
"""#!/usr/bin/env bash
|
|
54
|
+
case "$*" in
|
|
55
|
+
*"disable --now hermes-demo-pm-consumer.service"*)
|
|
56
|
+
[[ "$SYSTEMCTL_MODE" == "disable-fail" ]] && exit 1
|
|
57
|
+
touch "$SYSTEMCTL_RETIRED"; exit 0 ;;
|
|
58
|
+
*"is-active hermes-demo-pm-consumer.service"*)
|
|
59
|
+
[[ "$SYSTEMCTL_MODE" == "active-query-error" ]] && { echo "Failed to connect to bus" >&2; exit 1; }
|
|
60
|
+
if [[ -f "$SYSTEMCTL_RETIRED" ]]; then echo inactive; exit 3; else echo active; exit 0; fi ;;
|
|
61
|
+
*"is-enabled hermes-demo-pm-consumer.service"*)
|
|
62
|
+
[[ "$SYSTEMCTL_MODE" == "enabled-query-error" ]] && { echo "Failed to connect to bus" >&2; exit 1; }
|
|
63
|
+
if [[ -f "$SYSTEMCTL_RETIRED" ]]; then echo disabled; exit 1; else echo enabled; exit 0; fi ;;
|
|
64
|
+
*"daemon-reload"*) exit 0 ;;
|
|
65
|
+
esac
|
|
66
|
+
exit 1
|
|
67
|
+
""",
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
systemctl.chmod(0o755)
|
|
71
|
+
env = dict(os.environ)
|
|
72
|
+
env.update(
|
|
73
|
+
{
|
|
74
|
+
"HOME": str(home),
|
|
75
|
+
"PATH": f"{fake_bin}:{env['PATH']}",
|
|
76
|
+
"HERMES_FLEET_REGISTRY_FILE": str(registry),
|
|
77
|
+
"HERMES_FLEET_HOME": str(home / ".hermes"),
|
|
78
|
+
"SYSTEMCTL_RETIRED": str(retired),
|
|
79
|
+
"SYSTEMCTL_MODE": "success",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return env, registry, consumer, role
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_fleet_audit_reports_and_apply_retires_legacy_consumer(tmp_path: Path) -> None:
|
|
86
|
+
env, registry, consumer, _ = _fixture(tmp_path)
|
|
87
|
+
|
|
88
|
+
audit = subprocess.run(
|
|
89
|
+
["bash", str(FLEET_SYNC), "--agent", "demo-pm"],
|
|
90
|
+
env=env,
|
|
91
|
+
text=True,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
check=False,
|
|
94
|
+
)
|
|
95
|
+
assert audit.returncode == 1
|
|
96
|
+
assert "legacy per-profile Bloodbank consumer remains" in audit.stdout
|
|
97
|
+
|
|
98
|
+
applied = subprocess.run(
|
|
99
|
+
["bash", str(FLEET_SYNC), "--apply", "--no-restart", "--agent", "demo-pm"],
|
|
100
|
+
env=env,
|
|
101
|
+
text=True,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
check=False,
|
|
104
|
+
)
|
|
105
|
+
assert applied.returncode == 0, applied.stdout + applied.stderr
|
|
106
|
+
assert not consumer.exists()
|
|
107
|
+
entry = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]["demo-pm"]
|
|
108
|
+
assert "consumer_unit" not in entry["systemd"]
|
|
109
|
+
assert stat.S_IMODE(registry.stat().st_mode) == 0o600
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_fleet_apply_preserves_unit_and_metadata_when_disable_fails(tmp_path: Path) -> None:
|
|
113
|
+
env, registry, consumer, _ = _fixture(tmp_path)
|
|
114
|
+
env["SYSTEMCTL_MODE"] = "disable-fail"
|
|
115
|
+
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
["bash", str(FLEET_SYNC), "--apply", "--no-restart", "--agent", "demo-pm"],
|
|
118
|
+
env=env,
|
|
119
|
+
text=True,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
check=False,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
assert result.returncode != 0
|
|
125
|
+
assert "disable failed; unit and metadata preserved" in result.stdout
|
|
126
|
+
assert consumer.read_text(encoding="utf-8") == "legacy\n"
|
|
127
|
+
entry = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]["demo-pm"]
|
|
128
|
+
assert entry["systemd"]["consumer_unit"] == "hermes-demo-pm-consumer.service"
|
|
129
|
+
assert not Path(env["SYSTEMCTL_RETIRED"]).exists()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_fleet_audit_and_apply_fail_closed_on_state_query_errors(tmp_path: Path) -> None:
|
|
133
|
+
for mode in ("active-query-error", "enabled-query-error"):
|
|
134
|
+
case_dir = tmp_path / mode
|
|
135
|
+
case_dir.mkdir()
|
|
136
|
+
env, registry, consumer, _ = _fixture(case_dir)
|
|
137
|
+
env["SYSTEMCTL_MODE"] = mode
|
|
138
|
+
|
|
139
|
+
for apply_args in ([], ["--apply", "--no-restart"]):
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
["bash", str(FLEET_SYNC), *apply_args, "--agent", "demo-pm"],
|
|
142
|
+
env=env,
|
|
143
|
+
text=True,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
assert result.returncode != 0
|
|
148
|
+
assert "state query failed; unit and metadata preserved" in result.stdout
|
|
149
|
+
assert consumer.read_text(encoding="utf-8") == "legacy\n"
|
|
150
|
+
entry = yaml.safe_load(registry.read_text(encoding="utf-8"))["agents"]["demo-pm"]
|
|
151
|
+
assert entry["systemd"]["consumer_unit"] == "hermes-demo-pm-consumer.service"
|
|
152
|
+
assert not Path(env["SYSTEMCTL_RETIRED"]).exists()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_pinned_fork_publication_replaces_upstream_clean_install_path() -> None:
|
|
156
|
+
installer = (ROOT / "install-local.sh").read_text(encoding="utf-8")
|
|
157
|
+
config = (ROOT / "template" / ".scripts" / "config.example.toml").read_text(
|
|
158
|
+
encoding="utf-8"
|
|
159
|
+
)
|
|
160
|
+
expected_sha = "113e1b182b6d72a7dd02a191f134a41668ceaf0e"
|
|
161
|
+
|
|
162
|
+
assert "raw.githubusercontent.com/NousResearch/hermes-agent" not in installer
|
|
163
|
+
assert 'HERMES_RUNTIME_GIT_URL="https://github.com/delorenj/hermes-agent.git"' in installer
|
|
164
|
+
assert 'HERMES_RUNTIME_GIT_REF="feature/PJAN-19-routing-publication"' in installer
|
|
165
|
+
assert f'HERMES_RUNTIME_GIT_SHA="{expected_sha}"' in installer
|
|
166
|
+
assert "merge-base --is-ancestor" in installer
|
|
167
|
+
assert f'hermes_git_sha = "{expected_sha}"' in config
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_runtime_templatizer_pins_canonical_bmad_next31_pack() -> None:
|
|
171
|
+
templatizer = (ROOT / "scripts" / "hermes-runtime-templatize.py").read_text(
|
|
172
|
+
encoding="utf-8"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
assert "/packs/bmad/6.10.1-next.31" in templatizer
|
|
176
|
+
assert "/packs/bmad/6.10.2" not in templatizer
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).parents[1]
|
|
10
|
+
LAUNCHER_TEMPLATE = ROOT / "template" / "hermes.jinja"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_launcher_drops_shared_identity_credentials_but_keeps_policy(tmp_path: Path) -> None:
|
|
14
|
+
home = tmp_path / "home"
|
|
15
|
+
role = tmp_path / "role"
|
|
16
|
+
runtime = role / "runtime"
|
|
17
|
+
runtime.mkdir(parents=True)
|
|
18
|
+
fleet = home / ".hermes" / "fleet.env"
|
|
19
|
+
fleet.parent.mkdir(parents=True)
|
|
20
|
+
fake_hermes = tmp_path / "fake-hermes"
|
|
21
|
+
fake_hermes.write_text(
|
|
22
|
+
"""#!/usr/bin/env python3
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
keys = (
|
|
27
|
+
"TELEGRAM_BOT_TOKEN",
|
|
28
|
+
"SLACK_BOT_TOKEN",
|
|
29
|
+
"SLACK_APP_TOKEN",
|
|
30
|
+
"TELEGRAM_ALLOWED_USERS",
|
|
31
|
+
"SLACK_ALLOWED_USERS",
|
|
32
|
+
"HERMES_HOME",
|
|
33
|
+
)
|
|
34
|
+
print(json.dumps({key: os.environ.get(key) for key in keys}))
|
|
35
|
+
""",
|
|
36
|
+
encoding="utf-8",
|
|
37
|
+
)
|
|
38
|
+
fake_hermes.chmod(0o755)
|
|
39
|
+
fleet.write_text(
|
|
40
|
+
f"export HERMES_FLEET_BIN={fake_hermes}\n"
|
|
41
|
+
"export TELEGRAM_BOT_TOKEN=123456:shared-secret\n"
|
|
42
|
+
"export SLACK_BOT_TOKEN=xoxb-shared-secret\n"
|
|
43
|
+
"export SLACK_APP_TOKEN=xapp-shared-secret\n"
|
|
44
|
+
"export TELEGRAM_ALLOWED_USERS=111\n"
|
|
45
|
+
"export SLACK_ALLOWED_USERS=U111\n",
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
)
|
|
48
|
+
launcher = role / "hermes"
|
|
49
|
+
launcher.write_text(
|
|
50
|
+
LAUNCHER_TEMPLATE.read_text(encoding="utf-8").replace("{{ agent_id }}", "demo-pm"),
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
)
|
|
53
|
+
launcher.chmod(0o755)
|
|
54
|
+
env = {
|
|
55
|
+
key: value
|
|
56
|
+
for key, value in os.environ.items()
|
|
57
|
+
if key
|
|
58
|
+
not in {
|
|
59
|
+
"TELEGRAM_BOT_TOKEN",
|
|
60
|
+
"SLACK_BOT_TOKEN",
|
|
61
|
+
"SLACK_APP_TOKEN",
|
|
62
|
+
"TELEGRAM_ALLOWED_USERS",
|
|
63
|
+
"SLACK_ALLOWED_USERS",
|
|
64
|
+
"HERMES_BIN",
|
|
65
|
+
"HERMES_FLEET_BIN",
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
env.update({"HOME": str(home), "HERMES_FLEET_ENV": str(fleet)})
|
|
69
|
+
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
[str(launcher), "gateway", "run"],
|
|
72
|
+
env=env,
|
|
73
|
+
text=True,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
check=False,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert result.returncode == 0, result.stderr
|
|
79
|
+
observed = json.loads(result.stdout)
|
|
80
|
+
assert observed["TELEGRAM_BOT_TOKEN"] is None
|
|
81
|
+
assert observed["SLACK_BOT_TOKEN"] is None
|
|
82
|
+
assert observed["SLACK_APP_TOKEN"] is None
|
|
83
|
+
assert observed["TELEGRAM_ALLOWED_USERS"] == "111"
|
|
84
|
+
assert observed["SLACK_ALLOWED_USERS"] == "U111"
|
|
85
|
+
assert observed["HERMES_HOME"] == str(runtime)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).parents[1]
|
|
10
|
+
RUNTIME_SCRIPT = ROOT / "template" / ".scripts" / "20-runtime-repo.sh"
|
|
11
|
+
LIB_SCRIPT = ROOT / "template" / ".scripts" / "_lib.sh"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fixture(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
|
|
15
|
+
project = tmp_path / "project"
|
|
16
|
+
role = project / "agents" / "hermes" / "pm"
|
|
17
|
+
scripts = role / ".scripts"
|
|
18
|
+
scaffold = role / ".runtime-scaffold"
|
|
19
|
+
scripts.mkdir(parents=True)
|
|
20
|
+
scaffold.mkdir()
|
|
21
|
+
shutil.copy2(RUNTIME_SCRIPT, scripts / RUNTIME_SCRIPT.name)
|
|
22
|
+
shutil.copy2(LIB_SCRIPT, scripts / LIB_SCRIPT.name)
|
|
23
|
+
(scaffold / "MEMORY.md").write_text("agent={{agent_id}}\n", encoding="utf-8")
|
|
24
|
+
(role / "SOUL.md").write_text("local soul\n", encoding="utf-8")
|
|
25
|
+
(role / ".gitignore").write_text("runtime/\n", encoding="utf-8")
|
|
26
|
+
(role / "role.yaml").write_text(
|
|
27
|
+
"""repo: demo
|
|
28
|
+
role: pm
|
|
29
|
+
agent_id: demo-pm
|
|
30
|
+
display_name: "Demo PM"
|
|
31
|
+
profile: demo-pm
|
|
32
|
+
telegram:
|
|
33
|
+
bot_username: demo_pm_bot
|
|
34
|
+
plane:
|
|
35
|
+
workspace: test
|
|
36
|
+
runtime:
|
|
37
|
+
github_owner: legacy
|
|
38
|
+
github_repo: agent-hm-demo-pm
|
|
39
|
+
local_path: ./runtime
|
|
40
|
+
""",
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
)
|
|
43
|
+
subprocess.run(["git", "init", "--quiet"], cwd=project, check=True)
|
|
44
|
+
|
|
45
|
+
home = tmp_path / "home"
|
|
46
|
+
profiles = home / ".hermes" / "profiles"
|
|
47
|
+
profiles.mkdir(parents=True)
|
|
48
|
+
fake_bin = tmp_path / "bin"
|
|
49
|
+
fake_bin.mkdir()
|
|
50
|
+
hermes = fake_bin / "hermes"
|
|
51
|
+
hermes.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
|
52
|
+
hermes.chmod(0o755)
|
|
53
|
+
env = dict(os.environ)
|
|
54
|
+
env.update(
|
|
55
|
+
{
|
|
56
|
+
"HOME": str(home),
|
|
57
|
+
"HERMES_BIN": str(hermes),
|
|
58
|
+
"HERMES_FLEET_ENV": str(home / ".hermes" / "fleet.env"),
|
|
59
|
+
"RUNTIME_SCAFFOLD_DIR": str(scaffold),
|
|
60
|
+
"VOXXY_PLUGIN_DIR": str(tmp_path / "missing-voxxy"),
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
return project, role, env
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run(role: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
|
67
|
+
return subprocess.run(
|
|
68
|
+
["bash", str(role / ".scripts" / "20-runtime-repo.sh")],
|
|
69
|
+
env=env,
|
|
70
|
+
text=True,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_provisioner_creates_ignored_local_runtime_without_git(tmp_path: Path) -> None:
|
|
77
|
+
project, role, env = _fixture(tmp_path)
|
|
78
|
+
|
|
79
|
+
result = _run(role, env)
|
|
80
|
+
|
|
81
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
82
|
+
runtime = role / "runtime"
|
|
83
|
+
assert (runtime / "MEMORY.md").read_text(encoding="utf-8") == "agent=demo-pm\n"
|
|
84
|
+
assert (runtime / "SOUL.md").read_text(encoding="utf-8") == "local soul\n"
|
|
85
|
+
assert not (runtime / ".git").exists()
|
|
86
|
+
assert (Path(env["HOME"]) / ".hermes" / "profiles" / "demo-pm").resolve() == runtime
|
|
87
|
+
assert subprocess.run(
|
|
88
|
+
["git", "check-ignore", "-q", "agents/hermes/pm/runtime/"],
|
|
89
|
+
cwd=project,
|
|
90
|
+
check=False,
|
|
91
|
+
).returncode == 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_provisioner_preserves_existing_runtime_and_refuses_stale_mapping(tmp_path: Path) -> None:
|
|
95
|
+
project, role, env = _fixture(tmp_path)
|
|
96
|
+
runtime = role / "runtime"
|
|
97
|
+
runtime.mkdir()
|
|
98
|
+
private_state = runtime / "private-state.txt"
|
|
99
|
+
private_state.write_text("preserve exactly\n", encoding="utf-8")
|
|
100
|
+
(project / ".gitmodules").write_text(
|
|
101
|
+
"""[submodule "legacy-runtime"]
|
|
102
|
+
\tpath = agents/hermes/pm/runtime
|
|
103
|
+
\turl = git@github.com:example/legacy.git
|
|
104
|
+
""",
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result = _run(role, env)
|
|
109
|
+
|
|
110
|
+
assert result.returncode != 0
|
|
111
|
+
assert "stale .gitmodules mapping" in result.stderr
|
|
112
|
+
assert private_state.read_text(encoding="utf-8") == "preserve exactly\n"
|
|
113
|
+
assert not (runtime / ".git").exists()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_active_provisioner_has_no_runtime_repo_or_submodule_mutation() -> None:
|
|
117
|
+
script = RUNTIME_SCRIPT.read_text(encoding="utf-8")
|
|
118
|
+
|
|
119
|
+
for forbidden in (
|
|
120
|
+
"gh repo create",
|
|
121
|
+
"gh repo view",
|
|
122
|
+
"git submodule add",
|
|
123
|
+
"git submodule update",
|
|
124
|
+
'rm -rf "$RUNTIME_LOCAL"',
|
|
125
|
+
"git push",
|
|
126
|
+
"git init",
|
|
127
|
+
):
|
|
128
|
+
assert forbidden not in script
|
|
129
|
+
assert "stale .gitmodules mapping" in script
|
|
130
|
+
assert 'cp -an "$TMP/." "$RUNTIME_LOCAL/"' in script
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).parents[1]
|
|
9
|
+
ACTIVE_OPERATOR_SURFACES = (
|
|
10
|
+
ROOT / "README.md",
|
|
11
|
+
ROOT / "docs" / "operations.md",
|
|
12
|
+
ROOT / "docs" / "architecture.md",
|
|
13
|
+
ROOT / "docs" / "sentinel.md",
|
|
14
|
+
ROOT / "docs" / "sentinel" / "README.md",
|
|
15
|
+
ROOT / "docs" / "sentinel" / "architecture.md",
|
|
16
|
+
ROOT / "docs" / "sentinel" / "development.md",
|
|
17
|
+
ROOT / "template" / ".scripts" / "config.example.toml",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_active_operator_surfaces_describe_only_pure_local_runtime() -> None:
|
|
22
|
+
forbidden = {
|
|
23
|
+
"per-agent remote runtime name": r"agent-hm-",
|
|
24
|
+
"active runtime repository": r"(?:github\s+)?runtime repositor(?:y|ies)|runtime repo(?:s)?\b",
|
|
25
|
+
"runtime submodule": r"runtime.{0,60}submodule|submodule.{0,60}runtime",
|
|
26
|
+
"runtime LFS": r"git\s+lfs",
|
|
27
|
+
"runtime push": r"git\s+push|commit\s*\+\s*push",
|
|
28
|
+
"cascading profile deletion": r"hermes\s+profile\s+delete|cascad(?:e|es|ing)",
|
|
29
|
+
"blanket runtime deletion": r"rm\s+-rf[^\n]*(?:runtime|agents/hermes|\$RUNTIME|\$ROLE_DIR)",
|
|
30
|
+
"runtime submodule deletion": r"git\s+submodule\s+deinit|\.git/modules/agents/hermes|git\s+rm[^\n]*runtime",
|
|
31
|
+
"runtime find deletion": r"find[^\n]*(?:runtime|\$RUNTIME)[^\n]*-delete",
|
|
32
|
+
"runtime directory removal": r"rmdir[^\n]*(?:runtime|\$RUNTIME)",
|
|
33
|
+
"runtime deletion confirmation shell": r"read[^\n]*REMOVE LOCAL RUNTIME",
|
|
34
|
+
"step-20 GitHub creation claim": r"20-runtime-repo\.sh.{0,160}gh\s+repo\s+create",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for path in ACTIVE_OPERATOR_SURFACES:
|
|
38
|
+
content = path.read_text(encoding="utf-8")
|
|
39
|
+
for label, pattern in forbidden.items():
|
|
40
|
+
assert re.search(pattern, content, re.IGNORECASE | re.DOTALL) is None, (
|
|
41
|
+
f"{path.relative_to(ROOT)} contains obsolete {label} guidance"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_operations_make_recovery_and_retirement_boundaries_explicit() -> None:
|
|
46
|
+
operations = (ROOT / "docs" / "operations.md").read_text(encoding="utf-8")
|
|
47
|
+
normalized = " ".join(operations.split())
|
|
48
|
+
|
|
49
|
+
for required in (
|
|
50
|
+
"does **not** ship automatic backup",
|
|
51
|
+
"verified filesystem backup",
|
|
52
|
+
"Hindsight can restore only",
|
|
53
|
+
"secret manager can restore only",
|
|
54
|
+
"Retire an agent (preserves runtime by default)",
|
|
55
|
+
"Profile and service retirement always preserves the local runtime",
|
|
56
|
+
"This release intentionally provides no automated runtime purge",
|
|
57
|
+
"separately reviewed, path-safe tool",
|
|
58
|
+
"canonicalizes both the repository root and role path",
|
|
59
|
+
"verifies that the backup archive contains the expected runtime members",
|
|
60
|
+
):
|
|
61
|
+
assert required in normalized
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_legacy_runtime_owner_config_is_explicitly_inert() -> None:
|
|
65
|
+
config = (ROOT / "template" / ".scripts" / "config.example.toml").read_text(
|
|
66
|
+
encoding="utf-8"
|
|
67
|
+
)
|
|
68
|
+
github_block = config.split("[github]", 1)[1].split("[plane]", 1)[0]
|
|
69
|
+
|
|
70
|
+
assert "LEGACY INERT COMPATIBILITY METADATA ONLY" in github_block
|
|
71
|
+
assert 'runtime_repo_owner = ""' in github_block
|
|
72
|
+
assert "does not create, attach" in github_block
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_retired_checkpoint_runbook_is_historical_evidence_only() -> None:
|
|
76
|
+
runbook = (
|
|
77
|
+
ROOT / "docs" / "runbooks" / "runtime-checkpoint-repair.md"
|
|
78
|
+
).read_text(encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
assert "Historical evidence only" in runbook
|
|
81
|
+
assert "do not execute" in runbook
|
|
82
|
+
assert "commands have intentionally been removed" in runbook
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_step_20_is_documented_as_ignored_local_population() -> None:
|
|
86
|
+
development = (
|
|
87
|
+
ROOT / "docs" / "sentinel" / "development.md"
|
|
88
|
+
).read_text(encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
assert "`20-runtime-repo.sh` populates the ignored local runtime" in development
|
|
91
|
+
assert "it creates no GitHub storage" in development
|
|
92
|
+
assert "creates a private GitHub repo" not in development
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_retired_checkpoint_repair_is_a_non_mutating_tombstone(tmp_path: Path) -> None:
|
|
96
|
+
script = ROOT / "scripts" / "repair-runtime-checkpoint.sh"
|
|
97
|
+
source = script.read_text(encoding="utf-8")
|
|
98
|
+
forbidden = (
|
|
99
|
+
r"\bgit\s+(?:init|fetch|reset|lfs|push|remote|submodule|checkout|restore)\b",
|
|
100
|
+
r"\b(?:rm|mv|cp|touch|truncate)\b",
|
|
101
|
+
r">>",
|
|
102
|
+
)
|
|
103
|
+
for pattern in forbidden:
|
|
104
|
+
assert re.search(pattern, source, re.IGNORECASE) is None
|
|
105
|
+
|
|
106
|
+
runtime = tmp_path / "project" / "agents" / "hermes" / "pm" / "runtime"
|
|
107
|
+
private = runtime / "sessions" / "private-state.bin"
|
|
108
|
+
private.parent.mkdir(parents=True)
|
|
109
|
+
private.write_bytes(bytes((0, 17, 34, 255)))
|
|
110
|
+
(runtime / ".env").write_text("TOKEN=preserve-exactly\n", encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
def snapshot() -> list[tuple[str, str, int, bytes]]:
|
|
113
|
+
entries: list[tuple[str, str, int, bytes]] = []
|
|
114
|
+
for path in sorted(runtime.rglob("*")):
|
|
115
|
+
kind = "dir" if path.is_dir() else "file"
|
|
116
|
+
payload = b"" if path.is_dir() else path.read_bytes()
|
|
117
|
+
entries.append((str(path.relative_to(runtime)), kind, path.stat().st_mode, payload))
|
|
118
|
+
return entries
|
|
119
|
+
|
|
120
|
+
before = snapshot()
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
[str(script), "--apply", str(runtime)],
|
|
123
|
+
text=True,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
check=False,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert result.returncode != 0
|
|
129
|
+
assert result.stdout == ""
|
|
130
|
+
assert "retired and intentionally non-operational" in result.stderr
|
|
131
|
+
assert "changed nothing" in result.stderr
|
|
132
|
+
assert snapshot() == before
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
SCRIPT = Path(__file__).parents[1] / "template" / ".scripts" / "10-hermes-profile.sh"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_profile_env_symlink_is_detached_before_sanitization() -> None:
|
|
8
|
+
text = SCRIPT.read_text(encoding="utf-8")
|
|
9
|
+
detach = text.index('if [[ -L "$PROFILE_ENV" ]]')
|
|
10
|
+
dereference_copy = text.index('cp -L "$PROFILE_ENV" "$PROFILE_ENV_COPY"')
|
|
11
|
+
replace_link = text.index('mv -fT "$PROFILE_ENV_COPY" "$PROFILE_ENV"')
|
|
12
|
+
sanitize = text.index('python3 - "$PROFILE_ENV"')
|
|
13
|
+
|
|
14
|
+
assert detach < dereference_copy < replace_link < sanitize
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SCRIPT = Path(__file__).parents[1] / "scripts" / "migrate-profile-scoped-chat-secrets.py"
|
|
9
|
+
SPEC = importlib.util.spec_from_file_location("chat_secret_migration", SCRIPT)
|
|
10
|
+
assert SPEC and SPEC.loader
|
|
11
|
+
MODULE = importlib.util.module_from_spec(SPEC)
|
|
12
|
+
SPEC.loader.exec_module(MODULE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write(path: Path, text: str) -> None:
|
|
16
|
+
path.write_text(text, encoding="utf-8")
|
|
17
|
+
path.chmod(0o600)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_dry_run_never_changes_files_or_reports_values(tmp_path: Path) -> None:
|
|
21
|
+
central = tmp_path / "central.env"
|
|
22
|
+
target = tmp_path / "profile.env"
|
|
23
|
+
_write(
|
|
24
|
+
central,
|
|
25
|
+
"KIMI_API_KEY=provider-secret\nSLACK_APP_TOKEN=app-secret\n"
|
|
26
|
+
"SLACK_BOT_TOKEN=bot-secret\nSLACK_ALLOWED_USERS=U123\n",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
moved = MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=False)
|
|
30
|
+
|
|
31
|
+
assert moved == list(MODULE.DEFAULT_KEYS)
|
|
32
|
+
assert not target.exists()
|
|
33
|
+
assert "SLACK_BOT_TOKEN=bot-secret" in central.read_text(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_apply_moves_only_chat_keys_and_creates_private_backup(tmp_path: Path) -> None:
|
|
37
|
+
central = tmp_path / "central.env"
|
|
38
|
+
target = tmp_path / "profile.env"
|
|
39
|
+
_write(
|
|
40
|
+
central,
|
|
41
|
+
"KIMI_API_KEY=provider-secret\nSLACK_APP_TOKEN=app-secret\n"
|
|
42
|
+
"SLACK_BOT_TOKEN=bot-secret\nSLACK_ALLOWED_USERS=U123\n",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=True)
|
|
46
|
+
|
|
47
|
+
assert central.read_text(encoding="utf-8") == "KIMI_API_KEY=provider-secret\n"
|
|
48
|
+
target_text = target.read_text(encoding="utf-8")
|
|
49
|
+
assert "SLACK_APP_TOKEN=app-secret" in target_text
|
|
50
|
+
assert "SLACK_BOT_TOKEN=bot-secret" in target_text
|
|
51
|
+
assert "SLACK_ALLOWED_USERS=U123" in target_text
|
|
52
|
+
assert stat.S_IMODE(target.stat().st_mode) == 0o600
|
|
53
|
+
backups = list(tmp_path.glob("central.env.bak-*-before-chat-scope"))
|
|
54
|
+
assert len(backups) == 1
|
|
55
|
+
assert stat.S_IMODE(backups[0].stat().st_mode) == 0o600
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_refuses_target_symlink_to_shared_env(tmp_path: Path) -> None:
|
|
59
|
+
central = tmp_path / "central.env"
|
|
60
|
+
target = tmp_path / "profile.env"
|
|
61
|
+
_write(
|
|
62
|
+
central,
|
|
63
|
+
"SLACK_APP_TOKEN=app\nSLACK_BOT_TOKEN=bot\nSLACK_ALLOWED_USERS=user\n",
|
|
64
|
+
)
|
|
65
|
+
target.symlink_to(central)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=True)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
assert "not a symlink" in str(exc)
|
|
71
|
+
else:
|
|
72
|
+
raise AssertionError("expected symlink safety failure")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_refuses_conflicting_target_value(tmp_path: Path) -> None:
|
|
76
|
+
central = tmp_path / "central.env"
|
|
77
|
+
target = tmp_path / "profile.env"
|
|
78
|
+
_write(
|
|
79
|
+
central,
|
|
80
|
+
"SLACK_APP_TOKEN=app\nSLACK_BOT_TOKEN=bot\nSLACK_ALLOWED_USERS=user\n",
|
|
81
|
+
)
|
|
82
|
+
_write(target, "SLACK_BOT_TOKEN=different\n")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
MODULE.migrate(central, target, MODULE.DEFAULT_KEYS, apply=True)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
assert "different values" in str(exc)
|
|
88
|
+
else:
|
|
89
|
+
raise AssertionError("expected conflict safety failure")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).parents[1]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_host_local_runtime_payloads_are_ignored() -> None:
|
|
12
|
+
gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
|
|
13
|
+
|
|
14
|
+
assert ".codegraph/daemon.pid" in gitignore
|
|
15
|
+
assert ".omo/run-continuation/" in gitignore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_host_local_runtime_payloads_are_not_tracked() -> None:
|
|
19
|
+
tracked = subprocess.run(
|
|
20
|
+
["git", "ls-files", "-z"],
|
|
21
|
+
cwd=ROOT,
|
|
22
|
+
check=True,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
).stdout.decode("utf-8").split("\0")
|
|
25
|
+
forbidden = re.compile(
|
|
26
|
+
r"(?:^|/)(?:\.omo/run-continuation/|\.codegraph/[^/]+\.(?:pid|sock|socket)$)"
|
|
27
|
+
r"|(?:^|/)(?:sessions?|runtime-state)/"
|
|
28
|
+
r"|(?:^|/)[^/]+\.(?:pid|sock|socket)$"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
offenders = sorted(path for path in tracked if path and forbidden.search(path))
|
|
32
|
+
assert offenders == [], f"tracked host-local runtime payloads: {offenders}"
|