@delorenj/pjangler 1.3.0 → 1.4.2

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 (43) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +92 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +11307 -2809
  16. package/dist/mcp-server.js +9637 -2094
  17. package/dist/prompt.js +404 -0
  18. package/package.json +8 -5
  19. package/templates/commonproject/copier.yml +19 -5
  20. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  21. package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
  22. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  23. package/templates/commonproject/template/mise.toml.jinja +12 -6
  24. package/templates/hermes-agent/copier.yml +8 -11
  25. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  26. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  27. package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
  28. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  29. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
  30. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  31. package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
  32. package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
  33. package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
  34. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  35. package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
  36. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  37. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
  38. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  39. package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
  40. package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
  41. package/templates/hermes-agent/template/hermes.jinja +20 -8
  42. package/templates/hermes-agent/template/momo.jinja +177 -0
  43. package/templates/hermes-agent/template/role.yaml.jinja +19 -19
@@ -46,19 +46,63 @@ PYEOF
46
46
  # Apply a sed substitution to role.yaml in-place. Used to record IDs after
47
47
  # external provisioning steps return them.
48
48
  yaml_set() {
49
- # yaml_set KEY VALUE (only updates the first match; key must already exist)
49
+ # yaml_set KEY VALUE (supports flat and one-level nested keys)
50
50
  local key="$1" val="$2"
51
51
  python3 - "$ROLE_YAML" "$key" "$val" <<'PYEOF'
52
- import sys, re, pathlib
52
+ import json, pathlib, re, sys
53
+
53
54
  path, key, val = sys.argv[1:4]
54
- p = pathlib.Path(path); text = p.read_text()
55
- # Match `<indent><key>:<...>` and rewrite the value (last leaf only).
56
- leaf = key.split(".")[-1]
57
- new = re.sub(rf'(?m)^(\s*{re.escape(leaf)}:\s*)("?)[^"\n]*("?)\s*$',
58
- lambda m: f'{m.group(1)}"{val}"', text, count=1)
59
- if new == text:
60
- sys.exit(f"yaml_set: leaf '{leaf}' not found in {path}")
61
- p.write_text(new)
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")
62
106
  PYEOF
63
107
  }
64
108
 
@@ -144,12 +188,50 @@ clear_done() {
144
188
  rm -f -- "$ROLE_DIR/.scripts/.done-$1"
145
189
  }
146
190
 
191
+ # The parser protocol, unsafe-family scrub, complete framing validation, and
192
+ # atomic environment apply live in one reusable library shared by provisioners,
193
+ # launchers, and maintenance commands.
194
+ FLEET_ENV_LIBRARY="$ROLE_DIR/.scripts/lib/fleet-env.sh"
195
+ FLEET_ENV_PARSER="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
196
+ if [[ ! -f "$FLEET_ENV_LIBRARY" || -L "$FLEET_ENV_LIBRARY" ]]; then
197
+ builtin printf 'fleet environment loader rejected: trusted library is unavailable\n' >&2
198
+ return 1
199
+ fi
200
+ # shellcheck source=lib/fleet-env.sh
201
+ builtin source "$FLEET_ENV_LIBRARY"
202
+
147
203
  # Fleet source-of-truth (shared across all wrappers/provisioners).
148
204
  # Every default below resolves as: env var > fleet.env > config.toml > fallback.
205
+ # Invocation authority is not fleet configuration. Capture the caller's board
206
+ # gate before importing fleet.env so that file cannot weaken an MCP/CLI
207
+ # SKIP_PLANE decision or re-enable credentials by assigning SKIP_PLANE=0.
208
+ PJANGLER_INVOCATION_SKIP_PLANE="${SKIP_PLANE:-0}"
149
209
  FLEET_ENV="${HERMES_FLEET_ENV:-$(config_get fleet.fleet_env "$HOME/.hermes/fleet.env")}"
