Skip to content

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
mainfrom
fix/tahoe-tcc-cron-run-wrapper-2026-05-29
Open

fix(launchd): move cron-run.sh wrapper to ~/.local/ to escape Tahoe TCC exec block (network-database-build + 4 siblings)#341
mitwilli-create wants to merge 2 commits into
mainfrom
fix/tahoe-tcc-cron-run-wrapper-2026-05-29

Conversation

@mitwilli-create
Copy link
Copy Markdown
Owner

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 with chmod +x. Companion to the existing tahoe-sandbox-calendar-interval bug class (which covered log writes); this one covers exec-from-path.

Symptom 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 126 = "command found but not executable" — bash could stat the file but TCC blocked the exec. 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:

Plist Old ProgramArguments[1] New
delta-full-recalibration ~/Documents/career-ops/scripts/wrappers/cron-run.sh ~/.local/career-ops-wrappers/cron-run.sh
gamma-truth-audit same same
delta-ats-watch same same
network-enrich-batch same same
network-database-build same same

Plus:

  • WorkingDirectory changed from ~/Documents/career-ops/ to ~/ (matches dashboard-server-nohup-wrapper; ~/Documents/-as-cwd triggers getcwd: Operation not permitted warnings)
  • scripts/wrappers/cron-run.sh::LOG_DIR changed from $REPO/data/logs to $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:

Pre-fix Post-fix
last exit code 126 0
.err output Operation not permitted × 5 (empty for fresh run)
data/network-database.json mtime stale rebuilt at 16:53 PT
Wrapper log (never written — TCC blocked) ends with [END] network-database-build rc=0

Other 4 plists (weekly/monthly cadences — never fired before) reloaded with the new config; will fire at next scheduled time.

plutil -lint all 5 plists: OK.

Out-of-repo deployment

The deployed wrapper at ~/.local/career-ops-wrappers/cron-run.sh was created in-session by manual cp + REPO hardcoding (since $0/../.. no longer resolves to the repo when the wrapper sits outside it). 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 scripts/deploy/install-wrappers.sh that automates this. Deferred.

Test plan

  • plutil -lint all 5 plists → OK
  • launchctl kickstart -k network-database-build → exit 0, JSON rebuilt
  • Verify wrapper log written to ~/Library/Logs/career-ops/network-database-build-2026-05-29.log
  • Verify data/network-database.json mtime updated post-kickstart
  • (post-merge) verify 4 other plists succeed at their next scheduled fire time
  • (follow-up) ship install-wrappers.sh deploy script for fresh-clone scenarios

Diff size

The 5 plists show large insert/delete counts (174 / 257) because PlistBuddy reformats XML when editing. Functional changes per plist: ProgramArguments[1] path + WorkingDirectory. Verified via plutil -lint + live kickstart.

Rollback

git revert <merge-sha>
# Then manually re-bootstrap each plist:
for label in delta-full-recalibration gamma-truth-audit delta-ats-watch network-enrich-batch network-database-build; do
  launchctl bootout gui/$(id -u)/com.mitchell.career-ops.${label}
  launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mitchell.career-ops.${label}.plist
done

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

Plist Status
scan-email-poll ✅ OAuth re-auth completed earlier this session — 15 backlogged emails ingested
health-column-liveness ✅ Sibling-fixed in main today; kickstart cleared flap
phase-B-prime-daily ✅ CLI fallback shipped in PR #340
buttons-smoke ✅ Regex assertion shipped in PR #340
network-database-build THIS PR — wrapper relocation to escape Tahoe TCC

All 5 flapping plists resolved.

🤖 Generated with Claude Code

Mitchell Williams and others added 2 commits May 29, 2026 16:57
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant