fix(assistant): 巡檢改用字面絕對路徑,根目錄由排程條目帶進來

無人看管的排程輪次在第一支腳本就被權限層擋下。心跳因此寫不出來,助理斷了兩個多小時,沒有人發現。

2026-09-02 到 09-03 用非互動模式比照排程環境實測七種寫法,歸納出兩條判準。一、無人值守時只有允許清單上的完整字面指令跑得動,沒有「預設安全的唯讀指令」這回事,連 readlink 與 ls 都要有自己的規則。二、路徑中段的萬用字元不匹配,規則與指令都必須是完整字面,所以帶版本號的快取路徑放不進允許清單。「先解路徑再用」因此不成立:解路徑的指令自己就過不了,而路徑能寫成字面就不必解。

助理技能新增路徑守則與 Step 0。守則寫明權限層比對的是指令還沒展開的字面字串,帶未展開變數或波浪號的路徑一律要核准,並附上七列實測佐證表。Step 0 從「自己跑 readlink 解路徑」改成「從叫用文字的『工具根目錄=』取字面絕對路徑」,排程那一輪一個解析指令都不跑;人在現場叫用才用 readlink 解一次,那一次有人可以按同意。取不到根目錄就停下回報,收尾狀態取 aborted,不猜也不退回帶變數的路徑。全篇 46 處腳本呼叫改成字面絕對路徑。

排程工具在安裝時把解好的字面根目錄寫進條目的提示文字,並印成 patrol_root=。條目與允許規則共用同一個值,兩邊各算各的就會差開,而差開的那一輪是被靜靜擋掉。允許規則的提示改成完整字面路徑,不寫變數、波浪號與萬用字元。JSC_HOME 解不出絕對路徑時回結束碼 6,不讓相對路徑寫進條目。自訂巡檢指令沒帶那一段只警告、不中止。

