@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.
- package/.mise/scripts/link-agentfiles.sh +38 -5
- package/README.md +92 -0
- package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
- package/dist/assets/project-notebook-skill/SKILL.md +64 -0
- package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
- package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
- package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
- package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
- package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
- package/dist/index.js +11307 -2809
- package/dist/mcp-server.js +9637 -2094
- package/dist/prompt.js +404 -0
- package/package.json +8 -5
- package/templates/commonproject/copier.yml +19 -5
- package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
- package/templates/commonproject/template/mise.toml.jinja +12 -6
- package/templates/hermes-agent/copier.yml +8 -11
- package/templates/hermes-agent/template/.gitignore.jinja +1 -0
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
- package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
- package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
- package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
- package/templates/hermes-agent/template/hermes.jinja +20 -8
- package/templates/hermes-agent/template/momo.jinja +177 -0
- 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 (
|
|
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
|
|
52
|
+
import json, pathlib, re, sys
|
|
53
|
+
|
|
53
54
|
path, key, val = sys.argv[1:4]
|
|
54
|
-
p = pathlib.Path(path)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
sys.exit(f"yaml_set:
|
|
61
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|