← All posts

The -A Flag That Unhung My launchd Agent

A headless agent hung forever on startup because a macOS Keychain entry was created without the right ACL. Here's the gotcha and the recipe.

  • agents
  • macos
  • keychain
  • secrets
  • launchd
  • automation

The agent just sat there. No error, no crash, no log line past “fetching token.” It was supposed to wake up on a schedule, grab a credential, and go do its job — and instead it hung, silent, until I killed it by hand.

The credential in question is a 1Password service-account token — think of it as a password that lets a script log into a vault with no human clicking anything. My scheduled agents need it in their environment for every op command they run. And I’d decided, on principle, not to leave that token sitting in a plaintext file on disk. If you’ve never thought about where secrets live: a chmod-600 file (readable only by you) feels safe, but it’s still plaintext, and any backup tool that copies your home folder happily slurps it up and ships it somewhere. The OS keystore — Keychain on macOS, libsecret on Linux — encrypts the thing at rest and controls who’s allowed to read it. That’s where it belongs.

So I stored it there. Ran the fetch by hand in my terminal — worked instantly. Wired it into the scheduler. Hung.

Here’s the part that cost me an hour. When I run security find-generic-password in my own shell, macOS sees a trusted, interactive session and hands the token over. But launchd — the macOS thing that runs jobs on a schedule — runs the agent as a different context. Same user, different trust story. And a Keychain entry created without a permissive read policy doesn’t return “denied” to that context. It blocks, waiting for a GUI prompt that will never appear because there’s no one sitting there to click “Allow.” The agent waits forever on a dialog box nobody can see.

The fix is one flag when you create the entry: -A, which means “any app owned by this user may read this without prompting.” That’s the whole bug.

The Keychain recipe and the -A gotcha give me the detail

Store it once with -A so headless readers don’t trigger a hidden prompt:

# create — the -A is the load-bearing flag
security add-generic-password \
  -s op-service-token -a "$USER" \
  -w "$OP_SERVICE_ACCOUNT_TOKEN" -A

# read — what your launchd agent runs
export OP_SERVICE_ACCOUNT_TOKEN=$(
  security find-generic-password -s op-service-token -a "$USER" -w
)

On Linux the equivalent lives in libsecret: secret-tool store --label=op-token service op account "$USER" to write, secret-tool lookup service op account "$USER" to read. systemd user services read it fine because there’s no interactive-trust gate to hang on — that particular trap is macOS-only. Rotate on either platform by overwriting the same entry; the readers never change.

The lesson I keep relearning: a secret store isn’t just where the token lives, it’s who gets to read it without a human present. Test the fetch as the exact process that will run it in production — the scheduler, the daemon, the headless context — not from your cozy interactive shell where everything is trusted. The environment that hangs is never the one you tested in.