From 3b86674d1b1d18216d2fbd9a7acf0bcfbb15d30b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 18:28:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(schedule):=20=E8=AE=93=E6=8E=92=E7=A8=8B?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E8=BC=AA=E8=87=AA=E5=B7=B1=E5=B8=B6=E9=BD=8A?= =?UTF-8?q?=E7=92=B0=E5=A2=83=E8=88=87=E6=AC=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排程條目改成自己帶齊執行環境,install 完也印出那一輪要開的權限規則。 背景助理在無人值守的輪次連續失敗,查出四道關卡。cron 給的 PATH 很短, 不含使用者的 bin 目錄,裸的指令名每一輪都是找不到執行檔。cron 也不讀 殼的設定檔,那一輪沒有 Gitea 主機位址與 wiki 存放庫變數,連不上 wiki。 寫入確認只認 tty,排程沒有 tty,寫監控頁一定被擋。工具權限沒開,巡檢 腳本連跑都跑不起來,而那一輪沒有人可以按同意。四關任何一關沒過,那一 輪就不寫心跳,排程裝著卻永遠空轉,外面只看得到心跳過期。 CLI 改用 command -v 解成絕對路徑再寫進條目,解不到就停下,不猜路徑。 install 當下把主機位址、金鑰、狀態根目錄、心跳門檻與所有已設定的 wiki 存放庫變數就地快照,以名稱等於值的前綴寫進條目,值裡的單引號照 POSIX 寫法跳脫。條目自帶寫入確認旗標,無人值守的那一輪本來就沒有人可以按同 意。install 完印出四支腳本各三種呼叫形式的 allow 規則,路徑一律取 current 那一組不帶版本的連結,並順手檢查連結在不在,不在只警告、不代 建。腳本內部找心跳腳本也改成 current 優先,避免同一輪跑到混版的工具。 條目裡帶著金鑰快照,所以印條目時一律把金鑰遮成星號,遮到下一個空白為 止;crontab 檔案要保持只有本人讀得到,這幾個變數改過就要重跑一次 install。功能範圍是助理的系統排程安裝、移除與查現況。 --- tools/schedule.sh | 200 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 171 insertions(+), 29 deletions(-) diff --git a/tools/schedule.sh b/tools/schedule.sh index 9e78b91..c7ee6cc 100755 --- a/tools/schedule.sh +++ b/tools/schedule.sh @@ -60,6 +60,36 @@ # 條目一律接 `&2; } -# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序比照 jsc-hooks lib.sh 的 -# jsc_gitea_sh():先環境變數,再開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。 +# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序:先環境變數覆寫,再 +# $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。 +# current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑另一個 +# 版本,同一輪就會跑到混版的工具,而那種不一致查起來沒有任何線索。 heartbeat_sh() { if [ -n "${JSC_HOOKS_HOOKS:-}" ] && [ -f "$JSC_HOOKS_HOOKS/heartbeat.sh" ]; then printf '%s\n' "$JSC_HOOKS_HOOKS/heartbeat.sh"; return 0 fi + if [ -f "$CURRENT/jsc-hooks/hooks/heartbeat.sh" ]; then + printf '%s\n' "$CURRENT/jsc-hooks/hooks/heartbeat.sh"; return 0 + fi _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" for _c in "$_root/../hooks/hooks/heartbeat.sh" "$_root/../jsc-hooks/hooks/heartbeat.sh"; do [ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/heartbeat.sh\n' "$(pwd)"); return 0; } @@ -206,28 +245,74 @@ spec_of() { } # 巡檢要跑的指令。優先序:--patrol-cmd 或 JSC_ASSIST_PATROL_CMD > 依 CLI 代號推斷。 -# 判不出 CLI 就回非 0,由主流程回 6,不猜——猜錯會每 15 分鐘跑一支不存在的執行檔。 +# 判不出 CLI,或那一支的執行檔不在 PATH 上,都回非 0 由主流程回 6,不猜。 +# 執行檔一律用 `command -v` 解成絕對路徑:cron 的 PATH 只有 /usr/bin 與 /bin,裸的指令名 +# 每一輪都是 not found,而那一輪不會有人看到錯誤訊息。 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) printf 'claude -p "/jsc-assist:assistant 跑一輪巡檢"' ;; - codex) printf "codex exec '\$assistant 跑一輪巡檢'" ;; - copilot) printf 'copilot -p "跑一輪助理巡檢"' ;; - antigravity) printf 'agy -p "/jsc-assist:assistant 跑一輪巡檢"' ;; - kiro) printf 'kiro-cli -p "跑一輪助理巡檢"' ;; - *) return 1 ;; + 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 "跑一輪助理巡檢"' ;; + *) printf '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。\n' >&2 + return 1 ;; esac + _abs=$(command -v "$_bin" 2>/dev/null) || _abs='' + # 內建指令與別名也會被 command -v 認出來,但那些寫進 cron 沒有意義,只收絕對路徑。 + case "$_abs" in + /*) ;; + *) printf '這台機器的 PATH 上找不到 %s 的執行檔,排程條目沒有絕對路徑可以寫。請先確認 %s 裝好了,或改用 --patrol-cmd 指定完整指令。\n' "$_cli" "$_bin" >&2 + return 1 ;; + esac + printf "'%s' %s" "$_abs" "$_args" } +# 要快照進條目的環境變數名稱。前四支是巡檢那一輪一定要用到的;JSC_WIKI_REPO 系列逐台機器 +# 不同,直接從現在的環境撈出所有已設定的,不寫死清單。 +snapshot_names() { + printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL + env 2>/dev/null \ + | sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \ + | sort -u +} + +# 把值包成單引號字串。值裡的單引號照 POSIX 的 '\'' 寫法拆開再接回來,不然一個引號就把 +# 整條 cron 指令切斷。 +shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } + +# 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值—— +# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。 +# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁 +# 一定寫不成,而頁寫不成就不寫心跳,排程等於永遠空轉。 +env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值) + _p='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes' + _names='' + for _n in $(snapshot_names); do + eval "_v=\${$_n:-}" + [ -n "$_v" ] || continue + _p="$_p $_n=$(shq "$_v")" + if [ -z "$_names" ]; then _names="$_n"; else _names="$_names,$_n"; fi + done + [ -n "${1:-}" ] && printf '%s' "$_names" >"$1" + printf '%s' "$_p" +} + +# 條目裡有金鑰快照,原樣印出來就是把金鑰留在對話紀錄與 log 裡,所以印之前先遮掉值。 +# 遮到下一個空白為止,不遮到下一個單引號:值裡的單引號會跳脫成 '\'',遇到引號就收手會把 +# 金鑰的後半段漏出來。Gitea 的金鑰不含空白,所以以空白為界是安全的。 +mask_secret() { sed "s/GITEA_TOKEN=[^ ]*/GITEA_TOKEN='***'/g"; } + # 組出一筆 crontab 條目。`%` 在 crontab 是換行符號,一律跳脫。 cron_entry() { # $1=工作代號 _spec=$(spec_of "$1") case "$1" in heartbeat) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule '$HEARTBEAT' write" ;; - patrol) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule $PATROL_RESOLVED" ;; + patrol) _cmd="$ENV_PREFIX $PATROL_RESOLVED" ;; esac printf '%s %s >%s 2>&1 %s' \ "$_spec" "$_cmd" "'$LOG'" "$(marker_of "$1")" | sed 's/%/\\%/g' @@ -301,18 +386,69 @@ else PERIOD=$(period_for_ttl "$TTL") fi -# 巡檢指令在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡跑, -# 判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。 -PATROL_RESOLVED='' -case "$CMD:$JOBS" in - install:*patrol*) - PATROL_RESOLVED=$(patrol_command) \ - || die 6 '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。' ;; -esac - TMPD=$(mktemp -d) || die 4 '建不出暫存目錄。' trap 'rm -rf "$TMPD"' EXIT +# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡 +# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。 +PATROL_RESOLVED='' +ENV_PREFIX='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes' +SNAPSHOT_NAMES='' +case "$CMD:$JOBS" in + install:*patrol*) + if ! PATROL_RESOLVED=$(patrol_command 2>"$TMPD/cmd.err"); then + _why=$(tr '\n' ' ' <"$TMPD/cmd.err" 2>/dev/null) + [ -n "$_why" ] || _why='判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。' + die 6 "$_why" + fi + ENV_PREFIX=$(env_prefix "$TMPD/snapnames") + SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null) ;; +esac + +# --- 裝完要開的權限 --- + +# 排程那一輪跑在沒有人的工作階段:跳出一次權限詢問就是整輪卡住,卡到鎖逾時才有下一輪。 +# 這一輪會用到的三支腳本,各印裸路徑、`sh 路徑`、`bash 路徑` 三種呼叫形式——同一支腳本換 +# 一種叫法就是另一條規則,少印一種就會在那一種叫法上卡住。 +# 路徑一律是 $JSC_HOME/current/{外掛名} 那一組,不是這支腳本現在被放在哪裡:規則放行的是 +# 那一組路徑,巡檢那一輪也只能用那一組路徑去叫工具,兩邊對得起來才不會被靜靜擋掉。 +print_allow_rules() { + # gitea.sh 一定要有自己這一條。`Skill(jsc-gitea:wiki)` 只放行「叫用那支技能」,技能裡的 + # 每一個 Bash 呼叫仍然各自受檢,少了這一條,那一輪會在寫監控頁時靜靜被擋——頁寫不成就 + # 不寫心跳,外面只看得到心跳過期,看不出是權限擋的。 + for _s in "$CURRENT/jsc-assist/tools/schedule.sh" \ + "$CURRENT/jsc-assist/tools/patrol.sh" \ + "$CURRENT/jsc-hooks/hooks/heartbeat.sh" \ + "$CURRENT/jsc-gitea/tools/gitea.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" + done + # 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名 + # 等於這條規則永遠比對不到。 + printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR" + printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR" + printf 'allow_rule=Skill(jsc-gitea:wiki)\n' +} + +# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查 +# 與警告,不代建:連結是 deploy 的職責,兩個地方都在建同一組連結,壞掉時查不出是誰建的。 +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 + done + if [ -n "$_miss" ]; then + printf 'current_links=missing:%s\n' "$_miss" + note "找不到這幾個連結:$_paths。巡檢那一輪會照這一組路徑叫工具,連結不在就叫不到,那一輪一定失敗,也不會寫心跳。請先跑 deploy 把連結建起來——本腳本不代建。" + return 0 + fi + printf 'current_links=ok\n' + return 0 +} + # --- schtasks(Windows)--- schtasks_install() { @@ -397,12 +533,12 @@ crontab_install() { if [ "$DRYRUN" -eq 1 ]; then for _job in $JOBS; do - printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job")" + 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\n' \ - "$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" + 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 -- '--- 寫回後的 crontab ---\n' - cat "$_new" + mask_secret <"$_new" return 0 fi @@ -422,10 +558,10 @@ crontab_install() { || die 5 "別人的排程條目從 $_others 筆變成 $_kept 筆,寫回不完整。" for _job in $JOBS; do - printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job")" + 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 log=%s\n' \ - "$TTL" "$PERIOD" "$_legacy" "$_kept" "$LOG" + printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s log=%s\n' \ + "$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$LOG" return 0 } @@ -454,7 +590,7 @@ crontab_remove() { if [ "$DRYRUN" -eq 1 ]; then printf 'dryrun=crontab action=remove jobs=%s removed=%s\n' "$JOBS" "$_hit" printf -- '--- 寫回後的 crontab ---\n' - cat "$_new" + mask_secret <"$_new" return 0 fi @@ -480,7 +616,7 @@ crontab_status() { printf 'mechanism=crontab service=%s ttl=%s period=%s log=%s\n' \ "$(service_state)" "$TTL" "$PERIOD" "$LOG" for _job in heartbeat patrol; do - _line=$(cron_lines_for "$_cur" "$_job") + _line=$(cron_lines_for "$_cur" "$_job" | mask_secret) if [ -n "$_line" ]; then printf 'job=%s installed=yes entry=%s\n' "$_job" "$_line" else @@ -498,6 +634,12 @@ case "$CMD" in crontab) crontab_install ;; schtasks) schtasks_install ;; esac + # 權限規則與快照提醒排在服務檢查前面:結束碼 1 那一條也是條目已經寫進去了,那台機器 + # 一樣要開權限,只是還要先把 cron 服務叫起來。 + print_allow_rules + check_current_links + note '上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則放行的是 $JSC_HOME/current 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。' + note "條目帶著安裝當下的環境變數快照(${SNAPSHOT_NAMES:-無}),其中含 Gitea 金鑰:crontab 檔案請保持只有本人讀得到。這幾個變數改過就要重跑一次 install,條目才會跟著換。" [ "$DRYRUN" -eq 1 ] && exit 0 _svc=$(service_state) if [ "$_svc" = stopped ]; then