@delorenj/pjangler 1.2.21 → 1.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -1,147 +1,94 @@
1
1
  #!/usr/bin/env bash
2
- # Create the per-agent runtime GitHub repo, init from scaffold, submodule it.
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 repo already set up — skipping"; exit 0; }
8
- [[ "${SKIP_RUNTIME_REPO:-0}" == "1" ]] && { log "[20] runtime repo — SKIPPED (SKIP_RUNTIME_REPO=1)"; mark_done 20-runtime-repo; exit 0; }
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
- GH_OWNER="${RUNTIME_REPO%%/*}"
13
- GH_NAME="${RUNTIME_REPO##*/}"
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 repo: gh:$RUNTIME_REPO"
18
+ log "[20] local runtime: $RUNTIME_LOCAL"
16
19
 
17
- # 1. Create the GitHub repo (private) if it doesn't exist
18
- if gh repo view "$RUNTIME_REPO" >/dev/null 2>&1; then
19
- log " GH repo exists; reusing"
20
- else
21
- log " creating GH repo (private)"
22
- gh repo create "$RUNTIME_REPO" --private \
23
- --description "Hermes runtime (HERMES_HOME) for $AGENT_ID auto-checkpointed memory + state" \
24
- --disable-issues --disable-wiki >/dev/null
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
- REMOTE_URL=$(gh repo view "$RUNTIME_REPO" --json sshUrl -q .sshUrl)
28
-
29
- # 2. Check if remote already has commits if so, skip the scaffold push.
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
- # 2a. Stage the runtime scaffold into a tmp dir (only if remote is empty)
39
- TMP=$(mktemp -d)
40
- log " populating scaffold in $TMP"
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 sys, pathlib, re
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, "{{repo}}": repo, "{{role}}": role,
50
+ "{{agent_id}}": agent_id,
51
+ "{{repo}}": repo,
52
+ "{{role}}": role,
50
53
  "{{display_name}}": display,
51
54
  }
52
- for p in root.rglob("*"):
53
- if p.is_file() and p.suffix in (".md", ".yaml", ".yml", ".sh", ".py", ".gitignore", ".gitattributes"):
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
- t = p.read_text()
56
- for k, v in mapping.items(): t = t.replace(k, v)
57
- p.write_text(t)
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
- # 3. Seed the runtime config from the canonical PM config — the fleet's single
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" "$TMP/config.yaml"
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
- if [[ "$SUBMODULE_ALREADY_REGISTERED" == "1" ]]; then
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
- # 6. Fold the staging profile state into the runtime, then symlink the profile
126
- # name AT the runtime. HERMES_HOME is the runtime submodule, but the profile
127
- # symlink is LOAD-BEARING: hermes resolves agents launched as
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 submodule"
134
- # OAuth provider credentials are fleet-shared via HERMES_OAUTH_FILE, so do not
135
- # clone auth.json/auth.lock into each runtime.
136
- for f in .env config.yaml; do
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
- rm -rf "$PROFILE_HOME"
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: cd $ROLE_DIR && SKIP_TELEGRAM=0 ./.scripts/30-telegram.sh"
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 -sS "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe")
48
- echo "$info" | grep -q '"ok":true' || die "Telegram rejected the token: $info"
49
- bot_username=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['username'])")
50
- bot_id=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['id'])")
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
- # Write into runtime/.env (this is HERMES_HOME for the agent)
63
- ENVF="$RUNTIME/.env"
64
- python3 - "$ENVF" "$TELEGRAM_BOT_TOKEN" "$TELEGRAM_ALLOWED_USERS" <<'PYEOF'
65
- import sys, pathlib, re
66
- path, token, allowed = sys.argv[1:4]
67
- p = pathlib.Path(path); p.parent.mkdir(parents=True, exist_ok=True)
68
- text = p.read_text() if p.exists() else ""
69
- for var in ("TELEGRAM_BOT_TOKEN","TELEGRAM_ALLOWED_USERS"):
70
- text = re.sub(rf"(?m)^\s*#?\s*{var}=.*\n", "", text)
71
- text = text.rstrip() + "\n" + f'TELEGRAM_BOT_TOKEN="{token}"\nTELEGRAM_ALLOWED_USERS="{allowed}"\n'
72
- p.write_text(text)
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
- # Update role.yaml with the real bot username (in case it differs from the slug)
77
- yaml_set telegram.bot_username "$bot_username"
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 \