問題

我想讓 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。它只用來讀公開資料,不能代替我發文、按讚或修改帳號內容。

假設

我原本的假設是:

  1. Token 交給 macOS Keychain 保存,不進入 repo 或 Prompt。
  2. Codex 與 Claude Code 都能呼叫 macOS 內建的 /usr/bin/security
  3. Token 只存在短暫、未 export 的 shell 變數中,再由 stdin 傳給 curl
  4. 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 $tokensecurity -gcurl -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.textdata.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」,而是確認最小方案的停止點:

  1. X 公開貼文使用 App-only、read-only Bearer Token。
  2. Token 由 macOS Keychain 保存,不貼給 Agent。
  3. Codex 與 Claude Code 共用 /usr/bin/security 的取用路徑。
  4. Authorization header 經 curl config stdin 傳入,而且值必須加引號。
  5. 回應解析同時檢查 textnote_tweet.textarticle.plain_text
  6. 把 HTTP 錯誤、Keychain 權限與 X 資料空值分開診斷。

對我現在的個人使用情境而言,這已經足夠。不需要 MCP,不需要 Skill,也暫時不需要把它 scale 成一個服務。