150
- if [[ -f "$FLEET_ENV" ]]; then
151
- # shellcheck disable=SC1090
152
- source "$FLEET_ENV"
210
+
211
+ # A direct CLI caller may not have passed through the MCP parent boundary. The
212
+ # shared loader hardens both before parser startup and again after atomic import.
213
+ if ! load_fleet_environment "$FLEET_ENV" "$FLEET_ENV_PARSER"; then
214
+ return 1
215
+ fi
216
+ SKIP_PLANE="$PJANGLER_INVOCATION_SKIP_PLANE"
217
+ unset PJANGLER_INVOCATION_SKIP_PLANE
218
+ scrub_subprocess_interpreter_injection
219
+
220
+ # fleet.env is allowed to supply provider credentials only for an explicitly
221
+ # board-authorized invocation. A no-board/deferred phase must remove every
222
+ # supported provider alias after sourcing and before any Python, Hermes,
223
+ # systemd, provider, or other child process can inherit it.
224
+ scrub_ticket_provider_authority() {
225
+ local key
226
+ unset PLANE_API_KEY TRELLO_KEY TRELLO_TOKEN LINEAR_API_KEY
227
+ while IFS= read -r key; do
228
+ case "$key" in
229
+ PLANE_*_API_KEY) unset "$key" ;;
230
+ esac
231
+ done < <(compgen -A variable PLANE_)
232
+ }
233
+ if [[ "$SKIP_PLANE" == "1" ]]; then
234
+ scrub_ticket_provider_authority
153
235
  fi
154
236
  # Identity-bearing chat credentials are never fleet-scoped. Platform wiring
155
237
  # steps capture explicit invocation values before sourcing this library and
