mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
Merge branch 'main' of https://github.com/openclaw/openclaw
* 'main' of https://github.com/openclaw/openclaw: fix: exclude test support from raw fetch guard fix(ollama): preserve aborts with stream timeouts ci: require maintainer permission for command reactions docs(hooks/bundled/readme): cover session compaction and message events refactor: share docker e2e harness runner fix: keep browser test fetch out of runtime scan
This commit is contained in:
@@ -40,12 +40,6 @@ jobs:
|
||||
script: |
|
||||
const comment = context.payload.comment;
|
||||
const issue = context.payload.issue;
|
||||
const association = comment.author_association;
|
||||
const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
if (!maintainerAssociations.has(association)) {
|
||||
core.info(`Skipping non-maintainer command reaction for association ${association || "unknown"}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issue.pull_request) {
|
||||
core.info("Skipping command reaction because the comment is not on a pull request.");
|
||||
@@ -66,6 +60,28 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const maintainerPermissions = new Set(["admin", "maintain", "write"]);
|
||||
let permission = "none";
|
||||
try {
|
||||
const result = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: comment.user.login,
|
||||
});
|
||||
permission = String(result.data.permission || "none").toLowerCase();
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
core.info(`Could not resolve repository permission for ${comment.user.login}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!maintainerPermissions.has(permission)) {
|
||||
core.info(
|
||||
`Skipping non-maintainer command reaction for ${comment.user.login}; repository permission is ${permission}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
async function react(content) {
|
||||
try {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
|
||||
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus.
|
||||
- CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator.
|
||||
- MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc.
|
||||
- Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
setBrowserControlServerReachable,
|
||||
startBrowserControlServerFromConfig,
|
||||
} from "./server.control-server.test-harness.js";
|
||||
import { getBrowserTestFetch } from "./test-fetch.js";
|
||||
import { getBrowserTestFetch } from "./test-support/fetch.js";
|
||||
|
||||
type ActErrorResponse = {
|
||||
error?: string;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getBrowserControlServerTestState,
|
||||
getPwMocks,
|
||||
} from "./server.control-server.test-harness.js";
|
||||
import { getBrowserTestFetch, type BrowserTestFetch } from "./test-fetch.js";
|
||||
import { getBrowserTestFetch, type BrowserTestFetch } from "./test-support/fetch.js";
|
||||
|
||||
const state = getBrowserControlServerTestState();
|
||||
const pwMocks = getPwMocks();
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
installBrowserControlServerHooks,
|
||||
startBrowserControlServerFromConfig,
|
||||
} from "./server.control-server.test-harness.js";
|
||||
import { getBrowserTestFetch } from "./test-fetch.js";
|
||||
import { getBrowserTestFetch } from "./test-support/fetch.js";
|
||||
|
||||
export function installAgentContractHooks() {
|
||||
installBrowserControlServerHooks();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isAuthorizedBrowserRequest } from "./http-auth.js";
|
||||
import { getBrowserTestFetch, type BrowserTestFetch } from "./test-fetch.js";
|
||||
import { getBrowserTestFetch, type BrowserTestFetch } from "./test-support/fetch.js";
|
||||
|
||||
let server: ReturnType<typeof createServer> | null = null;
|
||||
let port = 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getBrowserTestFetch } from "./test-fetch.js";
|
||||
import { getFreePort } from "./test-port.js";
|
||||
import { getBrowserTestFetch } from "./test-support/fetch.js";
|
||||
|
||||
let testPort = 0;
|
||||
let prevGatewayPort: string | undefined;
|
||||
|
||||
@@ -23,6 +23,7 @@ type GuardedFetchCall = {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
policy?: unknown;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
auditContext?: string;
|
||||
};
|
||||
@@ -333,6 +334,29 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes caller abort signals at guard level when a timeout is present", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
|
||||
],
|
||||
async (fetchMock) => {
|
||||
const signal = new AbortController().signal;
|
||||
const stream = await createOllamaTestStream({
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
options: { signal, timeoutMs: 123_456 },
|
||||
});
|
||||
|
||||
await collectStreamEvents(stream);
|
||||
|
||||
const request = getGuardedFetchCall(fetchMock);
|
||||
expect(request.timeoutMs).toBe(123_456);
|
||||
expect(request.signal).toBe(signal);
|
||||
expect(request.init?.signal).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("maps native Ollama max thinking to think=high on the wire", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
@@ -1018,6 +1042,7 @@ async function createOllamaTestStream(params: {
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
}) {
|
||||
@@ -1401,8 +1426,9 @@ describe("createOllamaStreamFn", () => {
|
||||
const request = getGuardedFetchCall(fetchMock);
|
||||
expect(request.url).toBe("http://ollama-host:11434/api/chat");
|
||||
expect(request.auditContext).toBe("ollama-stream.chat");
|
||||
expect(request.signal).toBe(signal);
|
||||
const requestInit = request.init ?? {};
|
||||
expect(requestInit.signal).toBe(signal);
|
||||
expect(requestInit.signal).toBeUndefined();
|
||||
if (typeof requestInit.body !== "string") {
|
||||
throw new Error("Expected string request body");
|
||||
}
|
||||
|
||||
@@ -1000,9 +1000,9 @@ export function createOllamaStreamFn(
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
},
|
||||
policy: ssrfPolicy,
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
timeoutMs: resolveOllamaRequestTimeoutMs(
|
||||
model,
|
||||
options as { requestTimeoutMs?: unknown; timeoutMs?: unknown } | undefined,
|
||||
|
||||
@@ -18,8 +18,6 @@ const allowedRawFetchCallsites = new Set([
|
||||
bundledPluginCallsite("bluebubbles", "src/types.ts", 204),
|
||||
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268),
|
||||
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),
|
||||
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24),
|
||||
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 27),
|
||||
bundledPluginCallsite("chutes", "models.ts", 535),
|
||||
bundledPluginCallsite("chutes", "models.ts", 542),
|
||||
bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 417),
|
||||
@@ -96,6 +94,7 @@ export async function main() {
|
||||
sourceRoots,
|
||||
extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
|
||||
findCallLines: findRawFetchCallLines,
|
||||
skipRelativePath: (relPath) => relPath.includes("/test-support/"),
|
||||
allowCallsite: (callsite) => allowedRawFetchCallsites.has(callsite),
|
||||
header: "Found raw fetch() usage in channel/plugin runtime sources outside allowlist:",
|
||||
footer: "Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.",
|
||||
|
||||
@@ -7,7 +7,6 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-plugin-install-uninstal
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-plugin-install-uninstall
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 bundled-plugin-install-uninstall empty)"
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
DOCKER_ENV_ARGS=(
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
@@ -25,9 +24,8 @@ done
|
||||
|
||||
echo "Running bundled plugin install/uninstall Docker E2E..."
|
||||
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-plugin-install-uninstall.XXXXXX")"
|
||||
if ! docker run --rm \
|
||||
if ! docker_e2e_run_with_harness \
|
||||
"${DOCKER_ENV_ARGS[@]}" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh >"$RUN_LOG" 2>&1
|
||||
then
|
||||
|
||||
@@ -16,16 +16,14 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-first-run
|
||||
docker_e2e_harness_mount_args
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-first-run empty)"
|
||||
|
||||
echo "Running in-container Crestodian first-run smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
set +e
|
||||
docker run --rm \
|
||||
docker_e2e_run_with_harness \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
|
||||
|
||||
@@ -16,16 +16,14 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-planner
|
||||
docker_e2e_harness_mount_args
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-planner empty)"
|
||||
|
||||
echo "Running in-container Crestodian planner fallback smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
set +e
|
||||
docker run --rm \
|
||||
docker_e2e_run_with_harness \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
|
||||
|
||||
@@ -16,16 +16,14 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-rescue
|
||||
docker_e2e_harness_mount_args
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-rescue empty)"
|
||||
|
||||
echo "Running in-container Crestodian rescue smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
set +e
|
||||
docker run --rm \
|
||||
docker_e2e_run_with_harness \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
|
||||
|
||||
137
scripts/e2e/lib/plugin-update/probe.mjs
Normal file
137
scripts/e2e/lib/plugin-update/probe.mjs
Normal file
@@ -0,0 +1,137 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const home = os.homedir();
|
||||
|
||||
const readJson = (file) => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const pluginRecordSnapshot = () => {
|
||||
const config = readJson(openclawPath("openclaw.json"));
|
||||
const index = readJson(openclawPath("plugins", "installs.json"));
|
||||
const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {};
|
||||
const record = records["lossless-claw"] ?? records["@example/lossless-claw"];
|
||||
if (!record) {
|
||||
throw new Error("missing plugin install record");
|
||||
}
|
||||
const { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum } = record;
|
||||
return { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum };
|
||||
};
|
||||
|
||||
function legacyCompat(version) {
|
||||
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?/.exec(version);
|
||||
const [year, month, day] = match?.slice(1, 4).map(Number) ?? [];
|
||||
return (
|
||||
Boolean(match) && (year < 2026 || (year === 2026 && (month < 4 || (month === 4 && day <= 25))))
|
||||
);
|
||||
}
|
||||
|
||||
function openclawPath(...parts) {
|
||||
return path.join(home, ".openclaw", ...parts);
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function seedInstallState() {
|
||||
writeJson(openclawPath("extensions", "lossless-claw", "package.json"), {
|
||||
name: "@example/lossless-claw",
|
||||
version: "0.9.0",
|
||||
});
|
||||
writeJson(process.env.OPENCLAW_CONFIG_PATH, { plugins: {} });
|
||||
writeJson(openclawPath("plugins", "installs.json"), {
|
||||
version: 1,
|
||||
warning: "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.",
|
||||
hostContractVersion: "docker-e2e",
|
||||
compatRegistryVersion: "docker-e2e",
|
||||
migrationVersion: 1,
|
||||
policyHash: "docker-e2e",
|
||||
generatedAtMs: 1777118400000,
|
||||
installRecords: {
|
||||
"lossless-claw": {
|
||||
source: "npm",
|
||||
spec: "@example/lossless-claw@0.9.0",
|
||||
installPath: "~/.openclaw/extensions/lossless-claw",
|
||||
resolvedName: "@example/lossless-claw",
|
||||
resolvedVersion: "0.9.0",
|
||||
resolvedSpec: "@example/lossless-claw@0.9.0",
|
||||
integrity: "sha512-same",
|
||||
shasum: "same",
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function waitRegistry() {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (await registryHealthy()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("Local npm metadata registry failed to start");
|
||||
}
|
||||
|
||||
function registryHealthy() {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get("http://127.0.0.1:4873/@example%2flossless-claw", (res) => {
|
||||
resolve(res.statusCode === 200);
|
||||
res.resume();
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.setTimeout(200, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertSnapshot(beforePath) {
|
||||
const before = readJson(beforePath);
|
||||
const after = pluginRecordSnapshot();
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
throw new Error(
|
||||
`plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOutput(logPath) {
|
||||
const output = fs.readFileSync(logPath, "utf8");
|
||||
const failure = output.includes("Downloading @example/lossless-claw")
|
||||
? "Unexpected npm download/reinstall path"
|
||||
: !output.includes("lossless-claw is up to date (0.9.0).")
|
||||
? "Expected up-to-date output missing"
|
||||
: "";
|
||||
if (failure) {
|
||||
throw new Error(`${failure}\n${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [command, arg] = process.argv.slice(2);
|
||||
const commands = {
|
||||
"legacy-compat": () => console.log(legacyCompat(arg || "") ? "1" : "0"),
|
||||
seed: seedInstallState,
|
||||
"wait-registry": waitRegistry,
|
||||
snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)),
|
||||
"assert-snapshot": () => assertSnapshot(arg),
|
||||
"assert-output": () => assertOutput(arg),
|
||||
};
|
||||
const run = commands[command];
|
||||
await (
|
||||
run ??
|
||||
(() => {
|
||||
throw new Error(`Unknown plugin update probe command: ${command || "(missing)"}`);
|
||||
})
|
||||
)();
|
||||
@@ -8,76 +8,20 @@ openclaw_e2e_install_package /tmp/openclaw-install.log "mounted OpenClaw package
|
||||
|
||||
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
probe="scripts/e2e/lib/plugin-update/probe.mjs"
|
||||
package_version="$(node -p "require('$package_root/package.json').version")"
|
||||
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(
|
||||
PACKAGE_VERSION="$package_version" node -e 'const version = process.env.PACKAGE_VERSION || ""; const match = new RegExp("^(\\d{4})\\.(\\d{1,2})\\.(\\d{1,2})(?:[-+].*)?").exec(version); if (!match) { console.log("0"); process.exit(0); } const value = [Number(match[1]), Number(match[2]), Number(match[3])]; const max = [2026, 4, 25]; for (let i = 0; i < value.length; i += 1) { if (value[i] < max[i]) { console.log("1"); process.exit(0); } if (value[i] > max[i]) { console.log("0"); process.exit(0); } } console.log("1");'
|
||||
)"
|
||||
OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node "$probe" legacy-compat "$package_version")"
|
||||
export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT
|
||||
export NPM_CONFIG_REGISTRY=http://127.0.0.1:4873
|
||||
export PATH="/tmp/npm-prefix/bin:$PATH"
|
||||
|
||||
mkdir -p "$HOME/.openclaw/extensions/lossless-claw"
|
||||
cat > "$HOME/.openclaw/extensions/lossless-claw/package.json" <<'JSON'
|
||||
{
|
||||
"name": "@example/lossless-claw",
|
||||
"version": "0.9.0"
|
||||
}
|
||||
JSON
|
||||
cat > "$OPENCLAW_CONFIG_PATH" <<'JSON'
|
||||
{
|
||||
"plugins": {}
|
||||
}
|
||||
JSON
|
||||
mkdir -p "$HOME/.openclaw/plugins"
|
||||
cat > "$HOME/.openclaw/plugins/installs.json" <<'JSON'
|
||||
{
|
||||
"version": 1,
|
||||
"warning": "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.",
|
||||
"hostContractVersion": "docker-e2e",
|
||||
"compatRegistryVersion": "docker-e2e",
|
||||
"migrationVersion": 1,
|
||||
"policyHash": "docker-e2e",
|
||||
"generatedAtMs": 1777118400000,
|
||||
"installRecords": {
|
||||
"lossless-claw": {
|
||||
"source": "npm",
|
||||
"spec": "@example/lossless-claw@0.9.0",
|
||||
"installPath": "~/.openclaw/extensions/lossless-claw",
|
||||
"resolvedName": "@example/lossless-claw",
|
||||
"resolvedVersion": "0.9.0",
|
||||
"resolvedSpec": "@example/lossless-claw@0.9.0",
|
||||
"integrity": "sha512-same",
|
||||
"shasum": "same"
|
||||
}
|
||||
},
|
||||
"plugins": [],
|
||||
"diagnostics": []
|
||||
}
|
||||
JSON
|
||||
node "$probe" seed
|
||||
|
||||
node scripts/e2e/lib/plugin-update/registry-server.mjs >/tmp/openclaw-e2e-registry.log 2>&1 &
|
||||
registry_pid=$!
|
||||
trap 'kill "$registry_pid" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
registry_ready=0
|
||||
for _ in $(seq 1 50); do
|
||||
if node --input-type=module -e '
|
||||
import http from "node:http";
|
||||
const req = http.get("http://127.0.0.1:4873/@example%2flossless-claw", (res) => {
|
||||
process.exit(res.statusCode === 200 ? 0 : 1);
|
||||
});
|
||||
req.on("error", () => process.exit(1));
|
||||
req.setTimeout(200, () => {
|
||||
req.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
'; then
|
||||
registry_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [ "$registry_ready" -ne 1 ]; then
|
||||
if ! node "$probe" wait-registry; then
|
||||
echo "Local npm metadata registry failed to start"
|
||||
cat /tmp/openclaw-e2e-registry.log || true
|
||||
exit 1
|
||||
@@ -89,37 +33,7 @@ if [ "$OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT" != "1" ]; then
|
||||
fi
|
||||
plugin_update_timeout_seconds="${OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS:-180}"
|
||||
|
||||
node --input-type=module > /tmp/plugin-update-before.json <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const readJson = (file) => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const home = os.homedir();
|
||||
const config = readJson(path.join(home, ".openclaw", "openclaw.json"));
|
||||
const index = readJson(path.join(home, ".openclaw", "plugins", "installs.json"));
|
||||
const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {};
|
||||
const record = records["lossless-claw"] ?? records["@example/lossless-claw"];
|
||||
if (!record) {
|
||||
throw new Error("missing seeded plugin install record");
|
||||
}
|
||||
const snapshot = {
|
||||
source: record.source,
|
||||
spec: record.spec,
|
||||
resolvedName: record.resolvedName,
|
||||
resolvedVersion: record.resolvedVersion,
|
||||
resolvedSpec: record.resolvedSpec,
|
||||
integrity: record.integrity,
|
||||
shasum: record.shasum,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(snapshot, null, 2));
|
||||
NODE
|
||||
node "$probe" snapshot > /tmp/plugin-update-before.json
|
||||
|
||||
set +e
|
||||
timeout "${plugin_update_timeout_seconds}s" node "$entry" plugins update @example/lossless-claw > /tmp/plugin-update-output.log 2>&1
|
||||
@@ -143,48 +57,6 @@ if [ -n "$before_config_hash" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
node --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const readJson = (file) => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const home = os.homedir();
|
||||
const before = readJson("/tmp/plugin-update-before.json");
|
||||
const config = readJson(path.join(home, ".openclaw", "openclaw.json"));
|
||||
const index = readJson(path.join(home, ".openclaw", "plugins", "installs.json"));
|
||||
const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {};
|
||||
const record = records["lossless-claw"] ?? records["@example/lossless-claw"];
|
||||
if (!record) {
|
||||
throw new Error("missing plugin install record after update");
|
||||
}
|
||||
const after = {
|
||||
source: record.source,
|
||||
spec: record.spec,
|
||||
resolvedName: record.resolvedName,
|
||||
resolvedVersion: record.resolvedVersion,
|
||||
resolvedSpec: record.resolvedSpec,
|
||||
integrity: record.integrity,
|
||||
shasum: record.shasum,
|
||||
};
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
throw new Error(`plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`);
|
||||
}
|
||||
NODE
|
||||
if grep -q "Downloading @example/lossless-claw" /tmp/plugin-update-output.log; then
|
||||
echo "Unexpected npm download/reinstall path"
|
||||
cat /tmp/plugin-update-output.log
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "lossless-claw is up to date (0.9.0)." /tmp/plugin-update-output.log; then
|
||||
echo "Expected up-to-date output missing"
|
||||
cat /tmp/plugin-update-output.log
|
||||
exit 1
|
||||
fi
|
||||
node "$probe" assert-snapshot /tmp/plugin-update-before.json
|
||||
node "$probe" assert-output /tmp/plugin-update-output.log
|
||||
cat /tmp/plugin-update-output.log
|
||||
|
||||
@@ -38,17 +38,15 @@ prepare_package_tgz() {
|
||||
prepare_package_tgz
|
||||
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
docker_e2e_harness_mount_args
|
||||
run_log="$(docker_e2e_run_log npm-onboard-channel-agent)"
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 npm-onboard-channel-agent empty)"
|
||||
|
||||
echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..."
|
||||
if ! docker run --rm \
|
||||
if ! docker_e2e_run_with_harness \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_NPM_ONBOARD_CHANNEL="$CHANNEL" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -16,16 +16,14 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" pi-bundle-mcp-tools
|
||||
docker_e2e_harness_mount_args
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 pi-bundle-mcp-tools empty)"
|
||||
|
||||
echo "Running in-container Pi bundle MCP tool availability smoke..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
set +e
|
||||
docker run --rm \
|
||||
docker_e2e_run_with_harness \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "set -euo pipefail
|
||||
eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\"
|
||||
|
||||
@@ -12,19 +12,17 @@ SKIP_BUILD="${OPENCLAW_PLUGIN_UPDATE_E2E_SKIP_BUILD:-0}"
|
||||
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-update "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
|
||||
# Bare lanes mount the package artifact instead of baking app sources into the image.
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-update "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-update empty)"
|
||||
|
||||
echo "Running unchanged plugin update smoke..."
|
||||
docker run --rm \
|
||||
docker_e2e_run_with_harness \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e OPENCLAW_SKIP_CHANNELS=1 \
|
||||
-e OPENCLAW_SKIP_PROVIDERS=1 \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash scripts/e2e/lib/plugin-update/unchanged-scenario.sh
|
||||
|
||||
|
||||
@@ -17,15 +17,13 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" session-runtime-context
|
||||
docker_e2e_harness_mount_args
|
||||
|
||||
echo "Running session runtime context Docker E2E..."
|
||||
# Harness files are mounted read-only; the app under test comes from /app/dist.
|
||||
set +e
|
||||
docker run --rm \
|
||||
docker_e2e_run_with_harness \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
"${DOCKER_E2E_HARNESS_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc 'set -euo pipefail; tsx scripts/e2e/session-runtime-context-docker-client.ts' \
|
||||
>"$RUN_LOG" 2>&1
|
||||
|
||||
@@ -62,3 +62,8 @@ docker_e2e_package_mount_args() {
|
||||
docker_e2e_harness_mount_args() {
|
||||
DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" -v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro")
|
||||
}
|
||||
|
||||
docker_e2e_run_with_harness() {
|
||||
docker_e2e_harness_mount_args
|
||||
docker run --rm "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@"
|
||||
}
|
||||
|
||||
@@ -170,8 +170,10 @@ Currently supported events:
|
||||
- **command:stop**: `/stop` command
|
||||
- **agent:bootstrap**: Before workspace bootstrap files are injected
|
||||
- **gateway:startup**: Gateway startup (after channels start)
|
||||
|
||||
More event types coming soon (session lifecycle, agent errors, etc.).
|
||||
- **session:compact:before**: Pre-compaction snapshot before the embedded runner rewrites session context
|
||||
- **session:compact:after**: Post-compaction snapshot after the runner replaces session context
|
||||
- **message:received**: Inbound channel message accepted for dispatch
|
||||
- **message:sent**: Outbound channel message delivered (canonical payload only)
|
||||
|
||||
## Handler API
|
||||
|
||||
@@ -179,8 +181,8 @@ Hook handlers receive an `InternalHookEvent` object:
|
||||
|
||||
```typescript
|
||||
interface InternalHookEvent {
|
||||
type: "command" | "session" | "agent" | "gateway";
|
||||
action: string; // e.g., 'new', 'reset', 'stop'
|
||||
type: "command" | "session" | "agent" | "gateway" | "message";
|
||||
action: string; // e.g., 'new', 'reset', 'stop', 'compact:before', 'received', 'sent'
|
||||
sessionKey: string;
|
||||
context: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
|
||||
Reference in New Issue
Block a user