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: | run: |
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}" BASE="${{ github.event.before }}"
else elif [ "${{ github.event_name }}" = "pull_request" ]; then
# Use the exact base SHA from the event payload — stable regardless # Use the exact base SHA from the event payload — stable regardless
# of base branch movement (avoids origin/<ref> drift). # of base branch movement (avoids origin/<ref> drift).
BASE="${{ github.event.pull_request.base.sha }}" 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 fi
# Fail-safe: if we can't diff, assume non-docs (run everything) # Fail-safe: if we can't diff, assume non-docs (run everything)

View File

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

View File

@ -47,6 +47,10 @@ Docs: https://docs.openclaw.ai
### Fixes ### 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. - 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/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. - 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, resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment, sanitizeMatrixPathSegment,
} from "./helper-api.js"; } 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", "description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module", "type": "module",
"dependencies": { "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", "@tloncorp/tlon-skill": "0.2.2",
"@urbit/aura": "^3.0.0", "@urbit/aura": "^3.0.0",
"zod": "^4.3.6" "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: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: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: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:linux": "bash scripts/e2e/parallels-linux-smoke.sh",
"test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh", "test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh",
"test:parallels:windows": "bash scripts/e2e/parallels-windows-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: extensions/tlon:
dependencies: dependencies:
'@tloncorp/api': '@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 version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87
'@tloncorp/tlon-skill': '@tloncorp/tlon-skill':
specifier: 0.2.2 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 rawTestProfile = process.env.OPENCLAW_TEST_PROFILE?.trim().toLowerCase();
const testProfile = const testProfile =
rawTestProfile === "low" || rawTestProfile === "low" ||
rawTestProfile === "macmini" ||
rawTestProfile === "max" || rawTestProfile === "max" ||
rawTestProfile === "normal" || rawTestProfile === "normal" ||
rawTestProfile === "serial" rawTestProfile === "serial"
? rawTestProfile ? rawTestProfile
: "normal"; : "normal";
const isMacMiniProfile = testProfile === "macmini";
// Even on low-memory hosts, keep the isolated lane split so files like // 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. // git-commit.test.ts still get the worker/process isolation they require.
const shouldSplitUnitRuns = testProfile !== "serial"; const shouldSplitUnitRuns = testProfile !== "serial";
@ -162,6 +164,17 @@ const parsePassthroughArgs = (args) => {
}; };
const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } = const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } =
parsePassthroughArgs(passthroughArgs); 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 countExplicitEntryFilters = (entryArgs) => {
const { fileFilters } = parsePassthroughArgs(entryArgs.slice(2)); const { fileFilters } = parsePassthroughArgs(entryArgs.slice(2));
return fileFilters.length > 0 ? fileFilters.length : null; return fileFilters.length > 0 ? fileFilters.length : null;
@ -242,9 +255,25 @@ const allKnownUnitFiles = allKnownTestFiles.filter((file) => {
return isUnitConfigTestFile(file); return isUnitConfigTestFile(file);
}); });
const defaultHeavyUnitFileLimit = const defaultHeavyUnitFileLimit =
testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60; testProfile === "serial"
? 0
: isMacMiniProfile
? 90
: testProfile === "low"
? 20
: highMemLocalHost
? 80
: 60;
const defaultHeavyUnitLaneCount = 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( const heavyUnitFileLimit = parseEnvNumber(
"OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT", "OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT",
defaultHeavyUnitFileLimit, defaultHeavyUnitFileLimit,
@ -538,12 +567,16 @@ const targetedEntries = (() => {
// Node 25 local runs still show cross-process worker shutdown contention even // Node 25 local runs still show cross-process worker shutdown contention even
// after moving the known heavy files into singleton lanes. // after moving the known heavy files into singleton lanes.
const topLevelParallelEnabled = 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 overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
const resolvedOverride = const resolvedOverride =
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const parallelGatewayEnabled = 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. // Keep gateway serial by default except when explicitly requested or on high-memory local hosts.
const keepGatewaySerial = const keepGatewaySerial =
isWindowsCi || isWindowsCi ||
@ -570,6 +603,13 @@ const defaultWorkerBudget =
extensions: 4, extensions: 4,
gateway: 1, gateway: 1,
} }
: isMacMiniProfile
? {
unit: 3,
unitIsolated: 1,
extensions: 1,
gateway: 1,
}
: testProfile === "serial" : testProfile === "serial"
? { ? {
unit: 1, unit: 1,
@ -766,12 +806,13 @@ const run = async (entry, extraArgs = []) => {
return 0; return 0;
}; };
const runEntries = async (entries, extraArgs = []) => { const runEntriesWithLimit = async (entries, extraArgs = [], concurrency = 1) => {
if (topLevelParallelEnabled) { if (entries.length === 0) {
const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs))); return undefined;
return codes.find((code) => code !== 0);
} }
const normalizedConcurrency = Math.max(1, Math.floor(concurrency));
if (normalizedConcurrency <= 1) {
for (const entry of entries) { for (const entry of entries) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const code = await run(entry, extraArgs); const code = await run(entry, extraArgs);
@ -781,6 +822,36 @@ const runEntries = async (entries, extraArgs = []) => {
} }
return undefined; 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);
}
return runEntriesWithLimit(entries, extraArgs);
}; };
const shutdown = (signal) => { const shutdown = (signal) => {
@ -800,6 +871,17 @@ if (process.env.OPENCLAW_TEST_LIST_LANES === "1") {
process.exit(0); process.exit(0);
} }
if (passthroughMetadataOnly) {
const exitCode = await runOnce(
{
name: "vitest-meta",
args: ["vitest", "run"],
},
passthroughOptionArgs,
);
process.exit(exitCode);
}
if (targetedEntries.length > 0) { if (targetedEntries.length > 0) {
if (passthroughRequiresSingleRun && targetedEntries.length > 1) { if (passthroughRequiresSingleRun && targetedEntries.length > 1) {
console.error( console.error(
@ -834,9 +916,28 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) {
process.exit(2); process.exit(2);
} }
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs); if (isMacMiniProfile && targetedEntries.length === 0) {
if (failedParallel !== undefined) { 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); process.exit(failedParallel);
}
} }
for (const entry of serialRuns) { 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 { expect, vi } from "vitest";
import { import {
__testing as discordThreadBindingTesting, __testing as discordThreadBindingTesting,
createThreadBindingManager as createDiscordThreadBindingManager, createThreadBindingManager as createDiscordThreadBindingManager,
} from "../../../../extensions/discord/runtime-api.js"; } from "../../../../extensions/discord/runtime-api.js";
import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/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 { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js";
import type { OpenClawConfig } from "../../../config/config.js"; import type { OpenClawConfig } from "../../../config/config.js";
import { import {
@ -126,12 +134,39 @@ type DirectoryContractEntry = {
type SessionBindingContractEntry = { type SessionBindingContractEntry = {
id: string; id: string;
expectedCapabilities: SessionBindingCapabilities; expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities; getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>; bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>; unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | 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: { function expectResolvedSessionBinding(params: {
channel: string; channel: string;
accountId: 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", id: "telegram",
expectedCapabilities: { 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 discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.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 telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js";
import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js"; import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js";
import { sessionBindingContractRegistry } from "./registry.js"; import { sessionBindingContractRegistry } from "./registry.js";
import { installSessionBindingContractSuite } from "./suites.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(() => { beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests(); sessionBindingTesting.resetSessionBindingAdaptersForTests();
discordThreadBindingTesting.resetThreadBindingsForTests(); discordThreadBindingTesting.resetThreadBindingsForTests();
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
resetMatrixThreadBindingsForTests();
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
sendMessageMatrixMock.mockClear();
}); });
for (const entry of sessionBindingContractRegistry) { for (const entry of sessionBindingContractRegistry) {

View File

@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: {
} }
export function installSessionBindingContractSuite(params: { export function installSessionBindingContractSuite(params: {
getCapabilities: () => SessionBindingCapabilities; getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>; bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>; unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void; cleanup: () => Promise<void> | void;
expectedCapabilities: SessionBindingCapabilities; expectedCapabilities: SessionBindingCapabilities;
}) { }) {
it("registers the expected session binding capabilities", () => { it("registers the expected session binding capabilities", async () => {
expect(params.getCapabilities()).toEqual(params.expectedCapabilities); expect(await params.getCapabilities()).toEqual(params.expectedCapabilities);
}); });
it("binds and resolves a session binding through the shared service", async () => { 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 path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import { import {
clearInternalHooks, clearInternalHooks,
createInternalHookEvent, createInternalHookEvent,
@ -24,6 +26,8 @@ describe("bundle plugin hooks", () => {
beforeEach(async () => { beforeEach(async () => {
clearInternalHooks(); clearInternalHooks();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`); workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fsp.mkdir(workspaceDir, { recursive: true }); await fsp.mkdir(workspaceDir, { recursive: true });
previousBundledHooksDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR; previousBundledHooksDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
@ -32,6 +36,8 @@ describe("bundle plugin hooks", () => {
afterEach(() => { afterEach(() => {
clearInternalHooks(); clearInternalHooks();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
if (previousBundledHooksDir === undefined) { if (previousBundledHooksDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR; delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
} else { } else {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.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 { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.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 = () => { const setMattermostGatewayRegistry = () => {
setRegistry( setRegistry(
createTestRegistry([ createTestRegistry([

View File

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

View File

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

View File

@ -37,11 +37,20 @@ export function isPathInside(root: string, target: string): boolean {
const rootForCompare = normalizeWindowsPathForComparison(path.win32.resolve(root)); const rootForCompare = normalizeWindowsPathForComparison(path.win32.resolve(root));
const targetForCompare = normalizeWindowsPathForComparison(path.win32.resolve(target)); const targetForCompare = normalizeWindowsPathForComparison(path.win32.resolve(target));
const relative = path.win32.relative(rootForCompare, targetForCompare); 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 resolvedRoot = path.resolve(root);
const resolvedTarget = path.resolve(target); const resolvedTarget = path.resolve(target);
const relative = path.relative(resolvedRoot, resolvedTarget); 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; 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(); const tempHarness = createBundleMcpTempHarness();
afterEach(async () => { afterEach(async () => {
@ -46,8 +50,6 @@ describe("loadEnabledBundleMcpConfig", () => {
const loadedServer = loaded.config.mcpServers.bundleProbe; const loadedServer = loaded.config.mcpServers.bundleProbe;
const loadedArgs = getServerArgs(loadedServer); const loadedArgs = getServerArgs(loadedServer);
const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined; const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined;
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]); expect(loaded.diagnostics).toEqual([]);
expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node"); expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node");
expect(loadedArgs).toHaveLength(1); expect(loadedArgs).toHaveLength(1);
@ -56,7 +58,7 @@ describe("loadEnabledBundleMcpConfig", () => {
throw new Error("expected bundled MCP args to include the server path"); throw new Error("expected bundled MCP args to include the server path");
} }
expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath); expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath);
expect(loadedServer.cwd).toBe(resolvedPluginRoot); expect(loadedServer.cwd).toBe(pluginRoot);
} finally { } finally {
env.restore(); env.restore();
} }
@ -178,19 +180,19 @@ describe("loadEnabledBundleMcpConfig", () => {
}, },
}, },
}); });
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]); expect(loaded.diagnostics).toEqual([]);
expect(loaded.config.mcpServers.inlineProbe).toEqual({ const inlineProbe = loaded.config.mcpServers.inlineProbe;
command: path.join(resolvedPluginRoot, "bin", "server.sh"), expect(isRecord(inlineProbe)).toBe(true);
args: [ expect(normalizePluginPath(String(isRecord(inlineProbe) ? inlineProbe.command : ""))).toBe(
path.join(resolvedPluginRoot, "servers", "probe.mjs"), normalizePluginPath(path.join(pluginRoot, "bin", "server.sh")),
path.join(resolvedPluginRoot, "local-probe.mjs"), );
], expect(getServerArgs(inlineProbe)?.map((arg) => normalizePluginPath(String(arg)))).toEqual([
cwd: resolvedPluginRoot, normalizePluginPath(path.join(pluginRoot, "servers", "probe.mjs")),
env: { normalizePluginPath(path.join(pluginRoot, "local-probe.mjs")),
PLUGIN_ROOT: resolvedPluginRoot, ]);
}, expect(isRecord(inlineProbe) ? inlineProbe.cwd : undefined).toBe(pluginRoot);
expect(isRecord(inlineProbe) ? inlineProbe.env : undefined).toEqual({
PLUGIN_ROOT: pluginRoot,
}); });
} finally { } finally {
env.restore(); env.restore();

View File

@ -327,7 +327,7 @@ export function loadEnabledBundleMcpConfig(params: {
const loaded = loadBundleMcpConfig({ const loaded = loadBundleMcpConfig({
pluginId: record.id, pluginId: record.id,
rootDir: record.rootDir, rootDir: record.format === "bundle" ? record.source : record.rootDir,
bundleFormat: record.bundleFormat, bundleFormat: record.bundleFormat,
}); });
merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig; 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 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 { export function isPathInside(basePath: string, candidatePath: string): boolean {
const base = path.resolve(basePath); return isBoundaryPathInside(basePath, candidatePath);
const candidate = path.resolve(candidatePath);
const rel = path.relative(base, candidate);
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
} }
function safeRealpathSync(filePath: string): string | null { function safeRealpathSync(filePath: string): string | null {