diff --git a/server/templates/cli.sh b/server/templates/cli.sh index bb60884..4453c65 100644 --- a/server/templates/cli.sh +++ b/server/templates/cli.sh @@ -113,11 +113,16 @@ run_install() { # 安装到 bin 目录:优先可写目录,其次免密 sudo(sudo -n 不会卡住无人值守的 Agent),最后 ~/.local/bin install_to_bin() { - local src="$1" bin_dir="${ASH_BIN_DIR:-}" use_sudo="" + local src="$1" bin_dir="${ASH_BIN_DIR:-}" use_sudo="" current + current="$(command -v ash 2>/dev/null || true)" if [ -z "$bin_dir" ]; then - local current - current="$(command -v ash 2>/dev/null || true)" if [ -n "$current" ] && [ -w "$(dirname "$current")" ]; then bin_dir="$(dirname "$current")" + # 目录不可写、但 ash 文件本身可写(常见:root 建的 /usr/local/bin 里放着本人的 ash):原地覆盖。 + # 否则会在别处另装一份,而 PATH 里更靠前的旧版本永远不会被替换 + elif [ -n "$current" ] && [ -f "$current" ] && [ -w "$current" ]; then + cat "$src" > "$current" && rm -f "$src" + echo "✓ 已更新 $current" + return elif [ -w /usr/local/bin ]; then bin_dir=/usr/local/bin elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then bin_dir=/usr/local/bin; use_sudo="sudo -n" else bin_dir="$HOME/.local/bin"; fi @@ -130,6 +135,11 @@ install_to_bin() { *":$bin_dir:"*) ;; *) echo "⚠️ $bin_dir 不在 PATH 中,请执行:export PATH=\"$bin_dir:\$PATH\"(并写入 shell 配置)" ;; esac + hash -r 2>/dev/null || true + current="$(command -v ash 2>/dev/null || true)" + if [ -n "$current" ] && [ "$current" != "$bin_dir/ash" ]; then + echo "⚠️ PATH 里更靠前的 $current 不是刚安装的版本,运行 ash 仍会用到它:请删除它,或把 $bin_dir 放到 PATH 更前面" + fi } fetch_self() { @@ -429,6 +439,8 @@ EOF echo "正在更新 ash…" fetch_self echo "✅ ash 已更新到最新版本" + # 本文件可能刚被原地改写:立即退出,别让 bash 从改写后的文件里接着读 + exit 0 ;; help|-h|--help) show_help ;; *) show_help; exit 1 ;; diff --git a/server/tests/agent-flow.test.js b/server/tests/agent-flow.test.js index d4a2988..5a3b116 100644 --- a/server/tests/agent-flow.test.js +++ b/server/tests/agent-flow.test.js @@ -311,6 +311,38 @@ test('agent flow (review enabled)', async (t) => { const help = spawnSync(path.join(bin, 'ash'), ['help'], { encoding: 'utf8' }); assert.match(help.stdout, /ash push <技能目录\|SKILL\.md> \[--update\]/); }); + + await t.test('update replaces an ash in a read-only bin dir in place instead of shadowing it', async (t2) => { + const work = tmpDir('ash-update-'); + const locked = path.join(work, 'locked-bin'); + fs.mkdirSync(locked); + const stale = path.join(locked, 'ash'); + // 本人拥有的 ash 放在不可写的目录里(带一行标记,用来判断是否被替换) + const cli = await (await fetch(`${origin}/cli.sh`)).text(); + fs.writeFileSync(stale, cli.replace(/^(#![^\n]*\n)/, '$1# stale\n'), { mode: 0o755 }); + fs.chmodSync(locked, 0o555); + t2.after(() => fs.chmodSync(locked, 0o755)); + const env = { ...process.env, HOME: work, PATH: `${locked}:${process.env.PATH}` }; + delete env.ASH_BIN_DIR; + const run = spawnSync('ash', ['update'], { env, encoding: 'utf8' }); + assert.equal(run.status, 0, run.stderr + run.stdout); + assert.match(run.stdout, new RegExp(`已更新 ${stale}`)); + assert.doesNotMatch(fs.readFileSync(stale, 'utf8'), /# stale/, 'the file on PATH was replaced'); + assert.ok(!fs.existsSync(path.join(work, '.local', 'bin', 'ash')), 'no shadow copy elsewhere'); + assert.doesNotMatch(run.stdout + run.stderr, /command not found|syntax error/, 'rewriting the running script does not derail bash'); + }); + + await t.test('install warns when an earlier ash on PATH shadows the new one', async () => { + const work = tmpDir('ash-shadow-'); + const early = path.join(work, 'early'); + const target = path.join(work, 'target'); + fs.mkdirSync(early); + fs.writeFileSync(path.join(early, 'ash'), '#!/bin/sh\necho old\n', { mode: 0o755 }); + const script = await (await fetch(`${origin}/setup.sh`)).text(); + const run = spawnSync('bash', { input: script, env: { ...process.env, HOME: work, ASH_BIN_DIR: target, PATH: `${early}:${target}:${process.env.PATH}` }, encoding: 'utf8' }); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stdout, new RegExp(`PATH 里更靠前的 ${path.join(early, 'ash')}`)); + }); }); test('agent flow with review disabled still forces high-risk pushes into review', async (t) => {