fix(launchd): move cron-run.sh wrapper to ~/.local/ to escape Tahoe TCC exec block (network-database-build + 4 siblings)#341
Open
mitwilli-create wants to merge 2 commits into
Conversation
…CC ~/Documents/ exec block
Resolves network-database-build flapping at exit 126 with
"Operation not permitted" on macOS Tahoe (25.x).
### Root cause
macOS Tahoe's launchd TCC profile blocks EXECUTING scripts located
under ~/Documents/, even with chmod +x. Pattern matches the canonical
working wrapper dashboard-server-nohup-wrapper.plist which sits the
wrapper at ~/.local/career-ops-wrappers/dashboard-server-nohup.sh
(alongside cloudflared-nohup.sh).
Symptom on every launchd-triggered run:
```
shell-init: error retrieving current directory: getcwd: cannot access
parent directories: Operation not permitted
/bin/bash: /Users/mitchellwilliams/Documents/career-ops/scripts/wrappers
/cron-run.sh: Operation not permitted
```
Exit code 126 = "command found but not executable" — bash COULD stat
the file but couldn't EXEC it (TCC denied). The wrapper runs FINE when
invoked manually from a shell with normal TCC inheritance; only the
launchd-spawned bash hits the block.
### Fix
Five plists previously invoked
`/Users/mitchellwilliams/Documents/career-ops/scripts/wrappers/cron-run.sh`.
This PR moves the wrapper invocation to
`/Users/mitchellwilliams/.local/career-ops-wrappers/cron-run.sh` (the
already-blessed location for Tahoe-safe launchd wrappers) for all five:
- delta-full-recalibration
- gamma-truth-audit
- delta-ats-watch
- network-enrich-batch
- network-database-build
Also changes WorkingDirectory from
`/Users/mitchellwilliams/Documents/career-ops` to
`/Users/mitchellwilliams` (matches dashboard-server-nohup-wrapper).
The launchd-set cwd of ~/Documents was the source of the
"job-working-directory: getcwd" warnings.
`scripts/wrappers/cron-run.sh` LOG_DIR changes from
`$REPO/data/logs` to `$HOME/Library/Logs/career-ops` — TCC also blocks
log writes from launchd to ~/Documents/.
### Deployed wrapper at ~/.local/
The wrapper at ~/.local/career-ops-wrappers/cron-run.sh exists out of
this repo and was created in-session by manual cp + REPO hardcoding.
Future fresh clones need:
mkdir -p ~/.local/career-ops-wrappers
cp ~/Documents/career-ops/scripts/wrappers/cron-run.sh \
~/.local/career-ops-wrappers/cron-run.sh
chmod +x ~/.local/career-ops-wrappers/cron-run.sh
sed -i "" 's|^REPO="\$(cd .*$|REPO="'$HOME'/Documents/career-ops"|' \
~/.local/career-ops-wrappers/cron-run.sh
Long-term follow-up: ship a scripts/deploy/install-wrappers.sh that
automates this. Deferred.
### Verification
Pre-fix: launchctl kickstart of network-database-build →
exit 126, .err full of "Operation not permitted"
Post-fix: kickstart → exit 0, data/network-database.json rebuilt at
16:53 PT, wrapper log ends with [END] network-database-build rc=0
Other 4 plists (weekly/monthly cadences) reloaded with new config;
will fire at next scheduled time.
### Bug-class entry (proposed for AGENTS.md follow-up)
tahoe-tcc-blocks-launchd-exec-from-documents — Tahoe (25.x) launchd
context cannot exec scripts located in ~/Documents/, even with
chmod +x. Companion to tahoe-sandbox-calendar-interval (which covers
log writes); this one covers exec from path.
Screenshots: .claude/audit/tahoe-tcc-cron-run-wrapper-2026-05-29/notes.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…test + AGENTS.md alt-fix + cloudflared-staging migration Closes the structural gap in the Tahoe TCC fix (PR #341 base). The 5 plists that flapped today are resolved; this commit closes the TRAP so future plists + fresh clones can't reopen it. ### Added - scripts/deploy/install-wrappers.sh — idempotent deploy script that copies scripts/wrappers/*.sh to ~/.local/career-ops-wrappers/ and hardcodes REPO inside cron-run.sh. Run on fresh clones OR any time scripts/wrappers/*.sh changes. Replaces the manual cp+sed flow that created the deployed wrappers in-session. - tests/launchd-no-documents-wrappers-invariant.test.mjs — static contract test. Reads every scripts/launchd/*.plist and asserts that no bash-wrapper ProgramArguments[1] lives under ~/Documents/. Catches the Tahoe TCC trap at PR-creation time instead of waiting for the next 03:30 PT scheduled fire to flap silently. Caught one additional plist during this session's verify pass: cloudflared-staging-nohup-wrapper (referenced wrapper at scripts/launchd/cloudflared-staging-nohup.sh, would have flapped exit 126 if bootstrapped). Migrated alongside. ### Migrated - scripts/launchd/cloudflared-staging-nohup.sh → scripts/wrappers/ cloudflared-staging-nohup.sh (consolidation; now managed by install-wrappers.sh) - scripts/launchd/com.mitchell.career-ops.cloudflared-staging-nohup- wrapper.plist: ProgramArguments[1] → ~/.local/career-ops-wrappers/ cloudflared-staging-nohup.sh ### AGENTS.md updates Extended existing § Bug class: launchd-bash-wrapper-tahoe-tcc-block with an "Alternative fix (preserves wrapper features)" subsection documenting the move-to-~/.local/ pattern. The original entry recommended dropping the bash wrapper entirely; this preserves the cadence-guard / ledger / log-routing features the wrapper provides when those are load-bearing. Also documents the deploy mechanism + regression-prevention test. ### Verification - node --test tests/launchd-no-documents-wrappers-invariant.test.mjs → 2/2 pass (invariant + script-existence check) - bash scripts/deploy/install-wrappers.sh → idempotent, deploys both cron-run.sh + cloudflared-staging-nohup.sh successfully ### Smoke-test of the 4 sibling plists (per Mitchell ask) - delta-full-recalibration: kickstart → exit 0 (cadence guard fired: monthly-first day=29 > 7 → SKIP). Wrapper layer proven under launchd. - gamma-truth-audit: kickstart → wrapper ran clean (END rc=1 in wrapper log). Exit 1 is auditor design (data quality signal), NOT TCC. Wrapper layer proven under launchd. - delta-ats-watch + network-enrich-batch: structural verification only (kickstart would spend $0.50-20). Both plists have identical wrapper/cwd config to network-database-build which we live-verified. Screenshots: .claude/audit/tahoe-tcc-cron-run-wrapper-2026-05-29/notes.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mitwilli-create
added a commit
that referenced
this pull request
May 30, 2026
…t test (F4 closure) (#342) * fix(launchd): move cron-run.sh wrapper to ~/.local/ to escape Tahoe TCC ~/Documents/ exec block Resolves network-database-build flapping at exit 126 with "Operation not permitted" on macOS Tahoe (25.x). ### Root cause macOS Tahoe's launchd TCC profile blocks EXECUTING scripts located under ~/Documents/, even with chmod +x. Pattern matches the canonical working wrapper dashboard-server-nohup-wrapper.plist which sits the wrapper at ~/.local/career-ops-wrappers/dashboard-server-nohup.sh (alongside cloudflared-nohup.sh). Symptom on every launchd-triggered run: ``` shell-init: error retrieving current directory: getcwd: cannot access parent directories: Operation not permitted /bin/bash: /Users/mitchellwilliams/Documents/career-ops/scripts/wrappers /cron-run.sh: Operation not permitted ``` Exit code 126 = "command found but not executable" — bash COULD stat the file but couldn't EXEC it (TCC denied). The wrapper runs FINE when invoked manually from a shell with normal TCC inheritance; only the launchd-spawned bash hits the block. ### Fix Five plists previously invoked `/Users/mitchellwilliams/Documents/career-ops/scripts/wrappers/cron-run.sh`. This PR moves the wrapper invocation to `/Users/mitchellwilliams/.local/career-ops-wrappers/cron-run.sh` (the already-blessed location for Tahoe-safe launchd wrappers) for all five: - delta-full-recalibration - gamma-truth-audit - delta-ats-watch - network-enrich-batch - network-database-build Also changes WorkingDirectory from `/Users/mitchellwilliams/Documents/career-ops` to `/Users/mitchellwilliams` (matches dashboard-server-nohup-wrapper). The launchd-set cwd of ~/Documents was the source of the "job-working-directory: getcwd" warnings. `scripts/wrappers/cron-run.sh` LOG_DIR changes from `$REPO/data/logs` to `$HOME/Library/Logs/career-ops` — TCC also blocks log writes from launchd to ~/Documents/. ### Deployed wrapper at ~/.local/ The wrapper at ~/.local/career-ops-wrappers/cron-run.sh exists out of this repo and was created in-session by manual cp + REPO hardcoding. Future fresh clones need: mkdir -p ~/.local/career-ops-wrappers cp ~/Documents/career-ops/scripts/wrappers/cron-run.sh \ ~/.local/career-ops-wrappers/cron-run.sh chmod +x ~/.local/career-ops-wrappers/cron-run.sh sed -i "" 's|^REPO="\$(cd .*$|REPO="'$HOME'/Documents/career-ops"|' \ ~/.local/career-ops-wrappers/cron-run.sh Long-term follow-up: ship a scripts/deploy/install-wrappers.sh that automates this. Deferred. ### Verification Pre-fix: launchctl kickstart of network-database-build → exit 126, .err full of "Operation not permitted" Post-fix: kickstart → exit 0, data/network-database.json rebuilt at 16:53 PT, wrapper log ends with [END] network-database-build rc=0 Other 4 plists (weekly/monthly cadences) reloaded with new config; will fire at next scheduled time. ### Bug-class entry (proposed for AGENTS.md follow-up) tahoe-tcc-blocks-launchd-exec-from-documents — Tahoe (25.x) launchd context cannot exec scripts located in ~/Documents/, even with chmod +x. Companion to tahoe-sandbox-calendar-interval (which covers log writes); this one covers exec from path. Screenshots: .claude/audit/tahoe-tcc-cron-run-wrapper-2026-05-29/notes.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(launchd): structural hardening — install-wrappers.sh + invariant test + AGENTS.md alt-fix + cloudflared-staging migration Closes the structural gap in the Tahoe TCC fix (PR #341 base). The 5 plists that flapped today are resolved; this commit closes the TRAP so future plists + fresh clones can't reopen it. ### Added - scripts/deploy/install-wrappers.sh — idempotent deploy script that copies scripts/wrappers/*.sh to ~/.local/career-ops-wrappers/ and hardcodes REPO inside cron-run.sh. Run on fresh clones OR any time scripts/wrappers/*.sh changes. Replaces the manual cp+sed flow that created the deployed wrappers in-session. - tests/launchd-no-documents-wrappers-invariant.test.mjs — static contract test. Reads every scripts/launchd/*.plist and asserts that no bash-wrapper ProgramArguments[1] lives under ~/Documents/. Catches the Tahoe TCC trap at PR-creation time instead of waiting for the next 03:30 PT scheduled fire to flap silently. Caught one additional plist during this session's verify pass: cloudflared-staging-nohup-wrapper (referenced wrapper at scripts/launchd/cloudflared-staging-nohup.sh, would have flapped exit 126 if bootstrapped). Migrated alongside. ### Migrated - scripts/launchd/cloudflared-staging-nohup.sh → scripts/wrappers/ cloudflared-staging-nohup.sh (consolidation; now managed by install-wrappers.sh) - scripts/launchd/com.mitchell.career-ops.cloudflared-staging-nohup- wrapper.plist: ProgramArguments[1] → ~/.local/career-ops-wrappers/ cloudflared-staging-nohup.sh ### AGENTS.md updates Extended existing § Bug class: launchd-bash-wrapper-tahoe-tcc-block with an "Alternative fix (preserves wrapper features)" subsection documenting the move-to-~/.local/ pattern. The original entry recommended dropping the bash wrapper entirely; this preserves the cadence-guard / ledger / log-routing features the wrapper provides when those are load-bearing. Also documents the deploy mechanism + regression-prevention test. ### Verification - node --test tests/launchd-no-documents-wrappers-invariant.test.mjs → 2/2 pass (invariant + script-existence check) - bash scripts/deploy/install-wrappers.sh → idempotent, deploys both cron-run.sh + cloudflared-staging-nohup.sh successfully ### Smoke-test of the 4 sibling plists (per Mitchell ask) - delta-full-recalibration: kickstart → exit 0 (cadence guard fired: monthly-first day=29 > 7 → SKIP). Wrapper layer proven under launchd. - gamma-truth-audit: kickstart → wrapper ran clean (END rc=1 in wrapper log). Exit 1 is auditor design (data quality signal), NOT TCC. Wrapper layer proven under launchd. - delta-ats-watch + network-enrich-batch: structural verification only (kickstart would spend $0.50-20). Both plists have identical wrapper/cwd config to network-database-build which we live-verified. Screenshots: .claude/audit/tahoe-tcc-cron-run-wrapper-2026-05-29/notes.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(canonicalizer): add databricks.com + queue canonical_url invariant test (F4) Closes F4 from .claude/audit/2026-05-30/task-audit-2026-05-30.md — the queue rebuilder fix that shipped earlier already calls classifyUrl() and populates canonical_url for already-canonical report URLs, but databricks.com was missing from KNOWN_ATS_HOSTS so Databricks roles (e.g. #2104 Sr Developer Advocate) fell through to unknown-host. Plus no invariant test locked the contract. Changes: - lib/jd-url-canonicalizer.mjs: add 'databricks.com' to KNOWN_ATS_HOSTS - tests/queue-rebuild-canonical-url-invariant.test.mjs (NEW): asserts every apply-now-eligible ranked row whose report **URL:** classifies as already-canonical has canonical_url populated. Resolution on fail: `node scripts/rebuild-apply-now-queue.mjs`. - test-all.mjs: wire Section 13 for the new invariant, models on Section 12. - data/apply-now-queue.json: 16 previously-missing canonical_url values populated by re-running the rebuilder (Anthropic, Sierra, Mistral, Pinecone, Databricks #2104, etc — all already-canonical ATS hosts whose canonical_url was unset until this rebuild). Verified: - Pre-rebuild test caught 16 violations. - Post-rebuild test green. - no-linkedin-urls-invariant.test.mjs still green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(rebuilder): null-replace truthy equity_stage sentinel + invariant test (Phase 3.5 Margaret Hamilton fix) Margaret Hamilton persona (HIGH-block authority) flagged this during the 2026-05-29 /deploy-verify Phase 3.5 catalog-aware persona fan-out as sentinel-string-treated-as-truthy-by-gating-predicate (HIGH). Issue: scripts/rebuild-apply-now-queue.mjs auto-add path wrote `factors.equity_stage = 'unknown — set after manual review'` for every new auto-added row. That string is JS-truthy, so any downstream gating predicate `if (row.factors.equity_stage) { ...use as known stage... }` would treat the sentinel as a real stage. No active consumer is fooled today (wealth-ranking sources equityText from overpay[].equityText, not the queue), but latent for any future consumer. Same bug class family as F297 (People-cell `name='unknown'` sentinel) + documented in AGENTS.md `### Bug class: sentinel-string-treated-as-truthy- by-gating-predicate`. Changes: - scripts/rebuild-apply-now-queue.mjs: - Auto-add writer (~line 235): `equity_stage: null` instead of sentinel - NEW Pass 0 normalizer (before Pass 1): scans ALL ranked rows incl. orphans + migrates legacy sentinels to null. Idempotent; only touches rows matching the exact sentinel pattern. - tests/no-truthy-sentinel-factors-invariant.test.mjs (NEW): asserts no ranked row carries a JS-truthy sentinel STRING in factors.equity_stage. Sentinel regex covers unknown/pending/n/a/tbd/ set after/manual review/set later/to be determined/not yet. Canonical values (Pre-IPO/Public/Series X/Late/etc.) pass. Resolution on fail: re-run rebuilder which Pass-0-normalizes. - test-all.mjs: wire Section 14 for the new invariant. - data/apply-now-queue.json: 51 ranked rows migrated sentinel → null. Verified: - Pre-fix test: 51 violations. - Post-fix test: 0 violations (58 live rows scanned). - queue-rebuild-canonical-url invariant: still green. - no-linkedin-urls invariant: still green. Note on Phase 3.5 Finding #1 (stale-regression-baseline-after-deploy re: +26 queue row growth): structurally handled by Phase 6D auto-reseed later in this same /deploy-verify procedure — baselines get re-anchored post-deploy with full provenance (commit_sha + deploy_report path). No separate co-shipped reseed required. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Mitchell Williams <mitchellwilliams@Mitchells-MacBook-Air.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Resolves the 5th of 5 CRIT flapping plists triaged today (
network-database-build, exit 126) + preemptively fixes 4 sibling plists that share the same wrapper but haven't fired yet under launchd in Tahoe.Root cause
macOS Tahoe (25.x) launchd TCC profile blocks executing scripts located under
~/Documents/, even withchmod +x. Companion to the existingtahoe-sandbox-calendar-intervalbug class (which covered log writes); this one covers exec-from-path.Symptom every launchd-triggered run:
Exit 126 = "command found but not executable" — bash could
statthe file but TCC blocked theexec. Manual invocation works fine (different TCC inheritance); only launchd-spawned bash hits the block.Fix — match the canonical working pattern
dashboard-server-nohup-wrapper.plist(the only Tahoe-working bash-wrapper plist) lives the wrapper at/Users/mitchellwilliams/.local/career-ops-wrappers/dashboard-server-nohup.sh. TCC does NOT block exec from~/.local/.This PR migrates 5 plists to the same pattern:
ProgramArguments[1]delta-full-recalibration~/Documents/career-ops/scripts/wrappers/cron-run.sh~/.local/career-ops-wrappers/cron-run.shgamma-truth-auditdelta-ats-watchnetwork-enrich-batchnetwork-database-buildPlus:
WorkingDirectorychanged from~/Documents/career-ops/to~/(matches dashboard-server-nohup-wrapper;~/Documents/-as-cwd triggersgetcwd: Operation not permittedwarnings)scripts/wrappers/cron-run.sh::LOG_DIRchanged from$REPO/data/logsto$HOME/Library/Logs/career-ops(TCC also blocks log writes from launchd to~/Documents/)Verification
launchctl kickstart -k gui/$(id -u)/com.mitchell.career-ops.network-database-build:last exit code.err outputOperation not permitted× 5data/network-database.json mtime[END] network-database-build rc=0Other 4 plists (weekly/monthly cadences — never fired before) reloaded with the new config; will fire at next scheduled time.
plutil -lintall 5 plists: OK.Out-of-repo deployment
The deployed wrapper at
~/.local/career-ops-wrappers/cron-run.shwas created in-session by manualcp+REPOhardcoding (since$0/../..no longer resolves to the repo when the wrapper sits outside it). Future fresh clones need:Long-term follow-up: ship
scripts/deploy/install-wrappers.shthat automates this. Deferred.Test plan
plutil -lintall 5 plists → OKlaunchctl kickstart -k network-database-build→ exit 0, JSON rebuilt~/Library/Logs/career-ops/network-database-build-2026-05-29.logdata/network-database.jsonmtime updated post-kickstartinstall-wrappers.shdeploy script for fresh-clone scenariosDiff size
The 5 plists show large insert/delete counts (174 / 257) because
PlistBuddyreformats XML when editing. Functional changes per plist: ProgramArguments[1] path + WorkingDirectory. Verified viaplutil -lint+ live kickstart.Rollback
Note: rollback restores the broken state. The wrapper at
~/.local/would still exist on disk (no harm — just unused).Flapping-plist surface after this PR
scan-email-pollhealth-column-livenessphase-B-prime-dailybuttons-smokenetwork-database-buildAll 5 flapping plists resolved.
🤖 Generated with Claude Code