<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Handson on Tarragon</title><link>https://tarrragon.github.io/blog/tags/handson/</link><description>Recent content in Handson on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 08 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/handson/index.xml" rel="self" type="application/rss+xml"/><item><title>遠端 agent 工作機實作記錄：從 Docker image 到手機端跑通</title><link>https://tarrragon.github.io/blog/linux/tools/remote/agent-workstation-vm-handson/</link><pubDate>Wed, 08 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/linux/tools/remote/agent-workstation-vm-handson/</guid><description>&lt;p>本文是 &lt;a href="../agent-workstation-home-vs-vps/">遠端 agent 工作機選型&lt;/a> 的實作篇：把該文推導出的三層架構（連線＝怎麼接上遠端、session＝工作怎麼在斷線後存活、隔離＝agent 在哪個受限環境裡跑）在一台 UTM Arch Linux ARM VM 上完整架起來、直到手機端能丟任務、斷線、收通知、回來看結果。十個步驟與三個端到端情境都經實機跑通、指令與輸出是實跑結果、每步的除錯判讀記的是實測踩到的狀況。這份記錄也是 &lt;a href="../remote-agent-paved-road/">把遠端 agent 工作機鋪成一條路&lt;/a> 那條 on-ramp 的端到端驗證落地；還沒看過整條路順序總覽的，先看那篇再回來對照本篇的實機細節。&lt;/p>
&lt;p>這份記錄跑在一組特定環境上——&lt;strong>宿主機 macOS + UTM、VM 是 Arch Linux ARM、手機是 Android（Termius）&lt;/strong>——指令因此帶環境相依，換環境要換做法：套件管理用 &lt;code>pacman&lt;/code>（Debian / Ubuntu VM 對應 &lt;code>apt&lt;/code>，Step 6 那條「partial upgrade 升 kernel → 未重開 → docker 起不來」的 gotcha 是 Arch 專屬、其他發行版不會遇到）；宿主機層的 UTM 操作只適用 macOS（Linux host 改用 QEMU / virt-manager、Windows 用 Hyper-V / WSL2，NAT 穿透的直連 / 中繼結果也可能不同）。VM 本身怎麼建（裝虛擬化軟體、灌發行版、分割磁碟）是這篇的上游、見 &lt;a href="../../../install/">Linux 安裝&lt;/a>；本篇從「VM 已存在且會開機」起步。&lt;/p>
&lt;p>每一步固定四段：&lt;strong>概念與工具&lt;/strong>（這步在架構裡承擔什麼、細節連到對應文章）、&lt;strong>實作&lt;/strong>（具體動作）、&lt;strong>驗證&lt;/strong>（這步成功的可觀測判準）、&lt;strong>除錯判讀&lt;/strong>（失敗症狀怎麼分流）。寫法對齊 &lt;a href="../../../install/unattended-remote-work/">讓機器跑無人值守的長任務&lt;/a> 的障礙拆解、與 &lt;a href="../../../dotfile/vm-hyprland-handson-record/">vm-hyprland 實作記錄&lt;/a> 的邊做邊記形式。&lt;/p>
&lt;h2 id="全局圖與步驟總表">全局圖與步驟總表&lt;/h2>
&lt;p>目標狀態：手機 → Tailscale 私網 → mosh 進 VM → zellij session → container 內的 Claude Code；跑完由 hooks 推 ntfy 通知回手機。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>步驟&lt;/th>
 &lt;th>架構層&lt;/th>
 &lt;th>產出物&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1. 前置盤點&lt;/td>
 &lt;td>——&lt;/td>
 &lt;td>三台裝置與帳號的現況清單&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2. VM 基線可連入&lt;/td>
 &lt;td>連線層之下&lt;/td>
 &lt;td>SSH 金鑰登入成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3. Tailscale 打通私網&lt;/td>
 &lt;td>連線層&lt;/td>
 &lt;td>手機與 VM 互 ping 得到&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4. mosh 補連線手感&lt;/td>
 &lt;td>連線層&lt;/td>
 &lt;td>漫遊不斷線的互動 shell&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5. zellij 常駐 session&lt;/td>
 &lt;td>session 層&lt;/td>
 &lt;td>detach / attach 後任務仍在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>6. Dockerfile 建工作環境&lt;/td>
 &lt;td>隔離層&lt;/td>
 &lt;td>可重建的 agent 工作環境 image&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>7. Claude Code 落地與憑證&lt;/td>
 &lt;td>隔離層&lt;/td>
 &lt;td>container 重建後免重新登入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>8. hooks 接 ntfy 通知&lt;/td>
 &lt;td>通知&lt;/td>
 &lt;td>任務結束手機收到推播&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>9. 手機端連線與輸入&lt;/td>
 &lt;td>行動端&lt;/td>
 &lt;td>手機可操作、按鍵齊全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>10. 端到端驗收&lt;/td>
 &lt;td>全部&lt;/td>
 &lt;td>三個情境全數通過&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="step-1前置盤點">Step 1：前置盤點&lt;/h2>
