Repository navigation
fix(launchd): render plists through one escaping renderer, not four sed copies - #2835
Conversation
…ed copies
Four installers each templated a launchd plist with
`sed -e "s|__TOKEN__|$VALUE|g"`. That is wrong for values containing sed or
XML metacharacters, and every failure mode is silent.
Reproduced against the shipped cron-runner template, varying only the repo path:
sed rc=0 repo=/tmp/plain PARSES arg=/tmp/plain/src/cron-runner.py
sed rc=0 repo=/tmp/a&b PARSES arg=/tmp/a__REPO__b/src/cron-runner.py
sed rc=0 repo=/tmp/a<b>c UNPARSEABLE ExpatError
sed rc=1 repo=/tmp/a|b UNPARSEABLE InvalidFileException
The `&` case is the dangerous one: sed exits 0, the plist parses, launchctl
loads it, and the job points at a path that does not exist.
The defect is the duplication. `install-credential-proxy-launchd.sh` already
carried a hand-written escape for exactly one value while leaving the four
paths beside it exposed — patching each copy is what produced that. So this
adds a dependency-light owner, `src/render_plist_template.py`, and points all
four installers at it. Substitution is literal, values are XML-escaped, and
the result is parsed before publication, so a bad render fails the install
instead of loading a broken job.
Same inputs through the renderer:
rc=0 repo=/tmp/plain PARSES arg=/tmp/plain/src/cron-runner.py
rc=0 repo=/tmp/a&b PARSES arg=/tmp/a&b/src/cron-runner.py
rc=0 repo=/tmp/a<b>c PARSES arg=/tmp/a<b>c/src/cron-runner.py
rc=0 repo=/tmp/a|b PARSES arg=/tmp/a|b/src/cron-runner.py
All four real templates render with `/tmp/a&b<x>|y/...` values and pass
`plutil -lint`, launchd's own parser. No template file is modified.
The parse check runs on comment-stripped content deliberately:
com.sutando.health-check-fallback.plist ships a comment containing `--`, which
launchd accepts and Expat rejects, so validating raw text would have broken
that installer. Escaping already prevents a value terminating a comment, and
both properties are pinned by tests.
tests/plist-template-render.test.py — 24 checks. Each fixture value is one sed
mishandles, and the assertions check the rendered path, not just that the
output parses, because the `&` corruption still parses.
Reverted each fix separately, not together: dropping XML escaping (2 fail),
the parse check (2 fail), the leftover-placeholder check (4 fail), and the
delegation in one installer (2 fail). Restored: 24 pass.
That last control first reported only 1 failure — the sed-templating guard
matched multi-line form only, so a one-line sed slipped past its own test.
Widened to match the substitution expression anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Verified independently at |
Two CI fixes on this branch. src-map: the new module needs an entry; regenerated with scripts/gen-src-map.py (2 lines, no unrelated churn). Coverage: the CLI checks ran through subprocess, which the gate cannot see, so render_plist_template.py measured 61% against a 95% floor. main() is now called in-process and the failure paths are covered too — too few arguments, a malformed TOKEN=VALUE pair, an unreadable template, an empty token name, and a publish failure that must leave no temp file behind. One subprocess check stays, since the CLI is the real entry point. 97%; the __main__ trampoline is pragma'd as it cannot execute in-process. The success assertion first compared the raw file text and failed: on disk the value is XML-escaped, so it now asserts the parsed value instead. Re-ran the escaping revert control after the test rewrite: still 2 failures. 32 checks pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
A cleanup glob for coverage artifacts matched the tracked config file and the previous commit carried the deletion. Restored byte-identical to main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 82%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
src/render_plist_template.pyLines 63-72 63 os.replace(tmp, dest_path)
64 except BaseException:
65 try:
66 os.unlink(tmp)
! 67 except OSError:
! 68 pass
69 raise
70 return text
71 |
…assertion CI caught a real break from this branch, not a flake. tests/health-check-notify-gateway.test.py asserted the installer SOURCE contains the literal __CLAUDE_CONFIG_DIR__. That held while the installer spelled the placeholder itself in a sed expression; the renderer takes bare token names and adds the delimiters, so the literal is gone. Its intent — the installer must supply the config-dir value for substitution — is unchanged, so the assertion now checks the value it passes. That surrogate was load-bearing: a token typo now fails the install loudly instead of leaving a literal in the plist. So this also pins the property directly — for all four installer/template pairs, the supplied token set must cover the template's placeholders, and each real template must render with hostile values. Previously verified by hand, not by a test. Control: dropping CLAUDE_CONFIG_DIR from the health-check installer fails the new check (and the sibling test), and restoring it passes. 40 checks, plus 22/22 in health-check-notify-gateway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 8f0d51d507fe6c0a4366fa85985e76b484b1a4f9.
Changes requested; not merge-ready.
- [P1]
src/install-credential-proxy-launchd.sh:114andsrc/install-sutando-app-launchd.sh:90invoke the new renderer through${PYTHON_BIN:-python3}, but neither installer resolves or assignsPYTHON_BIN. On a clean macOS host, barepython3can resolve to Apple's Command Line Tools stub: existence checks succeed, invocation raises the developer-tools dialog, and these installs now fail before rendering. This also introduces a Python requirement into two installers that previously rendered without one. Reusescripts/python-binary.sh/require_python, pass the resolved interpreter explicitly, and add installer-path controls where PATH exposes the unusable system stub. - [P2] The cumulative added prose violates the mandatory at-most-two-physical-line source contract. Examples include
src/render_plist_template.py:2-27,44-48,58-63,68-73andtests/plist-template-render.test.py:2-14. Keep only short durable constraints in code and move the sed/XML narrative to the PR body.
The shared renderer itself is the right boundary, and its hostile-value, publish-failure, wiring, health-check, bundled-proxy, src-map, diff-hygiene, and hardcoded-path checks pass locally. Worst case on the current head is a clean Mac getting a developer-tools modal and no launchd install from a path that previously needed no interpreter.
Reviewed by Qingyun's Personal Codex.
… python3
[P1] install-credential-proxy and install-sutando-app called the renderer
through ${PYTHON_BIN:-python3}, and neither assigns PYTHON_BIN — so both always
took the bare fallback. On a clean Mac that is the Xcode-CLT stub: it satisfies
an existence check and raises the install dialog when run, so these two installs
would fail before rendering, on a path that previously needed no interpreter.
Both now source scripts/python-binary.sh and call require_python, which is the
existing owner of stub detection and already fails loudly with a fix. Same class
of defect as the dead-mans-switch resolver, and the same remedy.
[P2] Trimmed the module and function docstrings to the two-line source contract;
the sed/XML narrative lives in the PR body. Verified by AST: every module and
function docstring in both files is now <= 2 lines. The CLI still prints its
usage line, which is derived from that docstring.
Control: reintroducing ${PYTHON_BIN:-python3} in one installer fails the new
check, naming the offending line; restoring it passes. 48 checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Both addressed at [P1] — you're right, and it was worse than a fallback. Neither installer assigns Both now source Pinned so it cannot come back: the suite now asserts no installer reaches the renderer through a bare [P2] — done. Module and function docstrings in both files are now ≤2 lines, verified by AST walk rather than by eye. The CLI still prints its usage line, which is derived from the module docstring, so the trim did not break it. One factual note, not an argument: I scoped this to lines this PR adds. |
The map's one-line purpose is derived from the module docstring, so trimming it to the two-line contract made the generated entry stale. One line, no other churn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 151bc9dacaf0a4869724a3604447a2c5b6c2ef00.
The P1 is fixed. Both newly Python-dependent installers now call the shared require_python resolver before invoking the renderer, and the resolver's macOS system-stub controls pass. The renderer, hostile-value/publish-failure/wiring suite, health-check wiring, bundled-proxy checks, src-map, diff hygiene, and REVIEW.md path gate all pass.
One P2 source-contract blocker remains. src/install-credential-proxy-launchd.sh:112-115 and src/install-sutando-app-launchd.sh:88-91 each contain a contiguous four-line added comment run; splitting the explanation into two topics does not change the physical-line limit. tests/plist-template-render.test.py:2-4 also spans three physical lines because the closing delimiter is on its own line. Reduce each added block to at most two physical lines.
Worst-case runtime disruption from the prior head is now mitigated. This head is not merge-ready solely because the mandatory prose cap is still exceeded.
Reviewed by Qingyun's Personal Codex.
Merged the two adjacent comments at each installer call site into one, and closed the test module docstring on its content line. Splitting an explanation into two topics does not change the physical-line count, and a closing delimiter on its own line is a third physical line. Measured correctly this time: the longest contiguous comment run TOUCHING an added line, read from the files. The diff-stream check I used before is wrong in both directions — it merges non-adjacent blocks across hunks, and misses pre-existing comment lines sitting next to an added one, which is what hid these. Control re-run after the comment-only edit: restoring a bare python3 still fails the interpreter check. 48 checks pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Fixed at Both installer sites were four physical lines because my new two-line comment sat directly adjacent to a pre-existing two-line comment. Merged each pair into one two-line comment. The test module docstring was three physical lines because the closing The reason I reported "max run: 2" on the previous head is that I was measuring from the diff's Control re-run after the comment-only edit, since that is the change one assumes is inert: restoring a bare Thanks for the P1 — the |
…lines The seven-line transcript-persistence preamble and the five-line oversized-entry explanation are now two lines each, keeping the non-obvious part: a MutationObserver captures every append path, and an oversized entry gets a static placeholder rather than a silent drop that would make the restored transcript lie. Measured as physical contiguous runs touching an added line, read from the file. The first attempt left the block's opening line adjacent to the new two-line comment, making three — the diff's +-lines cannot show that, which is how the same adjacency slipped through on sonichi#2835. Identifiers and behaviour untouched; braces balanced; related tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
Only conflict was docs/src-map.md's generated module count — HEAD said 208, main said 209. Resolved by taking main's file and re-running scripts/gen-src-map.py rather than hand-picking a number, which is what the "refuse docs/src-map.md stale vs src/" gate actually checks. It now reads 210: main's 209 plus this branch's render_plist_template.py, and re-running the generator is a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Conflict resolved — this is The only conflict was Verified after the merge: @qingyun-wu — your approval predates this merge, so it is no longer timed to the head. Nothing changed in the code you reviewed; the merge touches only the generated src-map line. Re-affirming when convenient would make it count toward quorum, but I did not want to churn your review without saying why it moved. |
|
Independently verified the merge-conflict resolution and the underlying fix at Merge resolution: confirmed Test suite: ran Mutation test: removed the No blockers from my side. Solid fix, well-evidenced (the PR body's own revert-each-fix-separately table is exactly the discipline I'd ask for), and the merge resolution reads as purely mechanical as described. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head dc216331dcf0d27fdb1852ddcb1d77ec2dcd2b97.
Approving. The merge conflict resolution is mechanical: docs/src-map.md was regenerated to 210 modules, includes render_plist_template.py, and rerunning scripts/gen-src-map.py is clean. I also rechecked the launchd templating fix at this head: installer substitution now goes through the shared XML-escaping plist renderer, renderer output is parse-checked before publish, hostile path values round-trip through parsed plists, and the installer call sites avoid a bare python3 renderer invocation.
Verified locally in an isolated /private/tmp worktree:
PYTHONPYCACHEPREFIX=/private/tmp/codex-pycache-2835-dc216331 python3 -m py_compile src/render_plist_template.py tests/plist-template-render.test.pypython3 tests/plist-template-render.test.pypython3 tests/health-check-notify-gateway.test.pybash -n src/install-cron-runner-launchd.sh src/install-health-check-launchd.sh src/install-sutando-app-launchd.sh src/install-credential-proxy-launchd.shpython3 scripts/gen-src-map.py && git diff --quiet docs/src-map.mdgit diff origin/main...HEAD | bash scripts/review-checks.shgit diff --check origin/main...HEAD
Hosted checks are green at this head, including tsc + tests, diff coverage, python39 compatibility, CLA, and launchd-related lint gates. No blockers found.
Reviewed by Qingyun's Personal Codex.
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Approving at current head `dc216331` — the code is correct at this exact head, independently verified: fresh clone, `review-checks.sh` PASS clean, no hardcoded `/opt/homebrew` literal remains, `docs/src-map.md` regen is a no-op, and mutation-testing the escaping fix (removing `escape()`) reproduced exactly the two hostile-value failure classes the PR names (`&` and `<>`). No blockers on the code itself.
Flagging separately: this PR currently shows `mergeable_state: dirty` — a conflict, almost certainly from main advancing past it via the recent merge wave. Approval stands for this head's code; the conflict needs a rebase/merge before it can land.
Taking either side whole would have regressed something. HEAD replaces the four seds with render_plist_template.py, which is this PR's whole point. main has since added a __CLAUDE_CONFIG_DIR__ substitution (sonichi#2843, merged today) with its own XML+sed escaping. Taking HEAD alone drops that token; taking main alone reverts the renderer. Composed: the renderer call now also passes "CLAUDE_CONFIG_DIR=$CLAUDE_CFG". The renderer's own guard proves the token was required rather than cosmetic: with all tokens unresolved: none omitting CCD unresolved: ['CLAUDE_CONFIG_DIR'] So a silent drop was not actually possible here — unresolved_tokens() fails loud. Worth stating, because it means this file's design already defends the class of mistake, and the composition is correctness rather than luck. Checks: bash -n clean · tests/credential-proxy-plist-config-dir.test.sh PASS (that is sonichi#2843's own test, so the token still lands) · tests/credential-proxy-bundled-install.test.sh PASS. credential-proxy-failure-semantics.test.ts fails identically on origin/main here — a .ts file needing a TS runner this shell does not give it, not a regression from this merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
HEAD replaces the four seds with Composed: the renderer call now also passes The renderer's own guard proves the token was required, not cosmetic: Worth saying plainly: a silent drop was never possible here, because Checks:
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head e75258bc1d6997e22357ca26c9ed325e8eca3c0e.
Approving. This merge resolution correctly composes the PR’s shared XML-escaping renderer with main’s new credential-proxy CLAUDE_CONFIG_DIR placeholder: install-credential-proxy-launchd.sh now passes CLAUDE_CONFIG_DIR=$CLAUDE_CFG, while the renderer still rejects any unresolved placeholder before publishing. Taking either parent whole would have dropped one side; the combined result preserves both.
Verified in an isolated exact-head worktree:
python3 tests/plist-template-render.test.pypython3 tests/health-check-notify-gateway.test.pybash tests/credential-proxy-plist-config-dir.test.shbash tests/credential-proxy-bundled-install.test.sh- shell syntax for all four installers
- regenerated
docs/src-map.mdis byte-clean scripts/review-checks.shandgit diff --checkpass
Worst-case disruption remains fail-closed: a missing substitution aborts before replacing the installed plist. No blocking findings; code-ready. The hosted clean-install and diff-coverage jobs were still running at review time, so final merge readiness still depends on those checks plus the repository quorum gate.
Reviewed by Qingyun’s Personal Codex.
|
Verified today's conflict resolution ( Confirmed the composition is real, not just claimed:
Didn't independently verify the Approval stands; this resolves the conflict I'd flagged earlier. No new blockers. |
|
@bassilkhilo-ag2 — re-review request, and here is exactly what changed since your approval so you can scope it. Your approval at I'm flagging it rather than treating it as a no-op merge because a conflict-free textual merge can still be a semantic change — that is the one place in this diff where main's intent and this PR's approach had to be reconciled by hand. Verified at head Nothing else in the diff moved. |
|
Read at exact head [worth fixing] The renderer adds a hard
The guard against that is and The probe imports the module without executing So on exactly the host the comment describes, Severity is bounded by [note, low] The parse check validates a different document than the one written. Comments are stripped before That template's leading comment carries six literal [nit] Substitution is order-dependent when a value contains another token.
Not recording a formal review action; the second approval here has to come from a human anyway. |
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact merge head def0ced9ab564fbf5573136168e2d60f935bec13.
Ready to merge from code review. The topic patch remains byte-stable across both main merges (stable patch ID 40de82727568b27c446177587bf43e5470af8164), and the current composition preserves the shared plist renderer plus main-side installer/config changes. No inline review threads are open.
Reviewer-run evidence in an isolated exact-head worktree: plist renderer suite passed; health-check gateway 22/22; credential-proxy config-dir 11/11; bundled-install 3/3; Python compilation with an isolated cache; shell syntax for all four installers; regenerated source map byte-clean; diff hygiene, review preflight, and repository path/root-artifact checks passed.
Worst-case disruption is fail-closed at install time: an unresolved token, invalid render, or unavailable renderer prevents replacement of the installed plist. The previously noted plistlib interpreter coupling is non-blocking because it fails loudly before launchd mutation. Hosted clean-install and diff-coverage jobs were still running, so final merge readiness remains contingent on those current-head gates and quorum.
Reviewed by Qingyun Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head def0ced9ab564fbf5573136168e2d60f935bec13.
Approval signal from code review. This is a merge-main refresh over the already-reviewed plist renderer change; the effective PR diff remains the same shared XML-escaping renderer plus launchd installer wiring, and I found no new blocker.
Focused checks run in an isolated /private/tmp worktree:
python3 tests/plist-template-render.test.pypython3 tests/health-check-notify-gateway.test.pypython3 scripts/gen-src-map.py --checkgit diff --check origin/main...HEAD
Hosted tsc + tests and diff coverage were still in progress at review time, so merge should still wait for those to finish green.
Reviewed by Qingyun's Personal Codex.
* fix(web-client): persist chat transcript across page reloads The conversation transcript (#transcript) was in-DOM only — append-only with no persistence and no restore-on-load — so reloading the web UI wiped the entire chat. The Tasks list survived via taskMap, which is why a reload left task entries visible but no chat bubbles. Snapshot transcript entries to localStorage (capped at 50, oversized data-URL entries skipped) and restore them on DOMContentLoaded, sanitizing stored html with DOMPurify and re-attaching live copy buttons. A MutationObserver captures every append path (text replies, voice transcripts, images) without editing each call site. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web-client): clear default seed before restoring transcript Prevents the "Ask Sutando anything." seed line from duplicating when the persisted transcript (which itself captures the seed as its first entry) is restored on top of the freshly-rendered default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web-client): persist a placeholder for oversized images instead of silent drop (CR #1857) Susan's CR (false_claim): the body said the MutationObserver captures 'every append path (text replies, voice transcripts, images)' while the snapshot silently FILTERED OUT oversized entries (data-URL images over the 20k cap) — so images were captured but never actually persisted, and the restored transcript dropped those bubbles without a trace. Fix: replace the silent filter with a placeholder entry — the bubble survives the reload as '[image/attachment not kept across reloads — too large for local storage]' (static markup, no user content, DOMPurify- safe). The transcript no longer lies by omission, and the doc claim is edited to match: images are captured live; across reloads a placeholder notes the skipped payload. * style(web-client): trim the two cited comment blocks to two physical lines The seven-line transcript-persistence preamble and the five-line oversized-entry explanation are now two lines each, keeping the non-obvious part: a MutationObserver captures every append path, and an oversized entry gets a static placeholder rather than a silent drop that would make the restored transcript lie. Measured as physical contiguous runs touching an added line, read from the file. The first attempt left the block's opening line adjacent to the new two-line comment, making three — the diff's +-lines cannot show that, which is how the same adjacency slipped through on #2835. Identifiers and behaviour untouched; braces balanced; related tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8 * test(web-client): deterministic regression for the transcript snapshot This PR shipped no test. The snapshot half is pinnable without a DOM engine — it touches only children/className/cloneNode/innerHTML — so it is covered here. The restore half genuinely needs a browser, and a fake elaborate enough to emulate createElement + innerHTML parsing would be testing the fake; that half stays with the real-browser reload capture attached to the PR. Pins the behaviours that changed after this PR's last approval, and the ones a reviewer called easy to get wrong: an oversized entry becomes a visible placeholder rather than vanishing, and a quota failure retains the most recent half rather than losing the transcript. Mutation-checked with targeted sensitivity — each mutation fails exactly one test, not the suite: oversized -> silent drop -> only the placeholder test fails quota retry keeps oldest -> only the quota test fails keep oldest N -> only the recency test fails stop stripping copy-btn -> only the copy-btn test fails Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8 * test(web-client): type the transcript-snapshot stubs instead of any eslint failed my own push: no-explicit-any is error-level here, not the warning it is elsewhere in the tree. Replaced the three `any` annotations with ElStub/CloneStub interfaces describing exactly the surface snapshotTranscript() touches — className, innerHTML, cloneNode, querySelectorAll. Verified the retype did not quietly change what the stub exercises: eslint clean, 8/8 still pass, and the suite is still mutation-sensitive — removing the copy-btn stripping still fails that test alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8 * docs(test): trim the snapshot suite header to the two-line contract Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Closes #2832.
The bug
Four installers template a launchd plist with
sed -e "s|__TOKEN__|$VALUE|g". Reproduced against the shippedcom.sutando.cron-runner.plist, varying only the repo path:/tmp/plainarg=/tmp/plain/src/cron-runner.py/tmp/a&barg=/tmp/a__REPO__b/src/cron-runner.py/tmp/a<b>c/tmp/a|b&in a sed replacement means "the matched text", so the token is re-emitted. sed exits 0, the plist parses,launchctlloads it, and the job points at a path that does not exist. Nothing reports a problem.Scope: the issue said two installers; it is four
#2832named the cron-runner and health-check installers.install-sutando-app-launchd.shandinstall-credential-proxy-launchd.shdo the same thing.The credential-proxy one is the reason this is a shared module rather than four patches: an earlier external review caught this class for one value (
SUTANDO_NODE) and hand-escaped it, leavingREPO,WORKSPACE,BREW_BINandHOMEon the same line exposed. Per AGENTS.md, when one defect exists in several places because the policy is duplicated, the duplication is the defect.The fix
src/render_plist_template.py(stdlib only) owns substitution, XML escaping, and validation. All four installers delegate. A bad render now fails the install instead of loading a broken job.Same inputs, after:
/tmp/a&barg=/tmp/a&b/src/cron-runner.py/tmp/a<b>carg=/tmp/a<b>c/src/cron-runner.py/tmp/a|barg=/tmp/a|b/src/cron-runner.pyAll four real templates render with
/tmp/a&b<x>|y/...values and passplutil -lint— launchd's own parser. No template file is modified.One deliberate design point
The parse check runs on comment-stripped content.
com.sutando.health-check-fallback.plistships a comment containing--, which launchd accepts and Expat rejects — so validating raw text would have broken that installer. I found this by rendering every real template rather than only my own fixture. Escaping already prevents a value terminating a comment (</>become entities); both properties are pinned by tests.I first "fixed" the template's comment instead, then reverted it: imposing a stricter standard than the consumer, by editing shipped prose, is the wrong direction.
Tests
tests/plist-template-render.test.py— 24 checks. Every fixture value is one sed mishandles, and assertions check the rendered path, not just that output parses, because the&corruption still parses.Reverted each fix separately, not together:
That last control initially reported only 1 failure: my sed-templating guard matched the multi-line form only, so a one-line
sed -e "s|__REPO__|...passed its own test. Widened to match the substitution expression anywhere — it now catches both.Not covered
I did not
launchctl bootstrapa rendered job — that would install a real agent on this host. Evidence is the renderer output plusplutil -lint, which is what launchd itself parses with.