@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
|
@@ -1,147 +1,94 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# Provision the per-agent, pure-local Hermes runtime without creating a Git
|
|
3
|
+
# repository or a project submodule. Runtime durability belongs to Hindsight;
|
|
4
|
+
# this directory may contain secrets and mutable agent state (PJAN-41).
|
|
3
5
|
# shellcheck source=_lib.sh
|
|
4
6
|
source "$(dirname "$0")/_lib.sh"
|
|
5
7
|
load_role_env
|
|
6
8
|
|
|
7
|
-
already_done 20-runtime-repo && { log "[20] runtime
|
|
8
|
-
[[ "${SKIP_RUNTIME_REPO:-0}" == "1" ]] && { log "[20] runtime
|
|
9
|
+
already_done 20-runtime-repo && { log "[20] local runtime already set up — skipping"; exit 0; }
|
|
10
|
+
[[ "${SKIP_RUNTIME_REPO:-0}" == "1" ]] && { log "[20] local runtime — SKIPPED (SKIP_RUNTIME_REPO=1)"; mark_done 20-runtime-repo; exit 0; }
|
|
9
11
|
|
|
10
12
|
PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
|
|
11
13
|
RUNTIME_LOCAL="$ROLE_DIR/runtime"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
PROJECT_PATH="$(project_repo_path)" || die "no project git root"
|
|
15
|
+
REL_ROLE_PATH="$(realpath --relative-to="$PROJECT_PATH" "$ROLE_DIR")"
|
|
16
|
+
REL_RUNTIME_PATH="${REL_ROLE_PATH}/runtime"
|
|
14
17
|
|
|
15
|
-
log "[20] runtime
|
|
18
|
+
log "[20] local runtime: $RUNTIME_LOCAL"
|
|
16
19
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
# Fail closed if an older installation still models runtime as a project
|
|
21
|
+
# submodule. `pjangler migrate` performs the non-destructive index transition;
|
|
22
|
+
# this provisioner never removes or rewrites an existing nested repository.
|
|
23
|
+
if git -C "$PROJECT_PATH" ls-files --stage -- "$REL_RUNTIME_PATH" | grep -q '^160000 '; then
|
|
24
|
+
die "$REL_RUNTIME_PATH is still a tracked gitlink; run 'pjangler migrate' before provisioning"
|
|
25
|
+
fi
|
|
26
|
+
if [[ -f "$PROJECT_PATH/.gitmodules" ]] &&
|
|
27
|
+
git -C "$PROJECT_PATH" config -f .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null |
|
|
28
|
+
awk -v expected="$REL_RUNTIME_PATH" '$2 == expected { found=1 } END { exit !found }'; then
|
|
29
|
+
die "$REL_RUNTIME_PATH still has a stale .gitmodules mapping; run 'pjangler migrate' before provisioning"
|
|
25
30
|
fi
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# This makes the step idempotent across failed-run retries.
|
|
31
|
-
if git ls-remote --heads "$REMOTE_URL" 2>/dev/null | grep -q refs/heads; then
|
|
32
|
-
log " remote already has commits — skipping scaffold push"
|
|
33
|
-
REMOTE_HAS_CONTENT=1
|
|
34
|
-
else
|
|
35
|
-
REMOTE_HAS_CONTENT=0
|
|
32
|
+
mkdir -p "$RUNTIME_LOCAL"
|
|
33
|
+
if [[ -e "$RUNTIME_LOCAL/.git" ]]; then
|
|
34
|
+
warn " existing nested runtime repository preserved; no fetch, commit, or push will be attempted"
|
|
36
35
|
fi
|
|
37
36
|
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
# Render the scaffold in a temporary directory, then copy only missing paths.
|
|
38
|
+
# Existing memory, configuration, credentials, and sessions always win.
|
|
39
|
+
TMP="$(mktemp -d)"
|
|
40
|
+
cleanup() { rm -rf -- "$TMP"; }
|
|
41
|
+
trap cleanup EXIT
|
|
41
42
|
cp -a "$RUNTIME_SCAFFOLD_DIR/." "$TMP/"
|
|
42
|
-
|
|
43
|
-
# Render scaffold templates with role-specific values
|
|
44
43
|
python3 - "$TMP" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" <<'PYEOF'
|
|
45
|
-
import
|
|
44
|
+
import pathlib
|
|
45
|
+
import sys
|
|
46
|
+
|
|
46
47
|
root, agent_id, repo, role, display = sys.argv[1:6]
|
|
47
48
|
root = pathlib.Path(root)
|
|
48
49
|
mapping = {
|
|
49
|
-
"{{agent_id}}": agent_id,
|
|
50
|
+
"{{agent_id}}": agent_id,
|
|
51
|
+
"{{repo}}": repo,
|
|
52
|
+
"{{role}}": role,
|
|
50
53
|
"{{display_name}}": display,
|
|
51
54
|
}
|
|
52
|
-
for
|
|
53
|
-
if
|
|
55
|
+
for path in root.rglob("*"):
|
|
56
|
+
if path.is_file() and path.suffix in (".md", ".yaml", ".yml", ".sh", ".py", ".gitignore", ".gitattributes"):
|
|
54
57
|
try:
|
|
55
|
-
|
|
56
|
-
for
|
|
57
|
-
|
|
58
|
+
text = path.read_text()
|
|
59
|
+
for source, target in mapping.items():
|
|
60
|
+
text = text.replace(source, target)
|
|
61
|
+
path.write_text(text)
|
|
58
62
|
except UnicodeDecodeError:
|
|
59
63
|
pass
|
|
60
64
|
PYEOF
|
|
65
|
+
cp -an "$TMP/." "$RUNTIME_LOCAL/"
|
|
61
66
|
|
|
62
|
-
#
|
|
63
|
-
# config source of truth (defaults to the global ~/.hermes/config.yaml). This
|
|
64
|
-
# is a provision-time snapshot; Hermes loads $HERMES_HOME/config.yaml directly
|
|
65
|
-
# (there is no live profile inheritance). Override via config.toml
|
|
66
|
-
# [fleet].canonical_pm_config to share one curated PM config across all repos.
|
|
67
|
+
# Seed mutable identity/config only when no local value exists.
|
|
67
68
|
CANONICAL_PM_CONFIG="$(config_get fleet.canonical_pm_config "$HOME/.hermes/config.yaml")"
|
|
68
|
-
if [[ -f "$CANONICAL_PM_CONFIG" ]]; then
|
|
69
|
-
cp "$CANONICAL_PM_CONFIG" "$
|
|
70
|
-
fi
|
|
71
|
-
# Copy the project's SOUL.md as the canonical starting personality.
|
|
72
|
-
cp "$ROLE_DIR/SOUL.md" "$TMP/SOUL.md"
|
|
73
|
-
|
|
74
|
-
# 4. Git init + LFS + initial commit + push (skip if remote already has content)
|
|
75
|
-
if [[ "$REMOTE_HAS_CONTENT" == "0" ]]; then
|
|
76
|
-
(
|
|
77
|
-
cd "$TMP"
|
|
78
|
-
git init -b main >/dev/null
|
|
79
|
-
git lfs install --local >/dev/null 2>&1 || warn "git-lfs not installed; sessions.db will commit as raw binary"
|
|
80
|
-
git lfs track "*.db" >/dev/null 2>&1 || true
|
|
81
|
-
git lfs track "*.sqlite" >/dev/null 2>&1 || true
|
|
82
|
-
git add -A
|
|
83
|
-
git -c commit.gpgsign=false commit -m "Initial scaffold for $AGENT_ID" >/dev/null
|
|
84
|
-
git remote add origin "$REMOTE_URL"
|
|
85
|
-
git push -u origin main 2>&1 | tail -3
|
|
86
|
-
)
|
|
87
|
-
fi
|
|
88
|
-
|
|
89
|
-
# 5. Submodule-add into the role dir
|
|
90
|
-
PROJECT_PATH="$(project_repo_path)" || die "no project git root"
|
|
91
|
-
# Compute relative path from the ROLE dir (which exists), then append /runtime
|
|
92
|
-
REL_ROLE_PATH="$(realpath --relative-to="$PROJECT_PATH" "$ROLE_DIR")"
|
|
93
|
-
REL_SUBMODULE_PATH="${REL_ROLE_PATH}/runtime"
|
|
94
|
-
log " adding submodule at $REL_SUBMODULE_PATH"
|
|
95
|
-
|
|
96
|
-
# Idempotent: if the submodule is already registered, just update it.
|
|
97
|
-
# This handles re-runs where the .done marker was cleared or copier
|
|
98
|
-
# --overwrite regenerated the scripts.
|
|
99
|
-
SUBMODULE_ALREADY_REGISTERED=0
|
|
100
|
-
if git -C "$PROJECT_PATH" submodule status "$REL_SUBMODULE_PATH" >/dev/null 2>&1; then
|
|
101
|
-
SUBMODULE_ALREADY_REGISTERED=1
|
|
69
|
+
if [[ -f "$CANONICAL_PM_CONFIG" && ! -e "$RUNTIME_LOCAL/config.yaml" ]]; then
|
|
70
|
+
cp "$CANONICAL_PM_CONFIG" "$RUNTIME_LOCAL/config.yaml"
|
|
102
71
|
fi
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
log " submodule already registered — updating"
|
|
106
|
-
(
|
|
107
|
-
cd "$PROJECT_PATH"
|
|
108
|
-
# If the local dir is missing (e.g. previous rm -rf), re-init it
|
|
109
|
-
if [[ ! -d "$REL_SUBMODULE_PATH/.git" ]]; then
|
|
110
|
-
git submodule update --init "$REL_SUBMODULE_PATH" 2>&1 | tail -3
|
|
111
|
-
fi
|
|
112
|
-
)
|
|
113
|
-
else
|
|
114
|
-
# Clean any leftover untracked directory before adding
|
|
115
|
-
rm -rf "$RUNTIME_LOCAL"
|
|
116
|
-
(
|
|
117
|
-
cd "$PROJECT_PATH"
|
|
118
|
-
# -f forces the add even if a repo .gitignore matches the runtime path
|
|
119
|
-
# (e.g. a stray `runtime/` rule). The runtime is a tracked submodule gitlink,
|
|
120
|
-
# never ignored content, so forcing past .gitignore is always correct here.
|
|
121
|
-
git submodule add -f "$REMOTE_URL" "$REL_SUBMODULE_PATH" 2>&1 | tail -3
|
|
122
|
-
)
|
|
72
|
+
if [[ ! -e "$RUNTIME_LOCAL/SOUL.md" ]]; then
|
|
73
|
+
cp "$ROLE_DIR/SOUL.md" "$RUNTIME_LOCAL/SOUL.md"
|
|
123
74
|
fi
|
|
124
75
|
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
# `hermes --profile <name>` through ~/.hermes/profiles/<name> (and recreates it
|
|
129
|
-
# as a fresh standalone dir if it's missing, disconnecting the agent from its
|
|
130
|
-
# runtime), so that name MUST resolve to the runtime.
|
|
131
|
-
PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
|
|
76
|
+
# Fold the two supported staging-profile files into the runtime without
|
|
77
|
+
# deleting unknown profile content. Unknown content blocks the symlink so an
|
|
78
|
+
# operator can reconcile it explicitly instead of losing state.
|
|
132
79
|
if [[ -d "$PROFILE_HOME" && ! -L "$PROFILE_HOME" ]]; then
|
|
133
|
-
log " migrating staging profile state into the runtime
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
[[ -f "$PROFILE_HOME/$f" && ! -e "$RUNTIME_LOCAL/$f" ]] && cp "$PROFILE_HOME/$f" "$RUNTIME_LOCAL/$f"
|
|
80
|
+
log " migrating supported staging profile state into the local runtime"
|
|
81
|
+
for file_name in .env config.yaml; do
|
|
82
|
+
[[ -f "$PROFILE_HOME/$file_name" && ! -e "$RUNTIME_LOCAL/$file_name" ]] && cp "$PROFILE_HOME/$file_name" "$RUNTIME_LOCAL/$file_name"
|
|
83
|
+
[[ -f "$PROFILE_HOME/$file_name" ]] && rm -f -- "$PROFILE_HOME/$file_name"
|
|
138
84
|
done
|
|
139
|
-
|
|
85
|
+
if ! rmdir "$PROFILE_HOME" 2>/dev/null; then
|
|
86
|
+
die "staging profile contains unrecognized state and was preserved: $PROFILE_HOME"
|
|
87
|
+
fi
|
|
140
88
|
fi
|
|
141
89
|
ln -sfn "$RUNTIME_LOCAL" "$PROFILE_HOME"
|
|
142
90
|
log " profile symlink $PROFILE_HOME -> $RUNTIME_LOCAL"
|
|
143
91
|
|
|
144
|
-
# Apply the one genuine per-repo config delta directly to the runtime config.
|
|
145
92
|
env HERMES_HOME="$RUNTIME_LOCAL" "$HERMES_BIN" config set terminal.cwd "$PROJECT_PATH" >/dev/null 2>&1 || true
|
|
146
93
|
|
|
147
94
|
if [[ "$ROLE" == "pm" ]]; then
|
|
@@ -164,25 +111,4 @@ if [[ "$ROLE" == "pm" ]]; then
|
|
|
164
111
|
fi
|
|
165
112
|
fi
|
|
166
113
|
|
|
167
|
-
if [[ "$ROLE" == "pm" ]]; then
|
|
168
|
-
VOXXY_PLUGIN_DIR="${VOXXY_PLUGIN_DIR:-$(config_get fleet.voxxy_plugin_dir "$HOME/code/voxxy/plugins/tts/voxxy")}"
|
|
169
|
-
if [[ -d "$VOXXY_PLUGIN_DIR" ]]; then
|
|
170
|
-
mkdir -p "$RUNTIME_LOCAL/plugins/tts"
|
|
171
|
-
ln -sfn "$VOXXY_PLUGIN_DIR" "$RUNTIME_LOCAL/plugins/tts/voxxy"
|
|
172
|
-
log " linked Voxxy plugin into runtime"
|
|
173
|
-
else
|
|
174
|
-
warn " Voxxy plugin dir missing: $VOXXY_PLUGIN_DIR"
|
|
175
|
-
fi
|
|
176
|
-
|
|
177
|
-
if [[ -x "$HERMES_BIN" ]]; then
|
|
178
|
-
env HERMES_HOME="$RUNTIME_LOCAL" "$HERMES_BIN" config set plugins.enabled.0 tts/voxxy >/dev/null 2>&1 || true
|
|
179
|
-
env HERMES_HOME="$RUNTIME_LOCAL" "$HERMES_BIN" config set tts.provider voxxy >/dev/null 2>&1 || true
|
|
180
|
-
env HERMES_HOME="$RUNTIME_LOCAL" "$HERMES_BIN" config set tts.voice rick >/dev/null 2>&1 || true
|
|
181
|
-
log " set PM runtime TTS provider -> voxxy"
|
|
182
|
-
else
|
|
183
|
-
warn " Hermes bin missing; skipped PM Voxxy config"
|
|
184
|
-
fi
|
|
185
|
-
fi
|
|
186
|
-
|
|
187
|
-
rm -rf "$TMP"
|
|
188
114
|
mark_done 20-runtime-repo
|
|
@@ -1,18 +1,64 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Capture a BotFather token and wire it into the runtime profile.
|
|
3
|
+
#
|
|
4
|
+
# Telegram credentials are invocation-only inputs. Capture them before _lib.sh
|
|
5
|
+
# sources fleet.env so a token parked in shared fleet state can never provision
|
|
6
|
+
# or be silently reused by a profile. The non-secret allow-list may be shared.
|
|
7
|
+
INVOCATION_TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN-}"
|
|
8
|
+
INVOCATION_TELEGRAM_ALLOWED_USERS="${TELEGRAM_ALLOWED_USERS-}"
|
|
9
|
+
|
|
3
10
|
# shellcheck source=_lib.sh
|
|
4
11
|
source "$(dirname "$0")/_lib.sh"
|
|
5
12
|
load_role_env
|
|
6
13
|
|
|
14
|
+
TELEGRAM_BOT_TOKEN="$INVOCATION_TELEGRAM_BOT_TOKEN"
|
|
15
|
+
if [[ -n "$INVOCATION_TELEGRAM_ALLOWED_USERS" ]]; then
|
|
16
|
+
TELEGRAM_ALLOWED_USERS="$INVOCATION_TELEGRAM_ALLOWED_USERS"
|
|
17
|
+
else
|
|
18
|
+
TELEGRAM_ALLOWED_USERS="${TELEGRAM_ALLOWED_USERS-}"
|
|
19
|
+
fi
|
|
20
|
+
unset INVOCATION_TELEGRAM_BOT_TOKEN INVOCATION_TELEGRAM_ALLOWED_USERS
|
|
21
|
+
|
|
22
|
+
telegram_yaml_update() {
|
|
23
|
+
python3 - "$ROLE_YAML" "$@" <<'PYEOF'
|
|
24
|
+
import json
|
|
25
|
+
import pathlib
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
path = pathlib.Path(sys.argv[1])
|
|
30
|
+
updates = dict(zip(sys.argv[2::2], sys.argv[3::2]))
|
|
31
|
+
text = path.read_text(encoding="utf-8")
|
|
32
|
+
match = re.search(r"(?ms)^telegram:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text)
|
|
33
|
+
if not match:
|
|
34
|
+
raise SystemExit(f"telegram metadata block missing from {path}")
|
|
35
|
+
body = match.group("body")
|
|
36
|
+
for key, value in updates.items():
|
|
37
|
+
replacement = f" {key}: {json.dumps(value)}"
|
|
38
|
+
body, count = re.subn(
|
|
39
|
+
rf"(?m)^\s+{re.escape(key)}:\s*.*$", lambda _: replacement, body, count=1
|
|
40
|
+
)
|
|
41
|
+
if count == 0:
|
|
42
|
+
if body and not body.endswith("\n"):
|
|
43
|
+
body += "\n"
|
|
44
|
+
body += replacement + "\n"
|
|
45
|
+
path.write_text(text[: match.start("body")] + body + text[match.end("body") :], encoding="utf-8")
|
|
46
|
+
PYEOF
|
|
47
|
+
}
|
|
48
|
+
|
|
7
49
|
already_done 30-telegram && { log "[30] telegram already wired — skipping"; exit 0; }
|
|
8
50
|
|
|
9
51
|
if [[ "${SKIP_TELEGRAM:-0}" == "1" ]]; then
|
|
52
|
+
telegram_yaml_update provisioning_status disabled
|
|
10
53
|
log "[30] telegram — SKIPPED (SKIP_TELEGRAM=1)"
|
|
11
54
|
mark_done 30-telegram
|
|
12
55
|
exit 0
|
|
13
56
|
fi
|
|
14
57
|
|
|
15
58
|
RUNTIME="$ROLE_DIR/runtime"
|
|
59
|
+
ENVF="$RUNTIME/.env"
|
|
60
|
+
mkdir -p "$RUNTIME"
|
|
61
|
+
[[ ! -L "$ENVF" ]] || die "refusing to write Telegram credentials through symlink: $ENVF"
|
|
16
62
|
|
|
17
63
|
cat >&2 <<EOF
|
|
18
64
|
|
|
@@ -28,14 +74,15 @@ cat >&2 <<EOF
|
|
|
28
74
|
|
|
29
75
|
EOF
|
|
30
76
|
|
|
31
|
-
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
|
|
77
|
+
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" && -t 0 ]]; then
|
|
32
78
|
read -r -s -p "Paste the bot token for @$BOT_HANDLE (or empty to skip): " TELEGRAM_BOT_TOKEN
|
|
33
79
|
echo >&2
|
|
34
80
|
fi
|
|
35
81
|
|
|
36
82
|
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
|
|
83
|
+
telegram_yaml_update provisioning_status deferred
|
|
37
84
|
warn " no token provided; Telegram step deferred"
|
|
38
|
-
warn " re-run later
|
|
85
|
+
warn " re-run later with a profile-dedicated TELEGRAM_BOT_TOKEN invocation"
|
|
39
86
|
exit 0
|
|
40
87
|
fi
|
|
41
88
|
|
|
@@ -44,37 +91,244 @@ if [[ ! "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:.+ ]]; then
|
|
|
44
91
|
die "token doesn't look like a Telegram bot token"
|
|
45
92
|
fi
|
|
46
93
|
log " verifying token..."
|
|
47
|
-
info=$(curl -
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
94
|
+
info=$(curl -fsS "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe") \
|
|
95
|
+
|| die "Telegram getMe request failed"
|
|
96
|
+
identity=$(printf '%s' "$info" | python3 -c '
|
|
97
|
+
import json, sys
|
|
98
|
+
try:
|
|
99
|
+
data = json.load(sys.stdin)
|
|
100
|
+
except Exception:
|
|
101
|
+
raise SystemExit("Telegram getMe returned an invalid response")
|
|
102
|
+
result = data.get("result") or {}
|
|
103
|
+
if not data.get("ok") or not result.get("id") or not result.get("username"):
|
|
104
|
+
raise SystemExit("Telegram rejected the bot token or omitted identity fields")
|
|
105
|
+
print("{}\t{}".format(
|
|
106
|
+
result.get("id"), str(result.get("username")).replace(chr(9), " ").replace(chr(10), " ")
|
|
107
|
+
))
|
|
108
|
+
') || die "Telegram bot identity verification failed"
|
|
109
|
+
IFS=$'\t' read -r bot_id bot_username <<< "$identity"
|
|
51
110
|
log " verified: @$bot_username (id=$bot_id)"
|
|
52
111
|
|
|
53
|
-
# Resolve operator's allowed user id
|
|
112
|
+
# Resolve operator's allowed user id before taking the fleet claim lock.
|
|
54
113
|
if [[ -z "${TELEGRAM_ALLOWED_USERS:-}" && -f "$HOME/.hermes/.env" ]]; then
|
|
55
114
|
TELEGRAM_ALLOWED_USERS=$(grep -E '^[[:space:]]*#?[[:space:]]*TELEGRAM_ALLOWED_USERS=' "$HOME/.hermes/.env" \
|
|
56
115
|
| tail -1 | sed -E 's/^[[:space:]]*#?[[:space:]]*TELEGRAM_ALLOWED_USERS=//; s/^"//; s/"$//')
|
|
57
116
|
fi
|
|
58
|
-
if [[ -z "${TELEGRAM_ALLOWED_USERS:-}" ]]; then
|
|
117
|
+
if [[ -z "${TELEGRAM_ALLOWED_USERS:-}" && -t 0 ]]; then
|
|
59
118
|
read -r -p "Your Telegram user id (allow-list for this bot): " TELEGRAM_ALLOWED_USERS
|
|
60
119
|
fi
|
|
120
|
+
if [[ -z "${TELEGRAM_ALLOWED_USERS:-}" ]]; then
|
|
121
|
+
warn " Telegram is wired but denies all inbound users until TELEGRAM_ALLOWED_USERS is set"
|
|
122
|
+
fi
|
|
61
123
|
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
124
|
+
# Reject token reuse, token rotation onto an identity owned by another agent,
|
|
125
|
+
# and credentials parked in shared fleet/root env files. The scan, durable
|
|
126
|
+
# identity claim, and profile credential write share one fleet-wide flock.
|
|
127
|
+
export TELEGRAM_BOT_TOKEN TELEGRAM_ALLOWED_USERS
|
|
128
|
+
fleet_lock_acquire
|
|
129
|
+
trap 'fleet_lock_release' EXIT
|
|
130
|
+
python3 - "$REGISTRY_FILE" "$FLEET_ENV" "$ENVF" "$AGENT_ID" "$bot_id" \
|
|
131
|
+
"$ROLE_DIR" "$PROFILE_NAME" "$bot_username" <<'PYEOF'
|
|
132
|
+
import errno
|
|
133
|
+
import os
|
|
134
|
+
import pathlib
|
|
135
|
+
import re
|
|
136
|
+
import sys
|
|
137
|
+
import tempfile
|
|
138
|
+
try:
|
|
139
|
+
import yaml # type: ignore
|
|
140
|
+
except ImportError:
|
|
141
|
+
raise SystemExit("PyYAML is required for Telegram fleet claims")
|
|
142
|
+
|
|
143
|
+
(
|
|
144
|
+
registry_path,
|
|
145
|
+
fleet_path,
|
|
146
|
+
target_path,
|
|
147
|
+
agent_id,
|
|
148
|
+
bot_id,
|
|
149
|
+
role_dir,
|
|
150
|
+
profile_name,
|
|
151
|
+
bot_username,
|
|
152
|
+
) = sys.argv[1:]
|
|
153
|
+
token = os.environ["TELEGRAM_BOT_TOKEN"]
|
|
154
|
+
target = pathlib.Path(target_path).resolve(strict=False)
|
|
155
|
+
|
|
156
|
+
def env_token(path):
|
|
157
|
+
try:
|
|
158
|
+
text = path.read_text(encoding="utf-8")
|
|
159
|
+
except (OSError, UnicodeError):
|
|
160
|
+
return None
|
|
161
|
+
match = re.search(
|
|
162
|
+
r"(?m)^\s*(?:export\s+)?TELEGRAM_BOT_TOKEN\s*=\s*(.*)$", text
|
|
163
|
+
)
|
|
164
|
+
if not match:
|
|
165
|
+
return None
|
|
166
|
+
value = match.group(1).strip()
|
|
167
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
168
|
+
value = value[1:-1]
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
owners = [("shared fleet environment", pathlib.Path(fleet_path))]
|
|
172
|
+
registry = pathlib.Path(registry_path)
|
|
173
|
+
if registry.is_symlink():
|
|
174
|
+
raise SystemExit(f"refusing to update registry symlink: {registry}")
|
|
175
|
+
data = {"schema_version": 1, "agents": {}}
|
|
176
|
+
if registry.is_file():
|
|
177
|
+
try:
|
|
178
|
+
data = yaml.safe_load(registry.read_text(encoding="utf-8")) or data
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
raise SystemExit(f"cannot safely inspect Telegram ownership registry: {type(exc).__name__}")
|
|
181
|
+
if not isinstance(data, dict) or not isinstance(data.get("agents", {}), dict):
|
|
182
|
+
raise SystemExit("cannot safely inspect Telegram ownership registry: invalid agents mapping")
|
|
183
|
+
agents = data.setdefault("agents", {})
|
|
184
|
+
for other_id, entry in agents.items():
|
|
185
|
+
if other_id == agent_id or not isinstance(entry, dict):
|
|
186
|
+
continue
|
|
187
|
+
telegram = entry.get("telegram") or {}
|
|
188
|
+
if isinstance(telegram, dict) and str(telegram.get("bot_id") or "") == bot_id:
|
|
189
|
+
raise SystemExit(f"Telegram bot identity is already assigned to agent {other_id}")
|
|
190
|
+
other_role_dir = entry.get("role_dir")
|
|
191
|
+
if other_role_dir:
|
|
192
|
+
owners.append((f"agent {other_id}", pathlib.Path(str(other_role_dir)) / "runtime" / ".env"))
|
|
193
|
+
|
|
194
|
+
home_value = os.environ.get("HOME", "")
|
|
195
|
+
if home_value:
|
|
196
|
+
home = pathlib.Path(home_value)
|
|
197
|
+
owners.append(("shared Hermes root", home / ".hermes" / ".env"))
|
|
198
|
+
profiles = home / ".hermes" / "profiles"
|
|
199
|
+
if profiles.is_dir():
|
|
200
|
+
for profile in profiles.iterdir():
|
|
201
|
+
owners.append((f"profile {profile.name}", profile / ".env"))
|
|
202
|
+
|
|
203
|
+
seen = set()
|
|
204
|
+
for owner, path in owners:
|
|
205
|
+
resolved = path.resolve(strict=False)
|
|
206
|
+
if resolved == target or resolved in seen:
|
|
207
|
+
continue
|
|
208
|
+
seen.add(resolved)
|
|
209
|
+
if env_token(path) == token:
|
|
210
|
+
raise SystemExit(f"Telegram bot token is already assigned to {owner}")
|
|
211
|
+
|
|
212
|
+
claim = agents.setdefault(agent_id, {})
|
|
213
|
+
if not isinstance(claim, dict):
|
|
214
|
+
raise SystemExit(f"registry entry for {agent_id} is not a mapping")
|
|
215
|
+
claim["role_dir"] = role_dir
|
|
216
|
+
claim["profile_name"] = profile_name
|
|
217
|
+
claim["telegram"] = {
|
|
218
|
+
"provisioning_status": "verified",
|
|
219
|
+
"bot_username": bot_username,
|
|
220
|
+
"bot_id": bot_id,
|
|
221
|
+
}
|
|
222
|
+
registry.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
rendered = yaml.safe_dump(data, sort_keys=False)
|
|
224
|
+
|
|
225
|
+
def fsync_parent(target):
|
|
226
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
227
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
228
|
+
try:
|
|
229
|
+
directory_fd = os.open(target.parent, flags)
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
if exc.errno in unsupported:
|
|
232
|
+
return
|
|
233
|
+
raise
|
|
234
|
+
try:
|
|
235
|
+
try:
|
|
236
|
+
os.fsync(directory_fd)
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
if exc.errno not in unsupported:
|
|
239
|
+
raise
|
|
240
|
+
finally:
|
|
241
|
+
os.close(directory_fd)
|
|
242
|
+
|
|
243
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{registry.name}.telegram-", dir=registry.parent)
|
|
244
|
+
try:
|
|
245
|
+
os.fchmod(fd, 0o600)
|
|
246
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
247
|
+
handle.write(rendered)
|
|
248
|
+
handle.flush()
|
|
249
|
+
os.fsync(handle.fileno())
|
|
250
|
+
os.replace(temporary, registry)
|
|
251
|
+
os.chmod(registry, 0o600)
|
|
252
|
+
fsync_parent(registry)
|
|
253
|
+
except BaseException:
|
|
254
|
+
try:
|
|
255
|
+
os.unlink(temporary)
|
|
256
|
+
except FileNotFoundError:
|
|
257
|
+
pass
|
|
258
|
+
raise
|
|
73
259
|
PYEOF
|
|
74
|
-
chmod 600 "$ENVF"
|
|
75
260
|
|
|
76
|
-
#
|
|
77
|
-
|
|
261
|
+
# Atomically replace only Telegram fields in the profile-local runtime file.
|
|
262
|
+
export TELEGRAM_ALLOWED_USERS
|
|
263
|
+
python3 - "$ENVF" <<'PYEOF'
|
|
264
|
+
import errno
|
|
265
|
+
import json
|
|
266
|
+
import os
|
|
267
|
+
import pathlib
|
|
268
|
+
import re
|
|
269
|
+
import sys
|
|
270
|
+
import tempfile
|
|
271
|
+
|
|
272
|
+
path = pathlib.Path(sys.argv[1])
|
|
273
|
+
if path.is_symlink():
|
|
274
|
+
raise SystemExit(f"refusing to write Telegram credentials through symlink: {path}")
|
|
275
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
277
|
+
for key in ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS"):
|
|
278
|
+
text = re.sub(rf"(?m)^\s*(?:export\s+)?#?\s*{key}\s*=.*(?:\n|$)", "", text)
|
|
279
|
+
text = text.rstrip("\n")
|
|
280
|
+
if text:
|
|
281
|
+
text += "\n"
|
|
282
|
+
text += "".join(
|
|
283
|
+
f"{key}={json.dumps(os.environ.get(key, ''))}\n"
|
|
284
|
+
for key in ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS")
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def fsync_parent(target):
|
|
288
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
289
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
290
|
+
try:
|
|
291
|
+
directory_fd = os.open(target.parent, flags)
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
if exc.errno in unsupported:
|
|
294
|
+
return
|
|
295
|
+
raise
|
|
296
|
+
try:
|
|
297
|
+
try:
|
|
298
|
+
os.fsync(directory_fd)
|
|
299
|
+
except OSError as exc:
|
|
300
|
+
if exc.errno not in unsupported:
|
|
301
|
+
raise
|
|
302
|
+
finally:
|
|
303
|
+
os.close(directory_fd)
|
|
304
|
+
|
|
305
|
+
fd, temporary = tempfile.mkstemp(prefix=".env.telegram-", dir=path.parent)
|
|
306
|
+
try:
|
|
307
|
+
os.fchmod(fd, 0o600)
|
|
308
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
309
|
+
handle.write(text)
|
|
310
|
+
handle.flush()
|
|
311
|
+
os.fsync(handle.fileno())
|
|
312
|
+
os.replace(temporary, path)
|
|
313
|
+
os.chmod(path, 0o600)
|
|
314
|
+
fsync_parent(path)
|
|
315
|
+
except BaseException:
|
|
316
|
+
try:
|
|
317
|
+
os.unlink(temporary)
|
|
318
|
+
except FileNotFoundError:
|
|
319
|
+
pass
|
|
320
|
+
raise
|
|
321
|
+
PYEOF
|
|
322
|
+
unset TELEGRAM_BOT_TOKEN
|
|
323
|
+
|
|
324
|
+
# Record the verified identity, never the token.
|
|
325
|
+
telegram_yaml_update \
|
|
326
|
+
provisioning_status verified \
|
|
327
|
+
bot_username "$bot_username" \
|
|
328
|
+
bot_id "$bot_id"
|
|
329
|
+
|
|
330
|
+
fleet_lock_release
|
|
331
|
+
trap - EXIT
|
|
78
332
|
|
|
79
333
|
# Enable telegram toolset for the profile
|
|
80
334
|
env HERMES_HOME="$RUNTIME" "$HERMES_BIN" tools enable telegram hermes-telegram 2>/dev/null \
|