@delorenj/pjangler 1.3.0 → 1.3.7

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 (26) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +4510 -2485
  3. package/dist/mcp-server.js +4166 -1959
  4. package/dist/prompt.js +404 -0
  5. package/package.json +7 -5
  6. package/templates/commonproject/copier.yml +6 -1
  7. package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
  8. package/templates/hermes-agent/copier.yml +8 -11
  9. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  10. package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
  11. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  12. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
  13. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  14. package/templates/hermes-agent/template/.scripts/70-systemd.sh +73 -43
  15. package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
  16. package/templates/hermes-agent/template/.scripts/_lib.sh +62 -6
  17. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  18. package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
  19. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  20. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
  21. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  22. package/templates/hermes-agent/template/.scripts/providers/plane.sh +1 -1
  23. package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
  24. package/templates/hermes-agent/template/hermes.jinja +20 -8
  25. package/templates/hermes-agent/template/momo.jinja +177 -0
  26. package/templates/hermes-agent/template/role.yaml.jinja +19 -19
@@ -0,0 +1,734 @@
1
+ #!/usr/bin/env python3
2
+ """Parse the supported fleet.env assignment grammar without executing it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ import errno
8
+ import hashlib
9
+ import re
10
+ import os
11
+ import stat
12
+ import sys
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import Mapping, NamedTuple
16
+
17
+
18
+ HEADER = b"PJANGLER_FLEET_ENV_V1"
19
+ FOOTER = b"PJANGLER_FLEET_ENV_END"
20
+ NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
21
+ ASSIGNMENT = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=")
22
+ LEGACY_EXPANSION_NAME = "HERMES_FLEET_HOME"
23
+ LEGACY_EXPANSION = re.compile(
24
+ r"\$(?:\{HERMES_FLEET_HOME\}|HERMES_FLEET_HOME(?![A-Za-z0-9_]))"
25
+ )
26
+ UNICODE_ERROR = "fleet environment parse error: input and values must be valid UTF-8 Unicode"
27
+ INITIAL_DOCUMENT = (
28
+ "# Hermes fleet source of truth.\n"
29
+ "# All generated wrappers and provisioning scripts read this file.\n"
30
+ )
31
+
32
+
33
+ class FleetEnvParseError(ValueError):
34
+ def __init__(self, line: int, message: str) -> None:
35
+ super().__init__(f"line {line}: {message}")
36
+
37
+
38
+ class FleetEnvRecoveryError(OSError):
39
+ """A concurrent destination was preserved after rollback could not finish."""
40
+
41
+ def __init__(self, recovery_path: Path) -> None:
42
+ super().__init__(
43
+ "fleet environment destination changed and recovery failed; "
44
+ "concurrent data preserved"
45
+ )
46
+ self.recovery_path = recovery_path
47
+
48
+
49
+ class ParsedRecord(NamedTuple):
50
+ key: str
51
+ value: str
52
+ start: int
53
+ end: int
54
+
55
+
56
+ def line_number(text: str, offset: int) -> int:
57
+ return text.count("\n", 0, offset) + 1
58
+
59
+
60
+ def line_end(text: str, offset: int) -> int:
61
+ end = text.find("\n", offset)
62
+ return len(text) if end < 0 else end
63
+
64
+
65
+ def next_line(text: str, end: int) -> int:
66
+ return end if end == len(text) else end + 1
67
+
68
+
69
+ def validate_suffix(text: str, offset: int, origin: int) -> int:
70
+ end = line_end(text, offset)
71
+ suffix = text[offset:end]
72
+ if not re.fullmatch(r"[ \t]*(?:#.*)?", suffix):
73
+ raise FleetEnvParseError(
74
+ line_number(text, origin), "unexpected content after quoted value"
75
+ )
76
+ return next_line(text, end)
77
+
78
+
79
+ def parse_single_quoted(text: str, offset: int) -> tuple[str, int]:
80
+ origin = offset
81
+ closing = text.find("'", offset + 1)
82
+ if closing < 0:
83
+ raise FleetEnvParseError(line_number(text, origin), "unterminated single quote")
84
+ return text[offset + 1 : closing], validate_suffix(text, closing + 1, origin)
85
+
86
+
87
+ def parse_double_quoted(text: str, offset: int) -> tuple[str, int]:
88
+ origin = offset
89
+ cursor = offset + 1
90
+ value: list[str] = []
91
+ while cursor < len(text):
92
+ char = text[cursor]
93
+ if char == '"':
94
+ return "".join(value), validate_suffix(text, cursor + 1, origin)
95
+ if char in {"$", "`"}:
96
+ raise FleetEnvParseError(
97
+ line_number(text, cursor),
98
+ "dynamic expansion is not supported in fleet.env",
99
+ )
100
+ if char != "\\":
101
+ value.append(char)
102
+ cursor += 1
103
+ continue
104
+ if cursor + 1 >= len(text):
105
+ raise FleetEnvParseError(line_number(text, cursor), "unterminated escape")
106
+ escaped = text[cursor + 1]
107
+ if escaped == "\n":
108
+ cursor += 2
109
+ continue
110
+ if escaped in {'"', "\\", "$", "`"}:
111
+ value.append(escaped)
112
+ else:
113
+ value.extend(("\\", escaped))
114
+ cursor += 2
115
+ raise FleetEnvParseError(line_number(text, origin), "unterminated double quote")
116
+
117
+
118
+ ANSI_ESCAPES = {
119
+ "a": "\a",
120
+ "b": "\b",
121
+ "e": "\x1b",
122
+ "E": "\x1b",
123
+ "f": "\f",
124
+ "n": "\n",
125
+ "r": "\r",
126
+ "t": "\t",
127
+ "v": "\v",
128
+ "\\": "\\",
129
+ "'": "'",
130
+ '"': '"',
131
+ "?": "?",
132
+ }
133
+
134
+
135
+ def parse_hex_escape(text: str, offset: int, maximum: int) -> tuple[str, int]:
136
+ cursor = offset
137
+ while (
138
+ cursor < len(text)
139
+ and cursor - offset < maximum
140
+ and text[cursor] in "0123456789abcdefABCDEF"
141
+ ):
142
+ cursor += 1
143
+ if cursor == offset:
144
+ raise FleetEnvParseError(line_number(text, offset), "empty hexadecimal escape")
145
+ return chr(int(text[offset:cursor], 16)), cursor
146
+
147
+
148
+ def parse_ansi_c_quoted(text: str, offset: int) -> tuple[str, int]:
149
+ origin = offset
150
+ cursor = offset + 2
151
+ value: list[str] = []
152
+ while cursor < len(text):
153
+ char = text[cursor]
154
+ if char == "'":
155
+ parsed = "".join(value)
156
+ if "\0" in parsed:
157
+ raise FleetEnvParseError(
158
+ line_number(text, origin), "NUL is not allowed"
159
+ )
160
+ return parsed, validate_suffix(text, cursor + 1, origin)
161
+ if char != "\\":
162
+ value.append(char)
163
+ cursor += 1
164
+ continue
165
+ if cursor + 1 >= len(text):
166
+ raise FleetEnvParseError(line_number(text, cursor), "unterminated escape")
167
+ escaped = text[cursor + 1]
168
+ if escaped == "\n":
169
+ cursor += 2
170
+ continue
171
+ if escaped in ANSI_ESCAPES:
172
+ value.append(ANSI_ESCAPES[escaped])
173
+ cursor += 2
174
+ continue
175
+ if escaped == "x":
176
+ decoded, cursor = parse_hex_escape(text, cursor + 2, 2)
177
+ value.append(decoded)
178
+ continue
179
+ if escaped in {"u", "U"}:
180
+ width = 4 if escaped == "u" else 8
181
+ start = cursor + 2
182
+ digits = text[start : start + width]
183
+ if len(digits) != width or any(
184
+ c not in "0123456789abcdefABCDEF" for c in digits
185
+ ):
186
+ raise FleetEnvParseError(
187
+ line_number(text, cursor), "invalid Unicode escape"
188
+ )
189
+ try:
190
+ value.append(chr(int(digits, 16)))
191
+ except ValueError as error:
192
+ raise FleetEnvParseError(
193
+ line_number(text, cursor), "invalid Unicode code point"
194
+ ) from error
195
+ cursor = start + width
196
+ continue
197
+ if escaped in "01234567":
198
+ start = cursor + 1
199
+ end = start
200
+ while end < len(text) and end - start < 3 and text[end] in "01234567":
201
+ end += 1
202
+ value.append(chr(int(text[start:end], 8)))
203
+ cursor = end
204
+ continue
205
+ value.extend(("\\", escaped))
206
+ cursor += 2
207
+ raise FleetEnvParseError(line_number(text, origin), "unterminated ANSI-C quote")
208
+
209
+
210
+ def parse_unquoted(
211
+ text: str,
212
+ offset: int,
213
+ expansion_values: Mapping[str, str],
214
+ ) -> tuple[str, int]:
215
+ end = line_end(text, offset)
216
+ raw = text[offset:end]
217
+ comment = re.search(r"[ \t]+#", raw)
218
+ if comment:
219
+ raw = raw[: comment.start()]
220
+ if not raw:
221
+ return "", next_line(text, end)
222
+ if re.search(r"[ \t;&|<>()`\\'\"]", raw):
223
+ raise FleetEnvParseError(
224
+ line_number(text, offset), "unquoted value contains shell syntax"
225
+ )
226
+ if "$" not in raw:
227
+ return raw, next_line(text, end)
228
+
229
+ # Compatibility is intentionally narrow: exactly one allowed token must be
230
+ # the first byte, followed only by the already-validated literal suffix.
231
+ # Prefixes and repeated/partial tokens would turn this into a general
232
+ # expansion language and are therefore rejected.
233
+ match = LEGACY_EXPANSION.match(raw)
234
+ if match is None or match.start() != 0:
235
+ raise FleetEnvParseError(
236
+ line_number(text, offset),
237
+ "dynamic expansion is not supported in fleet.env",
238
+ )
239
+ suffix = raw[match.end() :]
240
+ if "$" in suffix:
241
+ raise FleetEnvParseError(
242
+ line_number(text, offset),
243
+ "dynamic expansion is not supported in fleet.env",
244
+ )
245
+ value = expansion_values.get(LEGACY_EXPANSION_NAME)
246
+ if value is None:
247
+ raise FleetEnvParseError(
248
+ line_number(text, offset),
249
+ "legacy HERMES_FLEET_HOME expansion has no value",
250
+ )
251
+ return value + suffix, next_line(text, end)
252
+
253
+
254
+ def parse_value(
255
+ text: str,
256
+ offset: int,
257
+ expansion_values: Mapping[str, str],
258
+ ) -> tuple[str, int]:
259
+ if offset >= len(text) or text[offset] == "\n":
260
+ end = line_end(text, offset)
261
+ return "", next_line(text, end)
262
+ if text.startswith("$'", offset):
263
+ return parse_ansi_c_quoted(text, offset)
264
+ if text[offset] == "'":
265
+ return parse_single_quoted(text, offset)
266
+ if text[offset] == '"':
267
+ return parse_double_quoted(text, offset)
268
+ return parse_unquoted(text, offset, expansion_values)
269
+
270
+
271
+ def normalize_text(text: str) -> str:
272
+ if "\0" in text:
273
+ raise FleetEnvParseError(1, "NUL is not allowed")
274
+ if "\r" in text:
275
+ text = text.replace("\r\n", "\n")
276
+ if "\r" in text:
277
+ raise FleetEnvParseError(1, "bare carriage return is not supported")
278
+ return text
279
+
280
+
281
+ def validate_unicode(value: str) -> None:
282
+ for char in value:
283
+ codepoint = ord(char)
284
+ if 0xD800 <= codepoint <= 0xDFFF:
285
+ raise UnicodeError("surrogate code point")
286
+ if 0xFDD0 <= codepoint <= 0xFDEF or codepoint & 0xFFFE == 0xFFFE:
287
+ raise UnicodeError("Unicode noncharacter")
288
+ value.encode("utf-8", errors="strict")
289
+
290
+
291
+ def parse_document(
292
+ text: str,
293
+ environment: Mapping[str, str] | None = None,
294
+ ) -> tuple[str, list[ParsedRecord]]:
295
+ text = normalize_text(text)
296
+ inherited = environment if environment is not None else os.environ
297
+ expansion_values: dict[str, str] = {}
298
+ caller_has_fleet_home = LEGACY_EXPANSION_NAME in inherited
299
+ if caller_has_fleet_home:
300
+ expansion_values[LEGACY_EXPANSION_NAME] = inherited[LEGACY_EXPANSION_NAME]
301
+
302
+ cursor = 0
303
+ records: list[ParsedRecord] = []
304
+ seen: set[str] = set()
305
+ while cursor < len(text):
306
+ origin = cursor
307
+ end = line_end(text, cursor)
308
+ physical = text[cursor:end]
309
+ if not physical.strip() or physical.lstrip().startswith("#"):
310
+ cursor = next_line(text, end)
311
+ continue
312
+ match = ASSIGNMENT.match(text, cursor)
313
+ if not match or match.end() > end:
314
+ raise FleetEnvParseError(
315
+ line_number(text, cursor), "expected KEY=value or export KEY=value"
316
+ )
317
+ key = match.group(1)
318
+ if not NAME.fullmatch(key):
319
+ raise FleetEnvParseError(line_number(text, cursor), "invalid variable name")
320
+ if key in seen:
321
+ raise FleetEnvParseError(
322
+ line_number(text, cursor), f"duplicate variable {key}"
323
+ )
324
+ value, cursor = parse_value(text, match.end(), expansion_values)
325
+ validate_unicode(value)
326
+ seen.add(key)
327
+ records.append(ParsedRecord(key, value, origin, cursor))
328
+ if key == LEGACY_EXPANSION_NAME and not caller_has_fleet_home:
329
+ expansion_values[key] = value
330
+ return text, records
331
+
332
+
333
+ def parse(
334
+ text: str,
335
+ environment: Mapping[str, str] | None = None,
336
+ ) -> list[tuple[str, str]]:
337
+ _, records = parse_document(text, environment)
338
+ return [(record.key, record.value) for record in records]
339
+
340
+
341
+ def serialize_literal(value: str) -> str:
342
+ validate_unicode(value)
343
+ escaped: list[str] = []
344
+ simple_escapes = {
345
+ "\\": r"\\",
346
+ "'": r"\'",
347
+ "\a": r"\a",
348
+ "\b": r"\b",
349
+ "\x1b": r"\e",
350
+ "\f": r"\f",
351
+ "\n": r"\n",
352
+ "\r": r"\r",
353
+ "\t": r"\t",
354
+ "\v": r"\v",
355
+ }
356
+ for char in value:
357
+ if char in simple_escapes:
358
+ escaped.append(simple_escapes[char])
359
+ elif ord(char) < 0x20 or ord(char) == 0x7F:
360
+ escaped.append(f"\\x{ord(char):02x}")
361
+ else:
362
+ escaped.append(char)
363
+ return "$'" + "".join(escaped) + "'"
364
+
365
+
366
+ def serialize_systemd_value(value: str) -> str:
367
+ """Return one lossless, injection-safe systemd.syntax scalar."""
368
+ validate_unicode(value)
369
+ if "\0" in value:
370
+ raise FleetEnvParseError(1, "NUL is not allowed in a systemd value")
371
+ if "\r" in value or "\n" in value:
372
+ raise FleetEnvParseError(1, "newline control characters are not allowed in a systemd value")
373
+ escaped: list[str] = []
374
+ for char in value:
375
+ if char == "\\":
376
+ escaped.append(r"\\")
377
+ elif char == '"':
378
+ escaped.append(r'\"')
379
+ elif char == "%":
380
+ # systemd specifiers are expanded after syntax unquoting. Doubling
381
+ # preserves a caller-provided percent byte as literal data.
382
+ escaped.append("%%")
383
+ elif char == "\t":
384
+ escaped.append(r"\t")
385
+ elif ord(char) < 0x20 or ord(char) == 0x7F:
386
+ escaped.append(f"\\x{ord(char):02x}")
387
+ else:
388
+ escaped.append(char)
389
+ return '"' + "".join(escaped) + '"'
390
+
391
+
392
+ def serialize_systemd_environment(name: str, value: str) -> str:
393
+ if not NAME.fullmatch(name):
394
+ raise FleetEnvParseError(1, "invalid systemd environment variable name")
395
+ return f"Environment={serialize_systemd_value(f'{name}={value}')}"
396
+
397
+
398
+ def serialize_systemd_exec_value(value: str) -> str:
399
+ """Quote one ExecStart token while suppressing systemd $/%% expansion."""
400
+ return serialize_systemd_value(value.replace("$", "$$"))
401
+
402
+
403
+ def read_regular_document(
404
+ path: Path,
405
+ *,
406
+ allow_missing: bool = False,
407
+ ) -> tuple[str, os.stat_result | None]:
408
+ try:
409
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
410
+ except FileNotFoundError:
411
+ if allow_missing:
412
+ return INITIAL_DOCUMENT, None
413
+ raise FleetEnvParseError(1, "fleet environment path is unavailable")
414
+ except OSError as error:
415
+ raise FleetEnvParseError(1, "fleet environment path must be a regular file") from error
416
+ try:
417
+ metadata = os.fstat(descriptor)
418
+ if not stat.S_ISREG(metadata.st_mode):
419
+ raise FleetEnvParseError(1, "fleet environment path must be a regular file")
420
+ with os.fdopen(descriptor, "rb", closefd=False) as stream:
421
+ content = stream.read()
422
+ after = os.fstat(descriptor)
423
+ if not _same_file_metadata(metadata, after):
424
+ raise FleetEnvParseError(1, "fleet environment changed while it was read")
425
+ return content.decode("utf-8"), after
426
+ finally:
427
+ os.close(descriptor)
428
+
429
+
430
+ def write_atomic_document(
431
+ path: Path,
432
+ content: str,
433
+ original: os.stat_result | None,
434
+ original_content: bytes | None = None,
435
+ ) -> None:
436
+ parent = path.parent
437
+ descriptor, temporary_name = tempfile.mkstemp(
438
+ prefix=f".{path.name}.",
439
+ dir=parent,
440
+ )
441
+ temporary = Path(temporary_name)
442
+ preserve_temporary = False
443
+ try:
444
+ desired_mode = stat.S_IMODE(original.st_mode) if original else 0o600
445
+ if original is not None:
446
+ # chown(2) may clear set-ID mode bits. Establish ownership first,
447
+ # then restore the exact original mode and attest both properties
448
+ # on the prepared inode before it can participate in an exchange.
449
+ # Permission failures are not an excuse to silently drift metadata.
450
+ os.fchown(descriptor, original.st_uid, original.st_gid)
451
+ encoded = content.encode("utf-8", errors="strict")
452
+ with os.fdopen(descriptor, "wb", closefd=False) as stream:
453
+ stream.write(encoded)
454
+ stream.flush()
455
+ # A content write can also clear set-ID bits. This is therefore the
456
+ # final metadata operation before attestation and atomic commit.
457
+ os.fchmod(descriptor, desired_mode)
458
+ prepared = os.fstat(descriptor)
459
+ if original is not None and (
460
+ prepared.st_uid != original.st_uid
461
+ or prepared.st_gid != original.st_gid
462
+ ):
463
+ raise OSError("fleet environment ownership could not be preserved")
464
+ if stat.S_IMODE(prepared.st_mode) != desired_mode:
465
+ raise OSError("fleet environment mode could not be preserved")
466
+ os.fsync(descriptor)
467
+ os.close(descriptor)
468
+ descriptor = -1
469
+
470
+ if original is None:
471
+ # link(2) supplies portable no-clobber creation. The temporary and
472
+ # destination share a directory/filesystem, so successful linking
473
+ # commits the fully-written inode without a check/use gap.
474
+ os.link(temporary, path, follow_symlinks=False)
475
+ temporary.unlink()
476
+ else:
477
+ # Linux renameat2(RENAME_EXCHANGE) swaps the prepared file and the
478
+ # current destination in one syscall. Only after that atomic claim
479
+ # do we inspect the displaced inode. A concurrent replacement is
480
+ # immediately exchanged back and reported, never overwritten.
481
+ # Platforms without atomic exchange fail closed before mutation;
482
+ # there is no portable conditional-replace primitive for this CAS.
483
+ _exchange_paths(temporary, path)
484
+ displaced = os.lstat(temporary)
485
+ if original_content is None or not _matches_file_snapshot(
486
+ temporary,
487
+ original,
488
+ original_content,
489
+ ):
490
+ try:
491
+ _exchange_paths(temporary, path)
492
+ except OSError as recovery_error:
493
+ # After the failed reverse exchange, `path` contains our
494
+ # prepared update and `temporary` contains the concurrent
495
+ # replacement. From this point onward the temporary name is
496
+ # data, not scratch: never let the generic finally cleanup
497
+ # unlink it. Give the displaced inode a deterministic,
498
+ # restrictive recovery name when the filesystem permits,
499
+ # otherwise retain the exact mkstemp name in exception
500
+ # metadata for operator recovery.
501
+ preserve_temporary = True
502
+ recovery_path = _preserve_displaced_recovery(
503
+ temporary,
504
+ path,
505
+ displaced,
506
+ )
507
+ raise FleetEnvRecoveryError(recovery_path) from recovery_error
508
+ raise OSError("fleet environment destination changed during update")
509
+ temporary.unlink()
510
+
511
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
512
+ directory_descriptor = os.open(parent, directory_flags)
513
+ try:
514
+ os.fsync(directory_descriptor)
515
+ finally:
516
+ os.close(directory_descriptor)
517
+ finally:
518
+ if descriptor >= 0:
519
+ os.close(descriptor)
520
+ if not preserve_temporary:
521
+ try:
522
+ temporary.unlink()
523
+ except FileNotFoundError:
524
+ pass
525
+
526
+
527
+ def _same_file_metadata(expected: os.stat_result, actual: os.stat_result) -> bool:
528
+ """Compare the stable identity, content-version, and security metadata."""
529
+ return (
530
+ stat.S_ISREG(actual.st_mode)
531
+ and (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino)
532
+ and actual.st_mode == expected.st_mode
533
+ and actual.st_uid == expected.st_uid
534
+ and actual.st_gid == expected.st_gid
535
+ and actual.st_size == expected.st_size
536
+ and actual.st_mtime_ns == expected.st_mtime_ns
537
+ )
538
+
539
+
540
+ def _matches_file_snapshot(
541
+ path: Path,
542
+ expected: os.stat_result,
543
+ expected_content: bytes,
544
+ ) -> bool:
545
+ """Attest one displaced inode without following a replacement symlink."""
546
+ try:
547
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
548
+ except OSError:
549
+ return False
550
+ try:
551
+ before = os.fstat(descriptor)
552
+ chunks: list[bytes] = []
553
+ while True:
554
+ chunk = os.read(descriptor, 1024 * 1024)
555
+ if not chunk:
556
+ break
557
+ chunks.append(chunk)
558
+ after = os.fstat(descriptor)
559
+ finally:
560
+ os.close(descriptor)
561
+ return (
562
+ _same_file_metadata(expected, before)
563
+ and _same_file_metadata(before, after)
564
+ and b"".join(chunks) == expected_content
565
+ )
566
+
567
+
568
+ def _remove_recovery_temporary(path: Path) -> bool:
569
+ """Best-effort cleanup after a durable recovery link was established."""
570
+ try:
571
+ path.unlink()
572
+ except OSError:
573
+ return False
574
+ return True
575
+
576
+
577
+ def _preserve_displaced_recovery(
578
+ temporary: Path,
579
+ destination: Path,
580
+ displaced: os.stat_result,
581
+ ) -> Path:
582
+ """Keep a displaced concurrent inode reachable without overwriting data."""
583
+ if stat.S_ISREG(displaced.st_mode):
584
+ descriptor = os.open(temporary, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
585
+ try:
586
+ os.fchmod(descriptor, 0o600)
587
+ digest = hashlib.sha256()
588
+ while True:
589
+ chunk = os.read(descriptor, 1024 * 1024)
590
+ if not chunk:
591
+ break
592
+ digest.update(chunk)
593
+ finally:
594
+ os.close(descriptor)
595
+ fingerprint = digest.hexdigest()[:16]
596
+ else:
597
+ fingerprint = f"mode-{stat.S_IFMT(displaced.st_mode):x}"
598
+
599
+ recovery = destination.parent / (
600
+ f".{destination.name}.pjangler-recovery-"
601
+ f"{displaced.st_dev:x}-{displaced.st_ino:x}-{fingerprint}"
602
+ )
603
+ try:
604
+ os.link(temporary, recovery, follow_symlinks=False)
605
+ except FileExistsError:
606
+ existing = os.lstat(recovery)
607
+ if (existing.st_dev, existing.st_ino) != (displaced.st_dev, displaced.st_ino):
608
+ return temporary
609
+ except OSError:
610
+ return temporary
611
+ _remove_recovery_temporary(temporary)
612
+ return recovery
613
+
614
+
615
+ def _exchange_paths(left: Path, right: Path) -> None:
616
+ """Atomically exchange two paths, or fail without a portability downgrade."""
617
+ try:
618
+ renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
619
+ except AttributeError as error:
620
+ raise OSError(errno.ENOTSUP, "atomic path exchange is unavailable") from error
621
+ renameat2.argtypes = (
622
+ ctypes.c_int,
623
+ ctypes.c_char_p,
624
+ ctypes.c_int,
625
+ ctypes.c_char_p,
626
+ ctypes.c_uint,
627
+ )
628
+ renameat2.restype = ctypes.c_int
629
+ at_fdcwd = -100
630
+ rename_exchange = 2
631
+ if renameat2(
632
+ at_fdcwd,
633
+ os.fsencode(left),
634
+ at_fdcwd,
635
+ os.fsencode(right),
636
+ rename_exchange,
637
+ ) != 0:
638
+ code = ctypes.get_errno()
639
+ raise OSError(code, os.strerror(code))
640
+
641
+
642
+ def render_upsert(
643
+ text: str,
644
+ key: str,
645
+ value: str,
646
+ environment: Mapping[str, str] | None = None,
647
+ ) -> str:
648
+ if not NAME.fullmatch(key):
649
+ raise FleetEnvParseError(1, "invalid variable name")
650
+ normalized, records = parse_document(text, environment)
651
+ matches = [record for record in records if record.key == key]
652
+ if len(matches) > 1:
653
+ raise FleetEnvParseError(1, f"duplicate variable {key}")
654
+
655
+ replacement = f"{key}={serialize_literal(value)}\n"
656
+ if matches:
657
+ record = matches[0]
658
+ updated = normalized[: record.start] + replacement + normalized[record.end :]
659
+ else:
660
+ separator = "" if not normalized or normalized.endswith("\n") else "\n"
661
+ updated = normalized + separator + replacement
662
+
663
+ # Validate the complete prospective document before opening a temporary
664
+ # output. This prevents a malformed legacy record from being partially
665
+ # repaired or hidden by an otherwise valid upsert.
666
+ parse_document(updated, environment)
667
+ return updated
668
+
669
+
670
+ def atomic_upsert(path: Path, key: str, value: str) -> None:
671
+ text, original = read_regular_document(path, allow_missing=True)
672
+ updated = render_upsert(text, key, value)
673
+ original_content = text.encode("utf-8", errors="strict") if original is not None else None
674
+ write_atomic_document(path, updated, original, original_content)
675
+
676
+
677
+ def emit_records(records: list[tuple[str, str]]) -> None:
678
+ framed = [HEADER]
679
+ framed.extend(f"{key}={value}".encode("utf-8") for key, value in records)
680
+ framed.extend((FOOTER, b"", b""))
681
+ sys.stdout.buffer.write(b"\0".join(framed))
682
+
683
+
684
+ def main() -> int:
685
+ parse_mode = len(sys.argv) == 2
686
+ upsert_mode = len(sys.argv) == 5 and sys.argv[1] == "--upsert"
687
+ systemd_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-value"
688
+ systemd_exec_value_mode = len(sys.argv) == 3 and sys.argv[1] == "--systemd-exec-value"
689
+ systemd_environment_mode = len(sys.argv) == 4 and sys.argv[1] == "--systemd-environment"
690
+ if not parse_mode and not upsert_mode and not systemd_value_mode and not systemd_exec_value_mode and not systemd_environment_mode:
691
+ print(
692
+ "usage: parse-fleet-env.py PATH | --upsert PATH KEY VALUE | "
693
+ "--systemd-value VALUE | --systemd-exec-value VALUE | --systemd-environment NAME VALUE",
694
+ file=sys.stderr,
695
+ )
696
+ return 2
697
+ try:
698
+ if systemd_value_mode:
699
+ print(serialize_systemd_value(sys.argv[2]), end="")
700
+ return 0
701
+ if systemd_exec_value_mode:
702
+ print(serialize_systemd_exec_value(sys.argv[2]), end="")
703
+ return 0
704
+ if systemd_environment_mode:
705
+ print(serialize_systemd_environment(sys.argv[2], sys.argv[3]), end="")
706
+ return 0
707
+ if upsert_mode:
708
+ atomic_upsert(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
709
+ return 0
710
+ path = Path(sys.argv[1])
711
+ text, _ = read_regular_document(path)
712
+ records = parse(text)
713
+ emit_records(records)
714
+ except UnicodeError:
715
+ print(UNICODE_ERROR, file=sys.stderr)
716
+ return 2
717
+ except FleetEnvParseError as error:
718
+ print(f"fleet environment parse error: {error}", file=sys.stderr)
719
+ return 2
720
+ except FleetEnvRecoveryError:
721
+ print(
722
+ "fleet environment write error: concurrent replacement preserved "
723
+ "for operator recovery",
724
+ file=sys.stderr,
725
+ )
726
+ return 2
727
+ except OSError:
728
+ print("fleet environment write error: update was not committed", file=sys.stderr)
729
+ return 2
730
+ return 0
731
+
732
+
733
+ if __name__ == "__main__":
734
+ raise SystemExit(main())