Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion scripts/6bots_undercover_game_profile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,22 @@ Bot 在一次激活里写的**每一段**文字都会被转发成群里的一条
└── skills/undercover-game-player/SKILL.md
```

`<source>/skills/<name>/` 会被 `scripts/modules/bots.sh` 装配进该 Bot 的运行时 workspace,和 `bcs-coordination` 并列。
每次 `singlebox.sh start bots --profile-dir <dir>`(Bot 已停止时)或 `restart bots --profile-dir <dir>`,
都会将 `<dir>/<source>/skills/` 的内容复制到该 Bot 的运行时 workspace `skills/`,
不按 `bots.json` 的 `skills` 能力描述筛选;保留已有 `openclaw.json` 也不会跳过技能刷新。
源 `skills/` 根目录及其内部文件、子目录都不能是软链接(包括内部相对链接和悬空链接);
检测到软链接时启动失败并报告路径,保留已有运行时技能及同步归属记录。请将所需技能以实体文件放入 profile。
同名技能目录完整替换,源码中删除的文件不会残留;之前由 profile 同步、后来从源码删除的技能也会移除。
其他自行安装的技能保留,公共 `bcs-coordination` 始终从 BCS 源码刷新(优先于 profile 中的同名技能)。
workspace 的 `.singlebox-profile-skills/` 记录同步归属,不应手动修改;游戏状态保存在 Bot 数据目录,
技能刷新不影响已有游戏状态。

## 回归测试

面板/事实层回归不需要起服务:

```bash
bash scripts/test_singlebox_profile_skills.sh
python3 -m unittest discover -s scripts/6bots_undercover_game_profile/referee/skills/undercover-game-referee/tests -v
cd src/bcs/assets/panel && npm run verify
```
Expand Down
58 changes: 58 additions & 0 deletions scripts/modules/bots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -699,8 +699,66 @@ bots_dynamic_copy_profile_files() {
rm -f "${workspace_dir}/${file}"
fi
done

bots_dynamic_sync_profile_skills "$source" "$workspace_dir" || return 1
}

bots_dynamic_sync_profile_skills() (
local source="$1"
local workspace_dir="$2"
local source_dir skills_dir managed_dir staging entry name symlink_path
source_dir="$(bots_dynamic_profile_dir)/${source}/skills"
skills_dir="${workspace_dir}/skills"
managed_dir="${workspace_dir}/.singlebox-profile-skills"

# A trailing '/.' would dereference a symlink used as the source root.
if [ -L "$source_dir" ]; then
log_error "Profile skills must not contain symlinks: ${source_dir}"
return 1
fi

# Stage the complete source before replacing any runtime skills. The marker
# directory records only profile-owned names, so other installed skills stay.
staging="$(mktemp -d "${workspace_dir}/.singlebox-skills.XXXXXX")" || return 1
trap 'rm -rf "$staging"' EXIT
mkdir -p "${staging}/skills" "${staging}/managed" || return 1
if [ -e "$source_dir" ]; then
cp -R -P "${source_dir}/." "${staging}/skills/" || return 1
fi
# Inspect the copied tree before changing active skills. Never install a
# live link to external content, including hidden or dangling links.
symlink_path="$(find -P "${staging}/skills" -type l -print -quit)" || return 1
if [ -n "$symlink_path" ]; then
log_error "Profile skills must not contain symlinks: ${source_dir}/${symlink_path#"${staging}/skills/"}"
return 1
fi
if [ -L "$skills_dir" ] || [ -L "$managed_dir" ]; then
log_error "Profile skills destination must not be a symlink: ${workspace_dir}"
return 1
fi
mkdir -p "$skills_dir" "$managed_dir" || return 1

for entry in "${staging}/skills/"* "${staging}/skills/".[!.]* "${staging}/skills/"..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
name="${entry##*/}"
# The shared coordination skill is always installed from BCS below.
[ "$name" != "bcs-coordination" ] || continue
touch "${staging}/managed/${name}" || return 1
rm -rf "${skills_dir:?}/${name}" || return 1
mv "$entry" "${skills_dir}/${name}" || return 1
done
for entry in "${managed_dir}/"* "${managed_dir}/".[!.]* "${managed_dir}/"..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
name="${entry##*/}"
[ "$name" != "bcs-coordination" ] || continue
if [ ! -e "${staging}/managed/${name}" ]; then
rm -rf "${skills_dir:?}/${name}" || return 1
fi
done
rm -rf "$managed_dir" || return 1
mv "${staging}/managed" "$managed_dir" || return 1
)

bots_dynamic_setup_bcs_skill() {
local workspace_dir="$1"
local skills_dir="${workspace_dir}/skills"
Expand Down
228 changes: 228 additions & 0 deletions scripts/test_singlebox_profile_skills.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Exercise profile skill delivery through start/restart without running services.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
TEMPORARY="$(mktemp -d)"
trap 'rm -rf "$TEMPORARY"' EXIT
BCS_DIR="${PROJECT_ROOT}/src/bcs"
BCS_PORT=21000
LOG_DIR="${TEMPORARY}/logs"
DEP_DIR="${TEMPORARY}/dependencies"
BOTS_PROFILE_DIR="${TEMPORARY}/source profiles"
OPENCLAW_PROFILE_ROOT="${TEMPORARY}/profiles"
OPENCLAW_PROFILE_PREFIX=""
OPENCLAW_WORKSPACE_ROOT="${TEMPORARY}/workspaces"
OPENCLAW_WORKSPACE_LAYOUT=profile
BCS_BOTS_PRESERVE_FILES=1
BOTS_EXCLUDED_PROFILE_SOURCE=""
mkdir -p "$LOG_DIR" "$DEP_DIR" "$BOTS_PROFILE_DIR"

source "${SCRIPT_DIR}/utils.sh"
source "${SCRIPT_DIR}/modules/bcs.sh"
source "${SCRIPT_DIR}/modules/bots.sh"

fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
assert_content() {
[ -f "$1" ] || fail "missing file: $1"
[ "$(cat "$1")" = "$2" ] || fail "unexpected content: $1"
}

cat > "${BOTS_PROFILE_DIR}/bots.json" <<'JSON'
{
"version": 1,
"name": "Profile skills",
"port_start": 30801,
"port_step": 10,
"bots": [
{"source": "referee", "profile": "host", "name": "Host", "summary": "Host", "domains": "game", "skills": "metadata-only"},
{"source": "player", "profile": "guest", "name": "Guest", "summary": "Guest", "domains": "game", "skills": "metadata-only"},
{"source": "empty", "profile": "empty", "name": "Empty", "summary": "Empty", "domains": "game", "skills": "metadata-only"}
]
}
JSON
for role in referee player empty; do
mkdir -p "${BOTS_PROFILE_DIR}/${role}"
for file in AGENTS.md IDENTITY.md KNOWLEDGE.md; do
printf '%s\n' "$role" > "${BOTS_PROFILE_DIR}/${role}/${file}"
done
done
for profile in host guest empty; do
mkdir -p "${OPENCLAW_PROFILE_ROOT}/${profile}/.bcs"
printf '%s\n' 'preserved config' > "${OPENCLAW_PROFILE_ROOT}/${profile}/openclaw.json"
printf '%s\n' 'preserved session' > "${OPENCLAW_PROFILE_ROOT}/${profile}/.bcs/session.json"
done

HOST_SOURCE="${BOTS_PROFILE_DIR}/referee/skills"
HOST_WORKSPACE="${OPENCLAW_WORKSPACE_ROOT}/host"
GUEST_WORKSPACE="${OPENCLAW_WORKSPACE_ROOT}/guest"
mkdir -p "${HOST_SOURCE}/game/scripts" "${HOST_SOURCE}/game/references" \
"${HOST_SOURCE}/retired" "${HOST_SOURCE}/bcs-coordination" \
"${BOTS_PROFILE_DIR}/player/skills/game" "${HOST_WORKSPACE}/skills/local-only"
printf '%s\n' 'host v1' > "${HOST_SOURCE}/game/SKILL.md"
printf '%s\n' '#!/bin/sh' 'exit 0' > "${HOST_SOURCE}/game/scripts/play.sh"
chmod +x "${HOST_SOURCE}/game/scripts/play.sh"
printf '%s\n' 'obsolete reference' > "${HOST_SOURCE}/game/references/old.md"
printf '%s\n' 'hidden reference' > "${HOST_SOURCE}/game/.hidden"
printf '%s\n' 'retired skill' > "${HOST_SOURCE}/retired/SKILL.md"
printf '%s\n' 'profile override' > "${HOST_SOURCE}/bcs-coordination/SKILL.md"
printf '%s\n' 'guest v1' > "${BOTS_PROFILE_DIR}/player/skills/game/SKILL.md"
printf '%s\n' 'local skill' > "${HOST_WORKSPACE}/skills/local-only/SKILL.md"

# Keep filesystem preparation and manifest/source mapping real; isolate network,
# process control, and model configuration from this delivery regression test.
resolve_bcs_server_env() { :; }
ensure_local_no_proxy() { :; }
bots_dynamic_group_fully_running() { return 1; }
bots_dynamic_check_ports_free() { :; }
setup_bcn_plugin() { :; }
bots_bcn_plugin_load_dir() { printf '%s\n' "${TEMPORARY}/plugin"; }
bots_dynamic_model_source_has_fields() { return 1; }
bots_dynamic_config_has_required_model() { :; }
bots_dynamic_config_has_bcs_core_tools() { :; }
bots_dynamic_config_matches() { :; }
bcs_health_ready() { :; }
bots_dynamic_preflight_existing_sessions() { :; }
bots_dynamic_capture_session_uuids() { :; }
bots_dynamic_validate_session_uuids() { :; }
bots_dynamic_wait_ready() { :; }
bots_dynamic_onboard() { :; }
bots_dynamic_fusion_enabled() { return 1; }
bots_dynamic_stop() { printf '%s\n' stop >> "${TEMPORARY}/events"; }
sleep() { :; }
bots_dynamic_start_openclaw() {
local workspace
workspace="$(bots_dynamic_workspace_dir "$1" "$2" "$5")"
if [ "$2" = host ]; then
assert_content "${workspace}/skills/game/SKILL.md" "$EXPECTED_HOST_CONTENT"
fi
printf 'start %s\n' "$2" >> "${TEMPORARY}/events"
}

EXPECTED_HOST_CONTENT='host v1'
bots_start
assert_content "${HOST_WORKSPACE}/skills/game/SKILL.md" 'host v1'
assert_content "${GUEST_WORKSPACE}/skills/game/SKILL.md" 'guest v1'
assert_content "${HOST_WORKSPACE}/skills/game/.hidden" 'hidden reference'
[ -x "${HOST_WORKSPACE}/skills/game/scripts/play.sh" ] || fail 'script lost executable permission'
[ ! -e "${HOST_WORKSPACE}/skills/metadata-only" ] || fail 'capability metadata used as a skill source'
cmp "${BCS_DIR}/crates/tools/bcs-cli/bcs-coordination/SKILL.md" \
"${HOST_WORKSPACE}/skills/bcs-coordination/SKILL.md"
[ -f "${OPENCLAW_WORKSPACE_ROOT}/empty/skills/bcs-coordination/SKILL.md" ] || fail 'profile without skills failed'

# A subsequent start must overwrite content even when config preservation is on.
printf '%s\n' 'host v2' > "${HOST_SOURCE}/game/SKILL.md"
rm "${HOST_SOURCE}/game/references/old.md"
printf '%s\n' 'new reference' > "${HOST_SOURCE}/game/references/new.md"
rm -rf "${HOST_SOURCE}/retired"
mkdir -p "${HOST_SOURCE}/new skill"
printf '%s\n' 'new skill' > "${HOST_SOURCE}/new skill/SKILL.md"
mkdir -p "${TEMPORARY}/linked-game"
printf '%s\n' 'external skill' > "${TEMPORARY}/linked-game/SKILL.md"
rm -rf "${HOST_WORKSPACE}/skills/game"
ln -s "${TEMPORARY}/linked-game" "${HOST_WORKSPACE}/skills/game"
EXPECTED_HOST_CONTENT='host v2'
bots_start
assert_content "${TEMPORARY}/linked-game/SKILL.md" 'external skill'
[ ! -L "${HOST_WORKSPACE}/skills/game" ] || fail 'runtime skill still links to external content'
assert_content "${HOST_WORKSPACE}/skills/game/references/new.md" 'new reference'
assert_content "${HOST_WORKSPACE}/skills/new skill/SKILL.md" 'new skill'
[ ! -e "${HOST_WORKSPACE}/skills/game/references/old.md" ] || fail 'removed file survived refresh'
[ ! -e "${HOST_WORKSPACE}/skills/retired" ] || fail 'removed profile skill survived refresh'
assert_content "${HOST_WORKSPACE}/skills/local-only/SKILL.md" 'local skill'

printf '%s\n' 'host v3' > "${HOST_SOURCE}/game/SKILL.md"
EXPECTED_HOST_CONTENT='host v3'
: > "${TEMPORARY}/events"
bots_restart
assert_content "${TEMPORARY}/events" $'stop\nstart host\nstart guest\nstart empty'
for profile in host guest empty; do
assert_content "${OPENCLAW_PROFILE_ROOT}/${profile}/openclaw.json" 'preserved config'
assert_content "${OPENCLAW_PROFILE_ROOT}/${profile}/.bcs/session.json" 'preserved session'
done

# Source links must never expose an external store or leave a live dependency
# in the active workspace. Rejection must preserve both skills and ownership.
cp -R "${HOST_WORKSPACE}/skills" "${TEMPORARY}/expected-skills"
cp -R "${HOST_WORKSPACE}/.singlebox-profile-skills" "${TEMPORARY}/expected-managed"
mkdir -p "${TEMPORARY}/external-store/private-skill"
printf '%s\n' 'external content' > "${TEMPORARY}/external-store/private-skill/SKILL.md"
assert_symlinked_source_rejected() {
: > "${TEMPORARY}/events"
if bots_start > "${TEMPORARY}/source-symlink.log" 2>&1; then
fail "startup accepted a source symlink: $1"
fi
grep -Fq 'Profile skills must not contain symlinks:' "${TEMPORARY}/source-symlink.log" \
|| fail "missing source symlink diagnostic: $1"
[ ! -s "${TEMPORARY}/events" ] || fail "gateway started with a source symlink: $1"
diff -r "${TEMPORARY}/expected-skills" "${HOST_WORKSPACE}/skills"
diff -r "${TEMPORARY}/expected-managed" "${HOST_WORKSPACE}/.singlebox-profile-skills"
assert_content "${TEMPORARY}/external-store/private-skill/SKILL.md" 'external content'
}

ln -s "${TEMPORARY}/external-store" "${HOST_SOURCE}/shared-store"
assert_symlinked_source_rejected 'top-level skill directory'
rm "${HOST_SOURCE}/shared-store"

ln -s "${TEMPORARY}/external-store" "${HOST_SOURCE}/game/.shared-store"
assert_symlinked_source_rejected 'hidden nested directory'
rm "${HOST_SOURCE}/game/.shared-store"

ln -s "${TEMPORARY}/external-store/private-skill/SKILL.md" "${HOST_SOURCE}/game/references/external.md"
assert_symlinked_source_rejected 'nested file'
rm "${HOST_SOURCE}/game/references/external.md"

ln -s ../SKILL.md "${HOST_SOURCE}/game/references/internal.md"
assert_symlinked_source_rejected 'relative link within the profile'
rm "${HOST_SOURCE}/game/references/internal.md"

ln -s missing.md "${HOST_SOURCE}/game/references/broken.md"
assert_symlinked_source_rejected 'dangling nested link'
rm "${HOST_SOURCE}/game/references/broken.md"

mv "$HOST_SOURCE" "${TEMPORARY}/saved-source-skills"
ln -s "${TEMPORARY}/saved-source-skills" "$HOST_SOURCE"
assert_symlinked_source_rejected 'skills root directory'
rm "$HOST_SOURCE"
ln -s "${TEMPORARY}/missing-skills" "$HOST_SOURCE"
assert_symlinked_source_rejected 'dangling skills root'
rm "$HOST_SOURCE"
mv "${TEMPORARY}/saved-source-skills" "$HOST_SOURCE"

# Copy errors must fail startup before any gateway starts, preserving old skills.
(
cp() {
if [ "$1" = -R ]; then return 1; fi
command cp "$@"
}
: > "${TEMPORARY}/events"
if bots_start; then fail 'skill copy failure did not fail startup'; fi
[ ! -s "${TEMPORARY}/events" ] || fail 'gateway started after skill copy failure'
assert_content "${HOST_WORKSPACE}/skills/game/SKILL.md" 'host v3'
)

mkdir -p "${TEMPORARY}/linked-workspace"
ln -s "${HOST_WORKSPACE}/skills" "${TEMPORARY}/linked-workspace/skills"
if bots_dynamic_copy_profile_files referee "${TEMPORARY}/linked-workspace"; then
fail 'shared skills root symlink was accepted'
fi
assert_content "${HOST_WORKSPACE}/skills/game/SKILL.md" 'host v3'

# Removing the source skills directory retires only previously delivered names.
rm -rf "$HOST_SOURCE"
bots_dynamic_copy_profile_files referee "$HOST_WORKSPACE"
[ ! -e "${HOST_WORKSPACE}/skills/game" ] || fail 'removed skills directory left profile skills active'
[ ! -e "${HOST_WORKSPACE}/skills/new skill" ] || fail 'removed skills directory left added skill active'
assert_content "${HOST_WORKSPACE}/skills/local-only/SKILL.md" 'local skill'
[ -f "${HOST_WORKSPACE}/skills/bcs-coordination/SKILL.md" ] || fail 'shared coordination skill was removed'
bots_dynamic_copy_profile_files referee "$HOST_WORKSPACE"

# Actual repository profiles must deliver all their nested skill assets as well.
BOTS_PROFILE_DIR="${PROJECT_ROOT}/scripts/6bots_undercover_game_profile"
bots_dynamic_copy_profile_files referee "${TEMPORARY}/real-referee"
diff -r "${BOTS_PROFILE_DIR}/referee/skills" "${TEMPORARY}/real-referee/skills"
bots_dynamic_copy_profile_files player-laochen "${TEMPORARY}/real-player"
diff -r "${BOTS_PROFILE_DIR}/player-laochen/skills" "${TEMPORARY}/real-player/skills"

printf 'PASS: singlebox profile skill start/restart and refresh tests\n'
Loading