跳到主要內容

【2026 最新】AgentsDock 教學:手機遠端控 Claude Code/Codex 的安全驗收與完整卸載

最後更新: ·
AgentsDock 教學首圖:手機遠端控制、Git worktree 隔離與完整卸載收據

這篇 AgentsDock 教學從一個真實情境開始:人在外面,手機收到「要不要允許執行指令」的通知;你按下同意後,家裡 Mac mini 上的 Claude Code 或 Codex 繼續改專案。真正的新手風險不是 prompt 怎麼打,而是手機、AgentsServer、模型 CLI 與專案資料夾各握有什麼權限,以及關掉 App 後 server 是否真的停止。

AgentsDock 在 2026 年 9 月 13 日登上 Hacker News;AlphaLab 於 9 月 15 日透過 HN API 複核為 81 points、33 comments。討論裡有人追問它和 Paseo 的差別,也有人表示把 App 丟進垃圾桶後 server 仍自動啟動。後者只是單一使用者回報,不能當成已重現的產品缺陷,卻點出一個正確問題:client 與 server 是兩個不同生命週期。

這篇 AgentsDock 教學不重做一般的手機遠端控制入門;若你只用 Claude Code,可先看既有的 Claude Code Remote Control 實戰。本文專注第三方、多模型、自架控制台的驗收:先用唯讀任務確認邊界,再把 Claude Code 與 Codex 放進不同 Git worktree,最後留下停止與卸載收據。核心公式是:

安全遠端 Agent=私有連線+受保護的 server token/直接 workspace API 邊界+獨立 working directory+CLI 最小權限+可驗證的停止收據。


先說結論:手機是遙控器,權限仍在你的 server

AgentsDock 官方設定指南AgentsServer 原始碼文件,一個 agent turn 分成四層:手機或桌面 App 是 client;你自己的 macOS/Linux 主機執行 AgentsServer;對話 turn 通常由 server 呼叫主機上已安裝並登入的 Claude Code/Codex CLI,再存取該 chat 的 working directory。但 AgentsDock 的檔案編輯器也能經過已驗證的 AgentsServer workspace API,直接建立、寫入、改名或刪除檔案;CLI approval/sandbox 只約束 provider turn,不能約束所有持有 server token 的 client/API 檔案操作。

AgentsDock agent turn 資料流:手機客戶端經私有網路連到 AgentsServer,再由模型 CLI 存取指定 worktree
手機不會掛載 server 的檔案系統,但可送出檔案編輯請求,也可能在裝置上快取內容;真正的 CLI execution 與專案檔案寫入仍發生在 server 主機。

這條鏈不能套到每一種檔案操作。AgentsDock 的檔案編輯、建立、改名與刪除可透過 authenticated workspace API 由 AgentsServer 直接處理,不會先問 Claude/Codex 的 approval,也不受 CLI sandbox 約束;因此 shared server token 本身就是高權限鑰匙。把 App 關掉也只是拔掉遙控器,不等於停止背景 user service。server 不是新的模型供應商,agent turn 的 prompt 與必要內容仍會送往所選 provider。想先理解控制台與 CLI 分工,可搭配 Agent Workspace 白話指南

AgentsDock 教學安裝前:先過版本、網路、登入五道檢查

本文在 2026 年 9 月 15 日交叉檢查官方 setup、README、installer 與 uninstaller 原始碼;沒有在你的主機代跑安裝,所以每一步都以你看到的輸出作為 pass/fail。截至查核時,AgentsDock 公開 GitHub repo 的 current desktop stable 與 agentsdock.net 首頁 desktop 直下載均已是 v1.0.0,release notes 要求搭配 AgentsServer v1.0.0。安裝時仍要核對所選通路的 release asset、相容性說明與 Settings/Runtimes 實際版本。

AgentsServer 的 v1.0.0 tag 在本次稽核期間曾以同版本替換;本次查核時解析到 commit d9d7210。下方安裝指令固定的是版本名稱,不保證 tag 未來不變;執行前仍要核對 tag 指向與 installer diff。

  1. 主機:AgentsServer v1.0.0 支援 Linux 與 Apple silicon macOS,Intel Mac 會被拒絕;遠端主機必須保持開機。Windows 10+ 可作 AgentsDock desktop client,v1.0.0 Windows installer 尚未簽署,可能觸發 SmartScreen;Windows 不能作 AgentsServer host。
  2. CLI:在 server 主機執行 command -v claude && claude --version 與/或 command -v codex && codex --version,再各自完成官方登入。AgentsServer 不會替你安裝或授權模型 CLI。
  3. 安裝前置:先安裝可信來源的 uv;macOS 可用 brew install uv,Linux 則使用可信任的 OS/套件管理來源。Linux 的 installer 路徑還要有可用的 systemctl --user session。
  4. tmux:執行 tmux -V。它是 App 內持久 terminal、pane 檢查與 managed update 所需能力;一般 chat、檔案、job、搜尋仍可在沒有 tmux 時運作。
  5. 私有網路:同一 LAN 可先測,跨網路則用 Tailscale。官方明確要求不要把 AgentsServer port 直接暴露到公網;若不熟 mesh VPN,先完成 Tailscale AI Agent 私有網教學

指定 v1.0.0 標籤的手動安裝路徑如下。執行前先閱讀 tag 內的 installer;只有 direct macOS/Linux desktop build 可從 App 啟動 local/SSH installer,其他 client 要用手動安裝。以下不固定 port:預設值被占用時,installer 可能自動選最多五個較高的空 port;務必記下最後印出的實際 Server URL,之後所有連線與卸載收據都代入同一個 port。

