Files
clawdbot/scripts/e2e/onboard-docker.sh
2026-01-17 16:41:25 +00:00

501 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE_NAME="clawdbot-onboard-e2e"
echo "Building Docker image..."
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running onboarding E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
export PATH="/tmp/clawdbot-bin:$PATH"
mkdir -p /tmp/clawdbot-bin
cat > /tmp/clawdbot-bin/trash <<'"'"'TRASH'"'"'
#!/usr/bin/env bash
set -euo pipefail
trash_dir="$HOME/.Trash"
mkdir -p "$trash_dir"
for target in "$@"; do
[ -e "$target" ] || continue
base="$(basename "$target")"
dest="$trash_dir/$base"
if [ -e "$dest" ]; then
dest="$trash_dir/${base}-$(date +%s)-$$"
fi
mv "$target" "$dest"
done
TRASH
chmod +x /tmp/clawdbot-bin/trash
send() {
local payload="$1"
local delay="${2:-0.4}"
# Let prompts render before sending keystrokes.
sleep "$delay"
printf "%b" "$payload" >&3 2>/dev/null || true
}
wait_for_log() {
local needle="$1"
local timeout_s="${2:-45}"
local needle_compact
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
local start_s
start_s="$(date +%s)"
while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if NEEDLE="$needle_compact" node --input-type=module -e "
import fs from \"node:fs\";
const file = process.env.WIZARD_LOG_PATH;
const needle = process.env.NEEDLE ?? \"\";
let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
process.exit(text.includes(needle) ? 0 : 1);
"; then
return 0
fi
fi
if [ $(( $(date +%s) - start_s )) -ge "$timeout_s" ]; then
echo "Timeout waiting for log: $needle"
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
tail -n 140 "$WIZARD_LOG_PATH" || true
fi
return 1
fi
sleep 0.2
done
}
start_gateway() {
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
}
wait_for_gateway() {
for _ in $(seq 1 10); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then
return 0
fi
sleep 1
done
cat /tmp/gateway-e2e.log
return 1
}
stop_gateway() {
local gw_pid="$1"
if [ -n "$gw_pid" ]; then
kill "$gw_pid" 2>/dev/null || true
wait "$gw_pid" || true
fi
}
run_wizard_cmd() {
local case_name="$1"
local home_dir="$2"
local command="$3"
local send_fn="$4"
local with_gateway="${5:-false}"
local validate_fn="${6:-}"
echo "== Wizard case: $case_name =="
export HOME="$home_dir"
mkdir -p "$HOME"
input_fifo="$(mktemp -u "/tmp/clawdbot-onboard-${case_name}.XXXXXX")"
mkfifo "$input_fifo"
local log_path="/tmp/clawdbot-onboard-${case_name}.log"
WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts.
script -q -c "$command" "$log_path" < "$input_fifo" &
wizard_pid=$!
exec 3> "$input_fifo"
local gw_pid=""
if [ "$with_gateway" = "true" ]; then
start_gateway
gw_pid="$GATEWAY_PID"
wait_for_gateway
fi
"$send_fn"
exec 3>&-
wait "$wizard_pid"
rm -f "$input_fifo"
stop_gateway "$gw_pid"
if [ -n "$validate_fn" ]; then
"$validate_fn" "$log_path"
fi
}
run_wizard() {
local case_name="$1"
local home_dir="$2"
local send_fn="$3"
local validate_fn="${4:-}"
# Default onboarding command wrapper.
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
}
make_home() {
mktemp -d "/tmp/clawdbot-e2e-$1.XXXXXX"
}
assert_file() {
local file_path="$1"
if [ ! -f "$file_path" ]; then
echo "Missing file: $file_path"
exit 1
fi
}
assert_dir() {
local dir_path="$1"
if [ ! -d "$dir_path" ]; then
echo "Missing dir: $dir_path"
exit 1
fi
}
select_skip_hooks() {
# Hooks multiselect: pick "Skip for now".
wait_for_log "Enable hooks?" 60 || true
send $'"'"' \r'"'"' 0.6
}
send_local_basic() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.6
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
send $'"'"'\r'"'"' 0.5
select_skip_hooks
}
send_reset_config_only() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.8
# Select reset flow for existing config.
wait_for_log "Config handling" 40 || true
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4
# Reset scope -> Config only (default).
wait_for_log "Reset scope" 40 || true
send $'"'"'\r'"'"' 0.4
select_skip_hooks
}
send_channels_flow() {
# Configure channels via configure wizard.
# Prompts are interactive; notes are not. Use conservative delays to stay in sync.
# Where will the Gateway run? -> Local (default)
send $'"'"'\r'"'"' 1.2
# Channels mode -> Configure/link (default)
send $'"'"'\r'"'"' 1.5
# Select a channel -> Finished (last option; clack wraps on Up)
send $'"'"'\e[A\r'"'"' 2.0
# Keep stdin open until wizard exits.
send "" 2.5
}
send_skills_flow() {
# Select skills section and skip optional installs.
wait_for_log "Where will the Gateway run?" 40 || true
send $'"'"'\r'"'"' 0.8
# Configure skills now? -> No
wait_for_log "Configure skills now?" 40 || true
send $'"'"'n\r'"'"' 0.8
wait_for_log "Configure complete." 40 || true
send "" 0.8
}
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log
# Assert config + workspace scaffolding.
workspace_dir="$HOME/clawd"
config_path="$HOME/.clawdbot/clawdbot.json"
sessions_dir="$HOME/.clawdbot/agents/main/sessions"
assert_file "$config_path"
assert_dir "$sessions_dir"
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
assert_file "$workspace_dir/$file"
done
CONFIG_PATH="$config_path" WORKSPACE_DIR="$workspace_dir" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const expectedWorkspace = process.env.WORKSPACE_DIR;
const errors = [];
if (cfg?.agents?.defaults?.workspace !== expectedWorkspace) {
errors.push(
`agents.defaults.workspace mismatch (got ${cfg?.agents?.defaults?.workspace ?? "unset"})`,
);
}
if (cfg?.gateway?.mode !== "local") {
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
}
if (cfg?.gateway?.bind !== "loopback") {
errors.push(`gateway.bind mismatch (got ${cfg?.gateway?.bind ?? "unset"})`);
}
if ((cfg?.gateway?.tailscale?.mode ?? "off") !== "off") {
errors.push(
`gateway.tailscale.mode mismatch (got ${cfg?.gateway?.tailscale?.mode ?? "unset"})`,
);
}
if (!cfg?.wizard?.lastRunAt) {
errors.push("wizard.lastRunAt missing");
}
if (!cfg?.wizard?.lastRunVersion) {
errors.push("wizard.lastRunVersion missing");
}
if (cfg?.wizard?.lastRunCommand !== "onboard") {
errors.push(
`wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`,
);
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(
`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`,
);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway.log 2>&1 &
GW_PID=$!
# Gate on gateway readiness, then run health.
for _ in $(seq 1 10); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
break
fi
sleep 1
done
if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
cat /tmp/gateway.log
exit 1
fi
node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1)
kill "$GW_PID"
wait "$GW_PID" || true
}
run_case_remote_non_interactive() {
local home_dir
home_dir="$(make_home remote-non-interactive)"
export HOME="$home_dir"
mkdir -p "$HOME"
# Smoke test non-interactive remote config write.
node dist/index.js onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
--skip-skills \
--skip-health
config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.gateway?.mode !== "remote") {
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
}
if (cfg?.gateway?.remote?.url !== "ws://gateway.local:18789") {
errors.push(`gateway.remote.url mismatch (got ${cfg?.gateway?.remote?.url ?? "unset"})`);
}
if (cfg?.gateway?.remote?.token !== "remote-token") {
errors.push(`gateway.remote.token mismatch (got ${cfg?.gateway?.remote?.token ?? "unset"})`);
}
if (cfg?.wizard?.lastRunMode !== "remote") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
run_case_reset() {
local home_dir
home_dir="$(make_home reset-config)"
export HOME="$home_dir"
mkdir -p "$HOME/.clawdbot"
# Seed a remote config to exercise reset path.
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
{
"agent": { "workspace": "/root/old" },
"gateway": {
"mode": "remote",
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
}
}
JSON
run_wizard reset-config "$home_dir" send_reset_config_only
config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.gateway?.mode !== "local") {
errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`);
}
if (cfg?.gateway?.remote?.url) {
errors.push(`gateway.remote.url should be cleared (got ${cfg?.gateway?.remote?.url})`);
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
run_case_channels() {
local home_dir
home_dir="$(make_home channels)"
# Channels-only configure flow.
run_wizard_cmd channels "$home_dir" "node dist/index.js configure --section channels" send_channels_flow
config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.telegram?.botToken) {
errors.push(`telegram.botToken should be unset (got ${cfg?.telegram?.botToken})`);
}
if (cfg?.discord?.token) {
errors.push(`discord.token should be unset (got ${cfg?.discord?.token})`);
}
if (cfg?.slack?.botToken || cfg?.slack?.appToken) {
errors.push(
`slack tokens should be unset (got bot=${cfg?.slack?.botToken ?? "unset"}, app=${cfg?.slack?.appToken ?? "unset"})`,
);
}
if (cfg?.wizard?.lastRunCommand !== "configure") {
errors.push(
`wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`,
);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
run_case_skills() {
local home_dir
home_dir="$(make_home skills)"
export HOME="$home_dir"
mkdir -p "$HOME/.clawdbot"
# Seed skills config to ensure it survives the wizard.
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
{
"skills": {
"allowBundled": ["__none__"],
"install": { "nodeManager": "bun" }
}
}
JSON
run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow
config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path"
CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"'
import fs from "node:fs";
import JSON5 from "json5";
const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8"));
const errors = [];
if (cfg?.skills?.install?.nodeManager !== "bun") {
errors.push(`skills.install.nodeManager mismatch (got ${cfg?.skills?.install?.nodeManager ?? "unset"})`);
}
if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") {
errors.push("skills.allowBundled missing");
}
if (cfg?.wizard?.lastRunMode !== "local") {
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}
NODE
}
assert_log_not_contains() {
local file_path="$1"
local needle="$2"
if grep -q "$needle" "$file_path"; then
echo "Unexpected log output: $needle"
exit 1
fi
}
validate_local_basic_log() {
local log_path="$1"
assert_log_not_contains "$log_path" "systemctl --user unavailable"
}
run_case_local_basic
run_case_remote_non_interactive
run_case_reset
run_case_channels
run_case_skills
'
echo "E2E complete."