&lt;h3 id="概念與工具">概念與工具&lt;/h3>
&lt;p>實作前先固定三台裝置的現況、把「環境不明」從除錯變數裡排除：宿主機（跑 VM 的機器）、VM 本體、手機。未知機器的盤點方法見 &lt;a href="../../../install/inventory-unknown-machine/">盤點一台不明機器&lt;/a>。&lt;/p>
&lt;h3 id="實作">實作&lt;/h3>
&lt;ul>
&lt;li>記錄宿主機平台、VM 的發行版與資源配額（vCPU / RAM / 磁碟）、VM 網路模式（NAT / bridged）&lt;/li>
&lt;li>記錄手機平台與要用的 client 候選&lt;/li>
&lt;li>確認 Tailscale 帳號與 ntfy 的 topic 規劃（私密值遵守只放佔位、真值不進 git 的原則）&lt;/li>
&lt;/ul>
&lt;p>本次實測環境盤點結果：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>實測值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>宿主機&lt;/td>
 &lt;td>macOS（Apple Silicon）、UTM QEMU 跑 VM&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>發行版&lt;/td>
 &lt;td>Arch Linux ARM（aarch64）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>kernel&lt;/td>
 &lt;td>&lt;code>7.1.2-2-aarch64-ARCH&lt;/code>（session 起始，實作中因升級變 &lt;code>7.1.3-1&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>vCPU&lt;/td>
 &lt;td>4&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RAM&lt;/td>
 &lt;td>3.8 GiB（起始可用約 2.0 GiB）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>磁碟&lt;/td>
 &lt;td>&lt;code>/dev/vda4&lt;/code> 37 GB、已用 7.8 GB、可用 27 GB（23%）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>網路模式&lt;/td>
 &lt;td>NAT（UTM Shared Network）、&lt;code>enp0s1&lt;/code> 192.168.64.6/24、閘道 .64.1（DHCP）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>已裝工具&lt;/td>
 &lt;td>zellij 0.44.3、git、curl&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>待裝工具&lt;/td>
 &lt;td>tailscale、mosh、docker&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>手機端&lt;/td>
 &lt;td>由 client 選型段決定（本輪走現成 client）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>kernel 版本在盤點時記下、成了後面 docker 除錯的關鍵對照值：實作過程中一次 &lt;code>pacman -Syu&lt;/code> 把 kernel 從 &lt;code>7.1.2-2&lt;/code> 升到 &lt;code>7.1.3-1&lt;/code>，而「執行中版本 vs 磁碟版本」的落差正是 Step 6 docker 起不來的根因（見該步除錯判讀）。盤點因此不是一次性動作——關鍵狀態值要能隨時回讀比對。&lt;/p></description><content:encoded><![CDATA[<p>本文是 <a href="../agent-workstation-home-vs-vps/">遠端 agent 工作機選型</a> 的實作篇：把該文推導出的三層架構（連線＝怎麼接上遠端、session＝工作怎麼在斷線後存活、隔離＝agent 在哪個受限環境裡跑）在一台 UTM Arch Linux ARM VM 上完整架起來、直到手機端能丟任務、斷線、收通知、回來看結果。十個步驟與三個端到端情境都經實機跑通、指令與輸出是實跑結果、每步的除錯判讀記的是實測踩到的狀況。這份記錄也是 <a href="../remote-agent-paved-road/">把遠端 agent 工作機鋪成一條路</a> 那條 on-ramp 的端到端驗證落地；還沒看過整條路順序總覽的，先看那篇再回來對照本篇的實機細節。</p>
<p>這份記錄跑在一組特定環境上——<strong>宿主機 macOS + UTM、VM 是 Arch Linux ARM、手機是 Android（Termius）</strong>——指令因此帶環境相依，換環境要換做法：套件管理用 <code>pacman</code>（Debian / Ubuntu VM 對應 <code>apt</code>，Step 6 那條「partial upgrade 升 kernel → 未重開 → docker 起不來」的 gotcha 是 Arch 專屬、其他發行版不會遇到）；宿主機層的 UTM 操作只適用 macOS（Linux host 改用 QEMU / virt-manager、Windows 用 Hyper-V / WSL2，NAT 穿透的直連 / 中繼結果也可能不同）。VM 本身怎麼建（裝虛擬化軟體、灌發行版、分割磁碟）是這篇的上游、見 <a href="../../../install/">Linux 安裝</a>；本篇從「VM 已存在且會開機」起步。</p>
<p>每一步固定四段：<strong>概念與工具</strong>（這步在架構裡承擔什麼、細節連到對應文章）、<strong>實作</strong>（具體動作）、<strong>驗證</strong>（這步成功的可觀測判準）、<strong>除錯判讀</strong>（失敗症狀怎麼分流）。寫法對齊 <a href="../../../install/unattended-remote-work/">讓機器跑無人值守的長任務</a> 的障礙拆解、與 <a href="../../../dotfile/vm-hyprland-handson-record/">vm-hyprland 實作記錄</a> 的邊做邊記形式。</p>
<h2 id="全局圖與步驟總表">全局圖與步驟總表</h2>
<p>目標狀態：手機 → Tailscale 私網 → mosh 進 VM → zellij session → container 內的 Claude Code；跑完由 hooks 推 ntfy 通知回手機。</p>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>架構層</th>
          <th>產出物</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1. 前置盤點</td>
          <td>——</td>
          <td>三台裝置與帳號的現況清單</td>
      </tr>
      <tr>
          <td>2. VM 基線可連入</td>
          <td>連線層之下</td>
          <td>SSH 金鑰登入成功</td>
      </tr>
      <tr>
          <td>3. Tailscale 打通私網</td>
          <td>連線層</td>
          <td>手機與 VM 互 ping 得到</td>
      </tr>
      <tr>
          <td>4. mosh 補連線手感</td>
          <td>連線層</td>
          <td>漫遊不斷線的互動 shell</td>
      </tr>
      <tr>
          <td>5. zellij 常駐 session</td>
          <td>session 層</td>
          <td>detach / attach 後任務仍在</td>
      </tr>
      <tr>
          <td>6. Dockerfile 建工作環境</td>
          <td>隔離層</td>
          <td>可重建的 agent 工作環境 image</td>
      </tr>
      <tr>
          <td>7. Claude Code 落地與憑證</td>
          <td>隔離層</td>
          <td>container 重建後免重新登入</td>
      </tr>
      <tr>
          <td>8. hooks 接 ntfy 通知</td>
          <td>通知</td>
          <td>任務結束手機收到推播</td>
      </tr>
      <tr>
          <td>9. 手機端連線與輸入</td>
          <td>行動端</td>
          <td>手機可操作、按鍵齊全</td>
      </tr>
      <tr>
          <td>10. 端到端驗收</td>
          <td>全部</td>
          <td>三個情境全數通過</td>
      </tr>
  </tbody>
</table>
<h2 id="step-1前置盤點">Step 1：前置盤點</h2>
<h3 id="概念與工具">概念與工具</h3>
<p>實作前先固定三台裝置的現況、把「環境不明」從除錯變數裡排除：宿主機（跑 VM 的機器）、VM 本體、手機。未知機器的盤點方法見 <a href="../../../install/inventory-unknown-machine/">盤點一台不明機器</a>。</p>
<h3 id="實作">實作</h3>
<ul>
<li>記錄宿主機平台、VM 的發行版與資源配額（vCPU / RAM / 磁碟）、VM 網路模式（NAT / bridged）</li>
<li>記錄手機平台與要用的 client 候選</li>
<li>確認 Tailscale 帳號與 ntfy 的 topic 規劃（私密值遵守只放佔位、真值不進 git 的原則）</li>
</ul>
<p>本次實測環境盤點結果：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>實測值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>宿主機</td>
          <td>macOS（Apple Silicon）、UTM QEMU 跑 VM</td>
      </tr>
      <tr>
          <td>發行版</td>
          <td>Arch Linux ARM（aarch64）</td>
      </tr>
      <tr>
          <td>kernel</td>
          <td><code>7.1.2-2-aarch64-ARCH</code>（session 起始，實作中因升級變 <code>7.1.3-1</code>）</td>
      </tr>
      <tr>
          <td>vCPU</td>
          <td>4</td>
      </tr>
      <tr>
          <td>RAM</td>
          <td>3.8 GiB（起始可用約 2.0 GiB）</td>
      </tr>
      <tr>
          <td>磁碟</td>
          <td><code>/dev/vda4</code> 37 GB、已用 7.8 GB、可用 27 GB（23%）</td>
      </tr>
      <tr>
          <td>網路模式</td>
          <td>NAT（UTM Shared Network）、<code>enp0s1</code> 192.168.64.6/24、閘道 .64.1（DHCP）</td>
      </tr>
      <tr>
          <td>已裝工具</td>
          <td>zellij 0.44.3、git、curl</td>
      </tr>
      <tr>
          <td>待裝工具</td>
          <td>tailscale、mosh、docker</td>
      </tr>
      <tr>
          <td>手機端</td>
          <td>由 client 選型段決定（本輪走現成 client）</td>
      </tr>
  </tbody>
</table>
<p>kernel 版本在盤點時記下、成了後面 docker 除錯的關鍵對照值：實作過程中一次 <code>pacman -Syu</code> 把 kernel 從 <code>7.1.2-2</code> 升到 <code>7.1.3-1</code>，而「執行中版本 vs 磁碟版本」的落差正是 Step 6 docker 起不來的根因（見該步除錯判讀）。盤點因此不是一次性動作——關鍵狀態值要能隨時回讀比對。</p>
<h3 id="驗證">驗證</h3>
<p>盤點表填完、每一項都有實際值而非「應該是」。</p>
<h3 id="除錯判讀">除錯判讀</h3>
<p>這一步的失敗形態是「以為知道」：VM 網路模式記錯會讓 Step 3 的連線除錯走錯方向。解法是每項都用指令回讀、以權威狀態為準，方法論見 <a href="../../../debug/diagnosis-read-authoritative-state/">診斷讀權威狀態</a>。</p>
<h2 id="step-2vm-基線可連入">Step 2：VM 基線可連入</h2>
<h3 id="概念與工具-1">概念與工具</h3>
<p>後續所有步驟都透過 SSH 進 VM 操作，這步先把「進得去」建立成基線。金鑰登入的 bootstrap 流程見 <a href="../../../install/ssh-keyless-bootstrap/">SSH 免密碼登入 bootstrap</a>。</p>
<h3 id="實作-1">實作</h3>
<ul>
<li>宿主機（或同網段機器）以金鑰 SSH 進 VM</li>
</ul>
<p>本次 VM 的金鑰登入在先前 session 已 bootstrap 完成（Mac 端 <code>~/.ssh/id_ed25519</code>、公鑰已進 VM 的 <code>authorized_keys</code>），這步只驗證基線仍成立：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ ssh tar@192.168.64.6 &#39;echo CONNECTED; whoami&#39;
</span></span><span class="line"><span class="ln">2</span><span class="cl">CONNECTED
</span></span><span class="line"><span class="ln">3</span><span class="cl">tar</span></span></code></pre></div><p>一行指令即登入、無密碼提示。後續每一步的 VM 操作都透過這條 SSH 通道下達（<code>ssh tar@192.168.64.6 '&lt;cmd&gt;'</code>），把「進得去」從變數表移除。</p>
<h3 id="驗證-1">驗證</h3>
<p>從宿主機一行指令登入成功、免輸入密碼；重開 VM 後仍成立。實測登入即回 <code>CONNECTED</code>、免密碼。</p>
<h3 id="除錯判讀-1">除錯判讀</h3>
<p>連不上先分層：機器沒起、網路不通、sshd 沒跑、認證失敗是四個不同層的問題，分流見 <a href="../../../debug/machine-unreachable/">機器連不到或起不來</a> 與 <a href="../../../debug/ssh-and-terminal-troubleshooting/">SSH 與終端機問題排查</a>。</p>
<h2 id="step-3tailscale-打通私網">Step 3：Tailscale 打通私網</h2>
<h3 id="概念與工具-2">概念與工具</h3>
<p>這步把「可達性」從 VM 的網路模式與家用 IP 解耦：VM 與手機加入同一個 tailnet 之後，手機用私網位址找到 VM、跟宿主機網段與公網 IP 都無關。原理與取捨見 <a href="../connection-and-sync-tools/">遠端連線與同步工具選型</a> 的網路層段、決策層判讀見 <a href="../agent-workstation-home-vs-vps/">選型文的浮動 IP 段</a>。tailnet、DERP 中繼 vs 直連、<code>tailscale status</code> 判讀的完整機制見 <a href="../tailscale-tailnet-and-relay/">Tailscale tailnet 與中繼</a>。</p>
<h3 id="實作-2">實作</h3>
<ul>
<li>VM 安裝 tailscaled、登入 tailnet</li>
<li>手機裝 Tailscale app、登入同一 tailnet</li>
</ul>
<p>VM 端安裝 tailscale（1.98.8）、啟用 daemon、<code>tailscale up</code> 取得 auth URL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ sudo systemctl enable --now tailscaled
</span></span><span class="line"><span class="ln">2</span><span class="cl">$ sudo tailscale up --hostname=agent-vm
</span></span><span class="line"><span class="ln">3</span><span class="cl">To authenticate, visit:
</span></span><span class="line"><span class="ln">4</span><span class="cl">	https://login.tailscale.com/a/…       # ← 在另一台裝置的瀏覽器開這個 URL 完成登入</span></span></code></pre></div><p>headless 機器沒有瀏覽器、<code>tailscale up</code> 會印一個 auth URL、在任何已登入該 tailnet 帳號的裝置上開它、核准這個節點即上線。認證後 VM 取得 tailnet 位址 <code>100.68.144.88</code>、主機名 <code>agent-vm</code>。</p>
<h3 id="驗證-2">驗證</h3>
<ul>
<li>手機在行動網路（非家用 Wi-Fi）下 ping 得到 VM 的 tailnet 位址</li>
<li>VM 端 <code>tailscale status</code> 看得到手機裝置</li>
<li>家用網路換 IP（或模擬：重啟光貓）後，上述兩項仍成立</li>
</ul>
<p>VM 端 <code>tailscale status</code> 認證後即列出 tailnet 全部裝置（VM <code>agent-vm</code>、Mac、iPad 等），登入這關通過。</p>
<p>跨裝置連通實測先從同帳號的 Mac 驗（Mac 也在 tailnet 上）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ ping -c3 100.68.144.88
</span></span><span class="line"><span class="ln">2</span><span class="cl">64 bytes from 100.68.144.88: … time=77.6 ms   # 0% loss、通
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ tailscale status | grep agent-vm
</span></span><span class="line"><span class="ln">4</span><span class="cl">100.68.144.88  agent-vm  …  active; relay &#34;hkg&#34;   # ← 走 DERP 中繼、非直連</span></span></code></pre></div><p>VM 就跑在這台 Mac 上（UTM）、物理上同一條區網，tailscale 卻走香港 DERP 中繼、不是直連（77 ms 而非區網的 sub-ms）。原因是 UTM 的 NAT（Shared Network）讓 tailscale 的 NAT 穿透建不起直連、退回中繼。功能完全可用（能連、能傳），只是延遲被中繼繞路放大——正是下面除錯判讀說的「中繼通但直連失敗」情境的實例。</p>
<p>手機側在 Step 4 / Step 9 的手機連線過程一併驗證：手機（Android、Galaxy A70）在<strong>行動網路</strong>下透過 tailnet 連上 VM、<code>tailscale status</code> 兩邊互見。而且觀察到路徑會依實體網路自動選擇——手機走家用 Wi-Fi 時是 DERP 中繼、切到行動網路後 tailscale 建起<strong>直連</strong>（<code>direct &lt;行動網路公網 IP&gt;</code>）。可見 tailnet 位址與可達性跟實體網路解耦：換網路只換底層路徑、tailnet 位址不變。</p>
<h3 id="除錯判讀-2">除錯判讀</h3>
<p>實測命中「看得到但走 DERP 中繼」這個分流：<code>tailscale status</code> 的裝置那行標 <code>relay &quot;hkg&quot;</code>、而非直連的 peer 位址，代表直連沒建起來、走了中繼。判讀鏈：tailnet 裝置清單看不到對方 → 登入 / 帳號問題；看得到、<code>ping</code> 通但延遲偏高且標 <code>relay</code> → NAT 穿透失敗退回中繼（本次 UTM NAT 就是這情況、功能可用只是慢）；看得到但 <code>ping</code> 完全不通 → 才往防火牆 / ACL 方向查。中繼可用時不必急著修直連——除非延遲影響到逐鍵互動的體感，否則中繼是可接受的退路。</p>
<p>還有一個從手機端連線時實測踩到的症狀：<strong>手機端 client 連 VM 的 tailnet 位址回「connection timed out」。</strong> 逾時是可達性層的訊號（封包送不到）、不是服務層的訊號（送達但被拒絕才是「連線被拒」）——所以症狀本身就把方向指向網路可達性，而不是 VM 的 sshd、防火牆或 client 設定。<code>100.68.144.88</code> 是 tailnet 私網位址、只有在同一個 tailnet 上的裝置才路由得到，手機的 Tailscale 若沒連上（app 沒開、開關沒打開、登入到別的帳號），這個位址對手機根本不存在、封包送不出去。從 VM 側 <code>tailscale status</code> 看那台手機在不在清單、是不是 <code>offline</code> 就能定位：手機沒出現或標 offline，問題就在手機端進 tailnet 這關、不在 VM。這條把「連線被拒 vs 連線逾時」當分岔點：前者往服務 / 認證層查、後者往網路可達性層查，兩者的除錯方向相反（通用判別見 <a href="/blog/linux/dotfile/knowledge-cards/connection-refused-vs-timeout/" data-link-title="Connection Refused vs. Timeout（連線被拒與逾時）" data-link-desc="遠端連不上、要判斷是封包到不了還是服務拒絕、決定往網路層還是服務層除錯時回來讀">連線逾時 vs 連線被拒</a>）。VM 側要先自證清白——<code>sudo ss -tlnp | grep :22</code> 確認 sshd 在聽、<code>tailscale status</code> 確認自己上線，把 VM 這層從嫌疑名單劃掉，再回頭要求手機端進 tailnet。</p>
<h2 id="step-4mosh-補連線手感">Step 4：mosh 補連線手感</h2>
<h3 id="概念與工具-3">概念與工具</h3>
<p>mosh 在連線層補兩個 SSH 的弱點：手機切網路不斷線（UDP 漫遊）、高 RTT 下打字順（本地回顯預測）。SSH 為何一換 IP 就斷、mosh 為何用 UDP 繞過見 <a href="/blog/linux/dotfile/knowledge-cards/tcp-connection-roaming/" data-link-title="TCP Connection Roaming（連線與漫遊）" data-link-desc="遠端連線一換網路（Wi-Fi 切行動網路、休眠喚醒、換 IP）就斷、想知道為什麼 SSH 扛不住而 mosh 撐得住時回來讀">TCP 連線與漫遊</a>；本地回顯預測的機制與 CJK 代價見 <a href="/blog/linux/dotfile/knowledge-cards/mosh-local-echo-prediction/" data-link-title="Mosh Local Echo Prediction（本地回顯預測）" data-link-desc="高延遲下 mosh 打字為何即時、它跟中文（雙寬字）顯示為何衝突、以及怎麼確認 client 真的走 mosh 而非退回 SSH 時回來讀">mosh 本地回顯預測</a>；機制與代價（UDP port、無 port forwarding）的選型面見 <a href="../connection-and-sync-tools/">遠端連線與同步工具選型</a> 的 mosh 段。</p>
<h3 id="實作-3">實作</h3>
<ul>
<li>VM 安裝 mosh、確認 UDP port 範圍放行（走 tailnet 的話防火牆範圍縮到 tailscale 介面）</li>
</ul>
<p>VM 端安裝：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ sudo pacman -S --needed mosh
</span></span><span class="line"><span class="ln">2</span><span class="cl">$ mosh-server --version
</span></span><span class="line"><span class="ln">3</span><span class="cl">mosh-server (mosh 1.4.0) [build mosh-1.4.0-dirty]</span></span></code></pre></div><p>VM 目前沒有啟用防火牆（NAT 後面、只對 tailnet 暴露的規劃在 Step 3），mosh 預設 UDP port 範圍 60000-61000 無需額外放行；日後在 VM 上啟防火牆時，要把這段 UDP 範圍限定到 tailscale 介面而非對全世界開。client 端連線指令與漫遊實測併入 Step 9 手機端一起做（mosh 的價值要在真實網路切換下才顯現，在同網段 SSH 通道裡看不出差異）。</p>
<h3 id="驗證-3">驗證</h3>
<ul>
<li>手機用 mosh 連入後、Wi-Fi 切行動網路，session 存活、免重連</li>
<li>高延遲下打字即時回顯（體感判準：按鍵顯示追得上輸入）</li>
</ul>
<p>實測用 Termius（Android、本輪未見任何 Pro / 付費提示）開 mosh 連入，在 zellij session 裡跑一個每秒印時間戳的迴圈、然後把手機從 Wi-Fi 切到行動網路：時間戳<strong>無斷檔</strong>（沒有跳掉任何一秒、輸出連續），但切換當下畫面<strong>凍結約 3 秒</strong>才恢復更新、不需手動重連。這是 mosh 漫遊的真實體感、要據實描述——它的價值是「不丟狀態、自動接回」，不是「零延遲、感覺不到切換」：那 3 秒是 tailscale 重建路徑加 mosh 重新同步的時間，恢復後前面的輸出一格不少。跟純 SSH 的對照才是關鍵：mosh 是凍 3 秒後無損接回、純 SSH 是直接斷線讓前景任務死。</p>
<p>要確認「真的走 mosh、不是退回 SSH」不能只信 client 說已連線、要看 server 側的權威狀態：mosh 運作時 VM 上會有 <code>mosh-server</code> 程序、而 SSH 連線在 spawn 完 mosh-server 後就關閉——所以 mosh 活著時 <code>ss -tnp</code> 反而看<strong>不到</strong>該 client 的 TCP:22。實測 VM 上 <code>ps aux | grep mosh-server</code> 看到 <code>mosh-server new -s -l LANG=…</code>、同時 <code>ss</code> 看不到手機的 SSH 連線，這個組合才是 mosh 生效的簽章。</p>
<p>第一條連線的快照曾看到手機的 <code>ESTAB … :22</code>、沒有 mosh-server（那條是 SSH），若就此下「Termius 退回 SSH」的結論會下太快——重連後 mosh 才正確接管、<code>mosh-server</code> 才出現。client 用不用 mosh 是會變的狀態，要以 server 側程序為準、且不能只查一次。</p>
<p>漫遊當下還觀察到網路層的協作：手機從 Wi-Fi（<code>tailscale status</code> 標走 <code>relay &quot;hkg&quot;</code>）切到行動網路後、tailscale 變成<strong>直連</strong>（<code>direct &lt;行動網路公網 IP&gt;</code>）。tailscale 在網路層保持 tailnet IP 不變並重建底層路徑、mosh 在連線層用 UDP 扛住端點變化，兩層疊起來才有「切網無感」。</p>
<h3 id="除錯判讀-3">除錯判讀</h3>
<p>安裝階段實測踩到一個跟 mosh 本身無關、但會擋住安裝的狀況：<code>pacman -S mosh</code> 中途對某個相依套件（<code>python-absl</code>）回 HTTP 404、<code>failed to retrieve some files</code>。根因不是網路、是本地套件資料庫過時——記錄的版本在 mirror 上已被新版取代（Arch 的 partial upgrade 陷阱）。關鍵在錯誤字串：「404 檔案不存在」而非「連線逾時」——前者是 DB 與 mirror 不同步、<code>sudo pacman -Syu</code> 對齊即解；後者才是網路層問題。這個修法有連鎖後果——<code>-Syu</code> 會順帶升級 kernel，是 Step 6 docker 起不來的伏筆。</p>
<p>連線階段的分流：mosh 連線起不來多半是 UDP 被擋（防火牆 / client 支援度）；「能連但漫遊會斷」要查 client 是否真用 mosh 協定而非退回 SSH——查法就是上面驗證段的 server 側 <code>mosh-server</code> 程序判斷，別只看 client 端顯示的連線狀態。本輪還意外驗到一個相關對照：第一次漫遊測試把迴圈跑在<strong>純 SSH shell</strong>（不在 zellij）裡、SSH 一斷迴圈就被 SIGHUP 殺掉（連線掛斷送給前景程序的訊號、預設終止程序，見 <a href="/blog/linux/dotfile/knowledge-cards/sighup-hangup-signal/" data-link-title="SIGHUP（斷線即死訊號）" data-link-desc="遠端跑的程序在 SSH 一斷就消失、想知道為什麼直接掛在連線上的任務活不過斷線、該把工作放哪一層時回來讀">SIGHUP 與斷線即死</a>）；第二次把迴圈放進 <strong>zellij session</strong> 才在斷線期間存活。對照組因此指向 session 層（Step 5）的必要——連線層無論 mosh 多穩、直接掛在 SSH shell 的前景程序都不該當成安全的長任務容器。</p>
<p>mosh 的本地回顯預測還有一個要記的代價：它跟 CJK 雙寬字元有顯示衝突、開了中文輸入後 mosh 連線下輸入行會錯位、純 SSH 才正常（詳見 Step 10 情境一的 CJK 段）。所以 mosh 與 SSH 各有適用場景——要打中文對話時、純 SSH 的無預測才是對的選擇、mosh 的漫遊優勢在此反成負擔。</p>
<h2 id="step-5zellij-常駐-session">Step 5：zellij 常駐 session</h2>
<h3 id="概念與工具-4">概念與工具</h3>
<p>session 層讓工作獨立於連線存活：zellij session 常駐在 VM、連線只是 attach 上去看，斷線任務照跑。session 持久化概念見 <a href="../../cli/tmux-persistence-and-basics/">tmux 基礎</a>、本步用到的 zellij session CLI 操作（<code>attach -b</code> 背景常駐、<code>--session run</code> 注入、<code>ls</code>、<code>delete-session</code>）見 <a href="../../cli/zellij-session-lifecycle/">Zellij session 生命週期</a>、pane 內部操作見 <a href="../../cli/zellij-pane/">zellij 分頁與 pane</a>。</p>
<h3 id="實作-4">實作</h3>
<ul>
<li>VM 安裝 zellij、建立固定名稱的工作 session</li>
<li>登入流程收斂成「連入即 attach」（shell 起始指令或 alias）</li>
</ul>
<p>VM 已內建 zellij 0.44.3。0.44 提供 <code>attach -b</code>（<code>--create-background</code>）直接建一個背景 detached session，適合把「連入即 attach」跟「session 常駐」拆開：session 先在背景存在，連線只是之後 attach 上去。實測用它建一個名為 <code>work</code> 的 session、在裡面用 <code>zellij --session work run</code> 起一個每秒寫時間戳的 heartbeat 任務：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ zellij attach -b work                        # 建背景 session
</span></span><span class="line"><span class="ln">2</span><span class="cl">$ zellij --session work run -- bash -c &#39;...heartbeat...&#39;   # 在 session 內起長任務
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ zellij ls
</span></span><span class="line"><span class="ln">4</span><span class="cl">work [Created 3s ago]</span></span></code></pre></div><p>「連入即 attach」的收斂留給登入 shell 處理（<code>.zshrc</code> / <code>.bashrc</code> 尾端 <code>zellij attach -c work</code>），本輪先驗 session 與連線解耦這個核心性質。</p>
<h3 id="驗證-4">驗證</h3>
<ul>
<li>session 內啟動一個長任務、detach、關掉連線，幾分鐘後重連 attach，任務仍在跑且輸出連續</li>
</ul>
<p>實測：起 heartbeat 後關掉 SSH 連線（斷線發生在 tick 3 之後），用<strong>全新 SSH 連線</strong>重連：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ zellij ls
</span></span><span class="line"><span class="ln">2</span><span class="cl">work [Created 18s ago]          # session 存活
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ wc -l ~/heartbeat.log
</span></span><span class="line"><span class="ln">4</span><span class="cl">18                              # 18 筆、且 tick 編號 1→18 連續無斷檔</span></span></code></pre></div><p>tick 編號連續（行數等於最後 tick 號）證明斷線那十幾秒內任務沒中斷——session 活在 VM 端的 zellij server、跟 SSH 連線的生死無關。</p>
<ul>
<li>VM 重開機後 session 消失是預期行為（session 活在記憶體）——這條列出來是把「重開機後要重建 session」記成已知邊界而非除錯項。本次 session 中段為修 docker 重開過一次機，重開後 <code>zellij ls</code> 確實空空如也，這條邊界就地成立</li>
</ul>
<h3 id="除錯判讀-4">除錯判讀</h3>
<p>attach 不到 session 先看 session 是否存在（<code>zellij list-sessions</code>）、再看是否 attach 到同名的新空 session——名稱拼錯會靜默開新 session、看起來像「任務不見了」、任務仍活在原名稱的 session 裡。</p>
<h2 id="step-6dockerfile-建-agent-工作環境">Step 6：Dockerfile 建 agent 工作環境</h2>
<h3 id="概念與工具-5">概念與工具</h3>
<p>隔離層把 agent 的工作環境做成可重建、可搬遷的 image：base image 拉取、Dockerfile 疊上工具鏈、掛載與資源上限在 run 時宣告。設計判讀見 <a href="../agent-workstation-home-vs-vps/">選型文的隔離段</a>；container 內日常操作的人體工學見 <a href="../../../dotfile/10-prod-parity/container-ergonomics/">container 使用的人體工學</a>、跟生產環境對齊的 runtime 選擇見 <a href="../../../dotfile/10-prod-parity/prod-parity-runtime/">prod parity 的 runtime</a>、tag 固定的理由見 <a href="../../../dotfile/knowledge-cards/image-tag-pinning/">image tag pinning</a>。</p>
<h3 id="實作-5">實作</h3>
<ul>
<li>VM 安裝 docker、確認非 root 使用者可操作</li>
<li>寫 Dockerfile：base image（固定 tag）、開發工具鏈、非 root 使用者</li>
<li>設計 <code>docker run</code> 的掛載與上限：專案目錄、<code>~/.claude</code> volume、memory / CPU 上限</li>
</ul>
<p>安裝後把使用者加進 docker group（<code>sudo usermod -aG docker tar</code>），重登入後即可免 sudo 操作 docker。實測的 Dockerfile：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># base image 固定 tag：Claude Code 是 npm 套件、用官方 node image 省一層 runtime 版本漂移</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="err"></span><span class="k">FROM</span><span class="s"> node:22-bookworm-slim</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="err"></span><span class="c"># 開發工具鏈：git 給版本控制、ripgrep 給搜尋、ca-certificates 給 HTTPS</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="err"></span><span class="c"># curl 是 ntfy hook（Step 8）依賴、node:slim 不內建、少了 hook 會靜默失效</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="err"></span><span class="k">RUN</span> apt-get update <span class="o">&amp;&amp;</span> apt-get install -y --no-install-recommends <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>      git ca-certificates curl ripgrep less <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> rm -rf /var/lib/apt/lists/*<span class="err">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="err"></span><span class="c"># agent 程式裝進 image（Step 7）</span><span class="err">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="err"></span><span class="k">RUN</span> npm install -g @anthropic-ai/claude-code<span class="err">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="err"></span><span class="c"># 預建 ~/.claude 並 chown 給 node：named volume 首次掛載會沿用 image 內該目錄的 owner</span><span class="err">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="err"></span><span class="c"># 少了這行、空 volume 會以 root 掛上、container 內的 node 寫不進憑證（實測踩過、見除錯判讀）</span><span class="err">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="err"></span><span class="k">RUN</span> mkdir -p /home/node/.claude <span class="o">&amp;&amp;</span> chown -R node:node /home/node/.claude<span class="err">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="err"></span><span class="c"># 非 root 使用者：直接用 node base 內建的 node(UID 1000)、對齊 host 的 tar(1000)</span><span class="err">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="err"></span><span class="k">USER</span><span class="s"> node</span><span class="err">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="err"></span><span class="k">ENV</span> <span class="nv">HOME</span><span class="o">=</span>/home/node<span class="err">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="err"></span><span class="k">WORKDIR</span><span class="s"> /work</span><span class="err">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="err"></span><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;bash&#34;</span><span class="p">]</span></span></span></code></pre></div><p>base image 用 tag <code>node:22-bookworm-slim</code>。tag 會隨上游 patch 移動，嚴格的固定是釘到 digest——本次 build 拉到的實際 digest 是 <code>node@sha256:53ada149…</code>，把這串記進版本文件、就能在任何機器重現同一個 base（tag pinning 的理由見 <a href="../../../dotfile/knowledge-cards/image-tag-pinning/">image tag pinning</a>）。</p>
<p>run 指令把三件事在 run time 宣告——專案目錄掛 <code>/work</code>、<code>~/.claude</code> 掛 named volume（Step 7）、記憶體上限：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker run --rm -it <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -v ~/agent-workstation/testproj:/work <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -v claude-home:/home/node/.claude <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --memory<span class="o">=</span>2g <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  agent-workstation:v1</span></span></code></pre></div><h3 id="驗證-5">驗證</h3>
<ul>
<li><code>docker build</code> 從零跑到完成、無 cache 情況下可重現</li>
</ul>
<p>實測 <code>docker build --no-cache</code> 約 2 分鐘完成、image 990 MB，內含 node v22.23.1、Claude Code 2.1.204、git 2.39.5、ripgrep 13.0.0，使用者 <code>node</code>(UID 1000)。</p>
<ul>
<li>container 內以非 root 使用者起 shell、看得到掛進來的專案目錄、看不到未掛載的 host 路徑</li>
</ul>
<p>實測掛 <code>~/agent-workstation/testproj:/work</code>：container 內 <code>ls /work</code> 讀得到 host 放的 <code>MARKER.txt</code>、<code>ls /home/tar</code>（未掛載）回 <code>No such file or directory</code>；container 內以 <code>node</code> 寫的檔、回到 host 看 owner 是 <code>tar</code>(1000)——UID 對映讓兩側 owner 一致，沒有「container 寫的檔在 host 上變成別人的」。</p>
<ul>
<li>在 container 內故意吃滿記憶體（壓力測試）、被 OOM 掉的是 container 內程序、host 的 tailscaled 與 zellij 無感</li>
</ul>
<p>實測 <code>--memory=256m</code> 下用 node 逐塊配置記憶體，配到約 200 MB 觸上限、程序被砍、container 退出碼 137（OOM kill 的 128+SIGKILL，見 <a href="/blog/linux/dotfile/knowledge-cards/oom-exit-code-137/" data-link-title="OOM Killer and Exit Code 137（OOM killer 與退出碼 137）" data-link-desc="程序或 container 被無預警砍掉、退出碼是 137、或編譯 / 測試在記憶體吃緊時突然死掉、要判斷是不是記憶體不足時回來讀">OOM killer 與退出碼 137</a>）。cgroup limit 計的是 container 總量、含 node runtime 底噪，所以可配空間比 256m 少了一截——200 MB 是這個環境的 runtime 底噪決定的、不是通則。同時 host 的 <code>free</code> 從 258 MiB 用量升到 294 MiB（幾乎沒動）、事先起的 host 對照程序存活、SSH 連線不受影響。資源上限把工作負載的爆炸限縮在 container cgroup 內（cgroup 是 Linux 對一組程序設 CPU / 記憶體上限的核心機制、container 的資源隔離就靠它）、連線基礎設施在 host 側安然無事。</p>
<h3 id="除錯判讀-5">除錯判讀</h3>
<p>本步實測踩到兩個 gotcha，分屬不同層：</p>
<p><strong>daemon 起不來、症狀在 iptables、根因在 kernel。</strong> <code>sudo systemctl start docker</code> 失敗，journal 顯示 <code>iptables (nf_tables): Could not fetch rule set generation id: Invalid argument</code>、建 NAT chain <code>DOCKER</code> 失敗。照症狀往「docker 網路 / 防火牆規則」除錯會走錯方向——根因是前一步為修 pacman 404 跑的 <code>-Syu</code> 順帶升級了 kernel（<code>7.1.2-2</code> → <code>7.1.3-1</code>）、但機器沒重開，執行中 kernel 的 module 目錄已不存在（磁碟只剩新版），<code>nf_tables</code> 模組載不進來。判讀方法是讀權威狀態：<code>uname -r</code>（執行中）對比 <code>ls /usr/lib/modules/</code>（磁碟上），兩者不一致就是 kernel 升級後未重開機，重開即解（方法論見 <a href="../../../debug/diagnosis-read-authoritative-state/">診斷讀權威狀態</a>）。這條 gotcha 鏈提醒：一個看似無關的修法（<code>-Syu</code>）可能埋下三步後才引爆的伏筆。</p>
<p><strong>named volume 掛載點是 root、非 root 使用者寫不進。</strong> 把 <code>~/.claude</code> 掛成 named volume 後、container 內的 <code>node</code> 對它 <code>touch</code> 回 <code>Permission denied</code>——掛載點 owner 是 <code>root</code>。根因是 Docker 對「image 內不存在的路徑」建 named volume 時預設 root-owned。修法是在 Dockerfile 裡（<code>USER node</code> 之前、還是 root 時）先 <code>mkdir -p /home/node/.claude &amp;&amp; chown node:node</code>：Docker 掛空 volume 時會沿用 image 內該目錄的 owner。這是「掛載點要先在 image 裡以對的 owner 存在」的通用原則、對任何要讓非 root 使用者寫的 volume 都適用（見 <a href="/blog/linux/dotfile/knowledge-cards/docker-named-volume-ownership/" data-link-title="Docker Named Volume Ownership（掛載點擁有者）" data-link-desc="container 內非 root 使用者寫不進掛載的 named volume、出現 permission denied 時回來讀">Docker named volume 掛載點 owner</a>）。</p>
<p>build 失敗的分流（本次未遇）：看是哪一層指令、跟 base image 版本漂移有關先查 tag / digest 是否固定；run 起來但檔案權限錯亂多半是 host / container 的 UID 對映問題（本次用 node(1000) 對齊 tar(1000) 避開）。</p>
<h2 id="step-7claude-code-落地與憑證持久化">Step 7：Claude Code 落地與憑證持久化</h2>
<h3 id="概念與工具-6">概念與工具</h3>
<p>agent 程式裝進 image、真正要解的是「認證怎麼活過 container 重建」。實測發現對 headless 工作機、Claude Code 的 <code>setup-token</code> 給出的是比持久化 OAuth session 更貼合的模型：它產生一個<strong>長效 token</strong>（宣告有效一年）、用環境變數 <code>CLAUDE_CODE_OAUTH_TOKEN</code> 注入。這讓認證變成一顆 host 側的 secret——存在 image 外、git 外，<code>docker run</code> 時才注入（機密為何不進 image layer 也不進 repo、runtime 注入才對，見 <a href="/blog/linux/dotfile/knowledge-cards/runtime-secret-injection/" data-link-title="Runtime Secret Injection（機密注入）" data-link-desc="要決定 token / 金鑰放哪、能不能烤進 image 或提交進（即使私有的）repo 時回來讀">機密 runtime 注入</a>）。設定（<code>settings.json</code>、含 hooks）走 volume 持久化、認證走 env var 注入，兩者分離：憑證輪替只要換 env 檔、不用碰 volume。信任邊界的判讀見 <a href="../agent-workstation-home-vs-vps/">選型文的隔離段</a>。這套機制（安裝、認證模型、hooks 通知）的獨立說明見 <a href="../claude-code-container-and-hooks/">在 container 裡跑 Claude Code</a>。</p>
<h3 id="實作-6">實作</h3>
<p>Claude Code 已在 Step 6 的 image 裡（實測版本 2.1.204）。認證分三步：產 token、存成 secret、注入。</p>
<p>第一步用 <code>setup-token</code> 走一次互動登入。它需要真 TTY（<code>docker run -it</code>），而 SSH 要帶 <code>-t</code> 才配置 TTY——<strong>從自己的終端機</strong>跑（透過工具管線或非互動 shell 都拿不到 TTY、docker 會回報輸入裝置不是 TTY 而拒絕啟動）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ssh -t tar@&lt;vm&gt; <span class="s1">&#39;docker run -it -v claude-home:/home/node/.claude agent-workstation:v1 claude setup-token&#39;</span></span></span></code></pre></div><p>流程會印一個授權 URL、在瀏覽器用 Anthropic 帳號授權、把授權碼貼回終端。完成後它印出長效 token。</p>
<p>這個流程會產生<strong>兩個不同的憑證</strong>、用途與生命週期不同，分清楚：</p>
<ul>
<li><strong>授權碼</strong>：授權那步你貼進<strong>瀏覽器流程</strong>的一次性碼，用完即棄，不是要保存的東西。</li>
<li><strong>長效 token</strong>：<code>setup-token</code> 跑完印在終端、<code>sk-ant-oat01-</code> 開頭那串，這才是要保存、之後每次啟動 container 用的憑證。</li>
</ul>
<p>第二步把長效 token 存成 host 側的 gitignored secret。<code>setup-token</code> <strong>不會</strong>把它寫進 <code>~/.claude</code>（它只印出來、明示要你設成 <code>CLAUDE_CODE_OAUTH_TOKEN</code>），所以持久化的責任在你、模型是「存 secret」而非「持久化登入態」。存的時候用 <code>read -s</code> 靜默讀入、避免 token 進 shell history。remote shell 是 zsh 時 <code>read</code> 語法跟 bash 不同（bash 的 <code>read -rsp &quot;提示&quot; VAR</code> 在 zsh 會報 <code>no coprocess</code>、zsh 要寫成 <code>read -rs &quot;VAR?提示&quot;</code>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在 VM 上（remote 為 zsh）：靜默讀入、寫成 600 權限的 env 檔</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">umask</span> <span class="m">077</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">read</span> -rs <span class="s2">&#34;T?貼上 token 後按 Enter: &#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;CLAUDE_CODE_OAUTH_TOKEN=%s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$T</span><span class="s2">&#34;</span> &gt; ~/agent-workstation/.env
</span></span><span class="line"><span class="ln">5</span><span class="cl">chmod <span class="m">600</span> ~/agent-workstation/.env</span></span></code></pre></div><p>第三步 <code>docker run</code> 時 <code>--env-file</code> 注入。加 <code>--dangerously-skip-permissions</code> 在這個架構下是正確選擇而非偷懶：container 邊界本身就是權限邊界（隔離層的核心論點），agent 只碰得到掛進去的 <code>/work</code>、爆了困在 cgroup 裡，容器內不必再疊一層檔案權限確認——選型文「信任邊界等於 mount 清單」在實機上就是這樣落地。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker run --rm --env-file ~/agent-workstation/.env <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -v claude-home:/home/node/.claude <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -v ~/agent-workstation/testproj:/work <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  agent-workstation:v1 <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  claude -p <span class="s2">&#34;在目前目錄建立 hello.txt、寫一行問候語&#34;</span> --dangerously-skip-permissions</span></span></code></pre></div><p>上面是 <code>-p</code> 一次性任務（fire-and-forget）。互動對話（坐著跟 agent 來回聊）用同一套注入、只是把 <code>-p</code> 換成 <code>-it</code>：把這串包成 helper、手機端一個指令就進已認證的互動 session。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cp"></span><span class="c1"># claude-shell.sh：注入 token、掛專案目錄、起互動 Claude Code</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">docker run --rm -it <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --env-file <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/agent-workstation/.env&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  -v claude-home:/home/node/.claude <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  -v <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/agent-workstation/testproj:/work&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  agent-workstation:v1 <span class="se">\
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="se"></span>  claude --dangerously-skip-permissions <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div><p>這個 env-var 模型有一個直接後果：<strong>認證綁在「每次 run 有沒有注入 token」、不綁在 session 或登入態上</strong>——沒有「一次登入、之後都在」這回事，直接打 <code>claude</code>（沒注入 token）即使在同一個還活著的 zellij session 裡、也會要你重新認證；而在 <code>--rm</code> 的臨時 container 裡真的走一次互動登入、憑證寫進容器的 <code>~/.claude</code>、容器一結束就蒸發（除非登入時掛了 volume 讓它落在 <code>claude-home</code>）。所以「臨時容器裡互動登入」多半是白做、下次又被要求認證。可靠的做法是不依賴任何登入態、每次用 helper 注入 token。這點用隔離測試釘死過：<strong>不掛任何 volume（排除一切存檔登入）、只注入 token 即認證成功；不注入 token 則回 <code>Not logged in</code></strong>——證明認證來源純粹是注入的 token、與 session、與 volume 裡有沒有登入檔都無關。</p>
<h3 id="驗證-6">驗證</h3>
<ul>
<li>在掛載的專案目錄內給 agent 一個小任務、能完成並寫入檔案、host 側看得到變更</li>
</ul>
<p>實測跑上面那條：agent 認證成功、在 <code>/work</code> 建了 <code>hello.txt</code>、回報「已建立」。回到 host 看：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ ls -la ~/agent-workstation/testproj/hello.txt
</span></span><span class="line"><span class="ln">2</span><span class="cl">-rw-r--r-- 1 tar tar 37 … hello.txt          # owner = tar(1000)、UID 對映正確
</span></span><span class="line"><span class="ln">3</span><span class="cl">$ cat ~/agent-workstation/testproj/hello.txt
</span></span><span class="line"><span class="ln">4</span><span class="cl">你好，祝你有美好的一天！</span></span></code></pre></div><ul>
<li>container 砍掉重建後、Claude Code 免重新登入直接可用</li>
</ul>
<p>這個模型下「免重登」不靠 volume 裡的登入態、靠的是 env 檔：<code>--rm</code> 砍掉 container、下次 <code>docker run</code> 一樣 <code>--env-file</code> 注入同一顆 token 即認證，image 重建（Step 6 改 Dockerfile 那幾次）也不影響——認證跟 image、container 生命週期完全解耦。</p>
<h3 id="除錯判讀-6">除錯判讀</h3>
<p>實測任務跑通、但輸出帶一則非致命警告：<code>Claude configuration file not found at: /home/node/.claude.json</code>。這揭露一個持久化邊界：Claude Code 的頂層設定檔 <code>.claude.json</code>（存專案信任、onboarding 狀態）在 <code>$HOME/.claude.json</code>、<strong>不在</strong> <code>~/.claude/</code> 這個 volume 裡，所以它不跨 container 重建持久化。用 token 注入 + <code>--dangerously-skip-permissions</code> 的無人值守流程不需要它（信任由 skip-permissions 跳過），任務照跑；但若要保留專案級狀態（MCP 設定、逐專案信任），得額外把 <code>/home/node/.claude.json</code> 也掛成持久檔。缺東西時先分清缺的是「認證」（env var、缺了 agent 直接無法認證）還是「設定」（<code>.claude.json</code>、缺了只是回到預設狀態、非致命）。</p>
<p>另一個要記的排除點：volume 掛載點若 root-owned、非 root 使用者寫不進（Step 6 已解，Dockerfile 預建 chown）。認證這條路的第一個檢查點永遠是 env var 有沒有真的注入進去（<code>docker run … env | grep CLAUDE_CODE_OAUTH_TOKEN</code> 回讀），以權威狀態為準、再往上懷疑 token 本身失效。</p>
<h2 id="step-8hooks-接-ntfy-通知">Step 8：hooks 接 ntfy 通知</h2>
<h3 id="概念與工具-7">概念與工具</h3>
<p>通知把工作流從「掛在終端上等」翻成「離開、跑完被叫回來」：agent 的任務結束事件觸發 hook、hook 對 ntfy topic 發一則推播、手機 app 訂閱該 topic。ntfy 的架構與自架取捨見 <a href="../../../debug/ntfy-push-notification-service/">ntfy 推播通知服務</a>、無人值守情境下「結果推得出去」的定位見 <a href="../../../install/unattended-remote-work/">讓機器跑無人值守的長任務</a>。</p>
<h3 id="實作-7">實作</h3>
<ul>
<li>手機安裝 ntfy app、訂閱規劃好的 topic</li>
<li>Claude Code 配置 Stop / Notification hooks、對 topic 發訊（topic 真值不進 git）</li>
</ul>
<p>觸發事件選 <code>Stop</code>——Claude Code 每次回應結束時觸發，對應「一輪任務跑完」這個要通知的時機（另一個候選 <code>Notification</code> 是 agent 主動要求關注時觸發、語意是「需要你介入」而非「跑完了」，兩者可並存但語意不同）。hook 設定寫進掛在 volume 的 <code>settings.json</code>、跨 container 重建持久化（認證則走 env var 注入、見 Step 7——設定持久化與認證注入是分開的兩條路）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;hooks&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nt">&#34;Stop&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="p">{</span> <span class="nt">&#34;hooks&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;command&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">          <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;curl -s -H &#39;Title: Claude Code 任務完成&#39; -d &#39;agent 在 VM 上跑完了&#39; https://ntfy.sh/&lt;你的-topic&gt;&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="p">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>topic 是私密值——猜到 topic 名的人就能發推播到你手機、也能收你的通知，所以真值不進 git（本輪用一個拋棄式測試 topic 驗鏈路）。</p>
<h3 id="驗證-7">驗證</h3>
<ul>
<li>從 shell 手動 curl 一則測試訊息、手機收到（先驗 ntfy 鏈路本身）</li>
</ul>
<p>實測從 VM 對 <code>https://ntfy.sh/&lt;test-topic&gt;</code> 手動 curl 一則：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ curl -s -w &#39;\nHTTP %{http_code}\n&#39; -d &#39;Step 8 鏈路測試：VM 發得出去&#39; https://ntfy.sh/&lt;test-topic&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">{&#34;id&#34;:&#34;XqDoIWLaB3a7&#34;, … ,&#34;event&#34;:&#34;message&#34;,&#34;topic&#34;:&#34;…&#34;,&#34;message&#34;:&#34;Step 8 鏈路測試：VM 發得出去&#34;}
</span></span><span class="line"><span class="ln">3</span><span class="cl">HTTP 200</span></span></code></pre></div><p>ntfy.sh 回 HTTP 200、訊息被接收（帶 message id）——VM→ntfy 這段鏈路本身通。手機端訂閱同一 topic 收訊的驗證屬行動端、隨手機配合一起做。</p>
<ul>
<li>給 agent 一個會跑幾分鐘的任務、手機關螢幕等待、任務結束收到推播（再驗 hook 觸發）</li>
</ul>
<p>實測：Step 7 那個真實 agent 任務（建 <code>hello.txt</code>）跑完後、poll ntfy 看最近訊息：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ curl -s &#39;https://ntfy.sh/&lt;test-topic&gt;/json?poll=1&amp;since=5m&#39;
</span></span><span class="line"><span class="ln">2</span><span class="cl">{… &#34;title&#34;:&#34;Claude Code 任務完成&#34;,&#34;message&#34;:&#34;agent 在 VM 上跑完了&#34;}</span></span></code></pre></div><p><code>Stop</code> hook 在任務結束時確實觸發、發出設定裡那則推播，手機端也收到——真實 agent 任務 → hook → ntfy → 手機這條端到端閉環成立。（本輪一併確認：手機訂閱該 topic 後，稍早 VM 側與 container 側的兩則手動測試推播都收到、鏈路無誤。）</p>
<h3 id="除錯判讀-7">除錯判讀</h3>
<p>兩段驗證把問題切開：手動 curl 通、hook 沒動靜，問題在 hook 配置層（<code>settings.json</code> 路徑 / JSON 格式 / container 內有沒有 curl）；curl 就不通，問題在 ntfy 鏈路（topic 名稱、網路、app 訂閱狀態）。</p>
<p>「container 內有沒有 curl」這點本輪實測真的踩到：<code>node:22-bookworm-slim</code> 不內建 curl，第一版 image 裡 hook 的 curl 指令會找不到執行檔而靜默失效——ntfy 鏈路手動測是通的、hook 卻不會發訊，剛好落在「手動 curl 通、hook 沒動靜」這個分流。修法是把 curl 加進 Dockerfile 的 <code>apt-get install</code>（Step 6 的 Dockerfile 已含）。這提醒 hook 的除錯要把「hook 指令依賴的工具在 container 裡存不存在」當第一個檢查點。修好後從 container 內 curl 到 ntfy 回 HTTP 200、鏈路層排除，剩下的變數只在手機訂閱與 hook 觸發兩點。</p>
<h2 id="step-9手機端連線與輸入">Step 9：手機端連線與輸入</h2>
<h3 id="概念與工具-8">概念與工具</h3>
<p>行動端輸入是整套工作流最容易用不下去的環節：終端 UI 依賴 Esc / Ctrl / 方向鍵、手機軟體鍵盤預設沒有，client 的擴充鍵列補這個缺。判讀見 <a href="../agent-workstation-home-vs-vps/">選型文的使用形態段</a>、client 之間（Blink / Termius / 自製 ttyd）的比較見 <a href="../mobile-terminal-client-selection/">手機終端 client 選型</a>。</p>
<h3 id="實作-8">實作</h3>
<ul>
<li>候選 A：現成 client（Termius / Blink Shell 這類），配 mosh + 擴充鍵列</li>
<li>候選 B：自製通道（ttyd 轉 WebSocket、走 tailnet、原生 app 收），適合要客製認證與稽核的情境</li>
<li>順序已定：本輪用候選 A 跑通全部步驟（控制變數——工作流本身未驗證時、client 端用成熟工具歸零變數）；候選 B 的功能對齊（擴充鍵列、斷線重連、多 endpoint、TUI 相容）記在該工具自己專案的提案系統、驗收規格採用本文跑通後凍結的判準</li>
</ul>
<p>本輪的手機是 Android（Galaxy A70），這件事先卡掉一半候選：Blink Shell 是 iOS 專屬、Android 裝不了，所以現成 client 落在 Termius。連線分兩步建立：先純 SSH 把「連得上 + 金鑰認證」驗通、再開 mosh 測漫遊（Step 4），控制變數。金鑰用 Termius 產一把 ED25519、把公鑰加進 VM 的 <code>authorized_keys</code>——手機端一律走金鑰、不用密碼（沿用 Step 2 的基線；私鑰在客戶端 / 公鑰授權在伺服器、per-device 各配一把的模型見 <a href="/blog/linux/dotfile/knowledge-cards/ssh-key-storage/" data-link-title="SSH Key Storage（SSH 金鑰儲放與 authorized_keys）" data-link-desc="配置 SSH 免密碼登入、要知道私鑰放哪、公鑰怎麼授權、多裝置各自的鑰匙怎麼管、或高權限操作該不該給完整金鑰時回來讀">SSH 金鑰儲放與 authorized_keys</a>）。實測 Termius 預設會退回問密碼、<code>tar</code> 沒設密碼所以失敗，加完公鑰重連即免密碼登入，VM 側 <code>journalctl -u sshd</code> 看到 <code>Accepted publickey from 100.71.173.84</code>。</p>
<h3 id="驗證-8">驗證</h3>
<ul>
<li>手機端完成一次完整互動：attach session、給 agent 下指令、Esc 中斷一次、方向鍵翻歷史</li>
</ul>
<p>實測 Termius（Android）連上後跑 <code>zellij attach -c work</code>、走四個關鍵按鍵：</p>
<table>
  <thead>
      <tr>
          <th>動作</th>
          <th>手機鍵列實測</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tab 補全</td>
          <td>鍵列有 Tab、可用</td>
      </tr>
      <tr>
          <td>Ctrl-C 中斷</td>
          <td>鍵列有 Ctrl、<code>Ctrl+C</code> 中斷得了</td>
      </tr>
      <tr>
          <td>方向鍵翻歷史</td>
          <td>鍵列有方向鍵、上鍵叫回歷史指令</td>
      </tr>
      <tr>
          <td>Esc</td>
          <td>鍵列<strong>有 Esc</strong>、但被水平捲動的鍵列推到畫面外遮住、需橫向拖動鍵列才露出；<code>Ctrl+[</code> 是不依賴找鍵的等價替代</td>
      </tr>
  </tbody>
</table>
<p>Esc 這格的實測過程本身就是一個判讀教訓：第一眼掃過 Termius 的擴充鍵列沒看到 Esc、以為缺這個鍵，實際是<strong>鍵列可以水平捲動、Esc 被推到可見範圍外遮住了</strong>，橫向拖動鍵列就露出來。行動端的擴充鍵列常是可橫向捲動的、可見的那幾顆不等於全部——「按鍵缺失」的結論要先把鍵列拖過一遍再下，這正是第一印象與實際互動不符時、以互動為準的例子。就算真的找不到某個鍵，終端層還有等價組合鍵可用：<code>Ctrl+[</code> 送出與 Esc 相同的 <code>0x1b</code> 控制碼、任何終端通用（實測在 zellij 進 PANE 模式後 <code>Ctrl+[</code> 能退回 NORMAL、等同 Esc），這條不依賴 client 把鍵擺在哪。這格對上選型文說的「擴充鍵列決定手機端是可操作還是只能看」——但可操作性的判讀要把「鍵列可捲動」算進去、別被預設可見範圍誤導。</p>
<ul>
<li>輸入體感可長用（判準：一段 prompt 打完的錯誤率與速度自評）</li>
</ul>
<p>實測純 SSH 與 mosh 下四個關鍵動作都能完成一次完整互動；mosh 漫遊下切網路有約 3 秒凍結（Step 4）、但輸出不丟、恢復後可續打，長用可接受。手機端派 agent 任務的免引號 helper 與踩到的引號 gotcha 記在 Step 10 情境一。</p>
<h3 id="除錯判讀-8">除錯判讀</h3>
<p>手機 client 連不上要先分「逾時 vs 被拒」：<strong>連線逾時（timed out）多半是手機不在 tailnet 上</strong>——client 設定沒問題也連不到 tailnet 私網位址，先回 Step 3 從 VM 側 <code>tailscale status</code> 確認手機有沒有上線，這是本輪實測第一個踩到的關卡（Termius 連 <code>100.68.144.88</code> 逾時、根因是手機 Tailscale 沒連上、詳見 Step 3 除錯判讀）；連線被拒（refused）才往 sshd / 認證 / 防火牆查。</p>
<p>連上之後的分流：以為某個鍵缺失時先別急著換 client——第一步是把擴充鍵列橫向拖過一遍（行動端鍵列常可捲動、鍵藏在可見範圍外，本篇 Esc 那格就是這例），找不到再用等價組合鍵（<code>Ctrl+[</code> 等於 Esc、<code>Ctrl+H</code> 等於 Backspace 這類終端通用等價），還要不到才看 client 能不能自訂鍵列、最後才換 client；亂碼與斷行錯位是終端 TERM / 字型問題，分流見 <a href="../../../debug/ssh-and-terminal-troubleshooting/">SSH 與終端機問題排查</a>。</p>
<h2 id="step-10端到端驗收">Step 10：端到端驗收</h2>
<h3 id="概念與工具-9">概念與工具</h3>
<p>前九步各自驗過自己那層、這步驗跨層組合：三個情境對應三個最可能在真實使用中出現的失敗面。</p>
<h3 id="實作與驗證">實作與驗證</h3>
<p>三個情境全部從手機端執行：</p>
<ol>
<li><strong>fire-and-forget</strong>：手機丟一個任務給 agent、鎖螢幕走人、收到 ntfy 推播後重連看結果。驗證斷線期間任務持續、通知準確。</li>
</ol>
<p>實測從手機在 zellij session 裡呼叫一個把 <code>docker run --env-file … claude -p</code> 包起來的 helper、派一個「在 /work 建 report.txt、寫三行架構重點」的任務、鎖螢幕離開。約一分鐘後手機收到 ntfy 推播 <code>Claude Code 任務完成</code>、重連 <code>zellij attach work</code> 看到任務輸出、host 的 <code>~/agent-workstation/testproj/report.txt</code>（281 bytes、owner <code>tar</code>）已生成、內含 agent 寫的三行。端到端閉環成立：派任務 → 離線 → agent 跑 → hook 推 → 手機收 → 接回。</p>
<p>這情境實測踩到兩個純行動端的輸入 gotcha。</p>
<p>其一是引號：第一次呼叫 helper 沒帶到任務參數、腳本印出用法提示就退出（VM 側查證：沒有新 container、沒生檔、沒推播——不是失敗、是腳本正確拒絕空任務）。根因是手機軟體鍵盤容易把直引號 <code>&quot;</code> 自動換成智慧引號 <code>“”</code>、shell 不認、參數解析壞掉。解法是把 helper 設計成免引號、而不是要求使用者小心打引號：沒帶參數時互動式從 stdin 讀整行任務、且用 <code>&quot;$*&quot;</code> 收全部參數而非只取 <code>$1</code>。行動端派工具要假設引號會被鍵盤偷換、從介面設計上避開，而不是靠使用者不犯錯。</p>
<p>其二是 CJK 輸入、且牽出一個 mosh 與中文顯示的硬權衡：本輪派任務時手機在終端裡打不出中文、只能用英文描述任務（這是為什麼 agent 回報是英文系統事實、而非中文架構重點——先前一度誤判成 agent 自行填補模糊、實際是輸入端受限）。逐層定位下來釐清了三件事：</p>
<ul>
<li>預設狀態下 Termius 終端不接受 CJK 即時輸入（輸入法切不到中文），但中文<strong>貼</strong>進終端能正常送出、編碼無誤——所以不是編碼問題。</li>
<li>Termius 有個 <code>Experimental Keyboard Support（Voice input and CJK layout support）</code>開關、開了之後終端就能切中文即時打字。</li>
<li>但開 CJK 後、<strong>mosh 連線下中文輸入行的畫面會錯位</strong>（輸出正常、只有正在編輯的輸入行亂）；同樣設定改用<strong>純 SSH 就完全正常</strong>。</li>
</ul>
<p>根因是 mosh 的本地回顯預測撞上 CJK 雙寬字元：mosh 先猜按鍵顯示、但雙寬字的寬度在預測層算錯、游標位置與重繪就錯位；純 SSH 沒有預測、只呈現 server 端（bash / zellij 正確處理雙寬字）的真實渲染、所以乾淨（雙寬字顯示與 raw 模式擋 IME 組字的完整機制見 <a href="/blog/linux/dotfile/knowledge-cards/terminal-cjk-input/" data-link-title="Terminal CJK Input（終端 CJK 雙寬字與即時輸入）" data-link-desc="手機 / 遠端終端能貼中文卻打不出來、或打中文時畫面錯位時回來讀">終端 CJK 雙寬字與即時輸入</a>）。落到操作上就是一個權衡——<strong>要坐著打中文跟 agent 對話、用純 SSH（顯示對、無漫遊但 zellij 補上斷線接回）；要移動中漫遊、用 mosh（但別打 CJK、會亂）</strong>。兩個使用形態不太重疊：移動中多半丟英文任務看結果、坐著深談才打大量中文而此時網路固定不需漫遊，所以存 SSH 與 mosh 兩個 host profile 分別服務，比勉強用一個好。理論上 <code>mosh --predict=never</code> 關掉預測能兩全、但 client 未必讓你傳 mosh 參數。這條把「行動端能不能派複雜任務」精確到：CJK 即時輸入要靠終端的 CJK 支援開關、且與 mosh 預測有顯示衝突——選型與使用形態要納入這一格。</p>
<ol start="2">
<li><strong>斷線復原</strong>：任務進行中把手機從 Wi-Fi 切到行動網路。驗證 mosh 漫遊 + zellij session 兩層的組合行為。</li>
</ol>
<p>這情境的核心在 Step 4 已實測：zellij 裡跑著的迴圈、手機切網路後畫面凍約 3 秒、恢復後輸出無斷檔、不需手動重連。兩層在此協作——mosh 在連線層用 UDP 扛端點變化、zellij 在 session 層讓工作獨立於連線存活。對照組也驗到了：同樣的迴圈若跑在純 SSH shell（不在 zellij）、SSH 一斷就被 SIGHUP 殺掉。所以「斷線復原」要兩層都在位：只有 mosh 沒有 zellij、連線接回了但前景任務已死；只有 zellij 沒有 mosh、任務活著但要手動重連 attach。</p>
<ol start="3">
<li><strong>資源保護</strong>：讓 container 內任務吃滿記憶體上限。驗證 OOM 只影響工作負載、連線與 session 基礎設施存活。</li>
</ol>
<p>這情境的機制在 Step 6 已實測：<code>--memory=256m</code> 下 container 內程序吃爆被砍、退出碼 137（OOM kill）、host 的 <code>free</code> 幾乎沒動、host 側對照程序與 SSH 連線存活。而「連線與 session 基礎設施活得比工作負載久」這個前提，在情境 2 已被獨立驗證（mosh + zellij 跟 container 是分開的層）——所以 container OOM 時、手機的 mosh session 與 zellij 不受波及、事後 attach 回去看得到發生什麼事，是這兩個獨立事實的組合結論。</p>
<h3 id="除錯判讀-9">除錯判讀</h3>
<p>情境失敗時回對應步驟的除錯段：通知沒來回 Step 8、斷線任務死掉回 Step 5、OOM 拖垮連線回 Step 6 的資源上限配置。跨層問題先確認單層驗證是否仍通過、再懷疑組合行為。</p>
<h2 id="完成與後續">完成與後續</h2>
<p>十個步驟與三個端到端情境都經實機跑通、本文的指令與輸出是實跑結果。過程中最值得回頭修正選型判斷的一點是 Step 7 的憑證模型：<code>setup-token</code> 給的是<strong>長效 token 的 env-var 注入模型</strong>、不是「持久化 OAuth session 到 volume」——<a href="../agent-workstation-home-vs-vps/">選型文</a> 隔離段講「狀態要顯式持久化」時把憑證與設定混在一起，實測顯示這兩者該分開（設定走 volume 持久化、認證走 runtime 注入的 secret），是該文可以再細化的一格。</p>
<p>session 層的 zellij 操作（背景常駐、注入指令、attach/detach 持久化、清理）在本文只帶過用到的部分、完整的 CLI 操作見 <a href="../../cli/zellij-session-lifecycle/">Zellij session 生命週期</a>。</p>
]]></content:encoded></item></channel></rss>