refactor(e2e): consolidate runner setup and observations

Share per-variant setup, process handling, bounded HTTP reads and upgrade
observations while preserving lane arguments, output and lifecycle order.
Close the diagnostic dependency copies used by isolated and frozen runners.
This commit is contained in:
Peter Steinberger committed 2026-10-08 21:39:15 -07:00
1 parent f0cfdadcfb
commit ef9ccba604
59 files changed
+887 -1375

No files matched your search

+45 -91
View File
@@ -195,28 +195,15 @@ const commandParentSignals: NodeJS.Signals[] =
let commandShutdownPromise: Promise<void[]> | undefined;
let commandSignalHandlersInstalled = false;
function installCommandSignalHandlers() {
if (commandSignalHandlersInstalled) {
function setCommandSignalHandlersInstalled(installed: boolean) {
if (commandSignalHandlersInstalled === installed) {
return;
}
commandSignalHandlersInstalled = true;
commandSignalHandlersInstalled = installed;
for (const signal of commandParentSignals) {
const handler = commandSignalHandlers.get(signal);
if (handler) {
process.on(signal, handler);
}
}
}
function removeCommandSignalHandlers() {
if (!commandSignalHandlersInstalled) {
return;
}
commandSignalHandlersInstalled = false;
for (const signal of commandParentSignals) {
const handler = commandSignalHandlers.get(signal);
if (handler) {
process.off(signal, handler);
process[installed ? "on" : "off"](signal, handler);
}
}
}
@@ -309,53 +296,34 @@ export function readPositiveTimerMs(raw: string | undefined, fallback: number, l
}
export function resolveKitchenSinkRpcConfig(env: ProcessEnv = process.env) {
const commandTimeoutMs = readPositiveTimerMs(
env.OPENCLAW_KITCHEN_SINK_RPC_COMMAND_MS,
DEFAULT_COMMAND_TIMEOUT_MS,
const integer = (key: string, fallback: number) => readPositiveInt(env[key], fallback, key);
const timer = (key: string, fallback: number) => readPositiveTimerMs(env[key], fallback, key);
const commandTimeoutMs = timer(
"OPENCLAW_KITCHEN_SINK_RPC_COMMAND_MS",
DEFAULT_COMMAND_TIMEOUT_MS,
);
return {
commandMaxRssMiB: readPositiveInt(
env.OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB,
DEFAULT_MAX_COMMAND_RSS_MIB,
commandMaxRssMiB: integer(
"OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB",
DEFAULT_MAX_COMMAND_RSS_MIB,
),
commandTimeoutMs,
fetchBodyMaxBytes: readPositiveInt(
env.OPENCLAW_KITCHEN_SINK_RPC_FETCH_BODY_BYTES,
DEFAULT_FETCH_BODY_MAX_BYTES,
fetchBodyMaxBytes: integer(
"OPENCLAW_KITCHEN_SINK_RPC_FETCH_BODY_BYTES",
DEFAULT_FETCH_BODY_MAX_BYTES,
),
fetchTimeoutMs: readPositiveTimerMs(
env.OPENCLAW_KITCHEN_SINK_RPC_FETCH_MS,
DEFAULT_FETCH_TIMEOUT_MS,
"OPENCLAW_KITCHEN_SINK_RPC_FETCH_MS",
),
installTimeoutMs: readPositiveTimerMs(
env.OPENCLAW_KITCHEN_SINK_RPC_INSTALL_MS,
Math.max(commandTimeoutMs, DEFAULT_INSTALL_TIMEOUT_MS),
fetchTimeoutMs: timer("OPENCLAW_KITCHEN_SINK_RPC_FETCH_MS", DEFAULT_FETCH_TIMEOUT_MS),
installTimeoutMs: timer(
"OPENCLAW_KITCHEN_SINK_RPC_INSTALL_MS",
Math.max(commandTimeoutMs, DEFAULT_INSTALL_TIMEOUT_MS),
),
maxRssMiB: readPositiveInt(
env.OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB,
DEFAULT_MAX_RSS_MIB,
"OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB",
),
outputCaptureChars: readPositiveInt(
env.OPENCLAW_KITCHEN_SINK_OUTPUT_CAPTURE_CHARS,
DEFAULT_OUTPUT_CAPTURE_CHARS,
maxRssMiB: integer("OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB", DEFAULT_MAX_RSS_MIB),
outputCaptureChars: integer(
"OPENCLAW_KITCHEN_SINK_OUTPUT_CAPTURE_CHARS",
DEFAULT_OUTPUT_CAPTURE_CHARS,
),
readyTimeoutMs: readPositiveTimerMs(
env.OPENCLAW_KITCHEN_SINK_RPC_READY_MS,
DEFAULT_READY_TIMEOUT_MS,
"OPENCLAW_KITCHEN_SINK_RPC_READY_MS",
),
rpcTimeoutMs: readPositiveTimerMs(
env.OPENCLAW_KITCHEN_SINK_RPC_CALL_MS,
DEFAULT_RPC_TIMEOUT_MS,
"OPENCLAW_KITCHEN_SINK_RPC_CALL_MS",
),
readyTimeoutMs: timer("OPENCLAW_KITCHEN_SINK_RPC_READY_MS", DEFAULT_READY_TIMEOUT_MS),
rpcTimeoutMs: timer("OPENCLAW_KITCHEN_SINK_RPC_CALL_MS", DEFAULT_RPC_TIMEOUT_MS),
};
}
@@ -529,7 +497,7 @@ export function runCommand(
detached: spawnOptions.detached ?? process.platform !== "win32",
});
activeCommandChildren.add(child);
installCommandSignalHandlers();
setCommandSignalHandlersInstalled(true);
const startedAt = Date.now();
let stdout = { text: "", truncatedChars: 0 };
let stderr = { text: "", truncatedChars: 0 };
@@ -692,7 +660,7 @@ async function finishTimedOutCommandProcessTree(
function releaseCommandChild(child: CommandChild) {
activeCommandChildren.delete(child);
if (activeCommandChildren.size === 0 && !commandShutdownPromise) {
removeCommandSignalHandlers();
setCommandSignalHandlersInstalled(false);
}
}
@@ -716,7 +684,7 @@ async function shutdownActiveCommands(signal: NodeJS.Signals) {
}),
),
).finally(() => {
removeCommandSignalHandlers();
setCommandSignalHandlersInstalled(false);
process.kill(process.pid, signal);
});
return commandShutdownPromise;
@@ -793,16 +761,11 @@ export function signalProcessGroup(
useProcessGroup?: boolean;
} = {},
) {
const {
platform = process.platform,
runTaskkill = childProcess.spawnSync,
useProcessGroup = platform !== "win32",
} = options;
signalChildProcessTree(child, signal, {
killProcess: (pid, childSignal) => process.kill(pid, childSignal),
platform,
runTaskkill,
useProcessGroup,
platform: options.platform,
runTaskkill: options.runTaskkill,
useProcessGroup: options.useProcessGroup,
killProcess: defaultKillProcess,
});
}
@@ -1531,17 +1494,12 @@ export function signalGateway(
useProcessGroup?: boolean;
} = {},
) {
const {
platform = process.platform,
runTaskkill = childProcess.spawnSync,
useProcessGroup = platform !== "win32",
} = options;
return signalChildProcessTree(child, signal, {
platform: options.platform,
runTaskkill: options.runTaskkill,
useProcessGroup: options.useProcessGroup,
groupEsrchMeansExited: true,
killProcess,
platform,
runTaskkill,
useProcessGroup,
});
}
@@ -1551,17 +1509,17 @@ function signalChildProcessTree(
options: {
groupEsrchMeansExited?: boolean;
killProcess: KillProcess;
platform: NodeJS.Platform;
runTaskkill: TaskkillRunner;
useProcessGroup: boolean;
platform?: NodeJS.Platform;
runTaskkill?: TaskkillRunner;
useProcessGroup?: boolean;
},
) {
const {
groupEsrchMeansExited = false,
killProcess,
platform,
runTaskkill,
useProcessGroup,
platform = process.platform,
runTaskkill = childProcess.spawnSync,
useProcessGroup = platform !== "win32",
} = options;
if (useProcessGroup && typeof child.pid === "number") {
try {
@@ -1833,12 +1791,16 @@ export function assertTtsProviderCoverage(payload: unknown, surface: "providers"
}
}
export function assertKitchenSinkSearchInvokeResult(payload: unknown) {
function kitchenSinkToolOutput(payload: unknown, kind: "search" | "text" | "image job") {
const candidate = asRecord(payload);
if (candidate.ok !== true || candidate.source !== "plugin") {
throw new Error(`Kitchen Sink search tool invoke failed: ${boundedJsonPreview(payload)}`);
throw new Error(`Kitchen Sink ${kind} tool invoke failed: ${boundedJsonPreview(payload)}`);
}
const output = assertObjectPayload(candidate.output, "Kitchen Sink search tool output");
return assertObjectPayload(candidate.output, `Kitchen Sink ${kind} tool output`);
}
export function assertKitchenSinkSearchInvokeResult(payload: unknown) {
const output = kitchenSinkToolOutput(payload, "search");
const results = Array.isArray(output.results) ? output.results : [];
const hasFixture = results.some(
(entry) => asRecord(entry).title === "Kitchen Sink image fixture",
@@ -1851,11 +1813,7 @@ export function assertKitchenSinkSearchInvokeResult(payload: unknown) {
}
export function assertKitchenSinkTextInvokeResult(payload: unknown) {
const candidate = asRecord(payload);
if (candidate.ok !== true || candidate.source !== "plugin") {
throw new Error(`Kitchen Sink text tool invoke failed: ${boundedJsonPreview(payload)}`);
}
const output = assertObjectPayload(candidate.output, "Kitchen Sink text tool output");
const output = kitchenSinkToolOutput(payload, "text");
if (
output.route !== "tool:kitchen_sink_text" ||
typeof output.text !== "string" ||
@@ -1868,11 +1826,7 @@ export function assertKitchenSinkTextInvokeResult(payload: unknown) {
}
export function assertKitchenSinkImageJobInvokeResult(payload: unknown) {
const candidate = asRecord(payload);
if (candidate.ok !== true || candidate.source !== "plugin") {
throw new Error(`Kitchen Sink image job tool invoke failed: ${boundedJsonPreview(payload)}`);
}
const output = assertObjectPayload(candidate.output, "Kitchen Sink image job tool output");
const output = kitchenSinkToolOutput(payload, "image job");
const image = assertObjectPayload(output.image, "Kitchen Sink image job image");
const imageMetadata = assertObjectPayload(
image.metadata,
@@ -996,7 +996,14 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
);
}
async function runGatewaySmoke(options, probe, successMessage) {
async function runGatewaySmoke(initialOptions, probe, successMessage, isolatedState) {
let options = initialOptions;
if (isolatedState) {
const { label, config } = isolatedState;
const env = createIsolatedStateEnv(label);
writeConfig(ensureGatewayConfig(config, options.port), env);
options = { ...options, env };
}
const child = startGateway(options);
try {
await waitForReady({ ...options, child });
@@ -1288,28 +1295,12 @@ async function smokeTtsGlobalDisable(pluginId, pluginDir, provider, pluginIndex,
return;
}
const port = resolveRuntimeSmokePort(pluginIndex, 1);
const env = createIsolatedStateEnv(`tts-disabled-${pluginId}`);
writeConfig(
ensureGatewayConfig(
{
plugins: {
enabled: false,
},
tts: {
provider: selectedProvider,
},
},
port,
),
env,
);
const logPath = `/tmp/openclaw-plugin-runtime-${pluginIndex}-${pluginId}-tts-disabled.log`;
await runGatewaySmoke(
{
logPath,
port,
entrypoint,
env,
skipChannels: true,
pluginId: `${pluginId}:tts-disabled`,
},
@@ -1319,6 +1310,17 @@ async function smokeTtsGlobalDisable(pluginId, pluginDir, provider, pluginIndex,
assertSpeechProviderVisible(providers, selectedProvider, "tts.providers global-disable");
},
`Global-disable TTS smoke passed for ${pluginId}/${selectedProvider}`,
{
label: `tts-disabled-${pluginId}`,
config: {
plugins: {
enabled: false,
},
tts: {
provider: selectedProvider,
},
},
},
);
}
@@ -1332,10 +1334,20 @@ async function smokeOpenAiTts(pluginIndex) {
return;
}
const port = resolveRuntimeSmokePort(pluginIndex, 2);
const env = createIsolatedStateEnv("tts-openai-live");
writeConfig(
ensureGatewayConfig(
{
const logPath = `/tmp/openclaw-plugin-runtime-${pluginIndex}-openai-tts-live.log`;
await runGatewaySmoke(
{ entrypoint, port, logPath, skipChannels: true, pluginId: "openai:tts-live" },
async (options) => {
await assertBaseGatewayProbes(options);
const result = await retryRpcCall("tts.convert", { text: "ok", provider: "openai" }, options);
if (!isNonEmptyString(result.audioPath) || !fs.existsSync(result.audioPath)) {
throw new Error(`tts.convert did not produce an audio file: ${JSON.stringify(result)}`);
}
},
"OpenAI key-backed TTS smoke passed",
{
label: "tts-openai-live",
config: {
plugins: {
enabled: true,
allow: ["openai"],
@@ -1352,21 +1364,7 @@ async function smokeOpenAiTts(pluginIndex) {
},
},
},
port,
),
env,
);
const logPath = `/tmp/openclaw-plugin-runtime-${pluginIndex}-openai-tts-live.log`;
await runGatewaySmoke(
{ entrypoint, port, logPath, env, skipChannels: true, pluginId: "openai:tts-live" },
async (options) => {
await assertBaseGatewayProbes(options);
const result = await retryRpcCall("tts.convert", { text: "ok", provider: "openai" }, options);
if (!isNonEmptyString(result.audioPath) || !fs.existsSync(result.audioPath)) {
throw new Error(`tts.convert did not produce an audio file: ${JSON.stringify(result)}`);
}
},
"OpenAI key-backed TTS smoke passed",
);
}
@@ -16,6 +16,15 @@ 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 agentConfig = {
model: { primary: "openai/gpt-5.6-luna", fallbacks: [] },
models: {
"openai/gpt-5.6-luna": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
};
const config = {
gateway: {
@@ -43,27 +52,13 @@ const config = {
},
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-luna", fallbacks: [] },
models: {
"openai/gpt-5.6-luna": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
...agentConfig,
skipBootstrap: true,
timeoutSeconds,
sandbox: { mode: "off" },
},
entries: {
main: {
model: { primary: "openai/gpt-5.6-luna", fallbacks: [] },
models: {
"openai/gpt-5.6-luna": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
},
main: agentConfig,
},
},
skills: { allowBundled: [] },
@@ -637,24 +637,14 @@ function extractOpenClawToolTimeline(transcriptEvents) {
return timeline;
}
function findSuccessfulToolResult(timeline, call, beforeIndex = Number.POSITIVE_INFINITY) {
function findToolResult(timeline, call, beforeIndex = Number.POSITIVE_INFINITY, successful = true) {
return timeline.find(
(entry) =>
entry.type === "result" &&
entry.id === call.id &&
entry.index > call.index &&
entry.index < beforeIndex &&
entry.isError !== true,
);
}
function findToolResult(timeline, call, beforeIndex = Number.POSITIVE_INFINITY) {
return timeline.find(
(entry) =>
entry.type === "result" &&
entry.id === call.id &&
entry.index > call.index &&
entry.index < beforeIndex,
(!successful || entry.isError !== true),
);
}
@@ -692,11 +682,11 @@ function assertFollowthroughTranscript({ transcriptEvents, progressMarker, compl
);
}
const [progressCall, completeCall] = markerCalls;
const progressResult = findSuccessfulToolResult(timeline, progressCall, completeCall.index);
const progressResult = findToolResult(timeline, progressCall, completeCall.index);
if (!progressResult) {
throw new Error("missing successful progress message result before completion");
}
const completeResult = findSuccessfulToolResult(timeline, completeCall);
const completeResult = findToolResult(timeline, completeCall);
if (!completeResult) {
throw new Error("missing successful completion message result");
}
@@ -728,13 +718,13 @@ function assertFollowthroughTranscript({ transcriptEvents, progressMarker, compl
entry.index < completeCall.index,
);
const successfulWorkCalls = workCalls.filter((entry) =>
findSuccessfulToolResult(timeline, entry, completeCall.index),
findToolResult(timeline, entry, completeCall.index),
);
if (successfulWorkCalls.length === 0) {
throw new Error("expected successful workspace work between progress and completion");
}
const unsettledWorkCalls = workCalls.filter(
(entry) => !findToolResult(timeline, entry, completeCall.index),
(entry) => !findToolResult(timeline, entry, completeCall.index, false),
);
if (unsettledWorkCalls.length > 0) {
throw new Error(
@@ -253,31 +253,23 @@ run_cross_state_approval_flow() {
exec_source_hash="$(sha256sum "$exec_source" | awk '{print $1}')"
plugin_source_hash="$(sha256sum "$plugin_source" | awk '{print $1}')"
run_logged_command "$automated_log" "$command_timeout" env \
OPENCLAW_STATE_DIR="$custom_state_dir" \
OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \
OPENCLAW_UPDATE_IN_PROGRESS=1 \
"$npm_bin" doctor --repair --yes --non-interactive
local log
local -a update_env=(OPENCLAW_UPDATE_IN_PROGRESS=1)
for log in "$automated_log" "$direct_log"; do
run_logged_command "$log" "$command_timeout" env \
"${update_env[@]}" \
OPENCLAW_STATE_DIR="$custom_state_dir" \
OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \
"$npm_bin" doctor --repair --yes --non-interactive
test "$(sha256sum "$exec_source" | awk '{print $1}')" = "$exec_source_hash"
test "$(sha256sum "$plugin_source" | awk '{print $1}')" = "$plugin_source_hash"
test ! -e "$exec_source.migrated"
test ! -e "$plugin_source.migrated"
test ! -e "$custom_state_dir/exec-approvals.json"
test "$(plugin_binding_approval_count "$state_database")" = "0"
run_logged_command "$direct_log" "$command_timeout" env \
-u OPENCLAW_UPDATE_IN_PROGRESS \
OPENCLAW_STATE_DIR="$custom_state_dir" \
OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \
"$npm_bin" doctor --repair --yes --non-interactive
test "$(sha256sum "$exec_source" | awk '{print $1}')" = "$exec_source_hash"
test "$(sha256sum "$plugin_source" | awk '{print $1}')" = "$plugin_source_hash"
test ! -e "$exec_source.migrated"
test ! -e "$plugin_source.migrated"
test ! -e "$custom_state_dir/exec-approvals.json"
test "$(plugin_binding_approval_count "$state_database")" = "0"
test "$(sha256sum "$exec_source" | awk '{print $1}')" = "$exec_source_hash"
test "$(sha256sum "$plugin_source" | awk '{print $1}')" = "$plugin_source_hash"
test ! -e "$exec_source.migrated"
test ! -e "$plugin_source.migrated"
test ! -e "$custom_state_dir/exec-approvals.json"
test "$(plugin_binding_approval_count "$state_database")" = "0"
update_env=(-u OPENCLAW_UPDATE_IN_PROGRESS)
done
}
run_cross_state_approval_flow
+15 -25
View File
@@ -87,6 +87,19 @@ function extractTranscriptToolCalls(message) {
if (message.role !== "assistant") {
return calls;
}
const appendCall = (call, functionRecord) => {
const tool = readNonEmptyString(call.name) ?? readNonEmptyString(functionRecord?.name);
if (tool) {
calls.push({
id:
readNonEmptyString(call.id) ??
readNonEmptyString(call.toolCallId) ??
readNonEmptyString(call.toolUseId),
tool,
input: call.arguments ?? call.input ?? functionRecord?.arguments,
});
}
};
const content = message.content;
if (Array.isArray(content)) {
for (const block of content) {
@@ -97,18 +110,7 @@ function extractTranscriptToolCalls(message) {
if (type !== "tool_use" && type !== "toolcall" && type !== "tool_call") {
continue;
}
const tool = readNonEmptyString(block.name);
if (!tool) {
continue;
}
calls.push({
id:
readNonEmptyString(block.id) ??
readNonEmptyString(block.toolCallId) ??
readNonEmptyString(block.toolUseId),
tool,
input: block.arguments ?? block.input,
});
appendCall(block);
}
}
@@ -119,19 +121,7 @@ function extractTranscriptToolCalls(message) {
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:
readNonEmptyString(call.id) ??
readNonEmptyString(call.toolCallId) ??
readNonEmptyString(call.toolUseId),
tool,
input: call.arguments ?? call.input ?? functionRecord?.arguments,
});
appendCall(call, isRecord(call.function) ? call.function : undefined);
}
return calls;
}
+2 -33
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
// Raw launchers meet the repo's Node 24.16.0 minimum, where native TS stripping is enabled.
import { truncateUtf16Safe } from "../../../packages/normalization-core/src/utf16-slice.ts";
import { readPositiveIntEnv } from "./env-limits.mjs";
import { readBoundedRequestBody } from "./request-body.mjs";
const DEFAULT_REQUEST_MAX_BYTES = 4 * 1024 * 1024;
const DEFAULT_REQUEST_LOG_BODY_MAX_BYTES = 256 * 1024;
@@ -38,39 +39,7 @@ export function isRequestBodyTooLargeError(error) {
* @param {{ requestLogBodyMaxBytes?: number; requestMaxBytes: number }} [limits]
*/
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)));
}
});
});
return readBoundedRequestBody(req, limits.requestMaxBytes, requestBodyTooLargeError);
}
/**
+8 -14
View File
@@ -188,8 +188,11 @@ async function nodes() {
return (await cli(`nodes-${Date.now()}`, ["nodes", "status"])).nodes;
}
async function nodeRow(name) {
return (await nodes()).find((row) => row.displayName === name && row.connected && row.paired);
async function nodeRow(name, version) {
const row = (await nodes()).find(
(entry) => entry.displayName === name && entry.connected && entry.paired,
);
return version === undefined || row?.version === version ? row : null;
}
async function invoke(nodeId, action = "ping") {
@@ -508,10 +511,7 @@ try {
assert(JSON.stringify(completed).includes("NODE_UPDATE_HOLD_COMPLETED"));
const updated = await waitFor(
"automatic version activation",
async () => {
const row = await nodeRow(positive.name);
return row?.version === versions[0] ? row : null;
},
() => nodeRow(positive.name, versions[0]),
180_000,
() => readNodeUpdateFailure(positive.logPath),
);
@@ -677,10 +677,7 @@ try {
});
const defaultUpdated = await waitFor(
"default-plugin node activates while idle",
async () => {
const row = await nodeRow(defaultPlugins.name);
return row?.version === versions[0] ? row : null;
},
() => nodeRow(defaultPlugins.name, versions[0]),
900_000,
() => readNodeUpdateFailure(defaultPlugins.logPath),
);
@@ -763,10 +760,7 @@ try {
metadataReleased = true;
const sharedUpdated = await waitFor(
"shared-state node activates independently",
async () => {
const row = await nodeRow(sharedNode.name);
return row?.version === versions[0] ? row : null;
},
() => nodeRow(sharedNode.name, versions[0]),
900_000,
() => readNodeUpdateFailure(sharedNode.logPath),
);
@@ -215,6 +215,20 @@ try {
await rpc("skills.upload.commit", { uploadId });
return { source: "upload", agentId: "main", uploadId, slug, force };
}
async function waitForUploadPolicy(label, enabled) {
await wait(label, async () => {
try {
await rpc("skills.upload.begin", {
kind: "skill-archive",
slug: enabled ? "admission" : slug,
sizeBytes: 1,
});
return enabled;
} catch (error) {
return enabled ? false : error.message.includes("uploads are disabled");
}
});
}
await wait("paired workspace activation", async () => {
try {
await rpc("skills.status", { agentId: "main" });
@@ -247,14 +261,7 @@ try {
const pendingUpload = await stage("replacement", true);
config.gateway.uploads.enabled = false;
await setConfig();
await wait("uploads disabled", async () => {
try {
await rpc("skills.upload.begin", { kind: "skill-archive", slug, sizeBytes: 1 });
return false;
} catch (e) {
return e.message.includes("uploads are disabled");
}
});
await waitForUploadPolicy("uploads disabled", false);
await assert.rejects(rpc("skills.install", pendingUpload), /uploads are disabled/);
assert.deepEqual(snapshot(), before);
assert.deepEqual(metadata(), metadataBefore);
@@ -265,14 +272,7 @@ try {
});
config.gateway.uploads.enabled = true;
await setConfig();
await wait("uploads reenabled", async () => {
try {
await rpc("skills.upload.begin", { kind: "skill-archive", slug: "admission", sizeBytes: 1 });
return true;
} catch {
return false;
}
});
await waitForUploadPolicy("uploads reenabled", true);
fs.writeFileSync(barrier + ".release", "");
fs.writeFileSync(barrier + ".armed", "");
const pending = rpc("skills.install", pendingUpload).then(
@@ -291,14 +291,7 @@ try {
record("paused-after-backup", { targetAbsent: true, backupCount: backups.length });
config.gateway.uploads.enabled = false;
await setConfig();
await wait("revocation committed", async () => {
try {
await rpc("skills.upload.begin", { kind: "skill-archive", slug, sizeBytes: 1 });
return false;
} catch (e) {
return e.message.includes("uploads are disabled");
}
});
await waitForUploadPolicy("revocation committed", false);
fs.writeFileSync(barrier + ".release", "release");
const refused = await pending;
assert(JSON.stringify(refused).includes("uploads are disabled"), JSON.stringify(refused));
+31 -47
View File
@@ -64,26 +64,34 @@ function tableExists(db, tableName) {
);
}
function readCurrentSqlitePluginIndex(db) {
if (!tableExists(db, "config_machine_state")) {
return {};
function readBoundedIndexRow(db, table, keyColumn, key, jsonColumns, columns) {
if (!tableExists(db, table)) {
return undefined;
}
const source = `FROM ${table} WHERE ${keyColumn} = ?`;
const lengths = db
.prepare(
`
SELECT octet_length(value_json) AS value_json_bytes
FROM config_machine_state
WHERE state_key = ?
`,
`SELECT ${jsonColumns.map((column) => `octet_length(${column}) AS ${column}_bytes`).join(", ")} ${source}`,
)
.get(STATE_KEY);
.get(key);
if (!lengths) {
return {};
return undefined;
}
assertIndexJsonByteLength(lengths.value_json_bytes, "plugin index value_json");
const row = db
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
.get(STATE_KEY);
for (const column of jsonColumns) {
assertIndexJsonByteLength(lengths[`${column}_bytes`], `plugin index ${column}`);
}
return db.prepare(`SELECT ${columns} ${source}`).get(key);
}
function readCurrentSqlitePluginIndex(db) {
const row = readBoundedIndexRow(
db,
"config_machine_state",
"state_key",
STATE_KEY,
["value_json"],
"value_json",
);
if (!row) {
return {};
}
@@ -92,40 +100,16 @@ function readCurrentSqlitePluginIndex(db) {
}
function readPreV13SqlitePluginIndex(db) {
if (!tableExists(db, "installed_plugin_index")) {
return {};
}
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(PRE_V13_INDEX_KEY);
if (!lengths) {
return {};
}
assertIndexJsonByteLength(
lengths.install_records_json_bytes,
"plugin index install_records_json",
const row = readBoundedIndexRow(
db,
"installed_plugin_index",
"index_key",
PRE_V13_INDEX_KEY,
["install_records_json", "plugins_json", "diagnostics_json"],
`version, warning, host_contract_version, compat_registry_version,
migration_version, policy_hash, generated_at_ms, refresh_reason,
install_records_json, plugins_json, diagnostics_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(PRE_V13_INDEX_KEY);
if (!row) {
return {};
}
+16 -33
View File
@@ -587,7 +587,7 @@ function assertNpmPluginUpdateUnchanged() {
assertNpmPlugin();
}
function assertNpmPluginRemoved() {
function assertNpmPluginUninstalled(keepFiles) {
const installPath = fs.readFileSync(scratchFile("plugins-npm-install-path.txt"), "utf8").trim();
const packageParent = path.dirname(installPath);
const nodeModulesPath = path.basename(packageParent).startsWith("@")
@@ -599,42 +599,25 @@ function assertNpmPluginRemoved() {
.trim();
assertPluginRemoved({
pluginId: "demo-plugin-npm",
listFile: scratchFile("plugins-npm-uninstalled.json"),
listFile: scratchFile(`plugins-npm-${keepFiles ? "retained" : "uninstalled"}.json`),
// Historical --keep-files retains a discoverable directory; current releases
// instead persist an exact disabled marker.
allowLegacyRetainedListing: keepFiles && pluginUninstallMode === "legacy",
});
if (fs.existsSync(installPath)) {
throw new Error(`npm managed package still exists after uninstall: ${installPath}`);
for (const [label, file] of [
["package", installPath],
["dependency", dependencyPackagePath],
]) {
if (fs.existsSync(file) !== keepFiles) {
const outcome = keepFiles ? "was deleted by --keep-files" : "still exists after uninstall";
throw new Error(`npm managed ${label} ${outcome}: ${file}`);
}
}
if (fs.existsSync(dependencyPackagePath)) {
throw new Error(
`npm managed dependency still exists after uninstall: ${dependencyPackagePath}`,
);
}
if (pluginUninstallMode !== "legacy" && fs.existsSync(projectRoot)) {
if (!keepFiles && pluginUninstallMode !== "legacy" && fs.existsSync(projectRoot)) {
throw new Error(`npm managed project still exists after uninstall: ${projectRoot}`);
}
}
function assertNpmPluginRetained() {
const installPath = fs.readFileSync(scratchFile("plugins-npm-install-path.txt"), "utf8").trim();
const dependencyPackagePath = fs
.readFileSync(scratchFile("plugins-npm-dependency-path.txt"), "utf8")
.trim();
assertPluginRemoved({
pluginId: "demo-plugin-npm",
listFile: scratchFile("plugins-npm-retained.json"),
// Historical --keep-files removed config ownership but retained a
// discoverable plugin directory. Its list entry is expected until the
// subsequent reinstall; current releases persist an exact disabled marker.
allowLegacyRetainedListing: pluginUninstallMode === "legacy",
});
if (!fs.existsSync(installPath)) {
throw new Error(`npm managed package was deleted by --keep-files: ${installPath}`);
}
if (!fs.existsSync(dependencyPackagePath)) {
throw new Error(`npm managed dependency was deleted by --keep-files: ${dependencyPackagePath}`);
}
}
function assertNpmPluginReinstalled() {
if (pluginUninstallMode === "legacy") {
return;
@@ -878,9 +861,9 @@ const commands = {
"plugin-file-removed": () => assertLocalPluginRemoved(localPluginScenarios.file),
"plugin-npm": assertNpmPlugin,
"plugin-npm-update": assertNpmPluginUpdateUnchanged,
"plugin-npm-retained": assertNpmPluginRetained,
"plugin-npm-retained": () => assertNpmPluginUninstalled(true),
"plugin-npm-reinstalled": assertNpmPluginReinstalled,
"plugin-npm-removed": assertNpmPluginRemoved,
"plugin-npm-removed": () => assertNpmPluginUninstalled(false),
"invalid-openclaw-extensions": assertInvalidOpenClawExtensionsRejected,
"bundle-disabled": assertClaudeBundleDisabled,
"bundle-inspect": assertClaudeBundleInspect,
+5 -15
View File
@@ -17,21 +17,11 @@ run_plugins_clawhub_scenario() {
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
openclaw_plugins_wait_fixture_port "$server_pid" "$server_port_file" "$server_log" "ClawHub fixture server"
local readiness_status=$?
[ "$readiness_status" -eq 0 ] || return "$readiness_status"
export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")"
return 0
}
local clawhub_default_plugin_spec="clawhub:@openclaw/plugin-e2e-fixture"
+33 -46
View File
@@ -141,41 +141,20 @@ openclaw_plugins_fixture_exit_trap() {
exit "$status"
}
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"
pack_fixture_archive() {
local fixture="$1"
local pack_dir="$2"
local output_tgz="$3"
shift 3
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"
node scripts/e2e/lib/fixture.mjs "$fixture" "$pack_dir/package" "$@"
tar -czf "$output_tgz" -C "$pack_dir" package
}
# Frozen corrupt-update scenarios are mounted onto the current harness.
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"
node scripts/e2e/lib/fixture.mjs plugin "$pack_dir/package" "$id" "$version" "$method" "$name"
tar -czf "$output_tgz" -C "$pack_dir" package
pack_fixture_archive plugin "$1" "$2" "$3" "$4" "$5" "$6"
}
pack_fixture_plugin_with_invalid_extension_entry() {
@@ -199,6 +178,24 @@ NODE
tar -czf "$output_tgz" -C "$pack_dir" package
}
openclaw_plugins_wait_fixture_port() {
local server_pid="$1" server_port_file="$2" server_log="$3" label="$4"
for _ in $(seq 1 100); do
if [[ -s "$server_port_file" ]]; then
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 $label." >&2
return 1
}
start_npm_fixture_registry() {
local package_name="$1"
local version="$2"
@@ -217,21 +214,11 @@ start_npm_fixture_registry() {
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")"
# Override both spellings so an inherited prerelease registry cannot win in npm.
export npm_config_registry="$NPM_CONFIG_REGISTRY"
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
openclaw_plugins_wait_fixture_port "$server_pid" "$server_port_file" "$server_log" "npm fixture registry"
local readiness_status=$?
[ "$readiness_status" -eq 0 ] || return "$readiness_status"
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$server_port_file")"
# Override both spellings so an inherited prerelease registry cannot win in npm.
export npm_config_registry="$NPM_CONFIG_REGISTRY"
return 0
}
+3 -3
View File
@@ -134,7 +134,7 @@ 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"
pack_fixture_archive plugin "$pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" demo-plugin-tgz 0.0.1 demo.tgz "Demo Plugin TGZ"
run_plugins_fixture_logged install-tgz plugins install "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2.json" plugins list --json
@@ -196,8 +196,8 @@ npm_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-npm-pack.XX
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_archive plugin-cli-registry-dep "$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_fixture_archive 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"
@@ -148,14 +148,13 @@ openclaw_prepublish_plugin_registry_run_mounted() (
local registry_root registry_pid="" command_pid=""
registry_root="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-prepublish-registry.XXXXXX")"
cleanup_registry_command() {
if [ -n "$command_pid" ]; then
kill "$command_pid" >/dev/null 2>&1 || true
wait "$command_pid" >/dev/null 2>&1 || true
fi
if [ -n "$registry_pid" ]; then
kill "$registry_pid" >/dev/null 2>&1 || true
wait "$registry_pid" >/dev/null 2>&1 || true
fi
local pid
for pid in "$command_pid" "$registry_pid"; do
if [ -n "$pid" ]; then
kill "$pid" >/dev/null 2>&1 || true
wait "$pid" >/dev/null 2>&1 || true
fi
done
rm -rf "$registry_root"
}
trap cleanup_registry_command EXIT
@@ -56,17 +56,6 @@ dump_debug_logs() {
}
openclaw_e2e_enable_failure_diagnostics
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)"
@@ -89,19 +78,7 @@ openclaw_e2e_enable_openclaw_cli_timeout
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
openclaw_release_onboard "$PORT" openclaw >"$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
@@ -30,17 +30,7 @@ openclaw_e2e_install_package /tmp/openclaw-release-plugin-marketplace-install.lo
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
openclaw_release_onboard "" openclaw >/tmp/openclaw-release-plugin-marketplace-onboard.log 2>&1
marketplace_root="$HOME/.claude/plugins/marketplaces/release-fixture-marketplace"
marketplace_assertions="scripts/e2e/lib/release-plugin-marketplace/lifecycle-assertions.mjs"
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
openclaw_release_prepare_docker() {
local lane="$1"
run_log=""
trap 'docker_e2e_cleanup_package_run "${PACKAGE_TGZ:-}" "${run_log:-}"' EXIT
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz "$lane" "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" "$lane" "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 "$lane" empty)"
run_log="$(docker_e2e_run_log "$lane")"
}
openclaw_release_run_docker() {
if ! docker_e2e_run_with_harness "$@" >"$run_log" 2>&1; then
docker_e2e_print_log "$run_log"
exit 1
fi
}
@@ -18,3 +18,50 @@ openclaw_release_scenario_logs() {
shift 2
done
}
openclaw_release_onboard() {
local port="$1"
shift
set -- "$@" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--auth-choice skip
if [ -n "$port" ]; then
set -- "$@" --gateway-port "$port" --gateway-bind loopback
fi
"$@" \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health
}
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=""
}
start_clickclack_fixture() {
local token="$1"
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
CLICKCLACK_FIXTURE_TOKEN="$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
}
@@ -100,12 +100,6 @@ fs.writeFileSync(process.argv[2], JSON.stringify({
NODE
}
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
@@ -120,32 +114,9 @@ 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_TEST_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
start_clickclack_fixture "$CLICKCLACK_TEST_TOKEN"
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
openclaw_release_onboard "$PORT" openclaw_e2e_run_command node "$baseline_entry" >"$ONBOARD_LOG" 2>&1
record_baseline_setup onboard
plugin_dir="$(mktemp -d "$scenario_tmp/plugin.XXXXXX")"
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
import { readBoundedRequestBody } from "../request-body.mjs";
const port = readTcpPortEnv("CLICKCLACK_FIXTURE_PORT", 44181);
const requestMaxBytes = readPositiveIntEnv("CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", 4 * 1024 * 1024);
@@ -82,49 +83,17 @@ function checkAuth(req, res) {
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(
Object.assign(
new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`),
{ code: "ETOOBIG" },
),
);
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)));
}
});
});
async function readBody(req) {
const body = await readBoundedRequestBody(req, requestMaxBytes, (limit) =>
Object.assign(new Error(`ClickClack fixture request body exceeded ${limit} bytes`), {
code: "ETOOBIG",
}),
);
try {
return body ? JSON.parse(body) : {};
} catch {
return {};
}
}
function handleRequestError(res, error) {
@@ -65,17 +65,6 @@ dump_debug_logs() {
}
openclaw_e2e_enable_failure_diagnostics
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() {
node --input-type=module - "$@" <<'NODE'
import { writeCliPlugin } from "./scripts/e2e/lib/fixtures/plugins.mjs";
@@ -92,33 +81,10 @@ 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
start_clickclack_fixture "$CLICKCLACK_BOT_TOKEN"
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
openclaw_release_onboard "$PORT" openclaw >"$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"
+40
View File
@@ -0,0 +1,40 @@
/**
* @param {NodeJS.ReadableStream} request
* @param {number} maxBytes
* @param {(limit: number) => Error} tooLargeError
* @returns {Promise<string>}
*/
export function readBoundedRequestBody(request, maxBytes, tooLargeError) {
return new Promise((resolve, reject) => {
let body = "";
let bytes = 0;
let settled = false;
request.setEncoding("utf8");
request.on("data", (chunk) => {
if (settled) {
return;
}
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > maxBytes) {
settled = true;
body = "";
request.resume();
reject(tooLargeError(maxBytes));
return;
}
body += chunk;
});
request.on("end", () => {
if (!settled) {
settled = true;
resolve(body);
}
});
request.on("error", (error) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
}
+10 -14
View File
@@ -130,6 +130,14 @@ export async function countSessionLogMentions(params: {
}): Promise<Record<string, number>> {
const limits = params.limits ?? readSessionLogMentionLimits();
const counts = Object.fromEntries(Object.keys(params.needles).map((key) => [key, 0]));
const countRecord = (text: string) => {
const scanText = sessionLogScanText(text);
if (scanText !== null) {
for (const [key, needle] of Object.entries(params.needles)) {
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
}
}
};
let files: string[];
try {
files = await fs.readdir(params.sessionsDir);
@@ -166,13 +174,7 @@ export async function countSessionLogMentions(params: {
limit: limits.fileMaxBytes,
});
for (const line of raw.split(/\r?\n/u)) {
const scanText = sessionLogScanText(line);
if (scanText === null) {
continue;
}
for (const [key, needle] of Object.entries(params.needles)) {
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
}
countRecord(line);
}
}
if (path.basename(params.sessionsDir) !== "sessions") {
@@ -210,13 +212,7 @@ export async function countSessionLogMentions(params: {
label: "total",
limit: limits.totalMaxBytes,
});
const scanText = sessionLogScanText(eventJson);
if (scanText === null) {
continue;
}
for (const [key, needle] of Object.entries(params.needles)) {
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
}
countRecord(eventJson);
}
return counts;
} catch (error) {
@@ -443,22 +443,14 @@ function main() {
);
return;
}
if (
(mode === "first-hop-tarball" ||
mode === "negative-tarball" ||
mode === "future-tarball" ||
mode === "unsupported-admission-tarball" ||
mode === "future-runtime-tarball") &&
packageRoot &&
outputTarball
) {
const pack = {
"first-hop-tarball": packFirstHopUpdateFixture,
"negative-tarball": packNegativeUpdateFixture,
"future-tarball": packFutureUpdateFixture,
"unsupported-admission-tarball": packUnsupportedAdmissionFixture,
"future-runtime-tarball": packFutureRuntimeFixture,
}[mode];
const packers = {
"first-hop-tarball": packFirstHopUpdateFixture,
"negative-tarball": packNegativeUpdateFixture,
"future-tarball": packFutureUpdateFixture,
"unsupported-admission-tarball": packUnsupportedAdmissionFixture,
"future-runtime-tarball": packFutureRuntimeFixture,
};
if (Object.hasOwn(packers, mode) && packageRoot && outputTarball) {
const fixtureArg =
mode === "negative-tarball"
? (sequence ?? "")
@@ -466,7 +458,7 @@ function main() {
? 0
: Number(sequence);
process.stdout.write(
`${JSON.stringify(pack(packageRoot, outputTarball, fixtureArg), null, 2)}\n`,
`${JSON.stringify(packers[mode](packageRoot, outputTarball, fixtureArg), null, 2)}\n`,
);
return;
}
+34 -64
View File
@@ -3,7 +3,6 @@ import { execFileSync, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import {
readSqliteTranscriptPayload,
sqliteTranscriptPayloadColumns,
@@ -135,13 +134,16 @@ function isPathInsideManagedNpmProjectPackageRoot(params) {
function seedLegacySessionMetadata(stateDir) {
const legacySessionsDir = path.join(stateDir, "agents", "main", "sessions");
const baseUpdatedAt = Date.now() - 24 * 60 * 60 * 1000;
writeJson(path.join(legacySessionsDir, "sessions.json"), {
const entry = (sessionId, offset) => ({
sessionId,
sessionFile: path.join(legacySessionsDir, `${sessionId}.jsonl`),
modelProvider: "openai",
model: "gpt-5.5",
updatedAt: baseUpdatedAt + offset,
});
const sessions = {
"agent:main:main": {
sessionId: LEGACY_SESSION_MAIN_ID,
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
modelProvider: "openai",
model: "gpt-5.5",
updatedAt: baseUpdatedAt,
...entry(LEGACY_SESSION_MAIN_ID, 0),
skillsSnapshot: {
prompt: "legacy prompt survives as metadata",
resolvedSkills: [
@@ -152,33 +154,17 @@ function seedLegacySessionMetadata(stateDir) {
],
},
},
"agent:main:+15551234567": {
sessionId: LEGACY_SESSION_DIRECT_ID,
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
modelProvider: "openai",
model: "gpt-5.5",
updatedAt: baseUpdatedAt + 100,
},
"agent:main:+15551234567": entry(LEGACY_SESSION_DIRECT_ID, 100),
"agent:main:slack:channel:cupgrade": {
sessionId: LEGACY_SESSION_GROUP_ID,
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
modelProvider: "openai",
model: "gpt-5.5",
updatedAt: baseUpdatedAt + 200,
...entry(LEGACY_SESSION_GROUP_ID, 200),
lastChannel: "slack",
lastTo: "CUPGRADE",
...(getScenario() === "acpx-openclaw-tools-bridge" ? { acp: LEGACY_ACP_META } : {}),
},
});
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`,
);
};
writeJson(path.join(legacySessionsDir, "sessions.json"), sessions);
for (const { sessionId, sessionFile } of Object.values(sessions)) {
write(sessionFile, `${JSON.stringify({ type: "session", id: sessionId })}\n`);
}
}
@@ -700,12 +686,9 @@ function assertLegacyOperatorPendingDelivery([updateJson, updateErr]) {
"published update stopped on legacy pending-delivery state",
);
}
const db = new DatabaseSync(path.join(stateDir, "agents/ops/agent/openclaw-agent.sqlite"), {
readOnly: true,
});
let liveRow;
let recoveredInputs = [];
try {
readDatabase(path.join(stateDir, "agents/ops/agent/openclaw-agent.sqlite"), (db) => {
liveRow = readLegacyOperatorPendingDelivery(db);
if (
liveRow &&
@@ -718,9 +701,7 @@ function assertLegacyOperatorPendingDelivery([updateJson, updateErr]) {
.all("legacy-pending-delivery")
.map((event) => JSON.parse(readSqliteTranscriptPayload(event)).message);
}
} finally {
db.close();
}
});
let row = liveRow;
if (process.env.OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE === "auto-auth") {
const witness = readJson(
@@ -1232,9 +1213,7 @@ function assertSessionMetadataMigrated(stateDir, stage) {
function readMigratedSessionStore(stateDir, targetStorePath) {
const dbPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
if (fs.existsSync(dbPath)) {
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const snapshot = readDatabase(dbPath, (db) => {
const tables = new Set(
db
.prepare(
@@ -1248,32 +1227,21 @@ function readMigratedSessionStore(stateDir, targetStorePath) {
);
// SQLite is authoritative once it owns a supported session table. A stale
// sessions.json must not hide missing, malformed, or unreadable database state.
const source = tables.has("session_nodes")
? "session_nodes"
: tables.has("session_entries")
? "session_entries"
: tables.has("cache_entries")
? "cache_entries"
: null;
const source = ["session_nodes", "session_entries", "cache_entries"].find((table) =>
tables.has(table),
);
if (source) {
const rows =
const query =
source === "session_nodes"
? db
.prepare(
`SELECT session_key AS key, current_session_id AS session_id, entry_json AS value_json
FROM session_nodes`,
)
.all()
? `SELECT session_key AS key, current_session_id AS session_id, entry_json AS value_json
FROM session_nodes`
: source === "session_entries"
? db
.prepare(
`SELECT session_key AS key, session_id, entry_json AS value_json
FROM session_entries`,
)
.all()
: db
.prepare("SELECT key, value_json FROM cache_entries WHERE scope = ?")
.all("session_entries");
? `SELECT session_key AS key, session_id, entry_json AS value_json
FROM session_entries`
: "SELECT key, value_json FROM cache_entries WHERE scope = ?";
const rows = db
.prepare(query)
.all(...(source === "cache_entries" ? ["session_entries"] : []));
const store = {};
for (const row of rows) {
if (typeof row?.key !== "string" || typeof row?.value_json !== "string") {
@@ -1304,8 +1272,10 @@ function readMigratedSessionStore(stateDir, targetStorePath) {
}
return { source, store };
}
} finally {
db?.close();
return undefined;
});
if (snapshot) {
return snapshot;
}
}
@@ -9,6 +9,7 @@ import {
sqliteTranscriptPayloadColumns,
transcriptIdentity,
} from "../../../lib/sqlite-transcript-payload.mjs";
import { hashFile as hashObservedFile } from "./fixture-files.mjs";
import { readDatabase } from "./observations.mjs";
const RESTORED_TRANSCRIPT = "agents/main/sessions/upgrade-restored-index-history.jsonl";
@@ -20,20 +21,7 @@ const quoteIdentifier = (value) => `"${value.replaceAll('"', '""')}"`;
const compareSessionKeys = (left, right) =>
left.key < right.key ? -1 : left.key > right.key ? 1 : 0;
function hashFile(file) {
const hash = createHash("sha256");
const descriptor = fs.openSync(file, "r");
try {
const buffer = Buffer.alloc(1024 * 1024);
let size;
while ((size = fs.readSync(descriptor, buffer, 0, buffer.length, null)) > 0) {
hash.update(buffer.subarray(0, size));
}
return hash.digest("hex");
} finally {
fs.closeSync(descriptor);
}
}
const hashFile = (file) => hashObservedFile(file, 1024 * 1024);
function containedPath(root, relative) {
assert(typeof relative === "string" && relative.length > 0, "missing inventory path");
@@ -389,21 +389,15 @@ export function resolveUpgradeSurvivorOpenClawCommand(
params: UpgradeSurvivorCommandParams = {},
) {
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,
};
}
const windows = platform === "win32";
return {
command: "openclaw",
args: argv,
command: windows
? (params.comSpec ?? resolveWindowsCmdExePath(params.env ?? process.env))
: "openclaw",
args: windows ? ["/d", "/s", "/c", buildCmdExeCommandLine("openclaw.cmd", argv)] : argv,
commandLabel: ["openclaw", ...argv].join(" "),
shell: false,
...(windows ? { windowsVerbatimArguments: true } : {}),
};
}
@@ -8,7 +8,12 @@ import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { isMainThread } from "node:worker_threads";
import { readPositiveIntEnv } from "../env-limits.mjs";
import { inspectCronBackups, readDatabase } from "./observations.mjs";
import {
findInstalledPackageRoot,
inspectCronBackups,
readDatabase,
recordProcessExitSnapshot,
} from "./observations.mjs";
const fixtureName = "cron-owner-fixture.json";
const prefix = "owner-proof-";
@@ -94,22 +99,21 @@ function snapshot(fixture) {
function installedProcessIdentity(entrypoint) {
const file = fs.realpathSync(entrypoint);
for (let root = path.dirname(file), depth = 0; depth < 4; root = path.dirname(root), depth++) {
const root = findInstalledPackageRoot(path.dirname(file), 4);
if (root) {
const manifestPath = path.join(root, "package.json");
if (fs.existsSync(manifestPath) && readJson(manifestPath).name === "openclaw") {
const manifest = fs.readFileSync(manifestPath);
const build = fs.readFileSync(path.join(root, "dist/build-info.json"));
const info = JSON.parse(build);
assert.equal(info.version, JSON.parse(manifest).version);
return {
version: info.version,
commit: info.commit,
manifestSha256: hash(manifest),
buildInfoSha256: hash(build),
entrypoint: path.relative(root, file),
entrypointSha256: hash(fs.readFileSync(file)),
};
}
const manifest = fs.readFileSync(manifestPath);
const build = fs.readFileSync(path.join(root, "dist/build-info.json"));
const info = JSON.parse(build);
assert.equal(info.version, JSON.parse(manifest).version);
return {
version: info.version,
commit: info.commit,
manifestSha256: hash(manifest),
buildInfoSha256: hash(build),
entrypoint: path.relative(root, file),
entrypointSha256: hash(fs.readFileSync(file)),
};
}
throw new Error("Observed CLI is outside an OpenClaw package");
}
@@ -145,15 +149,7 @@ function observeProcess() {
receipt.observationError = String(error);
}
const file = path.join(artifacts, `cron-owner-${role}-${process.pid}.json`);
writeJson(file, receipt);
process.once("exit", (exitCode) => {
try {
receipt.after = snapshot(fixture);
} catch (error) {
receipt.observationError = String(error);
}
writeJson(file, { ...receipt, exitCode });
});
recordProcessExitSnapshot(file, receipt, () => snapshot(fixture));
}
function gateway(artifacts, name, method, params) {
@@ -667,19 +663,14 @@ function assertRuntime(p) {
assert.deepEqual(
receipts.map((row) => Object.assign({}, row)),
[
{
job_id: `${prefix}explicit`,
agent_id: "research",
status: "ok",
request_run_id: runs.find((run) => run.jobId === `${prefix}explicit`).runId,
},
{
job_id: `${prefix}historical`,
agent_id: "ops",
status: "ok",
request_run_id: runs.find((run) => run.jobId === `${prefix}historical`).runId,
},
],
["explicit", "research"],
["historical", "ops"],
].map(([name, agentId]) => ({
job_id: `${prefix}${name}`,
agent_id: agentId,
status: "ok",
request_run_id: runs.find((run) => run.jobId === `${prefix}${name}`).runId,
})),
);
const proofPath = path.join(p.artifacts, "cron-owner-proof.json");
const proof = {
@@ -646,6 +646,22 @@ function migrationProjection(section, value, sanitize = (text) => text) {
throw new Error();
}
function projectMigrationSections(read, sanitize) {
return Object.fromEntries(
["doctor", "sessions", "archives", "sibling"].map((section) => {
try {
return [
section,
{ availability: "captured", ...migrationProjection(section, read(section), sanitize) },
];
} catch {
omissions[`migration-${section}`] ??= reasons[3];
return [section, { availability: "unavailable" }];
}
}),
);
}
// A native read-only open can create missing WAL sidecars or require journal recovery.
// This bootstrap observer never copies operator databases or imports a migrating runtime reader.
function assertNativeSqliteObservationSafe(handles, label) {
@@ -847,16 +863,7 @@ function captureMigrationEvidence(stateRoot, artifactRoot, observationRoot) {
};
},
};
return Object.fromEntries(
Object.entries(sources).map(([section, read]) => {
try {
return [section, { availability: "captured", ...migrationProjection(section, read()) }];
} catch {
omissions[`migration-${section}`] ??= reasons[3];
return [section, { availability: "unavailable" }];
}
}),
);
return projectMigrationSections((section) => sources[section]());
}
function sessionMigrationProjection(raw, kind, runId, sanitize = (text) => text) {
@@ -1136,13 +1143,17 @@ function armPostCoreCapture() {
}
}
async function pluginIdentities(stateRoot, artifactRoot) {
const unavailable = {
function unknownPluginIdentity() {
return {
availability: "unknown",
evidence: "persisted index + current bytes; not observed loaded modules",
reader: "SQLite or historical fallback; missing/error is not absence",
plugins: [],
};
}
async function pluginIdentities(stateRoot, artifactRoot) {
const unavailable = unknownPluginIdentity();
const handles = [];
try {
// The existing reader opens SQLite read-only. Fence every file it may read;
@@ -2074,12 +2085,7 @@ export function publishDiagnostics(
} catch {
// Do not promote a partial or unbound receipt into a reported Doctor outcome.
}
report.pluginIdentity = {
availability: "unknown",
evidence: "persisted index + current bytes; not observed loaded modules",
reader: "SQLite or historical fallback; missing/error is not absence",
plugins: [],
};
report.pluginIdentity = unknownPluginIdentity();
if (snapshot.pluginIdentity?.availability === "observed") {
try {
const plugins = boundedList(snapshot.pluginIdentity.plugins).map((entry) => {
@@ -2120,23 +2126,13 @@ export function publishDiagnostics(
omissions["plugin identity"] = reasons[3];
}
}
report.migration = Object.fromEntries(
["doctor", "sessions", "archives", "sibling"].map((section) => {
try {
const value = snapshot.migration?.[section];
if (value?.availability !== "captured") {
throw new Error();
}
return [
section,
{ availability: "captured", ...migrationProjection(section, value, sanitize) },
];
} catch {
omissions[`migration-${section}`] ??= reasons[3];
return [section, { availability: "unavailable" }];
}
}),
);
report.migration = projectMigrationSections((section) => {
const value = snapshot.migration?.[section];
if (value?.availability !== "captured") {
throw new Error();
}
return value;
}, sanitize);
writeReport(artifactRoot, destination, "failure.json", report, publicLimit);
if (Object.keys(omissions).length) {
process.stderr.write(
@@ -8,7 +8,12 @@ import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { isMainThread } from "node:worker_threads";
import { readPositiveIntEnv } from "../env-limits.mjs";
import { inspectCronBackups, readDatabase } from "./observations.mjs";
import {
findInstalledPackageRoot,
inspectCronBackups,
readDatabase,
recordProcessExitSnapshot,
} from "./observations.mjs";
import {
assertWorkerCellPackageIdentity,
readWorkerCellPackageIdentity,
@@ -386,31 +391,17 @@ function observeProcess() {
fixture.databasePath,
path.join(process.env.OPENCLAW_STATE_DIR, "state/openclaw.sqlite"),
);
let root = path.dirname(fs.realpathSync(process.argv[1]));
for (let depth = 0; depth < 3; depth++, root = path.dirname(root)) {
if (
fs.existsSync(path.join(root, "package.json")) &&
readJson(path.join(root, "package.json")).name === "openclaw"
) {
receipt.identity = installedIdentity(root);
receipt.entrypoint = path.relative(root, fs.realpathSync(process.argv[1]));
break;
}
const root = findInstalledPackageRoot(path.dirname(fs.realpathSync(process.argv[1])), 3);
if (root) {
receipt.identity = installedIdentity(root);
receipt.entrypoint = path.relative(root, fs.realpathSync(process.argv[1]));
}
receipt.before = snapshot(fixture);
} catch (error) {
receipt.observationError = String(error);
}
const file = path.join(observations, `dreaming-cron-${role}-${process.pid}.json`);
writeJson(file, receipt);
process.once("exit", (exitCode) => {
try {
receipt.after = snapshot(fixture);
} catch (error) {
receipt.observationError = String(error);
}
writeJson(file, { ...receipt, exitCode });
});
recordProcessExitSnapshot(file, receipt, () => snapshot(fixture));
}
function assertUpdated(artifacts, observations, packageRoot, candidateTarball) {
@@ -1,6 +1,22 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
export function hashFile(filename, chunkBytes) {
const hash = createHash("sha256");
const descriptor = fs.openSync(filename, "r");
try {
const buffer = Buffer.alloc(chunkBytes);
let size;
while ((size = fs.readSync(descriptor, buffer, 0, buffer.length, null)) > 0) {
hash.update(buffer.subarray(0, size));
}
return hash.digest("hex");
} finally {
fs.closeSync(descriptor);
}
}
export function childOf(root, file) {
const relative = path.relative(root, file);
return (
@@ -6,7 +6,11 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import { isMainThread } from "node:worker_threads";
import { readDatabase } from "./observations.mjs";
import {
findInstalledPackageRoot,
readDatabase,
recordProcessExitSnapshot,
} from "./observations.mjs";
const MIGRATION = "state:cron-run-logs-to-task-runs:v1";
const FIXTURE_NAME = "legacy-operator-cron-history.json";
@@ -193,15 +197,9 @@ function observeUpdateProcess() {
fixture.databasePath,
path.join(process.env.OPENCLAW_STATE_DIR, "state/openclaw.sqlite"),
);
let root = path.dirname(fs.realpathSync(process.argv[1]));
for (let depth = 0; depth < 3; depth++, root = path.dirname(root)) {
if (
fs.existsSync(path.join(root, "package.json")) &&
readJson(path.join(root, "package.json")).name === "openclaw"
) {
receipt.identity = installedIdentity(root);
break;
}
const root = findInstalledPackageRoot(path.dirname(fs.realpathSync(process.argv[1])), 3);
if (root) {
receipt.identity = installedIdentity(root);
}
receipt.updateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS === "1";
receipt.before = snapshot(fixture);
@@ -209,15 +207,7 @@ function observeUpdateProcess() {
receipt.observationError = String(error);
}
const file = path.join(observations, `cron-history-${command}-${process.pid}.json`);
writeJson(file, receipt);
process.once("exit", (exitCode) => {
try {
receipt.after = snapshot(fixture);
} catch (error) {
receipt.observationError = String(error);
}
writeJson(file, { ...receipt, exitCode });
});
recordProcessExitSnapshot(file, receipt, () => snapshot(fixture));
}
function assertProcessReceipt(observations, witness, role) {
@@ -885,16 +885,18 @@ async function verifyReconnect(params: {
}): Promise<void> {
const WebSocket = loadWebSocket(params.packageRoot);
await assertMissingPassword({ WebSocket, credentials: params.credentials });
const node = await connect({
WebSocket,
url: params.credentials.url,
client: params.credentials.client,
mode: "node",
role: "node",
scopes: params.credentials.node.scopes,
auth: { token: params.credentials.node.token },
identity: params.credentials.identity,
});
const connectRole = (role: ConnectRole) =>
connect({
WebSocket,
url: params.credentials.url,
client: params.credentials.client,
mode: role === "node" ? "node" : "ui",
role,
scopes: params.credentials[role].scopes,
auth: { token: params.credentials[role].token },
identity: params.credentials.identity,
});
const node = await connectRole("node");
let operator: ConnectResult | undefined;
try {
const nodeTransition = persistHelloCredential({
@@ -903,16 +905,7 @@ async function verifyReconnect(params: {
hello: node.hello,
});
writePrivateJson(params.credentialsFile, params.credentials);
operator = await connect({
WebSocket,
url: params.credentials.url,
client: params.credentials.client,
mode: "ui",
role: "operator",
scopes: params.credentials.operator.scopes,
auth: { token: params.credentials.operator.token },
identity: params.credentials.identity,
});
operator = await connectRole("operator");
const operatorTransition = persistHelloCredential({
credentials: params.credentials,
role: "operator",
@@ -146,7 +146,7 @@ function startThread(params, requestedId) {
injectedItemsByThread.set(id, []);
threadConfigurations.set(id, { params: structuredClone(params), response });
loaded.add(id);
return { ...threadConfigurations.get(id).response, thread: thread(id) };
return response;
}
function resumeChangesConfiguration(params, configured) {
@@ -259,17 +259,16 @@ function completeTurn(socket, phase, threadId, turn, text, status = "completed")
notify(socket, phase, "thread/status/changed", { threadId, status: { type: "idle" } });
}
function collabItem(socket, phase, threadId, turn, item, method) {
if (method === "item/completed") {
turn.items.push(item);
}
notify(socket, phase, method, { threadId, turnId: turn.id, item });
}
function runPhase(socket, phase, threadId, turn, input) {
if (turn.status !== "inProgress") {
return;
}
const collabItem = (item, method = "item/completed") => {
if (method === "item/completed") {
turn.items.push(item);
}
notify(socket, phase, method, { threadId, turnId: turn.id, item });
};
if (
phase === "seed-assignments" &&
threadId === parentId &&
@@ -287,23 +286,16 @@ function runPhase(socket, phase, threadId, turn, input) {
]) {
const child = startThread({ cwd: thread(parentId).cwd }, id).thread;
notify(socket, phase, "thread/started", { thread: child });
collabItem(
socket,
phase,
threadId,
turn,
{
id: `spawn-${id}`,
type: "collabAgentToolCall",
tool: "spawnAgent",
status: "completed",
senderThreadId: parentId,
receiverThreadIds: [id],
prompt: "Synthetic native upgrade assignment",
agentsStates: { [id]: { status: "running", message: null } },
},
"item/completed",
);
collabItem({
id: `spawn-${id}`,
type: "collabAgentToolCall",
tool: "spawnAgent",
status: "completed",
senderThreadId: parentId,
receiverThreadIds: [id],
prompt: "Synthetic native upgrade assignment",
agentsStates: { [id]: { status: "running", message: null } },
});
const childTurn = startTurn(socket, phase, id, turnId);
if (id === completeId) {
completeTurn(socket, phase, id, childTurn, "NATIVE_UPGRADE_PENDING_RESULT");
@@ -332,19 +324,12 @@ function runPhase(socket, phase, threadId, turn, input) {
agentsStates: {},
};
closeTurns.set(socket, { phase, threadId, turn });
collabItem(socket, phase, threadId, turn, item, "item/started");
collabItem(
socket,
phase,
threadId,
turn,
{
...item,
status: "completed",
agentsStates: { [runningId]: { status: "running", message: null } },
},
"item/completed",
);
collabItem(item, "item/started");
collabItem({
...item,
status: "completed",
agentsStates: { [runningId]: { status: "running", message: null } },
});
return;
}
completeTurn(
@@ -26,3 +26,31 @@ export function inspectCronBackups(databasePath) {
.digest("hex"),
}));
}
export function findInstalledPackageRoot(startDirectory, maxDepth) {
let directory = startDirectory;
for (let depth = 0; depth < maxDepth; depth++, directory = path.dirname(directory)) {
const manifest = path.join(directory, "package.json");
if (
fs.existsSync(manifest) &&
JSON.parse(fs.readFileSync(manifest, "utf8")).name === "openclaw"
) {
return directory;
}
}
return undefined;
}
export function recordProcessExitSnapshot(file, receipt, after) {
const write = (value) =>
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
write(receipt);
process.once("exit", (exitCode) => {
try {
receipt.after = after();
} catch (error) {
receipt.observationError = String(error);
}
write({ ...receipt, exitCode });
});
}
@@ -14,14 +14,13 @@ function installPackageActivationFault({ journal, evidence, cut, terminate }) {
// oxlint-disable-next-line typescript/unbound-method -- Capture for interception/restoration; every call supplies the intercepted database through .call.
const exec = DatabaseSync.prototype.exec;
const armed = new WeakSet();
const matched = (row) => row?.phase === "publication-complete";
const observeCommit = (db) => {
if (!armed.has(db) || db.location() !== journal) {
return;
}
armed.delete(db);
const row = prepare.call(db, "SELECT * FROM package_activation WHERE slot = 1").get();
if (!matched(row) || fs.existsSync(evidence)) {
if (row?.phase !== "publication-complete" || fs.existsSync(evidence)) {
return;
}
fs.writeFileSync(evidence, JSON.stringify({ cut, pid: process.pid, journal, row }));
@@ -55,10 +54,6 @@ function installPackageActivationFault({ journal, evidence, cut, terminate }) {
}
return result;
};
return () => {
DatabaseSync.prototype.prepare = prepare;
DatabaseSync.prototype.exec = exec;
};
}
const specPath = process.env.OPENCLAW_SURVIVOR_PACKAGE_FAULT;
@@ -3,8 +3,8 @@ import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import { readDatabase } from "./observations.mjs";
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const identity = (file) => {
@@ -191,13 +191,9 @@ function main([command, artifacts, ...args]) {
if (fault.cut === "verification") {
assert.equal(read(`${fault.evidence}.doctor`).exitCode, 1);
}
const db = new DatabaseSync(fault.journal, { readOnly: true });
let row;
try {
row = db.prepare("SELECT * FROM package_activation WHERE slot = 1").get();
} finally {
db.close();
}
const row = readDatabase(fault.journal, (db) =>
db.prepare("SELECT * FROM package_activation WHERE slot = 1").get(),
);
assert.equal(
row.phase,
"publication-complete",
@@ -252,17 +248,14 @@ function main([command, artifacts, ...args]) {
);
assertCandidate();
const row = read(fault.evidence).row;
const db = new DatabaseSync(fault.journal, { readOnly: true });
try {
readDatabase(fault.journal, (db) => {
const actual = db.prepare("SELECT * FROM package_activation WHERE slot = 1").get();
assert.deepEqual(
{ ...actual },
row,
"the rejected old updater must not alter its recovery obligation",
);
} finally {
db.close();
}
});
value.status = "passed";
value.targetSelector = "unmodified-released-9.8";
value.firstHop = {
@@ -264,6 +264,21 @@ process.exitCode = await runCancelableCommand(async (signal) => {
const port = await freePort();
const token = "published-driver-synthetic-token";
const probe = async (name, version) => {
await run(name, "openclaw", [
"gateway",
"probe",
"--url",
`ws://127.0.0.1:${port}`,
"--token",
token,
"--json",
]);
const target = output(name).targets.find((entry) => entry.url === `ws://127.0.0.1:${port}`);
assert.equal(target?.connect.ok, true);
assert.equal(target.server.version, version);
return target;
};
const config = {
gateway: {
mode: "local",
@@ -321,20 +336,7 @@ process.exitCode = await runCancelableCommand(async (signal) => {
await run("install-service", "openclaw", ["gateway", "install", "--force", "--json"]);
await ready("before-ready", port);
if (legacySqlite) {
await run("running-before", "openclaw", [
"gateway",
"probe",
"--url",
`ws://127.0.0.1:${port}`,
"--token",
token,
"--json",
]);
const serving = output("running-before").targets.find(
(entry) => entry.url === `ws://127.0.0.1:${port}`,
);
assert.equal(serving?.connect.ok, true);
assert.equal(serving.server.version, driverVersion);
const serving = await probe("running-before", driverVersion);
const buildComparable =
typeof serving.server.buildId === "string" && typeof driverBuild.buildId === "string";
if (buildComparable) {
@@ -470,20 +472,7 @@ process.exitCode = await runCancelableCommand(async (signal) => {
"Update did not replace the managed service",
);
await ready("after-ready", port);
await run("running-version", "openclaw", [
"gateway",
"probe",
"--url",
`ws://127.0.0.1:${port}`,
"--token",
token,
"--json",
]);
const target = output("running-version").targets.find(
(entry) => entry.url === `ws://127.0.0.1:${port}`,
);
assert.equal(target?.connect.ok, true);
assert.equal(target.server.version, build.version);
const target = await probe("running-version", build.version);
for (const session of sessions) {
const history = await gateway(`${session.kind}-after`, "chat.history", {
...session.params,
@@ -1,35 +1,21 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
readJson as readRecoveryJson,
writeJson as writeRecoveryJson,
} from "../fixtures/common.mjs";
import { hashFile } from "./fixture-files.mjs";
export function recoveryFileIdentity(file) {
const stat = fs.lstatSync(file, { bigint: true });
assert(stat.isFile(), `expected regular recovery file: ${file}`);
const digest = createHash("sha256");
const buffer = Buffer.alloc(64 * 1024);
const fd = fs.openSync(file, "r");
try {
for (;;) {
const bytes = fs.readSync(fd, buffer, 0, buffer.length, null);
if (!bytes) {
break;
}
digest.update(buffer.subarray(0, bytes));
}
} finally {
fs.closeSync(fd);
}
return {
dev: String(stat.dev),
ino: String(stat.ino),
size: Number(stat.size),
mtimeNs: String(stat.mtimeNs),
sha256: digest.digest("hex"),
sha256: hashFile(file, 64 * 1024),
};
}
+1 -8
View File
@@ -1422,14 +1422,7 @@ candidate_update_spec() {
printf '%s\n' "$CANDIDATE_SPEC"
return 0
fi
case "$CANDIDATE_SPEC" in
file:*)
printf '%s\n' "$CANDIDATE_SPEC"
;;
*)
printf 'file:%s\n' "$CANDIDATE_SPEC"
;;
esac
printf 'file:%s\n' "${CANDIDATE_SPEC#file:}"
}
is_extended_stable_release_version() {
@@ -1,7 +1,6 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import {
readSqliteTranscriptPayload,
@@ -38,29 +37,18 @@ const PREEXISTING_SESSION_FIXTURES = [
export function measureVolumeDoctorBudget(stateDir) {
const counts = { sessions: 0, events: 0, cronJobs: 0, pluginRoots: 0 };
for (const agentId of VOLUME_AGENT_IDS) {
const db = new DatabaseSync(
path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite"),
{ readOnly: true },
);
try {
readDatabase(path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite"), (db) => {
counts.sessions += Number(
db.prepare("SELECT count(*) AS count FROM session_nodes").get().count,
);
counts.events += Number(
db.prepare("SELECT count(*) AS count FROM transcript_events").get().count,
);
} finally {
db.close();
}
});
}
const db = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), {
readOnly: true,
});
try {
readDatabase(path.join(stateDir, "state", "openclaw.sqlite"), (db) => {
counts.cronJobs = Number(db.prepare("SELECT count(*) AS count FROM cron_jobs").get().count);
} finally {
db.close();
}
});
const index = readPluginInstallIndex({ stateDir, configPath: null });
counts.pluginRoots = new Set(
(index.plugins ?? []).filter((plugin) => plugin.enabled).map((plugin) => plugin.rootDir),
@@ -7,7 +7,7 @@ import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import { stripVTControlCharacters } from "node:util";
import { isMainThread } from "node:worker_threads";
import { readDatabase } from "./observations.mjs";
import { findInstalledPackageRoot, readDatabase } from "./observations.mjs";
import {
assertWorkshopProposalsRetired,
captureWorkshopLegacyState,
@@ -95,25 +95,24 @@ export function captureWorkshopCandidate(tarball, artifactRoot, candidateVersion
}
function hasMalformedWorkshopIndex(filename) {
const database = new DatabaseSync(filename, { readOnly: true });
try {
database.prepare("SELECT review_id FROM skill_workshop_collection_reviews LIMIT 1").get();
return false;
} catch (error) {
if (!(error instanceof Error)) {
return readDatabase(filename, (database) => {
try {
database.prepare("SELECT review_id FROM skill_workshop_collection_reviews LIMIT 1").get();
return false;
} catch (error) {
if (!(error instanceof Error)) {
throw error;
}
if (error.message.includes("malformed database schema") && error.message.includes(INDEX)) {
return true;
}
// Candidate Doctor retires the table that carries the malformed index.
if (error.message.includes("no such table: skill_workshop_collection_reviews")) {
return false;
}
throw error;
}
if (error.message.includes("malformed database schema") && error.message.includes(INDEX)) {
return true;
}
// Candidate Doctor retires the table that carries the malformed index.
if (error.message.includes("no such table: skill_workshop_collection_reviews")) {
return false;
}
throw error;
} finally {
database.close();
}
});
}
function inspectMalformedState(filename) {
@@ -455,14 +454,9 @@ function observeProcess() {
}
let identity = null;
try {
let directory = path.dirname(fs.realpathSync(process.argv[1]));
// Installed CLI entrypoints are at the package root or inside dist.
for (let depth = 0; depth < 3; depth++, directory = path.dirname(directory)) {
const manifest = path.join(directory, "package.json");
if (fs.existsSync(manifest) && readJson(manifest).name === "openclaw") {
identity = installedIdentity(directory);
break;
}
const root = findInstalledPackageRoot(path.dirname(fs.realpathSync(process.argv[1])), 3);
if (root) {
identity = installedIdentity(root);
}
} catch {
// Missing identity rejects the evidence without changing the observed CLI.
+10 -20
View File
@@ -377,14 +377,19 @@ function completedResponseEvent(id, output, inputTokens, outputTokens) {
};
}
function responseEvents(text, deltas = [text]) {
const item = {
function assistantMessage(text, phase, id = "msg_e2e_1") {
return {
type: "message",
id: "msg_e2e_1",
id,
role: "assistant",
status: "completed",
...(phase ? { phase } : {}),
content: [{ type: "output_text", text, annotations: [] }],
};
}
function responseEvents(text, deltas = [text]) {
const item = assistantMessage(text);
return [...messageEvents(item, text, deltas), completedResponseEvent("resp_e2e", [item], 11, 7)];
}
@@ -457,14 +462,7 @@ function functionCallEvents(call) {
// transport reads it straight off the item, so an untagged item produces no
// preamble at all and the scenario silently proves nothing.
function preambleThenToolCallEvents(preamble, name, args) {
const item = {
type: "message",
id: "msg_e2e_preamble",
role: "assistant",
status: "completed",
phase: "commentary",
content: [{ type: "output_text", text: preamble, annotations: [] }],
};
const item = assistantMessage(preamble, "commentary", "msg_e2e_preamble");
const call = buildMockFunctionCall(name, args);
return [
...messageEvents(item, preamble, splitResponseText(preamble)),
@@ -900,15 +898,7 @@ const server = http.createServer((req, res) => {
id: "resp_e2e",
object: "response",
status: "completed",
output: [
{
type: "message",
id: "msg_e2e_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: responseText, annotations: [] }],
},
],
output: [assistantMessage(responseText)],
usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18 },
});
return;
+41 -54
View File
@@ -703,34 +703,48 @@ Remove-Item -Path $runDir -Recurse -Force -ErrorAction SilentlyContinue`,
);
}
export class LinuxGuest {
private vmName: string;
private phases: PhaseRunner;
private getEnv: () => Record<string, string>;
abstract class ParallelsGuest<TOptions extends PosixGuestOptions> {
protected phases: PhaseRunner;
protected runCommand = run;
protected checkGuestSession = false;
protected abstract label: string;
protected abstract transportArgs(args: string[], env?: Record<string, string>): string[];
constructor(vmName: string, phases: PhaseRunner, getEnv = () => ({})) {
this.vmName = vmName;
constructor(phases: PhaseRunner) {
this.phases = phases;
this.getEnv = getEnv;
}
exec(args: string[], options: PosixGuestOptions = {}): string {
exec(args: string[], options?: TOptions): string {
return this.run(args, options).stdout.trim();
}
run(args: string[], options: PosixGuestOptions = {}): CommandResult {
const result = run("prlctl", this.transportArgs(args, options.env), {
run(args: string[], options?: TOptions): CommandResult {
const result = this.runCommand("prlctl", this.transportArgs(args, options?.env), {
check: false,
input: options.input,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
input: options?.input,
timeoutMs: this.phases.remainingTimeoutMs(options?.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
throwIfFailed("Linux guest command", result, options.check);
appendCommandResult(this.phases, result);
if (this.checkGuestSession) {
throwIfGuestSessionUnavailable(this.label, result, options?.check);
}
throwIfFailed(this.label, result, options?.check);
return result;
}
}
private transportArgs(args: string[], env: Record<string, string> = {}): string[] {
export class LinuxGuest extends ParallelsGuest<PosixGuestOptions> {
protected label = "Linux guest command";
private vmName: string;
private getEnv: () => Record<string, string>;
constructor(vmName: string, phases: PhaseRunner, getEnv = () => ({})) {
super(phases);
this.vmName = vmName;
this.getEnv = getEnv;
}
protected transportArgs(args: string[], env: Record<string, string> = {}): string[] {
const envArgs = Object.entries({
HOME: "/root",
OPENCLAW_ALLOW_ROOT: "1",
@@ -766,20 +780,18 @@ type MacosGuestInput = {
getEnv?: () => Record<string, string>;
};
export class MacosGuest {
export class MacosGuest extends ParallelsGuest<PosixGuestOptions> {
protected label = "macOS guest command";
protected override runCommand = runMacosHostCommand;
protected override checkGuestSession = true;
private input: MacosGuestInput;
private phases: PhaseRunner;
constructor(input: MacosGuestInput, phases: PhaseRunner) {
super(phases);
this.input = input;
this.phases = phases;
}
exec(args: string[], options: PosixGuestOptions = {}): string {
return this.run(args, options).stdout.trim();
}
private transportArgs(args: string[], env: Record<string, string> = {}): string[] {
protected transportArgs(args: string[], env: Record<string, string> = {}): string[] {
const envArgs = Object.entries({ PATH: this.input.path, ...this.input.getEnv?.(), ...env }).map(
([key, value]) => `${key}=${value}`,
);
@@ -802,19 +814,6 @@ export class MacosGuest {
: ["exec", this.input.vmName, "--current-user", "/usr/bin/env", ...envArgs, ...args];
}
run(args: string[], options: PosixGuestOptions = {}): CommandResult {
const result = runMacosHostCommand("prlctl", this.transportArgs(args, options.env), {
check: false,
input: options.input,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
throwIfGuestSessionUnavailable("macOS guest command", result, options.check);
throwIfFailed("macOS guest command", result, options.check);
return result;
}
sh(script: string, env: Record<string, string> = {}): string {
const scriptPath = `/tmp/${guestScriptName("sh")}`;
try {
@@ -850,31 +849,19 @@ export class MacosGuest {
}
}
export class WindowsGuest {
export class WindowsGuest extends ParallelsGuest<GuestExecOptions> {
protected label = "Windows guest command";
private vmName: string;
private phases: PhaseRunner;
private getEnv: () => Record<string, string>;
constructor(vmName: string, phases: PhaseRunner, getEnv = () => ({})) {
super(phases);
this.vmName = vmName;
this.phases = phases;
this.getEnv = getEnv;
}
exec(args: string[], options: GuestExecOptions = {}): string {
return this.run(args, options).stdout.trim();
}
run(args: string[], options: GuestExecOptions = {}): CommandResult {
const result = run("prlctl", ["exec", this.vmName, "--current-user", ...args], {
check: false,
input: options.input,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
throwIfFailed("Windows guest command", result, options.check);
return result;
protected transportArgs(args: string[]): string[] {
return ["exec", this.vmName, "--current-user", ...args];
}
powershell(script: string, options: GuestExecOptions = {}): string {
+4 -37
View File
@@ -22,7 +22,6 @@ import {
shouldSkipSnapshotRestore,
shellQuote,
validateSnapshotRestoreMode,
warn,
withProgressOnStderr,
writeJson,
writeSummaryMarkdown,
@@ -45,10 +44,12 @@ import {
printSmokeTargetSummary,
posixAgentTurnScript,
posixStopGatewayScript,
posixRefOnboardArgs,
parseSmokeCliArgs,
SmokeRunController,
smokeDefaultOptions,
smokeDefaultStatus,
verifyPosixGateway,
type SmokeCliOptions,
} from "./smoke-common.ts";
@@ -391,28 +392,7 @@ fi`);
}
private runRefOnboard(): void {
this.guest.exec([
"/usr/bin/env",
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
"openclaw",
"onboard",
"--non-interactive",
"--mode",
"local",
"--auth-choice",
this.auth.authChoice,
...(this.auth.tokenProvider ? ["--token-provider", this.auth.tokenProvider] : []),
"--secret-input-mode",
"ref",
"--gateway-port",
"18789",
"--gateway-bind",
"loopback",
"--skip-skills",
"--skip-health",
"--accept-risk",
"--json",
]);
this.guest.exec(posixRefOnboardArgs(this.auth));
}
private injectBadPluginFixture(): void {
@@ -490,20 +470,7 @@ setsid sh -lc ` +
}
private verifyGatewayStatus(): void {
for (let attempt = 1; attempt <= 8; attempt++) {
const result = this.guest.run(
["openclaw", "gateway", "status", "--deep", "--require-rpc", "--timeout", "15000"],
{ check: false },
);
if (result.status === 0) {
return;
}
if (attempt < 8) {
warn(`gateway-status retry ${attempt}`);
run("sleep", ["5"]);
}
}
throw new Error("gateway status did not become RPC-ready");
verifyPosixGateway(this.guest);
}
private async verifyBadPluginDiagnostic(lane: "fresh" | "upgrade"): Promise<void> {
+37 -76
View File
@@ -48,9 +48,11 @@ import {
printSmokeTargetSummary,
posixAgentTurnScript,
posixStopGatewayScript,
posixRefOnboardArgs,
SmokeRunController,
smokeDefaultOptions,
smokeDefaultStatus,
verifyPosixGateway,
type SmokeCliOptions,
} from "./smoke-common.ts";
@@ -317,16 +319,7 @@ class MacosSmoke extends SmokeRunController<MacosOptions> {
}
protected async runFreshLane(): Promise<void> {
await this.phases.phase("fresh.restore-snapshot", 780, () => this.restoreSnapshot());
await this.phases.phase("fresh.reset-state", 180, () => this.resetState());
await this.phases.phase("fresh.install-main", 420, () =>
this.installMain("openclaw-main-fresh.tgz"),
);
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
await this.phases.phase("fresh.verify-main-version", 60, () => this.verifyTargetVersion());
await this.phases.phase("fresh.verify-bundle-permissions", 180, () =>
this.verifyBundlePermissions(),
);
await this.runInstallLane("fresh");
await this.phases.phase("fresh.install-companions", 600, () =>
installSmokeRuntimeCompanions({
provider: this.options.provider,
@@ -341,31 +334,39 @@ class MacosSmoke extends SmokeRunController<MacosOptions> {
}
protected async runUpgradeLane(): Promise<void> {
await this.phases.phase("upgrade.restore-snapshot", 780, () => this.restoreSnapshot());
await this.phases.phase("upgrade.reset-state", 180, () => this.resetState());
await this.phases.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
await this.phases.phase("upgrade.verify-latest-version", 60, () =>
this.verifyVersionContains(this.installVersion),
);
if (this.options.skipLatestRefCheck) {
this.status.upgradePrecheck = "skipped";
} else if (
await this.phases.phaseReturns("upgrade.latest-ref-precheck", 180, () =>
this.captureLatestRefFailure(),
)
) {
this.status.upgradePrecheck = "latest-ref-pass";
} else {
this.status.upgradePrecheck = "latest-ref-fail";
}
if (this.options.targetPackageSpec) {
await this.phases.phase("upgrade.install-main", 420, () =>
this.installMain("openclaw-main-upgrade.tgz"),
await this.runInstallLane("upgrade");
await this.phases.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
await this.runGatewaySmoke("upgrade");
}
private async runInstallLane(lane: "fresh" | "upgrade"): Promise<void> {
await this.phases.phase(`${lane}.restore-snapshot`, 780, () => this.restoreSnapshot());
await this.phases.phase(`${lane}.reset-state`, 180, () => this.resetState());
if (lane === "upgrade") {
await this.phases.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
await this.phases.phase("upgrade.verify-latest-version", 60, () =>
this.verifyVersionContains(this.installVersion),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main");
await this.phases.phase("upgrade.verify-main-version", 60, () => this.verifyTargetVersion());
await this.phases.phase("upgrade.verify-bundle-permissions", 180, () =>
if (this.options.skipLatestRefCheck) {
this.status.upgradePrecheck = "skipped";
} else if (
await this.phases.phaseReturns("upgrade.latest-ref-precheck", 180, () =>
this.captureLatestRefFailure(),
)
) {
this.status.upgradePrecheck = "latest-ref-pass";
} else {
this.status.upgradePrecheck = "latest-ref-fail";
}
}
if (lane === "fresh" || this.options.targetPackageSpec) {
await this.phases.phase(`${lane}.install-main`, 420, () =>
this.installMain(`openclaw-main-${lane}.tgz`),
);
this.status[`${lane}Version`] = await this.extractLastVersion(`${lane}.install-main`);
await this.phases.phase(`${lane}.verify-main-version`, 60, () => this.verifyTargetVersion());
await this.phases.phase(`${lane}.verify-bundle-permissions`, 180, () =>
this.verifyBundlePermissions(),
);
} else {
@@ -377,8 +378,6 @@ class MacosSmoke extends SmokeRunController<MacosOptions> {
this.verifyDevChannelUpdate(),
);
}
await this.phases.phase("upgrade.onboard-ref", 420, () => this.runRefOnboard());
await this.runGatewaySmoke("upgrade");
}
private async runGatewaySmoke(lane: "fresh" | "upgrade"): Promise<void> {
@@ -632,28 +631,7 @@ fi`);
private runRefOnboard(): void {
const daemonFlag = this.guestTransport === "sudo" ? "--skip-health" : "--install-daemon";
this.guest.exec([
"/usr/bin/env",
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
guestOpenClaw,
"onboard",
"--non-interactive",
"--mode",
"local",
"--auth-choice",
this.auth.authChoice,
...(this.auth.tokenProvider ? ["--token-provider", this.auth.tokenProvider] : []),
"--secret-input-mode",
"ref",
"--gateway-port",
"18789",
"--gateway-bind",
"loopback",
daemonFlag,
"--skip-skills",
"--accept-risk",
"--json",
]);
this.guest.exec(posixRefOnboardArgs(this.auth, daemonFlag));
}
private captureLatestRefFailure(): void {
@@ -739,24 +717,7 @@ sleep 1`,
}
private verifyGateway(): void {
for (let attempt = 1; attempt <= 8; attempt++) {
const result = this.guestOpenClaw([
"gateway",
"status",
"--deep",
"--require-rpc",
"--timeout",
"15000",
]);
if (result) {
return;
}
if (attempt < 8) {
warn(`gateway-status retry ${attempt}`);
run("sleep", ["5"]);
}
}
throw new Error("gateway status did not become RPC-ready");
verifyPosixGateway(this.guest);
}
private showGatewayStatusCompat(): void {
+39 -55
View File
@@ -564,7 +564,6 @@ export class NpmUpdateSmoke {
private updateTargetTarball = "";
private targetTarballPath = "";
private targetTarballBuildCommit = "";
private targetDependencyPackages: NpmRegistryPackage[] = [];
private targetRegistryPackages: NpmRegistryPackage[] = [];
private targetTarballVersion = "";
private targetRegistryHostUrl = "";
@@ -820,7 +819,7 @@ export class NpmUpdateSmoke {
buildCommitShort: this.targetTarballBuildCommit.slice(0, 7),
path: hostedTarballPath,
version: this.targetTarballVersion,
registryPackages: [...this.targetDependencyPackages, ...this.targetRegistryPackages],
registryPackages: this.targetRegistryPackages,
};
} else if (!this.options.updateTarget || this.options.updateTarget === "local-main") {
const providerConfig = resolveProviderConfig(this.options.provider);
@@ -1362,59 +1361,44 @@ export class NpmUpdateSmoke {
]);
this.targetTarballVersion = targetPackageJson.version ?? "";
this.targetTarballBuildCommit = targetBuildCommit;
this.targetDependencyPackages = await Promise.all(
this.options.dependencyTarballs.map(async (dependencyTarball) => {
const tarballPath = path.resolve(dependencyTarball);
if (!existsSync(tarballPath)) {
throw new Error(`dependency tarball does not exist: ${tarballPath}`);
}
const dependencyPackage = await extractPackageJsonFromTgz<{
name?: string;
version?: string;
}>(tarballPath, "package/package.json");
const name = dependencyPackage.name ?? "";
const version = dependencyPackage.version ?? "";
if (!name || !version || name === "openclaw") {
throw new Error(`dependency tarball has invalid package metadata: ${tarballPath}`);
}
if (targetPackageJson.dependencies?.[name] !== version) {
throw new Error(
`target tarball requires ${name}@${targetPackageJson.dependencies?.[name] ?? "<missing>"}, but companion tarball provides ${version}`,
);
}
return { name, version, tarballPath };
}),
);
this.targetRegistryPackages = await Promise.all(
this.options.registryPackageTarballs.map(async (registryPackageTarball) => {
const tarballPath = path.resolve(registryPackageTarball);
if (!existsSync(tarballPath)) {
throw new Error(`registry package tarball does not exist: ${tarballPath}`);
}
const registryPackage = await extractPackageJsonFromTgz<{
name?: string;
version?: string;
}>(tarballPath, "package/package.json");
const name = registryPackage.name ?? "";
const version = registryPackage.version ?? "";
if (!name || !version || name === "openclaw") {
throw new Error(`registry package tarball has invalid metadata: ${tarballPath}`);
}
if (version !== this.targetTarballVersion) {
throw new Error(
`registry package ${name}@${version} does not match candidate ${this.targetTarballVersion}`,
);
}
return { name, version, tarballPath };
}),
);
const registryPackageNames = new Set(
[...this.targetDependencyPackages, ...this.targetRegistryPackages].map((pkg) => pkg.name),
);
if (
registryPackageNames.size !==
this.targetDependencyPackages.length + this.targetRegistryPackages.length
) {
const readTarballs = (tarballs: string[], kind: "dependency" | "registry package") =>
Promise.all(
tarballs.map(async (tarball) => {
const tarballPath = path.resolve(tarball);
if (!existsSync(tarballPath)) {
throw new Error(`${kind} tarball does not exist: ${tarballPath}`);
}
const pkg = await extractPackageJsonFromTgz<{
name?: string;
version?: string;
}>(tarballPath, "package/package.json");
const name = pkg.name ?? "";
const version = pkg.version ?? "";
if (!name || !version || name === "openclaw") {
throw new Error(
`${kind} tarball has invalid ${kind === "dependency" ? "package " : ""}metadata: ${tarballPath}`,
);
}
if (kind === "dependency") {
if (targetPackageJson.dependencies?.[name] !== version) {
throw new Error(
`target tarball requires ${name}@${targetPackageJson.dependencies?.[name] ?? "<missing>"}, but companion tarball provides ${version}`,
);
}
} else if (version !== this.targetTarballVersion) {
throw new Error(
`registry package ${name}@${version} does not match candidate ${this.targetTarballVersion}`,
);
}
return { name, version, tarballPath };
}),
);
this.targetRegistryPackages = [
...(await readTarballs(this.options.dependencyTarballs, "dependency")),
...(await readTarballs(this.options.registryPackageTarballs, "registry package")),
];
const registryPackageNames = new Set(this.targetRegistryPackages.map((pkg) => pkg.name));
if (registryPackageNames.size !== this.targetRegistryPackages.length) {
throw new Error("candidate registry tarballs must have unique package names");
}
if (!this.targetTarballVersion || !this.targetTarballBuildCommit) {
+59 -13
View File
@@ -6,7 +6,7 @@ import { stripLeadingPackageManagerSeparator } from "../../lib/arg-utils.mts";
import { resolveProviderConfig } from "../../lib/cross-os-release-checks/config.ts";
import { parseTcpPort } from "./env-limits.ts";
import { extractLastOpenClawVersionFromLog } from "./filesystem.ts";
import { run, say, die, shellQuote } from "./host-command.ts";
import { run, say, die, shellQuote, warn } from "./host-command.ts";
import {
resolveHostIp,
resolveHostPort,
@@ -20,7 +20,15 @@ import {
packOpenClaw,
} from "./package-artifact.ts";
import { ensureValue, parseMode, parseProvider } from "./provider-auth.ts";
import type { HostServer, Mode, PackageArtifact, Provider, SnapshotInfo } from "./types.ts";
import type {
CommandResult,
HostServer,
Mode,
PackageArtifact,
Provider,
ProviderAuth,
SnapshotInfo,
} from "./types.ts";
interface SmokeHostOptions {
hostIp?: string;
@@ -143,17 +151,7 @@ export function parseSmokeCliArgs<TOptions extends SmokeCliOptions>(
return options;
}
interface SmokeLaneStatuses {
freshAgent: string;
freshGateway: string;
freshMain: string;
freshVersion: string;
latestInstalledVersion: string;
upgrade: string;
upgradeAgent: string;
upgradeGateway: string;
upgradeVersion: string;
}
type SmokeLaneStatuses = typeof smokeDefaultStatus;
export abstract class SmokeRunController<TOptions extends SmokeRunOptions & SmokeHostOptions> {
protected hostIp = "";
@@ -234,6 +232,54 @@ export function npmRegistryEnv(registry?: string): Record<string, string> {
return registry ? { NPM_CONFIG_REGISTRY: registry, npm_config_registry: registry } : {};
}
export function posixRefOnboardArgs(
auth: ProviderAuth,
daemonFlag?: "--install-daemon" | "--skip-health",
): string[] {
return [
"/usr/bin/env",
`${auth.apiKeyEnv}=${auth.apiKeyValue}`,
"openclaw",
"onboard",
"--non-interactive",
"--mode",
"local",
"--auth-choice",
auth.authChoice,
...(auth.tokenProvider ? ["--token-provider", auth.tokenProvider] : []),
"--secret-input-mode",
"ref",
"--gateway-port",
"18789",
"--gateway-bind",
"loopback",
...(daemonFlag ? [daemonFlag] : []),
"--skip-skills",
...(daemonFlag ? [] : ["--skip-health"]),
"--accept-risk",
"--json",
];
}
export function verifyPosixGateway(guest: {
run(args: string[], options: { check: false }): CommandResult;
}): void {
for (let attempt = 1; attempt <= 8; attempt++) {
const result = guest.run(
["openclaw", "gateway", "status", "--deep", "--require-rpc", "--timeout", "15000"],
{ check: false },
);
if (result.status === 0) {
return;
}
if (attempt < 8) {
warn(`gateway-status retry ${attempt}`);
run("sleep", ["5"]);
}
}
throw new Error("gateway status did not become RPC-ready");
}
export function posixAgentTurnScript(input: {
command: string;
sessionIdExpression: string;
+11 -26
View File
@@ -1,5 +1,4 @@
// Snapshots script supports OpenClaw repository automation.
import { expectDefined } from "@openclaw/normalization-core";
import { die, run } from "./host-command.ts";
import type { Mode, SnapshotInfo } from "./types.ts";
@@ -99,33 +98,19 @@ function stringSimilarity(a: string, b: string): number {
if (a === b) {
return 1;
}
const rows = a.length + 1;
const cols = b.length + 1;
const matrix = Array.from({ length: rows }, () => Array<number>(cols).fill(0));
for (let i = 0; i < rows; i++) {
expectDefined(matrix[i], `snapshot similarity matrix row ${i}`)[0] = i;
}
const firstRow = expectDefined(matrix[0], "snapshot similarity first matrix row");
for (let j = 0; j < cols; j++) {
firstRow[j] = j;
}
for (let i = 1; i < rows; i++) {
const row = expectDefined(matrix[i], `snapshot similarity matrix row ${i}`);
const previousRow = expectDefined(matrix[i - 1], `snapshot similarity matrix row ${i - 1}`);
for (let j = 1; j < cols; j++) {
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
for (let i = 1; i <= a.length; i++) {
let diagonal = row[0]!;
row[0] = i;
for (let j = 1; j <= b.length; j++) {
const above = row[j]!;
row[j] = Math.min(
expectDefined(previousRow[j], `snapshot similarity deletion cell ${i},${j}`) + 1,
expectDefined(row[j - 1], `snapshot similarity insertion cell ${i},${j - 1}`) + 1,
expectDefined(
previousRow[j - 1],
`snapshot similarity substitution cell ${i - 1},${j - 1}`,
) + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1),
above + 1,
row[j - 1]! + 1,
diagonal + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1),
);
diagonal = above;
}
}
const distance = expectDefined(
expectDefined(matrix[a.length], "snapshot similarity final matrix row")[b.length],
"snapshot similarity final distance",
);
return 1 - distance / Math.max(a.length, b.length, 1);
return 1 - row[b.length]! / Math.max(a.length, b.length, 1);
}
+4 -15
View File
@@ -8,27 +8,16 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/e2e/lib/release-scenarios/docker.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-release-media-memory-e2e" OPENCLAW_RELEASE_MEDIA_MEMORY_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_RELEASE_MEDIA_MEMORY_E2E_SKIP_BUILD:-0}"
run_log=""
trap 'docker_e2e_cleanup_package_run "${PACKAGE_TGZ:-}" "${run_log:-}"' EXIT
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz release-media-memory "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" release-media-memory "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-media-memory empty)"
run_log="$(docker_e2e_run_log release-media-memory)"
openclaw_release_prepare_docker release-media-memory
echo "Running release media memory Docker E2E..."
if ! docker_e2e_run_with_harness \
openclaw_release_run_docker \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-media-memory/scenario.sh >"$run_log" 2>&1; then
docker_e2e_print_log "$run_log"
exit 1
fi
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-media-memory/scenario.sh
echo "Release media memory Docker E2E passed."
@@ -7,27 +7,16 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/e2e/lib/release-scenarios/docker.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-release-plugin-marketplace-e2e" OPENCLAW_RELEASE_PLUGIN_MARKETPLACE_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_RELEASE_PLUGIN_MARKETPLACE_E2E_SKIP_BUILD:-0}"
run_log=""
trap 'docker_e2e_cleanup_package_run "${PACKAGE_TGZ:-}" "${run_log:-}"' EXIT
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz release-plugin-marketplace "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" release-plugin-marketplace "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-plugin-marketplace empty)"
run_log="$(docker_e2e_run_log release-plugin-marketplace)"
openclaw_release_prepare_docker release-plugin-marketplace
echo "Running release plugin marketplace Docker E2E..."
if ! docker_e2e_run_with_harness \
openclaw_release_run_docker \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-plugin-marketplace/scenario.sh >"$run_log" 2>&1; then
docker_e2e_print_log "$run_log"
exit 1
fi
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-plugin-marketplace/scenario.sh
echo "Release plugin marketplace Docker E2E passed."
@@ -8,19 +8,11 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/e2e/lib/release-scenarios/docker.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-release-upgrade-user-journey-e2e" OPENCLAW_RELEASE_UPGRADE_USER_JOURNEY_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_RELEASE_UPGRADE_USER_JOURNEY_E2E_SKIP_BUILD:-0}"
run_log=""
trap 'docker_e2e_cleanup_package_run "${PACKAGE_TGZ:-}" "${run_log:-}"' EXIT
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz release-upgrade-user-journey "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" release-upgrade-user-journey "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-upgrade-user-journey empty)"
run_log="$(docker_e2e_run_log release-upgrade-user-journey)"
openclaw_release_prepare_docker release-upgrade-user-journey
DOCKER_ENV_ARGS=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
@@ -38,12 +30,9 @@ if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then
fi
echo "Running release upgrade user journey Docker E2E..."
if ! docker_e2e_run_with_harness \
openclaw_release_run_docker \
"${DOCKER_ENV_ARGS[@]}" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-upgrade-user-journey/scenario.sh >"$run_log" 2>&1; then
docker_e2e_print_log "$run_log"
exit 1
fi
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-upgrade-user-journey/scenario.sh
echo "Release upgrade user journey Docker E2E passed. Evidence: $ARTIFACT_DIR"
+4 -15
View File
@@ -7,6 +7,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/e2e/lib/release-scenarios/docker.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-release-user-journey-e2e" OPENCLAW_RELEASE_USER_JOURNEY_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_RELEASE_USER_JOURNEY_E2E_SKIP_BUILD:-0}"
@@ -16,26 +17,14 @@ HTTP_TIMEOUT_MS="$(
HTTP_BODY_MAX_BYTES="$(
docker_e2e_read_positive_int_env OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES 1048576
)"
run_log=""
trap 'docker_e2e_cleanup_package_run "${PACKAGE_TGZ:-}" "${run_log:-}"' EXIT
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz release-user-journey "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" release-user-journey "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-user-journey empty)"
run_log="$(docker_e2e_run_log release-user-journey)"
openclaw_release_prepare_docker release-user-journey
echo "Running release user journey Docker E2E..."
if ! docker_e2e_run_with_harness \
openclaw_release_run_docker \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e "OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS=$HTTP_TIMEOUT_MS" \
-e "OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES=$HTTP_BODY_MAX_BYTES" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-user-journey/scenario.sh >"$run_log" 2>&1; then
docker_e2e_print_log "$run_log"
exit 1
fi
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-user-journey/scenario.sh
echo "Release user journey Docker E2E passed."
@@ -170,69 +170,40 @@ async function main() {
const gatewayRestarts: string[] = [];
const gatewayCommand = makeParams("/openclaw restart gateway", cfg).command;
const gatewayPlan = await runSystemAgentRescueMessage({
cfg,
command: gatewayCommand,
commandBody: "/openclaw restart gateway",
agentId: "default",
isGroup: false,
deps: {
runGatewayRestart: async () => {
gatewayRestarts.push("restart");
},
const restartDeps = {
runGatewayRestart: async () => {
gatewayRestarts.push("restart");
},
});
};
const runGatewayCommand = (
commandBody: string,
deps?: Parameters<typeof runSystemAgentRescueMessage>[0]["deps"],
) =>
runSystemAgentRescueMessage({
cfg,
command: gatewayCommand,
commandBody,
agentId: "default",
isGroup: false,
deps,
});
const gatewayPlan = await runGatewayCommand("/openclaw restart gateway", restartDeps);
assert(
gatewayPlan?.includes("Reply /openclaw yes to apply"),
"gateway restart did not require approval",
);
const pluginList = await runSystemAgentRescueMessage({
cfg,
command: gatewayCommand,
commandBody: "/openclaw plugins list",
agentId: "default",
isGroup: false,
deps: {
runPluginsList: async (runtime) => runtime.log("plugin rows"),
},
const pluginList = await runGatewayCommand("/openclaw plugins list", {
runPluginsList: async (runtime) => runtime.log("plugin rows"),
});
assert(pluginList === "plugin rows", "read-only rescue command did not run");
const revokedApproval = await runSystemAgentRescueMessage({
cfg,
command: gatewayCommand,
commandBody: "/openclaw yes",
agentId: "default",
isGroup: false,
deps: {
runGatewayRestart: async () => {
gatewayRestarts.push("restart");
},
},
});
const revokedApproval = await runGatewayCommand("/openclaw yes", restartDeps);
assert(
revokedApproval === "No pending OpenClaw rescue change is waiting for approval.",
"fresh rescue command did not revoke the older pending change",
);
assert(gatewayRestarts.length === 0, "revoked gateway restart was invoked");
await runSystemAgentRescueMessage({
cfg,
command: gatewayCommand,
commandBody: "/openclaw restart gateway",
agentId: "default",
isGroup: false,
});
const gatewayApplied = await runSystemAgentRescueMessage({
cfg,
command: gatewayCommand,
commandBody: "/openclaw yes",
agentId: "default",
isGroup: false,
deps: {
runGatewayRestart: async () => {
gatewayRestarts.push("restart");
},
},
});
await runGatewayCommand("/openclaw restart gateway");
const gatewayApplied = await runGatewayCommand("/openclaw yes", restartDeps);
assert(
gatewayApplied?.includes("[openclaw] done: gateway.restart"),
"gateway restart did not apply",
+1
View File
@@ -161,6 +161,7 @@ if [ "$UPGRADE_TARGET_TRAIN" = extended-stable ]; then
cp "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/backup-rollback-summary.mjs" "$UPGRADE_SCENARIO_STAGE/backup-rollback-summary.mjs"
cp "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/native-assignment-summary.mjs" "$UPGRADE_SCENARIO_STAGE/native-assignment-summary.mjs"
cp "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/plugin-policy-summary.mjs" "$UPGRADE_SCENARIO_STAGE/plugin-policy-summary.mjs"
cp "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/observations.mjs" "$UPGRADE_SCENARIO_STAGE/observations.mjs"
cp "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/package-activation-recovery.mjs" "$UPGRADE_SCENARIO_STAGE/package-activation-recovery.mjs"
chmod 0755 "$UPGRADE_SCENARIO_STAGE"
UPGRADE_SCENARIO_ARGS+=(
@@ -10,6 +10,7 @@ export function copySurvivorCaptureClosure(workDir: string) {
"scripts/e2e/lib/env-limits.mjs",
"scripts/e2e/lib/text-file-utils.mjs",
UPGRADE_SURVIVOR_DIAGNOSTICS_PATH,
"scripts/e2e/lib/upgrade-survivor/observations.mjs",
"scripts/e2e/lib/upgrade-survivor/package-activation-recovery.mjs",
"scripts/e2e/lib/upgrade-survivor/backup-rollback-summary.mjs",
"scripts/e2e/lib/upgrade-survivor/native-assignment-summary.mjs",
+1 -1
View File
@@ -2577,7 +2577,7 @@ docker_e2e_docker_run_cmd run demo
expect(script, path).toContain("openclaw_e2e_enable_openclaw_cli_timeout");
}
expect(readFileSync(RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH, "utf8")).toContain(
'openclaw_e2e_run_command node "$baseline_entry" onboard',
'openclaw_release_onboard "$PORT" openclaw_e2e_run_command node "$baseline_entry"',
);
});
+1 -3
View File
@@ -102,9 +102,7 @@ openclaw_e2e_fixture_plugin_command openclaw_e2e_maybe_timeout 5s node "$OPENCLA
const sweepFixtureLoader = `
source() {
if [[ "$1" == scripts/e2e/lib/plugins/fixtures.sh ]]; then
pack_fixture_plugin() { :; }
pack_fixture_plugin_with_cli_registry_dependency() { :; }
pack_fake_is_number_package() { :; }
pack_fixture_archive() { :; }
pack_fixture_plugin_with_invalid_extension_entry() { :; }
start_npm_fixture_registry() { :; }
openclaw_plugins_cleanup_fixture_servers() { :; }