@delorenj/pjangler 1.4.2 → 1.4.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.
Files changed (39) hide show
  1. package/dist/index.js +3336 -1359
  2. package/dist/index.js.map +7 -0
  3. package/dist/mcp-server.js +1520 -838
  4. package/dist/mcp-server.js.map +7 -0
  5. package/dist/prompt.js +2 -1
  6. package/dist/prompt.js.map +7 -0
  7. package/package.json +8 -4
  8. package/templates/hermes-agent/copier.yml +16 -3
  9. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  10. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  11. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  12. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  13. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  14. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  15. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  16. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  17. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  18. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  19. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  20. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  21. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  22. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  23. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  25. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  26. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
  27. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  28. package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
  29. package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
  30. package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
  32. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +11 -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
- slack_yaml_update() {
27
- python3 - "$ROLE_YAML" "$@" <<'PYEOF'
28
- import json
29
- import pathlib
30
- import re
31
- import sys
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
- path = pathlib.Path(sys.argv[1])
34
- updates = dict(zip(sys.argv[2::2], sys.argv[3::2]))
35
- text = path.read_text(encoding="utf-8")
36
- match = re.search(r"(?ms)^slack:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text)
37
- if not match:
38
- raise SystemExit(f"slack metadata block missing from {path}")
39
- body = match.group("body")
40
- for key, value in updates.items():
41
- replacement = f" {key}: {json.dumps(value)}"
42
- body, count = re.subn(
43
- rf"(?m)^\s+{re.escape(key)}:\s*.*$", lambda _: replacement, body, count=1
44
- )
45
- if count != 1:
46
- raise SystemExit(f"Slack metadata key {key!r} missing from {path}")
47
- path.write_text(text[: match.start("body")] + body + text[match.end("body") :], encoding="utf-8")
48
- PYEOF
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
- slack_yaml_update provisioning_status disabled
60
- log "[31] slack — DISABLED (SKIP_SLACK=1)"
61
- mark_done 31-slack
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 && { log "[31] slack already wired — skipping"; exit 0; }
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
- slack_yaml_update provisioning_status deferred
74
- log "[31] slack — deferred (opt in with ENABLE_SLACK=1 or supply both Slack tokens)"
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" == xoxb-* ]] || die "SLACK_BOT_TOKEN must be a Bot User OAuth token (xoxb-...)"
96
- [[ "$SLACK_APP_TOKEN" == xapp-* ]] || die "SLACK_APP_TOKEN must be an App-Level Socket Mode token (xapp-...)"
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=$(curl -fsS \
111
- -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
112
- -H "Content-Type: application/x-www-form-urlencoded" \
113
- -X POST "https://slack.com/api/auth.test") \
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 - "$REGISTRY_FILE" "$FLEET_ENV" "$ENVF" "$AGENT_ID" \
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" <<'PYEOF'
142
- import errno
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
- bot_token = os.environ["SLACK_BOT_TOKEN"]
167
- app_token = os.environ["SLACK_APP_TOKEN"]
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
- claim = agents.setdefault(agent_id, {})
234
- if not isinstance(claim, dict):
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
- # Atomically replace only the Slack fields in the profile-local runtime file.
286
- # Secrets stay out of argv and are never written to root .env or fleet.env.
287
- python3 - "$ENVF" <<'PYEOF'
288
- import errno
289
- import json
290
- import os
291
- import pathlib
292
- import re
293
- import tempfile
294
-
295
- path = pathlib.Path(__import__("sys").argv[1])
296
- if path.is_symlink():
297
- raise SystemExit(f"refusing to write Slack credentials through symlink: {path}")
298
- path.parent.mkdir(parents=True, exist_ok=True)
299
- text = path.read_text(encoding="utf-8") if path.exists() else ""
300
- for key in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"):
301
- text = re.sub(rf"(?m)^\s*(?:export\s+)?#?\s*{key}\s*=.*(?:\n|$)", "", text)
302
- values = {key: os.environ.get(key, "") for key in (
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
- slack_yaml_update \
348
- provisioning_status verified \
349
- team_id "$slack_team_id" \
350
- team_name "$slack_team_name" \
351
- bot_user_id "$slack_bot_user_id" \
352
- bot_id "$slack_bot_id" \
353
- bot_username "$slack_bot_username"
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
- # Create a Plane project in the configured workspace (one project per agent).
3
- # Workspace + base URL come from role.yaml / config.toml (see _lib.sh).
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
- # Build the 5-char identifier: <repo[0..2]><role[0..1]> uppercased
14
- RAW=$(printf '%s%s' "${REPO:0:3}" "${ROLE:0:2}" | tr -cd '[:alnum:]' | tr '[:lower:]' '[:upper:]')
15
- while (( ${#RAW} < 3 )); do RAW="${RAW}X"; done
16
- IDENT="${RAW:0:5}"
17
-
18
- # Project name = display_name (already smart-cased: "Bloodbank PM", "Hermes-Agent Dev").
19
- # Plane forbids hyphens in names substitute spaces.
20
- NAME="${DISPLAY_NAME//-/ }"
21
-
22
- log "[40] creating plane project '$NAME' [$IDENT]"
23
-
24
- # Check for existing project with this identifier
25
- EXISTING=$(curl -sS "$PLANE_BASE/api/v1/workspaces/$PLANE_WORKSPACE/projects/?per_page=200" \
26
- -H "X-API-Key: $PLANE_API_KEY" \
27
- | python3 -c "
28
- import sys, json
29
- d = json.load(sys.stdin)
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
- RESP=$(curl -sS -X POST "$PLANE_BASE/api/v1/workspaces/$PLANE_WORKSPACE/projects/" \
40
- -H "X-API-Key: $PLANE_API_KEY" \
41
- -H "Content-Type: application/json" \
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
- yaml_set plane.identifier "$IDENT"
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