Files
openclaw/scripts/e2e/kitchen-sink-plugin-docker.sh
2026-04-28 17:04:01 -07:00

772 lines
26 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-kitchen-sink-plugin-e2e" OPENCLAW_KITCHEN_SINK_PLUGIN_E2E_IMAGE)"
docker_e2e_build_or_reuse "$IMAGE_NAME" kitchen-sink-plugin
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 kitchen-sink-plugin empty)"
DEFAULT_KITCHEN_SINK_SCENARIOS="$(cat <<'SCENARIOS'
npm-latest|npm:@openclaw/kitchen-sink@latest|openclaw-kitchen-sink-fixture|npm|success|full
npm-beta|npm:@openclaw/kitchen-sink@beta|openclaw-kitchen-sink-fixture|npm|failure|none
clawhub-latest|clawhub:openclaw-kitchen-sink@latest|openclaw-kitchen-sink-fixture|clawhub|success|basic
clawhub-beta|clawhub:openclaw-kitchen-sink@beta|openclaw-kitchen-sink-fixture|clawhub|failure|none
SCENARIOS
)"
KITCHEN_SINK_SCENARIOS="${OPENCLAW_KITCHEN_SINK_PLUGIN_SCENARIOS:-$DEFAULT_KITCHEN_SINK_SCENARIOS}"
MAX_MEMORY_MIB="${OPENCLAW_KITCHEN_SINK_MAX_MEMORY_MIB:-2048}"
MAX_CPU_PERCENT="${OPENCLAW_KITCHEN_SINK_MAX_CPU_PERCENT:-1200}"
CONTAINER_NAME="openclaw-kitchen-sink-plugin-e2e-$$"
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin.XXXXXX")"
STATS_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin-stats.XXXXXX")"
SCRIPT_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-kitchen-sink-plugin-script.XXXXXX")"
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$SCRIPT_FILE"
}
trap cleanup EXIT
cat > "$SCRIPT_FILE" <<'EOF'
set -euo pipefail
if [ -f dist/index.mjs ]; then
OPENCLAW_ENTRY="dist/index.mjs"
elif [ -f dist/index.js ]; then
OPENCLAW_ENTRY="dist/index.js"
else
echo "Missing dist/index.(m)js (build output):"
ls -la dist || true
exit 1
fi
export OPENCLAW_ENTRY
eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)"
run_logged() {
local label="$1"
shift
local log_file="/tmp/openclaw-kitchen-sink-${label}.log"
if ! "$@" >"$log_file" 2>&1; then
cat "$log_file"
exit 1
fi
cat "$log_file"
}
run_expect_failure() {
local label="$1"
shift
local output_file="/tmp/kitchen-sink-expected-failure-${label}.txt"
set +e
"$@" >"$output_file" 2>&1
local status="$?"
set -e
cat "$output_file"
if [ "$status" -eq 0 ]; then
echo "Expected ${label} to fail, but it succeeded." >&2
exit 1
fi
node - "$output_file" <<'NODE'
const fs = require("node:fs");
const output = fs.readFileSync(process.argv[2], "utf8");
const source = process.env.KITCHEN_SINK_SOURCE;
const spec = process.env.KITCHEN_SINK_SPEC;
const displayedSpec = source === "npm" ? spec.replace(/^npm:/u, "") : spec;
const expected =
source === "clawhub"
? /Version not found on ClawHub|ClawHub .* failed \(404\)|version.*not found/iu
: /No matching version|ETARGET|notarget|npm (?:error|ERR!)/iu;
if (!output.includes(displayedSpec)) {
throw new Error(`expected failure output to mention ${displayedSpec}`);
}
if (!expected.test(output)) {
throw new Error(`unexpected ${source} beta failure output:\n${output}`);
}
console.log("ok");
NODE
}
start_kitchen_sink_clawhub_fixture_server() {
local fixture_dir="$1"
local server_log="$fixture_dir/clawhub-fixture.log"
local server_port_file="$fixture_dir/clawhub-fixture-port"
local server_pid_file="$fixture_dir/clawhub-fixture-pid"
node - <<'NODE' "$server_port_file" >"$server_log" 2>&1 &
const crypto = require("node:crypto");
const http = require("node:http");
const path = require("node:path");
const { createRequire } = require("node:module");
const portFile = process.argv[2];
const requireFromApp = createRequire(path.join(process.cwd(), "package.json"));
const JSZip = requireFromApp("jszip");
const packageName = "openclaw-kitchen-sink";
const pluginId = "openclaw-kitchen-sink-fixture";
const version = "0.1.3";
async function main() {
const zip = new JSZip();
zip.file(
"package/package.json",
`${JSON.stringify(
{
name: packageName,
version,
openclaw: { extensions: ["./index.js"] },
},
null,
2,
)}\n`,
{ date: new Date(0) },
);
zip.file(
"package/index.js",
`module.exports = {
id: "${pluginId}",
name: "OpenClaw Kitchen Sink",
register(api) {
api.registerProvider({
id: "kitchen-sink-provider",
label: "Kitchen Sink Provider",
docsPath: "/providers/kitchen-sink",
auth: [],
});
api.registerChannel({
plugin: {
id: "kitchen-sink-channel",
meta: {
id: "kitchen-sink-channel",
label: "Kitchen Sink Channel",
selectionLabel: "Kitchen Sink",
docsPath: "/channels/kitchen-sink",
blurb: "Kitchen sink ClawHub fixture channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
});
},
};
`,
{ date: new Date(0) },
);
zip.file(
"package/openclaw.plugin.json",
`${JSON.stringify(
{
id: pluginId,
name: "OpenClaw Kitchen Sink",
channels: ["kitchen-sink-channel"],
providers: ["kitchen-sink-provider"],
configSchema: {
type: "object",
properties: {},
},
},
null,
2,
)}\n`,
{ date: new Date(0) },
);
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
const sha256hash = crypto.createHash("sha256").update(archive).digest("hex");
const packageDetail = {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
runtimeId: pluginId,
channel: "official",
isOfficial: true,
summary: "Kitchen sink plugin fixture for prerelease CI.",
ownerHandle: "openclaw",
createdAt: 0,
updatedAt: 0,
latestVersion: version,
tags: { latest: version },
capabilityTags: ["test-fixture"],
executesCode: true,
compatibility: {
pluginApiRange: ">=2026.4.11",
minGatewayVersion: "2026.4.11",
},
capabilities: {
executesCode: true,
runtimeId: pluginId,
capabilityTags: ["test-fixture"],
channels: ["kitchen-sink-channel"],
providers: ["kitchen-sink-provider"],
},
verification: {
tier: "source-linked",
sourceRepo: "https://github.com/openclaw/kitchen-sink",
hasProvenance: false,
scanStatus: "passed",
},
},
};
const versionDetail = {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
},
version: {
version,
createdAt: 0,
changelog: "Fixture package for kitchen-sink plugin prerelease CI.",
distTags: ["latest"],
sha256hash,
compatibility: packageDetail.package.compatibility,
capabilities: packageDetail.package.capabilities,
verification: packageDetail.package.verification,
},
};
const json = (response, value, status = 200) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(`${JSON.stringify(value)}\n`);
};
const server = http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
if (request.method !== "GET") {
response.writeHead(405);
response.end("method not allowed");
return;
}
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) {
json(response, packageDetail);
return;
}
if (
url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}`
) {
json(response, versionDetail);
return;
}
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`) {
json(response, { error: "version not found" }, 404);
return;
}
if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) {
response.writeHead(200, {
"content-type": "application/zip",
"content-length": String(archive.length),
});
response.end(archive);
return;
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
});
server.listen(0, "127.0.0.1", () => {
require("node:fs").writeFileSync(portFile, String(server.address().port));
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
local server_pid="$!"
echo "$server_pid" > "$server_pid_file"
for _ in $(seq 1 100); do
if [[ -s "$server_port_file" ]]; then
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
cat "$server_log"
return 1
fi
sleep 0.1
done
cat "$server_log"
echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2
return 1
}
scan_logs_for_unexpected_errors() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const roots = ["/tmp", path.join(process.env.HOME, ".openclaw")];
const files = [];
const visit = (entry) => {
if (!fs.existsSync(entry)) {
return;
}
const stat = fs.statSync(entry);
if (stat.isDirectory()) {
for (const child of fs.readdirSync(entry)) {
visit(path.join(entry, child));
}
return;
}
if (/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry))) {
if (entry.includes("/.npm/_logs/")) {
return;
}
files.push(entry);
}
};
for (const root of roots) {
visit(root);
}
const deny = [
/\buncaught exception\b/iu,
/\bunhandled rejection\b/iu,
/\bfatal\b/iu,
/\bpanic\b/iu,
/\blevel["']?\s*:\s*["']error["']/iu,
/\[(?:error|ERROR)\]/u,
];
const allow = [
/0 errors?/iu,
/expected no diagnostics errors?/iu,
/diagnostics errors?:\s*$/iu,
];
const findings = [];
for (const file of files) {
const text = fs.readFileSync(file, "utf8");
const lines = text.split(/\r?\n/u);
lines.forEach((line, index) => {
if (allow.some((pattern) => pattern.test(line))) {
return;
}
if (deny.some((pattern) => pattern.test(line))) {
findings.push(`${file}:${index + 1}: ${line}`);
}
});
}
if (findings.length > 0) {
throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}`);
}
console.log(`log scan passed (${files.length} file(s))`);
NODE
}
configure_kitchen_sink_runtime() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const pluginId = process.env.KITCHEN_SINK_ID;
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {};
config.plugins = config.plugins || {};
config.plugins.entries = config.plugins.entries || {};
config.plugins.entries[pluginId] = {
...(config.plugins.entries[pluginId] || {}),
hooks: {
...(config.plugins.entries[pluginId]?.hooks || {}),
allowConversationAccess: true,
},
};
config.channels = {
...(config.channels || {}),
"kitchen-sink-channel": { enabled: true, token: "kitchen-sink-ci" },
};
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
NODE
}
remove_kitchen_sink_channel_config() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
delete config.channels?.["kitchen-sink-channel"];
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
NODE
}
assert_kitchen_sink_installed() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const pluginId = process.env.KITCHEN_SINK_ID;
const spec = process.env.KITCHEN_SINK_SPEC;
const source = process.env.KITCHEN_SINK_SOURCE;
const surfaceMode = process.env.KITCHEN_SINK_SURFACE_MODE;
const label = process.env.KITCHEN_SINK_LABEL;
const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-plugins.json`, "utf8"));
const inspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect.json`, "utf8"));
const allInspect = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-inspect-all.json`, "utf8"));
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
if (!plugin) throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`);
if (plugin.status !== "loaded") {
throw new Error(`unexpected kitchen-sink status after enable: ${plugin.status}`);
}
if (inspect.plugin?.id !== pluginId) {
throw new Error(`unexpected inspected kitchen-sink plugin id: ${inspect.plugin?.id}`);
}
if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") {
throw new Error(
`expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`,
);
}
const expectIncludes = (listValue, expected, field) => {
if (!Array.isArray(listValue) || !listValue.includes(expected)) {
throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`);
}
};
expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels");
expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers");
const diagnostics = [
...(list.diagnostics || []),
...(inspect.diagnostics || []),
...(allInspect.diagnostics || []),
];
const errorMessages = new Set(
diagnostics
.filter((diag) => diag?.level === "error")
.map((diag) => String(diag.message || "")),
);
if (surfaceMode === "full") {
const toolNames = Array.isArray(inspect.tools)
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
: [];
expectIncludes(inspect.plugin?.speechProviderIds, "kitchen-sink-speech-provider", "speech providers");
expectIncludes(
inspect.plugin?.realtimeTranscriptionProviderIds,
"kitchen-sink-realtime-transcription-provider",
"realtime transcription providers",
);
expectIncludes(
inspect.plugin?.realtimeVoiceProviderIds,
"kitchen-sink-realtime-voice-provider",
"realtime voice providers",
);
expectIncludes(
inspect.plugin?.mediaUnderstandingProviderIds,
"kitchen-sink-media-understanding-provider",
"media understanding providers",
);
expectIncludes(
inspect.plugin?.imageGenerationProviderIds,
"kitchen-sink-image-generation-provider",
"image generation providers",
);
expectIncludes(
inspect.plugin?.videoGenerationProviderIds,
"kitchen-sink-video-generation-provider",
"video generation providers",
);
expectIncludes(
inspect.plugin?.musicGenerationProviderIds,
"kitchen-sink-music-generation-provider",
"music generation providers",
);
expectIncludes(inspect.plugin?.webFetchProviderIds, "kitchen-sink-web-fetch-provider", "web fetch providers");
expectIncludes(inspect.plugin?.webSearchProviderIds, "kitchen-sink-web-search-provider", "web search providers");
expectIncludes(inspect.plugin?.migrationProviderIds, "kitchen-sink-migration-provider", "migration providers");
expectIncludes(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses");
expectIncludes(inspect.services, "kitchen-sink-service", "services");
expectIncludes(inspect.commands, "kitchen-sink-command", "commands");
expectIncludes(toolNames, "kitchen-sink-tool", "tools");
if ((inspect.plugin?.hookCount || 0) < 30 || !Array.isArray(inspect.typedHooks) || inspect.typedHooks.length < 30) {
throw new Error(
`expected kitchen-sink typed hooks to load, got hookCount=${inspect.plugin?.hookCount} typedHooks=${inspect.typedHooks?.length}`,
);
}
const expectedErrorMessages = new Set([
"only bundled plugins can register agent tool result middleware",
"cli registration missing explicit commands metadata",
"only bundled plugins can register Codex app-server extension factories",
"http route registration missing or invalid auth: /kitchen-sink/http-route",
"plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider",
]);
for (const message of errorMessages) {
if (!expectedErrorMessages.has(message)) {
throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`);
}
}
for (const message of expectedErrorMessages) {
if (!errorMessages.has(message)) {
throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`);
}
}
} else if (errorMessages.size > 0) {
throw new Error(`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`);
}
const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json");
const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
const record = (index.installRecords ?? index.records ?? {})[pluginId];
if (!record) throw new Error(`missing kitchen-sink install record for ${pluginId}`);
if (record.source !== source) {
throw new Error(`expected kitchen-sink install source=${source}, got ${record.source}`);
}
if (source === "npm") {
const expectedSpec = spec.replace(/^npm:/u, "");
if (record.spec !== expectedSpec) {
throw new Error(`expected kitchen-sink npm spec ${expectedSpec}, got ${record.spec}`);
}
if (!record.resolvedVersion || !record.resolvedSpec) {
throw new Error(`missing npm resolution metadata: ${JSON.stringify(record)}`);
}
} else if (source === "clawhub") {
const value = spec.slice("clawhub:".length).trim();
const slashIndex = value.lastIndexOf("/");
const atIndex = value.lastIndexOf("@");
const packageName = atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value;
if (record.spec !== spec) {
throw new Error(`expected kitchen-sink ClawHub spec ${spec}, got ${record.spec}`);
}
if (record.clawhubPackage !== packageName) {
throw new Error(`expected ClawHub package ${packageName}, got ${record.clawhubPackage}`);
}
if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") {
throw new Error(`unexpected ClawHub family: ${record.clawhubFamily}`);
}
if (!record.version || !record.integrity || !record.resolvedAt) {
throw new Error(`missing ClawHub resolution metadata: ${JSON.stringify(record)}`);
}
}
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
throw new Error("missing kitchen-sink install path");
}
const installPath = record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME);
if (!fs.existsSync(installPath)) {
throw new Error(`kitchen-sink install path missing: ${record.installPath}`);
}
fs.writeFileSync(`/tmp/kitchen-sink-${label}-install-path.txt`, installPath, "utf8");
console.log("ok");
NODE
}
assert_kitchen_sink_removed() {
node - <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const pluginId = process.env.KITCHEN_SINK_ID;
const label = process.env.KITCHEN_SINK_LABEL;
const list = JSON.parse(fs.readFileSync(`/tmp/kitchen-sink-${label}-uninstalled.json`, "utf8"));
if ((list.plugins || []).some((entry) => entry.id === pluginId)) {
throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`);
}
const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json");
const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {};
const records = index.installRecords ?? index.records ?? {};
if (records[pluginId]) {
throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`);
}
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {};
if (config.plugins?.entries?.[pluginId]) {
throw new Error(`kitchen-sink config entry still present after uninstall: ${pluginId}`);
}
if ((config.plugins?.allow || []).includes(pluginId)) {
throw new Error(`kitchen-sink allowlist still contains ${pluginId}`);
}
if ((config.plugins?.deny || []).includes(pluginId)) {
throw new Error(`kitchen-sink denylist still contains ${pluginId}`);
}
if (config.channels?.["kitchen-sink-channel"]) {
throw new Error("kitchen-sink channel config still present after uninstall");
}
const installPathFile = `/tmp/kitchen-sink-${label}-install-path.txt`;
if (fs.existsSync(installPathFile)) {
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
if (installPath && fs.existsSync(installPath)) {
throw new Error(`kitchen-sink managed install directory still exists: ${installPath}`);
}
}
console.log("ok");
NODE
}
run_success_scenario() {
echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..."
run_logged "install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
run_logged "enable-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins enable "$KITCHEN_SINK_ID"
configure_kitchen_sink_runtime
node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json"
node "$OPENCLAW_ENTRY" plugins inspect "$KITCHEN_SINK_ID" --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json"
node "$OPENCLAW_ENTRY" plugins inspect --all --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect-all.json"
assert_kitchen_sink_installed
if [ "$KITCHEN_SINK_SOURCE" = "clawhub" ]; then
run_logged "uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_SPEC" --force
else
run_logged "uninstall-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins uninstall "$KITCHEN_SINK_ID" --force
fi
remove_kitchen_sink_channel_config
node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json"
assert_kitchen_sink_removed
}
run_failure_scenario() {
echo "Testing expected ${KITCHEN_SINK_LABEL} install failure from ${KITCHEN_SINK_SPEC}..."
run_expect_failure "install-${KITCHEN_SINK_LABEL}" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
remove_kitchen_sink_channel_config
node "$OPENCLAW_ENTRY" plugins list --json > "/tmp/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json"
assert_kitchen_sink_removed
}
if [[ "$KITCHEN_SINK_SCENARIOS" == *"clawhub:"* ]] &&
[[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" != "1" ]] &&
[[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then
clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-kitchen-sink-clawhub.XXXXXX")"
start_kitchen_sink_clawhub_fixture_server "$clawhub_fixture_dir"
fi
scenario_count=0
while IFS='|' read -r label spec plugin_id source expectation surface_mode; do
if [ -z "${label:-}" ] || [[ "$label" == \#* ]]; then
continue
fi
scenario_count=$((scenario_count + 1))
export KITCHEN_SINK_LABEL="$label"
export KITCHEN_SINK_SPEC="$spec"
export KITCHEN_SINK_ID="$plugin_id"
export KITCHEN_SINK_SOURCE="$source"
export KITCHEN_SINK_SURFACE_MODE="$surface_mode"
case "$expectation" in
success)
run_success_scenario
;;
failure)
run_failure_scenario
;;
*)
echo "Unknown kitchen-sink expectation for ${label}: ${expectation}" >&2
exit 1
;;
esac
done <<< "$KITCHEN_SINK_SCENARIOS"
if [ "$scenario_count" -eq 0 ]; then
echo "No kitchen-sink plugin scenarios configured." >&2
exit 1
fi
scan_logs_for_unexpected_errors
echo "kitchen-sink plugin Docker E2E passed (${scenario_count} scenario(s))"
EOF
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
-e "KITCHEN_SINK_SCENARIOS=$KITCHEN_SINK_SCENARIOS"
)
for env_name in \
OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB \
OPENCLAW_CLAWHUB_URL \
CLAWHUB_URL \
OPENCLAW_CLAWHUB_TOKEN \
CLAWHUB_TOKEN \
CLAWHUB_AUTH_TOKEN; do
env_value="${!env_name:-}"
if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then
DOCKER_ENV_ARGS+=(-e "$env_name")
fi
done
echo "Running kitchen-sink plugin Docker E2E..."
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run --name "$CONTAINER_NAME" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash -s \
>"$RUN_LOG" 2>&1 < "$SCRIPT_FILE" &
docker_pid="$!"
while kill -0 "$docker_pid" 2>/dev/null; do
if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker stats --no-stream --format '{{json .}}' "$CONTAINER_NAME" >>"$STATS_LOG" 2>/dev/null || true
fi
sleep 2
done
set +e
wait "$docker_pid"
run_status="$?"
set -e
cat "$RUN_LOG"
node - "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" <<'NODE'
const fs = require("node:fs");
const [statsFile, maxMemoryRaw, maxCpuRaw] = process.argv.slice(2);
const maxMemoryMiB = Number(maxMemoryRaw);
const maxCpuPercent = Number(maxCpuRaw);
const parseMemoryMiB = (raw) => {
const value = String(raw || "").split("/")[0]?.trim() || "";
const match = /^([0-9.]+)\s*([KMGT]?i?B)$/iu.exec(value);
if (!match) return 0;
const amount = Number(match[1]);
const unit = match[2].toLowerCase();
if (unit === "kb" || unit === "kib") return amount / 1024;
if (unit === "mb" || unit === "mib") return amount;
if (unit === "gb" || unit === "gib") return amount * 1024;
if (unit === "tb" || unit === "tib") return amount * 1024 * 1024;
return 0;
};
const lines = fs.existsSync(statsFile)
? fs.readFileSync(statsFile, "utf8").split(/\r?\n/u).filter(Boolean)
: [];
let maxObservedMemoryMiB = 0;
let maxObservedCpuPercent = 0;
for (const line of lines) {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
maxObservedMemoryMiB = Math.max(maxObservedMemoryMiB, parseMemoryMiB(parsed.MemUsage));
maxObservedCpuPercent = Math.max(
maxObservedCpuPercent,
Number(String(parsed.CPUPerc || "0").replace(/%$/u, "")) || 0,
);
}
console.log(
`kitchen-sink resource peak: memory=${maxObservedMemoryMiB.toFixed(1)}MiB cpu=${maxObservedCpuPercent.toFixed(1)}% samples=${lines.length}`,
);
if (lines.length === 0) {
throw new Error("no docker stats samples captured for kitchen-sink plugin lane");
}
if (maxObservedMemoryMiB > maxMemoryMiB) {
throw new Error(
`kitchen-sink memory peak ${maxObservedMemoryMiB.toFixed(1)}MiB exceeded ${maxMemoryMiB}MiB`,
);
}
if (maxObservedCpuPercent > maxCpuPercent) {
throw new Error(
`kitchen-sink CPU peak ${maxObservedCpuPercent.toFixed(1)}% exceeded ${maxCpuPercent}%`,
);
}
NODE
rm -f "$RUN_LOG" "$STATS_LOG"
exit "$run_status"