(
set -e
# 目標目錄必須尚不存在;clone 失敗就停止,不會改跑舊 checkout
git clone --depth 1 --branch v1.0.0 \
  https://github.com/ZhengyiLuo/AgentsServer.git AgentsServer-v1.0.0
cd AgentsServer-v1.0.0
./install.sh

# installer 會印出實際 origin;請逐字代入
AGENTSDOCK_ORIGIN='http://127.0.0.1:7850'  # 換成實際印出的 origin
AGENTSDOCK_TOKEN="$(./install.sh --show-token)"
printf 'Authorization: Bearer %s\n' "$AGENTSDOCK_TOKEN" | \
  curl -fsS -H @- "${AGENTSDOCK_ORIGIN}/api/health"
unset AGENTSDOCK_TOKEN
)

installer 會建立 bearer token,macOS 使用 com.agentsdock.server LaunchAgent,Linux 使用 agents-server.service user service。連線時以 installer 印出的實際 port 為準:同一主機用 loopback,私有網路把 host 換成 Tailscale IP,再貼入 token。token 不只控制 agent turn,也保護 workspace 檔案 API;截圖、教學影片、issue 與 log 都不應露出它。

AgentsDock 官方 Settings 畫面,分別顯示 server、tmux、Claude Code、Codex 與 AgentsServer 健康狀態
官方 Settings 畫面把 server、tmux 與模型 provider 分開檢查;版本與文字可能隨更新改變。圖/AgentsDock 官方。

AgentsDock 教學最重要一關:先收緊 approval 與 sandbox

「請不要改檔」只是 prompt,不是權限邊界。更重要的是,AgentsServer v1.0.0 原始碼顯示新 Codex session 的 server 預設為 approval_policy="never"sandbox_mode="danger-full-access";這是 AgentsDock 的產品預設,不是 OpenAI 的 Codex 預設,也不適合直接拿來跑新手測試。

開 Codex chat 後先展開 Permissions,把 Filesystem sandbox 設為 Read only、Approval prompts 設為 Ask when more access is neededon-request)、Who approves? 設為 Me (Codex default)user)。Claude Code chat 則依官方權限模式把 Permission mode 設為 Plan onlyplan)。完成唯讀驗收後,若要受控修改,再把 Codex 改成 Workspace writeAsk when more access is needed,Claude 改回 Ask for access;不要使用 Full access、bypass 或自動 reviewer。approval 決定「何時問你」,sandbox 決定「最多能碰哪裡」,而 App 的直接檔案 API 又是另一條權限路徑。

第一個唯讀任務:確認 Git 可見狀態未變

新手最容易一連上就叫 Agent 修 bug,結果無法分辨連線、權限與程式碼問題。先挑一個可丟棄的 Git 測試 repo,不要開啟或使用 App 檔案編輯器的 save/create/rename/delete 功能;在主機的可信 terminal 先留下基線,第一個指令必須沒有輸出,並保持這個 terminal 開著:

BASELINE_STATUS="$(git --no-optional-locks status --short --untracked-files=all)" || exit 1
if [ -n "$BASELINE_STATUS" ]; then
  printf '%s\n' "$BASELINE_STATUS"
  echo "STOP: use a clean disposable repo"
  exit 2
fi
BASELINE_HEAD="$(git rev-parse HEAD)" || exit 1
printf 'Baseline HEAD: %s\n' "$BASELINE_HEAD"

接著新增 chat,選 Claude Code 或 Codex、指定同一個 repo 路徑,再送出這段唯讀 prompt:

只讀取這個 repository。請列出:
1. 目前 branch 與 commit SHA
2. 頂層檔案
3. 建議我下一步執行的一個唯讀檢查
不得修改檔案、不得安裝套件、不得連外、不得執行 git write 操作。

回覆後,回到同一個可信 terminal 執行:

AFTER_STATUS="$(git --no-optional-locks status --short --untracked-files=all)" || exit 1
if [ -z "$AFTER_STATUS" ]; then
  echo "PASS: Git-visible status unchanged"
else
  echo "FAIL: Git-visible changes found"
  printf '%s\n' "$AFTER_STATUS"
fi
test "$(git rev-parse HEAD)" = "$BASELINE_HEAD" \
  && echo "PASS: HEAD unchanged" \
  || echo "FAIL: HEAD changed"

兩項都是 PASS,只能確認 Git 可見的 tracked/untracked 狀態與 HEAD;它看不到 ignored files、repo 外寫入、~/.agentsdock session state、client cache、套件安裝或網路活動,因此不能寫成「證明 AgentsDock 沒有改檔」。接著從手機重新開 App、resume 同一 chat,再要求重述 SHA;這只驗證 session 可接續,不代表 filesystem state 由對話自動保存。

Claude Code 與 Codex 隔離:一個 Agent 一個 worktree

AgentsDock 會保存 chat 指定的 working directory;一條 directory path 本身不等於獨立 Git worktree。本文為了取得可驗證隔離,先在可信 terminal 手動建立兩個 worktree,再把實際路徑逐一指定給兩個 chat。

# 兩個目標目錄與 agents/claude-safe、agents/codex-safe branch 名稱都必須尚不存在
git worktree add -b agents/claude-safe ../repo-claude HEAD
git worktree add -b agents/codex-safe ../repo-codex HEAD
git worktree list

# 驗收兩邊不是同一目錄、不是同一 branch
git -C ../repo-claude branch --show-current
git -C ../repo-codex branch --show-current

Claude chat 只指向 ../repo-claude,Codex chat 只指向 ../repo-codex。若你本來就在處理多 worktree 的 dev server 與 port,先套用 Portless 多 Worktree 教學;每個 worktree 有自己的 checkout、HEAD 與 index,但共享 object database 與多數 refs/config,也不隔離資料庫、port、雲端帳號或 cache。

完成前面的唯讀驗收後,才把權限切到上一節的受控修改模式。若要測媒體與 artifact,可上傳一張不含個資的測試圖,要求「描述圖片,不寫檔」;再在這個可丟棄的 worktree 讓 Agent 產生純文字報告,確認手機端能預覽與下載,並用 Git diff 檢查寫入。正式專案不要拿 API key、.env、客戶資料或 production dump 當連線測試材料。

