@delorenj/pjangler 1.4.2 → 1.4.4
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 +528 -0
- package/contracts/fleet-contract.yaml +513 -0
- package/dist/index.js +9333 -1509
- package/dist/mcp-server.js +6634 -1096
- package/dist/prompt.js +2 -1
- package/package.json +10 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +15 -4
|
@@ -8,6 +8,20 @@ INVOCATION_SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN-}"
|
|
|
8
8
|
INVOCATION_SLACK_APP_TOKEN="${SLACK_APP_TOKEN-}"
|
|
9
9
|
INVOCATION_SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS-}"
|
|
10
10
|
INVOCATION_ENABLE_SLACK="${ENABLE_SLACK-${WIRE_SLACK-}}"
|
|
11
|
+
# Copy invocation-only inputs, then immediately clear their exported form.
|
|
12
|
+
# Resolving/sourcing _lib.sh executes PATH children; none may inherit channel
|
|
13
|
+
# credentials (or accidentally treat the invocation policy as fleet state).
|
|
14
|
+
unset SLACK_BOT_TOKEN SLACK_APP_TOKEN SLACK_ALLOWED_USERS ENABLE_SLACK WIRE_SLACK
|
|
15
|
+
|
|
16
|
+
# Every channel write lands in the host-global profile root that 10-hermes-profile.sh
|
|
17
|
+
# creates. When that step is deferred the root does not exist yet, so there is no
|
|
18
|
+
# honest Slack state to record -- not even the disabled state, which is itself a
|
|
19
|
+
# write into the profile. Defer with it. This guard must precede _lib.sh because that
|
|
20
|
+
# library creates the role log and reads fleet configuration.
|
|
21
|
+
if [[ "${SKIP_HOST_STATE:-0}" == "1" ]]; then
|
|
22
|
+
printf '%s\n' '[31] slack — DEFERRED (SKIP_HOST_STATE=1)' >&2
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
11
25
|
|
|
12
26
|
# shellcheck source=_lib.sh
|
|
13
27
|
source "$(dirname "$0")/_lib.sh"
|
|
@@ -23,29 +37,75 @@ fi
|
|
|
23
37
|
ENABLE_SLACK="$INVOCATION_ENABLE_SLACK"
|
|
24
38
|
unset INVOCATION_SLACK_BOT_TOKEN INVOCATION_SLACK_APP_TOKEN
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
|
|
41
|
+
profile_root_require_real "$PROFILE_HOME"
|
|
42
|
+
RUNTIME="$ROLE_DIR/runtime"
|
|
43
|
+
ENVF="$RUNTIME/.env"
|
|
44
|
+
mkdir -p "$RUNTIME"
|
|
45
|
+
[[ ! -L "$ENVF" ]] || die "refusing to write Slack credentials through symlink: $ENVF"
|
|
32
46
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
slack_reconcile_existing() {
|
|
48
|
+
# Registry first, then the transaction helper's profile lock. The helper
|
|
49
|
+
# snapshots and validates both refs plus role metadata inside this window.
|
|
50
|
+
local rc=0
|
|
51
|
+
fleet_lock_acquire
|
|
52
|
+
trap 'fleet_lock_release' EXIT
|
|
53
|
+
channel_transaction_slack_existing "$PROFILE_HOME" "$ENVF" || rc=$?
|
|
54
|
+
fleet_lock_release
|
|
55
|
+
trap - EXIT
|
|
56
|
+
return "$rc"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
slack_defer_if_unconfigured() {
|
|
60
|
+
# Prepare and, when already verified, reconcile under one registry-lock
|
|
61
|
+
# window. The helpers independently take the profile lock second.
|
|
62
|
+
local prepare_rc=0 rc=0
|
|
63
|
+
fleet_lock_acquire
|
|
64
|
+
trap 'fleet_lock_release' EXIT
|
|
65
|
+
channel_transaction_slack_prepare_unconfigured \
|
|
66
|
+
"$PROFILE_HOME" "$ENVF" || prepare_rc=$?
|
|
67
|
+
case "$prepare_rc" in
|
|
68
|
+
0)
|
|
69
|
+
fleet_lock_release
|
|
70
|
+
trap - EXIT
|
|
71
|
+
return 2
|
|
72
|
+
;;
|
|
73
|
+
3)
|
|
74
|
+
channel_transaction_slack_existing "$PROFILE_HOME" "$ENVF" || rc=$?
|
|
75
|
+
;;
|
|
76
|
+
*)
|
|
77
|
+
die "Slack deferred-state preparation transaction failed"
|
|
78
|
+
;;
|
|
79
|
+
esac
|
|
80
|
+
case "$rc" in
|
|
81
|
+
0)
|
|
82
|
+
fleet_lock_release
|
|
83
|
+
trap - EXIT
|
|
84
|
+
return 0
|
|
85
|
+
;;
|
|
86
|
+
75)
|
|
87
|
+
die "Slack 1Password reference validation is temporarily unavailable; preserved existing verified wiring for retry"
|
|
88
|
+
;;
|
|
89
|
+
*)
|
|
90
|
+
die "Slack verified wiring reconciliation transaction failed"
|
|
91
|
+
;;
|
|
92
|
+
esac
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
slack_prepare_for_attempt() {
|
|
96
|
+
# A new profile becomes explicitly disabled before remote verification. A
|
|
97
|
+
# verified profile is a no-op so a failed rotation preserves active wiring.
|
|
98
|
+
local rc=0
|
|
99
|
+
fleet_lock_acquire
|
|
100
|
+
trap 'fleet_lock_release' EXIT
|
|
101
|
+
channel_transaction_slack_prepare_unconfigured \
|
|
102
|
+
"$PROFILE_HOME" "$ENVF" || rc=$?
|
|
103
|
+
fleet_lock_release
|
|
104
|
+
trap - EXIT
|
|
105
|
+
case "$rc" in
|
|
106
|
+
0|3) return 0 ;;
|
|
107
|
+
*) die "Slack deferred-state preparation transaction failed" ;;
|
|
108
|
+
esac
|
|
49
109
|
}
|
|
50
110
|
|
|
51
111
|
truthy() {
|
|
@@ -55,14 +115,48 @@ truthy() {
|
|
|
55
115
|
esac
|
|
56
116
|
}
|
|
57
117
|
|
|
118
|
+
# Preserve and reconcile durable wiring across transient 1Password outages.
|
|
119
|
+
# An explicit token is a rotation request. With no tokens, the complete current
|
|
120
|
+
# ref/identity snapshot is taken only after registry -> profile locks are held.
|
|
121
|
+
if [[ -z "${SLACK_BOT_TOKEN:-}" && -z "${SLACK_APP_TOKEN:-}" ]]; then
|
|
122
|
+
slack_reference_rc=0
|
|
123
|
+
slack_reconcile_existing || slack_reference_rc=$?
|
|
124
|
+
case "$slack_reference_rc" in
|
|
125
|
+
0)
|
|
126
|
+
log "[31] slack — existing verified 1Password wiring reconciled"
|
|
127
|
+
exit 0
|
|
128
|
+
;;
|
|
129
|
+
2) ;;
|
|
130
|
+
75)
|
|
131
|
+
die "Slack 1Password reference validation is temporarily unavailable; preserved existing verified wiring for retry"
|
|
132
|
+
;;
|
|
133
|
+
*)
|
|
134
|
+
die "Slack verified wiring reconciliation transaction failed"
|
|
135
|
+
;;
|
|
136
|
+
esac
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# Establish explicit disabled state for a first-time credential attempt. The
|
|
140
|
+
# registry -> profile lock recheck preserves any already-verified wiring and
|
|
141
|
+
# prevents this preparation from racing a concurrent rotation.
|
|
142
|
+
if [[ -n "${SLACK_BOT_TOKEN:-}" || -n "${SLACK_APP_TOKEN:-}" ]]; then
|
|
143
|
+
slack_prepare_for_attempt
|
|
144
|
+
fi
|
|
145
|
+
|
|
58
146
|
if [[ "${SKIP_SLACK:-0}" == "1" ]]; then
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
147
|
+
slack_defer_rc=0
|
|
148
|
+
slack_defer_if_unconfigured || slack_defer_rc=$?
|
|
149
|
+
if [[ $slack_defer_rc -eq 2 ]]; then
|
|
150
|
+
log "[31] slack — DEFERRED (SKIP_SLACK=1; no verified 1Password reference pair)"
|
|
151
|
+
else
|
|
152
|
+
log "[31] slack — existing verified 1Password wiring reconciled"
|
|
153
|
+
fi
|
|
62
154
|
exit 0
|
|
63
155
|
fi
|
|
64
156
|
|
|
65
|
-
already_done 31-slack
|
|
157
|
+
if already_done 31-slack; then
|
|
158
|
+
log "[31] existing completion marker preserved while Slack is reconciled"
|
|
159
|
+
fi
|
|
66
160
|
|
|
67
161
|
have_bot=0
|
|
68
162
|
have_app=0
|
|
@@ -70,8 +164,13 @@ have_app=0
|
|
|
70
164
|
[[ -n "$SLACK_APP_TOKEN" ]] && have_app=1
|
|
71
165
|
|
|
72
166
|
if ! truthy "${ENABLE_SLACK:-0}" && (( ! have_bot && ! have_app )); then
|
|
73
|
-
|
|
74
|
-
|
|
167
|
+
slack_defer_rc=0
|
|
168
|
+
slack_defer_if_unconfigured || slack_defer_rc=$?
|
|
169
|
+
if [[ $slack_defer_rc -eq 2 ]]; then
|
|
170
|
+
log "[31] slack — deferred (opt in with ENABLE_SLACK=1 or supply both Slack tokens)"
|
|
171
|
+
else
|
|
172
|
+
log "[31] slack — existing verified 1Password wiring reconciled"
|
|
173
|
+
fi
|
|
75
174
|
exit 0
|
|
76
175
|
fi
|
|
77
176
|
|
|
@@ -92,8 +191,8 @@ fi
|
|
|
92
191
|
|
|
93
192
|
[[ -n "$SLACK_BOT_TOKEN" && -n "$SLACK_APP_TOKEN" ]] \
|
|
94
193
|
|| die "Slack provisioning requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN"
|
|
95
|
-
[[ "$SLACK_BOT_TOKEN"
|
|
96
|
-
[[ "$SLACK_APP_TOKEN"
|
|
194
|
+
[[ "$SLACK_BOT_TOKEN" =~ ^xoxb-[A-Za-z0-9-]+$ ]] || die "SLACK_BOT_TOKEN must be a Bot User OAuth token (xoxb-...)"
|
|
195
|
+
[[ "$SLACK_APP_TOKEN" =~ ^xapp-[A-Za-z0-9-]+$ ]] || die "SLACK_APP_TOKEN must be an App-Level Socket Mode token (xapp-...)"
|
|
97
196
|
|
|
98
197
|
SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS//[[:space:]]/}"
|
|
99
198
|
if [[ -n "$SLACK_ALLOWED_USERS" && "$SLACK_ALLOWED_USERS" != "*" \
|
|
@@ -101,16 +200,14 @@ if [[ -n "$SLACK_ALLOWED_USERS" && "$SLACK_ALLOWED_USERS" != "*" \
|
|
|
101
200
|
die "SLACK_ALLOWED_USERS must be '*' or comma-separated Slack member IDs"
|
|
102
201
|
fi
|
|
103
202
|
|
|
104
|
-
RUNTIME="$ROLE_DIR/runtime"
|
|
105
|
-
ENVF="$RUNTIME/.env"
|
|
106
|
-
mkdir -p "$RUNTIME"
|
|
107
|
-
[[ ! -L "$ENVF" ]] || die "refusing to write Slack credentials through symlink: $ENVF"
|
|
108
|
-
|
|
109
203
|
log "[31] verifying Slack bot identity via auth.test"
|
|
110
|
-
auth_response
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
204
|
+
auth_response="$({
|
|
205
|
+
printf '%s\n' 'url = "https://slack.com/api/auth.test"'
|
|
206
|
+
printf 'header = "Authorization: Bearer %s"\n' "$SLACK_BOT_TOKEN"
|
|
207
|
+
printf '%s\n' 'header = "Content-Type: application/x-www-form-urlencoded"'
|
|
208
|
+
printf '%s\n' 'request = "POST"' 'fail' 'silent' 'show-error' \
|
|
209
|
+
'connect-timeout = 10' 'max-time = 20' 'max-filesize = 1048576'
|
|
210
|
+
} | curl --config -)" \
|
|
114
211
|
|| die "Slack auth.test request failed"
|
|
115
212
|
|
|
116
213
|
identity=$(printf '%s' "$auth_response" | python3 -c '
|
|
@@ -124,27 +221,97 @@ if not data.get("ok"):
|
|
|
124
221
|
safe = "".join(c for c in error if c.isalnum() or c in "_-.")[:80]
|
|
125
222
|
raise SystemExit("Slack auth.test rejected the bot token ({})".format(safe or "unknown_error"))
|
|
126
223
|
values = [data.get(k, "") for k in ("team_id", "team", "user_id", "bot_id", "user")]
|
|
127
|
-
if not values[0] or not values[2]:
|
|
224
|
+
if not values[0] or not values[2] or not values[3]:
|
|
128
225
|
raise SystemExit("Slack auth.test response omitted required identity fields")
|
|
129
226
|
print("\t".join(str(v).replace("\t", " ").replace("\n", " ") for v in values))
|
|
130
227
|
') || die "Slack bot identity verification failed"
|
|
131
228
|
IFS=$'\t' read -r slack_team_id slack_team_name slack_bot_user_id slack_bot_id slack_bot_username <<< "$identity"
|
|
132
229
|
|
|
230
|
+
# auth.test proves the bot installation/workspace, while bots.info provides the
|
|
231
|
+
# authoritative Slack app identity for that bot. Correlation requires the
|
|
232
|
+
# app's users:read scope; missing scope is a hard, truthful deferred outcome.
|
|
233
|
+
log "[31] resolving Slack bot app identity via bots.info"
|
|
234
|
+
bot_info_response="$({
|
|
235
|
+
printf '%s\n' 'url = "https://slack.com/api/bots.info"'
|
|
236
|
+
printf 'header = "Authorization: Bearer %s"\n' "$SLACK_BOT_TOKEN"
|
|
237
|
+
printf '%s\n' 'header = "Content-Type: application/x-www-form-urlencoded"'
|
|
238
|
+
printf 'data = "bot=%s"\n' "$slack_bot_id"
|
|
239
|
+
printf '%s\n' 'request = "POST"' 'fail' 'silent' 'show-error' \
|
|
240
|
+
'connect-timeout = 10' 'max-time = 20' 'max-filesize = 1048576'
|
|
241
|
+
} | curl --config -)" \
|
|
242
|
+
|| die "Slack bots.info request failed; Slack remains deferred"
|
|
243
|
+
slack_bot_app_id="$(printf '%s' "$bot_info_response" | python3 -c '
|
|
244
|
+
import json, re, sys
|
|
245
|
+
try:
|
|
246
|
+
data = json.load(sys.stdin)
|
|
247
|
+
except Exception:
|
|
248
|
+
raise SystemExit("Slack bots.info returned an invalid response")
|
|
249
|
+
if not isinstance(data, dict) or not data.get("ok"):
|
|
250
|
+
error = str(data.get("error") if isinstance(data, dict) else "unknown_error")
|
|
251
|
+
if error == "missing_scope":
|
|
252
|
+
raise SystemExit(
|
|
253
|
+
"Slack bots.info requires users:read; add that bot scope, reinstall the app, and rerun"
|
|
254
|
+
)
|
|
255
|
+
safe = "".join(c for c in error if c.isalnum() or c in "_-." )[:80]
|
|
256
|
+
raise SystemExit("Slack bots.info rejected identity lookup ({})".format(safe or "unknown_error"))
|
|
257
|
+
bot = data.get("bot")
|
|
258
|
+
app_id = bot.get("app_id") if isinstance(bot, dict) else None
|
|
259
|
+
if not isinstance(app_id, str) or not re.fullmatch(r"[A-Z][A-Z0-9]+", app_id):
|
|
260
|
+
raise SystemExit("Slack bots.info response omitted a valid bot.app_id")
|
|
261
|
+
print(app_id)
|
|
262
|
+
')" || die "Slack bot app identity verification failed; Slack remains deferred"
|
|
263
|
+
|
|
264
|
+
# Socket Mode requires a separately authenticated app-level token. A valid bot
|
|
265
|
+
# token says nothing about the xapp credential, so prove that credential can
|
|
266
|
+
# open a connection before claiming ownership or persisting either secret. The
|
|
267
|
+
# token and Slack's returned WebSocket URL stay on anonymous pipes: neither is
|
|
268
|
+
# exposed through curl argv, child environments, logs, or durable files.
|
|
269
|
+
log "[31] verifying Slack Socket Mode app token via apps.connections.open"
|
|
270
|
+
slack_socket_app_id="$({
|
|
271
|
+
printf '%s\n' 'url = "https://slack.com/api/apps.connections.open"'
|
|
272
|
+
printf 'header = "Authorization: Bearer %s"\n' "$SLACK_APP_TOKEN"
|
|
273
|
+
printf '%s\n' 'header = "Content-Type: application/x-www-form-urlencoded"'
|
|
274
|
+
printf '%s\n' 'request = "POST"' 'fail' 'silent' 'show-error' \
|
|
275
|
+
'connect-timeout = 10' 'max-time = 20' 'max-filesize = 1048576'
|
|
276
|
+
} | curl --config - | python3 -c '
|
|
277
|
+
import json, re, sys
|
|
278
|
+
from urllib.parse import parse_qs, urlparse
|
|
279
|
+
try:
|
|
280
|
+
data = json.load(sys.stdin)
|
|
281
|
+
except Exception:
|
|
282
|
+
raise SystemExit("Slack apps.connections.open returned an invalid response")
|
|
283
|
+
if not isinstance(data, dict) or not data.get("ok"):
|
|
284
|
+
error = str(data.get("error") if isinstance(data, dict) else "unknown_error")
|
|
285
|
+
safe = "".join(c for c in error if c.isalnum() or c in "_-." )[:80]
|
|
286
|
+
raise SystemExit("Slack apps.connections.open rejected the app token ({})".format(safe or "unknown_error"))
|
|
287
|
+
url = data.get("url")
|
|
288
|
+
if not isinstance(url, str):
|
|
289
|
+
raise SystemExit("Slack apps.connections.open response omitted a Socket Mode URL")
|
|
290
|
+
parsed = urlparse(url)
|
|
291
|
+
if parsed.scheme != "wss" or not parsed.netloc:
|
|
292
|
+
raise SystemExit("Slack apps.connections.open response omitted a valid Socket Mode URL")
|
|
293
|
+
app_ids = parse_qs(parsed.query, keep_blank_values=True).get("app_id", [])
|
|
294
|
+
if len(app_ids) != 1 or not re.fullmatch(r"[A-Z][A-Z0-9]+", app_ids[0]):
|
|
295
|
+
raise SystemExit("Slack Socket Mode URL omitted a valid app_id identity")
|
|
296
|
+
print(app_ids[0])
|
|
297
|
+
')" || die "Slack Socket Mode app-token verification failed; Slack remains deferred"
|
|
298
|
+
|
|
299
|
+
[[ "$slack_bot_app_id" == "$slack_socket_app_id" ]] \
|
|
300
|
+
|| die "Slack bot and Socket Mode tokens belong to different apps; Slack remains deferred"
|
|
301
|
+
|
|
133
302
|
# Reject credential reuse, token rotation onto an identity owned by another
|
|
134
303
|
# agent, and credentials parked in shared env files. The scan, durable identity
|
|
135
304
|
# claim, and profile credential write share one fleet-wide flock.
|
|
136
|
-
export SLACK_BOT_TOKEN SLACK_APP_TOKEN SLACK_ALLOWED_USERS
|
|
137
305
|
fleet_lock_acquire
|
|
138
306
|
trap 'fleet_lock_release' EXIT
|
|
139
|
-
python3
|
|
307
|
+
python3 /dev/fd/3 "$REGISTRY_FILE" "$FLEET_ENV" "$ENVF" "$AGENT_ID" \
|
|
140
308
|
"$slack_team_id" "$slack_bot_user_id" "$slack_bot_id" \
|
|
141
|
-
"$ROLE_DIR" "$PROFILE_NAME" "$slack_team_name" "$slack_bot_username"
|
|
142
|
-
|
|
309
|
+
"$ROLE_DIR" "$PROFILE_NAME" "$slack_team_name" "$slack_bot_username" \
|
|
310
|
+
3<<'PYEOF' <<<"${SLACK_BOT_TOKEN}"$'\n'"${SLACK_APP_TOKEN}"
|
|
143
311
|
import os
|
|
144
312
|
import pathlib
|
|
145
313
|
import re
|
|
146
314
|
import sys
|
|
147
|
-
import tempfile
|
|
148
315
|
try:
|
|
149
316
|
import yaml # type: ignore
|
|
150
317
|
except ImportError:
|
|
@@ -163,8 +330,15 @@ except ImportError:
|
|
|
163
330
|
team_name,
|
|
164
331
|
bot_username,
|
|
165
332
|
) = sys.argv[1:]
|
|
166
|
-
|
|
167
|
-
|
|
333
|
+
credential_lines = sys.stdin.read().splitlines()
|
|
334
|
+
if len(credential_lines) != 2 or not all(credential_lines):
|
|
335
|
+
raise SystemExit("Slack ownership scan received an invalid credential pair")
|
|
336
|
+
bot_token, app_token = credential_lines
|
|
337
|
+
for metadata_path in (pathlib.Path("/proc/self/cmdline"), pathlib.Path("/proc/self/environ")):
|
|
338
|
+
if metadata_path.is_file():
|
|
339
|
+
metadata = metadata_path.read_bytes()
|
|
340
|
+
if any(secret.encode() in metadata for secret in (bot_token, app_token)):
|
|
341
|
+
raise SystemExit("Slack ownership scan detected credential exposure in process metadata")
|
|
168
342
|
target = pathlib.Path(target_path).resolve(strict=False)
|
|
169
343
|
|
|
170
344
|
def env_values(path):
|
|
@@ -230,127 +404,49 @@ for owner, path in owners:
|
|
|
230
404
|
if values.get("SLACK_APP_TOKEN") == app_token:
|
|
231
405
|
raise SystemExit(f"Slack app token is already assigned to {owner}")
|
|
232
406
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
raise SystemExit(f"registry entry for {agent_id} is not a mapping")
|
|
236
|
-
claim["role_dir"] = role_dir
|
|
237
|
-
claim["profile_name"] = profile_name
|
|
238
|
-
claim["slack"] = {
|
|
239
|
-
"provisioning_status": "verified",
|
|
240
|
-
"team_id": team_id,
|
|
241
|
-
"team_name": team_name,
|
|
242
|
-
"bot_user_id": user_id,
|
|
243
|
-
"bot_id": bot_id,
|
|
244
|
-
"bot_username": bot_username,
|
|
245
|
-
}
|
|
246
|
-
registry.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
-
rendered = yaml.safe_dump(data, sort_keys=False)
|
|
248
|
-
|
|
249
|
-
def fsync_parent(target):
|
|
250
|
-
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
251
|
-
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
252
|
-
try:
|
|
253
|
-
directory_fd = os.open(target.parent, flags)
|
|
254
|
-
except OSError as exc:
|
|
255
|
-
if exc.errno in unsupported:
|
|
256
|
-
return
|
|
257
|
-
raise
|
|
258
|
-
try:
|
|
259
|
-
try:
|
|
260
|
-
os.fsync(directory_fd)
|
|
261
|
-
except OSError as exc:
|
|
262
|
-
if exc.errno not in unsupported:
|
|
263
|
-
raise
|
|
264
|
-
finally:
|
|
265
|
-
os.close(directory_fd)
|
|
266
|
-
|
|
267
|
-
fd, temporary = tempfile.mkstemp(prefix=f".{registry.name}.slack-", dir=registry.parent)
|
|
268
|
-
try:
|
|
269
|
-
os.fchmod(fd, 0o600)
|
|
270
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
271
|
-
handle.write(rendered)
|
|
272
|
-
handle.flush()
|
|
273
|
-
os.fsync(handle.fileno())
|
|
274
|
-
os.replace(temporary, registry)
|
|
275
|
-
os.chmod(registry, 0o600)
|
|
276
|
-
fsync_parent(registry)
|
|
277
|
-
except BaseException:
|
|
278
|
-
try:
|
|
279
|
-
os.unlink(temporary)
|
|
280
|
-
except FileNotFoundError:
|
|
281
|
-
pass
|
|
282
|
-
raise
|
|
407
|
+
# Read-only by design. The channel transaction repeats the identity conflict
|
|
408
|
+
# check under this same lock immediately before its recoverable registry write.
|
|
283
409
|
PYEOF
|
|
284
410
|
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"
|
|
304
|
-
)}
|
|
305
|
-
text = text.rstrip("\n")
|
|
306
|
-
if text:
|
|
307
|
-
text += "\n"
|
|
308
|
-
text += "".join(f"{key}={json.dumps(value)}\n" for key, value in values.items())
|
|
309
|
-
|
|
310
|
-
def fsync_parent(target):
|
|
311
|
-
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
312
|
-
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
313
|
-
try:
|
|
314
|
-
directory_fd = os.open(target.parent, flags)
|
|
315
|
-
except OSError as exc:
|
|
316
|
-
if exc.errno in unsupported:
|
|
317
|
-
return
|
|
318
|
-
raise
|
|
319
|
-
try:
|
|
320
|
-
try:
|
|
321
|
-
os.fsync(directory_fd)
|
|
322
|
-
except OSError as exc:
|
|
323
|
-
if exc.errno not in unsupported:
|
|
324
|
-
raise
|
|
325
|
-
finally:
|
|
326
|
-
os.close(directory_fd)
|
|
327
|
-
|
|
328
|
-
fd, temporary = tempfile.mkstemp(prefix=".env.slack-", dir=path.parent)
|
|
329
|
-
try:
|
|
330
|
-
os.fchmod(fd, 0o600)
|
|
331
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
332
|
-
handle.write(text)
|
|
333
|
-
handle.flush()
|
|
334
|
-
os.fsync(handle.fileno())
|
|
335
|
-
os.replace(temporary, path)
|
|
336
|
-
os.chmod(path, 0o600)
|
|
337
|
-
fsync_parent(path)
|
|
338
|
-
except BaseException:
|
|
339
|
-
try:
|
|
340
|
-
os.unlink(temporary)
|
|
341
|
-
except FileNotFoundError:
|
|
342
|
-
pass
|
|
343
|
-
raise
|
|
344
|
-
PYEOF
|
|
411
|
+
# Stage both credentials in one new 1Password item, verify both fields, then
|
|
412
|
+
# switch both profile refs with one atomic delta update. A failed rotation
|
|
413
|
+
# leaves the previously active pair untouched.
|
|
414
|
+
ONEPASSWORD_ITEM_PREFIX="${HERMES_ONEPASSWORD_ITEM_PREFIX:-$(config_get fleet.onepassword_item_prefix 'hermes-agent')}"
|
|
415
|
+
slack_stage="$(stage_onepassword_secret_pair \
|
|
416
|
+
"${ONEPASSWORD_ITEM_PREFIX}-${AGENT_ID}-slack-credentials" \
|
|
417
|
+
slack_bot_token "$SLACK_BOT_TOKEN" \
|
|
418
|
+
slack_app_token "$SLACK_APP_TOKEN")" \
|
|
419
|
+
|| die "Slack credential pair could not be staged and verified in 1Password"
|
|
420
|
+
if [[ "$slack_stage" != *$'\n'*$'\n'* ]]; then
|
|
421
|
+
die "Slack credential-pair storage returned an invalid reference set"
|
|
422
|
+
fi
|
|
423
|
+
slack_staged_item_id="${slack_stage%%$'\n'*}"
|
|
424
|
+
slack_pair_references="${slack_stage#*$'\n'}"
|
|
425
|
+
slack_bot_reference="${slack_pair_references%%$'\n'*}"
|
|
426
|
+
slack_app_reference="${slack_pair_references#*$'\n'}"
|
|
427
|
+
[[ "$slack_app_reference" != *$'\n'* ]] \
|
|
428
|
+
|| die "Slack credential-pair storage returned too many references"
|
|
345
429
|
unset SLACK_BOT_TOKEN SLACK_APP_TOKEN
|
|
346
430
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
431
|
+
slack_transaction_exit() {
|
|
432
|
+
local rc=$?
|
|
433
|
+
trap - EXIT
|
|
434
|
+
if [[ -n "${slack_staged_item_id:-}" ]]; then
|
|
435
|
+
delete_staged_onepassword_item "$slack_staged_item_id" >/dev/null 2>&1 \
|
|
436
|
+
|| warn " staged Slack item cleanup failed; archive manually by immutable item id"
|
|
437
|
+
fi
|
|
438
|
+
fleet_lock_release
|
|
439
|
+
exit "$rc"
|
|
440
|
+
}
|
|
441
|
+
trap slack_transaction_exit EXIT
|
|
442
|
+
|
|
443
|
+
channel_transaction_slack \
|
|
444
|
+
"$PROFILE_HOME" "$ENVF" "$slack_bot_reference" "$slack_app_reference" \
|
|
445
|
+
"$slack_team_id" "$slack_team_name" "$slack_bot_user_id" \
|
|
446
|
+
"$slack_bot_id" "$slack_bot_username" "$SLACK_ALLOWED_USERS" \
|
|
447
|
+
|| die "Slack local credential transaction failed"
|
|
448
|
+
|
|
449
|
+
slack_staged_item_id=""
|
|
354
450
|
|
|
355
451
|
fleet_lock_release
|
|
356
452
|
trap - EXIT
|
|
@@ -359,4 +455,3 @@ if [[ -z "$SLACK_ALLOWED_USERS" ]]; then
|
|
|
359
455
|
warn " Slack is wired but denies all inbound users until SLACK_ALLOWED_USERS is set"
|
|
360
456
|
fi
|
|
361
457
|
log " verified Slack bot $slack_bot_username in $slack_team_name (profile-local credentials)"
|
|
362
|
-
mark_done 31-slack
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# Bind this agent to its Plane board — WITHOUT inventing a board identifier.
|
|
3
|
+
#
|
|
4
|
+
# The identifier is Plane's to assign. This step never mints one. It resolves
|
|
5
|
+
# the board through providers/plane.sh and persists ONLY the identifier Plane
|
|
6
|
+
# reports back; if Plane reports none, it dies loudly rather than writing a
|
|
7
|
+
# guess into role.yaml.
|
|
8
|
+
#
|
|
9
|
+
# Resolution order:
|
|
10
|
+
# 1. The repo-root .project.json binding (the SOT) — read back live.
|
|
11
|
+
# 2. Otherwise a repo-named board lookup, falling back to creation.
|
|
12
|
+
#
|
|
13
|
+
# Proposing an identifier for a BRAND-NEW board is legitimate, but it may only
|
|
14
|
+
# come from explicit configuration (PLANE_IDENTIFIER, or an already-recorded
|
|
15
|
+
# role.yaml plane.identifier). Full new-board provisioning with the
|
|
16
|
+
# .project.json transaction lives in 42-ticket-provider.sh.
|
|
4
17
|
# shellcheck source=_lib.sh
|
|
5
18
|
source "$(dirname "$0")/_lib.sh"
|
|
6
19
|
load_role_env
|
|
@@ -10,42 +23,38 @@ already_done 40-plane && { log "[40] plane already set up — skipping"; exit 0;
|
|
|
10
23
|
|
|
11
24
|
[[ -n "$PLANE_API_KEY" ]] || { warn "[40] PLANE_API_KEY not set; skipping. set PLANE_33GOD_API_KEY and re-run ./.scripts/40-plane.sh"; exit 0; }
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ident = '$IDENT'
|
|
31
|
-
for p in d.get('results', []):
|
|
32
|
-
if (p.get('identifier') or '').upper() == ident:
|
|
33
|
-
print(p['id']); break")
|
|
34
|
-
|
|
35
|
-
if [[ -n "$EXISTING" ]]; then
|
|
36
|
-
log " plane project $IDENT already exists (id=$EXISTING) — reusing"
|
|
37
|
-
PROJECT_ID="$EXISTING"
|
|
26
|
+
PLANE_ADAPTER="$(dirname "$0")/providers/plane.sh"
|
|
27
|
+
[[ -x "$PLANE_ADAPTER" ]] || die "[40] missing Plane adapter: $PLANE_ADAPTER"
|
|
28
|
+
|
|
29
|
+
# The board belongs to the REPO, not to this role. display_name carries the
|
|
30
|
+
# role suffix ("Holocene PM") and must never become the board name — that is
|
|
31
|
+
# how role-suffixed duplicate boards get created. Matches 42-ticket-provider.sh.
|
|
32
|
+
NAME="$(printf '%s' "$REPO" | tr '_-' ' ' | python3 -c 'import sys; print(" ".join(w[:1].upper()+w[1:] for w in sys.stdin.read().split()))')"
|
|
33
|
+
|
|
34
|
+
# Identifier PROPOSAL, used only in a create request and never persisted as
|
|
35
|
+
# though it were confirmed. Explicit configuration only — never derived from
|
|
36
|
+
# the repo or role strings.
|
|
37
|
+
PROPOSED_IDENT="${PLANE_IDENTIFIER:-$(yaml_get plane.identifier)}"
|
|
38
|
+
|
|
39
|
+
# 1. Prefer the recorded .project.json binding and read it back live.
|
|
40
|
+
OUT="$("$PLANE_ADAPTER" resolve 2>/dev/null || true)"
|
|
41
|
+
if [[ -n "$OUT" ]]; then
|
|
42
|
+
log "[40] resolving plane board from the .project.json binding"
|
|
38
43
|
else
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-d "$(python3 -c "import json,sys; print(json.dumps({'name': sys.argv[1], 'identifier': sys.argv[2], 'description': sys.argv[3]}))" \
|
|
43
|
-
"$NAME" "$IDENT" "Hermes agent board for $AGENT_ID")")
|
|
44
|
-
PROJECT_ID=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id') or '')")
|
|
45
|
-
[[ -n "$PROJECT_ID" ]] || die "plane create failed: $RESP"
|
|
46
|
-
log " plane project created id=$PROJECT_ID"
|
|
44
|
+
log "[40] no bound board — resolving/creating repo board '$NAME'${PROPOSED_IDENT:+ (proposed identifier $PROPOSED_IDENT)}"
|
|
45
|
+
OUT="$("$PLANE_ADAPTER" create_board "$NAME" "$PROPOSED_IDENT" "Hermes agent board for $AGENT_ID")" \
|
|
46
|
+
|| die "[40] plane board resolution failed for '$NAME'"
|
|
47
47
|
fi
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
PROJECT_ID="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("board_id","") or "")')"
|
|
50
|
+
LIVE_IDENTIFIER="$(printf '%s' "$OUT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("identifier","") or "")')"
|
|
51
|
+
|
|
52
|
+
[[ -n "$PROJECT_ID" ]] || die "[40] plane returned no board id: $OUT"
|
|
53
|
+
[[ -n "$LIVE_IDENTIFIER" ]] \
|
|
54
|
+
|| die "[40] plane returned no authoritative identifier — refusing to persist a guess: $OUT"
|
|
55
|
+
|
|
56
|
+
log " plane board id=$PROJECT_ID identifier=$LIVE_IDENTIFIER (authoritative, read back from Plane)"
|
|
57
|
+
|
|
58
|
+
yaml_set plane.identifier "$LIVE_IDENTIFIER"
|
|
50
59
|
echo "$PROJECT_ID" > "$ROLE_DIR/.scripts/.plane-project-id"
|
|
51
60
|
mark_done 40-plane
|