@delorenj/pjangler 1.4.6 → 1.5.3
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/README.md +41 -596
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +9385 -18379
- package/dist/mcp-server.js +9432 -17852
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -584
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -1,1138 +0,0 @@
|
|
|
1
|
-
# shellcheck shell=bash
|
|
2
|
-
# Common helpers sourced by every numbered provisioning step.
|
|
3
|
-
|
|
4
|
-
set -euo pipefail
|
|
5
|
-
|
|
6
|
-
# These three are set by Copier into the rendered role.yaml; we re-derive them
|
|
7
|
-
# here so each script is callable in isolation (e.g. for repair runs).
|
|
8
|
-
ROLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
9
|
-
ROLE_YAML="$ROLE_DIR/role.yaml"
|
|
10
|
-
PROV_LOG="$ROLE_DIR/.scripts/.provision.log"
|
|
11
|
-
|
|
12
|
-
mkdir -p "$ROLE_DIR/.scripts"
|
|
13
|
-
|
|
14
|
-
# Logging
|
|
15
|
-
log() { local msg="[$(date +%H:%M:%S)] $*"; printf '\033[36m%s\033[0m\n' "$msg" >&2; printf '%s\n' "$msg" >> "$PROV_LOG"; }
|
|
16
|
-
warn() { local msg="[$(date +%H:%M:%S)] $*"; printf '\033[33m%s\033[0m\n' "$msg" >&2; printf '%s\n' "$msg" >> "$PROV_LOG"; }
|
|
17
|
-
err() { local msg="[$(date +%H:%M:%S)] $*"; printf '\033[31m%s\033[0m\n' "$msg" >&2; printf '%s\n' "$msg" >> "$PROV_LOG"; }
|
|
18
|
-
die() { err "$*"; exit 1; }
|
|
19
|
-
|
|
20
|
-
# Read a single field from role.yaml. Requires python3 (no yaml dep).
|
|
21
|
-
yaml_get() {
|
|
22
|
-
# yaml_get KEY[.SUBKEY] e.g. yaml_get role, yaml_get telegram.bot_username
|
|
23
|
-
local key="$1"
|
|
24
|
-
python3 - "$ROLE_YAML" "$key" <<'PYEOF'
|
|
25
|
-
import sys, re, pathlib
|
|
26
|
-
path, key = sys.argv[1:3]
|
|
27
|
-
text = pathlib.Path(path).read_text()
|
|
28
|
-
parts = key.split(".")
|
|
29
|
-
# Trivial YAML walker — handles flat and one-level nested keys.
|
|
30
|
-
indent = -1
|
|
31
|
-
prefix = ""
|
|
32
|
-
for part in parts[:-1]:
|
|
33
|
-
indent += 2
|
|
34
|
-
prefix += part + ":"
|
|
35
|
-
m = re.search(rf"(?m)^{re.escape(part)}:\s*$", text)
|
|
36
|
-
if not m:
|
|
37
|
-
sys.exit(0)
|
|
38
|
-
text = text[m.end():]
|
|
39
|
-
key = parts[-1]
|
|
40
|
-
m = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', text)
|
|
41
|
-
if m:
|
|
42
|
-
print(m.group(1).strip())
|
|
43
|
-
PYEOF
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
# Apply a sed substitution to role.yaml in-place. Used to record IDs after
|
|
47
|
-
# external provisioning steps return them.
|
|
48
|
-
yaml_set() {
|
|
49
|
-
# yaml_set KEY VALUE (supports flat and one-level nested keys)
|
|
50
|
-
local key="$1" val="$2"
|
|
51
|
-
python3 - "$ROLE_YAML" "$key" "$val" <<'PYEOF'
|
|
52
|
-
import json, pathlib, re, sys
|
|
53
|
-
|
|
54
|
-
path, key, val = sys.argv[1:4]
|
|
55
|
-
p = pathlib.Path(path)
|
|
56
|
-
text = p.read_text(encoding="utf-8")
|
|
57
|
-
parts = key.split(".")
|
|
58
|
-
if len(parts) not in (1, 2) or any(
|
|
59
|
-
not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", part) for part in parts
|
|
60
|
-
):
|
|
61
|
-
sys.exit(f"yaml_set: unsupported key '{key}'")
|
|
62
|
-
|
|
63
|
-
lines = text.splitlines(keepends=True)
|
|
64
|
-
start, end, parent_indent = 0, len(lines), -1
|
|
65
|
-
if len(parts) == 2:
|
|
66
|
-
parent, leaf = parts
|
|
67
|
-
parents = []
|
|
68
|
-
for index, raw in enumerate(lines):
|
|
69
|
-
line = raw.rstrip("\r\n")
|
|
70
|
-
match = re.fullmatch(rf"( *){re.escape(parent)}:\s*(?:#.*)?", line)
|
|
71
|
-
if match and len(match.group(1)) == 0:
|
|
72
|
-
parents.append(index)
|
|
73
|
-
if len(parents) != 1:
|
|
74
|
-
sys.exit(f"yaml_set: parent '{parent}' not found uniquely in {path}")
|
|
75
|
-
parent_index = parents[0]
|
|
76
|
-
parent_indent = 0
|
|
77
|
-
start = parent_index + 1
|
|
78
|
-
for index in range(start, len(lines)):
|
|
79
|
-
candidate = lines[index].rstrip("\r\n")
|
|
80
|
-
if not candidate.strip() or candidate.lstrip().startswith("#"):
|
|
81
|
-
continue
|
|
82
|
-
indent = len(candidate) - len(candidate.lstrip(" "))
|
|
83
|
-
if indent <= parent_indent:
|
|
84
|
-
end = index
|
|
85
|
-
break
|
|
86
|
-
else:
|
|
87
|
-
leaf = parts[0]
|
|
88
|
-
|
|
89
|
-
targets = []
|
|
90
|
-
for index in range(start, end):
|
|
91
|
-
raw = lines[index]
|
|
92
|
-
line = raw.rstrip("\r\n")
|
|
93
|
-
match = re.match(rf"^( *){re.escape(leaf)}:\s*", line)
|
|
94
|
-
if not match:
|
|
95
|
-
continue
|
|
96
|
-
indent = len(match.group(1))
|
|
97
|
-
if (parent_indent < 0 and indent == 0) or (parent_indent >= 0 and indent > parent_indent):
|
|
98
|
-
targets.append((index, match.group(1)))
|
|
99
|
-
if len(targets) != 1:
|
|
100
|
-
sys.exit(f"yaml_set: key '{key}' not found uniquely in {path}")
|
|
101
|
-
|
|
102
|
-
index, indent = targets[0]
|
|
103
|
-
ending = "\r\n" if lines[index].endswith("\r\n") else "\n" if lines[index].endswith("\n") else ""
|
|
104
|
-
lines[index] = f"{indent}{leaf}: {json.dumps(val, ensure_ascii=False)}{ending}"
|
|
105
|
-
p.write_text("".join(lines), encoding="utf-8")
|
|
106
|
-
PYEOF
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
# Upsert one scalar below a top-level mapping without requiring the mapping to
|
|
110
|
-
# have existed in an older rendered role.yaml. This is intentionally narrower
|
|
111
|
-
# than a general YAML editor: lifecycle scripts use it only for safe, generated
|
|
112
|
-
# status metadata such as service_state.gateway.
|
|
113
|
-
yaml_upsert_block_value() {
|
|
114
|
-
# yaml_upsert_block_value PARENT KEY VALUE [string|bool]
|
|
115
|
-
python3 - "$ROLE_YAML" "$1" "$2" "$3" "${4:-string}" <<'PYEOF'
|
|
116
|
-
import json, pathlib, re, sys
|
|
117
|
-
|
|
118
|
-
path, parent, key, value, kind = sys.argv[1:6]
|
|
119
|
-
if not all(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", item) for item in (parent, key)):
|
|
120
|
-
raise SystemExit("yaml_upsert_block_value: unsafe key")
|
|
121
|
-
if kind == "bool":
|
|
122
|
-
if value not in {"true", "false"}:
|
|
123
|
-
raise SystemExit("yaml_upsert_block_value: invalid boolean")
|
|
124
|
-
rendered_value = value
|
|
125
|
-
elif kind == "string":
|
|
126
|
-
rendered_value = json.dumps(value, ensure_ascii=False)
|
|
127
|
-
else:
|
|
128
|
-
raise SystemExit("yaml_upsert_block_value: unsupported scalar type")
|
|
129
|
-
p = pathlib.Path(path)
|
|
130
|
-
text = p.read_text(encoding="utf-8")
|
|
131
|
-
match = re.search(
|
|
132
|
-
rf"(?ms)^{re.escape(parent)}:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text
|
|
133
|
-
)
|
|
134
|
-
replacement = f" {key}: {rendered_value}"
|
|
135
|
-
if match:
|
|
136
|
-
body = match.group("body")
|
|
137
|
-
body, count = re.subn(
|
|
138
|
-
rf"(?m)^[ \t]+{re.escape(key)}:\s*.*$", replacement, body, count=1
|
|
139
|
-
)
|
|
140
|
-
if count == 0:
|
|
141
|
-
if body and not body.endswith("\n"):
|
|
142
|
-
body += "\n"
|
|
143
|
-
body += replacement + "\n"
|
|
144
|
-
text = text[: match.start("body")] + body + text[match.end("body") :]
|
|
145
|
-
else:
|
|
146
|
-
if text and not text.endswith("\n"):
|
|
147
|
-
text += "\n"
|
|
148
|
-
text += f"\n{parent}:\n{replacement}\n"
|
|
149
|
-
p.write_text(text, encoding="utf-8")
|
|
150
|
-
PYEOF
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
# Test whether an ignored dotenv file contains one non-empty exact assignment.
|
|
154
|
-
# Values remain inside the child process and are never printed or imported into
|
|
155
|
-
# the provisioning shell.
|
|
156
|
-
dotenv_has_nonempty() {
|
|
157
|
-
python3 - "$1" "$2" <<'PYEOF'
|
|
158
|
-
import pathlib, re, sys
|
|
159
|
-
|
|
160
|
-
path = pathlib.Path(sys.argv[1])
|
|
161
|
-
key = sys.argv[2]
|
|
162
|
-
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) or not path.is_file() or path.is_symlink():
|
|
163
|
-
raise SystemExit(1)
|
|
164
|
-
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
165
|
-
line = raw.strip()
|
|
166
|
-
if not line or line.startswith("#"):
|
|
167
|
-
continue
|
|
168
|
-
if line.startswith("export "):
|
|
169
|
-
line = line[7:].lstrip()
|
|
170
|
-
name, sep, value = line.partition("=")
|
|
171
|
-
if not sep or name.strip() != key:
|
|
172
|
-
continue
|
|
173
|
-
value = value.strip()
|
|
174
|
-
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
175
|
-
value = value[1:-1]
|
|
176
|
-
raise SystemExit(0 if value else 1)
|
|
177
|
-
raise SystemExit(1)
|
|
178
|
-
PYEOF
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
# Persist one process-only value to 1Password. The helper prints only the
|
|
182
|
-
# resulting op:// reference; the value crosses stdin and an anonymous pipe.
|
|
183
|
-
store_onepassword_secret() {
|
|
184
|
-
# store_onepassword_secret ITEM_NAME VALUE
|
|
185
|
-
local item="$1" value="$2" vault
|
|
186
|
-
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
187
|
-
[[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
|
|
188
|
-
[[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
|
|
189
|
-
|| die "trusted 1Password storage helper is missing"
|
|
190
|
-
printf '%s' "$value" \
|
|
191
|
-
| python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" "$vault" "$item"
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
# Stage one credential in a new immutable 1Password item. Output is two lines:
|
|
195
|
-
# item id, then op:// reference. Nothing is active until the channel transaction
|
|
196
|
-
# commits that reference.
|
|
197
|
-
stage_onepassword_secret() {
|
|
198
|
-
# stage_onepassword_secret ITEM_PREFIX FIELD VALUE
|
|
199
|
-
local item="$1" field="$2" value="$3" vault
|
|
200
|
-
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
201
|
-
[[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
|
|
202
|
-
[[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
|
|
203
|
-
|| die "trusted 1Password storage helper is missing"
|
|
204
|
-
printf '%s' "$value" \
|
|
205
|
-
| python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
|
|
206
|
-
--store-staged "$vault" "$item" "$field"
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
# Stage a credential pair with one atomic 1Password item create. Output is
|
|
210
|
-
# immutable item id followed by both verified op:// references.
|
|
211
|
-
stage_onepassword_secret_pair() {
|
|
212
|
-
# stage_onepassword_secret_pair ITEM_NAME FIELD_ONE VALUE_ONE FIELD_TWO VALUE_TWO
|
|
213
|
-
local item="$1" field_one="$2" value_one="$3" field_two="$4" value_two="$5" vault
|
|
214
|
-
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
215
|
-
[[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
|
|
216
|
-
[[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
|
|
217
|
-
|| die "trusted 1Password storage helper is missing"
|
|
218
|
-
printf '%s\n%s' "$value_one" "$value_two" \
|
|
219
|
-
| python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
|
|
220
|
-
--store-pair "$vault" "$item" "$field_one" "$field_two"
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
delete_staged_onepassword_item() {
|
|
224
|
-
# delete_staged_onepassword_item IMMUTABLE_ITEM_ID
|
|
225
|
-
local item_id="$1" vault
|
|
226
|
-
vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
|
|
227
|
-
[[ -n "$vault" && -n "$item_id" ]] || return 1
|
|
228
|
-
python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
|
|
229
|
-
--delete-item-id "$vault" "$item_id"
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
# Update one supported profile override and immediately regenerate config.yaml
|
|
233
|
-
# from fleet base + delta. Keeping both operations in one helper prevents the
|
|
234
|
-
# first deploy and fleet-sync paths from disagreeing about which file is the
|
|
235
|
-
# source of truth.
|
|
236
|
-
profile_config_delta_update() {
|
|
237
|
-
# profile_config_delta_update PROFILE_HOME secret-ref ENV_NAME OP_REFERENCE
|
|
238
|
-
# profile_config_delta_update PROFILE_HOME secret-ref-pair ENV REF ENV REF
|
|
239
|
-
# profile_config_delta_update PROFILE_HOME channel-enabled telegram|slack true|false
|
|
240
|
-
# profile_config_delta_update PROFILE_HOME voice PLUGIN VOICE
|
|
241
|
-
local profile_lock_tool="$ROLE_DIR/.scripts/lib/profile-config-lock.py"
|
|
242
|
-
[[ -f "$profile_lock_tool" && ! -L "$profile_lock_tool" ]] \
|
|
243
|
-
|| die "trusted PM profile config lock helper is unavailable"
|
|
244
|
-
if [[ "${2:-}" == "voice" ]]; then
|
|
245
|
-
[[ $# -eq 4 ]] || die "voice requires a profile, plugin, and voice"
|
|
246
|
-
local voice_tool="$ROLE_DIR/.scripts/lib/voice-config.py"
|
|
247
|
-
[[ -f "$voice_tool" && ! -L "$voice_tool" ]] \
|
|
248
|
-
|| die "trusted PM voice config helper is unavailable"
|
|
249
|
-
python3 -I "$voice_tool" reconcile \
|
|
250
|
-
--base "$1/../../config.yaml" \
|
|
251
|
-
--delta "$1/config.delta.yaml" \
|
|
252
|
-
--generated "$1/config.yaml" \
|
|
253
|
-
--plugin "$3" \
|
|
254
|
-
--voice "$4"
|
|
255
|
-
return
|
|
256
|
-
fi
|
|
257
|
-
python3 - "$profile_lock_tool" "$@" <<'PYEOF'
|
|
258
|
-
import atexit, copy, importlib.util, os, pathlib, re, sys, tempfile
|
|
259
|
-
try:
|
|
260
|
-
import yaml
|
|
261
|
-
except ImportError:
|
|
262
|
-
raise SystemExit("PyYAML is required for Hermes profile config")
|
|
263
|
-
|
|
264
|
-
lock_source = pathlib.Path(sys.argv[1])
|
|
265
|
-
spec = importlib.util.spec_from_file_location("pjangler_profile_config_lock", lock_source)
|
|
266
|
-
if spec is None or spec.loader is None:
|
|
267
|
-
raise SystemExit(f"cannot load profile config lock helper: {lock_source}")
|
|
268
|
-
profile_lock_module = importlib.util.module_from_spec(spec)
|
|
269
|
-
spec.loader.exec_module(profile_lock_module)
|
|
270
|
-
profile = pathlib.Path(sys.argv[2])
|
|
271
|
-
profile_lock = profile_lock_module.ProfileConfigLock(profile)
|
|
272
|
-
try:
|
|
273
|
-
profile_lock.acquire()
|
|
274
|
-
except profile_lock_module.ProfileConfigLockError as exc:
|
|
275
|
-
raise SystemExit(str(exc)) from exc
|
|
276
|
-
atexit.register(profile_lock.release)
|
|
277
|
-
# A profile root is a security boundary. Never follow the legacy symlink form:
|
|
278
|
-
# even a read would escape the named profile and a later atomic replace could
|
|
279
|
-
# mutate shared/runtime state. Migration must happen in the lifecycle step.
|
|
280
|
-
if profile.is_symlink():
|
|
281
|
-
raise SystemExit(
|
|
282
|
-
f"refusing symlinked profile root: {profile}; "
|
|
283
|
-
"run the pjangler Hermes runtime-singleton migration before provisioning channels"
|
|
284
|
-
)
|
|
285
|
-
if not profile.is_dir():
|
|
286
|
-
raise SystemExit(f"required profile root is unavailable: {profile}")
|
|
287
|
-
args = sys.argv[3:]
|
|
288
|
-
if not args:
|
|
289
|
-
raise SystemExit("profile config update mode is required")
|
|
290
|
-
mode = args[0]
|
|
291
|
-
base_path = profile.parent.parent / "config.yaml"
|
|
292
|
-
delta_path = profile / "config.delta.yaml"
|
|
293
|
-
generated_path = profile / "config.yaml"
|
|
294
|
-
|
|
295
|
-
def load(path):
|
|
296
|
-
if not path.is_file() or path.is_symlink():
|
|
297
|
-
raise SystemExit(f"required config source is unavailable: {path}")
|
|
298
|
-
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
299
|
-
if not isinstance(data, dict):
|
|
300
|
-
raise SystemExit(f"config source must be a mapping: {path}")
|
|
301
|
-
return data
|
|
302
|
-
|
|
303
|
-
LIST_PATCH_KEY = "x-pjangler-merge"
|
|
304
|
-
|
|
305
|
-
def plain_merge(base, override):
|
|
306
|
-
result = copy.deepcopy(base)
|
|
307
|
-
for key, value in override.items():
|
|
308
|
-
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
309
|
-
result[key] = plain_merge(result[key], value)
|
|
310
|
-
elif key in result and isinstance(result[key], dict) and value is None:
|
|
311
|
-
continue
|
|
312
|
-
else:
|
|
313
|
-
result[key] = copy.deepcopy(value)
|
|
314
|
-
return result
|
|
315
|
-
|
|
316
|
-
def apply_list_patches(result, directive):
|
|
317
|
-
if directive is None:
|
|
318
|
-
return
|
|
319
|
-
if not isinstance(directive, dict) or not isinstance(directive.get("list_patches", {}), dict):
|
|
320
|
-
raise SystemExit(f"{LIST_PATCH_KEY}.list_patches must be a mapping")
|
|
321
|
-
for dotted, rule in directive.get("list_patches", {}).items():
|
|
322
|
-
if not isinstance(dotted, str) or not dotted or not isinstance(rule, dict):
|
|
323
|
-
raise SystemExit("invalid list patch")
|
|
324
|
-
additions = rule.get("add", []) or []
|
|
325
|
-
removals = rule.get("remove", []) or []
|
|
326
|
-
if not isinstance(additions, list) or not isinstance(removals, list) or not all(
|
|
327
|
-
isinstance(item, str) for item in [*additions, *removals]
|
|
328
|
-
):
|
|
329
|
-
raise SystemExit(f"list patch for {dotted} must contain string lists")
|
|
330
|
-
cursor = result
|
|
331
|
-
parts = dotted.split(".")
|
|
332
|
-
for part in parts[:-1]:
|
|
333
|
-
child = cursor.setdefault(part, {})
|
|
334
|
-
if not isinstance(child, dict):
|
|
335
|
-
raise SystemExit(f"list patch parent for {dotted} is not a mapping")
|
|
336
|
-
cursor = child
|
|
337
|
-
current = cursor.get(parts[-1], []) or []
|
|
338
|
-
if not isinstance(current, list):
|
|
339
|
-
raise SystemExit(f"list patch target {dotted} is not a list")
|
|
340
|
-
removed = set(removals)
|
|
341
|
-
merged = [item for item in current if item not in removed]
|
|
342
|
-
for item in additions:
|
|
343
|
-
if item not in merged:
|
|
344
|
-
merged.append(item)
|
|
345
|
-
cursor[parts[-1]] = merged
|
|
346
|
-
|
|
347
|
-
def merge(base, override):
|
|
348
|
-
directive = override.get(LIST_PATCH_KEY)
|
|
349
|
-
ordinary = {key: value for key, value in override.items() if key != LIST_PATCH_KEY}
|
|
350
|
-
result = plain_merge(base, ordinary)
|
|
351
|
-
apply_list_patches(result, directive)
|
|
352
|
-
return result
|
|
353
|
-
|
|
354
|
-
def atomic_write(path, content):
|
|
355
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
356
|
-
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
357
|
-
try:
|
|
358
|
-
os.fchmod(fd, 0o600)
|
|
359
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
360
|
-
handle.write(content)
|
|
361
|
-
handle.flush()
|
|
362
|
-
os.fsync(handle.fileno())
|
|
363
|
-
if path.is_symlink():
|
|
364
|
-
path.unlink()
|
|
365
|
-
os.replace(temporary, path)
|
|
366
|
-
os.chmod(path, 0o600)
|
|
367
|
-
except BaseException:
|
|
368
|
-
try: os.unlink(temporary)
|
|
369
|
-
except FileNotFoundError: pass
|
|
370
|
-
raise
|
|
371
|
-
|
|
372
|
-
base = load(base_path)
|
|
373
|
-
if delta_path.exists():
|
|
374
|
-
delta = load(delta_path)
|
|
375
|
-
else:
|
|
376
|
-
delta = {}
|
|
377
|
-
if mode == "secret-ref":
|
|
378
|
-
if len(args) != 3:
|
|
379
|
-
raise SystemExit("secret-ref requires an environment name and reference")
|
|
380
|
-
name, value = args[1:]
|
|
381
|
-
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", name):
|
|
382
|
-
raise SystemExit("invalid secret environment variable name")
|
|
383
|
-
if not value.startswith("op://") or any(ch in value for ch in "\r\n\0"):
|
|
384
|
-
raise SystemExit("invalid 1Password reference")
|
|
385
|
-
onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
|
|
386
|
-
if not isinstance(onepassword, dict):
|
|
387
|
-
raise SystemExit("secrets.onepassword delta must be a mapping")
|
|
388
|
-
onepassword["enabled"] = True
|
|
389
|
-
env = onepassword.setdefault("env", {})
|
|
390
|
-
if not isinstance(env, dict):
|
|
391
|
-
raise SystemExit("secrets.onepassword.env delta must be a mapping")
|
|
392
|
-
env[name] = value
|
|
393
|
-
elif mode == "secret-ref-pair":
|
|
394
|
-
if len(args) != 5:
|
|
395
|
-
raise SystemExit("secret-ref-pair requires two environment/reference pairs")
|
|
396
|
-
name_one, value_one, name_two, value_two = args[1:]
|
|
397
|
-
pairs = ((name_one, value_one), (name_two, value_two))
|
|
398
|
-
if name_one == name_two:
|
|
399
|
-
raise SystemExit("secret-ref-pair environment names must be distinct")
|
|
400
|
-
for env_name, reference in pairs:
|
|
401
|
-
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", env_name):
|
|
402
|
-
raise SystemExit("invalid secret environment variable name")
|
|
403
|
-
if not reference.startswith("op://") or any(ch in reference for ch in "\r\n\0"):
|
|
404
|
-
raise SystemExit("invalid 1Password reference")
|
|
405
|
-
onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
|
|
406
|
-
if not isinstance(onepassword, dict):
|
|
407
|
-
raise SystemExit("secrets.onepassword delta must be a mapping")
|
|
408
|
-
onepassword["enabled"] = True
|
|
409
|
-
env = onepassword.setdefault("env", {})
|
|
410
|
-
if not isinstance(env, dict):
|
|
411
|
-
raise SystemExit("secrets.onepassword.env delta must be a mapping")
|
|
412
|
-
# Both refs enter the same in-memory document and one atomic delta write.
|
|
413
|
-
for env_name, reference in pairs:
|
|
414
|
-
env[env_name] = reference
|
|
415
|
-
elif mode == "channel-enabled":
|
|
416
|
-
if len(args) != 3:
|
|
417
|
-
raise SystemExit("channel-enabled requires a channel and boolean")
|
|
418
|
-
name, value = args[1:]
|
|
419
|
-
if name not in {"telegram", "slack"}:
|
|
420
|
-
raise SystemExit("unsupported channel enablement key")
|
|
421
|
-
if value not in {"true", "false"}:
|
|
422
|
-
raise SystemExit("channel enablement must be true or false")
|
|
423
|
-
platforms = delta.setdefault("platforms", {})
|
|
424
|
-
if not isinstance(platforms, dict):
|
|
425
|
-
raise SystemExit("platforms delta must be a mapping")
|
|
426
|
-
channel = platforms.setdefault(name, {})
|
|
427
|
-
if not isinstance(channel, dict):
|
|
428
|
-
raise SystemExit(f"platforms.{name} delta must be a mapping")
|
|
429
|
-
channel["enabled"] = value == "true"
|
|
430
|
-
else:
|
|
431
|
-
raise SystemExit("unsupported profile config delta update")
|
|
432
|
-
existing_comments = []
|
|
433
|
-
if delta_path.is_file() and not delta_path.is_symlink():
|
|
434
|
-
for line in delta_path.read_text(encoding="utf-8").splitlines():
|
|
435
|
-
if line.lstrip().startswith("#") and line not in existing_comments:
|
|
436
|
-
existing_comments.append(line)
|
|
437
|
-
standard_comments = [
|
|
438
|
-
"# Override-only delta for this Hermes profile.",
|
|
439
|
-
"# Contains configuration and secret references only; secret values remain in 1Password.",
|
|
440
|
-
]
|
|
441
|
-
comments = [*standard_comments, *(line for line in existing_comments if line not in standard_comments)]
|
|
442
|
-
atomic_write(delta_path, "\n".join(comments) + "\n" + yaml.safe_dump(delta, sort_keys=False))
|
|
443
|
-
header = (
|
|
444
|
-
"# GENERATED FILE -- DO NOT EDIT.\n"
|
|
445
|
-
"# source: fleet config.yaml + profile config.delta.yaml\n"
|
|
446
|
-
)
|
|
447
|
-
atomic_write(generated_path, header + yaml.safe_dump(merge(base, delta), sort_keys=False))
|
|
448
|
-
PYEOF
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
profile_voice_contract_set() {
|
|
452
|
-
# profile_voice_contract_set PROFILE_HOME PLUGIN VOICE
|
|
453
|
-
profile_config_delta_update "$1" voice "$2" "$3"
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
profile_root_require_real() {
|
|
457
|
-
# profile_root_require_real PROFILE_HOME
|
|
458
|
-
[[ ! -L "$1" ]] \
|
|
459
|
-
|| die "refusing symlinked profile root: $1; run the pjangler Hermes runtime-singleton migration before provisioning channels"
|
|
460
|
-
[[ -d "$1" ]] || die "required profile root is unavailable: $1"
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
profile_onepassword_ref_exists() {
|
|
464
|
-
# profile_onepassword_ref_exists PROFILE_HOME ENV_NAME
|
|
465
|
-
# This is a read-only service eligibility probe. Channel transactions do not
|
|
466
|
-
# use it: their refs and identity are captured under registry -> profile lock.
|
|
467
|
-
python3 - "$1/config.delta.yaml" "$2" <<'PYEOF'
|
|
468
|
-
import pathlib, sys
|
|
469
|
-
try:
|
|
470
|
-
import yaml
|
|
471
|
-
except ImportError:
|
|
472
|
-
raise SystemExit(1)
|
|
473
|
-
path = pathlib.Path(sys.argv[1])
|
|
474
|
-
if not path.is_file() or path.is_symlink():
|
|
475
|
-
raise SystemExit(1)
|
|
476
|
-
try:
|
|
477
|
-
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
478
|
-
ref = data["secrets"]["onepassword"]["env"].get(sys.argv[2], "")
|
|
479
|
-
except (KeyError, TypeError, ValueError, yaml.YAMLError):
|
|
480
|
-
raise SystemExit(1)
|
|
481
|
-
raise SystemExit(0 if isinstance(ref, str) and ref.startswith("op://") else 1)
|
|
482
|
-
PYEOF
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
profile_onepassword_ref_get() {
|
|
486
|
-
# profile_onepassword_ref_get PROFILE_HOME ENV_NAME
|
|
487
|
-
python3 - "$1/config.delta.yaml" "$2" <<'PYEOF'
|
|
488
|
-
import pathlib, sys
|
|
489
|
-
try:
|
|
490
|
-
import yaml
|
|
491
|
-
data = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) or {}
|
|
492
|
-
reference = data["secrets"]["onepassword"]["env"].get(sys.argv[2], "")
|
|
493
|
-
except Exception:
|
|
494
|
-
reference = ""
|
|
495
|
-
if not isinstance(reference, str) or not reference.startswith("op://"):
|
|
496
|
-
raise SystemExit(1)
|
|
497
|
-
print(reference)
|
|
498
|
-
PYEOF
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
profile_onepassword_ref_validate() {
|
|
502
|
-
# profile_onepassword_ref_validate PROFILE_HOME ENV_NAME
|
|
503
|
-
# Return 0 when the reference resolves, 2 when no syntactically valid
|
|
504
|
-
# mapping exists, and 75 when a valid mapping cannot currently be checked.
|
|
505
|
-
# This is a read-only service eligibility probe, never a transaction input.
|
|
506
|
-
local reference helper="$ROLE_DIR/.scripts/store-onepassword-secret.py"
|
|
507
|
-
[[ -f "$helper" ]] || return 75
|
|
508
|
-
reference="$(profile_onepassword_ref_get "$1" "$2")" || return 2
|
|
509
|
-
if python3 -I "$helper" --validate-reference "$reference" >/dev/null 2>&1; then
|
|
510
|
-
return 0
|
|
511
|
-
fi
|
|
512
|
-
return 75
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
channel_transaction_telegram() {
|
|
516
|
-
# channel_transaction_telegram PROFILE_HOME RUNTIME_ENV REF USERNAME BOT_ID ALLOWED
|
|
517
|
-
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
518
|
-
[[ -f "$helper" && ! -L "$helper" ]] || die "trusted channel transaction helper is missing"
|
|
519
|
-
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
520
|
-
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
521
|
-
|| die "trusted PM profile config lock helper is unavailable"
|
|
522
|
-
python3 -I "$helper" \
|
|
523
|
-
--channel telegram \
|
|
524
|
-
--profile "$1" \
|
|
525
|
-
--role-yaml "$ROLE_YAML" \
|
|
526
|
-
--registry "$REGISTRY_FILE" \
|
|
527
|
-
--runtime-env "$2" \
|
|
528
|
-
--done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
|
|
529
|
-
--agent-id "$AGENT_ID" \
|
|
530
|
-
--role-dir "$ROLE_DIR" \
|
|
531
|
-
--profile-name "$PROFILE_NAME" \
|
|
532
|
-
--allowed-value "$6" \
|
|
533
|
-
--reference TELEGRAM_BOT_TOKEN "$3" \
|
|
534
|
-
--metadata provisioning_status verified \
|
|
535
|
-
--metadata bot_username "$4" \
|
|
536
|
-
--metadata bot_id "$5"
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
channel_transaction_telegram_existing() {
|
|
540
|
-
# channel_transaction_telegram_existing PROFILE_HOME RUNTIME_ENV
|
|
541
|
-
# Caller holds the fleet registry lock; the helper acquires the profile lock
|
|
542
|
-
# and reads refs plus role metadata only after both locks are held.
|
|
543
|
-
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
544
|
-
local validator="$ROLE_DIR/.scripts/store-onepassword-secret.py"
|
|
545
|
-
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
546
|
-
|| die "trusted channel transaction helper is missing"
|
|
547
|
-
[[ -f "$validator" && ! -L "$validator" ]] \
|
|
548
|
-
|| die "trusted 1Password reference validator is missing"
|
|
549
|
-
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
550
|
-
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
551
|
-
|| die "trusted PM profile config lock helper is unavailable"
|
|
552
|
-
python3 -I "$helper" \
|
|
553
|
-
--channel telegram \
|
|
554
|
-
--profile "$1" \
|
|
555
|
-
--role-yaml "$ROLE_YAML" \
|
|
556
|
-
--registry "$REGISTRY_FILE" \
|
|
557
|
-
--runtime-env "$2" \
|
|
558
|
-
--done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
|
|
559
|
-
--agent-id "$AGENT_ID" \
|
|
560
|
-
--role-dir "$ROLE_DIR" \
|
|
561
|
-
--profile-name "$PROFILE_NAME" \
|
|
562
|
-
--reconcile-existing \
|
|
563
|
-
--reference-validator "$validator"
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
channel_transaction_telegram_prepare_unconfigured() {
|
|
567
|
-
# Caller holds the fleet registry lock. The helper disables only a channel
|
|
568
|
-
# whose locked role snapshot is not verified; exit 3 means verified/no-op.
|
|
569
|
-
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
570
|
-
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
571
|
-
|| die "trusted channel transaction helper is missing"
|
|
572
|
-
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
573
|
-
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
574
|
-
|| die "trusted PM profile config lock helper is unavailable"
|
|
575
|
-
python3 -I "$helper" \
|
|
576
|
-
--channel telegram \
|
|
577
|
-
--profile "$1" \
|
|
578
|
-
--role-yaml "$ROLE_YAML" \
|
|
579
|
-
--registry "$REGISTRY_FILE" \
|
|
580
|
-
--runtime-env "$2" \
|
|
581
|
-
--done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
|
|
582
|
-
--agent-id "$AGENT_ID" \
|
|
583
|
-
--role-dir "$ROLE_DIR" \
|
|
584
|
-
--profile-name "$PROFILE_NAME" \
|
|
585
|
-
--prepare-unconfigured
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
channel_transaction_slack() {
|
|
589
|
-
# channel_transaction_slack PROFILE ENV BOT_REF APP_REF TEAM_ID TEAM_NAME USER_ID BOT_ID USERNAME ALLOWED
|
|
590
|
-
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
591
|
-
[[ -f "$helper" && ! -L "$helper" ]] || die "trusted channel transaction helper is missing"
|
|
592
|
-
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
593
|
-
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
594
|
-
|| die "trusted PM profile config lock helper is unavailable"
|
|
595
|
-
python3 -I "$helper" \
|
|
596
|
-
--channel slack \
|
|
597
|
-
--profile "$1" \
|
|
598
|
-
--role-yaml "$ROLE_YAML" \
|
|
599
|
-
--registry "$REGISTRY_FILE" \
|
|
600
|
-
--runtime-env "$2" \
|
|
601
|
-
--done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
|
|
602
|
-
--agent-id "$AGENT_ID" \
|
|
603
|
-
--role-dir "$ROLE_DIR" \
|
|
604
|
-
--profile-name "$PROFILE_NAME" \
|
|
605
|
-
--allowed-value "${10}" \
|
|
606
|
-
--reference SLACK_BOT_TOKEN "$3" \
|
|
607
|
-
--reference SLACK_APP_TOKEN "$4" \
|
|
608
|
-
--metadata provisioning_status verified \
|
|
609
|
-
--metadata team_id "$5" \
|
|
610
|
-
--metadata team_name "$6" \
|
|
611
|
-
--metadata bot_user_id "$7" \
|
|
612
|
-
--metadata bot_id "$8" \
|
|
613
|
-
--metadata bot_username "$9"
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
channel_transaction_slack_existing() {
|
|
617
|
-
# channel_transaction_slack_existing PROFILE_HOME RUNTIME_ENV
|
|
618
|
-
# Caller holds the fleet registry lock; the helper acquires the profile lock
|
|
619
|
-
# and reads refs plus role metadata only after both locks are held.
|
|
620
|
-
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
621
|
-
local validator="$ROLE_DIR/.scripts/store-onepassword-secret.py"
|
|
622
|
-
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
623
|
-
|| die "trusted channel transaction helper is missing"
|
|
624
|
-
[[ -f "$validator" && ! -L "$validator" ]] \
|
|
625
|
-
|| die "trusted 1Password reference validator is missing"
|
|
626
|
-
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
627
|
-
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
628
|
-
|| die "trusted PM profile config lock helper is unavailable"
|
|
629
|
-
python3 -I "$helper" \
|
|
630
|
-
--channel slack \
|
|
631
|
-
--profile "$1" \
|
|
632
|
-
--role-yaml "$ROLE_YAML" \
|
|
633
|
-
--registry "$REGISTRY_FILE" \
|
|
634
|
-
--runtime-env "$2" \
|
|
635
|
-
--done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
|
|
636
|
-
--agent-id "$AGENT_ID" \
|
|
637
|
-
--role-dir "$ROLE_DIR" \
|
|
638
|
-
--profile-name "$PROFILE_NAME" \
|
|
639
|
-
--reconcile-existing \
|
|
640
|
-
--reference-validator "$validator"
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
channel_transaction_slack_prepare_unconfigured() {
|
|
644
|
-
# Caller holds the fleet registry lock. The helper disables only a channel
|
|
645
|
-
# whose locked role snapshot is not verified; exit 3 means verified/no-op.
|
|
646
|
-
local helper="$ROLE_DIR/.scripts/channel-transaction.py"
|
|
647
|
-
[[ -f "$helper" && ! -L "$helper" ]] \
|
|
648
|
-
|| die "trusted channel transaction helper is missing"
|
|
649
|
-
[[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
|
|
650
|
-
&& ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
|
|
651
|
-
|| die "trusted PM profile config lock helper is unavailable"
|
|
652
|
-
python3 -I "$helper" \
|
|
653
|
-
--channel slack \
|
|
654
|
-
--profile "$1" \
|
|
655
|
-
--role-yaml "$ROLE_YAML" \
|
|
656
|
-
--registry "$REGISTRY_FILE" \
|
|
657
|
-
--runtime-env "$2" \
|
|
658
|
-
--done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
|
|
659
|
-
--agent-id "$AGENT_ID" \
|
|
660
|
-
--role-dir "$ROLE_DIR" \
|
|
661
|
-
--profile-name "$PROFILE_NAME" \
|
|
662
|
-
--prepare-unconfigured
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
# ─── Distributable config (~/.config/hermes-agent-template/config.toml) ──────
|
|
666
|
-
# Single source of truth for environment-specific defaults so this template can
|
|
667
|
-
# be handed to someone else without editing any script. Ship config.example.toml
|
|
668
|
-
# is copied here on first provision (see .scripts/01-config.sh).
|
|
669
|
-
HERMES_TEMPLATE_CONFIG="${HERMES_TEMPLATE_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/hermes-agent-template/config.toml}"
|
|
670
|
-
export HERMES_TEMPLATE_CONFIG
|
|
671
|
-
|
|
672
|
-
# config_get <dotted.key> [default] — print a value from config.toml (paths are
|
|
673
|
-
# tilde-expanded; arrays are space-joined). Falls back to [default] when the file,
|
|
674
|
-
# python3, or the key is missing. Always exits 0 so it's safe under `set -e`.
|
|
675
|
-
config_get() {
|
|
676
|
-
local key="$1" def="${2:-}"
|
|
677
|
-
if [[ ! -f "$HERMES_TEMPLATE_CONFIG" ]] || ! command -v python3 >/dev/null 2>&1; then
|
|
678
|
-
printf '%s' "$def"; return 0
|
|
679
|
-
fi
|
|
680
|
-
python3 - "$HERMES_TEMPLATE_CONFIG" "$key" "$def" <<'PYEOF' || printf '%s' "$def"
|
|
681
|
-
import sys, os
|
|
682
|
-
try:
|
|
683
|
-
import tomllib
|
|
684
|
-
except ModuleNotFoundError: # python < 3.11
|
|
685
|
-
print(sys.argv[3], end=""); sys.exit(0)
|
|
686
|
-
path, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
687
|
-
try:
|
|
688
|
-
with open(path, "rb") as f:
|
|
689
|
-
cur = tomllib.load(f)
|
|
690
|
-
except Exception:
|
|
691
|
-
print(default, end=""); sys.exit(0)
|
|
692
|
-
for part in key.split("."):
|
|
693
|
-
if isinstance(cur, dict) and part in cur:
|
|
694
|
-
cur = cur[part]
|
|
695
|
-
else:
|
|
696
|
-
print(default, end=""); sys.exit(0)
|
|
697
|
-
if isinstance(cur, list):
|
|
698
|
-
cur = " ".join(str(x) for x in cur)
|
|
699
|
-
else:
|
|
700
|
-
cur = str(cur)
|
|
701
|
-
print(os.path.expanduser(cur), end="")
|
|
702
|
-
PYEOF
|
|
703
|
-
return 0
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
# Re-export role fields into the environment for the rest of the script.
|
|
707
|
-
load_role_env() {
|
|
708
|
-
ROLE=$(yaml_get role)
|
|
709
|
-
REPO=$(yaml_get repo)
|
|
710
|
-
AGENT_ID=$(yaml_get agent_id)
|
|
711
|
-
DISPLAY_NAME=$(yaml_get display_name)
|
|
712
|
-
BOT_HANDLE=$(yaml_get telegram.bot_username)
|
|
713
|
-
PROFILE_NAME=$(yaml_get profile)
|
|
714
|
-
|
|
715
|
-
# Plane workspace: empty in role.yaml -> resolve from config.toml.
|
|
716
|
-
PLANE_WORKSPACE=$(yaml_get plane.workspace)
|
|
717
|
-
[[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(config_get plane.workspace "33god")
|
|
718
|
-
|
|
719
|
-
# Runtime repo: role.yaml stores the bare repo name plus an optional owner.
|
|
720
|
-
# Older manifests stored "owner/name" directly in github_repo; honor both.
|
|
721
|
-
RUNTIME_REPO=$(yaml_get runtime.github_repo)
|
|
722
|
-
if [[ "$RUNTIME_REPO" != */* ]]; then
|
|
723
|
-
local owner; owner=$(yaml_get runtime.github_owner)
|
|
724
|
-
[[ -n "$owner" ]] || owner=$(config_get github.runtime_repo_owner "delorenj")
|
|
725
|
-
RUNTIME_REPO="$owner/$RUNTIME_REPO"
|
|
726
|
-
fi
|
|
727
|
-
|
|
728
|
-
export ROLE REPO AGENT_ID DISPLAY_NAME BOT_HANDLE \
|
|
729
|
-
PLANE_WORKSPACE RUNTIME_REPO PROFILE_NAME
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
# Skip a step if previously completed (idempotent reruns).
|
|
733
|
-
already_done() {
|
|
734
|
-
local marker="$ROLE_DIR/.scripts/.done-$1"
|
|
735
|
-
[[ -f "$marker" ]]
|
|
736
|
-
}
|
|
737
|
-
mark_done() {
|
|
738
|
-
touch "$ROLE_DIR/.scripts/.done-$1"
|
|
739
|
-
}
|
|
740
|
-
clear_done() {
|
|
741
|
-
# Remove only the marker for the explicitly deferred step. This lets a
|
|
742
|
-
# later activation reconcile that phase without disturbing completed,
|
|
743
|
-
# unrelated provisioning state.
|
|
744
|
-
rm -f -- "$ROLE_DIR/.scripts/.done-$1"
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
# The parser protocol, unsafe-family scrub, complete framing validation, and
|
|
748
|
-
# atomic environment apply live in one reusable library shared by provisioners,
|
|
749
|
-
# launchers, and maintenance commands.
|
|
750
|
-
FLEET_ENV_LIBRARY="$ROLE_DIR/.scripts/lib/fleet-env.sh"
|
|
751
|
-
FLEET_ENV_PARSER="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
|
|
752
|
-
if [[ ! -f "$FLEET_ENV_LIBRARY" || -L "$FLEET_ENV_LIBRARY" ]]; then
|
|
753
|
-
builtin printf 'fleet environment loader rejected: trusted library is unavailable\n' >&2
|
|
754
|
-
return 1
|
|
755
|
-
fi
|
|
756
|
-
# shellcheck source=lib/fleet-env.sh
|
|
757
|
-
builtin source "$FLEET_ENV_LIBRARY"
|
|
758
|
-
|
|
759
|
-
# Fleet source-of-truth (shared across all wrappers/provisioners).
|
|
760
|
-
# Every default below resolves as: env var > fleet.env > config.toml > fallback.
|
|
761
|
-
# Invocation authority is not fleet configuration. Capture the caller's board
|
|
762
|
-
# gate before importing fleet.env so that file cannot weaken an MCP/CLI
|
|
763
|
-
# SKIP_PLANE decision or re-enable credentials by assigning SKIP_PLANE=0.
|
|
764
|
-
PJANGLER_INVOCATION_SKIP_PLANE="${SKIP_PLANE:-0}"
|
|
765
|
-
FLEET_ENV="${HERMES_FLEET_ENV:-$(config_get fleet.fleet_env "$HOME/.hermes/fleet.env")}"
|
|
766
|
-
|
|
767
|
-
# A direct CLI caller may not have passed through the MCP parent boundary. The
|
|
768
|
-
# shared loader hardens both before parser startup and again after atomic import.
|
|
769
|
-
if ! load_fleet_environment "$FLEET_ENV" "$FLEET_ENV_PARSER"; then
|
|
770
|
-
return 1
|
|
771
|
-
fi
|
|
772
|
-
SKIP_PLANE="$PJANGLER_INVOCATION_SKIP_PLANE"
|
|
773
|
-
unset PJANGLER_INVOCATION_SKIP_PLANE
|
|
774
|
-
scrub_subprocess_interpreter_injection
|
|
775
|
-
|
|
776
|
-
# fleet.env is allowed to supply provider credentials only for an explicitly
|
|
777
|
-
# board-authorized invocation. A no-board/deferred phase must remove every
|
|
778
|
-
# supported provider alias after sourcing and before any Python, Hermes,
|
|
779
|
-
# systemd, provider, or other child process can inherit it.
|
|
780
|
-
scrub_ticket_provider_authority() {
|
|
781
|
-
local key
|
|
782
|
-
unset PLANE_API_KEY TRELLO_KEY TRELLO_TOKEN LINEAR_API_KEY
|
|
783
|
-
while IFS= read -r key; do
|
|
784
|
-
case "$key" in
|
|
785
|
-
PLANE_*_API_KEY) unset "$key" ;;
|
|
786
|
-
esac
|
|
787
|
-
done < <(compgen -A variable PLANE_)
|
|
788
|
-
}
|
|
789
|
-
if [[ "$SKIP_PLANE" == "1" ]]; then
|
|
790
|
-
scrub_ticket_provider_authority
|
|
791
|
-
fi
|
|
792
|
-
# Identity-bearing chat credentials are never fleet-scoped. Platform wiring
|
|
793
|
-
# steps capture explicit invocation values before sourcing this library and
|
|
794
|
-
# restore only those values afterward; all other provisioning steps stay clean.
|
|
795
|
-
unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
|
|
796
|
-
|
|
797
|
-
# Tools we expect on the host
|
|
798
|
-
HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-$(config_get fleet.hermes_bin "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes")}}"
|
|
799
|
-
HERMES_AGENT_REPO="${HERMES_AGENT_REPO:-${HERMES_FLEET_REPO:-$(config_get fleet.hermes_repo "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1")}}"
|
|
800
|
-
PJANGLER_BIN="${PJANGLER_BIN:-$(config_get fleet.pjangler_bin "pj")}"
|
|
801
|
-
HERMES_RUNTIME_GIT_URL="${HERMES_RUNTIME_GIT_URL:-$(config_get fleet.hermes_git_url 'https://github.com/delorenj/hermes-agent.git')}"
|
|
802
|
-
HERMES_RUNTIME_GIT_REF="${HERMES_RUNTIME_GIT_REF:-$(config_get fleet.hermes_git_ref 'main')}"
|
|
803
|
-
HERMES_RUNTIME_GIT_SHA="${HERMES_RUNTIME_GIT_SHA:-$(config_get fleet.hermes_git_sha '0408fec7a153e6c32c064acd2b8053917f1525f1')}"
|
|
804
|
-
HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get fleet.oauth_file "$HOME/.hermes/auth.json")}}"
|
|
805
|
-
CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
|
|
806
|
-
# Prefer a scaffold vendored into this agent directory; fall back to the configured template path.
|
|
807
|
-
RUNTIME_SCAFFOLD_DIR="${RUNTIME_SCAFFOLD_DIR:-$ROLE_DIR/.runtime-scaffold}"
|
|
808
|
-
if [[ ! -d "$RUNTIME_SCAFFOLD_DIR" ]]; then
|
|
809
|
-
RUNTIME_SCAFFOLD_DIR="${HERMES_TEMPLATE_RUNTIME_SCAFFOLD:-$(config_get fleet.runtime_scaffold_dir "$HOME/code/hermes-agent-template/runtime-scaffold")}"
|
|
810
|
-
fi
|
|
811
|
-
REGISTRY_FILE="${REGISTRY_FILE:-${HERMES_FLEET_REGISTRY_FILE:-$(config_get fleet.registry_file "$HOME/.hermes/agents-registry.yaml")}}"
|
|
812
|
-
|
|
813
|
-
# Cross-process serialization for fleet identity claims and registry updates.
|
|
814
|
-
# The lock file may remain on disk; flock ownership is kernel-scoped, so a
|
|
815
|
-
# crashed provisioner cannot leave a permanently stale lock.
|
|
816
|
-
FLEET_LOCK_FD=""
|
|
817
|
-
fleet_lock_acquire() {
|
|
818
|
-
command -v flock >/dev/null 2>&1 || die "flock is required for safe fleet registry updates"
|
|
819
|
-
local lock_file="${REGISTRY_FILE}.lock"
|
|
820
|
-
mkdir -p "$(dirname "$lock_file")"
|
|
821
|
-
[[ ! -L "$lock_file" ]] || die "refusing fleet lock symlink: $lock_file"
|
|
822
|
-
exec {FLEET_LOCK_FD}>"$lock_file"
|
|
823
|
-
chmod 600 "$lock_file"
|
|
824
|
-
if [[ -n "${PJANGLER_TEST_FLEET_LOCK_BARRIER:-}" ]]; then
|
|
825
|
-
[[ -n "${PYTEST_CURRENT_TEST:-}" ]] \
|
|
826
|
-
|| die "PJANGLER_TEST_FLEET_LOCK_BARRIER is test-only and requires pytest"
|
|
827
|
-
local barrier_file="$PJANGLER_TEST_FLEET_LOCK_BARRIER"
|
|
828
|
-
local barrier_parent="${barrier_file%/*}"
|
|
829
|
-
local barrier_timeout="${PJANGLER_TEST_FLEET_LOCK_BARRIER_TIMEOUT_SECONDS:-15}"
|
|
830
|
-
[[ "$barrier_parent" != "$barrier_file" ]] || barrier_parent="."
|
|
831
|
-
[[ ! -L "$barrier_file" && ! -L "$barrier_parent" && -d "$barrier_parent" ]] \
|
|
832
|
-
|| die "unsafe test fleet-lock barrier path: $barrier_file"
|
|
833
|
-
[[ "$barrier_timeout" =~ ^[1-9][0-9]*$ ]] \
|
|
834
|
-
|| die "PJANGLER_TEST_FLEET_LOCK_BARRIER_TIMEOUT_SECONDS must be a positive integer"
|
|
835
|
-
printf '%s\n' 'fleet-prelock' > "${barrier_file}.ready"
|
|
836
|
-
local barrier_started=$SECONDS
|
|
837
|
-
while [[ ! -f "${barrier_file}.resume" ]]; do
|
|
838
|
-
(( SECONDS - barrier_started < barrier_timeout )) \
|
|
839
|
-
|| die "timed out waiting at test fleet-lock barrier: $barrier_file"
|
|
840
|
-
sleep 0.02
|
|
841
|
-
done
|
|
842
|
-
fi
|
|
843
|
-
if [[ -n "${PJANGLER_TEST_FLEET_LOCK_ATTEMPT:-}" ]]; then
|
|
844
|
-
[[ -n "${PYTEST_CURRENT_TEST:-}" ]] \
|
|
845
|
-
|| die "PJANGLER_TEST_FLEET_LOCK_ATTEMPT is test-only and requires pytest"
|
|
846
|
-
local attempt_file="$PJANGLER_TEST_FLEET_LOCK_ATTEMPT"
|
|
847
|
-
[[ ! -L "$attempt_file" && ! -L "$(dirname "$attempt_file")" \
|
|
848
|
-
&& -d "$(dirname "$attempt_file")" ]] \
|
|
849
|
-
|| die "unsafe test fleet-lock attempt path: $attempt_file"
|
|
850
|
-
printf '%s\n' "$lock_file" > "$attempt_file"
|
|
851
|
-
fi
|
|
852
|
-
flock -w "${FLEET_LOCK_TIMEOUT_SECONDS:-30}" "$FLEET_LOCK_FD" \
|
|
853
|
-
|| die "timed out waiting for fleet registry lock: $lock_file"
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
fleet_lock_release() {
|
|
857
|
-
if [[ "${FLEET_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then
|
|
858
|
-
flock -u "$FLEET_LOCK_FD" 2>/dev/null || true
|
|
859
|
-
exec {FLEET_LOCK_FD}>&-
|
|
860
|
-
fi
|
|
861
|
-
FLEET_LOCK_FD=""
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
# Bloodbank / NATS
|
|
865
|
-
BLOODBANK_NATS_HOST="${BLOODBANK_NATS_HOST:-$(config_get bloodbank.nats_host '127.0.0.1')}"
|
|
866
|
-
BLOODBANK_NATS_PORT="${BLOODBANK_NATS_PORT:-$(config_get bloodbank.nats_port '4222')}"
|
|
867
|
-
BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_dir "$HOME/code/33GOD/bloodbank")}"
|
|
868
|
-
|
|
869
|
-
# Plane
|
|
870
|
-
PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
|
|
871
|
-
if [[ "$SKIP_PLANE" != "1" ]]; then
|
|
872
|
-
PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
|
|
873
|
-
fi
|
|
874
|
-
|
|
875
|
-
export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO PJANGLER_BIN HERMES_RUNTIME_GIT_URL \
|
|
876
|
-
HERMES_RUNTIME_GIT_REF HERMES_RUNTIME_GIT_SHA HERMES_OAUTH_FILE CODEX_HOME \
|
|
877
|
-
RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
|
|
878
|
-
BLOODBANK_NATS_HOST BLOODBANK_NATS_PORT BLOODBANK_COMPOSE_DIR \
|
|
879
|
-
PLANE_BASE SKIP_PLANE
|
|
880
|
-
if [[ "$SKIP_PLANE" != "1" ]]; then
|
|
881
|
-
export PLANE_API_KEY
|
|
882
|
-
fi
|
|
883
|
-
|
|
884
|
-
# systemd --user health check. Accept running/degraded/starting — only one
|
|
885
|
-
# broken unit shouldn't disqualify the rest of the user manager.
|
|
886
|
-
systemd_user_available() {
|
|
887
|
-
command -v systemctl >/dev/null || return 1
|
|
888
|
-
local state; state=$(systemctl --user is-system-running 2>&1)
|
|
889
|
-
[[ "$state" =~ ^(running|degraded|starting|maintenance)$ ]]
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
# Query one systemd user-unit state without conflating every non-zero exit with
|
|
893
|
-
# an inactive/disabled unit. Output is `ok|<state>` only for documented
|
|
894
|
-
# state/exit-code pairs; D-Bus, manager, and arbitrary query failures become
|
|
895
|
-
# `error|<safe summary>` so retirement callers can fail closed.
|
|
896
|
-
systemctl_user_unit_state() {
|
|
897
|
-
local query="$1" unit="$2" output rc first_line
|
|
898
|
-
command -v systemctl >/dev/null 2>&1 \
|
|
899
|
-
|| { printf 'error|systemctl unavailable'; return 0; }
|
|
900
|
-
set +e
|
|
901
|
-
output="$(LC_ALL=C systemctl --user "$query" "$unit" 2>&1)"
|
|
902
|
-
rc=$?
|
|
903
|
-
set -e
|
|
904
|
-
first_line="${output%%$'\n'*}"
|
|
905
|
-
first_line="${first_line//$'\t'/ }"
|
|
906
|
-
first_line="${first_line//|/}"
|
|
907
|
-
case "$query:$rc:$first_line" in
|
|
908
|
-
is-active:0:active|is-active:0:reloading|is-active:0:activating|is-active:0:deactivating)
|
|
909
|
-
printf 'ok|%s' "$first_line" ;;
|
|
910
|
-
is-active:3:inactive|is-active:3:failed)
|
|
911
|
-
printf 'ok|%s' "$first_line" ;;
|
|
912
|
-
is-active:4:inactive)
|
|
913
|
-
printf 'ok|not-found' ;;
|
|
914
|
-
is-enabled:0:enabled|is-enabled:0:enabled-runtime|is-enabled:0:linked|is-enabled:0:linked-runtime|is-enabled:0:alias|is-enabled:0:static|is-enabled:0:indirect|is-enabled:0:generated|is-enabled:0:transient)
|
|
915
|
-
printf 'ok|%s' "$first_line" ;;
|
|
916
|
-
is-enabled:1:disabled|is-enabled:1:masked|is-enabled:1:masked-runtime)
|
|
917
|
-
printf 'ok|%s' "$first_line" ;;
|
|
918
|
-
is-enabled:4:not-found)
|
|
919
|
-
printf 'ok|not-found' ;;
|
|
920
|
-
*)
|
|
921
|
-
[[ -n "$first_line" ]] || first_line="exit $rc with no state"
|
|
922
|
-
printf 'error|%s' "$first_line" ;;
|
|
923
|
-
esac
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
# Read-only live health checks for the unit states lifecycle scripts persist in
|
|
927
|
-
# role.yaml. They deliberately include process result, main exit status,
|
|
928
|
-
# restart count, and activation/substate; `is-active` alone can briefly report
|
|
929
|
-
# success for a launcher that is already on its way to exit 78.
|
|
930
|
-
systemctl_user_show() {
|
|
931
|
-
# systemctl_user_show UNIT PROPERTY...
|
|
932
|
-
local unit="$1" output rc first_line
|
|
933
|
-
shift
|
|
934
|
-
local -a arguments=(--user show "$unit" --no-pager)
|
|
935
|
-
local property
|
|
936
|
-
for property in "$@"; do arguments+=("--property=$property"); done
|
|
937
|
-
set +e
|
|
938
|
-
output="$(LC_ALL=C systemctl "${arguments[@]}" 2>&1)"
|
|
939
|
-
rc=$?
|
|
940
|
-
set -e
|
|
941
|
-
if [[ $rc -ne 0 ]]; then
|
|
942
|
-
first_line="${output%%$'\n'*}"
|
|
943
|
-
first_line="${first_line//$'\t'/ }"
|
|
944
|
-
first_line="${first_line//|/}"
|
|
945
|
-
[[ -n "$first_line" ]] || first_line="exit $rc with no state"
|
|
946
|
-
printf 'error|%s' "$first_line"
|
|
947
|
-
return 0
|
|
948
|
-
fi
|
|
949
|
-
printf 'ok|%s' "$output"
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
systemd_service_health_snapshot() {
|
|
953
|
-
# systemd_service_health_snapshot UNIT running|oneshot
|
|
954
|
-
local unit="$1" kind="$2" active enabled shown payload
|
|
955
|
-
local load_state="" active_state="" sub_state="" result=""
|
|
956
|
-
local exec_status="" restarts="" key value
|
|
957
|
-
active="$(systemctl_user_unit_state is-active "$unit")"
|
|
958
|
-
enabled="$(systemctl_user_unit_state is-enabled "$unit")"
|
|
959
|
-
[[ "$active" == "ok|active" ]] \
|
|
960
|
-
|| { printf 'error|activity=%s' "${active#*|}"; return 0; }
|
|
961
|
-
[[ "$enabled" =~ ^ok\|(enabled|enabled-runtime)$ ]] \
|
|
962
|
-
|| { printf 'error|enablement=%s' "${enabled#*|}"; return 0; }
|
|
963
|
-
shown="$(systemctl_user_show "$unit" LoadState ActiveState SubState Result ExecMainStatus NRestarts)"
|
|
964
|
-
[[ "$shown" == ok\|* ]] || { printf '%s' "$shown"; return 0; }
|
|
965
|
-
payload="${shown#ok|}"
|
|
966
|
-
while IFS='=' read -r key value; do
|
|
967
|
-
case "$key" in
|
|
968
|
-
LoadState) load_state="$value" ;;
|
|
969
|
-
ActiveState) active_state="$value" ;;
|
|
970
|
-
SubState) sub_state="$value" ;;
|
|
971
|
-
Result) result="$value" ;;
|
|
972
|
-
ExecMainStatus) exec_status="$value" ;;
|
|
973
|
-
NRestarts) restarts="$value" ;;
|
|
974
|
-
esac
|
|
975
|
-
done <<< "$payload"
|
|
976
|
-
[[ "$load_state" == loaded && "$active_state" == active \
|
|
977
|
-
&& "$result" == success && "$exec_status" == 0 \
|
|
978
|
-
&& "$restarts" =~ ^[0-9]+$ ]] \
|
|
979
|
-
|| { printf 'error|load=%s active=%s sub=%s result=%s status=%s restarts=%s' \
|
|
980
|
-
"$load_state" "$active_state" "$sub_state" "$result" "$exec_status" "$restarts"; return 0; }
|
|
981
|
-
if [[ "$kind" == running && "$sub_state" != running ]]; then
|
|
982
|
-
printf 'error|substate=%s' "$sub_state"
|
|
983
|
-
return 0
|
|
984
|
-
fi
|
|
985
|
-
printf 'ok|%s' "$restarts"
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
systemd_timer_health_snapshot() {
|
|
989
|
-
# systemd_timer_health_snapshot TIMER HEARTBEAT_SERVICE
|
|
990
|
-
local timer="$1" service="$2" active enabled shown payload
|
|
991
|
-
local load_state="" active_state="" sub_state="" key value
|
|
992
|
-
local svc_shown svc_payload svc_load="" svc_active="" svc_sub=""
|
|
993
|
-
local svc_result="" svc_status="" svc_restarts=""
|
|
994
|
-
local svc_started="" svc_exited=""
|
|
995
|
-
active="$(systemctl_user_unit_state is-active "$timer")"
|
|
996
|
-
enabled="$(systemctl_user_unit_state is-enabled "$timer")"
|
|
997
|
-
[[ "$active" == "ok|active" ]] \
|
|
998
|
-
|| { printf 'error|timer-activity=%s' "${active#*|}"; return 0; }
|
|
999
|
-
[[ "$enabled" =~ ^ok\|(enabled|enabled-runtime)$ ]] \
|
|
1000
|
-
|| { printf 'error|timer-enablement=%s' "${enabled#*|}"; return 0; }
|
|
1001
|
-
shown="$(systemctl_user_show "$timer" LoadState ActiveState SubState)"
|
|
1002
|
-
[[ "$shown" == ok\|* ]] || { printf '%s' "$shown"; return 0; }
|
|
1003
|
-
payload="${shown#ok|}"
|
|
1004
|
-
while IFS='=' read -r key value; do
|
|
1005
|
-
case "$key" in
|
|
1006
|
-
LoadState) load_state="$value" ;;
|
|
1007
|
-
ActiveState) active_state="$value" ;;
|
|
1008
|
-
SubState) sub_state="$value" ;;
|
|
1009
|
-
esac
|
|
1010
|
-
done <<< "$payload"
|
|
1011
|
-
[[ "$load_state" == loaded && "$active_state" == active \
|
|
1012
|
-
&& "$sub_state" =~ ^(waiting|running|elapsed)$ ]] \
|
|
1013
|
-
|| { printf 'error|timer-load=%s active=%s sub=%s' "$load_state" "$active_state" "$sub_state"; return 0; }
|
|
1014
|
-
|
|
1015
|
-
# Inspect the latest oneshot result separately from the timer. An inactive
|
|
1016
|
-
# (dead) service with Result=success/ExecMainStatus=0 is the healthy steady
|
|
1017
|
-
# state between ticks; failed/78 is never accepted.
|
|
1018
|
-
svc_shown="$(systemctl_user_show "$service" LoadState ActiveState SubState Result \
|
|
1019
|
-
ExecMainStatus NRestarts ExecMainStartTimestampMonotonic \
|
|
1020
|
-
ExecMainExitTimestampMonotonic)"
|
|
1021
|
-
[[ "$svc_shown" == ok\|* ]] || { printf '%s' "$svc_shown"; return 0; }
|
|
1022
|
-
svc_payload="${svc_shown#ok|}"
|
|
1023
|
-
while IFS='=' read -r key value; do
|
|
1024
|
-
case "$key" in
|
|
1025
|
-
LoadState) svc_load="$value" ;;
|
|
1026
|
-
ActiveState) svc_active="$value" ;;
|
|
1027
|
-
SubState) svc_sub="$value" ;;
|
|
1028
|
-
Result) svc_result="$value" ;;
|
|
1029
|
-
ExecMainStatus) svc_status="$value" ;;
|
|
1030
|
-
NRestarts) svc_restarts="$value" ;;
|
|
1031
|
-
ExecMainStartTimestampMonotonic) svc_started="$value" ;;
|
|
1032
|
-
ExecMainExitTimestampMonotonic) svc_exited="$value" ;;
|
|
1033
|
-
esac
|
|
1034
|
-
done <<< "$svc_payload"
|
|
1035
|
-
# A oneshot is healthy only after its main process has exited successfully.
|
|
1036
|
-
# systemd initializes Result=success/ExecMainStatus=0 before the first exit,
|
|
1037
|
-
# so accepting activating/start would turn a pre-exit sample into a false
|
|
1038
|
-
# completion claim. Monotonic start/exit timestamps prove a real invocation
|
|
1039
|
-
# completed and also make a new invocation visible to the stability window.
|
|
1040
|
-
[[ "$svc_load" == loaded && "$svc_active" == inactive \
|
|
1041
|
-
&& "$svc_sub" == dead \
|
|
1042
|
-
&& "$svc_result" == success && "$svc_status" == 0 \
|
|
1043
|
-
&& "$svc_restarts" =~ ^[0-9]+$ \
|
|
1044
|
-
&& "$svc_started" =~ ^[1-9][0-9]*$ \
|
|
1045
|
-
&& "$svc_exited" =~ ^[1-9][0-9]*$ \
|
|
1046
|
-
&& "$svc_exited" -ge "$svc_started" ]] \
|
|
1047
|
-
|| { printf 'error|heartbeat-load=%s active=%s sub=%s result=%s status=%s restarts=%s started=%s exited=%s' \
|
|
1048
|
-
"$svc_load" "$svc_active" "$svc_sub" "$svc_result" "$svc_status" \
|
|
1049
|
-
"$svc_restarts" "$svc_started" "$svc_exited"; return 0; }
|
|
1050
|
-
printf 'ok|timer=%s:result=%s:status=%s:restarts=%s:started=%s:exited=%s' \
|
|
1051
|
-
"$sub_state" "$svc_result" "$svc_status" "$svc_restarts" "$svc_started" "$svc_exited"
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
systemd_gateway_deferred_snapshot() {
|
|
1055
|
-
local unit="$1" active enabled
|
|
1056
|
-
active="$(systemctl_user_unit_state is-active "$unit")"
|
|
1057
|
-
enabled="$(systemctl_user_unit_state is-enabled "$unit")"
|
|
1058
|
-
if [[ "$active" == "ok|inactive" \
|
|
1059
|
-
&& "$enabled" =~ ^ok\|(disabled|masked|masked-runtime)$ ]]; then
|
|
1060
|
-
printf 'ok|deferred'
|
|
1061
|
-
else
|
|
1062
|
-
printf 'error|enablement=%s activity=%s' "${enabled#*|}" "${active#*|}"
|
|
1063
|
-
fi
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
systemd_wait_for_stable_health() {
|
|
1067
|
-
# systemd_wait_for_stable_health CHECK_FUNCTION ARGS...
|
|
1068
|
-
local checker="$1"
|
|
1069
|
-
shift
|
|
1070
|
-
local attempts="${SYSTEMD_STABILIZATION_ATTEMPTS:-6}"
|
|
1071
|
-
local required="${SYSTEMD_STABLE_SAMPLES:-3}"
|
|
1072
|
-
local interval="${SYSTEMD_STABILIZATION_INTERVAL_SECONDS:-1}"
|
|
1073
|
-
[[ "$attempts" =~ ^[1-9][0-9]*$ && "$required" =~ ^[1-9][0-9]*$ \
|
|
1074
|
-
&& "$interval" =~ ^[0-9]+([.][0-9]+)?$ ]] \
|
|
1075
|
-
|| { printf 'error|invalid stabilization settings'; return 1; }
|
|
1076
|
-
(( attempts >= required )) \
|
|
1077
|
-
|| { printf 'error|stabilization attempts must cover required samples'; return 1; }
|
|
1078
|
-
local sample="" previous="" last_error="" stable=0 attempt
|
|
1079
|
-
local ever_healthy=0 unstable_after_health=0
|
|
1080
|
-
for ((attempt = 1; attempt <= attempts; attempt++)); do
|
|
1081
|
-
sample="$($checker "$@")"
|
|
1082
|
-
if [[ "$sample" == ok\|* ]]; then
|
|
1083
|
-
if [[ -z "$previous" ]]; then
|
|
1084
|
-
previous="$sample"
|
|
1085
|
-
stable=1
|
|
1086
|
-
elif [[ "$sample" == "$previous" ]]; then
|
|
1087
|
-
stable=$((stable + 1))
|
|
1088
|
-
else
|
|
1089
|
-
(( ever_healthy == 0 )) || unstable_after_health=1
|
|
1090
|
-
previous="$sample"
|
|
1091
|
-
stable=1
|
|
1092
|
-
fi
|
|
1093
|
-
ever_healthy=1
|
|
1094
|
-
else
|
|
1095
|
-
[[ -z "$sample" ]] || last_error="$sample"
|
|
1096
|
-
(( ever_healthy == 0 )) || unstable_after_health=1
|
|
1097
|
-
previous=""
|
|
1098
|
-
stable=0
|
|
1099
|
-
fi
|
|
1100
|
-
if (( attempt < attempts )) && [[ "$interval" != 0 ]]; then
|
|
1101
|
-
sleep "$interval"
|
|
1102
|
-
fi
|
|
1103
|
-
done
|
|
1104
|
-
# Never return early: every configured sample belongs to the declared
|
|
1105
|
-
# observation window. Once a unit looked healthy, a later failure, restart,
|
|
1106
|
-
# or invocation timestamp change makes the whole window unstable.
|
|
1107
|
-
if (( stable >= required && unstable_after_health == 0 )); then
|
|
1108
|
-
printf '%s' "$sample"
|
|
1109
|
-
return 0
|
|
1110
|
-
fi
|
|
1111
|
-
printf '%s' "${last_error:-${sample:-error|no health sample}}"
|
|
1112
|
-
return 1
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
# Resolve project repo path (the repo that holds agents/hermes/<role>/).
|
|
1116
|
-
# Walk up from $ROLE_DIR until we find a git root that isn't us.
|
|
1117
|
-
project_repo_path() {
|
|
1118
|
-
# Structured provisioners know the project root even before a fresh target
|
|
1119
|
-
# receives its own .git directory. Accept only a root that contains this
|
|
1120
|
-
# exact role path; otherwise fail closed instead of walking into a parent
|
|
1121
|
-
# checkout and mutating its manifest.
|
|
1122
|
-
if [[ -n "${PJANGLER_PROJECT_ROOT:-}" ]]; then
|
|
1123
|
-
local explicit role_real
|
|
1124
|
-
explicit="$(cd "$PJANGLER_PROJECT_ROOT" 2>/dev/null && pwd -P)" || return 1
|
|
1125
|
-
role_real="$(cd "$ROLE_DIR" 2>/dev/null && pwd -P)" || return 1
|
|
1126
|
-
case "$role_real" in
|
|
1127
|
-
"$explicit"/agents/hermes/*) printf '%s\n' "$explicit"; return 0 ;;
|
|
1128
|
-
*) return 1 ;;
|
|
1129
|
-
esac
|
|
1130
|
-
fi
|
|
1131
|
-
local d="$ROLE_DIR"
|
|
1132
|
-
[[ -d "$d/.git" || -f "$d/.git" ]] && { echo "$d"; return 0; }
|
|
1133
|
-
for _ in 1 2 3 4 5; do
|
|
1134
|
-
d="$(dirname "$d")"
|
|
1135
|
-
[[ -d "$d/.git" || -f "$d/.git" ]] && { echo "$d"; return 0; }
|
|
1136
|
-
done
|
|
1137
|
-
return 1
|
|
1138
|
-
}
|