做一次可回復的衝突故障注入

  1. 確認 agents/claude-safeagents/codex-safe 從同一個 HEAD 建立;不要用未提交的正式工作測試。
  2. 要求 Claude 與 Codex 各自修改同一個測試檔、同一行,但寫入不同文字;每個 chat 都先只顯示計畫,等你 approve 才改。
  3. 在主機分別執行 git -C ../repo-claude diff --checkgit -C ../repo-codex diff --check 與專案最小測試;通過後,把兩邊的不同修改各自提交。
  4. git merge-base agents/claude-safe agents/codex-safe 找共同基線,從它建立可丟棄的 agents/integration-test branch/第三個 worktree。先 merge agents/claude-safe,再 merge agents/codex-safe;因兩個已提交變更改到同一行,第二次 merge 才應出現預期 conflict。
  5. 看到 conflict 就停止自動操作、人工讀三方差異,不要讓兩個 Agent 同時「幫忙解」。保存收據後先 git merge --abort,再移除第三個測試 worktree;最後執行 git worktree list,確認每條 branch 仍只屬於一個 worktree。

這個測試驗收的是「工作目錄邊界與人類停手機制」,不是比較哪個模型比較聰明。想理解兩套 CLI 的原生 review、approval 與使用情境,可再看 Claude Code vs Codex

AgentsDock、Paseo、原生 Remote Control 怎麼選?

不要只比「手機能不能回 prompt」。截至本文查核日,Claude Code 已有官方 Remote Control:本機只做 outbound HTTPS、不開 inbound port,執行與檔案權限仍留在本機;OpenAI 也推出透過 ChatGPT mobile 接手已連線 Mac/Windows 主機的 Codex Remote,可用性依 rollout 與 workspace 設定而定,設定流程從 ChatGPT desktop app 開始,不能從 Codex CLI 或 IDE extension 建立 mobile pairing。原生方案也有 worktree 能力:Claude Remote Control 可用 --spawn worktree 為 on-demand session 建立 worktree;Codex Remote 可接續 host chat,而 Codex desktop chat 本身可使用 managed worktree。若你只用單一 provider,原生方案通常少一層要維運的第三方 server。

AgentsDock、Paseo 與原生 Remote Control 比較圖,對照連線、provider、worktree、session 與維運責任
選型重點不是介面像不像,而是連線拓樸、worktree 誰管理,以及你願意維運哪一層。

Paseo 官方連線文件描述另一種自架 daemon:本機預設 loopback,可選直接 Tailscale/VPN,或啟用端對端加密 relay;它也有明確的 managed worktree流程。AgentsDock 則適合想用原生手機/桌面 client、切換多台 server、在同一介面操作 Claude Code 與 Codex,且願意自己管理 bearer token、user service 與 working directory 的人。

用同一個唯讀任務,留下四張比較卡

  1. 連線卡:兩邊各用全新的測試 session 與等價的乾淨 worktree。本文的 AgentsDock 測試只走 LAN/Tailscale;Paseo 先寫明採直接 VPN 還是可選 relay。記錄 client 斷線後 daemon/service 是否仍在,不把關 App 當停止。
  2. resume 卡:都送出前文的 branch+SHA 唯讀 prompt,關閉 client、重新連線,再從同一 session 要求重述 SHA。比較能否找到正確 session、歷史是否完整,以及 reconnect 花幾步,不比較模型答案文筆。
  3. artifact 卡:上傳同一張無個資測試圖,檢查手機預覽、下載與檔案/diff review;不要把 API key、.env 或客戶資料拿來測。
  4. 權限卡:先把 provider 設為唯讀,再要求它「提出一個修改但先不要執行」。記錄 approval 在哪一層出現、拒絕後有沒有寫入 Git 可見狀態;AgentsDock 這一輪不要使用可直接改檔的 App editor。比較的是權限表面,不是宣稱哪個產品絕對安全。

兩者目前都能處理多 provider、既有 session 與檔案/diff review,不能把「可 resume」寫成 AgentsDock 獨有優勢。也不要把「self-hosted」誤讀成「資料不離開主機」:控制層可自架,但模型 CLI 仍依 provider 政策送出必要內容;Paseo 的 relay、Tailscale 的 tunnel 與 AgentsDock 的 bearer token,也分別解決不同層次的問題。

完整停止與卸載:六個生命週期逐一驗收

只移除 App 不是完整卸載,因為 AgentsServer 是獨立 user service。開始前先停止仍在 running 的 turn、保存必要 diff 與 commit。趁 App 還能使用,編輯每一個 client 上的每一個 server profile:勾選 Remove saved access token、按 Save,然後重新開啟該 profile,確認不再顯示 saved token。其他手機與桌面 client 也做同一驗收,再回到原本的 AgentsServer checkout 執行官方 uninstaller:

# 以下三選一,不要依序全跑
# 保留 ~/.agentsdock 內的 chats、jobs、files、terminals
./uninstall.sh

# 無互動移除 service、runtime、generated config 與 token
./uninstall.sh --yes

# 只有確定不再需要歷史狀態時才用;必須互動輸入精確路徑確認
./uninstall.sh --purge-state

普通卸載會移除 user service、版本化 runtime 與產生的 config/token,但預設保留 ~/.agentsdock 狀態;--purge-state 才會刪除,而且 --yes 不能跳過它的精確路徑確認。預設根目錄是 runtime ~/.local/share/agents-server、config ~/.config/agents-server、state ~/.agentsdock。若安裝時曾設定下列 override,卸載時必須傳回當時實際使用的同一組值;只在事後檢查自訂路徑,並不會讓 uninstaller 改為移除它們。

# 只寫入安裝當時真的 override;不要猜路徑
AGENTS_SERVER_INSTALL_DIR='/actual/runtime/root' \
AGENTS_SERVER_CONFIG_DIR='/actual/config/root' \
AGENTSDOCK_STATE_DIR='/actual/state/root' \
./uninstall.sh --yes

