Compare commits
16 Commits
main
...
codex/wind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f571f6d534 | ||
|
|
52020d3a0c | ||
|
|
84b1e3296c | ||
|
|
726ccf4706 | ||
|
|
46aa10c04a | ||
|
|
e63bedb74b | ||
|
|
8d66245825 | ||
|
|
cc4464f2ce | ||
|
|
62de0853f3 | ||
|
|
b2213f147e | ||
|
|
080b574ad6 | ||
|
|
8b5206cc67 | ||
|
|
24daa04d67 | ||
|
|
9ec1f01b5a | ||
|
|
15fd465a48 | ||
|
|
9cd74ca94b |
@ -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)
|
||||||
|
|||||||
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
2
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
@ -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,45 +603,52 @@ const defaultWorkerBudget =
|
|||||||
extensions: 4,
|
extensions: 4,
|
||||||
gateway: 1,
|
gateway: 1,
|
||||||
}
|
}
|
||||||
: testProfile === "serial"
|
: isMacMiniProfile
|
||||||
? {
|
? {
|
||||||
unit: 1,
|
unit: 3,
|
||||||
unitIsolated: 1,
|
unitIsolated: 1,
|
||||||
extensions: 1,
|
extensions: 1,
|
||||||
gateway: 1,
|
gateway: 1,
|
||||||
}
|
}
|
||||||
: testProfile === "max"
|
: testProfile === "serial"
|
||||||
? {
|
? {
|
||||||
unit: localWorkers,
|
unit: 1,
|
||||||
unitIsolated: Math.min(4, localWorkers),
|
unitIsolated: 1,
|
||||||
extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))),
|
extensions: 1,
|
||||||
gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))),
|
gateway: 1,
|
||||||
}
|
}
|
||||||
: highMemLocalHost
|
: testProfile === "max"
|
||||||
? {
|
? {
|
||||||
// After peeling measured hotspots into dedicated lanes, the shared
|
unit: localWorkers,
|
||||||
// unit-fast lane shuts down more reliably with a slightly smaller
|
unitIsolated: Math.min(4, localWorkers),
|
||||||
// worker fan-out than the old "max it out" local default.
|
extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))),
|
||||||
unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))),
|
gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))),
|
||||||
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))),
|
|
||||||
}
|
}
|
||||||
: lowMemLocalHost
|
: highMemLocalHost
|
||||||
? {
|
? {
|
||||||
// Sub-64 GiB local hosts are prone to OOM with large vmFork runs.
|
// After peeling measured hotspots into dedicated lanes, the shared
|
||||||
unit: 2,
|
// unit-fast lane shuts down more reliably with a slightly smaller
|
||||||
unitIsolated: 1,
|
// worker fan-out than the old "max it out" local default.
|
||||||
extensions: 4,
|
unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))),
|
||||||
gateway: 1,
|
unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 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))),
|
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.
|
// 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.
|
// 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;
|
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 = []) => {
|
const runEntries = async (entries, extraArgs = []) => {
|
||||||
if (topLevelParallelEnabled) {
|
if (topLevelParallelEnabled) {
|
||||||
const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs)));
|
const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs)));
|
||||||
return codes.find((code) => code !== 0);
|
return codes.find((code) => code !== 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
return runEntriesWithLimit(entries, extraArgs);
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const code = await run(entry, extraArgs);
|
|
||||||
if (code !== 0) {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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");
|
||||||
process.exit(failedParallel);
|
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) {
|
for (const entry of serialRuns) {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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" };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
29
src/security/scan-paths.test.ts
Normal file
29
src/security/scan-paths.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user