Compare commits

...

16 Commits

Author SHA1 Message Date
Tak Hoffman
f571f6d534
Fix Windows bundle MCP and Matrix contract seams 2026-03-19 08:14:41 -05:00
Tak Hoffman
52020d3a0c
test: add macmini test profile 2026-03-19 07:47:07 -05:00
Tak Hoffman
84b1e3296c
Fail safe manual extension diffing 2026-03-19 07:46:09 -05:00
Tak Hoffman
726ccf4706
Handle manual CI base revisions 2026-03-19 07:43:41 -05:00
Tak Hoffman
46aa10c04a
Add matrix session binding contracts 2026-03-19 07:43:41 -05:00
Tak Hoffman
e63bedb74b
Fix HTTPS libsignal lock source 2026-03-19 07:43:41 -05:00
Tak Hoffman
8d66245825
Address Windows review regressions 2026-03-19 07:42:48 -05:00
Tak Hoffman
cc4464f2ce
Enable manual CI dispatch 2026-03-19 07:42:48 -05:00
Tak Hoffman
62de0853f3
Use HTTPS tarball for Tlon API 2026-03-19 07:42:47 -05:00
Tak Hoffman
b2213f147e
Preserve lexical bundle plugin roots 2026-03-19 07:41:31 -05:00
Tak Hoffman
080b574ad6
Fix outbound channel selection fast paths 2026-03-19 07:41:31 -05:00
Tak Hoffman
8b5206cc67
Reuse parsed hook frontmatter during entry loading 2026-03-19 07:41:31 -05:00
Tak Hoffman
24daa04d67
Use canonical hook file root for metadata reads 2026-03-19 07:41:31 -05:00
Tak Hoffman
9ec1f01b5a
Canonicalize hook metadata file paths 2026-03-19 07:41:31 -05:00
Tak Hoffman
15fd465a48
Isolate plugin hook tests from plugin caches 2026-03-19 07:41:31 -05:00
Tak Hoffman
9cd74ca94b
Fix Windows hook path containment 2026-03-19 07:41:30 -05:00
25 changed files with 517 additions and 131 deletions

View File

@ -21,10 +21,14 @@ runs:
run: |
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
elif [ "${{ github.event_name }}" = "pull_request" ]; then
# Use the exact base SHA from the event payload — stable regardless
# of base branch movement (avoids origin/<ref> drift).
BASE="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
fi
# Fail-safe: if we can't diff, assume non-docs (run everything)

View File

