Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 8 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,20 @@ 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` 也不会跳过技能刷新。
同名技能目录完整替换,源码中删除的文件不会残留;之前由 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
45 changes: 45 additions & 0 deletions scripts/modules/bots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -699,8 +699,53 @@ 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
source_dir="$(bots_dynamic_profile_dir)/${source}/skills"
skills_dir="${workspace_dir}/skills"
managed_dir="${workspace_dir}/.singlebox-profile-skills"

# 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" ] || [ -L "$source_dir" ]; then
cp -R "${source_dir}/." "${staging}/skills/" || return 1
Comment thread
quechao76 marked this conversation as resolved.
Outdated
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
180 changes: 180 additions & 0 deletions scripts/test_singlebox_profile_skills.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/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

# 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