Files
shared/scripts/version-guard.mjs
T
jiantw83andClaude Sonnet 5 7a2bef2e1c fix(version-guard): 遠端版本查詢加上重試,避免暫時性網路失敗被誤判為 fail-closed
原本一次 fetch 失敗(逾時/連線中斷/非 2xx)就直接視為查無遠端版本並擋下工具呼叫,
對暫時性網路問題過度敏感;改為最多重試 3 次、每次間隔 300ms,仍全部失敗才 fail-closed。
TLS 憑證驗證全程不跳過,維持 --use-system-ca 既有信任機制;--timeout-ms 預設由 5000
降到 3000,讓重試後的最長總耗時仍落在 hooks.json 設定的 15 秒逾時內。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 11:40:44 +08:00

210 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
/**
* version-guard.mjs — 依 /jsc-shared:spec-version-guard 規範,比對「遠端發佈版本(Gitea
* master 的 plugin.json)」與「當前實際載入版本(本次呼叫實際讀到的 plugin 目錄)」,
* 兩者不同或查不到遠端版本一律 fail-closed(exit 非 0)。可被 hook(PreToolUse/SessionStart)
* 與 agent/CLI 兩種路徑呼叫。
*
* 用法:
* node version-guard.mjs [--plugin-root <path>] [--host <host>] [--timeout-ms <ms>]
*
* --plugin-root 本地 plugin 根目錄(含 plugin.json)。省略時使用 $CLAUDE_PLUGIN_ROOT;
* 兩者皆無則視為無法判定,fail-closed。
* --host Gitea 主機,省略時依序取 $GITEA_HOST,再退回 gitea.jsc.idv.tw。
* --timeout-ms 遠端查詢單次逾時毫秒數,預設 3000(最多重試 3 次,見下方「重試」說明)。
*
* 兩種呼叫模式(自動偵測,不需額外旗標):
* 1. hook 模式:stdin 餵進合法 JSON(Claude Code/Copilot 的 PreToolUse event)時,
* 改用 `persona/hooks/_hook.mjs` 已驗證可行的 stdout JSON 回應機制
* (`{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny"|"allow",...}}`),
* exit code 固定 0(是否阻擋由 JSON 內容決定,不是 exit code)。
* 2. CLI/測試模式:stdin 沒有合法 JSON(互動終端機或空管線)時,用 exit code 表示結果
* (0=相符,非 0=阻擋),訊息走 stderr——供人工測試與端到端驗證使用。
*
* 更新時間:2026/08/19 11:39:27
* 相依:Node.js 標準內建功能(fetch/fs/path),無外部套件;需 Node ≥ 20.12 並以
* `node --use-system-ca` 啟動,讓 fetch 走系統信任的 CA store(而非 Node 內建的
* Mozilla CA 清單)——在企業網路/代理攔截 TLS 的環境下,curl 等工具通常已信任
* 系統憑證,但 Node fetch 預設不會,若不加這個旗標會誤判成「查無法取得遠端版本」
* 而非真正的版本落差;呼叫端(hooks.json)的 command 必須帶上此旗標。TLS 憑證驗證
* 全程不跳過——此腳本的查詢結果直接決定要不要放行工具呼叫,跳過驗證等於讓中間人可
* 偽造回應誘導誤判,risk 遠大於單次連線失敗;遇到暫時性網路問題改用下方重試機制處理。
* 重試:遠端查詢對暫時性失敗(逾時/連線中斷/非 2xx/回應格式錯誤)最多重試到
* REMOTE_FETCH_MAX_ATTEMPTS 次,每次間隔 REMOTE_FETCH_RETRY_DELAY_MS,仍全部失敗才
* 視為「查無法取得遠端版本」fail-closed;--timeout-ms 預設從 5000 降到 3000,讓
* 「重試次數 × 單次逾時+重試間隔」的最長總耗時控制在 hooks.json 設定的 15 秒內。
* 機密:不涉及 token(遠端 raw plugin.json 端點匿名可讀),輸出仍套用
* shared/scripts/lib/redact-patterns.json 的遮蔽規則以防萬一。
* 退出碼(CLI/測試模式):0=版本相符;2=版本不符、查不到遠端、或本地版本無法判定(皆為 fail-closed)。
* hook 模式固定 exit 0,改用 stdout JSON 的 permissionDecision 表示阻擋與否(見上方兩種呼叫模式)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { log } from './lib/log.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REDACT_RULES = JSON.parse(fs.readFileSync(path.join(__dirname, 'lib', 'redact-patterns.json'), 'utf8')).rules;
function redact(text) {
let out = text;
for (const rule of REDACT_RULES) {
out = out.replace(new RegExp(rule.pattern, 'g'), rule.replacement);
}
return out;
}
const REMOTE_FETCH_MAX_ATTEMPTS = 3;
const REMOTE_FETCH_RETRY_DELAY_MS = 300;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseArgs(argv) {
const args = { pluginRoot: null, host: null, timeoutMs: 3000 };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--plugin-root') args.pluginRoot = argv[++i];
else if (a === '--host') args.host = argv[++i];
else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i]);
else {
log('ERR', `未知參數:${redact(a)}`, '版本檢查');
process.exit(1);
}
}
return args;
}
function resolvePluginRoot(args) {
return args.pluginRoot || process.env.CLAUDE_PLUGIN_ROOT || null;
}
function resolveHost(args) {
return args.host || process.env.GITEA_HOST || 'gitea.jsc.idv.tw';
}
/** 從本地 plugin.json 的 name(例如 "jsc-shared")推得 repo 短名("shared")。 */
function shortNameFromPluginName(name) {
return name.startsWith('jsc-') ? name.slice('jsc-'.length) : name;
}
async function fetchRemoteVersionOnce(host, shortName, timeoutMs) {
const url = `https://${host}/api/v1/repos/plugins/${shortName}/raw/plugin.json?ref=master`;
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const body = await res.json();
if (typeof body.version !== 'string') {
throw new Error('遠端 plugin.json 缺少 version 欄位');
}
return body.version;
}
/** 對暫時性失敗(逾時/連線中斷/非 2xx/回應格式錯誤)重試,TLS 憑證驗證全程不跳過。 */
async function fetchRemoteVersion(host, shortName, timeoutMs) {
let lastErr;
for (let attempt = 1; attempt <= REMOTE_FETCH_MAX_ATTEMPTS; attempt++) {
try {
return await fetchRemoteVersionOnce(host, shortName, timeoutMs);
} catch (err) {
lastErr = err;
if (attempt < REMOTE_FETCH_MAX_ATTEMPTS) {
log(
'WRN',
`遠端版本查詢第 ${attempt} 次失敗(${String(err.message)}),${REMOTE_FETCH_RETRY_DELAY_MS}ms 後重試…`,
'版本檢查'
);
await sleep(REMOTE_FETCH_RETRY_DELAY_MS);
}
}
}
throw lastErr;
}
function blockMessage(pluginName, remoteVersion, loadedVersion) {
const remoteText = remoteVersion || '查無法取得遠端版本';
return (
`${pluginName} 遠端發佈版本為 ${remoteText},當前實際載入版本為 ${loadedVersion}。\n` +
'請執行 /jsc-shared:plugins-install 更新後重新開啟工作階段。'
);
}
/** 嘗試把 stdin 當 hook event JSON 讀(比照 persona/hooks/_hook.mjs 的 readEvent());讀不到回傳 null。 */
function readHookEvent() {
try {
const raw = fs.readFileSync(0, 'utf8');
if (!raw.trim()) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/** hook 模式的回應:exit code 固定 0,是否阻擋由 stdout JSON 的 permissionDecision 決定。 */
function respondHook(decision, reason) {
process.stdout.write(
`${JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: decision,
...(reason ? { permissionDecisionReason: reason } : {}),
},
})}\n`
);
process.exit(0);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const hookEvent = readHookEvent();
const isHookMode = hookEvent !== null;
function fail(message) {
log('ERR', redact(message), '版本檢查');
if (isHookMode) respondHook('deny', redact(message));
process.exit(2);
}
const pluginRoot = resolvePluginRoot(args);
if (!pluginRoot) {
fail('無法判定本地 plugin 根目錄(未帶 --plugin-root 且 $CLAUDE_PLUGIN_ROOT 未設定),fail-closed 阻擋。');
return;
}
const pluginJsonPath = path.join(pluginRoot, 'plugin.json');
let localMeta;
try {
localMeta = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
} catch (err) {
fail(`讀取本地 plugin.json 失敗(${String(err.message)}),fail-closed 阻擋。`);
return;
}
const loadedVersion = localMeta.version;
const shortName = shortNameFromPluginName(localMeta.name);
const host = resolveHost(args);
let remoteVersion = null;
try {
remoteVersion = await fetchRemoteVersion(host, shortName, args.timeoutMs);
} catch (err) {
fail(`${blockMessage(localMeta.name, null, loadedVersion)}(遠端查詢失敗:${String(err.message)})`);
return;
}
if (remoteVersion !== loadedVersion) {
fail(blockMessage(localMeta.name, remoteVersion, loadedVersion));
return;
}
log('INF', `${localMeta.name} 版本相符(${loadedVersion}),放行。`, '版本檢查');
if (isHookMode) respondHook('allow');
process.exit(0);
}
main();