上例的三個值是 placeholder,不能直接複製;沒用過 override 就使用前一個官方指令區塊,不要自行加上。uninstaller 也不會自動刪掉名稱為 zd_* 的 tmux session,所以要逐一辨認,不要對所有 tmux session 下通殺命令。下方兩份收據還會要求你輸入 installer 當時的 HOME 絕對路徑;不知道就不要猜。若用過 override,也要先把三個根目錄改成當時的實際值。

AgentsDock 完整卸載收據,逐項檢查 user service、實際 server listener、程序、tmux、token config 與保留狀態
完整卸載不是看圖示消失,而是六個控制面各自留下可讀的驗收結果。

macOS 卸載收據

# 請只輸入 installer 當時印出的實際 port;空白、前導零或範圍外都停止
printf 'Installer 實際 port(1-65535):' >&2
IFS= read -r AGENTSDOCK_PORT
case "$AGENTSDOCK_PORT" in
  ''|0*|*[!0-9]*) echo "INCONCLUSIVE: canonical recorded port required"; exit 2 ;;
esac
if [ "${#AGENTSDOCK_PORT}" -gt 5 ] \
  || [ "$AGENTSDOCK_PORT" -lt 1 ] \
  || [ "$AGENTSDOCK_PORT" -gt 65535 ]; then
  echo "INCONCLUSIVE: canonical recorded port required"
  exit 2
fi

