問題
我想讓 Codex 和 Claude Code 讀取 X(Twitter)的公開貼文,但不想把 X API 的憑證貼進 Prompt、寫進專案的 .env,或讓它出現在 shell history、log 與 Agent 的回答裡。
一開始我以為這件事需要 X MCP。實際做完後,個人 Mac 上的最小方案不需要 MCP、Skill 或額外服務:把 X 的 App-only Bearer Token 存進 macOS Keychain,讓 Agent 執行固定的唯讀 API 呼叫即可。
這裡使用的不是能代表使用者發文的 OAuth user token,而是 X Developer App 的 read-only、App-only Bearer Token。它只用來讀公開資料,不能代替我發文、按讚或修改帳號內容。
假設
我原本的假設是:
- Token 交給 macOS Keychain 保存,不進入 repo 或 Prompt。
- Codex 與 Claude Code 都能呼叫 macOS 內建的
/usr/bin/security。 - Token 只存在短暫、未 export 的 shell 變數中,再由 stdin 傳給
curl。 - Agent 最後只看到 X API 的 JSON 回應,不需要在對話裡看到 Token。
資料路徑很短:
X 貼文 URL
→ 擷取 Post ID
→ macOS Keychain 讀取 Bearer Token
→ GET api.x.com/2/tweets/{id}
→ 只輸出 X API 回應
實作
1. 建立唯讀的 X Developer App
我在 X Developer Console 建立 App,使用情境只填讀取公開貼文,並保存 App-only Bearer Token。後續命令中的「API key」實際上指的就是這個 Bearer Token,而不是 API Key/API Key Secret 組合。
2. 將 Token 存進 macOS Keychain
我使用以下命令建立一筆 Generic Password:
/usr/bin/security add-generic-password \
-a "chiu" \
-s "com.chiu.x-api.readonly-bearer" \
-l "X API read-only Bearer Token" \
-U \
-T "" \
-w
執行後出現:
password data for new item:
這是正常提示。此時貼上 Bearer Token 並按 Enter;終端機不會顯示輸入內容。
各參數的用途:
-a "chiu":Keychain item 的 account。-s "com.chiu.x-api.readonly-bearer":穩定且可查找的 service identifier。-l:在 Keychain Access 裡顯示的名稱。-U:同一個 account/service 已存在時更新,不另外新增重複項目。-T "":建立時不自動信任建立這筆資料的程式。- 最後的
-w:不把密碼放在 command arguments,而是由互動提示輸入。
只確認項目存在、不讀出 Token,可以執行:
/usr/bin/security find-generic-password \
-a "chiu" \
-s "com.chiu.x-api.readonly-bearer"
3. 理解 macOS 的「永遠允許」到底允許誰
第一次真正讀取 Token 時,macOS 會跳出 Keychain 授權視窗。我最後選了「永遠允許」,再到 Keychain Access 的「取用權限控制」確認授權對象。
重要的是:被永久允許的不是「Codex」或「Claude Code」這兩個 Agent,而是它們共同呼叫的 /usr/bin/security。
這正是兩個 Agent 都能使用同一筆 Token 的原因,但也是這個方案的安全邊界:同一個 macOS 使用者底下,只要某個程序能執行 /usr/bin/security,理論上就可能主動取出這筆 Token。它適合個人、本機、唯讀實驗;它不是強隔離的 Secret Broker。
4. 用不輸出 Token 的方式呼叫 X API
以下是這次實驗採用的核心寫法。POST_ID 換成 X URL 中 /status/ 後面的數字:
(
set +x
token="$(/usr/bin/security find-generic-password \
-a "chiu" \
-s "com.chiu.x-api.readonly-bearer" \
-w)" || exit 21
trap 'unset token' EXIT HUP INT TERM
builtin printf 'header = "Authorization: Bearer %s"\n' "$token" |
/usr/bin/curl --silent --show-error --fail-with-body --get \
--url "https://api.x.com/2/tweets/POST_ID" \
--data-urlencode "tweet.fields=article,author_id,created_at,note_tweet,entities,attachments,referenced_tweets" \
--data-urlencode "expansions=author_id,attachments.media_keys,referenced_tweets.id" \
--data-urlencode "user.fields=name,username,verified" \
--data-urlencode "media.fields=type,url,preview_image_url,alt_text" \
--config -
)
這段命令的幾個關鍵:
token沒有export,只活在一次性的 subshell。set +x避免 shell tracing 展開敏感值。builtin printf明確使用 shell builtin,不把 Token 當成外部printf程序的 argument。curl --config -從 stdin 讀取 Authorization header;header 不在curl的 command arguments。- subshell 結束時
trap清除變數。 - 不使用
echo $token、security -g、curl -v或把 header 寫進暫存檔。
這次 Claude Code 的第一次呼叫得到 401。原因不是 Token 失效,而是 curl config 裡的 header 沒有加引號,空白讓內容被截斷。固定使用下面這種格式後,第二次立即得到 200:
header = "Authorization: Bearer ..."
如果要檢查 API 回應檔是否意外含有 Token,也不能寫成:
grep -F "$token" response.json
因為這會讓 Token 短暫出現在 grep 的 process arguments。一定要檢查時,pattern 也要走 stdin:
builtin printf '%s\n' "$token" |
/usr/bin/grep -Fq -f - response.json
不過最小方案其實不需要反覆拿 Secret 自我比對;只要資料流固定、關閉 verbose/trace,並且不保存 Authorization header,就能減少 Secret 被更多程序碰觸的機會。
發現
Codex 與 Claude Code 都跑通了
我分別讓 Codex 與 Claude Code 使用同一個 Keychain item 呼叫 X API,兩邊都成功取得 HTTP 200。這證明在我的 macOS 環境中,不需要替每個 Agent 各存一份 Token,也不需要先安裝 X MCP。
X 的長文至少有兩種資料形態
一般貼文預設看 data.text,但長內容不能只看這個欄位:
- 一般長貼文可能把完整內容放在
data.note_tweet.text,data.text仍是被截斷的版本。 - X Article 的完整內容通常在
data.article.plain_text。
Claude Code 測試的一篇列舉十個美股資訊網站的長貼文,就是 note_tweet.text 有完整 807 字元,而主 text 只有被截斷的 204 字元。它沒有 article key,因為那不是 X Article。
API 成功不代表每篇 Article 都會有完整本文
另一個重要發現是:同一個 Token、endpoint 與 query fields 下,一篇 Dan Koe Article 能正常取得完整 article.plain_text,但另一篇公開可見的 Article 卻回傳空字串。後者的完整內容仍存在於 X 公開網頁中。
這代表「Article 內文為空」不能直接診斷成 Keychain、Token 或權限失敗。至少要分開判斷:
- Keychain item 找不到或未獲授權。
- X API 回傳 401/403。
- HTTP 200,但
article.plain_text為空。 - 貼文其實是
note_tweet,本來就不會有article。
受限環境也可能造成誤診
Codex Desktop 的受限子程序曾經在正確的 login keychain 中搜尋不到同一筆 item;取得額外執行權限後,相同路徑就成功回傳 200。這不能被說成「Token 消失」,而應該標記成執行環境或 Keychain search context 不同。
安全邊界
這套作法達成的是:
- Token 不需要貼進 Prompt。
- Token 不進 repo、
.env、一般 log 或 API 回應。 - Token 不出現在
curl的 process arguments。 - Codex 與 Claude Code 可以共用同一個 Keychain item。
它沒有達成的是:
- 阻止同帳號下的惡意程序刻意呼叫已獲授權的
/usr/bin/security。 - 讓 Agent 在作業系統層「絕對不可能」碰到 Token。
- 修正 X API 自己漏回 Article 內文的資料問題。
所以「Agent 看不到 Token」比較精確的說法是:Token 不會進入 Agent 的對話上下文與正常輸出,但 Agent 所操作的本機程序仍具備向 Keychain 取用它的能力。
如果未來需要把 Token 真正隔離,下一步才是做一個簽署過的 x-read CLI:它在內部讀 Keychain,只接受 X Post ID、只允許固定的 GET endpoint,而且永遠不提供輸出原始 Token 的功能。現階段只是少量讀公開貼文,先做這層會過度設計。
Takeaway
這次實驗最有價值的不是「成功呼叫一次 API」,而是確認最小方案的停止點:
- X 公開貼文使用 App-only、read-only Bearer Token。
- Token 由 macOS Keychain 保存,不貼給 Agent。
- Codex 與 Claude Code 共用
/usr/bin/security的取用路徑。 - Authorization header 經 curl config stdin 傳入,而且值必須加引號。
- 回應解析同時檢查
text、note_tweet.text與article.plain_text。 - 把 HTTP 錯誤、Keychain 權限與 X 資料空值分開診斷。
對我現在的個人使用情境而言,這已經足夠。不需要 MCP,不需要 Skill,也暫時不需要把它 scale 成一個服務。