行為契約四列與說明文件兩處敘述一併跟上。
This commit is contained in:
2026-09-03 10:10:01 +08:00
parent 225478f756
commit fb8b9baedd
4 changed files with 160 additions and 74 deletions
+67 -26
View File
@@ -17,8 +17,9 @@
# 3 這台機器沒有可用的排程機制:認不得作業系統,或 crontab 與 schtasks 都找不到
# 4 排程操作失敗:讀不到現有排程(且失敗原因不是「沒有排程」)、寫入或刪除回非 0
# 5 回讀驗證失敗:寫入回 0 但條目不在,或移除回 0 但條目還在,又或其他人的條目數量對不上
# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數、判不出要用哪一支 CLI 跑巡檢,
# 或 --period 給的週期塞不進心跳的過期門檻
# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數、判不出要用哪一支 CLI 跑巡檢、
# --period 給的週期塞不進心跳的過期門檻,或 JSC_HOME 解不出絕對路徑(條目裡的根目錄
# 只要不是絕對路徑,那一輪就叫不到任何工具)
#
# --- 排程只叫巡檢,心跳由巡檢寫 ---
#
@@ -80,12 +81,29 @@
# 被擋。頁寫不成就不寫心跳,於是排程裝著卻永遠空轉。所以條目自帶 JSC_GITEA_CONFIRM=yes:
# 無人值守的那一輪本來就沒有人可以按同意,擋下來也沒有人會看到。
#
# --- 根目錄從條目餵進去,不由那一輪自己解 ---
#
# 巡檢那一輪要用字面絕對路徑叫工具,所以它得先知道根目錄。那一輪自己解不出來:解路徑的
# 指令(`readlink -f "$JSC_HOME/current"`、`ls -d "$JSC_HOME/current"`)在無人值守的工作
# 階段實測一律被擋,連 `ls -d /root/.jsc/current` 這種字面唯讀指令沒有允許規則也照擋。
# 能寫成字面的話又不必解了。所以根目錄只能從外面餵進去。
# 本腳本是在機器上、由人叫起來的,解得到根目錄,也解得起。install 於是把解好的字面根目錄
# 寫進條目的提示文字,那一輪讀提示就拿得到,一個指令都不用跑。
# 提示裡的格式固定是「工具根目錄={字面絕對路徑}」:技能靠這一段取值,人也讀得懂。
# 寫進條目的一定是展開後的字面值,不是 $JSC_HOME:條目裡留變數,那一輪拿到的還是變數。
#
# --- 裝完要開哪些權限 ---
#
# 排程那一輪跑在沒有人的工作階段,跳出權限詢問就是卡住整輪,而且卡到鎖逾時才有下一輪。
# install 成功之後會把那一輪需要的 allow 規則印出來,一行一條。
# 路徑一律走 $JSC_HOME/current/{外掛名}:那是一組不帶版本的連結,指向該外掛在快取裡的最新
# 版,由 deploy 維護。規則就是那組確切路徑,比對得準,外掛升版也不用回頭改設定。
# 實測歸納出兩條判準,印規則一律照它走:
# 一、無人值守時只有允許清單上的完整字面指令跑得動。沒有「預設安全的唯讀指令」這回事:
# `ls -d` 這種指令一樣要有自己的規則,不然照擋。
# 二、路徑中段的萬用字元不匹配。規則與指令都必須是完整字面,所以規則裡不寫版本號的
# 萬用字元,也不寫 $JSC_HOME 或 ~。
# 路徑一律走 $JSC_HOME/current/{外掛名},而且印出來的是展開後的字面值:那是一組不帶版本的
# 連結,指向該外掛在快取裡的最新版,由 deploy 維護。規則就是那組確切路徑,比對得準,外掛
# 升版也不用回頭改設定;改指到快取的實體路徑反而會因為帶版本號而每次升版都失效。
# 檔案寫入只認 Edit(...),Write(...) 規則沒有作用,所以不印 Write。
# 連結不在就先警告:那一輪會因為找不到工具而失敗。連結由 deploy 建,本腳本不代建——排程
# 腳本自己去補外掛的部署結構,等於兩個地方管同一件事,壞掉的時候查不出是誰建的。
@@ -157,6 +175,20 @@ die() { # $1=結束碼 $2=訊息
note() { printf '[jsc][助理排程]:%s\n' "$1" >&2; }
# 條目與允許規則共用的字面根目錄。兩邊共用同一個值是刻意的:規則放行哪一組路徑,那一輪就
# 只能用哪一組路徑叫工具,兩邊各算各的就會差開,而差開的那一輪是被靜靜擋掉,沒有訊號。
# 相對路徑一律先解成絕對。相對路徑寫進條目等於指向 cron 的工作目錄,那一輪叫不到任何工具。
ROOT="$CURRENT"
case "$ROOT" in
/*) ;;
*)
_r=$(CDPATH= cd -- "$ROOT" 2>/dev/null && pwd -L) || _r=''
[ -n "$_r" ] || die 6 "JSC_HOME 是相對路徑($JSC_HOME),$ROOT 也解不出絕對路徑。條目與允許規則都需要字面絕對路徑,請把 JSC_HOME 設成絕對路徑再跑一次。"
ROOT="$_r" ;;
esac
# 條目提示文字裡的根目錄那一段。技能靠這一段取值,所以格式固定,不隨 CLI 變。
ROOT_TOKEN="工具根目錄=$ROOT"
# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序:先環境變數覆寫,再
# $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。
# current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑另一個
@@ -266,17 +298,18 @@ spec_of() {
# 判不出 CLI,或那一支的執行檔不在 PATH 上,都回非 0 由主流程回 6,不猜。
# 執行檔一律用 `command -v` 解成絕對路徑:cron 的 PATH 只有 /usr/bin 與 /bin,裸的指令名
# 每一輪都是 not found,而那一輪不會有人看到錯誤訊息。
# 每一支 CLI 的提示文字都接上根目錄那一段:那一輪自己解不出根目錄,只能從提示裡拿。
patrol_command() {
[ -n "$PATROL_CMD" ] && { printf '%s' "$PATROL_CMD"; return 0; }
_cli="$CLI"
[ -n "$_cli" ] || _cli="${JSC_CLI:-}"
[ -n "$_cli" ] || { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && _cli=claude; }
case "$_cli" in
claude) _bin='claude'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;;
codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢'" ;;
copilot) _bin='copilot'; _args='-p "跑一輪助理巡檢"' ;;
antigravity) _bin='agy'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;;
kiro) _bin='kiro-cli'; _args='-p "跑一輪助理巡檢"' ;;
claude) _bin='claude'; _args="-p \"/jsc-assist:assistant 跑一輪巡檢 $ROOT_TOKEN\"" ;;
codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢 $ROOT_TOKEN'" ;;
copilot) _bin='copilot'; _args="-p \"跑一輪助理巡檢 $ROOT_TOKEN\"" ;;
antigravity) _bin='agy'; _args="-p \"/jsc-assist:assistant 跑一輪巡檢 $ROOT_TOKEN\"" ;;
kiro) _bin='kiro-cli'; _args="-p \"跑一輪助理巡檢 $ROOT_TOKEN\"" ;;
*) printf '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。\n' >&2
return 1 ;;
esac
@@ -422,7 +455,14 @@ case "$CMD:$JOBS" in
die 6 "$_why"
fi
ENV_PREFIX=$(env_prefix "$TMPD/snapnames")
SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null) ;;
SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null)
# 提示文字裡沒有根目錄那一段,無人值守那一輪就拿不到根目錄,只能停下回報。本腳本自己
# 產生的指令一律帶著,所以會走到這裡的只有 --patrol-cmd 與 JSC_ASSIST_PATROL_CMD。
# 這裡只警告不中止:自訂指令有可能根本不是叫這支技能,中止會把那條路擋掉。
case "$PATROL_RESOLVED" in
*"$ROOT_TOKEN"*) ;;
*) note "自訂的巡檢指令裡沒有「$ROOT_TOKEN」。那一輪自己解不出根目錄,只能從提示文字拿,拿不到就會停下回報,什麼都不記。請把這一段原樣加進提示文字裡。" ;;
esac ;;
esac
# --- 裝完要開的權限 ---
@@ -444,13 +484,13 @@ print_allow_rules() {
# 代跑(那是已放行指令的子行程,不會再問一次),但收尾那一筆 skill-end 是技能自己用 Bash 叫的,
# 那一次就要這一條規則。少了它,那一輪會停在最後一步的權限詢問,而排程那一輪沒有人可以按
# 同意:那一輪的收尾事件寫不出去,start 永遠配不到 end,下一輪就把一輪其實做完的巡檢報成中止。
for _s in "$CURRENT/jsc-assist/tools/schedule.sh" \
"$CURRENT/jsc-assist/tools/patrol.sh" \
"$CURRENT/jsc-hooks/hooks/heartbeat.sh" \
"$CURRENT/jsc-hooks/tools/report-status.sh" \
"$CURRENT/jsc-gitea/tools/gitea.sh" \
"$CURRENT/jsc-gitea/tools/wiki-contents.sh" \
"$CURRENT/jsc-gitea/tools/link-check.sh"; do
for _s in "$ROOT/jsc-assist/tools/schedule.sh" \
"$ROOT/jsc-assist/tools/patrol.sh" \
"$ROOT/jsc-hooks/hooks/heartbeat.sh" \
"$ROOT/jsc-hooks/tools/report-status.sh" \
"$ROOT/jsc-gitea/tools/gitea.sh" \
"$ROOT/jsc-gitea/tools/wiki-contents.sh" \
"$ROOT/jsc-gitea/tools/link-check.sh"; do
printf 'allow_rule=Bash(%s:*)\n' "$_s"
printf 'allow_rule=Bash(sh %s:*)\n' "$_s"
printf 'allow_rule=Bash(bash %s:*)\n' "$_s"
@@ -467,9 +507,9 @@ print_allow_rules() {
check_current_links() {
_miss=''; _paths=''
for _p in jsc-assist jsc-gitea; do
[ -e "$CURRENT/$_p" ] && continue
if [ -z "$_miss" ]; then _miss="$_p"; _paths="$CURRENT/$_p"
else _miss="$_miss,$_p"; _paths="$_paths、$CURRENT/$_p"; fi
[ -e "$ROOT/$_p" ] && continue
if [ -z "$_miss" ]; then _miss="$_p"; _paths="$ROOT/$_p"
else _miss="$_miss,$_p"; _paths="$_paths、$ROOT/$_p"; fi
done
if [ -n "$_miss" ]; then
printf 'current_links=missing:%s\n' "$_miss"
@@ -507,7 +547,7 @@ schtasks_install() {
printf 'installed=%s task=%s\n' "$_job" "$_tn"
_rc=0
done
printf 'ttl=%s period=%s legacy_removed=%s log=%s\n' "$TTL" "$PERIOD" "$_legacy" "$LOG"
printf 'ttl=%s period=%s legacy_removed=%s patrol_root=%s log=%s\n' "$TTL" "$PERIOD" "$_legacy" "$ROOT" "$LOG"
return "$_rc"
}
@@ -566,8 +606,8 @@ crontab_install() {
for _job in $JOBS; do
printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)"
done
printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s env_snapshot=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" "${SNAPSHOT_NAMES:-無}"
printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s env_snapshot=%s patrol_root=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" "${SNAPSHOT_NAMES:-無}" "$ROOT"
printf -- '--- 寫回後的 crontab ---\n'
mask_secret <"$_new"
return 0
@@ -591,8 +631,8 @@ crontab_install() {
for _job in $JOBS; do
printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job" | mask_secret)"
done
printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s log=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$LOG"
printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s patrol_root=%s log=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$ROOT" "$LOG"
return 0
}
@@ -669,7 +709,8 @@ case "$CMD" in
# 一樣要開權限,只是還要先把 cron 服務叫起來。
print_allow_rules
check_current_links
note '上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則放行的是 $JSC_HOME/current 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。'
note "上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則與指令都要是完整字面:無人值守時只有清單上的完整字面指令跑得動,沒有預設放行的唯讀指令;路徑中段的萬用字元也不匹配,版本號寫成 * 的規則一樣擋。規則放行的是 $ROOT 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。"
note "條目的提示文字帶著「$ROOT_TOKEN」:那一輪自己解不出根目錄,解路徑的指令在無人值守時一樣被擋,所以根目錄由這一筆條目餵進去。這一段被改掉或刪掉,那一輪會停下回報,什麼都不記。"
note "條目帶著安裝當下的環境變數快照(${SNAPSHOT_NAMES:-無}),其中含 Gitea 金鑰:crontab 檔案請保持只有本人讀得到。這幾個變數改過就要重跑一次 install,條目才會跟著換。"
[ "$DRYRUN" -eq 1 ] && exit 0
_svc=$(service_state)