printf 'Installer 當時的 HOME 絕對路徑:' >&2
IFS= read -r AGENTSDOCK_INSTALL_HOME
PATH_DOT='.'
PATH_DOTDOT='..'
case "$AGENTSDOCK_INSTALL_HOME" in
  ''|'/'|"/${PATH_DOT}/"*|"/${PATH_DOTDOT}/"*|*//*|*"/${PATH_DOT}/"*|*"/${PATH_DOTDOT}/"*|*"/${PATH_DOT}"|*"/${PATH_DOTDOT}")
    echo "INCONCLUSIVE: canonical installer-time HOME required"
    exit 2
    ;;
  /*) ;;
  *)
    echo "INCONCLUSIVE: absolute installer-time HOME required"
    exit 2
    ;;
esac
if [ ! -d "$AGENTSDOCK_INSTALL_HOME" ] || [ ! -x "$AGENTSDOCK_INSTALL_HOME" ]; then
  echo "INCONCLUSIVE: installer-time HOME is not a searchable directory"
  exit 2
fi
if PHYSICAL_INSTALL_HOME="$(cd -P -- "$AGENTSDOCK_INSTALL_HOME" 2>/dev/null && pwd -P)"; then
  if [ "$PHYSICAL_INSTALL_HOME" = '/' ]; then
    echo "INCONCLUSIVE: installer-time HOME resolves to root"
    exit 2
  fi
  AGENTSDOCK_INSTALL_HOME="$PHYSICAL_INSTALL_HOME"
  unset PHYSICAL_INSTALL_HOME
else
  echo "INCONCLUSIVE: cannot resolve installer-time HOME"
  exit 2
fi

inspect_path() {
  CHECK_LABEL="$1"
  CHECK_PATH="$2"
  PRESENT_LEVEL="$3"
  ABSENT_LEVEL="$4"
  case "$CHECK_PATH" in
    "/${PATH_DOT}/"*|"/${PATH_DOTDOT}/"*|*//*|*"/${PATH_DOTDOT}/"*|*"/${PATH_DOTDOT}"|*"/${PATH_DOT}/"*|*"/${PATH_DOT}")
      echo "INCONCLUSIVE: ${CHECK_LABEL} path is not canonical"
      return
      ;;
    /*) ;;
    *)
      echo "INCONCLUSIVE: ${CHECK_LABEL} path is not absolute"
      return
      ;;
  esac
  if [ "$CHECK_PATH" = '/' ]; then
    echo "INCONCLUSIVE: ${CHECK_LABEL} path is too broad"
    return
  fi
  if [ -L "$CHECK_PATH" ] && [ ! -e "$CHECK_PATH" ]; then
    echo "REVIEW: dangling ${CHECK_LABEL} symlink remains at ${CHECK_PATH}"
    return
  fi
  if [ -e "$CHECK_PATH" ] || [ -L "$CHECK_PATH" ]; then
    echo "${PRESENT_LEVEL}: ${CHECK_LABEL} remains at ${CHECK_PATH}"
    return
  fi
  CHECK_PROBE="$CHECK_PATH"
  while [ ! -e "$CHECK_PROBE" ] && [ ! -L "$CHECK_PROBE" ]; do
    CHECK_PARENT="${CHECK_PROBE%/*}"
    [ -n "$CHECK_PARENT" ] || CHECK_PARENT='/'
    if [ "$CHECK_PARENT" = "$CHECK_PROBE" ]; then
      echo "INCONCLUSIVE: cannot resolve ${CHECK_LABEL} parent"
      return
    fi
    CHECK_PROBE="$CHECK_PARENT"
  done
  if [ -L "$CHECK_PROBE" ] && [ ! -e "$CHECK_PROBE" ]; then
    echo "INCONCLUSIVE: dangling ancestor blocks ${CHECK_LABEL} check at ${CHECK_PROBE}"
  elif [ -d "$CHECK_PROBE" ] && [ -x "$CHECK_PROBE" ]; then
    echo "${ABSENT_LEVEL}: ${CHECK_LABEL} absent"
  else
    echo "INCONCLUSIVE: nearest ${CHECK_LABEL} ancestor is not searchable: ${CHECK_PROBE}"
  fi
}

if ! command -v launchctl >/dev/null 2>&1; then
  echo "INCONCLUSIVE: launchctl unavailable"
elif LAUNCH_DOMAIN_OUTPUT="$(launchctl print "gui/$(id -u)" 2>&1)"; then
  unset LAUNCH_DOMAIN_OUTPUT
  if SERVICE_OUTPUT="$(launchctl print "gui/$(id -u)/com.agentsdock.server" 2>&1)"; then
    unset SERVICE_OUTPUT
    echo "FAIL: service still loaded"
  else
    SERVICE_RC=$?
    case "$SERVICE_OUTPUT" in
      *"Could not find service"*) echo "PASS: exact service absent" ;;
      *)
        echo "INCONCLUSIVE: exact service check failed (status ${SERVICE_RC})"
        printf '%s\n' "$SERVICE_OUTPUT"
        ;;
    esac
    unset SERVICE_OUTPUT
  fi
else
  LAUNCH_DOMAIN_RC=$?
  echo "INCONCLUSIVE: launchctl user domain unavailable (status ${LAUNCH_DOMAIN_RC})"
fi
inspect_path launchd-plist "${AGENTSDOCK_INSTALL_HOME}/Library/LaunchAgents/com.agentsdock.server.plist" FAIL PASS

if ! command -v lsof >/dev/null 2>&1; then
  echo "INCONCLUSIVE: lsof unavailable"
elif LSOF_OUTPUT="$(lsof -nP -Fp -iTCP:"${AGENTSDOCK_PORT}" -sTCP:LISTEN 2>&1)"; then
  LSOF_PID_ROWS=''
  while IFS= read -r LSOF_LINE; do
    case "$LSOF_LINE" in
      p*)
        case "${LSOF_LINE#p}" in
          ''|*[!0-9]*) ;;
          *) LSOF_PID_ROWS="${LSOF_PID_ROWS}${LSOF_LINE}
" ;;
        esac
        ;;
    esac
  done <<EOF
$LSOF_OUTPUT
EOF
  if [ -n "$LSOF_PID_ROWS" ]; then
    echo "FAIL: listener remains"
    printf '%s' "$LSOF_PID_ROWS"
  else
    echo "INCONCLUSIVE: lsof succeeded without a PID row"
  fi
else
  LSOF_RC=$?
  if [ "$LSOF_RC" -eq 1 ] && [ -z "$LSOF_OUTPUT" ]; then
    echo "PASS: listener absent"
  else
    echo "INCONCLUSIVE: lsof failed (status ${LSOF_RC})"
    [ -z "$LSOF_OUTPUT" ] || printf '%s\n' "$LSOF_OUTPUT"
  fi
fi

if ! command -v pgrep >/dev/null 2>&1; then
  echo "INCONCLUSIVE: pgrep unavailable"
elif PGREP_OUTPUT="$(pgrep -f '[a]gent_server[.]py[[:space:]]+serve([[:space:]]|$)' 2>&1)"; then
  PGREP_PID_ROWS=''
  while IFS= read -r PGREP_LINE; do
    case "$PGREP_LINE" in
      ''|*[!0-9]*) ;;
      *) PGREP_PID_ROWS="${PGREP_PID_ROWS}${PGREP_LINE}
" ;;
    esac
  done <<EOF
$PGREP_OUTPUT
EOF
  if [ -n "$PGREP_PID_ROWS" ]; then
    echo "REVIEW: matching process remains"
    printf '%s' "$PGREP_PID_ROWS"
  else
    echo "INCONCLUSIVE: pgrep succeeded without a PID"
  fi
else
  PGREP_RC=$?
  if [ "$PGREP_RC" -eq 1 ] && [ -z "$PGREP_OUTPUT" ]; then
    echo "PASS: matching process absent"
  else
    echo "INCONCLUSIVE: pgrep failed (status ${PGREP_RC})"
  fi
fi

if ! command -v tmux >/dev/null 2>&1; then
  echo "INCONCLUSIVE: tmux unavailable; cannot inspect zd_* sessions"
elif ! command -v env >/dev/null 2>&1; then
  echo "INCONCLUSIVE: env unavailable; cannot select the managed default tmux socket"
# AgentsServer managed session 排除 TMUX/TMUX_TMPDIR;這裡複查同一個 default socket
elif TMUX_OUTPUT="$(env -u TMUX -u TMUX_TMPDIR tmux list-sessions -F '#{session_name}' 2>&1)"; then
  ZD_SESSIONS=''
  while IFS= read -r SESSION_NAME; do
    case "$SESSION_NAME" in
      zd_*) ZD_SESSIONS="${ZD_SESSIONS}${SESSION_NAME}
" ;;
    esac
  done <<EOF
$TMUX_OUTPUT
EOF
  if [ -n "$ZD_SESSIONS" ]; then
    echo "REVIEW: AgentsDock-named tmux sessions remain"
    printf '%s' "$ZD_SESSIONS"
  else
    echo "PASS: no zd_* tmux session"
  fi
else
  TMUX_RC=$?
  case "$TMUX_OUTPUT" in
    "no sessions")
      echo "PASS: reachable tmux server has no sessions"
      ;;
    *)
      echo "INCONCLUSIVE: tmux inspection failed (status ${TMUX_RC})"
      [ -z "$TMUX_OUTPUT" ] || printf '%s\n' "$TMUX_OUTPUT"
      ;;
  esac
fi

# 若安裝時用過 override,先把以下三值改成當時的精確根目錄
AGENTS_RUNTIME_ROOT="${AGENTSDOCK_INSTALL_HOME}/.local/share/agents-server"
AGENTS_CONFIG_ROOT="${AGENTSDOCK_INSTALL_HOME}/.config/agents-server"
AGENTSDOCK_STATE_ROOT="${AGENTSDOCK_INSTALL_HOME}/.agentsdock"

inspect_path runtime "$AGENTS_RUNTIME_ROOT" REVIEW PASS
inspect_path config "$AGENTS_CONFIG_ROOT" REVIEW PASS
inspect_path state "$AGENTSDOCK_STATE_ROOT" INFO INFO

Linux 卸載收據

# 請只輸入 installer 當時印出的實際 port;空白、前導零或範圍外都停止
printf 'Installer 實際 port(1-65535):' >&2
IFS= read -r AGENTSDOCK_PORT
case "$AGENTSDOCK_PORT" in
  ''|0*|*[!0-9]*) echo "INCONCLUSIVE: canonical recorded port required"; exit 2 ;;
esac
if [ "${#AGENTSDOCK_PORT}" -gt 5 ] \
  || [ "$AGENTSDOCK_PORT" -lt 1 ] \
  || [ "$AGENTSDOCK_PORT" -gt 65535 ]; then
  echo "INCONCLUSIVE: canonical recorded port required"
  exit 2
fi

printf 'Installer 當時的 HOME 絕對路徑:' >&2
IFS= read -r AGENTSDOCK_INSTALL_HOME
PATH_DOT='.'
PATH_DOTDOT='..'
case "$AGENTSDOCK_INSTALL_HOME" in
  ''|'/'|"/${PATH_DOT}/"*|"/${PATH_DOTDOT}/"*|*//*|*"/${PATH_DOT}/"*|*"/${PATH_DOTDOT}/"*|*"/${PATH_DOT}"|*"/${PATH_DOTDOT}")
    echo "INCONCLUSIVE: canonical installer-time HOME required"
    exit 2
    ;;
  /*) ;;
  *)
    echo "INCONCLUSIVE: absolute installer-time HOME required"
    exit 2
    ;;
esac
if [ ! -d "$AGENTSDOCK_INSTALL_HOME" ] || [ ! -x "$AGENTSDOCK_INSTALL_HOME" ]; then
  echo "INCONCLUSIVE: installer-time HOME is not a searchable directory"
  exit 2
fi
if PHYSICAL_INSTALL_HOME="$(cd -P -- "$AGENTSDOCK_INSTALL_HOME" 2>/dev/null && pwd -P)"; then
  if [ "$PHYSICAL_INSTALL_HOME" = '/' ]; then
    echo "INCONCLUSIVE: installer-time HOME resolves to root"
    exit 2
  fi
  AGENTSDOCK_INSTALL_HOME="$PHYSICAL_INSTALL_HOME"
  unset PHYSICAL_INSTALL_HOME
else
  echo "INCONCLUSIVE: cannot resolve installer-time HOME"
  exit 2
fi

inspect_path() {
  CHECK_LABEL="$1"
  CHECK_PATH="$2"
  PRESENT_LEVEL="$3"
  ABSENT_LEVEL="$4"
  case "$CHECK_PATH" in
    "/${PATH_DOT}/"*|"/${PATH_DOTDOT}/"*|*//*|*"/${PATH_DOTDOT}/"*|*"/${PATH_DOTDOT}"|*"/${PATH_DOT}/"*|*"/${PATH_DOT}")
      echo "INCONCLUSIVE: ${CHECK_LABEL} path is not canonical"
      return
      ;;
    /*) ;;
    *)
      echo "INCONCLUSIVE: ${CHECK_LABEL} path is not absolute"
      return
      ;;
  esac
  if [ "$CHECK_PATH" = '/' ]; then
    echo "INCONCLUSIVE: ${CHECK_LABEL} path is too broad"
    return
  fi
  if [ -L "$CHECK_PATH" ] && [ ! -e "$CHECK_PATH" ]; then
    echo "REVIEW: dangling ${CHECK_LABEL} symlink remains at ${CHECK_PATH}"
    return
  fi
  if [ -e "$CHECK_PATH" ] || [ -L "$CHECK_PATH" ]; then
    echo "${PRESENT_LEVEL}: ${CHECK_LABEL} remains at ${CHECK_PATH}"
    return
  fi
  CHECK_PROBE="$CHECK_PATH"
  while [ ! -e "$CHECK_PROBE" ] && [ ! -L "$CHECK_PROBE" ]; do
    CHECK_PARENT="${CHECK_PROBE%/*}"
    [ -n "$CHECK_PARENT" ] || CHECK_PARENT='/'
    if [ "$CHECK_PARENT" = "$CHECK_PROBE" ]; then
      echo "INCONCLUSIVE: cannot resolve ${CHECK_LABEL} parent"
      return
    fi
    CHECK_PROBE="$CHECK_PARENT"
  done
  if [ -L "$CHECK_PROBE" ] && [ ! -e "$CHECK_PROBE" ]; then
    echo "INCONCLUSIVE: dangling ancestor blocks ${CHECK_LABEL} check at ${CHECK_PROBE}"
  elif [ -d "$CHECK_PROBE" ] && [ -x "$CHECK_PROBE" ]; then
    echo "${ABSENT_LEVEL}: ${CHECK_LABEL} absent"
  else
    echo "INCONCLUSIVE: nearest ${CHECK_LABEL} ancestor is not searchable: ${CHECK_PROBE}"
  fi
}

if ! command -v systemctl >/dev/null 2>&1; then
  echo "INCONCLUSIVE: systemctl unavailable"
elif SYSTEMD_ENV_OUTPUT="$(systemctl --user show-environment 2>&1)"; then
  unset SYSTEMD_ENV_OUTPUT
  if UNIT_OUTPUT="$(systemctl --user list-unit-files agents-server.service --no-legend 2>&1)"; then
    UNIT_FOUND=0
    while IFS= read -r UNIT_LINE; do
      case "$UNIT_LINE" in
        agents-server.service|agents-server.service[[:space:]]*) UNIT_FOUND=1 ;;
      esac
    done <<EOF
$UNIT_OUTPUT
EOF
    if [ "$UNIT_FOUND" -eq 1 ]; then
      echo "FAIL: service unit remains"
      printf '%s\n' "$UNIT_OUTPUT"
    elif [ -n "$UNIT_OUTPUT" ]; then
      echo "INCONCLUSIVE: unexpected service-unit output"
      printf '%s\n' "$UNIT_OUTPUT"
    elif SERVICE_STATE_OUTPUT="$(systemctl --user show agents-server.service --property=LoadState --property=ActiveState --no-pager 2>&1)"; then
      LOAD_STATE=''
      ACTIVE_STATE=''
      while IFS= read -r STATE_LINE; do
        case "$STATE_LINE" in
          LoadState=*) LOAD_STATE="${STATE_LINE#LoadState=}" ;;
          ActiveState=*) ACTIVE_STATE="${STATE_LINE#ActiveState=}" ;;
        esac
      done <<EOF
$SERVICE_STATE_OUTPUT
EOF
      if [ "$LOAD_STATE" = 'not-found' ] && [ "$ACTIVE_STATE" = 'inactive' ]; then
        echo "PASS: service unit absent and inactive"
      elif [ -n "$LOAD_STATE" ] && [ -n "$ACTIVE_STATE" ]; then
        echo "REVIEW: service remains known (LoadState=${LOAD_STATE}, ActiveState=${ACTIVE_STATE})"
      else
        echo "INCONCLUSIVE: malformed service-state output"
        printf '%s\n' "$SERVICE_STATE_OUTPUT"
      fi
    else
      SERVICE_STATE_RC=$?
      echo "INCONCLUSIVE: service-state inspection failed (status ${SERVICE_STATE_RC})"
      [ -z "$SERVICE_STATE_OUTPUT" ] || printf '%s\n' "$SERVICE_STATE_OUTPUT"
    fi
  else
    UNIT_RC=$?
    echo "INCONCLUSIVE: service-unit inspection failed (status ${UNIT_RC})"
    [ -z "$UNIT_OUTPUT" ] || printf '%s\n' "$UNIT_OUTPUT"
  fi
else
  SYSTEMD_ENV_RC=$?
  echo "INCONCLUSIVE: systemd user manager unavailable (status ${SYSTEMD_ENV_RC})"
fi

if ! command -v ss >/dev/null 2>&1; then
  echo "INCONCLUSIVE: ss unavailable"
elif ! command -v awk >/dev/null 2>&1; then
  echo "INCONCLUSIVE: awk unavailable; cannot match listener port"
elif SS_OUTPUT="$(ss -H -ltn 2>&1)"; then
  if SS_MATCHES="$(printf '%s\n' "$SS_OUTPUT" | awk -v port="$AGENTSDOCK_PORT" '$1 == "LISTEN" && $4 ~ (":" port "$") { print }')"; then
    if [ -n "$SS_MATCHES" ]; then
      echo "FAIL: listener remains"
      printf '%s\n' "$SS_MATCHES"
    else
      echo "PASS: listener absent"
    fi
  else
    AWK_RC=$?
    echo "INCONCLUSIVE: listener matching failed (status ${AWK_RC})"
  fi
else
  SS_RC=$?
  echo "INCONCLUSIVE: ss inspection failed (status ${SS_RC})"
  [ -z "$SS_OUTPUT" ] || printf '%s\n' "$SS_OUTPUT"
fi

if ! command -v pgrep >/dev/null 2>&1; then
  echo "INCONCLUSIVE: pgrep unavailable"
elif PGREP_OUTPUT="$(pgrep -f '[a]gent_server[.]py[[:space:]]+serve([[:space:]]|$)' 2>&1)"; then
  PGREP_PID_ROWS=''
  while IFS= read -r PGREP_LINE; do
    case "$PGREP_LINE" in
      ''|*[!0-9]*) ;;
      *) PGREP_PID_ROWS="${PGREP_PID_ROWS}${PGREP_LINE}
" ;;
    esac
  done <<EOF
$PGREP_OUTPUT
EOF
  if [ -n "$PGREP_PID_ROWS" ]; then
    echo "REVIEW: matching process remains"
    printf '%s' "$PGREP_PID_ROWS"
  else
    echo "INCONCLUSIVE: pgrep succeeded without a PID"
  fi
else
  PGREP_RC=$?
  if [ "$PGREP_RC" -eq 1 ] && [ -z "$PGREP_OUTPUT" ]; then
    echo "PASS: matching process absent"
  else
    echo "INCONCLUSIVE: pgrep failed (status ${PGREP_RC})"
  fi
fi

if ! command -v tmux >/dev/null 2>&1; then
  echo "INCONCLUSIVE: tmux unavailable; cannot inspect zd_* sessions"
elif ! command -v env >/dev/null 2>&1; then
  echo "INCONCLUSIVE: env unavailable; cannot select the managed default tmux socket"
# AgentsServer managed session 排除 TMUX/TMUX_TMPDIR;這裡複查同一個 default socket
elif TMUX_OUTPUT="$(env -u TMUX -u TMUX_TMPDIR tmux list-sessions -F '#{session_name}' 2>&1)"; then
  ZD_SESSIONS=''
  while IFS= read -r SESSION_NAME; do
    case "$SESSION_NAME" in
      zd_*) ZD_SESSIONS="${ZD_SESSIONS}${SESSION_NAME}
" ;;
    esac
  done <<EOF
$TMUX_OUTPUT
EOF
  if [ -n "$ZD_SESSIONS" ]; then
    echo "REVIEW: AgentsDock-named tmux sessions remain"
    printf '%s' "$ZD_SESSIONS"
  else
    echo "PASS: no zd_* tmux session"
  fi
else
  TMUX_RC=$?
  case "$TMUX_OUTPUT" in
    "no sessions")
      echo "PASS: reachable tmux server has no sessions"
      ;;
    *)
      echo "INCONCLUSIVE: tmux inspection failed (status ${TMUX_RC})"
      [ -z "$TMUX_OUTPUT" ] || printf '%s\n' "$TMUX_OUTPUT"
      ;;
  esac
fi

# 若安裝時用過 override,先把以下三值改成當時的精確根目錄
AGENTS_RUNTIME_ROOT="${AGENTSDOCK_INSTALL_HOME}/.local/share/agents-server"
AGENTS_CONFIG_ROOT="${AGENTSDOCK_INSTALL_HOME}/.config/agents-server"
AGENTSDOCK_STATE_ROOT="${AGENTSDOCK_INSTALL_HOME}/.agentsdock"

inspect_path runtime "$AGENTS_RUNTIME_ROOT" REVIEW PASS
inspect_path config "$AGENTS_CONFIG_ROOT" REVIEW PASS
inspect_path state "$AGENTSDOCK_STATE_ROOT" INFO INFO

預期是 exact service 不存在、實際 port 沒有 listener、沒有 server process,也沒有 zd_* session;~/.agentsdock 在普通卸載後存在則是「資料被保留」,不是卸載失敗。收據中任一項 INCONCLUSIVE 都不算 PASS,要先修復檢查工具或 user-service session 再重跑;REVIEW 則要人工核對殘留是否屬於這次安裝。tmux 的 default socket 不存在也只算 INCONCLUSIVE,因為running server 的 socket 可能被移除;不能只憑 ENOENT 推論 session 已消失。若 process 或 listener 還在,先記錄 PID 與啟動來源,再回頭查 LaunchAgent/systemd;不要把單一殘留推論成所有版本都會重啟。

server 收據全部確認後,移除不需要的 server profile、完全退出 App,再依平台移除 client 程式。若連 client cache 也不保留,先核對再把精確 app-data 目錄移到垃圾桶。v1.0.0 desktop 原始碼把一般 user data 放在名為 agentsdock-electron 的平台 app-data 目錄,Windows uninstaller 的設定明確不自動刪 app data

若 App 已刪除,最安全做法是重新安裝同版本,再用內建清除流程;若要放棄全部 client state,則在核對後同時移除精確 user-data 目錄,以及確認屬於 AgentsDock 的 Keychain 項目。v1.0.0 可同時保留 Keychain credential 與 settings 裡的加密 token,因此只刪 Keychain 不能作為 token 已清除的收據;原始碼可查看 token 讀取、寫入與刪除邏輯。最後用 Activity Monitor/工作管理員確認沒有 AgentsDock client process。這才同時處理程式、cache 與 client 端 token,不只處理 server。

常見問題

AgentsDock 教學一定要用 Tailscale 嗎?

同一主機可用 loopback,同一個可信 LAN 可先測 LAN IP;跨網路時官方建議 Tailscale。不要把 installer 選到的 AgentsServer port 做 public port forwarding。Bearer token 是身分驗證,不等於替公開 HTTP 自動加密。

沒有 tmux 就不能使用 AgentsDock 嗎?

不一定。一般 chat、turn、job、檔案與搜尋不靠 tmux;持久互動 terminal、tmux pane 檢查及 App 內 managed update 才需要它。缺少 tmux 時應把這三項視為不可用,不要把 server health 誤判成全部功能都壞掉。

Self-hosted 代表程式碼都不離開主機嗎?

不是。AgentsServer 與歷史 state 可由你自架,但 Claude Code/Codex 仍會依各自 provider 的服務與資料政策送出模型請求;client 也可能快取內容。把「控制層自架」和「模型完全本機」分開理解。

Windows 可以當 AgentsServer 主機嗎?

不行,至少本文查核的 AgentsServer v1.0.0 不支援。Windows 10+ 可以安裝 AgentsDock desktop client,但 server host 要用 Linux 或 Apple silicon macOS;Windows v1.0.0 installer 仍未簽署。

關掉 AgentsDock App,Agent 會停止嗎?

不能把「關 client」當成「停 server」。Agent 是否仍在執行要看 turn、process 與 user service;完整停止需在介面停止任務,再用 process、listener 與 service 收據複核。

每個 AgentsDock chat 都有自己的 worktree 嗎?

AgentsDock 會保存 chat 指定的 working directory;一條 directory path 本身不等於獨立 Git worktree。要隔離 Claude Code 與 Codex,請先手動建立兩個 worktree,再逐一指定,最後用 branch 與路徑驗收。

AgentsDock 比 Paseo 更安全嗎?

不能只用產品名稱判定。AgentsDock 的風險取決於 server 暴露方式、token、OS 使用者與 CLI 權限;Paseo 還要區分 loopback、直接網路、VPN 與可選 E2EE relay。先選拓樸,再檢查 approval、sandbox、working directory 與卸載收據。

原生 Claude Code/Codex 遠端功能出現後,還需要 AgentsDock 嗎?

若你只用單一 provider、想少維運一層,先評估原生方案。若你要一個跨 Claude Code/Codex、多 server、原生手機與桌面 client 的統一介面,AgentsDock 仍有不同價值;但這份便利同時增加 server、token 與版本相容性的維運責任。

接著閱讀

左右滑動查看更多推薦

結論:先做得到「確實停掉」,才算真的會遠端

AgentsDock 把手機、桌面、多台 server、Claude Code 與 Codex 收進一個舒服的控制面;但安全不會由漂亮介面自動產生。今天先完成一件事:只經 LAN/Tailscale 連線,用唯讀 prompt 驗證 repo,再為兩個 Agent 指定不同 worktree。等你能說清楚哪台主機持有 token、哪個 CLI 能寫哪個目錄,以及如何讓 service、listener、process 都歸零,再把它交給長任務。

回到開頭公式:私有連線擋住不必要的入口;server token 與可直接改檔的 workspace API 要獨立保護;worktree 分開改動;CLI approval/sandbox 限制 provider turn;卸載收據則證明「停止」不是憑感覺。想把這套思路延伸到其他工具,可從 AlphaLab AI 專區繼續閱讀;需要完整實作路線,也可查看 AlphaLab 課程

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多三封:一封 Weekly 週報與最多兩封關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。