@@ -190,7 +272,7 @@ fleet_lock_acquire() {
190
272
  fleet_lock_release() {
191
273
  if [[ "${FLEET_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then
192
274
  flock -u "$FLEET_LOCK_FD" 2>/dev/null || true
193
- eval "exec ${FLEET_LOCK_FD}>&-"
275
+ exec {FLEET_LOCK_FD}>&-
194
276
  fi
195
277
  FLEET_LOCK_FD=""
196
278
  }
@@ -202,13 +284,18 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
202
284
 
203
285
  # Plane
204
286
  PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
205
- PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
287
+ if [[ "$SKIP_PLANE" != "1" ]]; then
288
+ PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
289
+ fi
206
290
 
207
291
  export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO PJANGLER_BIN HERMES_RUNTIME_GIT_URL \
208
292
  HERMES_RUNTIME_GIT_REF HERMES_RUNTIME_GIT_SHA HERMES_OAUTH_FILE CODEX_HOME \
209
293
  RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
210
294
  BLOODBANK_NATS_HOST BLOODBANK_NATS_PORT BLOODBANK_COMPOSE_DIR \
211
- PLANE_BASE PLANE_API_KEY
295
+ PLANE_BASE SKIP_PLANE
296
+ if [[ "$SKIP_PLANE" != "1" ]]; then
297
+ export PLANE_API_KEY
298
+ fi
212
299
 
213
300
  # systemd --user health check. Accept running/degraded/starting — only one
214
301
  # broken unit shouldn't disqualify the rest of the user manager.
@@ -255,6 +342,19 @@ systemctl_user_unit_state() {
255
342
  # Resolve project repo path (the repo that holds agents/hermes/<role>/).
256
343
  # Walk up from $ROLE_DIR until we find a git root that isn't us.
257
344
  project_repo_path() {
345
+ # Structured provisioners know the project root even before a fresh target
346
+ # receives its own .git directory. Accept only a root that contains this
347
+ # exact role path; otherwise fail closed instead of walking into a parent
348
+ # checkout and mutating its manifest.
349
+ if [[ -n "${PJANGLER_PROJECT_ROOT:-}" ]]; then
350
+ local explicit role_real
351
+ explicit="$(cd "$PJANGLER_PROJECT_ROOT" 2>/dev/null && pwd -P)" || return 1
352
+ role_real="$(cd "$ROLE_DIR" 2>/dev/null && pwd -P)" || return 1
353
+ case "$role_real" in
354
+ "$explicit"/agents/hermes/*) printf '%s\n' "$explicit"; return 0 ;;
355
+ *) return 1 ;;
356
+ esac
357
+ fi
258
358
  local d="$ROLE_DIR"
259
359
  [[ -d "$d/.git" || -f "$d/.git" ]] && { echo "$d"; return 0; }
260
360
  for _ in 1 2 3 4 5; do
@@ -38,6 +38,23 @@ secret_scan_ok() {
38
38
  return 0
39
39
  }
40
40
 
41
+ # A checkpoint on a DETACHED HEAD belongs to no branch: `git push origin HEAD`
42
+ # cannot resolve a destination, so every commit stays local forever. One runtime
43
+ # reached 232 orphaned commits this way before anyone noticed.
44
+ BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
45
+ if [ "$BRANCH" = "HEAD" ]; then
46
+ printf '[checkpoint] ABORT: %s is on a DETACHED HEAD — refusing to create commits that can never be pushed.\n' "$RUNTIME_DIR" >&2
47
+ printf '[checkpoint] Fix: git -C %s switch -c main && git -C %s push -u origin main\n' "$RUNTIME_DIR" "$RUNTIME_DIR" >&2
48
+ exit 4
49
+ fi
50
+
51
+ # Report an existing backlog BEFORE adding to it, so a broken push is visible on
52
+ # the very next tick instead of compounding silently for months.
53
+ backlog="$(git rev-list --count "origin/$BRANCH..HEAD" 2>/dev/null || echo 0)"
54
+ if [ "$backlog" -ge 10 ]; then
55
+ printf '[checkpoint] WARNING: %s already has %s UNPUSHED commit(s) on %s.\n' "$RUNTIME_DIR" "$backlog" "$BRANCH" >&2
56
+ fi
57
+
41
58
  git add -A
42
59
  if git diff --cached --quiet; then
43
60
  exit 0
@@ -51,4 +68,15 @@ if ! secret_scan_ok; then
51
68
  fi
52
69
 
53
70
  git -c commit.gpgsign=false commit -m "checkpoint $(date -Iseconds)" >/dev/null
54
- git push origin HEAD 2>&1 | tail -1 || true
71
+
72
+ # The push MUST be observable. This line used to be
73
+ # git push origin HEAD 2>&1 | tail -1 || true
74
+ # where `|| true` swallowed every failure — diverged branch, auth, network —
75
+ # and the pipe masked git's exit status on top of it. Run hourly, that turns a
76
+ # broken push into an invisible, unbounded pile of local-only commits.
77
+ if ! git push origin "$BRANCH" >/dev/null 2>&1; then
78
+ ahead="$(git rev-list --count "origin/$BRANCH..HEAD" 2>/dev/null || echo '?')"
79
+ printf '[checkpoint] PUSH FAILED for %s (%s commit(s) exist ONLY on this disk).\n' "$RUNTIME_DIR" "$ahead" >&2
80
+ printf '[checkpoint] Diagnose: git -C %s push origin %s\n' "$RUNTIME_DIR" "$BRANCH" >&2
81
+ exit 5
82
+ fi
@@ -20,12 +20,24 @@ PROMPT_FILE="$ROLE_DIR/.scripts/sentinel.prompt.md"
20
20
  STATE_FILE="$RUNTIME/continuous-ticket-sentinel-state.json"
21
21
  LOCK_FILE="$RUNTIME/continuous-ticket-sentinel.lock"
22
22
  ROLE_YAML="$ROLE_DIR/role.yaml"
23
+ FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
24
+ FLEET_ENV_LIBRARY="$ROLE_DIR/.scripts/lib/fleet-env.sh"
25
+ FLEET_ENV_PARSER="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
23
26
  LOG_FILE="$RUNTIME/logs/heartbeat.log"
24
27
  CHECKPOINT_BIN="$ROLE_DIR/.scripts/checkpoint.sh"
25
28
  CHECKPOINT_STAMP="$RUNTIME/.last-checkpoint"
26
29
 
30
+ if [[ ! -f "$FLEET_ENV_LIBRARY" || -L "$FLEET_ENV_LIBRARY" \
31
+ || ! -f "$FLEET_ENV_PARSER" || -L "$FLEET_ENV_PARSER" ]]; then
32
+ echo "heartbeat: trusted fleet environment loader unavailable" >&2
33
+ exit 1
34
+ fi
35
+ # shellcheck source=lib/fleet-env.sh
36
+ builtin source "$FLEET_ENV_LIBRARY"
37
+ load_fleet_environment "$FLEET_ENV" "$FLEET_ENV_PARSER"
38
+
27
39
  # Hermes binary: explicit env > ~/.config/hermes-agent/hermes-bin > PATH.
28
- HERMES_BIN="${HERMES_BIN:-}"
40
+ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
29
41
  if [[ -z "$HERMES_BIN" ]]; then
30
42
  if [[ -r "$HOME/.config/hermes-agent/hermes-bin" ]]; then
31
43
  HERMES_BIN="$(cat "$HOME/.config/hermes-agent/hermes-bin")"
@@ -0,0 +1,202 @@
1
+ # shellcheck shell=bash
2
+ # Shared data-only fleet.env loader. This file is trusted template code; the
3
+ # fleet.env it reads is configuration data and is never evaluated by a shell.
4
+
5
+ if [[ "${__PJANGLER_FLEET_ENV_LIBRARY_LOADED:-0}" == "1" ]]; then
6
+ return 0
7
+ fi
8
+ __PJANGLER_FLEET_ENV_LIBRARY_LOADED=1
9
+
10
+ # fleet.env is shared configuration, not authority to inject code into Python,
11
+ # shell, Node, or dynamic-loader children. PATH remains intact so explicitly
12
+ # configured Hermes/PJangler/provider tools still resolve.
13
+ subprocess_injection_key_is_unsafe() {
14
+ local key="$1" loader_key="$1"
15
+ case "$key" in
16
+ PYTHONPATH|PYTHONHOME|PYTHONSTARTUP|PYTHONUSERBASE|\
17
+ BASH_ENV|ENV|BASHOPTS|SHELLOPTS|BASH_COMPAT|BASH_LOADABLES_PATH|\
18
+ BASH_XTRACEFD|PROMPT_COMMAND|PS0|PS1|PS2|PS3|PS4|\
19
+ NODE_OPTIONS|NODE_PATH|GLIBC_TUNABLES|BASH_FUNC_*|DYLD_*)
20
+ return 0
21
+ ;;
22
+ esac
23
+
24
+ # GNU and multilib loaders consume the same control stem with an optional
25
+ # _32/_64 ABI suffix. Do not delete unrelated application keys such as
26
+ # LD_SDK_KEY merely because they share the LD_ prefix.
27
+ case "$loader_key" in
28
+ LD_*_32|LD_*_64) loader_key="${loader_key%_*}" ;;
29
+ esac
30
+ case "$loader_key" in
31
+ LD_ASSUME_KERNEL|LD_AUDIT|LD_BIND_NOT|LD_BIND_NOW|LD_DEBUG|\
32
+ LD_DEBUG_OUTPUT|LD_DYNAMIC_WEAK|LD_HWCAP_MASK|LD_LIBRARY_PATH|\
33
+ LD_ORIGIN_PATH|LD_POINTER_GUARD|LD_PREFER_MAP_32BIT_EXEC|LD_PRELOAD|\
34
+ LD_PROFILE|LD_PROFILE_OUTPUT|LD_SHOW_AUXV|LD_TRACE_LOADED_OBJECTS|\
35
+ LD_TRACE_PRELINKING|LD_USE_LOAD_BIAS|LD_VERBOSE|LD_WARN)
36
+ return 0
37
+ ;;
38
+ esac
39
+ return 1
40
+ }
41
+
42
+ scrub_subprocess_interpreter_injection() {
43
+ local key declaration function_name
44
+
45
+ builtin set +x +v
46
+ while IFS= read -r key; do
47
+ if subprocess_injection_key_is_unsafe "$key"; then
48
+ case "$key" in
49
+ BASHOPTS|SHELLOPTS|BASH_XTRACEFD)
50
+ builtin export -n "$key" 2>/dev/null || true
51
+ ;;
52
+ *)
53
+ builtin unset -v "$key" 2>/dev/null || true
54
+ ;;
55
+ esac
56
+ fi
57
+ done < <(builtin compgen -A variable)
58
+
59
+ # Imported exported functions are live functions rather than ordinary
60
+ # BASH_FUNC_* variables. Remove the whole class before selecting any child.
61
+ while IFS= read -r declaration; do
62
+ function_name="${declaration##* }"
63
+ [[ -n "$function_name" ]] && builtin unset -f -- "$function_name"
64
+ done < <(builtin declare -Fx)
65
+
66
+ builtin export PYTHONNOUSERSITE=1 PYTHONSAFEPATH=1
67
+ }
68
+
69
+ # Apply staged records as one transaction. Existing variables always win. New
70
+ # variables are removed in reverse order if any assignment/export fails.
71
+ apply_fleet_environment_records() {
72
+ local -n __pjangler_fleet_keys_ref="$1"
73
+ local -n __pjangler_fleet_values_ref="$2"
74
+ local __pjangler_fleet_index __pjangler_fleet_key
75
+ local -a __pjangler_fleet_applied=()
76
+
77
+ if (( ${#__pjangler_fleet_keys_ref[@]} != ${#__pjangler_fleet_values_ref[@]} )); then
78
+ builtin printf 'fleet environment apply failed: mismatched staging arrays\n' >&2
79
+ return 1
80
+ fi
81
+
82
+ for ((__pjangler_fleet_index = 0; __pjangler_fleet_index < ${#__pjangler_fleet_keys_ref[@]}; __pjangler_fleet_index++)); do
83
+ __pjangler_fleet_key="${__pjangler_fleet_keys_ref[__pjangler_fleet_index]}"
84
+ if [[ ! "$__pjangler_fleet_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
85
+ || subprocess_injection_key_is_unsafe "$__pjangler_fleet_key"; then
86
+ builtin printf 'fleet environment apply failed: rejected variable name\n' >&2
87
+ for ((__pjangler_fleet_index = ${#__pjangler_fleet_applied[@]} - 1; __pjangler_fleet_index >= 0; __pjangler_fleet_index--)); do
88
+ builtin unset -v "${__pjangler_fleet_applied[__pjangler_fleet_index]}" 2>/dev/null || true
89
+ done
90
+ return 1
91
+ fi
92
+
93
+ if builtin declare -p "$__pjangler_fleet_key" >/dev/null 2>&1; then
94
+ continue
95
+ fi
96
+ if ! builtin printf -v "$__pjangler_fleet_key" '%s' \
97
+ "${__pjangler_fleet_values_ref[__pjangler_fleet_index]}"; then
98
+ builtin printf 'fleet environment apply failed: assignment rejected\n' >&2
99
+ for ((__pjangler_fleet_index = ${#__pjangler_fleet_applied[@]} - 1; __pjangler_fleet_index >= 0; __pjangler_fleet_index--)); do
100
+ builtin unset -v "${__pjangler_fleet_applied[__pjangler_fleet_index]}" 2>/dev/null || true
101
+ done
102
+ return 1
103
+ fi
104
+ __pjangler_fleet_applied+=("$__pjangler_fleet_key")
105
+ if ! builtin export "$__pjangler_fleet_key"; then
106
+ builtin printf 'fleet environment apply failed: export rejected\n' >&2
107
+ for ((__pjangler_fleet_index = ${#__pjangler_fleet_applied[@]} - 1; __pjangler_fleet_index >= 0; __pjangler_fleet_index--)); do
108
+ builtin unset -v "${__pjangler_fleet_applied[__pjangler_fleet_index]}" 2>/dev/null || true
109
+ done
110
+ return 1
111
+ fi
112
+ done
113
+ }
114
+
115
+ # Consume a parser child through a complete, double-NUL-terminated protocol.
116
+ # Nothing reaches this shell until child status and the entire frame validate.
117
+ import_fleet_environment_stream() {
118
+ local __pjangler_fleet_fd="$1" __pjangler_fleet_pid="$2"
119
+ local __pjangler_fleet_count __pjangler_fleet_index
120
+ local __pjangler_fleet_record __pjangler_fleet_key __pjangler_fleet_value
121
+ local -a __pjangler_fleet_records=() __pjangler_fleet_keys=() __pjangler_fleet_values=()
122
+ local -A __pjangler_fleet_seen=()
123
+
124
+ builtin mapfile -d '' -t -u "$__pjangler_fleet_fd" __pjangler_fleet_records || true
125
+ exec {__pjangler_fleet_fd}<&-
126
+ if ! builtin wait "$__pjangler_fleet_pid"; then
127
+ builtin printf 'fleet environment frame rejected: parser child failed\n' >&2
128
+ return 1
129
+ fi
130
+
131
+ __pjangler_fleet_count="${#__pjangler_fleet_records[@]}"
132
+ if (( __pjangler_fleet_count < 3 )) \
133
+ || [[ "${__pjangler_fleet_records[0]}" != "PJANGLER_FLEET_ENV_V1" ]] \
134
+ || [[ "${__pjangler_fleet_records[__pjangler_fleet_count - 2]}" != "PJANGLER_FLEET_ENV_END" ]] \
135
+ || [[ -n "${__pjangler_fleet_records[__pjangler_fleet_count - 1]}" ]]; then
136
+ builtin printf 'fleet environment frame rejected: incomplete framing\n' >&2
137
+ return 1
138
+ fi
139
+
140
+ for ((__pjangler_fleet_index = 1; __pjangler_fleet_index < __pjangler_fleet_count - 2; __pjangler_fleet_index++)); do
141
+ __pjangler_fleet_record="${__pjangler_fleet_records[__pjangler_fleet_index]}"
142
+ if [[ "$__pjangler_fleet_record" != *=* ]]; then
143
+ builtin printf 'fleet environment frame rejected: malformed record\n' >&2
144
+ return 1
145
+ fi
146
+ __pjangler_fleet_key="${__pjangler_fleet_record%%=*}"
147
+ __pjangler_fleet_value="${__pjangler_fleet_record#*=}"
148
+ if [[ ! "$__pjangler_fleet_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
149
+ || subprocess_injection_key_is_unsafe "$__pjangler_fleet_key"; then
150
+ builtin printf 'fleet environment frame rejected: unsafe variable\n' >&2
151
+ return 1
152
+ fi
153
+ if [[ -n "${__pjangler_fleet_seen[$__pjangler_fleet_key]+present}" ]]; then
154
+ builtin printf 'fleet environment frame rejected: duplicate variable\n' >&2
155
+ return 1
156
+ fi
157
+ __pjangler_fleet_seen["$__pjangler_fleet_key"]=1
158
+ __pjangler_fleet_keys+=("$__pjangler_fleet_key")
159
+ __pjangler_fleet_values+=("$__pjangler_fleet_value")
160
+ done
161
+
162
+ apply_fleet_environment_records __pjangler_fleet_keys __pjangler_fleet_values
163
+ }
164
+
165
+ # fleet.env is parsed by an isolated interpreter. The parser path is explicit
166
+ # so every caller binds to its colocated, attested copy rather than PATH state.
167
+ import_fleet_environment() {
168
+ local __pjangler_fleet_path="$1" __pjangler_fleet_parser="$2"
169
+ local __pjangler_fleet_python __pjangler_fleet_fd __pjangler_fleet_pid
170
+
171
+ if [[ "$__pjangler_fleet_parser" != /* ]] \
172
+ || [[ ! -f "$__pjangler_fleet_parser" || -L "$__pjangler_fleet_parser" ]]; then
173
+ builtin printf 'fleet environment frame rejected: trusted parser is unavailable\n' >&2
174
+ return 1
175
+ fi
176
+ __pjangler_fleet_python="$(builtin type -P python3)" || {
177
+ builtin printf 'fleet environment frame rejected: python3 is unavailable\n' >&2
178
+ return 1
179
+ }
180
+
181
+ exec {__pjangler_fleet_fd}< <(
182
+ "$__pjangler_fleet_python" -I "$__pjangler_fleet_parser" "$__pjangler_fleet_path"
183
+ )
184
+ __pjangler_fleet_pid=$!
185
+ import_fleet_environment_stream "$__pjangler_fleet_fd" "$__pjangler_fleet_pid"
186
+ }
187
+
188
+ # Public loader used by rendered provisioners, launchers, and maintenance
189
+ # scripts. Missing configuration remains optional; every existing path,
190
+ # including symlinks and non-regular files, must pass the parser's file checks.
191
+ load_fleet_environment() {
192
+ local __pjangler_fleet_path="$1" __pjangler_fleet_parser="$2"
193
+ scrub_subprocess_interpreter_injection
194
+ if [[ ! -e "$__pjangler_fleet_path" && ! -L "$__pjangler_fleet_path" ]]; then
195
+ return 0
196
+ fi
197
+ if ! import_fleet_environment "$__pjangler_fleet_path" "$__pjangler_fleet_parser"; then
198
+ builtin printf 'fleet environment import failed: %s\n' "$__pjangler_fleet_path" >&2
199
+ return 1
200
+ fi
201
+ scrub_subprocess_interpreter_injection
202
+ }