Skip to main content

launchd-daemon-cli-keychain-auth-fallback

A CLI tool (claude/gh/aws/codex…) invoked by a launchd/systemd daemon fails auth with 401 while the identical command works in your interactive shell — because the daemon's security session can't read the OS keychain and silently falls back to a stale on-disk credential. Diagnose from the daemon context, not your shell.

معلومات المصدر

المستودع
blas1n/claude-skills
آخر نشاط في المصدر
٣٠ يونيو ٢٠٢٦ في ١٥:١٩
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٢
التفرعات
٠

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
name
launchd-daemon-cli-keychain-auth-fallback
description
A CLI tool (claude/gh/aws/codex…) invoked by a launchd/systemd daemon fails auth with 401 while the identical command works in your interactive shell — because the daemon's security session can't read the OS keychain and silently falls back to a stale on-disk credential. Diagnose from the daemon context, not your shell.
# Daemon-invoked CLI 401 — keychain unreachable → stale on-disk credential fallback ## Problem A long-running background daemon (macOS launchd LaunchAgent, Linux systemd service) spawns a CLI tool as a subprocess (e.g. `claude --print`, `gh`, `aws`, `codex`). The CLI fails authentication — `Failed to authenticate. API Error: 401` / `Invalid bearer token` — and the daemon's job exits non-zero. The SAME command, with the SAME flags and env, run by hand in your terminal, succeeds. - **증상**: daemon job → CLI exits 1 with a 401/auth error, every time; your interactive run of the byte-identical command succeeds. Token file on disk looks valid (future `expiresAt`), so "expired token" is ruled out. - **근본 원인**: on macOS the CLI stores its live credential in the **Keychain** (`security` item, e.g. `"Claude Code-credentials"`), and the Keychain item's ACL grants only the CLI binary. A **launchd/GUI-agent security session cannot unlock/read the login Keychain** the way an interactive Terminal session can, so the CLI silently **falls back to the on-disk credential file** (`~/.claude/.credentials.json`) — whose token was server-rotated stale long ago → 401. (Linux analog: a service unit without access to the user keyring / `$XDG_RUNTIME_DIR`/dbus session falls back to a stale `~/.config/...` file.) - **흔한 오해**: the 401-after-retries pattern reads like **rate limiting / quota / "too much concurrent load"**. It is NOT — a 401 is auth, not 429, and it's deterministic by *execution context*, not by load. Whole debugging rounds get burned chasing RPM/TPM throttle when the discriminator is daemon-vs-shell. ## Solution 1. **Reproduce from the DAEMON context, not your shell.** Your interactive reproduction is contaminated — it inherits your session's keychain access (and possibly auth env vars). Prove the failure where it actually runs: - Prepend a logging shim dir to the daemon's `PATH` (edit the plist/unit env), reload the daemon, and have the shim log argv/env/stdin and run ONE trivial control call (`echo hi | claude --print --model haiku`) before exec-ing the real binary. If the trivial control 401s too → it's the context, not the prompt/flags/rate. 2. **Confirm the keychain split.** `security find-generic-password -s "<svc>"` shows the item EXISTS; `-w` returns empty (ACL bound to the binary). The on-disk credential file token, tested raw against the API, is rejected (rotated) while the interactive CLI's keychain token works. 3. **Fix by injecting the credential via an env var the daemon passes through.** Generate a long-lived token (`claude setup-token`, a service PAT, an API key) and set it in the daemon's `EnvironmentVariables`. **Mind the env sanitizer**: a worker that strips `CLAUDE_CODE_*` from the subprocess env will drop `CLAUDE_CODE_OAUTH_TOKEN` — use a var it preserves (`ANTHROPIC_AUTH_TOKEN` sends the OAuth token as the bearer and overrides keychain). 4. Reload the daemon (`launchctl bootout`/`bootstrap`, not just `kickstart -k`, to re-read plist env) and re-verify from the daemon context. ### Durable fix (don't pin a static token) A static token in the plist expires in hours. For a stable daemon, make it **self-manage the OAuth lifecycle**: keep its OWN credential file (separate from the CLI's, so an interactive re-login can't clobber it), refresh the access token when it nears expiry, and inject the fresh token via the passed-through env var per invocation. Key points proven in production: - Refresh tokens are **single-use** — persist the rotated pair atomically and serialise refresh across processes with an `flock` (else a concurrent refresher gets `invalid_grant`). - Refresh only when within a buffer of expiry (cheap per-call check; network only every ~token-lifetime). Soft-fail to None so the CLI's own auth is the fallback. - Force-verify the refresh path live (temporarily set `expires_at` near now) — don't wait to discover at real expiry that the refresh endpoint/UA is blocked from the daemon's network context. ```bash # launchd reload that actually re-reads EnvironmentVariables: launchctl bootout gui/$(id -u)/com.x.worker launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.x.worker.plist ``` ## Key Insights - A token's local `expiresAt` being in the future does NOT mean it's valid — the server can rotate/revoke it (a refresh elsewhere rotated the family). "Not expired by clock" ≠ "accepted". - The keychain is the live store; the on-disk file is a **stale mirror** the CLI uses only as fallback. Refreshing the *file* token (even successfully, via the OAuth endpoint) does NOT help if the CLI still prefers the unreachable keychain. - `env -i HOME=… PATH=… <cli>` from your shell is NOT a faithful daemon repro — keychain access is a property of the **security session**, not the env. Only a control call spawned by the daemon itself is decisive. - `kickstart -k` restarts the process but does NOT reload plist `EnvironmentVariables`; you need bootout+bootstrap (or unload/load). ## Red Flags - "Works when I run it, fails from the cron/launchd/systemd job" + a 401/auth error (not 429/connection). - The failure is deterministic per-context and clears the moment you run it in Terminal — yet you keep theorizing about rate limits / load / concurrency. - An OS keychain item for the tool exists but `security … -w` (or `secret-tool`) returns nothing, and an on-disk credential file is present. - A worker/orchestrator sanitizes the subprocess env by prefix (`CLAUDE_CODE_*`, `AWS_*`…) — it may be stripping the very token var you need; pick a passed-through var.
عرض على GitHub