Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
// Helpers for extracting agent turn output from E2E protocol events.
import fs from "node:fs";
import { readTextFileTail, tailText } from "./text-file-utils.mjs";
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
const OUTPUT_SCAN_TAIL_BYTES = 2 * 1024 * 1024;
const REPLY_TEXT_PREVIEW_BYTES = 8 * 1024;
const REPLY_TEXT_PREVIEW_COUNT = 5;
const REQUEST_LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const REQUEST_LOG_SCAN_CARRY_CHARS = 256;
const OPENAI_REQUEST_PATH_PATTERN = /\/v1\/(responses|chat\/completions)/u;
function textByteLength(text) {
return Buffer.byteLength(text, "utf8");
}
function summarizeReplyTexts(replyTexts) {
const previewStart = Math.max(0, replyTexts.length - REPLY_TEXT_PREVIEW_COUNT);
const recent = replyTexts.slice(previewStart).map((text, index) => ({
index: previewStart + index,
bytes: textByteLength(text),
tail: tailText(text, REPLY_TEXT_PREVIEW_BYTES),
}));
return JSON.stringify({ count: replyTexts.length, recent });
}
function fileContainsPattern(file, pattern) {
let stat;
try {
stat = fs.statSync(file);
} catch {
return false;
}
if (!stat.isFile() || stat.size <= 0) {
return false;
}
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(Math.min(REQUEST_LOG_SCAN_CHUNK_BYTES, stat.size));
let carry = "";
let offset = 0;
while (offset < stat.size) {
const bytesToRead = Math.min(buffer.length, stat.size - offset);
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
if (bytesRead <= 0) {
break;
}
offset += bytesRead;
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
if (pattern.test(text)) {
return true;
}
carry = text.slice(-REQUEST_LOG_SCAN_CARRY_CHARS);
}
return false;
} finally {
fs.closeSync(fd);
}
}
function parseJson(text) {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function isJsonObjectRecordStart(text, index) {
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const char = text[cursor];
if (char === "\n" || char === "\r") {
return true;
}
if (char !== " " && char !== "\t") {
return false;
}
}
return true;
}
function parseJsonObjectsFromText(text) {
const payloads = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (start === -1) {
if (char === "{" && isJsonObjectRecordStart(text, index)) {
start = index;
depth = 1;
inString = false;
escaped = false;
}
continue;
}
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char !== "}") {
continue;
}
depth -= 1;
if (depth === 0) {
const parsed = parseJson(text.slice(start, index + 1));
if (parsed !== undefined) {
payloads.push(parsed);
}
start = -1;
}
}
return payloads;
}
function parseJsonPayloads(text) {
const trimmed = text.trim();
if (!trimmed) {
return [];
}
const parsed = parseJson(trimmed);
if (parsed !== undefined) {
return [parsed];
}
return parseJsonObjectsFromText(trimmed);
}
function textValues(values) {
return values.filter((value) => typeof value === "string" && value.length > 0);
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isFailureStatus(value) {
return (
typeof value === "string" &&
["blocked", "canceled", "cancelled", "error", "failed", "failure"].includes(value.toLowerCase())
);
}
function hasFailureSignal(value) {
if (!isRecord(value)) {
return false;
}
return (
value.isError === true ||
value.ok === false ||
isFailureStatus(value.status) ||
isFailureStatus(value.livenessState) ||
(Object.hasOwn(value, "error") && value.error !== null && value.error !== undefined)
);
}
export function extractAgentReplyTexts(text) {
return parseJsonPayloads(text).flatMap((payload) => {
const envelopeFailed =
hasFailureSignal(payload) ||
hasFailureSignal(payload?.meta) ||
hasFailureSignal(payload?.result) ||
hasFailureSignal(payload?.result?.meta);
if (envelopeFailed) {
return [];
}
const payloadEntries = Array.isArray(payload?.payloads)
? payload.payloads
: Array.isArray(payload?.result?.payloads)
? payload.result.payloads
: [];
const directTexts = textValues([
payload?.finalAssistantVisibleText,
payload?.finalAssistantRawText,
payload?.meta?.finalAssistantVisibleText,
payload?.meta?.finalAssistantRawText,
payload?.result?.finalAssistantVisibleText,
payload?.result?.finalAssistantRawText,
payload?.result?.meta?.finalAssistantVisibleText,
payload?.result?.meta?.finalAssistantRawText,
]);
const payloadTexts = payloadEntries.flatMap((entry) =>
entry?.isError !== true && typeof entry?.text === "string" && entry.text.length > 0
? [entry.text]
: [],
);
return directTexts.concat(payloadTexts);
});
}
export function assertAgentReplyContainsMarker(marker, outputPath) {
const output = readTextFileTail(outputPath, OUTPUT_SCAN_TAIL_BYTES);
const replyTexts = extractAgentReplyTexts(output);
if (replyTexts.some((text) => text.includes(marker))) {
return;
}
const outputTail = tailText(output, ERROR_DETAIL_TAIL_BYTES);
throw new Error(
`agent reply payload did not contain marker ${marker}. Reply payload summary: ${summarizeReplyTexts(replyTexts)}. Output tail: ${outputTail}`,
);
}
export function assertOpenAiRequestLogUsed(requestLogPath, label = "mock OpenAI server") {
if (fileContainsPattern(requestLogPath, OPENAI_REQUEST_PATH_PATTERN)) {
return;
}
const requestLogTail = readTextFileTail(requestLogPath, ERROR_DETAIL_TAIL_BYTES);
throw new Error(`${label} was not used. Request log tail: ${requestLogTail}`);
}

View File

@@ -0,0 +1,62 @@
// Shared auth profile store assertions for install/onboard E2E proof.
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasExpectedOpenAiEnvRef(profile) {
if (!isRecord(profile)) {
return false;
}
const keyRef = profile.keyRef;
return (
profile.type === "api_key" &&
profile.provider === "openai" &&
!Object.hasOwn(profile, "key") &&
isRecord(keyRef) &&
keyRef.source === "env" &&
keyRef.provider === "default" &&
keyRef.id === "OPENAI_API_KEY"
);
}
function hasInlineOpenAiKey(profile) {
return (
isRecord(profile) &&
profile.type === "api_key" &&
profile.provider === "openai" &&
Object.hasOwn(profile, "key")
);
}
export function assertOpenAiEnvAuthProfileStore(storeJson, options = {}) {
const missingMessage = options.missingMessage ?? "auth profile store was not persisted";
const envRefMessage =
options.envRefMessage ?? "auth profile did not persist OPENAI_API_KEY env ref";
const rawKeyMessage = options.rawKeyMessage ?? "auth profile persisted an inline OpenAI key";
const rawKeyNeedle = options.rawKeyNeedle;
if (!storeJson) {
throw new Error(missingMessage);
}
if (rawKeyNeedle && storeJson.includes(rawKeyNeedle)) {
throw new Error(rawKeyMessage);
}
let store;
try {
store = JSON.parse(storeJson);
} catch {
throw new Error(envRefMessage);
}
const profiles = isRecord(store) && isRecord(store.profiles) ? store.profiles : null;
if (!profiles) {
throw new Error(envRefMessage);
}
const profileValues = Object.values(profiles);
if (profileValues.some(hasInlineOpenAiKey)) {
throw new Error(rawKeyMessage);
}
if (!profileValues.some(hasExpectedOpenAiEnvRef)) {
throw new Error(envRefMessage);
}
}

View File

@@ -0,0 +1,68 @@
// Bounded response body reader used by E2E HTTP fixture clients.
function bodyTooLargeError(label, byteLimit) {
return Object.assign(new Error(`${label} response body exceeded ${byteLimit} bytes`), {
code: "ETOOBIG",
});
}
function cancelReaderSoon(reader) {
void Promise.resolve()
.then(() => reader.cancel())
.catch(() => {});
}
function parseContentLengthHeader(headers) {
const raw = headers.get("content-length");
if (!raw || !/^\d+$/u.test(raw)) {
return undefined;
}
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY;
}
export async function readBoundedResponseText(response, label, byteLimit, timeoutPromise) {
const contentLength = parseContentLengthHeader(response.headers);
if (contentLength !== undefined && contentLength > byteLimit) {
await response.body?.cancel().catch(() => {});
throw bodyTooLargeError(label, byteLimit);
}
if (!response.body) {
return "";
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let byteCount = 0;
let text = "";
let canceled = false;
try {
while (true) {
const read = reader.read();
const readWithTimeout = timeoutPromise
? Promise.race([
read,
timeoutPromise.catch((error) => {
canceled = true;
cancelReaderSoon(reader);
throw error;
}),
])
: read;
const { done, value } = await readWithTimeout;
if (done) {
return text + decoder.decode();
}
byteCount += value.byteLength;
if (byteCount > byteLimit) {
canceled = true;
await reader.cancel().catch(() => {});
throw bodyTooLargeError(label, byteLimit);
}
text += decoder.decode(value, { stream: true });
}
} finally {
if (!canceled) {
reader.releaseLock();
}
}
}

View File

@@ -0,0 +1,71 @@
// Assertions for browser CDP snapshot E2E fixtures.
import fs from "node:fs";
const DEFAULT_SNAPSHOT_MAX_BYTES = 512 * 1024;
const SNAPSHOT_DIAGNOSTIC_MAX_BYTES = 32 * 1024;
const snapshotPath = process.argv[2] ?? "/tmp/browser-cdp-snapshot.txt";
function readPositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === "") {
return fallback;
}
const text = raw.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
return parsed;
}
function readBoundedSnapshot(file, maxBytes) {
const stats = fs.statSync(file);
if (!stats.isFile()) {
throw new Error(`${file} is not a file`);
}
if (stats.size > maxBytes) {
throw new Error(`browser CDP snapshot exceeded ${maxBytes} bytes: ${stats.size} bytes`);
}
const snapshot = fs.readFileSync(file, "utf8");
const bytes = Buffer.byteLength(snapshot, "utf8");
if (bytes > maxBytes) {
throw new Error(`browser CDP snapshot exceeded ${maxBytes} bytes: ${bytes} bytes`);
}
return snapshot;
}
function snapshotDiagnostic(snapshot) {
const buffer = Buffer.from(snapshot, "utf8");
if (buffer.byteLength <= SNAPSHOT_DIAGNOSTIC_MAX_BYTES) {
return snapshot;
}
return `[truncated snapshot diagnostic to ${SNAPSHOT_DIAGNOSTIC_MAX_BYTES} bytes]\n${buffer
.subarray(buffer.byteLength - SNAPSHOT_DIAGNOSTIC_MAX_BYTES)
.toString("utf8")}`;
}
const snapshotMaxBytes = readPositiveIntEnv(
"OPENCLAW_BROWSER_CDP_SNAPSHOT_MAX_BYTES",
DEFAULT_SNAPSHOT_MAX_BYTES,
);
const snapshot = readBoundedSnapshot(snapshotPath, snapshotMaxBytes);
for (const needle of [
'button "Save"',
'link "Docs"',
"https://docs.openclaw.ai/browser-cdp-live",
'generic "Clickable Card"',
"cursor:pointer",
'Iframe "Child"',
'button "Inside"',
]) {
if (!snapshot.includes(needle)) {
console.error(snapshotDiagnostic(snapshot));
throw new Error(`missing snapshot needle: ${needle}`);
}
}
console.log("ok");

View File

@@ -0,0 +1,24 @@
// Fixture HTTP server for browser CDP snapshot E2E scenarios.
import http from "node:http";
import { readTcpPortEnv } from "../env-limits.mjs";
const port = readTcpPortEnv("FIXTURE_PORT");
const html = `<!doctype html>
<html>
<body>
<main>
<button>Save</button>
<a href="https://docs.openclaw.ai/browser-cdp-live">Docs</a>
<div id="card" onclick="window.__clicked = true" style="cursor: pointer">Clickable Card</div>
<iframe title="Child" srcdoc='<button>Inside</button>'></iframe>
</main>
</body>
</html>`;
http
.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(html);
})
.listen(port, "127.0.0.1");

View File

@@ -0,0 +1,207 @@
// Assertions for Bun global install E2E validation.
import { spawn } from "node:child_process";
const DEFAULT_TIMEOUT_KILL_GRACE_MS = 30_000;
const PARENT_TERMINATION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
const usage = () => {
console.error("Usage: assertions.mjs <run-with-timeout|assert-image-providers> [...]");
process.exit(2);
};
const [mode, ...args] = process.argv.slice(2);
const parsePositiveNumber = (value, label) => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive number`);
}
return parsed;
};
const signalChild = (child, signal) => {
if (!child.pid) {
return;
}
try {
if (process.platform === "win32") {
child.kill(signal);
return;
}
process.kill(-child.pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") {
throw error;
}
}
};
const processGroupAlive = (child) => {
if (process.platform === "win32" || !child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
};
const waitForProcessGroupExit = async (child, timeout) => {
const deadlineAt = Date.now() + timeout;
while (Date.now() < deadlineAt) {
if (!processGroupAlive(child)) {
return true;
}
await new Promise((resolve) => {
setTimeout(resolve, 25);
});
}
return !processGroupAlive(child);
};
const resolveSignalExitCode = (signal) => {
switch (signal) {
case "SIGINT":
return 130;
case "SIGHUP":
return 129;
default:
return 143;
}
};
const runWithTimeout = async (timeout, command, commandArgs) => {
const killGrace = parsePositiveNumber(
process.env.OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS ??
String(DEFAULT_TIMEOUT_KILL_GRACE_MS),
"OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS",
);
const child = spawn(command, commandArgs, {
detached: process.platform !== "win32",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
let parentSignal = null;
let killTimer;
let killDeadlineAt = 0;
const scheduleForceKill = () => {
killDeadlineAt = Date.now() + killGrace;
killTimer ??= setTimeout(() => signalChild(child, "SIGKILL"), killGrace);
killTimer.unref();
};
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
const timeoutTimer = setTimeout(() => {
timedOut = true;
signalChild(child, "SIGTERM");
scheduleForceKill();
}, timeout);
timeoutTimer.unref();
const parentSignalHandlers = new Map(
PARENT_TERMINATION_SIGNALS.map((signal) => [
signal,
() => {
parentSignal ??= signal;
signalChild(child, signal);
scheduleForceKill();
},
]),
);
for (const [signal, handler] of parentSignalHandlers) {
process.on(signal, handler);
}
const cleanupParentSignalHandlers = () => {
for (const [signal, handler] of parentSignalHandlers) {
process.off(signal, handler);
}
};
let spawnError;
child.on("error", (error) => {
spawnError = error;
});
const result = await new Promise((resolve) => {
child.on("close", (status, signal) => resolve({ error: spawnError, signal, status }));
});
clearTimeout(timeoutTimer);
cleanupParentSignalHandlers();
if (timedOut || parentSignal) {
const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
if (remainingGraceMs > 0) {
await waitForProcessGroupExit(child, remainingGraceMs);
}
if (processGroupAlive(child)) {
signalChild(child, "SIGKILL");
await waitForProcessGroupExit(child, 100);
}
clearTimeout(killTimer);
}
if (parentSignal) {
process.exit(resolveSignalExitCode(parentSignal));
}
if (timedOut) {
console.error(`command timed out after ${timeout}ms: ${command}`);
process.exit(1);
}
clearTimeout(killTimer);
if (result.error) {
console.error(`command failed: ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.signal) {
console.error(`command terminated: ${command}: ${result.signal}`);
process.exit(1);
}
process.exit(result.status ?? 0);
};
if (mode === "run-with-timeout") {
const [timeoutMs, command, ...commandArgs] = args;
if (!command) {
usage();
}
let timeout;
try {
timeout = parsePositiveNumber(timeoutMs, "timeoutMs");
} catch {
usage();
}
await runWithTimeout(timeout, command, commandArgs);
}
if (mode === "assert-image-providers") {
const raw = process.env.OPENCLAW_IMAGE_PROVIDERS_JSON ?? "";
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
console.error(raw);
const message = error instanceof Error ? error.message : String(error);
throw new Error(`image providers output is not JSON: ${message}`, { cause: error });
}
if (!Array.isArray(parsed)) {
throw new Error("image providers output must be a JSON array");
}
if (parsed.length === 0) {
throw new Error("image providers output is empty");
}
const ids = new Set(parsed.map((entry) => (typeof entry?.id === "string" ? entry.id : "")));
for (const expected of ["google", "openai", "xai"]) {
if (!ids.has(expected)) {
throw new Error(`image providers output is missing bundled provider '${expected}'`);
}
}
console.log(`bun-global-install-smoke: image providers OK (${parsed.length} providers)`);
process.exit(0);
}
usage();

View File

@@ -0,0 +1,307 @@
// Probe script for bundled plugin install/uninstall E2E scenarios.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const normalizePathForProbe = (value) => String(value ?? "").replace(/\\/g, "/");
const bundledRuntimeFragments = (pluginDir) => [
`/dist/extensions/${pluginDir}`,
`/dist-runtime/extensions/${pluginDir}`,
];
const bundledRuntimeRootFragments = ["/dist/extensions/", "/dist-runtime/extensions/"];
const DEFAULT_PLUGIN_LIST_TIMEOUT_MS = 30_000;
const DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
function readIntegerEnv(name, fallback, minimum) {
const raw = process.env[name];
if (raw == null || raw === "") {
return fallback;
}
const text = raw.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value < minimum) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
function readPositiveIntEnv(name, fallback) {
return readIntegerEnv(name, fallback, 1);
}
function readNonNegativeIntEnv(name, fallback) {
return readIntegerEnv(name, fallback, 0);
}
function resolveStateDir() {
if (process.env.OPENCLAW_STATE_DIR) {
return process.env.OPENCLAW_STATE_DIR;
}
return path.join(process.env.HOME || os.homedir(), ".openclaw");
}
function pathReferencesBundledRuntime(value, pluginDir) {
const normalized = normalizePathForProbe(value);
return bundledRuntimeFragments(pluginDir).some((fragment) => normalized.includes(fragment));
}
function pathReferencesPackagedBundledRoot(value) {
const normalized = normalizePathForProbe(value);
return bundledRuntimeRootFragments.some((fragment) => normalized.includes(fragment));
}
function pathsEqualForProbe(actual, expected) {
return normalizePathForProbe(actual) === normalizePathForProbe(expected);
}
function resolveOpenClawEntry() {
if (process.env.OPENCLAW_ENTRY) {
return process.env.OPENCLAW_ENTRY;
}
for (const entry of ["dist/index.mjs", "dist/index.js"]) {
if (fs.existsSync(entry)) {
return entry;
}
}
throw new Error("Missing OPENCLAW_ENTRY and dist/index.(m)js");
}
function readPluginsList() {
const entry = resolveOpenClawEntry();
const timeoutMs = readPositiveIntEnv(
"OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS",
DEFAULT_PLUGIN_LIST_TIMEOUT_MS,
);
const result = spawnSync(process.execPath, [entry, "plugins", "list", "--json"], {
cwd: process.cwd(),
encoding: "utf8",
env: process.env,
maxBuffer: readPositiveIntEnv(
"OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES",
DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES,
),
killSignal: "SIGKILL",
timeout: timeoutMs,
});
if (result.error) {
const timedOut = result.error.code === "ETIMEDOUT";
throw new Error(
timedOut
? `Timed out listing packaged bundled plugins after ${timeoutMs}ms`
: `Unable to list packaged bundled plugins: ${result.error.message}`,
);
}
if (result.status !== 0) {
throw new Error(
`Unable to list packaged bundled plugins: ${result.stderr || result.stdout || `exit ${result.status}`}`,
);
}
const payload = parsePluginListOutput(result.stdout);
return Array.isArray(payload.plugins) ? payload.plugins : [];
}
function parsePluginListOutput(stdout) {
const trimmed = stdout.trim();
const parsed = parseJsonValue(trimmed);
if (parsed.ok) {
return parsed.value;
}
let lastParsed;
for (const line of trimmed.split(/\r?\n/u).toReversed()) {
if (!line.trimStart().startsWith("{")) {
continue;
}
const candidate = parseJsonValue(line);
if (!candidate.ok) {
continue;
}
lastParsed ??= candidate.value;
if (Array.isArray(candidate.value?.plugins)) {
return candidate.value;
}
}
if (lastParsed !== undefined) {
return lastParsed;
}
throw new Error(`Unable to parse packaged bundled plugin list JSON: ${trimmed}`);
}
function parseJsonValue(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function pluginRequiresConfig(pluginDir) {
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
throw new Error(`missing bundled plugin manifest: ${manifestPath}`);
}
const manifest = readJson(manifestPath);
const required = manifest.configSchema?.required;
return Array.isArray(required) && required.some((value) => typeof value === "string");
}
async function loadPackagedBundledEntries() {
return readPluginsList()
.filter((plugin) => plugin?.origin === "bundled")
.map((plugin) => {
const id = typeof plugin.id === "string" ? plugin.id.trim() : "";
const rootDir = typeof plugin.rootDir === "string" ? plugin.rootDir.trim() : "";
const source = typeof plugin.source === "string" ? plugin.source.trim() : "";
const pluginDir = rootDir || (source ? path.dirname(source) : "");
if (!id || !pluginDir || !pathReferencesPackagedBundledRoot(pluginDir)) {
return null;
}
return {
id,
dir: path.basename(pluginDir),
rootDir: pluginDir,
requiresConfig: pluginRequiresConfig(pluginDir),
};
})
.filter(Boolean)
.toSorted((a, b) => a.id.localeCompare(b.id));
}
async function loadManifestEntries() {
const explicit = (process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS || "")
.split(/[,\s]+/u)
.map((entry) => entry.trim())
.filter(Boolean);
const manifestEntries = await loadPackagedBundledEntries();
if (explicit.length === 0) {
return manifestEntries;
}
const available = manifestEntries.map((entry) => entry.id).join(", ");
return explicit.map((lookup) => {
const found = manifestEntries.find((entry) => entry.id === lookup || entry.dir === lookup);
if (!found) {
throw new Error(
`OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: ${lookup}. Available: ${available}`,
);
}
return found;
});
}
async function selectedManifestEntries() {
const allEntries = await loadManifestEntries();
const total = readPositiveIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL", 1);
const index = readNonNegativeIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX", 0);
if (index >= total) {
throw new Error(
`OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX must be in [0, ${total - 1}], got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX}`,
);
}
const selected = allEntries.filter((_, candidateIndex) => candidateIndex % total === index);
if (selected.length === 0) {
throw new Error(`No bundled plugin ids selected for shard ${index}/${total}`);
}
return selected;
}
function assertInstalled(pluginId, pluginDir, requiresConfig, selectedPluginRoot = "") {
const stateDir = resolveStateDir();
const configPath = path.join(stateDir, "openclaw.json");
const config = readJson(configPath);
const records = readPluginInstallRecords({ stateDir, configPath });
const record = records[pluginId];
if (!record) {
throw new Error(`missing install record for ${pluginId}`);
}
if (record.source !== "path") {
throw new Error(
`expected bundled install record source=path for ${pluginId}, got ${record.source}`,
);
}
const sourcePath = typeof record.sourcePath === "string" ? record.sourcePath : "";
if (!sourcePath) {
throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`);
}
if (selectedPluginRoot && !pathsEqualForProbe(sourcePath, selectedPluginRoot)) {
throw new Error(
`bundled source path for ${pluginId} did not match selected root: expected ${selectedPluginRoot}, got ${record.sourcePath}`,
);
}
if (!selectedPluginRoot && !pathReferencesBundledRuntime(sourcePath, pluginDir)) {
throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`);
}
if (selectedPluginRoot && !fs.existsSync(sourcePath)) {
throw new Error(`bundled source path for ${pluginId} does not exist: ${record.sourcePath}`);
}
if (!pathsEqualForProbe(record.installPath, record.sourcePath)) {
throw new Error(`bundled install path should equal source path for ${pluginId}`);
}
const paths = config.plugins?.load?.paths || [];
if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) {
throw new Error(`config load paths should not include bundled install path for ${pluginId}`);
}
if (requiresConfig && config.plugins?.entries?.[pluginId]?.enabled === true) {
throw new Error(
`plugin requiring config should not be enabled immediately after install for ${pluginId}`,
);
}
if (!requiresConfig && config.plugins?.entries?.[pluginId]?.enabled !== true) {
throw new Error(`config entry is not enabled after install for ${pluginId}`);
}
const allow = config.plugins?.allow || [];
if (Array.isArray(allow) && allow.length > 0 && !allow.includes(pluginId)) {
throw new Error(`existing allowlist does not include ${pluginId} after install`);
}
if ((config.plugins?.deny || []).includes(pluginId)) {
throw new Error(`denylist contains ${pluginId} after install`);
}
}
function assertUninstalled(pluginId, pluginDir) {
const stateDir = resolveStateDir();
const configPath = path.join(stateDir, "openclaw.json");
const config = fs.existsSync(configPath) ? readJson(configPath) : {};
const records = readPluginInstallRecords({ stateDir, configPath });
if (records[pluginId]) {
throw new Error(`install record still present after uninstall for ${pluginId}`);
}
const paths = config.plugins?.load?.paths || [];
if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) {
throw new Error(`load path still present after uninstall for ${pluginId}`);
}
if (config.plugins?.entries?.[pluginId]) {
throw new Error(`config entry still present after uninstall for ${pluginId}`);
}
if ((config.plugins?.allow || []).includes(pluginId)) {
throw new Error(`allowlist still contains ${pluginId} after uninstall`);
}
if ((config.plugins?.deny || []).includes(pluginId)) {
throw new Error(`denylist still contains ${pluginId} after uninstall`);
}
const managedPath = path.join(stateDir, "extensions", pluginId);
if (fs.existsSync(managedPath)) {
throw new Error(
`managed install directory unexpectedly exists for bundled plugin ${pluginId}: ${managedPath}`,
);
}
}
const [command, pluginId, pluginDir, requiresConfig, selectedPluginRoot] = process.argv.slice(2);
if (command === "select") {
for (const entry of await selectedManifestEntries()) {
console.log(`${entry.id}\t${entry.dir}\t${entry.requiresConfig ? "1" : "0"}\t${entry.rootDir}`);
}
} else if (command === "assert-installed") {
assertInstalled(pluginId, pluginDir, requiresConfig === "1", selectedPluginRoot);
} else if (command === "assert-uninstalled") {
assertUninstalled(pluginId, pluginDir);
} else {
throw new Error(`Unknown bundled plugin probe command: ${command || "(missing)"}`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
source scripts/lib/docker-e2e-logs.sh
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
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
probe="scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs"
runtime_smoke="scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs"
node "$probe" select > /tmp/bundled-plugin-sweep-ids
sweep_command_timeout="${OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT:-300s}"
now_ms() {
node -e 'process.stdout.write(String(Date.now()))'
}
run_logged_sweep_command() {
local label="$1"
local log_file="$2"
shift 2
if openclaw_e2e_maybe_timeout "$sweep_command_timeout" "$@" >"$log_file" 2>&1; then
return 0
else
local status=$?
docker_e2e_print_log "$log_file"
if [ "$status" -eq 124 ]; then
echo "Bundled plugin sweep command timed out after $sweep_command_timeout: $label" >&2
else
echo "Bundled plugin sweep command failed with status $status: $label" >&2
fi
return "$status"
fi
}
lifecycle_trace_enabled() {
case "${OPENCLAW_PLUGIN_LIFECYCLE_TRACE:-}" in
1 | true | TRUE | yes | YES)
return 0
;;
*)
return 1
;;
esac
}
plugin_entries=()
while IFS= read -r plugin_entry; do
plugin_entries+=("$plugin_entry")
done < /tmp/bundled-plugin-sweep-ids
selected_labels=()
for plugin_entry in "${plugin_entries[@]}"; do
IFS=$'\t' read -r plugin_id plugin_dir _requires_config _plugin_root <<<"$plugin_entry"
selected_labels+=("${plugin_id}@${plugin_dir}")
done
echo "Selected ${#plugin_entries[@]} bundled plugins for shard ${OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX:-0}/${OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL:-1}: ${selected_labels[*]}"
plugin_index=0
for plugin_entry in "${plugin_entries[@]}"; do
IFS=$'\t' read -r plugin_id plugin_dir requires_config plugin_root <<<"$plugin_entry"
install_log="/tmp/openclaw-install-${plugin_index}.log"
uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log"
plugin_started_at="$(now_ms)"
echo "Installing bundled plugin: $plugin_id ($plugin_dir)"
run_logged_sweep_command "install $plugin_id" "$install_log" \
node "$OPENCLAW_ENTRY" plugins install "$plugin_id"
if lifecycle_trace_enabled; then
docker_e2e_print_log "$install_log"
fi
install_finished_at="$(now_ms)"
node "$probe" assert-installed "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_root"
installed_asserted_at="$(now_ms)"
if [[ "${OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE:-1}" != "0" ]]; then
echo "Running bundled plugin runtime smoke: $plugin_id ($plugin_dir)"
node "$runtime_smoke" plugin "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" "$plugin_root"
node "$runtime_smoke" tts-global-disable "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index" "$plugin_root" ""
if [[ "$plugin_id" == "${OPENCLAW_BUNDLED_PLUGIN_TTS_LIVE_PROVIDER:-openai}" ]]; then
node "$runtime_smoke" tts-openai-live "$plugin_id" "$plugin_dir" "$requires_config" "$plugin_index"
fi
fi
runtime_finished_at="$(now_ms)"
echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)"
run_logged_sweep_command "uninstall $plugin_id" "$uninstall_log" \
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force
if lifecycle_trace_enabled; then
docker_e2e_print_log "$uninstall_log"
fi
uninstall_finished_at="$(now_ms)"
node "$probe" assert-uninstalled "$plugin_id" "$plugin_dir"
uninstalled_asserted_at="$(now_ms)"
echo "Bundled plugin lifecycle timing: $plugin_id install_ms=$((install_finished_at - plugin_started_at)) install_assert_ms=$((installed_asserted_at - install_finished_at)) runtime_ms=$((runtime_finished_at - installed_asserted_at)) uninstall_ms=$((uninstall_finished_at - runtime_finished_at)) uninstall_assert_ms=$((uninstalled_asserted_at - uninstall_finished_at)) total_ms=$((uninstalled_asserted_at - plugin_started_at))"
plugin_index=$((plugin_index + 1))
done
echo "bundled plugin install/uninstall sweep passed (${#plugin_entries[@]} plugin(s))"

View File

@@ -0,0 +1,497 @@
// CommonJS fixture server for ClawHub package/install E2E scenarios.
const crypto = require("node:crypto");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { createRequire } = require("node:module");
const profile = process.argv[2];
const portFile = process.argv[3];
const requireFromApp = createRequire(path.join(process.cwd(), "package.json"));
const JSZip = requireFromApp("jszip");
const tar = requireFromApp("tar");
const packageName = "@openclaw/kitchen-sink";
const pluginId = "openclaw-kitchen-sink-fixture";
const buildArtifactSummary = ({
clawpackSha256,
clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
}) => ({
kind: "npm-pack",
format: "tgz",
sha256: clawpackSha256,
size: clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
});
const buildClawPackSummary = ({
clawpackSha256,
clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
}) => ({
available: true,
format: "tgz",
sha256: clawpackSha256,
size: clawpackSize,
npmIntegrity,
npmShasum,
npmTarballName,
});
async function buildNpmPackArtifact(fixture) {
const packRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-fixture-"));
try {
const packageDir = path.join(packRoot, "package");
await fs.promises.mkdir(packageDir, { recursive: true });
await fs.promises.writeFile(
path.join(packageDir, "package.json"),
`${JSON.stringify(fixture.packageJson, null, 2)}\n`,
);
await fs.promises.writeFile(path.join(packageDir, "index.js"), fixture.indexJs);
await fs.promises.writeFile(
path.join(packageDir, "openclaw.plugin.json"),
`${JSON.stringify(fixture.manifest, null, 2)}\n`,
);
const npmTarballName = `${packageName.replace(/^@/, "").replace("/", "-")}-${fixture.version}.tgz`;
const archivePath = path.join(packRoot, npmTarballName);
await tar.c(
{
cwd: packRoot,
file: archivePath,
gzip: true,
portable: true,
noMtime: true,
},
["package"],
);
const archive = await fs.promises.readFile(archivePath);
return {
archive,
clawpackSha256: crypto.createHash("sha256").update(archive).digest("hex"),
clawpackSize: archive.length,
npmIntegrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
npmShasum: crypto.createHash("sha1").update(archive).digest("hex"),
npmTarballName,
};
} finally {
await fs.promises.rm(packRoot, { recursive: true, force: true }).catch(() => undefined);
}
}
const profiles = {
"kitchen-sink-plugin": {
version: "0.2.5",
packageJson: {
name: packageName,
version: "0.2.5",
type: "module",
dependencies: {
"is-number": "7.0.0",
},
peerDependencies: {
openclaw: ">=2026.4.11",
},
peerDependenciesMeta: {
openclaw: {
optional: true,
},
},
openclaw: { extensions: ["./index.js"] },
},
indexJs: `import isNumber from "is-number";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
const dependencyUrl = import.meta.resolve("is-number");
const expectedDependencyBaseUrl = new URL("./node_modules/is-number/", import.meta.url).href;
if (!dependencyUrl.startsWith(expectedDependencyBaseUrl)) {
throw new Error(\`kitchen-sink dependency resolved outside plugin root: \${dependencyUrl}\`);
}
export default definePluginEntry({
id: "${pluginId}",
name: "OpenClaw Kitchen Sink",
register(api) {
if (!isNumber(42)) {
throw new Error("kitchen-sink dependency sentinel did not load");
}
api.registerProvider({
id: "kitchen-sink-provider",
label: "Kitchen Sink Provider",
docsPath: "/providers/kitchen-sink",
auth: [],
});
api.registerContextEngine("${pluginId}", () => ({
info: {
id: "${pluginId}",
name: "Kitchen Sink Context Engine",
},
async ingest() {
return { ingested: false };
},
async assemble(params) {
return {
messages: params.messages,
estimatedTokens: 0,
};
},
async compact() {
return {
ok: true,
compacted: false,
reason: "kitchen-sink fixture does not compact",
};
},
}));
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" },
},
});
},
});
`,
manifest: {
id: pluginId,
name: "OpenClaw Kitchen Sink",
kind: "context-engine",
channels: ["kitchen-sink-channel"],
channelConfigs: {
"kitchen-sink-channel": {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean", default: true },
token: { type: "string" },
},
},
uiHints: {
token: {
sensitive: true,
},
},
label: "Kitchen Sink",
description:
"Credential-free channel fixture for deterministic Kitchen Sink install tests.",
commands: {
nativeCommandsAutoEnabled: true,
nativeSkillsAutoEnabled: true,
},
},
},
providers: ["kitchen-sink-provider"],
contracts: {
tools: ["kitchen-sink-tool"],
},
configSchema: {
type: "object",
properties: {},
},
},
packageDetail(artifact) {
const clawpack = buildClawPackSummary(artifact);
const packageArtifact = buildArtifactSummary(artifact);
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: this.version,
tags: { latest: this.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",
},
artifact: packageArtifact,
clawpack,
},
};
return {
packageDetail,
versionDetail: {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
},
version: {
version: this.version,
createdAt: 0,
changelog: "Fixture package for kitchen-sink plugin prerelease CI.",
distTags: ["latest"],
sha256hash: artifact.sha256hash,
compatibility: packageDetail.package.compatibility,
capabilities: packageDetail.package.capabilities,
verification: packageDetail.package.verification,
artifact: packageArtifact,
clawpack,
},
},
betaStatus: 404,
};
},
},
plugins: {
version: "0.1.0",
packageJson: {
name: packageName,
version: "0.1.0",
dependencies: {
"is-number": "7.0.0",
},
peerDependencies: {
openclaw: ">=2026.4.11",
},
peerDependenciesMeta: {
openclaw: {
optional: true,
},
},
openclaw: { extensions: ["./index.js"] },
},
indexJs: `module.exports = {
id: "${pluginId}",
name: "OpenClaw Kitchen Sink",
description: "Docker E2E kitchen-sink plugin fixture",
register(api) {
api.on("before_agent_start", async (event, context) => ({
kitchenSink: true,
observedEventKeys: Object.keys(event || {}),
observedContextKeys: Object.keys(context || {}),
}));
api.registerTool(() => null, { name: "kitchen_sink_tool" });
api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true }));
api.registerCli(() => {}, { commands: ["kitchen-sink"] });
api.registerService({ id: "kitchen-sink-service", start: () => {} });
},
};
`,
manifest: {
id: pluginId,
contracts: {
tools: ["kitchen-sink-tool", "kitchen_sink_tool"],
},
configSchema: {
type: "object",
properties: {},
},
},
packageDetail(artifact) {
const compatibility = {
pluginApiRange: ">=2026.4.26",
minGatewayVersion: "2026.4.26",
};
const clawpack = buildClawPackSummary(artifact);
const packageArtifact = buildArtifactSummary(artifact);
return {
packageDetail: {
package: {
name: packageName,
displayName: "OpenClaw Kitchen Sink",
family: "code-plugin",
channel: "official",
isOfficial: true,
runtimeId: pluginId,
latestVersion: this.version,
createdAt: 0,
updatedAt: 0,
compatibility,
artifact: packageArtifact,
clawpack,
},
},
versionDetail: {
version: {
version: this.version,
createdAt: 0,
changelog: "Kitchen-sink fixture package for Docker plugin E2E.",
sha256hash: artifact.sha256hash,
compatibility,
artifact: packageArtifact,
clawpack,
},
},
};
},
},
};
const fixture = profiles[profile];
if (!fixture || !portFile) {
console.error("usage: clawhub-fixture-server.cjs <kitchen-sink-plugin|plugins> <port-file>");
process.exit(1);
}
async function main() {
const zip = new JSZip();
zip.file("package/package.json", `${JSON.stringify(fixture.packageJson, null, 2)}\n`, {
date: new Date(0),
});
zip.file("package/index.js", fixture.indexJs, { date: new Date(0) });
const manifestJson = `${JSON.stringify(fixture.manifest, null, 2)}\n`;
zip.file("package/openclaw.plugin.json", manifestJson, { date: new Date(0) });
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
const sha256hash = crypto.createHash("sha256").update(archive).digest("hex");
const clawpack = await buildNpmPackArtifact(fixture);
const { packageDetail, versionDetail, betaStatus } = fixture.packageDetail({
sha256hash,
...clawpack,
});
const json = (response, value, status = 200) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(`${JSON.stringify(value)}\n`);
};
const artifactResolverDetail = {
package: versionDetail.package ?? {
name: packageName,
displayName: packageDetail.package?.displayName ?? "OpenClaw Kitchen Sink",
family: packageDetail.package?.family ?? "code-plugin",
},
version: versionDetail.version,
artifact: {
source: "clawhub",
artifactKind: "npm-pack",
packageName,
version: fixture.version,
artifactSha256: clawpack.clawpackSha256,
npmIntegrity: clawpack.npmIntegrity,
npmShasum: clawpack.npmShasum,
},
};
const securityDetail = {
package: artifactResolverDetail.package,
release: {
version: fixture.version,
},
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: false,
},
};
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/${fixture.version}`
) {
json(response, versionDetail);
return;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/artifact`
) {
json(response, artifactResolverDetail);
return;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/security`
) {
json(response, securityDetail);
return;
}
if (
betaStatus !== undefined &&
url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/beta`
) {
json(response, { error: "version not found" }, betaStatus ?? 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;
}
if (
url.pathname ===
`/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/artifact/download`
) {
response.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": String(clawpack.archive.length),
"X-ClawHub-Artifact-Type": "npm-pack-tarball",
"X-ClawHub-Artifact-Sha256": clawpack.clawpackSha256,
"X-ClawHub-Npm-Integrity": clawpack.npmIntegrity,
"X-ClawHub-Npm-Shasum": clawpack.npmShasum,
});
response.end(clawpack.archive);
return;
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
});
server.listen(0, "127.0.0.1", () => {
fs.writeFileSync(portFile, String(server.address().port));
});
}
main().catch(
/** @param {unknown} error */ (error) => {
console.error(error);
process.exit(1);
},
);

View File

@@ -0,0 +1,57 @@
// Shared Codex plugin install helpers for E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readJson } from "./fixtures/common.mjs";
import { readPluginInstallRecords } from "./plugin-index-sqlite.mjs";
export { readJson };
export function stateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
}
export function configPath() {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
}
export function managedNpmRoot() {
return path.join(stateDir(), "npm");
}
export function realPathMaybe(filePath) {
try {
return fs.realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
export function assertPathInside(parentPath, childPath, label) {
const parent = realPathMaybe(parentPath);
const child = realPathMaybe(childPath);
const relative = path.relative(parent, child);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} resolved outside ${parentPath}: ${child}`);
}
}
export function readInstallRecords(fallbackRecords = {}) {
return readPluginInstallRecords({ fallbackRecords });
}
export function npmProjectRootForInstalledPackage(installPath, packageName) {
const packageRoot = packageName
.split("/")
.reduce((current) => path.dirname(current), installPath);
return path.basename(packageRoot) === "node_modules"
? path.dirname(packageRoot)
: managedNpmRoot();
}
export function findPackageJson(packageName, roots) {
const packagePath = packageName.startsWith("@")
? path.join(...packageName.split("/"), "package.json")
: path.join(packageName, "package.json");
const candidates = roots.map((root) => path.join(root, "node_modules", packagePath));
return candidates.find((candidate) => fs.existsSync(candidate));
}

View File

@@ -0,0 +1,178 @@
// Client helpers for Codex media-path E2E fixtures.
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { PROTOCOL_VERSION } from "../../../../dist/gateway/protocol/index.js";
import { renderBitmapTextPngBase64 } from "../../../../test/helpers/live-image-probe.ts";
import { createGatewayWsClient } from "../../../lib/gateway-ws-client.ts";
import { resolveGatewaySuccessPayload } from "../gateway-frame-payload.mjs";
import { createJsonlRequestTailer } from "./jsonl-request-tail.mjs";
import { readPositiveIntEnv, readTcpPortEnv } from "./limits.mjs";
const portText = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const appServerLog =
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
"/tmp/openclaw-codex-media-path-app-server.jsonl";
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS", 180);
const logTailMaxBytes = readPositiveIntEnv(
"OPENCLAW_CODEX_MEDIA_PATH_LOG_TAIL_MAX_BYTES",
2 * 1024 * 1024,
);
if (!portText || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
const port = readTcpPortEnv("PORT", portText);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function sha256Base64(data) {
return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex");
}
const loggedRequests = createJsonlRequestTailer(appServerLog, {
maxReadBytes: logTailMaxBytes,
});
async function waitFor(label, predicate, timeoutMs) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = await predicate();
if (value !== undefined) {
return value;
}
await delay(50);
}
throw new Error(`timeout waiting for ${label}`);
}
async function connectGateway() {
const gatewayClient = createGatewayWsClient({
handshakeTimeoutMs: 45_000,
openTimeoutMs: 45_000,
openTimeoutMessage: "gateway ws open timeout",
url: `ws://127.0.0.1:${port}`,
});
await gatewayClient.waitOpen();
async function request(method, params, opts = {}) {
const timeoutMs = opts.timeoutMs ?? 60_000;
const response = await gatewayClient.request(method, params ?? {}, timeoutMs);
if (response.ok) {
return resolveGatewaySuccessPayload(response);
}
throw new Error(
response.error && typeof response.error === "object" && "message" in response.error
? String(response.error.message)
: "gateway request failed",
);
}
await request(
"connect",
{
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "gateway-client",
displayName: "docker-codex-media-path",
version: "1.0.0",
platform: process.platform,
mode: "backend",
},
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token },
},
{ timeoutMs: 60_000 },
);
await request("sessions.subscribe", {}, { timeoutMs: 60_000 });
return {
request,
async close() {
gatewayClient.close();
},
};
}
const gateway = await connectGateway();
function randomBitmapTextToken(length = 6) {
const alphabet = "24567ACEF";
return [...randomBytes(length)].map((byte) => alphabet[byte % alphabet.length]).join("");
}
try {
const expectedToken = randomBitmapTextToken();
const imageBase64 = renderBitmapTextPngBase64(expectedToken);
const expectedHash = sha256Base64(imageBase64);
const runId = `codex-media-path-${randomUUID()}`;
const started = Date.now();
const response = await gateway.request(
"chat.send",
{
sessionKey: "agent:main:codex-media-path-e2e",
idempotencyKey: runId,
message: "Read the code printed in the attached image. Reply only the code.",
attachments: [
{
mimeType: "image/png",
fileName: "codex-media-path-probe.png",
content: imageBase64,
},
],
originatingChannel: "codex-media-path-e2e",
originatingTo: "codex-media-path-e2e",
originatingAccountId: "codex-media-path-e2e",
},
{ timeoutMs: timeoutSeconds * 1000 },
);
assert(response?.status === "started", `chat.send did not start: ${JSON.stringify(response)}`);
const turnRequest = await waitFor(
"Codex turn/start image input",
() =>
loggedRequests.read().find((request) => {
if (request.method !== "turn/start") {
return undefined;
}
const imageInput = request.params?.input?.find?.(
(entry) => entry?.type === "image" && typeof entry.url === "string",
);
return imageInput ? request : undefined;
}),
timeoutSeconds * 1000,
);
const imageInput = turnRequest.params.input.find((entry) => entry?.type === "image");
const imageUrl = imageInput.url;
assert(
imageUrl.startsWith("data:image/png;base64,"),
`turn/start image input is not an inline PNG: ${JSON.stringify(imageInput)}`,
);
const actualBase64 = imageUrl.slice("data:image/png;base64,".length);
const actualHash = sha256Base64(actualBase64);
assert(
actualHash === expectedHash,
`forwarded PNG hash mismatch: expected ${expectedHash}, got ${actualHash}`,
);
await delay(50);
console.log(
JSON.stringify({
ok: true,
elapsedMs: Date.now() - started,
expectedToken,
imageSha256: actualHash,
}),
);
} finally {
await gateway.close();
}

View File

@@ -0,0 +1,104 @@
// Fake Codex app server used by media-path E2E scenarios.
import fs from "node:fs";
import readline from "node:readline";
const requestLog =
process.env.OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG ??
"/tmp/openclaw-codex-media-path-app-server.jsonl";
let turnCount = 0;
function appendRequest(request) {
try {
fs.appendFileSync(requestLog, `${JSON.stringify(request)}\n`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`fake Codex app-server request log write failed: ${message}\n`);
if (request?.id != null) {
sendError(request.id, `fake Codex app-server request log write failed: ${message}`);
}
return false;
}
}
function send(id, result) {
process.stdout.write(`${JSON.stringify({ id, result })}\n`);
}
function sendError(id, message) {
process.stdout.write(`${JSON.stringify({ error: { message }, id })}\n`);
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
if (!line.trim()) {
return;
}
const request = JSON.parse(line);
if (!appendRequest(request)) {
return;
}
const { id, method, params } = request;
if (method === "initialize") {
send(id, {
protocolVersion: "2",
serverInfo: { name: "openclaw-codex-media-path-e2e", version: "0.125.0" },
userAgent: "openclaw-codex-media-path-e2e/0.125.0 (Docker; test)",
});
return;
}
if (method === "thread/start") {
const now = Date.now();
send(id, {
thread: {
id: "thread-codex-media-path-e2e",
sessionId: "session-codex-media-path-e2e",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: now,
updatedAt: now,
cwd: params?.cwd ?? process.cwd(),
status: { type: "idle" },
path: null,
cliVersion: "0.125.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: params?.model ?? "gpt-5.5",
modelProvider: "openai",
serviceTier: null,
cwd: params?.cwd ?? process.cwd(),
instructionSources: [],
approvalPolicy: params?.approvalPolicy ?? "never",
approvalsReviewer: params?.approvalsReviewer ?? "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
});
return;
}
if (method === "turn/start") {
turnCount += 1;
send(id, {
turn: {
id: `turn-codex-media-path-e2e-${turnCount}`,
status: "completed",
items: [
{
type: "agentMessage",
id: `msg-codex-media-path-e2e-${turnCount}`,
text: "CODEX_MEDIA_PATH_E2E_OK",
},
],
},
});
return;
}
send(id, {});
});

View File

@@ -0,0 +1,43 @@
// Tails JSONL request logs for Codex media-path E2E assertions.
import {
createIncrementalLineReader,
resolvePositiveInteger,
} from "../incremental-line-reader.mjs";
const DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
const DEFAULT_HISTORY_LIMIT = 1024;
export function createJsonlRequestTailer(filePath, options = {}) {
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, DEFAULT_MAX_READ_BYTES);
const historyLimit = resolvePositiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
const reader = createIncrementalLineReader(filePath, { maxReadBytes });
let requests = [];
function parseLine(line) {
try {
return JSON.parse(line);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`invalid app-server JSONL at ${filePath}: ${message}`, { cause: error });
}
}
return {
read() {
const { lines, reset } = reader.readLines();
if (reset) {
requests = [];
}
for (const line of lines) {
if (!line.trim()) {
continue;
}
requests.push(parseLine(line));
}
if (requests.length > historyLimit) {
requests = requests.slice(-historyLimit);
}
return requests;
},
};
}

View File

@@ -0,0 +1,21 @@
// Limits shared by Codex media-path E2E fixtures.
export function readPositiveIntEnv(name, fallback, env = process.env) {
const text = String(env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readTcpPortEnv(name, fallback, env = process.env) {
const value = readPositiveIntEnv(name, fallback, env);
if (value > 65_535) {
const text = String(env[name] ?? fallback).trim();
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export OPENCLAW_SKIP_CHANNELS=1
export OPENCLAW_SKIP_GMAIL_WATCHER=1
export OPENCLAW_SKIP_CRON=1
export OPENCLAW_SKIP_CANVAS_HOST=1
export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1
export OPENCLAW_SKIP_ACPX_RUNTIME=1
export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
export OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG="/tmp/openclaw-codex-media-path-app-server.jsonl"
PORT="${PORT:?missing PORT}"
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
PLUGIN_SPEC="${OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC:?missing OPENCLAW_CODEX_MEDIA_PATH_PLUGIN_SPEC}"
GATEWAY_LOG="/tmp/openclaw-codex-media-path-gateway.log"
CLIENT_LOG="/tmp/openclaw-codex-media-path-client.log"
PLUGIN_INSTALL_LOG="/tmp/openclaw-codex-media-path-plugin-install.log"
PLUGIN_INSPECT_LOG="/tmp/openclaw-codex-media-path-plugin-inspect.json"
gateway_pid=""
cleanup() {
openclaw_e2e_stop_process "$gateway_pid"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "Codex media-path Docker E2E failed with exit code $status" >&2
openclaw_e2e_dump_logs "$PLUGIN_INSTALL_LOG" "$PLUGIN_INSPECT_LOG" "$GATEWAY_LOG" "$CLIENT_LOG" "$OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"
rm -f "$OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG"
openclaw_e2e_enable_openclaw_cli_timeout
echo "Installing Codex plugin: $PLUGIN_SPEC"
openclaw plugins install "$PLUGIN_SPEC" --force >"$PLUGIN_INSTALL_LOG" 2>&1
openclaw plugins inspect codex --runtime --json >"$PLUGIN_INSPECT_LOG"
node scripts/e2e/lib/codex-media-path/write-config.mjs
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 480 "$PORT"
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" \
tsx scripts/e2e/lib/codex-media-path/client.mjs >"$CLIENT_LOG" 2>&1
openclaw_e2e_print_log "$CLIENT_LOG"
echo "Codex media-path Docker E2E passed"

View File

@@ -0,0 +1,79 @@
// Writes config fixtures for Codex media-path E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readPositiveIntEnv, readTcpPortEnv } from "./limits.mjs";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
const configPath = requireEnv("OPENCLAW_CONFIG_PATH");
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
const token = requireEnv("OPENCLAW_GATEWAY_TOKEN");
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_CODEX_MEDIA_PATH_TIMEOUT_SECONDS", 180);
const gatewayPort = readTcpPortEnv("PORT", 18790);
const config = {
gateway: {
port: gatewayPort,
bind: "loopback",
auth: { mode: "token", token },
controlUi: { enabled: false },
},
plugins: {
enabled: true,
allow: ["codex"],
entries: {
codex: {
enabled: true,
config: {
appServer: {
mode: "yolo",
command: "node",
args: ["scripts/e2e/lib/codex-media-path/fake-codex-app-server.mjs"],
requestTimeoutMs: timeoutSeconds * 1000,
turnCompletionIdleTimeoutMs: timeoutSeconds * 1000,
},
},
},
},
},
agents: {
defaults: {
model: { primary: "codex/gpt-5.5", fallbacks: [] },
models: {
"codex/gpt-5.5": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
skipBootstrap: true,
timeoutSeconds,
sandbox: { mode: "off" },
},
list: [
{
id: "main",
default: true,
model: { primary: "codex/gpt-5.5", fallbacks: [] },
models: {
"codex/gpt-5.5": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
},
],
},
skills: { allowBundled: [] },
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true });

View File

@@ -0,0 +1,518 @@
// Assertions for Codex npm plugin live E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
import {
assertPathInside,
configPath,
findPackageJson,
managedNpmRoot,
npmProjectRootForInstalledPackage,
readInstallRecords,
readJson,
realPathMaybe,
stateDir,
} from "../codex-install-utils.mjs";
const command = process.argv[2];
const allowBetaCompatDiagnostics =
process.env.OPENCLAW_CODEX_NPM_PLUGIN_ALLOW_BETA_COMPAT_DIAGNOSTICS === "1";
const MAX_TEXT_FILE_BYTES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES",
1024 * 1024,
);
const MAX_ERROR_TAIL_BYTES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_ERROR_TAIL_BYTES",
64 * 1024,
);
const MAX_TRANSCRIPT_FILES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_FILES",
64,
);
const MAX_TRANSCRIPT_WALK_ENTRIES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_WALK_ENTRIES",
4096,
);
const MAX_TRANSCRIPT_SCAN_BYTES = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TRANSCRIPT_SCAN_BYTES",
2 * 1024 * 1024,
);
const AGENT_TURN_TIMEOUT_SECONDS = readPositiveIntEnv(
"OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS",
420,
);
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
return value;
}
function readTextFileBounded(filePath, label, maxBytes = MAX_TEXT_FILE_BYTES) {
const stat = fs.statSync(filePath);
if (stat.size > maxBytes) {
throw new Error(`${label} exceeded ${maxBytes} bytes: ${filePath}`);
}
return fs.readFileSync(filePath, "utf8");
}
function readTextFileTail(filePath, label, maxBytes = MAX_ERROR_TAIL_BYTES) {
if (!fs.existsSync(filePath)) {
return "";
}
const stat = fs.statSync(filePath);
if (stat.size <= maxBytes) {
return fs.readFileSync(filePath, "utf8");
}
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(maxBytes);
fs.readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
return `[${label} truncated to last ${maxBytes} bytes]\n${buffer.toString("utf8")}`;
} finally {
fs.closeSync(fd);
}
}
function configure() {
const modelRef = process.argv[3] || "codex/gpt-5.4";
const state = stateDir();
const cfgPath = configPath();
const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {};
cfg.plugins = {
...cfg.plugins,
enabled: true,
allow: Array.from(new Set([...(cfg.plugins?.allow || []), "codex"])).toSorted((left, right) =>
left.localeCompare(right),
),
entries: {
...cfg.plugins?.entries,
codex: {
...cfg.plugins?.entries?.codex,
enabled: true,
config: {
...cfg.plugins?.entries?.codex?.config,
discovery: { enabled: false },
appServer: {
...cfg.plugins?.entries?.codex?.config?.appServer,
mode: "yolo",
approvalPolicy: "never",
sandbox: "danger-full-access",
requestTimeoutMs: AGENT_TURN_TIMEOUT_SECONDS * 1000,
},
},
},
},
};
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: { primary: modelRef, fallbacks: [] },
models: {
...cfg.agents?.defaults?.models,
[modelRef]: { agentRuntime: { id: "codex" } },
},
workspace: path.join(state, "workspace"),
skipBootstrap: true,
timeoutSeconds: AGENT_TURN_TIMEOUT_SECONDS,
},
};
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
}
function readInstallRecord() {
const record = readInstallRecords().codex;
if (!record) {
throw new Error("missing codex install record");
}
return record;
}
function normalizePluginSpec(spec) {
if (spec.startsWith("npm:")) {
return {
expectedSpec: spec.slice("npm:".length),
source: "npm",
};
}
if (spec.startsWith("npm-pack:")) {
return {
artifactKind: "npm-pack",
source: "npm",
sourcePath: spec.slice("npm-pack:".length),
};
}
if (spec.startsWith("git:")) {
return {
expectedSpec: spec,
source: "git",
};
}
return {
expectedSpec: spec,
source: "npm",
};
}
function assertPlugin() {
const spec = process.argv[3] || "npm:@openclaw/codex";
const list = readJson("/tmp/openclaw-codex-plugins-list.json");
const inspect = readJson("/tmp/openclaw-codex-plugin-inspect.json");
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
if (!plugin) {
throw new Error("codex plugin not found in plugins list --json output");
}
if (plugin.status !== "loaded" || plugin.enabled !== true) {
throw new Error(
`expected codex to be enabled+loaded, got enabled=${plugin.enabled} status=${plugin.status}`,
);
}
if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") {
throw new Error(`unexpected inspect plugin state: ${JSON.stringify(inspect.plugin)}`);
}
if (
!Array.isArray(inspect.plugin?.providerIds) ||
!inspect.plugin.providerIds.includes("codex")
) {
throw new Error(`codex provider was not registered: ${JSON.stringify(inspect.plugin)}`);
}
const hasCodexHarness =
(Array.isArray(inspect.plugin?.agentHarnessIds) &&
inspect.plugin.agentHarnessIds.includes("codex")) ||
(Array.isArray(inspect.capabilities) &&
inspect.capabilities.some(
(entry) => entry?.kind === "agent-harness" && entry.ids?.includes("codex"),
));
if (!hasCodexHarness) {
throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`);
}
const diagnostics = [...(list.diagnostics || []), ...(inspect.diagnostics || [])];
const errors = diagnostics
.filter((diag) => diag?.level === "error")
.map((diag) => String(diag.message || ""));
const unexpectedErrors = allowBetaCompatDiagnostics
? errors.filter(
(message) => message !== "only bundled plugins can claim reserved command ownership: codex",
)
: errors;
if (unexpectedErrors.length > 0) {
throw new Error(`unexpected plugin diagnostics errors: ${unexpectedErrors.join("; ")}`);
}
const record = readInstallRecord();
const expected = normalizePluginSpec(spec);
if (record.source !== expected.source) {
throw new Error(
`expected codex ${expected.source} install record, got source=${record.source}`,
);
}
if (expected.expectedSpec && record.spec !== expected.expectedSpec) {
throw new Error(`expected codex install spec ${expected.expectedSpec}, got ${record.spec}`);
}
if (expected.artifactKind && record.artifactKind !== expected.artifactKind) {
throw new Error(
`expected codex artifact kind ${expected.artifactKind}, got ${record.artifactKind}`,
);
}
if (
expected.sourcePath &&
realPathMaybe(record.sourcePath || "") !== realPathMaybe(expected.sourcePath)
) {
throw new Error(`expected codex source path ${expected.sourcePath}, got ${record.sourcePath}`);
}
if (record.source === "npm" && (!record.resolvedVersion || !record.resolvedSpec)) {
throw new Error(`missing codex npm resolution metadata: ${JSON.stringify(record)}`);
}
if (record.source === "git" && !record.gitCommit) {
throw new Error(`missing codex git resolution metadata: ${JSON.stringify(record)}`);
}
}
function codexInstallPath() {
const record = readInstallRecord();
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
throw new Error(`missing codex installPath: ${JSON.stringify(record)}`);
}
return record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME);
}
function codexNpmProjectRoot() {
return npmProjectRootForInstalledPackage(codexInstallPath(), "@openclaw/codex");
}
function findCodexPackageJson(packageName) {
const projectRoot = codexNpmProjectRoot();
return findPackageJson(packageName, [projectRoot, codexInstallPath(), managedNpmRoot()]);
}
function assertNpmDeps() {
const npmRoot = managedNpmRoot();
const installPath = codexInstallPath();
const pluginPackageJson = path.join(installPath, "package.json");
if (!fs.existsSync(pluginPackageJson)) {
throw new Error(`missing npm-installed @openclaw/codex package.json: ${pluginPackageJson}`);
}
assertPathInside(npmRoot, installPath, "codex plugin install path");
assertPathInside(npmRoot, pluginPackageJson, "codex plugin package");
const pluginPackage = readJson(pluginPackageJson);
if (pluginPackage.name !== "@openclaw/codex") {
throw new Error(`unexpected codex package name: ${pluginPackage.name}`);
}
const openAiCodexPackageJson = findCodexPackageJson("@openai/codex");
if (!openAiCodexPackageJson) {
throw new Error("missing @openai/codex dependency under .openclaw/npm");
}
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
const bin = resolveCodexBin();
if (!fs.existsSync(bin)) {
throw new Error(`missing managed Codex binary: ${bin}`);
}
assertPathInside(npmRoot, bin, "managed Codex binary");
}
function resolveCodexBin() {
const commandName = process.platform === "win32" ? "codex.cmd" : "codex";
const candidates = [
path.join(codexNpmProjectRoot(), "node_modules", ".bin", commandName),
path.join(codexInstallPath(), "node_modules", ".bin", commandName),
path.join(managedNpmRoot(), "node_modules", ".bin", commandName),
];
const candidate = candidates.find((entry) => fs.existsSync(entry));
if (candidate) {
return candidate;
}
const packageJson = findCodexPackageJson("@openai/codex");
if (!packageJson) {
throw new Error("cannot resolve Codex binary without @openai/codex package");
}
const packageRoot = path.dirname(packageJson);
const pkg = readJson(packageJson);
const binPath =
typeof pkg.bin === "string"
? pkg.bin
: pkg.bin && typeof pkg.bin.codex === "string"
? pkg.bin.codex
: undefined;
if (!binPath) {
throw new Error(`@openai/codex package has no codex bin: ${packageJson}`);
}
return path.resolve(packageRoot, binPath);
}
function printCodexBin() {
assertNpmDeps();
process.stdout.write(`${resolveCodexBin()}\n`);
}
function assertPreflight() {
const marker = process.argv[3];
const output = readTextFileBounded("/tmp/openclaw-codex-preflight.log", "Codex preflight log");
if (!output.includes(marker)) {
throw new Error(`Codex CLI preflight did not contain ${marker}:\n${output}`);
}
}
function listFilesRecursive(root) {
if (!fs.existsSync(root)) {
return [];
}
const files = [];
const stack = [root];
let visited = 0;
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
visited += 1;
if (visited > MAX_TRANSCRIPT_WALK_ENTRIES) {
throw new Error(
`native Codex session transcript walk exceeded ${MAX_TRANSCRIPT_WALK_ENTRIES} entries under ${root}`,
);
}
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
return files;
}
function assertNativeCodexSessionEvidence(params) {
const roots = params.roots.filter((root) => fs.existsSync(root));
const files = roots
.flatMap((root) => listFilesRecursive(root).filter((filePath) => filePath.endsWith(".jsonl")))
.map((filePath) => ({ filePath, stat: fs.statSync(filePath) }))
.toSorted((left, right) => right.stat.mtimeMs - left.stat.mtimeMs)
.slice(0, MAX_TRANSCRIPT_FILES);
if (files.length === 0) {
throw new Error(
`missing native Codex session transcript files; checked ${params.roots.join(", ")}`,
);
}
let scannedBytes = 0;
const matchingFile = files.find(({ filePath, stat }) => {
const readableBytes = Math.min(stat.size, MAX_TEXT_FILE_BYTES);
if (scannedBytes + readableBytes > MAX_TRANSCRIPT_SCAN_BYTES) {
return false;
}
scannedBytes += readableBytes;
const content = readTextFileTail(filePath, "native Codex session transcript", readableBytes);
return content.includes(params.marker) || content.includes(params.threadId);
})?.filePath;
if (!matchingFile) {
throw new Error(
`native Codex session transcripts did not contain ${params.marker} or ${params.threadId}; scanned ${scannedBytes} bytes across ${files.length} newest files: ${files.map((entry) => entry.filePath).join(", ")}`,
);
}
assertPathInside(params.codexHome, matchingFile, "native Codex session transcript");
}
function assertAgentTurn() {
const marker = process.argv[3];
const sessionId = process.argv[4];
const modelRef = process.argv[5];
const stdout = readTextFileBounded("/tmp/openclaw-codex-agent.json", "OpenClaw agent JSON");
const stderr = readTextFileTail("/tmp/openclaw-codex-agent.err", "OpenClaw agent stderr");
const response = JSON.parse(stdout);
const text = extractAgentReplyTexts(JSON.stringify(response)).join("\n");
if (!text.includes(marker)) {
throw new Error(
`OpenClaw agent reply did not contain ${marker}:\nstdout=${stdout}\nstderr=${stderr}`,
);
}
const expectedProvider = modelRef.split("/")[0] || "codex";
const executionTrace = response.meta?.executionTrace;
if (!executionTrace || executionTrace.winnerProvider !== expectedProvider) {
throw new Error(
`expected Codex plugin model provider ${expectedProvider} to win the agent turn, got ${JSON.stringify(executionTrace)}`,
);
}
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
const storePath = path.join(sessionsDir, "sessions.json");
const store = readJson(storePath);
const entry = Object.values(store).find((candidate) => candidate?.sessionId === sessionId);
if (!entry) {
throw new Error(`missing session store entry for ${sessionId}: ${JSON.stringify(store)}`);
}
if (entry.agentHarnessId !== "codex") {
throw new Error(`expected codex harness in session entry, got ${entry.agentHarnessId}`);
}
if (entry.modelOverride && entry.modelOverride !== modelRef) {
throw new Error(`unexpected session model override: ${entry.modelOverride}`);
}
if (typeof entry.sessionFile !== "string" || !fs.existsSync(entry.sessionFile)) {
throw new Error(`missing OpenClaw session file: ${entry.sessionFile}`);
}
const bindingPath = `${entry.sessionFile}.codex-app-server.json`;
const binding = readJson(bindingPath);
if (![1, 2].includes(binding.schemaVersion) || typeof binding.threadId !== "string") {
throw new Error(`invalid Codex app-server binding: ${JSON.stringify(binding)}`);
}
if (binding.model !== modelRef.split("/").slice(1).join("/")) {
throw new Error(`unexpected Codex binding model: ${binding.model}`);
}
if (binding.modelProvider && !["codex", "openai"].includes(binding.modelProvider)) {
throw new Error(`unexpected Codex binding provider: ${binding.modelProvider}`);
}
const agentDir = path.join(stateDir(), "agents", "main");
const codexHomes = [
path.join(agentDir, "codex-home"),
path.join(agentDir, "agent", "codex-home"),
path.join(path.dirname(agentDir), "codex-home"),
].filter((entryValue, index, entries) => entries.indexOf(entryValue) === index);
const codexHome = codexHomes.find((entryLocal) => fs.existsSync(entryLocal));
if (!codexHome) {
throw new Error(`missing isolated Codex home; checked ${codexHomes.join(", ")}`);
}
const codexSessionRoot = path.join(codexHome, "sessions");
const nativeSessionRoot = path.join(codexHome, "home", ".codex", "sessions");
assertNativeCodexSessionEvidence({
codexHome,
marker,
roots: [codexSessionRoot, nativeSessionRoot],
threadId: binding.threadId,
});
}
function assertUninstalled() {
const records = readInstallRecords();
if (records.codex) {
throw new Error(
`codex install record still exists after uninstall: ${JSON.stringify(records.codex)}`,
);
}
const list = readJson("/tmp/openclaw-codex-plugins-list-after-uninstall.json");
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
if (plugin?.status === "loaded" || plugin?.enabled === true) {
throw new Error(`codex plugin still loaded/enabled after uninstall: ${JSON.stringify(plugin)}`);
}
const diagnostics = list.diagnostics || [];
const errors = diagnostics
.filter((diag) => diag?.level === "error")
.map((diag) => String(diag.message || ""));
if (errors.length > 0) {
throw new Error(`unexpected plugin diagnostics errors after uninstall: ${errors.join("; ")}`);
}
}
function assertAgentError() {
const status = Number(process.argv[3]);
if (!Number.isInteger(status) || status === 0) {
throw new Error(
`expected OpenClaw agent to fail after Codex uninstall, got status ${process.argv[3]}`,
);
}
const stdout = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.json")
? readTextFileTail(
"/tmp/openclaw-codex-agent-after-uninstall.json",
"post-uninstall agent stdout",
)
: "";
const stderr = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.err")
? readTextFileTail(
"/tmp/openclaw-codex-agent-after-uninstall.err",
"post-uninstall agent stderr",
)
: "";
const combined = `${stdout}\n${stderr}`;
if (
!combined.includes('Requested agent harness "codex" is not registered') &&
!combined.includes("Unknown model: codex/")
) {
throw new Error(`unexpected post-uninstall agent error:\nstdout=${stdout}\nstderr=${stderr}`);
}
}
const commands = {
configure,
"assert-plugin": assertPlugin,
"assert-npm-deps": assertNpmDeps,
"print-codex-bin": printCodexBin,
"assert-preflight": assertPreflight,
"assert-agent-turn": assertAgentTurn,
"assert-uninstalled": assertUninstalled,
"assert-agent-error": assertAgentError,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown codex npm plugin live assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,127 @@
// Assertions for Codex on-demand plugin E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
import {
assertPathInside,
configPath,
findPackageJson,
managedNpmRoot,
npmProjectRootForInstalledPackage,
readInstallRecords,
readJson,
stateDir,
} from "../codex-install-utils.mjs";
const cfg = readJson(configPath());
const inspect = readJson("/tmp/openclaw-codex-inspect.json");
const records = readInstallRecords(cfg.plugins?.installs);
const codexRecord = records.codex || inspect.install;
if (!codexRecord) {
throw new Error(`missing codex install record: ${JSON.stringify(records)}`);
}
if (codexRecord.source !== "npm") {
throw new Error(`expected npm codex install record, got ${codexRecord.source}`);
}
if (!String(codexRecord.spec || "").includes("@openclaw/codex")) {
throw new Error(`expected @openclaw/codex install spec, got ${codexRecord.spec}`);
}
const npmRoot = managedNpmRoot();
const installPath = String(codexRecord.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME);
if (!installPath) {
throw new Error(`missing codex installPath: ${JSON.stringify(codexRecord)}`);
}
assertPathInside(npmRoot, installPath, "codex install path");
const codexPackageJson = path.join(installPath, "package.json");
if (!fs.existsSync(codexPackageJson)) {
throw new Error(`missing npm-installed @openclaw/codex package: ${codexPackageJson}`);
}
const codexPackage = readJson(codexPackageJson);
if (codexPackage.name !== "@openclaw/codex") {
throw new Error(`unexpected codex package name: ${codexPackage.name}`);
}
const npmProjectRoot = npmProjectRootForInstalledPackage(installPath, "@openclaw/codex");
const openAiCodexPackageJson = findPackageJson("@openai/codex", [
installPath,
npmProjectRoot,
npmRoot,
]);
if (!openAiCodexPackageJson) {
throw new Error("missing @openai/codex dependency under managed npm root");
}
assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency");
const openAiCodexPackage = readJson(openAiCodexPackageJson);
const codexBinPath =
typeof openAiCodexPackage.bin === "string"
? openAiCodexPackage.bin
: openAiCodexPackage.bin && typeof openAiCodexPackage.bin.codex === "string"
? openAiCodexPackage.bin.codex
: undefined;
if (!codexBinPath) {
throw new Error(`@openai/codex package has no codex bin: ${openAiCodexPackageJson}`);
}
const codexBin = path.resolve(path.dirname(openAiCodexPackageJson), codexBinPath);
if (!fs.existsSync(codexBin)) {
throw new Error(`missing managed Codex binary: ${codexBin}`);
}
assertPathInside(npmRoot, codexBin, "managed Codex binary");
const list = readJson("/tmp/openclaw-plugins-list.json");
const plugin = (list.plugins || []).find((entry) => entry.id === "codex");
if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") {
throw new Error(`codex plugin was not enabled+loaded: ${JSON.stringify(plugin)}`);
}
if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") {
throw new Error(`unexpected codex inspect state: ${JSON.stringify(inspect.plugin)}`);
}
const hasHarness =
(Array.isArray(inspect.plugin?.agentHarnessIds) &&
inspect.plugin.agentHarnessIds.includes("codex")) ||
(Array.isArray(inspect.capabilities) &&
inspect.capabilities.some(
(entry) => entry?.kind === "agent-harness" && entry.ids?.includes("codex"),
));
if (!hasHarness) {
throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`);
}
const primaryModel = cfg.agents?.defaults?.model?.primary;
if (primaryModel !== "openai/gpt-5.5") {
throw new Error(`expected OpenAI onboarding model openai/gpt-5.5, got ${primaryModel}`);
}
const providerRuntime = cfg.models?.providers?.openai?.agentRuntime?.id;
if (providerRuntime && providerRuntime !== "codex") {
throw new Error(`unexpected OpenAI provider runtime: ${providerRuntime}`);
}
function readAuthProfileStoreText(agentDir) {
const dbPath = path.join(agentDir, "openclaw-agent.sqlite");
if (!fs.existsSync(dbPath)) {
throw new Error("auth profile SQLite store was not persisted");
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
.get("primary");
return typeof row?.store_json === "string" ? row.store_json : "";
} finally {
db?.close();
}
}
const authRaw = readAuthProfileStoreText(path.join(stateDir(), "agents", "main", "agent"));
if (!authRaw) {
throw new Error("auth profile SQLite store row was not persisted");
}
assertOpenAiEnvAuthProfileStore(authRaw, {
envRefMessage: "auth profile did not persist OPENAI_API_KEY env ref",
rawKeyMessage: "auth profile persisted the raw OpenAI test key",
rawKeyNeedle: "sk-openclaw-codex-on-demand-e2e",
});

View File

@@ -0,0 +1,39 @@
// Log assertions for config reload E2E scenarios.
import { sleep } from "../../../lib/sleep.mjs";
import { readPositiveIntEnv } from "../env-limits.mjs";
import { createConfigReloadLogScanner } from "./log-scanner.mjs";
const logPath = process.env.OPENCLAW_CONFIG_RELOAD_LOG_PATH ?? "/tmp/config-reload-e2e.log";
const deadlineMs = Date.now() + readPositiveIntEnv("OPENCLAW_CONFIG_RELOAD_LOG_TIMEOUT_MS", 30_000);
const maxReadBytes = readPositiveIntEnv("OPENCLAW_CONFIG_RELOAD_LOG_MAX_READ_BYTES", 256 * 1024);
const scanner = createConfigReloadLogScanner(logPath, {
maxReadBytes,
tailLineLimit: 160,
});
let result = { reloadLines: [], restartLines: [], tailLines: [] };
while (Date.now() < deadlineMs) {
result = scanner.scan();
if (result.restartLines.length > 0 || result.reloadLines.length > 0) {
break;
}
await sleep(500);
}
if (result.restartLines.length > 0) {
console.error(result.tailLines.join("\n"));
throw new Error("unexpected restart-required reload line found");
}
for (const line of result.reloadLines) {
for (const needle of ["gateway.auth.token", "plugins.entries.firecrawl.config.webFetch"]) {
if (line.includes(needle)) {
console.error(result.tailLines.join("\n"));
throw new Error(`runtime-only path appeared in reload diff: ${needle}`);
}
}
}
if (result.reloadLines.length === 0) {
console.error(result.tailLines.join("\n"));
throw new Error("expected config reload detection log after metadata write");
}

View File

@@ -0,0 +1,52 @@
// Streaming log scanner for config reload E2E scenarios.
import {
createIncrementalLineReader,
resolvePositiveInteger,
} from "../incremental-line-reader.mjs";
const DEFAULT_MAX_READ_BYTES = 256 * 1024;
const DEFAULT_TAIL_LINE_LIMIT = 160;
const RELOAD_NEEDLE = "config change detected; evaluating reload";
const RESTART_NEEDLE = "config change requires gateway restart";
export function inspectConfigReloadLogLine(line) {
return {
reload: line.includes(RELOAD_NEEDLE),
restart: line.includes(RESTART_NEEDLE),
};
}
export function createConfigReloadLogScanner(logPath, options = {}) {
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, DEFAULT_MAX_READ_BYTES);
const tailLineLimit = resolvePositiveInteger(options.tailLineLimit, DEFAULT_TAIL_LINE_LIMIT);
const reader = createIncrementalLineReader(logPath, { maxReadBytes });
let tailLines = [];
const reloadLines = [];
const restartLines = [];
return {
scan() {
const { lines, reset } = reader.readLines();
if (reset) {
tailLines = [];
reloadLines.length = 0;
restartLines.length = 0;
}
for (const line of lines) {
const trimmed = line.replace(/\r$/u, "");
tailLines.push(trimmed);
const match = inspectConfigReloadLogLine(trimmed);
if (match.reload) {
reloadLines.push(trimmed);
}
if (match.restart) {
restartLines.push(trimmed);
}
}
if (tailLines.length > tailLineLimit) {
tailLines = tailLines.slice(-tailLineLimit);
}
return { reloadLines, restartLines, tailLines };
},
};
}

View File

@@ -0,0 +1,7 @@
// Mutates plugin metadata fixtures for config reload E2E scenarios.
import fs from "node:fs";
const configPath = process.env.OPENCLAW_CONFIG_PATH;
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
config.gateway.channelHealthCheckMinutes = 2;
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");

View File

@@ -0,0 +1,143 @@
// Resource ceiling assertions for Docker E2E stats output.
import fs from "node:fs";
import { createInterface } from "node:readline";
const [statsFile, maxMemoryRaw, maxCpuRaw, label = "docker"] = process.argv.slice(2);
const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u;
function parseFiniteLimit(raw, name) {
const text = String(raw ?? "").trim();
if (!NON_NEGATIVE_DECIMAL_PATTERN.test(text)) {
throw new Error(
`${name} must be a finite non-negative number in decimal notation. Got: ${JSON.stringify(raw)}`,
);
}
const parsed = Number(text);
if (!Number.isFinite(parsed)) {
throw new Error(
`${name} must be a finite non-negative number in decimal notation. Got: ${JSON.stringify(raw)}`,
);
}
return parsed;
}
const maxMemoryMiB = parseFiniteLimit(maxMemoryRaw, "max memory MiB");
const maxCpuPercent = parseFiniteLimit(maxCpuRaw, "max CPU percent");
function parseMemoryMiB(raw) {
const value =
String(raw || "")
.split("/")[0]
?.trim() || "";
const match = /^([0-9.]+)\s*([KMGT]?i?B)$/iu.exec(value);
if (!match) {
return undefined;
}
const amount = Number(match[1]);
if (!Number.isFinite(amount)) {
return undefined;
}
const unit = match[2].toLowerCase();
if (unit === "b") {
return amount / 1024 / 1024;
}
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 undefined;
}
function parseCpuPercent(raw) {
const text = String(raw ?? "").trim();
const valueText = text.endsWith("%") ? text.slice(0, -1).trim() : text;
if (!NON_NEGATIVE_DECIMAL_PATTERN.test(valueText)) {
return undefined;
}
const parsed = Number(valueText);
return Number.isFinite(parsed) ? parsed : undefined;
}
function isTerminalZeroMemorySample(raw) {
const parts = String(raw || "").split("/");
if (parts.length !== 2) {
return false;
}
return parts.every((part) => parseMemoryMiB(part.trim()) === 0);
}
function assertSampleValue(value, raw, name, labelLocal) {
if (value === undefined) {
throw new Error(
`docker stats sample for ${labelLocal} had invalid ${name}: ${JSON.stringify(raw)}`,
);
}
if (name === "MemUsage" && value <= 0) {
throw new Error(
`docker stats sample for ${labelLocal} had non-positive ${name}: ${JSON.stringify(raw)}`,
);
}
}
async function scanStatsFileLines(file, onLine) {
if (!fs.existsSync(file)) {
return;
}
const input = fs.createReadStream(file, { encoding: "utf8" });
const lines = createInterface({ crlfDelay: Infinity, input });
for await (const line of lines) {
if (line) {
onLine(line);
}
}
}
let maxObservedMemoryMiB = 0;
let maxObservedCpuPercent = 0;
let parsedSamples = 0;
await scanStatsFileLines(statsFile, (line) => {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
throw new Error(`docker stats sample for ${label} was not valid JSON`);
}
const observedMemoryMiB = parseMemoryMiB(parsed.MemUsage);
const observedCpuPercent = parseCpuPercent(parsed.CPUPerc);
// Docker can emit 0B / 0B after the target container exits; it proves
// lifecycle timing, not resource usage. Keep the real captured samples.
if (isTerminalZeroMemorySample(parsed.MemUsage)) {
return;
}
assertSampleValue(observedMemoryMiB, parsed.MemUsage, "MemUsage", label);
assertSampleValue(observedCpuPercent, parsed.CPUPerc, "CPUPerc", label);
parsedSamples += 1;
maxObservedMemoryMiB = Math.max(maxObservedMemoryMiB, observedMemoryMiB);
maxObservedCpuPercent = Math.max(maxObservedCpuPercent, observedCpuPercent);
});
console.log(
`${label} resource peak: memory=${maxObservedMemoryMiB.toFixed(1)}MiB cpu=${maxObservedCpuPercent.toFixed(1)}% samples=${parsedSamples}`,
);
if (parsedSamples === 0) {
throw new Error(`no docker stats samples captured for ${label}`);
}
if (maxObservedMemoryMiB > maxMemoryMiB) {
throw new Error(
`${label} memory peak ${maxObservedMemoryMiB.toFixed(1)}MiB exceeded ${maxMemoryMiB}MiB`,
);
}
if (maxObservedCpuPercent > maxCpuPercent) {
throw new Error(
`${label} CPU peak ${maxObservedCpuPercent.toFixed(1)}% exceeded ${maxCpuPercent}%`,
);
}

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"
# Keep logs focused; the npm global install step can emit noisy deprecation warnings.
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1
# Stub systemd/loginctl so doctor + daemon flows work in Docker.
export PATH="/tmp/openclaw-bin:$PATH"
mkdir -p /tmp/openclaw-bin
cp scripts/e2e/lib/doctor-install-switch/shims/systemctl /tmp/openclaw-bin/systemctl
cp scripts/e2e/lib/doctor-install-switch/shims/loginctl /tmp/openclaw-bin/loginctl
chmod +x /tmp/openclaw-bin/systemctl /tmp/openclaw-bin/loginctl
package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}"
git_root="/tmp/openclaw-git"
mkdir -p "$git_root"
# The git-style install fixture is unpacked from the tarball so this lane does
# not depend on checkout source files being present in the Docker image.
tar -xzf "$package_tgz" -C "$git_root" --strip-components=1
(
cd "$git_root"
openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install --omit=optional --no-fund --no-audit >/tmp/openclaw-git-install.log 2>&1
git init -q
git config user.email "docker-e2e@openclaw.local"
git config user.name "OpenClaw Docker E2E"
git add -A --
git commit -qm "test fixture"
)
npm_log="/tmp/openclaw-doctor-switch-npm-install.log"
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$package_tgz" >"$npm_log" 2>&1; then
openclaw_e2e_print_log "$npm_log"
exit 1
fi
npm_bin="/tmp/npm-prefix/bin/openclaw"
npm_root="/tmp/npm-prefix/lib/node_modules/openclaw"
if [ -f "$npm_root/dist/index.mjs" ]; then
npm_entry="$npm_root/dist/index.mjs"
else
npm_entry="$npm_root/dist/index.js"
fi
if [ -f "$git_root/dist/index.mjs" ]; then
git_entry="$git_root/dist/index.mjs"
else
git_entry="$git_root/dist/index.js"
fi
git_cli="$git_root/openclaw.mjs"
package_version="$(node -p "require(\"$npm_root/package.json\").version")"
is_legacy_package_acceptance_compat() {
[ "$(node scripts/e2e/lib/package-compat.mjs "$1")" = "1" ]
}
assert_entrypoint() {
local unit_path="$1"
local expected="$2"
local exec_line=""
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
if [ -z "$exec_line" ]; then
echo "Missing ExecStart in $unit_path"
exit 1
fi
exec_line="${exec_line#ExecStart=}"
entrypoint=$(echo "$exec_line" | awk "{print \$2}")
entrypoint="${entrypoint%\"}"
entrypoint="${entrypoint#\"}"
if [ "$entrypoint" != "$expected" ]; then
echo "Expected entrypoint $expected, got $entrypoint"
exit 1
fi
}
assert_exec_arg() {
local unit_path="$1"
local index="$2"
local expected="$3"
local exec_line=""
local actual=""
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
if [ -z "$exec_line" ]; then
echo "Missing ExecStart in $unit_path"
exit 1
fi
exec_line="${exec_line#ExecStart=}"
actual=$(echo "$exec_line" | awk -v field="$index" "{print \$field}")
actual="${actual%\"}"
actual="${actual#\"}"
if [ "$actual" != "$expected" ]; then
echo "Expected ExecStart arg $index to be $expected, got $actual"
cat "$unit_path"
exit 1
fi
}
assert_env_value() {
local unit_path="$1"
local key="$2"
local expected="$3"
if ! grep -Fxq "Environment=${key}=${expected}" "$unit_path"; then
echo "Expected Environment=${key}=${expected} in $unit_path"
cat "$unit_path"
exit 1
fi
}
assert_no_env_key() {
local unit_path="$1"
local key="$2"
if grep -q "^Environment=${key}=" "$unit_path"; then
echo "Expected no Environment=${key}= line in $unit_path"
cat "$unit_path"
exit 1
fi
}
# Each flow: install service with one variant, run doctor from the other,
# and verify ExecStart entrypoint switches accordingly.
run_flow() {
local name="$1"
local install_cmd="$2"
local install_expected="$3"
local doctor_cmd="$4"
local doctor_expected="$5"
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log"
local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
export USER="testuser"
if ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd" >"$install_log" 2>&1; then
openclaw_e2e_print_log "$install_log"
exit 1
fi
rm -f "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"
rm -rf "$HOME/.config/fish" "$HOME/.config/powershell"
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
if [ ! -f "$unit_path" ]; then
echo "Missing unit file: $unit_path"
exit 1
fi
assert_entrypoint "$unit_path" "$install_expected"
if ! openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$doctor_cmd" >"$doctor_log" 2>&1; then
openclaw_e2e_print_log "$doctor_log"
exit 1
fi
assert_entrypoint "$unit_path" "$doctor_expected"
}
run_flow \
"npm-to-git" \
"$npm_bin daemon install --force" \
"$npm_entry" \
"OPENCLAW_UPDATE_IN_PROGRESS=1 node $git_cli doctor --repair --force --yes --non-interactive" \
"$git_entry"
run_flow \
"git-to-npm" \
"node $git_cli daemon install --force" \
"$git_entry" \
"OPENCLAW_UPDATE_IN_PROGRESS=1 $npm_bin doctor --repair --force --yes --non-interactive" \
"$npm_entry"
run_proxy_env_flow() {
local name="proxy-env-cleanup"
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log"
local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
export USER="testuser"
unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
HTTP_PROXY="http://proxy.local:7890" \
HTTPS_PROXY="https://proxy.local:7890" \
NO_PROXY="localhost,127.0.0.1" \
"$npm_bin" gateway install --force >"$install_log" 2>&1; then
openclaw_e2e_print_log "$install_log"
exit 1
fi
assert_no_env_key "$unit_path" "HTTP_PROXY"
assert_no_env_key "$unit_path" "HTTPS_PROXY"
assert_no_env_key "$unit_path" "NO_PROXY"
{
printf "%s\n" "Environment=HTTP_PROXY=http://stale-proxy.local:7890"
printf "%s\n" "Environment=HTTPS_PROXY=https://stale-proxy.local:7890"
} >>"$unit_path"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env OPENCLAW_UPDATE_IN_PROGRESS=1 \
node "$git_cli" doctor --repair --force --yes --non-interactive >"$doctor_log" 2>&1; then
openclaw_e2e_print_log "$doctor_log"
exit 1
fi
assert_no_env_key "$unit_path" "HTTP_PROXY"
assert_no_env_key "$unit_path" "HTTPS_PROXY"
}
run_proxy_env_flow
run_wrapper_flow() {
local name="wrapper-persistence"
local install_log="/tmp/openclaw-doctor-switch-${name}-install.log"
local reinstall_log="/tmp/openclaw-doctor-switch-${name}-reinstall.log"
local env_repair_log="/tmp/openclaw-doctor-switch-${name}-env-repair.log"
local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log"
local clear_log="/tmp/openclaw-doctor-switch-${name}-clear.log"
local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"
echo "== Flow: $name =="
openclaw_test_state_create "switch-${name}" empty
export USER="testuser"
mkdir -p "$HOME/.local/bin"
local wrapper="$HOME/.local/bin/openclaw-wrapper"
node scripts/e2e/lib/doctor-install-switch/write-wrapper.mjs \
"$wrapper" \
"$npm_bin" \
"$HOME/openclaw-wrapper-argv.log"
local unit_path="$HOME/.config/systemd/user/openclaw-gateway.service"
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force >"$install_log" 2>&1; then
openclaw_e2e_print_log "$install_log"
exit 1
fi
assert_exec_arg "$unit_path" 1 "$wrapper"
assert_exec_arg "$unit_path" 2 "gateway"
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --force >"$reinstall_log" 2>&1; then
openclaw_e2e_print_log "$reinstall_log"
exit 1
fi
assert_exec_arg "$unit_path" 1 "$wrapper"
assert_exec_arg "$unit_path" 2 "gateway"
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
sed -i "/^Environment=OPENCLAW_WRAPPER=/d" "$unit_path"
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then
openclaw_e2e_print_log "$env_repair_log"
exit 1
fi
assert_exec_arg "$unit_path" 1 "$wrapper"
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
sed -i "s#^Environment=OPENCLAW_WRAPPER=.*#Environment=OPENCLAW_WRAPPER=/tmp/stale-openclaw-wrapper#" "$unit_path"
if ! openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then
openclaw_e2e_print_log "$env_repair_log"
exit 1
fi
assert_exec_arg "$unit_path" 1 "$wrapper"
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
if ! openclaw_e2e_maybe_timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes >"$doctor_log" 2>&1; then
openclaw_e2e_print_log "$doctor_log"
exit 1
fi
if ! grep -Fq "Gateway service invokes OPENCLAW_WRAPPER:" "$doctor_log"; then
echo "Expected doctor to report active wrapper"
openclaw_e2e_print_log "$doctor_log"
exit 1
fi
assert_exec_arg "$unit_path" 1 "$wrapper"
assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env OPENCLAW_WRAPPER= "$npm_bin" gateway install --force >"$clear_log" 2>&1; then
openclaw_e2e_print_log "$clear_log"
exit 1
fi
assert_no_env_key "$unit_path" "OPENCLAW_WRAPPER"
assert_entrypoint "$unit_path" "$npm_entry"
}
if "$npm_bin" gateway install --help 2>&1 | grep -q -- "--wrapper"; then
run_wrapper_flow
elif is_legacy_package_acceptance_compat "$package_version"; then
# Legacy compatibility: 2026.4.25 and older did not ship gateway install --wrapper.
echo "Skipping wrapper persistence; package gateway install does not support --wrapper."
else
echo "Package $package_version must support gateway install --wrapper." >&2
exit 1
fi

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
case "$*" in
*show-user*) echo "Linger=yes" ;;
*enable-linger*) ;;
esac

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
args=("$@")
if [[ "${args[0]:-}" == "--user" ]]; then
args=("${args[@]:1}")
fi
cmd="${args[0]:-}"
case "$cmd" in
status) ;;
is-active)
echo "inactive" >&2
exit 3
;;
is-enabled)
unit="${args[1]:-}"
unit_path="$HOME/.config/systemd/user/${unit}"
if [ -f "$unit_path" ]; then
echo "enabled"
exit 0
fi
echo "disabled" >&2
exit 1
;;
show)
printf "%s\n" \
"ActiveState=inactive" \
"SubState=dead" \
"MainPID=0" \
"ExecMainStatus=0" \
"ExecMainCode=0"
;;
esac

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env node
// Writes wrapper scripts for doctor install-switch E2E scenarios.
import fs from "node:fs";
const [wrapperPath, npmBin, logPath = `${process.env.HOME}/openclaw-wrapper-argv.log`] =
process.argv.slice(2);
if (!wrapperPath || !npmBin || !logPath || logPath.startsWith("undefined/")) {
console.error("usage: write-wrapper.mjs <wrapper-path> <npm-bin> [log-path]");
process.exit(1);
}
function shellSingleQuote(value) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
fs.writeFileSync(
wrapperPath,
`#!/usr/bin/env bash
set -euo pipefail
printf "%s\\n" "$@" >> ${shellSingleQuote(logPath)}
exec ${shellSingleQuote(npmBin)} "$@"
`,
{ mode: 0o755 },
);

View File

@@ -0,0 +1,23 @@
// Environment limit helpers for E2E subprocess scenarios.
export function readPositiveIntEnv(name, fallback, env = process.env) {
const raw = env[name] ?? fallback;
const text = raw == null ? "unset" : String(raw).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readTcpPortEnv(name, fallback, env = process.env) {
const value = readPositiveIntEnv(name, fallback, env);
if (value > 65_535) {
const raw = env[name] ?? fallback;
const text = raw == null ? "unset" : String(raw).trim();
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}

View File

@@ -0,0 +1,17 @@
// Shared command fixture dispatcher for E2E scripts.
import { configCommands } from "./fixtures/config.mjs";
import { pluginCommands } from "./fixtures/plugins.mjs";
import { workspaceCommands } from "./fixtures/workspace.mjs";
const [command, ...args] = process.argv.slice(2);
const handler = {
...pluginCommands,
...configCommands,
...workspaceCommands,
}[command];
if (!handler) {
throw new Error(`unknown fixture command: ${command}`);
}
handler(args);

View File

@@ -0,0 +1,25 @@
// Common file/assertion helpers for E2E fixture writers.
import fs from "node:fs";
import path from "node:path";
export const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
export const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
export const write = (file, contents) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
};
export const writeJson = (file, value) => write(file, json(value));
export const requireArg = (value, name) => {
if (!value) {
throw new Error(`${name} is required`);
}
return value;
};
export const assert = (condition, message) => {
if (!condition) {
throw new Error(message);
}
};

View File

@@ -0,0 +1,122 @@
// Config fixture writer commands for E2E scenarios.
import path from "node:path";
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
import { requireArg, writeJson } from "./common.mjs";
function writeConfig(kind) {
const configPath = requireArg(process.env.OPENCLAW_CONFIG_PATH, "OPENCLAW_CONFIG_PATH");
const port = readTcpPortEnv("PORT", 18789);
const config =
kind === "config-reload"
? {
gateway: {
port,
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "GATEWAY_AUTH_TOKEN_REF" },
},
channelHealthCheckMinutes: 1,
controlUi: { enabled: false },
reload: { mode: "hybrid", debounceMs: 0 },
},
}
: kind === "browser-cdp"
? {
gateway: {
port,
auth: {
mode: "token",
token: requireArg(process.env.OPENCLAW_GATEWAY_TOKEN, "OPENCLAW_GATEWAY_TOKEN"),
},
controlUi: { enabled: false },
},
browser: {
enabled: true,
defaultProfile: "docker-cdp",
ssrfPolicy: { allowedHostnames: ["127.0.0.1"] },
profiles: {
"docker-cdp": {
cdpUrl: `http://127.0.0.1:${readTcpPortEnv("CDP_PORT", 19222)}`,
color: "#FF4500",
},
},
},
}
: null;
writeJson(configPath, requireArg(config, "known config kind"));
}
function writeOpenAiWebSearchMinimalConfig() {
writeJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"), {
agents: {
defaults: {
model: { primary: "openai/gpt-5" },
models: {
"openai/gpt-5": {
params: { transport: "sse", openaiWsWarmup: false },
},
},
},
},
models: {
providers: {
openai: {
api: "openai-responses",
baseUrl: "http://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
request: { allowPrivateNetwork: true },
models: [
{
id: "gpt-5",
name: "gpt-5",
api: "openai-responses",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
contextTokens: 96000,
maxTokens: 4096,
},
],
},
},
},
tools: { web: { search: { enabled: true, maxResults: 3 } } },
plugins: { enabled: true, allow: ["openai"], entries: { openai: { enabled: true } } },
gateway: { auth: { mode: "token", token: process.env.OPENCLAW_GATEWAY_TOKEN } },
});
}
function writeOpenWebUiConfig([openaiApiKey]) {
const batchPath = requireArg(
process.env.OPENCLAW_CONFIG_BATCH_PATH,
"OPENCLAW_CONFIG_BATCH_PATH",
);
writeJson(batchPath, [
{ path: "models.providers.openai.apiKey", value: requireArg(openaiApiKey, "OpenAI API key") },
{
path: "models.providers.openai.baseUrl",
value: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
},
{ path: "models.providers.openai.models", value: [] },
{
path: "models.providers.openai.timeoutSeconds",
value: readPositiveIntEnv("OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS", 900),
},
{ path: "models.providers.openai.agentRuntime", value: { id: "openclaw" } },
{ path: "gateway.controlUi.enabled", value: false },
{ path: "gateway.mode", value: "local" },
{ path: "gateway.bind", value: "lan" },
{ path: "gateway.auth.mode", value: "token" },
{ path: "gateway.auth.token", value: process.env.OPENCLAW_GATEWAY_TOKEN },
{ path: "gateway.http.endpoints.chatCompletions.enabled", value: true },
{ path: "agents.defaults.model.primary", value: process.env.OPENCLAW_OPENWEBUI_MODEL },
]);
}
export const configCommands = {
"config-reload": () => writeConfig("config-reload"),
"browser-cdp": () => writeConfig("browser-cdp"),
"openai-web-search-minimal-config": writeOpenAiWebSearchMinimalConfig,
"openwebui-config": writeOpenWebUiConfig,
};

View File

@@ -0,0 +1,100 @@
// Mock OpenAI model config helpers for E2E fixture generation.
function formatMockPortValue(value) {
return value === undefined ? "<missing>" : JSON.stringify(String(value));
}
export function parseMockOpenAiPort(value, label = "mock OpenAI port") {
const text = String(value ?? "").trim();
if (!/^[1-9]\d*$/u.test(text)) {
throw new Error(
`${label} must be a TCP port from 1 to 65535. Got: ${formatMockPortValue(value)}`,
);
}
const port = Number(text);
if (!Number.isSafeInteger(port) || port > 65535) {
throw new Error(
`${label} must be a TCP port from 1 to 65535. Got: ${formatMockPortValue(value)}`,
);
}
return port;
}
export function applyMockOpenAiModelConfig(cfg, params) {
const mockPort = parseMockOpenAiPort(params.mockPort);
const modelRef = params.modelRef ?? "openai/gpt-5.5";
const modelId = modelRef.split("/").at(-1) ?? "gpt-5.5";
const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
cfg.models = {
...cfg.models,
mode: "merge",
providers: {
...cfg.models?.providers,
openai: {
...cfg.models?.providers?.openai,
baseUrl: `http://127.0.0.1:${mockPort}/v1`,
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
api: "openai-responses",
agentRuntime: { id: "openclaw" },
request: { ...cfg.models?.providers?.openai?.request, allowPrivateNetwork: true },
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
agentRuntime: { id: "openclaw" },
reasoning: false,
input: ["text", "image"],
cost,
contextWindow: 128000,
contextTokens: 96000,
maxTokens: 4096,
},
],
},
},
};
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: { primary: modelRef },
...(params.includeImageDefaults
? {
imageModel: { primary: modelRef, timeoutMs: 30_000 },
imageGenerationModel: { primary: "openai/gpt-image-1", timeoutMs: 30_000 },
}
: {}),
models: {
...cfg.agents?.defaults?.models,
[modelRef]: {
agentRuntime: { id: "openclaw" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
},
...(Array.isArray(cfg.agents?.list)
? {
list: cfg.agents.list.map((agent) => ({
...agent,
model: { ...agent.model, primary: modelRef },
models: {
...agent.models,
[modelRef]: {
...agent.models?.[modelRef],
agentRuntime: { id: "openclaw" },
params: {
...agent.models?.[modelRef]?.params,
transport: "sse",
openaiWsWarmup: false,
},
},
},
})),
}
: {}),
};
cfg.plugins = {
...cfg.plugins,
enabled: true,
};
}

View File

@@ -0,0 +1,173 @@
// Plugin fixture writer commands for E2E scenarios.
import path from "node:path";
import { requireArg, write, writeJson } from "./common.mjs";
function writePluginManifest(file, id, extra = {}) {
writeJson(file, { id, ...extra, configSchema: { type: "object", properties: {} } });
}
function writeFakeIsNumberPackage(dir) {
writeJson(path.join(dir, "package.json"), {
name: "is-number",
version: "7.0.0",
main: "index.js",
});
write(path.join(dir, "index.js"), "module.exports = (value) => typeof value === 'number';\n");
}
function writePluginDemo([dir]) {
write(
path.join(requireArg(dir, "dir"), "index.js"),
'module.exports = { id: "demo-plugin", name: "Demo Plugin", description: "Docker E2E demo plugin", register(api) { api.registerTool(() => null, { name: "demo_tool" }); api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); api.registerCli(() => {}, { commands: ["demo"] }); api.registerService({ id: "demo-service", start: () => {} }); }, };\n',
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), "demo-plugin", {
contracts: { tools: ["demo_tool"] },
});
}
function writePlugin([dir, id, version, method, name]) {
for (const [value, label] of [
[dir, "dir"],
[id, "id"],
[version, "version"],
[method, "method"],
[name, "name"],
]) {
requireArg(value, label);
}
writeJson(path.join(dir, "package.json"), {
name: `@openclaw/${id}`,
version,
openclaw: { extensions: ["./index.js"] },
});
write(
path.join(dir, "index.js"),
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); }, };\n`,
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
}
function writePluginWithVendoredDependency([dir, id, version, method, name]) {
writePlugin([dir, id, version, method, name]);
const packageJsonPath = path.join(dir, "package.json");
writeJson(packageJsonPath, {
name: `@openclaw/${id}`,
version,
dependencies: { "is-number": "7.0.0" },
openclaw: { extensions: ["./index.js"] },
});
write(
path.join(dir, "index.js"),
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); }, };\n`,
);
writeFakeIsNumberPackage(path.join(dir, "node_modules", "is-number"));
}
function writePluginWithCli([dir, id, version, method, name, cliRoot, cliOutput]) {
for (const [value, label] of [
[dir, "dir"],
[id, "id"],
[version, "version"],
[method, "method"],
[name, "name"],
[cliRoot, "cliRoot"],
[cliOutput, "cliOutput"],
]) {
requireArg(value, label);
}
writeJson(path.join(dir, "package.json"), {
name: `@openclaw/${id}`,
version,
dependencies: { "is-number": "file:./deps/is-number" },
openclaw: { extensions: ["./index.js"] },
});
writeFakeIsNumberPackage(path.join(dir, "deps", "is-number"));
write(
path.join(dir, "index.js"),
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
}
function writePluginWithCliRegistryDependency([
dir,
id,
version,
method,
name,
cliRoot,
cliOutput,
]) {
for (const [value, label] of [
[dir, "dir"],
[id, "id"],
[version, "version"],
[method, "method"],
[name, "name"],
[cliRoot, "cliRoot"],
[cliOutput, "cliOutput"],
]) {
requireArg(value, label);
}
writeJson(path.join(dir, "package.json"), {
name: `@openclaw/${id}`,
version,
dependencies: { "is-number": "7.0.0" },
openclaw: { extensions: ["./index.js"] },
});
write(
path.join(dir, "index.js"),
`const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
);
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
}
function writeClaudeBundle(args) {
const root = requireArg(args[0], "root");
writeJson(path.join(root, ".claude-plugin", "plugin.json"), { name: "claude-bundle-e2e" });
write(
path.join(root, "commands", "office-hours.md"),
"---\ndescription: Help with architecture and rollout planning\n---\nAct as an engineering advisor.\n\nFocus on:\n$ARGUMENTS\n",
);
}
function writePluginMarketplace(args) {
const root = requireArg(args[0], "root");
writeJson(path.join(root, ".claude-plugin", "marketplace.json"), {
name: "Fixture Marketplace",
version: "1.0.0",
plugins: [
{
name: "marketplace-shortcut",
version: "0.0.1",
description: "Shortcut install fixture",
source: "./plugins/marketplace-shortcut",
},
{
name: "marketplace-direct",
version: "0.0.1",
description: "Explicit marketplace fixture",
source: { type: "path", path: "./plugins/marketplace-direct" },
},
],
});
writeJson(path.join(process.env.HOME, ".claude", "plugins", "known_marketplaces.json"), {
"claude-fixtures": {
installLocation: root,
source: { type: "github", repo: "openclaw/fixture-marketplace" },
},
});
}
export const pluginCommands = {
"plugin-demo": writePluginDemo,
plugin: writePlugin,
"plugin-vendored-dep": writePluginWithVendoredDependency,
"plugin-cli": writePluginWithCli,
"plugin-cli-registry-dep": writePluginWithCliRegistryDependency,
"fake-is-number-package": ([dir]) => writeFakeIsNumberPackage(requireArg(dir, "dir")),
"plugin-manifest": ([file, id]) =>
writePluginManifest(requireArg(file, "file"), requireArg(id, "id")),
"claude-bundle": writeClaudeBundle,
marketplace: writePluginMarketplace,
};

View File

@@ -0,0 +1,103 @@
// Workspace fixture writer commands for E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readTextFileTail } from "../text-file-utils.mjs";
import { assert, readJson, requireArg, write, writeJson } from "./common.mjs";
const AGENTS_DELETE_OUTPUT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_FIXTURE_AGENTS_DELETE_OUTPUT_MAX_BYTES",
1024 * 1024,
);
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
function writeOpenWebUiWorkspace() {
const workspace =
process.env.OPENCLAW_WORKSPACE_DIR || path.join(process.env.HOME, ".openclaw", "workspace");
write(
path.join(workspace, "IDENTITY.md"),
"# Identity\n\n- Name: OpenClaw\n- Purpose: Open WebUI Docker compatibility smoke test assistant.\n",
);
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
version: 1,
setupCompletedAt: "2026-01-01T00:00:00.000Z",
});
fs.rmSync(path.join(workspace, "BOOTSTRAP.md"), { force: true });
}
function writeAgentsDeleteConfig() {
const stateDir = requireArg(process.env.OPENCLAW_STATE_DIR, "OPENCLAW_STATE_DIR");
const sharedWorkspace = requireArg(process.env.SHARED_WORKSPACE, "SHARED_WORKSPACE");
const gatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
fs.mkdirSync(sharedWorkspace, { recursive: true });
writeJson(path.join(stateDir, "openclaw.json"), {
agents: {
list: [
{ id: "main", workspace: sharedWorkspace },
{ id: "ops", workspace: sharedWorkspace },
],
},
...(gatewayToken ? { gateway: { auth: { mode: "token", token: gatewayToken } } } : {}),
});
}
function assertAgentsDeleteResult([outputPath]) {
const resolvedOutputPath = requireArg(outputPath, "outputPath");
const outputStat = fs.statSync(resolvedOutputPath);
if (outputStat.isFile() && outputStat.size > AGENTS_DELETE_OUTPUT_MAX_BYTES) {
throw new Error(
`agents delete --json output exceeded ${AGENTS_DELETE_OUTPUT_MAX_BYTES} bytes:\nstdout tail=${readTextFileTail(
resolvedOutputPath,
ERROR_DETAIL_TAIL_BYTES,
)}`,
);
}
let parsed;
try {
parsed = readJson(resolvedOutputPath);
} catch (error) {
console.error("agents delete --json did not emit valid JSON:");
console.error(readTextFileTail(resolvedOutputPath, ERROR_DETAIL_TAIL_BYTES).trim());
const message = error instanceof Error ? error.message.split("\n").at(0) : String(error);
throw new Error(`agents delete --json parse failed: ${message}`, { cause: error });
}
for (const [actual, expected, label] of [
[parsed.agentId, "ops", "agentId"],
[parsed.workspace, process.env.SHARED_WORKSPACE, "workspace"],
[parsed.workspaceRetained, true, "workspaceRetained"],
[parsed.workspaceRetainedReason, "shared", "workspaceRetainedReason"],
]) {
assert(actual === expected, `${label} mismatch: ${JSON.stringify(actual)}`);
}
assert(
Array.isArray(parsed.workspaceSharedWith) && parsed.workspaceSharedWith.includes("main"),
"missing shared-with main marker",
);
assert(fs.existsSync(process.env.SHARED_WORKSPACE), "shared workspace was removed");
const remaining =
readJson(path.join(process.env.OPENCLAW_STATE_DIR, "openclaw.json"))?.agents?.list ?? [];
assert(Array.isArray(remaining), "agents list missing after delete");
assert(!remaining.some((entry) => entry?.id === "ops"), "deleted agent remained in config");
assert(
remaining.some((entry) => entry?.id === "main"),
"main agent missing after delete",
);
console.log("agents delete shared workspace smoke ok");
}
export const workspaceCommands = {
"openwebui-workspace": writeOpenWebUiWorkspace,
"agents-delete-config": writeAgentsDeleteConfig,
"agents-delete-assert": assertAgentsDeleteResult,
};

View File

@@ -0,0 +1,17 @@
// Gateway frame payload helpers for E2E WebSocket assertions.
function hasOwnEnvelopeField(frame, field) {
return (
((typeof frame === "object" && frame !== null) || typeof frame === "function") &&
Object.hasOwn(frame, field)
);
}
export function resolveGatewaySuccessPayload(frame) {
if (hasOwnEnvelopeField(frame, "payload")) {
return frame.payload;
}
if (hasOwnEnvelopeField(frame, "result")) {
return frame.result;
}
return undefined;
}

View File

@@ -0,0 +1,152 @@
// WebSocket client helpers for gateway network E2E scenarios.
import { pathToFileURL } from "node:url";
import { WebSocket } from "ws";
import { sleep as delay } from "../../../lib/sleep.mjs";
import { waitForWebSocketOpen } from "../websocket-open.mjs";
import { readGatewayNetworkClientConnectTimeoutMs } from "./limits.mjs";
import { onceFrame } from "./ws-frames.mjs";
function remainingDeadlineMs(deadline) {
return Math.max(1, deadline - Date.now());
}
async function openSocket(url, timeoutMs = 10_000) {
const ws = new WebSocket(url);
await waitForWebSocketOpen(ws, timeoutMs, "ws open timeout");
return ws;
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function hasGatewayHealthSummaryPayload(response) {
if (!isRecord(response) || !isRecord(response.payload)) {
return false;
}
const { payload } = response;
return (
payload.ok === true &&
typeof payload.ts === "number" &&
typeof payload.durationMs === "number" &&
typeof payload.defaultAgentId === "string" &&
payload.defaultAgentId.trim() !== "" &&
Array.isArray(payload.agents) &&
isRecord(payload.channels) &&
Array.isArray(payload.channelOrder) &&
isRecord(payload.sessions)
);
}
export function responseError(method, response) {
const message = response.error?.message ?? "unknown";
return new Error(`${method} failed: ${message}`);
}
export function isRetryableStartupError(message) {
return (
message.includes("gateway starting") ||
message.includes("closed before frame") ||
message.includes("closed before open") ||
message.includes("ws open timeout") ||
message.includes("ECONNREFUSED") ||
message.includes("ECONNRESET") ||
message.includes("timeout")
);
}
async function readProtocolVersion() {
const protocol = await import("../../../../dist/gateway/protocol/index.js");
return protocol.PROTOCOL_VERSION;
}
export async function runGatewayNetworkClient(
{ token, url, timeoutMs = readGatewayNetworkClientConnectTimeoutMs() },
deps = {},
) {
const deadline = Date.now() + timeoutMs;
const delayImpl = deps.delay ?? delay;
const onceFrameImpl = deps.onceFrame ?? onceFrame;
const openSocketImpl = deps.openSocket ?? openSocket;
const protocolVersion = deps.protocolVersion ?? (await readProtocolVersion());
const stdout = deps.stdout ?? console.log;
let lastError;
while (Date.now() < deadline) {
let ws;
try {
ws = await openSocketImpl(url, remainingDeadlineMs(deadline));
ws.send(
JSON.stringify({
type: "req",
id: "c1",
method: "connect",
params: {
minProtocol: protocolVersion,
maxProtocol: protocolVersion,
client: {
id: "test",
displayName: "docker-net-e2e",
version: "dev",
platform: process.platform,
mode: "test",
},
caps: [],
auth: { token },
},
}),
);
const connectRes = await onceFrameImpl(
ws,
(frame) => frame?.type === "res" && frame?.id === "c1",
remainingDeadlineMs(deadline),
);
if (!connectRes.ok) {
lastError = responseError("connect", connectRes);
if (!isRetryableStartupError(lastError.message)) {
throw lastError;
}
} else {
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
const healthRes = await onceFrameImpl(
ws,
(frame) => frame?.type === "res" && frame?.id === "h1",
remainingDeadlineMs(deadline),
);
if (healthRes.ok) {
if (!hasGatewayHealthSummaryPayload(healthRes)) {
throw new Error("health failed: missing health summary payload");
}
stdout("ok");
return;
}
throw responseError("health", healthRes);
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!isRetryableStartupError(lastError.message)) {
throw lastError;
}
} finally {
ws?.close();
}
const retryDelayMs = Math.min(500, deadline - Date.now());
if (retryDelayMs > 0) {
await delayImpl(retryDelayMs);
}
}
throw lastError ?? new Error("connect failed: timeout");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const url = process.env.GW_URL;
const token = process.env.GW_TOKEN;
if (!url || !token) {
throw new Error("missing GW_URL/GW_TOKEN");
}
await runGatewayNetworkClient({ token, url });
}

View File

@@ -0,0 +1,19 @@
// Limits shared by gateway network E2E fixtures.
function readPositiveIntEnv(name, fallback, env) {
const text = String(env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
export function readGatewayNetworkClientConnectTimeoutMs(env = process.env) {
if (env.OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS != null) {
return readPositiveIntEnv("OPENCLAW_GATEWAY_NETWORK_CLIENT_CONNECT_TIMEOUT_MS", 80000, env);
}
return readPositiveIntEnv("OPENCLAW_GATEWAY_NETWORK_CONNECT_READY_TIMEOUT_MS", 80000, env);
}

View File

@@ -0,0 +1,67 @@
// WebSocket frame helpers for gateway network E2E fixtures.
function formatCloseValue(value) {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return value.toString();
}
if (value instanceof Uint8Array) {
return Buffer.from(value).toString();
}
return JSON.stringify(value) ?? "";
}
export function onceFrame(ws, filter, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timer);
ws.off?.("message", onMessage);
ws.off?.("error", onError);
ws.off?.("close", onClose);
};
const settle = (fn, value) => {
if (settled) {
return;
}
settled = true;
cleanup();
fn(value);
};
const onMessage = (data) => {
let obj;
try {
obj = JSON.parse(String(data));
if (!filter(obj)) {
return;
}
} catch (error) {
settle(reject, error instanceof Error ? error : new Error(String(error)));
return;
}
settle(resolve, obj);
};
const onError = (error) =>
settle(reject, error instanceof Error ? error : new Error(String(error)));
const onClose = (code, reason) => {
const closeDetails = [formatCloseValue(code), formatCloseValue(reason)]
.filter(Boolean)
.join(" ");
const suffix = closeDetails ? `: ${closeDetails}` : "";
settle(reject, new Error(`closed before frame${suffix}`));
};
const timer = setTimeout(() => {
settle(reject, new Error("timeout"));
}, timeoutMs);
timer.unref?.();
ws.on("message", onMessage);
ws.once("error", onError);
ws.once("close", onClose);
});
}

View File

@@ -0,0 +1,142 @@
// Incremental line reader for streaming E2E logs.
import { createHash } from "node:crypto";
import fs from "node:fs";
function readSlice(filePath, start, length) {
if (length <= 0) {
return "";
}
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
fs.closeSync(fd);
}
}
function readBufferSlice(filePath, start, length) {
if (length <= 0) {
return Buffer.alloc(0);
}
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.subarray(0, bytesRead);
} finally {
fs.closeSync(fd);
}
}
function resolveFileIdentity(stats) {
if (Number.isSafeInteger(stats.dev) && Number.isSafeInteger(stats.ino) && stats.ino !== 0) {
return `${stats.dev}:${stats.ino}`;
}
return Number.isFinite(stats.birthtimeMs) ? `birth:${stats.birthtimeMs}` : undefined;
}
function readTailFingerprint(filePath, stats, maxReadBytes) {
const length = Math.min(stats.size, maxReadBytes);
const start = Math.max(0, stats.size - length);
const buffer = readBufferSlice(filePath, start, length);
const hash = createHash("sha256").update(buffer).digest("base64url");
return `${start}:${buffer.byteLength}:${hash}`;
}
export function resolvePositiveInteger(value, fallback) {
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
}
export function createIncrementalLineReader(filePath, options = {}) {
const maxReadBytes = resolvePositiveInteger(options.maxReadBytes, 256 * 1024);
let fileIdentity;
let contentFingerprint;
let offset = 0;
let pending = "";
return {
readLines() {
if (!fs.existsSync(filePath)) {
return { lines: [], reset: false };
}
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
return { lines: [], reset: false };
}
let reset = false;
const nextFileIdentity = resolveFileIdentity(stats);
if (
fileIdentity !== undefined &&
nextFileIdentity !== undefined &&
fileIdentity !== nextFileIdentity
) {
offset = 0;
pending = "";
reset = true;
}
fileIdentity = nextFileIdentity;
if (!reset && stats.size === offset && contentFingerprint !== undefined) {
const nextContentFingerprint = readTailFingerprint(filePath, stats, maxReadBytes);
if (contentFingerprint !== nextContentFingerprint) {
offset = 0;
pending = "";
reset = true;
} else {
contentFingerprint = nextContentFingerprint;
return { lines: [], reset: false };
}
}
if (stats.size < offset) {
offset = 0;
pending = "";
reset = true;
}
if (stats.size === offset) {
return { lines: [], reset };
}
let start = offset;
let discardFirstLine = false;
let clamped = false;
if (start === 0 && stats.size > maxReadBytes) {
start = stats.size - maxReadBytes;
pending = "";
clamped = true;
} else if (stats.size - start > maxReadBytes) {
start = stats.size - maxReadBytes;
pending = "";
clamped = true;
}
if (clamped && start > 0) {
discardFirstLine = readSlice(filePath, start - 1, 1) !== "\n";
}
const text = readSlice(filePath, start, stats.size - start);
offset = stats.size;
contentFingerprint = readTailFingerprint(filePath, stats, maxReadBytes);
if (!text) {
return { lines: [], reset };
}
let chunk = pending + text;
if (discardFirstLine) {
const newlineIndex = chunk.indexOf("\n");
if (newlineIndex === -1) {
pending = "";
return { lines: [], reset };
}
chunk = chunk.slice(newlineIndex + 1);
}
const lines = chunk.split("\n");
pending = lines.pop() ?? "";
return { lines, reset };
},
};
}

View File

@@ -0,0 +1,698 @@
// Assertions for kitchen-sink plugin E2E scenarios.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
const command = process.argv[2];
const scratchRoot = process.env.KITCHEN_SINK_TMP_DIR || os.tmpdir();
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const LOG_SCAN_FINDING_CONTEXT_CHARS = 2048;
const LOG_SCAN_MAX_ENTRIES = readPositiveIntEnv("KITCHEN_SINK_LOG_SCAN_MAX_ENTRIES", 20_000);
const LOG_SCAN_MAX_FILES = 5000;
const LOG_SCAN_MAX_FINDINGS = 100;
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
const LOG_SCAN_SEGMENT_OVERLAP_CHARS = 256;
const EXPECT_FAILURE_OUTPUT_MAX_BYTES = readPositiveIntEnv(
"KITCHEN_SINK_EXPECT_FAILURE_OUTPUT_MAX_BYTES",
1024 * 1024,
);
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const scratchFile = (name) => path.join(scratchRoot, name);
const normalizedPath = (filePath) => filePath.replaceAll("\\", "/");
function readPositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === "") {
return fallback;
}
const text = raw.trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer; got: ${raw}`);
}
return parsed;
}
function resolveHomePath(value) {
if (value === "~") {
return process.env.HOME;
}
if (value?.startsWith("~/") || value?.startsWith("~\\")) {
return path.join(process.env.HOME, value.slice(2));
}
return value;
}
function readTextFileBounded(file, maxBytes, label) {
const stats = fs.statSync(file);
if (stats.size > maxBytes) {
throw new Error(`${label} exceeded ${maxBytes} bytes: ${file} (${stats.size} bytes)`);
}
return fs.readFileSync(file, "utf8");
}
function expectFailure() {
const outputFile = process.argv[3];
const output = readTextFileBounded(
outputFile,
EXPECT_FAILURE_OUTPUT_MAX_BYTES,
"expected failure output",
);
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}`);
}
}
function scanTextFileLines(file, onLine) {
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(LOG_SCAN_CHUNK_BYTES);
let currentLine = "";
let lineNumber = 1;
const emitLine = (line, info = {}) => onLine(line, lineNumber, info);
const appendLineText = (text, complete) => {
currentLine += text;
while (currentLine.length > LOG_SCAN_MAX_LINE_CHARS) {
const segment = currentLine.slice(0, LOG_SCAN_MAX_LINE_CHARS);
currentLine = currentLine.slice(LOG_SCAN_MAX_LINE_CHARS - LOG_SCAN_SEGMENT_OVERLAP_CHARS);
if (!emitLine(segment, { truncated: true })) {
return false;
}
}
if (complete) {
if (!emitLine(currentLine)) {
return false;
}
currentLine = "";
lineNumber += 1;
}
return true;
};
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
if (bytesRead <= 0) {
break;
}
const text = buffer.subarray(0, bytesRead).toString("utf8");
const lines = text.split(/\r?\n/u);
for (let index = 0; index < lines.length - 1; index += 1) {
if (!appendLineText(lines[index], true)) {
return;
}
}
if (!appendLineText(lines.at(-1) ?? "", false)) {
return;
}
}
if (currentLine.length > 0) {
onLine(currentLine, lineNumber);
}
} finally {
fs.closeSync(fd);
}
}
function formatFindingLine(line, pattern, info = {}) {
const matchIndex = Math.max(0, line.search(pattern));
const halfWindow = Math.floor(LOG_SCAN_FINDING_CONTEXT_CHARS / 2);
const start = Math.max(0, matchIndex - halfWindow);
const end = Math.min(line.length, start + LOG_SCAN_FINDING_CONTEXT_CHARS);
const prefix = start > 0 ? "... " : "";
const suffix = end < line.length || info.truncated ? " ..." : "";
return `${prefix}${line.slice(start, end)}${suffix}`;
}
function shouldScanLogFile(entry) {
if (!(/\.(?:log|jsonl)$/u.test(entry) || /openclaw-kitchen-sink-/u.test(path.basename(entry)))) {
return false;
}
return !normalizedPath(entry).includes("/.npm/_logs/");
}
function scanLogFiles(roots, onFile) {
let scannedFiles = 0;
let visitedEntries = 0;
for (const root of roots) {
const pending = [{ entry: root, counted: false }];
while (pending.length > 0) {
const pendingEntry = pending.pop();
const entry = pendingEntry?.entry;
if (!entry || !fs.existsSync(entry)) {
continue;
}
if (!pendingEntry.counted) {
visitedEntries += 1;
if (visitedEntries > LOG_SCAN_MAX_ENTRIES) {
throw new Error(
`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_ENTRIES} filesystem entries`,
);
}
}
const entryType = pendingEntry.dirent ?? fs.lstatSync(entry);
if (entryType.isSymbolicLink()) {
continue;
}
if (entryType.isDirectory()) {
const dir = fs.opendirSync(entry);
try {
let child;
while ((child = dir.readSync()) !== null) {
visitedEntries += 1;
if (visitedEntries > LOG_SCAN_MAX_ENTRIES) {
throw new Error(
`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_ENTRIES} filesystem entries`,
);
}
pending.push({
counted: true,
dirent: child,
entry: path.join(entry, child.name),
});
}
} finally {
dir.closeSync();
}
continue;
}
if (!shouldScanLogFile(entry)) {
continue;
}
scannedFiles += 1;
if (scannedFiles > LOG_SCAN_MAX_FILES) {
throw new Error(`kitchen-sink log scan exceeded ${LOG_SCAN_MAX_FILES} candidate files`);
}
if (!onFile(entry, scannedFiles)) {
return scannedFiles;
}
}
}
return scannedFiles;
}
function scanLogs() {
if (!process.env.KITCHEN_SINK_TMP_DIR) {
throw new Error("KITCHEN_SINK_TMP_DIR is required for kitchen-sink log scans");
}
const roots = [scratchRoot, path.join(process.env.HOME, ".openclaw")];
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 = [
/^\s*0 errors?\s*$/iu,
/^\s*expected no diagnostics errors?\s*$/iu,
/^\s*diagnostics errors?:\s*$/iu,
];
const findings = [];
let omittedFindings = false;
const scannedFiles = scanLogFiles(roots, (file) => {
scanTextFileLines(file, (line, lineNumber, info) => {
if (allow.some((pattern) => pattern.test(line))) {
return true;
}
const matchedPattern = deny.find((pattern) => pattern.test(line));
if (matchedPattern) {
if (findings.length >= LOG_SCAN_MAX_FINDINGS) {
omittedFindings = true;
return false;
}
findings.push(`${file}:${lineNumber}: ${formatFindingLine(line, matchedPattern, info)}`);
}
return true;
});
if (omittedFindings) {
return false;
}
return true;
});
if (scannedFiles === 0) {
throw new Error(
"kitchen-sink log scan found no files under the isolated scratch root or OpenClaw home",
);
}
if (findings.length > 0) {
const suffix = omittedFindings ? "\n... additional findings omitted" : "";
throw new Error(`unexpected error-like log lines:\n${findings.join("\n")}${suffix}`);
}
console.log(`log scan passed (${scannedFiles} file(s))`);
}
function readConfig() {
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
return {
configPath,
exists: fs.existsSync(configPath),
config: fs.existsSync(configPath) ? readJson(configPath) : {},
};
}
function configureRuntime() {
const pluginId = process.env.KITCHEN_SINK_ID;
const { configPath, config } = readConfig();
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.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
function removeChannelConfig() {
const { configPath, exists, config } = readConfig();
if (!exists) {
return;
}
delete config.channels?.["kitchen-sink-channel"];
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
const expectIncludes = (listValue, expected, field) => {
if (!Array.isArray(listValue) || !listValue.includes(expected)) {
throw new Error(`${field} missing ${expected}: ${JSON.stringify(listValue)}`);
}
};
const expectIncludesAny = (listValue, expectedValues, field) => {
if (
!Array.isArray(listValue) ||
!expectedValues.some((expected) => listValue.includes(expected))
) {
throw new Error(
`${field} missing one of ${expectedValues.join(", ")}: ${JSON.stringify(listValue)}`,
);
}
};
const expectMissing = (listValue, expected, field) => {
if (Array.isArray(listValue) && listValue.includes(expected)) {
throw new Error(`${field} unexpectedly included ${expected}: ${JSON.stringify(listValue)}`);
}
};
const INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES = new Set(["full", "conformance", "adversarial"]);
const requiredFullDiagnosticCanaries = new Set([
"agent tool result middleware must be a function",
"trusted tool policy registration requires id, description, and evaluate()",
"plugin must declare contracts.tools for: kitchen-sink-tool",
'channel "kitchen-sink-channel-probe" registration missing required config helpers',
'agent harness "kitchen-sink-agent-harness" registration missing required runtime methods',
"session scheduler job registration requires unique id, sessionKey, and kind",
]);
function assertExpectedDiagnostics(surfaceMode, errorMessages) {
const expectedErrorMessages = new Set([
"cli registration missing explicit commands metadata",
"only bundled plugins can register Codex app-server extension factories",
"agent tool result middleware must be a function",
'compaction provider "kitchen-sink-compaction-provider" registration missing summarize',
"context engine registration missing id",
"control UI descriptor registration requires id, surface, label, and valid optional fields",
"hosted media resolver registration missing resolver",
"http route registration missing or invalid auth: /kitchen-sink/http-route",
"node invoke policy registration missing commands",
"trusted tool policy registration requires id, description, and evaluate()",
"plugin must declare contracts.embeddingProviders for adapter: kitchen-sink-embedding-provider",
"plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: kitchen-sink-memory-embedding-provider",
"plugin must declare contracts.tools for: kitchen-sink-tool",
'channel "kitchen-sink-channel-probe" registration missing required config helpers',
'agent harness "kitchen-sink-agent-harness" registration missing required runtime methods',
"memory prompt supplement registration missing builder",
"model catalog provider registration missing provider",
"session extension registration requires namespace and description",
"session scheduler job registration requires unique id, sessionKey, and kind",
"tool metadata registration missing toolName",
]);
const optionalErrorMessages = new Set([
"agent event subscription registration requires id and handle",
]);
const allowedErrorMessages = new Set([...expectedErrorMessages, ...optionalErrorMessages]);
if (!INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES.has(surfaceMode)) {
if (errorMessages.size > 0) {
throw new Error(
`unexpected kitchen-sink diagnostic errors: ${[...errorMessages].join(", ")}`,
);
}
return;
}
for (const message of errorMessages) {
if (!allowedErrorMessages.has(message)) {
throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`);
}
}
if (surfaceMode === "full") {
// Default Docker scenarios install the published package, which can lag this repo.
// Exhaustive matching is reserved for synchronized/current package fixtures.
const requiredMessages =
process.env.KITCHEN_SINK_REQUIRE_ALL_DIAGNOSTICS === "1"
? expectedErrorMessages
: requiredFullDiagnosticCanaries;
for (const message of requiredMessages) {
if (!errorMessages.has(message)) {
throw new Error(`missing expected kitchen-sink diagnostic error: ${message}`);
}
}
}
}
function assertRealPathInside(parentPath, childPath, label) {
const parentRealPath = fs.realpathSync(parentPath);
const childRealPath = fs.realpathSync(childPath);
if (
childRealPath !== parentRealPath &&
!childRealPath.startsWith(`${parentRealPath}${path.sep}`)
) {
throw new Error(`${label} resolved outside ${parentPath}: ${childRealPath}`);
}
}
function assertClawHubExternalInstallContract(installPath) {
const openclawPeerPath = path.join(installPath, "node_modules", "openclaw");
if (!fs.existsSync(openclawPeerPath)) {
throw new Error(`missing kitchen-sink openclaw peer symlink: ${openclawPeerPath}`);
}
if (!fs.lstatSync(openclawPeerPath).isSymbolicLink()) {
throw new Error(`kitchen-sink openclaw peer is not a symlink: ${openclawPeerPath}`);
}
const hostRoot = fs.realpathSync(process.cwd());
const linkedHostRoot = fs.realpathSync(openclawPeerPath);
if (linkedHostRoot !== hostRoot) {
throw new Error(`expected kitchen-sink openclaw peer ${linkedHostRoot} to target ${hostRoot}`);
}
const dependencyPackagePath = path.join(installPath, "node_modules", "is-number", "package.json");
if (fs.existsSync(dependencyPackagePath)) {
assertRealPathInside(installPath, dependencyPackagePath, "kitchen-sink isolated dependency");
}
}
function assertClawHubArtifactMetadata(record) {
if (record.artifactKind === "legacy-zip") {
if (record.artifactFormat !== "zip") {
throw new Error(
`missing kitchen-sink legacy ZIP artifact metadata: ${JSON.stringify(record)}`,
);
}
return;
}
if (record.artifactKind !== "npm-pack" || record.artifactFormat !== "tgz") {
throw new Error(`missing kitchen-sink ClawHub artifact metadata: ${JSON.stringify(record)}`);
}
if (!record.clawpackSha256 || typeof record.clawpackSize !== "number") {
throw new Error(`missing kitchen-sink ClawPack metadata: ${JSON.stringify(record)}`);
}
if (!record.npmIntegrity || !record.npmShasum || !record.npmTarballName) {
throw new Error(`missing kitchen-sink npm artifact metadata: ${JSON.stringify(record)}`);
}
}
function inferInstallSource(spec) {
if (spec?.startsWith("npm:")) {
return "npm";
}
if (spec?.startsWith("clawhub:")) {
return "clawhub";
}
return null;
}
function assertCutoverPreinstalled() {
const pluginId = process.env.KITCHEN_SINK_ID;
const preinstallSpec = process.env.KITCHEN_SINK_PREINSTALL_SPEC;
const source = inferInstallSource(preinstallSpec);
if (!pluginId || !preinstallSpec || !source) {
throw new Error(`invalid kitchen-sink cutover preinstall spec: ${preinstallSpec}`);
}
const record = readPluginInstallRecords()[pluginId];
if (!record) {
throw new Error(`missing kitchen-sink cutover preinstall record for ${pluginId}`);
}
if (record.source !== source) {
throw new Error(`expected kitchen-sink preinstall source=${source}, got ${record.source}`);
}
const expectedSpec = source === "npm" ? preinstallSpec.replace(/^npm:/u, "") : preinstallSpec;
if (record.spec !== expectedSpec) {
throw new Error(`expected kitchen-sink preinstall spec ${expectedSpec}, got ${record.spec}`);
}
}
function assertInstalled() {
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 = readJson(scratchFile(`kitchen-sink-${label}-plugins.json`));
const inspect = readJson(scratchFile(`kitchen-sink-${label}-inspect.json`));
const allInspect = readJson(scratchFile(`kitchen-sink-${label}-inspect-all.json`));
if (!Array.isArray(allInspect)) {
throw new Error("kitchen-sink inspect --all output was not an array");
}
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
if (!plugin) {
throw new Error(`kitchen-sink plugin not found after install: ${pluginId}`);
}
const allInspectPlugin = allInspect.find((entry) => entry?.plugin?.id === pluginId);
if (!allInspectPlugin) {
throw new Error(`kitchen-sink plugin missing from inspect --all output: ${pluginId}`);
}
if (!allInspectPlugin.plugin?.enabled || allInspectPlugin.plugin?.status !== "loaded") {
throw new Error(
`expected enabled loaded kitchen-sink plugin in inspect --all, got enabled=${allInspectPlugin.plugin?.enabled} status=${allInspectPlugin.plugin?.status}`,
);
}
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 || inspect.plugin?.status !== "loaded") {
throw new Error(
`expected enabled loaded kitchen-sink plugin, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`,
);
}
if (surfaceMode !== "adversarial") {
expectIncludes(inspect.plugin?.channelIds, "kitchen-sink-channel", "channels");
expectIncludes(inspect.plugin?.providerIds, "kitchen-sink-provider", "providers");
}
if (source === "clawhub") {
expectIncludes(inspect.plugin?.contextEngineIds, pluginId, "context engines");
}
const diagnostics = [
...(list.diagnostics || []),
...(inspect.diagnostics || []),
...(allInspectPlugin.diagnostics || []),
];
const errorMessages = new Set(
diagnostics.filter((diag) => diag?.level === "error").map((diag) => String(diag.message || "")),
);
if (surfaceMode === "full" || surfaceMode === "conformance") {
const toolNames = Array.isArray(inspect.tools)
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
: [];
const pluginSurfaceIds = {
speechProviderIds: [
["kitchen-sink-speech", "kitchen-sink-speech-provider"],
"speech providers",
],
realtimeTranscriptionProviderIds: [
["kitchen-sink-realtime-transcription", "kitchen-sink-realtime-transcription-provider"],
"realtime transcription providers",
],
realtimeVoiceProviderIds: [
["kitchen-sink-realtime-voice", "kitchen-sink-realtime-voice-provider"],
"realtime voice providers",
],
mediaUnderstandingProviderIds: [
["kitchen-sink-media", "kitchen-sink-media-understanding-provider"],
"media understanding providers",
],
imageGenerationProviderIds: [
["kitchen-sink-image", "kitchen-sink-image-generation-provider"],
"image generation providers",
],
videoGenerationProviderIds: [
["kitchen-sink-video", "kitchen-sink-video-generation-provider"],
"video generation providers",
],
musicGenerationProviderIds: [
["kitchen-sink-music", "kitchen-sink-music-generation-provider"],
"music generation providers",
],
webFetchProviderIds: [
["kitchen-sink-fetch", "kitchen-sink-web-fetch-provider"],
"web fetch providers",
],
webSearchProviderIds: [
["kitchen-sink-search", "kitchen-sink-web-search-provider"],
"web search providers",
],
migrationProviderIds: [
["kitchen-sink-migration-providers", "kitchen-sink-migration-provider"],
"migration providers",
],
};
for (const [field, [ids, labelLocal]] of Object.entries(pluginSurfaceIds)) {
expectIncludesAny(inspect.plugin?.[field], ids, labelLocal);
}
expectMissing(inspect.plugin?.agentHarnessIds, "kitchen-sink-agent-harness", "agent harnesses");
expectIncludes(inspect.services, "kitchen-sink-service", "services");
if (surfaceMode === "full") {
expectIncludesAny(inspect.commands, ["kitchen", "kitchen-sink-command"], "commands");
for (const toolName of [
"kitchen_sink_text",
"kitchen_sink_search",
"kitchen_sink_image_job",
]) {
expectIncludes(toolNames, toolName, "tools");
}
} else {
expectIncludes(inspect.commands, "kitchen", "commands");
expectIncludes(toolNames, "kitchen_sink_text", "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}`,
);
}
}
assertExpectedDiagnostics(surfaceMode, errorMessages);
const record = readPluginInstallRecords()[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)}`);
}
assertClawHubArtifactMetadata(record);
}
if (typeof record.installPath !== "string" || record.installPath.length === 0) {
throw new Error("missing kitchen-sink install path");
}
const installPath = resolveHomePath(record.installPath);
if (!fs.existsSync(installPath)) {
throw new Error(`kitchen-sink install path missing: ${record.installPath}`);
}
if (source === "clawhub") {
const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions");
assertRealPathInside(extensionsRoot, installPath, "kitchen-sink ClawHub install path");
}
if (source === "clawhub" && record.artifactKind === "npm-pack") {
assertClawHubExternalInstallContract(installPath);
}
fs.writeFileSync(scratchFile(`kitchen-sink-${label}-install-path.txt`), installPath, "utf8");
}
function assertRemoved() {
const pluginId = process.env.KITCHEN_SINK_ID;
const label = process.env.KITCHEN_SINK_LABEL;
const list = readJson(scratchFile(`kitchen-sink-${label}-uninstalled.json`));
if ((list.plugins || []).some((entry) => entry.id === pluginId)) {
throw new Error(`kitchen-sink plugin still listed after uninstall: ${pluginId}`);
}
const records = readPluginInstallRecords();
if (records[pluginId]) {
throw new Error(`kitchen-sink install record still present after uninstall: ${pluginId}`);
}
const { config } = readConfig();
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 = scratchFile(`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}`);
}
}
}
const commands = {
"expect-failure": expectFailure,
"scan-logs": scanLogs,
"configure-runtime": configureRuntime,
"remove-channel-config": removeChannelConfig,
"assert-cutover-preinstalled": assertCutoverPreinstalled,
"assert-installed": assertInstalled,
"assert-removed": assertRemoved,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown kitchen-sink assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
KITCHEN_SINK_SWEEP_SOURCE_ONLY="${KITCHEN_SINK_SWEEP_SOURCE_ONLY:-0}"
if [[ -z "${OPENCLAW_ENTRY:-}" && "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
fi
export OPENCLAW_ENTRY
KITCHEN_SINK_CREATED_TMP_DIR=0
if [[ -z "${KITCHEN_SINK_TMP_DIR:-}" ]]; then
KITCHEN_SINK_TMP_DIR="$(mktemp -d "/tmp/openclaw-kitchen-sink.XXXXXX")"
KITCHEN_SINK_CREATED_TMP_DIR=1
else
mkdir -p "$KITCHEN_SINK_TMP_DIR"
fi
export KITCHEN_SINK_TMP_DIR
KITCHEN_SINK_CLI_TIMEOUT="${KITCHEN_SINK_CLI_TIMEOUT:-180s}"
KITCHEN_SINK_CLAWHUB_FIXTURE_DIR=""
KITCHEN_SINK_CLAWHUB_PID_FILE=""
cleanup_kitchen_sink_sweep() {
if [[ -n "${KITCHEN_SINK_CLAWHUB_PID_FILE:-}" && -f "$KITCHEN_SINK_CLAWHUB_PID_FILE" ]]; then
openclaw_e2e_stop_process "$(cat "$KITCHEN_SINK_CLAWHUB_PID_FILE" 2>/dev/null || true)"
fi
if [[ -n "${KITCHEN_SINK_CLAWHUB_FIXTURE_DIR:-}" ]]; then
rm -rf "$KITCHEN_SINK_CLAWHUB_FIXTURE_DIR"
fi
if [[ "${KITCHEN_SINK_CREATED_TMP_DIR:-0}" = "1" ]]; then
rm -rf "$KITCHEN_SINK_TMP_DIR"
fi
}
if [[ "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
trap cleanup_kitchen_sink_sweep EXIT
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
fi
print_kitchen_sink_log() {
local log_file="$1"
local max_bytes
max_bytes="$(openclaw_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536)" || return $?
if [ ! -f "$log_file" ]; then
return 0
fi
local log_bytes
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
if ! [[ "$log_bytes" =~ ^[0-9]+$ ]]; then
log_bytes="0"
fi
if [ "$log_bytes" -le "$max_bytes" ]; then
cat "$log_file"
return 0
fi
echo "--- ${log_file} truncated: showing last ${max_bytes} of ${log_bytes} bytes ---"
tail -c "$max_bytes" "$log_file"
}
openclaw_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536 >/dev/null
run_kitchen_sink_openclaw_logged() {
local label="$1"
shift
local safe_label="${label//[^[:alnum:]._-]/_}"
local log_file="${KITCHEN_SINK_TMP_DIR}/${safe_label}.log"
if ! openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$log_file" 2>&1; then
print_kitchen_sink_log "$log_file"
return 1
fi
print_kitchen_sink_log "$log_file"
}
run_kitchen_sink_openclaw_capture() {
local output_file="$1"
shift
openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file"
}
run_expect_failure() {
local label="$1"
shift
local safe_label="${label//[^[:alnum:]._-]/_}"
local output_file="${KITCHEN_SINK_TMP_DIR}/kitchen-sink-expected-failure-${safe_label}.log"
set +e
"$@" >"$output_file" 2>&1
local status="$?"
set -e
print_kitchen_sink_log "$output_file"
if [ "$status" -eq 0 ]; then
echo "Expected ${label} to fail, but it succeeded." >&2
exit 1
fi
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs expect-failure "$output_file"
}
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 scripts/e2e/lib/clawhub-fixture-server.cjs kitchen-sink-plugin "$server_port_file" >"$server_log" 2>&1 &
local server_pid="$!"
echo "$server_pid" >"$server_pid_file"
KITCHEN_SINK_CLAWHUB_FIXTURE_DIR="$fixture_dir"
KITCHEN_SINK_CLAWHUB_PID_FILE="$server_pid_file"
local wait_attempts
wait_attempts="$(openclaw_e2e_read_positive_int_env OPENCLAW_CLAWHUB_FIXTURE_WAIT_ATTEMPTS 600)" || return $?
for _ in $(seq 1 "$wait_attempts"); do
if [[ -s "$server_port_file" ]]; then
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
print_kitchen_sink_log "$server_log"
return 1
fi
sleep 0.1
done
print_kitchen_sink_log "$server_log"
ps -p "$server_pid" -o pid=,stat=,etime=,command= || true
echo "Timed out waiting for kitchen-sink ClawHub fixture server." >&2
return 1
}
scan_logs_for_unexpected_errors() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs scan-logs
}
configure_kitchen_sink_runtime() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs configure-runtime
}
remove_kitchen_sink_channel_config() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs remove-channel-config
}
assert_kitchen_sink_installed() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-installed
}
assert_kitchen_sink_removed() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-removed
}
assert_kitchen_sink_cutover_preinstalled() {
node scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs assert-cutover-preinstalled
}
run_success_scenario() {
echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..."
local install_args=("$KITCHEN_SINK_SPEC")
if [ -n "${KITCHEN_SINK_PREINSTALL_SPEC:-}" ]; then
run_kitchen_sink_openclaw_logged "kitchen-sink-preinstall-${KITCHEN_SINK_LABEL}" plugins install "$KITCHEN_SINK_PREINSTALL_SPEC"
assert_kitchen_sink_cutover_preinstalled
install_args+=("--force")
fi
run_kitchen_sink_openclaw_logged "kitchen-sink-install-${KITCHEN_SINK_LABEL}" plugins install "${install_args[@]}"
configure_kitchen_sink_runtime
run_kitchen_sink_openclaw_logged "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" plugins enable "$KITCHEN_SINK_ID"
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json" plugins list --json
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect.json" plugins inspect "$KITCHEN_SINK_ID" --runtime --json
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-inspect-all.json" plugins inspect --all --runtime --json
assert_kitchen_sink_installed
if [ "$KITCHEN_SINK_SOURCE" = "clawhub" ]; then
run_kitchen_sink_openclaw_logged "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" plugins uninstall "$KITCHEN_SINK_SPEC" --force
else
run_kitchen_sink_openclaw_logged "kitchen-sink-uninstall-${KITCHEN_SINK_LABEL}" plugins uninstall "$KITCHEN_SINK_ID" --force
fi
remove_kitchen_sink_channel_config
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --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}" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC"
remove_kitchen_sink_channel_config
run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --json
assert_kitchen_sink_removed
}
run_kitchen_sink_sweep_main() {
if [[ "$KITCHEN_SINK_SCENARIOS" == *"clawhub:"* ]]; then
if [[ "${OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB:-0}" = "1" ]]; then
export OPENCLAW_CLAWHUB_URL="${OPENCLAW_CLAWHUB_URL:-${CLAWHUB_URL:-https://clawhub.ai}}"
else
if [[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]; then
echo "Ignoring ambient ClawHub URL for fixture-mode kitchen-sink E2E; set OPENCLAW_KITCHEN_SINK_LIVE_CLAWHUB=1 for live ClawHub."
fi
unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL
clawhub_fixture_dir="$(mktemp -d "${KITCHEN_SINK_TMP_DIR}/clawhub.XXXXXX")"
start_kitchen_sink_clawhub_fixture_server "$clawhub_fixture_dir"
fi
fi
scenario_count=0
while IFS='|' read -r label spec plugin_id source expectation surface_mode personality preinstall_spec; 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"
export KITCHEN_SINK_PERSONALITY="${personality:-}"
export OPENCLAW_KITCHEN_SINK_PERSONALITY="${personality:-}"
export KITCHEN_SINK_PREINSTALL_SPEC="${preinstall_spec:-}"
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))"
}
if [[ "$KITCHEN_SINK_SWEEP_SOURCE_ONLY" != "1" ]]; then
run_kitchen_sink_sweep_main
fi

View File

@@ -0,0 +1,635 @@
// Assertions for live plugin tool E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { extractAgentReplyTexts } from "../agent-turn-output.mjs";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
const command = process.argv[2];
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`invalid ${name}: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`invalid ${name}: ${text}`);
}
return value;
}
const agentTurnTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS",
300,
);
const SCAN_CHUNK_BYTES = 64 * 1024;
const SCAN_CARRY_CHARS = 256;
const SESSION_JSONL_LINE_MAX_BYTES = 1024 * 1024;
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const AGENT_OUTPUT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_MAX_BYTES",
1024 * 1024,
);
const SESSION_FILE_LIST_LIMIT = 20;
const SESSION_SCAN_MAX_ENTRIES = readPositiveIntEnv(
"OPENCLAW_LIVE_PLUGIN_TOOL_SESSION_SCAN_MAX_ENTRIES",
50_000,
);
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
function stateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
}
function configPath() {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
}
function agentOutputPath() {
return process.env.OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_OUTPUT_PATH || "/tmp/openclaw-agent.json";
}
function agentErrorPath() {
return process.env.OPENCLAW_LIVE_PLUGIN_TOOL_AGENT_ERROR_PATH || "/tmp/openclaw-agent.err";
}
function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readNonEmptyString(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeToolCallId(value) {
const id = readNonEmptyString(value);
return id || undefined;
}
function stringifyToolResult(value) {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value
.map((entry) => stringifyToolResult(entry))
.filter(Boolean)
.join("\n");
}
if (!isRecord(value)) {
return value == null ? "" : String(value);
}
const nested = value.text ?? value.content ?? value.result ?? value.output;
return nested === undefined ? JSON.stringify(value) : stringifyToolResult(nested);
}
function extractTranscriptText(value) {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value
.map((entry) => extractTranscriptText(entry))
.filter(Boolean)
.join("\n");
}
if (!isRecord(value)) {
return value == null ? "" : String(value);
}
return extractTranscriptText(value.text ?? value.content ?? value.result ?? value.output ?? "");
}
function extractTranscriptToolCalls(message) {
const calls = [];
const content = message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (!isRecord(block)) {
continue;
}
const type = readNonEmptyString(block.type)?.toLowerCase();
if (type !== "tool_use" && type !== "toolcall" && type !== "tool_call") {
continue;
}
const tool = readNonEmptyString(block.name);
if (!tool) {
continue;
}
calls.push({
id:
normalizeToolCallId(block.id) ??
normalizeToolCallId(block.toolCallId) ??
normalizeToolCallId(block.toolUseId),
tool,
});
}
}
const rawToolCalls =
message.tool_calls ?? message.toolCalls ?? message.function_call ?? message.functionCall;
const toolCalls = Array.isArray(rawToolCalls) ? rawToolCalls : rawToolCalls ? [rawToolCalls] : [];
for (const call of toolCalls) {
if (!isRecord(call)) {
continue;
}
const functionRecord = isRecord(call.function) ? call.function : undefined;
const tool = readNonEmptyString(call.name) ?? readNonEmptyString(functionRecord?.name);
if (!tool) {
continue;
}
calls.push({
id:
normalizeToolCallId(call.id) ??
normalizeToolCallId(call.toolCallId) ??
normalizeToolCallId(call.toolUseId),
tool,
});
}
return calls;
}
function isFailureLikeToolResult(params) {
return (
params.type === "tool_result_error" ||
params.isError === true ||
params.is_error === true ||
/\b(?:denied|enoent|error|exception|fail(?:ed|ure)?|forbidden|invalid|missing|not found|permission)\b/iu.test(
params.text,
)
);
}
function extractTranscriptToolResults(message) {
const results = [];
const tool =
readNonEmptyString(message.toolName) ??
readNonEmptyString(message.tool_name) ??
readNonEmptyString(message.name) ??
readNonEmptyString(message.tool);
if ((message.role === "tool" || message.role === "toolResult") && message.content !== undefined) {
const text = extractTranscriptText(message.content);
results.push({
id:
normalizeToolCallId(message.tool_call_id) ??
normalizeToolCallId(message.toolCallId) ??
normalizeToolCallId(message.toolUseId) ??
normalizeToolCallId(message.id),
...(tool ? { tool } : {}),
text,
failure: isFailureLikeToolResult({
text,
isError: message.isError,
is_error: message.is_error,
}),
});
}
const content = message.content;
if (!Array.isArray(content)) {
return results;
}
for (const block of content) {
if (!isRecord(block)) {
continue;
}
const type = readNonEmptyString(block.type)?.toLowerCase();
if (type !== "tool_result" && type !== "toolresult" && type !== "tool_result_error") {
continue;
}
const text = stringifyToolResult(
block.content ?? block.text ?? block.result ?? block.output ?? block.error ?? block.message,
);
const blockTool =
readNonEmptyString(block.toolName) ??
readNonEmptyString(block.tool_name) ??
readNonEmptyString(block.name) ??
readNonEmptyString(block.tool);
results.push({
id:
normalizeToolCallId(block.tool_use_id) ??
normalizeToolCallId(block.toolUseId) ??
normalizeToolCallId(block.tool_call_id) ??
normalizeToolCallId(block.toolCallId) ??
normalizeToolCallId(block.id),
...(blockTool ? { tool: blockTool } : {}),
text,
failure: isFailureLikeToolResult({
type,
text,
isError: block.isError,
is_error: block.is_error,
}),
});
}
return results;
}
function resultLinksToolCall(call, result, targetCallCount) {
if (call.id || result.id) {
return Boolean(call.id && result.id && call.id === result.id);
}
if (result.tool) {
return result.tool === call.tool;
}
return targetCallCount === 1;
}
function createToolEvidenceTracker(toolName, expected) {
const calls = [];
return {
recordMessage(message) {
for (const call of extractTranscriptToolCalls(message)) {
if (call.tool === toolName) {
calls.push(call);
}
}
for (const result of extractTranscriptToolResults(message)) {
if (result.failure || !result.text.includes(expected)) {
continue;
}
if (calls.some((call) => resultLinksToolCall(call, result, calls.length))) {
return true;
}
}
return false;
},
};
}
function transcriptMessageFromLine(line) {
try {
const parsed = JSON.parse(line);
if (!isRecord(parsed)) {
return undefined;
}
return isRecord(parsed.message) ? parsed.message : parsed;
} catch {
return undefined;
}
}
function scanFileForToolEvidence(file, toolName, expected) {
const tracker = createToolEvidenceTracker(toolName, expected);
let stat;
try {
stat = fs.statSync(file);
} catch {
return false;
}
if (!stat.isFile() || stat.size <= 0) {
return false;
}
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(Math.min(SCAN_CHUNK_BYTES, stat.size));
let pendingLine = "";
let offset = 0;
while (offset < stat.size) {
const bytesToRead = Math.min(buffer.length, stat.size - offset);
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
if (bytesRead <= 0) {
break;
}
offset += bytesRead;
const lines = (pendingLine + buffer.subarray(0, bytesRead).toString("utf8")).split(/\r?\n/u);
pendingLine = lines.pop() ?? "";
if (Buffer.byteLength(pendingLine) > SESSION_JSONL_LINE_MAX_BYTES) {
pendingLine = pendingLine.slice(-SCAN_CARRY_CHARS);
}
for (const line of lines) {
const message = transcriptMessageFromLine(line.trim());
if (message && tracker.recordMessage(message)) {
return true;
}
}
}
const message = transcriptMessageFromLine(pendingLine.trim());
if (message && tracker.recordMessage(message)) {
return true;
}
} finally {
fs.closeSync(fd);
}
return false;
}
function scanSessionTranscripts(sessionsDir, toolName, expected) {
const checkedFiles = [];
let filesChecked = 0;
let stat;
try {
stat = fs.statSync(sessionsDir);
} catch {
return { checkedFiles, filesChecked, found: false, missingDir: true };
}
if (!stat.isDirectory()) {
return { checkedFiles, filesChecked, found: false, missingDir: true };
}
const pendingDirs = [sessionsDir];
let scannedEntries = 0;
while (pendingDirs.length > 0) {
const dir = pendingDirs.pop();
const handle = fs.opendirSync(dir);
try {
let entry;
while ((entry = handle.readSync()) !== null) {
scannedEntries += 1;
if (scannedEntries > SESSION_SCAN_MAX_ENTRIES) {
throw new Error(
`session transcript scan exceeded ${SESSION_SCAN_MAX_ENTRIES} filesystem entries`,
);
}
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
pendingDirs.push(entryPath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
continue;
}
filesChecked += 1;
if (checkedFiles.length < SESSION_FILE_LIST_LIMIT) {
checkedFiles.push(path.relative(sessionsDir, entryPath));
}
if (scanFileForToolEvidence(entryPath, toolName, expected)) {
return { checkedFiles, filesChecked, found: true, missingDir: false };
}
}
} finally {
handle.closeSync();
}
}
return { checkedFiles, filesChecked, found: false, missingDir: false };
}
function realPathMaybe(filePath) {
try {
return fs.realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
function assertPathInside(parentPath, childPath, label) {
const parent = realPathMaybe(parentPath);
const child = realPathMaybe(childPath);
const relative = path.relative(parent, child);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} resolved outside ${parentPath}: ${child}`);
}
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function installRecords() {
const cfg = fs.existsSync(configPath()) ? readJson(configPath()) : {};
return readPluginInstallRecords({
stateDir: stateDir(),
configPath: configPath(),
fallbackRecords: cfg.plugins?.installs ?? {},
});
}
function pluginInstallPath() {
const pluginId = requireEnv("PLUGIN_ID");
const inspect = fs.existsSync("/tmp/openclaw-plugin-inspect.json")
? readJson("/tmp/openclaw-plugin-inspect.json")
: {};
const record = installRecords()[pluginId] || inspect.install;
if (!record) {
throw new Error(`missing ${pluginId} install record`);
}
if (record.source !== "npm" || record.artifactKind !== "npm-pack") {
throw new Error(`expected npm-pack install record: ${JSON.stringify(record)}`);
}
return String(record.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME);
}
function writeFixture() {
const dir = process.argv[3];
if (!dir) {
throw new Error("write-fixture requires output dir");
}
const pluginId = requireEnv("PLUGIN_ID");
const pluginName = requireEnv("PLUGIN_NAME");
const version = requireEnv("PLUGIN_VERSION");
const toolName = requireEnv("TOOL_NAME");
const seed = requireEnv("SEED");
writeJson(path.join(dir, "package.json"), {
name: pluginName,
version,
dependencies: { slugify: "^1.6.6" },
openclaw: { extensions: ["./index.js"] },
});
writeJson(path.join(dir, "openclaw.plugin.json"), {
id: pluginId,
name: "E2E Slug Tool",
description: "Docker E2E plugin tool fixture",
activation: { onStartup: true },
contracts: { tools: [toolName] },
configSchema: { type: "object", additionalProperties: false },
});
fs.writeFileSync(
path.join(dir, "index.js"),
`const slugify = require("slugify");\n` +
`const value = slugify(${JSON.stringify(seed)}, { lower: true, strict: true });\n` +
`module.exports = {\n` +
` id: ${JSON.stringify(pluginId)},\n` +
` name: "E2E Slug Tool",\n` +
` register(api) {\n` +
` api.registerTool({\n` +
` name: ${JSON.stringify(toolName)},\n` +
` description: "Return the hidden Docker E2E slug generated by the plugin dependency.",\n` +
` parameters: { type: "object", properties: {}, additionalProperties: false },\n` +
` async execute() {\n` +
` return { content: [{ type: "text", text: value }] };\n` +
` },\n` +
` });\n` +
` },\n` +
`};\n`,
);
}
function configure() {
const modelRef = requireEnv("MODEL_REF");
const pluginId = requireEnv("PLUGIN_ID");
const toolName = requireEnv("TOOL_NAME");
const cfgPath = configPath();
const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {};
const [providerId, modelId] = modelRef.split("/");
if (providerId !== "openai" || !modelId) {
throw new Error(`live plugin tool E2E expects an openai/* model, got ${modelRef}`);
}
cfg.plugins = {
...cfg.plugins,
enabled: true,
allow: Array.from(new Set([...(cfg.plugins?.allow || []), "openai", pluginId])).toSorted(
(left, right) => left.localeCompare(right),
),
entries: {
...cfg.plugins?.entries,
openai: { ...cfg.plugins?.entries?.openai, enabled: true },
[pluginId]: { ...cfg.plugins?.entries?.[pluginId], enabled: true },
},
};
cfg.tools = {
...cfg.tools,
allow: [toolName],
};
cfg.models = {
...cfg.models,
mode: "merge",
providers: {
...cfg.models?.providers,
openai: {
...cfg.models?.providers?.openai,
api: "openai-responses",
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
agentRuntime: { id: "openclaw" },
timeoutSeconds: agentTurnTimeoutSeconds,
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
contextTokens: 96000,
maxTokens: 512,
},
],
},
},
};
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: { primary: modelRef, fallbacks: [] },
models: {
...cfg.agents?.defaults?.models,
[modelRef]: {
...cfg.agents?.defaults?.models?.[modelRef],
agentRuntime: { id: "openclaw" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
workspace: path.join(stateDir(), "workspace"),
skipBootstrap: true,
timeoutSeconds: agentTurnTimeoutSeconds,
},
};
writeJson(cfgPath, cfg);
}
function findDependencyPackageJson(packageName) {
const installPath = pluginInstallPath();
const npmRoot = path.join(stateDir(), "npm");
const pluginName = requireEnv("PLUGIN_NAME");
const packageRoot = pluginName.split("/").reduce((current) => path.dirname(current), installPath);
const projectRoot =
path.basename(packageRoot) === "node_modules" ? path.dirname(packageRoot) : npmRoot;
return [
path.join(projectRoot, "node_modules", packageName, "package.json"),
path.join(installPath, "node_modules", packageName, "package.json"),
path.join(npmRoot, "node_modules", packageName, "package.json"),
].find((candidate) => fs.existsSync(candidate));
}
function assertInstalled() {
const pluginId = requireEnv("PLUGIN_ID");
const pluginName = requireEnv("PLUGIN_NAME");
const toolName = requireEnv("TOOL_NAME");
const npmRoot = path.join(stateDir(), "npm");
const installPath = pluginInstallPath();
assertPathInside(npmRoot, installPath, "fixture plugin install path");
const packageJson = path.join(installPath, "package.json");
if (!fs.existsSync(packageJson)) {
throw new Error(`missing fixture plugin package.json: ${packageJson}`);
}
const pkg = readJson(packageJson);
if (pkg.name !== pluginName) {
throw new Error(`unexpected fixture package name: ${pkg.name}`);
}
const slugifyPackageJson = findDependencyPackageJson("slugify");
if (!slugifyPackageJson) {
throw new Error("missing slugify dependency installed by npm-pack plugin install");
}
assertPathInside(npmRoot, slugifyPackageJson, "slugify dependency");
const list = readJson("/tmp/openclaw-plugins-list.json");
const plugin = (list.plugins || []).find((entry) => entry.id === pluginId);
if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") {
throw new Error(`fixture plugin was not enabled+loaded: ${JSON.stringify(plugin)}`);
}
const inspect = readJson("/tmp/openclaw-plugin-inspect.json");
const toolNames = Array.isArray(inspect.tools)
? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : []))
: [];
if (!toolNames.includes(toolName)) {
throw new Error(`fixture tool was not registered: ${JSON.stringify(inspect.tools)}`);
}
}
function assertAgentTurn() {
const expected = requireEnv("EXPECTED_SLUG");
const toolName = requireEnv("TOOL_NAME");
const outputPath = agentOutputPath();
const errorPath = agentErrorPath();
const outputStat = fs.statSync(outputPath);
if (outputStat.isFile() && outputStat.size > AGENT_OUTPUT_MAX_BYTES) {
const stdoutTail = readTextFileTail(outputPath, ERROR_DETAIL_TAIL_BYTES);
const stderrTail = readTextFileTail(errorPath, ERROR_DETAIL_TAIL_BYTES);
throw new Error(
`live agent output exceeded ${AGENT_OUTPUT_MAX_BYTES} bytes:\nstdout tail=${stdoutTail}\nstderr tail=${stderrTail}`,
);
}
const stdout = fs.readFileSync(outputPath, "utf8");
const response = JSON.parse(stdout);
const text = extractAgentReplyTexts(JSON.stringify(response)).join("\n");
if (!text.includes(expected)) {
const stderrTail = readTextFileTail(errorPath, ERROR_DETAIL_TAIL_BYTES);
throw new Error(
`live agent reply did not contain tool slug ${expected}:\nstdout tail=${tailText(stdout, ERROR_DETAIL_TAIL_BYTES)}\nstderr tail=${stderrTail}`,
);
}
const sessionsDir = path.join(stateDir(), "agents", "main", "sessions");
const scan = scanSessionTranscripts(sessionsDir, toolName, expected);
if (!scan.found) {
const checkedFiles = scan.checkedFiles.length > 0 ? scan.checkedFiles.join(", ") : "<none>";
const missingDir = scan.missingDir ? " sessions directory was missing." : "";
throw new Error(
`session transcript did not show ${toolName} returning ${expected}; missing causal tool-result evidence after checking ${scan.filesChecked} jsonl file(s): ${checkedFiles}.${missingDir}`,
);
}
}
const commands = {
"write-fixture": writeFixture,
configure,
"assert-installed": assertInstalled,
"assert-agent-turn": assertAgentTurn,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown live plugin tool assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,41 @@
// MCP code-mode probe server fixture shared by local and Docker E2E scripts.
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
export async function writeProbeMcpServer(serverPath: string) {
const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const zodPath = require.resolve("zod");
await fs.mkdir(path.dirname(serverPath), { recursive: true });
await fs.writeFile(
serverPath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(sdkMcpServerPath)};
import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)};
import { z } from ${JSON.stringify(zodPath)};
const notes = new Map([
["alpha", "fixture-note-alpha"],
["beta", "fixture-note-beta"],
]);
const server = new McpServer({ name: "code-mode-fixture", version: "1.0.0" });
server.tool(
"lookup_note",
"Look up one read-only fixture note by id.",
{
id: z.string().describe("Fixture note id to look up."),
},
async ({ id }) => ({
content: [{ type: "text", text: notes.get(id) ?? "missing-note" }],
}),
);
await server.connect(new StdioServerTransport());
`,
{ encoding: "utf8", mode: 0o755 },
);
}

View File

@@ -0,0 +1,61 @@
export type McpCodeModeMentions = Record<
"apiCall" | "apiFileList" | "apiFileRead" | "mcpNamespace" | "mcpTool" | "toolSearchPollution",
number
>;
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
export function outputText(response: unknown): string {
const output = (response as { output?: Array<{ type?: unknown; content?: unknown }> }).output;
if (!Array.isArray(output)) {
return "";
}
return output
.flatMap((item) => {
if (item.type !== "message" || !Array.isArray(item.content)) {
return [];
}
return item.content.flatMap((piece) => {
if (!piece || typeof piece !== "object") {
return [];
}
const record = piece as { text?: unknown };
return typeof record.text === "string" ? [record.text] : [];
});
})
.join("\n");
}
export function validateMcpCodeModeResult(
response: unknown,
mentions: McpCodeModeMentions,
options: { plannedTools?: string[]; requireExec?: boolean } = {},
): string {
const finalText = outputText(response);
assert(
finalText.includes("MCP_CODE_MODE_FILE_OK"),
`agent did not complete MCP API file check: ${finalText}`,
);
assert(
finalText.includes("fixture-note-alpha"),
`agent did not return fixture note from MCP call: ${finalText}`,
);
assert(
!/MCP\s+(?:was\s+)?not\s+defined|failed|error/i.test(finalText),
`agent reported MCP failure instead of a successful call: ${finalText}`,
);
if (options.requireExec) {
assert(options.plannedTools?.includes("exec"), "agent did not call code-mode exec");
}
assert(mentions.apiFileList > 0, "session log lacks API.list usage");
assert(mentions.apiFileRead > 0, "session log lacks API.read usage");
assert(mentions.mcpNamespace > 0, "session log lacks MCP.fixture usage");
assert(mentions.mcpTool > 0, "session log lacks fixture__lookup_note call");
assert(mentions.apiCall === 0, "agent should not call MCP.$api when API files are available");
assert(mentions.toolSearchPollution === 0, "agent should not use tools.search for MCP lookup");
return finalText;
}

View File

@@ -0,0 +1,124 @@
// Mock OpenAI-compatible HTTP server helpers for E2E scenarios.
import fs from "node:fs";
import { readPositiveIntEnv } from "./env-limits.mjs";
const DEFAULT_REQUEST_MAX_BYTES = 4 * 1024 * 1024;
const DEFAULT_REQUEST_LOG_BODY_MAX_BYTES = 256 * 1024;
const REQUEST_LOG_PREVIEW_CHARS = 4096;
export function readMockOpenAiHttpLimits(env = process.env) {
return {
requestMaxBytes: readPositiveIntEnv(
"OPENCLAW_MOCK_OPENAI_REQUEST_MAX_BYTES",
DEFAULT_REQUEST_MAX_BYTES,
env,
),
requestLogBodyMaxBytes: readPositiveIntEnv(
"OPENCLAW_MOCK_OPENAI_REQUEST_LOG_BODY_MAX_BYTES",
DEFAULT_REQUEST_LOG_BODY_MAX_BYTES,
env,
),
};
}
function requestBodyTooLargeError(limit) {
return Object.assign(new Error(`mock OpenAI request body exceeded ${limit} bytes`), {
code: "ETOOBIG",
});
}
export function isRequestBodyTooLargeError(error) {
return error instanceof Error && error.code === "ETOOBIG";
}
export function readBody(req, limits = readMockOpenAiHttpLimits()) {
const { requestMaxBytes } = limits;
return new Promise((resolve, reject) => {
let body = "";
let bytes = 0;
let settled = false;
req.setEncoding("utf8");
req.on("data", (chunk) => {
if (settled) {
return;
}
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > requestMaxBytes) {
settled = true;
body = "";
req.resume();
reject(requestBodyTooLargeError(requestMaxBytes));
return;
}
body += chunk;
});
req.on("end", () => {
if (!settled) {
settled = true;
resolve(body);
}
});
req.on("error", (error) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
}
export function boundedRequestLogBody(value, bodyText, limits = readMockOpenAiHttpLimits()) {
const { requestLogBodyMaxBytes } = limits;
const byteLength = Buffer.byteLength(bodyText, "utf8");
if (byteLength <= requestLogBodyMaxBytes) {
return value;
}
return {
truncated: true,
byteLength,
preview: bodyText.slice(0, REQUEST_LOG_PREVIEW_CHARS),
};
}
export function writeRequestLogEntryOrFail(
res,
{ requestLog, entry, label = "mock-openai", required = false },
) {
if (!requestLog) {
if (!required) {
return false;
}
const message = "MOCK_REQUEST_LOG is not configured";
console.error(`${label} request log write failed: ${message}`);
writeJson(res, 500, { error: { message: `mock OpenAI request log write failed: ${message}` } });
return true;
}
try {
fs.appendFileSync(requestLog, `${JSON.stringify(entry)}\n`);
return false;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${label} request log write failed: ${message}`);
writeJson(res, 500, { error: { message: `mock OpenAI request log write failed: ${message}` } });
return true;
}
}
export function writeJson(res, status, body) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
export function writeSse(res, events) {
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-store",
connection: "keep-alive",
});
for (const event of events) {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
}

View File

@@ -0,0 +1,275 @@
// Assertions for npm onboard channel-agent E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import {
assertAgentReplyContainsMarker,
assertOpenAiRequestLogUsed,
} from "../agent-turn-output.mjs";
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
import { readPositiveIntEnv } from "../env-limits.mjs";
import {
applyMockOpenAiModelConfig,
parseMockOpenAiPort,
} from "../fixtures/mock-openai-config.mjs";
import { readTextFileBounded, readTextFileTail } from "../text-file-utils.mjs";
const command = process.argv[2];
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_NPM_ONBOARD_JSON_ARTIFACT_MAX_BYTES",
1024 * 1024,
);
const STATUS_TEXT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_NPM_ONBOARD_STATUS_TEXT_MAX_BYTES",
1024 * 1024,
);
const ansiEscapePattern = new RegExp(String.raw`\u001b\[[0-?]*[ -/]*[@-~]`, "g");
function readJson(file) {
return JSON.parse(
readTextFileBounded(file, "JSON artifact", JSON_ARTIFACT_MAX_BYTES, {
tailBytes: ERROR_DETAIL_TAIL_BYTES,
}),
);
}
function stripAnsi(text) {
return text.replace(ansiEscapePattern, "");
}
const statusSectionTitles = new Set([
"openclaw status",
"overview",
"plugin compatibility",
"model selection",
"security audit",
"channels",
"sessions",
"system events",
"health",
"usage",
]);
function normalizedStatusHeading(line) {
return stripAnsi(line)
.trim()
.replace(/^#+\s*/, "")
.trim()
.toLowerCase();
}
function extractStatusSection(text, title) {
const target = title.toLowerCase();
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => normalizedStatusHeading(line) === target);
if (start === -1) {
return null;
}
const section = [];
for (const line of lines.slice(start + 1)) {
const normalized = normalizedStatusHeading(line);
if (normalized && statusSectionTitles.has(normalized)) {
break;
}
section.push(line);
}
return stripAnsi(section.join("\n"));
}
function readAuthProfileStoreText(agentDir) {
const dbPath = path.join(agentDir, "openclaw-agent.sqlite");
if (!fs.existsSync(dbPath)) {
return "";
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
.get("primary");
return typeof row?.store_json === "string" ? row.store_json : "";
} catch {
return "";
} finally {
db?.close();
}
}
function assertOnboardState() {
const home = process.argv[3];
const stateDir = path.join(home, ".openclaw");
const configPath = path.join(stateDir, "openclaw.json");
const agentDir = path.join(stateDir, "agents", "main", "agent");
if (!fs.existsSync(configPath)) {
throw new Error("onboard did not write openclaw.json");
}
if (!fs.existsSync(agentDir)) {
throw new Error("onboard did not create main agent dir");
}
const authStoreText = readAuthProfileStoreText(agentDir);
if (!authStoreText) {
throw new Error("onboard did not persist auth profile store");
}
assertOpenAiEnvAuthProfileStore(authStoreText, {
envRefMessage: "auth profile did not persist OPENAI_API_KEY env ref",
rawKeyMessage: "auth profile persisted the raw OpenAI test key",
rawKeyNeedle: "sk-openclaw-npm-onboard-e2e",
});
}
function configureMockModel() {
const mockPort = parseMockOpenAiPort(process.argv[3]);
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const cfg = readJson(configPath);
applyMockOpenAiModelConfig(cfg, { mockPort });
fs.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
}
function assertMockModelConfig() {
const mockPort = parseMockOpenAiPort(process.argv[3]);
const expectedModelRef = "openai/gpt-5.5";
const expectedBaseUrl = `http://127.0.0.1:${mockPort}/v1`;
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const cfg = readJson(configPath);
const provider = cfg.models?.providers?.openai;
const defaultModel = cfg.agents?.defaults?.model?.primary;
const defaultRuntime = cfg.agents?.defaults?.models?.[expectedModelRef]?.agentRuntime?.id;
const agent = Array.isArray(cfg.agents?.list)
? (cfg.agents.list.find((entry) => entry?.id === "main") ?? cfg.agents.list[0])
: undefined;
const agentModel = agent?.model?.primary;
const agentRuntime = agent?.models?.[expectedModelRef]?.agentRuntime?.id;
if (provider?.baseUrl !== expectedBaseUrl) {
throw new Error(
`mock OpenAI baseUrl was not preserved; expected ${expectedBaseUrl}, got ${provider?.baseUrl}`,
);
}
if (provider?.api !== "openai-responses") {
throw new Error(`mock OpenAI api was not preserved; got ${provider?.api}`);
}
if (provider?.agentRuntime?.id !== "openclaw") {
throw new Error(`mock OpenAI runtime was not preserved; got ${provider?.agentRuntime?.id}`);
}
if (defaultModel !== expectedModelRef) {
throw new Error(
`mock default model was not preserved; expected ${expectedModelRef}, got ${defaultModel}`,
);
}
if (defaultRuntime !== "openclaw") {
throw new Error(`mock default runtime was not preserved; got ${defaultRuntime}`);
}
if (agent && agentModel !== expectedModelRef) {
throw new Error(
`mock agent model was not preserved; expected ${expectedModelRef}, got ${agentModel}`,
);
}
if (agent && agentRuntime !== "openclaw") {
throw new Error(`mock agent runtime was not preserved; got ${agentRuntime}`);
}
}
function assertChannelConfig() {
const channel = process.argv[3];
const expectedTokens = process.argv.slice(4);
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
const cfg = readJson(configPath);
const entry = cfg.channels?.[channel];
if (!entry || entry.enabled === false) {
throw new Error(`${channel} was not enabled`);
}
const assertTokenField = (field, expected) => {
if (entry[field] !== expected) {
throw new Error(
`${channel} config did not persist ${field}; expected ${expected}, got ${JSON.stringify(entry[field])}`,
);
}
};
switch (channel) {
case "telegram": {
if (expectedTokens.length !== 1) {
throw new Error("telegram channel config assertion requires one bot token");
}
assertTokenField("botToken", expectedTokens[0]);
return;
}
case "discord": {
if (expectedTokens.length !== 1) {
throw new Error("discord channel config assertion requires one bot token");
}
assertTokenField("token", expectedTokens[0]);
return;
}
case "slack": {
if (expectedTokens.length !== 2) {
throw new Error("slack channel config assertion requires bot and app tokens");
}
assertTokenField("botToken", expectedTokens[0]);
assertTokenField("appToken", expectedTokens[1]);
return;
}
default:
throw new Error(`unsupported channel config assertion: ${channel}`);
}
}
function assertStatusSurfaces() {
const channel = process.argv[3];
const channelsStatusPath = process.argv[4];
const statusTextPath = process.argv[5];
const channelsStatus = readJson(channelsStatusPath);
const statusText = readTextFileBounded(
statusTextPath,
"plain status output",
STATUS_TEXT_MAX_BYTES,
{ tailBytes: ERROR_DETAIL_TAIL_BYTES },
);
const statusTail = readTextFileTail(statusTextPath, ERROR_DETAIL_TAIL_BYTES);
const configuredChannels = Array.isArray(channelsStatus.configuredChannels)
? channelsStatus.configuredChannels
: [];
if (!configuredChannels.includes(channel)) {
throw new Error(
`channels status did not list configured channel ${channel}. Payload: ${JSON.stringify(channelsStatus)}`,
);
}
if (!/channels/i.test(statusText)) {
throw new Error(
`plain status output did not render a Channels section. Output tail: ${statusTail}`,
);
}
const channelsSection = extractStatusSection(statusText, "channels");
if (!channelsSection) {
throw new Error(
`plain status output did not render a Channels section. Output tail: ${statusTail}`,
);
}
if (!channelsSection.toLowerCase().includes(channel.toLowerCase())) {
throw new Error(
`plain status output did not mention ${channel} in the Channels section. Output tail: ${statusTail}`,
);
}
}
function assertAgentTurn() {
const marker = process.argv[3];
const logPath = process.argv[4];
assertAgentReplyContainsMarker(marker, "/tmp/openclaw-agent.combined");
assertOpenAiRequestLogUsed(logPath);
}
const commands = {
"assert-onboard-state": assertOnboardState,
"configure-mock-model": configureMockModel,
"assert-mock-model-config": assertMockModelConfig,
"assert-channel-config": assertChannelConfig,
"assert-status-surfaces": assertStatusSurfaces,
"assert-agent-turn": assertAgentTurn,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown npm onboard/channel/agent assertion command: ${command}`);
}
fn();

View File

@@ -0,0 +1,14 @@
// Prepares package manifests for npm Telegram live E2E scenarios.
import fs from "node:fs";
for (const packageJsonPath of process.argv.slice(2)) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
pkg.exports = pkg.exports && typeof pkg.exports === "object" ? pkg.exports : {};
if (!pkg.exports["./plugin-sdk/gateway-runtime"]) {
pkg.exports["./plugin-sdk/gateway-runtime"] = {
types: "./dist/plugin-sdk/gateway-runtime.d.ts",
default: "./dist/plugin-sdk/gateway-runtime.js",
};
}
fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
}

View File

@@ -0,0 +1,87 @@
// Config assertions for onboard E2E scenarios.
import fs from "node:fs";
import JSON5 from "json5";
const [scenario, configPath, expectedWorkspace] = process.argv.slice(2);
if (!scenario || !configPath) {
throw new Error("usage: assert-config.mjs <scenario> <config-path> [expected-workspace]");
}
const cfg = JSON5.parse(fs.readFileSync(configPath, "utf8"));
const errors = [];
const got = (value) => value ?? "unset";
const expectEqual = (label, actual, expected) => {
if (actual !== expected) {
errors.push(`${label} mismatch (got ${got(actual)})`);
}
};
const assertLocalWizard = () => {
expectEqual("gateway.mode", cfg?.gateway?.mode, "local");
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "local");
};
const assertSectionScopedConfigure = () => {
expectEqual("wizard.lastRunCommand", cfg?.wizard?.lastRunCommand, "configure");
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "local");
if (cfg?.gateway?.mode) {
errors.push(`gateway.mode should stay unset (got ${cfg.gateway.mode})`);
}
};
switch (scenario) {
case "local-basic": {
expectEqual("agents.defaults.workspace", cfg?.agents?.defaults?.workspace, expectedWorkspace);
assertLocalWizard();
expectEqual("gateway.bind", cfg?.gateway?.bind, "loopback");
expectEqual("gateway.tailscale.mode", cfg?.gateway?.tailscale?.mode ?? "off", "off");
if (!cfg?.wizard?.lastRunAt) {
errors.push("wizard.lastRunAt missing");
}
if (!cfg?.wizard?.lastRunVersion) {
errors.push("wizard.lastRunVersion missing");
}
expectEqual("wizard.lastRunCommand", cfg?.wizard?.lastRunCommand, "onboard");
break;
}
case "remote-non-interactive":
expectEqual("gateway.mode", cfg?.gateway?.mode, "remote");
expectEqual("gateway.remote.url", cfg?.gateway?.remote?.url, "ws://gateway.local:18789");
expectEqual("gateway.remote.token", cfg?.gateway?.remote?.token, "remote-token");
expectEqual("wizard.lastRunMode", cfg?.wizard?.lastRunMode, "remote");
break;
case "reset":
assertLocalWizard();
if (cfg?.gateway?.remote?.url) {
errors.push(`gateway.remote.url should be cleared (got ${cfg.gateway.remote.url})`);
}
break;
case "channels":
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=${got(cfg?.slack?.botToken)}, app=${got(cfg?.slack?.appToken)})`,
);
}
assertSectionScopedConfigure();
break;
case "skills":
expectEqual("skills.install.nodeManager", cfg?.skills?.install?.nodeManager, "bun");
if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") {
errors.push("skills.allowBundled missing");
}
assertSectionScopedConfigure();
break;
default:
throw new Error(`unknown onboard assertion scenario: ${scenario}`);
}
if (errors.length > 0) {
console.error(errors.join("\n"));
process.exit(1);
}

View File

@@ -0,0 +1,58 @@
// Log substring assertion helper for onboard E2E scenarios.
import fs from "node:fs";
import { fileURLToPath } from "node:url";
export const DEFAULT_MAX_LOG_BYTES = 120_000;
const normalizeScriptOutput = (value) => value.replace(/\r?\n/g, "").replace(/\r/g, "");
const oscPattern = new RegExp(String.raw`\u001b\][^\u0007]*(?:\u0007|\u001b\\)`, "g");
const csiPattern = new RegExp(String.raw`\u001b\[[0-?]*[ -/]*[@-~]`, "g");
const stripAnsi = (value) =>
normalizeScriptOutput(value).replace(oscPattern, "").replace(csiPattern, "");
const compact = (value) =>
stripAnsi(value)
.toLowerCase()
.replace(/[^a-z]+/g, "");
export function readLogTail(file, maxBytes = DEFAULT_MAX_LOG_BYTES) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
throw new Error("maxBytes must be a positive integer");
}
const stats = fs.statSync(file);
if (!stats.isFile()) {
throw new Error(`${file} is not a file`);
}
const length = Math.min(stats.size, maxBytes);
const start = Math.max(0, stats.size - length);
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
fs.closeSync(fd);
}
}
export function logTailContains(file, needle, maxBytes = DEFAULT_MAX_LOG_BYTES) {
const compactNeedle = compact(needle);
if (!compactNeedle) {
return false;
}
return compact(readLogTail(file, maxBytes)).includes(compactNeedle);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const [file, needle] = process.argv.slice(2);
if (!file || !needle) {
process.exit(1);
}
try {
process.exit(logTailContains(file, needle) ? 0 : 1);
} catch {
process.exit(1);
}
}

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
source scripts/lib/openclaw-e2e-instance.sh
OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY="${OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY:-0}"
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"
fi
ONBOARD_FLAGS="${ONBOARD_FLAGS:---flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui}"
if [ -z "${OPENCLAW_ENTRY:-}" ] && [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
fi
export OPENCLAW_ENTRY
ONBOARD_TMP_ROOT="${OPENCLAW_ONBOARD_E2E_TMPDIR:-${TMPDIR:-/tmp}}"
ONBOARD_TMP_ROOT="${ONBOARD_TMP_ROOT%/}"
[ -n "$ONBOARD_TMP_ROOT" ] || ONBOARD_TMP_ROOT="/tmp"
mkdir -p "$ONBOARD_TMP_ROOT"
ONBOARD_TMP_DIR="$(mktemp -d "$ONBOARD_TMP_ROOT/openclaw-onboard.XXXXXX")"
OPENCLAW_E2E_LOG_DIR="$ONBOARD_TMP_DIR/logs"
GATEWAY_LOG_PATH="$ONBOARD_TMP_DIR/gateway-e2e.log"
export OPENCLAW_E2E_LOG_DIR
export GATEWAY_LOG_PATH
mkdir -p "$OPENCLAW_E2E_LOG_DIR"
cleanup_onboard_artifacts() {
openclaw_e2e_stop_process "${GATEWAY_PID:-}"
rm -rf "$ONBOARD_TMP_DIR"
}
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
trap cleanup_onboard_artifacts EXIT
fi
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
openclaw_e2e_install_trash_shim
fi
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 quiet_on_timeout="${3:-false}"
local start_s
start_s="$(date +%s)"
while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
return 0
fi
if node scripts/e2e/lib/onboard/log-contains.mjs "$WIZARD_LOG_PATH" "$needle"; then
return 0
fi
fi
if [ $(($(date +%s) - start_s)) -ge "$timeout_s" ]; then
if [ "$quiet_on_timeout" = "true" ]; then
return 1
fi
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() {
GATEWAY_PID="$(openclaw_e2e_start_gateway "$OPENCLAW_ENTRY" 18789 "$GATEWAY_LOG_PATH")"
}
wait_for_gateway() {
local wait_attempts
wait_attempts="$(openclaw_e2e_read_positive_int_env OPENCLAW_ONBOARD_GATEWAY_WAIT_ATTEMPTS 20)" || return $?
local wait_interval_s="${OPENCLAW_ONBOARD_GATEWAY_WAIT_INTERVAL_S:-1}"
local saw_listening_log="false"
for _ in $(seq 1 "$wait_attempts"); do
if openclaw_e2e_probe_tcp 127.0.0.1 18789 500 >/dev/null 2>&1; then
return 0
fi
if [ -f "$GATEWAY_LOG_PATH" ] && grep -E -q "listening on ws://[^ ]+:18789" "$GATEWAY_LOG_PATH"; then
saw_listening_log="true"
fi
sleep "$wait_interval_s"
done
echo "Gateway failed to start"
if [ "$saw_listening_log" = "true" ]; then
echo "Gateway log reported listening, but TCP probe never succeeded"
fi
cat "$GATEWAY_LOG_PATH" || true
return 1
}
stop_gateway() {
openclaw_e2e_stop_process "$1"
}
cleanup_wizard_case() {
exec 3>&- 2>/dev/null || true
openclaw_e2e_stop_process "${wizard_pid:-}"
stop_gateway "${gw_pid:-}"
rm -rf "${input_fifo_dir:-}"
}
run_wizard_cmd() {
local case_name="$1"
local state_ref="$2"
local command="$3"
local send_fn="$4"
local with_gateway="${5:-false}"
local validate_fn="${6:-}"
local input_fifo_dir=""
local input_fifo=""
local wizard_pid=""
local gw_pid=""
local wizard_status=0
echo "== Wizard case: $case_name =="
set_isolated_openclaw_env "$state_ref"
input_fifo_dir="$(mktemp -d "$ONBOARD_TMP_DIR/${case_name}.fifo.XXXXXX")"
input_fifo="$input_fifo_dir/stdin.fifo"
if ! mkfifo "$input_fifo"; then
rm -rf "$input_fifo_dir"
return 1
fi
local log_path="$OPENCLAW_E2E_LOG_DIR/${case_name}.log"
WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts.
openclaw_e2e_run_script_with_pty "$command" "$log_path" <"$input_fifo" >/dev/null 2>&1 &
wizard_pid=$!
if ! exec 3>"$input_fifo"; then
cleanup_wizard_case
return 1
fi
if [ "$with_gateway" = "true" ]; then
start_gateway
gw_pid="$GATEWAY_PID"
if ! wait_for_gateway; then
cleanup_wizard_case
exit 1
fi
fi
"$send_fn" || wizard_status=$?
if [ "$wizard_status" -ne 0 ]; then
cleanup_wizard_case
echo "Wizard input driver exited with status $wizard_status"
if [ -f "$log_path" ]; then
tail -n 160 "$log_path" || true
fi
exit "$wizard_status"
fi
wait "$wizard_pid" || wizard_status=$?
wizard_pid=""
if [ "$wizard_status" -ne 0 ]; then
cleanup_wizard_case
echo "Wizard exited with status $wizard_status"
if [ -f "$log_path" ]; then
tail -n 160 "$log_path" || true
fi
exit "$wizard_status"
fi
cleanup_wizard_case
if [ -n "$validate_fn" ]; then
"$validate_fn" "$log_path"
fi
}
assert_onboard_config() {
local scenario="$1"
shift
openclaw_e2e_assert_file "$OPENCLAW_CONFIG_PATH"
node scripts/e2e/lib/onboard/assert-config.mjs "$scenario" "$OPENCLAW_CONFIG_PATH" "$@"
}
set_isolated_openclaw_env() {
local state_ref="$1"
openclaw_test_state_create "$state_ref" empty
}
send_channels_flow() {
# Configure channels via configure wizard. Use the remove-config branch for
# a stable no-op smoke path when the config starts empty.
# Section-scoped configure flows skip gateway run-mode selection.
wait_for_log "Channel setup" 120
send $'\e[B\r' 0.8
# Keep stdin open until wizard exits.
send "" 2.0
}
send_skills_flow() {
# configure --section skills still runs the configure wizard, without the
# gateway run-mode prompt used by the full wizard.
wait_for_log "Configure skills now?" 120
send $'n\r' 0.8
send "" 2.0
}
run_case_local_basic() {
set_isolated_openclaw_env local-basic
openclaw_e2e_run_logged local-basic node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
validate_local_basic_log "$OPENCLAW_E2E_LAST_LOG_PATH"
# Assert config + workspace scaffolding.
workspace_dir="$OPENCLAW_STATE_DIR/workspace"
sessions_dir="$OPENCLAW_STATE_DIR/agents/main/sessions"
openclaw_e2e_assert_dir "$sessions_dir"
for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do
openclaw_e2e_assert_file "$workspace_dir/$file"
done
assert_onboard_config local-basic "$workspace_dir"
}
run_case_remote_non_interactive() {
set_isolated_openclaw_env remote-non-interactive
# Smoke test non-interactive remote config write.
openclaw_e2e_run_logged remote-non-interactive node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
--skip-skills \
--skip-health
assert_onboard_config remote-non-interactive
}
run_case_reset() {
set_isolated_openclaw_env reset-config
node scripts/e2e/lib/onboard/write-config.mjs reset "$OPENCLAW_CONFIG_PATH"
openclaw_e2e_run_logged reset-config node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
assert_onboard_config reset
}
run_case_channels() {
# Channels-only configure flow.
run_wizard_cmd channels channels "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
assert_onboard_config channels
}
run_case_skills() {
local home_dir
set_isolated_openclaw_env skills
home_dir="$HOME"
node scripts/e2e/lib/onboard/write-config.mjs skills "$OPENCLAW_CONFIG_PATH"
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
assert_onboard_config skills
}
validate_local_basic_log() {
local log_path="$1"
openclaw_e2e_assert_log_not_contains "$log_path" "systemctl --user unavailable"
}
if [ "$OPENCLAW_ONBOARD_SCENARIO_SOURCE_ONLY" != "1" ]; then
run_case_local_basic
run_case_remote_non_interactive
run_case_reset
run_case_channels
run_case_skills
fi

View File

@@ -0,0 +1,21 @@
// Config writer helper for onboard E2E scenarios.
import fs from "node:fs";
const [scenario, configPath] = process.argv.slice(2);
if (!scenario || !configPath) {
throw new Error("usage: write-config.mjs <reset|skills> <config-path>");
}
const config = {
reset: {
meta: {},
agents: { defaults: { workspace: "/root/old" } },
gateway: { mode: "remote", remote: { url: "ws://old.example:18789", token: "old-token" } },
},
skills: { meta: {}, skills: { allowBundled: ["__none__"], install: { nodeManager: "bun" } } },
}[scenario];
if (!config) {
throw new Error(`unknown config scenario: ${scenario}`);
}
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);

View File

@@ -0,0 +1,205 @@
// Gateway client for OpenAI chat tools E2E scenarios.
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
const portText = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const backendModel = process.env.MODEL_REF || "openai/gpt-5.4-mini";
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", 180);
const maxBodyBytes = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES", 1048576);
if (!portText || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
const port = readTcpPortEnv("PORT", portText);
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS: ${timeoutSeconds}`);
}
if (!Number.isFinite(maxBodyBytes) || maxBodyBytes <= 0) {
throw new Error(`invalid OPENCLAW_OPENAI_CHAT_TOOLS_MAX_BODY_BYTES: ${maxBodyBytes}`);
}
function cancelReaderSoon(reader) {
void Promise.resolve()
.then(() => reader.cancel())
.catch(() => undefined);
}
async function readResponseChunk(reader, timeoutPromise, markCanceled) {
const readPromise = reader.read();
if (!timeoutPromise) {
return await readPromise;
}
let waitingForRead = true;
const timeoutReadPromise = timeoutPromise.catch((error) => {
if (waitingForRead) {
markCanceled();
cancelReaderSoon(reader);
}
throw error;
});
try {
return await Promise.race([readPromise, timeoutReadPromise]);
} finally {
waitingForRead = false;
}
}
async function readBoundedResponseText(response, byteLimit, timeoutPromise) {
const contentLength = response.headers?.get?.("content-length");
if (contentLength && /^\d+$/u.test(contentLength)) {
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength > byteLimit) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
}
}
const reader = response.body?.getReader();
if (!reader) {
return "";
}
const chunks = [];
let totalBytes = 0;
let canceled = false;
try {
for (;;) {
const { done, value } = await readResponseChunk(reader, timeoutPromise, () => {
canceled = true;
});
if (done) {
break;
}
totalBytes += value.byteLength;
if (totalBytes > byteLimit) {
canceled = true;
await reader.cancel();
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
}
chunks.push(Buffer.from(value));
}
} finally {
if (!canceled) {
reader.releaseLock();
}
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
}
const controller = new AbortController();
const timeoutError = new Error(`chat completions request timed out after ${timeoutSeconds}s`);
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutSeconds * 1000);
timeout.unref?.();
});
const started = Date.now();
let response;
let text;
try {
response = await Promise.race([
fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
"x-openclaw-model": backendModel,
},
body: JSON.stringify({
model: "openclaw",
stream: false,
messages: [
{
role: "user",
content:
"Use the get_weather tool exactly once for Paris, France. Return the tool call only.",
},
],
tool_choice: "auto",
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Return weather for a city.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
properties: {
city: { type: "string", description: "City and country." },
},
required: ["city"],
},
},
},
],
}),
signal: controller.signal,
}),
timeoutPromise,
]);
text = await readBoundedResponseText(response, maxBodyBytes, timeoutPromise);
} finally {
clearTimeout(timeout);
}
let body;
try {
body = text ? JSON.parse(text) : {};
} catch {
throw new Error(`non-JSON response ${response.status}: ${text}`);
}
if (!response.ok) {
throw new Error(`chat completions request failed ${response.status}: ${JSON.stringify(body)}`);
}
const choice = body.choices?.[0];
const toolCalls = choice?.message?.tool_calls;
if (choice?.finish_reason !== "tool_calls") {
throw new Error(`expected finish_reason tool_calls: ${JSON.stringify(body)}`);
}
const messageContent = choice?.message?.content;
const hasVisibleContent =
(typeof messageContent === "string" && messageContent.trim().length > 0) ||
(Array.isArray(messageContent) && messageContent.length > 0) ||
(messageContent !== undefined &&
messageContent !== null &&
typeof messageContent !== "string" &&
!Array.isArray(messageContent));
if (hasVisibleContent) {
throw new Error(`expected tool call only response: ${JSON.stringify(choice.message)}`);
}
if (!Array.isArray(toolCalls) || toolCalls.length !== 1) {
throw new Error(`expected exactly one tool call: ${JSON.stringify(body)}`);
}
const [toolCall] = toolCalls;
if (toolCall?.type !== "function" || toolCall?.function?.name !== "get_weather") {
throw new Error(`unexpected tool call: ${JSON.stringify(toolCall)}`);
}
let args;
try {
args = JSON.parse(toolCall.function.arguments || "{}");
} catch {
throw new Error(`tool arguments were not valid JSON: ${toolCall.function.arguments}`);
}
if (typeof args.city !== "string" || !/paris/i.test(args.city)) {
throw new Error(`expected Paris city argument: ${JSON.stringify(args)}`);
}
console.log(
JSON.stringify({
ok: true,
elapsedMs: Date.now() - started,
finishReason: choice.finish_reason,
toolName: toolCall.function.name,
args,
}),
);

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export OPENCLAW_SKIP_CHANNELS=1
export OPENCLAW_SKIP_GMAIL_WATCHER=1
export OPENCLAW_SKIP_CRON=1
export OPENCLAW_SKIP_CANVAS_HOST=1
export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1
export OPENCLAW_SKIP_ACPX_RUNTIME=1
export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1
export OPENCLAW_AGENT_HARNESS_FALLBACK=none
for profile_path in "$HOME/.profile" /home/appuser/.profile; do
if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then
set +e +u
# shellcheck disable=SC1090
source "$profile_path"
set -euo pipefail
break
fi
done
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2
exit 1
fi
export OPENAI_API_KEY
if [ -n "${OPENAI_BASE_URL:-}" ]; then
export OPENAI_BASE_URL
fi
PORT="${PORT:?missing PORT}"
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
MODEL_REF="${OPENCLAW_OPENAI_CHAT_TOOLS_MODEL:?missing OPENCLAW_OPENAI_CHAT_TOOLS_MODEL}"
GATEWAY_LOG="/tmp/openclaw-openai-chat-tools-gateway.log"
CLIENT_LOG="/tmp/openclaw-openai-chat-tools-client.log"
gateway_pid=""
cleanup() {
openclaw_e2e_stop_process "$gateway_pid"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "OpenAI Chat Completions tools Docker E2E failed with exit code $status" >&2
openclaw_e2e_dump_logs "$GATEWAY_LOG" "$CLIENT_LOG"
if [ -f "$OPENCLAW_CONFIG_PATH" ]; then
echo "--- $OPENCLAW_CONFIG_PATH keys ---" >&2
node -e "const fs=require('fs'); const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); console.error(JSON.stringify({model:cfg.agents?.defaults?.model, tools:cfg.tools, provider:cfg.models?.providers?.openai && {api:cfg.models.providers.openai.api, baseUrl:cfg.models.providers.openai.baseUrl, agentRuntime:cfg.models.providers.openai.agentRuntime}}, null, 2));" "$OPENCLAW_CONFIG_PATH" || true
fi
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR" "$OPENCLAW_TEST_WORKSPACE_DIR"
node scripts/e2e/lib/openai-chat-tools/write-config.mjs
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
for _ in $(seq 1 360); do
if ! kill -0 "$gateway_pid" 2>/dev/null; then
echo "gateway exited before listening" >&2
exit 1
fi
if node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 120000 \
--json >/dev/null 2>&1; then
break
fi
sleep 0.25
done
node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 120000 \
--json >/dev/null
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" MODEL_REF="$MODEL_REF" \
node scripts/e2e/lib/openai-chat-tools/client.mjs >"$CLIENT_LOG" 2>&1
openclaw_e2e_print_log "$CLIENT_LOG"
echo "OpenAI Chat Completions tools Docker E2E passed"

View File

@@ -0,0 +1,90 @@
// Config writer for OpenAI chat tools E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`missing ${name}`);
}
return value;
}
const configPath = requireEnv("OPENCLAW_CONFIG_PATH");
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspaceDir = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
const modelRef = requireEnv("OPENCLAW_OPENAI_CHAT_TOOLS_MODEL");
const token = requireEnv("OPENCLAW_GATEWAY_TOKEN");
const timeoutSeconds = readPositiveIntEnv("OPENCLAW_OPENAI_CHAT_TOOLS_TIMEOUT_SECONDS", 180);
const gatewayPort = readTcpPortEnv("PORT", 18789);
const [providerId, modelId] = modelRef.split("/");
if (providerId !== "openai" || !modelId) {
throw new Error(`OPENCLAW_OPENAI_CHAT_TOOLS_MODEL must be openai/*, got ${modelRef}`);
}
const config = {
gateway: {
port: gatewayPort,
bind: "loopback",
auth: { mode: "token", token },
controlUi: { enabled: false },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
models: {
mode: "merge",
providers: {
openai: {
api: "openai-responses",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(),
agentRuntime: { id: "openclaw" },
timeoutSeconds,
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
contextTokens: 64000,
maxTokens: 512,
},
],
},
},
},
agents: {
defaults: {
model: { primary: modelRef, fallbacks: [] },
models: {
[modelRef]: {
agentRuntime: { id: "openclaw" },
params: { transport: "sse", openaiWsWarmup: false },
},
},
workspace: workspaceDir,
skipBootstrap: true,
timeoutSeconds,
contextTokens: 64000,
},
},
plugins: {
enabled: true,
allow: ["openai"],
entries: { openai: { enabled: true } },
},
skills: { allowBundled: [] },
tools: { allow: ["get_weather"] },
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
fs.mkdirSync(path.join(stateDir, "logs"), { recursive: true });

View File

@@ -0,0 +1,140 @@
// Assertions for minimal OpenAI web-search E2E scenarios.
import fs from "node:fs";
import { readTextFileTail, tailText } from "../text-file-utils.mjs";
const command = process.argv[2];
const ERROR_DETAIL_TAIL_BYTES = 64 * 1024;
const REQUEST_LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const RESPONSE_PREVIEW_BYTES = 8 * 1024;
const RESPONSE_PREVIEW_COUNT = 5;
function scanTextFileLines(file, onLine) {
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(REQUEST_LOG_SCAN_CHUNK_BYTES);
let carry = "";
let lineNumber = 1;
while (true) {
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
if (bytesRead <= 0) {
break;
}
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
const lines = text.split(/\r?\n/u);
carry = lines.pop() ?? "";
for (const line of lines) {
onLine(line, lineNumber);
lineNumber += 1;
}
}
if (carry.length > 0) {
onLine(carry, lineNumber);
}
} finally {
fs.closeSync(fd);
}
}
function scanSuccessRequest(logPath) {
let responseCount = 0;
let success;
const recentResponses = [];
scanTextFileLines(logPath, (line, lineNumber) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
const entry = JSON.parse(trimmed);
if (entry.method !== "POST" || entry.path !== "/v1/responses") {
return;
}
responseCount += 1;
const bodyText = JSON.stringify(entry.body);
if (recentResponses.length >= RESPONSE_PREVIEW_COUNT) {
recentResponses.shift();
}
recentResponses.push({
line: lineNumber,
bodyTail: tailText(bodyText, RESPONSE_PREVIEW_BYTES),
});
if (!success && bodyText.includes("OPENCLAW_SCHEMA_E2E_OK")) {
success = entry;
}
});
return { responseCount, success, recentResponses };
}
function assertPatchBehavior() {
return import("../../../../dist/extensions/openai/native-web-search.js").then(
({ patchOpenAINativeWebSearchPayload }) => {
const injectedPayload = {
reasoning: { effort: "minimal", summary: "auto" },
};
const injectedResult = patchOpenAINativeWebSearchPayload(injectedPayload);
if (injectedResult !== "injected") {
throw new Error(`expected native web_search injection, got ${injectedResult}`);
}
if (injectedPayload.reasoning.effort !== "low") {
throw new Error(
`expected injected native web_search to raise minimal reasoning to low, got ${JSON.stringify(injectedPayload.reasoning)}`,
);
}
if (!injectedPayload.tools?.some((tool) => tool?.type === "web_search")) {
throw new Error(`native web_search was not injected: ${JSON.stringify(injectedPayload)}`);
}
const existingNativePayload = {
tools: [{ type: "web_search" }],
reasoning: { effort: "minimal" },
};
const existingResult = patchOpenAINativeWebSearchPayload(existingNativePayload);
if (existingResult !== "native_tool_already_present") {
throw new Error(`expected existing native web_search, got ${existingResult}`);
}
if (existingNativePayload.reasoning.effort !== "low") {
throw new Error(
`expected existing native web_search to raise minimal reasoning to low, got ${JSON.stringify(existingNativePayload.reasoning)}`,
);
}
},
);
}
function assertSuccessRequest() {
const logPath = process.argv[3];
const { responseCount, success, recentResponses } = scanSuccessRequest(logPath);
if (responseCount < 1) {
throw new Error(
`mock OpenAI /v1/responses was not used. Request log tail: ${readTextFileTail(logPath, ERROR_DETAIL_TAIL_BYTES)}`,
);
}
if (!success) {
throw new Error(
`missing success request. Recent /v1/responses: ${JSON.stringify(recentResponses)}`,
);
}
const tools = Array.isArray(success.body.tools) ? success.body.tools : [];
const hasNativeWebSearch = tools.some((tool) => tool?.type === "web_search");
if (!hasNativeWebSearch) {
throw new Error(
`success request did not include native web_search. Body: ${JSON.stringify(success.body)}`,
);
}
if (success.body.reasoning?.effort === "minimal") {
throw new Error(
`expected web_search request to avoid minimal reasoning, got ${JSON.stringify(success.body.reasoning)}`,
);
}
}
const commands = {
"assert-patch-behavior": assertPatchBehavior,
"assert-success-request": assertSuccessRequest,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown OpenAI web-search minimal assertion command: ${command}`);
}
await fn();

View File

@@ -0,0 +1,201 @@
// Client script for minimal OpenAI web-search E2E scenarios.
import { readdirSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { readTcpPortEnv } from "../env-limits.mjs";
async function loadCallGateway() {
const candidates = readdirSync("/app/dist")
.filter((name) => /^call(?:\.runtime)?-[A-Za-z0-9_-]+\.js$/.test(name))
.toSorted();
for (const name of candidates) {
const mod = await import(pathToFileURL(`/app/dist/${name}`).href);
if (typeof mod.callGateway === "function") {
return mod.callGateway;
}
}
throw new Error(`unable to find callGateway export in /app/dist (${candidates.join(", ")})`);
}
const DEFAULT_RAW_SCHEMA_ERROR =
"400 The following tools cannot be used with reasoning.effort 'minimal': web_search.";
const DEFAULT_GATEWAY_SCHEMA_ERROR = "provider rejected the request schema or tool payload";
const SUCCESS_MARKER = "OPENCLAW_SCHEMA_E2E_OK";
function readExpectedRawSchemaError() {
return process.env.RAW_SCHEMA_ERROR?.trim() || DEFAULT_RAW_SCHEMA_ERROR;
}
function resolveGatewayPort(env = process.env) {
const portText = env.PORT;
if (!portText) {
throw new Error("missing PORT");
}
return readTcpPortEnv("PORT", portText, env);
}
async function gatewayAgent(params) {
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
if (!token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
const port = resolveGatewayPort();
try {
const callGateway = await loadCallGateway();
return {
ok: true,
value: await callGateway({
url: `ws://127.0.0.1:${port}`,
token,
method: "agent",
params,
expectFinal: true,
timeoutMs: 240_000,
clientName: "gateway-client",
mode: "backend",
scopes: ["operator.write"],
deviceIdentity: null,
}),
};
} catch (error) {
const combined = String(error);
return { ok: false, error: new Error(combined) };
}
}
function stringifyError(value) {
return value instanceof Error ? value.message || String(value) : String(value);
}
function validateRejectResult(result, expectedRawSchemaError = readExpectedRawSchemaError()) {
if (result.ok) {
throw new Error(`reject mode unexpectedly completed: ${JSON.stringify(result.value)}`);
}
const errorText = stringifyError(result.error);
if (
!errorText.includes(expectedRawSchemaError) &&
!errorText.includes(DEFAULT_GATEWAY_SCHEMA_ERROR)
) {
throw new Error(
`reject mode failed for an unexpected reason; expected ${JSON.stringify(
expectedRawSchemaError,
)} or ${JSON.stringify(DEFAULT_GATEWAY_SCHEMA_ERROR)} in ${JSON.stringify(errorText)}`,
);
}
return errorText;
}
function pushStringText(texts, value) {
if (typeof value === "string" && value.trim().length > 0) {
texts.push(value);
}
}
function pushContentText(texts, content) {
if (typeof content === "string") {
pushStringText(texts, content);
return;
}
if (!Array.isArray(content)) {
return;
}
for (const item of content) {
if (typeof item === "string") {
pushStringText(texts, item);
} else if (item && typeof item === "object") {
pushStringText(texts, item.text);
}
}
}
function extractSuccessReplyTexts(value) {
const texts = [];
pushSuccessReplyTexts(texts, value);
pushSuccessReplyTexts(texts, value?.result);
return texts;
}
function pushSuccessReplyTexts(texts, value) {
pushStringText(texts, value?.finalAssistantVisibleText);
pushStringText(texts, value?.meta?.finalAssistantVisibleText);
pushContentText(texts, value?.message?.content);
for (const payload of Array.isArray(value?.payloads) ? value.payloads : []) {
if (payload?.isError === true) {
continue;
}
pushStringText(texts, payload?.text);
pushContentText(texts, payload?.content);
}
}
function validateSuccessResult(result, marker = SUCCESS_MARKER) {
if (result.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
}
const replyTexts = extractSuccessReplyTexts(result.value);
if (!replyTexts.some((text) => text.includes(marker))) {
throw new Error(
`agent run completed without success marker ${JSON.stringify(marker)} in final reply: ${JSON.stringify(
result.value,
)}`,
);
}
}
async function main() {
const mode = process.argv[2];
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
const message = mode === "reject" ? "FORCE_SCHEMA_REJECT" : `Return exactly ${SUCCESS_MARKER}.`;
const id = mode === "reject" ? "schema-reject" : "schema-success";
const result = await gatewayAgent({
sessionKey,
message,
thinking: "minimal",
deliver: false,
timeout: 180,
idempotencyKey: id,
});
if (mode === "reject") {
console.error(validateRejectResult(result));
return;
}
if (!result.ok) {
throw toLintErrorObject(result.error, "Non-Error thrown");
}
validateSuccessResult(result);
}
function toLintErrorObject(value, fallbackMessage) {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
export const testing = {
DEFAULT_GATEWAY_SCHEMA_ERROR,
DEFAULT_RAW_SCHEMA_ERROR,
SUCCESS_MARKER,
extractSuccessReplyTexts,
resolveGatewayPort,
validateSuccessResult,
validateRejectResult,
};

View File

@@ -0,0 +1,166 @@
// Mock server for minimal OpenAI web-search E2E scenarios.
import http from "node:http";
import { readTcpPortEnv } from "../env-limits.mjs";
import {
boundedRequestLogBody,
isRequestBodyTooLargeError,
readBody,
writeRequestLogEntryOrFail,
writeJson,
writeSse,
} from "../mock-openai-http.mjs";
const port = readTcpPortEnv("MOCK_PORT");
const requestLog = process.env.MOCK_REQUEST_LOG;
const successMarker = process.env.SUCCESS_MARKER;
const rawSchemaError = process.env.RAW_SCHEMA_ERROR;
function writeOpenAiReject(res) {
writeJson(res, 400, {
error: {
message: rawSchemaError.replace(/^400\s+/, ""),
type: "invalid_request_error",
code: "invalid_request_error",
},
});
}
function hasWebSearchTool(tools) {
return (
Array.isArray(tools) &&
tools.some((tool) => {
if (!tool || typeof tool !== "object") {
return false;
}
if (tool.type === "web_search") {
return true;
}
if (tool.type === "function" && tool.name === "web_search") {
return true;
}
if (tool.type === "function" && tool.function?.name === "web_search") {
return true;
}
return false;
})
);
}
function bodyContainsForceReject(body) {
return JSON.stringify(body).includes("FORCE_SCHEMA_REJECT");
}
function responseEvents(text) {
return [
{
type: "response.output_item.added",
item: {
type: "message",
id: "msg_schema_e2e_1",
role: "assistant",
content: [],
status: "in_progress",
},
},
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_schema_e2e_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text, annotations: [] }],
},
},
{
type: "response.completed",
response: {
id: "resp_schema_e2e_1",
status: "completed",
usage: {
input_tokens: 11,
output_tokens: 7,
total_tokens: 18,
input_tokens_details: { cached_tokens: 0 },
},
},
},
];
}
const server = http.createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/health") {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
object: "list",
data: [{ id: "gpt-5", object: "model", owned_by: "openclaw-e2e" }],
});
return;
}
let bodyText;
try {
bodyText = await readBody(req);
} catch (error) {
if (isRequestBodyTooLargeError(error)) {
writeJson(res, 413, { error: { message: error.message } });
return;
}
throw error;
}
let body;
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
if (
writeRequestLogEntryOrFail(res, {
requestLog,
required: true,
label: "mock-openai-web-search",
entry: {
method: req.method,
path: url.pathname,
body: boundedRequestLogBody(body, bodyText),
},
})
) {
return;
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
if (bodyContainsForceReject(body)) {
writeOpenAiReject(res);
return;
}
if (body?.reasoning?.effort === "minimal" && hasWebSearchTool(body.tools)) {
writeOpenAiReject(res);
return;
}
writeSse(res, responseEvents(successMarker));
return;
}
writeJson(res, 404, {
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
});
})().catch((/** @type {unknown} */ error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`mock-openai-web-search request handler failed: ${message}`);
if (!res.headersSent) {
writeJson(res, 500, { error: { message: `mock OpenAI handler failed: ${message}` } });
return;
}
res.destroy(error instanceof Error ? error : new Error(message));
});
});
server.listen(port, "127.0.0.1", () => {
console.log(`mock-openai listening on ${port}`);
});

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export OPENCLAW_SKIP_CHANNELS=1
export OPENCLAW_SKIP_GMAIL_WATCHER=1
export OPENCLAW_SKIP_CRON=1
export OPENCLAW_SKIP_CANVAS_HOST=1
export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1
export OPENCLAW_SKIP_ACPX_RUNTIME=1
export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1
PORT="${PORT:?missing PORT}"
MOCK_PORT="${MOCK_PORT:?missing MOCK_PORT}"
TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}"
SUCCESS_MARKER="OPENCLAW_SCHEMA_E2E_OK"
RAW_SCHEMA_ERROR="400 The following tools cannot be used with reasoning.effort 'minimal': web_search."
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-openai-web-search-minimal.XXXXXX")"
MOCK_REQUEST_LOG="$scenario_tmp/requests.jsonl"
GATEWAY_LOG="$scenario_tmp/gateway.log"
MOCK_LOG="$scenario_tmp/mock.log"
CLIENT_SUCCESS_LOG="$scenario_tmp/client-success.log"
CLIENT_REJECT_LOG="$scenario_tmp/client-reject.log"
mock_pid=""
gateway_pid=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
rm -rf "$scenario_tmp"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "OpenAI web_search minimal Docker E2E failed with exit code $status" >&2
for file in \
"$GATEWAY_LOG" \
"$MOCK_LOG" \
"$CLIENT_SUCCESS_LOG" \
"$CLIENT_REJECT_LOG" \
"$MOCK_REQUEST_LOG" \
"$OPENCLAW_STATE_DIR/openclaw.json"; do
if [ -f "$file" ]; then
echo "--- $file ---" >&2
openclaw_e2e_print_log "$file" >&2
fi
done
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
entry="$(openclaw_e2e_resolve_entrypoint)"
mkdir -p "$OPENCLAW_STATE_DIR"
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-patch-behavior
node scripts/e2e/lib/fixture.mjs openai-web-search-minimal-config
MOCK_PORT="$MOCK_PORT" \
MOCK_REQUEST_LOG="$MOCK_REQUEST_LOG" \
SUCCESS_MARKER="$SUCCESS_MARKER" \
RAW_SCHEMA_ERROR="$RAW_SCHEMA_ERROR" \
node scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs >"$MOCK_LOG" 2>&1 &
mock_pid="$!"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360 "$PORT"
node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 120000 \
--json >/dev/null
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node scripts/e2e/lib/openai-web-search-minimal/client.mjs success >"$CLIENT_SUCCESS_LOG" 2>&1
node scripts/e2e/lib/openai-web-search-minimal/assertions.mjs assert-success-request "$MOCK_REQUEST_LOG"
PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node scripts/e2e/lib/openai-web-search-minimal/client.mjs reject >"$CLIENT_REJECT_LOG" 2>&1
for _ in $(seq 1 80); do
if grep -Fq "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG"; then
break
fi
sleep 0.25
done
grep -F "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG" >/dev/null
echo "OpenAI web_search minimal reasoning Docker E2E passed"

View File

@@ -0,0 +1,63 @@
// HTTP probe for OpenWebUI E2E scenarios.
import { pathToFileURL } from "node:url";
import { readPositiveIntEnv } from "../env-limits.mjs";
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
function parseExpectedStatus(raw) {
if (!/^[1-5]\d\d$/u.test(raw)) {
throw new Error(`expected status must be lt500 or a decimal HTTP status. Got: ${raw}`);
}
return Number(raw);
}
function resolveTimerTimeoutMs(valueMs, fallbackMs) {
const value = Number.isFinite(valueMs) ? valueMs : fallbackMs;
return Math.min(Math.max(Math.floor(value), 1), MAX_TIMER_TIMEOUT_MS);
}
export async function probeHttpStatus({
url,
expectedRaw = "200",
timeoutMs = 30_000,
bearer = "",
fetchImpl = fetch,
}) {
if (!url) {
throw new Error("usage: http-probe.mjs <url> [status|lt500]");
}
const expectedStatus = expectedRaw === "lt500" ? undefined : parseExpectedStatus(expectedRaw);
const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 30_000);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs);
let res;
const headers = {};
if (bearer) {
headers.authorization = `Bearer ${bearer}`;
}
try {
res = await fetchImpl(url, { headers, signal: controller.signal }).catch(() => null);
return expectedRaw === "lt500"
? Boolean(res && res.status < 500)
: res?.status === expectedStatus;
} finally {
clearTimeout(timer);
await res?.body?.cancel?.().catch(() => undefined);
}
}
async function main() {
const [url, expectedRaw = "200"] = process.argv.slice(2);
const ok = await probeHttpStatus({
url,
expectedRaw,
timeoutMs: readPositiveIntEnv("OPENCLAW_HTTP_PROBE_TIMEOUT_MS", 30_000),
bearer: process.env.OPENCLAW_HTTP_PROBE_BEARER,
});
process.exit(ok ? 0 : 1);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

View File

@@ -0,0 +1,12 @@
// Package-version compatibility helpers for E2E acceptance scripts.
export function legacyPackageAcceptanceCompat(version) {
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?/.exec(version || "");
const [year, month, day] = match?.slice(1, 4).map(Number) ?? [];
return (
Boolean(match) && (year < 2026 || (year === 2026 && (month < 4 || (month === 4 && day <= 25))))
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
console.log(legacyPackageAcceptanceCompat(process.argv[2]) ? "1" : "0");
}

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
parallels_macos_resolve_desktop_user() {
local vm_name="$1"
local user
user="$(prlctl exec "$vm_name" /usr/bin/stat -f '%Su' /dev/console 2>/dev/null | tr -d '\r' | tail -n 1 || true)"
if [[ "$user" =~ ^[A-Za-z0-9._-]+$ && "$user" != "root" && "$user" != "loginwindow" ]]; then
printf '%s\n' "$user"
return 0
fi
prlctl exec "$vm_name" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \
| tr -d '\r' \
| awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }'
}
parallels_macos_resolve_desktop_home() {
local vm_name="$1"
local user="$2"
local home
home="$(
prlctl exec "$vm_name" /usr/bin/dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null \
| tr -d '\r' \
| awk '/NFSHomeDirectory:/ { print $2; exit }'
)"
if [[ -n "$home" ]]; then
printf '%s\n' "$home"
else
printf '/Users/%s\n' "$user"
fi
}
parallels_macos_current_user_available() {
local vm_name="$1"
prlctl exec "$vm_name" --current-user /usr/bin/whoami >/dev/null 2>&1
}
parallels_macos_desktop_user_exec_with_secret_file() {
local vm_name="$1"
local user_flag="$2"
local user_name="$3"
local home="$4"
local path_value="$5"
local api_key_env="$6"
local api_key_value="$7"
shift 7
local secret_path
secret_path="/tmp/openclaw-secret-${api_key_env:-env}-$RANDOM-$RANDOM"
if [[ -n "$api_key_env" && -n "$api_key_value" ]]; then
if [[ "$user_flag" == "current-user" ]]; then
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
--current-user /usr/bin/base64 -D -o "$secret_path"
else
printf '%s' "$api_key_value" | /usr/bin/base64 | prlctl exec "$vm_name" \
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$secret_path"
fi
fi
local wrapper
local wrapper_path
wrapper_path="/tmp/openclaw-secret-env-wrapper-$RANDOM-$RANDOM.sh"
wrapper='#!/bin/bash
set -e
cleanup() {
rm -f "${OPENCLAW_WRAPPER_FILE:-}"
}
trap cleanup EXIT
if [ -n "${OPENCLAW_SECRET_ENV_NAME:-}" ] && [ -n "${OPENCLAW_SECRET_FILE:-}" ] && [ -f "$OPENCLAW_SECRET_FILE" ]; then
secret_value="$(cat "$OPENCLAW_SECRET_FILE")"
rm -f "$OPENCLAW_SECRET_FILE"
export "${OPENCLAW_SECRET_ENV_NAME}=${secret_value}"
fi
"$@"
'
if [[ "$user_flag" == "current-user" ]]; then
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
--current-user /usr/bin/base64 -D -o "$wrapper_path"
else
printf '%s' "$wrapper" | /usr/bin/base64 | prlctl exec "$vm_name" \
/usr/bin/sudo -H -u "$user_name" /usr/bin/base64 -D -o "$wrapper_path"
fi
if [[ "$user_flag" == "current-user" ]]; then
prlctl exec "$vm_name" --current-user /usr/bin/env \
"PATH=$path_value" \
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
"OPENCLAW_SECRET_FILE=$secret_path" \
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
/bin/bash "$wrapper_path" "$@"
return
fi
prlctl exec "$vm_name" /usr/bin/sudo -H -u "$user_name" /usr/bin/env \
"HOME=$home" \
"USER=$user_name" \
"LOGNAME=$user_name" \
"PATH=$path_value" \
"OPENCLAW_SECRET_ENV_NAME=$api_key_env" \
"OPENCLAW_SECRET_FILE=$secret_path" \
"OPENCLAW_WRAPPER_FILE=$wrapper_path" \
/bin/bash "$wrapper_path" "$@"
}

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
parallels_package_acquire_build_lock() {
local lock_dir="$1"
local owner_pid=""
while ! mkdir "$lock_dir" 2>/dev/null; do
if [[ -f "$lock_dir/pid" ]]; then
owner_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)"
if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then
printf 'warn: Removing stale Parallels build lock\n' >&2
rm -rf "$lock_dir"
continue
fi
fi
sleep 1
done
printf '%s\n' "$$" >"$lock_dir/pid"
}
parallels_package_release_build_lock() {
local lock_dir="$1"
if [[ -d "$lock_dir" ]]; then
rm -rf "$lock_dir"
fi
}

View File

@@ -0,0 +1,10 @@
// Validates build-info commit metadata for Parallels package E2E scenarios.
import fs from "node:fs";
const path = "dist/build-info.json";
if (!fs.existsSync(path)) {
console.log("");
} else {
const buildInfo = JSON.parse(fs.readFileSync(path, "utf8"));
console.log(buildInfo.commit ?? "");
}

View File

@@ -0,0 +1,22 @@
// Extracts progress markers from Parallels package E2E logs.
import fs from "node:fs";
import { readTextFileTail } from "../text-file-utils.mjs";
const LOG_PROGRESS_TAIL_BYTES = 256 * 1024;
const [logPath] = process.argv.slice(2);
if (!logPath || !fs.existsSync(logPath)) {
console.log("");
process.exit(0);
}
const text = readTextFileTail(logPath, LOG_PROGRESS_TAIL_BYTES);
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const reversed = lines.toReversed();
const progress = reversed.find((line) => line.startsWith("==> "));
const warning = reversed.find((line) => line.startsWith("warn:") || line.startsWith("error:"));
console.log(progress?.slice(4).trim() ?? warning ?? lines.at(-1)?.slice(0, 240) ?? "");

View File

@@ -0,0 +1,227 @@
// SQLite readers for plugin install indexes produced during E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { readPositiveIntEnv } from "./env-limits.mjs";
import { readTextFileBounded } from "./text-file-utils.mjs";
const INDEX_KEY = "installed-plugin-index";
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_PLUGIN_INDEX_JSON_MAX_BYTES",
1024 * 1024,
);
export function stateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
}
export function configPath() {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
}
function readJsonMaybe(file) {
let text;
try {
text = readTextFileBounded(file, "plugin index JSON artifact", JSON_ARTIFACT_MAX_BYTES, {
tailBytes: ERROR_DETAIL_TAIL_BYTES,
});
} catch (error) {
if (error?.code === "ETOOBIG") {
throw error;
}
return {};
}
try {
return JSON.parse(text);
} catch {
return {};
}
}
function textTooLargeError(message) {
return Object.assign(new Error(message), { code: "ETOOBIG" });
}
function parseIndexJsonText(text, label) {
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
}
return JSON.parse(text);
}
function assertIndexJsonByteLength(bytesRaw, label) {
const bytes = Number(bytesRaw);
if (!Number.isFinite(bytes) || bytes < 0) {
throw new Error(`${label} byte length was invalid: ${String(bytesRaw)}`);
}
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
}
}
function sqlitePath(root = stateDir()) {
return path.join(root, "state", "openclaw.sqlite");
}
function legacyIndexPath(root = stateDir()) {
return path.join(root, "plugins", "installs.json");
}
function readSqlitePluginIndex(root = stateDir()) {
const dbPath = sqlitePath(root);
if (!fs.existsSync(dbPath)) {
return {};
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const lengths = db
.prepare(
`
SELECT octet_length(install_records_json) AS install_records_json_bytes,
octet_length(plugins_json) AS plugins_json_bytes,
octet_length(diagnostics_json) AS diagnostics_json_bytes
FROM installed_plugin_index
WHERE index_key = ?
`,
)
.get(INDEX_KEY);
if (!lengths) {
return {};
}
assertIndexJsonByteLength(
lengths.install_records_json_bytes,
"plugin index install_records_json",
);
assertIndexJsonByteLength(lengths.plugins_json_bytes, "plugin index plugins_json");
assertIndexJsonByteLength(lengths.diagnostics_json_bytes, "plugin index diagnostics_json");
const row = db
.prepare(
`
SELECT version, warning, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json
FROM installed_plugin_index
WHERE index_key = ?
`,
)
.get(INDEX_KEY);
if (!row) {
return {};
}
return {
version: Number(row.version),
...(row.warning ? { warning: row.warning } : {}),
hostContractVersion: row.host_contract_version,
compatRegistryVersion: row.compat_registry_version,
migrationVersion: Number(row.migration_version),
policyHash: row.policy_hash,
generatedAtMs: Number(row.generated_at_ms),
...(row.refresh_reason ? { refreshReason: row.refresh_reason } : {}),
installRecords: parseIndexJsonText(
row.install_records_json,
"plugin index install_records_json",
),
plugins: parseIndexJsonText(row.plugins_json, "plugin index plugins_json"),
diagnostics: parseIndexJsonText(row.diagnostics_json, "plugin index diagnostics_json"),
};
} catch (error) {
if (error?.code === "ETOOBIG") {
throw error;
}
return {};
} finally {
db?.close();
}
}
export function readPluginInstallIndex(options = {}) {
const root = options.stateDir ?? stateDir();
const config = readJsonMaybe(options.configPath ?? configPath());
const sqliteIndex = readSqlitePluginIndex(root);
if (sqliteIndex.installRecords) {
return sqliteIndex;
}
const legacyIndex = readJsonMaybe(legacyIndexPath(root));
const installRecords =
legacyIndex.installRecords ??
legacyIndex.records ??
options.fallbackRecords ??
config.plugins?.installs ??
{};
return {
...legacyIndex,
installRecords,
};
}
export function readPluginInstallRecords(options = {}) {
return readPluginInstallIndex(options).installRecords ?? {};
}
export function writePluginInstallIndexForE2E(index, options = {}) {
const root = options.stateDir ?? stateDir();
const dbPath = sqlitePath(root);
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
version INTEGER NOT NULL,
host_contract_version TEXT NOT NULL,
compat_registry_version TEXT NOT NULL,
migration_version INTEGER NOT NULL,
policy_hash TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
refresh_reason TEXT,
install_records_json TEXT NOT NULL,
plugins_json TEXT NOT NULL,
diagnostics_json TEXT NOT NULL,
warning TEXT,
updated_at_ms INTEGER NOT NULL
);
`);
const now = Date.now();
db.prepare(
`
INSERT INTO installed_plugin_index (
index_key, version, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_json, warning, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(index_key) DO UPDATE SET
version = excluded.version,
host_contract_version = excluded.host_contract_version,
compat_registry_version = excluded.compat_registry_version,
migration_version = excluded.migration_version,
policy_hash = excluded.policy_hash,
generated_at_ms = excluded.generated_at_ms,
refresh_reason = excluded.refresh_reason,
install_records_json = excluded.install_records_json,
plugins_json = excluded.plugins_json,
diagnostics_json = excluded.diagnostics_json,
warning = excluded.warning,
updated_at_ms = excluded.updated_at_ms
`,
).run(
INDEX_KEY,
index.version ?? 1,
index.hostContractVersion ?? "docker-e2e",
index.compatRegistryVersion ?? "docker-e2e",
index.migrationVersion ?? 1,
index.policyHash ?? "docker-e2e",
index.generatedAtMs ?? now,
index.refreshReason ?? null,
JSON.stringify(index.installRecords ?? {}),
JSON.stringify(index.plugins ?? []),
JSON.stringify(index.diagnostics ?? []),
index.warning ?? "DO NOT EDIT. This row is generated by OpenClaw plugin registry commands.",
now,
);
} finally {
db.close();
}
}

View File

@@ -0,0 +1,388 @@
// Measures plugin lifecycle matrix E2E command timings.
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const [summaryPath, phase, separator, command, ...args] = process.argv.slice(2);
if (!summaryPath || !phase || separator !== "--" || !command) {
console.error("usage: measure.mjs <summary.tsv> <phase> -- <command> [args...]");
process.exit(2);
}
function readPositiveIntEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+$/u.test(text)) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
const value = Number(text);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer; got: ${text}`);
}
return value;
}
function readPositiveIntEnvOrGetconf(name, variable) {
if (process.env[name] !== undefined) {
return readPositiveIntEnv(name, "");
}
const result = spawnSync("getconf", [variable], { encoding: "utf8" });
if (result.error || result.status !== 0) {
const details =
result.error?.message || result.stderr.trim() || `exit ${String(result.status)}`;
throw new Error(
`failed to derive ${name} from getconf ${variable}: ${details}; set ${name} explicitly`,
);
}
return readPositiveIntEnv(name, result.stdout);
}
function readPositiveNumberEnv(name, fallback) {
const text = String(process.env[name] ?? fallback).trim();
if (!/^\d+(?:\.\d+)?$/u.test(text)) {
throw new Error(`${name} must be a positive number; got: ${text}`);
}
const value = Number(text);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive number; got: ${text}`);
}
return value;
}
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
function clampTimerTimeoutMs(valueMs) {
return Math.min(Math.max(Math.floor(valueMs), 1), MAX_TIMER_TIMEOUT_MS);
}
const pollMs = clampTimerTimeoutMs(
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS", 100),
);
const timeoutMs = clampTimerTimeoutMs(
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS", 300000),
);
const timeoutKillGraceMs = clampTimerTimeoutMs(
readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS", 2000),
);
const maxRssKbThreshold = readPositiveIntEnv(
"OPENCLAW_PLUGIN_LIFECYCLE_MAX_RSS_KB",
4 * 1024 * 1024,
);
const maxWallMs = readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_MAX_WALL_MS", timeoutMs);
const maxCpuCoreRatio = readPositiveNumberEnv("OPENCLAW_PLUGIN_LIFECYCLE_MAX_CPU_CORE_RATIO", 16);
if (!fs.existsSync("/proc")) {
console.error("plugin lifecycle resource sampler requires Linux /proc");
process.exit(2);
}
// /proc RSS is in host pages and CPU times are in host clock ticks. Query the
// live units so 64 KiB ARM kernels do not under-report resource use.
const pageSize = readPositiveIntEnvOrGetconf("OPENCLAW_PROC_PAGE_SIZE", "PAGESIZE");
const clockTicks = readPositiveIntEnvOrGetconf("OPENCLAW_PROC_CLK_TCK", "CLK_TCK");
function readProcSnapshot() {
const stats = new Map();
for (const entry of fs.readdirSync("/proc", { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) {
continue;
}
const pid = Number.parseInt(entry.name, 10);
const statPath = path.join("/proc", entry.name, "stat");
try {
const raw = fs.readFileSync(statPath, "utf8");
const closeParen = raw.lastIndexOf(")");
if (closeParen === -1) {
continue;
}
const fields = raw
.slice(closeParen + 2)
.trim()
.split(/\s+/u);
const ppid = Number.parseInt(fields[1] ?? "", 10);
const pgrp = Number.parseInt(fields[2] ?? "", 10);
const userTicks = Number.parseInt(fields[11] ?? "", 10);
const systemTicks = Number.parseInt(fields[12] ?? "", 10);
const rssPages = Number.parseInt(fields[21] ?? "", 10);
if (
!Number.isFinite(ppid) ||
!Number.isFinite(pgrp) ||
!Number.isFinite(userTicks) ||
!Number.isFinite(systemTicks) ||
!Number.isFinite(rssPages)
) {
continue;
}
stats.set(pid, {
ppid,
pgrp,
cpuTicks: userTicks + systemTicks,
rssBytes: Math.max(0, rssPages) * pageSize,
});
} catch {
// Processes can exit while /proc is being scanned.
}
}
return stats;
}
function descendantsOf(rootPid, stats) {
const children = new Map();
for (const [pid, stat] of stats.entries()) {
const siblings = children.get(stat.ppid) ?? [];
siblings.push(pid);
children.set(stat.ppid, siblings);
}
const seen = new Set([rootPid]);
const queue = [rootPid];
for (const queuedPid of queue) {
for (const child of children.get(queuedPid) ?? []) {
if (!seen.has(child)) {
seen.add(child);
queue.push(child);
}
}
}
return seen;
}
function sample(rootPid) {
const stats = readProcSnapshot();
const groupPids = new Set(
[...stats.entries()].filter(([, stat]) => stat.pgrp === rootPid).map(([pid]) => pid),
);
const pids = new Set([...descendantsOf(rootPid, stats), ...groupPids]);
let rssBytes = 0;
let cpuTicks = 0;
for (const pid of pids) {
const stat = stats.get(pid);
if (!stat) {
continue;
}
rssBytes += stat.rssBytes;
cpuTicks += stat.cpuTicks;
}
return { rssBytes, cpuTicks };
}
const started = performance.now();
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
detached: true,
stdio: "inherit",
});
let maxRssBytes = 0;
let maxCpuTicks = 0;
let timedOut = false;
let finished = false;
let parentSignalInFlight = false;
let forwardedParentSignal = null;
let killTimer;
let parentSignalTimer;
let parentSignalPollTimer;
let childGroupDrainTimer;
// The leader can exit before descendants in its detached process group.
// Keep the wrapper alive so timeout cleanup still owns those descendants.
let childClosedResult = null;
const updateMetrics = () => {
if (!child.pid) {
return;
}
const current = sample(child.pid);
maxRssBytes = Math.max(maxRssBytes, current.rssBytes);
maxCpuTicks = Math.max(maxCpuTicks, current.cpuTicks);
};
function finishChildClosedResultIfGroupDrained() {
if (childClosedResult && !childGroupExists()) {
finish(childClosedResult.code, childClosedResult.signal);
}
}
updateMetrics();
const interval = setInterval(updateMetrics, pollMs);
const timeoutTimer =
Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => {
if (childClosedResult && !childGroupExists()) {
finish(childClosedResult.code, childClosedResult.signal);
return;
}
timedOut = true;
terminateChildGroup("SIGTERM");
killTimer = setTimeout(() => {
terminateChildGroup("SIGKILL");
finish(124);
}, timeoutKillGraceMs);
killTimer.unref?.();
}, timeoutMs)
: null;
timeoutTimer?.unref?.();
function terminateChildGroup(signal) {
if (!child.pid) {
return;
}
try {
process.kill(-child.pid, signal);
return;
} catch {}
try {
child.kill(signal);
} catch {}
}
function childGroupExists() {
if (!child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
if (error && error.code === "ESRCH") {
return false;
}
return true;
}
}
function clearRuntimeTimers() {
clearInterval(interval);
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
if (killTimer) {
clearTimeout(killTimer);
}
if (parentSignalTimer) {
clearTimeout(parentSignalTimer);
}
if (parentSignalPollTimer) {
clearInterval(parentSignalPollTimer);
}
if (childGroupDrainTimer) {
clearInterval(childGroupDrainTimer);
}
}
function rethrowParentSignal(signal) {
clearRuntimeTimers();
process.removeAllListeners(signal);
process.kill(process.pid, signal);
process.exit(128);
}
function handleParentSignal(signal) {
if (parentSignalInFlight) {
terminateChildGroup("SIGKILL");
rethrowParentSignal(signal);
return;
}
parentSignalInFlight = true;
if (finished) {
rethrowParentSignal(signal);
return;
}
finished = true;
forwardedParentSignal = signal;
clearRuntimeTimers();
terminateChildGroup(signal);
parentSignalTimer = setTimeout(() => {
terminateChildGroup("SIGKILL");
rethrowParentSignal(signal);
}, timeoutKillGraceMs);
parentSignalPollTimer = setInterval(
() => {
if (!childGroupExists()) {
rethrowParentSignal(signal);
}
},
Math.min(50, timeoutKillGraceMs),
);
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.once(signal, () => handleParentSignal(signal));
}
process.once("exit", () => {
if (!finished) {
terminateChildGroup("SIGTERM");
}
});
function finish(code, signal) {
if (finished) {
return;
}
finished = true;
updateMetrics();
clearRuntimeTimers();
const wallMs = performance.now() - started;
const cpuSeconds = maxCpuTicks / clockTicks;
const maxRssKb = Math.round(maxRssBytes / 1024);
const cpuCoreRatio = wallMs > 0 ? cpuSeconds / (wallMs / 1000) : 0;
const summarySignal = timedOut ? "timeout" : (signal ?? "");
fs.appendFileSync(
summaryPath,
`${phase}\t${maxRssKb}\t${cpuSeconds.toFixed(3)}\t${wallMs.toFixed(0)}\t${cpuCoreRatio.toFixed(3)}\t${summarySignal}\n`,
);
console.log(
`plugin lifecycle resource: phase=${phase} max_rss_kb=${maxRssKb} cpu_s=${cpuSeconds.toFixed(3)} wall_ms=${wallMs.toFixed(0)} cpu_core_ratio=${cpuCoreRatio.toFixed(3)} signal=${summarySignal}`,
);
const violations = [];
if (maxRssKb > maxRssKbThreshold) {
violations.push(`max_rss_kb=${maxRssKb} > ${maxRssKbThreshold}`);
}
if (wallMs > maxWallMs) {
violations.push(`wall_ms=${wallMs.toFixed(0)} > ${maxWallMs}`);
}
if (cpuCoreRatio > maxCpuCoreRatio) {
violations.push(`cpu_core_ratio=${cpuCoreRatio.toFixed(3)} > ${maxCpuCoreRatio}`);
}
if (violations.length > 0) {
console.error(
`plugin lifecycle resource ceiling exceeded: phase=${phase} ${violations.join("; ")}`,
);
if (!timedOut && !signal && (code ?? 0) === 0) {
process.exit(1);
return;
}
}
if (timedOut) {
process.exit(124);
return;
}
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
}
child.on("error", (error) => {
finished = true;
clearRuntimeTimers();
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
child.on("exit", (code, signal) => {
if (parentSignalInFlight && forwardedParentSignal) {
if (!childGroupExists()) {
rethrowParentSignal(forwardedParentSignal);
}
return;
}
if (timedOut && killTimer) {
return;
}
if (childGroupExists()) {
childClosedResult = { code, signal };
childGroupDrainTimer = setInterval(finishChildClosedResultIfGroupDrained, Math.min(25, pollMs));
return;
}
finish(code, signal);
});

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
source scripts/e2e/lib/plugins/fixtures.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export npm_config_prefix=/tmp/npm-prefix
export NPM_CONFIG_PREFIX=/tmp/npm-prefix
export PATH="/tmp/npm-prefix/bin:$PATH"
export CI=true
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1
export OPENCLAW_NO_ONBOARD=1
export OPENCLAW_NO_PROMPT=1
baseline="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE:-openclaw@latest}"
update_timeout_seconds="$(openclaw_e2e_read_positive_int_env OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS 900)"
echo "Installing baseline OpenClaw package: $baseline"
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$baseline" >/tmp/openclaw-update-corrupt-baseline-install.log 2>&1; then
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-baseline-install.log >&2
exit 1
fi
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
export OPENCLAW_ENTRY="$entry"
npm_pack_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-pack.XXXXXX")"
npm_registry_dir="$(mktemp -d "/tmp/openclaw-corrupt-plugin-registry.XXXXXX")"
pack_fixture_plugin "$npm_pack_dir" /tmp/demo-corrupt-plugin.tgz demo-corrupt-plugin 0.0.1 demo.corrupt "Demo Corrupt Plugin"
start_npm_fixture_registry "@openclaw/demo-corrupt-plugin" "0.0.1" /tmp/demo-corrupt-plugin.tgz "$npm_registry_dir"
echo "Installing managed external plugin..."
node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" >/tmp/openclaw-corrupt-plugin-install.log 2>&1
node "$entry" plugins inspect demo-corrupt-plugin --runtime --json >/tmp/openclaw-corrupt-plugin-before.json
unset NPM_CONFIG_REGISTRY npm_config_registry
plugin_dir="$(
node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const installPath = payload.install?.installPath ?? payload.plugin?.rootDir;
if (!installPath) {
throw new Error("missing plugin install path in inspect output");
}
process.stdout.write(installPath);
' /tmp/openclaw-corrupt-plugin-before.json
)"
rm -f "$plugin_dir/package.json"
if [ -f "$plugin_dir/package.json" ]; then
echo "Expected corrupt plugin package.json to be removed before update." >&2
exit 1
fi
echo "Updating OpenClaw with corrupt plugin present..."
set +e
openclaw_e2e_maybe_timeout "${update_timeout_seconds}s" \
node "$entry" update \
--channel beta \
--tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" \
--yes \
--no-restart \
--json \
>/tmp/openclaw-update-corrupt-plugin.json \
2>/tmp/openclaw-update-corrupt-plugin.err
update_status=$?
set -e
if [ "$update_status" -ne 0 ]; then
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-legacy-post-update-plugin-failure /tmp/openclaw-update-corrupt-plugin.json; then
echo "openclaw update failed or timed out after ${update_timeout_seconds}s with corrupt plugin present" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.err >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.json >&2
exit "$update_status"
fi
echo "Legacy updater reported post-update plugin failure after installing the new core; verifying updated entrypoint..."
set +e
OPENCLAW_UPDATE_POST_CORE=1 \
OPENCLAW_UPDATE_POST_CORE_CHANNEL=beta \
OPENCLAW_UPDATE_POST_CORE_RESULT_PATH=/tmp/openclaw-update-corrupt-plugin-post-core.json \
openclaw_e2e_maybe_timeout "${update_timeout_seconds}s" \
node "$entry" update \
--yes \
--no-restart \
--json \
>/tmp/openclaw-update-corrupt-plugin-post-core.stdout \
2>/tmp/openclaw-update-corrupt-plugin-post-core.err
post_core_status=$?
set -e
if [ "$post_core_status" -ne 0 ]; then
echo "updated OpenClaw entry failed or timed out after ${update_timeout_seconds}s during post-core plugin verification" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.err >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.stdout >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin-post-core.json >&2
exit "$post_core_status"
fi
node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-plugin-result /tmp/openclaw-update-corrupt-plugin-post-core.json demo-corrupt-plugin
exit 0
fi
if ! node scripts/e2e/lib/plugin-update/probe.mjs assert-corrupt-update /tmp/openclaw-update-corrupt-plugin.json demo-corrupt-plugin; then
echo "corrupt update JSON payload:" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.json >&2
echo "corrupt update stderr:" >&2
openclaw_e2e_print_log /tmp/openclaw-update-corrupt-plugin.err >&2
exit 1
fi

View File

@@ -0,0 +1,307 @@
// Probe script for plugin update E2E scenarios.
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { legacyPackageAcceptanceCompat } from "../package-compat.mjs";
import {
readPluginInstallRecords,
writePluginInstallIndexForE2E,
} from "../plugin-index-sqlite.mjs";
const home = os.homedir();
const OUTPUT_TAIL_BYTES = 64 * 1024;
const OUTPUT_TAIL_LINES = 120;
const OUTPUT_SCAN_WINDOW_BYTES = 8 * 1024;
const readJson = (file) => {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return {};
}
};
const pluginRecordSnapshot = () => {
const config = readJson(openclawPath("openclaw.json"));
const records = readPluginInstallRecords({ fallbackRecords: config.plugins?.installs ?? {} });
const record = records["lossless-claw"] ?? records["@example/lossless-claw"];
if (!record) {
throw new Error("missing plugin install record");
}
const { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum } = record;
return { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum };
};
function openclawPath(...parts) {
return path.join(home, ".openclaw", ...parts);
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function seedInstallState() {
writeJson(openclawPath("extensions", "lossless-claw", "package.json"), {
name: "@example/lossless-claw",
version: "0.9.0",
});
writeJson(process.env.OPENCLAW_CONFIG_PATH, { plugins: {} });
writePluginInstallIndexForE2E({
version: 1,
warning: "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.",
hostContractVersion: "docker-e2e",
compatRegistryVersion: "docker-e2e",
migrationVersion: 1,
policyHash: "docker-e2e",
generatedAtMs: 1777118400000,
installRecords: {
"lossless-claw": {
source: "npm",
spec: "@example/lossless-claw@0.9.0",
installPath: "~/.openclaw/extensions/lossless-claw",
resolvedName: "@example/lossless-claw",
resolvedVersion: "0.9.0",
resolvedSpec: "@example/lossless-claw@0.9.0",
integrity: "sha512-same",
shasum: "same",
},
},
plugins: [],
diagnostics: [],
});
}
async function waitRegistry() {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await registryHealthy()) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
}
throw new Error("Local npm metadata registry failed to start");
}
function registryHealthy() {
return new Promise((resolve) => {
const registry = process.env.NPM_CONFIG_REGISTRY ?? "http://127.0.0.1:4873";
const req = http.get(`${registry.replace(/\/$/u, "")}/@example%2flossless-claw`, (res) => {
resolve(res.statusCode === 200);
res.resume();
});
req.on("error", () => resolve(false));
req.setTimeout(200, () => {
req.destroy();
resolve(false);
});
});
}
function assertSnapshot(beforePath) {
const before = readJson(beforePath);
const after = pluginRecordSnapshot();
if (JSON.stringify(before) !== JSON.stringify(after)) {
throw new Error(
`plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`,
);
}
}
function appendBufferTail(tail, chunk, maxBytes) {
if (chunk.length >= maxBytes) {
return chunk.subarray(chunk.length - maxBytes);
}
if (tail.length + chunk.length <= maxBytes) {
return Buffer.concat([tail, chunk]);
}
return Buffer.concat([tail, chunk]).subarray(tail.length + chunk.length - maxBytes);
}
async function readOutputEvidence(logPath) {
let outputTail = Buffer.alloc(0);
let scanWindow = "";
let sawDownload = false;
let sawUpToDate = false;
for await (const chunk of fs.createReadStream(logPath)) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const text = buffer.toString("utf8");
const searchable = `${scanWindow}${text}`;
outputTail = appendBufferTail(outputTail, buffer, OUTPUT_TAIL_BYTES);
sawDownload ||= searchable.includes("Downloading @example/lossless-claw");
sawUpToDate ||= searchable.includes("lossless-claw is up to date (0.9.0).");
scanWindow = searchable.slice(-OUTPUT_SCAN_WINDOW_BYTES);
}
return {
outputTail: outputTail
.toString("utf8")
.split(/\r?\n/u)
.slice(-OUTPUT_TAIL_LINES)
.join("\n")
.trimEnd(),
sawDownload,
sawUpToDate,
};
}
async function assertOutput(logPath) {
const evidence = await readOutputEvidence(logPath);
const failure = evidence.sawDownload
? "Unexpected npm download/reinstall path"
: !evidence.sawUpToDate
? "Expected up-to-date output missing"
: "";
if (failure) {
throw new Error(`${failure}\nOutput tail:\n${evidence.outputTail}`);
}
}
function assertCorruptUpdate(updateJsonPath, pluginId) {
const payload = readJson(updateJsonPath);
if (payload.status !== "ok") {
throw new Error(`expected core update status ok, got ${JSON.stringify(payload.status)}`);
}
const plugins = payload.postUpdate?.plugins;
if (!plugins) {
throw new Error(`missing postUpdate.plugins in update output: ${JSON.stringify(payload)}`);
}
assertCorruptPluginTolerated(plugins, pluginId);
}
function assertCorruptPluginResult(pluginJsonPath, pluginId) {
const plugins = readJson(pluginJsonPath);
assertCorruptPluginTolerated(plugins, pluginId);
}
function assertCorruptPluginTolerated(plugins, pluginId) {
const evidence = collectPluginEvidence(plugins, pluginId);
if (plugins.status === "ok") {
assertCorruptPluginCleanOrRepaired(evidence);
return;
}
if (plugins.status !== "warning") {
throw new Error(
`expected post-update plugin status warning, got ${JSON.stringify(plugins.status)}`,
);
}
assertCorruptPluginDetails(plugins, pluginId);
}
function isCorruptPluginDisabledAfterUpdate(evidence, pluginId) {
const outcome = evidence.outcome;
const message = typeof outcome?.message === "string" ? outcome.message : "";
return (
outcome?.status === "skipped" &&
message.includes(`Disabled "${pluginId}" after plugin update failure`) &&
message.includes("OpenClaw will continue without it")
);
}
function assertCorruptPluginCleanOrRepaired(evidence) {
if (evidence.outcome) {
throw new Error(
`expected clean or repaired corrupt plugin state, got ${JSON.stringify(evidence)}`,
);
}
if (evidence.warning || evidence.integrityDrift || evidence.syncMessages.length > 0) {
throw new Error(
`expected warning post-update status for corrupt plugin evidence, got ok: ${JSON.stringify(
evidence,
)}`,
);
}
}
function assertCorruptPluginDetails(plugins, pluginId) {
const evidence = collectPluginEvidence(plugins, pluginId);
const outcome = evidence.outcome;
const disabledAfterFailure = isCorruptPluginDisabledAfterUpdate(evidence, pluginId);
if (!outcome || (outcome.status !== "error" && !disabledAfterFailure)) {
throw new Error(
`expected error or disabled-after-failure outcome for ${pluginId}, got ${JSON.stringify({
outcomes: plugins.npm?.outcomes ?? [],
warnings: plugins.warnings ?? [],
sync: plugins.sync,
integrityDrifts: plugins.integrityDrifts ?? [],
})}`,
);
}
const warning = evidence.warning;
if (!warning) {
throw new Error(
`expected warning for ${pluginId}, got ${JSON.stringify(plugins.warnings ?? [])}`,
);
}
const text = [outcome.message, warning.reason, warning.message, ...(warning.guidance ?? [])]
.filter(Boolean)
.join(" ");
const expectedFragments = disabledAfterFailure
? [
`Disabled "${pluginId}" after plugin update failure`,
"OpenClaw will continue without it",
"Run openclaw update repair to retry post-update plugin repair.",
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
]
: [
"package.json is missing",
"Run openclaw update repair to retry post-update plugin repair.",
`Run openclaw plugins inspect ${pluginId} --runtime --json for details.`,
];
for (const expected of expectedFragments) {
if (!text.includes(expected)) {
throw new Error(`expected update output to include ${expected}: ${text}`);
}
}
}
function collectPluginEvidence(plugins, pluginId) {
const outcomes = plugins.npm?.outcomes ?? [];
const warnings = plugins.warnings ?? [];
const integrityDrifts = plugins.integrityDrifts ?? [];
const syncMessages = [...(plugins.sync?.warnings ?? []), ...(plugins.sync?.errors ?? [])].filter(
(message) => String(message).includes(pluginId),
);
return {
outcome: outcomes.find((entry) => entry?.pluginId === pluginId),
warning: warnings.find((entry) => entry?.pluginId === pluginId),
integrityDrift: integrityDrifts.find((entry) => entry?.pluginId === pluginId),
syncMessages,
};
}
function assertLegacyPostUpdatePluginFailure(updateJsonPath) {
const payload = readJson(updateJsonPath);
if (payload.status !== "error" || payload.reason !== "post-update-plugins") {
throw new Error(
`expected legacy post-update plugin failure, got ${JSON.stringify({
status: payload.status,
reason: payload.reason,
})}`,
);
}
if (!payload.after?.version) {
throw new Error(`expected core update to install a new version: ${JSON.stringify(payload)}`);
}
}
const [command, arg, arg2] = process.argv.slice(2);
const commands = {
"legacy-compat": () => console.log(legacyPackageAcceptanceCompat(arg || "") ? "1" : "0"),
seed: seedInstallState,
"wait-registry": waitRegistry,
snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)),
"assert-snapshot": () => assertSnapshot(arg),
"assert-output": () => assertOutput(arg),
"assert-corrupt-update": () => assertCorruptUpdate(arg, arg2),
"assert-corrupt-plugin-result": () => assertCorruptPluginResult(arg, arg2),
"assert-legacy-post-update-plugin-failure": () => assertLegacyPostUpdatePluginFailure(arg),
};
const run = commands[command];
await (
run ??
(() => {
throw new Error(`Unknown plugin update probe command: ${command || "(missing)"}`);
})
)();

View File

@@ -0,0 +1,52 @@
// Fixture npm registry server for plugin update E2E scenarios.
import fs from "node:fs";
import http from "node:http";
import { readTcpPortEnv } from "../env-limits.mjs";
const portFile = process.argv[2];
if (!portFile) {
console.error("usage: registry-server.mjs <port-file>");
process.exit(2);
}
function buildMetadata(req) {
const host = req.headers.host ?? "127.0.0.1";
return {
name: "@example/lossless-claw",
"dist-tags": { latest: "0.9.0" },
versions: {
"0.9.0": {
name: "@example/lossless-claw",
version: "0.9.0",
dist: {
integrity: "sha512-same",
shasum: "same",
tarball: `http://${host}/@example/lossless-claw/-/lossless-claw-0.9.0.tgz`,
},
},
},
};
}
const server = http.createServer((req, res) => {
if (req.url === "/@example%2flossless-claw" || req.url === "/@example%2Flossless-claw") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(buildMetadata(req)));
return;
}
res.writeHead(404, { "content-type": "text/plain" });
res.end(`not found: ${req.url}`);
});
const requestedPort =
process.env.OPENCLAW_PLUGIN_UPDATE_REGISTRY_PORT === undefined
? 0
: readTcpPortEnv("OPENCLAW_PLUGIN_UPDATE_REGISTRY_PORT");
server.listen(requestedPort, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("plugin update registry did not expose a TCP port");
}
fs.writeFileSync(portFile, `${address.port}\n`);
});

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_package /tmp/openclaw-install.log "mounted OpenClaw package" /tmp/npm-prefix
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
probe="scripts/e2e/lib/plugin-update/probe.mjs"
package_version="$(node -p "require('$package_root/package.json').version")"
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node "$probe" legacy-compat "$package_version")"
export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT
export PATH="/tmp/npm-prefix/bin:$PATH"
node "$probe" seed
registry_port_file=/tmp/openclaw-e2e-registry.port
rm -f "$registry_port_file"
node scripts/e2e/lib/plugin-update/registry-server.mjs "$registry_port_file" >/tmp/openclaw-e2e-registry.log 2>&1 &
registry_pid=$!
trap 'openclaw_e2e_stop_process "${registry_pid:-}"' EXIT
for _ in $(seq 1 50); do
if [ -s "$registry_port_file" ]; then
break
fi
sleep 0.1
done
if [ ! -s "$registry_port_file" ]; then
echo "Local npm metadata registry did not expose a port"
openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log
exit 1
fi
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"
export npm_config_registry="$NPM_CONFIG_REGISTRY"
if ! node "$probe" wait-registry; then
echo "Local npm metadata registry failed to start"
openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log
exit 1
fi
before_config_hash=""
if [ "$OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT" != "1" ]; then
before_config_hash="$(sha256sum "$OPENCLAW_CONFIG_PATH" | awk '{print $1}')"
fi
plugin_update_timeout_seconds="$(openclaw_e2e_read_positive_int_env OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS 180)"
node "$probe" snapshot > /tmp/plugin-update-before.json
set +e
openclaw_e2e_maybe_timeout "${plugin_update_timeout_seconds}s" node "$entry" plugins update @example/lossless-claw > /tmp/plugin-update-output.log 2>&1
plugin_update_status=$?
set -e
if [ "$plugin_update_status" -ne 0 ]; then
echo "Plugin update command failed or timed out after ${plugin_update_timeout_seconds}s (status ${plugin_update_status})"
echo "--- plugin update output ---"
openclaw_e2e_print_log /tmp/plugin-update-output.log
echo "--- local registry output ---"
openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log
exit "$plugin_update_status"
fi
if [ -n "$before_config_hash" ]; then
after_config_hash="$(sha256sum "$OPENCLAW_CONFIG_PATH" | awk '{print $1}')"
if [ "$before_config_hash" != "$after_config_hash" ]; then
echo "Config changed unexpectedly for modern package $package_version"
openclaw_e2e_print_log /tmp/plugin-update-output.log
exit 1
fi
fi
node "$probe" assert-snapshot /tmp/plugin-update-before.json
node "$probe" assert-output /tmp/plugin-update-output.log
openclaw_e2e_print_log /tmp/plugin-update-output.log

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
run_plugins_clawhub_scenario() {
if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then
echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)."
else
echo "Testing ClawHub plugin install and uninstall..."
CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:@openclaw/kitchen-sink}"
CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-openclaw-kitchen-sink-fixture}"
export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID
start_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"
openclaw_plugins_validate_fixture_log_print_bytes || return $?
node scripts/e2e/lib/clawhub-fixture-server.cjs plugins "$server_port_file" >"$server_log" 2>&1 &
local server_pid="$!"
echo "$server_pid" >"$server_pid_file"
openclaw_plugins_register_fixture_pid_file "$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")"
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
openclaw_plugins_print_fixture_log "$server_log"
return 1
fi
sleep 0.1
done
openclaw_plugins_print_fixture_log "$server_log"
echo "Timed out waiting for ClawHub fixture server." >&2
return 1
}
if [[ "${OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB:-0}" = "1" ]]; then
export OPENCLAW_CLAWHUB_URL="${OPENCLAW_CLAWHUB_URL:-${CLAWHUB_URL:-https://clawhub.ai}}"
export NPM_CONFIG_REGISTRY="${OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY:-https://registry.npmjs.org/}"
else
# Keep the release-path smoke hermetic; live ClawHub can rate-limit CI.
if [[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]; then
echo "Ignoring ambient ClawHub URL for fixture-mode plugin E2E; set OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB=1 for live ClawHub."
fi
unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL
clawhub_fixture_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-clawhub-fixture.XXXXXX")"
local fixture_status=0
start_clawhub_fixture_server "$clawhub_fixture_dir" || fixture_status="$?"
if [[ "$fixture_status" -ne 0 ]]; then
return "$fixture_status"
fi
fi
node scripts/e2e/lib/plugins/assertions.mjs clawhub-preflight
run_plugins_openclaw_logged install-clawhub plugins install "$CLAWHUB_PLUGIN_SPEC"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-installed.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-inspect.json" plugins inspect "$CLAWHUB_PLUGIN_ID" --json
node scripts/e2e/lib/plugins/assertions.mjs clawhub-installed
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update "$CLAWHUB_PLUGIN_ID" >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-update.log" 2>&1
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-updated.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-updated-inspect.json" plugins inspect "$CLAWHUB_PLUGIN_ID" --json
node scripts/e2e/lib/plugins/assertions.mjs clawhub-updated
run_plugins_openclaw_logged uninstall-clawhub plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-clawhub-uninstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs clawhub-removed
fi
}

View File

@@ -0,0 +1,284 @@
OPENCLAW_PLUGINS_FIXTURE_PID_FILES=()
OPENCLAW_PLUGINS_FIXTURE_EXIT_TRAP_INSTALLED=0
OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION=""
openclaw_plugins_read_positive_int_env() {
local name="${1:?missing environment variable name}"
local fallback="${2:?missing fallback value}"
local value="${!name-}"
if [[ -z "${!name+x}" ]]; then
value="$fallback"
fi
if [[ ! "$value" =~ ^[0-9]+$ ]] || (( 10#$value < 1 )); then
echo "invalid $name: $value" >&2
return 2
fi
printf "%s\n" "$((10#$value))"
}
openclaw_plugins_read_nonnegative_decimal_env() {
local name="${1:?missing environment variable name}"
local fallback="${2:?missing fallback value}"
local value="${!name-}"
if [[ -z "${!name+x}" ]]; then
value="$fallback"
fi
if [[ ! "$value" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
echo "invalid $name: $value" >&2
return 2
fi
printf "%s\n" "$value"
}
openclaw_plugins_cleanup_fixture_servers() {
local pid_file
local pid
for pid_file in "${OPENCLAW_PLUGINS_FIXTURE_PID_FILES[@]:-}"; do
[[ -f "$pid_file" ]] || continue
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [[ "$pid" =~ ^[0-9]+$ ]]; then
openclaw_plugins_stop_fixture_process "$pid"
fi
rm -f "$pid_file"
done
}
openclaw_plugins_signal_fixture_process() {
local pid="$1"
local signal="$2"
if kill -0 -- "-$pid" >/dev/null 2>&1; then
kill "-$signal" -- "-$pid" >/dev/null 2>&1 || true
return
fi
kill "-$signal" "$pid" >/dev/null 2>&1 || true
}
openclaw_plugins_fixture_process_alive() {
local pid="$1"
kill -0 "$pid" >/dev/null 2>&1 || kill -0 -- "-$pid" >/dev/null 2>&1
}
openclaw_plugins_stop_fixture_process() {
local pid="$1"
local _
local attempts interval
attempts="$(openclaw_plugins_read_positive_int_env OPENCLAW_PLUGINS_FIXTURE_STOP_ATTEMPTS 40)" || return $?
interval="$(openclaw_plugins_read_nonnegative_decimal_env OPENCLAW_PLUGINS_FIXTURE_STOP_INTERVAL_SECONDS 0.25)" || return $?
if declare -F openclaw_e2e_stop_process >/dev/null 2>&1; then
openclaw_e2e_stop_process "$pid"
return
fi
openclaw_plugins_signal_fixture_process "$pid" TERM
for _ in $(seq 1 "$attempts"); do
! openclaw_plugins_fixture_process_alive "$pid" && { wait "$pid" >/dev/null 2>&1 || true; return; }
sleep "$interval"
done
openclaw_plugins_signal_fixture_process "$pid" KILL
wait "$pid" >/dev/null 2>&1 || true
}
openclaw_plugins_print_fixture_log() {
local log_file="$1"
if declare -F docker_e2e_print_log >/dev/null 2>&1; then
docker_e2e_print_log "$log_file"
return
fi
if [ ! -f "$log_file" ]; then
return
fi
local max_bytes
max_bytes="$(openclaw_plugins_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536)" || return $?
local log_bytes
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="${log_bytes//[[:space:]]/}"
if ! [[ "$log_bytes" =~ ^[0-9]+$ ]]; then
log_bytes="0"
fi
if [ "$log_bytes" -le "$max_bytes" ]; then
cat "$log_file"
return
fi
echo "--- ${log_file} truncated: showing last ${max_bytes} of ${log_bytes} bytes ---"
tail -c "$max_bytes" "$log_file"
}
openclaw_plugins_validate_fixture_log_print_bytes() {
openclaw_plugins_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_PRINT_BYTES 65536 >/dev/null
}
openclaw_plugins_register_fixture_pid_file() {
local pid_file="$1"
OPENCLAW_PLUGINS_FIXTURE_PID_FILES+=("$pid_file")
openclaw_plugins_install_fixture_cleanup_trap
}
openclaw_plugins_install_fixture_cleanup_trap() {
if [[ "${OPENCLAW_PLUGINS_FIXTURE_EXIT_TRAP_INSTALLED:-0}" = "1" ]]; then
return
fi
local existing_trap
existing_trap="$(trap -p EXIT || true)"
if [[ -n "$existing_trap" && "$existing_trap" != *openclaw_plugins_fixture_exit_trap* ]]; then
local existing_action="${existing_trap#trap -- }"
existing_action="${existing_action% EXIT}"
eval "OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION=$existing_action"
fi
OPENCLAW_PLUGINS_FIXTURE_EXIT_TRAP_INSTALLED=1
trap openclaw_plugins_fixture_exit_trap EXIT
}
openclaw_plugins_fixture_exit_trap() {
local status="$?"
openclaw_plugins_cleanup_fixture_servers
if [[ -n "${OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION:-}" ]]; then
eval "$OPENCLAW_PLUGINS_FIXTURE_PREVIOUS_EXIT_ACTION"
fi
exit "$status"
}
record_fixture_plugin_trust() {
local plugin_id="$1"
local plugin_root="$2"
local enabled="$3"
node scripts/e2e/lib/plugins/assertions.mjs record-fixture-plugin-trust "$plugin_id" "$plugin_root" "$enabled"
}
write_demo_fixture_plugin() {
local dir="$1"
node scripts/e2e/lib/fixture.mjs plugin-demo "$dir"
}
write_fixture_plugin() {
local dir="$1"
local id="$2"
local version="$3"
local method="$4"
local name="$5"
node scripts/e2e/lib/fixture.mjs plugin "$dir" "$id" "$version" "$method" "$name"
}
write_fixture_plugin_with_cli() {
local dir="$1"
local id="$2"
local version="$3"
local method="$4"
local name="$5"
local cli_root="$6"
local cli_output="$7"
node scripts/e2e/lib/fixture.mjs plugin-cli "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output"
}
pack_fixture_plugin_with_cli_registry_dependency() {
local pack_dir="$1"
local output_tgz="$2"
local id="$3"
local version="$4"
local method="$5"
local name="$6"
local cli_root="$7"
local cli_output="$8"
mkdir -p "$pack_dir/package"
node scripts/e2e/lib/fixture.mjs plugin-cli-registry-dep "$pack_dir/package" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output"
tar -czf "$output_tgz" -C "$pack_dir" package
}
pack_fake_is_number_package() {
local pack_dir="$1"
local output_tgz="$2"
mkdir -p "$pack_dir/package"
node scripts/e2e/lib/fixture.mjs fake-is-number-package "$pack_dir/package"
tar -czf "$output_tgz" -C "$pack_dir" package
}
write_fixture_plugin_with_vendored_dependency() {
local dir="$1"
local id="$2"
local version="$3"
local method="$4"
local name="$5"
node scripts/e2e/lib/fixture.mjs plugin-vendored-dep "$dir" "$id" "$version" "$method" "$name"
}
pack_fixture_plugin() {
local pack_dir="$1"
local output_tgz="$2"
local id="$3"
local version="$4"
local method="$5"
local name="$6"
mkdir -p "$pack_dir/package"
write_fixture_plugin "$pack_dir/package" "$id" "$version" "$method" "$name"
tar -czf "$output_tgz" -C "$pack_dir" package
}
pack_fixture_plugin_with_invalid_extension_entry() {
local pack_dir="$1"
local output_tgz="$2"
local id="$3"
local version="$4"
local method="$5"
local name="$6"
mkdir -p "$pack_dir/package"
write_fixture_plugin "$pack_dir/package" "$id" "$version" "$method" "$name"
node --input-type=module - "$pack_dir/package/package.json" <<'NODE'
import fs from "node:fs";
const packageJsonPath = process.argv[2];
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
packageJson.openclaw.extensions = ["./index.js", " "];
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
NODE
tar -czf "$output_tgz" -C "$pack_dir" package
}
start_npm_fixture_registry() {
local package_name="$1"
local version="$2"
local tarball="$3"
local fixture_dir="$4"
local server_log="$fixture_dir/npm-registry.log"
local server_port_file="$fixture_dir/npm-registry-port"
local server_pid_file="$fixture_dir/npm-registry-pid"
shift 4
openclaw_plugins_validate_fixture_log_print_bytes || return $?
node scripts/e2e/lib/plugins/npm-registry-server.mjs "$server_port_file" "$package_name" "$version" "$tarball" "$@" >"$server_log" 2>&1 &
local server_pid="$!"
echo "$server_pid" >"$server_pid_file"
openclaw_plugins_register_fixture_pid_file "$server_pid_file"
for _ in $(seq 1 100); do
if [[ -s "$server_port_file" ]]; then
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$server_port_file")"
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
openclaw_plugins_print_fixture_log "$server_log"
return 1
fi
sleep 0.1
done
openclaw_plugins_print_fixture_log "$server_log"
echo "Timed out waiting for npm fixture registry." >&2
return 1
}
write_claude_bundle_fixture() {
local bundle_root="$1"
node scripts/e2e/lib/fixture.mjs claude-bundle "$bundle_root"
}

View File

@@ -0,0 +1,45 @@
run_plugins_marketplace_scenario() {
echo "Testing marketplace install and update flows..."
marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace"
mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin"
write_fixture_plugin \
"$marketplace_root/plugins/marketplace-shortcut" \
"marketplace-shortcut" \
"0.0.1" \
"demo.marketplace.shortcut.v1" \
"Marketplace Shortcut"
write_fixture_plugin \
"$marketplace_root/plugins/marketplace-direct" \
"marketplace-direct" \
"0.0.1" \
"demo.marketplace.direct.v1" \
"Marketplace Direct"
node scripts/e2e/lib/fixture.mjs marketplace "$marketplace_root"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/marketplace-list.json" plugins marketplace list claude-fixtures --json
node scripts/e2e/lib/plugins/assertions.mjs marketplace-list
run_plugins_openclaw_logged install-marketplace-shortcut plugins install marketplace-shortcut@claude-fixtures
run_plugins_openclaw_logged install-marketplace-direct plugins install marketplace-direct --marketplace claude-fixtures
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-shortcut-inspect.json" plugins inspect marketplace-shortcut --runtime --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-direct-inspect.json" plugins inspect marketplace-direct --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs marketplace-installed
node scripts/e2e/lib/plugins/assertions.mjs marketplace-records
write_fixture_plugin \
"$marketplace_root/plugins/marketplace-shortcut" \
"marketplace-shortcut" \
"0.0.2" \
"demo.marketplace.shortcut.v2" \
"Marketplace Shortcut"
run_plugins_openclaw_logged update-marketplace-shortcut-dry-run plugins update marketplace-shortcut --dry-run
run_plugins_openclaw_logged update-marketplace-shortcut plugins update marketplace-shortcut
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-updated.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-updated-inspect.json" plugins inspect marketplace-shortcut --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs marketplace-updated
}

View File

@@ -0,0 +1,120 @@
// Fixture npm registry server for plugin E2E scenarios.
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
const [portFile, ...packageArgs] = process.argv.slice(2);
if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) {
console.error(
"usage: npm-registry-server.mjs <port-file> <package-name> <version> <tarball-path> [...]",
);
process.exit(1);
}
const packages = new Map();
for (let index = 0; index < packageArgs.length; index += 3) {
const packageName = packageArgs[index];
const version = packageArgs[index + 1];
const tarballPath = packageArgs[index + 2];
const archive = fs.readFileSync(tarballPath);
const existing = packages.get(packageName) ?? {
encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"),
packageName,
latestVersion: version,
versions: new Map(),
};
existing.latestVersion = version;
existing.versions.set(version, {
archive,
dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {},
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
tarballName: path.basename(tarballPath),
version,
});
packages.set(packageName, existing);
}
const metadataFor = (entry, baseUrl) => ({
name: entry.packageName,
"dist-tags": { latest: entry.latestVersion },
versions: Object.fromEntries(
[...entry.versions.entries()].map(([version, versionEntry]) => [
version,
{
dependencies: versionEntry.dependencies,
name: entry.packageName,
version,
dist: {
integrity: versionEntry.integrity,
shasum: versionEntry.shasum,
tarball: `${baseUrl}/${entry.encodedPackageName}/-/${versionEntry.tarballName}`,
},
},
]),
),
});
function decodePackagePath(pathname) {
try {
return decodeURIComponent(pathname.slice(1));
} catch {
return undefined;
}
}
function findPackageForPath(pathname) {
const packageName = decodePackagePath(pathname);
return packageName === undefined ? undefined : packages.get(packageName);
}
function findTarballForPath(pathname) {
for (const entry of packages.values()) {
const prefix = `/${entry.encodedPackageName}/-/`;
if (!pathname.toLowerCase().startsWith(prefix.toLowerCase())) {
continue;
}
for (const versionEntry of entry.versions.values()) {
if (pathname.endsWith(`/${versionEntry.tarballName}`)) {
return versionEntry;
}
}
}
return undefined;
}
const server = http.createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const baseUrl = `http://127.0.0.1:${server.address().port}`;
if (request.method !== "GET") {
response.writeHead(405, { "content-type": "text/plain" });
response.end("method not allowed");
return;
}
const packageEntry = findPackageForPath(url.pathname);
if (packageEntry) {
response.writeHead(200, { "content-type": "application/json" });
response.end(`${JSON.stringify(metadataFor(packageEntry, baseUrl))}\n`);
return;
}
const tarballEntry = findTarballForPath(url.pathname);
if (tarballEntry) {
response.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": String(tarballEntry.archive.length),
});
response.end(tarballEntry.archive);
return;
}
response.writeHead(404, { "content-type": "text/plain" });
response.end(`not found: ${url.pathname}`);
});
server.listen(0, "127.0.0.1", () => {
fs.writeFileSync(portFile, String(server.address().port));
});

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
source scripts/lib/docker-e2e-logs.sh
OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY="${OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY:-0}"
if [[ -z "${OPENCLAW_ENTRY:-}" && "$OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY" != "1" ]]; then
OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)"
fi
export OPENCLAW_ENTRY
OPENCLAW_PLUGINS_CREATED_TMP_DIR=0
if [[ -z "${OPENCLAW_PLUGINS_TMP_DIR:-}" ]]; then
OPENCLAW_PLUGINS_TMP_DIR="$(mktemp -d "/tmp/openclaw-plugins.XXXXXX")"
OPENCLAW_PLUGINS_CREATED_TMP_DIR=1
fi
export OPENCLAW_PLUGINS_TMP_DIR
OPENCLAW_PLUGINS_CLI_TIMEOUT="${OPENCLAW_PLUGINS_CLI_TIMEOUT:-180s}"
mkdir -p "$OPENCLAW_PLUGINS_TMP_DIR"
run_plugins_openclaw_logged() {
local label="$1"
shift
run_logged "$label" openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@"
}
run_plugins_openclaw_capture() {
local output_file="$1"
shift
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file"
}
run_plugins_shell_logged() {
local label="$1"
shift
local command="$1"
run_logged "$label" openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" bash -c "$command"
}
source scripts/e2e/lib/plugins/fixtures.sh
source scripts/e2e/lib/plugins/marketplace.sh
source scripts/e2e/lib/plugins/clawhub.sh
cleanup_openclaw_plugins_sweep() {
openclaw_plugins_cleanup_fixture_servers
if [[ "${OPENCLAW_PLUGINS_CREATED_TMP_DIR:-0}" = "1" ]]; then
rm -rf "$OPENCLAW_PLUGINS_TMP_DIR"
fi
}
if [[ "$OPENCLAW_PLUGINS_SWEEP_SOURCE_ONLY" = "1" ]]; then
return 0 2>/dev/null || { cleanup_openclaw_plugins_sweep; exit 0; }
fi
trap cleanup_openclaw_plugins_sweep EXIT
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
PACKAGE_VERSION="$(node -p 'require("./package.json").version')"
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node scripts/e2e/lib/package-compat.mjs "$PACKAGE_VERSION")"
export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT
BUNDLED_PLUGIN_ROOT_DIR="extensions"
OPENCLAW_PLUGIN_HOME="$HOME/.openclaw/$BUNDLED_PLUGIN_ROOT_DIR"
demo_plugin_id="demo-plugin"
demo_plugin_root="$OPENCLAW_PLUGIN_HOME/$demo_plugin_id"
write_demo_fixture_plugin "$demo_plugin_root"
record_fixture_plugin_trust "$demo_plugin_id" "$demo_plugin_root" 1
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-inspect.json" plugins inspect demo-plugin --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs demo-plugin
echo "Testing tgz install flow..."
pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-pack.XXXXXX")"
pack_fixture_plugin "$pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" demo-plugin-tgz 0.0.1 demo.tgz "Demo Plugin TGZ"
run_plugins_openclaw_logged install-tgz plugins install "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2-inspect.json" plugins inspect demo-plugin-tgz --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-tgz
run_plugins_openclaw_logged uninstall-tgz plugins uninstall demo-plugin-tgz --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2-uninstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-tgz-removed
echo "Testing install from local folder (plugins.load.paths)..."
dir_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir.XXXXXX")"
write_fixture_plugin "$dir_plugin" demo-plugin-dir 0.0.1 demo.dir "Demo Plugin DIR"
run_plugins_openclaw_logged install-dir plugins install "$dir_plugin"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3-inspect.json" plugins inspect demo-plugin-dir --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir "$dir_plugin"
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update demo-plugin-dir >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-update.log" 2>&1
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-update-skipped
run_plugins_openclaw_logged uninstall-dir plugins uninstall demo-plugin-dir --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3-uninstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-removed
echo "Testing install from local folder with preinstalled dependencies..."
dir_deps_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir-deps.XXXXXX")"
write_fixture_plugin_with_vendored_dependency "$dir_deps_plugin" demo-plugin-dir-deps 0.0.1 demo.dir.deps "Demo Plugin DIR Deps"
run_plugins_openclaw_logged install-dir-deps plugins install "$dir_deps_plugin"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps-inspect.json" plugins inspect demo-plugin-dir-deps --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-deps "$dir_deps_plugin"
run_plugins_openclaw_logged uninstall-dir-deps plugins uninstall demo-plugin-dir-deps --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps-uninstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-deps-removed
echo "Testing install from npm spec (file:)..."
file_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-filepack.XXXXXX")"
write_fixture_plugin "$file_pack_dir/package" demo-plugin-file 0.0.1 demo.file "Demo Plugin FILE"
run_plugins_openclaw_logged install-file plugins install "file:$file_pack_dir/package"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4-inspect.json" plugins inspect demo-plugin-file --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-file "$file_pack_dir/package"
run_plugins_openclaw_logged uninstall-file plugins uninstall demo-plugin-file --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4-uninstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-file-removed
echo "Testing install and update from npm registry..."
npm_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-npm-pack.XXXXXX")"
npm_dep_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-npm-dep-pack.XXXXXX")"
invalid_npm_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-invalid-metadata-pack.XXXXXX")"
npm_registry_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-npm-registry.XXXXXX")"
pack_fixture_plugin_with_cli_registry_dependency "$npm_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-npm.tgz" demo-plugin-npm 0.0.1 demo.npm "Demo Plugin NPM" demo-npm "demo-plugin-npm:pong"
pack_fake_is_number_package "$npm_dep_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/is-number-7.0.0.tgz"
pack_fixture_plugin_with_invalid_extension_entry "$invalid_npm_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz" demo-plugin-invalid-metadata 0.0.1 demo.invalid.metadata "Demo Plugin Invalid Metadata"
start_npm_fixture_registry "@openclaw/demo-plugin-npm" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-npm.tgz" "$npm_registry_dir" "is-number" "7.0.0" "$OPENCLAW_PLUGINS_TMP_DIR/is-number-7.0.0.tgz" "@openclaw/demo-plugin-invalid-metadata" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz"
run_plugins_openclaw_logged install-npm plugins install "npm:@openclaw/demo-plugin-npm@0.0.1"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-inspect.json" plugins inspect demo-plugin-npm --runtime --json
run_plugins_shell_logged exec-npm-plugin-cli 'node "$OPENCLAW_ENTRY" demo-npm ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-cli.txt"'
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update demo-plugin-npm >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-update.log" 2>&1
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-update
run_plugins_openclaw_logged uninstall-npm plugins uninstall demo-plugin-npm --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-uninstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-removed
echo "Testing npm install rejects malformed package metadata..."
if openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "npm:@openclaw/demo-plugin-invalid-metadata@0.0.1" >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" 2>&1; then
cat "$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log"
echo "Expected malformed package metadata install to fail." >&2
exit 1
fi
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions-list.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs invalid-openclaw-extensions
echo "Testing install from git repo and plugin CLI execution..."
git_fixture_root="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-git.XXXXXX")"
git_repo="$git_fixture_root/repo"
git_repo_url="file://$git_repo"
write_fixture_plugin_with_cli "$git_repo" demo-plugin-git 0.0.1 demo.git "Demo Plugin Git" demo-git "demo-plugin-git:pong"
git -C "$git_repo" init -q
git -C "$git_repo" config user.email "docker-e2e@openclaw.local"
git -C "$git_repo" config user.name "OpenClaw Docker E2E"
git -C "$git_repo" add -A
git -C "$git_repo" commit -qm "test fixture"
git_ref="$(git -C "$git_repo" rev-parse HEAD)"
run_plugins_openclaw_logged install-git plugins install "git:$git_repo_url@$git_ref"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-inspect.json" plugins inspect demo-plugin-git --runtime --json
run_plugins_shell_logged exec-git-plugin-cli 'node "$OPENCLAW_ENTRY" demo-git ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-cli.txt"'
node scripts/e2e/lib/plugins/assertions.mjs plugin-git "$git_repo_url" "$git_ref"
run_plugins_openclaw_logged uninstall-git plugins uninstall demo-plugin-git --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-uninstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-git-removed
echo "Testing git plugin update from moving ref..."
git_update_fixture_root="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-git-update.XXXXXX")"
git_update_repo="$git_update_fixture_root/repo"
git_update_repo_url="file://$git_update_repo"
write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.1 demo.git.update.v1 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v1"
git -C "$git_update_repo" init -q
git -C "$git_update_repo" config user.email "docker-e2e@openclaw.local"
git -C "$git_update_repo" config user.name "OpenClaw Docker E2E"
git -C "$git_update_repo" checkout -qb main
git -C "$git_update_repo" add -A
git -C "$git_update_repo" commit -qm "test fixture v1"
git_update_ref_v1="$(git -C "$git_update_repo" rev-parse HEAD)"
run_plugins_openclaw_logged install-git-update plugins install "git:$git_update_repo_url@main"
write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.2 demo.git.update.v2 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v2"
git -C "$git_update_repo" add -A
git -C "$git_update_repo" commit -qm "test fixture v2"
openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins update demo-plugin-git-update >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update.log" 2>&1
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update-inspect.json" plugins inspect demo-plugin-git-update --runtime --json
run_plugins_shell_logged exec-updated-git-plugin-cli 'node "$OPENCLAW_ENTRY" demo-git-update ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-update-cli.txt"'
node scripts/e2e/lib/plugins/assertions.mjs plugin-git-updated "$git_update_ref_v1"
echo "Testing Claude bundle enable and inspect flow..."
bundle_plugin_id="claude-bundle-e2e"
bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id"
write_claude_bundle_fixture "$bundle_root"
record_fixture_plugin_trust "$bundle_plugin_id" "$bundle_root" 0
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-bundle-disabled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs bundle-disabled
run_plugins_openclaw_logged enable-claude-bundle plugins enable claude-bundle-e2e
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-bundle-inspect.json" plugins inspect claude-bundle-e2e --json
node scripts/e2e/lib/plugins/assertions.mjs bundle-inspect
echo "Testing plugin install visible after explicit restart..."
slash_install_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-slash-install.XXXXXX")"
write_fixture_plugin "$slash_install_dir" slash-install-plugin 0.0.1 demo.slash.install "Slash Install Plugin"
run_plugins_openclaw_logged install-slash-plugin plugins install "$slash_install_dir"
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugin-command-install-show.json" plugins inspect slash-install-plugin --runtime --json
node scripts/e2e/lib/plugins/assertions.mjs slash-install
run_plugins_marketplace_scenario
run_plugins_clawhub_scenario

View File

@@ -0,0 +1,69 @@
// Shared bounded file readers for release E2E assertion scripts.
import fs from "node:fs";
import { readTextFileTail } from "./text-file-utils.mjs";
const SCAN_CHUNK_BYTES = 64 * 1024;
const SCAN_CARRY_CHARS = 256;
export const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const JSON_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
export function readJson(file, maxBytes = JSON_ARTIFACT_MAX_BYTES) {
const stat = fs.statSync(file);
if (!stat.isFile()) {
throw new Error(`${file} is not a file`);
}
if (stat.size > maxBytes) {
throw new Error(
`JSON artifact exceeded ${maxBytes} bytes: ${file} (${stat.size} bytes). Tail: ${readTextFileTail(
file,
ERROR_DETAIL_TAIL_BYTES,
)}`,
);
}
const text = fs.readFileSync(file, "utf8");
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > maxBytes) {
throw new Error(
`JSON artifact exceeded ${maxBytes} bytes: ${file} (${bytes} bytes). Tail: ${readTextFileTail(
file,
ERROR_DETAIL_TAIL_BYTES,
)}`,
);
}
return JSON.parse(text);
}
export function fileContainsText(file, needle) {
let stat;
try {
stat = fs.statSync(file);
} catch {
return false;
}
if (!stat.isFile() || stat.size <= 0) {
return false;
}
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(Math.min(SCAN_CHUNK_BYTES, stat.size));
let carry = "";
let offset = 0;
while (offset < stat.size) {
const bytesToRead = Math.min(buffer.length, stat.size - offset);
const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, offset);
if (bytesRead <= 0) {
break;
}
offset += bytesRead;
const text = carry + buffer.subarray(0, bytesRead).toString("utf8");
if (text.includes(needle)) {
return true;
}
carry = text.slice(-Math.max(SCAN_CARRY_CHARS, needle.length - 1));
}
return false;
} finally {
fs.closeSync(fd);
}
}

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
export NO_COLOR=1
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_trash_shim
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export OPENAI_API_KEY="sk-openclaw-release-media-memory"
export OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER=1
PORT="18789"
MOCK_PORT="44200"
SUCCESS_MARKER="OPENCLAW_E2E_OK_MEDIA_MEMORY"
MEMORY_MARKER="release-media-memory-saffron-$(date +%s)"
media_root="$(mktemp -d /tmp/openclaw-release-media-memory.XXXXXX)"
INSTALL_LOG="$media_root/install.log"
ONBOARD_LOG="$media_root/onboard.log"
ENV_LOG="$media_root/env.log"
CONFIG_JSON="$media_root/config.json"
PACKAGE_FILES_LOG="$media_root/package-files.log"
PLUGINS_JSON="$media_root/plugins.json"
PLUGINS_STDERR_LOG="$media_root/plugins.stderr.log"
MOCK_OPENAI_LOG="$media_root/openai.log"
MOCK_REQUEST_LOG="$media_root/openai-requests.jsonl"
DESCRIBE_JSON="$media_root/describe.json"
DESCRIBE_STDERR_LOG="$media_root/describe.stderr.log"
GENERATE_JSON="$media_root/generate.json"
GENERATE_STDERR_LOG="$media_root/generate.stderr.log"
INDEX_LOG="$media_root/index.log"
SEARCH_BEFORE_JSON="$media_root/search-before.json"
SEARCH_BEFORE_STDERR_LOG="$media_root/search-before.stderr.log"
SEARCH_AFTER_JSON="$media_root/search-after.json"
SEARCH_AFTER_STDERR_LOG="$media_root/search-after.stderr.log"
GATEWAY_1_LOG="$media_root/gateway-1.log"
GATEWAY_2_LOG="$media_root/gateway-2.log"
export SUCCESS_MARKER MOCK_REQUEST_LOG
mock_pid=""
gateway_pid=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
if [ -n "${media_root:-}" ]; then
rm -rf "$media_root"
fi
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "release media memory failed with exit code $status" >&2
openclaw_e2e_dump_logs \
"$INSTALL_LOG" \
"$ONBOARD_LOG" \
"$ENV_LOG" \
"$CONFIG_JSON" \
"$PACKAGE_FILES_LOG" \
"$PLUGINS_JSON" \
"$PLUGINS_STDERR_LOG" \
"$MOCK_OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
"$DESCRIBE_JSON" \
"$DESCRIBE_STDERR_LOG" \
"$GENERATE_JSON" \
"$GENERATE_STDERR_LOG" \
"$INDEX_LOG" \
"$SEARCH_BEFORE_JSON" \
"$SEARCH_BEFORE_STDERR_LOG" \
"$SEARCH_AFTER_JSON" \
"$SEARCH_AFTER_STDERR_LOG" \
"$GATEWAY_1_LOG" \
"$GATEWAY_2_LOG"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
start_gateway() {
local log_path="$1"
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
}
stop_gateway() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
gateway_pid=""
}
openclaw_e2e_install_package "$INSTALL_LOG"
command -v openclaw >/dev/null
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
{
printf 'openclaw=%s\n' "$(command -v openclaw)"
printf 'package_root=%s\n' "$package_root"
printf 'entry=%s\n' "$entry"
printf 'HOME=%s\n' "$HOME"
printf 'OPENCLAW_HOME=%s\n' "$OPENCLAW_HOME"
printf 'OPENCLAW_STATE_DIR=%s\n' "$OPENCLAW_STATE_DIR"
printf 'OPENCLAW_CONFIG_PATH=%s\n' "$OPENCLAW_CONFIG_PATH"
} >"$ENV_LOG"
openclaw_e2e_enable_openclaw_cli_timeout
(
cd "$package_root/dist/extensions/memory-core"
find . -type f | sed 's#^\./##' | sort
) >"$PACKAGE_FILES_LOG"
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$MOCK_OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
openclaw onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--auth-choice skip \
--gateway-port "$PORT" \
--gateway-bind loopback \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >"$ONBOARD_LOG" 2>&1
cp "$OPENCLAW_CONFIG_PATH" "$CONFIG_JSON"
openclaw plugins list --json >"$PLUGINS_JSON" 2>"$PLUGINS_STDERR_LOG"
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGINS_JSON" memory-core
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
mkdir -p "$OPENCLAW_STATE_DIR/workspace/memory"
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=' | base64 -d >"$media_root/input.png"
openclaw infer image describe \
--file "$media_root/input.png" \
--model openai/gpt-5.5 \
--prompt "Describe this image and return marker $SUCCESS_MARKER" \
--json >"$DESCRIBE_JSON" 2>"$DESCRIBE_STDERR_LOG"
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-describe "$DESCRIBE_JSON" "$MOCK_REQUEST_LOG"
openclaw infer image generate \
--model openai/gpt-image-1 \
--prompt "Generate a tiny test image for $SUCCESS_MARKER" \
--output "$media_root/generated.png" \
--json >"$GENERATE_JSON" 2>"$GENERATE_STDERR_LOG"
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-generate "$GENERATE_JSON" "$MOCK_REQUEST_LOG"
cat >"$OPENCLAW_STATE_DIR/workspace/MEMORY.md" <<EOF
# Long-term memory
- The release media memory marker is $MEMORY_MARKER.
EOF
openclaw memory index --force >"$INDEX_LOG" 2>&1
openclaw memory search "$MEMORY_MARKER" --json >"$SEARCH_BEFORE_JSON" 2>"$SEARCH_BEFORE_STDERR_LOG"
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-memory-search "$SEARCH_BEFORE_JSON" "$MEMORY_MARKER"
start_gateway "$GATEWAY_1_LOG"
stop_gateway
start_gateway "$GATEWAY_2_LOG"
openclaw memory search "$MEMORY_MARKER" --json >"$SEARCH_AFTER_JSON" 2>"$SEARCH_AFTER_STDERR_LOG"
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-memory-search "$SEARCH_AFTER_JSON" "$MEMORY_MARKER"
stop_gateway
echo "Release media memory scenario passed."

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
export NO_COLOR=1
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_trash_shim
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
dump_debug_logs() {
local status="$1"
echo "release plugin marketplace failed with exit code $status" >&2
openclaw_e2e_dump_logs \
/tmp/openclaw-release-plugin-marketplace-install.log \
/tmp/openclaw-release-plugin-marketplace-onboard.log \
/tmp/openclaw-release-plugin-marketplace-list.json \
/tmp/openclaw-release-plugin-marketplace-install-plugin.log \
/tmp/openclaw-release-plugin-marketplace-cli-v1.log \
/tmp/openclaw-release-plugin-marketplace-update-dry-run.log \
/tmp/openclaw-release-plugin-marketplace-update.log \
/tmp/openclaw-release-plugin-marketplace-cli-v2.log \
/tmp/openclaw-release-plugin-marketplace-uninstall.log \
/tmp/openclaw-release-plugin-marketplace-cli-after-uninstall.log
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
openclaw_e2e_install_package /tmp/openclaw-release-plugin-marketplace-install.log
command -v openclaw >/dev/null
openclaw_e2e_enable_openclaw_cli_timeout
openclaw onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--auth-choice skip \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >/tmp/openclaw-release-plugin-marketplace-onboard.log 2>&1
marketplace_root="$HOME/.claude/plugins/marketplaces/release-fixture-marketplace"
mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin"
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
"$marketplace_root/plugins/release-marketplace-plugin" \
release-marketplace-plugin \
0.0.1 \
release.marketplace.v1 \
"Release Marketplace Plugin" \
release-market \
"release-marketplace-plugin:v1"
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
"$marketplace_root/plugins/release-marketplace-other" \
release-marketplace-other \
0.0.1 \
release.marketplace.other \
"Release Marketplace Other" \
release-market-other \
"release-marketplace-other:v1"
node scripts/e2e/lib/release-scenarios/write-marketplace.mjs \
"$marketplace_root" \
release-fixtures \
release-marketplace-plugin \
release-marketplace-other
openclaw plugins marketplace list release-fixtures --json >/tmp/openclaw-release-plugin-marketplace-list.json
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-list.json release-marketplace-plugin
openclaw plugins install release-marketplace-plugin@release-fixtures >/tmp/openclaw-release-plugin-marketplace-install-plugin.log 2>&1
openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-v1.log 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-cli-v1.log "release-marketplace-plugin:v1"
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
"$marketplace_root/plugins/release-marketplace-plugin" \
release-marketplace-plugin \
0.0.2 \
release.marketplace.v2 \
"Release Marketplace Plugin" \
release-market \
"release-marketplace-plugin:v2"
openclaw plugins update release-marketplace-plugin --dry-run >/tmp/openclaw-release-plugin-marketplace-update-dry-run.log 2>&1
openclaw plugins update release-marketplace-plugin >/tmp/openclaw-release-plugin-marketplace-update.log 2>&1
openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-v2.log 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-cli-v2.log "release-marketplace-plugin:v2"
openclaw plugins uninstall release-marketplace-plugin --force >/tmp/openclaw-release-plugin-marketplace-uninstall.log 2>&1
if openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-after-uninstall.log 2>&1; then
echo "release-market CLI should be gone after uninstall" >&2
exit 1
fi
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-plugin-uninstalled release-marketplace-plugin release-market
echo "Release plugin marketplace scenario passed."

View File

@@ -0,0 +1,224 @@
// Assertions for release scenario E2E packages and plugin state.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import {
assertAgentReplyContainsMarker,
assertOpenAiRequestLogUsed,
} from "../agent-turn-output.mjs";
import { assertOpenAiEnvAuthProfileStore } from "../auth-profile-store-assertions.mjs";
import {
applyMockOpenAiModelConfig,
parseMockOpenAiPort,
} from "../fixtures/mock-openai-config.mjs";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
import {
ERROR_DETAIL_TAIL_BYTES,
fileContainsText,
readJson,
} from "../release-assertion-files.mjs";
import { readTextFileTail } from "../text-file-utils.mjs";
const command = process.argv[2];
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function configPath() {
return (
process.env.OPENCLAW_CONFIG_PATH ??
path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json")
);
}
function writeConfig(cfg) {
fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`);
}
function authProfilesPath() {
return path.join(
process.env.HOME ?? "",
".openclaw",
"agents",
"main",
"agent",
"auth-profiles.json",
);
}
function authProfilesDatabasePath() {
return path.join(
process.env.HOME ?? "",
".openclaw",
"agents",
"main",
"agent",
"openclaw-agent.sqlite",
);
}
function readAuthProfileStoreSqliteText() {
const dbPath = authProfilesDatabasePath();
if (!fs.existsSync(dbPath)) {
return "";
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
.get("primary");
return typeof row?.store_json === "string" ? row.store_json : "";
} catch {
return "";
} finally {
db?.close();
}
}
function readStateText() {
const paths = [configPath(), authProfilesPath()].filter((file) => fs.existsSync(file));
return [...paths.map((file) => fs.readFileSync(file, "utf8")), readAuthProfileStoreSqliteText()]
.filter(Boolean)
.join("\n");
}
function configureMockOpenAi() {
const mockPort = parseMockOpenAiPort(process.argv[3]);
const cfg = readJson(configPath());
applyMockOpenAiModelConfig(cfg, { mockPort, includeImageDefaults: true });
writeConfig(cfg);
}
function assertOpenAiEnvRef() {
const rawKey = process.argv[3];
assert(fs.existsSync(configPath()), "openclaw.json missing");
assertOpenAiEnvAuthProfileStore(readAuthProfileStoreSqliteText(), {
missingMessage: "OpenAI env ref was not persisted",
envRefMessage: "OpenAI env ref was not persisted",
rawKeyMessage: "raw OpenAI key was persisted",
rawKeyNeedle: rawKey,
});
assert(!readStateText().includes(rawKey), "raw OpenAI key was persisted");
}
function assertSessionMemoryHookEnabled() {
const cfg = readJson(configPath());
assert(
cfg.hooks?.internal?.entries?.["session-memory"]?.enabled === true,
"session-memory hook was not enabled",
);
}
function assertAgentTurn() {
const marker = process.argv[3];
const outputPath = process.argv[4];
const requestLogPath = process.argv[5];
assertAgentReplyContainsMarker(marker, outputPath);
assertOpenAiRequestLogUsed(requestLogPath, "mock OpenAI");
}
function assertFileContains() {
const file = process.argv[3];
const needle = process.argv[4];
assert(
fileContainsText(file, needle),
`${file} did not contain ${needle}. Output tail: ${readTextFileTail(file, ERROR_DETAIL_TAIL_BYTES)}`,
);
}
function assertPackageVersion() {
const packageRoot = process.argv[3];
const expectedVersion = process.argv[4];
const label = process.argv[5] ?? "package";
assert(packageRoot, "missing package root");
assert(expectedVersion, "missing expected package version");
const packageJsonPath = path.join(packageRoot, "package.json");
const packageJson = readJson(packageJsonPath);
assert(
packageJson.version === expectedVersion,
`${label} package version mismatch: expected ${expectedVersion}, got ${packageJson.version}`,
);
}
function assertImageDescribe() {
const outputPath = process.argv[3];
const requestLogPath = process.argv[4];
const payload = readJson(outputPath);
assert(payload.ok === true, `image describe failed: ${JSON.stringify(payload)}`);
assert(payload.capability === "image.describe", "wrong image describe capability");
const output = payload.outputs?.[0];
assert(output?.text?.includes("OPENCLAW_E2E_OK"), "image description marker missing");
assert(output.provider === "openai", `unexpected image provider: ${output?.provider}`);
assert(
fileContainsText(requestLogPath, "/v1/responses"),
"image describe did not hit Responses API",
);
}
function assertImageGenerate() {
const outputPath = process.argv[3];
const requestLogPath = process.argv[4];
const payload = readJson(outputPath);
assert(payload.ok === true, `image generation failed: ${JSON.stringify(payload)}`);
assert(payload.capability === "image.generate", "wrong image generation capability");
const output = payload.outputs?.[0];
assert(output?.path && fs.existsSync(output.path), `generated image missing: ${output?.path}`);
assert(output.mimeType === "image/png", `unexpected generated mime type: ${output.mimeType}`);
assert(payload.provider === "openai", `unexpected generation provider: ${payload.provider}`);
assert(
fileContainsText(requestLogPath, "/v1/images/generations"),
"image generation endpoint was not used",
);
}
function assertMemorySearch() {
const outputPath = process.argv[3];
const needle = process.argv[4];
const payload = readJson(outputPath);
const haystack = JSON.stringify(payload);
assert(haystack.includes(needle), `memory search missed ${needle}: ${haystack}`);
}
function assertPluginUninstalled() {
const pluginId = process.argv[3];
const cliRoot = process.argv[4];
const cfg = readJson(configPath());
const installRecords = readPluginInstallRecords({ configPath: configPath() });
assert(!installRecords[pluginId], `install record still present for ${pluginId}`);
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
const managedRoot = path.join(
process.env.HOME ?? "",
".openclaw",
"plugins",
"installed",
pluginId,
);
assert(!fs.existsSync(managedRoot), `managed plugin directory still present: ${managedRoot}`);
if (cliRoot) {
const list = JSON.stringify(installRecords);
assert(!list.includes(cliRoot), `install records still mention CLI root ${cliRoot}`);
}
}
const commands = {
"configure-mock-openai": configureMockOpenAi,
"assert-openai-env-ref": assertOpenAiEnvRef,
"assert-session-memory-hook-enabled": assertSessionMemoryHookEnabled,
"assert-agent-turn": assertAgentTurn,
"assert-file-contains": assertFileContains,
"assert-package-version": assertPackageVersion,
"assert-image-describe": assertImageDescribe,
"assert-image-generate": assertImageGenerate,
"assert-memory-search": assertMemorySearch,
"assert-plugin-uninstalled": assertPluginUninstalled,
};
const fn = commands[command];
if (!fn) {
throw new Error(`unknown release scenario assertion command: ${command ?? "<missing>"}`);
}
await fn();

View File

@@ -0,0 +1,33 @@
// Writes a CLI plugin fixture for release scenario E2E tests.
import fs from "node:fs";
import path from "node:path";
const [dir, id, version, method, name, cliRoot, cliOutput] = process.argv.slice(2);
if (!dir || !id || !version || !method || !name || !cliRoot || !cliOutput) {
throw new Error(
"usage: write-cli-plugin.mjs <dir> <id> <version> <method> <name> <cliRoot> <cliOutput>",
);
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, "package.json"),
`${JSON.stringify(
{
name: `@openclaw/${id}`,
version,
openclaw: { extensions: ["./index.js"] },
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(dir, "index.js"),
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true, version: ${JSON.stringify(version)} })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
);
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
`${JSON.stringify({ id, configSchema: { type: "object", properties: {} } }, null, 2)}\n`,
);

View File

@@ -0,0 +1,42 @@
// Writes a marketplace fixture for release scenario E2E tests.
import fs from "node:fs";
import path from "node:path";
const [root, alias, ...plugins] = process.argv.slice(2);
if (!root || !alias || plugins.length === 0) {
throw new Error("usage: write-marketplace.mjs <root> <alias> <pluginId>...");
}
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
fs.mkdirSync(path.join(process.env.HOME, ".claude", "plugins"), { recursive: true });
fs.writeFileSync(
path.join(root, ".claude-plugin", "marketplace.json"),
`${JSON.stringify(
{
name: "Release Fixture Marketplace",
version: "1.0.0",
plugins: plugins.map((pluginId) => ({
name: pluginId,
version: "0.0.1",
description: `${pluginId} release fixture`,
source: { type: "path", path: `./plugins/${pluginId}` },
})),
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(process.env.HOME, ".claude", "plugins", "known_marketplaces.json"),
`${JSON.stringify(
{
[alias]: {
installLocation: root,
source: { type: "github", repo: "openclaw/release-fixture-marketplace" },
},
},
null,
2,
)}\n`,
);

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
export NO_COLOR=1
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_trash_shim
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export OPENAI_API_KEY="sk-openclaw-release-typed-onboarding"
PORT="18789"
MOCK_PORT="44190"
SUCCESS_MARKER="OPENCLAW_E2E_OK_TYPED_ONBOARDING"
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-typed-onboarding.XXXXXX")"
LOG_DIR="$scenario_tmp/logs"
mkdir -p "$LOG_DIR"
INSTALL_LOG="$LOG_DIR/install.log"
ONBOARD_LOG="$LOG_DIR/onboard.log"
OPENAI_LOG="$LOG_DIR/openai.log"
AGENT_LOG="$LOG_DIR/agent.log"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
export SUCCESS_MARKER MOCK_REQUEST_LOG
mock_pid=""
wizard_pid=""
input_fifo_dir=""
cleanup() {
exec 3>&- 2>/dev/null || true
openclaw_e2e_stop_process "${wizard_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
if [ -n "${input_fifo_dir:-}" ]; then
rm -rf "$input_fifo_dir"
fi
rm -rf "$scenario_tmp"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "release typed onboarding failed with exit code $status" >&2
openclaw_e2e_dump_logs \
"$INSTALL_LOG" \
"$ONBOARD_LOG" \
"$OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
"$AGENT_LOG" \
"$OPENCLAW_CONFIG_PATH" \
"$HOME/.openclaw/agents/main/agent/auth-profiles.json"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
send() {
local payload="$1"
local delay="${2:-0.4}"
sleep "$delay"
printf "%b" "$payload" >&3 2>/dev/null || true
}
wait_for_log() {
local needle="$1"
local timeout_s="${2:-60}"
local start_s
start_s="$(date +%s)"
while true; do
if [ -f "$ONBOARD_LOG" ]; then
if grep -a -F -q "$needle" "$ONBOARD_LOG"; then
return 0
fi
if node scripts/e2e/lib/onboard/log-contains.mjs "$ONBOARD_LOG" "$needle"; then
return 0
fi
fi
if [ $(($(date +%s) - start_s)) -ge "$timeout_s" ]; then
echo "Timeout waiting for log: $needle" >&2
tail -n 120 "$ONBOARD_LOG" 2>/dev/null || true
return 1
fi
sleep 0.2
done
}
openclaw_e2e_install_package "$INSTALL_LOG"
command -v openclaw >/dev/null
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
openclaw_e2e_enable_openclaw_cli_timeout
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
input_fifo_dir="$(mktemp -d "$scenario_tmp/input.XXXXXX")"
input_fifo="$input_fifo_dir/stdin.fifo"
mkfifo "$input_fifo"
openclaw_e2e_run_script_with_pty "node \"$entry\" onboard --flow quickstart --mode local --auth-choice skip --gateway-port \"$PORT\" --gateway-bind loopback --skip-daemon --skip-ui --skip-channels --skip-skills --skip-health" "$ONBOARD_LOG" <"$input_fifo" >/dev/null 2>&1 &
wizard_pid="$!"
exec 3>"$input_fifo"
wait_for_log "Continue?" 60
send $'y\r' 0.4
wait_for_log "to search" 60
send $'ollama\r' 0.4
wait "$wizard_pid"
wizard_pid=""
exec 3>&-
rm -rf "$input_fifo_dir"
input_fifo_dir=""
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-session-memory-hook-enabled
openclaw onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--auth-choice openai-api-key \
--secret-input-mode ref \
--gateway-port "$PORT" \
--gateway-bind loopback \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >>"$ONBOARD_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-openai-env-ref "$OPENAI_API_KEY"
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
openclaw agent --local \
--agent main \
--session-id release-typed-onboarding-agent \
--message "Return marker $SUCCESS_MARKER" \
--thinking off \
--json >"$AGENT_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
echo "Release typed onboarding scenario passed."

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
export NO_COLOR=1
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_trash_shim
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export OPENAI_API_KEY="sk-openclaw-release-upgrade-user-journey"
export CLICKCLACK_BOT_TOKEN="clickclack-release-upgrade-token"
PORT="18789"
MOCK_PORT="44210"
CLICKCLACK_PORT="44211"
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_UPGRADE"
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-upgrade-user-journey.XXXXXX")"
LOG_DIR="$scenario_tmp/logs"
mkdir -p "$LOG_DIR"
BASELINE_INSTALL_LOG="$LOG_DIR/baseline-install.log"
CANDIDATE_INSTALL_LOG="$LOG_DIR/candidate-install.log"
ONBOARD_LOG="$LOG_DIR/onboard.log"
OPENAI_LOG="$LOG_DIR/openai.log"
PLUGIN_INSTALL_LOG="$LOG_DIR/plugin-install.log"
PLUGIN_CLI_BEFORE_LOG="$LOG_DIR/plugin-cli-before.log"
PLUGIN_CLI_AFTER_LOG="$LOG_DIR/plugin-cli-after.log"
AGENT_LOG="$LOG_DIR/agent.log"
STATUS_JSON="$LOG_DIR/status.json"
STATUS_ERR="$LOG_DIR/status.err"
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
GATEWAY_LOG="$LOG_DIR/gateway.log"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
candidate_version="$(
tar -xOf "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" package/package.json |
node -e 'let raw = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { raw += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.parse(raw).version); });'
)"
if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then
BASELINE_SPEC="$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC"
else
BASELINE_SPEC="$(node scripts/lib/release-upgrade-baseline.mjs --candidate-version "$candidate_version")"
fi
mock_pid=""
clickclack_pid=""
gateway_pid=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${clickclack_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
rm -rf "$scenario_tmp"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "release upgrade user journey failed with exit code $status" >&2
openclaw_e2e_dump_logs \
"$BASELINE_INSTALL_LOG" \
"$CANDIDATE_INSTALL_LOG" \
"$ONBOARD_LOG" \
"$OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
"$PLUGIN_INSTALL_LOG" \
"$PLUGIN_CLI_BEFORE_LOG" \
"$PLUGIN_CLI_AFTER_LOG" \
"$AGENT_LOG" \
"$STATUS_JSON" \
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
"$CLICKCLACK_OUTBOUND_JSON" \
"$CLICKCLACK_SERVER_LOG" \
"$GATEWAY_LOG" \
"$CLICKCLACK_STATE"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
start_gateway() {
local log_path="$1"
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
}
echo "Installing published baseline $BASELINE_SPEC..."
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g "$BASELINE_SPEC" --no-fund --no-audit >"$BASELINE_INSTALL_LOG" 2>&1; then
cat "$BASELINE_INSTALL_LOG" >&2 || true
exit 1
fi
command -v openclaw >/dev/null
baseline_root="$(openclaw_e2e_package_root)"
baseline_entry="$(openclaw_e2e_package_entrypoint "$baseline_root")"
openclaw_e2e_enable_openclaw_cli_timeout
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
clickclack_pid="$!"
for _ in $(seq 1 100); do
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
break
fi
sleep 0.1
done
openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200
openclaw_e2e_run_command node "$baseline_entry" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--auth-choice skip \
--gateway-port "$PORT" \
--gateway-bind loopback \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >"$ONBOARD_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
plugin_dir="$(mktemp -d "$scenario_tmp/plugin.XXXXXX")"
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
"$plugin_dir" \
release-upgrade-plugin \
0.0.1 \
release.upgrade.plugin \
"Release Upgrade Plugin" \
release-upgrade \
"release-upgrade-plugin:pong"
openclaw plugins install "$plugin_dir" >"$PLUGIN_INSTALL_LOG" 2>&1
openclaw release-upgrade ping >"$PLUGIN_CLI_BEFORE_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_BEFORE_LOG" "release-upgrade-plugin:pong"
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
openclaw_e2e_install_package "$CANDIDATE_INSTALL_LOG" "candidate OpenClaw package"
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
openclaw_e2e_enable_openclaw_cli_timeout
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-package-version "$package_root" "$candidate_version" candidate
openclaw agent --local \
--agent main \
--session-id release-upgrade-user-journey-agent \
--message "Return marker $SUCCESS_MARKER" \
--thinking off \
--json >"$AGENT_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
openclaw release-upgrade ping >"$PLUGIN_CLI_AFTER_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_AFTER_LOG" "release-upgrade-plugin:pong"
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
openclaw message send \
--channel clickclack \
--target channel:general \
--message "release upgrade outbound" \
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release upgrade outbound"
start_gateway "$GATEWAY_LOG"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
echo "Release upgrade user journey scenario passed."

View File

@@ -0,0 +1,398 @@
// Assertions for release user-journey E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
assertAgentReplyContainsMarker,
assertOpenAiRequestLogUsed,
} from "../agent-turn-output.mjs";
import { readBoundedResponseText as readBoundedResponseTextWithLimit } from "../bounded-response-text.mjs";
import {
applyMockOpenAiModelConfig,
parseMockOpenAiPort,
} from "../fixtures/mock-openai-config.mjs";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
import {
ERROR_DETAIL_TAIL_BYTES,
fileContainsText,
readJson,
} from "../release-assertion-files.mjs";
import { readTextFileTail } from "../text-file-utils.mjs";
function clickClackHttpTimeoutMs() {
return readPositiveInt(
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS,
5000,
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS",
);
}
function clickClackHttpBodyMaxBytes() {
return readPositiveInt(
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES,
1024 * 1024,
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES",
);
}
function readPositiveInt(raw, fallback, label) {
const text = String(raw ?? "").trim();
if (!text) {
return fallback;
}
if (!/^\d+$/u.test(text)) {
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
}
return parsed;
}
async function withClickClackFixtureResponse(url, init, consume, options = {}) {
const timeoutMs = options.timeoutMs ?? clickClackHttpTimeoutMs();
const controller = new AbortController();
const timeoutError = new Error(`${url} timed out after ${timeoutMs}ms`);
let timer;
let response;
const timeoutPromise = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
});
try {
response = await Promise.race([
fetch(url, {
...init,
signal: controller.signal,
}),
timeoutPromise,
]);
return await consume(response, { timeoutPromise });
} finally {
clearTimeout(timer);
await response?.body?.cancel?.().catch(() => undefined);
}
}
async function readBoundedResponseText(
response,
label,
byteLimit = clickClackHttpBodyMaxBytes(),
options = {},
) {
return await readBoundedResponseTextWithLimit(response, label, byteLimit, options.timeoutPromise);
}
async function readBoundedResponseJson(response, label, options = {}) {
return JSON.parse(await readBoundedResponseText(response, label, undefined, options));
}
function resolveHomePath(value) {
if (value === "~") {
return process.env.HOME;
}
if (value?.startsWith("~/") || value?.startsWith("~\\")) {
return path.join(process.env.HOME ?? "", value.slice(2));
}
return value;
}
function comparablePath(value) {
const resolved = path.resolve(resolveHomePath(value));
try {
return fs.realpathSync.native(resolved);
} catch {
return resolved;
}
}
function pathsEqual(left, right) {
return comparablePath(left) === comparablePath(right);
}
function configPath() {
return (
process.env.OPENCLAW_CONFIG_PATH ??
path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json")
);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function writeConfig(cfg) {
fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`);
}
function installRecords() {
return readPluginInstallRecords({ configPath: configPath() });
}
function assertOnboard() {
const home = process.argv[3];
const stateDir = path.join(home, ".openclaw");
const authPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
assert(fs.existsSync(configPath()), "onboard did not write openclaw.json");
const stateRaw =
fs.readFileSync(configPath(), "utf8") +
(fs.existsSync(authPath) ? fs.readFileSync(authPath, "utf8") : "");
assert(
!stateRaw.includes("sk-openclaw-release-user-journey"),
"onboard persisted raw OpenAI key",
);
}
function configureMockModel() {
const mockPort = parseMockOpenAiPort(process.argv[3]);
const cfg = readJson(configPath());
applyMockOpenAiModelConfig(cfg, { mockPort });
writeConfig(cfg);
}
function assertAgentTurn() {
const marker = process.argv[3];
const outputPath = process.argv[4];
const requestLogPath = process.argv[5];
assertAgentReplyContainsMarker(marker, outputPath);
assertOpenAiRequestLogUsed(requestLogPath);
}
function assertFileContains() {
const file = process.argv[3];
const needle = process.argv[4];
assert(
fileContainsText(file, needle),
`${file} did not contain ${needle}. Output tail: ${readTextFileTail(file, ERROR_DETAIL_TAIL_BYTES)}`,
);
}
function rememberPluginInstallPath() {
const pluginId = process.argv[3];
const installPathFile = process.argv[4];
const sourcePathFile = process.argv[5];
const expectedSourcePath = process.argv[6];
assert(pluginId, "missing plugin id");
assert(installPathFile, "missing install path file");
const record = installRecords()[pluginId];
assert(record, `missing install record for ${pluginId}`);
const installPath = resolveHomePath(record.installPath);
assert(installPath, `install path missing for ${pluginId}`);
assert(
fs.existsSync(installPath),
`install path missing on disk for ${pluginId}: ${installPath}`,
);
if (expectedSourcePath && record.sourcePath) {
assert(
pathsEqual(record.sourcePath, expectedSourcePath),
`unexpected source path for ${pluginId}: ${record.sourcePath}, expected ${expectedSourcePath}`,
);
}
fs.writeFileSync(installPathFile, installPath, "utf8");
if (sourcePathFile && (expectedSourcePath || record.sourcePath)) {
fs.writeFileSync(
sourcePathFile,
expectedSourcePath || resolveHomePath(record.sourcePath),
"utf8",
);
}
}
function assertPluginUninstalled() {
const pluginId = process.argv[3];
const installPathFile = process.argv[4];
const sourcePathFile = process.argv[5];
const cfg = readJson(configPath());
const records = installRecords();
assert(!records[pluginId], `install record still present for ${pluginId}`);
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
if (!installPathFile) {
return;
}
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
const sourcePath =
sourcePathFile && fs.existsSync(sourcePathFile)
? fs.readFileSync(sourcePathFile, "utf8").trim()
: "";
if (sourcePath) {
assert(
fs.existsSync(sourcePath),
`source path was deleted during uninstall for ${pluginId}: ${sourcePath}`,
);
}
const installPathIsSourcePath = sourcePath ? pathsEqual(installPath, sourcePath) : false;
assert(
installPathIsSourcePath || !fs.existsSync(installPath),
`managed plugin directory still present: ${installPath}`,
);
}
function configureClickClack() {
const baseUrl = process.argv[3];
const cfg = readJson(configPath());
cfg.plugins = {
...cfg.plugins,
enabled: true,
entries: {
...cfg.plugins?.entries,
clickclack: {
...cfg.plugins?.entries?.clickclack,
enabled: true,
llm: {
...cfg.plugins?.entries?.clickclack?.llm,
allowAgentIdOverride: true,
allowModelOverride: true,
allowedModels: ["openai/gpt-5.5"],
},
},
},
};
cfg.channels = {
...cfg.channels,
clickclack: {
...cfg.channels?.clickclack,
enabled: true,
baseUrl,
token: { source: "env", provider: "default", id: "CLICKCLACK_BOT_TOKEN" },
workspace: "release",
defaultTo: "channel:general",
replyMode: "model",
model: "openai/gpt-5.5",
reconnectMs: 250,
},
};
writeConfig(cfg);
}
function assertChannelStatus() {
const channel = process.argv[3];
const statusPath = process.argv[4];
const status = readJson(statusPath);
const configured = Array.isArray(status.configuredChannels) ? status.configuredChannels : [];
const liveStatus = status.channels?.[channel];
assert(
configured.includes(channel) || liveStatus?.ok === true,
`${channel} missing from channels status: ${JSON.stringify(status)}`,
);
}
async function postClickClackInbound() {
const baseUrl = process.argv[3];
const body = process.argv[4];
await withClickClackFixtureResponse(
`${baseUrl}/fixture/inbound`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ body }),
},
async (response, options) => {
const text = response.ok
? ""
: await readBoundedResponseText(response, "ClickClack inbound", undefined, options);
assert(response.ok, `fixture inbound failed: ${response.status} ${text}`);
},
);
}
async function waitClickClackSocket() {
const baseUrl = process.argv[3];
const timeoutSeconds = readPositiveInt(
process.argv[4],
30,
"ClickClack websocket timeout seconds",
);
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const remainingMs = Math.max(1, deadline - Date.now());
const state = await withClickClackFixtureResponse(
`${baseUrl}/fixture/state`,
{},
async (response, options) =>
response.ok
? await readBoundedResponseJson(response, "ClickClack fixture state", options)
: undefined,
{
timeoutMs: Math.min(clickClackHttpTimeoutMs(), remainingMs),
},
).catch(() => undefined);
if (state) {
if (Number(state.socketCount ?? 0) > 0) {
return;
}
}
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
}
throw new Error(`Timed out waiting for ClickClack websocket connection at ${baseUrl}`);
}
function assertClickClackState() {
const mode = process.argv[3];
const statePath = process.argv[4];
const needle = process.argv[5];
const state = readJson(statePath);
const haystack = JSON.stringify(mode === "outbound" ? state.outboundMessages : state);
assert(haystack.includes(needle), `ClickClack state did not contain ${needle}: ${haystack}`);
}
async function waitClickClackReply() {
const statePath = process.argv[3];
const marker = process.argv[4];
const timeoutSeconds = readPositiveInt(process.argv[5], 30, "ClickClack reply timeout seconds");
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
if (fs.existsSync(statePath)) {
const state = readJson(statePath);
if (JSON.stringify(state.threadReplies ?? []).includes(marker)) {
return;
}
}
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
}
const state = fs.existsSync(statePath) ? fs.readFileSync(statePath, "utf8") : "<missing>";
throw new Error(`Timed out waiting for ClickClack reply marker ${marker}. State: ${state}`);
}
const commands = {
"assert-onboard": assertOnboard,
"remember-plugin-install-path": rememberPluginInstallPath,
"configure-mock-model": configureMockModel,
"assert-agent-turn": assertAgentTurn,
"assert-file-contains": assertFileContains,
"assert-plugin-uninstalled": assertPluginUninstalled,
"configure-clickclack": configureClickClack,
"assert-channel-status": assertChannelStatus,
"post-clickclack-inbound": postClickClackInbound,
"wait-clickclack-socket": waitClickClackSocket,
"assert-clickclack-state": assertClickClackState,
"wait-clickclack-reply": waitClickClackReply,
};
export async function runReleaseUserJourneyAssertion(command, args = []) {
const fn = commands[command];
if (!fn) {
throw new Error(`unknown release-user-journey assertion command: ${command ?? "<missing>"}`);
}
const previousArgv = process.argv;
process.argv = [previousArgv[0] ?? "node", fileURLToPath(import.meta.url), command, ...args];
try {
await fn();
} finally {
process.argv = previousArgv;
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await runReleaseUserJourneyAssertion(process.argv[2], process.argv.slice(3));
}

View File

@@ -0,0 +1,345 @@
// ClickClack fixture server for release user-journey E2E scenarios.
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
const port = readTcpPortEnv("CLICKCLACK_FIXTURE_PORT", 44181);
const requestMaxBytes = readPositiveIntEnv("CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", 4 * 1024 * 1024);
const token = process.env.CLICKCLACK_FIXTURE_TOKEN ?? "clickclack-release-token";
const statePath = process.env.CLICKCLACK_FIXTURE_STATE ?? "/tmp/openclaw-clickclack-fixture.json";
const workspace = {
id: "ws_release",
name: "Release Workspace",
slug: "release",
created_at: new Date(0).toISOString(),
};
const channel = {
id: "ch_general",
workspace_id: workspace.id,
name: "general",
kind: "text",
created_at: new Date(0).toISOString(),
};
const botUser = {
id: "usr_bot",
kind: "bot",
display_name: "OpenClaw Bot",
handle: "openclaw",
avatar_url: "",
created_at: new Date(0).toISOString(),
};
const humanUser = {
id: "usr_human",
kind: "human",
display_name: "Release User",
handle: "release-user",
avatar_url: "",
created_at: new Date(0).toISOString(),
};
let messageSeq = 0;
let eventSeq = 0;
const messages = [];
const threadReplies = [];
const outboundMessages = [];
const sockets = new Set();
function persist() {
fs.writeFileSync(
statePath,
`${JSON.stringify(
{
messages,
threadReplies,
outboundMessages,
socketCount: sockets.size,
},
null,
2,
)}\n`,
);
}
function now() {
return new Date().toISOString();
}
function json(res, status, body) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
function unauthorized(res) {
json(res, 401, { error: "unauthorized" });
}
function checkAuth(req, res) {
if (req.url?.startsWith("/fixture/") || req.url === "/health") {
return true;
}
if (req.headers.authorization !== `Bearer ${token}`) {
unauthorized(res);
return false;
}
return true;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
let bytes = 0;
let settled = false;
req.setEncoding("utf8");
req.on("data", (chunk) => {
if (settled) {
return;
}
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > requestMaxBytes) {
settled = true;
body = "";
req.resume();
reject(requestBodyTooLargeError());
return;
}
body += chunk;
});
req.on("end", () => {
if (settled) {
return;
}
settled = true;
try {
resolve(body ? JSON.parse(body) : {});
} catch {
resolve({});
}
});
req.on("error", (error) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
}
function requestBodyTooLargeError() {
return Object.assign(
new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`),
{
code: "ETOOBIG",
},
);
}
function isRequestBodyTooLargeError(error) {
return error instanceof Error && error.code === "ETOOBIG";
}
function handleRequestError(res, error) {
if (res.headersSent) {
res.destroy();
return;
}
if (isRequestBodyTooLargeError(error)) {
json(res, 413, { error: error.message });
return;
}
json(res, 500, { error: String(error instanceof Error ? error.message : error) });
}
function createMessage({ body, author = humanUser, parentMessageId }) {
messageSeq += 1;
const id = `msg_${messageSeq}`;
const message = {
id,
workspace_id: workspace.id,
channel_id: channel.id,
author_id: author.id,
...(parentMessageId ? { parent_message_id: parentMessageId } : {}),
thread_root_id: parentMessageId ?? id,
channel_seq: messageSeq,
thread_seq: parentMessageId ? threadReplies.length + 1 : 0,
body,
body_format: "markdown",
created_at: now(),
author,
};
if (parentMessageId) {
threadReplies.push(message);
} else {
messages.push(message);
}
persist();
return message;
}
function eventFor(message) {
eventSeq += 1;
return {
id: `evt_${eventSeq}`,
cursor: String(eventSeq),
type: message.parent_message_id ? "thread.reply_created" : "message.created",
workspace_id: workspace.id,
channel_id: channel.id,
seq: message.channel_seq,
created_at: now(),
payload: {
message_id: message.id,
author_id: message.author_id,
...(message.parent_message_id ? { root_message_id: message.thread_root_id } : {}),
},
};
}
function frameText(text) {
const payload = Buffer.from(text);
if (payload.length < 126) {
return Buffer.concat([Buffer.from([0x81, payload.length]), payload]);
}
if (payload.length < 65536) {
const header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(payload.length, 2);
return Buffer.concat([header, payload]);
}
const header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(payload.length), 2);
return Buffer.concat([header, payload]);
}
function broadcast(event) {
const frame = frameText(JSON.stringify(event));
for (const socket of sockets) {
socket.write(frame);
}
}
async function handleRequest(req, res) {
try {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (!checkAuth(req, res)) {
return;
}
if (req.method === "GET" && url.pathname === "/health") {
json(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/api/me") {
json(res, 200, { user: botUser });
return;
}
if (req.method === "GET" && url.pathname === "/api/workspaces") {
json(res, 200, { workspaces: [workspace] });
return;
}
if (req.method === "GET" && url.pathname === `/api/workspaces/${workspace.id}/channels`) {
json(res, 200, { channels: [channel] });
return;
}
if (req.method === "GET" && url.pathname === `/api/channels/${channel.id}/messages`) {
const afterSeq = Number(url.searchParams.get("after_seq") ?? 0);
json(res, 200, {
messages: messages.filter((message) => (message.channel_seq ?? 0) > afterSeq),
});
return;
}
if (req.method === "POST" && url.pathname === `/api/channels/${channel.id}/messages`) {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: botUser });
outboundMessages.push(message);
persist();
json(res, 200, { message });
return;
}
const threadReplyMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread\/replies$/u);
if (req.method === "POST" && threadReplyMatch) {
const body = await readBody(req);
const message = createMessage({
body: String(body.body ?? ""),
author: botUser,
parentMessageId: decodeURIComponent(threadReplyMatch[1]),
});
json(res, 200, { message });
return;
}
const threadMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread$/u);
if (req.method === "GET" && threadMatch) {
const rootId = decodeURIComponent(threadMatch[1]);
json(res, 200, {
root: messages.find((message) => message.id === rootId) ?? null,
replies: threadReplies.filter((message) => message.thread_root_id === rootId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/realtime/events") {
json(res, 200, { events: [] });
return;
}
if (req.method === "POST" && url.pathname === "/fixture/inbound") {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: humanUser });
broadcast(eventFor(message));
json(res, 200, { message });
return;
}
if (req.method === "GET" && url.pathname === "/fixture/state") {
json(res, 200, { messages, threadReplies, outboundMessages, socketCount: sockets.size });
return;
}
json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` });
} catch (error) {
handleRequestError(res, error);
}
}
const server = http.createServer((req, res) => {
void handleRequest(req, res);
});
server.on("upgrade", (req, socket) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (url.pathname !== "/api/realtime/ws" || req.headers.authorization !== `Bearer ${token}`) {
socket.destroy();
return;
}
const key = req.headers["sec-websocket-key"];
if (typeof key !== "string") {
socket.destroy();
return;
}
const accept = crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
socket.write(
[
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${accept}`,
"",
"",
].join("\r\n"),
);
sockets.add(socket);
persist();
socket.on("close", () => {
sockets.delete(socket);
persist();
});
socket.on("error", () => {
sockets.delete(socket);
persist();
});
});
persist();
server.listen(port, "127.0.0.1", () => {
console.log(`clickclack fixture listening on ${port}`);
});

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
export NO_COLOR=1
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_trash_shim
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export OPENAI_API_KEY="sk-openclaw-release-user-journey"
export OPENCLAW_GATEWAY_TOKEN="release-user-journey-token"
export CLICKCLACK_BOT_TOKEN="clickclack-release-token"
PORT="18789"
MOCK_PORT="44180"
CLICKCLACK_PORT="44181"
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_USER_JOURNEY"
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-user-journey.XXXXXX")"
LOG_DIR="$scenario_tmp/logs"
mkdir -p "$LOG_DIR"
INSTALL_LOG="$LOG_DIR/install.log"
ONBOARD_LOG="$LOG_DIR/onboard.log"
OPENAI_LOG="$LOG_DIR/openai.log"
AGENT_LOG="$LOG_DIR/agent.log"
PLUGIN_A_INSTALL_LOG="$LOG_DIR/plugin-a-install.log"
PLUGIN_A_CLI_LOG="$LOG_DIR/plugin-a-cli.log"
PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log"
PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log"
PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log"
PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json"
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
GATEWAY_1_LOG="$LOG_DIR/gateway-1.log"
GATEWAY_2_LOG="$LOG_DIR/gateway-2.log"
STATUS_JSON="$LOG_DIR/status.json"
STATUS_ERR="$LOG_DIR/status.err"
STATUS_AFTER_RESTART_JSON="$LOG_DIR/status-after-restart.json"
STATUS_AFTER_RESTART_ERR="$LOG_DIR/status-after-restart.err"
DOCTOR_LOG="$LOG_DIR/doctor.log"
PLUGIN_A_INSTALL_PATH_FILE="$scenario_tmp/plugin-a-install-path.txt"
PLUGIN_A_SOURCE_PATH_FILE="$scenario_tmp/plugin-a-source-path.txt"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
mock_pid=""
clickclack_pid=""
gateway_pid=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${clickclack_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
rm -rf "$scenario_tmp"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "release user journey failed with exit code $status" >&2
openclaw_e2e_dump_logs \
"$INSTALL_LOG" \
"$ONBOARD_LOG" \
"$OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
"$AGENT_LOG" \
"$PLUGIN_A_INSTALL_LOG" \
"$PLUGIN_A_CLI_LOG" \
"$PLUGIN_A_UNINSTALL_LOG" \
"$PLUGIN_B_INSTALL_LOG" \
"$PLUGIN_B_CLI_LOG" \
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
"$CLICKCLACK_SERVER_LOG" \
"$CLICKCLACK_OUTBOUND_JSON" \
"$GATEWAY_1_LOG" \
"$GATEWAY_2_LOG" \
"$STATUS_JSON" \
"$STATUS_AFTER_RESTART_JSON" \
"$DOCTOR_LOG" \
"$CLICKCLACK_STATE"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
start_gateway() {
local log_path="$1"
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
}
stop_gateway() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
gateway_pid=""
}
write_journey_plugin() {
local dir="$1"
local id="$2"
local version="$3"
local method="$4"
local name="$5"
local cli_root="$6"
local cli_output="$7"
mkdir -p "$dir"
node - "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const [dir, id, version, method, name, cliRoot, cliOutput] = process.argv.slice(2);
fs.writeFileSync(
path.join(dir, "package.json"),
`${JSON.stringify(
{
name: `@openclaw/${id}`,
version,
openclaw: { extensions: ["./index.js"] },
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(dir, "index.js"),
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
);
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
`${JSON.stringify({ id, configSchema: { type: "object", properties: {} } }, null, 2)}\n`,
);
NODE
}
openclaw_e2e_install_package "$INSTALL_LOG"
command -v openclaw >/dev/null
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
openclaw_e2e_enable_openclaw_cli_timeout
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
clickclack_pid="$!"
for _ in $(seq 1 100); do
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
break
fi
sleep 0.1
done
openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200
echo "Running non-interactive onboarding..."
openclaw onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--auth-choice skip \
--gateway-port "$PORT" \
--gateway-bind loopback \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >"$ONBOARD_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-onboard "$HOME"
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-mock-model "$MOCK_PORT"
echo "Running package-installed agent turn..."
openclaw agent --local \
--agent main \
--session-id release-user-journey-agent \
--message "Return marker $SUCCESS_MARKER" \
--thinking off \
--json >"$AGENT_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
echo "Installing first external plugin..."
plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")"
plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE"
plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE"
write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong"
openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs \
remember-plugin-install-path \
journey-plugin-a \
"$plugin_a_install_path_file" \
"$plugin_a_source_path_file" \
"$plugin_a_dir"
openclaw journey-a ping >"$PLUGIN_A_CLI_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_A_CLI_LOG" "journey-plugin-a:pong"
echo "Uninstalling first external plugin..."
openclaw plugins uninstall journey-plugin-a --force >"$PLUGIN_A_UNINSTALL_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs \
assert-plugin-uninstalled \
journey-plugin-a \
"$plugin_a_install_path_file" \
"$plugin_a_source_path_file"
echo "Installing replacement external plugin..."
plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")"
write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong"
openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1
openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong"
echo "Installing ClickClack fixture plugin..."
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
echo "Configuring ClickClack..."
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
echo "Sending ClickClack outbound message..."
openclaw message send \
--channel clickclack \
--target channel:general \
--message "release journey outbound" \
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release journey outbound"
echo "Starting Gateway for ClickClack inbound..."
start_gateway "$GATEWAY_1_LOG"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
echo "Restarting Gateway and checking state survival..."
stop_gateway
start_gateway "$GATEWAY_2_LOG"
openclaw plugins inspect journey-plugin-b --runtime --json >"$PLUGIN_B_AFTER_RESTART_JSON" 2>&1
openclaw channels status --json >"$STATUS_AFTER_RESTART_JSON" 2>"$STATUS_AFTER_RESTART_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_AFTER_RESTART_JSON"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_AFTER_RESTART_JSON" "journey-plugin-b"
stop_gateway
echo "Running doctor at end of release journey..."
openclaw doctor --repair --non-interactive >"$DOCTOR_LOG" 2>&1
echo "Release user journey scenario passed."

View File

@@ -0,0 +1,427 @@
#!/usr/bin/env node
// Writes the external ClickClack channel fixture used by release journey E2Es.
import fs from "node:fs";
import path from "node:path";
const pluginDir = process.argv[2];
if (!pluginDir) {
console.error("usage: write-clickclack-plugin.mjs <plugin-dir>");
process.exit(2);
}
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
`${JSON.stringify(
{
name: "clickclack",
version: "0.0.1",
type: "module",
openclaw: { extensions: ["./index.mjs"] },
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: "clickclack",
activation: { onStartup: false },
channels: ["clickclack"],
channelEnvVars: { clickclack: ["CLICKCLACK_BOT_TOKEN"] },
channelConfigs: {
clickclack: {
schema: {
type: "object",
additionalProperties: true,
properties: {
enabled: { type: "boolean", default: true },
baseUrl: { type: "string" },
workspace: { type: "string" },
defaultTo: { type: "string" },
token: {},
},
},
},
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(pluginDir, "index.mjs"),
`import crypto from "node:crypto";
import net from "node:net";
const CHANNEL_ID = "clickclack";
const DEFAULT_ACCOUNT_ID = "default";
function configFor(cfg) {
return cfg?.channels?.clickclack ?? {};
}
function readToken(raw) {
if (typeof raw === "string") {
return raw.trim();
}
if (raw && typeof raw === "object" && raw.source === "env" && typeof raw.id === "string") {
return String(process.env[raw.id] ?? "").trim();
}
return String(process.env.CLICKCLACK_BOT_TOKEN ?? "").trim();
}
function resolveAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) {
const config = configFor(cfg);
const token = readToken(config.token);
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
return {
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
enabled: config.enabled !== false,
configured: Boolean(baseUrl && token),
baseUrl,
token,
workspace: typeof config.workspace === "string" && config.workspace ? config.workspace : "release",
defaultTo: typeof config.defaultTo === "string" ? config.defaultTo : "channel:general",
reconnectMs: Number.isFinite(config.reconnectMs) ? Math.max(50, Number(config.reconnectMs)) : 250,
};
}
async function requestJson(account, method, pathname, body) {
const response = await fetch(new URL(pathname, account.baseUrl), {
method,
headers: {
authorization: \`Bearer \${account.token}\`,
...(body == null ? {} : { "content-type": "application/json" }),
},
...(body == null ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) {
throw new Error(\`ClickClack fixture \${response.status}: \${await response.text()}\`);
}
return await response.json();
}
async function resolveWorkspaceId(account) {
const data = await requestJson(account, "GET", "/api/workspaces");
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
const match = workspaces.find((workspace) =>
workspace?.id === account.workspace ||
workspace?.slug === account.workspace ||
workspace?.name === account.workspace
);
if (!match?.id) {
throw new Error(\`ClickClack workspace not found: \${account.workspace}\`);
}
return match.id;
}
async function resolveChannelId(account, workspaceId, rawTarget) {
const target = String(rawTarget ?? "").trim();
const channelName = target.startsWith("channel:") ? target.slice("channel:".length) : target;
const data = await requestJson(account, "GET", \`/api/workspaces/\${encodeURIComponent(workspaceId)}/channels\`);
const channels = Array.isArray(data.channels) ? data.channels : [];
const match = channels.find((channel) => channel?.id === channelName || channel?.name === channelName);
if (!match?.id) {
throw new Error(\`ClickClack channel not found: \${channelName}\`);
}
return match.id;
}
async function sendText(cfg, to, text, accountId, threadId, replyToId) {
const account = resolveAccount(cfg, accountId);
if (!account.configured) {
throw new Error("ClickClack is not configured");
}
const workspaceId = await resolveWorkspaceId(account);
const rootId = threadId == null ? String(replyToId ?? "") : String(threadId);
if (rootId) {
const data = await requestJson(
account,
"POST",
\`/api/messages/\${encodeURIComponent(rootId)}/thread/replies\`,
{ body: text },
);
return data.message;
}
const channelId = await resolveChannelId(account, workspaceId, to);
const data = await requestJson(account, "POST", \`/api/channels/\${encodeURIComponent(channelId)}/messages\`, {
body: text,
});
return data.message;
}
function decodeFrame(buffer) {
if (buffer.length < 2) {
return null;
}
const opcode = buffer[0] & 0x0f;
let length = buffer[1] & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < 4) {
return null;
}
length = buffer.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffer.length < 10) {
return null;
}
length = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
if (buffer.length < offset + length) {
return null;
}
return {
opcode,
text: buffer.subarray(offset, offset + length).toString("utf8"),
rest: buffer.subarray(offset + length),
};
}
function openEventSocket(account, workspaceId, afterCursor, onEvent, signal) {
const base = new URL(account.baseUrl);
const key = crypto.randomBytes(16).toString("base64");
const socket = net.createConnection({
host: base.hostname,
port: Number(base.port || (base.protocol === "https:" ? 443 : 80)),
});
let buffer = Buffer.alloc(0);
let upgraded = false;
const close = () => socket.destroy();
signal.addEventListener("abort", close, { once: true });
socket.on("connect", () => {
const query = new URLSearchParams({ workspace_id: workspaceId });
if (afterCursor) {
query.set("after_cursor", afterCursor);
}
socket.write(
[
\`GET /api/realtime/ws?\${query.toString()} HTTP/1.1\`,
\`Host: \${base.host}\`,
"Upgrade: websocket",
"Connection: Upgrade",
\`Sec-WebSocket-Key: \${key}\`,
"Sec-WebSocket-Version: 13",
\`Authorization: Bearer \${account.token}\`,
"",
"",
].join("\\r\\n"),
);
});
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
if (!upgraded) {
const headerEnd = buffer.indexOf("\\r\\n\\r\\n");
if (headerEnd === -1) {
return;
}
const headers = buffer.subarray(0, headerEnd).toString("utf8");
if (!headers.startsWith("HTTP/1.1 101")) {
socket.destroy(new Error(headers.split("\\r\\n")[0] || "websocket upgrade failed"));
return;
}
upgraded = true;
buffer = buffer.subarray(headerEnd + 4);
}
for (;;) {
const frame = decodeFrame(buffer);
if (!frame) {
return;
}
buffer = frame.rest;
if (frame.opcode === 1) {
onEvent(JSON.parse(frame.text));
} else if (frame.opcode === 8) {
socket.end();
return;
}
}
});
socket.on("close", () => signal.removeEventListener("abort", close));
return socket;
}
async function resolveEventMessage(account, event) {
if (event?.type !== "message.created" || !event.channel_id || typeof event.seq !== "number") {
return null;
}
const data = await requestJson(
account,
"GET",
\`/api/channels/\${encodeURIComponent(event.channel_id)}/messages?after_seq=\${Math.max(0, event.seq - 1)}\`,
);
const messages = Array.isArray(data.messages) ? data.messages : [];
return messages.find((message) => message?.id === event.payload?.message_id) ?? null;
}
async function dispatchInbound(ctx, account, message) {
const runtime = ctx.channelRuntime;
if (!runtime) {
throw new Error("ClickClack fixture requires channel runtime");
}
const target = \`channel:\${message.channel_id}\`;
const route = runtime.routing.resolveAgentRoute({
cfg: ctx.cfg,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: { kind: "channel", id: target },
});
const storePath = runtime.session.resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
const previousTimestamp = runtime.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const senderName = message.author?.display_name || message.author_id || "Release User";
const body = runtime.reply.formatAgentEnvelope({
channel: "ClickClack",
from: senderName,
timestamp: new Date(message.created_at),
previousTimestamp,
envelope: runtime.reply.resolveEnvelopeFormatOptions(ctx.cfg),
body: message.body,
});
const ctxPayload = runtime.reply.finalizeInboundContext({
Body: body,
BodyForAgent: message.body,
RawBody: message.body,
CommandBody: message.body,
From: target,
To: target,
SessionKey: route.sessionKey,
AccountId: route.accountId ?? account.accountId,
ChatType: "group",
WasMentioned: true,
ConversationLabel: message.channel_id,
GroupChannel: message.channel_id,
NativeChannelId: message.channel_id,
MessageSid: message.id,
MessageSidFull: message.id,
ReplyToId: message.id,
Timestamp: message.created_at,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: target,
CommandAuthorized: true,
});
await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
dispatcherOptions: {
deliver: async (payload) => {
const text = payload && typeof payload === "object" ? String(payload.text ?? "") : "";
if (text.trim()) {
await sendText(ctx.cfg, target, text, account.accountId, message.id, message.id);
}
},
onError: (error) => {
throw error instanceof Error ? error : new Error(String(error));
},
},
});
}
const clickclackPlugin = {
id: CHANNEL_ID,
meta: {
id: CHANNEL_ID,
label: "ClickClack",
selectionLabel: "ClickClack",
docsPath: "/channels/clickclack",
blurb: "Release journey ClickClack fixture.",
},
capabilities: { chatTypes: ["group"], threads: true },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
resolveAccount,
isConfigured: (account) => account.configured,
isEnabled: (account) => account.enabled,
resolveDefaultTo: ({ cfg }) => resolveAccount(cfg).defaultTo,
},
status: {
buildChannelSummary: ({ snapshot }) => ({
ok: snapshot.configured === true,
label: snapshot.configured ? "configured" : "missing config",
detail: snapshot.baseUrl ?? "",
}),
buildAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
},
outbound: {
deliveryMode: "direct",
sendText: async (ctx) => {
const message = await sendText(ctx.cfg, ctx.to, ctx.text, ctx.accountId, ctx.threadId, ctx.replyToId);
return { channel: CHANNEL_ID, messageId: message.id };
},
},
gateway: {
startAccount: async (ctx) => {
const account = resolveAccount(ctx.cfg, ctx.account.accountId);
if (!account.configured) {
throw new Error("ClickClack is not configured");
}
const workspaceId = await resolveWorkspaceId(account);
ctx.setStatus({
accountId: account.accountId,
running: true,
configured: true,
enabled: account.enabled,
baseUrl: account.baseUrl,
});
try {
while (!ctx.abortSignal.aborted) {
const socket = openEventSocket(
account,
workspaceId,
"",
(event) => {
void (async () => {
const message = await resolveEventMessage(account, event);
if (message && message.author?.kind !== "bot") {
await dispatchInbound(ctx, account, message);
}
})().catch((error) => {
ctx.log?.error?.(error instanceof Error ? error.message : String(error));
});
},
ctx.abortSignal,
);
await new Promise((resolve) => {
socket.once("close", resolve);
socket.once("error", resolve);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
}
}
} finally {
ctx.setStatus({ accountId: account.accountId, running: false });
}
},
},
};
export default {
id: CHANNEL_ID,
register(api) {
api.registerChannel({ plugin: clickclackPlugin });
},
};
`,
);

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import process from "node:process";
import { spawn as spawnPty } from "@lydell/node-pty";
import { readPositiveIntEnv } from "./env-limits.mjs";
const [logPath, command, ...args] = process.argv.slice(2);
const OUTPUT_MAX_BYTES = readPositiveIntEnv("OPENCLAW_E2E_PTY_OUTPUT_MAX_BYTES", 16 * 1024 * 1024);
const FORCE_KILL_MS = readPositiveIntEnv("OPENCLAW_E2E_PTY_FORCE_KILL_MS", 5_000);
if (!logPath || !command) {
console.error("usage: run-with-pty.mjs <log-path> <command> [args...]");
process.exit(2);
}
let exiting = false;
let forwardedSignal = null;
let forceKillTimer = null;
let terminationDrainTimer = null;
let terminationPids = [];
let pendingExitCode = null;
let logFailed = false;
const outputLimitMarker = `\n[run-with-pty output truncated after ${OUTPUT_MAX_BYTES} bytes]\n`;
const outputState = {
bytes: 0,
truncated: false,
};
const log = fs.createWriteStream(logPath, { flags: "w" });
const pty = spawnPty(command, args, {
name: process.env.TERM || "xterm-256color",
cols: readPositiveIntEnv("COLUMNS", 120),
rows: readPositiveIntEnv("LINES", 40),
cwd: process.cwd(),
env: process.env,
});
log.on("error", (error) => {
if (logFailed) {
return;
}
logFailed = true;
console.error(`run-with-pty transcript log failed: ${error.message}`);
if (exiting) {
process.exit(1);
}
if (!exiting) {
terminatePtyTree("SIGTERM");
}
});
function writeCappedOutput(data) {
if (outputState.truncated) {
return;
}
const buffer = Buffer.from(data);
const remainingBytes = OUTPUT_MAX_BYTES - outputState.bytes;
if (buffer.byteLength <= remainingBytes) {
outputState.bytes += buffer.byteLength;
if (!logFailed) {
log.write(buffer);
}
process.stdout.write(buffer);
return;
}
if (remainingBytes > 0) {
const head = buffer.subarray(0, remainingBytes);
if (!logFailed) {
log.write(head);
}
process.stdout.write(head);
}
outputState.bytes = OUTPUT_MAX_BYTES;
outputState.truncated = true;
if (!logFailed) {
log.write(outputLimitMarker);
}
process.stdout.write(outputLimitMarker);
}
pty.onData((data) => {
writeCappedOutput(data);
});
pty.onExit(({ exitCode, signal }) => {
exiting = true;
if (terminationPids.length === 0) {
clearTerminationTimers();
}
if (logFailed) {
exitWhenTerminationDrains(1);
return;
}
log.end(() => {
if (forwardedSignal) {
exitWhenTerminationDrains(signalExitCode(forwardedSignal));
return;
}
if (typeof exitCode === "number") {
exitWhenTerminationDrains(exitCode);
return;
}
exitWhenTerminationDrains(signal ? 128 + signal : 1);
});
});
process.stdin.on("data", (chunk) => {
pty.write(chunk.toString("utf8"));
});
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.on(signal, () => {
if (!exiting) {
forwardedSignal ??= signal;
terminatePtyTree(signal);
}
});
}
function terminatePtyTree(signal) {
// node-pty kill() targets only pty.pid on Unix; wrapper-owned shutdowns
// keep the captured child tree alive until ignored descendants drain.
if (terminationPids.length === 0) {
terminationPids = collectPtyProcessTreePids();
}
signalPtyProcessTree(signal);
forceKillTimer ??= setTimeout(() => {
signalPtyProcessTree("SIGKILL");
}, FORCE_KILL_MS);
forceKillTimer.unref?.();
}
function exitWhenTerminationDrains(exitCode) {
pendingExitCode = exitCode;
if (processTreeIsAlive(terminationPids)) {
terminationDrainTimer ??= setInterval(finishIfTerminationDrained, 25);
return;
}
finishIfTerminationDrained();
}
function finishIfTerminationDrained() {
if (processTreeIsAlive(terminationPids)) {
return;
}
clearTerminationTimers();
process.exit(pendingExitCode ?? 1);
}
function clearTerminationTimers() {
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
if (terminationDrainTimer) {
clearInterval(terminationDrainTimer);
terminationDrainTimer = null;
}
}
function collectPtyProcessTreePids() {
if (process.platform === "win32" || typeof pty.pid !== "number") {
return typeof pty.pid === "number" ? [pty.pid] : [];
}
const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
if (ps.status !== 0) {
return [pty.pid];
}
const childrenByParent = new Map();
for (const line of ps.stdout.split("\n")) {
const match = line.trim().match(/^(\d+)\s+(\d+)$/u);
if (!match) {
continue;
}
const pid = Number(match[1]);
const ppid = Number(match[2]);
const siblings = childrenByParent.get(ppid) ?? [];
siblings.push(pid);
childrenByParent.set(ppid, siblings);
}
const pids = [pty.pid];
for (const parentPid of pids) {
for (const pid of childrenByParent.get(parentPid) ?? []) {
pids.push(pid);
}
}
return [...new Set(pids)];
}
function processTreeIsAlive(pids) {
return pids.some((pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
});
}
function signalPtyProcessTree(signal) {
if (process.platform === "win32" || terminationPids.length === 0) {
pty.kill(signal);
return;
}
for (const pid of terminationPids.toReversed()) {
try {
process.kill(pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") {
throw error;
}
}
}
}
function signalExitCode(signal) {
switch (signal) {
case "SIGHUP":
return 129;
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}

View File

@@ -0,0 +1,152 @@
// Session Log Mentions script supports OpenClaw repository automation.
import fs from "node:fs/promises";
import path from "node:path";
import { readPositiveIntEnv } from "./env-limits.mjs";
export type SessionLogMentionLimits = {
fileMaxBytes: number;
totalMaxBytes: number;
};
export type SessionLogNeedles = Record<string, string>;
const DEFAULT_FILE_MAX_BYTES = 4 * 1024 * 1024;
const DEFAULT_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
export function readSessionLogMentionLimits(
env: NodeJS.ProcessEnv = process.env,
): SessionLogMentionLimits {
return {
fileMaxBytes: readPositiveIntEnv(
"OPENCLAW_SESSION_LOG_MENTION_FILE_MAX_BYTES",
DEFAULT_FILE_MAX_BYTES,
env,
),
totalMaxBytes: readPositiveIntEnv(
"OPENCLAW_SESSION_LOG_MENTION_TOTAL_MAX_BYTES",
DEFAULT_TOTAL_MAX_BYTES,
env,
),
};
}
function taggedError(message: string, code: string) {
return Object.assign(new Error(message), { code });
}
function countOccurrences(haystack: string, needle: string): number {
if (!needle) {
return 0;
}
let count = 0;
let offset = 0;
for (;;) {
const next = haystack.indexOf(needle, offset);
if (next < 0) {
return count;
}
count += 1;
offset = next + needle.length;
}
}
function createCounts(needles: SessionLogNeedles): Record<string, number> {
return Object.fromEntries(Object.keys(needles).map((key) => [key, 0]));
}
function recordRole(record: unknown): string | undefined {
if (!record || typeof record !== "object") {
return undefined;
}
const candidate = record as { message?: unknown; role?: unknown };
if (typeof candidate.role === "string") {
return candidate.role;
}
if (!candidate.message || typeof candidate.message !== "object") {
return undefined;
}
const message = candidate.message as { role?: unknown };
return typeof message.role === "string" ? message.role : undefined;
}
function shouldScanSessionLogLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) {
return false;
}
try {
return recordRole(JSON.parse(trimmed)) !== "user";
} catch {
return true;
}
}
function assertWithinLimit(params: {
byteCount: number;
filePath?: string;
label: string;
limit: number;
}) {
if (params.byteCount <= params.limit) {
return;
}
const source = params.filePath ? ` ${params.filePath}` : "";
throw taggedError(
`session log mention scan exceeded ${params.label} limit${source}: ${params.byteCount} > ${params.limit}`,
"ETOOBIG",
);
}
export async function countSessionLogMentions(params: {
limits?: SessionLogMentionLimits;
needles: SessionLogNeedles;
sessionsDir: string;
}): Promise<Record<string, number>> {
const limits = params.limits ?? readSessionLogMentionLimits();
const counts = createCounts(params.needles);
let files: string[];
try {
files = await fs.readdir(params.sessionsDir);
} catch {
return counts;
}
let totalBytes = 0;
for (const file of files.filter((candidate) => candidate.endsWith(".jsonl")).toSorted()) {
const filePath = path.join(params.sessionsDir, file);
const stat = await fs.stat(filePath).catch(() => null);
if (!stat?.isFile()) {
continue;
}
assertWithinLimit({
byteCount: stat.size,
filePath,
label: "per-file",
limit: limits.fileMaxBytes,
});
totalBytes += stat.size;
assertWithinLimit({
byteCount: totalBytes,
label: "total",
limit: limits.totalMaxBytes,
});
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
const actualBytes = Buffer.byteLength(raw, "utf8");
assertWithinLimit({
byteCount: actualBytes,
filePath,
label: "per-file",
limit: limits.fileMaxBytes,
});
for (const line of raw.split(/\r?\n/u)) {
if (!shouldScanSessionLogLine(line)) {
continue;
}
for (const [key, needle] of Object.entries(params.needles)) {
counts[key] += countOccurrences(line, needle);
}
}
}
return counts;
}

View File

@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# Live ClawHub skill install proof for package-backed Docker/Testbox lanes.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
cd "$ROOT_DIR"
source "$ROOT_DIR/scripts/lib/openclaw-e2e-instance.sh"
OPENCLAW_TEST_STATE_SCRIPT_B64="${OPENCLAW_TEST_STATE_SCRIPT_B64:-}"
openclaw_skill_install_owns_home=0
cleanup_clawhub_skill_install_home() {
if [ "$openclaw_skill_install_owns_home" = "1" ] && [ -n "${HOME:-}" ]; then
rm -rf "$HOME"
fi
}
trap cleanup_clawhub_skill_install_home EXIT
if [ -n "$OPENCLAW_TEST_STATE_SCRIPT_B64" ]; then
openclaw_e2e_eval_test_state_from_b64 "$OPENCLAW_TEST_STATE_SCRIPT_B64"
else
export HOME="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-skill-install-home.XXXXXX")"
openclaw_skill_install_owns_home=1
export USERPROFILE="$HOME"
export OPENCLAW_HOME="$HOME"
export OPENCLAW_STATE_DIR="$HOME/.openclaw"
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
mkdir -p "$OPENCLAW_STATE_DIR"
fi
if [ -n "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then
export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-$HOME/.npm-global}"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
openclaw_e2e_install_package /tmp/openclaw-skill-install-npm.log
fi
if [ -n "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ] && command -v openclaw >/dev/null 2>&1; then
OPENCLAW_CMD=(openclaw)
elif command -v pnpm >/dev/null 2>&1 && [ -f package.json ]; then
if [ "${OPENCLAW_SKILL_INSTALL_E2E_BUILD_SOURCE:-0}" = "1" ]; then
pnpm build >/tmp/openclaw-skill-install-build.log 2>&1
fi
OPENCLAW_CMD=(pnpm --silent openclaw)
elif command -v openclaw >/dev/null 2>&1; then
OPENCLAW_CMD=(openclaw)
else
echo "openclaw command not found; install package first or run from repo with pnpm" >&2
exit 1
fi
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
node --input-type=module - "$OPENCLAW_CONFIG_PATH" <<'NODE'
import fs from "node:fs";
const configPath = process.argv[2];
let config = {};
try {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {}
config.skills ??= {};
config.skills.install ??= {};
config.skills.install.allowUploadedArchives = false;
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
NODE
query="${OPENCLAW_SKILL_INSTALL_E2E_QUERY:-homeassistant}"
requested_slug="${OPENCLAW_SKILL_INSTALL_E2E_SLUG:-}"
preferred_slug="${OPENCLAW_SKILL_INSTALL_E2E_PREFERRED_SLUG:-homeassistant-skill}"
search_json="/tmp/openclaw-skill-install-search.json"
resolve_json="/tmp/openclaw-skill-install-resolved.json"
install_log="/tmp/openclaw-skill-install.log"
info_json="/tmp/openclaw-skill-install-info.json"
echo "Searching live ClawHub skills for: $query"
"${OPENCLAW_CMD[@]}" skills search "$query" --limit 8 --json >"$search_json"
node --input-type=module - "$search_json" "$resolve_json" "$requested_slug" "$preferred_slug" <<'NODE'
import fs from "node:fs";
const [searchPath, resolvePath, requestedSlug, preferredSlug] = process.argv.slice(2);
const payload = JSON.parse(fs.readFileSync(searchPath, "utf8"));
const results = Array.isArray(payload) ? payload : Array.isArray(payload.results) ? payload.results : [];
const slugs = results.map((entry) => String(entry.slug ?? "")).filter(Boolean);
let chosen;
if (requestedSlug) {
chosen = results.find((entry) => entry.slug === requestedSlug);
if (!chosen) {
throw new Error(`Requested skill slug ${requestedSlug} not found. Search returned: ${slugs.join(", ") || "(none)"}`);
}
} else {
chosen =
results.find((entry) => entry.slug === preferredSlug) ??
results.find((entry) => String(entry.slug ?? "").includes("homeassistant")) ??
results[0];
}
if (!chosen?.slug) {
throw new Error(`No installable skill slug found. Search returned: ${slugs.join(", ") || "(none)"}`);
}
fs.writeFileSync(resolvePath, `${JSON.stringify({
slug: chosen.slug,
version: chosen.version ?? null,
displayName: chosen.displayName ?? chosen.name ?? chosen.slug,
})}\n`);
NODE
slug="$(node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).slug)' "$resolve_json")"
echo "Installing live ClawHub skill: $slug"
if ! "${OPENCLAW_CMD[@]}" skills install "$slug" --force >"$install_log" 2>&1; then
echo "Skill install failed" >&2
openclaw_e2e_dump_logs /tmp/openclaw-skill-install-npm.log "$search_json" "$resolve_json" "$install_log"
exit 1
fi
workspace_dir="$HOME/.openclaw/workspace"
skill_dir="$workspace_dir/skills/$slug"
origin_json="$skill_dir/.clawhub/origin.json"
lock_json="$workspace_dir/.clawhub/lock.json"
openclaw_e2e_assert_file "$skill_dir/SKILL.md"
openclaw_e2e_assert_file "$origin_json"
openclaw_e2e_assert_file "$lock_json"
"${OPENCLAW_CMD[@]}" skills info "$slug" --json >"$info_json"
node --input-type=module - "$OPENCLAW_CONFIG_PATH" "$skill_dir" "$origin_json" "$lock_json" "$info_json" "$slug" <<'NODE'
import fs from "node:fs";
import path from "node:path";
const [configPath, skillDir, originPath, lockPath, infoPath, slug] = process.argv.slice(2);
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
function isPathInside(parentPath, childPath) {
const relative = path.relative(path.resolve(parentPath), path.resolve(childPath));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
const config = read(configPath);
if (config.skills?.install?.allowUploadedArchives !== false) {
throw new Error("skills.install.allowUploadedArchives must remain false during ClawHub install proof");
}
const origin = read(originPath);
if (origin.slug !== slug || origin.registry !== "https://clawhub.ai" || !origin.installedVersion) {
throw new Error(`Unexpected origin metadata: ${JSON.stringify(origin)}`);
}
const lock = read(lockPath);
if (lock.skills?.[slug]?.version !== origin.installedVersion) {
throw new Error(`Lockfile missing ${slug}@${origin.installedVersion}`);
}
const info = read(infoPath);
const infoFilePath = info.filePath ?? info.skill?.filePath;
const infoBaseDir = info.baseDir ?? info.skill?.baseDir;
if (
info.skillKey !== slug &&
(!infoFilePath || !isPathInside(skillDir, infoFilePath))
) {
throw new Error(`skills info did not report installed skill ${slug}: ${JSON.stringify(info)}`);
}
if (infoBaseDir && path.resolve(infoBaseDir) !== path.resolve(skillDir)) {
throw new Error(`skills info reported unexpected baseDir: ${infoBaseDir}`);
}
const skillText = fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf8");
if (!/^name:\s*/m.test(skillText)) {
throw new Error("Installed SKILL.md is missing frontmatter name");
}
process.stdout.write(`E2E_OK installed=${slug} version=${origin.installedVersion} uploadArchives=false\n`);
NODE

View File

@@ -0,0 +1,60 @@
// Temp State Dir script supports OpenClaw repository automation.
import { rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
type CleanupSignal = (typeof cleanupSignals)[number];
export type E2eStateDir = {
stateDir: string;
created: boolean;
cleanup: () => void;
registerExitCleanup: () => void;
};
export async function createE2eStateDir(prefix: string, env = process.env): Promise<E2eStateDir> {
const configuredStateDir = env.OPENCLAW_STATE_DIR?.trim();
const created = !configuredStateDir;
const stateDir = configuredStateDir || (await fs.mkdtemp(path.join(os.tmpdir(), prefix)));
const signalHandlers = new Map<CleanupSignal, () => void>();
let cleaned = false;
let cleanupRegistered = false;
const cleanup = () => {
if (created && !cleaned) {
rmSync(stateDir, { force: true, recursive: true });
cleaned = true;
}
};
const unregisterSignalCleanup = () => {
for (const [signal, handler] of signalHandlers) {
process.removeListener(signal, handler);
}
signalHandlers.clear();
};
const registerExitCleanup = () => {
if (!created || cleanupRegistered) {
return;
}
cleanupRegistered = true;
process.once("exit", cleanup);
for (const signal of cleanupSignals) {
const handleSignal = () => {
cleanup();
unregisterSignalCleanup();
if (process.listenerCount(signal) === 0) {
process.kill(process.pid, signal);
}
};
signalHandlers.set(signal, handleSignal);
process.once(signal, handleSignal);
}
};
return { stateDir, created, cleanup, registerExitCleanup };
}

View File

@@ -0,0 +1,72 @@
// Text file tail helpers for E2E assertions.
import fs from "node:fs";
export function tailText(text, maxBytes) {
if (Buffer.byteLength(text, "utf8") <= maxBytes) {
return text;
}
return Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8");
}
export function readTextFileTail(file, maxBytes) {
let stat;
try {
stat = fs.statSync(file);
} catch {
return "";
}
if (!stat.isFile() || stat.size <= 0) {
return "";
}
const length = Math.min(maxBytes, stat.size);
const start = stat.size - length;
let fd;
try {
fd = fs.openSync(file, "r");
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} catch {
return "";
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
// Tail diagnostics are best-effort; callers may be preserving a richer error.
}
}
}
}
function textFileTooLargeError(message) {
return Object.assign(new Error(message), { code: "ETOOBIG" });
}
export function readTextFileBounded(file, label, maxBytes, options = {}) {
const tailBytes = options.tailBytes ?? 16 * 1024;
const stat = fs.statSync(file);
if (!stat.isFile()) {
throw new Error(`${label} is not a file: ${file}`);
}
if (stat.size > maxBytes) {
throw textFileTooLargeError(
`${label} exceeded ${maxBytes} bytes: ${file} (${stat.size} bytes). Tail: ${readTextFileTail(
file,
tailBytes,
)}`,
);
}
const text = fs.readFileSync(file, "utf8");
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > maxBytes) {
throw textFileTooLargeError(
`${label} exceeded ${maxBytes} bytes: ${file} (${bytes} bytes). Tail: ${readTextFileTail(
file,
tailBytes,
)}`,
);
}
return text;
}

View File

@@ -0,0 +1,217 @@
// Assertions for update-channel switch E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { legacyPackageAcceptanceCompat } from "../package-compat.mjs";
const [command, ...args] = process.argv.slice(2);
const controlUiHtml = "<!doctype html><title>fixture</title>\n";
function usage() {
console.error(
"usage: assertions.mjs <prepare-git-fixture|write-control-ui|assert-update|assert-config-channel|assert-status-kind> [...]",
);
process.exit(2);
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
// Runs inside the bare Docker E2E image, before package dependencies are installed.
// Keep this to the small pnpm-workspace.yaml surface the fixture mutates.
function findTopLevelBlock(lines, key) {
const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line));
if (start === -1) {
return null;
}
let end = start + 1;
while (end < lines.length && !/^[A-Za-z0-9_-]+:\s*/.test(lines[end])) {
end += 1;
}
return { start, end };
}
function parseYamlScalar(raw) {
const trimmed = raw.trim();
const withoutComment = trimmed.replace(/\s+#.*$/, "");
if (withoutComment.startsWith('"') && withoutComment.endsWith('"')) {
return withoutComment.slice(1, -1);
}
if (withoutComment.startsWith("'") && withoutComment.endsWith("'")) {
return withoutComment.slice(1, -1);
}
return withoutComment;
}
function readWorkspacePatchedDependencies(file) {
const lines = fs.readFileSync(file, "utf8").split("\n");
const block = findTopLevelBlock(lines, "patchedDependencies");
if (!block) {
return { patches: undefined };
}
const patches = {};
for (const line of lines.slice(block.start + 1, block.end)) {
const match = line.match(/^\s+(.+?):\s+(.+?)\s*$/);
if (!match) {
continue;
}
patches[parseYamlScalar(match[1])] = parseYamlScalar(match[2]);
}
return { patches };
}
function writeWorkspacePnpmConfig(file, keptPatches) {
const original = fs.readFileSync(file, "utf8");
const hadTrailingNewline = original.endsWith("\n");
const lines = original.replace(/\n$/, "").split("\n");
const patchBlock = findTopLevelBlock(lines, "patchedDependencies");
if (patchBlock) {
const nextLines = [];
nextLines.push(...lines.slice(0, patchBlock.start));
if (Object.keys(keptPatches).length > 0) {
nextLines.push("patchedDependencies:");
for (const [dependency, patchFile] of Object.entries(keptPatches)) {
nextLines.push(` ${JSON.stringify(dependency)}: ${JSON.stringify(patchFile)}`);
}
}
nextLines.push(...lines.slice(patchBlock.end));
lines.length = 0;
lines.push(...nextLines);
}
const allowUnusedIndex = lines.findIndex((line) => /^allowUnusedPatches:\s*/.test(line));
if (allowUnusedIndex === -1) {
lines.push("allowUnusedPatches: true");
} else {
lines[allowUnusedIndex] = "allowUnusedPatches: true";
}
const minimumReleaseAgeIndex = lines.findIndex((line) => /^minimumReleaseAge:\s*/.test(line));
if (minimumReleaseAgeIndex === -1) {
lines.push("minimumReleaseAge: 0");
} else {
lines[minimumReleaseAgeIndex] = "minimumReleaseAge: 0";
}
fs.writeFileSync(file, `${lines.join("\n")}${hadTrailingNewline ? "\n" : ""}`);
}
function writeControlUi(root) {
const file = path.join(root, "dist", "control-ui", "index.html");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, controlUiHtml);
}
function prepareGitFixture(root) {
const packageJsonPath = path.join(root, "package.json");
const packageJson = readJson(packageJsonPath);
const pnpmWorkspacePath = path.join(root, "pnpm-workspace.yaml");
const workspaceConfig = fs.existsSync(pnpmWorkspacePath)
? readWorkspacePatchedDependencies(pnpmWorkspacePath)
: undefined;
const pnpmConfig = workspaceConfig ? {} : { ...packageJson.pnpm };
const patches = workspaceConfig?.patches ?? pnpmConfig.patchedDependencies;
const keptPatches = {};
if (patches && typeof patches === "object" && !Array.isArray(patches)) {
const missing = [];
for (const [dependency, patchFile] of Object.entries(patches)) {
const exists =
typeof patchFile === "string" &&
fs.existsSync(path.resolve(path.dirname(packageJsonPath), patchFile));
if (exists) {
keptPatches[dependency] = patchFile;
} else {
missing.push(`${dependency} -> ${String(patchFile)}`);
}
}
if (missing.length > 0 && !legacyPackageAcceptanceCompat(packageJson.version)) {
throw new Error(
`package ${packageJson.version} has missing pnpm patchedDependencies in package fixture: ${missing.join(", ")}`,
);
}
}
if (workspaceConfig) {
writeWorkspacePnpmConfig(pnpmWorkspacePath, keptPatches);
} else {
pnpmConfig.allowUnusedPatches = true;
pnpmConfig.minimumReleaseAge = 0;
if (Object.keys(keptPatches).length > 0) {
pnpmConfig.patchedDependencies = keptPatches;
} else {
delete pnpmConfig.patchedDependencies;
}
packageJson.pnpm = pnpmConfig;
}
const fixtureUiBuildSource = `const fs=require("node:fs");fs.mkdirSync("dist/control-ui",{recursive:true});fs.writeFileSync("dist/control-ui/index.html",${JSON.stringify(controlUiHtml)})`;
packageJson.scripts = {
...packageJson.scripts,
build: 'node -e "console.log(\\"fixture build skipped\\")"',
lint: 'node -e "console.log(\\"fixture lint skipped\\")"',
"ui:build": `node -e ${JSON.stringify(fixtureUiBuildSource)}`,
};
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
writeControlUi(root);
}
function assertUpdate(channel) {
const payload = JSON.parse(process.env.UPDATE_JSON ?? "");
if (payload.status !== "ok") {
throw new Error(`expected ${channel} update status ok, got ${payload.status}`);
}
if (channel === "dev" && payload.mode !== "git") {
throw new Error(`expected dev update mode git, got ${payload.mode}`);
}
if (channel === "stable" && !["npm", "pnpm", "bun"].includes(payload.mode)) {
throw new Error(`expected package-manager mode after stable switch, got ${payload.mode}`);
}
if (payload.postUpdate?.plugins && payload.postUpdate.plugins.status !== "ok") {
throw new Error(
`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`,
);
}
}
function assertConfigChannel(channel) {
const config = readJson(path.join(process.env.HOME, ".openclaw", "openclaw.json"));
if (config.update?.channel === channel) {
return;
}
if (process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1") {
console.log(
`legacy package did not persist update.channel ${channel}; got ${JSON.stringify(config.update?.channel)}`,
);
return;
}
throw new Error(
`expected persisted update.channel ${channel}, got ${JSON.stringify(config.update?.channel)}`,
);
}
function assertStatusKind(kind) {
const payload = JSON.parse(process.env.STATUS_JSON ?? "");
if (payload.update?.installKind !== kind) {
throw new Error(`expected ${kind} install after switch, got ${payload.update?.installKind}`);
}
}
switch (command) {
case "prepare-git-fixture":
prepareGitFixture(args[0] ?? "/tmp/openclaw-git");
break;
case "write-control-ui":
writeControlUi(args[0] ?? "/tmp/openclaw-git");
break;
case "assert-update":
assertUpdate(args[0]);
break;
case "assert-config-channel":
assertConfigChannel(args[0]);
break;
case "assert-status-kind":
assertStatusKind(args[0]);
break;
default:
usage();
}

View File

@@ -0,0 +1,671 @@
// Assertions for upgrade-survivor E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs";
const command = process.argv[2];
const SCENARIOS = new Set([
"base",
"acpx-openclaw-tools-bridge",
"feishu-channel",
"bootstrap-persona",
"channel-post-core-restore",
"plugin-deps-cleanup",
"configured-plugin-installs",
"stale-source-plugin-shadow",
"tilde-log-path",
"versioned-runtime-deps",
]);
const PERSONA_FILES = new Map([
["BOOTSTRAP.md", "# Existing Bootstrap\n\nDo not overwrite me during update.\n"],
["SOUL.md", "# Existing Soul\n\nKeep this voice intact.\n"],
["USER.md", "# Existing User\n\nPrefers survivor tests.\n"],
["MEMORY.md", "# Existing Memory\n\nUpgrade reports came from real users.\n"],
]);
const LEGACY_SESSION_MAIN_ID = "upgrade-main-session";
const LEGACY_SESSION_DIRECT_ID = "upgrade-direct-session";
const LEGACY_SESSION_GROUP_ID = "upgrade-group-session";
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function resolveHomePath(value) {
if (typeof value !== "string" || value.length === 0) {
return "";
}
if (value === "~") {
return process.env.HOME || value;
}
if (value.startsWith("~/")) {
return path.join(process.env.HOME || "", value.slice(2));
}
return value;
}
function isPathInside(parent, child) {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isPathInsideManagedNpmProjectPackageRoot(params) {
const relative = path.relative(path.join(params.stateDir, "npm", "projects"), params.installPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return false;
}
const segments = relative.split(path.sep);
const packageSegments = params.packageName.split("/");
return (
segments.length === 2 + packageSegments.length &&
Boolean(segments[0]) &&
segments[1] === "node_modules" &&
packageSegments.every((segment, index) => segments[index + 2] === segment)
);
}
function write(file, contents) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
}
function writeJson(file, value) {
write(file, `${JSON.stringify(value, null, 2)}\n`);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function seedLegacySessionMetadata(stateDir) {
const legacySessionsDir = path.join(stateDir, "sessions");
writeJson(path.join(legacySessionsDir, "sessions.json"), {
main: {
sessionId: LEGACY_SESSION_MAIN_ID,
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
provider: "openai",
model: "gpt-5.5",
updatedAt: 1710000000000,
skillsSnapshot: {
prompt: "legacy prompt survives as metadata",
resolvedSkills: [
{
name: "legacy-heavy-skill-cache",
filePath: "/tmp/openclaw-old-package/skills/legacy-heavy-skill-cache/SKILL.md",
},
],
},
},
"+15551234567": {
sessionId: LEGACY_SESSION_DIRECT_ID,
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
provider: "openai",
model: "gpt-5.5",
updatedAt: 1710000000100,
},
"slack:channel:CUPGRADE": {
sessionId: LEGACY_SESSION_GROUP_ID,
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
provider: "openai",
model: "gpt-5.5",
updatedAt: 1710000000200,
lastChannel: "slack",
lastTo: "CUPGRADE",
},
});
for (const sessionId of [
LEGACY_SESSION_MAIN_ID,
LEGACY_SESSION_DIRECT_ID,
LEGACY_SESSION_GROUP_ID,
]) {
write(
path.join(legacySessionsDir, `${sessionId}.jsonl`),
`${JSON.stringify({ type: "session", id: sessionId })}\n`,
);
}
}
function getScenario() {
const scenario = process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
assert(SCENARIOS.has(scenario), `unknown upgrade survivor scenario: ${scenario}`);
return scenario;
}
function getConfig() {
return readJson(requireEnv("OPENCLAW_CONFIG_PATH"));
}
function getCoverage() {
const file = process.env.OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON;
if (!file || !fs.existsSync(file)) {
return null;
}
return readJson(file);
}
function acceptsIntent(coverage, id) {
if (!coverage) {
return true;
}
return (
Array.isArray(coverage.acceptedIntents) &&
coverage.acceptedIntents.includes(id) &&
!coverage.skippedIntents?.includes(id)
);
}
function hasCoverage(coverage) {
return Boolean(coverage);
}
function seedState() {
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
const scenario = getScenario();
write(
path.join(workspace, "IDENTITY.md"),
"# Upgrade Survivor\n\nThis workspace must survive package update and doctor repair.\n",
);
if (scenario === "bootstrap-persona") {
for (const [fileName, contents] of PERSONA_FILES) {
write(path.join(workspace, fileName), contents);
}
}
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
version: 1,
setupCompletedAt: "2026-04-01T00:00:00.000Z",
});
writeJson(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json"), {
id: "legacy-session",
agentId: "main",
title: "Existing user session",
});
seedLegacySessionMetadata(stateDir);
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
for (const plugin of ["discord", "telegram", "whatsapp"]) {
writeJson(path.join(runtimeRoot, plugin, ".openclaw-runtime-deps-stamp.json"), {
version: 0,
plugin,
stale: true,
});
write(
path.join(
runtimeRoot,
plugin,
".openclaw-runtime-deps-copy-stale",
"node_modules",
"stale-sentinel",
"package.json",
),
`${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`,
);
}
if (scenario === "versioned-runtime-deps") {
const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24";
for (const plugin of ["discord", "feishu", "telegram", "whatsapp"]) {
writeJson(
path.join(
runtimeRoot,
`openclaw-${version}-${plugin}`,
".openclaw-runtime-deps-stamp.json",
),
{
packageVersion: version,
plugin,
stale: true,
},
);
write(
path.join(
runtimeRoot,
`openclaw-${version}-${plugin}`,
"node_modules",
"stale-sentinel",
"package.json",
),
`${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`,
);
}
}
writeJson(path.join(stateDir, "survivor-baseline.json"), {
agents: ["main", "ops"],
discordGuild: "222222222222222222",
discordChannel: "333333333333333333",
telegramGroup: "-1001234567890",
whatsappGroup: "120363000000000000@g.us",
workspaceIdentity: path.join(workspace, "IDENTITY.md"),
scenario,
});
}
function assertConfigSurvived() {
const config = getConfig();
const coverage = getCoverage();
if (acceptsIntent(coverage, "update")) {
assert(config.update?.channel === "stable", "update.channel was not preserved");
}
if (acceptsIntent(coverage, "gateway")) {
assert(config.gateway?.auth?.mode === "token", "gateway auth mode was not preserved");
}
if (acceptsIntent(coverage, "models")) {
assert(config.models?.providers?.openai, "OpenAI model provider missing");
}
if (acceptsIntent(coverage, "agents")) {
const agents = config.agents?.list ?? [];
assert(Array.isArray(agents), "agents.list missing after update/doctor");
assert(
agents.some((agent) => agent?.id === "main"),
"main agent missing",
);
assert(
agents.some((agent) => agent?.id === "ops"),
"ops agent missing",
);
if (hasCoverage(coverage)) {
assert(config.agents?.defaults?.contextTokens === 64000, "default contextTokens changed");
} else {
assert(
agents.find((agent) => agent?.id === "main")?.contextTokens === 64000,
"main agent contextTokens changed",
);
}
if (!hasCoverage(coverage) || !coverage.skippedIntents?.includes("agent-modern-preferences")) {
assert(
agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true,
"ops fastModeDefault changed",
);
}
}
if (acceptsIntent(coverage, "skills")) {
assert(config.skills?.allowBundled?.includes("memory"), "memory skill allowlist changed");
}
if (acceptsIntent(coverage, "plugins")) {
const pluginAllow = config.plugins?.allow ?? [];
assert(pluginAllow.includes("discord"), "discord plugin allow entry missing");
assert(pluginAllow.includes("telegram"), "telegram plugin allow entry missing");
if (getScenario() === "configured-plugin-installs") {
assert(pluginAllow.includes("matrix"), "matrix plugin allow entry missing");
} else {
assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing");
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) {
assert(pluginAllow.includes("feishu"), "feishu plugin allow entry missing");
}
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "acpx-openclaw-tools-bridge")) {
const pluginAllow = config.plugins?.allow ?? [];
assert(pluginAllow.includes("acpx"), "ACPX plugin allow entry missing");
assert(config.plugins?.entries?.acpx?.enabled === true, "ACPX plugin entry changed");
assert(
config.plugins?.entries?.acpx?.config?.openClawToolsMcpBridge === true,
"ACPX OpenClaw tools bridge config changed",
);
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
const pluginAllow = config.plugins?.allow ?? [];
assert(pluginAllow.includes("discord"), "configured install discord allow entry missing");
assert(pluginAllow.includes("telegram"), "configured install telegram allow entry missing");
assert(pluginAllow.includes("matrix"), "configured install matrix allow entry missing");
assert(
config.plugins?.entries?.matrix?.enabled === true,
"configured install matrix entry changed",
);
}
if (acceptsIntent(coverage, "discord-channel")) {
const discord = config.channels?.discord;
assert(discord?.enabled === true, "discord enabled flag changed");
const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom;
const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy;
assert(discordDmPolicy === "allowlist", "discord DM policy changed");
assert(
Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"),
"discord allowFrom changed",
);
assert(
discord.guilds?.["222222222222222222"]?.channels?.["333333333333333333"]?.requireMention ===
true,
"discord guild channel mention policy changed",
);
assert(discord.threadBindings?.idleHours === 72, "discord thread binding ttl changed");
}
if (acceptsIntent(coverage, "telegram-channel")) {
const telegram = config.channels?.telegram;
assert(telegram?.enabled === true, "telegram enabled flag changed");
assert(
telegram.groups?.["-1001234567890"]?.requireMention === true,
"telegram group policy changed",
);
}
if (
acceptsIntent(coverage, "whatsapp-channel") &&
getScenario() !== "configured-plugin-installs"
) {
const whatsapp = config.channels?.whatsapp;
assert(whatsapp?.enabled === true, "whatsapp enabled flag changed");
const whatsappGroup = whatsapp.groups?.["120363000000000000@g.us"];
if (hasCoverage(coverage)) {
assert(whatsappGroup?.requireMention === true, "whatsapp group policy changed");
} else {
assert(
whatsappGroup?.systemPrompt === "Use the existing WhatsApp group prompt.",
"whatsapp group policy changed",
);
}
}
if (getScenario() === "channel-post-core-restore") {
const whatsapp = config.channels?.whatsapp;
assert(whatsapp?.enabled === true, "post-core channel restore dropped WhatsApp");
assert(
whatsapp.groups?.["120363000000000000@g.us"]?.requireMention === true,
"post-core channel restore changed WhatsApp group config",
);
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
const matrix = config.channels?.matrix;
assert(matrix?.enabled === true, "matrix enabled flag changed");
assert(matrix?.homeserver === "https://matrix.example.invalid", "matrix homeserver changed");
assert(matrix?.userId === "@upgrade-survivor:matrix.example.invalid", "matrix userId changed");
assert(
!config.channels?.whatsapp,
"whatsapp channel config should be absent in matrix scenario",
);
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) {
const feishu = config.channels?.feishu;
assert(feishu?.enabled === true, "feishu enabled flag changed");
assert(feishu?.connectionMode === "webhook", "feishu connection mode changed");
assert(feishu?.defaultAccount === "default", "feishu default account changed");
assert(feishu?.accounts?.default?.appId === "cli_upgrade_survivor", "feishu account changed");
assert(
feishu.groups?.oc_upgrade_survivor?.requireMention === true,
"feishu group mention policy changed",
);
}
if (hasCoverage(coverage) && acceptsIntent(coverage, "logging")) {
assert(
config.logging?.file === "~/openclaw-upgrade-survivor/gateway.jsonl",
"logging.file tilde path changed",
);
}
}
function assertStateSurvived() {
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
const scenario = getScenario();
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
assert(fs.existsSync(path.join(workspace, "IDENTITY.md")), "workspace identity file missing");
assert(
fs.existsSync(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json")),
"legacy session file missing",
);
if (stage !== "baseline") {
assertSessionMetadataMigrated(stateDir);
}
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
if (stage === "baseline") {
if (fs.existsSync(legacyRuntimeRoot)) {
assert(
fs.existsSync(path.join(legacyRuntimeRoot, "discord")),
"legacy plugin runtime deps root exists but discord debris is missing before doctor cleanup",
);
}
} else {
assert(
!fs.existsSync(legacyRuntimeRoot),
`legacy plugin runtime deps root survived update/doctor: ${legacyRuntimeRoot}`,
);
}
if (scenario === "bootstrap-persona") {
for (const [fileName, contents] of PERSONA_FILES) {
const actual = fs.readFileSync(path.join(workspace, fileName), "utf8");
assert(actual === contents, `${fileName} was changed during update/doctor`);
}
}
if (scenario === "stale-source-plugin-shadow") {
const staleRoot = path.join(stateDir, "extensions", "opik-openclaw");
assert(
fs.existsSync(path.join(staleRoot, "src", "index.ts")),
"source-only plugin shadow fixture missing",
);
}
if (scenario === "versioned-runtime-deps") {
if (stage === "baseline") {
return;
}
const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24";
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
const staleVersionedRoots = fs.existsSync(runtimeRoot)
? fs.readdirSync(runtimeRoot).filter((entry) => entry.startsWith(`openclaw-${version}-`))
: [];
assert(
staleVersionedRoots.length === 0,
`stale versioned runtime deps survived update/doctor: ${staleVersionedRoots.join(", ")}`,
);
}
}
function assertSessionMetadataMigrated(stateDir) {
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
const agentSessionsDir = path.join(stateDir, "agents", "main", "sessions");
const targetStorePath = path.join(agentSessionsDir, "sessions.json");
assert(
!fs.existsSync(legacyStorePath),
`legacy sessions.json survived migration: ${legacyStorePath}`,
);
for (const sessionId of [
LEGACY_SESSION_MAIN_ID,
LEGACY_SESSION_DIRECT_ID,
LEGACY_SESSION_GROUP_ID,
]) {
assert(
fs.existsSync(path.join(agentSessionsDir, `${sessionId}.jsonl`)),
`legacy session transcript was not moved for ${sessionId}`,
);
}
const store = readMigratedSessionStore(stateDir, targetStorePath);
const main = store["agent:main:main"];
const direct = store["agent:main:+15551234567"];
const group = store["agent:main:slack:channel:cupgrade"];
assert(main?.sessionId === LEGACY_SESSION_MAIN_ID, "main legacy session row missing");
assert(direct?.sessionId === LEGACY_SESSION_DIRECT_ID, "direct legacy session row missing");
assert(group?.sessionId === LEGACY_SESSION_GROUP_ID, "channel legacy session row missing");
assert(
main?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
"main legacy session row still points at the old sessions directory",
);
assert(
direct?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
"direct legacy session row still points at the old sessions directory",
);
assert(
group?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
"channel legacy session row still points at the old sessions directory",
);
assert(
main.skillsSnapshot?.prompt === "legacy prompt survives as metadata",
"legacy session metadata prompt was not preserved",
);
assert(
main.skillsSnapshot?.resolvedSkills === undefined,
"heavy resolvedSkills cache was persisted into migrated session metadata",
);
}
function readMigratedSessionStore(stateDir, targetStorePath) {
if (fs.existsSync(targetStorePath)) {
return readJson(targetStorePath);
}
const dbPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
assert(fs.existsSync(dbPath), `agent session store missing: ${targetStorePath} or ${dbPath}`);
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const rows = db
.prepare("SELECT key, value_json FROM cache_entries WHERE scope = ?")
.all("session_entries");
const store = {};
for (const row of rows) {
if (typeof row?.key !== "string" || typeof row?.value_json !== "string") {
continue;
}
store[row.key] = JSON.parse(row.value_json);
}
return store;
} finally {
db?.close();
}
}
function readInstalledPluginIndex() {
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
const index = readPluginInstallIndex({ stateDir });
assert(index.installRecords, "installed plugin index missing");
return index;
}
function assertExternalPluginInstall(records, pluginId, packageName) {
const record = records[pluginId];
assert(record, `configured external ${pluginId} plugin install record missing`);
const installedFromNpm = record.source === "npm";
const installedFromOfficialClawHubNpmPack =
record.source === "clawhub" &&
record.clawhubChannel === "official" &&
record.artifactKind === "npm-pack";
assert(
installedFromNpm || installedFromOfficialClawHubNpmPack,
`configured external ${pluginId} plugin must be installed from npm or official ClawHub npm-pack, got: ${record.source}`,
);
const installPath = resolveHomePath(record.installPath);
assert(
installPath,
`configured external ${pluginId} plugin installPath missing: ${JSON.stringify(record)}`,
);
assert(
fs.existsSync(installPath),
`configured external ${pluginId} plugin installPath missing on disk: ${installPath}`,
);
assert(
fs.existsSync(path.join(installPath, "package.json")),
`configured external ${pluginId} plugin package.json missing: ${installPath}`,
);
const packageJson = readJson(path.join(installPath, "package.json"));
assert(
packageJson.name === packageName,
`configured external ${pluginId} package name changed: ${packageJson.name}`,
);
if (installedFromNpm) {
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
assert(
isPathInsideManagedNpmProjectPackageRoot({ stateDir, installPath, packageName }),
`configured external ${pluginId} npm install path outside managed npm project root: ${installPath}`,
);
assert(
String(record.spec ?? record.resolvedSpec ?? "").startsWith(packageName),
`configured external ${pluginId} plugin npm spec changed`,
);
return;
}
assert(
record.clawhubPackage === packageName,
`configured external ${pluginId} ClawHub package changed: ${record.clawhubPackage}`,
);
const extensionsRoot = path.join(requireEnv("OPENCLAW_STATE_DIR"), "extensions");
assert(
isPathInside(extensionsRoot, installPath),
`configured external ${pluginId} ClawHub install path outside managed extensions root: ${installPath}`,
);
}
function assertConfiguredPluginInstalls() {
const coverage = getCoverage();
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
if (!hasCoverage(coverage) || !acceptsIntent(coverage, "configured-plugin-installs")) {
return;
}
if (stage === "baseline") {
return;
}
const index = readInstalledPluginIndex();
const records = index.installRecords ?? {};
assertOptionalConfiguredPluginIndex(records, index.plugins ?? [], {
bundled: true,
packageName: "@openclaw/matrix",
pluginId: "matrix",
});
assertOptionalConfiguredPluginIndex(records, index.plugins ?? [], {
packageName: "@openclaw/brave-plugin",
pluginId: "brave",
});
assert(!records.telegram, "internal telegram plugin should not be installed externally");
}
function assertOptionalConfiguredPluginIndex(
records,
plugins,
{ bundled = false, packageName, pluginId },
) {
const record = records[pluginId];
const plugin = plugins.find((entry) => entry?.pluginId === pluginId);
if (record) {
assertExternalPluginInstall(records, pluginId, packageName);
}
if (plugin) {
assert(
plugin.enabled !== false,
`configured ${bundled ? "bundled" : "external"} ${pluginId} plugin is disabled`,
);
}
}
function assertStatusJson([file]) {
const status = readJson(file);
assert(status && typeof status === "object", "gateway status JSON was not an object");
const text = JSON.stringify(status);
assert(/running|connected|ok|ready/u.test(text), "gateway status did not report a healthy state");
}
if (command === "seed") {
seedState();
} else if (command === "assert-config") {
assertConfigSurvived();
} else if (command === "assert-state") {
assertStateSurvived();
assertConfiguredPluginInstalls();
} else if (command === "assert-status-json") {
assertStatusJson(process.argv.slice(3));
} else {
throw new Error(`unknown upgrade-survivor assertion command: ${command ?? "<missing>"}`);
}

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env node
// Builds config recipes for upgrade-survivor E2E scenarios.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseReleaseVersion } from "../../../lib/npm-publish-plan.mjs";
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../../windows-cmd-helpers.mjs";
const args = process.argv.slice(2);
const command = args.shift();
export const CONFIG_COMMAND_TIMEOUT_MS = 120_000;
export const CONFIG_COMMAND_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
function option(name, fallback) {
const index = args.indexOf(name);
if (index === -1) {
return fallback;
}
const value = args[index + 1];
if (!value) {
throw new Error(`missing value for ${name}`);
}
return value;
}
function tail(value, max = 2400) {
const text = String(value || "");
return text.length <= max ? text : text.slice(-max);
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
const configSectionDir = new URL("./config-recipe/", import.meta.url);
function readConfigSection(fileName) {
const fileUrl = new URL(fileName, configSectionDir);
return JSON.stringify(JSON.parse(fs.readFileSync(fileUrl, "utf8")));
}
export function isReleaseBefore(version, minimum) {
const parsed = parseReleaseVersion(String(version ?? ""));
const minimumParsed = parseReleaseFloor(minimum);
if (!parsed || !minimumParsed) {
return false;
}
for (const key of ["year", "month", "patch"]) {
const delta = parsed[key] - minimumParsed[key];
if (delta !== 0) {
return delta < 0;
}
}
return false;
}
function parseReleaseFloor(version) {
const match = /^([0-9]{4})\.([1-9][0-9]?)\.([0-9]+)$/u.exec(String(version ?? ""));
if (!match) {
return null;
}
const [year, month, patch] = match.slice(1).map((part) => Number(part));
if (
!Number.isSafeInteger(year) ||
!Number.isSafeInteger(month) ||
!Number.isSafeInteger(patch) ||
month < 1 ||
month > 12
) {
return null;
}
return { year, month, patch };
}
function configSetJsonFile(id, intent, configPath, fileName) {
return {
id,
intent,
argv: ["config", "set", configPath, readConfigSection(fileName), "--strict-json"],
};
}
const representativeConfigSteps = [
configSetJsonFile("models-openai", "models", "models.providers.openai", "models-openai.json"),
configSetJsonFile("agents", "agents", "agents", "agents.json"),
configSetJsonFile("skills", "skills", "skills", "skills.json"),
configSetJsonFile("plugins", "plugins", "plugins", "plugins.json"),
configSetJsonFile(
"channels-discord",
"discord-channel",
"channels.discord",
"channels-discord.json",
),
configSetJsonFile(
"channels-telegram",
"telegram-channel",
"channels.telegram",
"channels-telegram.json",
),
configSetJsonFile(
"channels-whatsapp",
"whatsapp-channel",
"channels.whatsapp",
"channels-whatsapp.json",
),
];
const scenarioConfigSteps = new Map([
[
"acpx-openclaw-tools-bridge",
[
configSetJsonFile(
"plugins-acpx-openclaw-tools-bridge",
"acpx-openclaw-tools-bridge",
"plugins",
"plugins-acpx-openclaw-tools-bridge.json",
),
],
],
[
"feishu-channel",
[
configSetJsonFile("plugins-feishu", "plugins", "plugins", "plugins-feishu.json"),
configSetJsonFile(
"channels-feishu",
"feishu-channel",
"channels.feishu",
"channels-feishu.json",
),
],
],
[
"tilde-log-path",
[
{
id: "logging-file",
intent: "logging",
argv: ["config", "set", "logging.file", "~/openclaw-upgrade-survivor/gateway.jsonl"],
},
],
],
[
"configured-plugin-installs",
[
configSetJsonFile(
"plugins-configured-installs",
"configured-plugin-installs",
"plugins",
"plugins-configured-installs.json",
),
{
id: "channels-whatsapp-unset",
intent: "configured-plugin-installs",
argv: ["config", "unset", "channels.whatsapp"],
},
configSetJsonFile(
"channels-matrix",
"configured-plugin-installs",
"channels.matrix",
"channels-matrix.json",
),
],
],
]);
const recipe = [
{
id: "update-channel",
intent: "update",
argv: ["config", "set", "update.channel", "stable"],
},
configSetJsonFile("gateway", "gateway", "gateway", "gateway.json"),
...representativeConfigSteps,
{
id: "validate",
intent: "validate",
argv: ["config", "validate"],
},
];
function selectedScenario() {
return process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
}
function adaptStepForBaseline(step, baselineVersion, summary) {
if (
step.intent === "acpx-openclaw-tools-bridge" &&
isReleaseBefore(baselineVersion, "2026.4.22")
) {
if (!summary.skippedIntents.includes("acpx-openclaw-tools-bridge")) {
summary.skippedIntents.push("acpx-openclaw-tools-bridge");
}
return null;
}
if (!isReleaseBefore(baselineVersion, "2026.4.0")) {
return step;
}
if (step.id === "plugins-feishu" || step.id === "channels-feishu") {
if (!summary.skippedIntents.includes("feishu-channel")) {
summary.skippedIntents.push("feishu-channel");
}
return null;
}
if (step.id === "agents") {
const agents = JSON.parse(step.argv[3]);
delete agents.defaults?.skills;
for (const agent of agents.list ?? []) {
delete agent.thinkingDefault;
delete agent.fastModeDefault;
delete agent.skills;
}
summary.skippedIntents.push("agent-modern-preferences");
return {
...step,
argv: [...step.argv.slice(0, 3), JSON.stringify(agents), ...step.argv.slice(4)],
};
}
if (step.intent === "plugins") {
const plugins = JSON.parse(step.argv[3]);
plugins.allow = (plugins.allow ?? []).filter((id) => id !== "memory");
delete plugins.entries?.memory;
if (!summary.skippedIntents.includes("memory-plugin-allow")) {
summary.skippedIntents.push("memory-plugin-allow");
}
return {
...step,
argv: [...step.argv.slice(0, 3), JSON.stringify(plugins), ...step.argv.slice(4)],
};
}
return step;
}
export function resolveUpgradeSurvivorOpenClawCommand(argv, params = {}) {
const platform = params.platform ?? process.platform;
if (platform === "win32") {
const comSpec = params.comSpec ?? resolveWindowsCmdExePath(params.env ?? process.env);
return {
command: comSpec,
args: ["/d", "/s", "/c", buildCmdExeCommandLine("openclaw.cmd", argv)],
commandLabel: ["openclaw", ...argv].join(" "),
shell: false,
windowsVerbatimArguments: true,
};
}
return {
command: "openclaw",
args: argv,
commandLabel: ["openclaw", ...argv].join(" "),
shell: false,
};
}
function errorCode(error) {
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
}
export function runUpgradeSurvivorOpenClawStep(step, params = {}) {
const invocation = resolveUpgradeSurvivorOpenClawCommand(step.argv);
const run = params.spawnSyncCommand ?? spawnSync;
const timeoutMs = params.timeoutMs ?? CONFIG_COMMAND_TIMEOUT_MS;
const maxBuffer = params.maxBufferBytes ?? CONFIG_COMMAND_MAX_BUFFER_BYTES;
const result = run(invocation.command, invocation.args, {
encoding: "utf8",
env: process.env,
killSignal: "SIGTERM",
maxBuffer,
shell: invocation.shell,
timeout: timeoutMs,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
const code = errorCode(result.error);
return {
id: step.id,
intent: step.intent,
command: invocation.commandLabel,
status: result.status,
signal: result.signal,
ok: result.status === 0 && !result.error,
errorCode: code,
errorMessage: result.error?.message ? tail(result.error.message) : undefined,
stdout: tail(result.stdout),
stderr: tail(result.stderr),
};
}
function applyRecipe() {
const summaryPath = option("--summary");
const baselineVersion = option("--baseline-version", null);
const scenario = selectedScenario();
const scenarioSteps = scenarioConfigSteps.get(scenario) ?? [];
const summary = {
source: "baseline-cli-command-recipe",
recipe: "upgrade-survivor-v1",
baselineVersion,
scenario,
acceptedIntents: [
"update",
"gateway",
"models",
"agents",
"skills",
"plugins",
"discord-channel",
"telegram-channel",
"whatsapp-channel",
...scenarioSteps.map((step) => step.intent),
],
skippedIntents: [],
steps: [],
};
for (const step of [...recipe.slice(0, -1), ...scenarioSteps, recipe.at(-1)]) {
const adaptedStep = adaptStepForBaseline(step, baselineVersion, summary);
if (!adaptedStep) {
continue;
}
const outcome = runUpgradeSurvivorOpenClawStep(adaptedStep);
summary.steps.push(outcome);
writeJson(summaryPath, summary);
if (!outcome.ok) {
const detail = outcome.errorCode ?? outcome.signal ?? outcome.status ?? "unknown";
throw new Error(`baseline config recipe failed at ${step.id}: ${detail}`);
}
}
}
function main() {
if (command === "apply") {
applyRecipe();
} else {
throw new Error(`unknown upgrade-survivor config-recipe command: ${command ?? "<missing>"}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}

View File

@@ -0,0 +1,30 @@
{
"defaults": {
"model": {
"primary": "openai/gpt-5.5"
},
"contextTokens": 64000
},
"list": [
{
"id": "main",
"default": true,
"name": "Main",
"workspace": "~/workspace",
"model": {
"primary": "openai/gpt-5.5"
},
"thinkingDefault": "low",
"skills": ["memory"]
},
{
"id": "ops",
"name": "Ops",
"workspace": "~/workspace/ops",
"model": {
"primary": "openai/gpt-5.5"
},
"fastModeDefault": true
}
]
}

View File

@@ -0,0 +1,32 @@
{
"enabled": true,
"token": {
"source": "env",
"provider": "default",
"id": "DISCORD_BOT_TOKEN"
},
"dm": {
"policy": "allowlist",
"allowFrom": ["111111111111111111"]
},
"groupPolicy": "allowlist",
"guilds": {
"222222222222222222": {
"slug": "survivor-guild",
"channels": {
"333333333333333333": {
"enabled": true,
"requireMention": true,
"tools": {
"allow": ["message_send"],
"deny": ["exec"]
}
}
}
}
},
"threadBindings": {
"enabled": true,
"idleHours": 72
}
}

View File

@@ -0,0 +1,37 @@
{
"enabled": true,
"domain": "feishu",
"connectionMode": "webhook",
"defaultAccount": "default",
"verificationToken": "upgrade-survivor-feishu-verification",
"encryptKey": "upgrade-survivor-feishu-encrypt",
"webhookPath": "/feishu/events",
"webhookHost": "127.0.0.1",
"webhookPort": 3000,
"accounts": {
"default": {
"enabled": true,
"name": "Upgrade Survivor Feishu",
"appId": "cli_upgrade_survivor",
"appSecret": {
"source": "env",
"provider": "default",
"id": "FEISHU_APP_SECRET"
}
}
},
"dmPolicy": "allowlist",
"allowFrom": ["ou_upgrade_survivor"],
"groupPolicy": "allowlist",
"groupAllowFrom": ["oc_upgrade_survivor"],
"groups": {
"oc_upgrade_survivor": {
"enabled": true,
"requireMention": true,
"tools": {
"allow": ["message_send"],
"deny": ["exec"]
}
}
}
}

View File

@@ -0,0 +1,24 @@
{
"enabled": true,
"homeserver": "https://matrix.example.invalid",
"userId": "@upgrade-survivor:matrix.example.invalid",
"accessToken": {
"source": "env",
"provider": "default",
"id": "MATRIX_ACCESS_TOKEN"
},
"dm": {
"policy": "allowlist",
"allowFrom": ["@driver:matrix.example.invalid"]
},
"groups": {
"!upgrade-survivor:matrix.example.invalid": {
"enabled": true,
"requireMention": true,
"tools": {
"allow": ["message_send"],
"deny": ["exec"]
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More