@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@ -31,8 +32,8 @@ jobs:
- name: Ensure docs-scope base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
- name: Detect docs-only changes
id: check
@ -61,8 +62,8 @@ jobs:
- name: Ensure changed-scope base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
- name: Detect changed scopes
id: scope
@ -72,8 +73,12 @@ jobs:
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
elif [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
fi
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
@ -96,8 +101,8 @@ jobs:
- name: Ensure changed-extensions base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@ -108,14 +113,31 @@ jobs:
- name: Detect changed extensions
id: changed
env:
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE_SHA="${{ github.event.before }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE_SHA="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
fi
export BASE_SHA
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
import {
listAvailableExtensionIds,
listChangedExtensionIds,
} from "./scripts/test-extension.mjs";
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
const baseSha = process.env.BASE_SHA?.trim();
const extensionIds = baseSha
? listChangedExtensionIds({ base: baseSha, head: "HEAD" })
: listAvailableExtensionIds();
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
@ -535,8 +557,8 @@ jobs:
- name: Ensure secrets base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@ -571,11 +593,19 @@ jobs:
run: pre-commit run --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
env:
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE_SHA="${{ github.event.before }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE_SHA="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
fi
if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
echo "No usable base SHA detected; skipping zizmor."
exit 0

View File

@ -47,6 +47,10 @@ Docs: https://docs.openclaw.ai
### Fixes
- Hooks/Windows: preserve Windows-aware hook path handling across plugin-managed hook loading and bundle MCP config resolution, so path aliases and canonicalization differences no longer drop hook metadata or break bundled MCP launches.
- Outbound/channels: skip full configured-channel scans when explicit channel selection already determines the target, so explicit sends and broadcasts avoid slow unrelated plugin configuration checks.
- Tlon/install: fetch `@tloncorp/api` from the pinned HTTPS tarball artifact instead of a Git transport URL so installs no longer depend on GitHub SSH access.
- WhatsApp/install: fetch the Baileys `libsignal` dependency from a pinned GitHub HTTPS tarball so frozen installs and Windows CI no longer depend on `git@github.com` access.
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.

View File

@ -12,3 +12,8 @@ export {
resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment,
} from "./helper-api.js";
export {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export { setMatrixRuntime } from "./src/runtime.js";

View File

@ -4,7 +4,7 @@
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {
"@tloncorp/api": "git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87",
"@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87",
"@tloncorp/tlon-skill": "0.2.2",
"@urbit/aura": "^3.0.0",
"zod": "^4.3.6"

View File

@ -655,7 +655,7 @@
"test:install:e2e:openai": "OPENCLAW_E2E_MODELS=openai CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
"test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
"test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs",
"test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=macmini node scripts/test-parallel.mjs",
"test:parallels:linux": "bash scripts/e2e/parallels-linux-smoke.sh",
"test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh",
"test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh",

2
pnpm-lock.yaml generated
View File

@ -536,7 +536,7 @@ importers:
extensions/tlon:
dependencies:
'@tloncorp/api':
specifier: git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87
specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87
version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87
'@tloncorp/tlon-skill':
specifier: 0.2.2

View File

@ -55,11 +55,13 @@ const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS ===
const rawTestProfile = process.env.OPENCLAW_TEST_PROFILE?.trim().toLowerCase();
const testProfile =
rawTestProfile === "low" ||
rawTestProfile === "macmini" ||
rawTestProfile === "max" ||
rawTestProfile === "normal" ||
rawTestProfile === "serial"
? rawTestProfile
: "normal";
const isMacMiniProfile = testProfile === "macmini";
// Even on low-memory hosts, keep the isolated lane split so files like
// git-commit.test.ts still get the worker/process isolation they require.
const shouldSplitUnitRuns = testProfile !== "serial";
@ -162,6 +164,17 @@ const parsePassthroughArgs = (args) => {
};
const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } =
parsePassthroughArgs(passthroughArgs);
const passthroughMetadataFlags = new Set(["-h", "--help", "--listTags", "--clearCache"]);
const passthroughMetadataOnly =
passthroughArgs.length > 0 &&
passthroughFileFilters.length === 0 &&
passthroughOptionArgs.every((arg) => {
if (!arg.startsWith("-")) {
return false;
}
const [flag] = arg.split("=", 1);
return passthroughMetadataFlags.has(flag);
});
const countExplicitEntryFilters = (entryArgs) => {
const { fileFilters } = parsePassthroughArgs(entryArgs.slice(2));
return fileFilters.length > 0 ? fileFilters.length : null;
@ -242,9 +255,25 @@ const allKnownUnitFiles = allKnownTestFiles.filter((file) => {
return isUnitConfigTestFile(file);
});
const defaultHeavyUnitFileLimit =
testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60;
testProfile === "serial"
? 0
: isMacMiniProfile
? 90
: testProfile === "low"
? 20
: highMemLocalHost
? 80
: 60;
const defaultHeavyUnitLaneCount =
testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4;
testProfile === "serial"
? 0
: isMacMiniProfile
? 6
: testProfile === "low"
? 2
: highMemLocalHost
? 5
: 4;
const heavyUnitFileLimit = parseEnvNumber(
"OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT",
defaultHeavyUnitFileLimit,
@ -538,12 +567,16 @@ const targetedEntries = (() => {
// Node 25 local runs still show cross-process worker shutdown contention even
// after moving the known heavy files into singleton lanes.
const topLevelParallelEnabled =
testProfile !== "low" && testProfile !== "serial" && !(!isCI && nodeMajor >= 25);
testProfile !== "low" &&
testProfile !== "serial" &&
!(!isCI && nodeMajor >= 25) &&
!isMacMiniProfile;
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
const resolvedOverride =
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const parallelGatewayEnabled =
process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost);
!isMacMiniProfile &&
(process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost));
// Keep gateway serial by default except when explicitly requested or on high-memory local hosts.
const keepGatewaySerial =
isWindowsCi ||
@ -570,45 +603,52 @@ const defaultWorkerBudget =
extensions: 4,
gateway: 1,
}
: testProfile === "serial"
: isMacMiniProfile
? {
unit: 1,
unit: 3,
unitIsolated: 1,
extensions: 1,
gateway: 1,
}
: testProfile === "max"
: testProfile === "serial"
? {
unit: localWorkers,
unitIsolated: Math.min(4, localWorkers),
extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))),
gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))),
unit: 1,
unitIsolated: 1,
extensions: 1,
gateway: 1,
}
: highMemLocalHost
: testProfile === "max"
? {
// After peeling measured hotspots into dedicated lanes, the shared
// unit-fast lane shuts down more reliably with a slightly smaller
// worker fan-out than the old "max it out" local default.
unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))),
unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)),
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))),
unit: localWorkers,
unitIsolated: Math.min(4, localWorkers),
extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))),
gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))),
}
: lowMemLocalHost
: highMemLocalHost
? {
// Sub-64 GiB local hosts are prone to OOM with large vmFork runs.
unit: 2,
unitIsolated: 1,
extensions: 4,
gateway: 1,
}
: {
// 64-95 GiB local hosts: conservative split with some parallel headroom.
unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))),
unitIsolated: 1,
// After peeling measured hotspots into dedicated lanes, the shared
// unit-fast lane shuts down more reliably with a slightly smaller
// worker fan-out than the old "max it out" local default.
unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))),
unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)),
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
gateway: 1,
};
gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))),
}
: lowMemLocalHost
? {
// Sub-64 GiB local hosts are prone to OOM with large vmFork runs.
unit: 2,
unitIsolated: 1,
extensions: 4,
gateway: 1,
}
: {
// 64-95 GiB local hosts: conservative split with some parallel headroom.
unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))),
unitIsolated: 1,
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
gateway: 1,
};
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
@ -766,21 +806,52 @@ const run = async (entry, extraArgs = []) => {
return 0;
};
const runEntriesWithLimit = async (entries, extraArgs = [], concurrency = 1) => {
if (entries.length === 0) {
return undefined;
}
const normalizedConcurrency = Math.max(1, Math.floor(concurrency));
if (normalizedConcurrency <= 1) {
for (const entry of entries) {
// eslint-disable-next-line no-await-in-loop
const code = await run(entry, extraArgs);
if (code !== 0) {
return code;
}
}
return undefined;
}
let nextIndex = 0;
let firstFailure;
const worker = async () => {
while (firstFailure === undefined) {
const entryIndex = nextIndex;
nextIndex += 1;
if (entryIndex >= entries.length) {
return;
}
const code = await run(entries[entryIndex], extraArgs);
if (code !== 0 && firstFailure === undefined) {
firstFailure = code;
}
}
};
const workerCount = Math.min(normalizedConcurrency, entries.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return firstFailure;
};
const runEntries = async (entries, extraArgs = []) => {
if (topLevelParallelEnabled) {
const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs)));
return codes.find((code) => code !== 0);
}
for (const entry of entries) {
// eslint-disable-next-line no-await-in-loop
const code = await run(entry, extraArgs);
if (code !== 0) {
return code;
}
}
return undefined;
return runEntriesWithLimit(entries, extraArgs);
};
const shutdown = (signal) => {
@ -800,6 +871,17 @@ if (process.env.OPENCLAW_TEST_LIST_LANES === "1") {
process.exit(0);
}
if (passthroughMetadataOnly) {
const exitCode = await runOnce(
{
name: "vitest-meta",
args: ["vitest", "run"],
},
passthroughOptionArgs,
);
process.exit(exitCode);
}
if (targetedEntries.length > 0) {
if (passthroughRequiresSingleRun && targetedEntries.length > 1) {
console.error(
@ -834,9 +916,28 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) {
process.exit(2);
}
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs);
if (failedParallel !== undefined) {
process.exit(failedParallel);
if (isMacMiniProfile && targetedEntries.length === 0) {
const unitFastEntry = parallelRuns.find((entry) => entry.name === "unit-fast");
if (unitFastEntry) {
const unitFastCode = await run(unitFastEntry, passthroughOptionArgs);
if (unitFastCode !== 0) {
process.exit(unitFastCode);
}
}
const deferredEntries = parallelRuns.filter((entry) => entry.name !== "unit-fast");
const failedMacMiniParallel = await runEntriesWithLimit(
deferredEntries,
passthroughOptionArgs,
3,
);
if (failedMacMiniParallel !== undefined) {
process.exit(failedMacMiniParallel);
}
} else {
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs);
if (failedParallel !== undefined) {
process.exit(failedParallel);
}
}
for (const entry of serialRuns) {

View File

@ -1,9 +1,17 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect, vi } from "vitest";
import {
__testing as discordThreadBindingTesting,
createThreadBindingManager as createDiscordThreadBindingManager,
} from "../../../../extensions/discord/runtime-api.js";
import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js";
import {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
setMatrixRuntime,
} from "../../../../extensions/matrix/runtime-api.js";
import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
@ -126,12 +134,39 @@ type DirectoryContractEntry = {
type SessionBindingContractEntry = {
id: string;
expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
};
const matrixSessionBindingAuth = {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
} as const;
function createMatrixSessionBindingStateDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-session-binding-contract-"));
}
async function setupMatrixSessionBindingManager() {
setMatrixRuntime({
state: {
resolveStateDir: () => createMatrixSessionBindingStateDir(),
},
} as never);
return await createMatrixThreadBindingManager({
accountId: "default",
auth: matrixSessionBindingAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
function expectResolvedSessionBinding(params: {
channel: string;
accountId: string;
@ -708,6 +743,58 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
});
},
},
{
id: "matrix",
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
await setupMatrixSessionBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: "default",
});
},
bindAndResolve: async () => {
await setupMatrixSessionBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "!room:example",
},
placement: "child",
metadata: {
label: "codex-matrix",
introText: "matrix contract intro",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: "default",
conversationId: "$root",
targetSessionKey: "agent:matrix:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await setupMatrixSessionBindingManager();
manager.stop();
resetMatrixThreadBindingsForTests();
expectClearedSessionBinding({
channel: "matrix",
accountId: "default",
conversationId: "$root",
});
},
},
{
id: "telegram",
expectedCapabilities: {

View File

@ -1,16 +1,36 @@
import { beforeEach, describe } from "vitest";
import { beforeEach, describe, vi } from "vitest";
import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js";
import { resetMatrixThreadBindingsForTests } from "../../../../extensions/matrix/runtime-api.js";
import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js";
import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js";
import { sessionBindingContractRegistry } from "./registry.js";
import { installSessionBindingContractSuite } from "./suites.js";
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$reply" : "$root",
roomId: "!room:example",
})),
);
vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../extensions/matrix/src/matrix/send.js")
>("../../../../extensions/matrix/src/matrix/send.js");
return {
...actual,
sendMessageMatrix: sendMessageMatrixMock,
};
});
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
discordThreadBindingTesting.resetThreadBindingsForTests();
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
resetMatrixThreadBindingsForTests();
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
sendMessageMatrixMock.mockClear();
});
for (const entry of sessionBindingContractRegistry) {

View File

@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: {
}
export function installSessionBindingContractSuite(params: {
getCapabilities: () => SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
expectedCapabilities: SessionBindingCapabilities;
}) {
it("registers the expected session binding capabilities", () => {
expect(params.getCapabilities()).toEqual(params.expectedCapabilities);
it("registers the expected session binding capabilities", async () => {
expect(await params.getCapabilities()).toEqual(params.expectedCapabilities);
});
it("binds and resolves a session binding through the shared service", async () => {

View File

@ -4,6 +4,8 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import {
clearInternalHooks,
createInternalHookEvent,
@ -24,6 +26,8 @@ describe("bundle plugin hooks", () => {
beforeEach(async () => {
clearInternalHooks();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fsp.mkdir(workspaceDir, { recursive: true });
previousBundledHooksDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
@ -32,6 +36,8 @@ describe("bundle plugin hooks", () => {
afterEach(() => {
clearInternalHooks();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
if (previousBundledHooksDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
} else {

View File

@ -37,6 +37,7 @@ export type Hook = {
description: string;
source: "openclaw-bundled" | "openclaw-managed" | "openclaw-workspace" | "openclaw-plugin";
pluginId?: string;
frontmatter?: ParsedHookFrontmatter;
filePath: string; // Path to HOOK.md
baseDir: string; // Directory containing hook
handlerPath: string; // Path to handler module (handler.ts/js)

View File

@ -14,14 +14,7 @@ import {
resolveHookInvocationPolicy,
} from "./frontmatter.js";
import { resolvePluginHookDirs } from "./plugin-hooks.js";
import type {
Hook,
HookEligibilityContext,
HookEntry,
HookSnapshot,
HookSource,
ParsedHookFrontmatter,
} from "./types.js";
import type { Hook, HookEligibilityContext, HookEntry, HookSnapshot, HookSource } from "./types.js";
type HookPackageManifest = {
name?: string;
@ -81,11 +74,19 @@ function loadHookFromDir(params: {
nameHint?: string;
}): Hook | null {
const hookMdPath = path.join(params.hookDir, "HOOK.md");
const content = readBoundaryFileUtf8({
const safeHookMdPath = resolveBoundaryFilePath({
absolutePath: hookMdPath,
rootPath: params.hookDir,
boundaryLabel: "hook directory",
});
if (!safeHookMdPath) {
return null;
}
const content = readBoundaryFileUtf8({
absolutePath: safeHookMdPath,
rootPath: params.hookDir,
boundaryLabel: "hook directory",
});
if (content === null) {
return null;
}
@ -127,7 +128,8 @@ function loadHookFromDir(params: {
description,
source: params.source,
pluginId: params.pluginId,
filePath: hookMdPath,
frontmatter,
filePath: safeHookMdPath,
baseDir,
handlerPath,
};
@ -212,15 +214,7 @@ export function loadHookEntriesFromDir(params: {
pluginId: params.pluginId,
});
return hooks.map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
const raw = readBoundaryFileUtf8({
absolutePath: hook.filePath,
rootPath: hook.baseDir,
boundaryLabel: "hook directory",
});
if (raw !== null) {
frontmatter = parseFrontmatter(raw);
}
const frontmatter = hook.frontmatter ?? {};
const entry: HookEntry = {
hook: {
...hook,
@ -303,15 +297,7 @@ function loadHookEntries(
}
return Array.from(merged.values()).map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
const raw = readBoundaryFileUtf8({
absolutePath: hook.filePath,
rootPath: hook.baseDir,
boundaryLabel: "hook directory",
});
if (raw !== null) {
frontmatter = parseFrontmatter(raw);
}
const frontmatter = hook.frontmatter ?? {};
return {
hook,
frontmatter,

View File

@ -143,6 +143,24 @@ describe("resolveMessageChannelSelection", () => {
});
});
it("skips configured-channel scanning when includeConfigured is false", async () => {
const isConfigured = vi.fn(async () => true);
mocks.listChannelPlugins.mockReturnValue([makePlugin({ id: "whatsapp", isConfigured })]);
const selection = await resolveMessageChannelSelection({
cfg: {} as never,
channel: "telegram",
includeConfigured: false,
});
expect(selection).toEqual({
channel: "telegram",
configured: [],
source: "explicit",
});
expect(isConfigured).not.toHaveBeenCalled();
});
it("falls back to tool context channel when explicit channel is unknown", async () => {
const selection = await resolveMessageChannelSelection({
cfg: {} as never,

View File

@ -146,11 +146,15 @@ export async function resolveMessageChannelSelection(params: {
cfg: OpenClawConfig;
channel?: string | null;
fallbackChannel?: string | null;
includeConfigured?: boolean;
}): Promise<{
channel: MessageChannelId;
configured: MessageChannelId[];
source: MessageChannelSelectionSource;
}> {
const includeConfigured = params.includeConfigured !== false;
const resolveConfigured = async () =>
includeConfigured ? await listConfiguredMessageChannels(params.cfg) : [];
const normalized = normalizeMessageChannel(params.channel);
if (normalized) {
const availableExplicit = resolveAvailableKnownChannel({
@ -165,7 +169,7 @@ export async function resolveMessageChannelSelection(params: {
if (fallback) {
return {
channel: fallback,
configured: await listConfiguredMessageChannels(params.cfg),
configured: await resolveConfigured(),
source: "tool-context-fallback",
};
}
@ -176,7 +180,7 @@ export async function resolveMessageChannelSelection(params: {
}
return {
channel: availableExplicit,
configured: await listConfiguredMessageChannels(params.cfg),
configured: await resolveConfigured(),
source: "explicit",
};
}
@ -188,12 +192,12 @@ export async function resolveMessageChannelSelection(params: {
if (fallback) {
return {
channel: fallback,
configured: await listConfiguredMessageChannels(params.cfg),
configured: await resolveConfigured(),
source: "tool-context-fallback",
};
}
const configured = await listConfiguredMessageChannels(params.cfg);
const configured = await resolveConfigured();
if (configured.length === 1) {
return { channel: configured[0], configured, source: "single-configured" };
}

View File

@ -222,10 +222,12 @@ async function resolveChannel(
params: Record<string, unknown>,
toolContext?: { currentChannelProvider?: string },
) {
const explicitChannel = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({
cfg,
channel: readStringParam(params, "channel"),
channel: explicitChannel,
fallbackChannel: toolContext?.currentChannelProvider,
includeConfigured: !explicitChannel,
});
if (selection.source === "tool-context-fallback") {
params.channel = selection.channel;
@ -318,14 +320,13 @@ async function handleBroadcastAction(
throw new Error("Broadcast requires at least one target in --targets.");
}
const channelHint = readStringParam(params, "channel");
const configured = await listConfiguredMessageChannels(input.cfg);
if (configured.length === 0) {
throw new Error("Broadcast requires at least one configured channel.");
}
const targetChannels =
channelHint && channelHint.trim().toLowerCase() !== "all"
? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)]
: configured;
: await listConfiguredMessageChannels(input.cfg);
if (targetChannels.length === 0) {
throw new Error("Broadcast requires at least one configured channel.");
}
const results: Array<{
channel: ChannelId;
to: string;

View File

@ -1,7 +1,11 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
createChannelTestPluginBase,
createMSTeamsTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
@ -242,6 +246,78 @@ describe("sendPoll channel normalization", () => {
});
});
describe("implicit single-channel selection", () => {
it("keeps single configured channel fallback for sendMessage when channel is omitted", async () => {
const sendText = vi.fn(async () => ({ channel: "msteams", messageId: "m1" }));
setRegistry(
createTestRegistry([
{
pluginId: "msteams",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isConfigured: () => true,
},
}),
outbound: {
...createMSTeamsOutbound(),
sendText,
},
},
},
]),
);
const result = await sendMessage({
cfg: {},
to: "conversation:19:abc@thread.tacv2",
content: "hi",
});
expect(result.channel).toBe("msteams");
expect(sendText).toHaveBeenCalled();
});
it("keeps single configured channel fallback for sendPoll when channel is omitted", async () => {
setRegistry(
createTestRegistry([
{
pluginId: "msteams",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isConfigured: () => true,
},
}),
outbound: createMSTeamsOutbound({ includePoll: true }),
},
},
]),
);
const result = await sendPoll({
cfg: {},
to: "conversation:19:abc@thread.tacv2",
question: "Lunch?",
options: ["Pizza", "Sushi"],
});
expect(result.channel).toBe("msteams");
});
});
const setMattermostGatewayRegistry = () => {
setRegistry(
createTestRegistry([

View File

@ -132,10 +132,12 @@ async function resolveRequiredChannel(params: {
cfg: OpenClawConfig;
channel?: string;
}): Promise<string> {
const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : "";
return (
await resolveMessageChannelSelection({
cfg: params.cfg,
channel: params.channel,
channel: explicitChannel || undefined,
includeConfigured: !explicitChannel,
})
).channel;
}

View File

@ -65,6 +65,7 @@ describe("isPathInside", () => {
it("accepts identical and nested paths but rejects escapes", () => {
expect(isPathInside("/workspace/root", "/workspace/root")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/nested/file.txt")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/..cache/file.txt")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/../escape.txt")).toBe(false);
});
@ -75,6 +76,9 @@ describe("isPathInside", () => {
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\Nested\File.txt`),
).toBe(true);
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..cache\file.txt`),
).toBe(true);
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..\escape.txt`),
).toBe(false);

View File

@ -37,11 +37,20 @@ export function isPathInside(root: string, target: string): boolean {
const rootForCompare = normalizeWindowsPathForComparison(path.win32.resolve(root));
const targetForCompare = normalizeWindowsPathForComparison(path.win32.resolve(target));
const relative = path.win32.relative(rootForCompare, targetForCompare);
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..\\`) &&
!relative.startsWith("../") &&
!path.win32.isAbsolute(relative))
);
}
const resolvedRoot = path.resolve(root);
const resolvedTarget = path.resolve(target);
const relative = path.relative(resolvedRoot, resolvedTarget);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
return (
relative === "" ||
(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
);
}

View File

@ -11,6 +11,10 @@ function getServerArgs(value: unknown): unknown[] | undefined {
return isRecord(value) && Array.isArray(value.args) ? value.args : undefined;
}
function normalizePluginPath(value: string): string {
return path.normalize(value.replaceAll("/", path.sep));
}
const tempHarness = createBundleMcpTempHarness();
afterEach(async () => {
@ -46,8 +50,6 @@ describe("loadEnabledBundleMcpConfig", () => {
const loadedServer = loaded.config.mcpServers.bundleProbe;
const loadedArgs = getServerArgs(loadedServer);
const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined;
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node");
expect(loadedArgs).toHaveLength(1);
@ -56,7 +58,7 @@ describe("loadEnabledBundleMcpConfig", () => {
throw new Error("expected bundled MCP args to include the server path");
}
expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath);
expect(loadedServer.cwd).toBe(resolvedPluginRoot);
expect(loadedServer.cwd).toBe(pluginRoot);
} finally {
env.restore();
}
@ -178,19 +180,19 @@ describe("loadEnabledBundleMcpConfig", () => {
},
},
});
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
expect(loaded.config.mcpServers.inlineProbe).toEqual({
command: path.join(resolvedPluginRoot, "bin", "server.sh"),
args: [
path.join(resolvedPluginRoot, "servers", "probe.mjs"),
path.join(resolvedPluginRoot, "local-probe.mjs"),
],
cwd: resolvedPluginRoot,
env: {
PLUGIN_ROOT: resolvedPluginRoot,
},
const inlineProbe = loaded.config.mcpServers.inlineProbe;
expect(isRecord(inlineProbe)).toBe(true);
expect(normalizePluginPath(String(isRecord(inlineProbe) ? inlineProbe.command : ""))).toBe(
normalizePluginPath(path.join(pluginRoot, "bin", "server.sh")),
);
expect(getServerArgs(inlineProbe)?.map((arg) => normalizePluginPath(String(arg)))).toEqual([
normalizePluginPath(path.join(pluginRoot, "servers", "probe.mjs")),
normalizePluginPath(path.join(pluginRoot, "local-probe.mjs")),
]);
expect(isRecord(inlineProbe) ? inlineProbe.cwd : undefined).toBe(pluginRoot);
expect(isRecord(inlineProbe) ? inlineProbe.env : undefined).toEqual({
PLUGIN_ROOT: pluginRoot,
});
} finally {
env.restore();

View File

@ -327,7 +327,7 @@ export function loadEnabledBundleMcpConfig(params: {
const loaded = loadBundleMcpConfig({
pluginId: record.id,
rootDir: record.rootDir,
rootDir: record.format === "bundle" ? record.source : record.rootDir,
bundleFormat: record.bundleFormat,
});
merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig;

View File

@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const originalPlatform = process.platform;
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
configurable: true,
value,
});
}
afterEach(() => {
setPlatform(originalPlatform);
vi.restoreAllMocks();
});
describe("security scan path guards", () => {
it("uses Windows-aware containment checks for differently normalized paths", async () => {
setPlatform("win32");
const { isPathInside } = await import("./scan-paths.js");
expect(
isPathInside(String.raw`C:\Workspace\Root`, String.raw`c:\workspace\root\hooks\hook`),
).toBe(true);
expect(
isPathInside(String.raw`\\?\C:\Workspace\Root`, String.raw`C:\workspace\root\hooks\hook`),
).toBe(true);
});
});

View File

@ -1,11 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { isPathInside as isBoundaryPathInside } from "../infra/path-guards.js";
export function isPathInside(basePath: string, candidatePath: string): boolean {
const base = path.resolve(basePath);
const candidate = path.resolve(candidatePath);
const rel = path.relative(base, candidate);
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
return isBoundaryPathInside(basePath, candidatePath);
}
function safeRealpathSync(filePath: string): string | null {